diff --git a/app/api/state/route.ts b/app/api/state/route.ts index fb1fa93..60988e9 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -4,7 +4,7 @@ import { getClients } from '@/lib/clients'; import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog'; import { ruleDiffs } from '@/lib/kept-rules'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; -import { historyFresh } from '@/lib/platform-client'; +import { historyFresh, type PlatformContract } from '@/lib/platform-client'; import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate'; import { obfuscationDayBooked } from '@/lib/auto-trade'; import type { AccountState } from '@/types'; @@ -176,9 +176,16 @@ export async function GET() { if (plan && !dayBooked && botStopsActive(cfg, effective)) { const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday); // The reserve grows with the position (dllReserve): at - // the size the next leg would trade, where it's known. + // the size the next leg would trade, on its instrument, + // where it's known. + const nextContract = holdEstimate + ? (client as { lastCandidates?: PlatformContract[] }).lastCandidates?.find((c) => String(c.id) === holdEstimate!.symbol) + : undefined; const hard = dllRoom !== null && !lastStandNext - ? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0, notional: holdEstimate?.notional, slippageFraction: client.stopSlippageFraction }) + ? dllStopDollars({ + room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0, notional: holdEstimate?.notional, + slippageFraction: nextContract?.stopSlippageFraction ?? client.stopSlippageFraction, + }) : Infinity; const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard); if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100; diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 0751b21..d3f4928 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -1426,7 +1426,7 @@ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tra maxLossRoom: floorRoom, obfuscationDollars: obfuscation, availableFunds: live.availableFunds ?? live.amount, - slippageFraction: item.client.stopSlippageFraction, + slippageFraction: contract.stopSlippageFraction ?? item.client.stopSlippageFraction, }); return { ...plan, pct, obfuscation, maxLossRoom: floorRoom }; } @@ -1577,7 +1577,7 @@ async function fireLeg( const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts); const notional = fill.price * pv * contracts; const dllDollars = dllOn && !plan.lastStand - ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional, slippageFraction: client.stopSlippageFraction }) + ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional, slippageFraction: legContract.stopSlippageFraction ?? client.stopSlippageFraction }) : Infinity; const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize }); if (ticks === 0) { diff --git a/lib/leg-estimate.ts b/lib/leg-estimate.ts index 99db137..7d50736 100644 --- a/lib/leg-estimate.ts +++ b/lib/leg-estimate.ts @@ -100,7 +100,7 @@ export function rankNextLeg(o: { maxLossRoom: floorRoom, obfuscationDollars: o.obfuscationDollars, availableFunds: funds, - slippageFraction: o.client.stopSlippageFraction, + slippageFraction: c.stopSlippageFraction ?? o.client.stopSlippageFraction, }); const p = plan(true); if (!p.sizing || p.cost === null) return []; diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 7aeeb2e..0d7ed5b 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -53,6 +53,10 @@ export interface PlatformContract { * says: no leg starts that wouldn't be done by then (clearsClose), each * account's at its own size (fireLeg). */ closesAt?: number; + /** This instrument's share of notional a stop may fill past its price, + * where it differs from the client's stopSlippageFraction (Vest: crypto, + * unmeasured, keeps the engine's default). */ + stopSlippageFraction?: number; } /** What an order is for, where the caller knows (see sendOrder). */ diff --git a/lib/vest-class.ts b/lib/vest-class.ts index 9e01731..70673ae 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -53,7 +53,7 @@ import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } import { vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop, - parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION, + parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION, vestStopSlippageFraction, vestStatusName, awaitingActivation, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince, } from './vest-helpers'; @@ -155,6 +155,9 @@ interface SymbolSpec { marginRate: number; tradingStatus: string; capitalTradingStatus: string; + /** exchangeInfo's asset class: "crypto", "forex", "stock" (indices and + * commodities too). */ + asset: string; } export interface VestConfig { @@ -558,6 +561,7 @@ export class VestClient implements PlatformClient { marginRate: capitalIm && capitalIm > 0 ? capitalIm : 0, tradingStatus: String(s.tradingStatus ?? ''), capitalTradingStatus: String(s.capitalTradingStatus ?? s.tradingStatus ?? ''), + asset: String(s.asset ?? ''), }); } if (next.size > 0) { @@ -1230,13 +1234,15 @@ export class VestClient implements PlatformClient { || (x.currentStop !== null && y.currentStop !== null ? lossAt(x.pos, x.currentStop) - lossAt(y.pos, y.currentStop) : 0) || x.pos.openedAt - y.pos.openedAt || x.pos.positionId.localeCompare(y.pos.positionId)); let committed = 0; - let notionalSoFar = 0; + // The slippage reserve the positions so far hold together, in + // dollars: each its own symbol's share of its notional. + let slippageSoFar = 0; for (const { pos, currentStop } of held) { const spec = specs.get(pos.symbol); if (!spec) { // Can't be fitted without its tick, but it still holds // its loss against the room the others share. - notionalSoFar += pos.quantity * pos.openPrice; + slippageSoFar += this.stopSlippageFraction * pos.quantity * pos.openPrice; if (currentStop !== null) committed += lossAt(pos, currentStop); else if (!guard.unfittable.has(pos.positionId)) { guard.unfittable.add(pos.positionId); @@ -1248,13 +1254,13 @@ export class VestClient implements PlatformClient { const mark = this.marks.get(pos.symbol)?.mark ?? NaN; const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null; const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice); - notionalSoFar += notional; + slippageSoFar += vestStopSlippageFraction(spec.asset) * notional; const exitFee = spec.takerFee * notional; const decision = resetSafeStop({ side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark, balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit, floorBalance: cash.amount, - reserve: dllReserve(cash.amount * a.maxDailyLossPct, notionalSoFar, this.stopSlippageFraction), + reserve: dllReserve(cash.amount * a.maxDailyLossPct, slippageSoFar, 1), exitFeeRate: spec.takerFee, currentStop, tick: spec.tick, }); const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null; @@ -2022,6 +2028,7 @@ export class VestClient implements PlatformClient { minContracts: 1, marginRate: spec.marginRate || undefined, closesAt: gateAt ?? undefined, + stopSlippageFraction: vestStopSlippageFraction(spec.asset), }, }); } diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index d6cf11c..3453136 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -148,6 +148,18 @@ export function nextVestResetMs(now: number): number { * past the trigger on average, 2.25 at worst — the 0.1% default held back * about 30. The flat 10% of the limit still applies under it. */ export const VEST_STOP_SLIPPAGE_FRACTION = 0.0002; +/** Crypto's (asset "crypto" in exchangeInfo: BTC, ETH, SOL, HYPE): unmeasured, + * so the engine's default share (trading-logic's DLL_SLIPPAGE_FRACTION). + * Vest's BTC book trades ~$10M an hour on a weekend against NDX's ~$600M, and + * a batch's stops fire together: a square-root impact estimate scaled to the + * NDX measurement puts the worst fill near 7bp. To come down once crypto + * stop-outs are measured, as NDX's was. */ +export const VEST_CRYPTO_STOP_SLIPPAGE_FRACTION = 0.001; + +/** The stop-slippage share for a symbol of this exchangeInfo asset class. */ +export function vestStopSlippageFraction(asset: string | undefined): number { + return asset === 'crypto' ? VEST_CRYPTO_STOP_SLIPPAGE_FRACTION : VEST_STOP_SLIPPAGE_FRACTION; +} /** * What to do with an open position before the daily reset so the reset can't diff --git a/scripts/check-vest-client.mjs b/scripts/check-vest-client.mjs index 1535096..6525394 100644 --- a/scripts/check-vest-client.mjs +++ b/scripts/check-vest-client.mjs @@ -62,7 +62,7 @@ const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib const { botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles, } = await import('../lib/db.ts'); -const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts'); +const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE, resetSafeStop } = await import('../lib/vest-helpers.ts'); const { encodeContractName } = await import('../lib/dxtrade-helpers.ts'); const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts'); const { isClientReady, isOrderNotPlaced } = await import('../lib/platform-client.ts'); @@ -93,10 +93,10 @@ const week = [ shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'), open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'), ]; -const spec = (symbol, assetId) => ({ - symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING', +const spec = (symbol, assetId, asset = 'stock') => ({ + symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING', asset, }); -const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]); +const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9, 'crypto')]]); /** A client with no sync running, its gateway answered by `answer`, holding * acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */ @@ -221,6 +221,9 @@ test('a symbol the close gate holds back stays on the board, and isn\'t picked; assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']); const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD'); assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z')); + // Crypto's stops keep the engine's slippage share; NDX's is measured. + assert.equal(pick.stopSlippageFraction, 0.001); + assert.equal(ndx.stopSlippageFraction, 0.0002); // The survey keeps it up for the dashboard, though nothing is picked. assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null); assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']); @@ -769,3 +772,42 @@ test('exits wait for the login session to be relayed again; an open goes now or client.disconnect(); } }); + +test("the reset guard holds each position's own slippage reserve: crypto's wider than NDX's", async () => { + // 7:56 PM ET: four minutes to Vest's reset. acc-1 holds BTC, acc-2 NDX, + // neither with a stop. + clockAt('2026-10-29T23:56:00Z'); + const stops = []; + const positions = [ + rawPosition('9201', 'acc-1', iso('2026-10-29T22:00:00Z'), { symbol: 'BTC-PERP', quantity: '5', openPrice: '70000' }), + rawPosition('9202', 'acc-2', iso('2026-10-29T22:00:00Z'), { quantity: '15', openPrice: '20000' }), + ]; + const client = testClient((method, path, opts) => { + if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } }; + if (method === 'POST' && path === '/v3/positions/stop-loss') { stops.push({ account: opts.account, ...opts.body }); return { data: {} }; } + throw new Error(`no gateway in this test: ${method} ${path}`); + }); + client.refreshMarks = async () => {}; + client.marks.set('BTC-PERP', { mark: 70000, at: Date.now() }); + client.marks.set('NDX-USD', { mark: 20000, at: Date.now() }); + for (const id of [1, 2]) { + client.capital.set(id, { status: VEST_STATUS_ACTIVE, maxDailyLossPct: 0.03, maxDrawdownLimit: 47000 }); + client.accountCashBalances[id] = { amount: 50000, realizedPnL: 0 }; + } + client.syncComplete = true; + const fitted = (quantity, openPrice, reserve) => resetSafeStop({ + side: 'long', quantity, openPrice, mark: openPrice, balance: 50000, floorBalance: 50000, dailyLossPct: 0.03, + maxDrawdownFloor: 47000, reserve, exitFeeRate: 0.0001, currentStop: null, tick: 0.01, + }); + try { + await client.guardReset(); + const by = Object.fromEntries(stops.map((s) => [s.account, Number(s.triggerPrice)])); + // BTC: 0.1% of $350k = $350 held back, over the flat $150. + assert.equal(by['acc-1'], fitted(5, 70000, 350).price); + assert.notEqual(by['acc-1'], fitted(5, 70000, 150).price); + // NDX: 0.02% of $300k is $60, so the flat $150 stands. + assert.equal(by['acc-2'], fitted(15, 20000, 150).price); + } finally { + client.disconnect(); + } +}); diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 345562c..3f924c3 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -15,7 +15,7 @@ import { hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims, claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName, - VEST_STATUS, + VEST_STATUS, vestStopSlippageFraction, VEST_STOP_SLIPPAGE_FRACTION, VEST_CRYPTO_STOP_SLIPPAGE_FRACTION, } from '../lib/vest-helpers.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts'; import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts'; @@ -664,3 +664,9 @@ test('a refused account token falls back to the login\'s and stays refused', () refuseVestSession(wallet2, account.replace('acct', 'accu'), now); assert.equal(vestSessionFor(wallet2, now), null); }); + +test("a crypto stop's slippage share is the engine default; everything else keeps NDX's measured one", () => { + assert.equal(VEST_CRYPTO_STOP_SLIPPAGE_FRACTION, 0.001); + assert.equal(vestStopSlippageFraction('crypto'), VEST_CRYPTO_STOP_SLIPPAGE_FRACTION); + for (const asset of ['stock', 'forex', '', undefined]) assert.equal(vestStopSlippageFraction(asset), VEST_STOP_SLIPPAGE_FRACTION); +});