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
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
// Verify the opening swap actually executed successfully
|
|
||||||
bool swapConfirmedInHistory = await VerifyOpeningSwapInHistory(internalPosition);
|
|
||||||
|
|
||||||
if (!swapConfirmedInHistory)
|
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)
|
||||||
{
|
{
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user