Enhance SpotBot to improve position recovery logic and add token balance verification after closing positions. The changes include filtering for recent unfinished positions and logging detailed information during position closure, ensuring better tracking and error handling for token balances.

This commit is contained in:
2025-12-29 16:39:02 +07:00
parent 493a2be368
commit ee8db1cdc8
2 changed files with 74 additions and 5 deletions

View File

@@ -221,11 +221,21 @@ public class SpotBot : TradingBotBase
_scopeFactory,
async tradingService => await tradingService.GetPositionsByInitiatorIdentifierAsync(Identifier));
// Find the last position for this account and ticker (regardless of status)
// The orphaned token balance indicates this position should actually be open
// 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.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();
@@ -233,8 +243,9 @@ public class SpotBot : TradingBotBase
{
await LogWarningAsync(
$"🔍 No Recoverable Position Found\n" +
$"No unfinished position found in database for this ticker\n" +
$"Token balance: `{tokenBalance:F5}` may be from external source");
$"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;
}
@@ -976,6 +987,12 @@ public class SpotBot : TradingBotBase
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);
@@ -1003,4 +1020,44 @@ public class SpotBot : TradingBotBase
}
}
}
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" +
$"Position may not have been fully closed on exchange");
}
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
}
}
}

View File

@@ -57,6 +57,11 @@ public class CloseSpotPositionCommandHandler(
}
amountToSwap = tokenBalance.Amount;
logger?.LogInformation(
"Closing spot position: PositionId={PositionId}, Ticker={Ticker}, TokenBalance={TokenBalance}, Swapping to USDC",
request.Position.Identifier, request.Position.Ticker, amountToSwap);
swapResult = await tradingService.SwapGmxTokensAsync(
request.Position.User,
account.Name,
@@ -70,10 +75,17 @@ public class CloseSpotPositionCommandHandler(
if (!swapResult.Success)
{
logger?.LogError(
"Failed to close spot position: PositionId={PositionId}, Ticker={Ticker}, Error={Error}",
request.Position.Identifier, request.Position.Ticker, swapResult.Error ?? swapResult.Message);
throw new InvalidOperationException(
$"Failed to close spot position: {swapResult.Error ?? swapResult.Message}");
}
logger?.LogInformation(
"Spot position swap completed successfully: PositionId={PositionId}, Ticker={Ticker}, AmountSwapped={AmountSwapped}, Hash={Hash}",
request.Position.Identifier, request.Position.Ticker, amountToSwap, swapResult.Hash);
// Build the closing trade directly for backtest (no exchange call needed)
var closedTrade = exchangeService.BuildEmptyTrade(
request.Position.Open.Ticker,