Refactoring TradingBotBase.cs + clean architecture (#38)

* Refactoring TradingBotBase.cs + clean architecture

* Fix basic tests

* Fix tests

* Fix workers

* Fix open positions

* Fix closing position stucking the grain

* Fix comments

* Refactor candle handling to use IReadOnlyList for chronological order preservation across various components
This commit is contained in:
Oda
2025-12-01 19:32:06 +07:00
committed by GitHub
parent ab26260f6d
commit 9d536ea49e
74 changed files with 4525 additions and 2350 deletions

View File

@@ -129,7 +129,7 @@ public class BacktestExecutor
/// <returns>The lightweight backtest result</returns>
public async Task<LightBacktest> ExecuteAsync(
TradingBotConfig config,
HashSet<Candle> candles,
IReadOnlyList<Candle> candles,
User user,
bool save = false,
bool withCandles = false,
@@ -166,7 +166,9 @@ public class BacktestExecutor
// Create a fresh TradingBotBase instance for this backtest
var tradingBot = CreateTradingBotInstance(config);
tradingBot.Account = user.Accounts.First();
var account = user.Accounts.First();
account.User = user; // Ensure Account.User is set for backtest
tradingBot.Account = account;
var totalCandles = candles.Count;
var currentCandle = 0;
@@ -220,8 +222,9 @@ public class BacktestExecutor
// The signal calculation depends on rolling window state and cannot be pre-calculated effectively
// Use optimized rolling window approach - TradingBox.GetSignal only needs last 600 candles
// Use List<Candle> directly to preserve chronological order and enable incremental updates
const int RollingWindowSize = 600; // TradingBox.GetSignal only needs last 600 candles
var rollingWindowCandles = new Queue<Candle>(RollingWindowSize);
var rollingWindowCandles = new List<Candle>(RollingWindowSize); // Pre-allocate capacity for performance
var candlesProcessed = 0;
// Signal caching optimization - reduce signal update frequency for better performance
@@ -253,21 +256,20 @@ public class BacktestExecutor
cancellationToken.ThrowIfCancellationRequested();
// Maintain rolling window of last 600 candles to prevent exponential memory growth
rollingWindowCandles.Enqueue(candle);
if (rollingWindowCandles.Count > RollingWindowSize)
// Incremental updates: remove oldest if at capacity, then add newest
// This preserves chronological order and avoids expensive HashSet recreation
if (rollingWindowCandles.Count >= RollingWindowSize)
{
rollingWindowCandles.Dequeue(); // Remove oldest candle
rollingWindowCandles.RemoveAt(0); // Remove oldest candle (O(n) but only 600 items max)
}
rollingWindowCandles.Add(candle); // Add newest candle (O(1) amortized)
tradingBot.LastCandle = candle;
// Run with optimized backtest path (minimize async calls)
var signalUpdateStart = Stopwatch.GetTimestamp();
// Convert rolling window to HashSet for TradingBot.UpdateSignals compatibility
// NOTE: Recreating HashSet each iteration is necessary to maintain correct enumeration order
// Incremental updates break business logic (changes PnL results)
var fixedCandles = new HashSet<Candle>(rollingWindowCandles);
await tradingBot.UpdateSignals(fixedCandles, preCalculatedIndicatorValues);
// Pass List<Candle> directly - no conversion needed, order is preserved
await tradingBot.UpdateSignals(rollingWindowCandles, preCalculatedIndicatorValues);
signalUpdateTotalTime += Stopwatch.GetElapsedTime(signalUpdateStart);
var backtestStepStart = Stopwatch.GetTimestamp();
@@ -542,7 +544,7 @@ public class BacktestExecutor
throw new InvalidOperationException("Bot configuration is not initialized");
}
if (!config.IsForBacktest)
if (config.TradingType != TradingType.BacktestFutures)
{
throw new InvalidOperationException("BacktestExecutor can only be used for backtesting");
}
@@ -550,7 +552,7 @@ public class BacktestExecutor
// Create the trading bot instance
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
var tradingBot = new BacktestFuturesBot(logger, _scopeFactory, config);
return tradingBot;
}

View File

