Add jobs
This commit is contained in:
280
src/Managing.Application/Backtests/BacktestExecutor.cs
Normal file
280
src/Managing.Application/Backtests/BacktestExecutor.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using Managing.Application.Abstractions;
|
||||
using Managing.Application.Abstractions.Repositories;
|
||||
using Managing.Application.Abstractions.Services;
|
||||
using Managing.Application.Bots;
|
||||
using Managing.Common;
|
||||
using Managing.Domain.Backtests;
|
||||
using Managing.Domain.Bots;
|
||||
using Managing.Domain.Candles;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Managing.Domain.Users;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Managing.Application.Backtests;
|
||||
|
||||
/// <summary>
|
||||
/// Service for executing backtests without Orleans dependencies.
|
||||
/// Extracted from BacktestTradingBotGrain to be reusable in compute workers.
|
||||
/// </summary>
|
||||
public class BacktestExecutor
|
||||
{
|
||||
private readonly ILogger<BacktestExecutor> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IBacktestRepository _backtestRepository;
|
||||
private readonly IScenarioService _scenarioService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IMessengerService _messengerService;
|
||||
|
||||
public BacktestExecutor(
|
||||
ILogger<BacktestExecutor> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IBacktestRepository backtestRepository,
|
||||
IScenarioService scenarioService,
|
||||
IAccountService accountService,
|
||||
IMessengerService messengerService)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_backtestRepository = backtestRepository;
|
||||
_scenarioService = scenarioService;
|
||||
_accountService = accountService;
|
||||
_messengerService = messengerService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a backtest with the given configuration and candles.
|
||||
/// </summary>
|
||||
/// <param name="config">The trading bot configuration</param>
|
||||
/// <param name="candles">The candles to use for backtesting</param>
|
||||
/// <param name="user">The user running the backtest</param>
|
||||
/// <param name="save">Whether to save the backtest result</param>
|
||||
/// <param name="withCandles">Whether to include candles in the result</param>
|
||||
/// <param name="requestId">The request ID to associate with this backtest</param>
|
||||
/// <param name="metadata">Additional metadata</param>
|
||||
/// <param name="progressCallback">Optional callback for progress updates (0-100)</param>
|
||||
/// <returns>The lightweight backtest result</returns>
|
||||
public async Task<LightBacktest> ExecuteAsync(
|
||||
TradingBotConfig config,
|
||||
HashSet<Candle> candles,
|
||||
User user,
|
||||
bool save = false,
|
||||
bool withCandles = false,
|
||||
string requestId = null,
|
||||
object metadata = null,
|
||||
Func<int, Task> progressCallback = null)
|
||||
{
|
||||
if (candles == null || candles.Count == 0)
|
||||
{
|
||||
throw new Exception("No candle to backtest");
|
||||
}
|
||||
|
||||
// Ensure user has accounts loaded
|
||||
if (user.Accounts == null || !user.Accounts.Any())
|
||||
{
|
||||
user.Accounts = (await _accountService.GetAccountsByUserAsync(user, hideSecrets: true, getBalance: false)).ToList();
|
||||
}
|
||||
|
||||
// Create a fresh TradingBotBase instance for this backtest
|
||||
var tradingBot = await CreateTradingBotInstance(config);
|
||||
tradingBot.Account = user.Accounts.First();
|
||||
|
||||
var totalCandles = candles.Count;
|
||||
var currentCandle = 0;
|
||||
var lastLoggedPercentage = 0;
|
||||
|
||||
_logger.LogInformation("Backtest requested by {UserId} with {TotalCandles} candles for {Ticker} on {Timeframe}",
|
||||
user.Id, totalCandles, config.Ticker, config.Timeframe);
|
||||
|
||||
// Initialize wallet balance with first candle
|
||||
tradingBot.WalletBalances.Clear();
|
||||
tradingBot.WalletBalances.Add(candles.FirstOrDefault()!.Date, config.BotTradingBalance);
|
||||
var initialBalance = config.BotTradingBalance;
|
||||
|
||||
var fixedCandles = new HashSet<Candle>();
|
||||
var lastProgressUpdate = DateTime.UtcNow;
|
||||
const int progressUpdateIntervalMs = 1000; // Update progress every second
|
||||
|
||||
// Process all candles
|
||||
foreach (var candle in candles)
|
||||
{
|
||||
fixedCandles.Add(candle);
|
||||
tradingBot.LastCandle = candle;
|
||||
|
||||
// Update signals manually only for backtesting
|
||||
await tradingBot.UpdateSignals(fixedCandles);
|
||||
await tradingBot.Run();
|
||||
|
||||
currentCandle++;
|
||||
|
||||
// Update progress callback if provided
|
||||
var currentPercentage = (currentCandle * 100) / totalCandles;
|
||||
var timeSinceLastUpdate = (DateTime.UtcNow - lastProgressUpdate).TotalMilliseconds;
|
||||
if (progressCallback != null && (timeSinceLastUpdate >= progressUpdateIntervalMs || currentPercentage >= lastLoggedPercentage + 10))
|
||||
{
|
||||
try
|
||||
{
|
||||
await progressCallback(currentPercentage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error in progress callback");
|
||||
}
|
||||
lastProgressUpdate = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Log progress every 10%
|
||||
if (currentPercentage >= lastLoggedPercentage + 10)
|
||||
{
|
||||
lastLoggedPercentage = currentPercentage;
|
||||
_logger.LogInformation(
|
||||
"Backtest progress: {Percentage}% ({CurrentCandle}/{TotalCandles} candles processed)",
|
||||
currentPercentage, currentCandle, totalCandles);
|
||||
}
|
||||
|
||||
// Check if wallet balance fell below 10 USDC and break if so
|
||||
var currentWalletBalance = tradingBot.WalletBalances.Values.LastOrDefault();
|
||||
if (currentWalletBalance < Constants.GMX.Config.MinimumPositionAmount)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Backtest stopped early: Wallet balance fell below {MinimumPositionAmount} USDC (Current: {CurrentBalance:F2} USDC) at candle {CurrentCandle}/{TotalCandles} from {CandleDate}",
|
||||
Constants.GMX.Config.MinimumPositionAmount, currentWalletBalance, currentCandle, totalCandles,
|
||||
candle.Date.ToString("yyyy-MM-dd HH:mm"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Backtest processing completed. Calculating final results...");
|
||||
|
||||
var finalPnl = tradingBot.GetProfitAndLoss();
|
||||
var winRate = tradingBot.GetWinRate();
|
||||
var stats = TradingHelpers.GetStatistics(tradingBot.WalletBalances);
|
||||
var growthPercentage =
|
||||
TradingHelpers.GetGrowthFromInitalBalance(tradingBot.WalletBalances.FirstOrDefault().Value, finalPnl);
|
||||
var hodlPercentage = TradingHelpers.GetHodlPercentage(candles.First(), candles.Last());
|
||||
|
||||
var fees = tradingBot.GetTotalFees();
|
||||
var scoringParams = new BacktestScoringParams(
|
||||
sharpeRatio: (double)stats.SharpeRatio,
|
||||
growthPercentage: (double)growthPercentage,
|
||||
hodlPercentage: (double)hodlPercentage,
|
||||
winRate: winRate,
|
||||
totalPnL: (double)finalPnl,
|
||||
fees: (double)fees,
|
||||
tradeCount: tradingBot.Positions.Count,
|
||||
maxDrawdownRecoveryTime: stats.MaxDrawdownRecoveryTime,
|
||||
maxDrawdown: stats.MaxDrawdown,
|
||||
initialBalance: tradingBot.WalletBalances.FirstOrDefault().Value,
|
||||
tradingBalance: config.BotTradingBalance,
|
||||
startDate: candles.First().Date,
|
||||
endDate: candles.Last().Date,
|
||||
timeframe: config.Timeframe,
|
||||
moneyManagement: config.MoneyManagement
|
||||
);
|
||||
|
||||
var scoringResult = BacktestScorer.CalculateDetailedScore(scoringParams);
|
||||
|
||||
// Generate requestId if not provided
|
||||
var finalRequestId = requestId != null ? Guid.Parse(requestId) : Guid.NewGuid();
|
||||
|
||||
// Create backtest result with conditional candles and indicators values
|
||||
var result = new Backtest(config, tradingBot.Positions, tradingBot.Signals,
|
||||
withCandles ? candles : new HashSet<Candle>())
|
||||
{
|
||||
FinalPnl = finalPnl,
|
||||
WinRate = winRate,
|
||||
GrowthPercentage = growthPercentage,
|
||||
HodlPercentage = hodlPercentage,
|
||||
Fees = fees,
|
||||
WalletBalances = tradingBot.WalletBalances.ToList(),
|
||||
Statistics = stats,
|
||||
Score = scoringResult.Score,
|
||||
ScoreMessage = scoringResult.GenerateSummaryMessage(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
RequestId = finalRequestId,
|
||||
Metadata = metadata,
|
||||
StartDate = candles.FirstOrDefault()!.OpenTime,
|
||||
EndDate = candles.LastOrDefault()!.OpenTime,
|
||||
InitialBalance = initialBalance,
|
||||
NetPnl = finalPnl - fees,
|
||||
};
|
||||
|
||||
if (save && user != null)
|
||||
{
|
||||
await _backtestRepository.InsertBacktestForUserAsync(user, result);
|
||||
}
|
||||
|
||||
// Send notification if backtest meets criteria
|
||||
await SendBacktestNotificationIfCriteriaMet(result);
|
||||
|
||||
// Convert Backtest to LightBacktest
|
||||
return ConvertToLightBacktest(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Backtest to LightBacktest
|
||||
/// </summary>
|
||||
private LightBacktest ConvertToLightBacktest(Backtest backtest)
|
||||
{
|
||||
return new LightBacktest
|
||||
{
|
||||
Id = backtest.Id,
|
||||
Config = backtest.Config,
|
||||
FinalPnl = backtest.FinalPnl,
|
||||
WinRate = backtest.WinRate,
|
||||
GrowthPercentage = backtest.GrowthPercentage,
|
||||
HodlPercentage = backtest.HodlPercentage,
|
||||
StartDate = backtest.StartDate,
|
||||
EndDate = backtest.EndDate,
|
||||
MaxDrawdown = backtest.Statistics?.MaxDrawdown,
|
||||
Fees = backtest.Fees,
|
||||
SharpeRatio = (double?)backtest.Statistics?.SharpeRatio,
|
||||
Score = backtest.Score,
|
||||
ScoreMessage = backtest.ScoreMessage,
|
||||
InitialBalance = backtest.InitialBalance,
|
||||
NetPnl = backtest.NetPnl
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TradingBotBase instance for backtesting
|
||||
/// </summary>
|
||||
private async Task<TradingBotBase> CreateTradingBotInstance(TradingBotConfig config)
|
||||
{
|
||||
// Validate configuration for backtesting
|
||||
if (config == null)
|
||||
{
|
||||
throw new InvalidOperationException("Bot configuration is not initialized");
|
||||
}
|
||||
|
||||
if (!config.IsForBacktest)
|
||||
{
|
||||
throw new InvalidOperationException("BacktestExecutor can only be used for backtesting");
|
||||
}
|
||||
|
||||
// Create the trading bot instance
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||
var tradingBot = new TradingBotBase(logger, _scopeFactory, config);
|
||||
return tradingBot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends notification if backtest meets criteria
|
||||
/// </summary>
|
||||
private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (backtest.Score > 60)
|
||||
{
|
||||
await _messengerService.SendBacktestNotification(backtest);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send backtest notification for backtest {Id}", backtest.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user