540 lines
21 KiB
C#
540 lines
21 KiB
C#
using Managing.Application.Abstractions;
|
|
using Managing.Application.Abstractions.Repositories;
|
|
using Managing.Application.Abstractions.Services;
|
|
using Managing.Application.Bots;
|
|
using Managing.Core.FixedSizedQueue;
|
|
using Managing.Domain.Accounts;
|
|
using Managing.Domain.Backtests;
|
|
using Managing.Domain.Bots;
|
|
using Managing.Domain.Candles;
|
|
using Managing.Domain.Scenarios;
|
|
using Managing.Domain.Shared.Helpers;
|
|
using Managing.Domain.Strategies;
|
|
using Managing.Domain.Strategies.Base;
|
|
using Managing.Domain.Users;
|
|
using Managing.Domain.Workflows;
|
|
using Microsoft.Extensions.Logging;
|
|
using static Managing.Common.Enums;
|
|
|
|
namespace Managing.Application.Backtesting
|
|
{
|
|
public class Backtester : IBacktester
|
|
{
|
|
private readonly IBacktestRepository _backtestRepository;
|
|
private readonly ILogger<Backtester> _logger;
|
|
private readonly IExchangeService _exchangeService;
|
|
private readonly IBotFactory _botFactory;
|
|
private readonly IScenarioService _scenarioService;
|
|
private readonly IAccountService _accountService;
|
|
private readonly IMessengerService _messengerService;
|
|
private readonly IKaigenService _kaigenService;
|
|
|
|
public Backtester(
|
|
IExchangeService exchangeService,
|
|
IBotFactory botFactory,
|
|
IBacktestRepository backtestRepository,
|
|
ILogger<Backtester> logger,
|
|
IScenarioService scenarioService,
|
|
IAccountService accountService,
|
|
IMessengerService messengerService,
|
|
IKaigenService kaigenService)
|
|
{
|
|
_exchangeService = exchangeService;
|
|
_botFactory = botFactory;
|
|
_backtestRepository = backtestRepository;
|
|
_logger = logger;
|
|
_scenarioService = scenarioService;
|
|
_accountService = accountService;
|
|
_messengerService = messengerService;
|
|
_kaigenService = kaigenService;
|
|
}
|
|
|
|
public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false)
|
|
{
|
|
var simplebot = _botFactory.CreateSimpleBot("scenario", workflow);
|
|
Backtest result = null;
|
|
if (save && result != null)
|
|
{
|
|
// Simple bot backtest not implemented yet, would need user
|
|
// _backtestRepository.InsertBacktestForUser(null, result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs a trading bot backtest with the specified configuration and date range.
|
|
/// Automatically handles different bot types based on config.BotType.
|
|
/// </summary>
|
|
/// <param name="config">The trading bot configuration (must include Scenario object or ScenarioName)</param>
|
|
/// <param name="startDate">The start date for the backtest</param>
|
|
/// <param name="endDate">The end date for the backtest</param>
|
|
/// <param name="user">The user running the backtest (optional)</param>
|
|
/// <param name="save">Whether to save the backtest results</param>
|
|
/// <param name="withCandles">Whether to include candles and indicators values in the response</param>
|
|
/// <param name="requestId">The request ID to associate with this backtest (optional)</param>
|
|
/// <param name="metadata">Additional metadata to associate with this backtest (optional)</param>
|
|
/// <returns>The backtest results</returns>
|
|
public async Task<Backtest> RunTradingBotBacktest(
|
|
TradingBotConfig config,
|
|
DateTime startDate,
|
|
DateTime endDate,
|
|
User user = null,
|
|
bool save = false,
|
|
bool withCandles = false,
|
|
string requestId = null,
|
|
object metadata = null)
|
|
{
|
|
string creditRequestId = null;
|
|
|
|
// Debit user credits before starting the backtest
|
|
if (user != null)
|
|
{
|
|
try
|
|
{
|
|
creditRequestId = await _kaigenService.DebitUserCreditsAsync(user, 3);
|
|
_logger.LogInformation("Successfully debited credits for user {UserName} with request ID {RequestId}",
|
|
user.Name, creditRequestId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to debit credits for user {UserName}. Backtest will not proceed.", user.Name);
|
|
throw new Exception($"Failed to debit credits: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
var candles = GetCandles(config.Ticker, config.Timeframe, startDate, endDate);
|
|
|
|
var result = await RunBacktestWithCandles(config, candles, user, withCandles, requestId, metadata);
|
|
|
|
// Set start and end dates
|
|
result.StartDate = startDate;
|
|
result.EndDate = endDate;
|
|
|
|
if (save && user != null)
|
|
{
|
|
_backtestRepository.InsertBacktestForUser(user, result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// If backtest fails and we debited credits, attempt to refund
|
|
if (user != null && !string.IsNullOrEmpty(creditRequestId))
|
|
{
|
|
try
|
|
{
|
|
var refundSuccess = await _kaigenService.RefundUserCreditsAsync(creditRequestId, user);
|
|
if (refundSuccess)
|
|
{
|
|
_logger.LogInformation("Successfully refunded credits for user {UserName} after backtest failure", user.Name);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogError("Failed to refund credits for user {UserName} after backtest failure", user.Name);
|
|
}
|
|
}
|
|
catch (Exception refundEx)
|
|
{
|
|
_logger.LogError(refundEx, "Error during refund attempt for user {UserName}", user.Name);
|
|
}
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs a trading bot backtest with pre-loaded candles.
|
|
/// Automatically handles different bot types based on config.BotType.
|
|
/// </summary>
|
|
/// <param name="config">The trading bot configuration (must include Scenario object or ScenarioName)</param>
|
|
/// <param name="candles">The candles to use for backtesting</param>
|
|
/// <param name="user">The user running the backtest (optional)</param>
|
|
/// <param name="withCandles">Whether to include candles and indicators values in the response</param>
|
|
/// <returns>The backtest results</returns>
|
|
public async Task<Backtest> RunTradingBotBacktest(
|
|
TradingBotConfig config,
|
|
List<Candle> candles,
|
|
User user = null,
|
|
bool withCandles = false,
|
|
string requestId = null,
|
|
object metadata = null)
|
|
{
|
|
return await RunBacktestWithCandles(config, candles, user, withCandles, requestId, metadata);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core backtesting logic - handles the actual backtest execution with pre-loaded candles
|
|
/// </summary>
|
|
private async Task<Backtest> RunBacktestWithCandles(
|
|
TradingBotConfig config,
|
|
List<Candle> candles,
|
|
User user = null,
|
|
bool withCandles = false,
|
|
string requestId = null,
|
|
object metadata = null)
|
|
{
|
|
var tradingBot = _botFactory.CreateBacktestTradingBot(config);
|
|
|
|
// Scenario and indicators should already be loaded in constructor by BotService
|
|
// This is just a validation check to ensure everything loaded properly
|
|
if (tradingBot is TradingBot bot && !bot.Indicators.Any())
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"No indicators were loaded for scenario '{config.ScenarioName ?? config.Scenario?.Name}'. " +
|
|
"This indicates a problem with scenario loading.");
|
|
}
|
|
|
|
tradingBot.User = user;
|
|
await tradingBot.LoadAccount();
|
|
|
|
var result = await GetBacktestingResult(config, tradingBot, candles, user, withCandles, requestId, metadata);
|
|
|
|
if (user != null)
|
|
{
|
|
result.User = user;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<Account> GetAccountFromConfig(TradingBotConfig config)
|
|
{
|
|
var account = await _accountService.GetAccount(config.AccountName, false, false);
|
|
if (account != null)
|
|
{
|
|
return account;
|
|
}
|
|
|
|
return new Account
|
|
{
|
|
Name = config.AccountName,
|
|
Exchange = TradingExchanges.GmxV2
|
|
};
|
|
}
|
|
|
|
private List<Candle> GetCandles(Ticker ticker, Timeframe timeframe,
|
|
DateTime startDate, DateTime endDate)
|
|
{
|
|
var candles = _exchangeService.GetCandlesInflux(TradingExchanges.Evm, ticker,
|
|
startDate, timeframe, endDate).Result;
|
|
|
|
if (candles == null || candles.Count == 0)
|
|
throw new Exception($"No candles for {ticker} on {timeframe} timeframe");
|
|
|
|
return candles;
|
|
}
|
|
|
|
private async Task<Backtest> GetBacktestingResult(
|
|
TradingBotConfig config,
|
|
ITradingBot bot,
|
|
List<Candle> candles,
|
|
User user = null,
|
|
bool withCandles = false,
|
|
string requestId = null,
|
|
object metadata = null)
|
|
{
|
|
if (candles == null || candles.Count == 0)
|
|
{
|
|
throw new Exception("No candle to backtest");
|
|
}
|
|
|
|
var totalCandles = candles.Count;
|
|
var currentCandle = 0;
|
|
var lastLoggedPercentage = 0;
|
|
|
|
_logger.LogInformation("Starting backtest with {TotalCandles} candles for {Ticker} on {Timeframe}",
|
|
totalCandles, config.Ticker, config.Timeframe);
|
|
|
|
bot.WalletBalances.Add(candles.FirstOrDefault().Date, config.BotTradingBalance);
|
|
|
|
foreach (var candle in candles)
|
|
{
|
|
bot.OptimizedCandles.Enqueue(candle);
|
|
bot.Candles.Add(candle);
|
|
bot.Run();
|
|
|
|
currentCandle++;
|
|
|
|
// Log progress every 10% or every 1000 candles, whichever comes first
|
|
var currentPercentage = (int)((double)currentCandle / totalCandles * 100);
|
|
var shouldLog = currentPercentage >= lastLoggedPercentage + 10 ||
|
|
currentCandle % 1000 == 0 ||
|
|
currentCandle == totalCandles;
|
|
|
|
if (shouldLog && currentPercentage > lastLoggedPercentage)
|
|
{
|
|
_logger.LogInformation(
|
|
"Backtest progress: {CurrentCandle}/{TotalCandles} ({Percentage}%) - Processing candle from {CandleDate}",
|
|
currentCandle, totalCandles, currentPercentage, candle.Date.ToString("yyyy-MM-dd HH:mm"));
|
|
lastLoggedPercentage = currentPercentage;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Backtest processing completed. Calculating final results...");
|
|
|
|
bot.Candles = new HashSet<Candle>(candles);
|
|
|
|
// Only calculate indicators values if withCandles is true
|
|
Dictionary<IndicatorType, IndicatorsResultBase> indicatorsValues = null;
|
|
if (withCandles)
|
|
{
|
|
indicatorsValues = GetIndicatorsValues(bot.Config.Scenario.Indicators, candles);
|
|
}
|
|
|
|
var finalPnl = bot.GetProfitAndLoss();
|
|
var winRate = bot.GetWinRate();
|
|
var optimizedMoneyManagement =
|
|
TradingBox.GetBestMoneyManagement(candles, bot.Positions, config.MoneyManagement);
|
|
var stats = TradingHelpers.GetStatistics(bot.WalletBalances);
|
|
var growthPercentage = TradingHelpers.GetGrowthFromInitalBalance(config.BotTradingBalance, finalPnl);
|
|
var hodlPercentage = TradingHelpers.GetHodlPercentage(candles[0], candles.Last());
|
|
|
|
var fees = bot.GetTotalFees();
|
|
var scoringParams = new BacktestScoringParams(
|
|
sharpeRatio: (double)stats.SharpeRatio,
|
|
maxDrawdownPc: (double)stats.MaxDrawdownPc,
|
|
growthPercentage: (double)growthPercentage,
|
|
hodlPercentage: (double)hodlPercentage,
|
|
winRate: winRate,
|
|
totalPnL: (double)finalPnl,
|
|
fees: (double)fees,
|
|
tradeCount: bot.Positions.Count,
|
|
maxDrawdownRecoveryTime: stats.MaxDrawdownRecoveryTime,
|
|
maxDrawdown: stats.MaxDrawdown,
|
|
initialBalance: config.BotTradingBalance,
|
|
startDate: candles[0].Date,
|
|
endDate: candles.Last().Date,
|
|
timeframe: config.Timeframe
|
|
);
|
|
|
|
var score = BacktestScorer.CalculateTotalScore(scoringParams);
|
|
|
|
// Create backtest result with conditional candles and indicators values
|
|
var result = new Backtest(config, bot.Positions, bot.Signals.ToList(),
|
|
withCandles ? candles : new List<Candle>())
|
|
{
|
|
FinalPnl = finalPnl,
|
|
WinRate = winRate,
|
|
GrowthPercentage = growthPercentage,
|
|
HodlPercentage = hodlPercentage,
|
|
Fees = fees,
|
|
WalletBalances = bot.WalletBalances.ToList(),
|
|
Statistics = stats,
|
|
OptimizedMoneyManagement = optimizedMoneyManagement,
|
|
IndicatorsValues = withCandles
|
|
? AggregateValues(indicatorsValues, bot.IndicatorsValues)
|
|
: new Dictionary<IndicatorType, IndicatorsResultBase>(),
|
|
Score = score,
|
|
Id = Guid.NewGuid().ToString(),
|
|
RequestId = requestId,
|
|
Metadata = metadata
|
|
};
|
|
|
|
// Send notification if backtest meets criteria
|
|
await SendBacktestNotificationIfCriteriaMet(result);
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task SendBacktestNotificationIfCriteriaMet(Backtest backtest)
|
|
{
|
|
try
|
|
{
|
|
|
|
if (backtest.Score > 80)
|
|
{
|
|
await _messengerService.SendBacktestNotification(backtest);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send backtest notification for backtest {Id}", backtest.Id);
|
|
}
|
|
}
|
|
|
|
private Dictionary<IndicatorType, IndicatorsResultBase> AggregateValues(
|
|
Dictionary<IndicatorType, IndicatorsResultBase> indicatorsValues,
|
|
Dictionary<IndicatorType, IndicatorsResultBase> botStrategiesValues)
|
|
{
|
|
// Foreach strategy type, only retrieve the values where the strategy is not present already in the bot
|
|
// Then, add the values to the bot values
|
|
|
|
var result = new Dictionary<IndicatorType, IndicatorsResultBase>();
|
|
foreach (var indicator in indicatorsValues)
|
|
{
|
|
// if (!botStrategiesValues.ContainsKey(strategy.Key))
|
|
// {
|
|
// result[strategy.Key] = strategy.Value;
|
|
// }else
|
|
// {
|
|
// result[strategy.Key] = botStrategiesValues[strategy.Key];
|
|
// }
|
|
|
|
result[indicator.Key] = indicator.Value;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private Dictionary<IndicatorType, IndicatorsResultBase> GetIndicatorsValues(List<Indicator> indicators,
|
|
List<Candle> candles)
|
|
{
|
|
var indicatorsValues = new Dictionary<IndicatorType, IndicatorsResultBase>();
|
|
var fixedCandles = new FixedSizeQueue<Candle>(10000);
|
|
foreach (var candle in candles)
|
|
{
|
|
fixedCandles.Enqueue(candle);
|
|
}
|
|
|
|
foreach (var indicator in indicators)
|
|
{
|
|
try
|
|
{
|
|
var s = ScenarioHelpers.BuildIndicator(indicator, 10000);
|
|
s.Candles = fixedCandles;
|
|
indicatorsValues[indicator.Type] = s.GetIndicatorValues();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine(e);
|
|
}
|
|
}
|
|
|
|
return indicatorsValues;
|
|
}
|
|
|
|
public bool DeleteBacktest(string id)
|
|
{
|
|
try
|
|
{
|
|
_backtestRepository.DeleteBacktestByIdForUser(null, id);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex.Message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool DeleteBacktests()
|
|
{
|
|
try
|
|
{
|
|
_backtestRepository.DeleteAllBacktestsForUser(null);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex.Message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public IEnumerable<Backtest> GetBacktestsByUser(User user)
|
|
{
|
|
var backtests = _backtestRepository.GetBacktestsByUser(user).ToList();
|
|
return backtests;
|
|
}
|
|
|
|
public IEnumerable<Backtest> GetBacktestsByRequestId(string requestId)
|
|
{
|
|
var backtests = _backtestRepository.GetBacktestsByRequestId(requestId).ToList();
|
|
return backtests;
|
|
}
|
|
|
|
public (IEnumerable<Backtest> Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(string requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc")
|
|
{
|
|
var (backtests, totalCount) = _backtestRepository.GetBacktestsByRequestIdPaginated(requestId, page, pageSize, sortBy, sortOrder);
|
|
return (backtests, totalCount);
|
|
}
|
|
|
|
public Backtest GetBacktestByIdForUser(User user, string id)
|
|
{
|
|
var backtest = _backtestRepository.GetBacktestByIdForUser(user, id);
|
|
|
|
if (backtest == null)
|
|
return null;
|
|
|
|
if (backtest.Candles == null || backtest.Candles.Count == 0 || backtest.Candles.Count < 10)
|
|
{
|
|
try
|
|
{
|
|
var account = new Account
|
|
{ Name = backtest.Config.AccountName, Exchange = TradingExchanges.Evm };
|
|
|
|
var candles = _exchangeService.GetCandlesInflux(
|
|
account.Exchange,
|
|
backtest.Config.Ticker,
|
|
backtest.StartDate,
|
|
backtest.Config.Timeframe,
|
|
backtest.EndDate).Result;
|
|
|
|
if (candles != null && candles.Count > 0)
|
|
{
|
|
backtest.Candles = candles;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to retrieve candles for backtest {Id}", id);
|
|
}
|
|
}
|
|
|
|
return backtest;
|
|
}
|
|
|
|
public bool DeleteBacktestByUser(User user, string id)
|
|
{
|
|
try
|
|
{
|
|
_backtestRepository.DeleteBacktestByIdForUser(user, id);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex.Message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool DeleteBacktestsByIdsForUser(User user, IEnumerable<string> ids)
|
|
{
|
|
try
|
|
{
|
|
_backtestRepository.DeleteBacktestsByIdsForUser(user, ids);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to delete backtests for user {UserName}", user.Name);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public bool DeleteBacktestsByUser(User user)
|
|
{
|
|
try
|
|
{
|
|
_backtestRepository.DeleteAllBacktestsForUser(user);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex.Message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public (IEnumerable<Backtest> Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, int pageSize, string sortBy = "score", string sortOrder = "desc")
|
|
{
|
|
var (backtests, totalCount) = _backtestRepository.GetBacktestsByUserPaginated(user, page, pageSize, sortBy, sortOrder);
|
|
return (backtests, totalCount);
|
|
}
|
|
}
|
|
} |