3056 lines
138 KiB
C#
3056 lines
138 KiB
C#
using System.Diagnostics;
|
|
using Managing.Application.Abstractions;
|
|
using Managing.Application.Abstractions.Grains;
|
|
using Managing.Application.Abstractions.Services;
|
|
using Managing.Application.Trading.Commands;
|
|
using Managing.Application.Trading.Handlers;
|
|
using Managing.Common;
|
|
using Managing.Core;
|
|
using Managing.Core.Exceptions;
|
|
using Managing.Domain.Accounts;
|
|
using Managing.Domain.Bots;
|
|
using Managing.Domain.Candles;
|
|
using Managing.Domain.Indicators;
|
|
using Managing.Domain.Scenarios;
|
|
using Managing.Domain.Shared.Helpers;
|
|
using Managing.Domain.Strategies;
|
|
using Managing.Domain.Strategies.Base;
|
|
using Managing.Domain.Synth.Models;
|
|
using Managing.Domain.Trades;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Newtonsoft.Json;
|
|
using Orleans.Streams;
|
|
using static Managing.Common.Enums;
|
|
|
|
namespace Managing.Application.Bots;
|
|
|
|
public class TradingBotBase : ITradingBot
|
|
{
|
|
public readonly ILogger<TradingBotBase> Logger;
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly IStreamProvider? _streamProvider;
|
|
private const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders
|
|
|
|
private const int
|
|
CLOSE_POSITION_GRACE_MS = 20000; // grace window before closing position to allow broker processing (20 seconds)
|
|
|
|
public TradingBotConfig Config { get; set; }
|
|
public Account Account { get; set; }
|
|
public Dictionary<string, LightSignal> Signals { get; set; }
|
|
public Dictionary<Guid, Position> Positions { get; set; }
|
|
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
|
|
private decimal _currentBalance;
|
|
public DateTime PreloadSince { get; set; }
|
|
public int PreloadedCandlesCount { get; set; }
|
|
public long ExecutionCount { get; set; } = 0;
|
|
public Guid Identifier { get; set; } = Guid.Empty;
|
|
public Candle LastCandle { get; set; }
|
|
public DateTime? LastPositionClosingTime { get; set; }
|
|
|
|
public TradingBotBase(
|
|
ILogger<TradingBotBase> logger,
|
|
IServiceScopeFactory scopeFactory,
|
|
TradingBotConfig config,
|
|
IStreamProvider? streamProvider = null
|
|
)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_streamProvider = streamProvider;
|
|
Logger = logger;
|
|
Config = config;
|
|
Signals = new Dictionary<string, LightSignal>();
|
|
Positions = new Dictionary<Guid, Position>();
|
|
WalletBalances = new Dictionary<DateTime, decimal>();
|
|
_currentBalance = config.BotTradingBalance;
|
|
PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe);
|
|
}
|
|
|
|
public async Task Start(BotStatus previousStatus)
|
|
{
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
// Start async initialization in the background without blocking
|
|
try
|
|
{
|
|
await LoadAccount();
|
|
await LoadLastCandle();
|
|
|
|
if (Account == null)
|
|
{
|
|
await LogWarning($"Account {Config.AccountName} not found. Bot cannot start.");
|
|
throw new ArgumentException("Account not found");
|
|
}
|
|
|
|
switch (previousStatus)
|
|
{
|
|
case BotStatus.Saved:
|
|
var indicatorNames = Config.Scenario.Indicators.Select(i => i.Type.ToString()).ToList();
|
|
var modeText = Config.IsForWatchingOnly ? "Watch Only" :
|
|
Config.IsForCopyTrading ? "Copy Trading" : "Live Trading";
|
|
|
|
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: `{modeText}`\n\n" +
|
|
(Config.IsForCopyTrading
|
|
? ""
|
|
: $"📈 Active Indicators: `{string.Join(", ", indicatorNames)}`\n\n") +
|
|
$"✅ Ready to monitor signals and execute trades\n" +
|
|
$"📢 Notifications will be sent when positions are triggered";
|
|
|
|
await LogInformation(startupMessage);
|
|
break;
|
|
|
|
case BotStatus.Running:
|
|
return;
|
|
|
|
case BotStatus.Stopped:
|
|
// If status was Stopped we log a message to inform the user that the bot is restarting
|
|
await LogInformation($"🔄 Bot Restarted\n" +
|
|
$"📊 Resuming operations with {Signals.Count} signals and {Positions.Count} positions\n" +
|
|
$"✅ Ready to continue trading");
|
|
break;
|
|
|
|
default:
|
|
// Handle any other status if needed
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error during bot startup: {Message}", ex.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task LoadLastCandle()
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
|
|
{
|
|
var grainKey = CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe);
|
|
var grain = grainFactory.GetGrain<ICandleStoreGrain>(grainKey);
|
|
|
|
try
|
|
{
|
|
// Add a small delay to ensure grain is fully activated
|
|
await Task.Delay(100);
|
|
var lastCandles = await grain.GetLastCandle(1);
|
|
LastCandle = lastCandles.FirstOrDefault();
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("invalid activation"))
|
|
{
|
|
Logger.LogWarning("Grain activation failed for {GrainKey}, retrying in 1 second...", grainKey);
|
|
|
|
// Wait a bit longer and retry once
|
|
await Task.Delay(1000);
|
|
try
|
|
{
|
|
var lastCandles = await grain.GetLastCandle(1);
|
|
LastCandle = lastCandles.FirstOrDefault();
|
|
}
|
|
catch (Exception retryEx)
|
|
{
|
|
Logger.LogError(retryEx, "Failed to load last candle for {GrainKey} after retry", grainKey);
|
|
LastCandle = null;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error loading last candle for {GrainKey}", grainKey);
|
|
LastCandle = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task LoadAccount()
|
|
{
|
|
if (Config.IsForBacktest) return;
|
|
await ServiceScopeHelpers.WithScopedService<IAccountService>(_scopeFactory, async accountService =>
|
|
{
|
|
var account = await accountService.GetAccountByAccountName(Config.AccountName, false, false);
|
|
Account = account;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the actual USDC balance and updates the config if the actual balance is less than the configured balance.
|
|
/// This prevents bots from trying to trade with more funds than are actually available.
|
|
/// </summary>
|
|
public async Task VerifyAndUpdateBalance()
|
|
{
|
|
if (Config.IsForBacktest) return;
|
|
if (Account == null)
|
|
{
|
|
Logger.LogWarning("Cannot verify balance: Account is null");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Fetch actual USDC balance
|
|
var actualBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
var balances = await exchangeService.GetBalances(Account);
|
|
var usdcBalance = balances.FirstOrDefault(b => b.TokenName?.ToUpper() == "USDC");
|
|
return usdcBalance?.Amount ?? 0;
|
|
});
|
|
|
|
// Check if actual balance is less than configured balance
|
|
if (actualBalance < Config.BotTradingBalance)
|
|
{
|
|
Logger.LogWarning(
|
|
"Actual USDC balance ({ActualBalance:F2}) is less than configured balance ({ConfiguredBalance:F2}). Updating configuration.",
|
|
actualBalance, Config.BotTradingBalance);
|
|
|
|
// Create new config with updated balance
|
|
var newConfig = Config;
|
|
newConfig.BotTradingBalance = actualBalance;
|
|
|
|
// Use UpdateConfiguration to notify and log the change
|
|
await UpdateConfiguration(newConfig);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogDebug(
|
|
"Balance verification passed. Actual: {ActualBalance:F2}, Configured: {ConfiguredBalance:F2}",
|
|
actualBalance, Config.BotTradingBalance);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error verifying and updating balance");
|
|
}
|
|
}
|
|
|
|
public async Task Run()
|
|
{
|
|
// Update signals for live trading only
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
if (!Config.IsForCopyTrading)
|
|
{
|
|
await UpdateSignals();
|
|
}
|
|
|
|
await LoadLastCandle();
|
|
}
|
|
|
|
if (!Config.IsForWatchingOnly)
|
|
await ManagePositions();
|
|
|
|
UpdateWalletBalances();
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
ExecutionCount++;
|
|
|
|
Logger.LogInformation(
|
|
"[{CopyTrading}][{AgentName}] Bot Status {Name} - ServerDate: {ServerDate}, LastCandleDate: {LastCandleDate}, Signals: {SignalCount}, Executions: {ExecutionCount}, Positions: {PositionCount}",
|
|
Config.IsForCopyTrading ? "CopyTrading" : "LiveTrading", Account.User.AgentName, Config.Name,
|
|
DateTime.UtcNow, LastCandle?.Date, Signals.Count, ExecutionCount, Positions.Count);
|
|
|
|
Logger.LogInformation("[{AgentName}] Internal Positions : {Position}", Account.User.AgentName,
|
|
string.Join(", ",
|
|
Positions.Values.Select(p => $"{p.SignalIdentifier} - Status: {p.Status}")));
|
|
}
|
|
}
|
|
|
|
public async Task UpdateSignals(HashSet<Candle> candles = null)
|
|
{
|
|
await UpdateSignals(candles, null);
|
|
}
|
|
|
|
public async Task UpdateSignals(HashSet<Candle> candles,
|
|
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues = null)
|
|
{
|
|
// Skip indicator checking if flipping is disabled and there's an open position
|
|
// This prevents unnecessary indicator calculations when we can't act on signals anyway
|
|
if (!Config.FlipPosition && Positions.Any(p => p.Value.IsOpen()))
|
|
{
|
|
Logger.LogDebug(
|
|
$"Skipping signal update: Position open and flip disabled. Open positions: {Positions.Count(p => p.Value.IsOpen())}");
|
|
return;
|
|
}
|
|
|
|
// Check if we're in cooldown period for any direction
|
|
if (await IsInCooldownPeriodAsync())
|
|
{
|
|
// Still in cooldown period, skip signal generation
|
|
return;
|
|
}
|
|
|
|
if (Config.IsForBacktest)
|
|
{
|
|
var backtestSignal = TradingBox.GetSignal(candles, Config.Scenario, Signals, Config.Scenario.LoopbackPeriod,
|
|
preCalculatedIndicatorValues);
|
|
if (backtestSignal == null) return;
|
|
await AddSignal(backtestSignal);
|
|
}
|
|
else
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
|
|
{
|
|
var scenarioRunnerGrain = grainFactory.GetGrain<IScenarioRunnerGrain>(Guid.NewGuid());
|
|
var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle);
|
|
if (signal == null) return;
|
|
await AddSignal(signal);
|
|
});
|
|
}
|
|
}
|
|
|
|
private async Task<LightSignal> RecreateSignalFromPosition(Position position)
|
|
{
|
|
try
|
|
{
|
|
// Create a dummy candle for the position opening time
|
|
var positionCandle = new Candle
|
|
{
|
|
Date = position.Open.Date,
|
|
OpenTime = position.Open.Date,
|
|
Open = position.Open.Price,
|
|
Close = position.Open.Price,
|
|
High = position.Open.Price,
|
|
Low = position.Open.Price,
|
|
Volume = 0,
|
|
Exchange = TradingExchanges.Evm,
|
|
Ticker = Config.Ticker,
|
|
Timeframe = Config.Timeframe
|
|
};
|
|
|
|
// Create a new signal based on position information
|
|
var recreatedSignal = new LightSignal(
|
|
ticker: Config.Ticker,
|
|
direction: position.OriginDirection,
|
|
confidence: Confidence.Medium, // Default confidence for recreated signals
|
|
candle: positionCandle,
|
|
date: position.Open.Date,
|
|
exchange: TradingExchanges.Evm,
|
|
indicatorType: IndicatorType.Stc, // Use a valid strategy type for recreated signals
|
|
signalType: SignalType.Signal,
|
|
indicatorName: "RecreatedSignal"
|
|
);
|
|
|
|
// Since Signal identifier is auto-generated, we need to update our position
|
|
// to use the new signal identifier, or find another approach
|
|
// For now, let's update the position's SignalIdentifier to match the recreated signal
|
|
position.SignalIdentifier = recreatedSignal.Identifier;
|
|
|
|
recreatedSignal.Status = SignalStatus.PositionOpen;
|
|
|
|
// Add the recreated signal to our collection
|
|
Signals.Add(recreatedSignal.Identifier, recreatedSignal);
|
|
|
|
await LogInformation(
|
|
$"🔍 Signal Recovery Success\nRecreated signal: `{recreatedSignal.Identifier}`\nFor position: `{position.Identifier}`");
|
|
return recreatedSignal;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning($"Error recreating signal for position {position.Identifier}: {ex.Message}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Task ManagePositions()
|
|
{
|
|
// Recovery Logic: Check for recently canceled positions that might need recovery
|
|
await RecoverRecentlyCanceledPositions();
|
|
|
|
// Early exit optimization - skip if no positions to manage
|
|
// Optimized: Use for loop to avoid multiple iterations
|
|
bool hasOpenPositions = false;
|
|
foreach (var position in Positions.Values)
|
|
{
|
|
if (!position.IsFinished())
|
|
{
|
|
hasOpenPositions = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
bool hasWaitingSignals = false;
|
|
if (!hasOpenPositions) // Only check signals if no open positions
|
|
{
|
|
foreach (var signal in Signals.Values)
|
|
{
|
|
if (signal.Status == SignalStatus.WaitingForPosition)
|
|
{
|
|
hasWaitingSignals = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasOpenPositions && !hasWaitingSignals)
|
|
return;
|
|
|
|
// First, process all existing positions that are not finished
|
|
// Optimized: Inline the filter to avoid LINQ overhead
|
|
foreach (var position in Positions.Values)
|
|
{
|
|
if (position.IsFinished())
|
|
continue;
|
|
|
|
var signalForPosition = Signals[position.SignalIdentifier];
|
|
if (signalForPosition == null)
|
|
{
|
|
await LogInformation(
|
|
$"🔍 Signal Recovery\nSignal not found for position `{position.Identifier}`\nRecreating signal from position data...");
|
|
|
|
// Recreate the signal based on position information
|
|
signalForPosition = await RecreateSignalFromPosition(position);
|
|
|
|
if (signalForPosition == null)
|
|
{
|
|
await LogWarning($"Failed to recreate signal for position {position.Identifier}");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Ensure signal status is correctly set to PositionOpen if position is not finished
|
|
if (signalForPosition.Status != SignalStatus.PositionOpen && position.Status != PositionStatus.Finished)
|
|
{
|
|
await LogInformation(
|
|
$"🔄 Signal Status Update\nSignal: `{signalForPosition.Identifier}`\nStatus: `{signalForPosition.Status}` → `PositionOpen`");
|
|
SetSignalStatus(signalForPosition.Identifier, SignalStatus.PositionOpen);
|
|
}
|
|
|
|
await UpdatePosition(signalForPosition, position);
|
|
}
|
|
|
|
// Then, open positions for signals waiting for a position open
|
|
// But first, check if we already have a position for any of these signals
|
|
var signalsWaitingForPosition = Signals.Values.Where(s => s.Status == SignalStatus.WaitingForPosition);
|
|
|
|
foreach (var signal in signalsWaitingForPosition)
|
|
{
|
|
if (LastCandle != null && signal.Date < LastCandle.Date)
|
|
{
|
|
await LogWarning(
|
|
$"❌ Signal Expired\nSignal `{signal.Identifier}` is older than last candle `{LastCandle.Date}`\nStatus: `Expired`");
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
continue;
|
|
}
|
|
|
|
// Check if we already have a position for this signal (in case it was added but not processed yet)
|
|
var existingPosition = Positions.Values.FirstOrDefault(p => p.SignalIdentifier == signal.Identifier);
|
|
|
|
if (existingPosition != null)
|
|
{
|
|
// Position already exists for this signal, update signal status
|
|
await LogInformation(
|
|
$"🔄 Signal Status Update\nSignal: `{signal.Identifier}`\nStatus: `{signal.Status}` → `PositionOpen`\nPosition already exists: `{existingPosition.Identifier}`");
|
|
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
|
|
continue;
|
|
}
|
|
|
|
// No existing position found, proceed to open a new one
|
|
var newlyCreatedPosition = await OpenPosition(signal);
|
|
|
|
// Position is now added to Positions collection inside OpenPosition method
|
|
// No need to add it here again
|
|
}
|
|
}
|
|
|
|
private void UpdateWalletBalances()
|
|
{
|
|
var date = Config.IsForBacktest ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow;
|
|
|
|
if (WalletBalances.Count == 0)
|
|
{
|
|
WalletBalances[date] = _currentBalance;
|
|
return;
|
|
}
|
|
|
|
if (!WalletBalances.ContainsKey(date))
|
|
{
|
|
WalletBalances[date] = _currentBalance;
|
|
}
|
|
}
|
|
|
|
private async Task UpdatePosition(LightSignal signal, Position positionForSignal)
|
|
{
|
|
try
|
|
{
|
|
// Skip processing if position is already canceled or rejected (never filled)
|
|
if (positionForSignal.Status == PositionStatus.Canceled ||
|
|
positionForSignal.Status == PositionStatus.Rejected)
|
|
{
|
|
await LogDebug(
|
|
$"Skipping update for position {positionForSignal.Identifier} - status is {positionForSignal.Status} (never filled)");
|
|
return;
|
|
}
|
|
|
|
Position internalPosition = null;
|
|
var brokerPositions = await ServiceScopeHelpers.WithScopedService<ITradingService, List<Position>>(
|
|
_scopeFactory, async tradingService =>
|
|
{
|
|
internalPosition = Config.IsForBacktest
|
|
? positionForSignal
|
|
: await tradingService.GetPositionByIdentifierAsync(positionForSignal.Identifier);
|
|
|
|
if (Config.IsForBacktest)
|
|
{
|
|
return new List<Position> { internalPosition };
|
|
}
|
|
else
|
|
{
|
|
return await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
|
|
_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
return [.. await exchangeService.GetBrokerPositions(Account)];
|
|
});
|
|
}
|
|
});
|
|
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
// Improved broker position matching with more robust logic
|
|
var brokerPosition = brokerPositions
|
|
.Where(p => p.Ticker == Config.Ticker)
|
|
.OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue)
|
|
.FirstOrDefault(p => p.OriginDirection == positionForSignal.OriginDirection);
|
|
|
|
if (brokerPosition != null)
|
|
{
|
|
var previousPositionStatus = internalPosition.Status;
|
|
// Position found on the broker, means the position is filled
|
|
var brokerPnlBeforeFees = brokerPosition.GetPnLBeforeFees();
|
|
UpdatePositionPnl(positionForSignal.Identifier, brokerPnlBeforeFees);
|
|
var totalFees = internalPosition.GasFees + internalPosition.UiFees;
|
|
var netPnl = brokerPnlBeforeFees - totalFees;
|
|
internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = brokerPnlBeforeFees, Net = netPnl };
|
|
internalPosition.Status = PositionStatus.Filled;
|
|
await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled);
|
|
|
|
internalPosition.Open.SetStatus(TradeStatus.Filled);
|
|
positionForSignal.Open.SetStatus(TradeStatus.Filled);
|
|
|
|
internalPosition.Open.Price = brokerPosition.Open.Price;
|
|
positionForSignal.Open.Price = brokerPosition.Open.Price;
|
|
|
|
// Update Open trade ExchangeOrderId if broker position has one
|
|
if (brokerPosition.Open?.ExchangeOrderId != null && internalPosition.Open != null)
|
|
{
|
|
internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId);
|
|
positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId);
|
|
}
|
|
|
|
// Update Stop Loss and Take Profit trades with correct ExchangeOrderId from broker
|
|
if (brokerPosition.StopLoss != null && internalPosition.StopLoss != null)
|
|
{
|
|
internalPosition.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId);
|
|
positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId);
|
|
}
|
|
|
|
if (brokerPosition.TakeProfit1 != null && internalPosition.TakeProfit1 != null)
|
|
{
|
|
internalPosition.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId);
|
|
positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId);
|
|
}
|
|
|
|
if (brokerPosition.TakeProfit2 != null && internalPosition.TakeProfit2 != null)
|
|
{
|
|
internalPosition.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId);
|
|
positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId);
|
|
}
|
|
|
|
await UpdatePositionDatabase(internalPosition);
|
|
|
|
if (previousPositionStatus != PositionStatus.Filled &&
|
|
internalPosition.Status == PositionStatus.Filled)
|
|
{
|
|
await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionOpened, internalPosition);
|
|
}
|
|
else
|
|
{
|
|
await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionUpdated, internalPosition);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Position not found in broker's active positions list
|
|
// Need to verify if it was actually closed or just not returned by the API
|
|
if (internalPosition.Status.Equals(PositionStatus.Filled))
|
|
{
|
|
Logger.LogWarning(
|
|
$"⚠️ Position Sync Issue Detected\n" +
|
|
$"Internal position {internalPosition.Identifier} shows Filled\n" +
|
|
$"But not found in broker positions list (Count: {brokerPositions.Count})\n" +
|
|
$"Checking position history before marking as closed...");
|
|
|
|
// Verify in exchange history before assuming it's closed
|
|
var (existsInHistory, hadWeb3ProxyError) =
|
|
await CheckPositionInExchangeHistory(positionForSignal);
|
|
|
|
if (hadWeb3ProxyError)
|
|
{
|
|
// Web3Proxy error - don't assume position is closed, wait for next cycle
|
|
await LogWarning(
|
|
$"⏳ Web3Proxy Error During Position Verification\n" +
|
|
$"Position: `{positionForSignal.Identifier}`\n" +
|
|
$"Cannot verify if position is closed\n" +
|
|
$"Will retry on next execution cycle");
|
|
// Don't change position status, wait for next cycle
|
|
return;
|
|
}
|
|
else if (existsInHistory)
|
|
{
|
|
// Position was actually filled and closed by the exchange
|
|
Logger.LogInformation(
|
|
$"✅ Position Confirmed Closed via History\n" +
|
|
$"Position {internalPosition.Identifier} found in exchange history\n" +
|
|
$"Proceeding with HandleClosedPosition");
|
|
|
|
internalPosition.Status = PositionStatus.Finished;
|
|
await HandleClosedPosition(internalPosition);
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
// Position not in history either - could be API issue or timing problem
|
|
// Don't immediately close, just log warning and retry next cycle
|
|
await LogDebug(
|
|
$"⚠️ Position Synchronization Warning\n" +
|
|
$"Position `{internalPosition.Identifier}` ({internalPosition.OriginDirection} {Config.Ticker})\n" +
|
|
$"Not found in broker positions OR exchange history\n" +
|
|
$"Status: `{internalPosition.Status}`\n" +
|
|
$"This could indicate:\n" +
|
|
$"• API returned incomplete data\n" +
|
|
$"• Timing issue with broker API\n" +
|
|
$"• Position direction mismatch\n" +
|
|
$"Will retry verification on next cycle before taking action");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (internalPosition.Status == PositionStatus.New)
|
|
{
|
|
// Grace period: give the broker time to register open orders before we evaluate
|
|
var now = Config.IsForBacktest ? (LastCandle?.Date ?? DateTime.UtcNow) : DateTime.UtcNow;
|
|
var secondsSinceOpenRequest = (now - positionForSignal.Open.Date).TotalSeconds;
|
|
if (secondsSinceOpenRequest < NEW_POSITION_GRACE_SECONDS)
|
|
{
|
|
var remaining = NEW_POSITION_GRACE_SECONDS - secondsSinceOpenRequest;
|
|
await LogInformation(
|
|
$"⏳ Waiting for broker confirmation\nElapsed: `{secondsSinceOpenRequest:F0}s`\nGrace left: `{remaining:F0}s`");
|
|
return; // skip early checks until grace period elapses
|
|
}
|
|
|
|
var orders = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Trade>>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
return [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)];
|
|
});
|
|
|
|
if (orders.Any())
|
|
{
|
|
var ordersCount = orders.Count();
|
|
if (ordersCount >= 3)
|
|
{
|
|
var currentTime = Config.IsForBacktest ? LastCandle?.Date ?? DateTime.UtcNow : DateTime.UtcNow;
|
|
var timeSinceRequest = currentTime - positionForSignal.Open.Date;
|
|
var waitTimeMinutes = 10;
|
|
|
|
if (timeSinceRequest.TotalMinutes >= waitTimeMinutes)
|
|
{
|
|
await LogWarning(
|
|
$"⚠️ Orders Cleanup\nTime elapsed: {waitTimeMinutes}min\nCanceling all orders...");
|
|
try
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
await exchangeService.CancelOrder(Account, Config.Ticker);
|
|
});
|
|
await LogInformation(
|
|
$"✅ Orders for {internalPosition.OriginDirection} {Config.Ticker} successfully canceled");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning($"Failed to cancel orders: {ex.Message}");
|
|
}
|
|
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Canceled);
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
|
|
positionForSignal.Status = PositionStatus.Canceled;
|
|
positionForSignal.Open.SetStatus(TradeStatus.Cancelled);
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
|
|
|
await UpdatePositionDatabase(positionForSignal);
|
|
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 if (ordersCount == 2)
|
|
{
|
|
// TODO: This should never happen, but just in case
|
|
// Check if position is already open on broker with 2 orders
|
|
await LogInformation(
|
|
$"🔍 Checking Broker Position\nPosition has exactly `{orders.Count()}` open orders\nChecking if position is already open on broker...");
|
|
|
|
Position brokerPosition = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
var brokerPositions = await exchangeService.GetBrokerPositions(Account);
|
|
brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker);
|
|
});
|
|
|
|
if (brokerPosition != null)
|
|
{
|
|
await LogInformation(
|
|
$"✅ Position Found on Broker\nPosition is already open on broker\nUpdating position status to Filled");
|
|
|
|
// Calculate net PnL after fees for broker position
|
|
var brokerNetPnL = brokerPosition.GetPnLBeforeFees();
|
|
UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL);
|
|
|
|
// Update Open trade status when position is found on broker with 2 orders
|
|
if (internalPosition.Open != null)
|
|
{
|
|
internalPosition.Open.SetStatus(TradeStatus.Filled);
|
|
// Update Open trade ExchangeOrderId if broker position has one
|
|
if (brokerPosition.Open?.ExchangeOrderId != null)
|
|
{
|
|
internalPosition.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId);
|
|
}
|
|
}
|
|
|
|
// Also update the position in the bot's positions dictionary
|
|
if (positionForSignal.Open != null)
|
|
{
|
|
positionForSignal.Open.SetStatus(TradeStatus.Filled);
|
|
// Update Open trade ExchangeOrderId if broker position has one
|
|
if (brokerPosition.Open?.ExchangeOrderId != null)
|
|
{
|
|
positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId);
|
|
}
|
|
}
|
|
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Filled);
|
|
}
|
|
else
|
|
{
|
|
await LogInformation(
|
|
$"⏸️ Position Pending\nPosition still waiting to open\n`{orders.Count()}` open orders remaining");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogInformation(
|
|
$"⏸️ Position Pending\nPosition still waiting to open\n`{orders.Count()}` open orders remaining");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogWarning(
|
|
$"❌ Position Never Filled\nNo position on exchange and no orders\nChecking position history before marking as canceled.");
|
|
|
|
// Position might be canceled by the broker
|
|
// Check if position exists in exchange history with PnL before canceling
|
|
var (positionFoundInHistory, hadWeb3ProxyError) =
|
|
await CheckPositionInExchangeHistory(positionForSignal);
|
|
|
|
if (hadWeb3ProxyError)
|
|
{
|
|
// Web3Proxy error occurred - don't mark as cancelled, wait for next cycle
|
|
await LogWarning(
|
|
$"⏳ Web3Proxy Error - Skipping Position Cancellation\n" +
|
|
$"Position: `{positionForSignal.Identifier}`\n" +
|
|
$"Status remains: `{positionForSignal.Status}`\n" +
|
|
$"Will retry position verification on next execution cycle");
|
|
// Don't change signal status to Expired, let it continue
|
|
return;
|
|
}
|
|
else if (positionFoundInHistory)
|
|
{
|
|
// Position was actually filled and closed, process it properly
|
|
await HandleClosedPosition(positionForSignal);
|
|
await LogInformation(
|
|
$"✅ Position Found in Exchange History\n" +
|
|
$"Position was actually filled and closed\n" +
|
|
$"Processing with HandleClosedPosition");
|
|
}
|
|
else
|
|
{
|
|
// Position was never filled, just mark as canceled without processing PnL
|
|
positionForSignal.Status = PositionStatus.Canceled;
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Canceled);
|
|
await UpdatePositionDatabase(positionForSignal);
|
|
await LogWarning(
|
|
$"❌ Position Confirmed Never Filled\nNo position in exchange history\nMarking as canceled without PnL processing");
|
|
}
|
|
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
}
|
|
}
|
|
else if (internalPosition.Status == PositionStatus.Finished ||
|
|
internalPosition.Status == PositionStatus.Flipped)
|
|
{
|
|
await HandleClosedPosition(positionForSignal);
|
|
}
|
|
else if (internalPosition.Status == PositionStatus.Filled)
|
|
{
|
|
Candle lastCandle = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
|
|
{
|
|
lastCandle = Config.IsForBacktest
|
|
? LastCandle
|
|
: await exchangeService.GetCandle(Account, Config.Ticker,
|
|
DateTime.UtcNow);
|
|
});
|
|
|
|
var currentTime = Config.IsForBacktest ? lastCandle.Date : DateTime.UtcNow;
|
|
var currentPnl = positionForSignal.ProfitAndLoss?.Net ?? 0;
|
|
var pnlPercentage = TradingBox.CalculatePnLPercentage(currentPnl, positionForSignal.Open.Price,
|
|
positionForSignal.Open.Quantity);
|
|
|
|
var isPositionInProfit = TradingBox.IsPositionInProfit(positionForSignal.Open.Price, lastCandle.Close,
|
|
positionForSignal.OriginDirection);
|
|
|
|
var hasExceededTimeLimit = TradingBox.HasPositionExceededTimeLimit(positionForSignal.Open.Date,
|
|
currentTime, Config.MaxPositionTimeHours);
|
|
|
|
if (hasExceededTimeLimit)
|
|
{
|
|
var shouldCloseOnTimeLimit = !Config.CloseEarlyWhenProfitable || isPositionInProfit;
|
|
|
|
if (shouldCloseOnTimeLimit)
|
|
{
|
|
var profitStatus = isPositionInProfit ? "in profit" : "at a loss";
|
|
|
|
await LogInformation(
|
|
$"⏰ Time Limit Close\nClosing position due to time limit: `{Config.MaxPositionTimeHours}h` exceeded\n📈 Position Status: {profitStatus}\n💰 Entry: `${positionForSignal.Open.Price}` → Current: `${lastCandle.Close}`\n📊 Realized PNL: `${currentPnl:F2}` (`{pnlPercentage:F2}%`)");
|
|
// Force a market close: compute PnL based on current price instead of SL/TP
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.Open, lastCandle.Close, true,
|
|
true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// For backtest and to make sure position is closed based on SL and TP
|
|
if (positionForSignal.OriginDirection == TradeDirection.Long)
|
|
{
|
|
if (positionForSignal.StopLoss.Price >= lastCandle.Low)
|
|
{
|
|
positionForSignal.StopLoss.SetDate(lastCandle.Date);
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Filled);
|
|
|
|
if (positionForSignal.TakeProfit1 != null)
|
|
{
|
|
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
if (positionForSignal.TakeProfit2 != null)
|
|
{
|
|
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogInformation(
|
|
$"🛑 Stop Loss Hit\nClosing LONG position\nPrice: `${positionForSignal.StopLoss.Price:F2}`");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss,
|
|
positionForSignal.StopLoss.Price, true);
|
|
}
|
|
else if (positionForSignal.TakeProfit1.Price <= lastCandle.High &&
|
|
positionForSignal.TakeProfit1.Status != TradeStatus.Filled)
|
|
{
|
|
positionForSignal.TakeProfit1.SetDate(lastCandle.Date);
|
|
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel SL trade when TP is hit
|
|
if (positionForSignal.StopLoss != null)
|
|
{
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogInformation(
|
|
$"🎯 Take Profit 1 Hit\nClosing LONG position\nPrice: `${positionForSignal.TakeProfit1.Price:F2}`");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1,
|
|
positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null);
|
|
}
|
|
else if (positionForSignal.TakeProfit2?.Price <= lastCandle.High)
|
|
{
|
|
positionForSignal.TakeProfit2.SetDate(lastCandle.Date);
|
|
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel SL trade when TP is hit
|
|
if (positionForSignal.StopLoss != null)
|
|
{
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogInformation(
|
|
$"🎯 Take Profit 2 Hit\nClosing LONG position\nPrice: `${positionForSignal.TakeProfit2.Price:F2}`");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2,
|
|
positionForSignal.TakeProfit2.Price, true);
|
|
}
|
|
}
|
|
else if (positionForSignal.OriginDirection == TradeDirection.Short)
|
|
{
|
|
if (positionForSignal.StopLoss.Price <= lastCandle.High)
|
|
{
|
|
positionForSignal.StopLoss.SetDate(lastCandle.Date);
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel TP trades when SL is hit
|
|
if (positionForSignal.TakeProfit1 != null)
|
|
{
|
|
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
if (positionForSignal.TakeProfit2 != null)
|
|
{
|
|
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogInformation(
|
|
$"🛑 Stop Loss Hit\nClosing SHORT position\nPrice: `${positionForSignal.StopLoss.Price:F2}`");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.StopLoss,
|
|
positionForSignal.StopLoss.Price, true);
|
|
}
|
|
else if (positionForSignal.TakeProfit1.Price >= lastCandle.Low &&
|
|
positionForSignal.TakeProfit1.Status != TradeStatus.Filled)
|
|
{
|
|
// Use actual execution price (lastCandle.Low for TP hit on SHORT)
|
|
positionForSignal.TakeProfit1.SetDate(lastCandle.Date);
|
|
positionForSignal.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel SL trade when TP is hit
|
|
if (positionForSignal.StopLoss != null)
|
|
{
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogInformation(
|
|
$"🎯 Take Profit 1 Hit\nClosing SHORT position\nPrice: `${positionForSignal.TakeProfit1.Price:F2}` (was `${positionForSignal.TakeProfit1.Price:F2}`)");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit1,
|
|
positionForSignal.TakeProfit1.Price, positionForSignal.TakeProfit2 == null);
|
|
}
|
|
else if (positionForSignal.TakeProfit2?.Price >= lastCandle.Low)
|
|
{
|
|
// Use actual execution price (lastCandle.Low for TP hit on SHORT)
|
|
positionForSignal.TakeProfit2.SetDate(lastCandle.Date);
|
|
positionForSignal.TakeProfit2.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel SL trade when TP is hit
|
|
if (positionForSignal.StopLoss != null)
|
|
{
|
|
positionForSignal.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogInformation(
|
|
$"🎯 Take Profit 2 Hit\nClosing SHORT position\nPrice: `${positionForSignal.TakeProfit2.Price:F2}` (was `${positionForSignal.TakeProfit2.Price:F2}`)");
|
|
await CloseTrade(signal, positionForSignal, positionForSignal.TakeProfit2,
|
|
positionForSignal.TakeProfit2.Price, true);
|
|
}
|
|
}
|
|
}
|
|
// else if (internalPosition.Status == PositionStatus.Rejected ||
|
|
// internalPosition.Status == PositionStatus.Canceled)
|
|
// {
|
|
// await LogWarning($"Open position trade is rejected for signal {signal.Identifier}");
|
|
// if (signal.Status == SignalStatus.PositionOpen)
|
|
// {
|
|
// Logger.LogInformation($"Try to re-open position");
|
|
// await OpenPosition(signal);
|
|
// }
|
|
// }
|
|
|
|
if (Config.UseSynthApi && !Config.IsForBacktest &&
|
|
positionForSignal.Status == PositionStatus.Filled)
|
|
{
|
|
decimal currentPrice = 0;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
currentPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker);
|
|
});
|
|
var riskResult = default(SynthRiskResult);
|
|
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory, async tradingService =>
|
|
{
|
|
riskResult = await tradingService.MonitorSynthPositionRiskAsync(
|
|
Config.Ticker,
|
|
positionForSignal.OriginDirection,
|
|
currentPrice,
|
|
positionForSignal.StopLoss.Price,
|
|
positionForSignal.Identifier,
|
|
Config);
|
|
});
|
|
|
|
if (riskResult.ShouldWarn && !string.IsNullOrEmpty(riskResult.WarningMessage))
|
|
{
|
|
await LogWarning(riskResult.WarningMessage);
|
|
}
|
|
|
|
if (riskResult.ShouldAutoClose && !string.IsNullOrEmpty(riskResult.EmergencyMessage))
|
|
{
|
|
await LogWarning(riskResult.EmergencyMessage);
|
|
await CloseTrade(Signals[positionForSignal.SignalIdentifier], positionForSignal,
|
|
positionForSignal.StopLoss,
|
|
currentPrice, true);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning($"Cannot update position {positionForSignal.Identifier}: {ex.Message}, {ex.StackTrace}");
|
|
SentrySdk.CaptureException(ex);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private async Task UpdatePositionDatabase(Position position)
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory,
|
|
async tradingService => { await tradingService.UpdatePositionAsync(position); });
|
|
}
|
|
|
|
private async Task<Position> OpenPosition(LightSignal signal)
|
|
{
|
|
await LogDebug($"🔓 Opening position for signal: `{signal.Identifier}`");
|
|
|
|
// Check for any existing open position (not finished) for this ticker
|
|
var openedPosition =
|
|
Positions.Values.FirstOrDefault(p => p.IsOpen() && p.SignalIdentifier != signal.Identifier);
|
|
|
|
decimal lastPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
return Config.IsForBacktest
|
|
? LastCandle?.Close ?? 0
|
|
: await exchangeService.GetCurrentPrice(Account, Config.Ticker);
|
|
});
|
|
|
|
if (openedPosition != null)
|
|
{
|
|
var previousSignal = Signals[openedPosition.SignalIdentifier];
|
|
|
|
if (openedPosition.OriginDirection == signal.Direction)
|
|
{
|
|
await LogInformation(
|
|
$"📍 Same Direction Signal\nSignal `{signal.Identifier}` tried to open position\nBut `{previousSignal.Identifier}` already open for same direction");
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
return null;
|
|
}
|
|
else
|
|
{
|
|
if (Config.FlipPosition)
|
|
{
|
|
var isPositionInProfit = (openedPosition.ProfitAndLoss?.Realized ?? 0) > 0;
|
|
var shouldFlip = !Config.FlipOnlyWhenInProfit || isPositionInProfit;
|
|
|
|
if (shouldFlip)
|
|
{
|
|
var flipReason = Config.FlipOnlyWhenInProfit
|
|
? "current position is in profit"
|
|
: "FlipOnlyWhenInProfit is disabled";
|
|
|
|
await LogInformation(
|
|
$"🔄 Position Flip Initiated\nFlipping position due to opposite signal\nReason: {flipReason}");
|
|
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
|
|
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Flipped);
|
|
var newPosition = await OpenPosition(signal);
|
|
await LogInformation(
|
|
$"✅ Position Flipped\nPosition: `{previousSignal.Identifier}` → `{signal.Identifier}`\nPrice: `${lastPrice}`");
|
|
return newPosition;
|
|
}
|
|
else
|
|
{
|
|
var currentPnl = openedPosition.ProfitAndLoss?.Realized ?? 0;
|
|
await LogInformation(
|
|
$"💸 Flip Blocked - Not Profitable\nPosition `{previousSignal.Identifier}` PnL: `${currentPnl:F2}`\nSignal `{signal.Identifier}` will wait for profitability");
|
|
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
return null;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogInformation(
|
|
$"🚫 Flip Disabled\nPosition already open for: `{previousSignal.Identifier}`\nFlipping disabled, new signal expired");
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
bool canOpen = await CanOpenPosition(signal);
|
|
if (!canOpen)
|
|
{
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Verify actual balance before opening position
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
await VerifyAndUpdateBalance();
|
|
}
|
|
|
|
var command = new OpenPositionRequest(
|
|
Config.AccountName,
|
|
Config.MoneyManagement,
|
|
signal.Direction,
|
|
Config.Ticker,
|
|
PositionInitiator.Bot,
|
|
signal.Date,
|
|
Account.User,
|
|
Config.BotTradingBalance,
|
|
Config.IsForBacktest,
|
|
lastPrice,
|
|
signalIdentifier: signal.Identifier,
|
|
initiatorIdentifier: Identifier);
|
|
|
|
var position = await ServiceScopeHelpers
|
|
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
|
|
_scopeFactory,
|
|
async (exchangeService, accountService, tradingService) =>
|
|
{
|
|
return await new OpenPositionCommandHandler(exchangeService, accountService, tradingService)
|
|
.Handle(command);
|
|
});
|
|
|
|
if (position != null)
|
|
{
|
|
// Add position to internal collection before any status updates
|
|
Positions[position.Identifier] = position;
|
|
|
|
if (position.Open.Status != TradeStatus.Cancelled && position.Status != PositionStatus.Rejected)
|
|
{
|
|
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
|
|
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
|
|
async messengerService => { await messengerService.SendPosition(position); });
|
|
}
|
|
|
|
await LogDebug($"✅ Position requested successfully for signal: `{signal.Identifier}`");
|
|
|
|
await SendPositionToCopyTrading(position);
|
|
|
|
return position;
|
|
}
|
|
else
|
|
{
|
|
SentrySdk.CaptureMessage("Position rejected", SentryLevel.Error);
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Rejected);
|
|
position.Status = PositionStatus.Rejected;
|
|
await UpdatePositionDatabase(position);
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
return position;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
catch (InsufficientFundsException ex)
|
|
{
|
|
// Handle insufficient funds errors with user-friendly messaging
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
await LogWarning(ex.UserMessage);
|
|
|
|
// Log the technical details for debugging
|
|
Logger.LogError(ex, "Insufficient funds error for signal {SignalId}: {ErrorMessage}", signal.Identifier,
|
|
ex.Message);
|
|
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
|
|
SentrySdk.CaptureException(ex);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SendPositionToCopyTrading(Position position)
|
|
{
|
|
try
|
|
{
|
|
// Only send to copy trading stream if this is not a copy trading bot itself
|
|
if (Config.IsForCopyTrading || _streamProvider == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Create stream keyed by this bot's identifier for copy trading bots to subscribe to
|
|
var streamId = StreamId.Create("CopyTrading", Identifier);
|
|
var stream = _streamProvider.GetStream<Position>(streamId);
|
|
|
|
// Publish the position to the stream
|
|
await stream.OnNextAsync(position);
|
|
|
|
await LogDebug($"📡 Position {position.Identifier} sent to copy trading stream for bot {Identifier}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to send position {PositionId} to copy trading stream for bot {BotId}",
|
|
position.Identifier, Identifier);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a copy of a position from a master bot for copy trading
|
|
/// </summary>
|
|
public async Task CopyPositionFromMasterAsync(Position masterPosition)
|
|
{
|
|
try
|
|
{
|
|
// Create a copy signal based on the master position using the proper constructor
|
|
var copySignal = new LightSignal(
|
|
ticker: Config.Ticker,
|
|
direction: masterPosition.OriginDirection,
|
|
confidence: Confidence.Medium, // Default confidence for copy trading
|
|
candle: LastCandle ?? new Candle
|
|
{
|
|
Ticker = Config.Ticker,
|
|
Timeframe = Config.Timeframe,
|
|
Date = DateTime.UtcNow,
|
|
Open = masterPosition.Open.Price,
|
|
Close = masterPosition.Open.Price,
|
|
High = masterPosition.Open.Price,
|
|
Low = masterPosition.Open.Price,
|
|
Volume = 0
|
|
},
|
|
date: masterPosition.Open.Date,
|
|
exchange: TradingExchanges.GmxV2, // Default exchange
|
|
indicatorType: IndicatorType.Composite,
|
|
signalType: SignalType.Signal,
|
|
indicatorName: "CopyTrading"
|
|
);
|
|
|
|
// Override the identifier to include master position info
|
|
copySignal.Identifier = $"copy-{masterPosition.SignalIdentifier}-{Guid.NewGuid()}";
|
|
|
|
// Store the signal
|
|
Signals[copySignal.Identifier] = copySignal;
|
|
|
|
await LogInformation(
|
|
$"📋 Copy trading: Created copy signal {copySignal.Identifier} for master position {masterPosition.Identifier}");
|
|
|
|
// Attempt to open the position using the existing OpenPosition method
|
|
// This will handle all the position creation logic properly
|
|
await OpenPosition(copySignal);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to copy position {MasterPositionId} for bot {BotId}",
|
|
masterPosition.Identifier, Identifier);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
|
|
private async Task<bool> CanOpenPosition(LightSignal signal)
|
|
{
|
|
// Early return if we're in backtest mode and haven't executed yet
|
|
// TODO : check if its a startup cycle
|
|
if (!Config.IsForBacktest && ExecutionCount == 0)
|
|
{
|
|
await LogInformation("⏳ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet");
|
|
return false;
|
|
}
|
|
|
|
// Check if we're in backtest mode
|
|
if (Config.IsForBacktest)
|
|
{
|
|
return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal);
|
|
}
|
|
|
|
// Check broker positions for live trading
|
|
var canOpenPosition = await CheckBrokerPositions();
|
|
if (!canOpenPosition)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Synth-based pre-trade risk assessment
|
|
if (Config.UseSynthApi)
|
|
{
|
|
decimal currentPrice = 0;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
|
|
{
|
|
currentPrice = Config.IsForBacktest
|
|
? LastCandle?.Close ?? 0
|
|
: await exchangeService.GetCurrentPrice(Account, Config.Ticker);
|
|
});
|
|
|
|
|
|
bool synthRisk = false;
|
|
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory, async tradingService =>
|
|
{
|
|
synthRisk = await tradingService.AssessSynthPositionRiskAsync(Config.Ticker, signal.Direction,
|
|
currentPrice,
|
|
Config, Config.IsForBacktest);
|
|
});
|
|
if (!synthRisk)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check cooldown period and loss streak
|
|
return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal);
|
|
}
|
|
|
|
private async Task<bool> CheckLossStreak(LightSignal signal)
|
|
{
|
|
// If MaxLossStreak is 0, there's no limit
|
|
if (Config.MaxLossStreak <= 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Get the last N finished positions regardless of direction
|
|
var recentPositions = Positions
|
|
.Values
|
|
.Where(p => p.IsFinished())
|
|
.OrderByDescending(p => p.Open.Date)
|
|
.Take(Config.MaxLossStreak)
|
|
.ToList();
|
|
|
|
var canOpen = TradingBox.CheckLossStreak(recentPositions, Config.MaxLossStreak, signal.Direction);
|
|
|
|
if (!canOpen)
|
|
{
|
|
var lastPosition = recentPositions.First();
|
|
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 canOpen;
|
|
}
|
|
|
|
private async Task<bool> CheckBrokerPositions()
|
|
{
|
|
try
|
|
{
|
|
List<Position> positions = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService => { positions = [.. await exchangeService.GetBrokerPositions(Account)]; });
|
|
|
|
// Check if there's a position for this ticker on the broker
|
|
var brokerPositionForTicker = positions.FirstOrDefault(p => p.Ticker == Config.Ticker);
|
|
if (brokerPositionForTicker == null)
|
|
{
|
|
// No position on broker for this ticker, safe to open
|
|
return true;
|
|
}
|
|
|
|
// Handle existing position on broker
|
|
await LogDebug(
|
|
$"🔍 Broker Position Found\n" +
|
|
$"Ticker: {Config.Ticker}\n" +
|
|
$"Direction: {brokerPositionForTicker.OriginDirection}\n" +
|
|
$"Checking internal positions for synchronization...");
|
|
|
|
var previousPosition = Positions.Values.LastOrDefault();
|
|
List<Trade> orders = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
orders = [.. await exchangeService.GetOpenOrders(Account, Config.Ticker)];
|
|
});
|
|
|
|
var reason =
|
|
$"Cannot open position. There is already a position open for {Config.Ticker} on the broker (Direction: {brokerPositionForTicker.OriginDirection}).";
|
|
|
|
if (previousPosition != null)
|
|
{
|
|
// Check if this position matches the broker position
|
|
if (previousPosition.OriginDirection == brokerPositionForTicker.OriginDirection)
|
|
{
|
|
// Same direction - this is likely the same position
|
|
if (orders.Count >= 2)
|
|
{
|
|
Logger.LogInformation(
|
|
$"✅ Broker Position Matched with Internal Position\n" +
|
|
$"Position: {previousPosition.Identifier}\n" +
|
|
$"Direction: {previousPosition.OriginDirection}\n" +
|
|
$"Orders found: {orders.Count}\n" +
|
|
$"Setting status to Filled");
|
|
await SetPositionStatus(previousPosition.SignalIdentifier, PositionStatus.Filled);
|
|
}
|
|
else
|
|
{
|
|
// Position exists on broker but not enough orders - something is wrong
|
|
Logger.LogWarning(
|
|
$"⚠️ Incomplete Order Set\n" +
|
|
$"Position: {previousPosition.Identifier}\n" +
|
|
$"Direction: {previousPosition.OriginDirection}\n" +
|
|
$"Expected orders: ≥2, Found: {orders.Count}\n" +
|
|
$"This position may need manual intervention");
|
|
|
|
reason += $" Position exists on broker but only has {orders.Count} orders (expected ≥2).";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Different direction - possible flip scenario or orphaned position
|
|
Logger.LogWarning(
|
|
$"⚠️ Direction Mismatch Detected\n" +
|
|
$"Internal: {previousPosition.OriginDirection}\n" +
|
|
$"Broker: {brokerPositionForTicker.OriginDirection}\n" +
|
|
$"This could indicate a flipped position or orphaned broker position");
|
|
|
|
reason +=
|
|
$" Direction mismatch: Internal ({previousPosition.OriginDirection}) vs Broker ({brokerPositionForTicker.OriginDirection}).";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Broker has a position but we don't have any internal tracking
|
|
Logger.LogWarning(
|
|
$"⚠️ Orphaned Broker Position Detected\n" +
|
|
$"Broker has position for {Config.Ticker} ({brokerPositionForTicker.OriginDirection})\n" +
|
|
$"But no internal position found in bot tracking\n" +
|
|
$"This may require manual cleanup");
|
|
|
|
reason += " Position open on broker but no internal position tracked 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;
|
|
}
|
|
}
|
|
|
|
public async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice,
|
|
bool tradeClosingPosition = false, bool forceMarketClose = false)
|
|
{
|
|
await LogInformation(
|
|
$"🔧 Closing {position.OriginDirection} Trade\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n📋 Type: `{tradeToClose.TradeType}`\n📊 Quantity: `{tradeToClose.Quantity:F5}`");
|
|
|
|
decimal quantity = 0;
|
|
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
// TODO should also pass the direction to get quantity in correct position
|
|
quantity = await exchangeService.GetQuantityInPosition(Account, Config.Ticker);
|
|
});
|
|
}
|
|
|
|
// Get status of position before closing it. The position might be already close by the exchange
|
|
if (!Config.IsForBacktest && quantity == 0)
|
|
{
|
|
await LogDebug($"✅ Trade already closed on exchange for position: `{position.Identifier}`");
|
|
await HandleClosedPosition(position, forceMarketClose ? lastPrice : (decimal?)null, forceMarketClose);
|
|
}
|
|
else
|
|
{
|
|
var command = new ClosePositionCommand(position, position.AccountId, lastPrice,
|
|
isForBacktest: Config.IsForBacktest);
|
|
try
|
|
{
|
|
// Grace period: give the broker time to process any ongoing close operations
|
|
// Using ConfigureAwait(false) to ensure non-blocking operation
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
await Task.Delay(CLOSE_POSITION_GRACE_MS).ConfigureAwait(false);
|
|
}
|
|
|
|
Position closedPosition = null;
|
|
await ServiceScopeHelpers.WithScopedServices<IExchangeService, IAccountService, ITradingService>(
|
|
_scopeFactory, async (exchangeService, accountService, tradingService) =>
|
|
{
|
|
closedPosition =
|
|
await new ClosePositionCommandHandler(exchangeService, accountService, tradingService,
|
|
_scopeFactory)
|
|
.Handle(command);
|
|
});
|
|
|
|
if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped)
|
|
{
|
|
if (tradeClosingPosition)
|
|
{
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
|
|
}
|
|
|
|
await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : (decimal?)null,
|
|
forceMarketClose);
|
|
}
|
|
else
|
|
{
|
|
throw new Exception($"Wrong position status : {closedPosition.Status}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning($"Position {signal.Identifier} not closed : {ex.Message}");
|
|
|
|
if (position.Status == PositionStatus.Canceled || position.Status == PositionStatus.Rejected)
|
|
{
|
|
// Trade close on exchange => Should close trade manually
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
|
|
// Ensure trade dates are properly updated even for canceled/rejected positions
|
|
await HandleClosedPosition(position, forceMarketClose ? lastPrice : (decimal?)null,
|
|
forceMarketClose);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task HandleClosedPosition(Position position, decimal? forcedClosingPrice = null,
|
|
bool forceMarketClose = false)
|
|
{
|
|
if (Positions.ContainsKey(position.Identifier))
|
|
{
|
|
Candle currentCandle = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory, async exchangeService =>
|
|
{
|
|
currentCandle = Config.IsForBacktest
|
|
? LastCandle
|
|
: await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow);
|
|
});
|
|
|
|
// For live trading on GMX, fetch the actual position history to get real PnL data
|
|
if (!Config.IsForBacktest && !forceMarketClose)
|
|
{
|
|
try
|
|
{
|
|
await LogDebug(
|
|
$"🔍 Fetching Position History from GMX\nPosition: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
|
|
|
|
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
|
|
_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
// Get position history from the last 24 hours for better coverage
|
|
var fromDate = DateTime.UtcNow.AddHours(-24);
|
|
var toDate = DateTime.UtcNow;
|
|
return await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate);
|
|
});
|
|
|
|
// Find the matching position in history based on the most recent closed position with same direction
|
|
if (positionHistory != null && positionHistory.Any())
|
|
{
|
|
// Get the most recent closed position from GMX that matches the direction
|
|
var brokerPosition = positionHistory
|
|
.Where(p => p.OriginDirection == position.OriginDirection) // Ensure same direction
|
|
.OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue)
|
|
.FirstOrDefault();
|
|
|
|
if (brokerPosition != null && brokerPosition.ProfitAndLoss != null)
|
|
{
|
|
await LogDebug(
|
|
$"✅ Broker Position History Found\n" +
|
|
$"Position: `{position.Identifier}`\n" +
|
|
$"Realized PnL (after fees): `${brokerPosition.ProfitAndLoss.Realized:F2}`\n" +
|
|
$"Bot's UI Fees: `${position.UiFees:F2}`\n" +
|
|
$"Bot's Gas Fees: `${position.GasFees:F2}`");
|
|
|
|
// Use the actual GMX PnL data (this is already net of fees from GMX)
|
|
// We use this for reconciliation with the bot's own calculations
|
|
var closingVolume = brokerPosition.Open.Price * position.Open.Quantity *
|
|
position.Open.Leverage;
|
|
var totalBotFees = position.GasFees + position.UiFees +
|
|
TradingBox.CalculateClosingUiFees(closingVolume);
|
|
var gmxNetPnl = brokerPosition.ProfitAndLoss.Realized; // This is already after GMX fees
|
|
|
|
position.ProfitAndLoss = new ProfitAndLoss
|
|
{
|
|
// GMX's realized PnL is already after their fees
|
|
Realized = gmxNetPnl,
|
|
// For net, we keep it the same since GMX PnL is already net of their fees
|
|
Net = gmxNetPnl - totalBotFees
|
|
};
|
|
|
|
// Update the closing trade price if available
|
|
if (brokerPosition.Open != null)
|
|
{
|
|
var brokerClosingPrice = brokerPosition.Open.Price;
|
|
var isProfitable = position.OriginDirection == TradeDirection.Long
|
|
? position.Open.Price < brokerClosingPrice
|
|
: position.Open.Price > brokerClosingPrice;
|
|
|
|
if (isProfitable)
|
|
{
|
|
if (position.TakeProfit1 != null)
|
|
{
|
|
position.TakeProfit1.Price = brokerClosingPrice;
|
|
position.TakeProfit1.SetDate(brokerPosition.Open.Date);
|
|
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
}
|
|
|
|
// Cancel SL trade when TP is hit
|
|
if (position.StopLoss != null)
|
|
{
|
|
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (position.StopLoss != null)
|
|
{
|
|
position.StopLoss.Price = brokerClosingPrice;
|
|
position.StopLoss.SetDate(brokerPosition.Open.Date);
|
|
position.StopLoss.SetStatus(TradeStatus.Filled);
|
|
}
|
|
|
|
// Cancel TP trades when SL is hit
|
|
if (position.TakeProfit1 != null)
|
|
{
|
|
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
if (position.TakeProfit2 != null)
|
|
{
|
|
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
}
|
|
|
|
await LogDebug(
|
|
$"📊 Position Reconciliation Complete\n" +
|
|
$"Position: `{position.Identifier}`\n" +
|
|
$"Closing Price: `${brokerClosingPrice:F2}`\n" +
|
|
$"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" +
|
|
$"PnL from broker: `${position.ProfitAndLoss.Realized:F2}`");
|
|
}
|
|
|
|
// Skip the candle-based PnL calculation since we have actual GMX data
|
|
goto SkipCandleBasedCalculation;
|
|
}
|
|
else
|
|
{
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Logger.LogWarning(
|
|
$"⚠️ No GMX Position History Found\nPosition: `{position.Identifier}`\nFalling back to candle-based calculation");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex,
|
|
"Error fetching position history from GMX for position {PositionId}. Falling back to candle-based calculation.",
|
|
position.Identifier);
|
|
}
|
|
}
|
|
|
|
// Calculate P&L for backtests even if currentCandle is null
|
|
decimal closingPrice = 0;
|
|
bool pnlCalculated = false;
|
|
|
|
// If we are forcing a market close (e.g., time limit), use the provided closing price
|
|
if (forceMarketClose && forcedClosingPrice.HasValue)
|
|
{
|
|
closingPrice = forcedClosingPrice.Value;
|
|
|
|
bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long
|
|
? closingPrice > position.Open.Price
|
|
: closingPrice < position.Open.Price;
|
|
|
|
if (isManualCloseProfitable)
|
|
{
|
|
if (position.TakeProfit1 != null)
|
|
{
|
|
position.TakeProfit1.Price = closingPrice;
|
|
position.TakeProfit1.SetDate(currentCandle?.Date ?? DateTime.UtcNow);
|
|
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
}
|
|
|
|
if (position.StopLoss != null)
|
|
{
|
|
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (position.StopLoss != null)
|
|
{
|
|
position.StopLoss.Price = closingPrice;
|
|
position.StopLoss.SetDate(currentCandle?.Date ?? DateTime.UtcNow);
|
|
position.StopLoss.SetStatus(TradeStatus.Filled);
|
|
}
|
|
|
|
if (position.TakeProfit1 != null)
|
|
{
|
|
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
if (position.TakeProfit2 != null)
|
|
{
|
|
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
}
|
|
|
|
pnlCalculated = true;
|
|
}
|
|
|
|
if (currentCandle != null)
|
|
{
|
|
List<Candle> recentCandles = null;
|
|
|
|
if (Config.IsForBacktest)
|
|
{
|
|
recentCandles = LastCandle != null ? new List<Candle>() { LastCandle } : new List<Candle>();
|
|
}
|
|
else
|
|
{
|
|
// Use CandleStoreGrain to get recent candles instead of calling exchange service directly
|
|
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
|
|
{
|
|
var grainKey =
|
|
CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe);
|
|
var grain = grainFactory.GetGrain<ICandleStoreGrain>(grainKey);
|
|
|
|
try
|
|
{
|
|
recentCandles = await grain.GetLastCandle(5);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error retrieving recent candles from CandleStoreGrain for {GrainKey}",
|
|
grainKey);
|
|
recentCandles = new List<Candle>();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check if we have any candles before proceeding
|
|
if (recentCandles == null || !recentCandles.Any())
|
|
{
|
|
await LogWarning(
|
|
$"No recent candles available for position {position.Identifier}. Using current candle data instead.");
|
|
|
|
// Fallback to current candle if available
|
|
if (currentCandle != null)
|
|
{
|
|
recentCandles = new List<Candle> { currentCandle };
|
|
}
|
|
else
|
|
{
|
|
await LogWarning(
|
|
$"No candle data available for position {position.Identifier}. Cannot determine stop loss/take profit hit.");
|
|
Logger.LogError(
|
|
"No candle data available for position {PositionId}. Cannot determine stop loss/take profit hit.",
|
|
position.Identifier);
|
|
return;
|
|
}
|
|
}
|
|
|
|
var minPriceRecent = recentCandles.Min(c => c.Low);
|
|
var maxPriceRecent = recentCandles.Max(c => c.High);
|
|
|
|
bool wasStopLossHit = false;
|
|
bool wasTakeProfitHit = false;
|
|
|
|
if (position.OriginDirection == TradeDirection.Long)
|
|
{
|
|
wasStopLossHit = minPriceRecent <= position.StopLoss.Price;
|
|
wasTakeProfitHit = maxPriceRecent >= position.TakeProfit1.Price;
|
|
}
|
|
else
|
|
{
|
|
wasStopLossHit = maxPriceRecent >= position.StopLoss.Price;
|
|
wasTakeProfitHit = minPriceRecent <= position.TakeProfit1.Price;
|
|
}
|
|
|
|
if (wasStopLossHit)
|
|
{
|
|
// For backtesting: use the configured SL price to ensure consistent PnL per money management
|
|
// For live trading: use actual execution price to reflect real market conditions (slippage)
|
|
if (Config.IsForBacktest)
|
|
{
|
|
closingPrice = position.StopLoss.Price;
|
|
}
|
|
else
|
|
{
|
|
// Use actual execution price based on direction for live trading
|
|
closingPrice = position.OriginDirection == TradeDirection.Long
|
|
? minPriceRecent // For LONG, SL hits at the low
|
|
: maxPriceRecent; // For SHORT, SL hits at the high
|
|
|
|
position.StopLoss.Price = closingPrice;
|
|
}
|
|
|
|
position.StopLoss.SetDate(currentCandle.Date);
|
|
position.StopLoss.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel TP trades when SL is hit
|
|
if (position.TakeProfit1 != null)
|
|
{
|
|
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
if (position.TakeProfit2 != null)
|
|
{
|
|
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogDebug(
|
|
$"🛑 Stop Loss Execution Confirmed\n" +
|
|
$"Position: `{position.Identifier}`\n" +
|
|
$"Closing Price: `${closingPrice:F2}`\n" +
|
|
$"Configured SL: `${position.StopLoss.Price:F2}`\n" +
|
|
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`");
|
|
}
|
|
else if (wasTakeProfitHit)
|
|
{
|
|
// For backtesting: use the configured TP price to ensure consistent PnL per money management
|
|
// For live trading: use actual execution price to reflect real market conditions (slippage)
|
|
if (Config.IsForBacktest)
|
|
{
|
|
closingPrice = position.TakeProfit1.Price;
|
|
}
|
|
else
|
|
{
|
|
// Use actual execution price based on direction for live trading
|
|
closingPrice = position.OriginDirection == TradeDirection.Long
|
|
? maxPriceRecent // For LONG, TP hits at the high
|
|
: minPriceRecent; // FOR SHORT, TP hits at the low
|
|
|
|
position.TakeProfit1.Price = closingPrice;
|
|
}
|
|
|
|
position.TakeProfit1.SetDate(currentCandle.Date);
|
|
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel SL trade when TP is hit
|
|
if (position.StopLoss != null)
|
|
{
|
|
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
await LogDebug(
|
|
$"🎯 Take Profit Execution Confirmed\n" +
|
|
$"Position: `{position.Identifier}`\n" +
|
|
$"Closing Price: `${closingPrice:F2}`\n" +
|
|
$"Configured TP: `${position.TakeProfit1.Price:F2}`\n" +
|
|
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`");
|
|
}
|
|
else
|
|
{
|
|
closingPrice = Config.IsForBacktest
|
|
? currentCandle.Close
|
|
: 0;
|
|
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
closingPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker);
|
|
});
|
|
}
|
|
|
|
bool isManualCloseProfitable = position.OriginDirection == TradeDirection.Long
|
|
? closingPrice > position.Open.Price
|
|
: closingPrice < position.Open.Price;
|
|
|
|
if (isManualCloseProfitable)
|
|
{
|
|
position.TakeProfit1.SetPrice(closingPrice, 2);
|
|
position.TakeProfit1.SetDate(currentCandle.Date);
|
|
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel SL trade when TP is used for manual close
|
|
if (position.StopLoss != null)
|
|
{
|
|
position.StopLoss.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
position.StopLoss.SetPrice(closingPrice, 2);
|
|
position.StopLoss.SetDate(currentCandle.Date);
|
|
position.StopLoss.SetStatus(TradeStatus.Filled);
|
|
|
|
// Cancel TP trades when SL is used for manual close
|
|
if (position.TakeProfit1 != null)
|
|
{
|
|
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
|
|
if (position.TakeProfit2 != null)
|
|
{
|
|
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
|
|
}
|
|
}
|
|
|
|
await LogDebug(
|
|
$"✋ Manual/Exchange Close Detected\n" +
|
|
$"Position: `{position.Identifier}`\n" +
|
|
$"SL: `${position.StopLoss.Price:F2}` | TP: `${position.TakeProfit1.Price:F2}`\n" +
|
|
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`\n" +
|
|
$"Closing at market price: `${closingPrice:F2}`");
|
|
}
|
|
|
|
pnlCalculated = true;
|
|
}
|
|
else if (Config.IsForBacktest)
|
|
{
|
|
// For backtests when currentCandle is null, use a fallback closing price
|
|
// This ensures P&L calculation always happens for backtests
|
|
Logger.LogWarning(
|
|
$"⚠️ Backtest: No current candle available for position {position.Identifier}. Using fallback closing price calculation.");
|
|
|
|
// Use the position's stop loss or take profit price as closing price
|
|
if (position.StopLoss != null && position.StopLoss.Price > 0)
|
|
{
|
|
closingPrice = position.StopLoss.Price;
|
|
position.StopLoss.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else if (position.TakeProfit1 != null && position.TakeProfit1.Price > 0)
|
|
{
|
|
closingPrice = position.TakeProfit1.Price;
|
|
position.TakeProfit1.SetStatus(TradeStatus.Filled);
|
|
}
|
|
else
|
|
{
|
|
// Last resort: use entry price (no profit/loss)
|
|
closingPrice = position.Open.Price;
|
|
Logger.LogWarning(
|
|
$"⚠️ Backtest: Using entry price as closing price for position {position.Identifier}");
|
|
}
|
|
|
|
pnlCalculated = true;
|
|
}
|
|
|
|
// Calculate P&L if we have a closing price
|
|
if (pnlCalculated && closingPrice > 0)
|
|
{
|
|
var entryPrice = position.Open.Price;
|
|
var positionSize = TradingBox.CalculatePositionSize(position.Open.Quantity, position.Open.Leverage);
|
|
|
|
decimal pnl = TradingBox.CalculatePnL(entryPrice, closingPrice, position.Open.Quantity,
|
|
position.Open.Leverage, position.OriginDirection);
|
|
|
|
if (position.ProfitAndLoss == null)
|
|
{
|
|
var totalFees = position.GasFees + position.UiFees;
|
|
var netPnl = pnl - totalFees;
|
|
position.ProfitAndLoss = new ProfitAndLoss { Realized = pnl, Net = netPnl };
|
|
}
|
|
else if (position.ProfitAndLoss.Realized == 0 || position.ProfitAndLoss.Net == 0)
|
|
{
|
|
var totalFees = position.GasFees + position.UiFees;
|
|
var netPnl = pnl - totalFees;
|
|
position.ProfitAndLoss.Realized = pnl;
|
|
position.ProfitAndLoss.Net = netPnl;
|
|
}
|
|
|
|
// Enhanced logging for backtest debugging
|
|
var logMessage = $"💰 P&L Calculated for Position {position.Identifier}\n" +
|
|
$"Direction: `{position.OriginDirection}`\n" +
|
|
$"Entry Price: `${entryPrice:F2}` | Exit Price: `${closingPrice:F2}`\n" +
|
|
$"Position Size: `{position.Open.Quantity:F8}` | Leverage: `{position.Open.Leverage}x`\n" +
|
|
$"Position Value: `${positionSize:F8}`\n" +
|
|
$"Price Difference: `${TradingBox.CalculatePriceDifference(entryPrice, closingPrice, position.OriginDirection):F2}`\n" +
|
|
$"Realized P&L: `${pnl:F2}`\n" +
|
|
$"Gas Fees: `${position.GasFees:F2}` | UI Fees: `${position.UiFees:F2}`\n" +
|
|
$"Total Fees: `${position.GasFees + position.UiFees:F2}`\n" +
|
|
$"Net P&L (after fees): `${position.ProfitAndLoss.Net:F2}`";
|
|
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
await LogDebug(logMessage);
|
|
}
|
|
}
|
|
|
|
SkipCandleBasedCalculation:
|
|
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished);
|
|
|
|
// Update position in database with all trade changes
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
position.Status = PositionStatus.Finished;
|
|
await UpdatePositionDatabase(position);
|
|
|
|
// Only send PositionClosed notification if the position was actually filled
|
|
// Check if Open trade was filled (means position was opened on the broker)
|
|
if (position.Open?.Status == TradeStatus.Filled)
|
|
{
|
|
await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionClosed, position);
|
|
|
|
// Update the last position closing time for cooldown period tracking
|
|
// Only update if position was actually filled
|
|
LastPositionClosingTime = Config.IsForBacktest ? currentCandle.Date : DateTime.UtcNow;
|
|
}
|
|
else
|
|
{
|
|
await LogDebug(
|
|
$"Skipping PositionClosed notification for position {position.Identifier} - position was never filled (Open trade status: {position.Open?.Status})");
|
|
}
|
|
}
|
|
|
|
// Only update balance and log success if position was actually filled
|
|
if (position.Open?.Status == TradeStatus.Filled)
|
|
{
|
|
await LogDebug(
|
|
$"✅ Position Closed Successfully\nPosition: `{position.SignalIdentifier}`\nPnL: `${position.ProfitAndLoss?.Net:F2}`");
|
|
|
|
if (position.ProfitAndLoss != null)
|
|
{
|
|
// Update the current balance when position closes
|
|
_currentBalance += position.ProfitAndLoss.Net;
|
|
Config.BotTradingBalance += position.ProfitAndLoss.Net;
|
|
|
|
await LogDebug(
|
|
string.Format("💰 Balance Updated\nNew bot trading balance: `${0:F2}`",
|
|
Config.BotTradingBalance));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogDebug(
|
|
$"✅ Position Cleanup\nPosition: `{position.SignalIdentifier}` was never filled - no balance or PnL changes");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogWarning("Weird things happen - Trying to update position status, but no position found");
|
|
}
|
|
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
|
|
async messengerService => { await messengerService.SendClosedPosition(position, Account.User); });
|
|
}
|
|
|
|
await CancelAllOrders();
|
|
}
|
|
|
|
private async Task CancelAllOrders()
|
|
{
|
|
if (!Config.IsForBacktest && !Config.IsForWatchingOnly)
|
|
{
|
|
try
|
|
{
|
|
List<Trade> openOrders = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
openOrders = (await exchangeService.GetOpenOrders(Account, Config.Ticker)).ToList();
|
|
});
|
|
if (openOrders.Any())
|
|
{
|
|
List<Position> openPositions = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
openPositions = (await exchangeService.GetBrokerPositions(Account))
|
|
.Where(p => p.Ticker == Config.Ticker).ToList();
|
|
});
|
|
var cancelClose = openPositions.Any();
|
|
|
|
if (cancelClose)
|
|
{
|
|
await LogDebug($"Position still open, cancel close orders");
|
|
}
|
|
else
|
|
{
|
|
await LogDebug($"Canceling all orders for {Config.Ticker}");
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
await exchangeService.CancelOrder(Account, Config.Ticker);
|
|
var closePendingOrderStatus = await exchangeService.CancelOrder(Account, Config.Ticker);
|
|
await LogDebug(
|
|
$"Closing all {Config.Ticker} orders status : {closePendingOrderStatus}");
|
|
});
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogDebug($"No need to cancel orders for {Config.Ticker}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error during cancelOrders");
|
|
SentrySdk.CaptureException(ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SetPositionStatus(string signalIdentifier, PositionStatus positionStatus)
|
|
{
|
|
try
|
|
{
|
|
var position = Positions.Values.First(p => p.SignalIdentifier == signalIdentifier);
|
|
|
|
if (positionStatus.Equals(PositionStatus.Canceled | PositionStatus.Rejected))
|
|
{
|
|
var stackTrace = new StackTrace(true);
|
|
var callingMethod = stackTrace.GetFrame(1)?.GetMethod();
|
|
var callingMethodName = callingMethod?.DeclaringType?.Name + "." + callingMethod?.Name;
|
|
|
|
var exception =
|
|
new InvalidOperationException(
|
|
$"Position {signalIdentifier} is already canceled for User {Account.User.Name}");
|
|
exception.Data["SignalIdentifier"] = signalIdentifier;
|
|
exception.Data["PositionId"] = position.Identifier;
|
|
exception.Data["CurrentStatus"] = position.Status.ToString();
|
|
exception.Data["RequestedStatus"] = positionStatus.ToString();
|
|
exception.Data["AccountName"] = Account.Name;
|
|
exception.Data["BotName"] = Config.Name;
|
|
exception.Data["CallingMethod"] = callingMethodName;
|
|
exception.Data["CallStack"] = Environment.StackTrace;
|
|
SentrySdk.CaptureException(exception);
|
|
}
|
|
|
|
if (!position.Status.Equals(positionStatus))
|
|
{
|
|
Positions.Values.First(p => p.SignalIdentifier == signalIdentifier).Status = positionStatus;
|
|
await LogInformation(
|
|
$"📊 Position Status Change\nPosition: {position.OriginDirection} {position.Ticker}\nNew Status: `{positionStatus}`");
|
|
|
|
// Update Open trade status when position becomes Filled
|
|
if (positionStatus == PositionStatus.Filled)
|
|
{
|
|
position.Open.SetStatus(TradeStatus.Filled);
|
|
}
|
|
}
|
|
|
|
SetSignalStatus(signalIdentifier,
|
|
positionStatus == PositionStatus.Filled ? SignalStatus.PositionOpen : SignalStatus.Expired);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning(
|
|
$"Failed to update position status for signal {signalIdentifier}: {ex.Message} {ex.StackTrace}");
|
|
SentrySdk.CaptureException(ex);
|
|
}
|
|
}
|
|
|
|
private void UpdatePositionPnl(Guid identifier, decimal realized)
|
|
{
|
|
var position = Positions[identifier];
|
|
var totalFees = position.GasFees + position.UiFees;
|
|
var netPnl = realized - totalFees;
|
|
|
|
if (position.ProfitAndLoss == null)
|
|
{
|
|
position.ProfitAndLoss = new ProfitAndLoss()
|
|
{
|
|
Realized = realized,
|
|
Net = netPnl
|
|
};
|
|
}
|
|
else
|
|
{
|
|
position.ProfitAndLoss.Realized = realized;
|
|
position.ProfitAndLoss.Net = netPnl;
|
|
}
|
|
}
|
|
|
|
private void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus)
|
|
{
|
|
if (Signals.ContainsKey(signalIdentifier) && Signals[signalIdentifier].Status != signalStatus)
|
|
{
|
|
Signals[signalIdentifier].Status = signalStatus;
|
|
Logger.LogDebug($"Signal {signalIdentifier} is now {signalStatus}");
|
|
}
|
|
}
|
|
|
|
|
|
public async Task ToggleIsForWatchOnly()
|
|
{
|
|
Config.IsForWatchingOnly = !Config.IsForWatchingOnly;
|
|
await LogInformation(
|
|
$"🔄 Watch Mode Toggle\nBot: `{Config.Name}`\nWatch Only: `{(Config.IsForWatchingOnly ? "ON" : "OFF")}`");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles bot stopping and notifies platform summary
|
|
/// </summary>
|
|
public async Task StopBot(string reason = null)
|
|
{
|
|
await LogInformation(
|
|
$"🛑 Bot Stopped\nBot: `{Config.Name}`\nTicker: `{Config.Ticker}`\nReason: `{reason ?? "No reason provided"}`");
|
|
}
|
|
|
|
public async Task LogInformation(string message)
|
|
{
|
|
if (Config.IsForBacktest)
|
|
return;
|
|
|
|
Logger.LogInformation(message);
|
|
|
|
try
|
|
{
|
|
await SendTradeMessage(message);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine(e);
|
|
}
|
|
}
|
|
|
|
public async Task LogWarning(string message)
|
|
{
|
|
if (Config.IsForBacktest)
|
|
return;
|
|
|
|
message = $"[{Config.Name}] {message}";
|
|
|
|
try
|
|
{
|
|
await SendTradeMessage(message, true);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine(e);
|
|
}
|
|
}
|
|
|
|
public async Task LogDebug(string message)
|
|
{
|
|
if (Config.IsForBacktest)
|
|
return;
|
|
|
|
Logger.LogDebug(message);
|
|
|
|
try
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
|
|
async messengerService =>
|
|
{
|
|
await messengerService.SendDebugMessage($"🤖 {Account.User.AgentName} - {Config.Name}\n{message}");
|
|
});
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine(e);
|
|
}
|
|
}
|
|
|
|
private async Task SendTradeMessage(string message, bool isBadBehavior = false)
|
|
{
|
|
if (!Config.IsForBacktest)
|
|
{
|
|
var user = Account.User;
|
|
var messageWithBotName = $"🤖 {user.AgentName} - {Config.Name}\n{message}";
|
|
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
|
|
async messengerService =>
|
|
{
|
|
await messengerService.SendTradeMessage(messageWithBotName, isBadBehavior, user);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Manually opens a position using the bot's settings and a generated signal.
|
|
/// Relies on the bot's MoneyManagement for Stop Loss and Take Profit placement.
|
|
/// </summary>
|
|
/// <param name="direction">The direction of the trade (Long/Short).</param>
|
|
/// <returns>The created Position object.</returns>
|
|
/// <exception cref="Exception">Throws if no candles are available or position opening fails.</exception>
|
|
public async Task<LightSignal> CreateManualSignal(TradeDirection direction)
|
|
{
|
|
if (LastCandle == null)
|
|
{
|
|
throw new Exception("No candles available to open position");
|
|
}
|
|
|
|
// Create a fake signal for manual position opening
|
|
var signal = new LightSignal(Config.Ticker, direction, Confidence.Low, LastCandle, LastCandle.Date,
|
|
TradingExchanges.GmxV2,
|
|
IndicatorType.Stc, SignalType.Signal, "Manual Signal");
|
|
signal.Status = SignalStatus.WaitingForPosition; // Ensure status is correct
|
|
signal.Identifier =
|
|
signal.Identifier + "-manual" + Guid.NewGuid(); // Ensure unique identifier for manual signals
|
|
|
|
// Add the signal to our collection
|
|
await AddSignal(signal);
|
|
await ManagePositions();
|
|
|
|
return signal;
|
|
}
|
|
|
|
public async Task AddSignal(LightSignal signal)
|
|
{
|
|
try
|
|
{
|
|
// Set signal status based on configuration
|
|
if (Config.IsForWatchingOnly || (ExecutionCount < 1 && !Config.IsForBacktest))
|
|
{
|
|
signal.Status = SignalStatus.Expired;
|
|
}
|
|
|
|
var indicatorNames = Config.Scenario.Indicators.Select(i => i.Type.ToString()).ToList();
|
|
var signalText = $"🎯 New Trading Signal\n\n" +
|
|
$"📊 Signal Details:\n" +
|
|
$"📈 Action: `{signal.Direction}` {Config.Ticker}\n" +
|
|
$"⏰ Timeframe: `{Config.Timeframe}`\n" +
|
|
$"🎯 Confidence: `{signal.Confidence}`\n" +
|
|
$"🔍 Indicators: `{string.Join(", ", indicatorNames)}`\n" +
|
|
$"🆔 Signal ID: `{signal.Identifier}`";
|
|
|
|
// Apply Synth-based signal filtering if enabled
|
|
if (Config.UseSynthApi && !Config.IsForBacktest && ExecutionCount > 0)
|
|
{
|
|
await ServiceScopeHelpers.WithScopedServices<ITradingService, IExchangeService>(_scopeFactory,
|
|
async (tradingService, exchangeService) =>
|
|
{
|
|
var currentPrice = await exchangeService.GetCurrentPrice(Account, Config.Ticker);
|
|
|
|
var signalValidationResult = await tradingService.ValidateSynthSignalAsync(
|
|
signal,
|
|
currentPrice,
|
|
Config,
|
|
Config.IsForBacktest);
|
|
|
|
if (signalValidationResult.Confidence == Confidence.None ||
|
|
signalValidationResult.Confidence == Confidence.Low ||
|
|
signalValidationResult.IsBlocked)
|
|
{
|
|
signal.Status = SignalStatus.Expired;
|
|
await LogDebug($"Signal {signal.Identifier} blocked by Synth risk assessment");
|
|
}
|
|
else
|
|
{
|
|
signal.Confidence = signalValidationResult.Confidence;
|
|
await LogDebug(
|
|
$"Signal {signal.Identifier} passed Synth risk assessment with confidence {signalValidationResult.Confidence}");
|
|
}
|
|
});
|
|
}
|
|
|
|
Signals.Add(signal.Identifier, signal);
|
|
|
|
await LogInformation(signalText);
|
|
|
|
if (Config.IsForWatchingOnly && !Config.IsForBacktest && ExecutionCount > 0)
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory, async messengerService =>
|
|
{
|
|
await messengerService.SendSignal(signalText, Account.Exchange, Config.Ticker, signal.Direction,
|
|
Config.Timeframe);
|
|
});
|
|
}
|
|
|
|
await LogDebug(
|
|
$"Processed signal for {Config.Ticker}: {signal.Direction} with status {signal.Status}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to add signal for {Ticker}", Config.Ticker);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a position has exceeded the maximum time limit for being open.
|
|
/// </summary>
|
|
/// <param name="position">The position to check</param>
|
|
/// <param name="currentTime">The current time to compare against</param>
|
|
/// <returns>True if the position has exceeded the time limit, false otherwise</returns>
|
|
/// <summary>
|
|
/// Updates the trading bot configuration with new settings.
|
|
/// This method validates the new configuration and applies it to the running bot.
|
|
/// </summary>
|
|
/// <param name="newConfig">The new configuration to apply</param>
|
|
/// <returns>True if the configuration was successfully updated, false otherwise</returns>
|
|
/// <exception cref="ArgumentException">Thrown when the new configuration is invalid</exception>
|
|
public async Task<bool> UpdateConfiguration(TradingBotConfig newConfig)
|
|
{
|
|
try
|
|
{
|
|
// Validate the new configuration
|
|
if (newConfig == null)
|
|
{
|
|
throw new ArgumentException("Configuration cannot be null");
|
|
}
|
|
|
|
if (newConfig.BotTradingBalance <= Constants.GMX.Config.MinimumPositionAmount)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Bot trading balance must be greater than {Constants.GMX.Config.MinimumPositionAmount}");
|
|
}
|
|
|
|
// if (string.IsNullOrEmpty(newConfig.AccountName))
|
|
// {
|
|
// throw new ArgumentException("Account name cannot be null or empty");
|
|
// }
|
|
|
|
if (newConfig.Scenario == null)
|
|
{
|
|
throw new ArgumentException("Scenario object must be provided in configuration");
|
|
}
|
|
|
|
// Track changes for logging
|
|
var changes = new List<string>();
|
|
|
|
// Check for changes and build change list
|
|
if (Config.BotTradingBalance != newConfig.BotTradingBalance)
|
|
{
|
|
changes.Add($"💰 Balance: ${Config.BotTradingBalance:F2} → ${newConfig.BotTradingBalance:F2}");
|
|
}
|
|
|
|
if (Config.MaxPositionTimeHours != newConfig.MaxPositionTimeHours)
|
|
{
|
|
var oldTime = Config.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled";
|
|
var newTime = newConfig.MaxPositionTimeHours?.ToString() + "h" ?? "Disabled";
|
|
changes.Add($"⏱️ Max Time: {oldTime} → {newTime}");
|
|
}
|
|
|
|
if (Config.FlipOnlyWhenInProfit != newConfig.FlipOnlyWhenInProfit)
|
|
{
|
|
var oldFlip = Config.FlipOnlyWhenInProfit ? "✅" : "❌";
|
|
var newFlip = newConfig.FlipOnlyWhenInProfit ? "✅" : "❌";
|
|
changes.Add($"📈 Flip Only in Profit: {oldFlip} → {newFlip}");
|
|
}
|
|
|
|
if (Config.CooldownPeriod != newConfig.CooldownPeriod)
|
|
{
|
|
changes.Add($"⏳ Cooldown: {Config.CooldownPeriod} → {newConfig.CooldownPeriod} candles");
|
|
}
|
|
|
|
if (Config.MaxLossStreak != newConfig.MaxLossStreak)
|
|
{
|
|
changes.Add($"📉 Max Loss Streak: {Config.MaxLossStreak} → {newConfig.MaxLossStreak}");
|
|
}
|
|
|
|
if (Config.FlipPosition != newConfig.FlipPosition)
|
|
{
|
|
var oldFlipPos = Config.FlipPosition ? "✅" : "❌";
|
|
var newFlipPos = newConfig.FlipPosition ? "✅" : "❌";
|
|
changes.Add($"🔄 Flip Position: {oldFlipPos} → {newFlipPos}");
|
|
}
|
|
|
|
if (Config.CloseEarlyWhenProfitable != newConfig.CloseEarlyWhenProfitable)
|
|
{
|
|
var oldCloseEarly = Config.CloseEarlyWhenProfitable ? "✅" : "❌";
|
|
var newCloseEarly = newConfig.CloseEarlyWhenProfitable ? "✅" : "❌";
|
|
changes.Add($"⏰ Close Early When Profitable: {oldCloseEarly} → {newCloseEarly}");
|
|
}
|
|
|
|
if (Config.UseSynthApi != newConfig.UseSynthApi)
|
|
{
|
|
var oldSynth = Config.UseSynthApi ? "✅" : "❌";
|
|
var newSynth = newConfig.UseSynthApi ? "✅" : "❌";
|
|
changes.Add($"🔗 Use Synth API: {oldSynth} → {newSynth}");
|
|
}
|
|
|
|
if (Config.UseForPositionSizing != newConfig.UseForPositionSizing)
|
|
{
|
|
var oldPositionSizing = Config.UseForPositionSizing ? "✅" : "❌";
|
|
var newPositionSizing = newConfig.UseForPositionSizing ? "✅" : "❌";
|
|
changes.Add($"📏 Use Synth for Position Sizing: {oldPositionSizing} → {newPositionSizing}");
|
|
}
|
|
|
|
if (Config.UseForSignalFiltering != newConfig.UseForSignalFiltering)
|
|
{
|
|
var oldSignalFiltering = Config.UseForSignalFiltering ? "✅" : "❌";
|
|
var newSignalFiltering = newConfig.UseForSignalFiltering ? "✅" : "❌";
|
|
changes.Add($"🔍 Use Synth for Signal Filtering: {oldSignalFiltering} → {newSignalFiltering}");
|
|
}
|
|
|
|
if (Config.UseForDynamicStopLoss != newConfig.UseForDynamicStopLoss)
|
|
{
|
|
var oldDynamicStopLoss = Config.UseForDynamicStopLoss ? "✅" : "❌";
|
|
var newDynamicStopLoss = newConfig.UseForDynamicStopLoss ? "✅" : "❌";
|
|
changes.Add($"🎯 Use Synth for Dynamic Stop Loss: {oldDynamicStopLoss} → {newDynamicStopLoss}");
|
|
}
|
|
|
|
if (Config.IsForWatchingOnly != newConfig.IsForWatchingOnly)
|
|
{
|
|
var oldWatch = Config.IsForWatchingOnly ? "✅" : "❌";
|
|
var newWatch = newConfig.IsForWatchingOnly ? "✅" : "❌";
|
|
changes.Add($"👀 Watch Only: {oldWatch} → {newWatch}");
|
|
}
|
|
|
|
// Check for changes in individual MoneyManagement properties
|
|
if (Config.MoneyManagement?.StopLoss != newConfig.MoneyManagement?.StopLoss)
|
|
{
|
|
var oldStopLoss = Config.MoneyManagement?.StopLoss.ToString("P2") ?? "None";
|
|
var newStopLoss = newConfig.MoneyManagement?.StopLoss.ToString("P2") ?? "None";
|
|
changes.Add($"🛑 Stop Loss: {oldStopLoss} → {newStopLoss}");
|
|
}
|
|
|
|
if (Config.MoneyManagement?.TakeProfit != newConfig.MoneyManagement?.TakeProfit)
|
|
{
|
|
var oldTakeProfit = Config.MoneyManagement?.TakeProfit.ToString("P2") ?? "None";
|
|
var newTakeProfit = newConfig.MoneyManagement?.TakeProfit.ToString("P2") ?? "None";
|
|
changes.Add($"🎯 Take Profit: {oldTakeProfit} → {newTakeProfit}");
|
|
}
|
|
|
|
if (Config.MoneyManagement?.Leverage != newConfig.MoneyManagement?.Leverage)
|
|
{
|
|
var oldLeverage = Config.MoneyManagement?.Leverage.ToString("F1") + "x" ?? "None";
|
|
var newLeverage = newConfig.MoneyManagement?.Leverage.ToString("F1") + "x" ?? "None";
|
|
changes.Add($"⚡ Leverage: {oldLeverage} → {newLeverage}");
|
|
}
|
|
|
|
if (Config.RiskManagement != newConfig.RiskManagement)
|
|
{
|
|
// Compare risk management by serializing (complex object comparison)
|
|
var oldRiskSerialized = JsonConvert.SerializeObject(Config.RiskManagement, Formatting.None);
|
|
var newRiskSerialized = JsonConvert.SerializeObject(newConfig.RiskManagement, Formatting.None);
|
|
|
|
if (oldRiskSerialized != newRiskSerialized)
|
|
{
|
|
changes.Add($"⚠️ Risk Management: Configuration Updated");
|
|
}
|
|
}
|
|
|
|
if (Config.ScenarioName != newConfig.ScenarioName)
|
|
{
|
|
changes.Add($"📋 Scenario Name: {Config.ScenarioName ?? "None"} → {newConfig.ScenarioName ?? "None"}");
|
|
}
|
|
|
|
if (Config.Name != newConfig.Name)
|
|
{
|
|
changes.Add($"🏷️ Name: {Config.Name} → {newConfig.Name}");
|
|
}
|
|
|
|
// if (Config.AccountName != newConfig.AccountName)
|
|
// {
|
|
// changes.Add($"👤 Account: {Config.AccountName} → {newConfig.AccountName}");
|
|
// }
|
|
|
|
if (Config.Ticker != newConfig.Ticker)
|
|
{
|
|
changes.Add($"📊 Ticker: {Config.Ticker} → {newConfig.Ticker}");
|
|
}
|
|
|
|
if (Config.Timeframe != newConfig.Timeframe)
|
|
{
|
|
changes.Add($"📈 Timeframe: {Config.Timeframe} → {newConfig.Timeframe}");
|
|
}
|
|
|
|
// Check if the actual Scenario object changed (not just the name)
|
|
var scenarioChanged = false;
|
|
if (Config.Scenario != newConfig.Scenario)
|
|
{
|
|
var oldScenarioSerialized = JsonConvert.SerializeObject(Config.Scenario, Formatting.None);
|
|
var newScenarioSerialized = JsonConvert.SerializeObject(newConfig.Scenario, Formatting.None);
|
|
|
|
if (oldScenarioSerialized != newScenarioSerialized)
|
|
{
|
|
scenarioChanged = true;
|
|
changes.Add(
|
|
$"🎯 Scenario: {Config.Scenario?.Name ?? "None"} → {newConfig.Scenario?.Name ?? "None"}");
|
|
}
|
|
}
|
|
|
|
// Protect critical properties that shouldn't change for running bots
|
|
var protectedIsForBacktest = Config.IsForBacktest;
|
|
|
|
newConfig.AccountName = Config.AccountName;
|
|
|
|
// Update the configuration
|
|
Config = newConfig;
|
|
|
|
// Restore protected properties
|
|
Config.IsForBacktest = protectedIsForBacktest;
|
|
|
|
// Update bot name and identifier if allowed
|
|
if (!string.IsNullOrEmpty(newConfig.Name))
|
|
{
|
|
Config.Name = newConfig.Name;
|
|
}
|
|
|
|
// If account changed, reload it
|
|
if (Config.AccountName != Account?.Name)
|
|
{
|
|
await LoadAccount();
|
|
}
|
|
|
|
// If scenario changed, reload it and track indicator changes
|
|
if (scenarioChanged)
|
|
{
|
|
if (newConfig.Scenario != null)
|
|
{
|
|
// Compare indicators after scenario change
|
|
var newIndicators = newConfig.Scenario.Indicators?.ToList() ?? new List<LightIndicator>();
|
|
var indicatorChanges = ScenarioHelpers.CompareIndicators(Config.Scenario.Indicators, newIndicators);
|
|
|
|
if (indicatorChanges.Any())
|
|
{
|
|
changes.AddRange(indicatorChanges);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw new ArgumentException("New scenario object must be provided when updating configuration.");
|
|
}
|
|
}
|
|
|
|
// Only log if there are actual changes
|
|
if (changes.Any())
|
|
{
|
|
var changeMessage = "⚙️ Configuration Updated\n" + string.Join("\n", changes);
|
|
await LogInformation(changeMessage);
|
|
}
|
|
else
|
|
{
|
|
await LogInformation(
|
|
"⚙️ Configuration Update\n✅ No changes detected - configuration already up to date");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await LogWarning($"Failed to update bot configuration: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current trading bot configuration.
|
|
/// </summary>
|
|
/// <returns>A copy of the current configuration</returns>
|
|
public TradingBotConfig GetConfiguration()
|
|
{
|
|
return new TradingBotConfig
|
|
{
|
|
AccountName = Config.AccountName,
|
|
MoneyManagement = Config.MoneyManagement,
|
|
Ticker = Config.Ticker,
|
|
ScenarioName = Config.ScenarioName,
|
|
Scenario = Config.Scenario,
|
|
Timeframe = Config.Timeframe,
|
|
IsForWatchingOnly = Config.IsForWatchingOnly,
|
|
BotTradingBalance = Config.BotTradingBalance,
|
|
IsForBacktest = Config.IsForBacktest,
|
|
CooldownPeriod = Config.CooldownPeriod,
|
|
MaxLossStreak = Config.MaxLossStreak,
|
|
MaxPositionTimeHours = Config.MaxPositionTimeHours,
|
|
FlipOnlyWhenInProfit = Config.FlipOnlyWhenInProfit,
|
|
FlipPosition = Config.FlipPosition,
|
|
Name = Config.Name,
|
|
CloseEarlyWhenProfitable = Config.CloseEarlyWhenProfitable,
|
|
UseSynthApi = Config.UseSynthApi,
|
|
UseForPositionSizing = Config.UseForPositionSizing,
|
|
UseForSignalFiltering = Config.UseForSignalFiltering,
|
|
UseForDynamicStopLoss = Config.UseForDynamicStopLoss,
|
|
RiskManagement = Config.RiskManagement,
|
|
IsForCopyTrading = Config.IsForCopyTrading,
|
|
MasterBotIdentifier = Config.MasterBotIdentifier,
|
|
MasterBotUserId = Config.MasterBotUserId,
|
|
};
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Checks if the bot is currently in a cooldown period for any direction.
|
|
/// </summary>
|
|
/// <returns>True if in cooldown period for any direction, false otherwise</returns>
|
|
private async Task<bool> IsInCooldownPeriodAsync()
|
|
{
|
|
if (LastPositionClosingTime == null)
|
|
{
|
|
return false; // No previous position closing time, no cooldown
|
|
}
|
|
|
|
// Force refresh last candle if it's null
|
|
if (LastCandle == null)
|
|
{
|
|
await ForceRefreshLastCandleAsync();
|
|
if (LastCandle == null)
|
|
{
|
|
Logger.LogWarning("Unable to refresh last candle, skipping cooldown check");
|
|
return false; // No last candle available, no cooldown check possible
|
|
}
|
|
}
|
|
|
|
// Calculate cooldown end time based on last position closing time
|
|
var cooldownEndTime =
|
|
TradingBox.CalculateCooldownEndTime(LastPositionClosingTime.Value, Config.Timeframe, Config.CooldownPeriod);
|
|
var isInCooldown = (Config.IsForBacktest ? LastCandle.Date : DateTime.UtcNow) < cooldownEndTime;
|
|
|
|
if (isInCooldown)
|
|
{
|
|
var remainingTime = cooldownEndTime - LastCandle.Date;
|
|
|
|
Logger.LogWarning(
|
|
$"⏳ [{Account.User.AgentName}-{Config.Name}] Cooldown Period Active\n" +
|
|
$"Cannot open new positions\n" +
|
|
$"Last position closed: `{LastPositionClosingTime:HH:mm:ss}`\n" +
|
|
$"Cooldown period: `{Config.CooldownPeriod}` candles\n" +
|
|
$"Cooldown ends: `{cooldownEndTime:HH:mm:ss}`\n" +
|
|
$"Remaining time: `{remainingTime.TotalMinutes:F1} minutes`");
|
|
}
|
|
|
|
return isInCooldown;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Forces a refresh of the last candle by calling the CandleStoreGrain
|
|
/// </summary>
|
|
private async Task ForceRefreshLastCandleAsync()
|
|
{
|
|
try
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
|
|
{
|
|
var grainKey = CandleHelpers.GetCandleStoreGrainKey(Account.Exchange, Config.Ticker, Config.Timeframe);
|
|
var grain = grainFactory.GetGrain<ICandleStoreGrain>(grainKey);
|
|
|
|
var lastCandles = await grain.GetLastCandle(1);
|
|
LastCandle = lastCandles.FirstOrDefault();
|
|
|
|
if (LastCandle != null)
|
|
{
|
|
await LogDebug($"Successfully refreshed last candle for {Config.Ticker} at {LastCandle.Date}");
|
|
}
|
|
else
|
|
{
|
|
Logger.LogWarning("No candles available from CandleStoreGrain for {Ticker}", Config.Ticker);
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error refreshing last candle for {Ticker}", Config.Ticker);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notifies both AgentGrain and PlatformSummaryGrain about bot events using unified event data
|
|
/// </summary>
|
|
/// <param name="eventType">The type of event (e.g., PositionOpened, PositionClosed, PositionUpdated)</param>
|
|
/// <param name="position">Optional position data for platform summary events</param>
|
|
private async Task NotifyAgentAndPlatformGrainAsync(NotificationEventType eventType,
|
|
Position position)
|
|
{
|
|
if (Config.IsForBacktest)
|
|
{
|
|
return; // Skip notifications for backtest
|
|
}
|
|
|
|
try
|
|
{
|
|
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
|
|
{
|
|
var agentGrain = grainFactory.GetGrain<IAgentGrain>(Account.User.Id);
|
|
var platformGrain = grainFactory.GetGrain<IPlatformSummaryGrain>("platform-summary");
|
|
|
|
// Create unified event objects based on event type
|
|
switch (eventType)
|
|
{
|
|
case NotificationEventType.PositionOpened:
|
|
var positionOpenEvent = new PositionOpenEvent
|
|
{
|
|
PositionIdentifier = position.Identifier,
|
|
Ticker = position.Ticker,
|
|
Volume = position.Open.Price * position.Open.Quantity * position.Open.Leverage,
|
|
Fee = position.GasFees + position.UiFees,
|
|
Direction = position.OriginDirection
|
|
};
|
|
|
|
await agentGrain.OnPositionOpenedAsync(positionOpenEvent);
|
|
await platformGrain.OnPositionOpenAsync(positionOpenEvent);
|
|
|
|
await LogDebug($"Sent position opened event to both grains for position {position.Identifier}");
|
|
break;
|
|
|
|
case NotificationEventType.PositionClosed:
|
|
var positionClosedEvent = new PositionClosedEvent
|
|
{
|
|
PositionIdentifier = position.Identifier,
|
|
Ticker = position.Ticker,
|
|
RealizedPnL = position.ProfitAndLoss?.Realized ?? 0,
|
|
Volume = position.Open.Price * position.Open.Quantity * position.Open.Leverage,
|
|
};
|
|
|
|
await agentGrain.OnPositionClosedAsync(positionClosedEvent);
|
|
await platformGrain.OnPositionClosedAsync(positionClosedEvent);
|
|
|
|
await LogDebug($"Sent position closed event to both grains for position {position.Identifier}");
|
|
break;
|
|
|
|
case NotificationEventType.PositionUpdated:
|
|
var positionUpdatedEvent = new PositionUpdatedEvent
|
|
{
|
|
PositionIdentifier = position.Identifier,
|
|
};
|
|
|
|
await agentGrain.OnPositionUpdatedAsync(positionUpdatedEvent);
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to send notifications: {EventType} for bot {BotId}", eventType, Identifier);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a position exists in the exchange history with PnL data.
|
|
/// This helps determine if a position was actually filled and closed on the exchange
|
|
/// even if the bot's internal tracking shows it as never filled.
|
|
/// </summary>
|
|
/// <param name="position">The position to check</param>
|
|
/// <returns>True if position found in exchange history with PnL, false otherwise</returns>
|
|
private async Task<(bool found, bool hadError)> CheckPositionInExchangeHistory(Position position)
|
|
{
|
|
if (Config.IsForBacktest)
|
|
{
|
|
// For backtests, we don't have exchange history, so return false
|
|
return (false, false);
|
|
}
|
|
|
|
try
|
|
{
|
|
await LogDebug(
|
|
$"🔍 Checking Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
|
|
|
|
List<Position> positionHistory = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
// Get position history from the last 24 hours for comprehensive check
|
|
var fromDate = DateTime.UtcNow.AddHours(-24);
|
|
var toDate = DateTime.UtcNow;
|
|
positionHistory =
|
|
await exchangeService.GetPositionHistory(Account, Config.Ticker, fromDate, toDate);
|
|
});
|
|
|
|
// Check if there's a recent position with PnL data and matching direction
|
|
if (positionHistory != null && positionHistory.Any())
|
|
{
|
|
var recentPosition = positionHistory
|
|
.Where(p => p.OriginDirection == position.OriginDirection) // Ensure same direction
|
|
.OrderByDescending(p => p.Open?.Date ?? DateTime.MinValue)
|
|
.FirstOrDefault();
|
|
|
|
if (recentPosition != null && recentPosition.ProfitAndLoss != null)
|
|
{
|
|
await LogDebug(
|
|
$"✅ Position Found in Exchange History\n" +
|
|
$"Position: `{position.Identifier}`\n" +
|
|
$"Direction: `{position.OriginDirection}` (Matched: ✅)\n" +
|
|
$"Exchange PnL: `${recentPosition.ProfitAndLoss.Realized:F2}`\n" +
|
|
$"Position was actually filled and closed");
|
|
return (true, false);
|
|
}
|
|
else
|
|
{
|
|
// Found positions in history but none match the direction
|
|
var allHistoryDirections = positionHistory.Select(p => p.OriginDirection).Distinct().ToList();
|
|
await LogDebug(
|
|
$"⚠️ Direction Mismatch in History\n" +
|
|
$"Looking for: `{position.OriginDirection}`\n" +
|
|
$"Found in history: `{string.Join(", ", allHistoryDirections)}`\n" +
|
|
$"No matching position found");
|
|
}
|
|
}
|
|
|
|
await LogDebug(
|
|
$"❌ No Position Found in Exchange History\nPosition: `{position.Identifier}`\nPosition was never filled");
|
|
return (false, false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error checking position history for position {PositionId}", position.Identifier);
|
|
await LogWarning(
|
|
$"⚠️ Web3Proxy Error During Position History Check\n" +
|
|
$"Position: `{position.Identifier}`\n" +
|
|
$"Error: {ex.Message}\n" +
|
|
$"Will retry on next execution cycle");
|
|
return (false, true); // found=false, hadError=true
|
|
}
|
|
}
|
|
|
|
private async Task RecoverRecentlyCanceledPositions()
|
|
{
|
|
if (Config.IsForBacktest)
|
|
{
|
|
// For backtests, we don't have broker positions, so skip recovery
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Get the last (most recent) position from all positions
|
|
var lastPosition = Positions.Values.LastOrDefault();
|
|
if (lastPosition == null)
|
|
{
|
|
return; // No positions at all
|
|
}
|
|
|
|
// Only attempt recovery if the last position is cancelled and recovery hasn't been attempted yet
|
|
if (lastPosition.Status != PositionStatus.Canceled || lastPosition.RecoveryAttempted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Also get count of cancelled positions for logging
|
|
var canceledPositionsCount = Positions.Values.Count(p => p.Status == PositionStatus.Canceled);
|
|
|
|
await LogDebug(
|
|
$"🔄 Position Recovery Check\nFound `{canceledPositionsCount}` canceled positions\nLast position `{lastPosition.Identifier}` is cancelled\nAttempting recovery from broker...");
|
|
|
|
// Get the signal for the last position
|
|
if (!Signals.TryGetValue(lastPosition.SignalIdentifier, out var signal))
|
|
{
|
|
await LogWarning(
|
|
$"⚠️ Signal Not Found for Recovery\nPosition: `{lastPosition.Identifier}`\nSignal: `{lastPosition.SignalIdentifier}`\nCannot recover without signal");
|
|
return;
|
|
}
|
|
|
|
// Mark recovery as attempted before proceeding
|
|
lastPosition.RecoveryAttempted = true;
|
|
Positions[lastPosition.Identifier] = lastPosition;
|
|
|
|
// Attempt recovery for the last position only
|
|
bool recovered = await RecoverOpenPositionFromBroker(signal, lastPosition);
|
|
if (recovered)
|
|
{
|
|
await LogInformation(
|
|
$"🎉 Position Recovery Successful\nPosition `{lastPosition.Identifier}` recovered from broker\nStatus restored to Filled\nWill continue normal processing");
|
|
}
|
|
else
|
|
{
|
|
await LogDebug(
|
|
$"❌ Recovery Not Needed\nPosition `{lastPosition.Identifier}` confirmed canceled\nNo open position found on broker");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error during recently canceled positions recovery");
|
|
await LogWarning($"Position recovery check failed due to exception: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task<bool> RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal)
|
|
{
|
|
if (Config.IsForBacktest)
|
|
{
|
|
// For backtests, we don't have broker positions, so return false
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
await LogDebug(
|
|
$"🔄 Attempting Position Recovery\n" +
|
|
$"Signal: `{signal.Identifier}`\n" +
|
|
$"Position: `{positionForSignal.Identifier}`\n" +
|
|
$"Direction: `{positionForSignal.OriginDirection}`\n" +
|
|
$"Ticker: `{Config.Ticker}`\n" +
|
|
$"Checking broker for open position...");
|
|
|
|
Position brokerPosition = null;
|
|
await ServiceScopeHelpers.WithScopedService<IExchangeService>(_scopeFactory,
|
|
async exchangeService =>
|
|
{
|
|
var brokerPositions = await exchangeService.GetBrokerPositions(Account);
|
|
brokerPosition = brokerPositions.FirstOrDefault(p => p.Ticker == Config.Ticker);
|
|
});
|
|
|
|
if (brokerPosition != null)
|
|
{
|
|
// Check if the broker position matches our expected direction
|
|
if (brokerPosition.OriginDirection == positionForSignal.OriginDirection)
|
|
{
|
|
await LogInformation(
|
|
$"✅ Position Recovered from Broker\n" +
|
|
$"Position: `{positionForSignal.Identifier}`\n" +
|
|
$"Direction: `{positionForSignal.OriginDirection}` (Matched: ✅)\n" +
|
|
$"Broker Position Size: `{brokerPosition.Open?.Quantity ?? 0}`\n" +
|
|
$"Broker Position Price: `${brokerPosition.Open?.Price ?? 0:F2}`\n" +
|
|
$"Restoring position status to Filled");
|
|
|
|
// Update position status back to Filled (from Canceled)
|
|
positionForSignal.Status = PositionStatus.Filled;
|
|
await SetPositionStatus(signal.Identifier, PositionStatus.Filled);
|
|
|
|
// Update signal status back to PositionOpen since position is recovered
|
|
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
|
|
|
|
// Update PnL from broker position
|
|
var brokerNetPnL = brokerPosition.GetPnLBeforeFees();
|
|
UpdatePositionPnl(positionForSignal.Identifier, brokerNetPnL);
|
|
|
|
// Update trade details if available
|
|
if (positionForSignal.Open != null && brokerPosition.Open != null)
|
|
{
|
|
positionForSignal.Open.SetStatus(TradeStatus.Filled);
|
|
if (brokerPosition.Open.ExchangeOrderId != null)
|
|
{
|
|
positionForSignal.Open.SetExchangeOrderId(brokerPosition.Open.ExchangeOrderId);
|
|
}
|
|
}
|
|
|
|
// Update stop loss and take profit trades if available
|
|
if (positionForSignal.StopLoss != null && brokerPosition.StopLoss != null)
|
|
{
|
|
positionForSignal.StopLoss.SetExchangeOrderId(brokerPosition.StopLoss.ExchangeOrderId);
|
|
}
|
|
|
|
if (positionForSignal.TakeProfit1 != null && brokerPosition.TakeProfit1 != null)
|
|
{
|
|
positionForSignal.TakeProfit1.SetExchangeOrderId(brokerPosition.TakeProfit1.ExchangeOrderId);
|
|
}
|
|
|
|
if (positionForSignal.TakeProfit2 != null && brokerPosition.TakeProfit2 != null)
|
|
{
|
|
positionForSignal.TakeProfit2.SetExchangeOrderId(brokerPosition.TakeProfit2.ExchangeOrderId);
|
|
}
|
|
|
|
// Update database
|
|
await UpdatePositionDatabase(positionForSignal);
|
|
|
|
// Notify about position recovery
|
|
await NotifyAgentAndPlatformGrainAsync(NotificationEventType.PositionUpdated, positionForSignal);
|
|
|
|
await LogInformation(
|
|
$"🎉 Position Recovery Complete\n" +
|
|
$"Position `{positionForSignal.Identifier}` successfully recovered\n" +
|
|
$"Status restored to Filled\n" +
|
|
$"Database and internal state updated");
|
|
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
await LogWarning(
|
|
$"⚠️ Direction Mismatch During Recovery\n" +
|
|
$"Expected: `{positionForSignal.OriginDirection}`\n" +
|
|
$"Broker Position: `{brokerPosition.OriginDirection}`\n" +
|
|
$"Cannot recover - directions don't match");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await LogDebug(
|
|
$"❌ No Open Position Found on Broker\n" +
|
|
$"Ticker: `{Config.Ticker}`\n" +
|
|
$"Position recovery not possible");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error during position recovery for position {PositionId}",
|
|
positionForSignal.Identifier);
|
|
await LogWarning($"Position recovery failed due to exception: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
} |