Files
managing-apps/src/Managing.Application/Bots/BacktestSpotBot.cs

326 lines
13 KiB
C#

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 BacktestSpotBot : TradingBotBase, ITradingBot
{
public BacktestSpotBot(
ILogger<TradingBotBase> logger,
IServiceScopeFactory scopeFactory,
TradingBotConfig config,
IStreamProvider? streamProvider = null
) : base(logger, scopeFactory, config, streamProvider)
{
// Backtest-specific initialization
Config.TradingType = TradingType.BacktestSpot;
}
public override async Task Start(BotStatus previousStatus)
{
// Backtest mode: Skip account loading and broker initialization
// Just log basic startup info
await LogInformation($"🔬 Backtest Spot 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 spot 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(
"[BacktestSpot][{BotName}] Execution {ExecutionCount} - LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Positions: {PositionCount}",
Config.Name, ExecutionCount, LastCandle?.Date, Signals.Count, Positions.Count);
}
protected override async Task<Position> GetInternalPositionForUpdate(Position position)
{
// In backtest mode, return the position as-is (no database lookup needed)
return position;
}
protected override async Task UpdatePositionWithBrokerData(Position position, List<Position> brokerPositions)
{
// In backtest mode, skip broker synchronization
return;
}
protected override async Task<Candle> GetCurrentCandleForPositionClose(Account account, string ticker)
{
// In backtest mode, use LastCandle
return LastCandle;
}
protected override async Task<bool> CanOpenPositionWithBrokerChecks(LightSignal signal)
{
// In backtest mode, skip broker position checks
return await CanOpenPosition(signal);
}
protected override async Task LoadAccountAsync()
{
// In backtest mode, skip account loading
return;
}
protected override async Task VerifyAndUpdateBalanceAsync()
{
// In backtest mode, skip balance verification
return;
}
protected override async Task SendPositionToCopyTradingStream(Position position)
{
// In backtest mode, skip copy trading stream
return;
}
protected override async Task NotifyAgentAndPlatformAsync(NotificationEventType eventType, Position position)
{
// In backtest mode, skip notifications
return;
}
protected override async Task UpdatePositionInDatabaseAsync(Position position)
{
// In backtest mode, skip database updates
return;
}
protected override async Task SendClosedPositionToMessenger(Position position, User user)
{
// In backtest mode, skip messenger updates
return;
}
protected override async Task CancelAllOrdersAsync()
{
// In backtest mode, no orders to cancel
return;
}
protected override async Task LogInformationAsync(string message)
{
// In backtest mode, skip user notifications, just log to system
if (Config.TradingType == TradingType.BacktestSpot)
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.BacktestSpot)
return;
await base.LogWarningAsync(message);
}
protected override async Task LogDebugAsync(string message)
{
// In backtest mode, skip messenger debug logs
if (Config.TradingType == TradingType.BacktestSpot)
return;
await base.LogDebugAsync(message);
}
protected override async Task SendTradeMessageAsync(string message, bool isBadBehavior)
{
// In backtest mode, skip trade messages
return;
}
protected override async Task UpdateSignalsCore(IReadOnlyList<Candle> candles,
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues = null)
{
// Call base implementation for common optimizations (flip check, cooldown check)
// This will return early if:
// - FlipPosition is disabled AND there's an open position
// - Bot is in cooldown period
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.LookbackPeriod,
preCalculatedIndicatorValues);
if (backtestSignal == null) return;
await AddSignal(backtestSignal);
}
protected override async Task<bool> CanOpenPosition(LightSignal signal)
{
// For spot trading, only LONG signals can open positions
if (signal.Direction != TradeDirection.Long)
{
await LogInformationAsync(
$"🚫 Short Signal Ignored\nShort signals cannot open positions in spot trading\nSignal: `{signal.Identifier}` will be ignored");
return false;
}
// Backtest-specific logic: only check cooldown and loss streak
// No broker checks, no synth risk assessment, no startup cycle check needed
return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal);
}
protected override async Task<Position> HandleFlipPosition(LightSignal signal, Position openedPosition,
LightSignal previousSignal, decimal lastPrice)
{
// For spot trading, SHORT signals should close the open LONG position
// LONG signals should not flip (they would be same direction)
if (signal.Direction == TradeDirection.Short && openedPosition.OriginDirection == TradeDirection.Long)
{
// SHORT signal closes the open LONG position
await LogInformationAsync(
$"🔻 Short Signal - Closing Long Position\nClosing position `{openedPosition.Identifier}` due to SHORT signal\nSignal: `{signal.Identifier}`");
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Finished);
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null; // No new position opened for SHORT signals
}
else if (signal.Direction == TradeDirection.Long && openedPosition.OriginDirection == TradeDirection.Long)
{
// Same direction LONG signal - ignore it
await LogInformationAsync(
$"📍 Same Direction Signal\nLONG signal `{signal.Identifier}` ignored\nPosition `{openedPosition.Identifier}` already open for LONG");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
else
{
// This shouldn't happen in spot trading, but handle it gracefully
await LogInformationAsync(
$"⚠️ Unexpected Signal Direction\nSignal: `{signal.Identifier}` Direction: `{signal.Direction}`\nPosition: `{openedPosition.Identifier}` Direction: `{openedPosition.OriginDirection}`\nSignal ignored");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
}
protected override async Task<Position> ExecuteOpenPosition(LightSignal signal, decimal lastPrice)
{
// Backtest-specific position opening: no balance verification, no exchange calls
// Only LONG signals should reach here (SHORT signals are filtered out earlier)
if (signal.Direction != TradeDirection.Long)
{
throw new InvalidOperationException(
$"Only LONG signals can open positions in spot trading. Received: {signal.Direction}");
}
if (Account == null || Account.User == null)
{
throw new InvalidOperationException("Account and Account.User must be set before opening a position");
}
var command = new OpenSpotPositionRequest(
Config.AccountName,
Config.MoneyManagement,
signal.Direction,
Config.Ticker,
PositionInitiator.Bot,
signal.Date,
Account.User,
Config.BotTradingBalance,
isForPaperTrading: true, // Backtest is always paper trading
lastPrice,
signalIdentifier: signal.Identifier,
initiatorIdentifier: Identifier,
tradingType: Config.TradingType);
var position = await ServiceScopeHelpers
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
_scopeFactory,
async (exchangeService, accountService, tradingService) =>
{
return await new OpenSpotPositionCommandHandler(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} Spot 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 CloseSpotPositionCommand(position, position.AccountId, lastPrice);
try
{
Position closedPosition = null;
await ServiceScopeHelpers.WithScopedServices<IExchangeService, IAccountService, ITradingService>(
_scopeFactory, async (exchangeService, accountService, tradingService) =>
{
closedPosition =
await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService)
.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);
}
}
}
}