Add copy trading functionality with StartCopyTrading endpoint and related models. Implemented position copying from master bot and subscription to copy trading stream in LiveTradingBotGrain. Updated TradingBotConfig to support copy trading parameters.
This commit is contained in:
@@ -12,6 +12,7 @@ using Managing.Domain.Trades;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Orleans.Streams;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Bots.Grains;
|
||||
@@ -31,6 +32,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
private TradingBotBase? _tradingBot;
|
||||
private IDisposable? _timer;
|
||||
private string _reminderName = "RebootReminder";
|
||||
private StreamSubscriptionHandle<Position>? _copyTradingStreamHandle;
|
||||
|
||||
public LiveTradingBotGrain(
|
||||
[PersistentState("live-trading-bot", "bot-store")]
|
||||
@@ -222,6 +224,9 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
RegisterAndStartTimer();
|
||||
await RegisterReminder();
|
||||
|
||||
// Subscribe to copy trading stream if configured
|
||||
await SubscribeToCopyTradingStreamAsync();
|
||||
|
||||
// Update both database and registry status
|
||||
await SaveBotAsync(BotStatus.Running);
|
||||
await UpdateBotRegistryStatus(BotStatus.Running);
|
||||
@@ -337,6 +342,9 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
StopAndDisposeTimer();
|
||||
await UnregisterReminder();
|
||||
|
||||
// Unsubscribe from copy trading stream
|
||||
await UnsubscribeFromCopyTradingStreamAsync();
|
||||
|
||||
// Track runtime: accumulate current session runtime when stopping
|
||||
if (_state.State.LastStartTime.HasValue)
|
||||
{
|
||||
@@ -386,6 +394,76 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the copy trading stream if this bot is configured for copy trading
|
||||
/// </summary>
|
||||
private async Task SubscribeToCopyTradingStreamAsync()
|
||||
{
|
||||
// Only subscribe if this is a copy trading bot and we have a master bot identifier
|
||||
if (!_state.State.Config.IsForCopyTrading || !_state.State.Config.MasterBotIdentifier.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var streamProvider = this.GetStreamProvider("ManagingStreamProvider");
|
||||
var streamId = StreamId.Create("CopyTrading", _state.State.Config.MasterBotIdentifier.Value);
|
||||
_copyTradingStreamHandle = await streamProvider.GetStream<Position>(streamId)
|
||||
.SubscribeAsync(OnCopyTradingPositionReceivedAsync);
|
||||
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} subscribed to copy trading stream for master bot {MasterBotId}",
|
||||
this.GetPrimaryKey(), _state.State.Config.MasterBotIdentifier.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to subscribe to copy trading stream for bot {GrainId}", this.GetPrimaryKey());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from the copy trading stream
|
||||
/// </summary>
|
||||
private async Task UnsubscribeFromCopyTradingStreamAsync()
|
||||
{
|
||||
if (_copyTradingStreamHandle != null)
|
||||
{
|
||||
await _copyTradingStreamHandle.UnsubscribeAsync();
|
||||
_copyTradingStreamHandle = null;
|
||||
_logger.LogInformation("LiveTradingBotGrain {GrainId} unsubscribed from copy trading stream", this.GetPrimaryKey());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming positions from the copy trading stream
|
||||
/// </summary>
|
||||
private async Task OnCopyTradingPositionReceivedAsync(Position masterPosition, StreamSequenceToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_tradingBot == null)
|
||||
{
|
||||
_logger.LogWarning("Received copy trading position {PositionId} but trading bot is not running for bot {GrainId}",
|
||||
masterPosition.Identifier, this.GetPrimaryKey());
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("📡 Copy trading: Received position {PositionId} from master bot for bot {GrainId}",
|
||||
masterPosition.Identifier, this.GetPrimaryKey());
|
||||
|
||||
// Create a copy of the position for this bot
|
||||
await _tradingBot.CopyPositionFromMasterAsync(masterPosition);
|
||||
|
||||
_logger.LogInformation("✅ Copy trading: Successfully copied position {PositionId} for bot {GrainId}",
|
||||
masterPosition.Identifier, this.GetPrimaryKey());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to handle copy trading position {PositionId} for bot {GrainId}",
|
||||
masterPosition.Identifier, this.GetPrimaryKey());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TradingBotBase instance using composition
|
||||
/// </summary>
|
||||
@@ -398,7 +476,8 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
|
||||
var streamProvider = this.GetStreamProvider("ManagingStreamProvider");
|
||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, config, streamProvider);
|
||||
|
||||
// Load state into the trading bot instance
|
||||
LoadStateIntoTradingBot(tradingBot);
|
||||
|
||||
@@ -20,6 +20,7 @@ using Managing.Domain.Trades;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Orleans.Streams;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.Bots;
|
||||
@@ -28,6 +29,7 @@ public class TradingBotBase : ITradingBot
|
||||
{
|
||||
public readonly ILogger<TradingBotBase> Logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IStreamProvider? _streamProvider;
|
||||
private const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders
|
||||
|
||||
private const int
|
||||
@@ -48,10 +50,12 @@ public class TradingBotBase : ITradingBot
|
||||
public TradingBotBase(
|
||||
ILogger<TradingBotBase> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
TradingBotConfig config
|
||||
TradingBotConfig config,
|
||||
IStreamProvider? streamProvider = null
|
||||
)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_streamProvider = streamProvider;
|
||||
Logger = logger;
|
||||
Config = config;
|
||||
Signals = new Dictionary<string, LightSignal>();
|
||||
@@ -228,8 +232,12 @@ public class TradingBotBase : ITradingBot
|
||||
// Update signals for live trading only
|
||||
if (!Config.IsForBacktest)
|
||||
{
|
||||
await UpdateSignals();
|
||||
await LoadLastCandle();
|
||||
|
||||
if (!Config.IsForCopyTrading)
|
||||
{
|
||||
await UpdateSignals();
|
||||
}
|
||||
}
|
||||
|
||||
if (!Config.IsForWatchingOnly)
|
||||
@@ -1139,6 +1147,9 @@ public class TradingBotBase : ITradingBot
|
||||
}
|
||||
|
||||
await LogDebug($"✅ Position requested successfully for signal: `{signal.Identifier}`");
|
||||
|
||||
await SendPositionToCopyTrading(position);
|
||||
|
||||
return position;
|
||||
}
|
||||
else
|
||||
@@ -1175,6 +1186,83 @@ public class TradingBotBase : ITradingBot
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendPositionToCopyTrading(Position position)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Only send to copy trading stream if this is not a copy trading bot itself
|
||||
if (Config.IsForCopyTrading || _streamProvider == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Create stream keyed by this bot's identifier for copy trading bots to subscribe to
|
||||
var streamId = StreamId.Create("CopyTrading", Identifier);
|
||||
var stream = _streamProvider.GetStream<Position>(streamId);
|
||||
|
||||
// Publish the position to the stream
|
||||
await stream.OnNextAsync(position);
|
||||
|
||||
await LogDebug($"📡 Position {position.Identifier} sent to copy trading stream for bot {Identifier}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to send position {PositionId} to copy trading stream for bot {BotId}",
|
||||
position.Identifier, Identifier);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of a position from a master bot for copy trading
|
||||
/// </summary>
|
||||
public async Task CopyPositionFromMasterAsync(Position masterPosition)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create a copy signal based on the master position using the proper constructor
|
||||
var copySignal = new LightSignal(
|
||||
ticker: Config.Ticker,
|
||||
direction: masterPosition.OriginDirection,
|
||||
confidence: Confidence.Medium, // Default confidence for copy trading
|
||||
candle: LastCandle ?? new Candle
|
||||
{
|
||||
Ticker = Config.Ticker,
|
||||
Timeframe = Config.Timeframe,
|
||||
Date = DateTime.UtcNow,
|
||||
Open = masterPosition.Open.Price,
|
||||
Close = masterPosition.Open.Price,
|
||||
High = masterPosition.Open.Price,
|
||||
Low = masterPosition.Open.Price,
|
||||
Volume = 0
|
||||
},
|
||||
date: masterPosition.Open.Date,
|
||||
exchange: TradingExchanges.GmxV2, // Default exchange
|
||||
indicatorType: IndicatorType.Composite,
|
||||
signalType: SignalType.Signal,
|
||||
indicatorName: "CopyTrading"
|
||||
);
|
||||
|
||||
// Override the identifier to include master position info
|
||||
copySignal.Identifier = $"copy-{masterPosition.SignalIdentifier}-{Guid.NewGuid()}";
|
||||
|
||||
// Store the signal
|
||||
Signals[copySignal.Identifier] = copySignal;
|
||||
|
||||
await LogInformation($"📋 Copy trading: Created copy signal {copySignal.Identifier} for master position {masterPosition.Identifier}");
|
||||
|
||||
// Attempt to open the position using the existing OpenPosition method
|
||||
// This will handle all the position creation logic properly
|
||||
await OpenPosition(copySignal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to copy position {MasterPositionId} for bot {BotId}",
|
||||
masterPosition.Identifier, Identifier);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> CanOpenPosition(LightSignal signal)
|
||||
{
|
||||
// Early return if we're in backtest mode and haven't executed yet
|
||||
|
||||
@@ -18,4 +18,18 @@ namespace Managing.Application.ManageBot.Commands
|
||||
CreateOnly = createOnly;
|
||||
}
|
||||
}
|
||||
|
||||
public class StartCopyTradingCommand : IRequest<string>
|
||||
{
|
||||
public Guid MasterBotIdentifier { get; }
|
||||
public decimal BotTradingBalance { get; }
|
||||
public User User { get; }
|
||||
|
||||
public StartCopyTradingCommand(Guid masterBotIdentifier, decimal botTradingBalance, User user)
|
||||
{
|
||||
MasterBotIdentifier = masterBotIdentifier;
|
||||
BotTradingBalance = botTradingBalance;
|
||||
User = user;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Grains;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.ManageBot.Commands;
|
||||
using Managing.Common;
|
||||
using Managing.Domain.Accounts;
|
||||
using Managing.Domain.Bots;
|
||||
using MediatR;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Application.ManageBot
|
||||
{
|
||||
public class StartCopyTradingCommandHandler : IRequestHandler<StartCopyTradingCommand, string>
|
||||
{
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IGrainFactory _grainFactory;
|
||||
private readonly IBotService _botService;
|
||||
|
||||
public StartCopyTradingCommandHandler(
|
||||
IAccountService accountService, IGrainFactory grainFactory, IBotService botService)
|
||||
{
|
||||
_accountService = accountService;
|
||||
_grainFactory = grainFactory;
|
||||
_botService = botService;
|
||||
}
|
||||
|
||||
public async Task<string> Handle(StartCopyTradingCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate the trading balance
|
||||
if (request.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Bot trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}");
|
||||
}
|
||||
|
||||
// Get the master bot configuration
|
||||
var masterBot = await _botService.GetBotByIdentifier(request.MasterBotIdentifier);
|
||||
if (masterBot == null)
|
||||
{
|
||||
throw new ArgumentException($"Master bot with identifier {request.MasterBotIdentifier} not found");
|
||||
}
|
||||
|
||||
// Verify the user owns the master bot keys
|
||||
// if (masterBot.User?.Name != request.User.Name)
|
||||
// {
|
||||
// throw new UnauthorizedAccessException("You don't have permission to copy trades from this bot");
|
||||
// }
|
||||
|
||||
// Get the master bot configuration
|
||||
var masterConfig = await _botService.GetBotConfig(request.MasterBotIdentifier);
|
||||
if (masterConfig == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not retrieve configuration for master bot {request.MasterBotIdentifier}");
|
||||
}
|
||||
|
||||
// Get account information from the requesting user's accounts
|
||||
var userAccounts = await _accountService.GetAccountsByUserAsync(request.User, true, true);
|
||||
var firstAccount = userAccounts.FirstOrDefault();
|
||||
if (firstAccount == null)
|
||||
{
|
||||
throw new InvalidOperationException($"User '{request.User.Name}' has no accounts configured.");
|
||||
}
|
||||
Account account = firstAccount;
|
||||
|
||||
// Check balances for EVM/GMX V2 accounts before starting
|
||||
if (account.Exchange == TradingExchanges.Evm || account.Exchange == TradingExchanges.GmxV2)
|
||||
{
|
||||
var balanceCheckResult = await _botService.CheckAccountBalancesAsync(account);
|
||||
if (!balanceCheckResult.IsSuccessful)
|
||||
{
|
||||
throw new InvalidOperationException(balanceCheckResult.Message);
|
||||
}
|
||||
}
|
||||
|
||||
Balance usdcBalance = null;
|
||||
// For other exchanges, keep the original USDC balance check
|
||||
if (account.Exchange != TradingExchanges.Evm && account.Exchange != TradingExchanges.GmxV2)
|
||||
{
|
||||
usdcBalance = account.Balances.FirstOrDefault(b => b.TokenName == Ticker.USDC.ToString());
|
||||
|
||||
if (usdcBalance == null ||
|
||||
usdcBalance.Value < Constants.GMX.Config.MinimumPositionAmount ||
|
||||
usdcBalance.Value < request.BotTradingBalance)
|
||||
{
|
||||
throw new Exception(
|
||||
$"Account {masterConfig.AccountName} has no USDC balance or not enough balance for copy trading");
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce allocation limit across all bots using the same account
|
||||
var availableAllocation = await _botService.GetAvailableAllocationUsdAsync(account, default);
|
||||
if (request.BotTradingBalance > availableAllocation)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Insufficient available allocation. Requested: {request.BotTradingBalance:F2} USDC, " +
|
||||
$"Balance : {usdcBalance?.Value:F2 ?? 0} Available: {availableAllocation:F2} USDC.");
|
||||
}
|
||||
|
||||
// Create copy trading configuration based on master bot
|
||||
var copyTradingConfig = new TradingBotConfig
|
||||
{
|
||||
// Copy all master configuration
|
||||
AccountName = account.Name,
|
||||
MoneyManagement = masterConfig.MoneyManagement,
|
||||
Ticker = masterConfig.Ticker,
|
||||
Scenario = masterConfig.Scenario,
|
||||
ScenarioName = masterConfig.ScenarioName,
|
||||
Timeframe = masterConfig.Timeframe,
|
||||
IsForWatchingOnly = masterConfig.IsForWatchingOnly,
|
||||
BotTradingBalance = request.BotTradingBalance, // Use the provided trading balance
|
||||
CooldownPeriod = masterConfig.CooldownPeriod,
|
||||
MaxLossStreak = masterConfig.MaxLossStreak,
|
||||
MaxPositionTimeHours = masterConfig.MaxPositionTimeHours,
|
||||
FlipOnlyWhenInProfit = masterConfig.FlipOnlyWhenInProfit,
|
||||
CloseEarlyWhenProfitable = masterConfig.CloseEarlyWhenProfitable,
|
||||
UseSynthApi = masterConfig.UseSynthApi,
|
||||
UseForPositionSizing = masterConfig.UseForPositionSizing,
|
||||
UseForSignalFiltering = masterConfig.UseForSignalFiltering,
|
||||
UseForDynamicStopLoss = masterConfig.UseForDynamicStopLoss,
|
||||
FlipPosition = masterConfig.FlipPosition,
|
||||
|
||||
// Set copy trading specific properties
|
||||
IsForCopyTrading = true,
|
||||
MasterBotIdentifier = request.MasterBotIdentifier,
|
||||
|
||||
// Set computed/default properties
|
||||
IsForBacktest = false,
|
||||
Name = masterConfig.Name
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var botGrain = _grainFactory.GetGrain<ILiveTradingBotGrain>(Guid.NewGuid());
|
||||
await botGrain.CreateAsync(copyTradingConfig, request.User);
|
||||
|
||||
// Start the copy trading bot immediately
|
||||
await botGrain.StartAsync();
|
||||
|
||||
return $"Copy trading bot started successfully, following master bot '{masterConfig.Name}' with {request.BotTradingBalance:F2} USDC balance";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception($"Failed to start copy trading bot: {ex.Message}, {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user