From 756cd5fb11d8171d5e7df3396969b0b9a636bf92 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Wed, 4 Jun 2025 15:42:21 +0700 Subject: [PATCH] Update bot config on front and back --- .../Controllers/BacktestController.cs | 26 +-- src/Managing.Api/Controllers/BotController.cs | 129 +++++--------- .../Services/IBacktester.cs | 59 ++----- src/Managing.Application.Tests/BotsTests.cs | 136 +++++++++++++-- .../StatisticService.cs | 29 +++- .../Backtesting/Backtester.cs | 163 +++++------------- src/Managing.Application/Bots/TradingBot.cs | 73 ++++++-- src/Managing.Domain/Bots/TradingBotConfig.cs | 8 + .../organism/Backtest/backtestCards.tsx | 51 ++++-- .../organism/Backtest/backtestModal.tsx | 40 +++-- .../organism/Backtest/backtestRowDetails.tsx | 23 +++ .../organism/Backtest/backtestTable.tsx | 38 +++- .../src/generated/ManagingApi.ts | 15 +- src/Managing.WebApp/src/global/type.tsx | 1 + 14 files changed, 422 insertions(+), 369 deletions(-) diff --git a/src/Managing.Api/Controllers/BacktestController.cs b/src/Managing.Api/Controllers/BacktestController.cs index 52e27a2..3b76aea 100644 --- a/src/Managing.Api/Controllers/BacktestController.cs +++ b/src/Managing.Api/Controllers/BacktestController.cs @@ -178,39 +178,21 @@ public class BacktestController : BaseController break; case BotType.ScalpingBot: backtestResult = await _backtester.RunScalpingBotBacktest( - account, - moneyManagement, - request.Config.Ticker, - scenario, - request.Config.Timeframe, - request.Balance, + backtestConfig, request.StartDate, request.EndDate, user, - request.WatchOnly, request.Save, - cooldownPeriod: request.Config.CooldownPeriod, - maxLossStreak: request.Config.MaxLossStreak, - maxPositionTimeHours: request.Config.MaxPositionTimeHours, - flipOnlyWhenInProfit: request.Config.FlipOnlyWhenInProfit); + null); break; case BotType.FlippingBot: backtestResult = await _backtester.RunFlippingBotBacktest( - account, - moneyManagement, - request.Config.Ticker, - scenario, - request.Config.Timeframe, - request.Balance, + backtestConfig, request.StartDate, request.EndDate, user, - request.WatchOnly, request.Save, - cooldownPeriod: request.Config.CooldownPeriod, - maxLossStreak: request.Config.MaxLossStreak, - maxPositionTimeHours: request.Config.MaxPositionTimeHours, - flipOnlyWhenInProfit: request.Config.FlipOnlyWhenInProfit); + null); break; } diff --git a/src/Managing.Api/Controllers/BotController.cs b/src/Managing.Api/Controllers/BotController.cs index 2ca79c0..8333497 100644 --- a/src/Managing.Api/Controllers/BotController.cs +++ b/src/Managing.Api/Controllers/BotController.cs @@ -105,52 +105,64 @@ public class BotController : BaseController { try { - // Check if user owns the account - if (!await UserOwnsBotAccount(request.Identifier, request.AccountName)) + if (request.Config == null) { - return Forbid("You don't have permission to start this bot"); + return BadRequest("Bot configuration is required"); + } + + // Check if user owns the account specified in the request + if (!await UserOwnsBotAccount(null, request.Config.AccountName)) + { + return Forbid("You don't have permission to start a bot with this account"); } // Trigger error if money management is not provided - if (string.IsNullOrEmpty(request.MoneyManagementName)) + if (string.IsNullOrEmpty(request.MoneyManagementName) && request.Config.MoneyManagement == null) { - return BadRequest("Money management name is required"); + return BadRequest("Money management name or money management object is required"); } var user = await GetUser(); - var moneyManagement = await _moneyManagementService.GetMoneyMangement(user, request.MoneyManagementName); - if (moneyManagement == null) + + // Get money management if name is provided + MoneyManagement moneyManagement = request.Config.MoneyManagement; + if (!string.IsNullOrEmpty(request.MoneyManagementName)) { - return BadRequest("Money management not found"); + moneyManagement = await _moneyManagementService.GetMoneyMangement(user, request.MoneyManagementName); + if (moneyManagement == null) + { + return BadRequest("Money management not found"); + } } // Validate initialTradingBalance - if (request.InitialTradingBalance <= Constants.GMX.Config.MinimumPositionAmount) + if (request.Config.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount) { return BadRequest( $"Initial trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}"); } + // Update the config with final money management var config = new TradingBotConfig { - AccountName = request.AccountName, + AccountName = request.Config.AccountName, MoneyManagement = moneyManagement, - Ticker = request.Ticker, - ScenarioName = request.Scenario, - Timeframe = request.Timeframe, - IsForWatchingOnly = request.IsForWatchOnly, - BotTradingBalance = request.InitialTradingBalance, - BotType = request.BotType, - CooldownPeriod = request.CooldownPeriod, - MaxLossStreak = request.MaxLossStreak, - MaxPositionTimeHours = request.MaxPositionTimeHours, - FlipOnlyWhenInProfit = request.FlipOnlyWhenInProfit, + Ticker = request.Config.Ticker, + ScenarioName = request.Config.ScenarioName, + Timeframe = request.Config.Timeframe, + IsForWatchingOnly = request.Config.IsForWatchingOnly, + BotTradingBalance = request.Config.BotTradingBalance, + BotType = request.Config.BotType, + CooldownPeriod = request.Config.CooldownPeriod, + MaxLossStreak = request.Config.MaxLossStreak, + MaxPositionTimeHours = request.Config.MaxPositionTimeHours, + FlipOnlyWhenInProfit = request.Config.FlipOnlyWhenInProfit, IsForBacktest = false, - FlipPosition = request.BotType == BotType.FlippingBot, - Name = request.Name + FlipPosition = request.Config.BotType == BotType.FlippingBot, + Name = request.Config.Name }; - var result = await _mediator.Send(new StartBotCommand(config, request.Name, user)); + var result = await _mediator.Send(new StartBotCommand(config, request.Config.Name, user)); await NotifyBotSubscriberAsync(); return Ok(result); @@ -684,77 +696,14 @@ public class ClosePositionRequest public class StartBotRequest { /// - /// The type of bot to start + /// The trading bot configuration /// - public BotType BotType { get; set; } + public TradingBotConfig Config { get; set; } /// - /// The identifier of the bot + /// Optional money management name (if not included in Config.MoneyManagement) /// - public string Identifier { get; set; } - - /// - /// The ticker to trade - /// - public Ticker Ticker { get; set; } - - /// - /// The scenario to use - /// - public string Scenario { get; set; } - - /// - /// The timeframe to use - /// - public Timeframe Timeframe { get; set; } - - /// - /// The account name to use - /// - public string AccountName { get; set; } - - /// - /// The money management name to use - /// - public string MoneyManagementName { get; set; } - - /// - /// Whether the bot is for watching only - /// - public bool IsForWatchOnly { get; set; } - - /// - /// The initial trading balance - /// - public decimal InitialTradingBalance { get; set; } - - /// - /// The cooldown period in candles between positions - /// - public int CooldownPeriod { get; set; } - - /// - /// The maximum number of consecutive losses before stopping - /// - public int MaxLossStreak { get; set; } - - /// - /// The name of the bot - /// - public string Name { get; set; } - - /// - /// Maximum time in hours that a position can remain open before being automatically closed. - /// Supports fractional values (e.g., 2.5 for 2 hours and 30 minutes). - /// If null, time-based position closure is disabled. - /// - public decimal? MaxPositionTimeHours { get; set; } = null; - - /// - /// If true, positions will only be flipped when the current position is in profit. - /// If false, positions will be flipped regardless of profit status. - /// - public bool FlipOnlyWhenInProfit { get; set; } = true; + public string? MoneyManagementName { get; set; } } /// diff --git a/src/Managing.Application.Abstractions/Services/IBacktester.cs b/src/Managing.Application.Abstractions/Services/IBacktester.cs index 330daf7..1fcdd83 100644 --- a/src/Managing.Application.Abstractions/Services/IBacktester.cs +++ b/src/Managing.Application.Abstractions/Services/IBacktester.cs @@ -1,79 +1,40 @@ -using Managing.Domain.Accounts; -using Managing.Domain.Backtests; +using Managing.Domain.Backtests; +using Managing.Domain.Bots; using Managing.Domain.Candles; -using Managing.Domain.MoneyManagements; -using Managing.Domain.Scenarios; using Managing.Domain.Users; -using static Managing.Common.Enums; namespace Managing.Application.Abstractions.Services { public interface IBacktester { Task RunScalpingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Ticker ticker, - Scenario scenario, - Timeframe timeframe, - decimal balance, + TradingBotConfig config, DateTime startDate, DateTime endDate, User user = null, - bool isForWatchingOnly = false, bool save = false, - List? initialCandles = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true); + List? initialCandles = null); Task RunFlippingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Ticker ticker, - Scenario scenario, - Timeframe timeframe, - decimal balance, + TradingBotConfig config, DateTime startDate, DateTime endDate, User user = null, - bool isForWatchingOnly = false, bool save = false, - List? initialCandles = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true); + List? initialCandles = null); bool DeleteBacktest(string id); bool DeleteBacktests(); Task RunScalpingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Scenario scenario, - Timeframe timeframe, + TradingBotConfig config, List candles, - decimal balance, - User user = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true); + User user = null); Task RunFlippingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Scenario scenario, - Timeframe timeframe, + TradingBotConfig config, List candles, - decimal balance, - User user = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true); + User user = null); // User-specific operations Task> GetBacktestsByUser(User user); diff --git a/src/Managing.Application.Tests/BotsTests.cs b/src/Managing.Application.Tests/BotsTests.cs index ad975bc..05bce9f 100644 --- a/src/Managing.Application.Tests/BotsTests.cs +++ b/src/Managing.Application.Tests/BotsTests.cs @@ -6,6 +6,7 @@ using Managing.Application.Abstractions.Services; using Managing.Application.Backtesting; using Managing.Application.Bots.Base; using Managing.Core; +using Managing.Domain.Bots; using Managing.Domain.Candles; using Managing.Domain.MoneyManagements; using Managing.Domain.Scenarios; @@ -42,7 +43,7 @@ namespace Managing.Application.Tests _tradingService.Object, botService); _backtester = new Backtester(_exchangeService, _botFactory, backtestRepository, backtestLogger, - scenarioService); + scenarioService, _accountService.Object); _elapsedTimes = new List(); } @@ -58,10 +59,25 @@ namespace Managing.Application.Tests var localCandles = FileHelpers.ReadJson>($"{ticker.ToString()}-{timeframe.ToString()}-candles.json"); + var config = new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = MoneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + BotType = BotType.FlippingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = true, + Name = "Test" + }; + // Act - var backtestResult = await _backtester.RunFlippingBotBacktest(_account, MoneyManagement, ticker, scenario, - timeframe, 1000, new DateTime().AddDays(-3), DateTime.UtcNow, - initialCandles: localCandles.TakeLast(500).ToList()); + var backtestResult = await _backtester.RunFlippingBotBacktest(config, localCandles.TakeLast(500).ToList()); var json = JsonConvert.SerializeObject(backtestResult, Formatting.None); File.WriteAllText($"{ticker.ToString()}-{timeframe.ToString()}-{Guid.NewGuid()}.json", json); @@ -90,9 +106,25 @@ namespace Managing.Application.Tests var strategy = ScenarioHelpers.BuildStrategy(StrategyType.RsiDivergence, "RsiDiv", period: 5); scenario.AddStrategy(strategy); + var config = new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = MoneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + BotType = BotType.ScalpingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = false, + Name = "Test" + }; + // Act - var backtestResult = await _backtester.RunScalpingBotBacktest(_account, MoneyManagement, ticker, scenario, - timeframe, 1000, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null); + var backtestResult = await _backtester.RunScalpingBotBacktest(config, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null, false, null); //WriteCsvReport(backtestResult.GetStringReport()); // Assert @@ -120,9 +152,25 @@ namespace Managing.Application.Tests TakeProfit = 0.02m }; + var config = new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = moneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + BotType = BotType.ScalpingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = false, + Name = "Test" + }; + // Act - var backtestResult = await _backtester.RunScalpingBotBacktest(_account, moneyManagement, ticker, scenario, - timeframe, 1000, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null); + var backtestResult = await _backtester.RunScalpingBotBacktest(config, DateTime.UtcNow.AddDays(-6), DateTime.UtcNow, null, false, null); WriteCsvReport(backtestResult.GetStringReport()); // Assert @@ -191,10 +239,38 @@ namespace Managing.Application.Tests var backtestResult = botType switch { BotType.SimpleBot => throw new NotImplementedException(), - BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(_account, moneyManagement, - scenario, timeframe, candles, 1000, null).Result, - BotType.FlippingBot => _backtester.RunFlippingBotBacktest(_account, moneyManagement, - scenario, timeframe, candles, 1000, null).Result, + BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = moneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + BotType = BotType.ScalpingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = false, + Name = "Test" + }, candles, null).Result, + BotType.FlippingBot => _backtester.RunFlippingBotBacktest(new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = moneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + BotType = BotType.FlippingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = true, + Name = "Test" + }, candles, null).Result, _ => throw new NotImplementedException(), }; timer.Stop(); @@ -299,10 +375,38 @@ namespace Managing.Application.Tests var backtestResult = botType switch { BotType.SimpleBot => throw new NotImplementedException(), - BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(_account, moneyManagement, - scenario, timeframe, candles, 1000, null).Result, - BotType.FlippingBot => _backtester.RunFlippingBotBacktest(_account, moneyManagement, - scenario, timeframe, candles, 1000, null).Result, + BotType.ScalpingBot => _backtester.RunScalpingBotBacktest(new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = moneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + BotType = BotType.ScalpingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = false, + Name = "Test" + }, candles, null).Result, + BotType.FlippingBot => _backtester.RunFlippingBotBacktest(new TradingBotConfig + { + AccountName = _account.Name, + MoneyManagement = moneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = false, + BotTradingBalance = 1000, + BotType = BotType.FlippingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = true, + Name = "Test" + }, candles, null).Result, _ => throw new NotImplementedException(), }; diff --git a/src/Managing.Application.Workers/StatisticService.cs b/src/Managing.Application.Workers/StatisticService.cs index edfcd38..b72161b 100644 --- a/src/Managing.Application.Workers/StatisticService.cs +++ b/src/Managing.Application.Workers/StatisticService.cs @@ -1,6 +1,7 @@ using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; using Managing.Domain.Accounts; +using Managing.Domain.Bots; using Managing.Domain.MoneyManagements; using Managing.Domain.Scenarios; using Managing.Domain.Shared.Helpers; @@ -262,16 +263,30 @@ public class StatisticService : IStatisticService TakeProfit = 0.02m }; + var config = new TradingBotConfig + { + AccountName = account.Name, + MoneyManagement = moneyManagement, + Ticker = ticker, + ScenarioName = scenario.Name, + Timeframe = timeframe, + IsForWatchingOnly = true, + BotTradingBalance = 1000, + BotType = BotType.ScalpingBot, + IsForBacktest = true, + CooldownPeriod = 1, + MaxLossStreak = 0, + FlipPosition = false, + Name = "StatisticsBacktest" + }; + var backtest = await _backtester.RunScalpingBotBacktest( - account, - moneyManagement, - ticker, - scenario, - timeframe, - 1000, + config, DateTime.Now.AddDays(-7), DateTime.Now, - isForWatchingOnly: true); + null, + false, + null); return backtest.Signals; } diff --git a/src/Managing.Application/Backtesting/Backtester.cs b/src/Managing.Application/Backtesting/Backtester.cs index ff5715c..4ad0d72 100644 --- a/src/Managing.Application/Backtesting/Backtester.cs +++ b/src/Managing.Application/Backtesting/Backtester.cs @@ -1,13 +1,11 @@ using Managing.Application.Abstractions; using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; -using Managing.Core; using Managing.Core.FixedSizedQueue; using Managing.Domain.Accounts; using Managing.Domain.Backtests; using Managing.Domain.Bots; using Managing.Domain.Candles; -using Managing.Domain.MoneyManagements; using Managing.Domain.Scenarios; using Managing.Domain.Shared.Helpers; using Managing.Domain.Strategies; @@ -26,18 +24,22 @@ namespace Managing.Application.Backtesting private readonly IExchangeService _exchangeService; private readonly IBotFactory _botFactory; private readonly IScenarioService _scenarioService; + private readonly IAccountService _accountService; public Backtester( IExchangeService exchangeService, IBotFactory botFactory, IBacktestRepository backtestRepository, - ILogger logger, IScenarioService scenarioService) + ILogger logger, + IScenarioService scenarioService, + IAccountService accountService) { _exchangeService = exchangeService; _botFactory = botFactory; _backtestRepository = backtestRepository; _logger = logger; _scenarioService = scenarioService; + _accountService = accountService; } public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false) @@ -54,45 +56,19 @@ namespace Managing.Application.Backtesting } public async Task RunScalpingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Ticker ticker, - Scenario scenario, - Timeframe timeframe, - decimal balance, + TradingBotConfig config, DateTime startDate, DateTime endDate, User user = null, - bool isForWatchingOnly = false, bool save = false, - List? initialCandles = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true) + List? initialCandles = null) { - var config = new TradingBotConfig - { - AccountName = account.Name, - MoneyManagement = moneyManagement, - Ticker = ticker, - ScenarioName = scenario.Name, - Timeframe = timeframe, - IsForWatchingOnly = isForWatchingOnly, - BotTradingBalance = balance, - BotType = BotType.ScalpingBot, - IsForBacktest = true, - CooldownPeriod = cooldownPeriod, - MaxLossStreak = maxLossStreak, - MaxPositionTimeHours = maxPositionTimeHours, - FlipOnlyWhenInProfit = flipOnlyWhenInProfit - }; - + var account = await GetAccountFromConfig(config); var scalpingBot = _botFactory.CreateBacktestScalpingBot(config); - scalpingBot.LoadScenario(scenario.Name); + scalpingBot.LoadScenario(config.ScenarioName); scalpingBot.User = user; await scalpingBot.LoadAccount(); - var candles = initialCandles ?? GetCandles(account, ticker, timeframe, startDate, endDate); + var candles = initialCandles ?? GetCandles(account, config.Ticker, config.Timeframe, startDate, endDate); var result = GetBacktestingResult(config, scalpingBot, candles); if (user != null) @@ -113,46 +89,20 @@ namespace Managing.Application.Backtesting } public async Task RunFlippingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Ticker ticker, - Scenario scenario, - Timeframe timeframe, - decimal balance, + TradingBotConfig config, DateTime startDate, DateTime endDate, User user = null, - bool isForWatchingOnly = false, bool save = false, - List? initialCandles = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true) + List? initialCandles = null) { - var config = new TradingBotConfig - { - AccountName = account.Name, - MoneyManagement = moneyManagement, - Ticker = ticker, - ScenarioName = scenario.Name, - Timeframe = timeframe, - IsForWatchingOnly = isForWatchingOnly, - BotTradingBalance = balance, - BotType = BotType.FlippingBot, - IsForBacktest = true, - CooldownPeriod = cooldownPeriod, - MaxLossStreak = maxLossStreak, - MaxPositionTimeHours = maxPositionTimeHours, - FlipOnlyWhenInProfit = flipOnlyWhenInProfit - }; - + var account = await GetAccountFromConfig(config); var flippingBot = _botFactory.CreateBacktestFlippingBot(config); - flippingBot.LoadScenario(scenario.Name); + flippingBot.LoadScenario(config.ScenarioName); flippingBot.User = user; await flippingBot.LoadAccount(); - var candles = initialCandles ?? GetCandles(account, ticker, timeframe, startDate, endDate); + var candles = initialCandles ?? GetCandles(account, config.Ticker, config.Timeframe, startDate, endDate); var result = GetBacktestingResult(config, flippingBot, candles); if (user != null) @@ -173,38 +123,13 @@ namespace Managing.Application.Backtesting } public async Task RunScalpingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Scenario scenario, - Timeframe timeframe, + TradingBotConfig config, List candles, - decimal balance, - User user = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true) + User user = null) { - var ticker = MiscExtensions.ParseEnum(candles.FirstOrDefault().Ticker); - var config = new TradingBotConfig - { - AccountName = account.Name, - MoneyManagement = moneyManagement, - Ticker = ticker, - ScenarioName = scenario.Name, - Timeframe = timeframe, - IsForWatchingOnly = false, - BotTradingBalance = balance, - BotType = BotType.ScalpingBot, - IsForBacktest = true, - CooldownPeriod = cooldownPeriod, - MaxLossStreak = maxLossStreak, - MaxPositionTimeHours = maxPositionTimeHours, - FlipOnlyWhenInProfit = flipOnlyWhenInProfit - }; - + var account = await GetAccountFromConfig(config); var bot = _botFactory.CreateBacktestScalpingBot(config); - bot.LoadScenario(scenario.Name); + bot.LoadScenario(config.ScenarioName); bot.User = user; await bot.LoadAccount(); @@ -219,38 +144,13 @@ namespace Managing.Application.Backtesting } public async Task RunFlippingBotBacktest( - Account account, - MoneyManagement moneyManagement, - Scenario scenario, - Timeframe timeframe, + TradingBotConfig config, List candles, - decimal balance, - User user = null, - int cooldownPeriod = 1, - int maxLossStreak = 0, - decimal? maxPositionTimeHours = null, - bool flipOnlyWhenInProfit = true) + User user = null) { - var ticker = MiscExtensions.ParseEnum(candles.FirstOrDefault().Ticker); - var config = new TradingBotConfig - { - AccountName = account.Name, - MoneyManagement = moneyManagement, - Ticker = ticker, - ScenarioName = scenario.Name, - Timeframe = timeframe, - IsForWatchingOnly = false, - BotTradingBalance = balance, - BotType = BotType.FlippingBot, - IsForBacktest = true, - CooldownPeriod = cooldownPeriod, - MaxLossStreak = maxLossStreak, - MaxPositionTimeHours = maxPositionTimeHours, - FlipOnlyWhenInProfit = flipOnlyWhenInProfit - }; - + var account = await GetAccountFromConfig(config); var bot = _botFactory.CreateBacktestFlippingBot(config); - bot.LoadScenario(scenario.Name); + bot.LoadScenario(config.ScenarioName); bot.User = user; await bot.LoadAccount(); @@ -264,6 +164,23 @@ namespace Managing.Application.Backtesting return result; } + private async Task GetAccountFromConfig(TradingBotConfig config) + { + // Use the account service to get the actual account + var account = await _accountService.GetAccount(config.AccountName, false, false); + if (account != null) + { + return account; + } + + // Fallback: create a basic account structure if not found + return new Account + { + Name = config.AccountName, + Exchange = TradingExchanges.GmxV2 // Default exchange, should be configurable + }; + } + private List GetCandles(Account account, Ticker ticker, Timeframe timeframe, DateTime startDate, DateTime endDate) { diff --git a/src/Managing.Application/Bots/TradingBot.cs b/src/Managing.Application/Bots/TradingBot.cs index cc578d2..ac14fdd 100644 --- a/src/Managing.Application/Bots/TradingBot.cs +++ b/src/Managing.Application/Bots/TradingBot.cs @@ -468,28 +468,55 @@ public class TradingBot : Bot, ITradingBot var currentTime = Config.IsForBacktest ? lastCandle.Date : DateTime.UtcNow; - // Check if position has exceeded maximum time limit (only if MaxPositionTimeHours is set) - if (Config.MaxPositionTimeHours.HasValue && HasPositionExceededTimeLimit(positionForSignal, currentTime)) + // Check time-based position management (only if MaxPositionTimeHours is set) + if (Config.MaxPositionTimeHours.HasValue) { - // Check if position is in profit or at breakeven before closing var isPositionInProfit = await IsPositionInProfit(positionForSignal, lastCandle.Close); var isAtBreakeven = Math.Abs(lastCandle.Close - positionForSignal.Open.Price) < 0.01m; // Small tolerance for breakeven + var hasExceededTimeLimit = HasPositionExceededTimeLimit(positionForSignal, currentTime); - if (isPositionInProfit || isAtBreakeven) + // Calculate current unrealized PNL for logging + var currentPnl = CalculateUnrealizedPnl(positionForSignal, lastCandle.Close); + var pnlPercentage = Math.Round((currentPnl / (positionForSignal.Open.Price * positionForSignal.Open.Quantity)) * 100, 2); + + // Early closure logic when CloseEarlyWhenProfitable is enabled + if (Config.CloseEarlyWhenProfitable && (isPositionInProfit || isAtBreakeven)) { await LogInformation( - $"Closing position due to time limit - Position opened at {positionForSignal.Open.Date}, " + - $"current time {currentTime}, max time limit {Config.MaxPositionTimeHours} hours. " + - $"Position is {(isPositionInProfit ? "in profit" : "at breakeven")} (entry: {positionForSignal.Open.Price}, current: {lastCandle.Close})"); + $"Closing position early due to profitability - Position opened at {positionForSignal.Open.Date}, " + + $"current time {currentTime}. Position is {(isPositionInProfit ? "in profit" : "at breakeven")} " + + $"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " + + $"Current PNL: ${currentPnl:F2} ({pnlPercentage:F2}%). " + + $"CloseEarlyWhenProfitable is enabled."); await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true); return; } - else + + // Time limit exceeded logic + if (hasExceededTimeLimit) { - await LogInformation( - $"Position has exceeded time limit ({Config.MaxPositionTimeHours} hours) but is at a loss " + - $"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " + - $"Waiting for profit or breakeven before closing."); + if (Config.CloseEarlyWhenProfitable || isPositionInProfit || isAtBreakeven) + { + // Close when time limit is reached if: + // 1. CloseEarlyWhenProfitable is enabled (safety net), OR + // 2. Position is in profit/breakeven (when CloseEarlyWhenProfitable is disabled) + await LogInformation( + $"Closing position due to time limit - Position opened at {positionForSignal.Open.Date}, " + + $"current time {currentTime}, max time limit {Config.MaxPositionTimeHours} hours. " + + $"Position is {(isPositionInProfit ? "in profit" : isAtBreakeven ? "at breakeven" : "at a loss")} " + + $"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " + + $"Current PNL: ${currentPnl:F2} ({pnlPercentage:F2}%)"); + await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true); + return; + } + else + { + await LogInformation( + $"Position has exceeded time limit ({Config.MaxPositionTimeHours} hours) but is at a loss " + + $"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " + + $"Current PNL: ${currentPnl:F2} ({pnlPercentage:F2}%). " + + $"CloseEarlyWhenProfitable is disabled - waiting for profit or breakeven before closing."); + } } } @@ -1269,6 +1296,28 @@ public class TradingBot : Bot, ITradingBot return timeOpen >= maxTimeAllowed; } + /// + /// Calculates the current unrealized PNL for a position + /// + /// The position to calculate PNL for + /// The current market price + /// The current unrealized PNL + private decimal CalculateUnrealizedPnl(Position position, decimal currentPrice) + { + if (position.OriginDirection == TradeDirection.Long) + { + return currentPrice - position.Open.Price; + } + else if (position.OriginDirection == TradeDirection.Short) + { + return position.Open.Price - currentPrice; + } + else + { + throw new ArgumentException("Invalid position direction"); + } + } + /// /// Updates the trading bot configuration with new settings. /// This method validates the new configuration and applies it to the running bot. diff --git a/src/Managing.Domain/Bots/TradingBotConfig.cs b/src/Managing.Domain/Bots/TradingBotConfig.cs index 2787140..dce250f 100644 --- a/src/Managing.Domain/Bots/TradingBotConfig.cs +++ b/src/Managing.Domain/Bots/TradingBotConfig.cs @@ -26,6 +26,14 @@ public class TradingBotConfig /// public decimal? MaxPositionTimeHours { get; set; } + /// + /// When MaxPositionTimeHours is set and this is true, the position will be closed as soon as + /// it reaches breakeven or profit, rather than waiting for the full time duration. + /// If false, the position will only be closed when MaxPositionTimeHours is reached. + /// Default is false to maintain existing behavior. + /// + public bool CloseEarlyWhenProfitable { get; set; } = false; + /// /// If true, positions will only be flipped when the current position is in profit. /// If false, positions will be flipped regardless of profit status. diff --git a/src/Managing.WebApp/src/components/organism/Backtest/backtestCards.tsx b/src/Managing.WebApp/src/components/organism/Backtest/backtestCards.tsx index 5a66319..31d62b3 100644 --- a/src/Managing.WebApp/src/components/organism/Backtest/backtestCards.tsx +++ b/src/Managing.WebApp/src/components/organism/Backtest/backtestCards.tsx @@ -5,12 +5,11 @@ import {useQuery} from '@tanstack/react-query' import useApiUrlStore from '../../../app/store/apiStore' import type { - Backtest, - MoneyManagement, - RunBacktestRequest, - StartBotRequest, - Ticker, - TradingBotConfig + Backtest, + MoneyManagement, + RunBacktestRequest, + StartBotRequest, + TradingBotConfig } from '../../../generated/ManagingApi' import {BacktestClient, BotClient, MoneyManagementClient} from '../../../generated/ManagingApi' import type {IBacktestCards} from '../../../global/type' @@ -90,23 +89,39 @@ const BacktestCards: React.FC = ({ list, setBacktests }) => { moneyManagementName.toLowerCase() === 'custom' || moneyManagementName.toLowerCase().includes('custom'); - const request: StartBotRequest = { + // Create TradingBotConfig from the backtest configuration + const tradingBotConfig: TradingBotConfig = { accountName: backtest.config.accountName, - name: botName, - botType: backtest.config.botType, - isForWatchOnly: isForWatchOnly, - // Only use the money management name if it's not a custom money management, otherwise use optimized - moneyManagementName: isCustomMoneyManagement ? - (backtest.optimizedMoneyManagement?.name || backtest.config.moneyManagement?.name) : - moneyManagementName, - scenario: backtest.config.scenarioName, - ticker: backtest.config.ticker as Ticker, + ticker: backtest.config.ticker, + scenarioName: backtest.config.scenarioName, timeframe: backtest.config.timeframe, - initialTradingBalance: initialTradingBalance, + botType: backtest.config.botType, + isForWatchingOnly: isForWatchOnly, + isForBacktest: false, // This is for running a live bot cooldownPeriod: backtest.config.cooldownPeriod, maxLossStreak: backtest.config.maxLossStreak, maxPositionTimeHours: backtest.config.maxPositionTimeHours, - flipOnlyWhenInProfit: backtest.config.flipOnlyWhenInProfit + flipOnlyWhenInProfit: backtest.config.flipOnlyWhenInProfit, + flipPosition: backtest.config.flipPosition, + name: botName, + botTradingBalance: initialTradingBalance, + // Use the optimized or original money management from backtest if it's custom + moneyManagement: isCustomMoneyManagement ? + (backtest.optimizedMoneyManagement || backtest.config.moneyManagement || { + name: 'default', + leverage: 1, + stopLoss: 0.01, + takeProfit: 0.02, + timeframe: backtest.config.timeframe + }) : + backtest.config.moneyManagement, // Always provide a valid MoneyManagement object + closeEarlyWhenProfitable: backtest.config.closeEarlyWhenProfitable || false + }; + + const request: StartBotRequest = { + config: tradingBotConfig, + // Only use the money management name if it's not a custom money management + moneyManagementName: isCustomMoneyManagement ? undefined : moneyManagementName } await client diff --git a/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx b/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx index 2a2d582..a563218 100644 --- a/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx +++ b/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx @@ -47,7 +47,9 @@ const BacktestModal: React.FC = ({ cooldownPeriod: 10, // Default cooldown period of 10 minutes maxLossStreak: 0, // Default max loss streak of 0 (no limit) maxPositionTimeHours: null, // Default to null (disabled) - flipOnlyWhenInProfit: true // Default to true + flipOnlyWhenInProfit: true, // Default to true + balance: 10000, // Default balance + closeEarlyWhenProfitable: false // Default to false } }); const [selectedAccount, setSelectedAccount] = useState('') @@ -123,14 +125,15 @@ const BacktestModal: React.FC = ({ flipOnlyWhenInProfit: form.flipOnlyWhenInProfit ?? true, flipPosition: form.botType === BotType.FlippingBot, // Set based on bot type name: `Backtest-${scenarioName}-${ticker}-${new Date().toISOString()}`, - botTradingBalance: 0, // Will be set in the request + botTradingBalance: form.balance, moneyManagement: customMoneyManagement || moneyManagements?.find(m => m.name === selectedMoneyManagement) || moneyManagements?.[0] || { name: 'placeholder', leverage: 1, stopLoss: 0.01, takeProfit: 0.02, timeframe: form.timeframe - } + }, + closeEarlyWhenProfitable: form.closeEarlyWhenProfitable ?? false }; // Create the RunBacktestRequest @@ -138,7 +141,7 @@ const BacktestModal: React.FC = ({ config: tradingBotConfig, startDate: new Date(form.startDate), endDate: new Date(form.endDate), - balance: balance, + balance: form.balance, watchOnly: false, save: form.save || false, moneyManagementName: customMoneyManagement ? undefined : selectedMoneyManagement, @@ -335,8 +338,8 @@ const BacktestModal: React.FC = ({ } else if (selectedMoneyManagement) { mm = moneyManagements.find((m) => m.name === selectedMoneyManagement); } - // Use actual initial balance and a minimum threshold - const initialBalance = balance; + // Use form balance if available, otherwise fall back to state balance + const initialBalance = balance; // This state is kept in sync with form const minBalance = 10; // You can make this configurable if needed if (mm && mm.leverage && mm.stopLoss && initialBalance > minBalance) { const perLoss = mm.leverage * mm.stopLoss; @@ -400,8 +403,11 @@ const BacktestModal: React.FC = ({ setBalance(Number(e.target.value))} + {...register('balance', { valueAsNumber: true })} + onChange={(e) => { + setValue('balance', Number(e.target.value)); + setBalance(Number(e.target.value)); // Keep state in sync for UI calculations + }} /> @@ -443,7 +449,7 @@ const BacktestModal: React.FC = ({ - {/* Sixth Row: Flip Only When In Profit & Save */} + {/* Sixth Row: Flip Only When In Profit & Close Early When Profitable */}
= ({
+ + +
+ If enabled, positions will close early when they become profitable +
+
+ + + {/* Seventh Row: Save */} +
= ({
- {/* Seventh Row: Start Date & End Date */} + {/* Eighth Row: Start Date & End Date */}
= ({ const cooldownRecommendations = getCooldownRecommendations(); + // Calculate average trades per day + const getAverageTradesPerDay = () => { + if (positions.length === 0) return "0.00"; + + // Get all trade dates and sort them + const tradeDates = positions.map(position => new Date(position.open.date)).sort((a, b) => a.getTime() - b.getTime()); + + if (tradeDates.length < 2) return positions.length.toString(); + + // Calculate the date range in days + const firstTradeDate = tradeDates[0]; + const lastTradeDate = tradeDates[tradeDates.length - 1]; + const diffInMs = lastTradeDate.getTime() - firstTradeDate.getTime(); + const diffInDays = Math.max(1, diffInMs / (1000 * 60 * 60 * 24)); // Ensure at least 1 day + + const averageTradesPerDay = positions.length / diffInDays; + return averageTradesPerDay.toFixed(2); + }; + return ( <>
@@ -325,6 +344,10 @@ const BacktestRowDetails: React.FC = ({ title="Median Cooldown" content={cooldownRecommendations.median + " candles"} > +
diff --git a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx index b715295..e9c621f 100644 --- a/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx +++ b/src/Managing.WebApp/src/components/organism/Backtest/backtestTable.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useState} from 'react' import {useQuery} from '@tanstack/react-query' import useApiUrlStore from '../../../app/store/apiStore' -import type {Backtest, StartBotRequest, Ticker,} from '../../../generated/ManagingApi' +import type {Backtest, StartBotRequest, Ticker, TradingBotConfig} from '../../../generated/ManagingApi' import {BacktestClient, BotClient, MoneyManagementClient} from '../../../generated/ManagingApi' import type {IBacktestCards} from '../../../global/type' import {CardText, SelectColumnFilter, Table, Toast} from '../../mollecules' @@ -58,19 +58,39 @@ const BacktestTable: React.FC = ({ list, isFetching, setBacktest moneyManagementName.toLowerCase() === 'custom' || moneyManagementName.toLowerCase().includes('custom'); - const request: StartBotRequest = { + // Create TradingBotConfig from the backtest configuration + const tradingBotConfig: TradingBotConfig = { accountName: backtest.config.accountName, - botType: backtest.config.botType, - isForWatchOnly: isForWatchOnly, - // Only use the money management name if it's not a custom money management - moneyManagementName: isCustomMoneyManagement ? '' : moneyManagementName, - scenario: backtest.config.scenarioName, - ticker: backtest.config.ticker as Ticker, + ticker: backtest.config.ticker, + scenarioName: backtest.config.scenarioName, timeframe: backtest.config.timeframe, - initialTradingBalance: initialTradingBalance, + botType: backtest.config.botType, + isForWatchingOnly: isForWatchOnly, + isForBacktest: false, // This is for running a live bot cooldownPeriod: backtest.config.cooldownPeriod, maxLossStreak: backtest.config.maxLossStreak, + maxPositionTimeHours: backtest.config.maxPositionTimeHours, + flipOnlyWhenInProfit: backtest.config.flipOnlyWhenInProfit, + flipPosition: backtest.config.flipPosition, name: botName, + botTradingBalance: initialTradingBalance, + // Use the money management from backtest if it's custom, otherwise leave null and use moneyManagementName + moneyManagement: isCustomMoneyManagement ? + (backtest.config.moneyManagement || { + name: 'default', + leverage: 1, + stopLoss: 0.01, + takeProfit: 0.02, + timeframe: backtest.config.timeframe + }) : + backtest.config.moneyManagement, // Always provide a valid MoneyManagement object + closeEarlyWhenProfitable: backtest.config.closeEarlyWhenProfitable || false + }; + + const request: StartBotRequest = { + config: tradingBotConfig, + // Only use the money management name if it's not a custom money management + moneyManagementName: isCustomMoneyManagement ? undefined : moneyManagementName } await client diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index f53309a..d1fa72b 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -2699,6 +2699,7 @@ export interface TradingBotConfig { flipPosition: boolean; name: string; maxPositionTimeHours?: number | null; + closeEarlyWhenProfitable?: boolean; flipOnlyWhenInProfit: boolean; } @@ -3113,20 +3114,8 @@ export interface RunBacktestRequest { } export interface StartBotRequest { - botType?: BotType; - identifier?: string | null; - ticker?: Ticker; - scenario?: string | null; - timeframe?: Timeframe; - accountName?: string | null; + config?: TradingBotConfig | null; moneyManagementName?: string | null; - isForWatchOnly?: boolean; - initialTradingBalance?: number; - cooldownPeriod?: number; - maxLossStreak?: number; - name?: string | null; - maxPositionTimeHours?: number | null; - flipOnlyWhenInProfit?: boolean; } export interface TradingBot { diff --git a/src/Managing.WebApp/src/global/type.tsx b/src/Managing.WebApp/src/global/type.tsx index d9eb759..f611157 100644 --- a/src/Managing.WebApp/src/global/type.tsx +++ b/src/Managing.WebApp/src/global/type.tsx @@ -116,6 +116,7 @@ export type IBacktestsFormInput = { maxLossStreak: number maxPositionTimeHours?: number | null flipOnlyWhenInProfit?: boolean + closeEarlyWhenProfitable?: boolean } export type IBacktestCards = {