Merge pull request #40 from CryptoOda/fix/spot-position-sync-dust-balance
Fix/spot position sync dust balance
This commit is contained in:
@@ -539,7 +539,7 @@ public class BotController : BaseController
|
||||
WinRate = (item.TradeWins + item.TradeLosses) != 0
|
||||
? item.TradeWins / (item.TradeWins + item.TradeLosses)
|
||||
: 0,
|
||||
ProfitAndLoss = item.Pnl,
|
||||
ProfitAndLoss = item.NetPnL,
|
||||
Roi = item.Roi,
|
||||
Identifier = item.Identifier.ToString(),
|
||||
AgentName = item.User.AgentName,
|
||||
|
||||
@@ -918,6 +918,7 @@ public class DataController : ControllerBase
|
||||
public async Task<ActionResult<PaginatedResponse<TradingBotResponse>>> GetStrategiesPaginated(
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10,
|
||||
BotStatus? status = null,
|
||||
string? name = null,
|
||||
string? ticker = null,
|
||||
string? agentName = null,
|
||||
@@ -943,11 +944,14 @@ public class DataController : ControllerBase
|
||||
// Check environment variable for filtering profitable strategies only
|
||||
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(
|
||||
pageNumber,
|
||||
pageSize,
|
||||
null, // No specific status filter - we'll exclude Saved in the service call
|
||||
statusFilter,
|
||||
name,
|
||||
ticker,
|
||||
agentName,
|
||||
@@ -957,9 +961,9 @@ public class DataController : ControllerBase
|
||||
sortDirection,
|
||||
showOnlyProfitable);
|
||||
|
||||
// Filter out Saved status bots
|
||||
var filteredBots = bots.Where(bot => bot.Status != BotStatus.Saved).ToList();
|
||||
var filteredCount = totalCount - bots.Count(bot => bot.Status == BotStatus.Saved);
|
||||
// No additional filtering needed since we're using the status filter directly
|
||||
var filteredBots = bots.ToList();
|
||||
var filteredCount = totalCount;
|
||||
|
||||
// Map to response objects
|
||||
var tradingBotResponses = MapBotsToTradingBotResponse(filteredBots);
|
||||
@@ -1028,7 +1032,7 @@ public class DataController : ControllerBase
|
||||
WinRate = (item.TradeWins + item.TradeLosses) != 0
|
||||
? item.TradeWins / (item.TradeWins + item.TradeLosses)
|
||||
: 0,
|
||||
ProfitAndLoss = item.Pnl,
|
||||
ProfitAndLoss = item.NetPnL,
|
||||
Roi = item.Roi,
|
||||
Identifier = item.Identifier.ToString(),
|
||||
AgentName = item.User.AgentName,
|
||||
|
||||
@@ -593,6 +593,18 @@ public class LiveTradingBotGrain : Grain, ILiveTradingBotGrain, IRemindable
|
||||
// Load state into the trading bot instance
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -454,6 +454,75 @@ public class SpotBot : TradingBotBase
|
||||
|
||||
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
|
||||
// If balance is greater, it could be orphaned tokens from previous positions
|
||||
if (tokenBalanceAmount < positionQuantity)
|
||||
@@ -462,6 +531,9 @@ public class SpotBot : TradingBotBase
|
||||
var difference = positionQuantity - tokenBalanceAmount;
|
||||
|
||||
if (difference > tolerance)
|
||||
{
|
||||
// Only log warning if this is not a dust amount (already handled above)
|
||||
if (!isDustAmount && !balanceIsVeryLow)
|
||||
{
|
||||
await LogWarningAsync(
|
||||
$"⚠️ Token Balance Below Position Quantity\n" +
|
||||
@@ -472,6 +544,7 @@ public class SpotBot : TradingBotBase
|
||||
$"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
|
||||
}
|
||||
}
|
||||
@@ -490,16 +563,57 @@ 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;
|
||||
|
||||
// Only check history if position is not already Filled
|
||||
if (internalPosition.Status != PositionStatus.Filled)
|
||||
{
|
||||
// Verify the opening swap actually executed successfully
|
||||
// Always verify the opening swap exists in history, even if position is already marked as Filled
|
||||
// This catches cases where the position was prematurely marked as Filled by the command handler
|
||||
bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition);
|
||||
|
||||
if (!swapConfirmedInHistory)
|
||||
{
|
||||
// Swap not found in history - check if this is a failed swap or just delayed
|
||||
// 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
|
||||
|
||||
var isDustAmount = tokenBalanceAmount <= dustThreshold;
|
||||
var balanceIsVeryLow = tokenBalanceAmount < positionQuantity * 0.1m; // Less than 10% of expected
|
||||
|
||||
if (isDustAmount || balanceIsVeryLow)
|
||||
{
|
||||
// Balance is very low and swap not in history - swap likely failed
|
||||
// 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" +
|
||||
$"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 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" +
|
||||
@@ -510,6 +624,8 @@ public class SpotBot : TradingBotBase
|
||||
$"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
|
||||
}
|
||||
}
|
||||
|
||||
// Position confirmed on broker (token balance exists AND swap confirmed in history)
|
||||
|
||||
@@ -40,7 +40,6 @@ public abstract class TradingBotBase : ITradingBot
|
||||
public Dictionary<string, LightSignal> Signals { get; set; }
|
||||
public Dictionary<Guid, Position> Positions { get; set; }
|
||||
public Dictionary<DateTime, decimal> WalletBalances { get; set; }
|
||||
private decimal _currentBalance;
|
||||
public DateTime PreloadSince { get; set; }
|
||||
public int PreloadedCandlesCount { get; set; }
|
||||
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
|
||||
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(
|
||||
ILogger<TradingBotBase> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
@@ -65,7 +70,6 @@ public abstract class TradingBotBase : ITradingBot
|
||||
Signals = new Dictionary<string, LightSignal>();
|
||||
Positions = new Dictionary<Guid, Position>();
|
||||
WalletBalances = new Dictionary<DateTime, decimal>();
|
||||
_currentBalance = config.BotTradingBalance;
|
||||
PreloadSince = CandleHelpers.GetBotPreloadSinceFromTimeframe(config.Timeframe);
|
||||
}
|
||||
|
||||
@@ -433,13 +437,13 @@ public abstract class TradingBotBase : ITradingBot
|
||||
|
||||
if (WalletBalances.Count == 0)
|
||||
{
|
||||
WalletBalances[date] = _currentBalance;
|
||||
WalletBalances[date] = Config.BotTradingBalance;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WalletBalances.ContainsKey(date))
|
||||
{
|
||||
WalletBalances[date] = _currentBalance;
|
||||
WalletBalances[date] = Config.BotTradingBalance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1270,13 +1274,15 @@ public abstract class TradingBotBase : ITradingBot
|
||||
|
||||
if (position.ProfitAndLoss != null)
|
||||
{
|
||||
// Update the current balance when position closes
|
||||
_currentBalance += position.ProfitAndLoss.Net;
|
||||
// Update the balance when position closes
|
||||
Config.BotTradingBalance += position.ProfitAndLoss.Net;
|
||||
|
||||
await LogDebugAsync(
|
||||
string.Format("💰 Balance Updated\nNew bot trading balance: `${0:F2}`",
|
||||
Config.BotTradingBalance));
|
||||
|
||||
// For live trading, immediately sync and save to database
|
||||
await OnBalanceUpdatedAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -2060,6 +2066,18 @@ public abstract class TradingBotBase : ITradingBot
|
||||
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
|
||||
public async Task LogInformation(string message)
|
||||
{
|
||||
|
||||
@@ -83,6 +83,7 @@ public class PostgreSqlBotRepository : IBotRepository
|
||||
existingEntity.AccumulatedRunTimeSeconds = bot.AccumulatedRunTimeSeconds;
|
||||
existingEntity.MasterBotUserId =
|
||||
bot.MasterBotUserId ?? existingEntity.MasterBotUserId;
|
||||
existingEntity.BotTradingBalance = bot.BotTradingBalance;
|
||||
|
||||
await _context.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user