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
This commit is contained in:
Oda
2025-12-01 19:32:06 +07:00
committed by GitHub
parent ab26260f6d
commit 9d536ea49e
74 changed files with 4525 additions and 2350 deletions

View File

@@ -0,0 +1,20 @@
using Managing.Domain.Trades;
using MediatR;
namespace Managing.Application.Trading.Commands
{
public class CloseBacktestFuturesPositionCommand : IRequest<Position>
{
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; }
}
}

View File

@@ -0,0 +1,20 @@
using Managing.Domain.Trades;
using MediatR;
namespace Managing.Application.Trading.Commands
{
public class CloseFuturesPositionCommand : IRequest<Position>
{
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; }
}
}

View File

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

View File

@@ -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<CloseBacktestFuturesPositionCommandHandler> logger = null)
: ICommandHandler<CloseBacktestFuturesPositionCommand, Position>
{
public async Task<Position> 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;
}
}
}

View File

@@ -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<CloseFuturesPositionCommandHandler> logger = null)
: ICommandHandler<CloseFuturesPositionCommand, Position>
{
public async Task<Position> 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;
}
}
}

View File

@@ -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))
{

View File

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

View File

@@ -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,