Files
managing-apps/src/Managing.Application/Bots/TradingBot.cs

1834 lines
75 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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<IIndicator> Indicators { 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<IndicatorType, IndicatorsResultBase> IndicatorsValues { get; set; }
public DateTime StartupTime { get; set; }
public DateTime PreloadSince { get; set; }
public int PreloadedCandlesCount { get; set; }
public decimal Fee { 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;
Indicators = new HashSet<IIndicator>();
Signals = new HashSet<Signal>();
OptimizedCandles = new FixedSizeQueue<Candle>(600);
Candles = new HashSet<Candle>();
Positions = new List<Position>();
WalletBalances = new Dictionary<DateTime, decimal>();
IndicatorsValues = new Dictionary<IndicatorType, IndicatorsResultBase>();
// Load indicators if scenario is provided in config
if (Config.Scenario != null)
{
LoadIndicators(Config.Scenario);
}
else
{
throw new ArgumentException(
"Scenario object must be provided in TradingBotConfig. ScenarioName alone is not sufficient.");
}
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)
{
// Scenario and indicators should already be loaded in constructor
// This is just a safety check
if (Config.Scenario == null || !Indicators.Any())
{
throw new InvalidOperationException(
"Scenario or indicators not loaded properly in constructor. This indicates a configuration error.");
}
PreloadCandles().GetAwaiter().GetResult();
CancelAllOrders().GetAwaiter().GetResult();
// Send startup message only for fresh starts (not reboots)
var isReboot = Signals.Any() || Positions.Any();
if (!isReboot)
{
try
{
var indicatorNames = 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.";
LogInformation(startupMessage).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Logger.LogError(ex, ex.Message);
}
}
else
{
try
{
LogInformation($"🔄 **Bot Restarted**\n" +
$"📊 Resuming operations with {Signals.Count} signals and {Positions.Count} positions\n" +
$"✅ Ready to continue trading").GetAwaiter().GetResult();
}
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(Scenario scenario)
{
if (scenario == null)
{
var errorMessage = "Null scenario provided";
Logger.LogWarning(errorMessage);
// If called during construction, throw exception instead of Stop()
if (Status == BotStatus.Down)
{
throw new ArgumentException(errorMessage);
}
else
{
Stop();
}
}
else
{
// Store the scenario in config and load indicators
Config.Scenario = scenario;
LoadIndicators(ScenarioHelpers.GetIndicatorsFromScenario(scenario));
Logger.LogInformation($"Loaded scenario '{scenario.Name}' with {Indicators.Count} indicators");
}
}
public void LoadIndicators(Scenario scenario)
{
LoadIndicators(ScenarioHelpers.GetIndicatorsFromScenario(scenario));
}
public void LoadIndicators(IEnumerable<IIndicator> indicators)
{
// Clear existing indicators to prevent duplicates
Indicators.Clear();
foreach (var indicator in indicators)
{
Indicators.Add(indicator);
}
Logger.LogInformation($"Loaded {Indicators.Count} indicators for bot '{Name}'");
}
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 && Positions.All(p => p.IsFinished()))
{
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} - 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();
UpdateIndicatorsValues();
}
UpdateWalletBalances();
if (!Config.IsForBacktest) // 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 UpdateIndicatorsValues()
{
foreach (var strategy in Indicators)
{
IndicatorsValues[strategy.Type] = ((Indicator)strategy).GetIndicatorValues();
}
}
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)
{
// If position open and not flipped, do not update signals
if (!Config.FlipPosition && Positions.Any(p => !p.IsFinished())) return;
var signal = TradingBox.GetSignal(candles.ToHashSet(), Indicators, Signals, Config.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;
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}";
// Apply Synth-based signal filtering if enabled
if (Config.UseSynthApi)
{
var currentPrice = Config.IsForBacktest
? OptimizedCandles.Last().Close
: ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
var signalValidationResult = TradingService.ValidateSynthSignalAsync(signal, currentPrice, Config,
Config.IsForBacktest).GetAwaiter().GetResult();
if (signalValidationResult.Confidence == Confidence.None ||
signalValidationResult.Confidence == Confidence.Low ||
signalValidationResult.IsBlocked)
{
signal.Status = SignalStatus.Expired;
await LogInformation(
$"🚫 **Synth Signal Filter** - Signal {signal.Identifier} blocked by Synth risk assessment. Context : {signalValidationResult.ValidationContext}");
return;
}
else
{
signal.SetConfidence(signalValidationResult.Confidence);
signalText +=
$" and Synth risk assessment passed. Context : {signalValidationResult.ValidationContext}";
}
}
Signals.Add(signal);
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,
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;
recreatedSignal.User = Account.User;
// Add the recreated signal to our collection
Signals.Add(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()
{
foreach (var position in Positions.Where(p => !p.IsFinished()))
{
var signalForPosition = Signals.FirstOrDefault(s => s.Identifier == 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);
}
// 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
{
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(
$"⚠️ **Order Cleanup**\nToo many open orders: `{orders.Count()}`\nPosition: `{positionForSignal.Identifier}`\nTime elapsed: `{waitTimeMinutes}min`\nCanceling all orders...");
try
{
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 Not Found**\nNo position on exchange and no orders\nSignal: `{signal.Identifier}`\nPosition might be already closed");
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(
$"⏰ **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;
}
else
{
await LogInformation(
$"⏳ **Time Limit - Waiting**\nTime limit exceeded but position at loss\n📉 Entry: `${positionForSignal.Open.Price}` → Current: `${lastCandle.Close}`\n💰 Realized PNL: `${currentPnl:F2}` (`{pnlPercentage:F2}%`\n🎯 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(
$"🛑 **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 | 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);
}
}
// Synth-based position monitoring for liquidation risk
if (Config.UseSynthApi && !Config.IsForBacktest &&
positionForSignal.Status == PositionStatus.Filled)
{
var currentPrice = ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
var riskResult = await TradingService.MonitorSynthPositionRiskAsync(
Config.Ticker,
positionForSignal.OriginDirection,
currentPrice,
positionForSignal.StopLoss.Price,
positionForSignal.Identifier,
Config);
if (riskResult != null && riskResult.ShouldWarn && !string.IsNullOrEmpty(riskResult.WarningMessage))
{
await LogWarning(riskResult.WarningMessage);
}
if (riskResult.ShouldAutoClose && !string.IsNullOrEmpty(riskResult.EmergencyMessage))
{
await LogWarning(riskResult.EmergencyMessage);
var signalForAutoClose =
Signals.FirstOrDefault(s => s.Identifier == positionForSignal.SignalIdentifier);
if (signalForAutoClose != null)
{
await CloseTrade(signalForAutoClose, positionForSignal, positionForSignal.StopLoss,
currentPrice, true);
}
}
}
}
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)
{
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);
}
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(
$"🔄 **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);
await OpenPosition(signal);
await LogInformation(
$"✅ **Position Flipped**\nPosition: `{previousSignal.Identifier}` → `{signal.Identifier}`\nPrice: `${lastPrice}`");
}
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;
}
}
else
{
await LogInformation(
$"🚫 **Flip Disabled**\nPosition already open for: `{previousSignal.Identifier}`\nFlipping disabled, new signal expired");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
}
}
}
else
{
if (!(await CanOpenPosition(signal)))
{
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return;
}
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,
User,
Config.BotTradingBalance,
Config.IsForBacktest,
lastPrice,
signalIdentifier: signal.Identifier);
var position = (new OpenPositionCommandHandler(ExchangeService, AccountService, TradingService)
.Handle(command)).GetAwaiter().GetResult();
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("⏳ **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 await CheckCooldownPeriod(signal) && 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)
{
var currentPrice = Config.IsForBacktest
? OptimizedCandles.Last().Close
: ExchangeService.GetPrice(Account, Config.Ticker, DateTime.UtcNow);
if (!(await TradingService.AssessSynthPositionRiskAsync(Config.Ticker, signal.Direction, currentPrice,
Config, Config.IsForBacktest)))
{
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(
$"🔥 **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
{
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($"❌ **Broker Position Check Failed**\nError checking broker positions\n{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("📊 **Cooldown Check Failed**\nCannot check cooldown\nNot enough candles available");
return false;
}
// Get the actual closing date of the position instead of signal date
var positionClosingDate = GetPositionClosingDate(lastPosition);
if (positionClosingDate == null)
{
await LogWarning($"Cannot determine closing date for last position {lastPosition.Identifier}");
return false;
}
var canOpenPosition = positionClosingDate < cooldownCandle.Date;
if (!canOpenPosition)
{
await LogInformation(
$"⏳ **Cooldown Active**\nPosition blocked by cooldown period\n📅 Last Position Closed: `{positionClosingDate:MM/dd HH:mm}`\n🕒 Cooldown Until: `{cooldownCandle.Date:MM/dd HH:mm}`");
}
return canOpenPosition;
}
/// <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(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(
$"🔧 **Closing Trade**\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n📋 Type: `{tradeToClose.TradeType}`\n📊 Quantity: `{tradeToClose.Quantity}`\n🎯 Closing Position: `{(tradeClosingPosition ? "Yes" : "No")}`");
// 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 Closed Successfully**\nPosition: `{position.SignalIdentifier}`\nPnL: `${position.ProfitAndLoss?.Realized:F2}`");
// 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;
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 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)
{
try
{
var position = Positions.First(p => p.SignalIdentifier == signalIdentifier);
if (!position.Status.Equals(positionStatus))
{
Positions.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(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.
/// 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.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;
// UI Fee: 0.1% of position size paid BOTH on opening AND closing
var uiFeeRate = 0.001m; // 0.1%
var uiFeeOpen = positionSizeUsd * uiFeeRate; // Fee paid on opening
var uiFeeClose = positionSizeUsd * uiFeeRate; // Fee paid on closing
var totalUiFees = uiFeeOpen + uiFeeClose; // Total: 0.2% of position size
fees += totalUiFees;
// 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: `{Name}`\nWatch Only: `{(Config.IsForWatchingOnly ? "ON" : "OFF")}`");
}
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)
{
var user = Account.User;
var messageWithBotName = $"🤖 **{user.AgentName} - {Name}**\n{message}";
await MessengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user);
}
}
public override void SaveBackup()
{
var data = new TradingBotBackup
{
Config = Config,
Signals = Signals,
Positions = Positions,
WalletBalances = WalletBalances,
StartupTime = StartupTime
};
BotService.SaveOrUpdateBotBackup(User, Identifier, Status, JsonConvert.SerializeObject(data));
}
public override void LoadBackup(BotBackup backup)
{
var data = JsonConvert.DeserializeObject<TradingBotBackup>(backup.Data);
// Load the configuration directly
Config = data.Config;
// Ensure IsForBacktest is always false when loading from backup
Config.IsForBacktest = false;
// Load runtime state
Signals = data.Signals ?? new HashSet<Signal>();
Positions = data.Positions ?? new List<Position>();
WalletBalances = data.WalletBalances ?? new Dictionary<DateTime, decimal>();
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,
IndicatorType.Stc, SignalType.Signal, "Manual 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(
$"👤 **Manual Position Opened**\nPosition: `{position.Identifier}`\nSignal: `{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.
/// </summary>
/// <param name="newConfig">The new configuration to apply</param>
/// <returns>True if the configuration was successfully updated, false otherwise</returns>
public async Task<bool> UpdateConfiguration(TradingBotConfig newConfig)
{
return await UpdateConfiguration(newConfig, allowNameChange: false);
}
/// <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>
/// <param name="allowNameChange">Whether to allow changing the bot name/identifier</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, bool allowNameChange = false)
{
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 (allowNameChange && 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}");
}
// Capture current indicators before any changes for scenario comparison
var oldIndicators = Indicators?.ToList() ?? new List<IIndicator>();
// 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;
var protectedName = allowNameChange ? newConfig.Name : Config.Name;
// Update the configuration
Config = newConfig;
// Restore protected properties
Config.IsForBacktest = protectedIsForBacktest;
Config.Name = protectedName;
// Update bot name and identifier if allowed
if (allowNameChange && !string.IsNullOrEmpty(newConfig.Name))
{
Name = newConfig.Name;
Identifier = 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)
{
LoadScenario(newConfig.Scenario);
// Compare indicators after scenario change
var newIndicators = Indicators?.ToList() ?? new List<IIndicator>();
var indicatorChanges = CompareIndicators(oldIndicators, 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");
}
// 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,
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>
/// Compares two lists of indicators and returns a list of changes (added, removed, modified).
/// </summary>
/// <param name="oldIndicators">The previous list of indicators</param>
/// <param name="newIndicators">The new list of indicators</param>
/// <returns>A list of change descriptions</returns>
private List<string> CompareIndicators(List<IIndicator> oldIndicators, List<IIndicator> newIndicators)
{
var changes = new List<string>();
// Create dictionaries for easier comparison using Type as key
var oldIndicatorDict = oldIndicators.ToDictionary(i => i.Type, i => i);
var newIndicatorDict = newIndicators.ToDictionary(i => i.Type, i => i);
// Find removed indicators
var removedTypes = oldIndicatorDict.Keys.Except(newIndicatorDict.Keys);
foreach (var removedType in removedTypes)
{
var indicator = oldIndicatorDict[removedType];
changes.Add($" **Removed Indicator:** {removedType} ({indicator.GetType().Name})");
}
// Find added indicators
var addedTypes = newIndicatorDict.Keys.Except(oldIndicatorDict.Keys);
foreach (var addedType in addedTypes)
{
var indicator = newIndicatorDict[addedType];
changes.Add($" **Added Indicator:** {addedType} ({indicator.GetType().Name})");
}
// Find modified indicators (same type but potentially different configuration)
var commonTypes = oldIndicatorDict.Keys.Intersect(newIndicatorDict.Keys);
foreach (var commonType in commonTypes)
{
var oldIndicator = oldIndicatorDict[commonType];
var newIndicator = newIndicatorDict[commonType];
// Compare indicators by serializing them (simple way to detect configuration changes)
var oldSerialized = JsonConvert.SerializeObject(oldIndicator, Formatting.None);
var newSerialized = JsonConvert.SerializeObject(newIndicator, Formatting.None);
if (oldSerialized != newSerialized)
{
changes.Add($"🔄 **Modified Indicator:** {commonType} ({newIndicator.GetType().Name})");
}
}
// Add summary if there are changes
if (changes.Any())
{
var summary =
$"📊 **Indicator Changes:** {addedTypes.Count()} added, {removedTypes.Count()} removed, {commonTypes.Count(c => JsonConvert.SerializeObject(oldIndicatorDict[c]) != JsonConvert.SerializeObject(newIndicatorDict[c]))} modified";
changes.Insert(0, summary);
}
return changes;
}
}
public class TradingBotBackup
{
/// <summary>
/// The complete trading bot configuration
/// </summary>
public TradingBotConfig Config { get; set; }
/// <summary>
/// Runtime state: Active signals for the bot
/// </summary>
public HashSet<Signal> Signals { get; set; }
/// <summary>
/// Runtime state: Open and closed positions for the bot
/// </summary>
public List<Position> Positions { get; set; }
/// <summary>
/// Runtime state: Historical wallet balances over time
/// </summary>
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
/// <summary>
/// Runtime state: When the bot was started
/// </summary>
public DateTime StartupTime { get; set; }
}