Files
managing-apps/src/Managing.Application/Bots/TradingBotBase.cs
2025-08-16 06:32:25 +07:00

1860 lines
79 KiB
C#

using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Grains;
using Managing.Application.Abstractions.Services;
using Managing.Application.Trading.Commands;
using Managing.Application.Trading.Handlers;
using Managing.Common;
using Managing.Core;
using Managing.Domain.Accounts;
using Managing.Domain.Bots;
using Managing.Domain.Candles;
using Managing.Domain.Indicators;
using Managing.Domain.Scenarios;
using Managing.Domain.Shared.Helpers;
using Managing.Domain.Strategies;
using Managing.Domain.Synth.Models;
using Managing.Domain.Trades;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using static Managing.Common.Enums;
namespace Managing.Application.Bots;
public class TradingBotBase : ITradingBot
{
public readonly ILogger<TradingBotBase> Logger;
private readonly IServiceScopeFactory _scopeFactory;
public TradingBotConfig Config { get; set; }
public Account Account { get; set; }
public Dictionary<string, LightSignal> Signals { get; set; }
public Dictionary<Guid, Position> Positions { get; set; }
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
public DateTime PreloadSince { get; set; }
public int PreloadedCandlesCount { get; set; }
public long ExecutionCount { get; set; } = 0;
public Guid Identifier { get; set; } = Guid.Empty;
public Candle LastCandle { get; set; }
public DateTime? LastPositionClosingTime { get; set; }
public TradingBotBase(
ILogger<TradingBotBase> logger,
IServiceScopeFactory scopeFactory,
TradingBotConfig config
)
{
_scopeFactory = scopeFactory;
Logger = logger;
Config = config;
Signals = new Dictionary<string, LightSignal>();
Positions = new Dictionary<Guid, Position>();
WalletBalances = new Dictionary<DateTime, decimal>();
PreloadSince = CandleExtensions.GetBotPreloadSinceFromTimeframe(config.Timeframe);
}
public async Task Start(BotStatus previousStatus)
{
if (!Config.IsForBacktest)
{
// Start async initialization in the background without blocking
try
{
// Load account asynchronously
await LoadAccount();
// Load last candle asynchronously
await LoadLastCandle();
if (Account == null)
{
await LogWarning($"Account {Config.AccountName} not found. Bot cannot start.");
throw new ArgumentException("Account not found");
}
// Cancel orders
// await CancelAllOrders();
// Send startup message only for fresh starts (not reboots)
switch (previousStatus)
{
case BotStatus.Saved:
var indicatorNames = Config.Scenario.Indicators.Select(i => i.Type.ToString()).ToList();
var startupMessage = $"🚀 **Bot Started Successfully!**\n\n" +
$"📊 **Trading Setup:**\n" +
$"🎯 Ticker: `{Config.Ticker}`\n" +
$"⏰ Timeframe: `{Config.Timeframe}`\n" +
$"🎮 Scenario: `{Config.Scenario?.Name ?? "Unknown"}`\n" +
$"💰 Balance: `${Config.BotTradingBalance:F2}`\n" +
$"👀 Mode: `{(Config.IsForWatchingOnly ? "Watch Only" : "Live Trading")}`\n\n" +
$"📈 **Active Indicators:** `{string.Join(", ", indicatorNames)}`\n\n" +
$"✅ Ready to monitor signals and execute trades!\n" +
$"📢 I'll notify you when signals are triggered.";
await LogInformation(startupMessage);
break;
case BotStatus.Running:
return;
case BotStatus.Stopped:
// If status was Stopped we log a message to inform the user that the bot is restarting
await LogInformation($"🔄 **Bot Restarted**\n" +
$"📊 Resuming operations with {Signals.Count} signals and {Positions.Count} positions\n" +
$"✅ Ready to continue trading");
break;
default:
// Handle any other status if needed
break;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during bot startup: {Message}", ex.Message);
}
}
}
public async Task LoadLastCandle()
{
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
{
var candles = await exchangeService.GetCandlesInflux(Account.Exchange, Config.Ticker, PreloadSince,
Config.Timeframe, DateTime.UtcNow, 2);
LastCandle = candles.Last();
});
}
public async Task LoadAccount()
{
if (Config.IsForBacktest) return;
await ServiceScopeHelpers.WithScopedService<IAccountService>(_scopeFactory, async accountService =>
{
var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false);
Account = account;
});
}
public async Task Run()
{
if (!Config.IsForBacktest)
{
// Check broker balance before running
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
{
var balance = await exchangeService.GetBalance(Account, false);
if (balance < Constants.GMX.Config.MinimumPositionAmount && Positions.All(p => p.Value.IsFinished()))
{
await LogWarning(
$"Balance on broker is below {Constants.GMX.Config.MinimumPositionAmount} USD (actual: {balance}). Stopping bot {Identifier}.");
return;
}
});
await LoadLastCandle();
}
// Update signals for live trading only
if (!Config.IsForBacktest)
{
await UpdateSignals();
}
if (!Config.IsForWatchingOnly)
await ManagePositions();
UpdateWalletBalances();
if (!Config.IsForBacktest)
{
ExecutionCount++;
Logger.LogInformation("Bot Status - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}",
DateTime.UtcNow, LastCandle.Date, Signals.Count, ExecutionCount, Positions.Count);
}
}
public async Task UpdateSignals(HashSet<Candle>? candles = null)
{
// If position open and not flipped, do not update signals
if (!Config.FlipPosition && Positions.Any(p => !p.Value.IsFinished())) return;
// Check if we're in cooldown period for any direction
if (IsInCooldownPeriod())
{
// Still in cooldown period, skip signal generation
return;
}
if (Config.IsForBacktest && candles != null)
{
var backtestSignal =
TradingBox.GetSignal(candles, Config.Scenario, Signals, Config.Scenario.LoopbackPeriod);
if (backtestSignal == null) return;
await AddSignal(backtestSignal);
}
else
{
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
{
var scenarioRunnerGrain = grainFactory.GetGrain<IScenarioRunnerGrain>(Guid.NewGuid());
var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, PreloadSince, LastCandle);
if (signal == null) return;
await AddSignal(signal);
});
}
}
private async Task<LightSignal> RecreateSignalFromPosition(Position position)
{
try
{
// Create a dummy candle for the position opening time
var positionCandle = new Candle
{
Date = position.Open.Date,
OpenTime = position.Open.Date,
Open = position.Open.Price,
Close = position.Open.Price,
High = position.Open.Price,
Low = position.Open.Price,
Volume = 0,
Exchange = TradingExchanges.Evm,
Ticker = Config.Ticker.ToString(),
Timeframe = Config.Timeframe
};
// Create a new signal based on position information
var recreatedSignal = new LightSignal(
ticker: Config.Ticker,
direction: position.OriginDirection,
confidence: Confidence.Medium, // Default confidence for recreated signals
candle: positionCandle,
date: position.Open.Date,
exchange: TradingExchanges.Evm,
indicatorType: IndicatorType.Stc, // Use a valid strategy type for recreated signals
signalType: SignalType.Signal,
indicatorName: "RecreatedSignal"
);
// 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;
// Add the recreated signal to our collection
Signals.Add(recreatedSignal.Identifier, recreatedSignal);
await LogInformation(
$"🔍 **Signal Recovery Success**\nRecreated signal: `{recreatedSignal.Identifier}`\nFor 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()
{
// First, process all existing positions that are not finished
foreach (var position in Positions.Values.Where(p => !p.IsFinished()))
{
var signalForPosition = Signals[position.SignalIdentifier];
if (signalForPosition == null)
{
await LogInformation(
$"🔍 **Signal Recovery**\nSignal not found for position `{position.Identifier}`\nRecreating signal from position data...");
// 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(
$"🔄 **Signal Status Update**\nSignal: `{signalForPosition.Identifier}`\nStatus: `{signalForPosition.Status}` → `PositionOpen`");
SetSignalStatus(signalForPosition.Identifier, SignalStatus.PositionOpen);
}
await UpdatePosition(signalForPosition, position);
}
// Then, open positions for signals waiting for a position open
// But first, check if we already have a position for any of these signals
var signalsWaitingForPosition = Signals.Values.Where(s => s.Status == SignalStatus.WaitingForPosition);
foreach (var signal in signalsWaitingForPosition)
{
if (signal.Date < LastCandle.Date)
{
await LogWarning(
$"❌ **Signal Expired**\nSignal `{signal.Identifier}` is older than last candle `{LastCandle.Date}`\nStatus: `Expired`");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
continue;
}
// Check if we already have a position for this signal (in case it was added but not processed yet)
var existingPosition = Positions.Values.FirstOrDefault(p => p.SignalIdentifier == signal.Identifier);
if (existingPosition != null)
{
// Position already exists for this signal, update signal status
await LogInformation(
$"🔄 **Signal Status Update**\nSignal: `{signal.Identifier}`\nStatus: `{signal.Status}` → `PositionOpen`\nPosition already exists: `{existingPosition.Identifier}`");
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
continue;
}
// No existing position found, proceed to open a new one
var newlyCreatedPosition = await OpenPosition(signal);
if (newlyCreatedPosition != null)
{
Positions[newlyCreatedPosition.Identifier] = newlyCreatedPosition;
}
else
{
await LogWarning(
$"⚠️ **Position Creation Failed**\nSignal: `{signal.Identifier}`\nPosition creation returned null");
}
}
}
private void UpdateWalletBalances()
{
var date = DateTime.UtcNow;
if (WalletBalances.Count == 0)
{
WalletBalances[date] = Config.BotTradingBalance;
return;
}
if (!WalletBalances.ContainsKey(date))
{
var previousBalance = WalletBalances.First().Value;
WalletBalances[date] = previousBalance + GetProfitAndLoss();
}
}
private async Task UpdatePosition(LightSignal signal, Position positionForSignal)
{
try
{
Position position = null;
List<Position> positionsExchange = null;
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory, async tradingService =>
{
position = Config.IsForBacktest
? positionForSignal
: await tradingService.GetPositionByIdentifierAsync(positionForSignal.Identifier);
if (Config.IsForBacktest)
{
positionsExchange = new List<Position> { position };
}
else
{
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
positionsExchange = (await exchangeService.GetBrokerPositions(Account)).ToList();
});
}
});
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);
// Notify platform summary about the executed trade
try
{
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory,
async grainFactory =>
{
var platformGrain =
grainFactory.GetGrain<IPlatformSummaryGrain>("platform-summary");
var tradeExecutedEvent = new TradeExecutedEvent
{
TradeId = position.Identifier,
Ticker = position.Ticker,
Volume = position.Open.Price * position.Open.Quantity * position.Open.Leverage
};
await platformGrain.OnTradeExecutedAsync(tradeExecutedEvent);
});
}
catch (Exception ex)
{
Logger.LogWarning(ex,
"Failed to notify platform summary about trade execution for position {PositionId}",
position.Identifier);
}
}
position = brokerPosition;
}
else
{
if (!position.Status.Equals(PositionStatus.New))
{
position.Status = PositionStatus.Filled;
}
}
}
if (position.Status == PositionStatus.New)
{
List<Trade> orders = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService => { orders = await exchangeService.GetOpenOrders(Account, Config.Ticker); });
if (orders.Any())
{
if (orders.Count() >= 3)
{
var currentTime = Config.IsForBacktest ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow;
var timeSinceRequest = currentTime - positionForSignal.Open.Date;
var waitTimeMinutes = 10;
if (timeSinceRequest.TotalMinutes >= waitTimeMinutes)
{
await LogWarning(
$"⚠️ **Order Cleanup**\nToo many open orders: `{orders.Count()}`\nPosition: `{positionForSignal.Identifier}`\nTime elapsed: `{waitTimeMinutes}min`\nCanceling all orders...");
try
{
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
await exchangeService.CancelOrder(Account, Config.Ticker);
});
await LogInformation(
$"✅ **Orders Canceled**\nSuccessfully 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(
$"⏳ **Waiting for Orders**\nPosition has `{orders.Count()}` open orders\nElapsed: `{timeSinceRequest.TotalMinutes:F1}min`\nWaiting `{remainingMinutes:F1}min` more before canceling");
}
}
else
{
await LogInformation(
$"⏸️ **Position Pending**\nPosition still waiting to open\n`{orders.Count()}` open orders remaining");
}
}
else
{
await LogWarning(
$"❌ **Position Closed**\nNo position on exchange and no orders\nSignal: `{signal.Identifier}`\nPosition have been closed.");
await HandleClosedPosition(positionForSignal);
}
}
else if (position.Status == PositionStatus.Finished || position.Status == PositionStatus.Flipped)
{
await HandleClosedPosition(positionForSignal);
}
else if (position.Status == PositionStatus.Filled || position.Status == PositionStatus.PartiallyFilled)
{
Candle lastCandle = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
{
lastCandle = Config.IsForBacktest
? LastCandle
: await 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;
var isPositionInProfit = positionForSignal.OriginDirection == TradeDirection.Long
? lastCandle.Close > positionForSignal.Open.Price
: lastCandle.Close < positionForSignal.Open.Price;
var hasExceededTimeLimit = Config.MaxPositionTimeHours.HasValue &&
HasPositionExceededTimeLimit(positionForSignal, currentTime);
if (hasExceededTimeLimit)
{
var shouldCloseOnTimeLimit = !Config.CloseEarlyWhenProfitable || isPositionInProfit;
if (shouldCloseOnTimeLimit)
{
var profitStatus = isPositionInProfit ? "in profit" : "at a loss";
await LogInformation(
$"⏰ **Time Limit Close**\nClosing position due to time limit: `{Config.MaxPositionTimeHours}h` exceeded\n📈 Position Status: **{profitStatus}**\n💰 Entry: `${positionForSignal.Open.Price}` → Current: `${lastCandle.Close}`\n📊 Realized PNL: `${currentPnl:F2}` (`{pnlPercentage:F2}%`)");
await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true);
return;
}
}
if (positionForSignal.OriginDirection == TradeDirection.Long)
{
if (positionForSignal.StopLoss.Price >= lastCandle.Low)
{
await LogInformation(
$"🛑 **Stop Loss Hit**\nClosing LONG position\nPrice: `${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(
$"🎯 **Take Profit 1 Hit**\nClosing LONG position\nPrice: `${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(
$"🎯 **Take Profit 2 Hit**\nClosing LONG position\nPrice: `${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(
$"🛑 **Stop Loss Hit**\nClosing SHORT position\nPrice: `${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(
$"🎯 **Take Profit 1 Hit**\nClosing SHORT position\nPrice: `${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(
$"🎯 **Take Profit 2 Hit**\nClosing SHORT position\nPrice: `${positionForSignal.TakeProfit2.Price}`");
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2,
positionForSignal.TakeProfit2.Price, true);
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled);
}
}
}
else if (position.Status == PositionStatus.Rejected || position.Status == PositionStatus.Canceled)
{
await LogWarning($"Open position trade is rejected for signal {signal.Identifier}");
if (signal.Status == SignalStatus.PositionOpen)
{
Logger.LogInformation($"Try to re-open position");
await OpenPosition(signal);
}
}
if (Config.UseSynthApi && !Config.IsForBacktest &&
positionForSignal.Status == PositionStatus.Filled)
{
decimal currentPrice = 0;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
currentPrice = await exchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
});
var riskResult = default(SynthRiskResult);
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory, async tradingService =>
{
riskResult = await tradingService.MonitorSynthPositionRiskAsync(
Config.Ticker,
positionForSignal.OriginDirection,
currentPrice,
positionForSignal.StopLoss.Price,
positionForSignal.Identifier,
Config);
});
if (riskResult.ShouldWarn && !string.IsNullOrEmpty(riskResult.WarningMessage))
{
await LogWarning(riskResult.WarningMessage);
}
if (riskResult.ShouldAutoClose && !string.IsNullOrEmpty(riskResult.EmergencyMessage))
{
await LogWarning(riskResult.EmergencyMessage);
await CloseTrade(Signals[positionForSignal.SignalIdentifier], positionForSignal,
positionForSignal.StopLoss,
currentPrice, true);
}
}
}
catch (Exception ex)
{
await LogWarning($"Cannot update position {positionForSignal.Identifier}: {ex.Message}, {ex.StackTrace}");
SentrySdk.CaptureException(ex);
return;
}
}
private async Task<Position> OpenPosition(LightSignal signal)
{
Logger.LogInformation($"Opening position for {signal.Identifier}");
// Check for any existing open position (not finished) for this ticker
var openedPosition =
Positions.Values.FirstOrDefault(p => !p.IsFinished() && p.SignalIdentifier != signal.Identifier);
decimal lastPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(_scopeFactory,
async exchangeService =>
{
return Config.IsForBacktest
? LastCandle?.Close ?? 0
: await exchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
});
if (openedPosition != null)
{
var previousSignal = Signals[openedPosition.SignalIdentifier];
if (openedPosition.OriginDirection == signal.Direction)
{
await LogInformation(
$"📍 **Same Direction Signal**\nSignal `{signal.Identifier}` tried to open position\nBut `{previousSignal.Identifier}` already open for same direction");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
else
{
if (Config.FlipPosition)
{
var isPositionInProfit = (openedPosition.ProfitAndLoss?.Realized ?? 0) > 0;
var shouldFlip = !Config.FlipOnlyWhenInProfit || isPositionInProfit;
if (shouldFlip)
{
var flipReason = Config.FlipOnlyWhenInProfit
? "current position is in profit"
: "FlipOnlyWhenInProfit is disabled";
await LogInformation(
$"🔄 **Position Flip Initiated**\nFlipping position due to opposite signal\nReason: {flipReason}");
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped);
var newPosition = await OpenPosition(signal);
await LogInformation(
$"✅ **Position Flipped**\nPosition: `{previousSignal.Identifier}` → `{signal.Identifier}`\nPrice: `${lastPrice}`");
return newPosition;
}
else
{
var currentPnl = openedPosition.ProfitAndLoss?.Realized ?? 0;
await LogInformation(
$"💸 **Flip Blocked - Not Profitable**\nPosition `{previousSignal.Identifier}` PnL: `${currentPnl:F2}`\nSignal `{signal.Identifier}` will wait for profitability");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
}
else
{
await LogInformation(
$"🚫 **Flip Disabled**\nPosition already open for: `{previousSignal.Identifier}`\nFlipping disabled, new signal expired");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
}
}
else
{
bool canOpen = await CanOpenPosition(signal);
if (!canOpen)
{
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
await LogInformation(
$"🚀 **Opening Position**\nTime: `{signal.Date:HH:mm:ss}`\nSignal: `{signal.Identifier}`");
try
{
var command = new OpenPositionRequest(
Config.AccountName,
Config.MoneyManagement,
signal.Direction,
Config.Ticker,
PositionInitiator.Bot,
signal.Date,
Account.User,
Config.BotTradingBalance,
Config.IsForBacktest,
lastPrice,
signalIdentifier: signal.Identifier,
initiatorIdentifier: Identifier);
var position = await ServiceScopeHelpers
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
_scopeFactory,
async (exchangeService, accountService, tradingService) =>
{
return await new OpenPositionCommandHandler(exchangeService, accountService, tradingService)
.Handle(command);
});
if (position != null)
{
if (position.Open.Status != TradeStatus.Cancelled)
{
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
if (!Config.IsForBacktest)
{
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
async messengerService => { await messengerService.SendPosition(position); });
}
Logger.LogInformation($"Position requested");
return position; // Return the created position without adding to list
}
else
{
await SetPositionStatus(signal.Identifier, PositionStatus.Rejected);
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
}
return null;
}
catch (Exception ex)
{
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
await LogWarning($"Cannot open trade : {ex.Message}, stackTrace : {ex.StackTrace}");
return null;
}
}
}
private async Task<bool> CanOpenPosition(LightSignal signal)
{
// Early return if we're in backtest mode and haven't executed yet
// TODO : check if its a startup cycle
if (!Config.IsForBacktest && ExecutionCount == 0)
{
await LogInformation("⏳ **Bot Not Ready**\nCannot open position\nBot hasn't executed first cycle yet");
return false;
}
// Check if we're in backtest mode
if (Config.IsForBacktest)
{
return !IsInCooldownPeriod() && await CheckLossStreak(signal);
}
// Check broker positions for live trading
var canOpenPosition = await CheckBrokerPositions();
if (!canOpenPosition)
{
return false;
}
// Synth-based pre-trade risk assessment
if (Config.UseSynthApi)
{
decimal currentPrice = 0;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
{
currentPrice = Config.IsForBacktest
? LastCandle?.Close ?? 0
: await exchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
});
bool synthRisk = false;
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory, async tradingService =>
{
synthRisk = await tradingService.AssessSynthPositionRiskAsync(Config.Ticker, signal.Direction,
currentPrice,
Config, Config.IsForBacktest);
});
if (!synthRisk)
{
return false;
}
}
// Check cooldown period and loss streak
return !IsInCooldownPeriod() && await CheckLossStreak(signal);
}
private async Task<bool> CheckLossStreak(LightSignal 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
.Values
.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(
$"🔥 **Loss Streak Limit**\nCannot open position\nMax loss streak: `{Config.MaxLossStreak}` reached\n📉 Last `{recentPositions.Count}` trades were losses\n🎯 Last position: `{lastPosition.OriginDirection}`\nWaiting for opposite direction signal");
return false;
}
return true;
}
private async Task<bool> CheckBrokerPositions()
{
try
{
List<Position> positions = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService => { positions = (await exchangeService.GetBrokerPositions(Account)).ToList(); });
if (!positions.Any(p => p.Ticker == Config.Ticker))
{
return true;
}
// Handle existing position on broker
var previousPosition = Positions.Values.LastOrDefault();
List<Trade> orders = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
orders = (await exchangeService.GetOpenOrders(Account, Config.Ticker)).ToList();
});
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($"❌ **Broker Position Check Failed**\nError checking broker positions\n{ex.Message}");
return false;
}
}
/// <summary>
/// Gets the actual closing date of a position by checking which trade (Stop Loss or Take Profit) was executed.
/// </summary>
/// <param name="position">The finished position</param>
/// <returns>The date when the position was closed, or null if cannot be determined</returns>
private DateTime? GetPositionClosingDate(Position position)
{
if (!position.IsFinished())
{
return null;
}
// Check which trade actually closed the position
if (position.StopLoss?.Status == TradeStatus.Filled && position.StopLoss.Date != default)
{
return position.StopLoss.Date;
}
if (position.TakeProfit1?.Status == TradeStatus.Filled && position.TakeProfit1.Date != default)
{
return position.TakeProfit1.Date;
}
if (position.TakeProfit2?.Status == TradeStatus.Filled && position.TakeProfit2.Date != default)
{
return position.TakeProfit2.Date;
}
// Fallback: if we can't determine the exact closing trade, use the latest date available
var availableDates = new List<DateTime>();
if (position.StopLoss?.Date != default)
availableDates.Add(position.StopLoss.Date);
if (position.TakeProfit1?.Date != default)
availableDates.Add(position.TakeProfit1.Date);
if (position.TakeProfit2?.Date != default)
availableDates.Add(position.TakeProfit2.Date);
return availableDates.Any() ? availableDates.Max() : position.Open.Date;
}
public async Task CloseTrade(LightSignal 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(
$"🔧 **Closing Trade**\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n📋 Type: `{tradeToClose.TradeType}`\n📊 Quantity: `{tradeToClose.Quantity}`\n🎯 Closing Position: `{(tradeClosingPosition ? "Yes" : "No")}`");
decimal quantity = 0;
if (!Config.IsForBacktest)
{
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
quantity = await exchangeService.GetQuantityInPosition(Account, Config.Ticker);
});
}
// Get status of position before closing it. The position might be already close by the exchange
if (!Config.IsForBacktest && quantity == 0)
{
Logger.LogInformation($"Trade already close on exchange");
await HandleClosedPosition(position);
}
else
{
var command = new ClosePositionCommand(position, lastPrice, isForBacktest: Config.IsForBacktest);
try
{
Position closedPosition = null;
await ServiceScopeHelpers.WithScopedServices<IExchangeService, IAccountService, ITradingService>(
_scopeFactory, async (exchangeService, accountService, tradingService) =>
{
closedPosition =
await new ClosePositionCommandHandler(exchangeService, accountService, tradingService,
_scopeFactory)
.Handle(command);
});
if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == 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 || position.Status == PositionStatus.Rejected)
{
// Trade close on exchange => Should close trade manually
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
}
}
}
}
private async Task HandleClosedPosition(Position position)
{
if (Positions.ContainsKey(position.Identifier))
{
Candle currentCandle = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
{
currentCandle = Config.IsForBacktest
? LastCandle
: await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow);
});
if (currentCandle != null)
{
List<Candle> recentCandles = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
{
recentCandles = Config.IsForBacktest
? (LastCandle != null ? new List<Candle>() { LastCandle } : new List<Candle>())
: (await exchangeService.GetCandlesInflux(TradingExchanges.Evm, Config.Ticker,
DateTime.UtcNow.AddHours(-4), Config.Timeframe)).ToList();
});
// Check if we have any candles before proceeding
if (recentCandles == null || !recentCandles.Any())
{
await LogWarning(
$"No recent candles available for position {position.Identifier}. Using current candle data instead.");
// Fallback to current candle if available
if (currentCandle != null)
{
recentCandles = new List<Candle> { currentCandle };
}
else
{
await LogWarning(
$"No candle data available for position {position.Identifier}. Cannot determine stop loss/take profit hit.");
Logger.LogError(
"No candle data available for position {PositionId}. Cannot determine stop loss/take profit hit.",
position.Identifier);
return;
}
}
var minPriceRecent = recentCandles.Min(c => c.Low);
var maxPriceRecent = recentCandles.Max(c => c.High);
bool wasStopLossHit = false;
bool wasTakeProfitHit = false;
if (position.OriginDirection == TradeDirection.Long)
{
wasStopLossHit = minPriceRecent <= position.StopLoss.Price;
wasTakeProfitHit = maxPriceRecent >= position.TakeProfit1.Price;
}
else
{
wasStopLossHit = maxPriceRecent >= position.StopLoss.Price;
wasTakeProfitHit = minPriceRecent <= position.TakeProfit1.Price;
}
decimal closingPrice;
if (wasStopLossHit)
{
closingPrice = position.StopLoss.Price;
position.StopLoss.SetDate(currentCandle.Date);
Logger.LogInformation(
$"🛑 **Stop Loss Execution Confirmed**\n" +
$"Position: `{position.Identifier}`\n" +
$"SL Price: `${position.StopLoss.Price:F2}` was hit\n" +
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`");
}
else if (wasTakeProfitHit)
{
closingPrice = position.TakeProfit1.Price;
position.TakeProfit1.SetDate(currentCandle.Date);
Logger.LogInformation(
$"🎯 **Take Profit Execution Confirmed**\n" +
$"Position: `{position.Identifier}`\n" +
$"TP Price: `${position.TakeProfit1.Price:F2}` was hit\n" +
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`");
}
else
{
closingPrice = Config.IsForBacktest
? currentCandle.Close
: 0;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
closingPrice = await exchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
});
bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long
? closingPrice > position.Open.Price
: closingPrice < position.Open.Price;
if (isManualCloseProfitable)
{
position.TakeProfit1.SetDate(currentCandle.Date);
}
else
{
position.StopLoss.SetDate(currentCandle.Date);
}
Logger.LogInformation(
$"✋ **Manual/Exchange Close Detected**\n" +
$"Position: `{position.Identifier}`\n" +
$"SL: `${position.StopLoss.Price:F2}` | TP: `${position.TakeProfit1.Price:F2}`\n" +
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`\n" +
$"Closing at market price: `${closingPrice:F2}`");
}
var entryPrice = position.Open.Price;
var positionSize = position.Open.Quantity * position.Open.Leverage;
decimal pnl;
if (position.OriginDirection == TradeDirection.Long)
{
pnl = (closingPrice - entryPrice) * positionSize;
}
else
{
pnl = (entryPrice - closingPrice) * positionSize;
}
if (position.ProfitAndLoss == null)
{
position.ProfitAndLoss = new ProfitAndLoss { Realized = pnl };
}
else if (position.ProfitAndLoss.Realized == 0)
{
position.ProfitAndLoss.Realized = pnl;
}
}
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished);
// Update the last position closing time for cooldown period tracking
LastPositionClosingTime = Config.IsForBacktest ? currentCandle.Date : DateTime.UtcNow;
Logger.LogInformation(
$"✅ **Position Closed Successfully**\nPosition: `{position.SignalIdentifier}`\nPnL: `${position.ProfitAndLoss?.Realized:F2}`");
if (position.ProfitAndLoss != null)
{
Config.BotTradingBalance += position.ProfitAndLoss.Realized;
Logger.LogInformation(
$"💰 **Balance Updated**\nNew bot trading balance: `${Config.BotTradingBalance:F2}`");
}
}
else
{
await LogWarning("Weird things happen - Trying to update position status, but no position found");
}
if (!Config.IsForBacktest)
{
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
async messengerService => { await messengerService.SendClosingPosition(position); });
}
await CancelAllOrders();
}
private async Task CancelAllOrders()
{
if (!Config.IsForBacktest && !Config.IsForWatchingOnly)
{
try
{
List<Trade> openOrders = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
openOrders = (await exchangeService.GetOpenOrders(Account, Config.Ticker)).ToList();
});
if (openOrders.Any())
{
List<Position> openPositions = null;
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
openPositions = (await exchangeService.GetBrokerPositions(Account))
.Where(p => p.Ticker == Config.Ticker).ToList();
});
var cancelClose = openPositions.Any();
if (cancelClose)
{
Logger.LogInformation($"Position still open, cancel close orders");
}
else
{
Logger.LogInformation($"Canceling all orders for {Config.Ticker}");
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
async exchangeService =>
{
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)
{
try
{
var position = Positions.Values.First(p => p.SignalIdentifier == signalIdentifier);
if (!position.Status.Equals(positionStatus))
{
Positions.Values.First(p => p.SignalIdentifier == signalIdentifier).Status = positionStatus;
await LogInformation(
$"📊 **Position Status Change**\nPosition: `{signalIdentifier}`\nStatus: `{position.Status}` → `{positionStatus}`");
}
SetSignalStatus(signalIdentifier,
positionStatus == PositionStatus.Filled ? SignalStatus.PositionOpen : SignalStatus.Expired);
}
catch (Exception ex)
{
await LogWarning($"Failed to update position status for signal {signalIdentifier}: {ex.Message}");
}
}
private void UpdatePositionPnl(Guid identifier, decimal realized)
{
Positions[identifier].ProfitAndLoss = new ProfitAndLoss()
{
Realized = realized
};
}
private void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus)
{
if (Signals.ContainsKey(signalIdentifier) && Signals[signalIdentifier].Status != signalStatus)
{
Signals[signalIdentifier].Status = signalStatus;
Logger.LogInformation($"Signal {signalIdentifier} is now {signalStatus}");
}
}
public int GetWinRate()
{
var succeededPositions = Positions.Values.Where(p => p.IsFinished()).Count(p => p.ProfitAndLoss?.Realized > 0);
var total = Positions.Values.Where(p => p.IsFinished()).Count();
if (total == 0)
return 0;
return (succeededPositions * 100) / total;
}
public decimal GetProfitAndLoss()
{
var pnl = Positions.Values.Where(p => p.ProfitAndLoss != null && p.IsFinished())
.Sum(p => p.ProfitAndLoss.Realized);
return pnl - GetTotalFees();
}
/// <summary>
/// Calculates the total fees paid by the trading bot for each position.
/// Includes UI fees (0.1% of position size) and network fees ($0.50 for opening).
/// Closing fees are handled by oracle, so no network fee for closing.
/// </summary>
/// <returns>Returns the total fees paid as a decimal value.</returns>
public decimal GetTotalFees()
{
decimal totalFees = 0;
foreach (var position in Positions.Values.Where(p => p.Open.Price > 0 && p.Open.Quantity > 0))
{
totalFees += CalculatePositionFees(position);
}
return totalFees;
}
/// <summary>
/// Calculates the total fees for a specific position based on GMX V2 fee structure
/// </summary>
/// <param name="position">The position to calculate fees for</param>
/// <returns>The total fees for the position</returns>
private decimal CalculatePositionFees(Position position)
{
decimal fees = 0;
// Calculate position size in USD (leverage is already included in quantity calculation)
var positionSizeUsd = (position.Open.Price * position.Open.Quantity) * position.Open.Leverage;
// UI Fee: 0.1% of position size paid on opening
var uiFeeRate = 0.001m; // 0.1%
var uiFeeOpen = positionSizeUsd * uiFeeRate; // Fee paid on opening
fees += uiFeeOpen;
// UI Fee: 0.1% of position size paid on closing - only if position was actually closed
// Check which closing trade was executed (StopLoss, TakeProfit1, or TakeProfit2)
// Calculate closing fee based on the actual executed trade's price and quantity
if (position.StopLoss?.Status == TradeStatus.Filled)
{
var stopLossPositionSizeUsd =
(position.StopLoss.Price * position.StopLoss.Quantity) * position.StopLoss.Leverage;
var uiFeeClose = stopLossPositionSizeUsd * uiFeeRate; // Fee paid on closing via StopLoss
fees += uiFeeClose;
}
else if (position.TakeProfit1?.Status == TradeStatus.Filled)
{
var takeProfit1PositionSizeUsd = (position.TakeProfit1.Price * position.TakeProfit1.Quantity) *
position.TakeProfit1.Leverage;
var uiFeeClose = takeProfit1PositionSizeUsd * uiFeeRate; // Fee paid on closing via TakeProfit1
fees += uiFeeClose;
}
else if (position.TakeProfit2?.Status == TradeStatus.Filled)
{
var takeProfit2PositionSizeUsd = (position.TakeProfit2.Price * position.TakeProfit2.Quantity) *
position.TakeProfit2.Leverage;
var uiFeeClose = takeProfit2PositionSizeUsd * uiFeeRate; // Fee paid on closing via TakeProfit2
fees += uiFeeClose;
}
// Network Fee: $0.50 for opening position only
// Closing is handled by oracle, so no network fee for closing
var networkFeeForOpening = 0.50m;
fees += networkFeeForOpening;
return fees;
}
public async Task ToggleIsForWatchOnly()
{
Config.IsForWatchingOnly = !Config.IsForWatchingOnly;
await LogInformation(
$"🔄 **Watch Mode Toggle**\nBot: `{Config.Name}`\nWatch Only: `{(Config.IsForWatchingOnly ? "ON" : "OFF")}`");
}
private async Task LogInformation(string message)
{
Logger.LogInformation(message);
if (Config.IsForBacktest)
return;
try
{
await SendTradeMessage(message);
}
catch (Exception e)
{
Console.WriteLine(e);
}
}
private async Task LogWarning(string message)
{
message = $"[{Config.Name}] {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)
{
var user = Account.User;
var messageWithBotName = $"🤖 **{user.AgentName} - {Config.Name}**\n{message}";
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
async messengerService =>
{
await messengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user);
});
}
}
/// <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)
{
if (LastCandle == null)
{
throw new Exception("No candles available to open position");
}
// Create a fake signal for manual position opening
var signal = new LightSignal(Config.Ticker, direction, Confidence.Low, LastCandle, LastCandle.Date,
TradingExchanges.GmxV2,
IndicatorType.Stc, SignalType.Signal, "Manual Signal");
signal.Status = SignalStatus.WaitingForPosition; // Ensure status is correct
// Add the signal to our collection
await AddSignal(signal);
// Open the position using the generated signal (SL/TP handled by MoneyManagement)
var position = await OpenPosition(signal);
if (position == null)
{
// Clean up the signal if position creation failed
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
throw new Exception("Failed to open position");
}
// Add the position to the list after successful creation
Positions[position.Identifier] = position;
Logger.LogInformation(
$"👤 **Manual Position Opened**\nPosition: `{position.Identifier}`\nSignal: `{signal.Identifier}`\nAdded to positions list");
return position;
}
public async Task AddSignal(LightSignal signal)
{
try
{
// Set signal status based on configuration
if (Config.IsForWatchingOnly || (ExecutionCount < 1 && !Config.IsForBacktest))
{
signal.Status = SignalStatus.Expired;
}
var indicatorNames = Config.Scenario.Indicators.Select(i => i.Type.ToString()).ToList();
var signalText = $"🎯 **New Trading Signal**\n\n" +
$"📊 **Signal Details:**\n" +
$"📈 Action: `{signal.Direction}` {Config.Ticker}\n" +
$"⏰ Timeframe: `{Config.Timeframe}`\n" +
$"🎯 Confidence: `{signal.Confidence}`\n" +
$"🔍 Indicators: `{string.Join(", ", indicatorNames)}`\n" +
$"🆔 Signal ID: `{signal.Identifier}`";
// Apply Synth-based signal filtering if enabled
if ((Config.UseSynthApi || !Config.IsForBacktest) && ExecutionCount > 0)
{
await ServiceScopeHelpers.WithScopedServices<ITradingService, IExchangeService>(_scopeFactory,
async (tradingService, exchangeService) =>
{
var currentPrice = await exchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
var signalValidationResult = await tradingService.ValidateSynthSignalAsync(
signal,
currentPrice,
Config,
Config.IsForBacktest);
if (signalValidationResult.Confidence == Confidence.None ||
signalValidationResult.Confidence == Confidence.Low ||
signalValidationResult.IsBlocked)
{
signal.Status = SignalStatus.Expired;
Logger.LogInformation($"Signal {signal.Identifier} blocked by Synth risk assessment");
}
else
{
signal.Confidence = signalValidationResult.Confidence;
Logger.LogInformation(
$"Signal {signal.Identifier} passed Synth risk assessment with confidence {signalValidationResult.Confidence}");
}
});
}
Signals.Add(signal.Identifier, signal);
Logger.LogInformation(signalText);
if (Config.IsForWatchingOnly && !Config.IsForBacktest && ExecutionCount > 0)
{
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory, async messengerService =>
{
await messengerService.SendSignal(signalText, Account.Exchange, Config.Ticker, signal.Direction,
Config.Timeframe);
});
}
Logger.LogInformation(
$"Processed signal for {Config.Ticker}: {signal.Direction} with status {signal.Status}");
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to add signal for {Ticker}", Config.Ticker);
throw;
}
}
/// <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 || Config.MaxPositionTimeHours.Value <= 0)
{
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 (newConfig.Scenario == null)
{
throw new ArgumentException("Scenario object must be provided in configuration");
}
// Track changes for logging
var changes = new List<string>();
// Check for changes and build change list
if (Config.BotTradingBalance != newConfig.BotTradingBalance)
{
changes.Add($"💰 Balance: ${Config.BotTradingBalance:F2} → ${newConfig.BotTradingBalance:F2}");
}
if (Config.MaxPositionTimeHours != newConfig.MaxPositionTimeHours)
{
var oldTime = Config.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled";
var newTime = newConfig.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled";
changes.Add($"⏱️ Max Time: {oldTime} → {newTime}");
}
if (Config.FlipOnlyWhenInProfit != newConfig.FlipOnlyWhenInProfit)
{
var oldFlip = Config.FlipOnlyWhenInProfit ? "✅" : "❌";
var newFlip = newConfig.FlipOnlyWhenInProfit ? "✅" : "❌";
changes.Add($"📈 Flip Only in Profit: {oldFlip} → {newFlip}");
}
if (Config.CooldownPeriod != newConfig.CooldownPeriod)
{
changes.Add($"⏳ Cooldown: {Config.CooldownPeriod} → {newConfig.CooldownPeriod} candles");
}
if (Config.MaxLossStreak != newConfig.MaxLossStreak)
{
changes.Add($"📉 Max Loss Streak: {Config.MaxLossStreak} → {newConfig.MaxLossStreak}");
}
if (Config.FlipPosition != newConfig.FlipPosition)
{
var oldFlipPos = Config.FlipPosition ? "✅" : "❌";
var newFlipPos = newConfig.FlipPosition ? "✅" : "❌";
changes.Add($"🔄 Flip Position: {oldFlipPos} → {newFlipPos}");
}
if (Config.CloseEarlyWhenProfitable != newConfig.CloseEarlyWhenProfitable)
{
var oldCloseEarly = Config.CloseEarlyWhenProfitable ? "✅" : "❌";
var newCloseEarly = newConfig.CloseEarlyWhenProfitable ? "✅" : "❌";
changes.Add($"⏰ Close Early When Profitable: {oldCloseEarly} → {newCloseEarly}");
}
if (Config.UseSynthApi != newConfig.UseSynthApi)
{
var oldSynth = Config.UseSynthApi ? "✅" : "❌";
var newSynth = newConfig.UseSynthApi ? "✅" : "❌";
changes.Add($"🔗 Use Synth API: {oldSynth} → {newSynth}");
}
if (Config.UseForPositionSizing != newConfig.UseForPositionSizing)
{
var oldPositionSizing = Config.UseForPositionSizing ? "✅" : "❌";
var newPositionSizing = newConfig.UseForPositionSizing ? "✅" : "❌";
changes.Add($"📏 Use Synth for Position Sizing: {oldPositionSizing} → {newPositionSizing}");
}
if (Config.UseForSignalFiltering != newConfig.UseForSignalFiltering)
{
var oldSignalFiltering = Config.UseForSignalFiltering ? "✅" : "❌";
var newSignalFiltering = newConfig.UseForSignalFiltering ? "✅" : "❌";
changes.Add($"🔍 Use Synth for Signal Filtering: {oldSignalFiltering} → {newSignalFiltering}");
}
if (Config.UseForDynamicStopLoss != newConfig.UseForDynamicStopLoss)
{
var oldDynamicStopLoss = Config.UseForDynamicStopLoss ? "✅" : "❌";
var newDynamicStopLoss = newConfig.UseForDynamicStopLoss ? "✅" : "❌";
changes.Add($"🎯 Use Synth for Dynamic Stop Loss: {oldDynamicStopLoss} → {newDynamicStopLoss}");
}
if (Config.IsForWatchingOnly != newConfig.IsForWatchingOnly)
{
var oldWatch = Config.IsForWatchingOnly ? "✅" : "❌";
var newWatch = newConfig.IsForWatchingOnly ? "✅" : "❌";
changes.Add($"👀 Watch Only: {oldWatch} → {newWatch}");
}
if (Config.MoneyManagement?.GetType().Name != newConfig.MoneyManagement?.GetType().Name)
{
var oldMM = Config.MoneyManagement?.GetType().Name ?? "None";
var newMM = newConfig.MoneyManagement?.GetType().Name ?? "None";
changes.Add($"💰 Money Management: {oldMM} → {newMM}");
}
if (Config.RiskManagement != newConfig.RiskManagement)
{
// Compare risk management by serializing (complex object comparison)
var oldRiskSerialized = JsonConvert.SerializeObject(Config.RiskManagement, Formatting.None);
var newRiskSerialized = JsonConvert.SerializeObject(newConfig.RiskManagement, Formatting.None);
if (oldRiskSerialized != newRiskSerialized)
{
changes.Add($"⚠️ Risk Management: Configuration Updated");
}
}
if (Config.ScenarioName != newConfig.ScenarioName)
{
changes.Add($"📋 Scenario Name: {Config.ScenarioName ?? "None"} → {newConfig.ScenarioName ?? "None"}");
}
if (Config.Name != newConfig.Name)
{
changes.Add($"🏷️ Name: {Config.Name} → {newConfig.Name}");
}
if (Config.AccountName != newConfig.AccountName)
{
changes.Add($"👤 Account: {Config.AccountName} → {newConfig.AccountName}");
}
if (Config.Ticker != newConfig.Ticker)
{
changes.Add($"📊 Ticker: {Config.Ticker} → {newConfig.Ticker}");
}
if (Config.Timeframe != newConfig.Timeframe)
{
changes.Add($"📈 Timeframe: {Config.Timeframe} → {newConfig.Timeframe}");
}
// Check if the actual Scenario object changed (not just the name)
var scenarioChanged = false;
if (Config.Scenario != newConfig.Scenario)
{
var oldScenarioSerialized = JsonConvert.SerializeObject(Config.Scenario, Formatting.None);
var newScenarioSerialized = JsonConvert.SerializeObject(newConfig.Scenario, Formatting.None);
if (oldScenarioSerialized != newScenarioSerialized)
{
scenarioChanged = true;
changes.Add(
$"🎯 Scenario: {Config.Scenario?.Name ?? "None"} → {newConfig.Scenario?.Name ?? "None"}");
}
}
// Protect critical properties that shouldn't change for running bots
var protectedIsForBacktest = Config.IsForBacktest;
// Update the configuration
Config = newConfig;
// Restore protected properties
Config.IsForBacktest = protectedIsForBacktest;
// Update bot name and identifier if allowed
if (!string.IsNullOrEmpty(newConfig.Name))
{
Config.Name = newConfig.Name;
}
// If account changed, reload it
if (Config.AccountName != Account?.Name)
{
await LoadAccount();
}
// If scenario changed, reload it and track indicator changes
if (scenarioChanged)
{
if (newConfig.Scenario != null)
{
// Compare indicators after scenario change
var newIndicators = newConfig.Scenario.Indicators?.ToList() ?? new List<LightIndicator>();
var indicatorChanges = ScenarioHelpers.CompareIndicators(Config.Scenario.Indicators, newIndicators);
if (indicatorChanges.Any())
{
changes.AddRange(indicatorChanges);
}
}
else
{
throw new ArgumentException("New scenario object must be provided when updating configuration.");
}
}
// Only log if there are actual changes
if (changes.Any())
{
var changeMessage = "⚙️ **Configuration Updated**\n" + string.Join("\n", changes);
await LogInformation(changeMessage);
}
else
{
await LogInformation(
"⚙️ **Configuration Update**\n✅ No changes detected - configuration already up to date");
}
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,
Scenario = Config.Scenario,
Timeframe = Config.Timeframe,
IsForWatchingOnly = Config.IsForWatchingOnly,
BotTradingBalance = Config.BotTradingBalance,
IsForBacktest = Config.IsForBacktest,
CooldownPeriod = Config.CooldownPeriod,
MaxLossStreak = Config.MaxLossStreak,
MaxPositionTimeHours = Config.MaxPositionTimeHours,
FlipOnlyWhenInProfit = Config.FlipOnlyWhenInProfit,
FlipPosition = Config.FlipPosition,
Name = Config.Name,
CloseEarlyWhenProfitable = Config.CloseEarlyWhenProfitable,
UseSynthApi = Config.UseSynthApi,
UseForPositionSizing = Config.UseForPositionSizing,
UseForSignalFiltering = Config.UseForSignalFiltering,
UseForDynamicStopLoss = Config.UseForDynamicStopLoss,
RiskManagement = Config.RiskManagement,
};
}
/// <summary>
/// Checks if the bot is currently in a cooldown period for any direction.
/// </summary>
/// <returns>True if in cooldown period for any direction, false otherwise</returns>
private bool IsInCooldownPeriod()
{
if (LastPositionClosingTime == null)
{
return false; // No previous position closing time, no cooldown
}
// Calculate cooldown end time based on last position closing time
var baseIntervalSeconds = CandleExtensions.GetBaseIntervalInSeconds(Config.Timeframe);
var cooldownEndTime = LastPositionClosingTime.Value.AddSeconds(baseIntervalSeconds * Config.CooldownPeriod);
var isInCooldown = LastCandle.Date < cooldownEndTime;
if (isInCooldown)
{
var remainingTime = cooldownEndTime - LastCandle.Date;
Logger.LogWarning(
$"⏳ **Cooldown Period Active**\n" +
$"Cannot open new positions\n" +
$"Last position closed: `{LastPositionClosingTime:HH:mm:ss}`\n" +
$"Cooldown period: `{Config.CooldownPeriod}` candles\n" +
$"Cooldown ends: `{cooldownEndTime:HH:mm:ss}`\n" +
$"Remaining time: `{remainingTime.TotalMinutes:F1} minutes`");
}
return isInCooldown;
}
}