- Added logic to check if remaining token balances are below $2 USD and verified in exchange history before logging warnings or accepting them as successfully closed. - Improved logging messages for better clarity on the status of token balances after closing positions and force close attempts, ensuring accurate tracking of transactions.
1680 lines
80 KiB
C#
1680 lines
80 KiB
C#
#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
|
||
// ticker parameter is a string representation of the position's ticker
|
||
var tickerEnum = Enum.Parse<Ticker>(ticker);
|
||
return await ServiceScopeHelpers.WithScopedService<IExchangeService, Candle>(_scopeFactory,
|
||
async exchangeService => await exchangeService.GetCandle(Account, tickerEnum, 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
|
||
// For ETH, use a higher threshold since gas reserves are expected to be significant
|
||
// For other tokens, use a lower threshold
|
||
decimal minOrphanedBalanceValue = Config.Ticker == Ticker.ETH
|
||
? 100m // ETH: $100 threshold (gas reserves can be $20-50+, so this is safe)
|
||
: 10m; // Other tokens: $10 threshold
|
||
|
||
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" +
|
||
$"{(Config.Ticker == Ticker.ETH ? "(ETH gas reserve - expected behavior)" : "(Dust amount)")}\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.007m; // 0.7% tolerance for slippage and gas reserve
|
||
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);
|
||
|
||
// Only update quantity if actual balance is less than position quantity (slippage loss)
|
||
// Don't update if balance is higher (likely includes gas reserve for ETH)
|
||
if (tokenBalance < positionQuantity)
|
||
{
|
||
await LogInformationAsync(
|
||
$"📉 Adjusting Position Quantity Due to Slippage\n" +
|
||
$"Original Quantity: `{positionQuantity:F5}`\n" +
|
||
$"Actual Balance: `{tokenBalance:F5}`\n" +
|
||
$"Difference: `{difference:F5}`");
|
||
lastPosition.Open.Quantity = tokenBalance;
|
||
}
|
||
else
|
||
{
|
||
await LogInformationAsync(
|
||
$"ℹ️ Keeping Original Position Quantity\n" +
|
||
$"Position Quantity: `{positionQuantity:F5}`\n" +
|
||
$"Actual Balance: `{tokenBalance:F5}`\n" +
|
||
$"Not updating (balance includes gas reserve or is within tolerance)");
|
||
}
|
||
|
||
// 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
|
||
{
|
||
// First, check if the opening swap was canceled by the broker
|
||
// This prevents confusing warning messages about token balance mismatches
|
||
if (internalPosition.Status == PositionStatus.New)
|
||
{
|
||
var swapWasCanceled = await CheckIfOpeningSwapWasCanceled(internalPosition);
|
||
if (swapWasCanceled)
|
||
{
|
||
// Mark position as Canceled
|
||
var previousStatus = internalPosition.Status;
|
||
internalPosition.Status = PositionStatus.Canceled;
|
||
internalPosition.Open.SetStatus(TradeStatus.Cancelled);
|
||
positionForSignal.Open.SetStatus(TradeStatus.Cancelled);
|
||
await SetPositionStatus(internalPosition.SignalIdentifier, PositionStatus.Canceled);
|
||
|
||
await UpdatePositionInDatabaseAsync(internalPosition);
|
||
|
||
await LogWarningAsync(
|
||
$"❌ Position Opening Failed - Swap Canceled by Broker\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Signal: `{internalPosition.SignalIdentifier}`\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Expected Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
|
||
$"Status Changed: `{previousStatus}` → `Canceled`\n" +
|
||
$"The opening swap (USDC → {Config.Ticker}) was canceled by the ExchangeRouter contract\n" +
|
||
$"Position will not be tracked or managed");
|
||
|
||
// Notify about the canceled position (using PositionClosed as PositionCanceled doesn't exist)
|
||
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
|
||
|
||
return; // Exit - no further synchronization needed
|
||
}
|
||
}
|
||
|
||
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
|
||
var positionQuantity = internalPosition.Open.Quantity;
|
||
var tokenBalanceAmount = tokenBalance.Amount;
|
||
|
||
if (positionQuantity > 0)
|
||
{
|
||
// Only check tolerance if token balance is LESS than position quantity
|
||
// If balance is greater, it could be orphaned tokens from previous positions
|
||
if (tokenBalanceAmount < positionQuantity)
|
||
{
|
||
var tolerance = positionQuantity * 0.007m; // 0.7% tolerance to account for slippage and gas reserve
|
||
var difference = positionQuantity - tokenBalanceAmount;
|
||
|
||
if (difference > tolerance)
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Token Balance Below Position Quantity\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Position Quantity: `{positionQuantity:F5}`\n" +
|
||
$"Token Balance: `{tokenBalanceAmount:F5}`\n" +
|
||
$"Difference: `{difference:F5}`\n" +
|
||
$"Tolerance (0.7%): `{tolerance:F5}`\n" +
|
||
$"Token balance is significantly lower than expected\n" +
|
||
$"Skipping position synchronization");
|
||
return; // Skip processing if balance is too low
|
||
}
|
||
}
|
||
else if (tokenBalanceAmount > positionQuantity)
|
||
{
|
||
// Token balance is higher than position - likely orphaned tokens
|
||
// Log but continue with synchronization
|
||
var excess = tokenBalanceAmount - positionQuantity;
|
||
await LogDebugAsync(
|
||
$"ℹ️ Token Balance Exceeds Position Quantity\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Position Quantity: `{positionQuantity:F5}`\n" +
|
||
$"Token Balance: `{tokenBalanceAmount:F5}`\n" +
|
||
$"Excess: `{excess:F5}`\n" +
|
||
$"Proceeding with position synchronization");
|
||
}
|
||
}
|
||
|
||
// Token balance exists - verify the opening swap actually succeeded in history before marking as Filled
|
||
var previousPositionStatus = internalPosition.Status;
|
||
|
||
// Only check history if position is not already Filled
|
||
if (internalPosition.Status != PositionStatus.Filled)
|
||
{
|
||
// Verify the opening swap actually executed successfully
|
||
bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition);
|
||
|
||
if (!swapConfirmedInHistory)
|
||
{
|
||
await LogDebugAsync(
|
||
$"⏳ Opening Swap Not Yet Confirmed in History\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Token Balance: `{tokenBalanceAmount:F5}`\n" +
|
||
$"Status: `{internalPosition.Status}`\n" +
|
||
$"Waiting for swap to appear in exchange history...\n" +
|
||
$"Will retry on next cycle");
|
||
return; // Don't mark as Filled yet, wait for history confirmation
|
||
}
|
||
}
|
||
|
||
// Position confirmed on broker (token balance exists AND swap confirmed in history)
|
||
// 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 only if difference is within acceptable tolerance
|
||
// For ETH, we need to be careful not to include gas reserve in the position quantity
|
||
var actualTokenBalance = tokenBalance.Amount;
|
||
var quantityTolerance = internalPosition.Open.Quantity * 0.007m; // 0.7% tolerance for slippage and gas reserve
|
||
var quantityDifference = Math.Abs(internalPosition.Open.Quantity - actualTokenBalance);
|
||
|
||
// Only update if the actual balance is LESS than expected (slippage loss)
|
||
// Don't update if balance is higher (likely includes gas reserve or orphaned tokens)
|
||
if (actualTokenBalance < internalPosition.Open.Quantity && quantityDifference > quantityTolerance)
|
||
{
|
||
await LogDebugAsync(
|
||
$"🔄 Token Balance Lower Than Expected (Slippage)\n" +
|
||
$"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
|
||
$"Broker Balance: `{actualTokenBalance:F5}`\n" +
|
||
$"Difference: `{quantityDifference:F5}`\n" +
|
||
$"Tolerance (0.7%): `{quantityTolerance:F5}`\n" +
|
||
$"Updating to match actual balance");
|
||
internalPosition.Open.Quantity = actualTokenBalance;
|
||
positionForSignal.Open.Quantity = actualTokenBalance;
|
||
}
|
||
else if (actualTokenBalance > internalPosition.Open.Quantity)
|
||
{
|
||
await LogDebugAsync(
|
||
$"ℹ️ Token Balance Higher Than Position Quantity\n" +
|
||
$"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
|
||
$"Broker Balance: `{actualTokenBalance:F5}`\n" +
|
||
$"Difference: `{quantityDifference:F5}`\n" +
|
||
$"Keeping original position quantity (extra balance likely gas reserve or orphaned tokens)");
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
private async Task<bool> VerifyOpeningSwapInHistory(Position position)
|
||
{
|
||
try
|
||
{
|
||
await LogDebugAsync(
|
||
$"🔍 Verifying Opening Swap in History\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Ticker: `{Config.Ticker}`\n" +
|
||
$"Expected Quantity: `{position.Open.Quantity:F5}`");
|
||
|
||
// Get swap history from exchange
|
||
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
|
||
_scopeFactory,
|
||
async exchangeService =>
|
||
{
|
||
// Check swaps from 1 hour before position date to now
|
||
var fromDate = position.Date.AddHours(-1);
|
||
var toDate = DateTime.UtcNow;
|
||
return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
|
||
});
|
||
|
||
if (positionHistory == null || positionHistory.Count == 0)
|
||
{
|
||
await LogDebugAsync(
|
||
$"ℹ️ No History Found Yet\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Swap may still be pending or history not yet indexed");
|
||
return false;
|
||
}
|
||
|
||
// For a LONG position, the opening swap should be LONG (USDC → Token)
|
||
// Find a matching swap in the history
|
||
var openingSwaps = positionHistory
|
||
.Where(p => p.OriginDirection == TradeDirection.Long &&
|
||
Math.Abs((p.Date - position.Date).TotalMinutes) < 10) // Within 10 minutes of position creation
|
||
.OrderBy(p => Math.Abs((p.Date - position.Date).TotalSeconds))
|
||
.ToList();
|
||
|
||
if (!openingSwaps.Any())
|
||
{
|
||
await LogDebugAsync(
|
||
$"ℹ️ No Matching Opening Swap Found in History\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Position Date: `{position.Date}`\n" +
|
||
$"Searched {positionHistory.Count} history entries\n" +
|
||
$"No LONG swaps found within 10 minutes of position creation");
|
||
return false;
|
||
}
|
||
|
||
// Check if any of the swaps match our position quantity (with tolerance)
|
||
var matchingSwap = openingSwaps.FirstOrDefault(swap =>
|
||
{
|
||
if (position.Open.Quantity == 0) return false;
|
||
var quantityDifference = Math.Abs(swap.Open.Quantity - position.Open.Quantity) / position.Open.Quantity;
|
||
return quantityDifference < 0.02m; // Within 2% tolerance
|
||
});
|
||
|
||
if (matchingSwap != null)
|
||
{
|
||
await LogDebugAsync(
|
||
$"✅ Opening Swap Confirmed in History\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Swap Date: `{matchingSwap.Date}`\n" +
|
||
$"Swap Quantity: `{matchingSwap.Open.Quantity:F5}`\n" +
|
||
$"Expected Quantity: `{position.Open.Quantity:F5}`\n" +
|
||
$"Swap successfully executed");
|
||
return true;
|
||
}
|
||
|
||
// Found swaps around the time, but none match the quantity
|
||
await LogDebugAsync(
|
||
$"⚠️ Found Swaps But None Match Position Quantity\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Expected Quantity: `{position.Open.Quantity:F5}`\n" +
|
||
$"Found {openingSwaps.Count} LONG swaps around position creation time\n" +
|
||
$"But none match the quantity (within 2% tolerance)");
|
||
return false;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error verifying opening swap in history for position {PositionId}", position.Identifier);
|
||
await LogWarningAsync(
|
||
$"⚠️ Error Verifying Opening Swap\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Error: {ex.Message}\n" +
|
||
$"Assuming swap not yet confirmed, will retry on next cycle");
|
||
return false; // On error, don't assume swap succeeded
|
||
}
|
||
}
|
||
|
||
private async Task<bool> CheckIfOpeningSwapWasCanceled(Position position)
|
||
{
|
||
try
|
||
{
|
||
// Only check for canceled swaps if position is in New status
|
||
// (positions that haven't been filled yet)
|
||
if (position.Status != PositionStatus.New)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
await LogDebugAsync(
|
||
$"🔍 Checking for Canceled Opening Swap\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Ticker: `{Config.Ticker}`");
|
||
|
||
// Get swap history from exchange to check for canceled orders
|
||
// We need to check if the opening swap (USDC -> ETH for LONG) was canceled
|
||
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
|
||
_scopeFactory,
|
||
async exchangeService =>
|
||
{
|
||
// Check swaps from 1 hour before position date to now
|
||
// This covers the time window when the swap could have been canceled
|
||
var fromDate = position.Date.AddHours(-1);
|
||
var toDate = DateTime.UtcNow;
|
||
return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
|
||
});
|
||
|
||
if (positionHistory == null || positionHistory.Count == 0)
|
||
{
|
||
// No history found - swap might still be pending
|
||
return false;
|
||
}
|
||
|
||
// For a LONG position, the opening swap should be LONG (USDC -> Token)
|
||
// If we find a LONG swap around the position creation time, check if it was actually executed
|
||
var openingSwaps = positionHistory
|
||
.Where(p => p.OriginDirection == TradeDirection.Long &&
|
||
Math.Abs((p.Date - position.Date).TotalMinutes) < 10) // Within 10 minutes of position creation
|
||
.OrderBy(p => Math.Abs((p.Date - position.Date).TotalSeconds))
|
||
.ToList();
|
||
|
||
if (openingSwaps.Any())
|
||
{
|
||
// We found swap(s) around the position creation time
|
||
// If the quantity matches our expected position quantity, this is our opening swap
|
||
var matchingSwap = openingSwaps.FirstOrDefault(swap =>
|
||
Math.Abs(swap.Open.Quantity - position.Open.Quantity) / position.Open.Quantity < 0.02m); // Within 2% tolerance
|
||
|
||
if (matchingSwap != null)
|
||
{
|
||
// Found the matching opening swap - it was executed successfully
|
||
await LogDebugAsync(
|
||
$"✅ Opening Swap Found and Executed\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Swap Quantity: `{matchingSwap.Open.Quantity:F5}`\n" +
|
||
$"Expected Quantity: `{position.Open.Quantity:F5}`\n" +
|
||
$"Swap was successful");
|
||
return false; // Swap was not canceled
|
||
}
|
||
}
|
||
|
||
// If we reach here, we didn't find a matching executed swap
|
||
// This likely means the swap was canceled by the broker
|
||
// Double-check by verifying the token balance is significantly lower than expected
|
||
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
|
||
_scopeFactory,
|
||
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
|
||
|
||
if (tokenBalance != null && position.Open.Quantity > 0)
|
||
{
|
||
var tolerance = position.Open.Quantity * 0.10m; // 10% tolerance
|
||
var difference = position.Open.Quantity - tokenBalance.Amount;
|
||
|
||
if (difference > tolerance)
|
||
{
|
||
// Token balance is significantly lower than expected position quantity
|
||
// This confirms the swap was likely canceled
|
||
await LogWarningAsync(
|
||
$"❌ Opening Swap Appears to be Canceled by Broker\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Expected Quantity: `{position.Open.Quantity:F5}`\n" +
|
||
$"Actual Token Balance: `{tokenBalance.Amount:F5}`\n" +
|
||
$"Difference: `{difference:F5}` (exceeds 10% tolerance)\n" +
|
||
$"No matching executed swap found in history\n" +
|
||
$"Position will be marked as Canceled");
|
||
return true; // Swap was canceled
|
||
}
|
||
}
|
||
|
||
return false; // Swap status unclear - don't assume it was canceled
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError(ex, "Error checking if opening swap was canceled for position {PositionId}", position.Identifier);
|
||
await LogWarningAsync(
|
||
$"⚠️ Error Checking for Canceled Swap\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Error: {ex.Message}");
|
||
return false; // On error, don't assume swap was canceled
|
||
}
|
||
}
|
||
|
||
protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition,
|
||
Position positionForSignal)
|
||
{
|
||
// Spot trading doesn't use orders like futures - positions are opened via swaps
|
||
// Check if the opening swap was successful or canceled
|
||
if (internalPosition.Status == PositionStatus.New)
|
||
{
|
||
// First, check if the opening swap was canceled by the broker
|
||
var swapWasCanceled = await CheckIfOpeningSwapWasCanceled(internalPosition);
|
||
if (swapWasCanceled)
|
||
{
|
||
// Mark position as Canceled
|
||
internalPosition.Status = PositionStatus.Canceled;
|
||
internalPosition.Open.SetStatus(TradeStatus.Cancelled);
|
||
positionForSignal.Open.SetStatus(TradeStatus.Cancelled);
|
||
await SetPositionStatus(signal.Identifier, PositionStatus.Canceled);
|
||
|
||
await UpdatePositionInDatabaseAsync(internalPosition);
|
||
|
||
await LogWarningAsync(
|
||
$"❌ Position Opening Failed - Swap Canceled by Broker\n" +
|
||
$"Position: `{internalPosition.Identifier}`\n" +
|
||
$"Signal: `{signal.Identifier}`\n" +
|
||
$"The opening swap was canceled by the ExchangeRouter contract\n" +
|
||
$"Position status set to Canceled");
|
||
|
||
// Notify about the canceled position (using PositionClosed as PositionCanceled doesn't exist)
|
||
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
|
||
|
||
return; // Exit - position is canceled
|
||
}
|
||
|
||
// 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;
|
||
|
||
// If broker PNL is 0 or invalid, calculate it ourselves using actual prices
|
||
if (brokerRealizedPnl == 0 && brokerPosition.Open != null)
|
||
{
|
||
var entryPrice = position.Open.Price;
|
||
var exitPrice = brokerPosition.Open.Price;
|
||
var quantity = position.Open.Quantity;
|
||
|
||
// Calculate PNL: (exitPrice - entryPrice) * quantity for LONG
|
||
brokerRealizedPnl = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, 1, position.OriginDirection);
|
||
|
||
await LogDebugAsync(
|
||
$"⚠️ Broker PNL was 0, calculated from prices\n" +
|
||
$"Entry Price: `${entryPrice:F2}` | Exit Price: `${exitPrice:F2}`\n" +
|
||
$"Quantity: `{quantity:F8}`\n" +
|
||
$"Calculated PNL: `${brokerRealizedPnl:F2}`");
|
||
}
|
||
|
||
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;
|
||
|
||
// If brokerClosingPrice is 0 or invalid, calculate it from PNL
|
||
// This handles cases where very small prices (like PEPE) lose precision
|
||
if (brokerClosingPrice <= 0 && brokerPosition.ProfitAndLoss != null &&
|
||
brokerPosition.ProfitAndLoss.Realized != 0 && position.Open != null)
|
||
{
|
||
// Calculate closing price from PNL formula
|
||
// For LONG: PNL = (exitPrice - entryPrice) * quantity * leverage
|
||
// exitPrice = (PNL / (quantity * leverage)) + entryPrice
|
||
// For SHORT: PNL = (entryPrice - exitPrice) * quantity * leverage
|
||
// exitPrice = entryPrice - (PNL / (quantity * leverage))
|
||
var realizedPnl = brokerPosition.ProfitAndLoss.Realized;
|
||
var entryPrice = position.Open.Price;
|
||
var quantity = position.Open.Quantity;
|
||
var leverage = position.Open.Leverage;
|
||
|
||
if (quantity > 0 && leverage > 0)
|
||
{
|
||
var pnlPerUnit = realizedPnl / (quantity * leverage);
|
||
|
||
if (position.OriginDirection == TradeDirection.Long)
|
||
{
|
||
brokerClosingPrice = entryPrice + pnlPerUnit;
|
||
}
|
||
else // Short
|
||
{
|
||
brokerClosingPrice = entryPrice - pnlPerUnit;
|
||
}
|
||
|
||
await LogDebugAsync(
|
||
$"⚠️ Broker closing price was 0, calculated from PNL\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Entry Price: `${entryPrice:F8}`\n" +
|
||
$"PNL: `${realizedPnl:F8}`\n" +
|
||
$"Quantity: `{quantity:F8}`\n" +
|
||
$"Leverage: `{leverage}`\n" +
|
||
$"Calculated Closing Price: `${brokerClosingPrice:F8}`");
|
||
}
|
||
}
|
||
|
||
// Only update TP/SL prices if we have a valid closing price
|
||
if (brokerClosingPrice > 0)
|
||
{
|
||
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:F8}`\n" +
|
||
$"Used: `{(isProfitable ? "Take Profit" : "Stop Loss")}`\n" +
|
||
$"PnL from broker: `${position.ProfitAndLoss.Realized:F8}`");
|
||
}
|
||
else
|
||
{
|
||
await LogWarningAsync(
|
||
$"⚠️ Cannot update closing trade price - broker price is invalid\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Broker PNL: `{brokerPosition.ProfitAndLoss?.Realized:F8}`\n" +
|
||
$"Entry Price: `${position.Open?.Price:F8}`\n" +
|
||
$"Broker Closing Price: `{brokerPosition.Open.Price}`\n" +
|
||
$"Will skip updating TP/SL prices to avoid zero-price fills");
|
||
}
|
||
}
|
||
|
||
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}`");
|
||
|
||
try
|
||
{
|
||
await CloseTrade(previousSignal, openedPosition, openedPosition.Open, lastPrice, true);
|
||
// Only mark as Finished if close was successful
|
||
await SetPositionStatus(previousSignal.Identifier, PositionStatus.Finished);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await LogWarningAsync(
|
||
$"❌ Failed to Close Position on SHORT Signal\n" +
|
||
$"Position: `{openedPosition.Identifier}`\n" +
|
||
$"Signal: `{signal.Identifier}`\n" +
|
||
$"Error: {ex.Message}\n" +
|
||
$"Position status NOT changed - will retry on next cycle");
|
||
// Don't change position status if close failed
|
||
// The position will be retried on the next bot cycle
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Wait for swap to settle and refresh USDC balance
|
||
// This prevents the bot from stopping due to "low USDC" before the ETH→USDC swap completes
|
||
if (!Config.IsForWatchingOnly && Config.TradingType != TradingType.BacktestSpot)
|
||
{
|
||
await LogDebugAsync(
|
||
$"⏳ Waiting for swap to settle and refreshing USDC balance...\n" +
|
||
$"Position: `{closedPosition.Identifier}`");
|
||
|
||
// Wait 3 seconds for the swap to settle on-chain
|
||
await Task.Delay(3000);
|
||
|
||
// Refresh USDC balance to reflect the swapped tokens
|
||
await VerifyAndUpdateBalanceAsync();
|
||
|
||
// Invalidate AgentGrain balance cache to force fresh fetch on next balance check
|
||
// This ensures the bot won't stop due to "low USDC" using stale cached data
|
||
await ServiceScopeHelpers.WithScopedService<IGrainFactory>(_scopeFactory, async grainFactory =>
|
||
{
|
||
var agentGrain = grainFactory.GetGrain<IAgentGrain>(Account.User.Id);
|
||
await agentGrain.ForceUpdateSummaryImmediate();
|
||
});
|
||
|
||
await LogDebugAsync($"✅ Balance refreshed and cache invalidated after position close");
|
||
}
|
||
|
||
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);
|
||
}
|
||
else
|
||
{
|
||
// Re-throw exception for other cases so caller knows the operation failed
|
||
throw;
|
||
}
|
||
}
|
||
}
|
||
|
||
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));
|
||
|
||
// For ETH, remaining balance is expected (gas reserve) - use higher threshold
|
||
// For other tokens, very small dust amounts are acceptable
|
||
var maxDustAmount = Config.Ticker == Ticker.ETH
|
||
? 0.01m // ETH: up to 0.01 ETH is acceptable (gas reserve)
|
||
: 0.0001m; // Other tokens: only dust amounts acceptable
|
||
|
||
if (tokenBalance is { Amount: > 0 } && tokenBalance.Amount > maxDustAmount)
|
||
{
|
||
// Check if remaining balance is small enough (< $2 USD) and verified in history
|
||
if (tokenBalance.Value < 2m)
|
||
{
|
||
// Check if the closing swap exists in history
|
||
var (sellFoundInHistory, _) = await CheckSpotPositionInExchangeHistory(closedPosition);
|
||
|
||
if (sellFoundInHistory)
|
||
{
|
||
await LogDebugAsync(
|
||
$"✅ Small Leftover Accepted - Position Verified Closed\n" +
|
||
$"Position: `{closedPosition.Identifier}`\n" +
|
||
$"Ticker: {Config.Ticker}\n" +
|
||
$"Remaining Token Balance: `{tokenBalance.Amount:F5}`\n" +
|
||
$"USD Value: `${tokenBalance.Value:F2}` (below $2 threshold)\n" +
|
||
$"Sell transaction confirmed in exchange history\n" +
|
||
$"Accepting as successfully closed - leftover is likely slippage/rounding");
|
||
return; // Position is verified closed, no force close needed
|
||
}
|
||
}
|
||
|
||
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" +
|
||
$"USD Value: `${tokenBalance.Value:F2}`\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" +
|
||
$"{(Config.Ticker == Ticker.ETH && tokenBalance?.Amount > 0 ? "(Gas reserve - expected)" : "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 })
|
||
{
|
||
// Check if remaining balance is small enough (< $2 USD) and verified in history
|
||
if (finalBalance.Value < 2m)
|
||
{
|
||
var (sellFoundInHistory, _) = await CheckSpotPositionInExchangeHistory(position);
|
||
|
||
if (sellFoundInHistory)
|
||
{
|
||
await LogInformationAsync(
|
||
$"✅ Small Leftover Accepted After Force Close\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Remaining: `{finalBalance.Amount:F5}`\n" +
|
||
$"USD Value: `${finalBalance.Value:F2}` (below $2 threshold)\n" +
|
||
$"Sell transaction confirmed in exchange history\n" +
|
||
$"Accepting as successfully closed - leftover is likely slippage/rounding");
|
||
return;
|
||
}
|
||
}
|
||
|
||
await LogWarningAsync(
|
||
$"⚠️ Balance Still Remaining After Force Close Attempt\n" +
|
||
$"Position: `{position.Identifier}`\n" +
|
||
$"Remaining: `{finalBalance.Amount:F5}`\n" +
|
||
$"USD Value: `${finalBalance.Value:F2}`\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");
|
||
}
|
||
}
|
||
} |