Implement orphaned position recovery in SpotBot; enhance logging for recovery attempts and failures, and ensure position synchronization with token balance checks.

This commit is contained in:
2025-12-24 20:52:08 +07:00
parent 667ac44b03
commit 2db6cc9033

View File

@@ -182,14 +182,24 @@ public class SpotBot : TradingBotBase
if (tokenBalance is { Value: > 1m })
{
// We have a token balance but no internal position - orphaned position
// We have a 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}`\n" +
$"But no internal position tracked\n" +
$"This may require manual cleanup");
return false; // Don't allow opening new position until resolved
$"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
@@ -202,6 +212,135 @@ public class SpotBot : TradingBotBase
}
}
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));
// Find the last position for this account and ticker (regardless of status)
// The orphaned token balance indicates this position should actually be open
var lastPosition = allPositions
.Where(p => p.AccountId == Account.Id &&
p.Ticker == Config.Ticker)
.OrderByDescending(p => p.Date)
.FirstOrDefault();
if (lastPosition == null)
{
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");
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