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:
@@ -182,14 +182,24 @@ public class SpotBot : TradingBotBase
|
|||||||
|
|
||||||
if (tokenBalance is { Value: > 1m })
|
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(
|
await LogWarningAsync(
|
||||||
$"⚠️ Orphaned Token Balance Detected\n" +
|
$"⚠️ Orphaned Token Balance Detected\n" +
|
||||||
$"Ticker: {Config.Ticker}\n" +
|
$"Ticker: {Config.Ticker}\n" +
|
||||||
$"Token balance: `{tokenBalance.Amount:F5}`\n" +
|
$"Token balance: `{tokenBalance.Amount:F5}`\n" +
|
||||||
$"But no internal position tracked\n" +
|
$"But no internal position tracked\n" +
|
||||||
$"This may require manual cleanup");
|
$"Attempting to recover position from database...");
|
||||||
return false; // Don't allow opening new position until resolved
|
|
||||||
|
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
|
// 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()
|
protected override async Task LoadAccountAsync()
|
||||||
{
|
{
|
||||||
// Live trading: load real account from database
|
// Live trading: load real account from database
|
||||||
|
|||||||
Reference in New Issue
Block a user