Files
managing-apps/src/Managing.Application/Bots/SpotBot.cs
cryptooda 25a2b202a1 Enhance SpotBot logging and orphaned position handling
- Updated SpotBot to log detailed information when detecting small token balances, indicating potential gas reserves or dust.
- Introduced a minimum threshold for orphaned positions, improving decision-making on whether to open new positions.
- Enhanced logging for potential zombie positions, providing clearer warnings when token balances are missing.
- Improved force close logging to clarify the status of remaining balances after attempts to clear them.
2026-01-05 19:49:59 +07:00

1194 lines
54 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#nullable enable
using Managing.Application.Abstractions.Grains;
using Managing.Application.Abstractions.Services;
using Managing.Application.Trading.Commands;
using Managing.Application.Trading.Handlers;
using Managing.Core;
using Managing.Domain.Accounts;
using Managing.Domain.Bots;
using Managing.Domain.Candles;
using Managing.Domain.Indicators;
using Managing.Domain.Shared.Helpers;
using Managing.Domain.Strategies.Base;
using Managing.Domain.Trades;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Orleans.Streams;
using static Managing.Common.Enums;
namespace Managing.Application.Bots;
public class SpotBot : TradingBotBase
{
public SpotBot(
ILogger<TradingBotBase> logger,
IServiceScopeFactory scopeFactory,
TradingBotConfig config,
IStreamProvider? streamProvider = null
) : base(logger, scopeFactory, config, streamProvider)
{
// Spot trading mode - ensure it's not backtest
Config.TradingType = TradingType.Spot;
}
// SpotBot uses the base implementation for Start() which includes
// account loading, balance verification, and live trading startup messages
public override async Task Run()
{
// Live trading signal update logic
if (!Config.IsForCopyTrading)
{
await UpdateSignals();
}
await LoadLastCandle();
if (!Config.IsForWatchingOnly)
{
await ManagePositions();
}
UpdateWalletBalances();
// Live trading execution logging
ExecutionCount++;
Logger.LogInformation(
"[Spot][{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}")));
}
protected override async Task<Position> GetInternalPositionForUpdate(Position position)
{
// For live trading, get position from database via trading service
return await ServiceScopeHelpers.WithScopedService<ITradingService, Position>(
_scopeFactory,
async tradingService => await tradingService.GetPositionByIdentifierAsync(position.Identifier));
}
protected override async Task UpdatePositionWithBrokerData(Position position, List<Position> brokerPositions)
{
// For spot trading, fetch token balance directly and update PnL based on current price
try
{
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
_scopeFactory,
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
if (tokenBalance is not { Amount: > 0 })
{
// No token balance found - position might be closed
return;
}
// Get current price from LastCandle
var currentPrice = LastCandle?.Close ?? 0;
if (currentPrice == 0)
{
// Try to get current price from exchange
currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
_scopeFactory,
async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker));
}
if (currentPrice == 0)
{
Logger.LogWarning("Cannot update PnL: current price is 0");
return;
}
// Calculate PnL based on current token balance and current price
// For LONG spot position: PnL = (currentPrice - openPrice) * tokenBalance
var openPrice = position.Open.Price;
var pnlBeforeFees =
TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance.Amount, 1, TradeDirection.Long);
// Update position PnL
UpdatePositionPnl(position.Identifier, pnlBeforeFees);
var totalFees = position.GasFees + position.UiFees;
var netPnl = pnlBeforeFees - totalFees;
if (position.ProfitAndLoss == null)
{
position.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl };
}
else
{
position.ProfitAndLoss.Realized = pnlBeforeFees;
position.ProfitAndLoss.Net = netPnl;
}
await LogDebugAsync(
$"📊 Spot Position PnL Updated\n" +
$"Position: `{position.Identifier}`\n" +
$"Token Balance: `{tokenBalance.Amount:F5}`\n" +
$"Open Price: `${openPrice:F2}`\n" +
$"Current Price: `${currentPrice:F2}`\n" +
$"PnL (before fees): `${pnlBeforeFees:F2}`\n" +
$"Net PnL: `${netPnl:F2}`");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error updating position PnL from token balance");
}
}
protected override async Task<Candle> GetCurrentCandleForPositionClose(Account account, string ticker)
{
// For live trading, get real-time candle from exchange
return await ServiceScopeHelpers.WithScopedService<IExchangeService, Candle>(_scopeFactory,
async exchangeService => await exchangeService.GetCandle(Account, Config.Ticker, DateTime.UtcNow));
}
protected override async Task<bool> CheckBrokerPositions()
{
// For spot trading, check token balances to verify position status
try
{
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
_scopeFactory,
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
var hasOpenPosition = Positions.Values.Any(p => p.IsOpen());
if (hasOpenPosition)
{
// We have an internal position - verify it matches broker balance
if (tokenBalance is { Amount: > 0 })
{
await LogDebugAsync(
$"✅ Spot Position Verified\n" +
$"Ticker: {Config.Ticker}\n" +
$"Internal position: Open\n" +
$"Token balance: `{tokenBalance.Amount:F5}`\n" +
$"Position matches broker balance");
return false; // Position already open, cannot open new one
}
await LogWarningAsync(
$"⚠️ Position Mismatch\n" +
$"Ticker: {Config.Ticker}\n" +
$"Internal position exists but no token balance found\n" +
$"Position may need synchronization");
return false; // Don't allow opening new position until resolved
}
if (tokenBalance is { Amount: > 0 })
{
// Check if this is a meaningful balance or just gas reserves / dust
// Minimum threshold: $10 USD value to be considered an orphaned position
const decimal minOrphanedBalanceValue = 10m;
if (tokenBalance.Value < minOrphanedBalanceValue)
{
await LogDebugAsync(
$" Small Token Balance Detected (Likely Gas Reserve or Dust)\n" +
$"Ticker: {Config.Ticker}\n" +
$"Token balance: `{tokenBalance.Amount:F8}`\n" +
$"USD Value: `${tokenBalance.Value:F2}`\n" +
$"Below orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" +
$"Ignoring - safe to open new position");
return true; // Safe to open new position - this is just dust/gas reserve
}
// We have a significant token balance but no internal position - attempt to recover orphaned position
await LogWarningAsync(
$"⚠️ Orphaned Token Balance Detected\n" +
$"Ticker: {Config.Ticker}\n" +
$"Token balance: `{tokenBalance.Amount:F5}` (Value: ${tokenBalance.Value:F2})\n" +
$"Above orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" +
$"But no internal position tracked\n" +
$"Attempting to recover position from database...");
var recovered = await TryRecoverOrphanedPosition(tokenBalance.Amount);
if (!recovered)
{
await LogWarningAsync(
$"❌ Position Recovery Failed\n" +
$"Could not recover orphaned position\n" +
$"Manual cleanup may be required");
}
return false; // Don't allow opening new position until next cycle
}
// No position and no balance - safe to open
return true;
}
catch (Exception ex)
{
await LogWarningAsync($"❌ Broker Position Check Failed\nError checking token balances\n{ex.Message}");
return false;
}
}
private async Task<bool> TryRecoverOrphanedPosition(decimal tokenBalance)
{
try
{
// Get all positions for this bot from database
var allPositions = await ServiceScopeHelpers.WithScopedService<ITradingService, IEnumerable<Position>>(
_scopeFactory,
async tradingService => await tradingService.GetPositionsByInitiatorIdentifierAsync(Identifier));
// Calculate the maximum age for recovery (2 candles ago)
// Only recover positions that are recent enough to be legitimate orphaned positions
var candleIntervalSeconds = Managing.Domain.Candles.CandleHelpers.GetBaseIntervalInSeconds(Config.Timeframe);
var maxAgeSeconds = candleIntervalSeconds * 2; // 2 candles
var minDate = DateTime.UtcNow.AddSeconds(-maxAgeSeconds);
// Find the last position for this account and ticker that is:
// 1. Not Finished (Finished positions should not be recovered)
// 2. Less than 2 candles old
// 3. In a recoverable state (New, Cancelled, or Filled but not tracked)
var lastPosition = allPositions
.Where(p => p.AccountId == Account.Id &&
p.Ticker == Config.Ticker &&
p.Status != PositionStatus.Finished && // Never recover Finished positions
p.Date >= minDate) // Only check recent positions (less than 2 candles old)
.OrderByDescending(p => p.Date)
.FirstOrDefault();
if (lastPosition == null)
{
await LogWarningAsync(
$"🔍 No Recoverable Position Found\n" +
$"No recent unfinished position found in database for this ticker\n" +
$"Token balance: `{tokenBalance:F5}` may be from external source or old position\n" +
$"Only checking positions less than 2 candles old (max age: {maxAgeSeconds}s)");
return false;
}
// Verify that the Open trade exists
if (lastPosition.Open == null)
{
await LogWarningAsync(
$"⚠️ Invalid Position Data\n" +
$"Position `{lastPosition.Identifier}` has no Open trade\n" +
$"Cannot recover position");
return false;
}
// Verify that the token balance matches the position quantity with tolerance
var positionQuantity = lastPosition.Open.Quantity;
if (positionQuantity == 0)
{
await LogWarningAsync(
$"⚠️ Invalid Position Data\n" +
$"Position `{lastPosition.Identifier}` has zero quantity\n" +
$"Cannot recover position");
return false;
}
var tolerance = positionQuantity * 0.006m; // 0.6% tolerance for slippage
var difference = Math.Abs(tokenBalance - positionQuantity);
if (difference > tolerance)
{
await LogWarningAsync(
$"⚠️ Token Balance Mismatch\n" +
$"Position: `{lastPosition.Identifier}`\n" +
$"Position Quantity: `{positionQuantity:F5}`\n" +
$"Token Balance: `{tokenBalance:F5}`\n" +
$"Difference: `{difference:F5}` exceeds tolerance `{tolerance:F5}`\n" +
$"Cannot recover - amounts don't match");
return false;
}
// Recover the position by setting status to Filled
var previousStatus = lastPosition.Status;
lastPosition.Status = PositionStatus.Filled;
lastPosition.Open.SetStatus(TradeStatus.Filled);
// Update quantity to match actual token balance
lastPosition.Open.Quantity = tokenBalance;
// Calculate current PnL
var currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
_scopeFactory,
async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker));
if (currentPrice > 0)
{
var openPrice = lastPosition.Open.Price;
var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, tokenBalance, 1,
TradeDirection.Long);
var totalFees = lastPosition.GasFees + lastPosition.UiFees;
var netPnl = pnlBeforeFees - totalFees;
lastPosition.ProfitAndLoss = new ProfitAndLoss
{
Realized = pnlBeforeFees,
Net = netPnl
};
}
// Update position in database
await UpdatePositionInDatabaseAsync(lastPosition);
// Add to internal positions collection
Positions[lastPosition.Identifier] = lastPosition;
// Update signal status if signal already exists
if (Signals.ContainsKey(lastPosition.SignalIdentifier))
{
SetSignalStatus(lastPosition.SignalIdentifier, SignalStatus.PositionOpen);
}
await LogWarningAsync(
$"✅ Orphaned Position Recovered\n" +
$"Position: `{lastPosition.Identifier}`\n" +
$"Signal: `{lastPosition.SignalIdentifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Previous Status: `{previousStatus}` → `Filled`\n" +
$"Token Balance: `{tokenBalance:F5}`\n" +
$"Open Price: `${lastPosition.Open.Price:F2}`\n" +
$"Open Date: `{lastPosition.Open.Date}`\n" +
$"Current Price: `${currentPrice:F2}`\n" +
$"Current PnL: `${lastPosition.ProfitAndLoss?.Realized ?? 0:F2}`\n" +
$"Position is now being tracked and will be managed on next cycle");
// Send notification
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, lastPosition);
return true;
}
catch (Exception ex)
{
Logger.LogError(ex, "Error recovering orphaned position");
await LogWarningAsync($"❌ Position Recovery Error\n{ex.Message}");
return false;
}
}
protected override async Task LoadAccountAsync()
{
// Live trading: load real account from database
if (Config.TradingType == TradingType.BacktestSpot) return;
await ServiceScopeHelpers.WithScopedService<IAccountService>(_scopeFactory, async accountService =>
{
var account = await accountService.GetAccountByAccountName(Config.AccountName, false);
Account = account;
});
}
protected override async Task SynchronizeWithBrokerPositions(Position internalPosition, Position positionForSignal)
{
// For spot trading, fetch token balance directly and verify/match with internal position
try
{
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
_scopeFactory,
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
if (tokenBalance is { Amount: > 0 })
{
// Verify that the token balance matches the position amount with 0.1% tolerance
var positionQuantity = internalPosition.Open.Quantity;
var tokenBalanceAmount = tokenBalance.Amount;
if (positionQuantity > 0)
{
var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage
var difference = Math.Abs(tokenBalanceAmount - positionQuantity);
if (difference > tolerance)
{
await LogWarningAsync(
$"⚠️ Token Balance Mismatch - Position Verification Failed\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Position Quantity: `{positionQuantity:F5}`\n" +
$"Token Balance: `{tokenBalanceAmount:F5}`\n" +
$"Difference: `{difference:F5}`\n" +
$"Tolerance (0.6%): `{tolerance:F5}`\n" +
$"Token balance does not match position amount within tolerance\n" +
$"Skipping position synchronization");
return; // Skip processing if amounts don't match
}
}
// Token balance exists and matches position - verify position is filled
var previousPositionStatus = internalPosition.Status;
// Position found on broker (token balance exists), means the position is filled
// Update position status
internalPosition.Status = PositionStatus.Filled;
await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Filled);
// Update Open trade status
internalPosition.Open.SetStatus(TradeStatus.Filled);
positionForSignal.Open.SetStatus(TradeStatus.Filled);
// Update quantity to match actual token balance
var actualTokenBalance = tokenBalance.Amount;
var quantityTolerance = internalPosition.Open.Quantity * 0.006m; // 0.6% tolerance for slippage
var quantityDifference = Math.Abs(internalPosition.Open.Quantity - actualTokenBalance);
if (quantityDifference > quantityTolerance)
{
await LogDebugAsync(
$"🔄 Token Balance Mismatch\n" +
$"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
$"Broker Balance: `{actualTokenBalance:F5}`\n" +
$"Difference: `{quantityDifference:F5}`\n" +
$"Tolerance (0.6%): `{quantityTolerance:F5}`\n" +
$"Updating to match broker balance");
internalPosition.Open.Quantity = actualTokenBalance;
positionForSignal.Open.Quantity = actualTokenBalance;
}
// Calculate and update PnL based on current price
var currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
_scopeFactory,
async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker));
if (currentPrice > 0)
{
var openPrice = internalPosition.Open.Price;
var pnlBeforeFees = TradingBox.CalculatePnL(openPrice, currentPrice, actualTokenBalance, 1,
TradeDirection.Long);
UpdatePositionPnl(positionForSignal.Identifier, pnlBeforeFees);
var totalFees = internalPosition.GasFees + internalPosition.UiFees;
var netPnl = pnlBeforeFees - totalFees;
internalPosition.ProfitAndLoss = new ProfitAndLoss { Realized = pnlBeforeFees, Net = netPnl };
}
await UpdatePositionInDatabaseAsync(internalPosition);
if (previousPositionStatus != PositionStatus.Filled &&
internalPosition.Status == PositionStatus.Filled)
{
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition);
}
else
{
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionUpdated, internalPosition);
}
}
else
{
// No token balance found - check if position was closed
if (internalPosition.Status == PositionStatus.Filled)
{
var (positionFoundInHistory, hadWeb3ProxyError) =
await CheckSpotPositionInExchangeHistory(internalPosition);
if (hadWeb3ProxyError)
{
await LogWarningAsync(
$"⏳ Web3Proxy Error During Spot Position Verification\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Cannot verify if position is closed\n" +
$"Will retry on next execution cycle");
return;
}
if (positionFoundInHistory)
{
internalPosition.Status = PositionStatus.Finished;
await HandleClosedPosition(internalPosition);
return;
}
// Position is Filled but no token balance and not found in history
// This could be a zombie position - check if token balance exists via direct exchange call
await LogWarningAsync(
$"⚠️ Potential Zombie Position Detected\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Status: Filled\n" +
$"Token balance: 0\n" +
$"Not found in exchange history\n" +
$"This position may have been closed externally or data is delayed\n" +
$"Will retry verification on next cycle");
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error synchronizing position with token balance");
}
}
private async Task<(bool found, bool hadError)> CheckSpotPositionInExchangeHistory(Position position)
{
try
{
await LogDebugAsync(
$"🔍 Checking Spot Position History for Position: `{position.Identifier}`\nTicker: `{Config.Ticker}`");
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
_scopeFactory,
async exchangeService =>
{
var fromDate = DateTime.UtcNow.AddHours(-24);
var toDate = DateTime.UtcNow;
return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
});
if (positionHistory != null && positionHistory.Count != 0)
{
// Find a SELL/SHORT position that occurred AFTER our open position was created
// This indicates our LONG position was closed
var closingPosition = positionHistory
.Where(p => p.OriginDirection == TradeDirection.Short &&
p.Date >= position.Date)
.OrderByDescending(p => p.Date)
.FirstOrDefault();
if (closingPosition != null)
{
await LogDebugAsync(
$"✅ Closing Position Found in History\n" +
$"Position: `{position.Identifier}`\n" +
$"Ticker: `{closingPosition.Ticker}`\n" +
$"Direction: `{closingPosition.OriginDirection}` (SHORT = Sold/Closed)\n" +
$"Date: `{closingPosition.Date}`\n" +
$"Our Position Open Date: `{position.Date}`");
return (true, false);
}
await LogDebugAsync(
$" No Closing Position Found in History\n" +
$"Position: `{position.Identifier}`\n" +
$"Found {positionHistory.Count} history entries but none indicate position was closed\n" +
$"Position is likely still open");
}
else
{
await LogDebugAsync(
$" No Position History Available\n" +
$"Position: `{position.Identifier}`\n" +
$"Position is likely still open");
}
return (false, false);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error checking spot position history for position {PositionId}", position.Identifier);
await LogWarningAsync(
$"⚠️ Web3Proxy Error During Spot Position History Check\n" +
$"Position: `{position.Identifier}`\n" +
$"Error: {ex.Message}\n" +
$"Will retry on next execution cycle");
return (false, true);
}
}
protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition,
Position positionForSignal)
{
// Spot trading doesn't use orders like futures - positions are opened via swaps
// Just check if the swap was successful
if (internalPosition.Status == PositionStatus.New)
{
// Check if swap was successful by verifying position status
// For spot, if Open trade is Filled, the position is filled
if (positionForSignal.Open?.Status == TradeStatus.Filled)
{
internalPosition.Status = PositionStatus.Filled;
await SetPositionStatus(signal.Identifier, PositionStatus.Filled);
await UpdatePositionInDatabaseAsync(internalPosition);
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionOpened, internalPosition);
}
}
}
protected override Task MonitorSynthRisk(LightSignal signal, Position position)
{
// Spot trading doesn't use Synth risk monitoring (futures-specific feature)
return Task.CompletedTask;
}
protected override Task<bool> RecoverOpenPositionFromBroker(LightSignal signal, Position positionForSignal)
{
// Spot trading doesn't have broker positions to recover
// Positions are token balances, not tracked positions
return Task.FromResult(false);
}
protected override async Task<bool> ReconcileWithBrokerHistory(Position position, Candle currentCandle)
{
// Spot-specific: reconcile with spot position history
try
{
await LogDebugAsync(
$"🔍 Fetching Spot Position History\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.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
});
// Find the most recent position in history
if (positionHistory != null && positionHistory.Any())
{
// Get the most recent position from spot history (ordered by date)
var brokerPosition = positionHistory
.OrderByDescending(p => p.Open?.Date ?? p.Date)
.FirstOrDefault();
// For spot trading, SHORT direction means the spot was sold/closed
// We need to verify the last position is SHORT to confirm the position was correctly closed
if (brokerPosition != null && brokerPosition.OriginDirection == TradeDirection.Short)
{
if (brokerPosition.ProfitAndLoss != null)
{
await LogDebugAsync(
$"✅ Spot Position History Found\n" +
$"Position: `{position.Identifier}`\n" +
$"Last Position Direction: `{brokerPosition.OriginDirection}` (SHORT = Sold/Closed) ✅\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 spot PnL data from broker history
// For spot, fees are simpler (no leverage, no closing UI fees)
var totalBotFees = position.GasFees + position.UiFees;
var brokerRealizedPnl = brokerPosition.ProfitAndLoss.Realized;
position.ProfitAndLoss = new ProfitAndLoss
{
Realized = brokerRealizedPnl,
Net = brokerRealizedPnl - 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 LogDebugAsync(
$"📊 Spot 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}`");
}
return true; // Successfully reconciled, skip candle-based calculation
}
}
else if (brokerPosition != null)
{
await LogDebugAsync(
$"⚠️ Last Position Not SHORT\n" +
$"Position: `{position.Identifier}`\n" +
$"Last Position Direction: `{brokerPosition.OriginDirection}`\n" +
$"Expected SHORT to confirm spot was sold/closed\n" +
$"Will continue with candle-based calculation");
}
}
return false; // No matching position found or not reconciled, continue with candle-based calculation
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reconciling spot position with broker history for position {PositionId}", position.Identifier);
await LogWarningAsync(
$"⚠️ Error During Spot Position History Reconciliation\n" +
$"Position: `{position.Identifier}`\n" +
$"Error: {ex.Message}\n" +
$"Will continue with candle-based calculation");
return false; // On error, continue with candle-based calculation
}
}
protected override Task<(decimal closingPrice, bool pnlCalculated)> CalculatePositionClosingFromCandles(
Position position, Candle? currentCandle, bool forceMarketClose, decimal? forcedClosingPrice)
{
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 spot trading, check if SL/TP was hit using candle data
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);
}
}
}
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 != null)
{
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 Task.FromResult((closingPrice, pnlCalculated));
}
protected override async Task UpdateSignalsCore(IReadOnlyList<Candle> candles,
Dictionary<IndicatorType, IndicatorsResultBase>? preCalculatedIndicatorValues = null)
{
// For spot trading, always fetch signals regardless of open positions
// Check if we're in cooldown period
if (await IsInCooldownPeriodAsync())
{
// Still in cooldown period, skip signal generation
return;
}
// Ensure account is loaded before accessing Account.Exchange
if (Account == null)
{
Logger.LogWarning("Cannot update signals: Account is null. Loading account...");
await LoadAccountAsync();
if (Account == null)
{
Logger.LogError("Cannot update signals: Account failed to load");
return;
}
}
// Live trading: use ScenarioRunnerGrain to get signals
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
{
var scenarioRunnerGrain = grainFactory.GetGrain<IScenarioRunnerGrain>(Guid.NewGuid());
// Create indicator combo config from user settings
var indicatorComboConfig = TradingBox.CreateConfigFromUserSettings(Account.User);
var signal = await scenarioRunnerGrain.GetSignals(Config, Signals, Account.Exchange, LastCandle, indicatorComboConfig);
if (signal == null) return;
await AddSignal(signal);
});
}
protected override async Task<bool> CanOpenPosition(LightSignal signal)
{
// For spot trading, only LONG signals can open positions
if (signal.Direction != TradeDirection.Long)
{
await LogInformationAsync(
$"🚫 Short Signal Ignored\nShort signals cannot open positions in spot trading\nSignal: `{signal.Identifier}` will be ignored");
return false;
}
// 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 override async Task<Position?> HandleFlipPosition(LightSignal signal, Position openedPosition,
LightSignal previousSignal, decimal lastPrice)
{
// For spot trading, SHORT signals should close the open LONG position
// LONG signals should not flip (they would be same direction)
if (signal.Direction == TradeDirection.Short && openedPosition.OriginDirection == TradeDirection.Long)
{
// SHORT signal closes the open LONG position
await LogInformationAsync(
$"🔻 Short Signal - Closing Long Position\nClosing position `{openedPosition.Identifier}` due to SHORT signal\nSignal: `{signal.Identifier}`");
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Finished);
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null; // No new position opened for SHORT signals
}
else if (signal.Direction == TradeDirection.Long && openedPosition.OriginDirection == TradeDirection.Long)
{
// Same direction LONG signal - ignore it
await LogInformationAsync(
$"📍 Same Direction Signal\nLONG signal `{signal.Identifier}` ignored\nPosition `{openedPosition.Identifier}` already open for LONG");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
else
{
// This shouldn't happen in spot trading, but handle it gracefully
await LogInformationAsync(
$"⚠️ Unexpected Signal Direction\nSignal: `{signal.Identifier}` Direction: `{signal.Direction}`\nPosition: `{openedPosition.Identifier}` Direction: `{openedPosition.OriginDirection}`\nSignal ignored");
SetSignalStatus(signal.Identifier, SignalStatus.Expired);
return null;
}
}
protected override async Task<Position> ExecuteOpenPosition(LightSignal signal, decimal lastPrice)
{
// Spot-specific position opening: includes balance verification and live exchange calls
if (signal.Direction != TradeDirection.Long)
{
throw new InvalidOperationException(
$"Only LONG signals can open positions in spot trading. Received: {signal.Direction}");
}
if (Account == null || Account.User == null)
{
throw new InvalidOperationException("Account and Account.User must be set before opening a position");
}
// Verify actual balance before opening position
await VerifyAndUpdateBalanceAsync();
var command = new OpenSpotPositionRequest(
Config.AccountName,
Config.MoneyManagement,
signal.Direction,
Config.Ticker,
PositionInitiator.Bot,
signal.Date,
Account.User,
Config.BotTradingBalance,
isForPaperTrading: false, // Spot is live trading
lastPrice,
signalIdentifier: signal.Identifier,
initiatorIdentifier: Identifier,
tradingType: Config.TradingType);
var position = await ServiceScopeHelpers
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
_scopeFactory,
async (exchangeService, accountService, tradingService) =>
await new OpenSpotPositionCommandHandler(exchangeService, accountService, tradingService)
.Handle(command));
return position;
}
public override async Task CloseTrade(LightSignal signal, Position position, Trade tradeToClose, decimal lastPrice,
bool tradeClosingPosition = false, bool forceMarketClose = false)
{
await LogInformationAsync(
$"🔧 Closing {position.OriginDirection} Spot Trade\nTicker: `{Config.Ticker}`\nPrice: `${lastPrice}`\n📋 Type: `{tradeToClose.TradeType}`\n📊 Quantity: `{tradeToClose.Quantity:F5}`");
// Live spot trading: close position via swap
var command = new CloseSpotPositionCommand(position, position.AccountId, lastPrice);
try
{
Position closedPosition = await ServiceScopeHelpers
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
_scopeFactory, async (exchangeService, accountService, tradingService) =>
await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService)
.Handle(command));
if (closedPosition.Status == PositionStatus.Finished || closedPosition.Status == PositionStatus.Flipped)
{
// Verify that token balance is cleared after closing (for live trading only)
if (!Config.IsForWatchingOnly && Config.TradingType != TradingType.BacktestSpot)
{
await VerifyTokenBalanceCleared(closedPosition);
}
if (tradeClosingPosition)
{
await SetPositionStatus(signal.Identifier, PositionStatus.Finished);
}
await HandleClosedPosition(closedPosition, forceMarketClose ? lastPrice : null,
forceMarketClose);
}
else
{
throw new Exception($"Wrong position status : {closedPosition.Status}");
}
}
catch (Exception ex)
{
await LogWarningAsync($"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 : null,
forceMarketClose);
}
}
}
private async Task VerifyTokenBalanceCleared(Position closedPosition)
{
try
{
// Wait a short moment for the swap to complete on-chain
await Task.Delay(2000); // 2 seconds delay
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
_scopeFactory,
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
// Token balance should be zero or very small (dust) after closing
var maxDustAmount = 0.0001m; // Consider amounts less than this as cleared
if (tokenBalance is { Amount: > 0 } && tokenBalance.Amount > maxDustAmount)
{
await LogWarningAsync(
$"⚠️ Token Balance Not Fully Cleared After Closing\n" +
$"Position: `{closedPosition.Identifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Remaining Token Balance: `{tokenBalance.Amount:F5}`\n" +
$"Expected: `0` or less than `{maxDustAmount:F5}` (dust)\n" +
$"Attempting to force close remaining balance...");
// Attempt to force close the remaining balance
await ForceCloseRemainingBalance(closedPosition, tokenBalance.Amount);
}
else
{
await LogDebugAsync(
$"✅ Token Balance Verified Cleared\n" +
$"Position: `{closedPosition.Identifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Token Balance: `{tokenBalance?.Amount ?? 0:F5}`\n" +
$"Position successfully closed on exchange");
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error verifying token balance cleared for position {PositionId}", closedPosition.Identifier);
// Don't throw - this is just a verification step
}
}
private async Task ForceCloseRemainingBalance(Position position, decimal remainingBalance)
{
try
{
// Prevent infinite retry loop - only attempt force close once
// The next bot cycle will handle verification
await LogInformationAsync(
$"🔄 Force Closing Remaining Balance (One-Time Attempt)\n" +
$"Position: `{position.Identifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Remaining Balance: `{remainingBalance:F5}`");
// Get current price for closing
var currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
_scopeFactory,
async exchangeService => await exchangeService.GetCurrentPrice(Account, Config.Ticker));
if (currentPrice <= 0)
{
await LogWarningAsync(
$"❌ Cannot Force Close\n" +
$"Current price is invalid: `{currentPrice}`\n" +
$"Will retry on next cycle");
return;
}
// Create a new command to close the remaining balance
var retryCommand = new CloseSpotPositionCommand(position, position.AccountId, currentPrice);
Position retryClosedPosition = await ServiceScopeHelpers
.WithScopedServices<IExchangeService, IAccountService, ITradingService, Position>(
_scopeFactory, async (exchangeService, accountService, tradingService) =>
await new CloseSpotPositionCommandHandler(exchangeService, accountService, tradingService)
.Handle(retryCommand));
if (retryClosedPosition != null &&
(retryClosedPosition.Status == PositionStatus.Finished || retryClosedPosition.Status == PositionStatus.Flipped))
{
await LogInformationAsync(
$"✅ Remaining Balance Force Closed Successfully\n" +
$"Position: `{position.Identifier}`\n" +
$"Cleared Balance: `{remainingBalance:F5}`\n" +
$"Close Price: `${currentPrice:F2}`");
// Verify one more time that balance is now cleared (without triggering another force close)
await Task.Delay(2000); // Wait for swap to complete
var finalBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
_scopeFactory,
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
if (finalBalance is { Amount: > 0.0001m })
{
await LogWarningAsync(
$"⚠️ Balance Still Remaining After Force Close Attempt\n" +
$"Position: `{position.Identifier}`\n" +
$"Remaining: `{finalBalance.Amount:F5}`\n" +
$"This will be handled on the next bot cycle\n" +
$"Manual intervention may be required if issue persists");
}
else
{
await LogInformationAsync(
$"✅ Balance Fully Cleared After Force Close\n" +
$"Position: `{position.Identifier}`\n" +
$"Final Balance: `{finalBalance?.Amount ?? 0:F5}`");
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error force closing remaining balance for position {PositionId}", position.Identifier);
await LogWarningAsync(
$"❌ Force Close Failed\n" +
$"Position: `{position.Identifier}`\n" +
$"Error: {ex.Message}\n" +
$"Manual intervention may be required");
}
}
}