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:
2025-12-01 23:41:23 +07:00
parent 3771bb5dde
commit 5bd03259da
9 changed files with 847 additions and 5 deletions

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}