Merge pull request #40 from CryptoOda/fix/spot-position-sync-dust-balance

Fix/spot position sync dust balance
This commit is contained in:
Oda
2026-01-09 03:55:36 +07:00
committed by GitHub
6 changed files with 186 additions and 35 deletions

View File

@@ -539,7 +539,7 @@ public class BotController : BaseController
WinRate = (item.TradeWins + item.TradeLosses) != 0 WinRate = (item.TradeWins + item.TradeLosses) != 0
? item.TradeWins / (item.TradeWins + item.TradeLosses) ? item.TradeWins / (item.TradeWins + item.TradeLosses)
: 0, : 0,
ProfitAndLoss = item.Pnl, ProfitAndLoss = item.NetPnL,
Roi = item.Roi, Roi = item.Roi,
Identifier = item.Identifier.ToString(), Identifier = item.Identifier.ToString(),
AgentName = item.User.AgentName, AgentName = item.User.AgentName,

View File

@@ -918,6 +918,7 @@ public class DataController : ControllerBase
public async Task<ActionResult<PaginatedResponse<TradingBotResponse>>> GetStrategiesPaginated( public async Task<ActionResult<PaginatedResponse<TradingBotResponse>>> GetStrategiesPaginated(
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10, int pageSize = 10,
BotStatus? status = null,
string? name = null, string? name = null,
string? ticker = null, string? ticker = null,
string? agentName = null, string? agentName = null,
@@ -943,11 +944,14 @@ public class DataController : ControllerBase
// Check environment variable for filtering profitable strategies only // Check environment variable for filtering profitable strategies only
var showOnlyProfitable = _configuration.GetValue<bool>("showOnlyProfitable", false); var showOnlyProfitable = _configuration.GetValue<bool>("showOnlyProfitable", false);
// Get paginated bots excluding Saved status // Default to Running status if not provided
var statusFilter = status ?? BotStatus.Running;
// Get paginated bots with status filter
var (bots, totalCount) = await _botService.GetBotsPaginatedAsync( var (bots, totalCount) = await _botService.GetBotsPaginatedAsync(
pageNumber, pageNumber,
pageSize, pageSize,
null, // No specific status filter - we'll exclude Saved in the service call statusFilter,
name, name,
ticker, ticker,
agentName, agentName,
@@ -957,9 +961,9 @@ public class DataController : ControllerBase
sortDirection, sortDirection,
showOnlyProfitable); showOnlyProfitable);
// Filter out Saved status bots // No additional filtering needed since we're using the status filter directly
var filteredBots = bots.Where(bot => bot.Status != BotStatus.Saved).ToList(); var filteredBots = bots.ToList();
var filteredCount = totalCount - bots.Count(bot => bot.Status == BotStatus.Saved); var filteredCount = totalCount;
// Map to response objects // Map to response objects
var tradingBotResponses = MapBotsToTradingBotResponse(filteredBots); var tradingBotResponses = MapBotsToTradingBotResponse(filteredBots);
@@ -1028,7 +1032,7 @@ public class DataController : ControllerBase
WinRate = (item.TradeWins + item.TradeLosses) != 0 WinRate = (item.TradeWins + item.TradeLosses) != 0
? item.TradeWins / (item.TradeWins + item.TradeLosses) ? item.TradeWins / (item.TradeWins + item.TradeLosses)
: 0, : 0,
ProfitAndLoss = item.Pnl, ProfitAndLoss = item.NetPnL,
Roi = item.Roi, Roi = item.Roi,
Identifier = item.Identifier.ToString(), Identifier = item.Identifier.ToString(),
AgentName = item.User.AgentName, AgentName = item.User.AgentName,

View File

@@ -593,6 +593,18 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
// Load state into the trading bot instance // Load state into the trading bot instance
LoadStateIntoTradingBot(tradingBot); LoadStateIntoTradingBot(tradingBot);
// Set up callback for immediate balance sync and save when balance is updated
tradingBot.OnBalanceUpdatedCallback = async () =>
{
SyncStateFromBase();
await _state.WriteStateAsync();
// Save to database immediately so GetStrategiesPaginated shows correct balance
var botRegistry = GrainFactory.GetGrain<ILiveBotRegistryGrain>(0);
var status = await botRegistry.GetBotStatus(this.GetPrimaryKey());
await SaveBotAsync(status);
};
return tradingBot; return tradingBot;
} }

View File

