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