Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/execution/futures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,10 +1405,17 @@ export function createFuturesExecutionService(config: FuturesConfig): FuturesExe
const maxPositionSize = config.maxPositionSize ?? 10000; // $10k default

async function resolveOrderPrice(request: FuturesOrderRequest): Promise<number> {
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;
}

Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions tests/trading/futures-pre-trade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof service.placeMarketOrder>[0], 'price'>);

assert.equal(result.success, false);
assert.match(result.error ?? '', /exceeds max \$1000/);
} finally {
globalThis.fetch = originalFetch;
}
});
});