diff --git a/src/execution/futures.ts b/src/execution/futures.ts index 38bb25d1..21b06bc6 100644 --- a/src/execution/futures.ts +++ b/src/execution/futures.ts @@ -1405,10 +1405,17 @@ export function createFuturesExecutionService(config: FuturesConfig): FuturesExe const maxPositionSize = config.maxPositionSize ?? 10000; // $10k default async function resolveOrderPrice(request: FuturesOrderRequest): Promise { - if (request.price !== undefined && Number.isFinite(request.price) && request.price > 0) { + // MARKET orders fill at the venue's current price, which the exchange + // determines at execution time. A caller-supplied price is never forwarded + // for MARKET submissions (see placeBinanceFuturesOrder), so trusting it + // here would let a low `price` shrink the notional cap check while the + // order still fills at market. MARKET (and orderType-less, which submits + // as MARKET) must therefore value exclusively off discovered price. + const isMarketOrder = request.orderType === undefined || request.orderType === 'MARKET'; + if (!isMarketOrder && request.price !== undefined && Number.isFinite(request.price) && request.price > 0) { return request.price; } - if (request.stopPrice !== undefined && Number.isFinite(request.stopPrice) && request.stopPrice > 0) { + if (!isMarketOrder && request.stopPrice !== undefined && Number.isFinite(request.stopPrice) && request.stopPrice > 0) { return request.stopPrice; } @@ -1452,7 +1459,12 @@ export function createFuturesExecutionService(config: FuturesConfig): FuturesExe try { if (request.platform === 'mexc' && config.mexc) { const valuation = await getMexcOrderValuation(config.mexc, request.symbol); - checkPrice = request.price ?? request.stopPrice ?? valuation.price; + // Same MARKET rule as resolveOrderPrice: caller price is never the + // fill price for MARKET orders, so the cap must use venue price. + const isMarketOrder = request.orderType === undefined || request.orderType === 'MARKET'; + checkPrice = isMarketOrder + ? valuation.price + : request.price ?? request.stopPrice ?? valuation.price; contractSize = valuation.contractSize; } else { checkPrice = await resolveOrderPrice(request); diff --git a/tests/trading/futures-pre-trade.test.ts b/tests/trading/futures-pre-trade.test.ts index d891c305..e1558bd3 100644 --- a/tests/trading/futures-pre-trade.test.ts +++ b/tests/trading/futures-pre-trade.test.ts @@ -98,4 +98,32 @@ describe('futures pre-trade enforcement', () => { globalThis.fetch = originalFetch; } }); + + it('ignores caller-supplied price for MARKET notional cap (#126)', async () => { + configurePreTradeGate({ maxOrderSize: 1000 }); + const originalFetch = globalThis.fetch; + // Venue discovers BTC at $60,000: 1 BTC = $60,000 notional > $1,000 cap. + // A caller-smuggled `price: 1` must not shrink the cap check to $1. + globalThis.fetch = async () => + new Response(JSON.stringify({ markPrice: '60000' }), { status: 200 }); + + try { + const service = createFuturesExecutionService({ + binance: { apiKey: 'test', secretKey: 'test' }, + dryRun: true, + }); + const result = await service.placeMarketOrder({ + platform: 'binance', + symbol: 'BTCUSDT', + side: 'long', + size: 1, + price: 1, + } as unknown as Omit[0], 'price'>); + + assert.equal(result.success, false); + assert.match(result.error ?? '', /exceeds max \$1000/); + } finally { + globalThis.fetch = originalFetch; + } + }); });