@@ -42,19 +42,22 @@ public class BacktestExecutorAdapter : IBacktester
object metadata = null)
{
// Load candles using ExchangeService
var candles = await _exchangeService.GetCandlesInflux(
var candlesHashSet = await _exchangeService.GetCandlesInflux(
TradingExchanges.Evm,
config.Ticker,
startDate,
config.Timeframe,
endDate);
if (candles == null || candles.Count == 0)
if (candlesHashSet == null || candlesHashSet.Count == 0)
{
throw new InvalidOperationException(
$"No candles found for {config.Ticker} on {config.Timeframe} from {startDate} to {endDate}");
}
// Convert to ordered List to preserve chronological order for backtest
var candles = candlesHashSet.OrderBy(c => c.Date).ToList();
// Execute using BacktestExecutor
var result = await _executor.ExecuteAsync(
config,
@@ -73,12 +76,15 @@ public class BacktestExecutorAdapter : IBacktester
public async Task<LightBacktest> RunTradingBotBacktest(
TradingBotConfig config,
HashSet<Candle> candles,
HashSet<Candle> candlesHashSet,
User user = null,
bool withCandles = false,
string requestId = null,
object metadata = null)
{
// Convert to ordered List to preserve chronological order for backtest
var candles = candlesHashSet.OrderBy(c => c.Date).ToList();
// Execute using BacktestExecutor
var result = await _executor.ExecuteAsync(
config,

View File

@@ -197,7 +197,7 @@ public class JobService
Timeframe = backtestRequest.Config.Timeframe,
IsForWatchingOnly = backtestRequest.Config.IsForWatchingOnly,
BotTradingBalance = backtestRequest.Config.BotTradingBalance,
IsForBacktest = true,
TradingType = TradingType.BacktestFutures,
CooldownPeriod = backtestRequest.Config.CooldownPeriod ?? 1,
MaxLossStreak = backtestRequest.Config.MaxLossStreak,
MaxPositionTimeHours = backtestRequest.Config.MaxPositionTimeHours,

View File

@@ -1,5 +1,332 @@
using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Services;
using Managing.Application.Trading.Commands;
using Managing.Application.Trading.Handlers;
using Managing.Core;
using Managing.Domain.Accounts;
using Managing.Domain.Bots;
using Managing.Domain.Candles;
using Managing.Domain.Indicators;
using Managing.Domain.Shared.Helpers;
using Managing.Domain.Strategies.Base;
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;
public class BacktestFuturesBot
public class BacktestFuturesBot : TradingBotBase, ITradingBot
{
public BacktestFuturesBot(
ILogger<TradingBotBase> logger,
IServiceScopeFactory scopeFactory,
TradingBotConfig config,
IStreamProvider? streamProvider = null
) : base(logger, scopeFactory, config, streamProvider)
{
// Backtest-specific initialization
Config.TradingType = TradingType.BacktestFutures;
}
public override async Task Start(BotStatus previousStatus)
{
// Backtest mode: Skip account loading and broker initialization
// Just log basic startup info
await LogInformation($"🔬 Backtest Bot Started\n" +
$"📊 Testing Setup:\n" +
$"🎯 Ticker: `{Config.Ticker}`\n" +
$"⏰ Timeframe: `{Config.Timeframe}`\n" +
$"🎮 Scenario: `{Config.Scenario?.Name ?? "Unknown"}`\n" +
$"💰 Initial Balance: `${Config.BotTradingBalance:F2}`\n" +
$"✅ Ready to run backtest simulation");
}
public override async Task Run()
{
// Backtest signal update is handled in BacktestExecutor loop
// No need to call UpdateSignals() here
if (!Config.IsForWatchingOnly)
await ManagePositions();
UpdateWalletBalances();
// Backtest logging - simplified, no account dependency
ExecutionCount++;
Logger.LogInformation(
"[Backtest][{BotName}] Execution {ExecutionCount} - LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Positions: {PositionCount}",
Config.Name, ExecutionCount, LastCandle?.Date, Signals.Count, Positions.Count);
}
protected override async Task<Position> GetInternalPositionForUpdate(Position position)
{
// In backtest mode, return the position as-is (no database lookup needed)
return position;
}
protected override async Task<List<Position>> GetBrokerPositionsForUpdate(Account account)
{
// In backtest mode, return empty list (no broker positions to check)
return new List<Position>();
}
protected override async Task UpdatePositionWithBrokerData(Position position, List<Position> brokerPositions)
{
// In backtest mode, skip broker synchronization
return;
}
protected override async Task<Candle> GetCurrentCandleForPositionClose(Account account, string ticker)
{
// In backtest mode, use LastCandle
return LastCandle;
}
protected override async Task<bool> CanOpenPositionWithBrokerChecks(LightSignal signal)
{
// In backtest mode, skip broker position checks
return await CanOpenPosition(signal);
}
protected override async Task LoadAccountAsync()
{
// In backtest mode, skip account loading
return;
}
protected override async Task VerifyAndUpdateBalanceAsync()
{
// In backtest mode, skip balance verification
return;
}
protected override async Task SendPositionToCopyTradingStream(Position position)
{
// In backtest mode, skip copy trading stream
return;
}
protected override async Task NotifyAgentAndPlatformAsync(NotificationEventType eventType, Position position)
{
// In backtest mode, skip notifications
return;
}
protected override async Task UpdatePositionInDatabaseAsync(Position position)
{
// In backtest mode, skip database updates
return;
}
protected override async Task SendClosedPositionToMessenger(Position position, User user)
{
// In backtest mode, skip messenger updates
return;
}
protected override async Task CancelAllOrdersAsync()
{
// In backtest mode, no orders to cancel
return;
}
protected override async Task LogInformationAsync(string message)
{
// In backtest mode, skip user notifications, just log to system
if (Config.TradingType == TradingType.BacktestFutures)
return;
await base.LogInformationAsync(message);
}
protected override async Task LogWarningAsync(string message)
{
// In backtest mode, skip user notifications, just log to system
if (Config.TradingType == TradingType.BacktestFutures)
return;
await base.LogWarningAsync(message);
}
protected override async Task LogDebugAsync(string message)
{
// In backtest mode, skip messenger debug logs
if (Config.TradingType == TradingType.BacktestFutures)
return;
await base.LogDebugAsync(message);
}
protected override async Task SendTradeMessageAsync(string message, bool isBadBehavior)
{
// In backtest mode, skip trade messages
return;
}
protected override async Task UpdateSignalsCore(IReadOnlyList<Candle> candles,
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues = null)
{
// Call base implementation for common logic (flip check, cooldown check)
await base.UpdateSignalsCore(candles, preCalculatedIndicatorValues);
// For backtest, if no candles provided (called from Run()), skip signal generation
// Signals are generated in BacktestExecutor with rolling window candles
if (candles == null || candles.Count == 0)
return;
if (Config.Scenario == null)
throw new ArgumentNullException(nameof(Config.Scenario), "Config.Scenario cannot be null");
// Use TradingBox.GetSignal for backtest with pre-calculated indicators
var backtestSignal = TradingBox.GetSignal(candles, Config.Scenario, Signals, Config.Scenario.LoopbackPeriod,
preCalculatedIndicatorValues);
if (backtestSignal == null) return;
await AddSignal(backtestSignal);
}
protected override async Task<decimal> GetLastPriceForPositionOpeningAsync()
{
// For backtest, use LastCandle close price
return LastCandle?.Close ?? 0;
}
protected override async Task<bool> CanOpenPosition(LightSignal signal)
{
// Backtest-specific logic: only check cooldown and loss streak
// No broker checks, no synth risk assessment, no startup cycle check needed
return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal);
}
protected override async Task<Position> HandleFlipPosition(LightSignal signal, Position openedPosition,
LightSignal previousSignal, decimal lastPrice)
{
// Backtest-specific flip logic
if (Config.FlipPosition)
{
var isPositionInProfit = (openedPosition.ProfitAndLoss?.Realized ?? 0) > 0;
var shouldFlip = !Config.FlipOnlyWhenInProfit || isPositionInProfit;
if (shouldFlip)
{
var flipReason = Config.FlipOnlyWhenInProfit
? "current position is in profit"
: "FlipOnlyWhenInProfit is disabled";
await LogInformationAsync(
$"🔄 Position Flip Initiated\nFlipping position due to opposite signal\nReason: {flipReason}");
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped);
var newPosition = await OpenPosition(signal);
await LogInformationAsync(
$"✅ Position Flipped\nPosition: `{previousSignal.Identifier}` → `{signal.Identifier}`\nPrice: `${lastPrice}`");
return newPosition;
}
else
{
var currentPnl = openedPosition.ProfitAndLoss?.Realized ?? 0;
await LogInformationAsync(
$"💸 Flip Blocked - Not Profitable\nPosition `{previousSignal.Identifier}` PnL: `${currentPnl:F2}`\nSignal `{signal.Identifier}` will wait for profitability");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
}
else
{
await LogInformationAsync(
$"🚫 Flip Disabled\nPosition already open for: `{previousSignal.Identifier}`\nFlipping disabled, new signal expired");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
}
protected override async Task<Position> ExecuteOpenPosition(LightSignal signal, decimal lastPrice)
{
// Backtest-specific position opening: no balance verification, no exchange calls
if (Account == null || Account.User == null)
{
throw new InvalidOperationException("Account and Account.User must be set before opening a position");
}
var command = new OpenPositionRequest(
Config.AccountName,
Config.MoneyManagement,
signal.Direction,
Config.Ticker,
PositionInitiator.Bot,
signal.Date,
Account.User,
Config.BotTradingBalance,
isForPaperTrading: true, // Backtest is always paper trading
lastPrice,
signalIdentifier: signal.Identifier,
initiatorIdentifier: Identifier,
tradingType: Config.TradingType);
var position = await ServiceScopeHelpers
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
_scopeFactory,
async (exchangeService, accountService, tradingService) =>
{
return await new OpenPositionCommandHandler(exchangeService, accountService, tradingService)
.Handle(command);
});
return position;
}
public override async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice,
bool tradeClosingPosition = false, bool forceMarketClose = false)
{
await LogInformationAsync(
$"🔧 Closing {position.OriginDirection} Trade\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n📋 Type: `{tradeToClose.TradeType}`\n📊 Quantity: `{tradeToClose.Quantity:F5}`");
// Backtest-specific: no exchange quantity check, no grace period, direct close
var command = new CloseBacktestFuturesPositionCommand(position, position.AccountId, lastPrice);
try
{
Position closedPosition = null;
await ServiceScopeHelpers.WithScopedServices<IExchangeService, IAccountService, ITradingService>(
_scopeFactory, async (exchangeService, accountService, tradingService) =>
{
closedPosition =
await new CloseBacktestFuturesPositionCommandHandler(exchangeService, accountService, tradingService,
_scopeFactory)
.Handle(command);
});
if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped)
{
if (tradeClosingPosition)
{
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
}
await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : (decimal?)null,
forceMarketClose);
}
else
{
throw new Exception($"Wrong position status : {closedPosition.Status}");
}
}
catch (Exception ex)
{
await LogWarningAsync($"Position {signal.Identifier} not closed : {ex.Message}");
if (position.Status == PositionStatus.Canceled || position.Status == PositionStatus.Rejected)
{
// Trade close on exchange => Should close trade manually
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
// Ensure trade dates are properly updated even for canceled/rejected positions
await HandleClosedPosition(position, forceMarketClose ? lastPrice : (decimal?)null,
forceMarketClose);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -53,7 +53,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
/// <returns>The complete backtest result</returns>
public async Task<LightBacktest> RunBacktestAsync(
TradingBotConfig config,
HashSet<Candle> candles,
IReadOnlyList<Candle> candles,
User user = null,
bool save = false,
bool withCandles = false,
@@ -67,7 +67,9 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
// Create a fresh TradingBotBase instance for this backtest
var tradingBot = await CreateTradingBotInstance(config);
tradingBot.Account = user.Accounts.First();
var account = user.Accounts.First();
account.User = user; // Ensure Account.User is set for backtest
tradingBot.Account = account;
var totalCandles = candles.Count;
var currentCandle = 0;
@@ -81,7 +83,9 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
tradingBot.WalletBalances.Add(candles.FirstOrDefault()!.Date, config.BotTradingBalance);
var initialBalance = config.BotTradingBalance;
var fixedCandles = new HashSet<Candle>();
const int RollingWindowSize = 600; // TradingBox.GetSignal only needs last 600 candles
// Use List<Candle> directly to preserve chronological order and enable incremental updates
var rollingWindowCandles = new List<Candle>(RollingWindowSize); // Pre-allocate capacity for performance
var lastYieldTime = DateTime.UtcNow;
const int yieldIntervalMs = 5000; // Yield control every 5 seconds to prevent timeout
const int candlesPerBatch = 100; // Process in batches to allow Orleans to check for cancellation
@@ -89,11 +93,19 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
// Process all candles following the exact pattern from GetBacktestingResult
foreach (var candle in candles)
{
fixedCandles.Add(candle);
// Maintain rolling window: remove oldest if at capacity, then add newest
// This preserves chronological order and avoids expensive HashSet recreation
if (rollingWindowCandles.Count >= RollingWindowSize)
{
rollingWindowCandles.RemoveAt(0); // Remove oldest candle (O(n) but only 600 items max)
}
rollingWindowCandles.Add(candle); // Add newest candle (O(1) amortized)
tradingBot.LastCandle = candle;
// Update signals manually only for backtesting
await tradingBot.UpdateSignals(fixedCandles);
// Update signals manually only for backtesting with rolling window
// Pass List<Candle> directly - no conversion needed, order is preserved
await tradingBot.UpdateSignals(rollingWindowCandles);
await tradingBot.Run();
currentCandle++;
@@ -132,11 +144,12 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
_logger.LogInformation("Backtest processing completed. Calculating final results...");
var finalPnl = TradingBox.GetTotalNetPnL(tradingBot.Positions);
var realizedPnl = TradingBox.GetTotalRealizedPnL(tradingBot.Positions); // PnL before fees
var netPnl = TradingBox.GetTotalNetPnL(tradingBot.Positions); // PnL after fees
var winRate = TradingBox.GetWinRate(tradingBot.Positions);
var stats = TradingBox.GetStatistics(tradingBot.WalletBalances);
var growthPercentage =
TradingBox.GetGrowthFromInitalBalance(tradingBot.WalletBalances.FirstOrDefault().Value, finalPnl);
TradingBox.GetGrowthFromInitalBalance(tradingBot.WalletBalances.FirstOrDefault().Value, netPnl);
var hodlPercentage = TradingBox.GetHodlPercentage(candles.First(), candles.Last());
var fees = TradingBox.GetTotalFees(tradingBot.Positions);
@@ -145,7 +158,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
growthPercentage: (double)growthPercentage,
hodlPercentage: (double)hodlPercentage,
winRate: winRate,
totalPnL: (double)finalPnl,
totalPnL: (double)realizedPnl,
fees: (double)fees,
tradeCount: tradingBot.Positions.Count,
maxDrawdownRecoveryTime: stats.MaxDrawdownRecoveryTime,
@@ -166,7 +179,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
// Create backtest result with conditional candles and indicators values
var result = new Backtest(config, tradingBot.Positions, tradingBot.Signals)
{
FinalPnl = finalPnl,
FinalPnl = realizedPnl, // Realized PnL before fees
WinRate = winRate,
GrowthPercentage = growthPercentage,
HodlPercentage = hodlPercentage,
@@ -180,7 +193,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
StartDate = candles.FirstOrDefault()!.OpenTime,
EndDate = candles.LastOrDefault()!.OpenTime,
InitialBalance = initialBalance,
NetPnl = finalPnl - fees,
NetPnl = netPnl, // Net PnL after fees
};
if (save && user != null)
@@ -233,14 +246,14 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
throw new InvalidOperationException("Bot configuration is not initialized");
}
if (!config.IsForBacktest)
if (config.TradingType != TradingType.BacktestFutures)
{
throw new InvalidOperationException("BacktestTradingBotGrain can only be used for backtesting");
}
// Create the trading bot instance
var logger = _scopeFactory.CreateScope().ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
var tradingBot = new BacktestFuturesBot(logger, _scopeFactory, config);
return tradingBot;
}
@@ -284,7 +297,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain
/// Gets indicators values (following Backtester.cs pattern)
/// </summary>
private Dictionary<IndicatorType, IndicatorsResultBase> GetIndicatorsValues(List<LightIndicator> indicators,
HashSet<Candle> candles)
IReadOnlyList<Candle> candles)
{
var indicatorsValues = new Dictionary<IndicatorType, IndicatorsResultBase>();

View File

@@ -72,7 +72,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
throw new InvalidOperationException("Bot configuration is not properly initialized");
}
if (config.IsForBacktest)
if (config.TradingType == TradingType.BacktestFutures)
{
throw new InvalidOperationException("LiveTradingBotGrain cannot be used for backtesting");
}
@@ -531,7 +531,7 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
using var scope = _scopeFactory.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
var streamProvider = this.GetStreamProvider("ManagingStreamProvider");
var tradingBot = new TradingBotBase(logger, _scopeFactory, config, streamProvider);
var tradingBot = new FuturesBot(logger, _scopeFactory, config, streamProvider);
// Load state into the trading bot instance
LoadStateIntoTradingBot(tradingBot);

File diff suppressed because it is too large Load Diff

View File

@@ -955,7 +955,7 @@ public class TradingBotChromosome : ChromosomeBase
Ticker = request.Ticker,
Timeframe = request.Timeframe,
BotTradingBalance = request.Balance,
IsForBacktest = true,
TradingType = TradingType.BacktestFutures,
IsForWatchingOnly = false,
CooldownPeriod = Convert.ToInt32(genes[2].Value),
MaxLossStreak = Convert.ToInt32(genes[3].Value),
@@ -1104,7 +1104,7 @@ public class TradingBotFitness : IFitness
_serviceScopeFactory,
async executor => await executor.ExecuteAsync(
config,
_candles,
_candles.OrderBy(c => c.Date).ToList(),
_request.User,
save: true,
withCandles: false,

View File

@@ -1,534 +0,0 @@
using System.Text.Json;
using Managing.Application.Abstractions.Grains;
using Managing.Application.Abstractions.Services;
using Managing.Application.Orleans;
using Managing.Core;
using Managing.Domain.Accounts;
using Managing.Domain.Backtests;
using Managing.Domain.Bots;
using Managing.Domain.MoneyManagements;
using Managing.Domain.Scenarios;
using Managing.Domain.Strategies;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Orleans.Concurrency;
using static Managing.Common.Enums;
namespace Managing.Application.Grains;
/// <summary>
/// Stateless worker grain for processing bundle backtest requests
/// Uses the bundle request ID as the primary key (Guid)
/// Implements IRemindable for automatic retry of failed bundles
/// Uses custom compute placement with random fallback.
/// </summary>
[StatelessWorker]
[TradingPlacement] // Use custom compute placement with random fallback
public class BundleBacktestGrain : Grain, IBundleBacktestGrain, IRemindable
{
private readonly ILogger<BundleBacktestGrain> _logger;
private readonly IServiceScopeFactory _scopeFactory;
// Reminder configuration
private const string RETRY_REMINDER_NAME = "BundleBacktestRetry";
private static readonly TimeSpan RETRY_INTERVAL = TimeSpan.FromMinutes(30);
public BundleBacktestGrain(
ILogger<BundleBacktestGrain> logger,
IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task ProcessBundleRequestAsync()
{
// Get the RequestId from the grain's primary key
var bundleRequestId = this.GetPrimaryKey();
try
{
// Create a new service scope to get fresh instances of services with scoped DbContext
using var scope = _scopeFactory.CreateScope();
var backtester = scope.ServiceProvider.GetRequiredService<IBacktester>();
var messengerService = scope.ServiceProvider.GetRequiredService<IMessengerService>();
// Get the specific bundle request by ID
var bundleRequest = await GetBundleRequestById(backtester, bundleRequestId);
if (bundleRequest == null)
{
_logger.LogError("Bundle request {RequestId} not found", bundleRequestId);
return;
}
// Process only this specific bundle request
await ProcessBundleRequest(bundleRequest, backtester, messengerService);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in BundleBacktestGrain for request {RequestId}", bundleRequestId);
throw;
}
}
private async Task<BundleBacktestRequest> GetBundleRequestById(IBacktester backtester, Guid bundleRequestId)
{
try
{
// Get pending and failed bundle backtest requests for retry capability
var pendingRequests =
await backtester.GetBundleBacktestRequestsByStatusAsync(BundleBacktestRequestStatus.Pending);
var failedRequests =
await backtester.GetBundleBacktestRequestsByStatusAsync(BundleBacktestRequestStatus.Failed);
var allRequests = pendingRequests.Concat(failedRequests);
return allRequests.FirstOrDefault(r => r.RequestId == bundleRequestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get bundle request {RequestId}", bundleRequestId);
return null;
}
}
private async Task ProcessBundleRequest(
BundleBacktestRequest bundleRequest,
IBacktester backtester,
IMessengerService messengerService)
{
try
{
_logger.LogInformation("Starting to process bundle backtest request {RequestId}", bundleRequest.RequestId);
// Update status to running
bundleRequest.Status = BundleBacktestRequestStatus.Running;
await backtester.UpdateBundleBacktestRequestAsync(bundleRequest);
// Generate backtest requests from variant configuration
var backtestRequests = await GenerateBacktestRequestsFromVariants(bundleRequest);
if (backtestRequests == null || !backtestRequests.Any())
{
throw new InvalidOperationException("Failed to generate backtest requests from variants");
}
// Process each backtest request sequentially
for (int i = 0; i < backtestRequests.Count; i++)
{
await ProcessSingleBacktest(backtester, backtestRequests[i], bundleRequest, i);
}
// Update final status and send notifications
await UpdateFinalStatus(bundleRequest, backtester, messengerService);
_logger.LogInformation("Completed processing bundle backtest request {RequestId} with status {Status}",
bundleRequest.RequestId, bundleRequest.Status);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing bundle backtest request {RequestId}", bundleRequest.RequestId);
SentrySdk.CaptureException(ex);
await HandleBundleRequestError(bundleRequest, backtester, ex);
}
}
/// <summary>
/// Generates individual backtest requests from variant configuration
/// </summary>
private async Task<List<RunBacktestRequest>> GenerateBacktestRequestsFromVariants(
BundleBacktestRequest bundleRequest)
{
try
{
// Deserialize the variant configurations
var universalConfig =
JsonSerializer.Deserialize<BundleBacktestUniversalConfig>(bundleRequest.UniversalConfigJson);
var dateTimeRanges = JsonSerializer.Deserialize<List<DateTimeRange>>(bundleRequest.DateTimeRangesJson);
var moneyManagementVariants =
JsonSerializer.Deserialize<List<MoneyManagementVariant>>(bundleRequest.MoneyManagementVariantsJson);
var tickerVariants = JsonSerializer.Deserialize<List<Ticker>>(bundleRequest.TickerVariantsJson);
if (universalConfig == null || dateTimeRanges == null || moneyManagementVariants == null ||
tickerVariants == null)
{
_logger.LogError("Failed to deserialize variant configurations for bundle request {RequestId}",
bundleRequest.RequestId);
return new List<RunBacktestRequest>();
}
// Get the first account for the user using AccountService
var firstAccount = await ServiceScopeHelpers.WithScopedService<IAccountService, Account?>(
_scopeFactory,
async service =>
{
var accounts =
await service.GetAccountsByUserAsync(bundleRequest.User, hideSecrets: true, getBalance: false);
return accounts.FirstOrDefault();
});
if (firstAccount == null)
{
_logger.LogError("No accounts found for user {UserId} in bundle request {RequestId}",
bundleRequest.User.Id, bundleRequest.RequestId);
return new List<RunBacktestRequest>();
}
var backtestRequests = new List<RunBacktestRequest>();
foreach (var dateRange in dateTimeRanges)
{
foreach (var mmVariant in moneyManagementVariants)
{
foreach (var ticker in tickerVariants)
{
var config = new TradingBotConfigRequest
{
AccountName = firstAccount.Name,
Ticker = ticker,
Timeframe = universalConfig.Timeframe,
IsForWatchingOnly = universalConfig.IsForWatchingOnly,
BotTradingBalance = universalConfig.BotTradingBalance,
Name =
$"{universalConfig.BotName}_{ticker}_{dateRange.StartDate:yyyyMMdd}_{dateRange.EndDate:yyyyMMdd}",
FlipPosition = universalConfig.FlipPosition,
CooldownPeriod = universalConfig.CooldownPeriod,
MaxLossStreak = universalConfig.MaxLossStreak,
Scenario = universalConfig.Scenario,
ScenarioName = universalConfig.ScenarioName,
MoneyManagement = mmVariant.MoneyManagement,
MaxPositionTimeHours = universalConfig.MaxPositionTimeHours,
CloseEarlyWhenProfitable = universalConfig.CloseEarlyWhenProfitable,
FlipOnlyWhenInProfit = universalConfig.FlipOnlyWhenInProfit,
UseSynthApi = universalConfig.UseSynthApi,
UseForPositionSizing = universalConfig.UseForPositionSizing,
UseForSignalFiltering = universalConfig.UseForSignalFiltering,
UseForDynamicStopLoss = universalConfig.UseForDynamicStopLoss
};
var backtestRequest = new RunBacktestRequest
{
Config = config,
StartDate = dateRange.StartDate,
EndDate = dateRange.EndDate,
Balance = universalConfig.BotTradingBalance,
WatchOnly = universalConfig.WatchOnly,
Save = universalConfig.Save,
WithCandles = false, // Bundle backtests never return candles
MoneyManagement = mmVariant.MoneyManagement != null
? new MoneyManagement
{
Name = mmVariant.MoneyManagement.Name,
Timeframe = mmVariant.MoneyManagement.Timeframe,
StopLoss = mmVariant.MoneyManagement.StopLoss,
TakeProfit = mmVariant.MoneyManagement.TakeProfit,
Leverage = mmVariant.MoneyManagement.Leverage
}
: null
};
backtestRequests.Add(backtestRequest);
}
}
}
return backtestRequests;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating backtest requests from variants for bundle request {RequestId}",
bundleRequest.RequestId);
return new List<RunBacktestRequest>();
}
}
private async Task ProcessSingleBacktest(
IBacktester backtester,
RunBacktestRequest runBacktestRequest,
BundleBacktestRequest bundleRequest,
int index)
{
try
{
// Calculate total count from the variant configuration
var totalCount = bundleRequest.TotalBacktests;
// Update current backtest being processed
bundleRequest.CurrentBacktest = $"Backtest {index + 1} of {totalCount}";
await backtester.UpdateBundleBacktestRequestAsync(bundleRequest);
bundleRequest.User.Accounts = await ServiceScopeHelpers.WithScopedService<IAccountService, List<Account>>(
_scopeFactory,
async service => { return (await service.GetAccountsByUserAsync(bundleRequest.User, true)).ToList(); });
// Run the backtest directly with the strongly-typed request
var backtestId = await RunSingleBacktest(backtester, runBacktestRequest, bundleRequest, index);
if (!string.IsNullOrEmpty(backtestId))
{
bundleRequest.Results.Add(backtestId);
}
// Update progress
bundleRequest.CompletedBacktests++;
await backtester.UpdateBundleBacktestRequestAsync(bundleRequest);
_logger.LogInformation("Completed backtest {Index} for bundle request {RequestId}",
index + 1, bundleRequest.RequestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing backtest {Index} for bundle request {RequestId}",
index + 1, bundleRequest.RequestId);
bundleRequest.FailedBacktests++;
await backtester.UpdateBundleBacktestRequestAsync(bundleRequest);
SentrySdk.CaptureException(ex);
}
}
private async Task<string> RunSingleBacktest(
IBacktester backtester,
RunBacktestRequest runBacktestRequest,
BundleBacktestRequest bundleRequest,
int index)
{
if (runBacktestRequest?.Config == null)
{
_logger.LogError("Invalid RunBacktestRequest in bundle (null config)");
return string.Empty;
}
// Map MoneyManagement
MoneyManagement moneyManagement = null;
if (!string.IsNullOrEmpty(runBacktestRequest.Config.MoneyManagementName))
{
_logger.LogWarning("MoneyManagementName provided but cannot resolve in grain context: {Name}",
runBacktestRequest.Config.MoneyManagementName);
}
else if (runBacktestRequest.Config.MoneyManagement != null)
{
var mmReq = runBacktestRequest.Config.MoneyManagement;
moneyManagement = new MoneyManagement
{
Name = mmReq.Name,
Timeframe = mmReq.Timeframe,
StopLoss = mmReq.StopLoss,
TakeProfit = mmReq.TakeProfit,
Leverage = mmReq.Leverage
};
moneyManagement.FormatPercentage();
}
// Map Scenario
LightScenario scenario = null;
if (runBacktestRequest.Config.Scenario != null)
{
var sReq = runBacktestRequest.Config.Scenario;
scenario = new LightScenario(sReq.Name, sReq.LoopbackPeriod)
{
Indicators = sReq.Indicators?.Select(i => new LightIndicator(i.Name, i.Type)
{
MinimumHistory = i.MinimumHistory,
Period = i.Period,
FastPeriods = i.FastPeriods,
SlowPeriods = i.SlowPeriods,
SignalPeriods = i.SignalPeriods,
Multiplier = i.Multiplier,
SmoothPeriods = i.SmoothPeriods,
StochPeriods = i.StochPeriods,
CyclePeriods = i.CyclePeriods
}).ToList() ?? new List<LightIndicator>()
};
}
// Map TradingBotConfig
var backtestConfig = new TradingBotConfig
{
AccountName = runBacktestRequest.Config.AccountName,
MoneyManagement = moneyManagement,
Ticker = runBacktestRequest.Config.Ticker,
ScenarioName = runBacktestRequest.Config.ScenarioName,
Scenario = scenario,
Timeframe = runBacktestRequest.Config.Timeframe,
IsForWatchingOnly = runBacktestRequest.Config.IsForWatchingOnly,
BotTradingBalance = runBacktestRequest.Config.BotTradingBalance,
IsForBacktest = true,
CooldownPeriod = runBacktestRequest.Config.CooldownPeriod ?? 1,
MaxLossStreak = runBacktestRequest.Config.MaxLossStreak,
MaxPositionTimeHours = runBacktestRequest.Config.MaxPositionTimeHours,
FlipOnlyWhenInProfit = runBacktestRequest.Config.FlipOnlyWhenInProfit,
FlipPosition = runBacktestRequest.Config.FlipPosition,
Name = $"{bundleRequest.Name} #{index + 1}",
CloseEarlyWhenProfitable = runBacktestRequest.Config.CloseEarlyWhenProfitable,
UseSynthApi = runBacktestRequest.Config.UseSynthApi,
UseForPositionSizing = runBacktestRequest.Config.UseForPositionSizing,
UseForSignalFiltering = runBacktestRequest.Config.UseForSignalFiltering,
UseForDynamicStopLoss = runBacktestRequest.Config.UseForDynamicStopLoss
};
// Run the backtest
var result = await backtester.RunTradingBotBacktest(
backtestConfig,
runBacktestRequest.StartDate,
runBacktestRequest.EndDate,
bundleRequest.User,
true,
runBacktestRequest.WithCandles,
bundleRequest.RequestId.ToString()
);
_logger.LogInformation("Processed backtest for bundle request {RequestId}", bundleRequest.RequestId);
return result.Id;
}
private async Task UpdateFinalStatus(
BundleBacktestRequest bundleRequest,
IBacktester backtester,
IMessengerService messengerService)
{
if (bundleRequest.FailedBacktests == 0)
{
bundleRequest.Status = BundleBacktestRequestStatus.Completed;
await NotifyUser(bundleRequest, messengerService);
}
else if (bundleRequest.CompletedBacktests == 0)
{
bundleRequest.Status = BundleBacktestRequestStatus.Failed;
bundleRequest.ErrorMessage = "All backtests failed";
}
else
{
bundleRequest.Status = BundleBacktestRequestStatus.Completed;
bundleRequest.ErrorMessage = $"{bundleRequest.FailedBacktests} backtests failed";
await NotifyUser(bundleRequest, messengerService);
}
bundleRequest.CompletedAt = DateTime.UtcNow;
bundleRequest.CurrentBacktest = null;
await backtester.UpdateBundleBacktestRequestAsync(bundleRequest);
// Unregister retry reminder since bundle completed
await UnregisterRetryReminder();
}
private async Task HandleBundleRequestError(
BundleBacktestRequest bundleRequest,
IBacktester backtester,
Exception ex)
{
bundleRequest.Status = BundleBacktestRequestStatus.Failed;
bundleRequest.ErrorMessage = ex.Message;
bundleRequest.CompletedAt = DateTime.UtcNow;
await backtester.UpdateBundleBacktestRequestAsync(bundleRequest);
// Register retry reminder for failed bundle
await RegisterRetryReminder();
}
private async Task NotifyUser(BundleBacktestRequest bundleRequest, IMessengerService messengerService)
{
if (bundleRequest.User?.TelegramChannel != null)
{
var message = bundleRequest.FailedBacktests == 0
? $"✅ Bundle backtest '{bundleRequest.Name}' (ID: {bundleRequest.RequestId}) completed successfully."
: $"⚠️ Bundle backtest '{bundleRequest.Name}' (ID: {bundleRequest.RequestId}) completed with {bundleRequest.FailedBacktests} failed backtests.";
await messengerService.SendMessage(message, bundleRequest.User.TelegramChannel);
}
}
#region IRemindable Implementation
/// <summary>
/// Handles reminder callbacks for automatic retry of failed bundle backtests
/// </summary>
public async Task ReceiveReminder(string reminderName, TickStatus status)
{
if (reminderName != RETRY_REMINDER_NAME)
{
_logger.LogWarning("Unknown reminder {ReminderName} received", reminderName);
return;
}
var bundleRequestId = this.GetPrimaryKey();
_logger.LogInformation("Retry reminder triggered for bundle request {RequestId}", bundleRequestId);
try
{
using var scope = _scopeFactory.CreateScope();
var backtester = scope.ServiceProvider.GetRequiredService<IBacktester>();
// Get the bundle request
var bundleRequest = await GetBundleRequestById(backtester, bundleRequestId);
if (bundleRequest == null)
{
_logger.LogWarning("Bundle request {RequestId} not found during retry", bundleRequestId);
await UnregisterRetryReminder();
return;
}
// Check if bundle is still failed
if (bundleRequest.Status != BundleBacktestRequestStatus.Failed)
{
_logger.LogInformation(
"Bundle request {RequestId} is no longer failed (status: {Status}), unregistering reminder",
bundleRequestId, bundleRequest.Status);
await UnregisterRetryReminder();
return;
}
// Retry the bundle processing
_logger.LogInformation("Retrying failed bundle request {RequestId}", bundleRequestId);
// Reset status to pending for retry
bundleRequest.Status = BundleBacktestRequestStatus.Pending;
bundleRequest.ErrorMessage = null;
bundleRequest.CurrentBacktest = null;
await backtester.UpdateBundleBacktestRequestAsync(bundleRequest);
// Process the bundle again
await ProcessBundleRequestAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during bundle backtest retry for request {RequestId}", bundleRequestId);
}
}
/// <summary>
/// Registers a retry reminder for this bundle request
/// </summary>
private async Task RegisterRetryReminder()
{
try
{
await this.RegisterOrUpdateReminder(RETRY_REMINDER_NAME, RETRY_INTERVAL, RETRY_INTERVAL);
_logger.LogInformation("Registered retry reminder for bundle request {RequestId}", this.GetPrimaryKey());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register retry reminder for bundle request {RequestId}",
this.GetPrimaryKey());
}
}
/// <summary>
/// Unregisters the retry reminder for this bundle request
/// </summary>
private async Task UnregisterRetryReminder()
{
try
{
var reminder = await this.GetReminder(RETRY_REMINDER_NAME);
if (reminder != null)
{
await this.UnregisterReminder(reminder);
_logger.LogInformation("Unregistered retry reminder for bundle request {RequestId}",
this.GetPrimaryKey());
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to unregister retry reminder for bundle request {RequestId}",
this.GetPrimaryKey());
}
}
#endregion
}

View File

@@ -1,95 +0,0 @@
using Managing.Application.Abstractions.Grains;
using Managing.Application.Abstractions.Services;
using Managing.Application.Orleans;
using Managing.Core;
using Managing.Domain.Accounts;
using Managing.Domain.Backtests;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Orleans.Concurrency;
namespace Managing.Application.Grains;
/// <summary>
/// Stateless worker grain for processing genetic backtest requests.
/// Uses the genetic request ID (string) as the primary key.
/// Uses custom compute placement with random fallback.
/// </summary>
[StatelessWorker]
[TradingPlacement] // Use custom compute placement with random fallback
public class GeneticBacktestGrain : Grain, IGeneticBacktestGrain
{
private readonly ILogger<GeneticBacktestGrain> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public GeneticBacktestGrain(
ILogger<GeneticBacktestGrain> logger,
IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task ProcessGeneticRequestAsync()
{
var requestId = this.GetPrimaryKeyString();
try
{
using var scope = _scopeFactory.CreateScope();
var geneticService = scope.ServiceProvider.GetRequiredService<IGeneticService>();
// Load the request by status lists and filter by ID (Pending first, then Failed for retries)
var pending = await geneticService.GetGeneticRequestsAsync(GeneticRequestStatus.Pending);
var failed = await geneticService.GetGeneticRequestsAsync(GeneticRequestStatus.Failed);
var request = pending.Concat(failed).FirstOrDefault(r => r.RequestId == requestId);
if (request == null)
{
_logger.LogWarning("[GeneticBacktestGrain] Request {RequestId} not found among pending/failed.",
requestId);
return;
}
// Mark running
request.Status = GeneticRequestStatus.Running;
await geneticService.UpdateGeneticRequestAsync(request);
request.User.Accounts = await ServiceScopeHelpers.WithScopedService<IAccountService, List<Account>>(
_scopeFactory,
async accountService => (await accountService.GetAccountsByUserAsync(request.User)).ToList());
// Run GA
var result = await geneticService.RunGeneticAlgorithm(request, CancellationToken.None);
// Update final state
request.Status = GeneticRequestStatus.Completed;
request.CompletedAt = DateTime.UtcNow;
request.BestFitness = result.BestFitness;
request.BestIndividual = result.BestIndividual;
request.ProgressInfo = result.ProgressInfo;
await geneticService.UpdateGeneticRequestAsync(request);
_logger.LogInformation("[GeneticBacktestGrain] Completed request {RequestId}", requestId);
}
catch (Exception ex)
{
SentrySdk.CaptureException(ex);
try
{
using var scope = _scopeFactory.CreateScope();
var geneticService = scope.ServiceProvider.GetRequiredService<IGeneticService>();
var running = await geneticService.GetGeneticRequestsAsync(GeneticRequestStatus.Running);
var req = running.FirstOrDefault(r => r.RequestId == requestId) ?? new GeneticRequest(requestId);
req.Status = GeneticRequestStatus.Failed;
req.ErrorMessage = ex.Message;
req.CompletedAt = DateTime.UtcNow;
await geneticService.UpdateGeneticRequestAsync(req);
}
catch
{
}
}
}
}

View File

@@ -187,7 +187,7 @@ namespace Managing.Application.ManageBot
MasterBotUserId = masterBot.User.Id,
// Set computed/default properties
IsForBacktest = false,
TradingType = TradingType.Futures,
Name = masterConfig.Name
};

View File

@@ -61,16 +61,19 @@ public class ScenarioRunnerGrain : Grain, IScenarioRunnerGrain
try
{
var candlesHashSet = await GetCandlesAsync(tradingExchanges, config);
if (candlesHashSet.LastOrDefault()!.Date <= candle.Date)
// Convert to ordered List to preserve chronological order for indicators
var candlesList = candlesHashSet.OrderBy(c => c.Date).ToList();
if (candlesList.LastOrDefault()!.Date <= candle.Date)
{
_logger.LogWarning($"No new candles for {config.Ticker} for {config.Name}");
return null; // No new candles, no need to generate a signal
}
_logger.LogInformation($"Fetched {candlesHashSet.Count} candles for {config.Ticker} for {config.Name}");
_logger.LogInformation($"Fetched {candlesList.Count} candles for {config.Ticker} for {config.Name}");
var signal = TradingBox.GetSignal(
candlesHashSet,
candlesList,
config.Scenario,
previousSignals,
config.Scenario?.LoopbackPeriod ?? 1);

View File

@@ -0,0 +1,20 @@
using Managing.Domain.Trades;
using MediatR;
namespace Managing.Application.Trading.Commands
{
public class CloseBacktestFuturesPositionCommand : IRequest<Position>
{
public CloseBacktestFuturesPositionCommand(Position position, int accountId, decimal? executionPrice = null)
{
Position = position;
AccountId = accountId;
ExecutionPrice = executionPrice;
}
public Position Position { get; }
public int AccountId { get; }
public decimal? ExecutionPrice { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
using Managing.Domain.Trades;
using MediatR;
namespace Managing.Application.Trading.Commands
{
public class CloseFuturesPositionCommand : IRequest<Position>
{
public CloseFuturesPositionCommand(Position position, int accountId, decimal? executionPrice = null)
{
Position = position;
AccountId = accountId;
ExecutionPrice = executionPrice;
}
public Position Position { get; }
public int AccountId { get; }
public decimal? ExecutionPrice { get; set; }
}
}

View File

@@ -20,7 +20,8 @@ namespace Managing.Application.Trading.Commands
bool isForPaperTrading = false,
decimal? price = null,
string signalIdentifier = null,
Guid? initiatorIdentifier = null)
Guid? initiatorIdentifier = null,
TradingType tradingType = TradingType.Futures)
{
AccountName = accountName;
MoneyManagement = moneyManagement;
@@ -43,6 +44,7 @@ namespace Managing.Application.Trading.Commands
InitiatorIdentifier = initiatorIdentifier ??
throw new ArgumentNullException(nameof(initiatorIdentifier),
"InitiatorIdentifier is required");
TradingType = tradingType;
}
public string SignalIdentifier { get; set; }
@@ -57,5 +59,6 @@ namespace Managing.Application.Trading.Commands
public PositionInitiator Initiator { get; }
public User User { get; }
public Guid InitiatorIdentifier { get; }
public TradingType TradingType { get; }
}
}

View File

@@ -0,0 +1,70 @@
using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Services;
using Managing.Application.Trading.Commands;
using Managing.Common;
using Managing.Domain.Shared.Helpers;
using Managing.Domain.Trades;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
namespace Managing.Application.Trading.Handlers;
public class CloseBacktestFuturesPositionCommandHandler(
IExchangeService exchangeService,
IAccountService accountService,
ITradingService tradingService,
IServiceScopeFactory scopeFactory,
ILogger<CloseBacktestFuturesPositionCommandHandler> logger = null)
: ICommandHandler<CloseBacktestFuturesPositionCommand, Position>
{
public async Task<Position> Handle(CloseBacktestFuturesPositionCommand request)
{
try
{
// For backtest, use execution price directly
var lastPrice = request.ExecutionPrice.GetValueOrDefault();
// Calculate closing direction (opposite of opening direction)
var direction = request.Position.OriginDirection == TradeDirection.Long
? TradeDirection.Short
: TradeDirection.Long;
// Build the closing trade directly for backtest (no exchange call needed)
var closedTrade = exchangeService.BuildEmptyTrade(
request.Position.Open.Ticker,
lastPrice,
request.Position.Open.Quantity,
direction,
request.Position.Open.Leverage,
TradeType.Market,
request.Position.Open.Date,
TradeStatus.Filled);
// Update position status and calculate PnL
request.Position.Status = PositionStatus.Finished;
request.Position.ProfitAndLoss =
TradingBox.GetProfitAndLoss(request.Position, closedTrade.Quantity, lastPrice,
request.Position.Open.Leverage);
// Add UI fees for closing the position
var closingPositionSizeUsd = (lastPrice * closedTrade.Quantity) * request.Position.Open.Leverage;
var closingUiFees = TradingBox.CalculateClosingUiFees(closingPositionSizeUsd);
request.Position.AddUiFees(closingUiFees);
request.Position.AddGasFees(Constants.GMX.Config.GasFeePerTransaction);
// For backtest, skip database update
return request.Position;
}
catch (Exception ex)
{
logger?.LogError(ex, "Error closing backtest futures position: {Message} \n Stacktrace : {StackTrace}", ex.Message,
ex.StackTrace);
SentrySdk.CaptureException(ex);
throw;
}
}
}

View File

@@ -0,0 +1,110 @@
using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Services;
using Managing.Application.Trading.Commands;
using Managing.Common;
using Managing.Domain.Accounts;
using Managing.Domain.Shared.Helpers;
using Managing.Domain.Trades;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
namespace Managing.Application.Trading.Handlers;
public class CloseFuturesPositionCommandHandler(
IExchangeService exchangeService,
IAccountService accountService,
ITradingService tradingService,
IServiceScopeFactory scopeFactory,
ILogger<CloseFuturesPositionCommandHandler> logger = null)
: ICommandHandler<CloseFuturesPositionCommand, Position>
{
public async Task<Position> Handle(CloseFuturesPositionCommand request)
{
try
{
if (request.Position == null)
{
logger?.LogWarning("Attempted to close position but position is null for account {AccountId}", request.AccountId);
throw new ArgumentNullException(nameof(request.Position), "Position cannot be null for closing");
}
// This handler should ONLY handle live trading positions
// Backtest/paper trading positions must use CloseBacktestFuturesPositionCommandHandler
if (request.Position.TradingType == TradingType.BacktestFutures ||
request.Position.Initiator == PositionInitiator.PaperTrading)
{
logger?.LogError(
"CloseFuturesPositionCommandHandler received a backtest/paper trading position. " +
"Position: {PositionId}, TradingType: {TradingType}, Initiator: {Initiator}. " +
"Use CloseBacktestFuturesPositionCommandHandler instead.",
request.Position.Identifier, request.Position.TradingType, request.Position.Initiator);
throw new InvalidOperationException(
$"CloseFuturesPositionCommandHandler cannot handle backtest/paper trading positions. " +
$"Position {request.Position.Identifier} has TradingType={request.Position.TradingType} and Initiator={request.Position.Initiator}. " +
$"Use CloseBacktestFuturesPositionCommandHandler instead.");
}
Account account = await accountService.GetAccountById(request.AccountId, false, false);
// For live trading, always get price from exchange
var lastPrice = await exchangeService.GetPrice(account, request.Position.Ticker, DateTime.UtcNow);
// Check if position still open on broker
var p = (await exchangeService.GetBrokerPositions(account))
.FirstOrDefault(x => x.Ticker == request.Position.Ticker);
// Position not available on the broker, so be sure to update the status
if (p == null)
{
request.Position.Status = PositionStatus.Finished;
request.Position.ProfitAndLoss =
TradingBox.GetProfitAndLoss(request.Position, request.Position.Open.Quantity, lastPrice,
request.Position.Open.Leverage);
// Add UI fees for closing the position (broker closed it)
var closingPositionSizeUsd =
(lastPrice * request.Position.Open.Quantity) * request.Position.Open.Leverage;
var closingUiFees = TradingBox.CalculateClosingUiFees(closingPositionSizeUsd);
request.Position.AddUiFees(closingUiFees);
request.Position.AddGasFees(Constants.GMX.Config.GasFeePerTransaction);
await tradingService.UpdatePositionAsync(request.Position);
return request.Position;
}
var closeRequestedOrders = true; // TODO: For gmx no need to close orders since they are closed automatically
// Close market
var closedPosition =
await exchangeService.ClosePosition(account, request.Position, lastPrice);
if (closeRequestedOrders || closedPosition.Status == (TradeStatus.PendingOpen | TradeStatus.Filled))
{
request.Position.Status = PositionStatus.Finished;
request.Position.ProfitAndLoss =
TradingBox.GetProfitAndLoss(request.Position, closedPosition.Quantity, lastPrice,
request.Position.Open.Leverage);
// Add UI fees for closing the position
var closingPositionSizeUsd = (lastPrice * closedPosition.Quantity) * request.Position.Open.Leverage;
var closingUiFees = TradingBox.CalculateClosingUiFees(closingPositionSizeUsd);
request.Position.AddUiFees(closingUiFees);
request.Position.AddGasFees(Constants.GMX.Config.GasFeePerTransaction);
await tradingService.UpdatePositionAsync(request.Position);
}
return request.Position;
}
catch (Exception ex)
{
logger?.LogError(ex, "Error closing futures position: {Message} \n Stacktrace : {StackTrace}", ex.Message,
ex.StackTrace);
SentrySdk.CaptureException(ex);
throw;
}
}
}

View File

@@ -34,8 +34,6 @@ public class ClosePositionCommandHandler(
}
}
var isForPaperTrading = request.IsForBacktest;
var lastPrice = request.Position.Initiator == PositionInitiator.PaperTrading
? request.ExecutionPrice.GetValueOrDefault()
: await exchangeService.GetPrice(account, request.Position.Ticker, DateTime.UtcNow);
@@ -72,7 +70,7 @@ public class ClosePositionCommandHandler(
// Close market
var closedPosition =
await exchangeService.ClosePosition(account, request.Position, lastPrice, isForPaperTrading);
await exchangeService.ClosePosition(account, request.Position, lastPrice);
if (closeRequestedOrders || closedPosition.Status == (TradeStatus.PendingOpen | TradeStatus.Filled))
{

View File

@@ -32,6 +32,7 @@ namespace Managing.Application.Trading.Handlers
}
position.InitiatorIdentifier = request.InitiatorIdentifier;
position.TradingType = request.TradingType;
// Always use BotTradingBalance directly as the balance to risk
// Round to 2 decimal places to prevent precision errors

View File

@@ -287,7 +287,7 @@ public class StatisticService : IStatisticService
Timeframe = timeframe,
IsForWatchingOnly = true,
BotTradingBalance = 1000,
IsForBacktest = true,
TradingType = TradingType.BacktestFutures,
CooldownPeriod = 1,
MaxLossStreak = 0,
FlipPosition = false,

View File

@@ -280,12 +280,15 @@ public class BacktestComputeWorker : BackgroundService
var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(_options.JobTimeoutMinutes));
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
// Convert HashSet to List - candles are already ordered from repository
var candlesList = candles.ToList();
LightBacktest result;
try
{
result = await executor.ExecuteAsync(
config,
candles,
candlesList,
user,
save: true,
withCandles: false,

View File

@@ -253,7 +253,7 @@ public class BundleBacktestWorker : BaseWorker<BundleBacktestWorker>
Timeframe = runBacktestRequest.Config.Timeframe,
IsForWatchingOnly = runBacktestRequest.Config.IsForWatchingOnly,
BotTradingBalance = runBacktestRequest.Config.BotTradingBalance,
IsForBacktest = true,
TradingType = TradingType.BacktestFutures,
CooldownPeriod = runBacktestRequest.Config.CooldownPeriod ?? 1,
MaxLossStreak = runBacktestRequest.Config.MaxLossStreak,
MaxPositionTimeHours = runBacktestRequest.Config.MaxPositionTimeHours,