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

2143 lines
90 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.Trades;
using Managing.Domain.Users;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Orleans.Streams;
using static Managing.Common.Enums;
namespace Managing.Application.Bots;
public abstract class TradingBotBase : ITradingBot
{
public readonly ILogger<TradingBotBase> Logger;
protected readonly IServiceScopeFactory _scopeFactory;
protected readonly IStreamProvider? _streamProvider;
protected const int NEW_POSITION_GRACE_SECONDS = 45; // grace window before evaluating missing orders
protected 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; }
// OPTIMIZATION 2: Cache open position state to avoid expensive Positions.Any() calls
private bool _hasOpenPosition = false;
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 virtual async Task Start(BotStatus previousStatus)
{
if (TradingBox.IsLiveTrading(Config.TradingType))
{
// Start async initialization in the background without blocking
try
{
await LoadAccountAsync();
await LoadLastCandle();
if (Account == null)
{
await LogWarningAsync($"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 LogInformationAsync(startupMessage);
break;
case BotStatus.Running:
case BotStatus.Stopped:
return;
default:
return;
}
}
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 (TradingBox.IsBacktestTrading(Config.TradingType)) 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 (TradingBox.IsBacktestTrading(Config.TradingType)) 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 virtual async Task Run()
{
// Signal updates are handled by subclasses via UpdateSignals() override
if (!Config.IsForWatchingOnly)
await ManagePositions();
UpdateWalletBalances();
if (TradingBox.IsLiveTrading(Config.TradingType))
{
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(IReadOnlyList<Candle> candles = null)
{
await UpdateSignals(candles, null);
}
public async Task UpdateSignals(IReadOnlyList<Candle> candles,
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues = null)
{
await UpdateSignalsCore(candles, preCalculatedIndicatorValues);
}
protected virtual async Task UpdateSignalsCore(IReadOnlyList<Candle> candles,
Dictionary<IndicatorType, IndicatorsResultBase> preCalculatedIndicatorValues = null)
{
// OPTIMIZATION 2: Use cached open position state instead of expensive Positions.Any() call
// 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 && _hasOpenPosition)
{
Logger.LogDebug(
$"Skipping signal update: Position open and flip disabled.");
return;
}
// Check if we're in cooldown period for any direction
if (await IsInCooldownPeriodAsync())
{
// Still in cooldown period, skip signal generation
return;
}
// Default implementation: do nothing (subclasses should override with signal generation logic)
}
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;
}
}
protected async Task ManagePositions()
{
// OPTIMIZATION 6: Combine early exit checks and collect unfinished positions in one pass
// Collect unfinished positions in first iteration to avoid LINQ Where() later
var unfinishedPositions = new List<Position>();
foreach (var position in Positions.Values)
{
if (!position.IsFinished())
{
unfinishedPositions.Add(position);
}
}
bool hasWaitingSignals = false;
if (unfinishedPositions.Count == 0) // Only check signals if no open positions
{
foreach (var signal in Signals.Values)
{
if (signal.Status == SignalStatus.WaitingForPosition)
{
hasWaitingSignals = true;
break;
}
}
}
if (unfinishedPositions.Count == 0 && !hasWaitingSignals)
return;
// First, process all existing positions that are not finished
foreach (var position in unfinishedPositions)
{
// OPTIMIZATION 3: Use TryGetValue instead of direct dictionary access
if (!Signals.TryGetValue(position.SignalIdentifier, out var signalForPosition))
{
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
}
}
protected void UpdateWalletBalances()
{
var date = TradingBox.IsBacktestTrading(Config.TradingType)
? LastCandle?.Date ?? DateTime.UtcNow
: DateTime.UtcNow;
if (WalletBalances.Count == 0)
{
WalletBalances[date] = _currentBalance;
return;
}
if (!WalletBalances.ContainsKey(date))
{
WalletBalances[date] = _currentBalance;
}
}
protected 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 LogDebugAsync(
$"Skipping update for position {positionForSignal.Identifier} - status is {positionForSignal.Status} (never filled)");
return;
}
Position internalPosition = await GetInternalPositionForUpdate(positionForSignal);
// Handle broker position synchronization (futures-specific logic)
await SynchronizeWithBrokerPositions(internalPosition, positionForSignal);
// Handle order management and position status (futures-specific logic)
await HandleOrderManagementAndPositionStatus(signal, internalPosition, positionForSignal);
// Common position status handling
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 = TradingBox.IsBacktestTrading(Config.TradingType)
? LastCandle
: await exchangeService.GetCandle(Account, Config.Ticker,
DateTime.UtcNow);
});
var currentTime = TradingBox.IsBacktestTrading(Config.TradingType) ? 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);
}
}
}
// Synth risk monitoring (only for live trading)
if (Config.UseSynthApi && TradingBox.IsLiveTrading(Config.TradingType) &&
positionForSignal.Status == PositionStatus.Filled)
{
await MonitorSynthRisk(signal, positionForSignal);
}
}
catch (Exception ex)
{
await LogWarningAsync(
$"Cannot update position {positionForSignal.Identifier}: {ex.Message}, {ex.StackTrace}");
SentrySdk.CaptureException(ex);
return;
}
}
// Virtual methods for trading mode-specific behavior
protected virtual async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal)
{
// Default implementation: do nothing (for backtest)
}
protected virtual async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition,
Position positionForSignal)
{
// Default implementation: do nothing (for backtest)
}
protected virtual async Task MonitorSynthRisk(LightSignal signal, Position position)
{
// Default implementation: do nothing (for backtest)
}
protected virtual async Task<bool> RecoverOpenPositionFromBroker(LightSignal signal, Position position)
{
// Default implementation: no recovery for backtest
return false;
}
protected virtual async Task<bool> CheckBrokerPositions()
{
// Default implementation: no broker checks for backtest, always allow
return true;
}
protected virtual async Task<bool> ReconcileWithBrokerHistory(Position position, Candle currentCandle)
{
// Default implementation: no broker history reconciliation for backtest
return false; // Return false to continue with candle-based calculation
}
protected virtual async Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles(
Position position, Candle currentCandle, bool forceMarketClose, decimal? forcedClosingPrice)
{
// Used in Futures and Spot bots
decimal closingPrice = 0;
bool pnlCalculated = false;
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;
}
else if (currentCandle != null)
{
// For backtest: use configured SL/TP prices to ensure consistent PnL
if (position.OriginDirection == TradeDirection.Long)
{
if (position.StopLoss.Price >= currentCandle.Low)
{
closingPrice = position.StopLoss.Price;
position.StopLoss.SetDate(currentCandle.Date);
position.StopLoss.SetStatus(TradeStatus.Filled);
if (position.TakeProfit1 != null)
{
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
}
if (position.TakeProfit2 != null)
{
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
}
}
else if (position.TakeProfit1.Price <= currentCandle.High &&
position.TakeProfit1.Status != TradeStatus.Filled)
{
closingPrice = position.TakeProfit1.Price;
position.TakeProfit1.SetDate(currentCandle.Date);
position.TakeProfit1.SetStatus(TradeStatus.Filled);
if (position.StopLoss != null)
{
position.StopLoss.SetStatus(TradeStatus.Cancelled);
}
}
}
else if (position.OriginDirection == TradeDirection.Short)
{
if (position.StopLoss.Price <= currentCandle.High)
{
closingPrice = position.StopLoss.Price;
position.StopLoss.SetDate(currentCandle.Date);
position.StopLoss.SetStatus(TradeStatus.Filled);
if (position.TakeProfit1 != null)
{
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
}
if (position.TakeProfit2 != null)
{
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
}
}
else if (position.TakeProfit1.Price >= currentCandle.Low &&
position.TakeProfit1.Status != TradeStatus.Filled)
{
closingPrice = position.TakeProfit1.Price;
position.TakeProfit1.SetDate(currentCandle.Date);
position.TakeProfit1.SetStatus(TradeStatus.Filled);
if (position.StopLoss != null)
{
position.StopLoss.SetStatus(TradeStatus.Cancelled);
}
}
}
if (closingPrice == 0)
{
// Manual/exchange close - use current candle close
closingPrice = currentCandle.Close;
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);
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);
if (position.TakeProfit1 != null)
{
position.TakeProfit1.SetStatus(TradeStatus.Cancelled);
}
if (position.TakeProfit2 != null)
{
position.TakeProfit2.SetStatus(TradeStatus.Cancelled);
}
}
}
pnlCalculated = true;
}
return (closingPrice, pnlCalculated);
}
protected async Task UpdatePositionDatabase(Position position)
{
await ServiceScopeHelpers.WithScopedService<ITradingService>(_scopeFactory,
async tradingService => { await tradingService.UpdatePositionAsync(position); });
}
protected async Task<decimal> GetLastPriceForPositionOpeningAsync()
{
if (TradingBox.IsLiveTrading(Config.TradingType))
{
return await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(_scopeFactory,
async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker));
}
return LastCandle?.Close ?? 0;
}
protected async Task<Position> OpenPosition(LightSignal signal)
{
await LogDebugAsync($"🔓 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 GetLastPriceForPositionOpeningAsync();
if (openedPosition != null)
{
// OPTIMIZATION 3: Use TryGetValue instead of direct dictionary access
if (!Signals.TryGetValue(openedPosition.SignalIdentifier, out var previousSignal))
{
// Signal not found, expire new signal and return
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
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
{
// Handle flip position - trading type specific logic
var flippedPosition = await HandleFlipPosition(signal, openedPosition, previousSignal, lastPrice);
return flippedPosition;
}
}
else
{
bool canOpen = await CanOpenPosition(signal);
if (!canOpen)
{
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
try
{
// Execute position opening - trading type specific logic
var position = await ExecuteOpenPosition(signal, lastPrice);
// Common logic: Handle position result
if (position != null)
{
// Add position to internal collection before any status updates
Positions[position.Identifier] = position;
// OPTIMIZATION 2: Update cached open position state
_hasOpenPosition = true;
if (position.Open.Status != TradeStatus.Cancelled && position.Status != PositionStatus.Rejected)
{
SetSignalStatus(signal.Identifier, SignalStatus.PositionOpen);
await SendPositionToCopyTradingStream(position);
await LogDebugAsync($"✅ Position requested successfully for signal: `{signal.Identifier}`");
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;
}
}
}
/// <summary>
/// Handles position flipping logic when an opposite direction signal is received.
/// This method is trading-type specific and should be overridden in derived classes.
/// </summary>
protected virtual async Task<Position> HandleFlipPosition(LightSignal signal, Position openedPosition,
LightSignal previousSignal, decimal lastPrice)
{
// Default implementation - subclasses should override
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;
}
}
/// <summary>
/// Executes the actual position opening logic.
/// This method is trading-type specific and should be overridden in derived classes.
/// </summary>
protected virtual async Task<Position> ExecuteOpenPosition(LightSignal signal, decimal lastPrice)
{
// Default implementation - subclasses should override
// Verify actual balance before opening position
await VerifyAndUpdateBalanceAsync();
var command = new OpenPositionRequest(
Config.AccountName,
Config.MoneyManagement,
signal.Direction,
Config.Ticker,
PositionInitiator.Bot,
signal.Date,
Account.User,
Config.BotTradingBalance,
TradingBox.IsBacktestTrading(Config.TradingType),
lastPrice,
signalIdentifier: signal.Identifier,
initiatorIdentifier: Identifier,
tradingType: Config.TradingType);
var position = await ServiceScopeHelpers
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
_scopeFactory,
async (exchangeService, accountService, tradingService) =>
{
return await new OpenPositionCommandHandler(exchangeService, accountService, tradingService)
.Handle(command);
});
return position;
}
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 LogDebugAsync($"📡 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;
}
}
protected virtual async Task<bool> CanOpenPosition(LightSignal signal)
{
// Default implementation for live trading
// Early return if bot hasn't executed first cycle yet
if (ExecutionCount == 0)
{
await LogInformationAsync("⏳ Bot Not Ready\nCannot open position\nBot hasn't executed first cycle yet");
return false;
}
// Check broker positions for live trading
var canOpenPosition = await CanOpenPositionWithBrokerChecks(signal);
if (!canOpenPosition)
{
return false;
}
// Check cooldown period and loss streak
return !await IsInCooldownPeriodAsync() && await CheckLossStreak(signal);
}
protected 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;
}
public abstract Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice,
bool tradeClosingPosition = false, bool forceMarketClose = false);
protected async Task HandleClosedPosition(Position position, decimal? forcedClosingPrice = null,
bool forceMarketClose = false)
{
if (Positions.ContainsKey(position.Identifier))
{
Candle currentCandle = await GetCurrentCandleForPositionClose(Account, Config.Ticker.ToString());
// Try broker history reconciliation first
var brokerHistoryReconciled = await ReconcileWithBrokerHistory(position, currentCandle);
if (brokerHistoryReconciled && !forceMarketClose)
{
goto SkipCandleBasedCalculation;
}
// Calculate position closing details using subclass-specific logic
var (closingPrice, pnlCalculated) = await CalculatePositionClosingFromCandles(
position, currentCandle, forceMarketClose, forcedClosingPrice);
// 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 (TradingBox.IsLiveTrading(Config.TradingType))
{
await LogDebugAsync(logMessage);
}
}
SkipCandleBasedCalculation:
await SetPositionStatus(position.SignalIdentifier, PositionStatus.Finished);
// OPTIMIZATION 2: Update cached open position state after closing position
_hasOpenPosition = Positions.Values.Any(p => p.IsOpen());
// Update position in database with all trade changes
if (TradingBox.IsLiveTrading(Config.TradingType))
{
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 NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, position);
// Update the last position closing time for cooldown period tracking
// Only update if position was actually filled
LastPositionClosingTime = TradingBox.IsBacktestTrading(Config.TradingType)
? currentCandle.Date
: DateTime.UtcNow;
}
else
{
await LogDebugAsync(
$"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 LogDebugAsync(
$"✅ 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 LogDebugAsync(
string.Format("💰 Balance Updated\nNew bot trading balance: `${0:F2}`",
Config.BotTradingBalance));
}
}
else
{
await LogDebugAsync(
$"✅ 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");
}
await SendClosedPositionToMessenger(position, Account.User);
await CancelAllOrdersAsync();
}
private async Task CancelAllOrders()
{
if (TradingBox.IsLiveTrading(Config.TradingType) && !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 LogDebugAsync($"Position still open, cancel close orders");
}
else
{
await LogDebugAsync($"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 LogDebugAsync(
$"Closing all {Config.Ticker} orders status : {closePendingOrderStatus}");
});
}
}
else
{
await LogDebugAsync($"No need to cancel orders for {Config.Ticker}");
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during cancelOrders");
SentrySdk.CaptureException(ex);
}
}
}
protected 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);
}
}
protected 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;
}
}
protected void SetSignalStatus(string signalIdentifier, SignalStatus signalStatus)
{
// OPTIMIZATION 4: Use TryGetValue instead of ContainsKey + direct access (single lookup)
if (Signals.TryGetValue(signalIdentifier, out var signal) && signal.Status != signalStatus)
{
signal.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"}`");
}
/// <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
{
// OPTIMIZATION 1: Early return for backtest - skip all logging and validation
if (TradingBox.IsBacktestTrading(Config.TradingType))
{
Signals.Add(signal.Identifier, signal);
return;
}
// Set signal status based on configuration
if (Config.IsForWatchingOnly || (ExecutionCount < 1 && TradingBox.IsLiveTrading(Config.TradingType)))
{
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 && TradingBox.IsLiveTrading(Config.TradingType) && 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,
TradingBox.IsBacktestTrading(Config.TradingType));
if (signalValidationResult.Confidence == Confidence.None ||
signalValidationResult.Confidence == Confidence.Low ||
signalValidationResult.IsBlocked)
{
signal.Status = SignalStatus.Expired;
await LogDebugAsync($"Signal {signal.Identifier} blocked by Synth risk assessment");
}
else
{
signal.Confidence = signalValidationResult.Confidence;
await LogDebugAsync(
$"Signal {signal.Identifier} passed Synth risk assessment with confidence {signalValidationResult.Confidence}");
}
});
}
Signals.Add(signal.Identifier, signal);
await LogInformation(signalText);
if (Config.IsForWatchingOnly && TradingBox.IsLiveTrading(Config.TradingType) && ExecutionCount > 0)
{
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory, async messengerService =>
{
await messengerService.SendSignal(signalText, Account.Exchange, Config.Ticker, signal.Direction,
Config.Timeframe);
});
}
await LogDebugAsync(
$"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 protectedTradingType = Config.TradingType;
newConfig.AccountName = Config.AccountName;
// Update the configuration
Config = newConfig;
// Restore protected properties
Config.TradingType = protectedTradingType;
// 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,
TradingType = Config.TradingType,
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>
protected 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 = (TradingBox.IsBacktestTrading(Config.TradingType) ? 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 LogDebugAsync($"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 (TradingBox.IsBacktestTrading(Config.TradingType))
{
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 LogDebugAsync(
$"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 LogDebugAsync(
$"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);
}
}
// Virtual methods for mode-specific behavior
protected virtual async Task LoadAccountAsync()
{
await LoadAccount();
}
protected virtual async Task VerifyAndUpdateBalanceAsync()
{
await VerifyAndUpdateBalance();
}
protected virtual async Task<Position> GetInternalPositionForUpdate(Position position)
{
return position; // Default implementation for backtest
}
protected virtual async Task UpdatePositionWithBrokerData(Position position, List<Position> brokerPositions)
{
// Default: do nothing for backtest
}
protected virtual async Task<Candle> GetCurrentCandleForPositionClose(Account account, string ticker)
{
return LastCandle; // Default for backtest
}
protected virtual async Task<bool> CanOpenPositionWithBrokerChecks(LightSignal signal)
{
// Check broker positions for live trading
var canOpenPosition = await CheckBrokerPositions();
if (!canOpenPosition)
{
return false;
}
return true;
}
protected virtual async Task SendPositionToCopyTradingStream(Position position)
{
await SendPositionToCopyTrading(position);
}
protected virtual async Task NotifyAgentAndPlatformAsync(NotificationEventType eventType, Position position)
{
await NotifyAgentAndPlatformGrainAsync(eventType, position);
}
protected virtual async Task UpdatePositionInDatabaseAsync(Position position)
{
await UpdatePositionDatabase(position);
}
protected virtual async Task SendClosedPositionToMessenger(Position position, User user)
{
await ServiceScopeHelpers.WithScopedService<IMessengerService>(_scopeFactory,
async messengerService => { await messengerService.SendClosedPosition(position, user); });
}
protected virtual async Task CancelAllOrdersAsync()
{
await CancelAllOrders();
}
// Interface implementation
public async Task LogInformation(string message)
{
await LogInformationAsync(message);
}
public async Task LogWarning(string message)
{
await LogWarningAsync(message);
}
protected virtual async Task LogInformationAsync(string message)
{
if (TradingBox.IsBacktestTrading(Config.TradingType))
return;
Logger.LogInformation(message);
try
{
await SendTradeMessageAsync(message);
}
catch (Exception e)
{
Console.WriteLine(e);
}
}
protected virtual async Task LogWarningAsync(string message)
{
if (TradingBox.IsBacktestTrading(Config.TradingType))
return;
message = $"[{Config.Name}] {message}";
try
{
await SendTradeMessageAsync(message, true);
}
catch (Exception e)
{
Console.WriteLine(e);
}
}
protected virtual async Task LogDebugAsync(string message)
{
if (TradingBox.IsBacktestTrading(Config.TradingType))
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);
}
}
protected virtual async Task SendTradeMessageAsync(string message, bool isBadBehavior = false)
{
if (TradingBox.IsLiveTrading(Config.TradingType))
{
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);
});
}
}
}