Enhance SpotBot slippage handling and logging

- Increased slippage tolerance from 0.6% to 0.7% to account for gas reserves.
- Improved logging to provide detailed information when adjusting position quantities due to slippage or when retaining original quantities.
- Updated CloseSpotPositionCommandHandler to use the position's opened quantity instead of the entire wallet balance, ensuring gas fees are preserved.
- Adjusted Web3ProxyService settings for retry attempts and operation timeouts to improve performance.
- Enhanced swap token implementation to handle native tokens correctly and increased operation timeout for better reliability.
This commit is contained in:
2026-01-05 22:13:18 +07:00
parent a0d5e336d5
commit 645bbe6d95
5 changed files with 106 additions and 55 deletions

View File

@@ -287,7 +287,7 @@ public class SpotBot : TradingBotBase
return false;
}
var tolerance = positionQuantity * 0.006m; // 0.6% tolerance for slippage
var tolerance = positionQuantity * 0.007m; // 0.7% tolerance for slippage and gas reserve
var difference = Math.Abs(tokenBalance - positionQuantity);
if (difference > tolerance)
@@ -307,8 +307,25 @@ public class SpotBot : TradingBotBase
lastPosition.Status = PositionStatus.Filled;
lastPosition.Open.SetStatus(TradeStatus.Filled);
// Update quantity to match actual token balance
lastPosition.Open.Quantity = tokenBalance;
// Only update quantity if actual balance is less than position quantity (slippage loss)
// Don't update if balance is higher (likely includes gas reserve for ETH)
if (tokenBalance < positionQuantity)
{
await LogInformationAsync(
$"📉 Adjusting Position Quantity Due to Slippage\n" +
$"Original Quantity: `{positionQuantity:F5}`\n" +
$"Actual Balance: `{tokenBalance:F5}`\n" +
$"Difference: `{difference:F5}`");
lastPosition.Open.Quantity = tokenBalance;
}
else
{
await LogInformationAsync(
$" Keeping Original Position Quantity\n" +
$"Position Quantity: `{positionQuantity:F5}`\n" +
$"Actual Balance: `{tokenBalance:F5}`\n" +
$"Not updating (balance includes gas reserve or is within tolerance)");
}
// Calculate current PnL
var currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(
@@ -402,7 +419,7 @@ public class SpotBot : TradingBotBase
// If balance is greater, it could be orphaned tokens from previous positions
if (tokenBalanceAmount < positionQuantity)
{
var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage
var tolerance = positionQuantity * 0.007m; // 0.7% tolerance to account for slippage and gas reserve
var difference = positionQuantity - tokenBalanceAmount;
if (difference > tolerance)
@@ -413,7 +430,7 @@ public class SpotBot : TradingBotBase
$"Position Quantity: `{positionQuantity:F5}`\n" +
$"Token Balance: `{tokenBalanceAmount:F5}`\n" +
$"Difference: `{difference:F5}`\n" +
$"Tolerance (0.6%): `{tolerance: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
@@ -446,23 +463,35 @@ public class SpotBot : TradingBotBase
internalPosition.Open.SetStatus(TradeStatus.Filled);
positionForSignal.Open.SetStatus(TradeStatus.Filled);
// Update quantity to match actual token balance
// Update quantity to match actual token balance only if difference is within acceptable tolerance
// For ETH, we need to be careful not to include gas reserve in the position quantity
var actualTokenBalance = tokenBalance.Amount;
var quantityTolerance = internalPosition.Open.Quantity * 0.006m; // 0.6% tolerance for slippage
var quantityTolerance = internalPosition.Open.Quantity * 0.007m; // 0.7% tolerance for slippage and gas reserve
var quantityDifference = Math.Abs(internalPosition.Open.Quantity - actualTokenBalance);
if (quantityDifference > quantityTolerance)
// Only update if the actual balance is LESS than expected (slippage loss)
// Don't update if balance is higher (likely includes gas reserve or orphaned tokens)
if (actualTokenBalance < internalPosition.Open.Quantity && quantityDifference > quantityTolerance)
{
await LogDebugAsync(
$"🔄 Token Balance Mismatch\n" +
$"🔄 Token Balance Lower Than Expected (Slippage)\n" +
$"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
$"Broker Balance: `{actualTokenBalance:F5}`\n" +
$"Difference: `{quantityDifference:F5}`\n" +
$"Tolerance (0.6%): `{quantityTolerance:F5}`\n" +
$"Updating to match broker balance");
$"Tolerance (0.7%): `{quantityTolerance:F5}`\n" +
$"Updating to match actual balance");
internalPosition.Open.Quantity = actualTokenBalance;
positionForSignal.Open.Quantity = actualTokenBalance;
}
else if (actualTokenBalance > internalPosition.Open.Quantity)
{
await LogDebugAsync(
$" Token Balance Higher Than Position Quantity\n" +
$"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" +
$"Broker Balance: `{actualTokenBalance:F5}`\n" +
$"Difference: `{quantityDifference:F5}`\n" +
$"Keeping original position quantity (extra balance likely gas reserve or orphaned tokens)");
}
// Calculate and update PnL based on current price
var currentPrice = await ServiceScopeHelpers.WithScopedService<IExchangeService, decimal>(

View File

@@ -48,18 +48,13 @@ public class CloseSpotPositionCommandHandler(
{
// For live trading, call SwapGmxTokensAsync
var account = await accountService.GetAccountById(request.AccountId);
var tokenBalance = await exchangeService.GetBalance(account, request.Position.Ticker);
if (tokenBalance == null || tokenBalance.Amount <= 0)
{
throw new InvalidOperationException(
$"No available balance to close spot position for {request.Position.Ticker}");
}
amountToSwap = tokenBalance.Amount;
// Use the position's opened quantity instead of the entire wallet balance
// This naturally leaves extra ETH for gas fees and avoids the need for explicit reservation
amountToSwap = request.Position.Open.Quantity;
logger?.LogInformation(
"Closing spot position: PositionId={PositionId}, Ticker={Ticker}, TokenBalance={TokenBalance}, Swapping to USDC",
"Closing spot position: PositionId={PositionId}, Ticker={Ticker}, PositionQuantity={PositionQuantity}, Swapping to USDC",
request.Position.Identifier, request.Position.Ticker, amountToSwap);
swapResult = await tradingService.SwapGmxTokensAsync(

View File

@@ -21,7 +21,7 @@ namespace Managing.Infrastructure.Evm.Services
public class Web3ProxySettings
{
public string BaseUrl { get; set; } = "http://localhost:3000";
public int MaxRetryAttempts { get; set; } = 2;
public int MaxRetryAttempts { get; set; } = 1;
public int RetryDelayMs { get; set; } = 2500;
public int TimeoutSeconds { get; set; } = 30;
}
@@ -633,7 +633,7 @@ namespace Managing.Infrastructure.Evm.Services
pageSize,
ticker,
fromDateTime = fromDate?.ToString("O"), // ISO 8601 format
toDateTime = toDate?.ToString("O") // ISO 8601 format
toDateTime = toDate?.ToString("O") // ISO 8601 format
};
var response = await GetGmxServiceAsync<GetGmxPositionHistoryResponse>("/position-history", payload);

View File

@@ -4,7 +4,7 @@ import {z} from 'zod'
import {GmxSdk} from '../../generated/gmxsdk/index.js'
import {arbitrum} from 'viem/chains';
import {getTokenBySymbol} from '../../generated/gmxsdk/configs/tokens.js';
import {getTokenBySymbol, NATIVE_TOKEN_ADDRESS} from '../../generated/gmxsdk/configs/tokens.js';
import {createSwapOrderTxn} from '../../generated/gmxsdk/modules/orders/transactions/createSwapOrderTxn.js';
import {MarketInfo, MarketsInfoData} from '../../generated/gmxsdk/types/markets.js';
@@ -60,7 +60,7 @@ interface CacheEntry {
const CACHE_TTL = 60 * 60 * 1000; // 60 minutes in milliseconds (increased from 30 minutes)
const marketsCache = new Map<string, CacheEntry>();
const MAX_CACHE_SIZE = 20; // Increased cache size to allow more markets data
const OPERATION_TIMEOUT = 30000; // 30 seconds timeout for operations
const OPERATION_TIMEOUT = 60000; // 60 seconds timeout for operations (increased to handle slower swaps)
const MEMORY_WARNING_THRESHOLD = 0.85; // Warn when memory usage exceeds 85%
const MEMORY_CLEAR_THRESHOLD = 0.95; // Clear cache when memory usage exceeds 95%
@@ -342,7 +342,7 @@ export async function estimatePositionGasFee(
// Fallback RPC configuration
const FALLBACK_RPC_URL = "https://radial-shy-cherry.arbitrum-mainnet.quiknode.pro/098e57e961b05b24bcde008c4ca02fff6fb13b51/";
const PRIMARY_RPC_URL = "https://arb1.arbitrum.io/rpc";
const MAX_RETRY_ATTEMPTS = 2; // Only allow one retry to prevent infinite loops
const MAX_RETRY_ATTEMPTS = 1; // Only allow one retry (2 total attempts: initial + 1 retry)
/**
@@ -2947,13 +2947,20 @@ export const swapGmxTokensImpl = async (
const fromTokenData = getTokenDataFromTicker(fromTicker, tokensData);
const toTokenData = getTokenDataFromTicker(toTicker, tokensData);
// IMPORTANT: For native tokens (ETH), we must use NATIVE_TOKEN_ADDRESS (0x0000...)
// instead of the wrapped token address (WETH) for the swap to work correctly
const fromTokenAddress = fromTokenData?.isNative ? NATIVE_TOKEN_ADDRESS : fromTokenData?.address;
const toTokenAddress = toTokenData?.isNative ? NATIVE_TOKEN_ADDRESS : toTokenData?.address;
console.log(`🔄 Swap details:`, {
fromTicker,
toTicker,
fromTokenAddress: fromTokenData?.address,
fromTokenAddress,
fromTokenAddressOriginal: fromTokenData?.address,
fromTokenIsNative: fromTokenData?.isNative,
fromTokenIsSynthetic: fromTokenData?.isSynthetic,
toTokenAddress: toTokenData?.address,
toTokenAddress,
toTokenAddressOriginal: toTokenData?.address,
toTokenIsNative: toTokenData?.isNative,
toTokenIsSynthetic: toTokenData?.isSynthetic
});
@@ -2983,10 +2990,11 @@ export const swapGmxTokensImpl = async (
}
// Check for zero addresses (but allow native tokens like ETH)
if (fromTokenData.address === "0x0000000000000000000000000000000000000000" && !fromTokenData.isNative) {
// Native tokens will have NATIVE_TOKEN_ADDRESS (0x0000...) which is valid
if (fromTokenAddress === "0x0000000000000000000000000000000000000000" && !fromTokenData.isNative) {
throw new Error(`From token ${fromTicker} has zero address - token not found or invalid`);
}
if (toTokenData.address === "0x0000000000000000000000000000000000000000" && !toTokenData.isNative) {
if (toTokenAddress === "0x0000000000000000000000000000000000000000" && !toTokenData.isNative) {
throw new Error(`To token ${toTicker} has zero address - token not found or invalid`);
}
@@ -2997,6 +3005,7 @@ export const swapGmxTokensImpl = async (
walletBalance = await sdk.publicClient.getBalance({ address: sdk.account as Address });
} else {
// For ERC20 tokens, get balance using multicall
// Use the original token address (not NATIVE_TOKEN_ADDRESS) for ERC20 balance checks
const balanceResult = await sdk.executeMulticall({
token: {
contractAddress: fromTokenData.address,
@@ -3029,7 +3038,9 @@ export const swapGmxTokensImpl = async (
console.log('💰 Wallet balance check:', {
fromTicker,
fromTokenAddress: fromTokenData.address,
fromTokenAddress,
fromTokenAddressOriginal: fromTokenData.address,
isNative: fromTokenData.isNative,
requestedAmount: requestedAmountBigInt.toString(),
walletBalance: walletBalance.toString(),
requestedAmountFormatted: requestedAmountNumber.toFixed(6),
@@ -3162,8 +3173,8 @@ export const swapGmxTokensImpl = async (
try {
const swapParams = {
fromAmount: verifiedFromTokenAmount,
fromTokenAddress: fromTokenData.address,
toTokenAddress: toTokenData.address,
fromTokenAddress,
toTokenAddress,
allowedSlippageBps: allowedSlippageBps,
referralCodeForTxn: encodeReferralCode("kaigen_ai"),
skipSimulation: true,
@@ -3173,6 +3184,10 @@ export const swapGmxTokensImpl = async (
swapParams.tokensData = tokensData;
console.log('🔄 Attempting SDK swap with slippage:', {
fromTokenAddress,
toTokenAddress,
fromIsNative: fromTokenData.isNative,
toIsNative: toTokenData.isNative,
allowedSlippageBps: allowedSlippageBps,
slippagePercentage: slippagePercentage
});
@@ -3198,8 +3213,8 @@ export const swapGmxTokensImpl = async (
// Find the proper swap path using createFindSwapPath (works for both synthetic and non-synthetic tokens)
const findSwapPath = createFindSwapPath({
chainId: sdk.chainId,
fromTokenAddress: fromTokenData.address,
toTokenAddress: toTokenData.address,
fromTokenAddress,
toTokenAddress,
marketsInfoData,
gasEstimationParams: {
gasLimits,
@@ -3233,7 +3248,7 @@ export const swapGmxTokensImpl = async (
// No swap path found - this should not happen for valid token pairs
// (Synthetic tokens are already validated early, so we won't reach here for them)
console.warn('⚠️ No swap path found, using direct path (may not work for all token pairs)');
swapPath = [fromTokenData.address, toTokenData.address];
swapPath = [fromTokenAddress, toTokenAddress];
swapsCount = 1;
}
@@ -3297,10 +3312,20 @@ export const swapGmxTokensImpl = async (
});
}
console.log('🚀 Creating swap order with:', {
fromTokenAddress,
fromIsNative: fromTokenData.isNative,
toTokenAddress,
toIsNative: toTokenData.isNative,
fromTokenAmount: verifiedFromTokenAmount.toString(),
executionFee: executionFee.feeTokenAmount.toString(),
swapPath
});
await createSwapOrderTxn(sdk, {
fromTokenAddress: fromTokenData.address,
fromTokenAddress,
fromTokenAmount: verifiedFromTokenAmount,
toTokenAddress: toTokenData.address,
toTokenAddress,
swapPath: swapPath,
orderType: OrderType.MarketSwap,
minOutputAmount: minOutputAmount,

View File

@@ -1,32 +1,34 @@
import {describe, it} from 'node:test'
import assert from 'node:assert'
import {getClientForAddress, swapGmxTokensImpl} from '../../src/plugins/custom/gmx.js'
import {Ticker} from '../../src/generated/ManagingApiTypes.js'
describe('swap tokens implementation', () => {
it('should swap SOL to USDC successfully', async () => {
it('should swap $4 worth of ETH to USDC successfully', async () => {
try {
const testAccount = '0x932167388dD9aad41149b3cA23eBD489E2E2DD78'
const sdk = await getClientForAddress(testAccount)
console.log('\n🔄 Starting ETH → USDC swap ($4 worth of ETH to USDC)...\n')
// Swap ~0.0012 ETH to get approximately $4 worth of USDC (at ~$3400 ETH price)
// 0.0012 ETH * $3,400 = ~$4.08
const result = await swapGmxTokensImpl(
sdk,
Ticker.USDC,
Ticker.RENDER,
5, // 5 USDC
'ETH',
'USDC',
0.0012, // ~0.0012 ETH should give us ~$4 worth of USDC
'market',
undefined,
0.5
)
console.log('\n✅ ETH → USDC swap successful! Swapped ~$4 worth of ETH to USDC\n')
assert.strictEqual(typeof result, 'string')
assert.strictEqual(result, 'swap_order_created')
} catch (error) {
console.log('error', error)
// Test that the error is related to actual execution rather than parameter validation
console.log('\n❌ ETH → USDC swap failed:', error)
assert.fail(error.message)
}
})