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:
2025-11-16 14:54:17 +07:00
parent 428e36d744
commit 1e15d5f23b
10 changed files with 711 additions and 173 deletions

View File

@@ -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);

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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}");
}
}
}
}