Files
managing-apps/src/Managing.Application/Bots/TradingBot.cs
2025-06-09 01:04:02 +07:00

1426 lines
57 KiB
C#

using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Services;
using Managing.Application.Trading;
using Managing.Application.Trading.Commands;
using Managing.Common;
using Managing.Core.FixedSizedQueue;
using Managing.Domain.Accounts;
using Managing.Domain.Bots;
using Managing.Domain.Candles;
using Managing.Domain.MoneyManagements;
using Managing.Domain.Scenarios;
using Managing.Domain.Shared.Helpers;
using Managing.Domain.Strategies;
using Managing.Domain.Strategies.Base;
using Managing.Domain.Trades;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using static Managing.Common.Enums;
namespace Managing.Application.Bots;
public class TradingBot : Bot, ITradingBot
{
public readonly ILogger<TradingBot> Logger;
public readonly IExchangeService ExchangeService;
public readonly IMessengerService MessengerService;
public readonly IAccountService AccountService;
private readonly ITradingService TradingService;
private readonly IBotService BotService;
public TradingBotConfig Config { get; set; }
public Account Account { get; set; }
public HashSet<IStrategy> Strategies { get; set; }
public FixedSizeQueue<Candle> OptimizedCandles { get; set; }
public HashSet<Candle> Candles { get; set; }
public HashSet<Signal> Signals { get; set; }
public List<Position> Positions { get; set; }
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
public Dictionary<StrategyType, StrategiesResultBase> StrategiesValues { get; set; }
public DateTime StartupTime { get; set; }
public DateTime PreloadSince { get; set; }
public int PreloadedCandlesCount { get; set; }
public decimal Fee { get; set; }
public Scenario Scenario { get; set; }
public TradingBot(
IExchangeService exchangeService,
ILogger<TradingBot> logger,
ITradingService tradingService,
IAccountService accountService,
IMessengerService messengerService,
IBotService botService,
TradingBotConfig config
)
: base(config.Name)
{
ExchangeService = exchangeService;
AccountService = accountService;
MessengerService = messengerService;
TradingService = tradingService;
BotService = botService;
Logger = logger;
if (config.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
{
throw new ArgumentException(
$"Initial trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}",
nameof(config.BotTradingBalance));
}
Config = config;
Strategies = new HashSet<IStrategy>();
Signals = new HashSet<Signal>();
OptimizedCandles = new FixedSizeQueue<Candle>(600);
Candles = new HashSet<Candle>();
Positions = new List<Position>();
WalletBalances = new Dictionary<DateTime, decimal>();
StrategiesValues = new Dictionary<StrategyType, StrategiesResultBase>();
if (!Config.IsForBacktest)
{
Interval = CandleExtensions.GetIntervalFromTimeframe(Config.Timeframe);
PreloadSince = CandleExtensions.GetBotPreloadSinceFromTimeframe(Config.Timeframe);
}
}
public override void Start()
{
base.Start();
// Load account synchronously
LoadAccount().GetAwaiter().GetResult();
if (!Config.IsForBacktest)
{
LoadScenario(Config.ScenarioName);
PreloadCandles().GetAwaiter().GetResult();
CancelAllOrders().GetAwaiter().GetResult();
try
{
// await MessengerService.SendMessage(
// $"Hey everyone! I'm about to start {Name}. 🚀\n" +
// $"I'll post an update here each time a signal is triggered by the following strategies: {string.Join(", ", Strategies.Select(s => s.Name))}."
// );
}
catch (Exception ex)
{
Logger.LogError(ex, ex.Message);
}
InitWorker(Run).GetAwaiter().GetResult();
}
// Fee = TradingService.GetFee(Account, IsForBacktest);
}
public async Task LoadAccount()
{
var account = await AccountService.GetAccount(Config.AccountName, false, false);
if (account == null)
{
Logger.LogWarning($"No account found for this {Config.AccountName}");
Stop();
}
else
{
Account = account;
}
}
public void LoadScenario(string scenarioName)
{
var scenario = TradingService.GetScenarioByName(scenarioName);
if (scenario == null)
{
Logger.LogWarning("No scenario found for this scenario name");
Stop();
}
else
{
Scenario = scenario;
LoadStrategies(ScenarioHelpers.GetStrategiesFromScenario(scenario));
}
}
public void LoadStrategies(IEnumerable<IStrategy> strategies)
{
foreach (var strategy in strategies)
{
Strategies.Add(strategy);
}
}
public async Task Run()
{
if (!Config.IsForBacktest)
{
// Check broker balance before running
var balance = await ExchangeService.GetBalance(Account, false);
if (balance < Constants.GMX.Config.MinimumPositionAmount)
{
await LogWarning(
$"Balance on broker is below {Constants.GMX.Config.MinimumPositionAmount} USD (actual: {balance}). Stopping bot {Identifier} and saving backup.");
SaveBackup();
Stop();
return;
}
Logger.LogInformation($"____________________{Name}____________________");
Logger.LogInformation(
$"Time : {DateTime.Now} - Server time {DateTime.Now.ToUniversalTime()} - Last candle : {OptimizedCandles.Last().Date} - Bot : {Name} - Type {Config.BotType} - Ticker : {Config.Ticker}");
}
var previousLastCandle = OptimizedCandles.LastOrDefault();
if (!Config.IsForBacktest)
await UpdateCandles();
var currentLastCandle = OptimizedCandles.LastOrDefault();
if (currentLastCandle != previousLastCandle || Config.IsForBacktest)
await UpdateSignals(OptimizedCandles);
else
Logger.LogInformation($"No need to update signals for {Config.Ticker}");
if (!Config.IsForWatchingOnly)
await ManagePositions();
if (!Config.IsForBacktest)
{
SaveBackup();
UpdateStrategiesValues();
}
UpdateWalletBalances();
if (OptimizedCandles.Count % 100 == 0) // Log every 10th execution
{
Logger.LogInformation($"Candle date : {OptimizedCandles.Last().Date:u}");
Logger.LogInformation($"Signals : {Signals.Count}");
Logger.LogInformation($"ExecutionCount : {ExecutionCount}");
Logger.LogInformation($"Positions : {Positions.Count}");
Logger.LogInformation("__________________________________________________");
}
}
public void UpdateStrategiesValues()
{
foreach (var strategy in Strategies)
{
StrategiesValues[strategy.Type] = ((Strategy)strategy).GetStrategyValues();
}
}
private async Task PreloadCandles()
{
if (OptimizedCandles.Any())
return;
var haveSignal = Signals.Any();
if (haveSignal)
{
PreloadSince = Signals.First().Date;
}
var candles =
await ExchangeService.GetCandlesInflux(Account.Exchange, Config.Ticker, PreloadSince, Config.Timeframe);
foreach (var candle in candles.Where(c => c.Date < DateTime.Now.ToUniversalTime()))
{
if (!OptimizedCandles.Any(c => c.Date == candle.Date))
{
OptimizedCandles.Enqueue(candle);
Candles.Add(candle);
if (!haveSignal)
{
await UpdateSignals(OptimizedCandles);
}
}
}
PreloadedCandlesCount = OptimizedCandles.Count();
}
private async Task UpdateSignals(FixedSizeQueue<Candle> candles)
{
var signal = TradingBox.GetSignal(candles.ToHashSet(), Strategies, Signals, Scenario.LoopbackPeriod);
if (signal == null) return;
signal.User = Account.User;
await AddSignal(signal);
}
private async Task AddSignal(Signal signal)
{
if (Config.IsForWatchingOnly || (ExecutionCount < 1 && !Config.IsForBacktest))
signal.Status = SignalStatus.Expired;
Signals.Add(signal);
var signalText = $"{Config.ScenarioName} trigger a signal. Signal told you " +
$"to {signal.Direction} {Config.Ticker} on {Config.Timeframe}. The confidence in this signal is {signal.Confidence}. Identifier : {signal.Identifier}";
Logger.LogInformation(signalText);
if (Config.IsForWatchingOnly && !Config.IsForBacktest && ExecutionCount > 0)
{
await MessengerService.SendSignal(signalText, Account.Exchange, Config.Ticker, signal.Direction,
Config.Timeframe);
}
}
protected async Task UpdateCandles()
{
if (OptimizedCandles.Count == 0 || ExecutionCount == 0)
return;
var lastCandle = OptimizedCandles.Last();
var newCandle =
await ExchangeService.GetCandlesInflux(Account.Exchange, Config.Ticker, lastCandle.Date, Config.Timeframe);
foreach (var candle in newCandle.Where(c => c.Date < DateTime.Now.ToUniversalTime()))
{
OptimizedCandles.Enqueue(candle);
Candles.Add(candle);
}
}
private async Task<Signal> RecreateSignalFromPosition(Position position)
{
try
{
// Get the candle that corresponds to the position opening time
var positionCandle = OptimizedCandles.FirstOrDefault(c => c.Date <= position.Open.Date)
?? OptimizedCandles.LastOrDefault();
if (positionCandle == null)
{
await LogWarning(
$"Cannot find candle for position {position.Identifier} opened at {position.Open.Date}");
return null;
}
// Create a new signal based on position information
var recreatedSignal = new Signal(
ticker: Config.Ticker,
direction: position.OriginDirection,
confidence: Confidence.Medium, // Default confidence for recreated signals
candle: positionCandle,
date: position.Open.Date,
exchange: Account.Exchange,
strategyType: StrategyType.Stc, // Use a valid strategy type for recreated signals
signalType: SignalType.Signal
);
// Since Signal identifier is auto-generated, we need to update our position
// to use the new signal identifier, or find another approach
// For now, let's update the position's SignalIdentifier to match the recreated signal
position.SignalIdentifier = recreatedSignal.Identifier;
recreatedSignal.Status = SignalStatus.PositionOpen;
recreatedSignal.User = Account.User;
// Add the recreated signal to our collection
Signals.Add(recreatedSignal);
await LogInformation(
$"Successfully recreated signal {recreatedSignal.Identifier} for position {position.Identifier}");
return recreatedSignal;
}
catch (Exception ex)
{
await LogWarning($"Error recreating signal for position {position.Identifier}: {ex.Message}");
return null;
}
}
private async Task ManagePositions()
{
foreach (var position in Positions.Where(p => !p.IsFinished()))
{
var signalForPosition = Signals.FirstOrDefault(s => s.Identifier == position.SignalIdentifier);
if (signalForPosition == null)
{
await LogInformation($"Signal not found for position {position.Identifier}. Recreating signal...");
// Recreate the signal based on position information
signalForPosition = await RecreateSignalFromPosition(position);
if (signalForPosition == null)
{
await LogWarning($"Failed to recreate signal for position {position.Identifier}");
continue;
}
}
// Ensure signal status is correctly set to PositionOpen if position is not finished
if (signalForPosition.Status != SignalStatus.PositionOpen && position.Status != PositionStatus.Finished)
{
await LogInformation(
$"Updating signal {signalForPosition.Identifier} status from {signalForPosition.Status} to PositionOpen");
SetSignalStatus(signalForPosition.Identifier, SignalStatus.PositionOpen);
}
await UpdatePosition(signalForPosition, position);
}
// Open position for signals waiting for a position open
foreach (var signal in Signals.Where(s => s.Status == SignalStatus.WaitingForPosition))
{
Task.Run(() => OpenPosition(signal)).GetAwaiter().GetResult();
}
}
private void UpdateWalletBalances()
{
var lastCandle = OptimizedCandles.LastOrDefault();
if (lastCandle == null) return;
var date = lastCandle.Date;
if (WalletBalances.Count == 0)
{
// WalletBalances[date] = await ExchangeService.GetBalance(Account, IsForBacktest);
WalletBalances[date] = Config.BotTradingBalance;
return;
}
if (!WalletBalances.ContainsKey(date))
{
var previousBalance = WalletBalances.First().Value;
WalletBalances[date] = previousBalance + GetProfitAndLoss();
}
}
private async Task UpdatePosition(Signal signal, Position positionForSignal)
{
try
{
Logger.LogInformation($"Updating position {positionForSignal.SignalIdentifier}");
var position = Config.IsForBacktest
? positionForSignal
: TradingService.GetPositionByIdentifier(positionForSignal.Identifier);
var positionsExchange = Config.IsForBacktest
? new List<Position> { position }
: await TradingService.GetBrokerPositions(Account);
if (!Config.IsForBacktest)
{
var brokerPosition = positionsExchange.FirstOrDefault(p => p.Ticker == Config.Ticker);
if (brokerPosition != null)
{
UpdatePositionPnl(positionForSignal.Identifier, brokerPosition.ProfitAndLoss.Realized);
if (position.Status.Equals(PositionStatus.New))
{
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Filled);
}
position = brokerPosition;
}
else
{
// No position, position close on the broker
if (!position.Status.Equals(PositionStatus.New))
{
// Setup the previous status of the position
position.Status = PositionStatus.Filled;
}
}
}
if (position.Status == PositionStatus.New)
{
var orders = await ExchangeService.GetOpenOrders(Account, Config.Ticker);
if (orders.Any())
{
// If there are 3 or more orders and position is still not filled, check if enough time has passed
if (orders.Count() >= 3)
{
var currentTime = Config.IsForBacktest ? OptimizedCandles.Last().Date : DateTime.UtcNow;
var timeSinceRequest = currentTime - positionForSignal.Open.Date;
var waitTimeMinutes = 10;
if (timeSinceRequest.TotalMinutes >= waitTimeMinutes)
{
await LogWarning(
$"Too many open orders ({orders.Count()}) for unfilled position and {waitTimeMinutes} minutes have passed. Canceling all orders and marking position as canceled.");
try
{
await ExchangeService.CancelOrder(Account, Config.Ticker);
await LogInformation($"Successfully canceled all orders for {Config.Ticker}");
}
catch (Exception ex)
{
await LogWarning($"Failed to cancel orders: {ex.Message}");
}
await SetPositionStatus(signal.Identifier, PositionStatus.Canceled);
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return;
}
else
{
var remainingMinutes = waitTimeMinutes - timeSinceRequest.TotalMinutes;
await LogInformation(
$"Position has {orders.Count()} open orders but only {timeSinceRequest.TotalMinutes:F1} minutes have passed. Waiting {remainingMinutes:F1} more minutes before canceling.");
}
}
else
{
await LogInformation(
$"Cannot update Position. Position is still waiting for opening. There is {orders.Count()} open orders.");
}
}
else
{
await LogWarning(
$"Cannot update Position. No position on exchange and no orders. Position {signal.Identifier} might be closed already.");
await HandleClosedPosition(positionForSignal);
}
}
else if (position.Status == (PositionStatus.Finished | PositionStatus.Flipped))
{
await HandleClosedPosition(positionForSignal);
}
else if (position.Status == (PositionStatus.Filled | PositionStatus.PartiallyFilled))
{
// For backtesting or force close if not executed on exchange :
// check if position is still open
// Check status, if still open update the status of the position
// Position might be partially filled, meaning that TPSL havent been sended yet
// But the position might already been closed by the exchange so we have to check should be closed
var lastCandle = Config.IsForBacktest
? OptimizedCandles.Last()
: ExchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow);
var currentTime = Config.IsForBacktest ? lastCandle.Date : DateTime.UtcNow;
var currentPnl = positionForSignal.ProfitAndLoss?.Realized ?? 0;
var pnlPercentage = positionForSignal.Open.Price * positionForSignal.Open.Quantity != 0
? Math.Round((currentPnl / (positionForSignal.Open.Price * positionForSignal.Open.Quantity)) * 100,
2)
: 0;
// Check if position is in profit by comparing entry price with current market price
var isPositionInProfit = positionForSignal.OriginDirection == TradeDirection.Long
? lastCandle.Close > positionForSignal.Open.Price
: lastCandle.Close < positionForSignal.Open.Price;
var hasExceededTimeLimit = Config.MaxPositionTimeHours.HasValue &&
HasPositionExceededTimeLimit(positionForSignal, currentTime);
// 2. Time-based closure (if time limit exceeded)
if (hasExceededTimeLimit)
{
// If CloseEarlyWhenProfitable is enabled, only close if profitable
// If CloseEarlyWhenProfitable is disabled, close regardless of profit status
var shouldCloseOnTimeLimit = !Config.CloseEarlyWhenProfitable || isPositionInProfit;
if (shouldCloseOnTimeLimit)
{
var profitStatus = isPositionInProfit ? "in profit" : "at a loss";
await LogInformation(
$"Closing position due to time limit ({Config.MaxPositionTimeHours} hours exceeded) - " +
$"Position is {profitStatus} (entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " +
$"Realized PNL: ${currentPnl:F2} ({pnlPercentage:F2}%)");
await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true);
return;
}
else
{
await LogInformation(
$"Time limit exceeded but position is at a loss " +
$"(entry: {positionForSignal.Open.Price}, current: {lastCandle.Close}). " +
$"Realized PNL: ${currentPnl:F2} ({pnlPercentage:F2}%). " +
$"Waiting for profit before closing (CloseEarlyWhenProfitable enabled).");
}
}
// 3. Normal stop loss and take profit checks
if (positionForSignal.OriginDirection == TradeDirection.Long)
{
if (positionForSignal.StopLoss.Price >= lastCandle.Low)
{
await LogInformation($"Closing position - SL hit at {positionForSignal.StopLoss.Price}");
await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss,
positionForSignal.StopLoss.Price, true);
positionForSignal.StopLoss.SetStatus(TradeStatus.Filled);
}
else if (positionForSignal.TakeProfit1.Price <= lastCandle.High &&
positionForSignal.TakeProfit1.Status != TradeStatus.Filled)
{
await LogInformation($"Closing position - TP1 hit at {positionForSignal.TakeProfit1.Price}");
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1,
positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null);
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled);
}
else if (positionForSignal.TakeProfit2?.Price <= lastCandle.High)
{
await LogInformation($"Closing position - TP2 hit at {positionForSignal.TakeProfit2.Price}");
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2,
positionForSignal.TakeProfit2.Price, true);
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled);
}
}
else if (positionForSignal.OriginDirection == TradeDirection.Short)
{
if (positionForSignal.StopLoss.Price <= lastCandle.High)
{
await LogInformation($"Closing position - SL hit at {positionForSignal.StopLoss.Price}");
await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss,
positionForSignal.StopLoss.Price, true);
positionForSignal.StopLoss.SetStatus(TradeStatus.Filled);
}
else if (positionForSignal.TakeProfit1.Price >= lastCandle.Low &&
positionForSignal.TakeProfit1.Status != TradeStatus.Filled)
{
await LogInformation($"Closing position - TP1 hit at {positionForSignal.TakeProfit1.Price}");
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1,
positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null);
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled);
}
else if (positionForSignal.TakeProfit2?.Price >= lastCandle.Low)
{
await LogInformation($"Closing position - TP2 hit at {positionForSignal.TakeProfit2.Price}");
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2,
positionForSignal.TakeProfit2.Price, true);
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled);
}
}
}
else if (position.Status == (PositionStatus.Rejected | PositionStatus.Canceled))
{
await LogWarning($"Open position trade is rejected for signal {signal.Identifier}");
// if position is not open
// Re-open the trade for the signal only if signal still up
if (signal.Status == SignalStatus.PositionOpen)
{
Logger.LogInformation($"Try to re-open position");
await OpenPosition(signal);
}
}
}
catch (Exception ex)
{
await LogWarning($"Cannot update position {positionForSignal.Identifier}: {ex.Message}");
SentrySdk.CaptureException(ex);
return;
}
}
private async Task OpenPosition(Signal signal)
{
// Check if a position is already open
Logger.LogInformation($"Opening position for {signal.Identifier}");
var openedPosition = Positions.FirstOrDefault(p => p.Status == PositionStatus.Filled
&& p.SignalIdentifier != signal.Identifier);
var lastPrice = Config.IsForBacktest
? OptimizedCandles.Last().Close
: ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
// If position open
if (openedPosition != null)
{
var previousSignal = Signals.First(s => s.Identifier == openedPosition.SignalIdentifier);
// Check if signal is the opposite side => flip the position
if (openedPosition.OriginDirection == signal.Direction)
{
// An operation is already open for the same direction
await LogInformation(
$"Signal {signal.Identifier} try to open a position but {previousSignal.Identifier} is already open for the same direction");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
}
else
{
// An operation is already open for the opposite direction
// ==> Flip the position
if (Config.FlipPosition)
{
// Check if current position is in profit before flipping
var isPositionInProfit = (openedPosition.ProfitAndLoss?.Realized ?? 0) > 0;
// Determine if we should flip based on configuration
var shouldFlip = !Config.FlipOnlyWhenInProfit || isPositionInProfit;
if (shouldFlip)
{
var flipReason = Config.FlipOnlyWhenInProfit
? "current position is in profit"
: "FlipOnlyWhenInProfit is disabled";
await LogInformation(
$"Try to flip the position because of an opposite direction signal and {flipReason}");
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped);
await OpenPosition(signal);
await LogInformation(
$"Position {previousSignal.Identifier} flipped by {signal.Identifier} at {lastPrice}$");
}
else
{
var currentPnl = openedPosition.ProfitAndLoss?.Realized ?? 0;
await LogInformation(
$"Position {previousSignal.Identifier} is not in profit (PnL: ${currentPnl:F2}). " +
$"Signal {signal.Identifier} will wait for position to become profitable before flipping.");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return;
}
}
else
{
await LogInformation(
$"A position is already open for signal {previousSignal.Identifier}. Position flipping is currently not enable, the position will not be flipped.");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
}
}
}
else
{
if (!(await CanOpenPosition(signal)))
{
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return;
}
await LogInformation(
$"Open position - Date: {signal.Date:T} - SignalIdentifier : {signal.Identifier}");
try
{
var command = new OpenPositionRequest(
Config.AccountName,
Config.MoneyManagement,
signal.Direction,
Config.Ticker,
PositionInitiator.Bot,
signal.Date,
User,
Config.BotTradingBalance,
Config.IsForBacktest,
lastPrice,
signalIdentifier: signal.Identifier);
var position = (new OpenPositionCommandHandler(ExchangeService, AccountService, TradingService)
.Handle(command)).Result;
if (position != null)
{
Positions.Add(position);
if (position.Open.Status != TradeStatus.Cancelled)
{
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
if (!Config.IsForBacktest)
{
await MessengerService.SendPosition(position);
}
Logger.LogInformation($"Position requested");
}
else
{
await SetPositionStatus(signal.Identifier, PositionStatus.Rejected);
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
}
}
}
catch (Exception ex)
{
// Keep signal open for debug purpose
//SetSignalStatus(signal.Identifier, SignalStatus.Expired);
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
await LogWarning($"Cannot open trade : {ex.Message}, stackTrace : {ex.StackTrace}");
}
}
}
private async Task<bool> CanOpenPosition(Signal signal)
{
// Early return if we're in backtest mode and haven't executed yet
if (!Config.IsForBacktest && ExecutionCount < 1)
{
await LogInformation("Cannot open position: Bot hasn't executed yet");
return false;
}
// Check if we're in backtest mode
if (Config.IsForBacktest)
{
return await CheckCooldownPeriod(signal) && await CheckLossStreak(signal);
}
// Check broker positions for live trading
var canOpenPosition = await CheckBrokerPositions();
if (!canOpenPosition)
{
return false;
}
// Check cooldown period and loss streak
return await CheckCooldownPeriod(signal) && await CheckLossStreak(signal);
}
private async Task<bool> CheckLossStreak(Signal signal)
{
// If MaxLossStreak is 0, there's no limit
if (Config.MaxLossStreak <= 0)
{
return true;
}
// Get the last N finished positions regardless of direction
var recentPositions = Positions
.Where(p => p.IsFinished())
.OrderByDescending(p => p.Open.Date)
.Take(Config.MaxLossStreak)
.ToList();
// If we don't have enough positions to form a streak, we can open
if (recentPositions.Count < Config.MaxLossStreak)
{
return true;
}
// Check if all recent positions were losses
var allLosses = recentPositions.All(p => p.ProfitAndLoss?.Realized < 0);
if (!allLosses)
{
return true;
}
// If we have a loss streak, check if the last position was in the same direction as the signal
var lastPosition = recentPositions.First();
if (lastPosition.OriginDirection == signal.Direction)
{
await LogWarning($"Cannot open position: Max loss streak ({Config.MaxLossStreak}) reached. " +
$"Last {recentPositions.Count} trades were losses. " +
$"Last position was {lastPosition.OriginDirection}, waiting for a signal in the opposite direction.");
return false;
}
return true;
}
private async Task<bool> CheckBrokerPositions()
{
try
{
var positions = await ExchangeService.GetBrokerPositions(Account);
if (!positions.Any(p => p.Ticker == Config.Ticker))
{
return true;
}
// Handle existing position on broker
var previousPosition = Positions.LastOrDefault();
var orders = await ExchangeService.GetOpenOrders(Account, Config.Ticker);
var reason = $"Cannot open position. There is already a position open for {Config.Ticker} on the broker.";
if (previousPosition != null && orders.Count >= 2)
{
await SetPositionStatus(previousPosition.SignalIdentifier, PositionStatus.Filled);
}
else
{
reason +=
" Position open on broker but not enough orders or no previous position internally saved by the bot";
}
await LogWarning(reason);
return false;
}
catch (Exception ex)
{
await LogWarning($"Error checking broker positions: {ex.Message}");
return false;
}
}
private async Task<bool> CheckCooldownPeriod(Signal signal)
{
var lastPosition = Positions.LastOrDefault(p => p.IsFinished()
&& p.SignalIdentifier != signal.Identifier
&& p.OriginDirection == signal.Direction);
if (lastPosition == null)
{
return true;
}
var cooldownCandle = OptimizedCandles.TakeLast((int)Config.CooldownPeriod).FirstOrDefault();
if (cooldownCandle == null)
{
await LogWarning("Cannot check cooldown period: Not enough candles available");
return false;
}
var positionSignal = Signals.FirstOrDefault(s => s.Identifier == lastPosition.SignalIdentifier);
if (positionSignal == null)
{
await LogWarning($"Cannot find signal for last position {lastPosition.Identifier}");
return false;
}
var canOpenPosition = positionSignal.Date < cooldownCandle.Date;
if (!canOpenPosition)
{
await LogInformation(
$"Position cannot be opened: Cooldown period not elapsed. Last position date: {positionSignal.Date}, Cooldown candle date: {cooldownCandle.Date}");
}
return canOpenPosition;
}
public async Task CloseTrade(Signal signal, Position position, Trade tradeToClose, decimal lastPrice,
bool tradeClosingPosition = false)
{
if (position.TakeProfit2 != null && position.TakeProfit1.Status == TradeStatus.Filled &&
tradeToClose.TradeType == TradeType.StopMarket)
{
// If trade is the 2nd Take profit
tradeToClose.Quantity = position.TakeProfit2.Quantity;
}
await LogInformation(
$"Trying to close trade {Config.Ticker} at {lastPrice} - Type : {tradeToClose.TradeType} - Quantity : {tradeToClose.Quantity} " +
$"- Closing Position : {tradeClosingPosition}");
// Get status of position before closing it. The position might be already close by the exchange
if (!Config.IsForBacktest && await ExchangeService.GetQuantityInPosition(Account, Config.Ticker) == 0)
{
Logger.LogInformation($"Trade already close on exchange");
await HandleClosedPosition(position);
}
else
{
var command = new ClosePositionCommand(position, lastPrice, isForBacktest: Config.IsForBacktest);
try
{
var closedPosition = (new ClosePositionCommandHandler(ExchangeService, AccountService, TradingService)
.Handle(command)).Result;
if (closedPosition.Status == (PositionStatus.Finished | PositionStatus.Flipped))
{
if (tradeClosingPosition)
{
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
}
await HandleClosedPosition(closedPosition);
}
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 | PositionStatus.Rejected))
{
// Trade close on exchange => Should close trade manually
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
}
}
}
}
private async Task HandleClosedPosition(Position position)
{
if (Positions.Any(p => p.Identifier == position.Identifier))
{
// Update the close date for the trade that actually closed the position
var currentCandle = Config.IsForBacktest
? OptimizedCandles.LastOrDefault()
: ExchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow);
if (currentCandle != null && position.ProfitAndLoss != null)
{
// Determine which trade closed the position based on realized P&L
if (position.ProfitAndLoss.Realized > 0)
{
// Profitable close = Take Profit
position.TakeProfit1.SetDate(currentCandle.Date);
}
else
{
// Loss or breakeven close = Stop Loss
position.StopLoss.SetDate(currentCandle.Date);
}
}
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished);
Logger.LogInformation(
$"Position {position.SignalIdentifier} type correctly close. Pnl on position : {position.ProfitAndLoss?.Realized}");
// Update the bot's trading balance after position is closed
if (position.ProfitAndLoss != null)
{
// Add PnL (could be positive or negative)
Config.BotTradingBalance += position.ProfitAndLoss.Realized;
// Subtract fees
Config.BotTradingBalance -= GetPositionFees(position);
Logger.LogInformation($"Updated bot trading balance to: {Config.BotTradingBalance}");
}
}
else
{
await LogWarning("Weird things happen - Trying to update position status, but no position found");
}
if (!Config.IsForBacktest)
{
await MessengerService.SendClosingPosition(position);
}
await CancelAllOrders();
}
private async Task CancelAllOrders()
{
if (!Config.IsForBacktest && !Config.IsForWatchingOnly)
{
try
{
var openOrders = await ExchangeService.GetOpenOrders(Account, Config.Ticker);
if (openOrders.Any())
{
var openPositions = (await ExchangeService.GetBrokerPositions(Account))
.Where(p => p.Ticker == Config.Ticker);
var cancelClose = openPositions.Any();
if (cancelClose)
{
Logger.LogInformation($"Position still open, cancel close orders&");
}
else
{
Logger.LogInformation($"Canceling all orders for {Config.Ticker}");
await ExchangeService.CancelOrder(Account, Config.Ticker);
var closePendingOrderStatus = await ExchangeService.CancelOrder(Account, Config.Ticker);
Logger.LogInformation($"Closing all {Config.Ticker} orders status : {closePendingOrderStatus}");
}
}
else
{
Logger.LogInformation($"No need to cancel orders for {Config.Ticker}");
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during cancelOrders");
SentrySdk.CaptureException(ex);
}
}
}
private async Task SetPositionStatus(string signalIdentifier, PositionStatus positionStatus)
{
var position = Positions.First(p => p.SignalIdentifier == signalIdentifier);
if (!position.Status.Equals(positionStatus))
{
Positions.First(p => p.SignalIdentifier == signalIdentifier).Status = positionStatus;
await LogInformation($"Position {signalIdentifier} new status {position.Status} => {positionStatus}");
}
SetSignalStatus(signalIdentifier,
positionStatus == PositionStatus.Filled ? SignalStatus.PositionOpen : SignalStatus.Expired);
}
private void UpdatePositionPnl(string identifier, decimal realized)
{
Positions.First(p => p.Identifier == identifier).ProfitAndLoss = new ProfitAndLoss()
{
Realized = realized
};
}
private void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus)
{
if (Signals.Any(s => s.Identifier == signalIdentifier && s.Status != signalStatus))
{
Signals.First(s => s.Identifier == signalIdentifier).Status = signalStatus;
Logger.LogInformation($"Signal {signalIdentifier} is now {signalStatus}");
}
}
public int GetWinRate()
{
var succeededPositions = Positions.Where(p => p.IsFinished()).Count(p => p.ProfitAndLoss?.Realized > 0);
var total = Positions.Where(p => p.IsFinished()).Count();
if (total == 0)
return 0;
return (succeededPositions * 100) / total;
}
public decimal GetProfitAndLoss()
{
var pnl = Positions.Where(p => p.ProfitAndLoss != null).Sum(p => p.ProfitAndLoss.Realized);
return pnl - GetTotalFees();
}
/// <summary>
/// Calculates the total fees paid by the trading bot for each position.
/// </summary>
/// <returns>Returns the total fees paid as a decimal value.</returns>
public decimal GetTotalFees()
{
decimal fees = 0;
foreach (var position in Positions.Where(p => p.Open.Fee > 0))
{
fees += position.Open.Fee;
fees += position.StopLoss.Status == TradeStatus.Filled ? position.StopLoss.Fee : 0;
fees += position.TakeProfit1.Status == TradeStatus.Filled ? position.TakeProfit1.Fee : 0;
if (position.IsFinished() &&
position.StopLoss.Status != TradeStatus.Filled && position.TakeProfit1.Status != TradeStatus.Filled)
fees += position.Open.Fee;
if (position.TakeProfit2 != null)
fees += position.TakeProfit2.Fee;
}
return fees;
}
/// <summary>
/// Calculates the total fees for a specific position
/// </summary>
/// <param name="position">The position to calculate fees for</param>
/// <returns>The total fees for the position</returns>
private decimal GetPositionFees(Position position)
{
decimal fees = 0;
fees += position.Open.Fee;
fees += position.StopLoss.Status == TradeStatus.Filled ? position.StopLoss.Fee : 0;
fees += position.TakeProfit1.Status == TradeStatus.Filled ? position.TakeProfit1.Fee : 0;
if (position.IsFinished() &&
position.StopLoss.Status != TradeStatus.Filled && position.TakeProfit1.Status != TradeStatus.Filled)
fees += position.Open.Fee;
if (position.TakeProfit2 != null)
fees += position.TakeProfit2.Status == TradeStatus.Filled ? position.TakeProfit2.Fee : 0;
return fees;
}
public async Task ToggleIsForWatchOnly()
{
Config.IsForWatchingOnly = !Config.IsForWatchingOnly;
await LogInformation($"Watch only toggle for bot : {Name} - Watch only : {Config.IsForWatchingOnly}");
}
private async Task LogInformation(string message)
{
Logger.LogInformation(message);
try
{
await SendTradeMessage(message);
}
catch (Exception e)
{
Console.WriteLine(e);
}
}
private async Task LogWarning(string message)
{
message = $"[{Identifier}] {message}";
SentrySdk.CaptureException(new Exception(message));
try
{
await SendTradeMessage(message, true);
}
catch (Exception e)
{
Console.WriteLine(e);
}
}
private async Task SendTradeMessage(string message, bool isBadBehavior = false)
{
if (!Config.IsForBacktest)
{
await MessengerService.SendTradeMessage(message, isBadBehavior, Account?.User);
}
}
public override void SaveBackup()
{
var data = new TradingBotBackup
{
Name = Name,
BotType = Config.BotType,
Signals = Signals,
Positions = Positions,
Timeframe = Config.Timeframe,
Ticker = Config.Ticker,
ScenarioName = Config.ScenarioName,
AccountName = Config.AccountName,
IsForWatchingOnly = Config.IsForWatchingOnly,
WalletBalances = WalletBalances,
MoneyManagement = Config.MoneyManagement,
BotTradingBalance = Config.BotTradingBalance,
StartupTime = StartupTime,
CooldownPeriod = Config.CooldownPeriod,
MaxLossStreak = Config.MaxLossStreak,
MaxPositionTimeHours = Config.MaxPositionTimeHours ?? 0m,
FlipOnlyWhenInProfit = Config.FlipOnlyWhenInProfit,
CloseEarlyWhenProfitable = Config.CloseEarlyWhenProfitable,
};
BotService.SaveOrUpdateBotBackup(User, Identifier, Config.BotType, Status, JsonConvert.SerializeObject(data));
}
public override void LoadBackup(BotBackup backup)
{
var data = JsonConvert.DeserializeObject<TradingBotBackup>(backup.Data);
Config = new TradingBotConfig
{
AccountName = data.AccountName,
MoneyManagement = data.MoneyManagement,
Ticker = data.Ticker,
ScenarioName = data.ScenarioName,
Timeframe = data.Timeframe,
IsForBacktest = false, // Always false when loading from backup
IsForWatchingOnly = data.IsForWatchingOnly,
BotTradingBalance = data.BotTradingBalance,
BotType = data.BotType,
CooldownPeriod = data.CooldownPeriod,
MaxLossStreak = data.MaxLossStreak,
MaxPositionTimeHours = data.MaxPositionTimeHours == 0m ? null : data.MaxPositionTimeHours,
FlipOnlyWhenInProfit = data.FlipOnlyWhenInProfit,
CloseEarlyWhenProfitable = data.CloseEarlyWhenProfitable,
Name = data.Name
};
Signals = data.Signals;
Positions = data.Positions;
WalletBalances = data.WalletBalances;
PreloadSince = data.StartupTime;
Identifier = backup.Identifier;
User = backup.User;
Status = backup.LastStatus;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="direction">The direction of the trade (Long/Short).</param>
/// <returns>The created Position object.</returns>
/// <exception cref="Exception">Throws if no candles are available or position opening fails.</exception>
public async Task<Position> OpenPositionManually(TradeDirection direction)
{
var lastCandle = OptimizedCandles.LastOrDefault();
if (lastCandle == null)
{
throw new Exception("No candles available to open position");
}
// Create a fake signal for manual position opening
var signal = new Signal(Config.Ticker, direction, Confidence.Low, lastCandle, lastCandle.Date,
TradingExchanges.GmxV2,
StrategyType.Stc, SignalType.Signal);
signal.Status = SignalStatus.WaitingForPosition; // Ensure status is correct
signal.User = Account.User; // Assign user
// Add the signal to our collection
await AddSignal(signal);
// Open the position using the generated signal (SL/TP handled by MoneyManagement)
await OpenPosition(signal);
// Get the opened position
var position = Positions.FirstOrDefault(p => p.SignalIdentifier == signal.Identifier);
if (position == null)
{
// Clean up the signal if position creation failed
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
throw new Exception("Failed to open position");
}
Logger.LogInformation($"Manually opened position {position.Identifier} for signal {signal.Identifier}");
return position;
}
/// <summary>
/// Checks if a position has exceeded the maximum time limit for being open.
/// </summary>
/// <param name="position">The position to check</param>
/// <param name="currentTime">The current time to compare against</param>
/// <returns>True if the position has exceeded the time limit, false otherwise</returns>
private bool HasPositionExceededTimeLimit(Position position, DateTime currentTime)
{
if (!Config.MaxPositionTimeHours.HasValue)
{
return false; // Time-based closure is disabled
}
var timeOpen = currentTime - position.Open.Date;
var maxTimeAllowed = TimeSpan.FromHours((double)Config.MaxPositionTimeHours.Value);
return timeOpen >= maxTimeAllowed;
}
/// <summary>
/// Updates the trading bot configuration with new settings.
/// This method validates the new configuration and applies it to the running bot.
/// </summary>
/// <param name="newConfig">The new configuration to apply</param>
/// <returns>True if the configuration was successfully updated, false otherwise</returns>
/// <exception cref="ArgumentException">Thrown when the new configuration is invalid</exception>
public async Task<bool> UpdateConfiguration(TradingBotConfig newConfig)
{
try
{
// Validate the new configuration
if (newConfig == null)
{
throw new ArgumentException("Configuration cannot be null");
}
if (newConfig.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
{
throw new ArgumentException(
$"Bot trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}");
}
if (string.IsNullOrEmpty(newConfig.AccountName))
{
throw new ArgumentException("Account name cannot be null or empty");
}
if (string.IsNullOrEmpty(newConfig.ScenarioName))
{
throw new ArgumentException("Scenario name cannot be null or empty");
}
// Protect critical properties that shouldn't change for running bots
var protectedBotType = Config.BotType;
var protectedIsForBacktest = Config.IsForBacktest;
var protectedName = Config.Name;
// Log the configuration update (before changing anything)
await LogInformation("⚙️ **Configuration Update**\n" +
"📊 **Previous Settings:**\n" +
$"💰 Balance: ${Config.BotTradingBalance:F2}\n" +
$"⏱️ Max Time: {(Config.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled")}\n" +
$"📈 Flip Only in Profit: {(Config.FlipOnlyWhenInProfit ? "" : "")}\n" +
$"⏳ Cooldown: {Config.CooldownPeriod} candles\n" +
$"📉 Max Loss Streak: {Config.MaxLossStreak}");
// Update the configuration
Config = newConfig;
// Restore protected properties
Config.BotType = protectedBotType;
Config.IsForBacktest = protectedIsForBacktest;
Config.Name = protectedName;
// If account changed, reload it
if (Config.AccountName != Account?.Name)
{
await LoadAccount();
}
// If scenario changed, reload it
var currentScenario = Scenario?.Name;
if (Config.ScenarioName != currentScenario)
{
LoadScenario(Config.ScenarioName);
}
await LogInformation("✅ **Configuration Applied**\n" +
"🔧 **New Settings:**\n" +
$"💰 Balance: ${Config.BotTradingBalance:F2}\n" +
$"⏱️ Max Time: {(Config.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled")}\n" +
$"📈 Flip Only in Profit: {(Config.FlipOnlyWhenInProfit ? "" : "")}\n" +
$"⏳ Cooldown: {Config.CooldownPeriod} candles\n" +
$"📉 Max Loss Streak: {Config.MaxLossStreak}");
// Save the updated configuration as backup
if (!Config.IsForBacktest)
{
SaveBackup();
}
return true;
}
catch (Exception ex)
{
await LogWarning($"Failed to update bot configuration: {ex.Message}");
return false;
}
}
/// <summary>
/// Gets the current trading bot configuration.
/// </summary>
/// <returns>A copy of the current configuration</returns>
public TradingBotConfig GetConfiguration()
{
return new TradingBotConfig
{
AccountName = Config.AccountName,
MoneyManagement = Config.MoneyManagement,
Ticker = Config.Ticker,
ScenarioName = Config.ScenarioName,
Timeframe = Config.Timeframe,
IsForWatchingOnly = Config.IsForWatchingOnly,
BotTradingBalance = Config.BotTradingBalance,
BotType = Config.BotType,
IsForBacktest = Config.IsForBacktest,
CooldownPeriod = Config.CooldownPeriod,
MaxLossStreak = Config.MaxLossStreak,
MaxPositionTimeHours = Config.MaxPositionTimeHours,
FlipOnlyWhenInProfit = Config.FlipOnlyWhenInProfit,
FlipPosition = Config.FlipPosition,
Name = Config.Name,
CloseEarlyWhenProfitable = Config.CloseEarlyWhenProfitable
};
}
}
public class TradingBotBackup
{
public string Name { get; set; }
public BotType BotType { get; set; }
public HashSet<Signal> Signals { get; set; }
public List<Position> Positions { get; set; }
public Timeframe Timeframe { get; set; }
public Ticker Ticker { get; set; }
public string ScenarioName { get; set; }
public string AccountName { get; set; }
public bool IsForWatchingOnly { get; set; }
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
public MoneyManagement MoneyManagement { get; set; }
public DateTime StartupTime { get; set; }
public decimal BotTradingBalance { get; set; }
public int CooldownPeriod { get; set; }
public int MaxLossStreak { get; set; }
public decimal MaxPositionTimeHours { get; set; }
public bool FlipOnlyWhenInProfit { get; set; }
public bool CloseEarlyWhenProfitable { get; set; }
}