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:
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user