Add BacktestSpotBot and update BacktestExecutor for spot trading support
- Introduced BacktestSpotBot class to handle backtesting for spot trading scenarios. - Updated BacktestExecutor to support both BacktestFutures and BacktestSpot trading types. - Enhanced error handling to provide clearer messages for unsupported trading types. - Registered new command handlers for OpenSpotPositionRequest and CloseSpotPositionCommand in ApiBootstrap. - Added unit tests for executing backtests with spot trading configurations, ensuring correct behavior and metrics validation.
This commit is contained in:
@@ -544,15 +544,22 @@ public class BacktestExecutor
|
|||||||
throw new InvalidOperationException("Bot configuration is not initialized");
|
throw new InvalidOperationException("Bot configuration is not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.TradingType != TradingType.BacktestFutures)
|
if (config.TradingType != TradingType.BacktestFutures && config.TradingType != TradingType.BacktestSpot)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("BacktestExecutor can only be used for backtesting");
|
throw new InvalidOperationException($"BacktestExecutor can only be used for backtesting. TradingType must be BacktestFutures or BacktestSpot, but got {config.TradingType}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the trading bot instance
|
// Create the trading bot instance based on TradingType
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TradingBotBase>>();
|
||||||
var tradingBot = new BacktestFuturesBot(logger, _scopeFactory, config);
|
|
||||||
|
TradingBotBase tradingBot = config.TradingType switch
|
||||||
|
{
|
||||||
|
TradingType.BacktestFutures => new BacktestFuturesBot(logger, _scopeFactory, config),
|
||||||
|
TradingType.BacktestSpot => new BacktestSpotBot(logger, _scopeFactory, config),
|
||||||
|
_ => throw new InvalidOperationException($"Unsupported TradingType for backtest: {config.TradingType}")
|
||||||
|
};
|
||||||
|
|
||||||
return tradingBot;
|
return tradingBot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
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<List<Position>> GetBrokerPositionsForUpdate(Account account)
|
||||||
|
{
|
||||||
|
// In backtest mode, return empty list (no broker positions to check)
|
||||||
|
return new List<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)
|
||||||
|
{
|
||||||
|
// For spot trading, always fetch signals regardless of open positions
|
||||||
|
// Check if we're in cooldown period
|
||||||
|
if (await IsInCooldownPeriodAsync())
|
||||||
|
{
|
||||||
|
// Still in cooldown period, skip signal generation
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<decimal> GetLastPriceForPositionOpeningAsync()
|
||||||
|
{
|
||||||
|
// For backtest, use LastCandle close price
|
||||||
|
return LastCandle?.Close ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Managing.Domain.Trades;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Managing.Application.Trading.Commands
|
||||||
|
{
|
||||||
|
public class CloseSpotPositionCommand : IRequest<Position>
|
||||||
|
{
|
||||||
|
public CloseSpotPositionCommand(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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Managing.Common;
|
||||||
|
using Managing.Domain.Trades;
|
||||||
|
using Managing.Domain.Users;
|
||||||
|
using MediatR;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Application.Trading.Commands
|
||||||
|
{
|
||||||
|
public class OpenSpotPositionRequest : IRequest<Position>
|
||||||
|
{
|
||||||
|
public OpenSpotPositionRequest(
|
||||||
|
string accountName,
|
||||||
|
LightMoneyManagement moneyManagement,
|
||||||
|
TradeDirection direction,
|
||||||
|
Ticker ticker,
|
||||||
|
PositionInitiator initiator,
|
||||||
|
DateTime date,
|
||||||
|
User user,
|
||||||
|
decimal amountToTrade,
|
||||||
|
bool isForPaperTrading = false,
|
||||||
|
decimal? price = null,
|
||||||
|
string signalIdentifier = null,
|
||||||
|
Guid? initiatorIdentifier = null,
|
||||||
|
TradingType tradingType = TradingType.BacktestSpot)
|
||||||
|
{
|
||||||
|
AccountName = accountName;
|
||||||
|
MoneyManagement = moneyManagement;
|
||||||
|
Direction = direction;
|
||||||
|
Ticker = ticker;
|
||||||
|
Initiator = initiator;
|
||||||
|
Date = date;
|
||||||
|
User = user;
|
||||||
|
|
||||||
|
if (amountToTrade <= Constants.GMX.Config.MinimumPositionAmount)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Bot trading balance must be greater than : 5usdc", nameof(amountToTrade));
|
||||||
|
}
|
||||||
|
|
||||||
|
AmountToTrade = amountToTrade;
|
||||||
|
|
||||||
|
IsForPaperTrading = isForPaperTrading;
|
||||||
|
Price = price;
|
||||||
|
SignalIdentifier = signalIdentifier;
|
||||||
|
InitiatorIdentifier = initiatorIdentifier ??
|
||||||
|
throw new ArgumentNullException(nameof(initiatorIdentifier),
|
||||||
|
"InitiatorIdentifier is required");
|
||||||
|
TradingType = tradingType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SignalIdentifier { get; set; }
|
||||||
|
public string AccountName { get; }
|
||||||
|
public LightMoneyManagement MoneyManagement { get; }
|
||||||
|
public TradeDirection Direction { get; }
|
||||||
|
public Ticker Ticker { get; }
|
||||||
|
public bool IsForPaperTrading { get; }
|
||||||
|
public decimal? Price { get; }
|
||||||
|
public decimal AmountToTrade { get; }
|
||||||
|
public DateTime Date { get; }
|
||||||
|
public PositionInitiator Initiator { get; }
|
||||||
|
public User User { get; }
|
||||||
|
public Guid InitiatorIdentifier { get; }
|
||||||
|
public TradingType TradingType { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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.Logging;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Application.Trading.Handlers;
|
||||||
|
|
||||||
|
public class CloseSpotPositionCommandHandler(
|
||||||
|
IExchangeService exchangeService,
|
||||||
|
IAccountService accountService,
|
||||||
|
ITradingService tradingService,
|
||||||
|
ILogger<CloseSpotPositionCommandHandler> logger = null)
|
||||||
|
: ICommandHandler<CloseSpotPositionCommand, Position>
|
||||||
|
{
|
||||||
|
public async Task<Position> Handle(CloseSpotPositionCommand 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;
|
||||||
|
|
||||||
|
// For spot trading, determine swap direction for closing
|
||||||
|
// Long position: Swap Token -> USDC (sell token for USDC)
|
||||||
|
// Short position: Swap USDC -> Token (buy token with USDC)
|
||||||
|
Ticker fromTicker;
|
||||||
|
Ticker toTicker;
|
||||||
|
double swapAmount;
|
||||||
|
|
||||||
|
if (request.Position.OriginDirection == TradeDirection.Long)
|
||||||
|
{
|
||||||
|
fromTicker = request.Position.Ticker;
|
||||||
|
toTicker = Ticker.USDC;
|
||||||
|
swapAmount = (double)request.Position.Open.Quantity;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fromTicker = Ticker.USDC;
|
||||||
|
toTicker = request.Position.Ticker;
|
||||||
|
// For short, we need to calculate how much USDC to swap back
|
||||||
|
// This should be the original amount + profit/loss
|
||||||
|
var originalAmount = request.Position.Open.Price * request.Position.Open.Quantity;
|
||||||
|
swapAmount = (double)originalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backtest/paper trading, simulate the swap without calling the exchange
|
||||||
|
SwapInfos swapResult;
|
||||||
|
if (request.Position.TradingType == TradingType.BacktestSpot)
|
||||||
|
{
|
||||||
|
// Simulate successful swap for backtest
|
||||||
|
swapResult = new SwapInfos
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Hash = Guid.NewGuid().ToString(),
|
||||||
|
Message = "Backtest spot position closed successfully"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For live trading, call SwapGmxTokensAsync
|
||||||
|
var account = await accountService.GetAccountById(request.AccountId);
|
||||||
|
swapResult = await tradingService.SwapGmxTokensAsync(
|
||||||
|
request.Position.User,
|
||||||
|
account.Name,
|
||||||
|
fromTicker,
|
||||||
|
toTicker,
|
||||||
|
swapAmount,
|
||||||
|
"market",
|
||||||
|
null,
|
||||||
|
0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!swapResult.Success)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to close spot position: {swapResult.Error ?? swapResult.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
1, // Spot trading has no 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,
|
||||||
|
1); // Spot trading has no leverage
|
||||||
|
|
||||||
|
// Add UI fees for closing the position
|
||||||
|
var closingPositionSizeUsd = lastPrice * closedTrade.Quantity;
|
||||||
|
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 spot position: {Message} \n Stacktrace : {StackTrace}", ex.Message,
|
||||||
|
ex.StackTrace);
|
||||||
|
|
||||||
|
SentrySdk.CaptureException(ex);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using Managing.Application.Abstractions;
|
||||||
|
using Managing.Application.Abstractions.Services;
|
||||||
|
using Managing.Application.Trading.Commands;
|
||||||
|
using Managing.Common;
|
||||||
|
using Managing.Core.Exceptions;
|
||||||
|
using Managing.Domain.Accounts;
|
||||||
|
using Managing.Domain.Shared.Helpers;
|
||||||
|
using Managing.Domain.Trades;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Application.Trading.Handlers
|
||||||
|
{
|
||||||
|
public class OpenSpotPositionCommandHandler(
|
||||||
|
IExchangeService exchangeService,
|
||||||
|
IAccountService accountService,
|
||||||
|
ITradingService tradingService)
|
||||||
|
: ICommandHandler<OpenSpotPositionRequest, Position>
|
||||||
|
{
|
||||||
|
public async Task<Position> Handle(OpenSpotPositionRequest request)
|
||||||
|
{
|
||||||
|
var account = await accountService.GetAccount(request.AccountName, hideSecrets: false, getBalance: false);
|
||||||
|
|
||||||
|
var initiator = request.IsForPaperTrading ? PositionInitiator.PaperTrading : request.Initiator;
|
||||||
|
var position = new Position(Guid.NewGuid(), account.Id, request.Direction,
|
||||||
|
request.Ticker,
|
||||||
|
request.MoneyManagement,
|
||||||
|
initiator, request.Date, request.User);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(request.SignalIdentifier))
|
||||||
|
{
|
||||||
|
position.SignalIdentifier = request.SignalIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
decimal balanceToRisk = Math.Round(request.AmountToTrade, 0, MidpointRounding.ToZero);
|
||||||
|
|
||||||
|
// Minimum check
|
||||||
|
if (balanceToRisk < Constants.GMX.Config.MinimumPositionAmount)
|
||||||
|
{
|
||||||
|
throw new Exception(
|
||||||
|
$"Bot trading balance of {balanceToRisk} USD is less than the minimum {Constants.GMX.Config.MinimumPositionAmount} USD required to trade");
|
||||||
|
}
|
||||||
|
|
||||||
|
var price = request.IsForPaperTrading && request.Price.HasValue
|
||||||
|
? request.Price.Value
|
||||||
|
: await exchangeService.GetPrice(account, request.Ticker, DateTime.Now);
|
||||||
|
var quantity = balanceToRisk / price;
|
||||||
|
|
||||||
|
var openPrice = request.IsForPaperTrading || request.Price.HasValue
|
||||||
|
? request.Price.Value
|
||||||
|
: price;
|
||||||
|
|
||||||
|
// For spot trading, determine swap direction
|
||||||
|
// Long: Swap USDC -> Token (buy token with USDC)
|
||||||
|
// Short: Swap Token -> USDC (sell token for USDC)
|
||||||
|
Ticker fromTicker;
|
||||||
|
Ticker toTicker;
|
||||||
|
double swapAmount;
|
||||||
|
|
||||||
|
if (request.Direction == TradeDirection.Long)
|
||||||
|
{
|
||||||
|
fromTicker = Ticker.USDC;
|
||||||
|
toTicker = request.Ticker;
|
||||||
|
swapAmount = (double)balanceToRisk;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fromTicker = request.Ticker;
|
||||||
|
toTicker = Ticker.USDC;
|
||||||
|
swapAmount = (double)quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backtest/paper trading, simulate the swap without calling the exchange
|
||||||
|
SwapInfos swapResult;
|
||||||
|
if (request.IsForPaperTrading)
|
||||||
|
{
|
||||||
|
// Simulate successful swap for backtest
|
||||||
|
swapResult = new SwapInfos
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Hash = Guid.NewGuid().ToString(),
|
||||||
|
Message = "Backtest spot position opened successfully"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For live trading, call SwapGmxTokensAsync
|
||||||
|
swapResult = await tradingService.SwapGmxTokensAsync(
|
||||||
|
request.User,
|
||||||
|
request.AccountName,
|
||||||
|
fromTicker,
|
||||||
|
toTicker,
|
||||||
|
swapAmount,
|
||||||
|
"market",
|
||||||
|
null,
|
||||||
|
0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!swapResult.Success)
|
||||||
|
{
|
||||||
|
position.Status = PositionStatus.Rejected;
|
||||||
|
throw new InvalidOperationException($"Failed to open spot position: {swapResult.Error ?? swapResult.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the opening trade
|
||||||
|
var trade = exchangeService.BuildEmptyTrade(
|
||||||
|
request.Ticker,
|
||||||
|
openPrice,
|
||||||
|
quantity,
|
||||||
|
request.Direction,
|
||||||
|
1, // Spot trading has no leverage
|
||||||
|
TradeType.Market,
|
||||||
|
request.Date,
|
||||||
|
TradeStatus.Filled);
|
||||||
|
|
||||||
|
position.Open = trade;
|
||||||
|
|
||||||
|
// Calculate and set fees for the position
|
||||||
|
position.GasFees = TradingBox.CalculateOpeningGasFees();
|
||||||
|
|
||||||
|
// Set UI fees for opening
|
||||||
|
var positionSizeUsd = TradingBox.GetVolumeForPosition(position);
|
||||||
|
position.UiFees = TradingBox.CalculateOpeningUiFees(positionSizeUsd);
|
||||||
|
|
||||||
|
var closeDirection = request.Direction == TradeDirection.Long
|
||||||
|
? TradeDirection.Short
|
||||||
|
: TradeDirection.Long;
|
||||||
|
|
||||||
|
// Determine SL/TP Prices
|
||||||
|
var stopLossPrice = RiskHelpers.GetStopLossPrice(request.Direction, openPrice, request.MoneyManagement);
|
||||||
|
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(request.Direction, openPrice, request.MoneyManagement);
|
||||||
|
|
||||||
|
// Stop loss
|
||||||
|
position.StopLoss = exchangeService.BuildEmptyTrade(
|
||||||
|
request.Ticker,
|
||||||
|
stopLossPrice,
|
||||||
|
position.Open.Quantity,
|
||||||
|
closeDirection,
|
||||||
|
1, // Spot trading has no leverage
|
||||||
|
TradeType.StopLoss,
|
||||||
|
request.Date,
|
||||||
|
TradeStatus.Requested);
|
||||||
|
|
||||||
|
// Take profit
|
||||||
|
position.TakeProfit1 = exchangeService.BuildEmptyTrade(
|
||||||
|
request.Ticker,
|
||||||
|
takeProfitPrice,
|
||||||
|
quantity,
|
||||||
|
closeDirection,
|
||||||
|
1, // Spot trading has no leverage
|
||||||
|
TradeType.TakeProfit,
|
||||||
|
request.Date,
|
||||||
|
TradeStatus.Requested);
|
||||||
|
|
||||||
|
position.Status = IsOpenTradeHandled(position.Open.Status)
|
||||||
|
? position.Status
|
||||||
|
: PositionStatus.Rejected;
|
||||||
|
|
||||||
|
if (position.Status == PositionStatus.Rejected)
|
||||||
|
{
|
||||||
|
SentrySdk.CaptureException(
|
||||||
|
new Exception($"Position {position.Identifier} for {request.SignalIdentifier} rejected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.IsForPaperTrading)
|
||||||
|
{
|
||||||
|
await tradingService.InsertPositionAsync(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOpenTradeHandled(TradeStatus tradeStatus)
|
||||||
|
{
|
||||||
|
return tradeStatus == TradeStatus.Filled
|
||||||
|
|| tradeStatus == TradeStatus.Requested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -396,7 +396,9 @@ public static class ApiBootstrap
|
|||||||
services.AddScoped<ISynthApiClient, SynthApiClient>();
|
services.AddScoped<ISynthApiClient, SynthApiClient>();
|
||||||
services.AddScoped<IPricesService, PricesService>();
|
services.AddScoped<IPricesService, PricesService>();
|
||||||
services.AddTransient<ICommandHandler<OpenPositionRequest, Position>, OpenPositionCommandHandler>();
|
services.AddTransient<ICommandHandler<OpenPositionRequest, Position>, OpenPositionCommandHandler>();
|
||||||
|
services.AddTransient<ICommandHandler<OpenSpotPositionRequest, Position>, OpenSpotPositionCommandHandler>();
|
||||||
services.AddTransient<ICommandHandler<CloseBacktestFuturesPositionCommand, Position>, CloseBacktestFuturesPositionCommandHandler>();
|
services.AddTransient<ICommandHandler<CloseBacktestFuturesPositionCommand, Position>, CloseBacktestFuturesPositionCommandHandler>();
|
||||||
|
services.AddTransient<ICommandHandler<CloseSpotPositionCommand, Position>, CloseSpotPositionCommandHandler>();
|
||||||
services.AddTransient<ICommandHandler<CloseFuturesPositionCommand, Position>, CloseFuturesPositionCommandHandler>();
|
services.AddTransient<ICommandHandler<CloseFuturesPositionCommand, Position>, CloseFuturesPositionCommandHandler>();
|
||||||
// Keep old handler for backward compatibility
|
// Keep old handler for backward compatibility
|
||||||
services.AddTransient<ICommandHandler<ClosePositionCommand, Position>, ClosePositionCommandHandler>();
|
services.AddTransient<ICommandHandler<ClosePositionCommand, Position>, ClosePositionCommandHandler>();
|
||||||
|
|||||||
@@ -620,6 +620,11 @@ public static class Enums
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Backtest futures trading mode
|
/// Backtest futures trading mode
|
||||||
/// </summary>
|
/// </summary>
|
||||||
BacktestFutures
|
BacktestFutures,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backtest spot trading mode
|
||||||
|
/// </summary>
|
||||||
|
BacktestSpot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,6 +498,101 @@ public class BacktestExecutorTests : BaseTests, IDisposable
|
|||||||
$"✅ Two-scenarios performance test passed: {candlesPerSecond:F1} candles/sec with {scenario.Indicators.Count} indicators");
|
$"✅ Two-scenarios performance test passed: {candlesPerSecond:F1} candles/sec with {scenario.Indicators.Count} indicators");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteBacktestSpot_With_ETH_FifteenMinutes_Data_Should_Return_LightBacktest()
|
||||||
|
{
|
||||||
|
Console.WriteLine("TEST START: ExecuteBacktestSpot_With_ETH_FifteenMinutes_Data_Should_Return_LightBacktest");
|
||||||
|
// Arrange
|
||||||
|
var candles = FileHelpers.ReadJson<List<Candle>>("../../../Data/ETH-FifteenMinutes-candles.json");
|
||||||
|
Assert.NotNull(candles);
|
||||||
|
Assert.NotEmpty(candles);
|
||||||
|
|
||||||
|
var scenario = new Scenario("ETH_Spot_BacktestScenario");
|
||||||
|
var rsiDivIndicator = ScenarioHelpers.BuildIndicator(IndicatorType.RsiDivergence, "RsiDiv", period: 14);
|
||||||
|
scenario.Indicators = new List<IndicatorBase> { (IndicatorBase)rsiDivIndicator };
|
||||||
|
scenario.LoopbackPeriod = 15;
|
||||||
|
|
||||||
|
var config = new TradingBotConfig
|
||||||
|
{
|
||||||
|
AccountName = _account.Name,
|
||||||
|
MoneyManagement = MoneyManagement,
|
||||||
|
Ticker = Ticker.ETH,
|
||||||
|
Scenario = LightScenario.FromScenario(scenario),
|
||||||
|
Timeframe = Timeframe.FifteenMinutes,
|
||||||
|
IsForWatchingOnly = false,
|
||||||
|
BotTradingBalance = 1000,
|
||||||
|
TradingType = TradingType.BacktestSpot, // Use BacktestSpot instead of BacktestFutures
|
||||||
|
CooldownPeriod = 1,
|
||||||
|
MaxLossStreak = 0,
|
||||||
|
FlipPosition = false,
|
||||||
|
Name = "ETH_FifteenMinutes_Spot_Test",
|
||||||
|
FlipOnlyWhenInProfit = true,
|
||||||
|
MaxPositionTimeHours = null,
|
||||||
|
CloseEarlyWhenProfitable = false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _backtestExecutor.ExecuteAsync(
|
||||||
|
config,
|
||||||
|
candles,
|
||||||
|
_testUser,
|
||||||
|
save: false,
|
||||||
|
withCandles: false,
|
||||||
|
requestId: null,
|
||||||
|
bundleRequestId: null,
|
||||||
|
metadata: null,
|
||||||
|
progressCallback: null);
|
||||||
|
|
||||||
|
// Output the result to console for review
|
||||||
|
var json = JsonConvert.SerializeObject(new
|
||||||
|
{
|
||||||
|
result.FinalPnl,
|
||||||
|
result.WinRate,
|
||||||
|
result.GrowthPercentage,
|
||||||
|
result.HodlPercentage,
|
||||||
|
result.Fees,
|
||||||
|
result.NetPnl,
|
||||||
|
result.MaxDrawdown,
|
||||||
|
result.SharpeRatio,
|
||||||
|
result.Score,
|
||||||
|
result.InitialBalance,
|
||||||
|
StartDate = result.StartDate.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
EndDate = result.EndDate.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
TradingType = result.Config.TradingType
|
||||||
|
}, Formatting.Indented);
|
||||||
|
|
||||||
|
Console.WriteLine("BacktestExecutor Spot Results:");
|
||||||
|
Console.WriteLine(json);
|
||||||
|
|
||||||
|
// Debug: Verify telemetry is working
|
||||||
|
Console.WriteLine($"DEBUG: Spot backtest completed successfully with {result.WinRate}% win rate");
|
||||||
|
|
||||||
|
// Assert - Validate specific backtest results
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.IsType<LightBacktest>(result);
|
||||||
|
|
||||||
|
// Verify TradingType is BacktestSpot
|
||||||
|
Assert.Equal(TradingType.BacktestSpot, result.Config.TradingType);
|
||||||
|
|
||||||
|
// Validate key metrics - Updated with actual backtest results
|
||||||
|
Assert.Equal(1000.0m, result.InitialBalance);
|
||||||
|
Assert.Equal(-71.63m, Math.Round(result.FinalPnl, 2));
|
||||||
|
Assert.Equal(16, result.WinRate);
|
||||||
|
Assert.Equal(-10.86m, Math.Round(result.GrowthPercentage, 2));
|
||||||
|
Assert.Equal(-0.67m, Math.Round(result.HodlPercentage, 2));
|
||||||
|
Assert.Equal(32.59m, Math.Round(result.Fees, 2));
|
||||||
|
Assert.Equal(-108.65m, Math.Round(result.NetPnl, 2));
|
||||||
|
Assert.Equal(111.76m, Math.Round((decimal)result.MaxDrawdown, 2));
|
||||||
|
Assert.Equal(-0.107, Math.Round((double)(result.SharpeRatio ?? 0), 3));
|
||||||
|
Assert.True(Math.Abs(result.Score - 0.0) < 0.001,
|
||||||
|
$"Score {result.Score} should be within 0.001 of expected value 0.0");
|
||||||
|
|
||||||
|
// Validate dates
|
||||||
|
Assert.Equal(new DateTime(2025, 10, 14, 12, 0, 0), result.StartDate);
|
||||||
|
Assert.Equal(new DateTime(2025, 10, 24, 11, 45, 0), result.EndDate);
|
||||||
|
Assert.True(result.StartDate < result.EndDate);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_loggerFactory?.Dispose();
|
_loggerFactory?.Dispose();
|
||||||
|
|||||||
Reference in New Issue
Block a user