@@ -454,6 +454,75 @@ public class SpotBot : TradingBotBase
if (positionQuantity > 0) if (positionQuantity > 0)
{ {
// Check if token balance is very low (dust) compared to position quantity
// This likely means the position was closed but status wasn't updated
var dustThreshold = Config.Ticker == Ticker.ETH
? 0.01m // ETH: 0.01 ETH is likely gas reserve or dust
: 0.0001m; // Other tokens: very small amount is dust
var isDustAmount = tokenBalanceAmount <= dustThreshold;
var balanceIsVeryLow = tokenBalanceAmount < positionQuantity * 0.1m; // Less than 10% of expected
// If balance is dust or very low, check if opening swap failed or position was closed
if (isDustAmount || balanceIsVeryLow)
{
if (internalPosition.Status == PositionStatus.Filled)
{
// First, check if the opening swap actually succeeded (exists in history)
// If not, the swap likely failed and position should be marked as Canceled
var openingSwapConfirmed = await VerifyOpeningSwapInHistory(internalPosition);
if (!openingSwapConfirmed)
{
// Opening swap not found in history - likely failed
// Mark position as Canceled since it never actually opened
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 Not Found in History\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Signal: `{internalPosition.SignalIdentifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Expected Quantity: `{positionQuantity:F5}`\n" +
$"Token Balance: `{tokenBalanceAmount:F5}` (very low)\n" +
$"Status Changed: `{previousStatus}` → `Canceled`\n" +
$"The opening swap (USDC → {Config.Ticker}) was not found in exchange history\n" +
$"This indicates the swap failed and position never opened\n" +
$"Position will not be tracked or managed");
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
return; // Exit - position failed to open
}
// Opening swap exists, so position did open - check if it was closed
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)
{
// Position was closed - mark as Finished
internalPosition.Status = PositionStatus.Finished;
await HandleClosedPosition(internalPosition);
return;
}
}
}
// Only check tolerance if token balance is LESS than position quantity // Only check tolerance if token balance is LESS than position quantity
// If balance is greater, it could be orphaned tokens from previous positions // If balance is greater, it could be orphaned tokens from previous positions
if (tokenBalanceAmount < positionQuantity) if (tokenBalanceAmount < positionQuantity)
@@ -463,15 +532,19 @@ public class SpotBot : TradingBotBase
if (difference > tolerance) if (difference > tolerance)
{ {
await LogWarningAsync( // Only log warning if this is not a dust amount (already handled above)
$"⚠️ Token Balance Below Position Quantity\n" + if (!isDustAmount && !balanceIsVeryLow)
$"Position: `{internalPosition.Identifier}`\n" + {
$"Position Quantity: `{positionQuantity:F5}`\n" + await LogWarningAsync(
$"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"⚠️ Token Balance Below Position Quantity\n" +
$"Difference: `{difference:F5}`\n" + $"Position: `{internalPosition.Identifier}`\n" +
$"Tolerance (0.7%): `{tolerance:F5}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" +
$"Token balance is significantly lower than expected\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" +
$"Skipping position synchronization"); $"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 return; // Skip processing if balance is too low
} }
} }
@@ -490,25 +563,68 @@ public class SpotBot : TradingBotBase
} }
} }
// Token balance exists - verify the opening swap actually succeeded in history before marking as Filled // Token balance exists - ALWAYS verify the opening swap actually succeeded in history
// This is critical because Web3Proxy may return success when transaction is sent,
// but the swap might fail on-chain. We must verify it actually executed.
var previousPositionStatus = internalPosition.Status; var previousPositionStatus = internalPosition.Status;
// Only check history if position is not already Filled // Always verify the opening swap exists in history, even if position is already marked as Filled
if (internalPosition.Status != PositionStatus.Filled) // This catches cases where the position was prematurely marked as Filled by the command handler
bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition);
if (!swapConfirmedInHistory)
{ {
// Verify the opening swap actually executed successfully // Swap not found in history - check if this is a failed swap or just delayed
bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition); // If balance is very low, the swap likely failed
var dustThreshold = Config.Ticker == Ticker.ETH
? 0.01m // ETH: 0.01 ETH is likely gas reserve or dust
: 0.0001m; // Other tokens: very small amount is dust
if (!swapConfirmedInHistory) var isDustAmount = tokenBalanceAmount <= dustThreshold;
var balanceIsVeryLow = tokenBalanceAmount < positionQuantity * 0.1m; // Less than 10% of expected
if (isDustAmount || balanceIsVeryLow)
{ {
await LogDebugAsync( // Balance is very low and swap not in history - swap likely failed
$"⏳ Opening Swap Not Yet Confirmed in History\n" + // Mark position as Canceled since swap failed on-chain
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 Not Found in History\n" +
$"Position: `{internalPosition.Identifier}`\n" + $"Position: `{internalPosition.Identifier}`\n" +
$"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Signal: `{internalPosition.SignalIdentifier}`\n" +
$"Status: `{internalPosition.Status}`\n" + $"Ticker: {Config.Ticker}\n" +
$"Waiting for swap to appear in exchange history...\n" + $"Expected Quantity: `{positionQuantity:F5}`\n" +
$"Will retry on next cycle"); $"Token Balance: `{tokenBalanceAmount:F5}` (very low)\n" +
return; // Don't mark as Filled yet, wait for history confirmation $"Status Changed: `{previousStatus}` → `Canceled`\n" +
$"The opening swap (USDC → {Config.Ticker}) was not found in exchange history\n" +
$"This indicates the swap transaction failed on-chain even though it was sent\n" +
$"Position will not be tracked or managed");
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
return; // Exit - swap failed
}
else
{
// Balance exists but swap not yet in history - might be delayed
// Only wait if position is not already Filled (to avoid repeated checks)
if (internalPosition.Status != PositionStatus.Filled)
{
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
}
// If already Filled, continue - might be a timing issue with history indexing
} }
} }

