1572 lines
63 KiB
C#
1572 lines
63 KiB
C#
using Managing.Application.Abstractions;
|
||
using Managing.Application.Abstractions.Services;
|
||
using Managing.Application.Trading;
|
||
using Managing.Application.Trading.Commands;
|
||
using Managing.Common;
|
||
using Managing.Core.FixedSizedQueue;
|
||
using Managing.Domain.Accounts;
|
||
using Managing.Domain.Bots;
|
||
using Managing.Domain.Candles;
|
||
using Managing.Domain.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>();
|
||
|
||
if (!Config.IsForBacktest)
|
||
{
|
||
Interval = CandleExtensions.GetIntervalFromTimeframe(Config.Timeframe);
|
||
PreloadSince = CandleExtensions.GetBotPreloadSinceFromTimeframe(Config.Timeframe);
|
||
}
|
||
}
|
||
|
||
public override void Start()
|
||
{
|
||
base.Start();
|
||
// Load account synchronously
|
||
LoadAccount().GetAwaiter().GetResult();
|
||
|
||
if (!Config.IsForBacktest)
|
||
{
|
||
LoadScenario(Config.ScenarioName);
|
||
PreloadCandles().GetAwaiter().GetResult();
|
||
CancelAllOrders().GetAwaiter().GetResult();
|
||
|
||
try
|
||
{
|
||
// await MessengerService.SendMessage(
|
||
// $"Hey everyone! I'm about to start {Name}. 🚀\n" +
|
||
// $"I'll post an update here each time a signal is triggered by the following strategies: {string.Join(", ", Strategies.Select(s => s.Name))}."
|
||
// );
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, ex.Message);
|
||
}
|
||
|
||
InitWorker(Run).GetAwaiter().GetResult();
|
||
}
|
||
|
||
// Fee = TradingService.GetFee(Account, IsForBacktest);
|
||
}
|
||
|
||
public async Task LoadAccount()
|
||
{
|
||
var account = await AccountService.GetAccount(Config.AccountName, false, false);
|
||
if (account == null)
|
||
{
|
||
Logger.LogWarning($"No account found for this {Config.AccountName}");
|
||
Stop();
|
||
}
|
||
else
|
||
{
|
||
Account = account;
|
||
}
|
||
}
|
||
|
||
public void LoadScenario(string scenarioName)
|
||
{
|
||
if (Config.Scenario != null)
|
||
return;
|
||
|
||
var scenario = TradingService.GetScenarioByName(scenarioName);
|
||
if (scenario == null)
|
||
{
|
||
Logger.LogWarning("No scenario found for this scenario name");
|
||
Stop();
|
||
}
|
||
else
|
||
{
|
||
LoadIndicators(ScenarioHelpers.GetIndicatorsFromScenario(scenario));
|
||
}
|
||
}
|
||
|
||
public void LoadScenario(Scenario scenario)
|
||
{
|
||
if (scenario == null)
|
||
{
|
||
Logger.LogWarning("Null scenario provided");
|
||
Stop();
|
||
}
|
||
else
|
||
{
|
||
LoadIndicators(ScenarioHelpers.GetIndicatorsFromScenario(scenario));
|
||
}
|
||
}
|
||
|
||
public void LoadIndicators(Scenario scenario)
|
||
{
|
||
LoadIndicators(ScenarioHelpers.GetIndicatorsFromScenario(scenario));
|
||
}
|
||
|
||
public void LoadIndicators(IEnumerable<IIndicator> indicators)
|
||
{
|
||
foreach (var strategy in indicators)
|
||
{
|
||
Indicators.Add(strategy);
|
||
}
|
||
}
|
||
|
||
public async Task Run()
|
||
{
|
||
if (!Config.IsForBacktest)
|
||
{
|
||
// Check broker balance before running
|
||
var balance = await ExchangeService.GetBalance(Account, false);
|
||
if (balance < Constants.GMX.Config.MinimumPositionAmount)
|
||
{
|
||
await LogWarning(
|
||
$"Balance on broker is below {Constants.GMX.Config.MinimumPositionAmount} USD (actual: {balance}). Stopping bot {Identifier} and saving backup.");
|
||
SaveBackup();
|
||
Stop();
|
||
return;
|
||
}
|
||
|
||
Logger.LogInformation($"____________________{Name}____________________");
|
||
Logger.LogInformation(
|
||
$"Time : {DateTime.Now} - Server time {DateTime.Now.ToUniversalTime()} - Last candle : {OptimizedCandles.Last().Date} - Bot : {Name} - 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)
|
||
{
|
||
// Add bot name at the top of every message
|
||
var messageWithBotName = $"🤖 **{Name}**\n{message}";
|
||
await MessengerService.SendTradeMessage(messageWithBotName, isBadBehavior, Account?.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 (string.IsNullOrEmpty(newConfig.ScenarioName))
|
||
{
|
||
throw new ArgumentException("Scenario name cannot be null or empty");
|
||
}
|
||
|
||
// Protect critical properties that shouldn't change for running bots
|
||
var protectedIsForBacktest = Config.IsForBacktest;
|
||
var protectedName = allowNameChange ? newConfig.Name : Config.Name;
|
||
|
||
// Log the configuration update (before changing anything)
|
||
await LogInformation("⚙️ **Configuration Update**\n" +
|
||
"📊 **Previous Settings:**\n" +
|
||
$"💰 Balance: ${Config.BotTradingBalance:F2}\n" +
|
||
$"⏱️ Max Time: {(Config.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled")}\n" +
|
||
$"📈 Flip Only in Profit: {(Config.FlipOnlyWhenInProfit ? "✅" : "❌")}\n" +
|
||
$"⏳ Cooldown: {Config.CooldownPeriod} candles\n" +
|
||
$"📉 Max Loss Streak: {Config.MaxLossStreak}" +
|
||
(allowNameChange && newConfig.Name != Config.Name
|
||
? $"\n🏷️ Name: {Config.Name} → {newConfig.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
|
||
var currentScenario = Config.Scenario?.Name;
|
||
if (Config.ScenarioName != currentScenario)
|
||
{
|
||
LoadScenario(Config.ScenarioName);
|
||
}
|
||
|
||
await LogInformation("✅ **Configuration Applied**\n" +
|
||
"🔧 **New Settings:**\n" +
|
||
$"💰 Balance: ${Config.BotTradingBalance:F2}\n" +
|
||
$"⏱️ Max Time: {(Config.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled")}\n" +
|
||
$"📈 Flip Only in Profit: {(Config.FlipOnlyWhenInProfit ? "✅" : "❌")}\n" +
|
||
$"⏳ Cooldown: {Config.CooldownPeriod} candles\n" +
|
||
$"📉 Max Loss Streak: {Config.MaxLossStreak}");
|
||
|
||
// Save the updated configuration as backup
|
||
if (!Config.IsForBacktest)
|
||
{
|
||
SaveBackup();
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await LogWarning($"Failed to update bot configuration: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the current trading bot configuration.
|
||
/// </summary>
|
||
/// <returns>A copy of the current configuration</returns>
|
||
public TradingBotConfig GetConfiguration()
|
||
{
|
||
return new TradingBotConfig
|
||
{
|
||
AccountName = Config.AccountName,
|
||
MoneyManagement = Config.MoneyManagement,
|
||
Ticker = Config.Ticker,
|
||
ScenarioName = Config.ScenarioName,
|
||
Timeframe = Config.Timeframe,
|
||
IsForWatchingOnly = Config.IsForWatchingOnly,
|
||
BotTradingBalance = Config.BotTradingBalance,
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
|
||
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; }
|
||
} |