From 9d536ea49ec6f03cd7e013fdb64ea3be0c8e6a55 Mon Sep 17 00:00:00 2001 From: Oda <102867384+CryptoOda@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:32:06 +0700 Subject: [PATCH] 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 --- .../Controllers/BacktestController.cs | 2 +- src/Managing.Api/Controllers/BotController.cs | 4 +- .../Controllers/DataController.cs | 4 +- .../Controllers/TradingController.cs | 23 +- .../Grains/IBacktestTradingBotGrain.cs | 2 +- .../Services/IExchangeService.cs | 2 +- .../BacktestTests.cs | 18 +- .../IndicatorBaseTests.cs | 12 +- .../PositionTests.cs | 7 +- .../Backtests/BacktestExecutor.cs | 28 +- .../Backtests/BacktestExecutorAdapter.cs | 12 +- .../Backtests/BacktestJobService.cs | 2 +- .../Bots/BacktestFuturesBot.cs | 329 ++- src/Managing.Application/Bots/FuturesBot.cs | 1322 ++++++++++- .../Bots/Grains/BacktestTradingBotGrain.cs | 41 +- .../Bots/Grains/LiveTradingBotGrain.cs | 4 +- .../Bots/TradingBotBase.cs | 1969 +++++------------ src/Managing.Application/GeneticService.cs | 4 +- .../Grains/BundleBacktestGrain.cs | 534 ----- .../Grains/GeneticBacktestGrain.cs | 95 - .../StartCopyTradingCommandHandler.cs | 2 +- .../Scenarios/ScenarioRunnerGrain.cs | 9 +- .../CloseBacktestFuturesPositionCommand.cs | 20 + .../Commands/CloseFuturesPositionCommand.cs | 20 + .../Trading/Commands/OpenPositionRequest.cs | 5 +- ...seBacktestFuturesPositionCommandHandler.cs | 70 + .../CloseFuturesPositionCommandHandler.cs | 110 + .../Handlers/ClosePositionCommandHandler.cs | 4 +- .../Handlers/OpenPositionCommandHandler.cs | 1 + .../Trading/StatisticService.cs | 2 +- .../Workers/BacktestComputeWorker.cs | 5 +- .../Workers/BundleBacktestWorker.cs | 2 +- src/Managing.Bootstrap/ApiBootstrap.cs | 3 + src/Managing.Common/Enums.cs | 16 + src/Managing.Domain.Tests/IndicatorTests.cs | 18 +- .../Indicators/RunIndicatorsBase.cs | 7 +- .../SignalProcessingTests.cs | 14 +- src/Managing.Domain/Backtests/Backtest.cs | 5 +- src/Managing.Domain/Bots/TradingBotConfig.cs | 2 +- .../Indicators/Base/BollingerBandsBase.cs | 8 +- .../BollingerBandsVolatilityProtection.cs | 2 +- .../Indicators/Context/StDevContext.cs | 8 +- src/Managing.Domain/Indicators/IIndicator.cs | 8 +- .../Indicators/IndicatorBase.cs | 8 +- .../BollingerBandsPercentBMomentumBreakout.cs | 2 +- .../Signals/ChandelierExitIndicatorBase.cs | 16 +- .../Signals/DualEmaCrossIndicatorBase.cs | 8 +- .../Indicators/Signals/EmaCrossIndicator.cs | 8 +- .../Signals/EmaCrossIndicatorBase.cs | 8 +- .../Indicators/Signals/LaggingSTC.cs | 8 +- .../Signals/MacdCrossIndicatorBase.cs | 8 +- .../RsiDivergenceConfirmIndicatorBase.cs | 14 +- .../Signals/RsiDivergenceIndicatorBase.cs | 14 +- .../Indicators/Signals/StcIndicatorBase.cs | 8 +- .../Signals/StochasticCrossIndicator.cs | 8 +- .../Indicators/Signals/SuperTrendCrossEma.cs | 8 +- .../Signals/SuperTrendIndicatorBase.cs | 8 +- .../ThreeWhiteSoldiersIndicatorBase.cs | 8 +- .../Trends/EmaTrendIndicatorBase.cs | 8 +- .../Indicators/Trends/IchimokuKumoTrend.cs | 10 +- .../Trends/StochRsiTrendIndicatorBase.cs | 8 +- .../Shared/Helpers/TradingBox.cs | 19 +- src/Managing.Domain/Trades/Position.cs | 6 + .../ManagingDbContextModelSnapshot.cs | 12 + .../PostgreSql/Entities/PositionEntity.cs | 5 + .../PostgreSql/PostgreSqlMappers.cs | 21 +- ...4050_AddTradingTypeToPositions.Designer.cs | 1744 +++++++++++++++ ...0251127104050_AddTradingTypeToPositions.cs | 50 + .../ExchangeService.cs | 18 +- .../Discord/DiscordService.cs | 15 +- .../pages/settingsPage/UserInfoSettings.tsx | 8 +- .../BacktestExecutorTests.cs | 54 +- .../performance-benchmarks-two-scenarios.csv | 4 + .../performance-benchmarks.csv | 4 + 74 files changed, 4525 insertions(+), 2350 deletions(-) delete mode 100644 src/Managing.Application/Grains/BundleBacktestGrain.cs delete mode 100644 src/Managing.Application/Grains/GeneticBacktestGrain.cs create mode 100644 src/Managing.Application/Trading/Commands/CloseBacktestFuturesPositionCommand.cs create mode 100644 src/Managing.Application/Trading/Commands/CloseFuturesPositionCommand.cs create mode 100644 src/Managing.Application/Trading/Handlers/CloseBacktestFuturesPositionCommandHandler.cs create mode 100644 src/Managing.Application/Trading/Handlers/CloseFuturesPositionCommandHandler.cs create mode 100644 src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.Designer.cs create mode 100644 src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.cs diff --git a/src/Managing.Api/Controllers/BacktestController.cs b/src/Managing.Api/Controllers/BacktestController.cs index 751b12f3..0e15ee83 100644 --- a/src/Managing.Api/Controllers/BacktestController.cs +++ b/src/Managing.Api/Controllers/BacktestController.cs @@ -568,7 +568,7 @@ public class BacktestController : BaseController Timeframe = request.Config.Timeframe, IsForWatchingOnly = request.Config.IsForWatchingOnly, BotTradingBalance = request.Config.BotTradingBalance, - IsForBacktest = true, + TradingType = TradingType.BacktestFutures, CooldownPeriod = request.Config.CooldownPeriod ?? 1, MaxLossStreak = request.Config.MaxLossStreak, MaxPositionTimeHours = request.Config.MaxPositionTimeHours, diff --git a/src/Managing.Api/Controllers/BotController.cs b/src/Managing.Api/Controllers/BotController.cs index fc0b3911..8c79bd9a 100644 --- a/src/Managing.Api/Controllers/BotController.cs +++ b/src/Managing.Api/Controllers/BotController.cs @@ -814,7 +814,7 @@ public class BotController : BaseController UseForSignalFiltering = request.Config.UseForSignalFiltering, UseForDynamicStopLoss = request.Config.UseForDynamicStopLoss, // Set computed/default properties - IsForBacktest = false, + TradingType = TradingType.Futures, FlipPosition = request.Config.FlipPosition, Name = request.Config.Name }; @@ -976,7 +976,7 @@ public class BotController : BaseController UseForSignalFiltering = request.Config.UseForSignalFiltering, UseForDynamicStopLoss = request.Config.UseForDynamicStopLoss, // Set computed/default properties - IsForBacktest = false, + TradingType = TradingType.Futures, FlipPosition = request.Config.FlipPosition, Name = request.Config.Name }; diff --git a/src/Managing.Api/Controllers/DataController.cs b/src/Managing.Api/Controllers/DataController.cs index 73276d05..445f20ec 100644 --- a/src/Managing.Api/Controllers/DataController.cs +++ b/src/Managing.Api/Controllers/DataController.cs @@ -350,7 +350,9 @@ public class DataController : ControllerBase { // Map ScenarioRequest to domain Scenario object var domainScenario = MapScenarioRequestToScenario(request.Scenario); - indicatorsValues = TradingBox.CalculateIndicatorsValues(domainScenario, candles); + // Convert to ordered List to preserve chronological order for indicators + var candlesList = candles.OrderBy(c => c.Date).ToList(); + indicatorsValues = TradingBox.CalculateIndicatorsValues(domainScenario, candlesList); } return Ok(new CandlesWithIndicatorsResponse diff --git a/src/Managing.Api/Controllers/TradingController.cs b/src/Managing.Api/Controllers/TradingController.cs index c7512330..fc215a96 100644 --- a/src/Managing.Api/Controllers/TradingController.cs +++ b/src/Managing.Api/Controllers/TradingController.cs @@ -25,7 +25,8 @@ namespace Managing.Api.Controllers; public class TradingController : BaseController { private readonly ICommandHandler _openTradeCommandHandler; - private readonly ICommandHandler _closeTradeCommandHandler; + private readonly ICommandHandler _closeBacktestFuturesCommandHandler; + private readonly ICommandHandler _closeFuturesCommandHandler; private readonly ITradingService _tradingService; private readonly IMoneyManagementService _moneyManagementService; private readonly IMediator _mediator; @@ -50,7 +51,8 @@ public class TradingController : BaseController public TradingController( ILogger logger, ICommandHandler openTradeCommandHandler, - ICommandHandler closeTradeCommandHandler, + ICommandHandler closeBacktestFuturesCommandHandler, + ICommandHandler closeFuturesCommandHandler, ITradingService tradingService, IMediator mediator, IMoneyManagementService moneyManagementService, IUserService userService, IAdminConfigurationService adminService, @@ -60,7 +62,8 @@ public class TradingController : BaseController { _logger = logger; _openTradeCommandHandler = openTradeCommandHandler; - _closeTradeCommandHandler = closeTradeCommandHandler; + _closeBacktestFuturesCommandHandler = closeBacktestFuturesCommandHandler; + _closeFuturesCommandHandler = closeFuturesCommandHandler; _tradingService = tradingService; _mediator = mediator; _moneyManagementService = moneyManagementService; @@ -98,20 +101,6 @@ public class TradingController : BaseController return Ok(result); } - /// - /// Closes a position identified by its unique identifier. - /// - /// The unique identifier of the position to close. - /// The closed position. - [HttpPost("ClosePosition")] - public async Task> ClosePosition(Guid identifier) - { - var position = await _tradingService.GetPositionByIdentifierAsync(identifier); - - var result = await _closeTradeCommandHandler.Handle(new ClosePositionCommand(position, position.AccountId)); - return Ok(result); - } - /// /// Opens a new position based on the provided parameters. /// diff --git a/src/Managing.Application.Abstractions/Grains/IBacktestTradingBotGrain.cs b/src/Managing.Application.Abstractions/Grains/IBacktestTradingBotGrain.cs index 9bffc514..52eafd6f 100644 --- a/src/Managing.Application.Abstractions/Grains/IBacktestTradingBotGrain.cs +++ b/src/Managing.Application.Abstractions/Grains/IBacktestTradingBotGrain.cs @@ -23,5 +23,5 @@ public interface IBacktestTradingBotGrain : IGrainWithGuidKey /// The request ID to associate with this backtest /// Additional metadata to associate with this backtest /// The complete backtest result - Task RunBacktestAsync(TradingBotConfig config, HashSet candles, User user = null, bool save = false, bool withCandles = false, string requestId = null, object metadata = null); + Task RunBacktestAsync(TradingBotConfig config, IReadOnlyList candles, User user = null, bool save = false, bool withCandles = false, string requestId = null, object metadata = null); } diff --git a/src/Managing.Application.Abstractions/Services/IExchangeService.cs b/src/Managing.Application.Abstractions/Services/IExchangeService.cs index 5ae71ed1..e86f9b5c 100644 --- a/src/Managing.Application.Abstractions/Services/IExchangeService.cs +++ b/src/Managing.Application.Abstractions/Services/IExchangeService.cs @@ -41,7 +41,7 @@ public interface IExchangeService decimal takeProfitPrice, decimal quantity, bool isForPaperTrading = false, DateTime? currentDate = null); - Task ClosePosition(Account account, Position position, decimal lastPrice, bool isForPaperTrading = false); + Task ClosePosition(Account account, Position position, decimal lastPrice); decimal GetVolume(Account account, Ticker ticker); Task> GetTrades(Account account, Ticker ticker); Task CancelOrder(Account account, Ticker ticker); diff --git a/src/Managing.Application.Tests/BacktestTests.cs b/src/Managing.Application.Tests/BacktestTests.cs index c7bca0f0..49806621 100644 --- a/src/Managing.Application.Tests/BacktestTests.cs +++ b/src/Managing.Application.Tests/BacktestTests.cs @@ -169,7 +169,7 @@ public class BacktestTests : BaseTests Timeframe = Timeframe.FifteenMinutes, IsForWatchingOnly = false, BotTradingBalance = 1000, - IsForBacktest = true, + TradingType = TradingType.BacktestFutures, CooldownPeriod = 1, MaxLossStreak = 0, FlipPosition = false, @@ -182,7 +182,7 @@ public class BacktestTests : BaseTests // Act - Call BacktestTradingBotGrain directly (no Orleans needed) var backtestResult = await _backtestGrain.RunBacktestAsync( config, - candles.ToHashSet(), + candles, // candles is already a List, no conversion needed _testUser, save: false, withCandles: false); @@ -212,19 +212,19 @@ public class BacktestTests : BaseTests Assert.NotNull(backtestResult); // Financial metrics - using decimal precision - Assert.Equal(-17.74m, Math.Round(backtestResult.FinalPnl, 2)); - Assert.Equal(-77.71m, Math.Round(backtestResult.NetPnl, 2)); - Assert.Equal(59.97m, Math.Round(backtestResult.Fees, 2)); + Assert.Equal(8.79m, Math.Round(backtestResult.FinalPnl, 2)); + Assert.Equal(-61.36m, Math.Round(backtestResult.NetPnl, 2)); + Assert.Equal(66.46m, Math.Round(backtestResult.Fees, 2)); Assert.Equal(1000.0m, backtestResult.InitialBalance); // Performance metrics - Assert.Equal(32, backtestResult.WinRate); - Assert.Equal(-1.77m, Math.Round(backtestResult.GrowthPercentage, 2)); + Assert.Equal(31, backtestResult.WinRate); + Assert.Equal(-6.14m, Math.Round(backtestResult.GrowthPercentage, 2)); Assert.Equal(-0.67m, Math.Round(backtestResult.HodlPercentage, 2)); // Risk metrics - Assert.Equal(158.79m, Math.Round(backtestResult.MaxDrawdown.Value, 2)); - Assert.Equal(-0.004, Math.Round(backtestResult.SharpeRatio.Value, 3)); + Assert.Equal(202.29m, Math.Round(backtestResult.MaxDrawdown.Value, 2)); + Assert.Equal(-0.015, Math.Round(backtestResult.SharpeRatio.Value, 3)); Assert.True(Math.Abs(backtestResult.Score - 0.0) < 0.001, $"Score {backtestResult.Score} should be within 0.001 of expected value 0.0"); diff --git a/src/Managing.Application.Tests/IndicatorBaseTests.cs b/src/Managing.Application.Tests/IndicatorBaseTests.cs index 663ba08d..db6b94c9 100644 --- a/src/Managing.Application.Tests/IndicatorBaseTests.cs +++ b/src/Managing.Application.Tests/IndicatorBaseTests.cs @@ -26,7 +26,7 @@ namespace Managing.Application.Tests // Act foreach (var candle in _candles) { - var signals = rsiStrategy.Run(new HashSet { candle }); + var signals = rsiStrategy.Run(new List { candle }); } if (rsiStrategy.Signals != null && rsiStrategy.Signals.Count > 0) @@ -48,7 +48,7 @@ namespace Managing.Application.Tests // Act foreach (var candle in _candles) { - var signals = macdStrategy.Run(new HashSet { candle }); + var signals = macdStrategy.Run(new List { candle }); } if (macdStrategy.Signals != null && macdStrategy.Signals.Count > 0) @@ -69,7 +69,7 @@ namespace Managing.Application.Tests // Act foreach (var candle in _candles) { - var signals = superTrendStrategy.Run(new HashSet { candle }); + var signals = superTrendStrategy.Run(new List { candle }); } if (superTrendStrategy.Signals != null && superTrendStrategy.Signals.Count > 0) @@ -90,7 +90,7 @@ namespace Managing.Application.Tests // Act foreach (var candle in _candles) { - var signals = chandelierExitStrategy.Run(new HashSet { candle }); + var signals = chandelierExitStrategy.Run(new List { candle }); } if (chandelierExitStrategy.Signals is { Count: > 0 }) @@ -111,7 +111,7 @@ namespace Managing.Application.Tests // Act foreach (var candle in _candles) { - var signals = emaTrendStrategy.Run(new HashSet { candle }); + var signals = emaTrendStrategy.Run(new List { candle }); } if (emaTrendStrategy.Signals != null && emaTrendStrategy.Signals.Count > 0) @@ -133,7 +133,7 @@ namespace Managing.Application.Tests // Act foreach (var candle in _candles) { - var signals = stochRsiStrategy.Run(new HashSet { candle }); + var signals = stochRsiStrategy.Run(new List { candle }); } if (stochRsiStrategy.Signals != null && stochRsiStrategy.Signals.Count > 0) diff --git a/src/Managing.Application.Tests/PositionTests.cs b/src/Managing.Application.Tests/PositionTests.cs index 487bbe73..3c3560ee 100644 --- a/src/Managing.Application.Tests/PositionTests.cs +++ b/src/Managing.Application.Tests/PositionTests.cs @@ -50,14 +50,15 @@ public class PositionTests : BaseTests PositionInitiator.User, DateTime.UtcNow, new User()) { - Open = openTrade + Open = openTrade, + TradingType = TradingType.Futures // Set trading type for the position }; - var command = new ClosePositionCommand(position, 1); _ = _tradingService.Setup(m => m.GetPositionByIdentifierAsync(It.IsAny())).ReturnsAsync(position); _ = _tradingService.Setup(m => m.GetPositionByIdentifierAsync(It.IsAny())).ReturnsAsync(position); var mockScope = new Mock(); - var handler = new ClosePositionCommandHandler( + var command = new CloseFuturesPositionCommand(position, 1); + var handler = new CloseFuturesPositionCommandHandler( _exchangeService, _accountService.Object, _tradingService.Object, diff --git a/src/Managing.Application/Backtests/BacktestExecutor.cs b/src/Managing.Application/Backtests/BacktestExecutor.cs index 539411ad..e76a2378 100644 --- a/src/Managing.Application/Backtests/BacktestExecutor.cs +++ b/src/Managing.Application/Backtests/BacktestExecutor.cs @@ -129,7 +129,7 @@ public class BacktestExecutor /// The lightweight backtest result public async Task ExecuteAsync( TradingBotConfig config, - HashSet candles, + IReadOnlyList 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 directly to preserve chronological order and enable incremental updates const int RollingWindowSize = 600; // TradingBox.GetSignal only needs last 600 candles - var rollingWindowCandles = new Queue(RollingWindowSize); + var rollingWindowCandles = new List(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(rollingWindowCandles); - await tradingBot.UpdateSignals(fixedCandles, preCalculatedIndicatorValues); + // Pass List 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>(); - var tradingBot = new TradingBotBase(logger, _scopeFactory, config); + var tradingBot = new BacktestFuturesBot(logger, _scopeFactory, config); return tradingBot; } diff --git a/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs b/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs index 96820b75..8bc595ab 100644 --- a/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs +++ b/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs @@ -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 RunTradingBotBacktest( TradingBotConfig config, - HashSet candles, + HashSet 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, diff --git a/src/Managing.Application/Backtests/BacktestJobService.cs b/src/Managing.Application/Backtests/BacktestJobService.cs index 2e61076a..6793707f 100644 --- a/src/Managing.Application/Backtests/BacktestJobService.cs +++ b/src/Managing.Application/Backtests/BacktestJobService.cs @@ -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, diff --git a/src/Managing.Application/Bots/BacktestFuturesBot.cs b/src/Managing.Application/Bots/BacktestFuturesBot.cs index 7aaaa1d6..ccd60caa 100644 --- a/src/Managing.Application/Bots/BacktestFuturesBot.cs +++ b/src/Managing.Application/Bots/BacktestFuturesBot.cs @@ -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 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 GetInternalPositionForUpdate(Position position) + { + // In backtest mode, return the position as-is (no database lookup needed) + return position; + } + + protected override async Task> GetBrokerPositionsForUpdate(Account account) + { + // In backtest mode, return empty list (no broker positions to check) + return new List(); + } + + protected override async Task UpdatePositionWithBrokerData(Position position, List brokerPositions) + { + // In backtest mode, skip broker synchronization + return; + } + + protected override async Task GetCurrentCandleForPositionClose(Account account, string ticker) + { + // In backtest mode, use LastCandle + return LastCandle; + } + + protected override async Task 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 candles, + Dictionary 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 GetLastPriceForPositionOpeningAsync() + { + // For backtest, use LastCandle close price + return LastCandle?.Close ?? 0; + } + + protected override async Task 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 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 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( + _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( + _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); + } + } + } } \ No newline at end of file diff --git a/src/Managing.Application/Bots/FuturesBot.cs b/src/Managing.Application/Bots/FuturesBot.cs index 54398b7b..6d986805 100644 --- a/src/Managing.Application/Bots/FuturesBot.cs +++ b/src/Managing.Application/Bots/FuturesBot.cs @@ -1,5 +1,1325 @@ +using Managing.Application.Abstractions; +using Managing.Application.Abstractions.Grains; +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.Synth.Models; +using Managing.Domain.Trades; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orleans.Streams; +using static Managing.Common.Enums; + namespace Managing.Application.Bots; -public class FuturesBot +public class FuturesBot : TradingBotBase, ITradingBot { + public FuturesBot( + ILogger logger, + IServiceScopeFactory scopeFactory, + TradingBotConfig config, + IStreamProvider? streamProvider = null + ) : base(logger, scopeFactory, config, streamProvider) + { + // Futures trading mode - ensure it's not backtest + Config.TradingType = TradingType.Futures; + } + + // FuturesBot uses the base implementation for Start() which includes + // account loading, balance verification, and live trading startup messages + + public override async Task Run() + { + // Live trading signal update logic + if (!Config.IsForCopyTrading) + { + await UpdateSignals(); + } + + await LoadLastCandle(); + + if (!Config.IsForWatchingOnly) + { + // Recovery Logic: Check for recently canceled positions that might need recovery (futures only) + await RecoverRecentlyCanceledPositions(); + await ManagePositions(); + } + + UpdateWalletBalances(); + + // Live trading execution logging + ExecutionCount++; + + Logger.LogInformation( + "[{CopyTrading}][{AgentName}] Bot Status {Name} - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}", + Config.IsForCopyTrading ? "CopyTrading" : "LiveTrading", Account.User.AgentName, Config.Name, + DateTime.UtcNow, LastCandle?.Date, Signals.Count, ExecutionCount, Positions.Count); + + Logger.LogInformation("[{AgentName}] Internal Positions : {Position}", Account.User.AgentName, + string.Join(", ", + Positions.Values.Select(p => $"{p.SignalIdentifier} - Status: {p.Status}"))); + } + + protected override async Task GetInternalPositionForUpdate(Position position) + { + // For live trading, get position from database via trading service + return await ServiceScopeHelpers.WithScopedService( + _scopeFactory, + async tradingService => { return await tradingService.GetPositionByIdentifierAsync(position.Identifier); }); + } + + protected override async Task> GetBrokerPositionsForUpdate(Account account) + { + // For live trading, get real broker positions + return await ServiceScopeHelpers.WithScopedService>( + _scopeFactory, + async exchangeService => { return [.. await exchangeService.GetBrokerPositions(Account)]; }); + } + + protected override async Task UpdatePositionWithBrokerData(Position position, List brokerPositions) + { + // Live trading broker position synchronization logic is handled in the base UpdatePosition method + // This override allows for any futures-specific synchronization if needed + await base.UpdatePositionWithBrokerData(position, brokerPositions); + } + + protected override async Task GetCurrentCandleForPositionClose(Account account, string ticker) + { + // For live trading, get real-time candle from exchange + return await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + return await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); + }); + } + + protected override async Task CheckBrokerPositions() + { + try + { + List positions = null; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => { positions = [.. await exchangeService.GetBrokerPositions(Account)]; }); + + // Check if there's a position for this ticker on the broker + var brokerPositionForTicker = positions.FirstOrDefault(p => p.Ticker == Config.Ticker); + if (brokerPositionForTicker == null) + { + // No position on broker for this ticker, safe to open + return true; + } + + // Handle existing position on broker + await LogDebugAsync( + $"๐Ÿ” Broker Position Found\n" + + $"Ticker: {Config.Ticker}\n" + + $"Direction: {brokerPositionForTicker.OriginDirection}\n" + + $"Checking internal positions for synchronization..."); + + var previousPosition = Positions.Values.LastOrDefault(); + List orders = null; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + orders = [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)]; + }); + + var reason = + $"Cannot open position. There is already a position open for {Config.Ticker} on the broker (Direction: {brokerPositionForTicker.OriginDirection})."; + + if (previousPosition != null) + { + // Check if this position matches the broker position + if (previousPosition.OriginDirection == brokerPositionForTicker.OriginDirection) + { + // Same direction - this is likely the same position + if (orders.Count >= 2) + { + Logger.LogInformation( + $"โœ… Broker Position Matched with Internal Position\n" + + $"Position: {previousPosition.Identifier}\n" + + $"Direction: {previousPosition.OriginDirection}\n" + + $"Orders found: {orders.Count}\n" + + $"Setting status to Filled"); + await SetPositionStatus(previousPosition.SignalIdentifier, PositionStatus.Filled); + } + else + { + // Position exists on broker but not enough orders - something is wrong + Logger.LogWarning( + $"โš ๏ธ Incomplete Order Set\n" + + $"Position: {previousPosition.Identifier}\n" + + $"Direction: {previousPosition.OriginDirection}\n" + + $"Expected orders: โ‰ฅ2, Found: {orders.Count}\n" + + $"This position may need manual intervention"); + + reason += $" Position exists on broker but only has {orders.Count} orders (expected โ‰ฅ2)."; + } + } + else + { + // Different direction - possible flip scenario or orphaned position + Logger.LogWarning( + $"โš ๏ธ Direction Mismatch Detected\n" + + $"Internal: {previousPosition.OriginDirection}\n" + + $"Broker: {brokerPositionForTicker.OriginDirection}\n" + + $"This could indicate a flipped position or orphaned broker position"); + + reason += + $" Direction mismatch: Internal ({previousPosition.OriginDirection}) vs Broker ({brokerPositionForTicker.OriginDirection})."; + } + } + else + { + // Broker has a position but we don't have any internal tracking + Logger.LogWarning( + $"โš ๏ธ Orphaned Broker Position Detected\n" + + $"Broker has position for {Config.Ticker} ({brokerPositionForTicker.OriginDirection})\n" + + $"But no internal position found in bot tracking\n" + + $"This may require manual cleanup"); + + reason += " Position open on broker but no internal position tracked by the bot."; + } + + await LogWarningAsync(reason); + return false; + } + catch (Exception ex) + { + await LogWarningAsync($"โŒ Broker Position Check Failed\nError checking broker positions\n{ex.Message}"); + return false; + } + } + + protected override async Task LoadAccountAsync() + { + // Live trading: load real account from database + if (Config.TradingType == TradingType.BacktestFutures) return; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, async accountService => + { + var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false); + Account = account; + }); + } + + protected override async Task VerifyAndUpdateBalanceAsync() + { + // Live trading: verify real USDC balance + if (Config.TradingType == TradingType.BacktestFutures) return; + + try + { + var actualBalance = await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + var balances = await exchangeService.GetBalances(Account); + var usdcBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "USDC"); + return usdcBalance?.Amount ?? 0; + }); + + if (actualBalance < Config.BotTradingBalance) + { + Logger.LogWarning( + "Actual USDC balance ({ActualBalance:F2}) is less than configured balance ({ConfiguredBalance:F2}). Updating configuration.", + actualBalance, Config.BotTradingBalance); + + var newConfig = Config; + newConfig.BotTradingBalance = actualBalance; + await UpdateConfiguration(newConfig); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error verifying and updating balance"); + } + } + + protected override async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal, + List brokerPositions) + { + // Improved broker position matching with more robust logic + var brokerPosition = brokerPositions + .Where(p => p.Ticker == Config.Ticker) + .OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue) + .FirstOrDefault(p => p.OriginDirection == positionForSignal.OriginDirection); + + if (brokerPosition != null) + { + var previousPositionStatus = internalPosition.Status; + // Position found on the broker, means the position is filled + var brokerPnlBeforeFees = brokerPosition.GetPnLBeforeFees(); + UpdatePositionPnl(positionForSignal.Identifier, brokerPnlBeforeFees); + var totalFees = internalPosition.GasFees + internalPosition.UiFees; + var netPnl = brokerPnlBeforeFees - totalFees; + internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = brokerPnlBeforeFees, Net = netPnl }; + internalPosition.Status = PositionStatus.Filled; + await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled); + + internalPosition.Open.SetStatus(TradeStatus.Filled); + positionForSignal.Open.SetStatus(TradeStatus.Filled); + + internalPosition.Open.Price = brokerPosition.Open.Price; + positionForSignal.Open.Price = brokerPosition.Open.Price; + + // Update Open trade ExchangeOrderId if broker position has one + if (brokerPosition.Open?.ExchangeOrderId != null && internalPosition.Open != null) + { + internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); + positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); + } + + // Update Stop Loss and Take Profit trades with correct ExchangeOrderId from broker + if (brokerPosition.StopLoss != null && internalPosition.StopLoss != null) + { + internalPosition.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); + positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); + } + + if (brokerPosition.TakeProfit1 != null && internalPosition.TakeProfit1 != null) + { + internalPosition.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); + positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); + } + + if (brokerPosition.TakeProfit2 != null && internalPosition.TakeProfit2 != null) + { + internalPosition.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); + positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); + } + + await UpdatePositionInDatabaseAsync(internalPosition); + + if (previousPositionStatus != PositionStatus.Filled && + internalPosition.Status == PositionStatus.Filled) + { + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition); + } + else + { + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionUpdated, internalPosition); + } + } + else + { + // Position not found in broker's active positions list + // Need to verify if it was actually closed or just not returned by the API + if (internalPosition.Status.Equals(PositionStatus.Filled)) + { + Logger.LogWarning( + $"โš ๏ธ Position Sync Issue Detected\n" + + $"Internal position {internalPosition.Identifier} shows Filled\n" + + $"But not found in broker positions list (Count: {brokerPositions.Count})\n" + + $"Checking position history before marking as closed..."); + + // Verify in exchange history before assuming it's closed + var (existsInHistory, hadWeb3ProxyError) = + await CheckPositionInExchangeHistory(positionForSignal); + + if (hadWeb3ProxyError) + { + // Web3Proxy error - don't assume position is closed, wait for next cycle + await LogWarningAsync( + $"โณ Web3Proxy Error During Position Verification\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Cannot verify if position is closed\n" + + $"Will retry on next execution cycle"); + // Don't change position status, wait for next cycle + return; + } + else if (existsInHistory) + { + // Position was actually filled and closed by the exchange + Logger.LogInformation( + $"โœ… Position Confirmed Closed via History\n" + + $"Position {internalPosition.Identifier} found in exchange history\n" + + $"Proceeding with HandleClosedPosition"); + + internalPosition.Status = PositionStatus.Finished; + await HandleClosedPosition(internalPosition); + return; + } + else + { + // Position not in history either - could be API issue or timing problem + // Don't immediately close, just log warning and retry next cycle + await LogDebugAsync( + $"โš ๏ธ Position Synchronization Warning\n" + + $"Position `{internalPosition.Identifier}` ({internalPosition.OriginDirection} {Config.Ticker})\n" + + $"Not found in broker positions OR exchange history\n" + + $"Status: `{internalPosition.Status}`\n" + + $"This could indicate:\n" + + $"โ€ข API returned incomplete data\n" + + $"โ€ข Timing issue with broker API\n" + + $"โ€ข Position direction mismatch\n" + + $"Will retry verification on next cycle before taking action"); + } + } + } + } + + protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition, + Position positionForSignal) + { + if (internalPosition.Status == PositionStatus.New) + { + // Grace period: give the broker time to register open orders before we evaluate + var now = DateTime.UtcNow; + var secondsSinceOpenRequest = (now - positionForSignal.Open.Date).TotalSeconds; + if (secondsSinceOpenRequest < NEW_POSITION_GRACE_SECONDS) + { + var remaining = NEW_POSITION_GRACE_SECONDS - secondsSinceOpenRequest; + await LogInformationAsync( + $"โณ Waiting for broker confirmation\nElapsed: `{secondsSinceOpenRequest:F0}s`\nGrace left: `{remaining:F0}s`"); + return; // skip early checks until grace period elapses + } + + var orders = await ServiceScopeHelpers.WithScopedService>(_scopeFactory, + async exchangeService => { return [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)]; }); + + if (orders.Any()) + { + var ordersCount = orders.Count(); + if (ordersCount >= 3) + { + var currentTime = DateTime.UtcNow; + var timeSinceRequest = currentTime - positionForSignal.Open.Date; + var waitTimeMinutes = 10; + + if (timeSinceRequest.TotalMinutes >= waitTimeMinutes) + { + await LogWarningAsync( + $"โš ๏ธ Orders Cleanup\nTime elapsed: {waitTimeMinutes}min\nCanceling all orders..."); + try + { + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + await exchangeService.CancelOrder(Account, Config.Ticker); + }); + await LogInformationAsync( + $"โœ… Orders for {internalPosition.OriginDirection} {Config.Ticker} successfully canceled"); + } + catch (Exception ex) + { + await LogWarningAsync($"Failed to cancel orders: {ex.Message}"); + } + + await SetPositionStatus(signal.Identifier, PositionStatus.Canceled); + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + + positionForSignal.Status = PositionStatus.Canceled; + positionForSignal.Open.SetStatus(TradeStatus.Cancelled); + positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled); + positionForSignal.TakeProfit1.SetStatus(TradeStatus.Cancelled); + + await UpdatePositionDatabase(positionForSignal); + return; + } + else + { + var remainingMinutes = waitTimeMinutes - timeSinceRequest.TotalMinutes; + await LogInformationAsync( + $"โณ Waiting for Orders\nPosition has `{orders.Count()}` open orders\nElapsed: `{timeSinceRequest.TotalMinutes:F1}min`\nWaiting `{remainingMinutes:F1}min` more before canceling"); + } + } + else if (ordersCount == 2) + { + // TODO: This should never happen, but just in case + // Check if position is already open on broker with 2 orders + await LogInformationAsync( + $"๐Ÿ” Checking Broker Position\nPosition has exactly `{orders.Count()}` open orders\nChecking if position is already open on broker..."); + + Position brokerPosition = null; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + var brokerPositions = await exchangeService.GetBrokerPositions(Account); + brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker); + }); + + if (brokerPosition != null) + { + await LogInformationAsync( + $"โœ… Position Found on Broker\nPosition is already open on broker\nUpdating position status to Filled"); + + // Calculate net PnL after fees for broker position + var brokerNetPnL = brokerPosition.GetPnLBeforeFees(); + UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL); + + // Update Open trade status when position is found on broker with 2 orders + if (internalPosition.Open != null) + { + internalPosition.Open.SetStatus(TradeStatus.Filled); + // Update Open trade ExchangeOrderId if broker position has one + if (brokerPosition.Open?.ExchangeOrderId != null) + { + internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); + } + } + + // Also update the position in the bot's positions dictionary + if (positionForSignal.Open != null) + { + positionForSignal.Open.SetStatus(TradeStatus.Filled); + // Update Open trade ExchangeOrderId if broker position has one + if (brokerPosition.Open?.ExchangeOrderId != null) + { + positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); + } + } + + await SetPositionStatus(signal.Identifier, PositionStatus.Filled); + } + else + { + await LogInformationAsync( + $"โธ๏ธ Position Pending\nPosition still waiting to open\n`{orders.Count()}` open orders remaining"); + } + } + else + { + await LogInformationAsync( + $"โธ๏ธ Position Pending\nPosition still waiting to open\n`{orders.Count()}` open orders remaining"); + } + } + else + { + await LogWarningAsync( + $"โŒ Position Never Filled\nNo position on exchange and no orders\nChecking position history before marking as canceled."); + + // Position might be canceled by the broker + // Check if position exists in exchange history with PnL before canceling + var (positionFoundInHistory, hadWeb3ProxyError) = + await CheckPositionInExchangeHistory(positionForSignal); + + if (hadWeb3ProxyError) + { + // Web3Proxy error occurred - don't mark as cancelled, wait for next cycle + await LogWarningAsync( + $"โณ Web3Proxy Error - Skipping Position Cancellation\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Status remains: `{positionForSignal.Status}`\n" + + $"Will retry position verification on next execution cycle"); + // Don't change signal status to Expired, let it continue + return; + } + else if (positionFoundInHistory) + { + // Position was actually filled and closed, process it properly + await HandleClosedPosition(positionForSignal); + await LogInformationAsync( + $"โœ… Position Found in Exchange History\n" + + $"Position was actually filled and closed\n" + + $"Processing with HandleClosedPosition"); + } + else + { + // Position was never filled, just mark as canceled without processing PnL + positionForSignal.Status = PositionStatus.Canceled; + await SetPositionStatus(signal.Identifier, PositionStatus.Canceled); + await UpdatePositionDatabase(positionForSignal); + await LogWarningAsync( + $"โŒ Position Confirmed Never Filled\nNo position in exchange history\nMarking as canceled without PnL processing"); + } + + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + } + } + } + + protected override async Task MonitorSynthRisk(LightSignal signal, Position position) + { + decimal currentPrice = 0; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => { currentPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); + var riskResult = default(SynthRiskResult); + await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => + { + riskResult = await tradingService.MonitorSynthPositionRiskAsync( + Config.Ticker, + position.OriginDirection, + currentPrice, + position.StopLoss.Price, + position.Identifier, + Config); + }); + + if (riskResult.ShouldWarn && !string.IsNullOrEmpty(riskResult.WarningMessage)) + { + await LogWarningAsync(riskResult.WarningMessage); + } + + if (riskResult.ShouldAutoClose && !string.IsNullOrEmpty(riskResult.EmergencyMessage)) + { + await LogWarningAsync(riskResult.EmergencyMessage); + await CloseTrade(Signals[position.SignalIdentifier], position, + position.StopLoss, + currentPrice, true); + } + } + + /// + /// Checks if a position exists in the exchange history with PnL data (futures/live only). + /// This helps determine if a position was actually filled and closed on the exchange + /// even if the bot's internal tracking shows it as never filled. + /// + /// The position to check + /// True if position found in exchange history with PnL, false otherwise; hadError indicates Web3/infra issues + protected async Task<(bool found, bool hadError)> CheckPositionInExchangeHistory(Position position) + { + try + { + await LogDebugAsync( + $"๐Ÿ” Checking Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); + + List positionHistory = null; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + // Get position history from the last 24 hours for comprehensive check + var fromDate = DateTime.UtcNow.AddHours(-24); + var toDate = DateTime.UtcNow; + positionHistory = + await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate); + }); + + // Check if there's a recent position with PnL data and matching direction + if (positionHistory != null && positionHistory.Any()) + { + var recentPosition = positionHistory + .Where(p => p.OriginDirection == position.OriginDirection) // Ensure same direction + .OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue) + .FirstOrDefault(); + + if (recentPosition != null && recentPosition.ProfitAndLoss != null) + { + await LogDebugAsync( + $"โœ… Position Found in Exchange History\n" + + $"Position: `{position.Identifier}`\n" + + $"Direction: `{position.OriginDirection}` (Matched: โœ…)\n" + + $"Exchange PnL: `${recentPosition.ProfitAndLoss.Realized:F2}`\n" + + $"Position was actually filled and closed"); + return (true, false); + } + else + { + // Found positions in history but none match the direction + var allHistoryDirections = positionHistory.Select(p => p.OriginDirection).Distinct().ToList(); + await LogDebugAsync( + $"โš ๏ธ Direction Mismatch in History\n" + + $"Looking for: `{position.OriginDirection}`\n" + + $"Found in history: `{string.Join(", ", allHistoryDirections)}`\n" + + $"No matching position found"); + } + } + + await LogDebugAsync( + $"โŒ No Position Found in Exchange History\nPosition: `{position.Identifier}`\nPosition was never filled"); + return (false, false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error checking position history for position {PositionId}", position.Identifier); + await LogWarningAsync( + $"โš ๏ธ Web3Proxy Error During Position History Check\n" + + $"Position: `{position.Identifier}`\n" + + $"Error: {ex.Message}\n" + + $"Will retry on next execution cycle"); + return (false, true); // found=false, hadError=true + } + } + + protected override async Task RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal) + { + try + { + await LogDebugAsync( + $"๐Ÿ”„ Attempting Position Recovery\n" + + $"Signal: `{signal.Identifier}`\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Direction: `{positionForSignal.OriginDirection}`\n" + + $"Ticker: `{Config.Ticker}`\n" + + $"Checking broker for open position..."); + + Position brokerPosition = null; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + var brokerPositions = await exchangeService.GetBrokerPositions(Account); + brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker); + }); + + if (brokerPosition != null) + { + // Check if the broker position matches our expected direction + if (brokerPosition.OriginDirection == positionForSignal.OriginDirection) + { + await LogInformationAsync( + $"โœ… Position Recovered from Broker\n" + + $"Position: `{positionForSignal.Identifier}`\n" + + $"Direction: `{positionForSignal.OriginDirection}` (Matched: โœ…)\n" + + $"Broker Position Size: `{brokerPosition.Open?.Quantity ?? 0}`\n" + + $"Broker Position Price: `${brokerPosition.Open?.Price ?? 0:F2}`\n" + + $"Restoring position status to Filled"); + + // Update position status back to Filled (from Canceled) + positionForSignal.Status = PositionStatus.Filled; + await SetPositionStatus(signal.Identifier, PositionStatus.Filled); + + // Update signal status back to PositionOpen since position is recovered + SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); + + // Update PnL from broker position + var brokerNetPnL = brokerPosition.GetPnLBeforeFees(); + UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL); + + // Update trade details if available + if (positionForSignal.Open != null && brokerPosition.Open != null) + { + positionForSignal.Open.SetStatus(TradeStatus.Filled); + if (brokerPosition.Open.ExchangeOrderId != null) + { + positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); + } + } + + // Update stop loss and take profit trades if available + if (positionForSignal.StopLoss != null && brokerPosition.StopLoss != null) + { + positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); + } + + if (positionForSignal.TakeProfit1 != null && brokerPosition.TakeProfit1 != null) + { + positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); + } + + if (positionForSignal.TakeProfit2 != null && brokerPosition.TakeProfit2 != null) + { + positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); + } + + // Update database + await UpdatePositionDatabase(positionForSignal); + + // Notify about position recovery + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionUpdated, positionForSignal); + + await LogInformationAsync( + $"๐ŸŽ‰ Position Recovery Complete\n" + + $"Position `{positionForSignal.Identifier}` successfully recovered\n" + + $"Status restored to Filled\n" + + $"Database and internal state updated"); + + return true; + } + else + { + await LogWarningAsync( + $"โš ๏ธ Direction Mismatch During Recovery\n" + + $"Expected: `{positionForSignal.OriginDirection}`\n" + + $"Broker Position: `{brokerPosition.OriginDirection}`\n" + + $"Cannot recover - directions don't match"); + } + } + else + { + await LogDebugAsync( + $"โŒ No Open Position Found on Broker\n" + + $"Ticker: `{Config.Ticker}`\n" + + $"Position recovery not possible"); + } + + return false; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error during position recovery for position {PositionId}", + positionForSignal.Identifier); + await LogWarningAsync($"Position recovery failed due to exception: {ex.Message}"); + return false; + } + } + + protected override async Task UpdateSignalsCore(IReadOnlyList candles, + Dictionary preCalculatedIndicatorValues = null) + { + // Call base implementation for common logic (flip check, cooldown check) + await base.UpdateSignalsCore(candles, preCalculatedIndicatorValues); + + // Live trading: use ScenarioRunnerGrain to get signals + await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => + { + var scenarioRunnerGrain = grainFactory.GetGrain(Guid.NewGuid()); + var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle); + if (signal == null) return; + await AddSignal(signal); + }); + } + + protected override async Task ReconcileWithBrokerHistory(Position position, Candle currentCandle) + { + // Futures-specific: reconcile with GMX position history + try + { + await LogDebugAsync( + $"๐Ÿ” Fetching Position History from GMX\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); + + var positionHistory = await ServiceScopeHelpers.WithScopedService>( + _scopeFactory, + async exchangeService => + { + // Get position history from the last 24 hours for better coverage + var fromDate = DateTime.UtcNow.AddHours(-24); + var toDate = DateTime.UtcNow; + return await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate); + }); + + // Find the matching position in history based on the most recent closed position with same direction + if (positionHistory != null && positionHistory.Any()) + { + // Get the most recent closed position from GMX that matches the direction + var brokerPosition = positionHistory + .Where(p => p.OriginDirection == position.OriginDirection) // Ensure same direction + .OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue) + .FirstOrDefault(); + + if (brokerPosition != null && brokerPosition.ProfitAndLoss != null) + { + await LogDebugAsync( + $"โœ… Broker Position History Found\n" + + $"Position: `{position.Identifier}`\n" + + $"Realized PnL (after fees): `${brokerPosition.ProfitAndLoss.Realized:F2}`\n" + + $"Bot's UI Fees: `${position.UiFees:F2}`\n" + + $"Bot's Gas Fees: `${position.GasFees:F2}`"); + + // Use the actual GMX PnL data (this is already net of fees from GMX) + // We use this for reconciliation with the bot's own calculations + var closingVolume = brokerPosition.Open.Price * position.Open.Quantity * + position.Open.Leverage; + var totalBotFees = position.GasFees + position.UiFees + + TradingBox.CalculateClosingUiFees(closingVolume); + var gmxNetPnl = brokerPosition.ProfitAndLoss.Realized; // This is already after GMX fees + + position.ProfitAndLoss = new ProfitAndLoss + { + // GMX's realized PnL is already after their fees + Realized = gmxNetPnl, + // For net, we keep it the same since GMX PnL is already net of their fees + Net = gmxNetPnl - totalBotFees + }; + + // Update the closing trade price if available + if (brokerPosition.Open != null) + { + var brokerClosingPrice = brokerPosition.Open.Price; + var isProfitable = position.OriginDirection == TradeDirection.Long + ? position.Open.Price < brokerClosingPrice + : position.Open.Price > brokerClosingPrice; + + if (isProfitable) + { + if (position.TakeProfit1 != null) + { + position.TakeProfit1.Price = brokerClosingPrice; + position.TakeProfit1.SetDate(brokerPosition.Open.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + } + + // Cancel SL trade when TP is hit + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + else + { + if (position.StopLoss != null) + { + position.StopLoss.Price = brokerClosingPrice; + position.StopLoss.SetDate(brokerPosition.Open.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + } + + // Cancel TP trades when SL is hit + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + + await LogDebugAsync( + $"๐Ÿ“Š Position Reconciliation Complete\n" + + $"Position: `{position.Identifier}`\n" + + $"Closing Price: `${brokerClosingPrice:F2}`\n" + + $"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" + + $"PnL from broker: `${position.ProfitAndLoss.Realized:F2}`"); + } + + return true; // Successfully reconciled, skip candle-based calculation + } + } + else + { + Logger.LogWarning( + $"โš ๏ธ No GMX Position History Found\nPosition: `{position.Identifier}`\nFalling back to candle-based calculation"); + } + } + catch (Exception ex) + { + Logger.LogError(ex, + "Error fetching position history from GMX for position {PositionId}. Falling back to candle-based calculation.", + position.Identifier); + } + + return false; // Continue with candle-based calculation + } + + protected override async Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles( + Position position, Candle currentCandle, bool forceMarketClose, decimal? forcedClosingPrice) + { + decimal closingPrice = 0; + bool pnlCalculated = false; + + // If we are forcing a market close (e.g., time limit), use the provided closing price + if (forceMarketClose && forcedClosingPrice.HasValue) + { + closingPrice = forcedClosingPrice.Value; + + bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long + ? closingPrice > position.Open.Price + : closingPrice < position.Open.Price; + + if (isManualCloseProfitable) + { + if (position.TakeProfit1 != null) + { + position.TakeProfit1.Price = closingPrice; + position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + } + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + else + { + if (position.StopLoss != null) + { + position.StopLoss.Price = closingPrice; + position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow); + position.StopLoss.SetStatus(TradeStatus.Filled); + } + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + + pnlCalculated = true; + } + else if (currentCandle != null) + { + // Use CandleStoreGrain to get recent candles for live trading + List recentCandles = null; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => + { + var grainKey = CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe); + var grain = grainFactory.GetGrain(grainKey); + + try + { + recentCandles = await grain.GetLastCandle(5); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error retrieving recent candles from CandleStoreGrain for {GrainKey}", + grainKey); + recentCandles = new List(); + } + }); + + // Check if we have any candles before proceeding + if (recentCandles == null || !recentCandles.Any()) + { + await LogWarningAsync( + $"No recent candles available for position {position.Identifier}. Using current candle data instead."); + + // Fallback to current candle if available + if (currentCandle != null) + { + recentCandles = new List { currentCandle }; + } + else + { + await LogWarningAsync( + $"No candle data available for position {position.Identifier}. Cannot determine stop loss/take profit hit."); + Logger.LogError( + "No candle data available for position {PositionId}. Cannot determine stop loss/take profit hit.", + position.Identifier); + return (0, false); + } + } + + var minPriceRecent = recentCandles.Min(c => c.Low); + var maxPriceRecent = recentCandles.Max(c => c.High); + + bool wasStopLossHit = false; + bool wasTakeProfitHit = false; + + if (position.OriginDirection == TradeDirection.Long) + { + wasStopLossHit = minPriceRecent <= position.StopLoss.Price; + wasTakeProfitHit = maxPriceRecent >= position.TakeProfit1.Price; + } + else + { + wasStopLossHit = maxPriceRecent >= position.StopLoss.Price; + wasTakeProfitHit = minPriceRecent <= position.TakeProfit1.Price; + } + + if (wasStopLossHit) + { + // For live trading: use actual execution price to reflect real market conditions (slippage) + closingPrice = position.OriginDirection == TradeDirection.Long + ? minPriceRecent // For LONG, SL hits at the low + : maxPriceRecent; // For SHORT, SL hits at the high + + position.StopLoss.Price = closingPrice; + position.StopLoss.SetDate(currentCandle.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + + // Cancel TP trades when SL is hit + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + + await LogDebugAsync( + $"๐Ÿ›‘ Stop Loss Execution Confirmed\n" + + $"Position: `{position.Identifier}`\n" + + $"Closing Price: `${closingPrice:F2}`\n" + + $"Configured SL: `${position.StopLoss.Price:F2}`\n" + + $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); + } + else if (wasTakeProfitHit) + { + // For live trading: use actual execution price to reflect real market conditions (slippage) + closingPrice = position.OriginDirection == TradeDirection.Long + ? maxPriceRecent // For LONG, TP hits at the high + : minPriceRecent; // FOR SHORT, TP hits at the low + + position.TakeProfit1.Price = closingPrice; + position.TakeProfit1.SetDate(currentCandle.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + + // Cancel SL trade when TP is hit + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + + await LogDebugAsync( + $"๐ŸŽฏ Take Profit Execution Confirmed\n" + + $"Position: `{position.Identifier}`\n" + + $"Closing Price: `${closingPrice:F2}`\n" + + $"Configured TP: `${position.TakeProfit1.Price:F2}`\n" + + $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); + } + else + { + // Manual/exchange close - get current market price + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + closingPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker); + }); + + bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long + ? closingPrice > position.Open.Price + : closingPrice < position.Open.Price; + + if (isManualCloseProfitable) + { + position.TakeProfit1.SetPrice(closingPrice, 2); + position.TakeProfit1.SetDate(currentCandle.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + + // Cancel SL trade when TP is used for manual close + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + else + { + position.StopLoss.SetPrice(closingPrice, 2); + position.StopLoss.SetDate(currentCandle.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + + // Cancel TP trades when SL is used for manual close + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + + await LogDebugAsync( + $"โœ‹ Manual/Exchange Close Detected\n" + + $"Position: `{position.Identifier}`\n" + + $"SL: `${position.StopLoss.Price:F2}` | TP: `${position.TakeProfit1.Price:F2}`\n" + + $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`\n" + + $"Closing at market price: `${closingPrice:F2}`"); + } + + pnlCalculated = true; + } + + return (closingPrice, pnlCalculated); + } + + private async Task RecoverRecentlyCanceledPositions() + { + // Futures-only: attempt to recover last canceled position from broker + try + { + // Get the last (most recent) position from all positions + var lastPosition = Positions.Values.LastOrDefault(); + if (lastPosition == null) + { + return; // No positions at all + } + + // Only attempt recovery if the last position is cancelled and recovery hasn't been attempted yet + if (lastPosition.Status != PositionStatus.Canceled || lastPosition.RecoveryAttempted) + { + return; + } + + // Also get count of cancelled positions for logging + var canceledPositionsCount = Positions.Values.Count(p => p.Status == PositionStatus.Canceled); + + await LogDebugAsync( + $"๐Ÿ”„ Position Recovery Check\nFound `{canceledPositionsCount}` canceled positions\nLast position `{lastPosition.Identifier}` is cancelled\nAttempting recovery from broker..."); + + // Get the signal for the last position + if (!Signals.TryGetValue(lastPosition.SignalIdentifier, out var signal)) + { + await LogWarningAsync( + $"โš ๏ธ Signal Not Found for Recovery\nPosition: `{lastPosition.Identifier}`\nSignal: `{lastPosition.SignalIdentifier}`\nCannot recover without signal"); + return; + } + + // Mark recovery as attempted before proceeding + lastPosition.RecoveryAttempted = true; + Positions[lastPosition.Identifier] = lastPosition; + + // Attempt recovery for the last position only + bool recovered = await RecoverOpenPositionFromBroker(signal, lastPosition); + if (recovered) + { + await LogInformationAsync( + $"๐ŸŽ‰ Position Recovery Successful\nPosition `{lastPosition.Identifier}` recovered from broker\nStatus restored to Filled\nWill continue normal processing"); + } + else + { + await LogDebugAsync( + $"โŒ Recovery Not Needed\nPosition `{lastPosition.Identifier}` confirmed canceled\nNo open position found on broker"); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error during recently canceled positions recovery"); + await LogWarningAsync($"Position recovery check failed due to exception: {ex.Message}"); + } + } + + protected override async Task GetLastPriceForPositionOpeningAsync() + { + // For live trading, get current price from exchange + return await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => { return await exchangeService.GetCurrentPrice(Account, Config.Ticker); }); + } + + protected override async Task HandleFlipPosition(LightSignal signal, Position openedPosition, + LightSignal previousSignal, decimal lastPrice) + { + // Futures-specific flip logic (same as base but explicit for clarity) + 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 ExecuteOpenPosition(LightSignal signal, decimal lastPrice) + { + // Futures-specific position opening: includes balance verification and live exchange calls + if (Account == null || Account.User == null) + { + throw new InvalidOperationException("Account and Account.User must be set before opening a position"); + } + + // Verify actual balance before opening position + await VerifyAndUpdateBalanceAsync(); + + var command = new OpenPositionRequest( + Config.AccountName, + Config.MoneyManagement, + signal.Direction, + Config.Ticker, + PositionInitiator.Bot, + signal.Date, + Account.User, + Config.BotTradingBalance, + isForPaperTrading: false, // Futures is live trading + lastPrice, + signalIdentifier: signal.Identifier, + initiatorIdentifier: Identifier, + tradingType: Config.TradingType); + + var position = await ServiceScopeHelpers + .WithScopedServices( + _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}`"); + + // Live trading: check quantity from exchange + decimal quantity = 0; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async exchangeService => + { + // TODO should also pass the direction to get quantity in correct position + quantity = await exchangeService.GetQuantityInPosition(Account, Config.Ticker); + }); + + // Get status of position before closing it. The position might be already close by the exchange + if (quantity == 0) + { + await LogDebugAsync($"โœ… Trade already closed on exchange for position: `{position.Identifier}`"); + await HandleClosedPosition(position, forceMarketClose ? lastPrice : (decimal?)null, forceMarketClose); + } + else + { + var command = new CloseFuturesPositionCommand(position, position.AccountId, lastPrice); + try + { + // Grace period: give the broker time to process any ongoing close operations + await Task.Delay(CLOSE_POSITION_GRACE_MS); + + Position closedPosition = null; + await ServiceScopeHelpers.WithScopedServices( + _scopeFactory, async (exchangeService, accountService, tradingService) => + { + closedPosition = + await new CloseFuturesPositionCommandHandler(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); + } + } + } + } } \ No newline at end of file diff --git a/src/Managing.Application/Bots/Grains/BacktestTradingBotGrain.cs b/src/Managing.Application/Bots/Grains/BacktestTradingBotGrain.cs index 1de74ca4..e5df92bb 100644 --- a/src/Managing.Application/Bots/Grains/BacktestTradingBotGrain.cs +++ b/src/Managing.Application/Bots/Grains/BacktestTradingBotGrain.cs @@ -53,7 +53,7 @@ public class BacktestTradingBotGrain : Grain, IBacktestTradingBotGrain /// The complete backtest result public async Task RunBacktestAsync( TradingBotConfig config, - HashSet candles, + IReadOnlyList 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(); + const int RollingWindowSize = 600; // TradingBox.GetSignal only needs last 600 candles + // Use List directly to preserve chronological order and enable incremental updates + var rollingWindowCandles = new List(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 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>(); - 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) /// private Dictionary GetIndicatorsValues(List indicators, - HashSet candles) + IReadOnlyList candles) { var indicatorsValues = new Dictionary(); diff --git a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs index 013ac583..6a1915ab 100644 --- a/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs +++ b/src/Managing.Application/Bots/Grains/LiveTradingBotGrain.cs @@ -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>(); 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); diff --git a/src/Managing.Application/Bots/TradingBotBase.cs b/src/Managing.Application/Bots/TradingBotBase.cs index b90dfb02..f931697d 100644 --- a/src/Managing.Application/Bots/TradingBotBase.cs +++ b/src/Managing.Application/Bots/TradingBotBase.cs @@ -15,8 +15,8 @@ using Managing.Domain.Scenarios; using Managing.Domain.Shared.Helpers; using Managing.Domain.Strategies; using Managing.Domain.Strategies.Base; -using Managing.Domain.Synth.Models; using Managing.Domain.Trades; +using Managing.Domain.Users; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -25,14 +25,14 @@ using static Managing.Common.Enums; namespace Managing.Application.Bots; -public class TradingBotBase : ITradingBot +public abstract class TradingBotBase : ITradingBot { public readonly ILogger Logger; - private readonly IServiceScopeFactory _scopeFactory; - private readonly IStreamProvider? _streamProvider; - private const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders + protected readonly IServiceScopeFactory _scopeFactory; + protected readonly IStreamProvider? _streamProvider; + protected const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders - private const int + protected const int CLOSE_POSITION_GRACE_MS = 20000; // grace window before closing position to allow broker processing (20 seconds) public TradingBotConfig Config { get; set; } @@ -66,19 +66,19 @@ public class TradingBotBase : ITradingBot PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe); } - public async Task Start(BotStatus previousStatus) + public virtual async Task Start(BotStatus previousStatus) { - if (!Config.IsForBacktest) + if (Config.TradingType == TradingType.Futures) { // Start async initialization in the background without blocking try { - await LoadAccount(); + await LoadAccountAsync(); await LoadLastCandle(); if (Account == null) { - await LogWarning($"Account {Config.AccountName} not found. Bot cannot start."); + await LogWarningAsync($"Account {Config.AccountName} not found. Bot cannot start."); throw new ArgumentException("Account not found"); } @@ -102,7 +102,7 @@ public class TradingBotBase : ITradingBot $"โœ… Ready to monitor signals and execute trades\n" + $"๐Ÿ“ข Notifications will be sent when positions are triggered"; - await LogInformation(startupMessage); + await LogInformationAsync(startupMessage); break; case BotStatus.Running: @@ -110,7 +110,7 @@ public class TradingBotBase : ITradingBot case BotStatus.Stopped: // If status was Stopped we log a message to inform the user that the bot is restarting - await LogInformation($"๐Ÿ”„ Bot Restarted\n" + + await LogInformationAsync($"๐Ÿ”„ Bot Restarted\n" + $"๐Ÿ“Š Resuming operations with {Signals.Count} signals and {Positions.Count} positions\n" + $"โœ… Ready to continue trading"); break; @@ -168,7 +168,7 @@ public class TradingBotBase : ITradingBot public async Task LoadAccount() { - if (Config.IsForBacktest) return; + if (Config.TradingType == TradingType.BacktestFutures) return; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async accountService => { var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false); @@ -182,7 +182,7 @@ public class TradingBotBase : ITradingBot /// public async Task VerifyAndUpdateBalance() { - if (Config.IsForBacktest) return; + if (Config.TradingType == TradingType.BacktestFutures) return; if (Account == null) { Logger.LogWarning("Cannot verify balance: Account is null"); @@ -227,24 +227,15 @@ public class TradingBotBase : ITradingBot } } - public async Task Run() + public virtual async Task Run() { - // Update signals for live trading only - if (!Config.IsForBacktest) - { - if (!Config.IsForCopyTrading) - { - await UpdateSignals(); - } - - await LoadLastCandle(); - } + // Signal updates are handled by subclasses via UpdateSignals() override if (!Config.IsForWatchingOnly) await ManagePositions(); UpdateWalletBalances(); - if (!Config.IsForBacktest) + if (Config.TradingType == TradingType.Futures) { ExecutionCount++; @@ -259,12 +250,18 @@ public class TradingBotBase : ITradingBot } } - public async Task UpdateSignals(HashSet candles = null) + public async Task UpdateSignals(IReadOnlyList candles = null) { await UpdateSignals(candles, null); } - public async Task UpdateSignals(HashSet candles, + public async Task UpdateSignals(IReadOnlyList candles, + Dictionary preCalculatedIndicatorValues = null) + { + await UpdateSignalsCore(candles, preCalculatedIndicatorValues); + } + + protected virtual async Task UpdateSignalsCore(IReadOnlyList candles, Dictionary preCalculatedIndicatorValues = null) { // Skip indicator checking if flipping is disabled and there's an open position @@ -283,23 +280,7 @@ public class TradingBotBase : ITradingBot return; } - if (Config.IsForBacktest) - { - var backtestSignal = TradingBox.GetSignal(candles, Config.Scenario, Signals, Config.Scenario.LoopbackPeriod, - preCalculatedIndicatorValues); - if (backtestSignal == null) return; - await AddSignal(backtestSignal); - } - else - { - await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => - { - var scenarioRunnerGrain = grainFactory.GetGrain(Guid.NewGuid()); - var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle); - if (signal == null) return; - await AddSignal(signal); - }); - } + // Default implementation: do nothing (subclasses should override with signal generation logic) } private async Task RecreateSignalFromPosition(Position position) @@ -355,11 +336,8 @@ public class TradingBotBase : ITradingBot } } - private async Task ManagePositions() + protected async Task ManagePositions() { - // Recovery Logic: Check for recently canceled positions that might need recovery - await RecoverRecentlyCanceledPositions(); - // Early exit optimization - skip if no positions to manage // Optimized: Use for loop to avoid multiple iterations bool hasOpenPositions = false; @@ -456,9 +434,9 @@ public class TradingBotBase : ITradingBot } } - private void UpdateWalletBalances() + protected void UpdateWalletBalances() { - var date = Config.IsForBacktest ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow; + var date = Config.TradingType == TradingType.BacktestFutures ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow; if (WalletBalances.Count == 0) { @@ -472,7 +450,7 @@ public class TradingBotBase : ITradingBot } } - private async Task UpdatePosition(LightSignal signal, Position positionForSignal) + protected async Task UpdatePosition(LightSignal signal, Position positionForSignal) { try { @@ -480,325 +458,22 @@ public class TradingBotBase : ITradingBot if (positionForSignal.Status == PositionStatus.Canceled || positionForSignal.Status == PositionStatus.Rejected) { - await LogDebug( + await LogDebugAsync( $"Skipping update for position {positionForSignal.Identifier} - status is {positionForSignal.Status} (never filled)"); return; } - Position internalPosition = null; - var brokerPositions = await ServiceScopeHelpers.WithScopedService>( - _scopeFactory, async tradingService => - { - internalPosition = Config.IsForBacktest - ? positionForSignal - : await tradingService.GetPositionByIdentifierAsync(positionForSignal.Identifier); + Position internalPosition = await GetInternalPositionForUpdate(positionForSignal); + var brokerPositions = await GetBrokerPositionsForUpdate(Account); - if (Config.IsForBacktest) - { - return new List { internalPosition }; - } - else - { - return await ServiceScopeHelpers.WithScopedService>( - _scopeFactory, - async exchangeService => - { - return [.. await exchangeService.GetBrokerPositions(Account)]; - }); - } - }); + // Handle broker position synchronization (futures-specific logic) + await SynchronizeWithBrokerPositions(internalPosition, positionForSignal, brokerPositions); - if (!Config.IsForBacktest) - { - // Improved broker position matching with more robust logic - var brokerPosition = brokerPositions - .Where(p => p.Ticker == Config.Ticker) - .OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue) - .FirstOrDefault(p => p.OriginDirection == positionForSignal.OriginDirection); + // Handle order management and position status (futures-specific logic) + await HandleOrderManagementAndPositionStatus(signal, internalPosition, positionForSignal); - if (brokerPosition != null) - { - var previousPositionStatus = internalPosition.Status; - // Position found on the broker, means the position is filled - var brokerPnlBeforeFees = brokerPosition.GetPnLBeforeFees(); - UpdatePositionPnl(positionForSignal.Identifier, brokerPnlBeforeFees); - var totalFees = internalPosition.GasFees + internalPosition.UiFees; - var netPnl = brokerPnlBeforeFees - totalFees; - internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = brokerPnlBeforeFees, Net = netPnl }; - internalPosition.Status = PositionStatus.Filled; - await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled); - - internalPosition.Open.SetStatus(TradeStatus.Filled); - positionForSignal.Open.SetStatus(TradeStatus.Filled); - - internalPosition.Open.Price = brokerPosition.Open.Price; - positionForSignal.Open.Price = brokerPosition.Open.Price; - - // Update Open trade ExchangeOrderId if broker position has one - if (brokerPosition.Open?.ExchangeOrderId != null && internalPosition.Open != null) - { - internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); - positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); - } - - // Update Stop Loss and Take Profit trades with correct ExchangeOrderId from broker - if (brokerPosition.StopLoss != null && internalPosition.StopLoss != null) - { - internalPosition.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); - positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); - } - - if (brokerPosition.TakeProfit1 != null && internalPosition.TakeProfit1 != null) - { - internalPosition.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); - positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); - } - - if (brokerPosition.TakeProfit2 != null && internalPosition.TakeProfit2 != null) - { - internalPosition.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); - positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); - } - - await UpdatePositionDatabase(internalPosition); - - if (previousPositionStatus != PositionStatus.Filled && - internalPosition.Status == PositionStatus.Filled) - { - await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionOpened, internalPosition); - } - else - { - await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionUpdated, internalPosition); - } - } - else - { - // Position not found in broker's active positions list - // Need to verify if it was actually closed or just not returned by the API - if (internalPosition.Status.Equals(PositionStatus.Filled)) - { - Logger.LogWarning( - $"โš ๏ธ Position Sync Issue Detected\n" + - $"Internal position {internalPosition.Identifier} shows Filled\n" + - $"But not found in broker positions list (Count: {brokerPositions.Count})\n" + - $"Checking position history before marking as closed..."); - - // Verify in exchange history before assuming it's closed - var (existsInHistory, hadWeb3ProxyError) = - await CheckPositionInExchangeHistory(positionForSignal); - - if (hadWeb3ProxyError) - { - // Web3Proxy error - don't assume position is closed, wait for next cycle - await LogWarning( - $"โณ Web3Proxy Error During Position Verification\n" + - $"Position: `{positionForSignal.Identifier}`\n" + - $"Cannot verify if position is closed\n" + - $"Will retry on next execution cycle"); - // Don't change position status, wait for next cycle - return; - } - else if (existsInHistory) - { - // Position was actually filled and closed by the exchange - Logger.LogInformation( - $"โœ… Position Confirmed Closed via History\n" + - $"Position {internalPosition.Identifier} found in exchange history\n" + - $"Proceeding with HandleClosedPosition"); - - internalPosition.Status = PositionStatus.Finished; - await HandleClosedPosition(internalPosition); - return; - } - else - { - // Position not in history either - could be API issue or timing problem - // Don't immediately close, just log warning and retry next cycle - await LogDebug( - $"โš ๏ธ Position Synchronization Warning\n" + - $"Position `{internalPosition.Identifier}` ({internalPosition.OriginDirection} {Config.Ticker})\n" + - $"Not found in broker positions OR exchange history\n" + - $"Status: `{internalPosition.Status}`\n" + - $"This could indicate:\n" + - $"โ€ข API returned incomplete data\n" + - $"โ€ข Timing issue with broker API\n" + - $"โ€ข Position direction mismatch\n" + - $"Will retry verification on next cycle before taking action"); - } - } - } - } - - if (internalPosition.Status == PositionStatus.New) - { - // Grace period: give the broker time to register open orders before we evaluate - var now = Config.IsForBacktest ? (LastCandle?.Date ?? DateTime.UtcNow) : DateTime.UtcNow; - var secondsSinceOpenRequest = (now - positionForSignal.Open.Date).TotalSeconds; - if (secondsSinceOpenRequest < NEW_POSITION_GRACE_SECONDS) - { - var remaining = NEW_POSITION_GRACE_SECONDS - secondsSinceOpenRequest; - await LogInformation( - $"โณ Waiting for broker confirmation\nElapsed: `{secondsSinceOpenRequest:F0}s`\nGrace left: `{remaining:F0}s`"); - return; // skip early checks until grace period elapses - } - - var orders = await ServiceScopeHelpers.WithScopedService>(_scopeFactory, - async exchangeService => - { - return [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)]; - }); - - if (orders.Any()) - { - var ordersCount = orders.Count(); - if (ordersCount >= 3) - { - var currentTime = Config.IsForBacktest ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow; - var timeSinceRequest = currentTime - positionForSignal.Open.Date; - var waitTimeMinutes = 10; - - if (timeSinceRequest.TotalMinutes >= waitTimeMinutes) - { - await LogWarning( - $"โš ๏ธ Orders Cleanup\nTime elapsed: {waitTimeMinutes}min\nCanceling all orders..."); - try - { - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - await exchangeService.CancelOrder(Account, Config.Ticker); - }); - await LogInformation( - $"โœ… Orders for {internalPosition.OriginDirection} {Config.Ticker} successfully canceled"); - } - catch (Exception ex) - { - await LogWarning($"Failed to cancel orders: {ex.Message}"); - } - - await SetPositionStatus(signal.Identifier, PositionStatus.Canceled); - SetSignalStatus(signal.Identifier, SignalStatus.Expired); - - positionForSignal.Status = PositionStatus.Canceled; - positionForSignal.Open.SetStatus(TradeStatus.Cancelled); - positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled); - positionForSignal.TakeProfit1.SetStatus(TradeStatus.Cancelled); - - await UpdatePositionDatabase(positionForSignal); - return; - } - else - { - var remainingMinutes = waitTimeMinutes - timeSinceRequest.TotalMinutes; - await LogInformation( - $"โณ Waiting for Orders\nPosition has `{orders.Count()}` open orders\nElapsed: `{timeSinceRequest.TotalMinutes:F1}min`\nWaiting `{remainingMinutes:F1}min` more before canceling"); - } - } - else if (ordersCount == 2) - { - // TODO: This should never happen, but just in case - // Check if position is already open on broker with 2 orders - await LogInformation( - $"๐Ÿ” Checking Broker Position\nPosition has exactly `{orders.Count()}` open orders\nChecking if position is already open on broker..."); - - Position brokerPosition = null; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - var brokerPositions = await exchangeService.GetBrokerPositions(Account); - brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker); - }); - - if (brokerPosition != null) - { - await LogInformation( - $"โœ… Position Found on Broker\nPosition is already open on broker\nUpdating position status to Filled"); - - // Calculate net PnL after fees for broker position - var brokerNetPnL = brokerPosition.GetPnLBeforeFees(); - UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL); - - // Update Open trade status when position is found on broker with 2 orders - if (internalPosition.Open != null) - { - internalPosition.Open.SetStatus(TradeStatus.Filled); - // Update Open trade ExchangeOrderId if broker position has one - if (brokerPosition.Open?.ExchangeOrderId != null) - { - internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); - } - } - - // Also update the position in the bot's positions dictionary - if (positionForSignal.Open != null) - { - positionForSignal.Open.SetStatus(TradeStatus.Filled); - // Update Open trade ExchangeOrderId if broker position has one - if (brokerPosition.Open?.ExchangeOrderId != null) - { - positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); - } - } - - await SetPositionStatus(signal.Identifier, PositionStatus.Filled); - } - else - { - await LogInformation( - $"โธ๏ธ Position Pending\nPosition still waiting to open\n`{orders.Count()}` open orders remaining"); - } - } - else - { - await LogInformation( - $"โธ๏ธ Position Pending\nPosition still waiting to open\n`{orders.Count()}` open orders remaining"); - } - } - else - { - await LogWarning( - $"โŒ Position Never Filled\nNo position on exchange and no orders\nChecking position history before marking as canceled."); - - // Position might be canceled by the broker - // Check if position exists in exchange history with PnL before canceling - var (positionFoundInHistory, hadWeb3ProxyError) = - await CheckPositionInExchangeHistory(positionForSignal); - - if (hadWeb3ProxyError) - { - // Web3Proxy error occurred - don't mark as cancelled, wait for next cycle - await LogWarning( - $"โณ Web3Proxy Error - Skipping Position Cancellation\n" + - $"Position: `{positionForSignal.Identifier}`\n" + - $"Status remains: `{positionForSignal.Status}`\n" + - $"Will retry position verification on next execution cycle"); - // Don't change signal status to Expired, let it continue - return; - } - else if (positionFoundInHistory) - { - // Position was actually filled and closed, process it properly - await HandleClosedPosition(positionForSignal); - await LogInformation( - $"โœ… Position Found in Exchange History\n" + - $"Position was actually filled and closed\n" + - $"Processing with HandleClosedPosition"); - } - else - { - // Position was never filled, just mark as canceled without processing PnL - positionForSignal.Status = PositionStatus.Canceled; - await SetPositionStatus(signal.Identifier, PositionStatus.Canceled); - await UpdatePositionDatabase(positionForSignal); - await LogWarning( - $"โŒ Position Confirmed Never Filled\nNo position in exchange history\nMarking as canceled without PnL processing"); - } - - SetSignalStatus(signal.Identifier, SignalStatus.Expired); - } - } - else if (internalPosition.Status == PositionStatus.Finished || + // Common position status handling + if (internalPosition.Status == PositionStatus.Finished || internalPosition.Status == PositionStatus.Flipped) { await HandleClosedPosition(positionForSignal); @@ -808,13 +483,13 @@ public class TradingBotBase : ITradingBot Candle lastCandle = null; await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { - lastCandle = Config.IsForBacktest + lastCandle = Config.TradingType == TradingType.BacktestFutures ? LastCandle : await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); }); - var currentTime = Config.IsForBacktest ? lastCandle.Date : DateTime.UtcNow; + var currentTime = Config.TradingType == TradingType.BacktestFutures ? lastCandle.Date : DateTime.UtcNow; var currentPnl = positionForSignal.ProfitAndLoss?.Net ?? 0; var pnlPercentage = TradingBox.CalculatePnLPercentage(currentPnl, positionForSignal.Open.Price, positionForSignal.Open.Quantity); @@ -959,81 +634,238 @@ public class TradingBotBase : ITradingBot } } } - // else if (internalPosition.Status == PositionStatus.Rejected || - // internalPosition.Status == PositionStatus.Canceled) - // { - // await LogWarning($"Open position trade is rejected for signal {signal.Identifier}"); - // if (signal.Status == SignalStatus.PositionOpen) - // { - // Logger.LogInformation($"Try to re-open position"); - // await OpenPosition(signal); - // } - // } - if (Config.UseSynthApi && !Config.IsForBacktest && + // Synth risk monitoring (only for live trading) + if (Config.UseSynthApi && Config.TradingType == TradingType.Futures && positionForSignal.Status == PositionStatus.Filled) { - decimal currentPrice = 0; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - currentPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker); - }); - var riskResult = default(SynthRiskResult); - await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => - { - riskResult = await tradingService.MonitorSynthPositionRiskAsync( - Config.Ticker, - positionForSignal.OriginDirection, - currentPrice, - positionForSignal.StopLoss.Price, - positionForSignal.Identifier, - Config); - }); - - if (riskResult.ShouldWarn && !string.IsNullOrEmpty(riskResult.WarningMessage)) - { - await LogWarning(riskResult.WarningMessage); - } - - if (riskResult.ShouldAutoClose && !string.IsNullOrEmpty(riskResult.EmergencyMessage)) - { - await LogWarning(riskResult.EmergencyMessage); - await CloseTrade(Signals[positionForSignal.SignalIdentifier], positionForSignal, - positionForSignal.StopLoss, - currentPrice, true); - } + await MonitorSynthRisk(signal, positionForSignal); } } catch (Exception ex) { - await LogWarning($"Cannot update position {positionForSignal.Identifier}: {ex.Message}, {ex.StackTrace}"); + await LogWarningAsync($"Cannot update position {positionForSignal.Identifier}: {ex.Message}, {ex.StackTrace}"); SentrySdk.CaptureException(ex); return; } } - private async Task UpdatePositionDatabase(Position position) + // Virtual methods for trading mode-specific behavior + protected virtual async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal, List brokerPositions) + { + // Default implementation: do nothing (for backtest) + } + + protected virtual async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition, Position positionForSignal) + { + // Default implementation: do nothing (for backtest) + } + + protected virtual async Task MonitorSynthRisk(LightSignal signal, Position position) + { + // Default implementation: do nothing (for backtest) + } + + protected virtual async Task RecoverOpenPositionFromBroker(LightSignal signal, Position position) + { + // Default implementation: no recovery for backtest + return false; + } + + protected virtual async Task CheckBrokerPositions() + { + // Default implementation: no broker checks for backtest, always allow + return true; + } + + protected virtual async Task ReconcileWithBrokerHistory(Position position, Candle currentCandle) + { + // Default implementation: no broker history reconciliation for backtest + return false; // Return false to continue with candle-based calculation + } + + protected virtual async Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles( + Position position, Candle currentCandle, bool forceMarketClose, decimal? forcedClosingPrice) + { + // Used in Futures and Spot bots + decimal closingPrice = 0; + bool pnlCalculated = false; + + if (forceMarketClose && forcedClosingPrice.HasValue) + { + closingPrice = forcedClosingPrice.Value; + + bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long + ? closingPrice > position.Open.Price + : closingPrice < position.Open.Price; + + if (isManualCloseProfitable) + { + if (position.TakeProfit1 != null) + { + position.TakeProfit1.Price = closingPrice; + position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + } + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + else + { + if (position.StopLoss != null) + { + position.StopLoss.Price = closingPrice; + position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow); + position.StopLoss.SetStatus(TradeStatus.Filled); + } + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + + pnlCalculated = true; + } + else if (currentCandle != null) + { + // For backtest: use configured SL/TP prices to ensure consistent PnL + if (position.OriginDirection == TradeDirection.Long) + { + if (position.StopLoss.Price >= currentCandle.Low) + { + closingPrice = position.StopLoss.Price; + position.StopLoss.SetDate(currentCandle.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + else if (position.TakeProfit1.Price <= currentCandle.High && + position.TakeProfit1.Status != TradeStatus.Filled) + { + closingPrice = position.TakeProfit1.Price; + position.TakeProfit1.SetDate(currentCandle.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + } + else if (position.OriginDirection == TradeDirection.Short) + { + if (position.StopLoss.Price <= currentCandle.High) + { + closingPrice = position.StopLoss.Price; + position.StopLoss.SetDate(currentCandle.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + else if (position.TakeProfit1.Price >= currentCandle.Low && + position.TakeProfit1.Status != TradeStatus.Filled) + { + closingPrice = position.TakeProfit1.Price; + position.TakeProfit1.SetDate(currentCandle.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + } + + if (closingPrice == 0) + { + // Manual/exchange close - use current candle close + closingPrice = currentCandle.Close; + + bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long + ? closingPrice > position.Open.Price + : closingPrice < position.Open.Price; + + if (isManualCloseProfitable) + { + position.TakeProfit1.SetPrice(closingPrice, 2); + position.TakeProfit1.SetDate(currentCandle.Date); + position.TakeProfit1.SetStatus(TradeStatus.Filled); + + if (position.StopLoss != null) + { + position.StopLoss.SetStatus(TradeStatus.Cancelled); + } + } + else + { + position.StopLoss.SetPrice(closingPrice, 2); + position.StopLoss.SetDate(currentCandle.Date); + position.StopLoss.SetStatus(TradeStatus.Filled); + + if (position.TakeProfit1 != null) + { + position.TakeProfit1.SetStatus(TradeStatus.Cancelled); + } + + if (position.TakeProfit2 != null) + { + position.TakeProfit2.SetStatus(TradeStatus.Cancelled); + } + } + } + + pnlCalculated = true; + } + + return (closingPrice, pnlCalculated); + } + + protected async Task UpdatePositionDatabase(Position position) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => { await tradingService.UpdatePositionAsync(position); }); } - private async Task OpenPosition(LightSignal signal) + protected virtual async Task GetLastPriceForPositionOpeningAsync() { - await LogDebug($"๐Ÿ”“ Opening position for signal: `{signal.Identifier}`"); + // Default implementation - subclasses should override + return 0; + } + + protected async Task OpenPosition(LightSignal signal) + { + await LogDebugAsync($"๐Ÿ”“ Opening position for signal: `{signal.Identifier}`"); // Check for any existing open position (not finished) for this ticker var openedPosition = Positions.Values.FirstOrDefault(p => p.IsOpen() && p.SignalIdentifier != signal.Identifier); - decimal lastPrice = await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - return Config.IsForBacktest - ? LastCandle?.Close ?? 0 - : await exchangeService.GetCurrentPrice(Account, Config.Ticker); - }); + decimal lastPrice = await GetLastPriceForPositionOpeningAsync(); if (openedPosition != null) { @@ -1048,6 +880,83 @@ public class TradingBotBase : ITradingBot } else { + // Handle flip position - trading type specific logic + var flippedPosition = await HandleFlipPosition(signal, openedPosition, previousSignal, lastPrice); + return flippedPosition; + } + } + else + { + bool canOpen = await CanOpenPosition(signal); + if (!canOpen) + { + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + return null; + } + + try + { + // Execute position opening - trading type specific logic + var position = await ExecuteOpenPosition(signal, lastPrice); + + // Common logic: Handle position result + if (position != null) + { + // Add position to internal collection before any status updates + Positions[position.Identifier] = position; + + if (position.Open.Status != TradeStatus.Cancelled && position.Status != PositionStatus.Rejected) + { + SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); + + await SendPositionToCopyTradingStream(position); + + await LogDebugAsync($"โœ… Position requested successfully for signal: `{signal.Identifier}`"); + + return position; + } + else + { + SentrySdk.CaptureMessage("Position rejected", SentryLevel.Error); + await SetPositionStatus(signal.Identifier, PositionStatus.Rejected); + position.Status = PositionStatus.Rejected; + await UpdatePositionDatabase(position); + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + return position; + } + } + + return null; + } + catch (InsufficientFundsException ex) + { + // Handle insufficient funds errors with user-friendly messaging + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + await LogWarning(ex.UserMessage); + + // Log the technical details for debugging + Logger.LogError(ex, "Insufficient funds error for signal {SignalId}: {ErrorMessage}", signal.Identifier, + ex.Message); + + return null; + } + catch (Exception ex) + { + SetSignalStatus(signal.Identifier, SignalStatus.Expired); + SentrySdk.CaptureException(ex); + return null; + } + } + } + + /// + /// Handles position flipping logic when an opposite direction signal is received. + /// This method is trading-type specific and should be overridden in derived classes. + /// + protected virtual async Task HandleFlipPosition(LightSignal signal, Position openedPosition, + LightSignal previousSignal, decimal lastPrice) + { + // Default implementation - subclasses should override if (Config.FlipPosition) { var isPositionInProfit = (openedPosition.ProfitAndLoss?.Realized ?? 0) > 0; @@ -1086,23 +995,16 @@ public class TradingBotBase : ITradingBot return null; } } - } - else - { - bool canOpen = await CanOpenPosition(signal); - if (!canOpen) - { - SetSignalStatus(signal.Identifier, SignalStatus.Expired); - return null; - } - try - { + /// + /// Executes the actual position opening logic. + /// This method is trading-type specific and should be overridden in derived classes. + /// + protected virtual async Task ExecuteOpenPosition(LightSignal signal, decimal lastPrice) + { + // Default implementation - subclasses should override // Verify actual balance before opening position - if (!Config.IsForBacktest) - { - await VerifyAndUpdateBalance(); - } + await VerifyAndUpdateBalanceAsync(); var command = new OpenPositionRequest( Config.AccountName, @@ -1113,10 +1015,11 @@ public class TradingBotBase : ITradingBot signal.Date, Account.User, Config.BotTradingBalance, - Config.IsForBacktest, + Config.TradingType == TradingType.BacktestFutures, lastPrice, signalIdentifier: signal.Identifier, - initiatorIdentifier: Identifier); + initiatorIdentifier: Identifier, + tradingType: Config.TradingType); var position = await ServiceScopeHelpers .WithScopedServices( @@ -1127,59 +1030,7 @@ public class TradingBotBase : ITradingBot .Handle(command); }); - if (position != null) - { - // Add position to internal collection before any status updates - Positions[position.Identifier] = position; - - if (position.Open.Status != TradeStatus.Cancelled && position.Status != PositionStatus.Rejected) - { - SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); - - if (!Config.IsForBacktest) - { - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async messengerService => { await messengerService.SendPosition(position); }); - } - - await LogDebug($"โœ… Position requested successfully for signal: `{signal.Identifier}`"); - - await SendPositionToCopyTrading(position); - return position; - } - else - { - SentrySdk.CaptureMessage("Position rejected", SentryLevel.Error); - await SetPositionStatus(signal.Identifier, PositionStatus.Rejected); - position.Status = PositionStatus.Rejected; - await UpdatePositionDatabase(position); - SetSignalStatus(signal.Identifier, SignalStatus.Expired); - return position; - } - } - - return null; - } - catch (InsufficientFundsException ex) - { - // Handle insufficient funds errors with user-friendly messaging - SetSignalStatus(signal.Identifier, SignalStatus.Expired); - await LogWarning(ex.UserMessage); - - // Log the technical details for debugging - Logger.LogError(ex, "Insufficient funds error for signal {SignalId}: {ErrorMessage}", signal.Identifier, - ex.Message); - - return null; - } - catch (Exception ex) - { - SetSignalStatus(signal.Identifier, SignalStatus.Expired); - SentrySdk.CaptureException(ex); - return null; - } - } } private async Task SendPositionToCopyTrading(Position position) @@ -1199,7 +1050,7 @@ public class TradingBotBase : ITradingBot // Publish the position to the stream await stream.OnNextAsync(position); - await LogDebug($"๐Ÿ“ก Position {position.Identifier} sent to copy trading stream for bot {Identifier}"); + await LogDebugAsync($"๐Ÿ“ก Position {position.Identifier} sent to copy trading stream for bot {Identifier}"); } catch (Exception ex) { @@ -1260,59 +1111,28 @@ public class TradingBotBase : ITradingBot } - private async Task CanOpenPosition(LightSignal signal) + protected virtual async Task CanOpenPosition(LightSignal signal) { - // Early return if we're in backtest mode and haven't executed yet - // TODO : check if its a startup cycle - if (!Config.IsForBacktest && ExecutionCount == 0) + // Default implementation for live trading + // Early return if bot hasn't executed first cycle yet + if (ExecutionCount == 0) { - await LogInformation("โณ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet"); + await LogInformationAsync("โณ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet"); return false; } - // Check if we're in backtest mode - if (Config.IsForBacktest) - { - return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal); - } - // Check broker positions for live trading - var canOpenPosition = await CheckBrokerPositions(); + var canOpenPosition = await CanOpenPositionWithBrokerChecks(signal); if (!canOpenPosition) { return false; } - // Synth-based pre-trade risk assessment - if (Config.UseSynthApi) - { - decimal currentPrice = 0; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => - { - currentPrice = Config.IsForBacktest - ? LastCandle?.Close ?? 0 - : await exchangeService.GetCurrentPrice(Account, Config.Ticker); - }); - - - bool synthRisk = false; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, async tradingService => - { - synthRisk = await tradingService.AssessSynthPositionRiskAsync(Config.Ticker, signal.Direction, - currentPrice, - Config, Config.IsForBacktest); - }); - if (!synthRisk) - { - return false; - } - } - // Check cooldown period and loss streak return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal); } - private async Task CheckLossStreak(LightSignal signal) + protected async Task CheckLossStreak(LightSignal signal) { // If MaxLossStreak is 0, there's no limit if (Config.MaxLossStreak <= 0) @@ -1340,599 +1160,27 @@ public class TradingBotBase : ITradingBot return canOpen; } - private async Task CheckBrokerPositions() - { - try - { - List positions = null; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => { positions = [.. await exchangeService.GetBrokerPositions(Account)]; }); - // Check if there's a position for this ticker on the broker - var brokerPositionForTicker = positions.FirstOrDefault(p => p.Ticker == Config.Ticker); - if (brokerPositionForTicker == null) - { - // No position on broker for this ticker, safe to open - return true; - } + public abstract Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice, + bool tradeClosingPosition = false, bool forceMarketClose = false); - // Handle existing position on broker - await LogDebug( - $"๐Ÿ” Broker Position Found\n" + - $"Ticker: {Config.Ticker}\n" + - $"Direction: {brokerPositionForTicker.OriginDirection}\n" + - $"Checking internal positions for synchronization..."); - - var previousPosition = Positions.Values.LastOrDefault(); - List orders = null; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - orders = [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)]; - }); - - var reason = - $"Cannot open position. There is already a position open for {Config.Ticker} on the broker (Direction: {brokerPositionForTicker.OriginDirection})."; - - if (previousPosition != null) - { - // Check if this position matches the broker position - if (previousPosition.OriginDirection == brokerPositionForTicker.OriginDirection) - { - // Same direction - this is likely the same position - if (orders.Count >= 2) - { - Logger.LogInformation( - $"โœ… Broker Position Matched with Internal Position\n" + - $"Position: {previousPosition.Identifier}\n" + - $"Direction: {previousPosition.OriginDirection}\n" + - $"Orders found: {orders.Count}\n" + - $"Setting status to Filled"); - await SetPositionStatus(previousPosition.SignalIdentifier, PositionStatus.Filled); - } - else - { - // Position exists on broker but not enough orders - something is wrong - Logger.LogWarning( - $"โš ๏ธ Incomplete Order Set\n" + - $"Position: {previousPosition.Identifier}\n" + - $"Direction: {previousPosition.OriginDirection}\n" + - $"Expected orders: โ‰ฅ2, Found: {orders.Count}\n" + - $"This position may need manual intervention"); - - reason += $" Position exists on broker but only has {orders.Count} orders (expected โ‰ฅ2)."; - } - } - else - { - // Different direction - possible flip scenario or orphaned position - Logger.LogWarning( - $"โš ๏ธ Direction Mismatch Detected\n" + - $"Internal: {previousPosition.OriginDirection}\n" + - $"Broker: {brokerPositionForTicker.OriginDirection}\n" + - $"This could indicate a flipped position or orphaned broker position"); - - reason += - $" Direction mismatch: Internal ({previousPosition.OriginDirection}) vs Broker ({brokerPositionForTicker.OriginDirection})."; - } - } - else - { - // Broker has a position but we don't have any internal tracking - Logger.LogWarning( - $"โš ๏ธ Orphaned Broker Position Detected\n" + - $"Broker has position for {Config.Ticker} ({brokerPositionForTicker.OriginDirection})\n" + - $"But no internal position found in bot tracking\n" + - $"This may require manual cleanup"); - - reason += " Position open on broker but no internal position tracked by the bot."; - } - - await LogWarning(reason); - return false; - } - catch (Exception ex) - { - await LogWarning($"โŒ Broker Position Check Failed\nError checking broker positions\n{ex.Message}"); - return false; - } - } - - public async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice, - bool tradeClosingPosition = false, bool forceMarketClose = false) - { - await LogInformation( - $"๐Ÿ”ง Closing {position.OriginDirection} Trade\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n๐Ÿ“‹ Type: `{tradeToClose.TradeType}`\n๐Ÿ“Š Quantity: `{tradeToClose.Quantity:F5}`"); - - decimal quantity = 0; - - if (!Config.IsForBacktest) - { - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - // TODO should also pass the direction to get quantity in correct position - quantity = await exchangeService.GetQuantityInPosition(Account, Config.Ticker); - }); - } - - // Get status of position before closing it. The position might be already close by the exchange - if (!Config.IsForBacktest && quantity == 0) - { - await LogDebug($"โœ… Trade already closed on exchange for position: `{position.Identifier}`"); - await HandleClosedPosition(position, forceMarketClose ? lastPrice : (decimal?)null, forceMarketClose); - } - else - { - var command = new ClosePositionCommand(position, position.AccountId, lastPrice, - isForBacktest: Config.IsForBacktest); - try - { - // Grace period: give the broker time to process any ongoing close operations - // Using ConfigureAwait(false) to ensure non-blocking operation - if (!Config.IsForBacktest) - { - await Task.Delay(CLOSE_POSITION_GRACE_MS).ConfigureAwait(false); - } - - Position closedPosition = null; - await ServiceScopeHelpers.WithScopedServices( - _scopeFactory, async (exchangeService, accountService, tradingService) => - { - closedPosition = - await new ClosePositionCommandHandler(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 LogWarning($"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); - } - } - } - } - - private async Task HandleClosedPosition(Position position, decimal? forcedClosingPrice = null, + protected async Task HandleClosedPosition(Position position, decimal? forcedClosingPrice = null, bool forceMarketClose = false) { if (Positions.ContainsKey(position.Identifier)) { - Candle currentCandle = null; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => + Candle currentCandle = await GetCurrentCandleForPositionClose(Account, Config.Ticker.ToString()); + + // Try broker history reconciliation first (futures-specific) + var brokerHistoryReconciled = await ReconcileWithBrokerHistory(position, currentCandle); + if (brokerHistoryReconciled && !forceMarketClose) { - currentCandle = Config.IsForBacktest - ? LastCandle - : await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow); - }); - - // For live trading on GMX, fetch the actual position history to get real PnL data - if (!Config.IsForBacktest && !forceMarketClose) - { - try - { - await LogDebug( - $"๐Ÿ” Fetching Position History from GMX\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); - - var positionHistory = await ServiceScopeHelpers.WithScopedService>( - _scopeFactory, - async exchangeService => - { - // Get position history from the last 24 hours for better coverage - var fromDate = DateTime.UtcNow.AddHours(-24); - var toDate = DateTime.UtcNow; - return await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate); - }); - - // Find the matching position in history based on the most recent closed position with same direction - if (positionHistory != null && positionHistory.Any()) - { - // Get the most recent closed position from GMX that matches the direction - var brokerPosition = positionHistory - .Where(p => p.OriginDirection == position.OriginDirection) // Ensure same direction - .OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue) - .FirstOrDefault(); - - if (brokerPosition != null && brokerPosition.ProfitAndLoss != null) - { - await LogDebug( - $"โœ… Broker Position History Found\n" + - $"Position: `{position.Identifier}`\n" + - $"Realized PnL (after fees): `${brokerPosition.ProfitAndLoss.Realized:F2}`\n" + - $"Bot's UI Fees: `${position.UiFees:F2}`\n" + - $"Bot's Gas Fees: `${position.GasFees:F2}`"); - - // Use the actual GMX PnL data (this is already net of fees from GMX) - // We use this for reconciliation with the bot's own calculations - var closingVolume = brokerPosition.Open.Price * position.Open.Quantity * - position.Open.Leverage; - var totalBotFees = position.GasFees + position.UiFees + - TradingBox.CalculateClosingUiFees(closingVolume); - var gmxNetPnl = brokerPosition.ProfitAndLoss.Realized; // This is already after GMX fees - - position.ProfitAndLoss = new ProfitAndLoss - { - // GMX's realized PnL is already after their fees - Realized = gmxNetPnl, - // For net, we keep it the same since GMX PnL is already net of their fees - Net = gmxNetPnl - totalBotFees - }; - - // Update the closing trade price if available - if (brokerPosition.Open != null) - { - var brokerClosingPrice = brokerPosition.Open.Price; - var isProfitable = position.OriginDirection == TradeDirection.Long - ? position.Open.Price < brokerClosingPrice - : position.Open.Price > brokerClosingPrice; - - if (isProfitable) - { - if (position.TakeProfit1 != null) - { - position.TakeProfit1.Price = brokerClosingPrice; - position.TakeProfit1.SetDate(brokerPosition.Open.Date); - position.TakeProfit1.SetStatus(TradeStatus.Filled); - } - - // Cancel SL trade when TP is hit - if (position.StopLoss != null) - { - position.StopLoss.SetStatus(TradeStatus.Cancelled); - } - } - else - { - if (position.StopLoss != null) - { - position.StopLoss.Price = brokerClosingPrice; - position.StopLoss.SetDate(brokerPosition.Open.Date); - position.StopLoss.SetStatus(TradeStatus.Filled); - } - - // Cancel TP trades when SL is hit - if (position.TakeProfit1 != null) - { - position.TakeProfit1.SetStatus(TradeStatus.Cancelled); - } - - if (position.TakeProfit2 != null) - { - position.TakeProfit2.SetStatus(TradeStatus.Cancelled); - } - } - - await LogDebug( - $"๐Ÿ“Š Position Reconciliation Complete\n" + - $"Position: `{position.Identifier}`\n" + - $"Closing Price: `${brokerClosingPrice:F2}`\n" + - $"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" + - $"PnL from broker: `${position.ProfitAndLoss.Realized:F2}`"); - } - - // Skip the candle-based PnL calculation since we have actual GMX data goto SkipCandleBasedCalculation; - } - else - { - } - } - else - { - Logger.LogWarning( - $"โš ๏ธ No GMX Position History Found\nPosition: `{position.Identifier}`\nFalling back to candle-based calculation"); - } - } - catch (Exception ex) - { - Logger.LogError(ex, - "Error fetching position history from GMX for position {PositionId}. Falling back to candle-based calculation.", - position.Identifier); - } } - // Calculate P&L for backtests even if currentCandle is null - decimal closingPrice = 0; - bool pnlCalculated = false; - - // If we are forcing a market close (e.g., time limit), use the provided closing price - if (forceMarketClose && forcedClosingPrice.HasValue) - { - closingPrice = forcedClosingPrice.Value; - - bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long - ? closingPrice > position.Open.Price - : closingPrice < position.Open.Price; - - if (isManualCloseProfitable) - { - if (position.TakeProfit1 != null) - { - position.TakeProfit1.Price = closingPrice; - position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow); - position.TakeProfit1.SetStatus(TradeStatus.Filled); - } - - if (position.StopLoss != null) - { - position.StopLoss.SetStatus(TradeStatus.Cancelled); - } - } - else - { - if (position.StopLoss != null) - { - position.StopLoss.Price = closingPrice; - position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow); - position.StopLoss.SetStatus(TradeStatus.Filled); - } - - if (position.TakeProfit1 != null) - { - position.TakeProfit1.SetStatus(TradeStatus.Cancelled); - } - - if (position.TakeProfit2 != null) - { - position.TakeProfit2.SetStatus(TradeStatus.Cancelled); - } - } - - pnlCalculated = true; - } - - if (currentCandle != null) - { - List recentCandles = null; - - if (Config.IsForBacktest) - { - recentCandles = LastCandle != null ? new List() { LastCandle } : new List(); - } - else - { - // Use CandleStoreGrain to get recent candles instead of calling exchange service directly - await ServiceScopeHelpers.WithScopedService(_scopeFactory, async grainFactory => - { - var grainKey = - CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe); - var grain = grainFactory.GetGrain(grainKey); - - try - { - recentCandles = await grain.GetLastCandle(5); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error retrieving recent candles from CandleStoreGrain for {GrainKey}", - grainKey); - recentCandles = new List(); - } - }); - } - - // Check if we have any candles before proceeding - if (recentCandles == null || !recentCandles.Any()) - { - await LogWarning( - $"No recent candles available for position {position.Identifier}. Using current candle data instead."); - - // Fallback to current candle if available - if (currentCandle != null) - { - recentCandles = new List { currentCandle }; - } - else - { - await LogWarning( - $"No candle data available for position {position.Identifier}. Cannot determine stop loss/take profit hit."); - Logger.LogError( - "No candle data available for position {PositionId}. Cannot determine stop loss/take profit hit.", - position.Identifier); - return; - } - } - - var minPriceRecent = recentCandles.Min(c => c.Low); - var maxPriceRecent = recentCandles.Max(c => c.High); - - bool wasStopLossHit = false; - bool wasTakeProfitHit = false; - - if (position.OriginDirection == TradeDirection.Long) - { - wasStopLossHit = minPriceRecent <= position.StopLoss.Price; - wasTakeProfitHit = maxPriceRecent >= position.TakeProfit1.Price; - } - else - { - wasStopLossHit = maxPriceRecent >= position.StopLoss.Price; - wasTakeProfitHit = minPriceRecent <= position.TakeProfit1.Price; - } - - if (wasStopLossHit) - { - // For backtesting: use the configured SL price to ensure consistent PnL per money management - // For live trading: use actual execution price to reflect real market conditions (slippage) - if (Config.IsForBacktest) - { - closingPrice = position.StopLoss.Price; - } - else - { - // Use actual execution price based on direction for live trading - closingPrice = position.OriginDirection == TradeDirection.Long - ? minPriceRecent // For LONG, SL hits at the low - : maxPriceRecent; // For SHORT, SL hits at the high - - position.StopLoss.Price = closingPrice; - } - - position.StopLoss.SetDate(currentCandle.Date); - position.StopLoss.SetStatus(TradeStatus.Filled); - - // Cancel TP trades when SL is hit - if (position.TakeProfit1 != null) - { - position.TakeProfit1.SetStatus(TradeStatus.Cancelled); - } - - if (position.TakeProfit2 != null) - { - position.TakeProfit2.SetStatus(TradeStatus.Cancelled); - } - - await LogDebug( - $"๐Ÿ›‘ Stop Loss Execution Confirmed\n" + - $"Position: `{position.Identifier}`\n" + - $"Closing Price: `${closingPrice:F2}`\n" + - $"Configured SL: `${position.StopLoss.Price:F2}`\n" + - $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); - } - else if (wasTakeProfitHit) - { - // For backtesting: use the configured TP price to ensure consistent PnL per money management - // For live trading: use actual execution price to reflect real market conditions (slippage) - if (Config.IsForBacktest) - { - closingPrice = position.TakeProfit1.Price; - } - else - { - // Use actual execution price based on direction for live trading - closingPrice = position.OriginDirection == TradeDirection.Long - ? maxPriceRecent // For LONG, TP hits at the high - : minPriceRecent; // FOR SHORT, TP hits at the low - - position.TakeProfit1.Price = closingPrice; - } - - position.TakeProfit1.SetDate(currentCandle.Date); - position.TakeProfit1.SetStatus(TradeStatus.Filled); - - // Cancel SL trade when TP is hit - if (position.StopLoss != null) - { - position.StopLoss.SetStatus(TradeStatus.Cancelled); - } - - await LogDebug( - $"๐ŸŽฏ Take Profit Execution Confirmed\n" + - $"Position: `{position.Identifier}`\n" + - $"Closing Price: `${closingPrice:F2}`\n" + - $"Configured TP: `${position.TakeProfit1.Price:F2}`\n" + - $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); - } - else - { - closingPrice = Config.IsForBacktest - ? currentCandle.Close - : 0; - - if (!Config.IsForBacktest) - { - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - closingPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker); - }); - } - - bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long - ? closingPrice > position.Open.Price - : closingPrice < position.Open.Price; - - if (isManualCloseProfitable) - { - position.TakeProfit1.SetPrice(closingPrice, 2); - position.TakeProfit1.SetDate(currentCandle.Date); - position.TakeProfit1.SetStatus(TradeStatus.Filled); - - // Cancel SL trade when TP is used for manual close - if (position.StopLoss != null) - { - position.StopLoss.SetStatus(TradeStatus.Cancelled); - } - } - else - { - position.StopLoss.SetPrice(closingPrice, 2); - position.StopLoss.SetDate(currentCandle.Date); - position.StopLoss.SetStatus(TradeStatus.Filled); - - // Cancel TP trades when SL is used for manual close - if (position.TakeProfit1 != null) - { - position.TakeProfit1.SetStatus(TradeStatus.Cancelled); - } - - if (position.TakeProfit2 != null) - { - position.TakeProfit2.SetStatus(TradeStatus.Cancelled); - } - } - - await LogDebug( - $"โœ‹ Manual/Exchange Close Detected\n" + - $"Position: `{position.Identifier}`\n" + - $"SL: `${position.StopLoss.Price:F2}` | TP: `${position.TakeProfit1.Price:F2}`\n" + - $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`\n" + - $"Closing at market price: `${closingPrice:F2}`"); - } - - pnlCalculated = true; - } - else if (Config.IsForBacktest) - { - // For backtests when currentCandle is null, use a fallback closing price - // This ensures P&L calculation always happens for backtests - Logger.LogWarning( - $"โš ๏ธ Backtest: No current candle available for position {position.Identifier}. Using fallback closing price calculation."); - - // Use the position's stop loss or take profit price as closing price - if (position.StopLoss != null && position.StopLoss.Price > 0) - { - closingPrice = position.StopLoss.Price; - position.StopLoss.SetStatus(TradeStatus.Filled); - } - else if (position.TakeProfit1 != null && position.TakeProfit1.Price > 0) - { - closingPrice = position.TakeProfit1.Price; - position.TakeProfit1.SetStatus(TradeStatus.Filled); - } - else - { - // Last resort: use entry price (no profit/loss) - closingPrice = position.Open.Price; - Logger.LogWarning( - $"โš ๏ธ Backtest: Using entry price as closing price for position {position.Identifier}"); - } - - pnlCalculated = true; - } + // Calculate position closing details using subclass-specific logic + var (closingPrice, pnlCalculated) = await CalculatePositionClosingFromCandles( + position, currentCandle, forceMarketClose, forcedClosingPrice); // Calculate P&L if we have a closing price if (pnlCalculated && closingPrice > 0) @@ -1969,9 +1217,9 @@ public class TradingBotBase : ITradingBot $"Total Fees: `${position.GasFees + position.UiFees:F2}`\n" + $"Net P&L (after fees): `${position.ProfitAndLoss.Net:F2}`"; - if (!Config.IsForBacktest) + if (Config.TradingType == TradingType.Futures) { - await LogDebug(logMessage); + await LogDebugAsync(logMessage); } } @@ -1979,7 +1227,7 @@ public class TradingBotBase : ITradingBot await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished); // Update position in database with all trade changes - if (!Config.IsForBacktest) + if (Config.TradingType == TradingType.Futures) { position.Status = PositionStatus.Finished; await UpdatePositionDatabase(position); @@ -1988,15 +1236,15 @@ public class TradingBotBase : ITradingBot // Check if Open trade was filled (means position was opened on the broker) if (position.Open?.Status == TradeStatus.Filled) { - await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionClosed, position); + await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, position); // Update the last position closing time for cooldown period tracking // Only update if position was actually filled - LastPositionClosingTime = Config.IsForBacktest ? currentCandle.Date : DateTime.UtcNow; + LastPositionClosingTime = Config.TradingType == TradingType.BacktestFutures ? currentCandle.Date : DateTime.UtcNow; } else { - await LogDebug( + await LogDebugAsync( $"Skipping PositionClosed notification for position {position.Identifier} - position was never filled (Open trade status: {position.Open?.Status})"); } } @@ -2004,7 +1252,7 @@ public class TradingBotBase : ITradingBot // Only update balance and log success if position was actually filled if (position.Open?.Status == TradeStatus.Filled) { - await LogDebug( + await LogDebugAsync( $"โœ… Position Closed Successfully\nPosition: `{position.SignalIdentifier}`\nPnL: `${position.ProfitAndLoss?.Net:F2}`"); if (position.ProfitAndLoss != null) @@ -2013,14 +1261,14 @@ public class TradingBotBase : ITradingBot _currentBalance += position.ProfitAndLoss.Net; Config.BotTradingBalance += position.ProfitAndLoss.Net; - await LogDebug( + await LogDebugAsync( string.Format("๐Ÿ’ฐ Balance Updated\nNew bot trading balance: `${0:F2}`", Config.BotTradingBalance)); } } else { - await LogDebug( + await LogDebugAsync( $"โœ… Position Cleanup\nPosition: `{position.SignalIdentifier}` was never filled - no balance or PnL changes"); } } @@ -2029,18 +1277,13 @@ public class TradingBotBase : ITradingBot await LogWarning("Weird things happen - Trying to update position status, but no position found"); } - if (!Config.IsForBacktest) - { - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async messengerService => { await messengerService.SendClosedPosition(position, Account.User); }); - } - - await CancelAllOrders(); + await SendClosedPositionToMessenger(position, Account.User); + await CancelAllOrdersAsync(); } private async Task CancelAllOrders() { - if (!Config.IsForBacktest && !Config.IsForWatchingOnly) + if (Config.TradingType == TradingType.Futures && !Config.IsForWatchingOnly) { try { @@ -2063,24 +1306,24 @@ public class TradingBotBase : ITradingBot if (cancelClose) { - await LogDebug($"Position still open, cancel close orders"); + await LogDebugAsync($"Position still open, cancel close orders"); } else { - await LogDebug($"Canceling all orders for {Config.Ticker}"); + await LogDebugAsync($"Canceling all orders for {Config.Ticker}"); await ServiceScopeHelpers.WithScopedService(_scopeFactory, async exchangeService => { await exchangeService.CancelOrder(Account, Config.Ticker); var closePendingOrderStatus = await exchangeService.CancelOrder(Account, Config.Ticker); - await LogDebug( + await LogDebugAsync( $"Closing all {Config.Ticker} orders status : {closePendingOrderStatus}"); }); } } else { - await LogDebug($"No need to cancel orders for {Config.Ticker}"); + await LogDebugAsync($"No need to cancel orders for {Config.Ticker}"); } } catch (Exception ex) @@ -2091,7 +1334,7 @@ public class TradingBotBase : ITradingBot } } - private async Task SetPositionStatus(string signalIdentifier, PositionStatus positionStatus) + protected async Task SetPositionStatus(string signalIdentifier, PositionStatus positionStatus) { try { @@ -2141,7 +1384,7 @@ public class TradingBotBase : ITradingBot } } - private void UpdatePositionPnl(Guid identifier, decimal realized) + protected void UpdatePositionPnl(Guid identifier, decimal realized) { var position = Positions[identifier]; var totalFees = position.GasFees + position.UiFees; @@ -2162,7 +1405,7 @@ public class TradingBotBase : ITradingBot } } - private void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus) + protected void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus) { if (Signals.ContainsKey(signalIdentifier) && Signals[signalIdentifier].Status != signalStatus) { @@ -2188,75 +1431,6 @@ public class TradingBotBase : ITradingBot $"๐Ÿ›‘ Bot Stopped\nBot: `{Config.Name}`\nTicker: `{Config.Ticker}`\nReason: `{reason ?? "No reason provided"}`"); } - public async Task LogInformation(string message) - { - if (Config.IsForBacktest) - return; - - Logger.LogInformation(message); - - try - { - await SendTradeMessage(message); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - public async Task LogWarning(string message) - { - if (Config.IsForBacktest) - return; - - message = $"[{Config.Name}] {message}"; - - try - { - await SendTradeMessage(message, true); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - public async Task LogDebug(string message) - { - if (Config.IsForBacktest) - return; - - Logger.LogDebug(message); - - try - { - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async messengerService => - { - await messengerService.SendDebugMessage($"๐Ÿค– {Account.User.AgentName} - {Config.Name}\n{message}"); - }); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - private async Task SendTradeMessage(string message, bool isBadBehavior = false) - { - if (!Config.IsForBacktest) - { - var user = Account.User; - var messageWithBotName = $"๐Ÿค– {user.AgentName} - {Config.Name}\n{message}"; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async messengerService => - { - await messengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user); - }); - } - } - /// /// Manually opens a position using the bot's settings and a generated signal. /// Relies on the bot's MoneyManagement for Stop Loss and Take Profit placement. @@ -2291,7 +1465,7 @@ public class TradingBotBase : ITradingBot try { // Set signal status based on configuration - if (Config.IsForWatchingOnly || (ExecutionCount < 1 && !Config.IsForBacktest)) + if (Config.IsForWatchingOnly || (ExecutionCount < 1 && Config.TradingType == TradingType.Futures)) { signal.Status = SignalStatus.Expired; } @@ -2306,7 +1480,7 @@ public class TradingBotBase : ITradingBot $"๐Ÿ†” Signal ID: `{signal.Identifier}`"; // Apply Synth-based signal filtering if enabled - if (Config.UseSynthApi && !Config.IsForBacktest && ExecutionCount > 0) + if (Config.UseSynthApi && Config.TradingType == TradingType.Futures && ExecutionCount > 0) { await ServiceScopeHelpers.WithScopedServices(_scopeFactory, async (tradingService, exchangeService) => @@ -2317,19 +1491,19 @@ public class TradingBotBase : ITradingBot signal, currentPrice, Config, - Config.IsForBacktest); + Config.TradingType == TradingType.BacktestFutures); if (signalValidationResult.Confidence == Confidence.None || signalValidationResult.Confidence == Confidence.Low || signalValidationResult.IsBlocked) { signal.Status = SignalStatus.Expired; - await LogDebug($"Signal {signal.Identifier} blocked by Synth risk assessment"); + await LogDebugAsync($"Signal {signal.Identifier} blocked by Synth risk assessment"); } else { signal.Confidence = signalValidationResult.Confidence; - await LogDebug( + await LogDebugAsync( $"Signal {signal.Identifier} passed Synth risk assessment with confidence {signalValidationResult.Confidence}"); } }); @@ -2339,7 +1513,7 @@ public class TradingBotBase : ITradingBot await LogInformation(signalText); - if (Config.IsForWatchingOnly && !Config.IsForBacktest && ExecutionCount > 0) + if (Config.IsForWatchingOnly && Config.TradingType == TradingType.Futures && ExecutionCount > 0) { await ServiceScopeHelpers.WithScopedService(_scopeFactory, async messengerService => { @@ -2348,7 +1522,7 @@ public class TradingBotBase : ITradingBot }); } - await LogDebug( + await LogDebugAsync( $"Processed signal for {Config.Ticker}: {signal.Direction} with status {signal.Status}"); } catch (Exception ex) @@ -2554,7 +1728,7 @@ public class TradingBotBase : ITradingBot } // Protect critical properties that shouldn't change for running bots - var protectedIsForBacktest = Config.IsForBacktest; + var protectedTradingType = Config.TradingType; newConfig.AccountName = Config.AccountName; @@ -2562,7 +1736,7 @@ public class TradingBotBase : ITradingBot Config = newConfig; // Restore protected properties - Config.IsForBacktest = protectedIsForBacktest; + Config.TradingType = protectedTradingType; // Update bot name and identifier if allowed if (!string.IsNullOrEmpty(newConfig.Name)) @@ -2633,7 +1807,7 @@ public class TradingBotBase : ITradingBot Timeframe = Config.Timeframe, IsForWatchingOnly = Config.IsForWatchingOnly, BotTradingBalance = Config.BotTradingBalance, - IsForBacktest = Config.IsForBacktest, + TradingType = Config.TradingType, CooldownPeriod = Config.CooldownPeriod, MaxLossStreak = Config.MaxLossStreak, MaxPositionTimeHours = Config.MaxPositionTimeHours, @@ -2657,7 +1831,7 @@ public class TradingBotBase : ITradingBot /// Checks if the bot is currently in a cooldown period for any direction. /// /// True if in cooldown period for any direction, false otherwise - private async Task IsInCooldownPeriodAsync() + protected async Task IsInCooldownPeriodAsync() { if (LastPositionClosingTime == null) { @@ -2678,7 +1852,7 @@ public class TradingBotBase : ITradingBot // Calculate cooldown end time based on last position closing time var cooldownEndTime = TradingBox.CalculateCooldownEndTime(LastPositionClosingTime.Value, Config.Timeframe, Config.CooldownPeriod); - var isInCooldown = (Config.IsForBacktest ? LastCandle.Date : DateTime.UtcNow) < cooldownEndTime; + var isInCooldown = (Config.TradingType == TradingType.BacktestFutures ? LastCandle.Date : DateTime.UtcNow) < cooldownEndTime; if (isInCooldown) { @@ -2713,7 +1887,7 @@ public class TradingBotBase : ITradingBot if (LastCandle != null) { - await LogDebug($"Successfully refreshed last candle for {Config.Ticker} at {LastCandle.Date}"); + await LogDebugAsync($"Successfully refreshed last candle for {Config.Ticker} at {LastCandle.Date}"); } else { @@ -2735,7 +1909,7 @@ public class TradingBotBase : ITradingBot private async Task NotifyAgentAndPlatformGrainAsync(NotificationEventType eventType, Position position) { - if (Config.IsForBacktest) + if (Config.TradingType == TradingType.BacktestFutures) { return; // Skip notifications for backtest } @@ -2763,7 +1937,7 @@ public class TradingBotBase : ITradingBot await agentGrain.OnPositionOpenedAsync(positionOpenEvent); await platformGrain.OnPositionOpenAsync(positionOpenEvent); - await LogDebug($"Sent position opened event to both grains for position {position.Identifier}"); + await LogDebugAsync($"Sent position opened event to both grains for position {position.Identifier}"); break; case NotificationEventType.PositionClosed: @@ -2778,7 +1952,7 @@ public class TradingBotBase : ITradingBot await agentGrain.OnPositionClosedAsync(positionClosedEvent); await platformGrain.OnPositionClosedAsync(positionClosedEvent); - await LogDebug($"Sent position closed event to both grains for position {position.Identifier}"); + await LogDebugAsync($"Sent position closed event to both grains for position {position.Identifier}"); break; case NotificationEventType.PositionUpdated: @@ -2798,259 +1972,152 @@ public class TradingBotBase : ITradingBot } } - /// - /// Checks if a position exists in the exchange history with PnL data. - /// This helps determine if a position was actually filled and closed on the exchange - /// even if the bot's internal tracking shows it as never filled. - /// - /// The position to check - /// True if position found in exchange history with PnL, false otherwise - private async Task<(bool found, bool hadError)> CheckPositionInExchangeHistory(Position position) + // Virtual methods for mode-specific behavior + protected virtual async Task LoadAccountAsync() { - if (Config.IsForBacktest) - { - // For backtests, we don't have exchange history, so return false - return (false, false); - } - - try - { - await LogDebug( - $"๐Ÿ” Checking Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`"); - - List positionHistory = null; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - // Get position history from the last 24 hours for comprehensive check - var fromDate = DateTime.UtcNow.AddHours(-24); - var toDate = DateTime.UtcNow; - positionHistory = - await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate); - }); - - // Check if there's a recent position with PnL data and matching direction - if (positionHistory != null && positionHistory.Any()) - { - var recentPosition = positionHistory - .Where(p => p.OriginDirection == position.OriginDirection) // Ensure same direction - .OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue) - .FirstOrDefault(); - - if (recentPosition != null && recentPosition.ProfitAndLoss != null) - { - await LogDebug( - $"โœ… Position Found in Exchange History\n" + - $"Position: `{position.Identifier}`\n" + - $"Direction: `{position.OriginDirection}` (Matched: โœ…)\n" + - $"Exchange PnL: `${recentPosition.ProfitAndLoss.Realized:F2}`\n" + - $"Position was actually filled and closed"); - return (true, false); - } - else - { - // Found positions in history but none match the direction - var allHistoryDirections = positionHistory.Select(p => p.OriginDirection).Distinct().ToList(); - await LogDebug( - $"โš ๏ธ Direction Mismatch in History\n" + - $"Looking for: `{position.OriginDirection}`\n" + - $"Found in history: `{string.Join(", ", allHistoryDirections)}`\n" + - $"No matching position found"); - } - } - - await LogDebug( - $"โŒ No Position Found in Exchange History\nPosition: `{position.Identifier}`\nPosition was never filled"); - return (false, false); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error checking position history for position {PositionId}", position.Identifier); - await LogWarning( - $"โš ๏ธ Web3Proxy Error During Position History Check\n" + - $"Position: `{position.Identifier}`\n" + - $"Error: {ex.Message}\n" + - $"Will retry on next execution cycle"); - return (false, true); // found=false, hadError=true - } + await LoadAccount(); } - private async Task RecoverRecentlyCanceledPositions() + protected virtual async Task VerifyAndUpdateBalanceAsync() { - if (Config.IsForBacktest) + await VerifyAndUpdateBalance(); + } + + protected virtual async Task GetInternalPositionForUpdate(Position position) + { + return position; // Default implementation for backtest + } + + protected virtual async Task> GetBrokerPositionsForUpdate(Account account) + { + return new List(); // Default implementation for backtest + } + + protected virtual async Task UpdatePositionWithBrokerData(Position position, List brokerPositions) + { + // Default: do nothing for backtest + } + + protected virtual async Task GetCurrentCandleForPositionClose(Account account, string ticker) + { + return LastCandle; // Default for backtest + } + + protected virtual async Task CanOpenPositionWithBrokerChecks(LightSignal signal) + { + // Check broker positions for live trading + var canOpenPosition = await CheckBrokerPositions(); + if (!canOpenPosition) { - // For backtests, we don't have broker positions, so skip recovery + return false; + } + + return true; + } + + protected virtual async Task SendPositionToCopyTradingStream(Position position) + { + await SendPositionToCopyTrading(position); + } + + protected virtual async Task NotifyAgentAndPlatformAsync(NotificationEventType eventType, Position position) + { + await NotifyAgentAndPlatformGrainAsync(eventType, position); + } + + protected virtual async Task UpdatePositionInDatabaseAsync(Position position) + { + await UpdatePositionDatabase(position); + } + + protected virtual async Task SendClosedPositionToMessenger(Position position, User user) + { + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async messengerService => { await messengerService.SendClosedPosition(position, user); }); + } + + protected virtual async Task CancelAllOrdersAsync() + { + await CancelAllOrders(); + } + + // Interface implementation + public async Task LogInformation(string message) + { + await LogInformationAsync(message); + } + + public async Task LogWarning(string message) + { + await LogWarningAsync(message); + } + + protected virtual async Task LogInformationAsync(string message) + { + if (Config.TradingType == TradingType.BacktestFutures) return; - } + + Logger.LogInformation(message); try { - // Get the last (most recent) position from all positions - var lastPosition = Positions.Values.LastOrDefault(); - if (lastPosition == null) - { - return; // No positions at all - } - - // Only attempt recovery if the last position is cancelled and recovery hasn't been attempted yet - if (lastPosition.Status != PositionStatus.Canceled || lastPosition.RecoveryAttempted) - { - return; - } - - // Also get count of cancelled positions for logging - var canceledPositionsCount = Positions.Values.Count(p => p.Status == PositionStatus.Canceled); - - await LogDebug( - $"๐Ÿ”„ Position Recovery Check\nFound `{canceledPositionsCount}` canceled positions\nLast position `{lastPosition.Identifier}` is cancelled\nAttempting recovery from broker..."); - - // Get the signal for the last position - if (!Signals.TryGetValue(lastPosition.SignalIdentifier, out var signal)) - { - await LogWarning( - $"โš ๏ธ Signal Not Found for Recovery\nPosition: `{lastPosition.Identifier}`\nSignal: `{lastPosition.SignalIdentifier}`\nCannot recover without signal"); - return; - } - - // Mark recovery as attempted before proceeding - lastPosition.RecoveryAttempted = true; - Positions[lastPosition.Identifier] = lastPosition; - - // Attempt recovery for the last position only - bool recovered = await RecoverOpenPositionFromBroker(signal, lastPosition); - if (recovered) - { - await LogInformation( - $"๐ŸŽ‰ Position Recovery Successful\nPosition `{lastPosition.Identifier}` recovered from broker\nStatus restored to Filled\nWill continue normal processing"); - } - else - { - await LogDebug( - $"โŒ Recovery Not Needed\nPosition `{lastPosition.Identifier}` confirmed canceled\nNo open position found on broker"); - } + await SendTradeMessageAsync(message); } - catch (Exception ex) + catch (Exception e) { - Logger.LogError(ex, "Error during recently canceled positions recovery"); - await LogWarning($"Position recovery check failed due to exception: {ex.Message}"); + Console.WriteLine(e); } } - private async Task RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal) + protected virtual async Task LogWarningAsync(string message) { - if (Config.IsForBacktest) - { - // For backtests, we don't have broker positions, so return false - return false; - } + if (Config.TradingType == TradingType.BacktestFutures) + return; + + message = $"[{Config.Name}] {message}"; try { - await LogDebug( - $"๐Ÿ”„ Attempting Position Recovery\n" + - $"Signal: `{signal.Identifier}`\n" + - $"Position: `{positionForSignal.Identifier}`\n" + - $"Direction: `{positionForSignal.OriginDirection}`\n" + - $"Ticker: `{Config.Ticker}`\n" + - $"Checking broker for open position..."); - - Position brokerPosition = null; - await ServiceScopeHelpers.WithScopedService(_scopeFactory, - async exchangeService => - { - var brokerPositions = await exchangeService.GetBrokerPositions(Account); - brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker); - }); - - if (brokerPosition != null) - { - // Check if the broker position matches our expected direction - if (brokerPosition.OriginDirection == positionForSignal.OriginDirection) - { - await LogInformation( - $"โœ… Position Recovered from Broker\n" + - $"Position: `{positionForSignal.Identifier}`\n" + - $"Direction: `{positionForSignal.OriginDirection}` (Matched: โœ…)\n" + - $"Broker Position Size: `{brokerPosition.Open?.Quantity ?? 0}`\n" + - $"Broker Position Price: `${brokerPosition.Open?.Price ?? 0:F2}`\n" + - $"Restoring position status to Filled"); - - // Update position status back to Filled (from Canceled) - positionForSignal.Status = PositionStatus.Filled; - await SetPositionStatus(signal.Identifier, PositionStatus.Filled); - - // Update signal status back to PositionOpen since position is recovered - SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen); - - // Update PnL from broker position - var brokerNetPnL = brokerPosition.GetPnLBeforeFees(); - UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL); - - // Update trade details if available - if (positionForSignal.Open != null && brokerPosition.Open != null) - { - positionForSignal.Open.SetStatus(TradeStatus.Filled); - if (brokerPosition.Open.ExchangeOrderId != null) - { - positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId); - } - } - - // Update stop loss and take profit trades if available - if (positionForSignal.StopLoss != null && brokerPosition.StopLoss != null) - { - positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId); - } - - if (positionForSignal.TakeProfit1 != null && brokerPosition.TakeProfit1 != null) - { - positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId); - } - - if (positionForSignal.TakeProfit2 != null && brokerPosition.TakeProfit2 != null) - { - positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId); - } - - // Update database - await UpdatePositionDatabase(positionForSignal); - - // Notify about position recovery - await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionUpdated, positionForSignal); - - await LogInformation( - $"๐ŸŽ‰ Position Recovery Complete\n" + - $"Position `{positionForSignal.Identifier}` successfully recovered\n" + - $"Status restored to Filled\n" + - $"Database and internal state updated"); - - return true; - } - else - { - await LogWarning( - $"โš ๏ธ Direction Mismatch During Recovery\n" + - $"Expected: `{positionForSignal.OriginDirection}`\n" + - $"Broker Position: `{brokerPosition.OriginDirection}`\n" + - $"Cannot recover - directions don't match"); - } - } - else - { - await LogDebug( - $"โŒ No Open Position Found on Broker\n" + - $"Ticker: `{Config.Ticker}`\n" + - $"Position recovery not possible"); - } - - return false; + await SendTradeMessageAsync(message, true); } - catch (Exception ex) + catch (Exception e) { - Logger.LogError(ex, "Error during position recovery for position {PositionId}", - positionForSignal.Identifier); - await LogWarning($"Position recovery failed due to exception: {ex.Message}"); - return false; + Console.WriteLine(e); + } + } + + protected virtual async Task LogDebugAsync(string message) + { + if (Config.TradingType == TradingType.BacktestFutures) + return; + + Logger.LogDebug(message); + + try + { + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async messengerService => + { + await messengerService.SendDebugMessage($"๐Ÿค– {Account.User.AgentName} - {Config.Name}\n{message}"); + }); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + + protected virtual async Task SendTradeMessageAsync(string message, bool isBadBehavior = false) + { + if (Config.TradingType == TradingType.Futures) + { + var user = Account.User; + var messageWithBotName = $"๐Ÿค– {user.AgentName} - {Config.Name}\n{message}"; + await ServiceScopeHelpers.WithScopedService(_scopeFactory, + async messengerService => + { + await messengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user); + }); } } } \ No newline at end of file diff --git a/src/Managing.Application/GeneticService.cs b/src/Managing.Application/GeneticService.cs index b6173064..d896c2c0 100644 --- a/src/Managing.Application/GeneticService.cs +++ b/src/Managing.Application/GeneticService.cs @@ -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, diff --git a/src/Managing.Application/Grains/BundleBacktestGrain.cs b/src/Managing.Application/Grains/BundleBacktestGrain.cs deleted file mode 100644 index 4749113f..00000000 --- a/src/Managing.Application/Grains/BundleBacktestGrain.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -[StatelessWorker] -[TradingPlacement] // Use custom compute placement with random fallback -public class BundleBacktestGrain : Grain, IBundleBacktestGrain, IRemindable -{ - private readonly ILogger _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 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(); - var messengerService = scope.ServiceProvider.GetRequiredService(); - - // 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 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); - } - } - - /// - /// Generates individual backtest requests from variant configuration - /// - private async Task> GenerateBacktestRequestsFromVariants( - BundleBacktestRequest bundleRequest) - { - try - { - // Deserialize the variant configurations - var universalConfig = - JsonSerializer.Deserialize(bundleRequest.UniversalConfigJson); - var dateTimeRanges = JsonSerializer.Deserialize>(bundleRequest.DateTimeRangesJson); - var moneyManagementVariants = - JsonSerializer.Deserialize>(bundleRequest.MoneyManagementVariantsJson); - var tickerVariants = JsonSerializer.Deserialize>(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(); - } - - // Get the first account for the user using AccountService - var firstAccount = await ServiceScopeHelpers.WithScopedService( - _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(); - } - - var backtestRequests = new List(); - - 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(); - } - } - - 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>( - _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 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() - }; - } - - // 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 - - /// - /// Handles reminder callbacks for automatic retry of failed bundle backtests - /// - 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(); - - // 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); - } - } - - /// - /// Registers a retry reminder for this bundle request - /// - 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()); - } - } - - /// - /// Unregisters the retry reminder for this bundle request - /// - 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 -} \ No newline at end of file diff --git a/src/Managing.Application/Grains/GeneticBacktestGrain.cs b/src/Managing.Application/Grains/GeneticBacktestGrain.cs deleted file mode 100644 index fcf0b85d..00000000 --- a/src/Managing.Application/Grains/GeneticBacktestGrain.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -[StatelessWorker] -[TradingPlacement] // Use custom compute placement with random fallback -public class GeneticBacktestGrain : Grain, IGeneticBacktestGrain -{ - private readonly ILogger _logger; - private readonly IServiceScopeFactory _scopeFactory; - - public GeneticBacktestGrain( - ILogger 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(); - - // 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>( - _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(); - 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 - { - } - } - } -} \ No newline at end of file diff --git a/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs b/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs index 46f541cc..75206523 100644 --- a/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs +++ b/src/Managing.Application/ManageBot/StartCopyTradingCommandHandler.cs @@ -187,7 +187,7 @@ namespace Managing.Application.ManageBot MasterBotUserId = masterBot.User.Id, // Set computed/default properties - IsForBacktest = false, + TradingType = TradingType.Futures, Name = masterConfig.Name }; diff --git a/src/Managing.Application/Scenarios/ScenarioRunnerGrain.cs b/src/Managing.Application/Scenarios/ScenarioRunnerGrain.cs index dde4d6c7..c3fc5bf7 100644 --- a/src/Managing.Application/Scenarios/ScenarioRunnerGrain.cs +++ b/src/Managing.Application/Scenarios/ScenarioRunnerGrain.cs @@ -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); diff --git a/src/Managing.Application/Trading/Commands/CloseBacktestFuturesPositionCommand.cs b/src/Managing.Application/Trading/Commands/CloseBacktestFuturesPositionCommand.cs new file mode 100644 index 00000000..88a3bac7 --- /dev/null +++ b/src/Managing.Application/Trading/Commands/CloseBacktestFuturesPositionCommand.cs @@ -0,0 +1,20 @@ +using Managing.Domain.Trades; +using MediatR; + +namespace Managing.Application.Trading.Commands +{ + public class CloseBacktestFuturesPositionCommand : IRequest + { + 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; } + } +} + diff --git a/src/Managing.Application/Trading/Commands/CloseFuturesPositionCommand.cs b/src/Managing.Application/Trading/Commands/CloseFuturesPositionCommand.cs new file mode 100644 index 00000000..e3768cf8 --- /dev/null +++ b/src/Managing.Application/Trading/Commands/CloseFuturesPositionCommand.cs @@ -0,0 +1,20 @@ +using Managing.Domain.Trades; +using MediatR; + +namespace Managing.Application.Trading.Commands +{ + public class CloseFuturesPositionCommand : IRequest + { + 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; } + } +} + diff --git a/src/Managing.Application/Trading/Commands/OpenPositionRequest.cs b/src/Managing.Application/Trading/Commands/OpenPositionRequest.cs index ea28c1ae..0be18466 100644 --- a/src/Managing.Application/Trading/Commands/OpenPositionRequest.cs +++ b/src/Managing.Application/Trading/Commands/OpenPositionRequest.cs @@ -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; } } } \ No newline at end of file diff --git a/src/Managing.Application/Trading/Handlers/CloseBacktestFuturesPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/CloseBacktestFuturesPositionCommandHandler.cs new file mode 100644 index 00000000..78d604ee --- /dev/null +++ b/src/Managing.Application/Trading/Handlers/CloseBacktestFuturesPositionCommandHandler.cs @@ -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 logger = null) + : ICommandHandler +{ + public async Task 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; + } + } +} + diff --git a/src/Managing.Application/Trading/Handlers/CloseFuturesPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/CloseFuturesPositionCommandHandler.cs new file mode 100644 index 00000000..9ce3ab9e --- /dev/null +++ b/src/Managing.Application/Trading/Handlers/CloseFuturesPositionCommandHandler.cs @@ -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 logger = null) + : ICommandHandler +{ + public async Task 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; + } + } +} + diff --git a/src/Managing.Application/Trading/Handlers/ClosePositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/ClosePositionCommandHandler.cs index aa1ed20e..db5288ad 100644 --- a/src/Managing.Application/Trading/Handlers/ClosePositionCommandHandler.cs +++ b/src/Managing.Application/Trading/Handlers/ClosePositionCommandHandler.cs @@ -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)) { diff --git a/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs index b762262a..80eab73a 100644 --- a/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs +++ b/src/Managing.Application/Trading/Handlers/OpenPositionCommandHandler.cs @@ -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 diff --git a/src/Managing.Application/Trading/StatisticService.cs b/src/Managing.Application/Trading/StatisticService.cs index c3962b18..bc3cf79c 100644 --- a/src/Managing.Application/Trading/StatisticService.cs +++ b/src/Managing.Application/Trading/StatisticService.cs @@ -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, diff --git a/src/Managing.Application/Workers/BacktestComputeWorker.cs b/src/Managing.Application/Workers/BacktestComputeWorker.cs index 501930e2..21c78bbf 100644 --- a/src/Managing.Application/Workers/BacktestComputeWorker.cs +++ b/src/Managing.Application/Workers/BacktestComputeWorker.cs @@ -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, diff --git a/src/Managing.Application/Workers/BundleBacktestWorker.cs b/src/Managing.Application/Workers/BundleBacktestWorker.cs index 47c6f404..bb651aa2 100644 --- a/src/Managing.Application/Workers/BundleBacktestWorker.cs +++ b/src/Managing.Application/Workers/BundleBacktestWorker.cs @@ -253,7 +253,7 @@ public class BundleBacktestWorker : BaseWorker 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, diff --git a/src/Managing.Bootstrap/ApiBootstrap.cs b/src/Managing.Bootstrap/ApiBootstrap.cs index 5bda770d..0f18c4c8 100644 --- a/src/Managing.Bootstrap/ApiBootstrap.cs +++ b/src/Managing.Bootstrap/ApiBootstrap.cs @@ -396,6 +396,9 @@ public static class ApiBootstrap services.AddScoped(); services.AddScoped(); services.AddTransient, OpenPositionCommandHandler>(); + services.AddTransient, CloseBacktestFuturesPositionCommandHandler>(); + services.AddTransient, CloseFuturesPositionCommandHandler>(); + // Keep old handler for backward compatibility services.AddTransient, ClosePositionCommandHandler>(); // Processors diff --git a/src/Managing.Common/Enums.cs b/src/Managing.Common/Enums.cs index 9666052d..a570df69 100644 --- a/src/Managing.Common/Enums.cs +++ b/src/Managing.Common/Enums.cs @@ -606,4 +606,20 @@ public static class Enums /// Genetic } + + /// + /// Type of trading mode for trading bots + /// + public enum TradingType + { + /// + /// Live futures trading mode + /// + Futures, + + /// + /// Backtest futures trading mode + /// + BacktestFutures + } } \ No newline at end of file diff --git a/src/Managing.Domain.Tests/IndicatorTests.cs b/src/Managing.Domain.Tests/IndicatorTests.cs index 3fedf5a5..5faa5b61 100644 --- a/src/Managing.Domain.Tests/IndicatorTests.cs +++ b/src/Managing.Domain.Tests/IndicatorTests.cs @@ -42,7 +42,7 @@ public class IndicatorTests public void CalculateIndicatorsValues_WithNullScenario_ReturnsEmptyDictionary() { // Arrange - var candles = new HashSet { CreateTestCandle() }; + var candles = new List { CreateTestCandle() }; // Act var result = TradingBox.CalculateIndicatorsValues(null, candles); @@ -56,7 +56,7 @@ public class IndicatorTests { // Arrange var scenario = new Scenario(name: "TestScenario"); - var candles = new HashSet { CreateTestCandle() }; + var candles = new List { CreateTestCandle() }; // Act var result = TradingBox.CalculateIndicatorsValues(scenario, candles); @@ -70,7 +70,7 @@ public class IndicatorTests { // Arrange var scenario = new Scenario(name: "TestScenario") { Indicators = null }; - var candles = new HashSet { CreateTestCandle() }; + var candles = new List { CreateTestCandle() }; // Act var result = TradingBox.CalculateIndicatorsValues(scenario, candles); @@ -83,7 +83,7 @@ public class IndicatorTests public void CalculateIndicatorsValues_WithValidScenario_DoesNotThrow() { // Arrange - Create more realistic candle data - var candles = new HashSet(); + var candles = new List(); for (int i = 0; i < 20; i++) { var date = TestDate.AddMinutes(i * 5); @@ -120,7 +120,7 @@ public class IndicatorTests public void CalculateIndicatorsValues_WithMultipleIndicators_DoesNotThrow() { // Arrange - Create more realistic candle data - var candles = new HashSet(); + var candles = new List(); for (int i = 0; i < 30; i++) { var date = TestDate.AddMinutes(i * 5); @@ -160,7 +160,7 @@ public class IndicatorTests public void CalculateIndicatorsValues_WithExceptionInIndicator_CatchesAndContinues() { // Arrange - Create realistic candle data - var candles = new HashSet(); + var candles = new List(); for (int i = 0; i < 25; i++) { candles.Add(CreateTestCandle(date: TestDate.AddMinutes(i))); @@ -370,7 +370,7 @@ public class IndicatorTests public void CalculateIndicatorsValues_HandlesLargeCandleSets() { // Arrange - var candles = new HashSet(); + var candles = new List(); for (int i = 0; i < 100; i++) { var date = TestDate.AddMinutes(i * 2); @@ -427,8 +427,8 @@ public class IndicatorTests public void CalculateIndicatorsValues_DoesNotModifyInputCandles() { // Arrange - var originalCandles = new HashSet { CreateTestCandle() }; - var candlesCopy = new HashSet(originalCandles.Select(c => new Candle + var originalCandles = new List { CreateTestCandle() }; + var candlesCopy = new List(originalCandles.Select(c => new Candle { Open = c.Open, High = c.High, diff --git a/src/Managing.Domain.Tests/Indicators/RunIndicatorsBase.cs b/src/Managing.Domain.Tests/Indicators/RunIndicatorsBase.cs index be1c0321..cacfe661 100644 --- a/src/Managing.Domain.Tests/Indicators/RunIndicatorsBase.cs +++ b/src/Managing.Domain.Tests/Indicators/RunIndicatorsBase.cs @@ -22,11 +22,8 @@ public class RunIndicatorsBase /// List of signals generated by the indicator protected List RunIndicatorAndGetSignals(IndicatorBase indicator) { - // Convert list to HashSet as expected by the Run method - var candleSet = TestCandles.ToHashSet(); - - // Run the indicator - var signals = indicator.Run(candleSet); + // Use List directly - preserves chronological order required for indicators + var signals = indicator.Run(TestCandles); return signals ?? new List(); } diff --git a/src/Managing.Domain.Tests/SignalProcessingTests.cs b/src/Managing.Domain.Tests/SignalProcessingTests.cs index 1bc1f492..f0d06fd6 100644 --- a/src/Managing.Domain.Tests/SignalProcessingTests.cs +++ b/src/Managing.Domain.Tests/SignalProcessingTests.cs @@ -84,7 +84,7 @@ public class SignalProcessingTests : TradingBoxTests public void GetSignal_WithNullScenario_ThrowsArgumentNullException() { // Arrange - var candles = new HashSet { CreateTestCandle() }; + var candles = new List { CreateTestCandle() }; var signals = new Dictionary(); // Act & Assert @@ -98,7 +98,7 @@ public class SignalProcessingTests : TradingBoxTests public void GetSignal_WithEmptyCandles_ReturnsNull() { // Arrange - var candles = new HashSet(); + var candles = new List(); var scenario = CreateTestScenario(CreateTestIndicator()); var signals = new Dictionary(); @@ -113,7 +113,7 @@ public class SignalProcessingTests : TradingBoxTests public void GetSignal_WithScenarioHavingNoIndicators_ReturnsNull() { // Arrange - var candles = new HashSet { CreateTestCandle() }; + var candles = new List { CreateTestCandle() }; var scenario = CreateTestScenario(); // Empty indicators var signals = new Dictionary(); @@ -348,8 +348,8 @@ public class SignalProcessingTests : TradingBoxTests testCandles.Should().NotBeNull(); testCandles.Should().NotBeEmpty(); - // Use last 100 candles for the test - var candles = testCandles.TakeLast(100).ToHashSet(); + // Use last 100 candles for the test (preserve order) + var candles = testCandles.TakeLast(100).ToList(); var scenario = CreateTestScenario(CreateTestIndicator(IndicatorType.Stc, "StcIndicator")); var signals = new Dictionary(); @@ -373,8 +373,8 @@ public class SignalProcessingTests : TradingBoxTests testCandles.Should().NotBeNull(); testCandles.Should().NotBeEmpty(); - // Use last 500 candles for the test - var candles = testCandles.TakeLast(500).ToHashSet(); + // Use last 500 candles for the test (preserve order) + var candles = testCandles.TakeLast(500).ToList(); var scenario = CreateTestScenario(CreateTestIndicator(IndicatorType.Stc, "StcIndicator")); var signals = new Dictionary(); diff --git a/src/Managing.Domain/Backtests/Backtest.cs b/src/Managing.Domain/Backtests/Backtest.cs index 1a04d1a1..35982d92 100644 --- a/src/Managing.Domain/Backtests/Backtest.cs +++ b/src/Managing.Domain/Backtests/Backtest.cs @@ -5,6 +5,7 @@ using Managing.Domain.Bots; using Managing.Domain.Indicators; using Managing.Domain.Trades; using Managing.Domain.Users; +using static Managing.Common.Enums; namespace Managing.Domain.Backtests; @@ -65,7 +66,7 @@ public class Backtest Timeframe = Config.Timeframe, IsForWatchingOnly = false, // Always start as active bot BotTradingBalance = initialTradingBalance, - IsForBacktest = false, // Always false for live bots + TradingType = TradingType.Futures, // Always Futures for live bots CooldownPeriod = Config.CooldownPeriod, MaxLossStreak = Config.MaxLossStreak, MaxPositionTimeHours = Config.MaxPositionTimeHours, // Properly copy nullable value @@ -99,7 +100,7 @@ public class Backtest Timeframe = Config.Timeframe, IsForWatchingOnly = Config.IsForWatchingOnly, BotTradingBalance = balance, - IsForBacktest = true, + TradingType = TradingType.BacktestFutures, CooldownPeriod = Config.CooldownPeriod, MaxLossStreak = Config.MaxLossStreak, MaxPositionTimeHours = Config.MaxPositionTimeHours, diff --git a/src/Managing.Domain/Bots/TradingBotConfig.cs b/src/Managing.Domain/Bots/TradingBotConfig.cs index 22407212..c5cc0c3f 100644 --- a/src/Managing.Domain/Bots/TradingBotConfig.cs +++ b/src/Managing.Domain/Bots/TradingBotConfig.cs @@ -21,7 +21,7 @@ public class TradingBotConfig [Id(5)] [Required] public decimal BotTradingBalance { get; set; } - [Id(6)] [Required] public bool IsForBacktest { get; set; } + [Id(6)] [Required] public TradingType TradingType { get; set; } [Id(7)] [Required] public int CooldownPeriod { get; set; } diff --git a/src/Managing.Domain/Indicators/Base/BollingerBandsBase.cs b/src/Managing.Domain/Indicators/Base/BollingerBandsBase.cs index 19976ec5..bb6bc812 100644 --- a/src/Managing.Domain/Indicators/Base/BollingerBandsBase.cs +++ b/src/Managing.Domain/Indicators/Base/BollingerBandsBase.cs @@ -18,7 +18,7 @@ public abstract class BollingerBandsBase : IndicatorBase StDev = stdev; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= Period) { @@ -45,7 +45,7 @@ public abstract class BollingerBandsBase : IndicatorBase } } - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= Period) { @@ -81,7 +81,7 @@ public abstract class BollingerBandsBase : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { @@ -93,7 +93,7 @@ public abstract class BollingerBandsBase : IndicatorBase /// /// Abstract method for processing Bollinger Bands signals - implemented by child classes /// - protected abstract void ProcessBollingerBandsSignals(List bbResults, HashSet candles); + protected abstract void ProcessBollingerBandsSignals(List bbResults, IReadOnlyList candles); /// /// Maps Bollinger Bands results to candle objects with all BollingerBandsResult properties diff --git a/src/Managing.Domain/Indicators/Context/BollingerBandsVolatilityProtection.cs b/src/Managing.Domain/Indicators/Context/BollingerBandsVolatilityProtection.cs index a465b4e6..043e2406 100644 --- a/src/Managing.Domain/Indicators/Context/BollingerBandsVolatilityProtection.cs +++ b/src/Managing.Domain/Indicators/Context/BollingerBandsVolatilityProtection.cs @@ -20,7 +20,7 @@ public class BollingerBandsVolatilityProtection : BollingerBandsBase /// /// List of Bollinger Bands calculation results /// Candles to process - protected override void ProcessBollingerBandsSignals(List bbResults, HashSet candles) + protected override void ProcessBollingerBandsSignals(List bbResults, IReadOnlyList candles) { var bbCandles = MapBollingerBandsToCandle(bbResults, candles.TakeLast(Period.Value)).ToList(); diff --git a/src/Managing.Domain/Indicators/Context/StDevContext.cs b/src/Managing.Domain/Indicators/Context/StDevContext.cs index 53637f0e..60ed9759 100644 --- a/src/Managing.Domain/Indicators/Context/StDevContext.cs +++ b/src/Managing.Domain/Indicators/Context/StDevContext.cs @@ -18,7 +18,7 @@ public class StDevContext : IndicatorBase Period = period; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= Period) { @@ -44,7 +44,7 @@ public class StDevContext : IndicatorBase /// /// Runs the indicator using pre-calculated StdDev values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= Period) { @@ -85,7 +85,7 @@ public class StDevContext : IndicatorBase /// /// List of StdDev calculation results /// Candles to process - private void ProcessStDevSignals(List stDev, HashSet candles) + private void ProcessStDevSignals(List stDev, IReadOnlyList candles) { var stDevCandles = MapStDev(stDev, candles.TakeLast(Period.Value)); @@ -126,7 +126,7 @@ public class StDevContext : IndicatorBase AddSignal(lastCandle, TradeDirection.None, confidence); } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { var test = new IndicatorsResultBase() { diff --git a/src/Managing.Domain/Indicators/IIndicator.cs b/src/Managing.Domain/Indicators/IIndicator.cs index 731c5780..11c462f5 100644 --- a/src/Managing.Domain/Indicators/IIndicator.cs +++ b/src/Managing.Domain/Indicators/IIndicator.cs @@ -21,17 +21,17 @@ namespace Managing.Domain.Strategies double? KFactor { get; set; } double? DFactor { get; set; } - List Run(HashSet candles); + List Run(IReadOnlyList candles); /// /// Runs the indicator using pre-calculated indicator values for performance optimization. /// If pre-calculated values are not available or not applicable, falls back to regular Run(). /// - /// The candles to process + /// The candles to process (must be ordered chronologically) /// Pre-calculated indicator values (optional) /// List of signals generated by the indicator - List Run(HashSet candles, IndicatorsResultBase preCalculatedValues); + List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues); - IndicatorsResultBase GetIndicatorValues(HashSet candles); + IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles); } } \ No newline at end of file diff --git a/src/Managing.Domain/Indicators/IndicatorBase.cs b/src/Managing.Domain/Indicators/IndicatorBase.cs index 792e05b8..165f34e5 100644 --- a/src/Managing.Domain/Indicators/IndicatorBase.cs +++ b/src/Managing.Domain/Indicators/IndicatorBase.cs @@ -55,7 +55,7 @@ namespace Managing.Domain.Strategies public User User { get; set; } - public virtual List Run(HashSet candles) + public virtual List Run(IReadOnlyList candles) { throw new NotImplementedException(); } @@ -64,14 +64,16 @@ namespace Managing.Domain.Strategies /// Runs the indicator using pre-calculated values if available, otherwise falls back to regular Run(). /// Default implementation falls back to regular Run() - override in derived classes to use pre-calculated values. /// - public virtual List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + /// The candles to process (must be ordered chronologically) + /// Pre-calculated indicator values (optional) + public virtual List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { // Default implementation: ignore pre-calculated values and use regular Run() // Derived classes should override this to use pre-calculated values for performance return Run(candles); } - public virtual IndicatorsResultBase GetIndicatorValues(HashSet candles) + public virtual IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { throw new NotImplementedException(); } diff --git a/src/Managing.Domain/Indicators/Signals/BollingerBandsPercentBMomentumBreakout.cs b/src/Managing.Domain/Indicators/Signals/BollingerBandsPercentBMomentumBreakout.cs index 285dab66..0d42de22 100644 --- a/src/Managing.Domain/Indicators/Signals/BollingerBandsPercentBMomentumBreakout.cs +++ b/src/Managing.Domain/Indicators/Signals/BollingerBandsPercentBMomentumBreakout.cs @@ -19,7 +19,7 @@ public class BollingerBandsPercentBMomentumBreakout : BollingerBandsBase /// Long signals: %B crosses above 0.8 after being below (strong upward momentum) /// Short signals: %B crosses below 0.2 after being above (strong downward momentum) /// - protected override void ProcessBollingerBandsSignals(List bbResults, HashSet candles) + protected override void ProcessBollingerBandsSignals(List bbResults, IReadOnlyList candles) { var bbCandles = MapBollingerBandsToCandle(bbResults, candles.TakeLast(Period.Value)).ToList(); diff --git a/src/Managing.Domain/Indicators/Signals/ChandelierExitIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/ChandelierExitIndicatorBase.cs index 11400b56..75404de5 100644 --- a/src/Managing.Domain/Indicators/Signals/ChandelierExitIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/ChandelierExitIndicatorBase.cs @@ -21,7 +21,7 @@ public class ChandelierExitIndicatorBase : IndicatorBase MinimumHistory = 1 + Period.Value; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= MinimumHistory) { @@ -43,7 +43,7 @@ public class ChandelierExitIndicatorBase : IndicatorBase /// /// Runs the indicator using pre-calculated Chandelier values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= MinimumHistory) { @@ -88,7 +88,7 @@ public class ChandelierExitIndicatorBase : IndicatorBase /// This method is shared between the regular Run() and optimized Run() methods. /// /// Candles to process - private void ProcessChandelierSignals(HashSet candles) + private void ProcessChandelierSignals(IReadOnlyList candles) { GetSignals(ChandelierType.Long, candles); GetSignals(ChandelierType.Short, candles); @@ -103,13 +103,13 @@ public class ChandelierExitIndicatorBase : IndicatorBase private void ProcessChandelierSignalsWithPreCalculated( List chandelierLong, List chandelierShort, - HashSet candles) + IReadOnlyList candles) { GetSignalsWithPreCalculated(ChandelierType.Long, chandelierLong, candles); GetSignalsWithPreCalculated(ChandelierType.Short, chandelierShort, candles); } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { @@ -118,7 +118,7 @@ public class ChandelierExitIndicatorBase : IndicatorBase }; } - private void GetSignals(ChandelierType chandelierType, HashSet candles) + private void GetSignals(ChandelierType chandelierType, IReadOnlyList candles) { var chandelier = candles.GetChandelier(Period.Value, Multiplier.Value, chandelierType) .Where(s => s.ChandelierExit.HasValue).ToList(); @@ -126,7 +126,7 @@ public class ChandelierExitIndicatorBase : IndicatorBase } private void GetSignalsWithPreCalculated(ChandelierType chandelierType, List chandelier, - HashSet candles) + IReadOnlyList candles) { ProcessChandelierSignalsForType(chandelier, chandelierType, candles); } @@ -139,7 +139,7 @@ public class ChandelierExitIndicatorBase : IndicatorBase /// Type of Chandelier (Long or Short) /// Candles to process private void ProcessChandelierSignalsForType(List chandelier, ChandelierType chandelierType, - HashSet candles) + IReadOnlyList candles) { var chandelierCandle = MapChandelierToCandle(chandelier, candles.TakeLast(MinimumHistory)); if (chandelierCandle.Count == 0) diff --git a/src/Managing.Domain/Indicators/Signals/DualEmaCrossIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/DualEmaCrossIndicatorBase.cs index 859a8369..821323da 100644 --- a/src/Managing.Domain/Indicators/Signals/DualEmaCrossIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/DualEmaCrossIndicatorBase.cs @@ -21,7 +21,7 @@ public class DualEmaCrossIndicatorBase : EmaBaseIndicatorBase MinimumHistory = Math.Max(fastPeriod, slowPeriod) * 2; } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { @@ -30,7 +30,7 @@ public class DualEmaCrossIndicatorBase : EmaBaseIndicatorBase }; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= MinimumHistory) { @@ -58,7 +58,7 @@ public class DualEmaCrossIndicatorBase : EmaBaseIndicatorBase /// /// Runs the indicator using pre-calculated EMA values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= MinimumHistory) { @@ -105,7 +105,7 @@ public class DualEmaCrossIndicatorBase : EmaBaseIndicatorBase /// List of Fast EMA calculation results /// List of Slow EMA calculation results /// Candles to process - private void ProcessDualEmaCrossSignals(List fastEma, List slowEma, HashSet candles) + private void ProcessDualEmaCrossSignals(List fastEma, List slowEma, IReadOnlyList candles) { var dualEmaCandles = MapDualEmaToCandle(fastEma, slowEma, candles.TakeLast(MinimumHistory)); diff --git a/src/Managing.Domain/Indicators/Signals/EmaCrossIndicator.cs b/src/Managing.Domain/Indicators/Signals/EmaCrossIndicator.cs index 93eea26e..ea879cb8 100644 --- a/src/Managing.Domain/Indicators/Signals/EmaCrossIndicator.cs +++ b/src/Managing.Domain/Indicators/Signals/EmaCrossIndicator.cs @@ -18,7 +18,7 @@ public class EmaCrossIndicator : EmaBaseIndicatorBase Period = period; } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { @@ -26,7 +26,7 @@ public class EmaCrossIndicator : EmaBaseIndicatorBase }; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= Period) { @@ -52,7 +52,7 @@ public class EmaCrossIndicator : EmaBaseIndicatorBase /// /// Runs the indicator using pre-calculated EMA values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= Period) { @@ -93,7 +93,7 @@ public class EmaCrossIndicator : EmaBaseIndicatorBase /// /// List of EMA calculation results /// Candles to process - private void ProcessEmaCrossSignals(List ema, HashSet candles) + private void ProcessEmaCrossSignals(List ema, IReadOnlyList candles) { var emaCandles = MapEmaToCandle(ema, candles.TakeLast(Period.Value)); diff --git a/src/Managing.Domain/Indicators/Signals/EmaCrossIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/EmaCrossIndicatorBase.cs index 4eb4157c..87cab52e 100644 --- a/src/Managing.Domain/Indicators/Signals/EmaCrossIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/EmaCrossIndicatorBase.cs @@ -18,7 +18,7 @@ public class EmaCrossIndicatorBase : EmaBaseIndicatorBase Period = period; } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { @@ -26,7 +26,7 @@ public class EmaCrossIndicatorBase : EmaBaseIndicatorBase }; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= Period) { @@ -52,7 +52,7 @@ public class EmaCrossIndicatorBase : EmaBaseIndicatorBase /// /// Runs the indicator using pre-calculated EMA values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= Period) { @@ -93,7 +93,7 @@ public class EmaCrossIndicatorBase : EmaBaseIndicatorBase /// /// List of EMA calculation results /// Candles to process - private void ProcessEmaCrossSignals(List ema, HashSet candles) + private void ProcessEmaCrossSignals(List ema, IReadOnlyList candles) { var emaCandles = MapEmaToCandle(ema, candles.TakeLast(Period.Value).ToHashSet()); diff --git a/src/Managing.Domain/Indicators/Signals/LaggingSTC.cs b/src/Managing.Domain/Indicators/Signals/LaggingSTC.cs index a0fd1c65..22560e07 100644 --- a/src/Managing.Domain/Indicators/Signals/LaggingSTC.cs +++ b/src/Managing.Domain/Indicators/Signals/LaggingSTC.cs @@ -28,7 +28,7 @@ public class LaggingSTC : IndicatorBase CyclePeriods = cyclePeriods; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= 2 * (SlowPeriods + CyclePeriods)) { @@ -54,7 +54,7 @@ public class LaggingSTC : IndicatorBase /// /// Runs the indicator using pre-calculated STC values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= 2 * (SlowPeriods + CyclePeriods)) { @@ -95,7 +95,7 @@ public class LaggingSTC : IndicatorBase /// /// List of STC calculation results /// Candles to process - private void ProcessLaggingStcSignals(List stc, HashSet candles) + private void ProcessLaggingStcSignals(List stc, IReadOnlyList candles) { var stcCandles = MapStcToCandle(stc, candles.TakeLast(CyclePeriods.Value * 3)); @@ -142,7 +142,7 @@ public class LaggingSTC : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { var stc = candles.GetStc(FastPeriods.Value, FastPeriods.Value, SlowPeriods.Value).ToList(); return new IndicatorsResultBase diff --git a/src/Managing.Domain/Indicators/Signals/MacdCrossIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/MacdCrossIndicatorBase.cs index eeb65e91..a0d4e65f 100644 --- a/src/Managing.Domain/Indicators/Signals/MacdCrossIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/MacdCrossIndicatorBase.cs @@ -21,7 +21,7 @@ public class MacdCrossIndicatorBase : IndicatorBase SignalPeriods = signalPeriods; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= 2 * (SlowPeriods + SignalPeriods)) { @@ -47,7 +47,7 @@ public class MacdCrossIndicatorBase : IndicatorBase /// /// Runs the indicator using pre-calculated MACD values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= 2 * (SlowPeriods + SignalPeriods)) { @@ -88,7 +88,7 @@ public class MacdCrossIndicatorBase : IndicatorBase /// /// List of MACD calculation results /// Candles to process - private void ProcessMacdSignals(List macd, HashSet candles) + private void ProcessMacdSignals(List macd, IReadOnlyList candles) { var macdCandle = MapMacdToCandle(macd, candles.TakeLast(SignalPeriods.Value)); @@ -114,7 +114,7 @@ public class MacdCrossIndicatorBase : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { diff --git a/src/Managing.Domain/Indicators/Signals/RsiDivergenceConfirmIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/RsiDivergenceConfirmIndicatorBase.cs index f11fd9da..04905aea 100644 --- a/src/Managing.Domain/Indicators/Signals/RsiDivergenceConfirmIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/RsiDivergenceConfirmIndicatorBase.cs @@ -22,7 +22,7 @@ public class RsiDivergenceConfirmIndicatorBase : IndicatorBase /// Get RSI signals /// /// - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= Period) { @@ -48,7 +48,7 @@ public class RsiDivergenceConfirmIndicatorBase : IndicatorBase /// /// Runs the indicator using pre-calculated RSI values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= Period) { @@ -90,7 +90,7 @@ public class RsiDivergenceConfirmIndicatorBase : IndicatorBase /// /// List of RSI calculation results /// Candles to process - private void ProcessRsiDivergenceConfirmSignals(List rsiResult, HashSet candles) + private void ProcessRsiDivergenceConfirmSignals(List rsiResult, IReadOnlyList candles) { var candlesRsi = MapRsiToCandle(rsiResult, candles.TakeLast(10 * Period.Value)); @@ -101,7 +101,7 @@ public class RsiDivergenceConfirmIndicatorBase : IndicatorBase GetShortSignals(candlesRsi, candles); } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { @@ -109,7 +109,7 @@ public class RsiDivergenceConfirmIndicatorBase : IndicatorBase }; } - private void GetLongSignals(List candlesRsi, HashSet candles) + private void GetLongSignals(List candlesRsi, IReadOnlyList candles) { // Set the low and high for first candle var firstCandleRsi = candlesRsi.First(c => c.Rsi > 0); @@ -182,7 +182,7 @@ public class RsiDivergenceConfirmIndicatorBase : IndicatorBase } } - private void GetShortSignals(List candlesRsi, HashSet candles) + private void GetShortSignals(List candlesRsi, IReadOnlyList candles) { // Set the low and high for first candle var firstCandleRsi = candlesRsi.First(c => c.Rsi > 0); @@ -256,7 +256,7 @@ public class RsiDivergenceConfirmIndicatorBase : IndicatorBase } } - private void CheckIfConfimation(CandleRsi currentCandle, TradeDirection direction, HashSet candles) + private void CheckIfConfimation(CandleRsi currentCandle, TradeDirection direction, IReadOnlyList candles) { var lastCandleOnPeriod = candles.TakeLast(Period.Value).ToList(); var signalsOnPeriod = Signals.Where(s => s.Date >= lastCandleOnPeriod[0].Date diff --git a/src/Managing.Domain/Indicators/Signals/RsiDivergenceIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/RsiDivergenceIndicatorBase.cs index 2bacbfe6..68dc7ceb 100644 --- a/src/Managing.Domain/Indicators/Signals/RsiDivergenceIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/RsiDivergenceIndicatorBase.cs @@ -25,7 +25,7 @@ public class RsiDivergenceIndicatorBase : IndicatorBase /// Get RSI signals /// /// - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (!Period.HasValue || candles.Count <= Period) { @@ -51,7 +51,7 @@ public class RsiDivergenceIndicatorBase : IndicatorBase /// /// Runs the indicator using pre-calculated RSI values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (!Period.HasValue || candles.Count <= Period) { @@ -93,7 +93,7 @@ public class RsiDivergenceIndicatorBase : IndicatorBase /// /// List of RSI calculation results /// Candles to process - private void ProcessRsiDivergenceSignals(List rsiResult, HashSet candles) + private void ProcessRsiDivergenceSignals(List rsiResult, IReadOnlyList candles) { var candlesRsi = MapRsiToCandle(rsiResult, candles.TakeLast(10 * Period.Value)); @@ -104,7 +104,7 @@ public class RsiDivergenceIndicatorBase : IndicatorBase GetShortSignals(candlesRsi, candles); } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { @@ -112,7 +112,7 @@ public class RsiDivergenceIndicatorBase : IndicatorBase }; } - private void GetLongSignals(List candlesRsi, HashSet candles) + private void GetLongSignals(List candlesRsi, IReadOnlyList candles) { // Set the low and high for first candle var firstCandleRsi = candlesRsi.First(c => c.Rsi > 0); @@ -183,7 +183,7 @@ public class RsiDivergenceIndicatorBase : IndicatorBase } } - private void GetShortSignals(List candlesRsi, HashSet candles) + private void GetShortSignals(List candlesRsi, IReadOnlyList candles) { // Set the low and high for first candle var firstCandleRsi = candlesRsi.First(c => c.Rsi > 0); @@ -255,7 +255,7 @@ public class RsiDivergenceIndicatorBase : IndicatorBase } } - private void AddSignal(CandleRsi candleSignal, TradeDirection direction, HashSet candles) + private void AddSignal(CandleRsi candleSignal, TradeDirection direction, IReadOnlyList candles) { var signal = new LightSignal(candleSignal.Ticker, direction, Confidence.Low, candleSignal, candleSignal.Date, candleSignal.Exchange, Type, SignalType, Name); diff --git a/src/Managing.Domain/Indicators/Signals/StcIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/StcIndicatorBase.cs index 2655d682..6529eecf 100644 --- a/src/Managing.Domain/Indicators/Signals/StcIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/StcIndicatorBase.cs @@ -21,7 +21,7 @@ public class StcIndicatorBase : IndicatorBase CyclePeriods = cyclePeriods; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= 2 * (SlowPeriods + CyclePeriods)) { @@ -50,7 +50,7 @@ public class StcIndicatorBase : IndicatorBase /// /// Runs the indicator using pre-calculated STC values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= 2 * (SlowPeriods + CyclePeriods)) { @@ -85,7 +85,7 @@ public class StcIndicatorBase : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { if (FastPeriods != null && SlowPeriods != null) { @@ -105,7 +105,7 @@ public class StcIndicatorBase : IndicatorBase /// /// List of STC calculation results /// Candles to process - private void ProcessStcSignals(List stc, HashSet candles) + private void ProcessStcSignals(List stc, IReadOnlyList candles) { if (CyclePeriods == null) return; diff --git a/src/Managing.Domain/Indicators/Signals/StochasticCrossIndicator.cs b/src/Managing.Domain/Indicators/Signals/StochasticCrossIndicator.cs index cc0a6399..1b5cea78 100644 --- a/src/Managing.Domain/Indicators/Signals/StochasticCrossIndicator.cs +++ b/src/Managing.Domain/Indicators/Signals/StochasticCrossIndicator.cs @@ -28,7 +28,7 @@ public class StochasticCrossIndicator : IndicatorBase DFactor = dFactor; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= 10 * StochPeriods.Value + 50) { @@ -55,7 +55,7 @@ public class StochasticCrossIndicator : IndicatorBase } } - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= 10 * StochPeriods.Value + 50) { @@ -95,7 +95,7 @@ public class StochasticCrossIndicator : IndicatorBase /// Long signals: %K crosses above %D when both lines are below 20 (oversold) /// Short signals: %K crosses below %D when both lines are above 80 (overbought) /// - private void ProcessStochasticSignals(List stochResults, HashSet candles) + private void ProcessStochasticSignals(List stochResults, IReadOnlyList candles) { var stochCandles = MapStochToCandle(stochResults, candles.TakeLast(StochPeriods.Value)); @@ -132,7 +132,7 @@ public class StochasticCrossIndicator : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { diff --git a/src/Managing.Domain/Indicators/Signals/SuperTrendCrossEma.cs b/src/Managing.Domain/Indicators/Signals/SuperTrendCrossEma.cs index 18bbe6a3..cec5b9db 100644 --- a/src/Managing.Domain/Indicators/Signals/SuperTrendCrossEma.cs +++ b/src/Managing.Domain/Indicators/Signals/SuperTrendCrossEma.cs @@ -20,7 +20,7 @@ public class SuperTrendCrossEma : IndicatorBase MinimumHistory = 100 + Period.Value; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { // Validate sufficient historical data for all indicators const int emaPeriod = 50; @@ -89,7 +89,7 @@ public class SuperTrendCrossEma : IndicatorBase /// Runs the indicator using pre-calculated SuperTrend values for performance optimization. /// Note: EMA50 and ADX are still calculated on-the-fly as they're not part of the standard indicator values. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { // Validate sufficient historical data for all indicators const int emaPeriod = 50; @@ -157,7 +157,7 @@ public class SuperTrendCrossEma : IndicatorBase List superTrend, List ema50, List adxResults, - HashSet candles, + IReadOnlyList candles, int minimumRequiredHistory, int adxThreshold) { @@ -237,7 +237,7 @@ public class SuperTrendCrossEma : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { diff --git a/src/Managing.Domain/Indicators/Signals/SuperTrendIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/SuperTrendIndicatorBase.cs index 0bba3b48..29f7e8eb 100644 --- a/src/Managing.Domain/Indicators/Signals/SuperTrendIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/SuperTrendIndicatorBase.cs @@ -20,7 +20,7 @@ public class SuperTrendIndicatorBase : IndicatorBase MinimumHistory = 100 + Period.Value; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= MinimumHistory) { @@ -48,7 +48,7 @@ public class SuperTrendIndicatorBase : IndicatorBase /// /// Runs the indicator using pre-calculated SuperTrend values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= MinimumHistory) { @@ -89,7 +89,7 @@ public class SuperTrendIndicatorBase : IndicatorBase /// /// List of SuperTrend calculation results /// Candles to process - private void ProcessSuperTrendSignals(List superTrend, HashSet candles) + private void ProcessSuperTrendSignals(List superTrend, IReadOnlyList candles) { var superTrendCandle = MapSuperTrendToCandle(superTrend, candles.TakeLast(MinimumHistory)); @@ -112,7 +112,7 @@ public class SuperTrendIndicatorBase : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { diff --git a/src/Managing.Domain/Indicators/Signals/ThreeWhiteSoldiersIndicatorBase.cs b/src/Managing.Domain/Indicators/Signals/ThreeWhiteSoldiersIndicatorBase.cs index d6cb7197..0d43755f 100644 --- a/src/Managing.Domain/Indicators/Signals/ThreeWhiteSoldiersIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Signals/ThreeWhiteSoldiersIndicatorBase.cs @@ -17,7 +17,7 @@ namespace Managing.Domain.Strategies.Signals public TradeDirection Direction { get; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { var signals = new List(); @@ -43,7 +43,7 @@ namespace Managing.Domain.Strategies.Signals /// Note: ThreeWhiteSoldiers is a pattern-based indicator that doesn't use traditional indicator calculations, /// so pre-calculated values are not applicable. This method falls back to regular Run(). /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { // ThreeWhiteSoldiers doesn't use traditional indicators, so pre-calculated values don't apply // Fall back to regular calculation @@ -55,7 +55,7 @@ namespace Managing.Domain.Strategies.Signals /// This method is shared between the regular Run() and optimized Run() methods. /// /// Candles to process - private void ProcessThreeWhiteSoldiersSignals(HashSet candles) + private void ProcessThreeWhiteSoldiersSignals(IReadOnlyList candles) { var lastFourCandles = candles.TakeLast(4); Candle previousCandles = null; @@ -75,7 +75,7 @@ namespace Managing.Domain.Strategies.Signals } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { throw new NotImplementedException(); } diff --git a/src/Managing.Domain/Indicators/Trends/EmaTrendIndicatorBase.cs b/src/Managing.Domain/Indicators/Trends/EmaTrendIndicatorBase.cs index 3de2c984..92fefd9e 100644 --- a/src/Managing.Domain/Indicators/Trends/EmaTrendIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Trends/EmaTrendIndicatorBase.cs @@ -18,7 +18,7 @@ public class EmaTrendIndicatorBase : EmaBaseIndicatorBase Period = period; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= 2 * Period) { @@ -44,7 +44,7 @@ public class EmaTrendIndicatorBase : EmaBaseIndicatorBase /// /// Runs the indicator using pre-calculated EMA values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= 2 * Period) { @@ -85,7 +85,7 @@ public class EmaTrendIndicatorBase : EmaBaseIndicatorBase /// /// List of EMA calculation results /// Candles to process - private void ProcessEmaTrendSignals(List ema, HashSet candles) + private void ProcessEmaTrendSignals(List ema, IReadOnlyList candles) { var emaCandles = MapEmaToCandle(ema, candles.TakeLast(Period.Value)); @@ -108,7 +108,7 @@ public class EmaTrendIndicatorBase : EmaBaseIndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { diff --git a/src/Managing.Domain/Indicators/Trends/IchimokuKumoTrend.cs b/src/Managing.Domain/Indicators/Trends/IchimokuKumoTrend.cs index dedf02be..b322f343 100644 --- a/src/Managing.Domain/Indicators/Trends/IchimokuKumoTrend.cs +++ b/src/Managing.Domain/Indicators/Trends/IchimokuKumoTrend.cs @@ -30,7 +30,7 @@ public class IchimokuKumoTrend : IndicatorBase ChikouOffset = chikouOffset; // Separate offset for Chikou span } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { // Need at least the greater of tenkanPeriods, kijunPeriods, senkouBPeriods, and all offset periods var maxOffset = Math.Max(Math.Max(OffsetPeriods.Value, SenkouOffset ?? OffsetPeriods.Value), ChikouOffset ?? OffsetPeriods.Value); @@ -56,7 +56,7 @@ public class IchimokuKumoTrend : IndicatorBase } } - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { // Need at least the greater of tenkanPeriods, kijunPeriods, senkouBPeriods, and all offset periods var maxOffset = Math.Max(Math.Max(OffsetPeriods.Value, SenkouOffset ?? OffsetPeriods.Value), ChikouOffset ?? OffsetPeriods.Value); @@ -160,7 +160,7 @@ public class IchimokuKumoTrend : IndicatorBase return candleIchimokuResults; } - private void ProcessKumoTrendSignals(List ichimokuResults, HashSet candles) + private void ProcessKumoTrendSignals(List ichimokuResults, IReadOnlyList candles) { var mappedData = ichimokuResults; @@ -193,7 +193,7 @@ public class IchimokuKumoTrend : IndicatorBase } } - private void ProcessKumoTrendSignalsFromResults(List ichimokuResults, HashSet candles) + private void ProcessKumoTrendSignalsFromResults(List ichimokuResults, IReadOnlyList candles) { if (ichimokuResults.Count == 0) return; @@ -229,7 +229,7 @@ public class IchimokuKumoTrend : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { IEnumerable ichimokuResults; diff --git a/src/Managing.Domain/Indicators/Trends/StochRsiTrendIndicatorBase.cs b/src/Managing.Domain/Indicators/Trends/StochRsiTrendIndicatorBase.cs index 372ad996..923cf3b1 100644 --- a/src/Managing.Domain/Indicators/Trends/StochRsiTrendIndicatorBase.cs +++ b/src/Managing.Domain/Indicators/Trends/StochRsiTrendIndicatorBase.cs @@ -26,7 +26,7 @@ public class StochRsiTrendIndicatorBase : IndicatorBase Period = period; } - public override List Run(HashSet candles) + public override List Run(IReadOnlyList candles) { if (candles.Count <= 10 * Period + 50) { @@ -55,7 +55,7 @@ public class StochRsiTrendIndicatorBase : IndicatorBase /// /// Runs the indicator using pre-calculated StochRsi values for performance optimization. /// - public override List Run(HashSet candles, IndicatorsResultBase preCalculatedValues) + public override List Run(IReadOnlyList candles, IndicatorsResultBase preCalculatedValues) { if (candles.Count <= 10 * Period + 50) { @@ -96,7 +96,7 @@ public class StochRsiTrendIndicatorBase : IndicatorBase /// /// List of StochRsi calculation results /// Candles to process - private void ProcessStochRsiTrendSignals(List stochRsi, HashSet candles) + private void ProcessStochRsiTrendSignals(List stochRsi, IReadOnlyList candles) { var stochRsiCandles = MapStochRsiToCandle(stochRsi, candles.TakeLast(Period.Value)); @@ -119,7 +119,7 @@ public class StochRsiTrendIndicatorBase : IndicatorBase } } - public override IndicatorsResultBase GetIndicatorValues(HashSet candles) + public override IndicatorsResultBase GetIndicatorValues(IReadOnlyList candles) { return new IndicatorsResultBase() { diff --git a/src/Managing.Domain/Shared/Helpers/TradingBox.cs b/src/Managing.Domain/Shared/Helpers/TradingBox.cs index cc5ec061..56a074df 100644 --- a/src/Managing.Domain/Shared/Helpers/TradingBox.cs +++ b/src/Managing.Domain/Shared/Helpers/TradingBox.cs @@ -55,13 +55,13 @@ public static class TradingBox { private static readonly IndicatorComboConfig _defaultConfig = new(); - public static LightSignal GetSignal(HashSet newCandles, LightScenario scenario, + public static LightSignal GetSignal(IReadOnlyList newCandles, LightScenario scenario, Dictionary previousSignal, int? loopbackPeriod = 1) { return GetSignal(newCandles, scenario, previousSignal, _defaultConfig, loopbackPeriod, null); } - public static LightSignal GetSignal(HashSet newCandles, LightScenario scenario, + public static LightSignal GetSignal(IReadOnlyList newCandles, LightScenario scenario, Dictionary previousSignal, int? loopbackPeriod, Dictionary preCalculatedIndicatorValues) { @@ -69,13 +69,13 @@ public static class TradingBox preCalculatedIndicatorValues); } - public static LightSignal GetSignal(HashSet newCandles, LightScenario lightScenario, + public static LightSignal GetSignal(IReadOnlyList newCandles, LightScenario lightScenario, Dictionary previousSignal, IndicatorComboConfig config, int? loopbackPeriod = 1) { return GetSignal(newCandles, lightScenario, previousSignal, config, loopbackPeriod, null); } - public static LightSignal GetSignal(HashSet newCandles, LightScenario lightScenario, + public static LightSignal GetSignal(IReadOnlyList newCandles, LightScenario lightScenario, Dictionary previousSignal, IndicatorComboConfig config, int? loopbackPeriod, Dictionary preCalculatedIndicatorValues) { @@ -127,12 +127,11 @@ public static class TradingBox continue; } - var limitedCandles = newCandles.ToList(); - // Optimized: limitedCandles is already ordered, no need to re-order + // newCandles is already a List and ordered chronologically var loopback = loopbackPeriod.HasValue && loopbackPeriod > 1 ? loopbackPeriod.Value : 1; - var candleLoopback = limitedCandles.Count > loopback - ? limitedCandles.Skip(limitedCandles.Count - loopback).ToList() - : limitedCandles; + var candleLoopback = newCandles.Count > loopback + ? newCandles.Skip(newCandles.Count - loopback).ToList() + : newCandles.ToList(); if (!candleLoopback.Any()) { @@ -920,7 +919,7 @@ public static class TradingBox /// A dictionary of indicator types to their calculated values. public static Dictionary CalculateIndicatorsValues( Scenario scenario, - HashSet candles) + IReadOnlyList candles) { var indicatorsValues = new Dictionary(); diff --git a/src/Managing.Domain/Trades/Position.cs b/src/Managing.Domain/Trades/Position.cs index f644e5d7..9c8fec13 100644 --- a/src/Managing.Domain/Trades/Position.cs +++ b/src/Managing.Domain/Trades/Position.cs @@ -84,6 +84,12 @@ namespace Managing.Domain.Trades [Id(18)] public bool RecoveryAttempted { get; set; } + /// + /// The trading type for this position (BacktestFutures or Futures) + /// + [Id(19)] + public TradingType TradingType { get; set; } + /// /// Return true if position is finished even if the position was canceled or rejected /// diff --git a/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs b/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs index 941889a2..8193a57d 100644 --- a/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs +++ b/src/Managing.Infrastructure.Database/Migrations/ManagingDbContextModelSnapshot.cs @@ -372,6 +372,8 @@ namespace Managing.Infrastructure.Databases.Migrations b.HasIndex("Identifier") .IsUnique(); + b.HasIndex("MasterBotUserId"); + b.HasIndex("Status"); b.HasIndex("UserId"); @@ -929,6 +931,9 @@ namespace Managing.Infrastructure.Databases.Migrations .IsRequired() .HasColumnType("text"); + b.Property("TradingType") + .HasColumnType("integer"); + b.Property("UiFees") .HasColumnType("decimal(18,8)"); @@ -1571,12 +1576,19 @@ namespace Managing.Infrastructure.Databases.Migrations modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BotEntity", b => { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "MasterBotUser") + .WithMany() + .HasForeignKey("MasterBotUserId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.SetNull) .IsRequired(); + b.Navigation("MasterBotUser"); + b.Navigation("User"); }); diff --git a/src/Managing.Infrastructure.Database/PostgreSql/Entities/PositionEntity.cs b/src/Managing.Infrastructure.Database/PostgreSql/Entities/PositionEntity.cs index 514155be..95a6797e 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/Entities/PositionEntity.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/Entities/PositionEntity.cs @@ -57,4 +57,9 @@ public class PositionEntity [ForeignKey("TakeProfit2TradeId")] public virtual TradeEntity? TakeProfit2Trade { get; set; } [Column(TypeName = "decimal(18,8)")] public decimal NetPnL { get; set; } + + /// + /// The trading type for this position (BacktestFutures or Futures) + /// + public TradingType TradingType { get; set; } } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs index 4f94388c..593babc6 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs @@ -331,6 +331,13 @@ public static class PostgreSqlMappers { if (backtest == null) return null; + // Configure JSON serializer to handle circular references + var jsonSettings = new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + PreserveReferencesHandling = PreserveReferencesHandling.None + }; + return new BacktestEntity { Identifier = backtest.Id, @@ -339,20 +346,20 @@ public static class PostgreSqlMappers WinRate = backtest.WinRate, GrowthPercentage = backtest.GrowthPercentage, HodlPercentage = backtest.HodlPercentage, - ConfigJson = JsonConvert.SerializeObject(backtest.Config), + ConfigJson = JsonConvert.SerializeObject(backtest.Config, jsonSettings), Name = backtest.Config?.Name ?? string.Empty, Ticker = backtest.Config?.Ticker.ToString() ?? string.Empty, Timeframe = (int)backtest.Config.Timeframe, IndicatorsCsv = string.Join(',', backtest.Config.Scenario.Indicators.Select(i => i.Type.ToString())), IndicatorsCount = backtest.Config.Scenario.Indicators.Count, - PositionsJson = JsonConvert.SerializeObject(backtest.Positions.Values.ToList()), - SignalsJson = JsonConvert.SerializeObject(backtest.Signals.Values.ToList()), + PositionsJson = JsonConvert.SerializeObject(backtest.Positions.Values.ToList(), jsonSettings), + SignalsJson = JsonConvert.SerializeObject(backtest.Signals.Values.ToList(), jsonSettings), StartDate = backtest.StartDate, EndDate = backtest.EndDate, Duration = backtest.EndDate - backtest.StartDate, - MoneyManagementJson = JsonConvert.SerializeObject(backtest.Config?.MoneyManagement), + MoneyManagementJson = JsonConvert.SerializeObject(backtest.Config?.MoneyManagement, jsonSettings), UserId = backtest.User?.Id ?? 0, - StatisticsJson = backtest.Statistics != null ? JsonConvert.SerializeObject(backtest.Statistics) : null, + StatisticsJson = backtest.Statistics != null ? JsonConvert.SerializeObject(backtest.Statistics, jsonSettings) : null, SharpeRatio = backtest.Statistics?.SharpeRatio ?? 0m, MaxDrawdown = backtest.Statistics?.MaxDrawdown ?? 0m, MaxDrawdownRecoveryTime = backtest.Statistics?.MaxDrawdownRecoveryTime ?? TimeSpan.Zero, @@ -615,7 +622,8 @@ public static class PostgreSqlMappers { Status = entity.Status, SignalIdentifier = entity.SignalIdentifier, - InitiatorIdentifier = entity.InitiatorIdentifier + InitiatorIdentifier = entity.InitiatorIdentifier, + TradingType = entity.TradingType }; // Set ProfitAndLoss with proper type @@ -657,6 +665,7 @@ public static class PostgreSqlMappers SignalIdentifier = position.SignalIdentifier, UserId = position.User?.Id ?? 0, InitiatorIdentifier = position.InitiatorIdentifier, + TradingType = position.TradingType, MoneyManagementJson = position.MoneyManagement != null ? JsonConvert.SerializeObject(position.MoneyManagement) : null, diff --git a/src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.Designer.cs b/src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.Designer.cs new file mode 100644 index 00000000..08ee9d65 --- /dev/null +++ b/src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.Designer.cs @@ -0,0 +1,1744 @@ +๏ปฟ// +using System; +using Managing.Infrastructure.Databases.PostgreSql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Managing.Infrastructure.Databases.src.Managing.Infrastructure.Database.Migrations +{ + [DbContext(typeof(ManagingDbContext))] + [Migration("20251127104050_AddTradingTypeToPositions")] + partial class AddTradingTypeToPositions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exchange") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsGmxInitialized") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Key") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Secret") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AgentSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActiveStrategiesCount") + .HasColumnType("integer"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BacktestCount") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Losses") + .HasColumnType("integer"); + + b.Property("NetPnL") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Runtime") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalBalance") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("TotalFees") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("TotalPnL") + .HasColumnType("decimal(18,8)"); + + b.Property("TotalROI") + .HasColumnType("decimal(18,8)"); + + b.Property("TotalVolume") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Wins") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AgentName") + .IsUnique(); + + b.HasIndex("TotalPnL"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("AgentSummaries"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BacktestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0)); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Fees") + .HasColumnType("decimal(18,8)"); + + b.Property("FinalPnl") + .HasColumnType("decimal(18,8)"); + + b.Property("GrowthPercentage") + .HasColumnType("decimal(18,8)"); + + b.Property("HodlPercentage") + .HasColumnType("decimal(18,8)"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndicatorsCount") + .HasColumnType("integer"); + + b.Property("IndicatorsCsv") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitialBalance") + .HasColumnType("decimal(18,8)"); + + b.Property("MaxDrawdown") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,8)") + .HasDefaultValue(0m); + + b.Property("MaxDrawdownRecoveryTime") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0)); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("MoneyManagementJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("NetPnl") + .HasColumnType("decimal(18,8)"); + + b.Property("PositionCount") + .HasColumnType("integer"); + + b.Property("PositionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.Property("ScoreMessage") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("text"); + + b.Property("SharpeRatio") + .ValueGeneratedOnAdd() + .HasColumnType("decimal(18,8)") + .HasDefaultValue(0m); + + b.Property("SignalsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StatisticsJson") + .HasColumnType("jsonb"); + + b.Property("Ticker") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Timeframe") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("WinRate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("RequestId"); + + b.HasIndex("Score"); + + b.HasIndex("UserId"); + + b.HasIndex("RequestId", "Score"); + + b.HasIndex("UserId", "Name"); + + b.HasIndex("UserId", "Score"); + + b.HasIndex("UserId", "Ticker"); + + b.HasIndex("UserId", "Timeframe"); + + b.ToTable("Backtests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BotEntity", b => + { + b.Property("Identifier") + .ValueGeneratedOnAdd() + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("AccumulatedRunTimeSeconds") + .HasColumnType("bigint"); + + b.Property("CreateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Fees") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("LastStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastStopTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LongPositionCount") + .HasColumnType("integer"); + + b.Property("MasterBotUserId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("NetPnL") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Pnl") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("Roi") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.Property("ShortPositionCount") + .HasColumnType("integer"); + + b.Property("StartupTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("TradeLosses") + .HasColumnType("integer"); + + b.Property("TradeWins") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Volume") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)"); + + b.HasKey("Identifier"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("MasterBotUserId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BundleBacktestRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedBacktests") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentBacktest") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("DateTimeRangesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("EstimatedTimeRemainingSeconds") + .HasColumnType("integer"); + + b.Property("FailedBacktests") + .HasColumnType("integer"); + + b.Property("MoneyManagementVariantsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProgressInfo") + .HasColumnType("text"); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("uuid"); + + b.Property("ResultsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TickerVariantsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalBacktests") + .HasColumnType("integer"); + + b.Property("UniversalConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Version") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.HasIndex("UserId", "Name", "Version"); + + b.ToTable("BundleBacktestRequests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.FundingRateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("Exchange") + .HasColumnType("integer"); + + b.Property("OpenInterest") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Rate") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Exchange"); + + b.HasIndex("Ticker"); + + b.HasIndex("Exchange", "Date"); + + b.HasIndex("Ticker", "Exchange"); + + b.HasIndex("Ticker", "Exchange", "Date") + .IsUnique(); + + b.ToTable("FundingRates"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.GeneticRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Balance") + .HasColumnType("decimal(18,8)"); + + b.Property("BestChromosome") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("BestFitness") + .HasColumnType("double precision"); + + b.Property("BestFitnessSoFar") + .HasColumnType("double precision"); + + b.Property("BestIndividual") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrossoverMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("CurrentGeneration") + .HasColumnType("integer"); + + b.Property("EligibleIndicatorsJson") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ElitismPercentage") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Generations") + .HasColumnType("integer"); + + b.Property("MaxTakeProfit") + .HasColumnType("double precision"); + + b.Property("MutationMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("MutationRate") + .HasColumnType("double precision"); + + b.Property("PopulationSize") + .HasColumnType("integer"); + + b.Property("ProgressInfo") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SelectionMethod") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("GeneticRequests"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CyclePeriods") + .HasColumnType("integer"); + + b.Property("FastPeriods") + .HasColumnType("integer"); + + b.Property("MinimumHistory") + .HasColumnType("integer"); + + b.Property("Multiplier") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Period") + .HasColumnType("integer"); + + b.Property("SignalPeriods") + .HasColumnType("integer"); + + b.Property("SignalType") + .IsRequired() + .HasColumnType("text"); + + b.Property("SlowPeriods") + .HasColumnType("integer"); + + b.Property("SmoothPeriods") + .HasColumnType("integer"); + + b.Property("StochPeriods") + .HasColumnType("integer"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name"); + + b.ToTable("Indicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedWorkerId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("BundleRequestId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FailureCategory") + .HasColumnType("integer"); + + b.Property("GeneticRequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsRetryable") + .HasColumnType("boolean"); + + b.Property("JobType") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("LastHeartbeat") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxRetries") + .HasColumnType("integer"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("ProgressPercentage") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("RequestId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ResultJson") + .HasColumnType("jsonb"); + + b.Property("RetryAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BundleRequestId") + .HasDatabaseName("idx_bundle_request"); + + b.HasIndex("GeneticRequestId") + .HasDatabaseName("idx_genetic_request"); + + b.HasIndex("AssignedWorkerId", "Status") + .HasDatabaseName("idx_assigned_worker"); + + b.HasIndex("UserId", "Status") + .HasDatabaseName("idx_user_status"); + + b.HasIndex("Status", "JobType", "Priority", "CreatedAt") + .HasDatabaseName("idx_status_jobtype_priority_created"); + + b.ToTable("Jobs", (string)null); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Leverage") + .HasColumnType("decimal(18,8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StopLoss") + .HasColumnType("decimal(18,8)"); + + b.Property("TakeProfit") + .HasColumnType("decimal(18,8)"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("UserName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserName"); + + b.HasIndex("UserName", "Name"); + + b.ToTable("MoneyManagements"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.PositionEntity", b => + { + b.Property("Identifier") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("GasFees") + .HasColumnType("decimal(18,8)"); + + b.Property("Initiator") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitiatorIdentifier") + .HasColumnType("uuid"); + + b.Property("MoneyManagementJson") + .HasColumnType("text"); + + b.Property("NetPnL") + .HasColumnType("decimal(18,8)"); + + b.Property("OpenTradeId") + .HasColumnType("integer"); + + b.Property("OriginDirection") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfitAndLoss") + .HasColumnType("decimal(18,8)"); + + b.Property("SignalIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StopLossTradeId") + .HasColumnType("integer"); + + b.Property("TakeProfit1TradeId") + .HasColumnType("integer"); + + b.Property("TakeProfit2TradeId") + .HasColumnType("integer"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("TradingType") + .HasColumnType("integer"); + + b.Property("UiFees") + .HasColumnType("decimal(18,8)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Identifier"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("InitiatorIdentifier"); + + b.HasIndex("OpenTradeId"); + + b.HasIndex("Status"); + + b.HasIndex("StopLossTradeId"); + + b.HasIndex("TakeProfit1TradeId"); + + b.HasIndex("TakeProfit2TradeId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Identifier"); + + b.ToTable("Positions"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LoopbackPeriod") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name"); + + b.ToTable("Scenarios"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioIndicatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IndicatorId") + .HasColumnType("integer"); + + b.Property("ScenarioId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IndicatorId"); + + b.HasIndex("ScenarioId", "IndicatorId") + .IsUnique(); + + b.ToTable("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SignalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CandleJson") + .HasColumnType("text"); + + b.Property("Confidence") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IndicatorName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SignalType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timeframe") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Identifier"); + + b.HasIndex("Status"); + + b.HasIndex("Ticker"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Date"); + + b.HasIndex("Identifier", "Date", "UserId") + .IsUnique(); + + b.ToTable("Signals"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SpotlightOverviewEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Identifier") + .HasColumnType("uuid"); + + b.Property("ScenarioCount") + .HasColumnType("integer"); + + b.Property("SpotlightsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DateTime"); + + b.HasIndex("Identifier") + .IsUnique(); + + b.HasIndex("DateTime", "ScenarioCount"); + + b.ToTable("SpotlightOverviews"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SynthMinersLeaderboardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Asset") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBacktest") + .HasColumnType("boolean"); + + b.Property("MinersData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SignalDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeIncrement") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("SynthMinersLeaderboards"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SynthPredictionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Asset") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBacktest") + .HasColumnType("boolean"); + + b.Property("MinerUid") + .HasColumnType("integer"); + + b.Property("PredictionData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SignalDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TimeIncrement") + .HasColumnType("integer"); + + b.Property("TimeLength") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("SynthPredictions"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TopVolumeTickerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Exchange") + .HasColumnType("integer"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Ticker") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Volume") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("Exchange"); + + b.HasIndex("Ticker"); + + b.HasIndex("Date", "Rank"); + + b.HasIndex("Exchange", "Date"); + + b.ToTable("TopVolumeTickers"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExchangeOrderId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Leverage") + .HasColumnType("decimal(18,8)"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("decimal(18,8)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,8)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ticker") + .IsRequired() + .HasColumnType("text"); + + b.Property("TradeType") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("ExchangeOrderId"); + + b.HasIndex("Status"); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.TraderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AverageLoss") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("AverageWin") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBestTrader") + .HasColumnType("boolean"); + + b.Property("Pnl") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Roi") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("TradeCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Winrate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Address"); + + b.HasIndex("IsBestTrader"); + + b.HasIndex("Pnl"); + + b.HasIndex("Roi"); + + b.HasIndex("Winrate"); + + b.HasIndex("Address", "IsBestTrader") + .IsUnique(); + + b.HasIndex("IsBestTrader", "Roi"); + + b.HasIndex("IsBestTrader", "Winrate"); + + b.ToTable("Traders"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastConnectionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OwnerWalletAddress") + .HasColumnType("text"); + + b.Property("TelegramChannel") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.HasIndex("AgentName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WhitelistAccountEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmbeddedWallet") + .IsRequired() + .HasMaxLength(42) + .HasColumnType("character varying(42)"); + + b.Property("ExternalEthereumAccount") + .HasMaxLength(42) + .HasColumnType("character varying(42)"); + + b.Property("IsWhitelisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PrivyCreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivyId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TwitterAccount") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("EmbeddedWallet") + .IsUnique(); + + b.HasIndex("ExternalEthereumAccount"); + + b.HasIndex("PrivyId") + .IsUnique(); + + b.HasIndex("TwitterAccount"); + + b.ToTable("WhitelistAccounts"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.WorkerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DelayTicks") + .HasColumnType("bigint"); + + b.Property("ExecutionCount") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("WorkerType") + .IsUnique(); + + b.ToTable("Workers"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AccountEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.AgentSummaryEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BacktestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BotEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "MasterBotUser") + .WithMany() + .HasForeignKey("MasterBotUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("MasterBotUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.BundleBacktestRequestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.GeneticRequestEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.JobEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.MoneyManagementEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.PositionEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "OpenTrade") + .WithMany() + .HasForeignKey("OpenTradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "StopLossTrade") + .WithMany() + .HasForeignKey("StopLossTradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "TakeProfit1Trade") + .WithMany() + .HasForeignKey("TakeProfit1TradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.TradeEntity", "TakeProfit2Trade") + .WithMany() + .HasForeignKey("TakeProfit2TradeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("OpenTrade"); + + b.Navigation("StopLossTrade"); + + b.Navigation("TakeProfit1Trade"); + + b.Navigation("TakeProfit2Trade"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioIndicatorEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", "Indicator") + .WithMany("ScenarioIndicators") + .HasForeignKey("IndicatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", "Scenario") + .WithMany("ScenarioIndicators") + .HasForeignKey("ScenarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Indicator"); + + b.Navigation("Scenario"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.SignalEntity", b => + { + b.HasOne("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.IndicatorEntity", b => + { + b.Navigation("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.ScenarioEntity", b => + { + b.Navigation("ScenarioIndicators"); + }); + + modelBuilder.Entity("Managing.Infrastructure.Databases.PostgreSql.Entities.UserEntity", b => + { + b.Navigation("Accounts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.cs b/src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.cs new file mode 100644 index 00000000..61c7c446 --- /dev/null +++ b/src/Managing.Infrastructure.Database/src/Managing.Infrastructure.Database/Migrations/20251127104050_AddTradingTypeToPositions.cs @@ -0,0 +1,50 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Managing.Infrastructure.Databases.src.Managing.Infrastructure.Database.Migrations +{ + /// + public partial class AddTradingTypeToPositions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TradingType", + table: "Positions", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Bots_MasterBotUserId", + table: "Bots", + column: "MasterBotUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Bots_Users_MasterBotUserId", + table: "Bots", + column: "MasterBotUserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Bots_Users_MasterBotUserId", + table: "Bots"); + + migrationBuilder.DropIndex( + name: "IX_Bots_MasterBotUserId", + table: "Bots"); + + migrationBuilder.DropColumn( + name: "TradingType", + table: "Positions"); + } + } +} diff --git a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs index f68ac518..f3fb3d01 100644 --- a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs +++ b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs @@ -121,26 +121,14 @@ namespace Managing.Infrastructure.Exchanges reduceOnly: true); } - public async Task ClosePosition(Account account, Position position, decimal lastPrice, - bool isForPaperTrading = false) + public async Task ClosePosition(Account account, Position position, decimal lastPrice) { var direction = position.OriginDirection == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long; - if (isForPaperTrading) - { - var fake = BuildEmptyTrade(position.Open.Ticker, - lastPrice, - position.Open.Quantity, - direction, - position.Open.Leverage, - TradeType.Market, - position.Open.Date, - TradeStatus.Filled); - return fake; - } - + // Paper trading logic has been moved to CloseBacktestFuturesPositionCommandHandler + // This method now only handles live trading var processor = GetProcessor(account); var closedTrade = await processor.OpenTrade( account, diff --git a/src/Managing.Infrastructure.Messengers/Discord/DiscordService.cs b/src/Managing.Infrastructure.Messengers/Discord/DiscordService.cs index b10a2d93..f49277fa 100644 --- a/src/Managing.Infrastructure.Messengers/Discord/DiscordService.cs +++ b/src/Managing.Infrastructure.Messengers/Discord/DiscordService.cs @@ -306,10 +306,19 @@ namespace Managing.Infrastructure.Messengers.Discord await component.RespondAsync("Alright, let met few seconds to close this position"); var position = await tradingService.GetPositionByIdentifierAsync(Guid.Parse(parameters[1])); - var command = new ClosePositionCommand(position, position.AccountId); - var result = - await new ClosePositionCommandHandler(exchangeService, accountService, tradingService, scopeFactory) + Position result; + if (position.TradingType == TradingType.BacktestFutures) + { + var command = new CloseBacktestFuturesPositionCommand(position, position.AccountId); + result = await new CloseBacktestFuturesPositionCommandHandler(exchangeService, accountService, tradingService, scopeFactory) .Handle(command); + } + else + { + var command = new CloseFuturesPositionCommand(position, position.AccountId); + result = await new CloseFuturesPositionCommandHandler(exchangeService, accountService, tradingService, scopeFactory) + .Handle(command); + } var fields = new List() { new EmbedFieldBuilder diff --git a/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx b/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx index a31d5d0c..08c11973 100644 --- a/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx +++ b/src/Managing.WebApp/src/pages/settingsPage/UserInfoSettings.tsx @@ -295,15 +295,15 @@ function UserInfoSettings() { {telegramErrors.telegramChannel && (