View File

@@ -40,7 +40,6 @@ public abstract class TradingBotBase : ITradingBot
public Dictionary<string, LightSignal> Signals { get; set; } public Dictionary<string, LightSignal> Signals { get; set; }
public Dictionary<Guid, Position> Positions { get; set; } public Dictionary<Guid, Position> Positions { get; set; }
public Dictionary<DateTime, decimal> WalletBalances { get; set; } public Dictionary<DateTime, decimal> WalletBalances { get; set; }
private decimal _currentBalance;
public DateTime PreloadSince { get; set; } public DateTime PreloadSince { get; set; }
public int PreloadedCandlesCount { get; set; } public int PreloadedCandlesCount { get; set; }
public long ExecutionCount { get; set; } = 0; public long ExecutionCount { get; set; } = 0;
@@ -51,6 +50,12 @@ public abstract class TradingBotBase : ITradingBot
// OPTIMIZATION 2: Cache open position state to avoid expensive Positions.Any() calls // OPTIMIZATION 2: Cache open position state to avoid expensive Positions.Any() calls
private bool _hasOpenPosition = false; private bool _hasOpenPosition = false;
/// <summary>
/// Callback to notify the grain when balance is updated (for immediate sync and save to database).
/// Set by the grain after creating the bot instance.
/// </summary>
public Func<Task>? OnBalanceUpdatedCallback { get; set; }
public TradingBotBase( public TradingBotBase(
ILogger<TradingBotBase> logger, ILogger<TradingBotBase> logger,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
@@ -65,7 +70,6 @@ public abstract class TradingBotBase : ITradingBot
Signals = new Dictionary<string, LightSignal>(); Signals = new Dictionary<string, LightSignal>();
Positions = new Dictionary<Guid, Position>(); Positions = new Dictionary<Guid, Position>();
WalletBalances = new Dictionary<DateTime, decimal>(); WalletBalances = new Dictionary<DateTime, decimal>();
_currentBalance = config.BotTradingBalance;
PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe); PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe);
} }
@@ -433,13 +437,13 @@ public abstract class TradingBotBase : ITradingBot
if (WalletBalances.Count == 0) if (WalletBalances.Count == 0)
{ {
WalletBalances[date] = _currentBalance; WalletBalances[date] = Config.BotTradingBalance;
return; return;
} }
if (!WalletBalances.ContainsKey(date)) if (!WalletBalances.ContainsKey(date))
{ {
WalletBalances[date] = _currentBalance; WalletBalances[date] = Config.BotTradingBalance;
} }
} }
@@ -1270,13 +1274,15 @@ public abstract class TradingBotBase : ITradingBot
if (position.ProfitAndLoss != null) if (position.ProfitAndLoss != null)
{ {
// Update the current balance when position closes // Update the balance when position closes
_currentBalance += position.ProfitAndLoss.Net;
Config.BotTradingBalance += position.ProfitAndLoss.Net; Config.BotTradingBalance += position.ProfitAndLoss.Net;
await LogDebugAsync( await LogDebugAsync(
string.Format("💰 Balance Updated\nNew bot trading balance: `${0:F2}`", string.Format("💰 Balance Updated\nNew bot trading balance: `${0:F2}`",
Config.BotTradingBalance)); Config.BotTradingBalance));
// For live trading, immediately sync and save to database
await OnBalanceUpdatedAsync();
} }
} }
else else
@@ -2060,6 +2066,18 @@ public abstract class TradingBotBase : ITradingBot
await CancelAllOrders(); await CancelAllOrders();
} }
/// <summary>
/// Called when the bot trading balance is updated (e.g., after a position closes).
/// Calls the OnBalanceUpdatedCallback if set (by the grain for live trading).
/// </summary>
protected virtual async Task OnBalanceUpdatedAsync()
{
if (OnBalanceUpdatedCallback != null)
{
await OnBalanceUpdatedCallback();
}
}
// Interface implementation // Interface implementation
public async Task LogInformation(string message) public async Task LogInformation(string message)
{ {

View File

@@ -83,6 +83,7 @@ public class PostgreSqlBotRepository : IBotRepository
existingEntity.AccumulatedRunTimeSeconds = bot.AccumulatedRunTimeSeconds; existingEntity.AccumulatedRunTimeSeconds = bot.AccumulatedRunTimeSeconds;
existingEntity.MasterBotUserId = existingEntity.MasterBotUserId =
bot.MasterBotUserId ?? existingEntity.MasterBotUserId; bot.MasterBotUserId ?? existingEntity.MasterBotUserId;
existingEntity.BotTradingBalance = bot.BotTradingBalance;
await _context.SaveChangesAsync().ConfigureAwait(false); await _context.SaveChangesAsync().ConfigureAwait(false);
} }