Enhance SpotBot position management and logging

- Introduced logic to check if the opening swap was canceled by the broker, marking positions as canceled when necessary.
- Adjusted orphaned balance thresholds for ETH and other tokens to improve balance management.
- Enhanced logging to provide detailed information on swap status, including warnings for canceled swaps and their implications on position management.
- Added a new method to verify swap execution status, improving the robustness of position handling in SpotBot.
This commit is contained in:
2026-01-06 00:13:21 +07:00
parent 6134364ddd
commit efbb116ed2
2 changed files with 281 additions and 6 deletions

View File

@@ -183,8 +183,11 @@ public class SpotBot : TradingBotBase
if (tokenBalance is { Amount: > 0 }) if (tokenBalance is { Amount: > 0 })
{ {
// Check if this is a meaningful balance or just gas reserves / dust // Check if this is a meaningful balance or just gas reserves / dust
// Minimum threshold: $10 USD value to be considered an orphaned position // For ETH, use a higher threshold since gas reserves are expected to be significant
const decimal minOrphanedBalanceValue = 10m; // For other tokens, use a lower threshold
decimal minOrphanedBalanceValue = Config.Ticker == Ticker.ETH
? 100m // ETH: $100 threshold (gas reserves can be $20-50+, so this is safe)
: 10m; // Other tokens: $10 threshold
if (tokenBalance.Value < minOrphanedBalanceValue) if (tokenBalance.Value < minOrphanedBalanceValue)
{ {
@@ -194,6 +197,7 @@ public class SpotBot : TradingBotBase
$"Token balance: `{tokenBalance.Amount:F8}`\n" + $"Token balance: `{tokenBalance.Amount:F8}`\n" +
$"USD Value: `${tokenBalance.Value:F2}`\n" + $"USD Value: `${tokenBalance.Value:F2}`\n" +
$"Below orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" + $"Below orphaned threshold of `${minOrphanedBalanceValue:F2}`\n" +
$"{(Config.Ticker == Ticker.ETH ? "(ETH gas reserve - expected behavior)" : "(Dust amount)")}\n" +
$"Ignoring - safe to open new position"); $"Ignoring - safe to open new position");
return true; // Safe to open new position - this is just dust/gas reserve return true; // Safe to open new position - this is just dust/gas reserve
} }
@@ -403,6 +407,39 @@ public class SpotBot : TradingBotBase
// For spot trading, fetch token balance directly and verify/match with internal position // For spot trading, fetch token balance directly and verify/match with internal position
try try
{ {
// First, check if the opening swap was canceled by the broker
// This prevents confusing warning messages about token balance mismatches
if (internalPosition.Status == PositionStatus.New)
{
var swapWasCanceled = await CheckIfOpeningSwapWasCanceled(internalPosition);
if (swapWasCanceled)
{
// Mark position as Canceled
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 Canceled by Broker\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Signal: `{internalPosition.SignalIdentifier}`\n" +
$"Ticker: {Config.Ticker}\n" +
$"Expected Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
$"Status Changed: `{previousStatus}` → `Canceled`\n" +
$"The opening swap (USDC → {Config.Ticker}) was canceled by the ExchangeRouter contract\n" +
$"Position will not be tracked or managed");
// Notify about the canceled position (using PositionClosed as PositionCanceled doesn't exist)
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
return; // Exit - no further synchronization needed
}
}
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>( var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
_scopeFactory, _scopeFactory,
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker)); async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
@@ -632,13 +669,142 @@ public class SpotBot : TradingBotBase
} }
} }
private async Task<bool> CheckIfOpeningSwapWasCanceled(Position position)
{
try
{
// Only check for canceled swaps if position is in New status
// (positions that haven't been filled yet)
if (position.Status != PositionStatus.New)
{
return false;
}
await LogDebugAsync(
$"🔍 Checking for Canceled Opening Swap\n" +
$"Position: `{position.Identifier}`\n" +
$"Ticker: `{Config.Ticker}`");
// Get swap history from exchange to check for canceled orders
// We need to check if the opening swap (USDC -> ETH for LONG) was canceled
var positionHistory = await ServiceScopeHelpers.WithScopedService<IExchangeService, List<Position>>(
_scopeFactory,
async exchangeService =>
{
// Check swaps from 1 hour before position date to now
// This covers the time window when the swap could have been canceled
var fromDate = position.Date.AddHours(-1);
var toDate = DateTime.UtcNow;
return await exchangeService.GetSpotPositionHistory(Account, Config.Ticker, fromDate, toDate);
});
if (positionHistory == null || positionHistory.Count == 0)
{
// No history found - swap might still be pending
return false;
}
// For a LONG position, the opening swap should be LONG (USDC -> Token)
// If we find a LONG swap around the position creation time, check if it was actually executed
var openingSwaps = positionHistory
.Where(p => p.OriginDirection == TradeDirection.Long &&
Math.Abs((p.Date - position.Date).TotalMinutes) < 10) // Within 10 minutes of position creation
.OrderBy(p => Math.Abs((p.Date - position.Date).TotalSeconds))
.ToList();
if (openingSwaps.Any())
{
// We found swap(s) around the position creation time
// If the quantity matches our expected position quantity, this is our opening swap
var matchingSwap = openingSwaps.FirstOrDefault(swap =>
Math.Abs(swap.Open.Quantity - position.Open.Quantity) / position.Open.Quantity < 0.02m); // Within 2% tolerance
if (matchingSwap != null)
{
// Found the matching opening swap - it was executed successfully
await LogDebugAsync(
$"✅ Opening Swap Found and Executed\n" +
$"Position: `{position.Identifier}`\n" +
$"Swap Quantity: `{matchingSwap.Open.Quantity:F5}`\n" +
$"Expected Quantity: `{position.Open.Quantity:F5}`\n" +
$"Swap was successful");
return false; // Swap was not canceled
}
}
// If we reach here, we didn't find a matching executed swap
// This likely means the swap was canceled by the broker
// Double-check by verifying the token balance is significantly lower than expected
var tokenBalance = await ServiceScopeHelpers.WithScopedService<IExchangeService, Balance?>(
_scopeFactory,
async exchangeService => await exchangeService.GetBalance(Account, Config.Ticker));
if (tokenBalance != null && position.Open.Quantity > 0)
{
var tolerance = position.Open.Quantity * 0.10m; // 10% tolerance
var difference = position.Open.Quantity - tokenBalance.Amount;
if (difference > tolerance)
{
// Token balance is significantly lower than expected position quantity
// This confirms the swap was likely canceled
await LogWarningAsync(
$"❌ Opening Swap Appears to be Canceled by Broker\n" +
$"Position: `{position.Identifier}`\n" +
$"Expected Quantity: `{position.Open.Quantity:F5}`\n" +
$"Actual Token Balance: `{tokenBalance.Amount:F5}`\n" +
$"Difference: `{difference:F5}` (exceeds 10% tolerance)\n" +
$"No matching executed swap found in history\n" +
$"Position will be marked as Canceled");
return true; // Swap was canceled
}
}
return false; // Swap status unclear - don't assume it was canceled
}
catch (Exception ex)
{
Logger.LogError(ex, "Error checking if opening swap was canceled for position {PositionId}", position.Identifier);
await LogWarningAsync(
$"⚠️ Error Checking for Canceled Swap\n" +
$"Position: `{position.Identifier}`\n" +
$"Error: {ex.Message}");
return false; // On error, don't assume swap was canceled
}
}
protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition, protected override async Task HandleOrderManagementAndPositionStatus(LightSignal signal, Position internalPosition,
Position positionForSignal) Position positionForSignal)
{ {
// Spot trading doesn't use orders like futures - positions are opened via swaps // Spot trading doesn't use orders like futures - positions are opened via swaps
// Just check if the swap was successful // Check if the opening swap was successful or canceled
if (internalPosition.Status == PositionStatus.New) if (internalPosition.Status == PositionStatus.New)
{ {
// First, check if the opening swap was canceled by the broker
var swapWasCanceled = await CheckIfOpeningSwapWasCanceled(internalPosition);
if (swapWasCanceled)
{
// Mark position as Canceled
internalPosition.Status = PositionStatus.Canceled;
internalPosition.Open.SetStatus(TradeStatus.Cancelled);
positionForSignal.Open.SetStatus(TradeStatus.Cancelled);
await SetPositionStatus(signal.Identifier, PositionStatus.Canceled);
await UpdatePositionInDatabaseAsync(internalPosition);
await LogWarningAsync(
$"❌ Position Opening Failed - Swap Canceled by Broker\n" +
$"Position: `{internalPosition.Identifier}`\n" +
$"Signal: `{signal.Identifier}`\n" +
$"The opening swap was canceled by the ExchangeRouter contract\n" +
$"Position status set to Canceled");
// Notify about the canceled position (using PositionClosed as PositionCanceled doesn't exist)
await NotifyAgentAndPlatformAsync(NotificationEventType.PositionClosed, internalPosition);
return; // Exit - position is canceled
}
// Check if swap was successful by verifying position status // Check if swap was successful by verifying position status
// For spot, if Open trade is Filled, the position is filled // For spot, if Open trade is Filled, the position is filled
if (positionForSignal.Open?.Status == TradeStatus.Filled) if (positionForSignal.Open?.Status == TradeStatus.Filled)

View File

@@ -2,6 +2,8 @@ import {test} from 'node:test'
import assert from 'node:assert' import assert from 'node:assert'
import {getClientForAddress, getSpotPositionHistoryImpl} from '../../src/plugins/custom/gmx.js' import {getClientForAddress, getSpotPositionHistoryImpl} from '../../src/plugins/custom/gmx.js'
import {Ticker} from '../../src/generated/ManagingApiTypes.js' import {Ticker} from '../../src/generated/ManagingApiTypes.js'
import {TradeActionType} from '../../src/generated/gmxsdk/types/tradeHistory.js'
import {OrderType} from '../../src/generated/gmxsdk/types/orders.js'
test('GMX get spot position history - Market swaps', async (t) => { test('GMX get spot position history - Market swaps', async (t) => {
await t.test('should get spot swap executions', async () => { await t.test('should get spot swap executions', async () => {
@@ -18,17 +20,124 @@ test('GMX get spot position history - Market swaps', async (t) => {
sdk, sdk,
0, // pageIndex 0, // pageIndex
100, // pageSize 100, // pageSize
Ticker.BTC, // ticker Ticker.ETH, // ticker - changed to ETH to investigate the issue
fromDateTime, // fromDateTime (today's start) fromDateTime, // fromDateTime (today's start)
toDateTime // toDateTime (today's end) toDateTime // toDateTime (today's end)
) )
console.log('\n📊 Spot Swap History Summary:') console.log('\n📊 Spot Swap History Summary (ETH):')
console.log(`Total swaps: ${result.length}`) console.log(`Total swaps: ${result.length}`)
console.log(`📊 Result:`, result);
// Log detailed information about each swap
if (result.length > 0) {
console.log('\n📋 Detailed Swap Information:')
result.forEach((swap, index) => {
console.log(`\n[${index + 1}] Swap:`, JSON.stringify(swap, null, 2))
})
} else {
console.log('⚠️ No swaps found for ETH in the specified time range')
}
console.log(`\n📊 Full Result:`, result);
assert.ok(result, 'Spot position history result should be defined') assert.ok(result, 'Spot position history result should be defined')
assert.ok(Array.isArray(result), 'Spot position history should be an array') assert.ok(Array.isArray(result), 'Spot position history should be an array')
}) })
await t.test('should get ALL swap events (including canceled)', async () => {
const sdk = await getClientForAddress('0x932167388dD9aad41149b3cA23eBD489E2E2DD78')
// Get today's date range (start and end of today)
const today = new Date()
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
const fromTimestamp = Math.floor(startOfDay.getTime() / 1000)
const toTimestamp = Math.floor(endOfDay.getTime() / 1000)
// Fetch markets and tokens data
const marketsInfoResult = await sdk.markets.getMarketsInfo()
const tokensDataResult = await sdk.tokens.getTokensData()
if (!marketsInfoResult?.marketsInfoData || !tokensDataResult?.tokensData) {
throw new Error("No markets or tokens info data")
}
const marketsInfoData = marketsInfoResult.marketsInfoData
const tokensData = tokensDataResult.tokensData
// Fetch ALL swap events (executed, canceled, created, etc.)
const allSwapEvents = await sdk.trades.getTradeHistory({
pageIndex: 0,
pageSize: 100,
fromTxTimestamp: fromTimestamp,
toTxTimestamp: toTimestamp,
marketsInfoData,
tokensData,
marketsDirectionsFilter: undefined,
orderEventCombinations: [
{
eventName: TradeActionType.OrderExecuted,
orderType: OrderType.MarketSwap,
},
{
eventName: TradeActionType.OrderCancelled,
orderType: OrderType.MarketSwap,
},
{
eventName: TradeActionType.OrderCreated,
orderType: OrderType.MarketSwap,
}
],
})
console.log('\n🔍 ALL Swap Events (ETH):')
console.log(`Total events: ${allSwapEvents.length}`)
// Group by event type
const executedEvents = allSwapEvents.filter(e => e.eventName === TradeActionType.OrderExecuted)
const canceledEvents = allSwapEvents.filter(e => e.eventName === TradeActionType.OrderCancelled)
const createdEvents = allSwapEvents.filter(e => e.eventName === TradeActionType.OrderCreated)
console.log(`\n📊 Event Breakdown:`)
console.log(` ✅ Executed: ${executedEvents.length}`)
console.log(` ❌ Canceled: ${canceledEvents.length}`)
console.log(` 📝 Created: ${createdEvents.length}`)
// Log details of canceled events (these are likely the culprits)
if (canceledEvents.length > 0) {
console.log('\n❌ CANCELED SWAP DETAILS (THIS IS LIKELY THE ISSUE):')
canceledEvents.forEach((event, index) => {
const swapEvent = event as any
console.log(`\n[${index + 1}] Canceled Swap:`)
console.log(` Transaction: ${swapEvent.transaction?.hash}`)
console.log(` Order Key: ${swapEvent.orderKey}`)
console.log(` From Token: ${swapEvent.initialCollateralToken?.symbol}`)
console.log(` To Token: ${swapEvent.targetCollateralToken?.symbol}`)
console.log(` Amount In: ${swapEvent.initialCollateralDeltaAmount ? Number(swapEvent.initialCollateralDeltaAmount) / 1e18 : 'N/A'}`)
console.log(` Min Amount Out: ${swapEvent.minOutputAmount ? Number(swapEvent.minOutputAmount) / 1e18 : 'N/A'}`)
console.log(` Reason: ${swapEvent.reason || 'N/A'}`)
console.log(` Timestamp: ${new Date(Number(swapEvent.timestamp) * 1000).toISOString()}`)
})
}
// Log details of executed events
if (executedEvents.length > 0) {
console.log('\n✅ EXECUTED SWAP DETAILS:')
executedEvents.forEach((event, index) => {
const swapEvent = event as any
console.log(`\n[${index + 1}] Executed Swap:`)
console.log(` Transaction: ${swapEvent.transaction?.hash}`)
console.log(` Order Key: ${swapEvent.orderKey}`)
console.log(` From Token: ${swapEvent.initialCollateralToken?.symbol}`)
console.log(` To Token: ${swapEvent.targetCollateralToken?.symbol}`)
console.log(` Amount In: ${swapEvent.initialCollateralDeltaAmount ? Number(swapEvent.initialCollateralDeltaAmount) / 1e18 : 'N/A'}`)
console.log(` Amount Out: ${swapEvent.executionAmountOut ? Number(swapEvent.executionAmountOut) / 1e18 : 'N/A'}`)
console.log(` Timestamp: ${new Date(Number(swapEvent.timestamp) * 1000).toISOString()}`)
})
}
assert.ok(allSwapEvents, 'All swap events should be defined')
assert.ok(Array.isArray(allSwapEvents), 'All swap events should be an array')
})
}) })