Vest: a crypto leg's stop holds back the engine's default slippage, not NDX's

Crypto (exchangeInfo asset "crypto": BTC, ETH, SOL, HYPE) trades on weekends
when NDX, SPX and GC are shut, but its stop slippage on Vest is unmeasured, and
its book is a fraction of NDX's: about $10M an hour on a weekend against $600M,
with a batch's stops firing together. Its stops now leave 0.1% of the
position unspent (the engine's default) instead of the 0.02% measured on NDX
stop-outs; every other symbol keeps 0.02%.

The share rides on the picked contract and is used wherever a Vest leg's stop
is sized: the batch's plan, the leg's own stop, the dashboard's next stop, and
the 7:55 PM ET reset guard, which now sums each open position's own share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-30 17:26:53 -05:00
co-authored by Claude Opus 5.5
parent 618976bf5f
commit 10da0ca83e
8 changed files with 94 additions and 16 deletions
+10 -3
View File
@@ -4,7 +4,7 @@ import { getClients } from '@/lib/clients';
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog'; import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
import { ruleDiffs } from '@/lib/kept-rules'; import { ruleDiffs } from '@/lib/kept-rules';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; 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 { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate';
import { obfuscationDayBooked } from '@/lib/auto-trade'; import { obfuscationDayBooked } from '@/lib/auto-trade';
import type { AccountState } from '@/types'; import type { AccountState } from '@/types';
@@ -176,9 +176,16 @@ export async function GET() {
if (plan && !dayBooked && botStopsActive(cfg, effective)) { if (plan && !dayBooked && botStopsActive(cfg, effective)) {
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday); const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
// The reserve grows with the position (dllReserve): at // 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 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; : Infinity;
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard); const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100; if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
+2 -2
View File
@@ -1426,7 +1426,7 @@ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tra
maxLossRoom: floorRoom, maxLossRoom: floorRoom,
obfuscationDollars: obfuscation, obfuscationDollars: obfuscation,
availableFunds: live.availableFunds ?? live.amount, availableFunds: live.availableFunds ?? live.amount,
slippageFraction: item.client.stopSlippageFraction, slippageFraction: contract.stopSlippageFraction ?? item.client.stopSlippageFraction,
}); });
return { ...plan, pct, obfuscation, maxLossRoom: floorRoom }; 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 dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts);
const notional = fill.price * pv * contracts; const notional = fill.price * pv * contracts;
const dllDollars = dllOn && !plan.lastStand 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; : Infinity;
const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize }); const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize });
if (ticks === 0) { if (ticks === 0) {
+1 -1
View File
@@ -100,7 +100,7 @@ export function rankNextLeg(o: {
maxLossRoom: floorRoom, maxLossRoom: floorRoom,
obfuscationDollars: o.obfuscationDollars, obfuscationDollars: o.obfuscationDollars,
availableFunds: funds, availableFunds: funds,
slippageFraction: o.client.stopSlippageFraction, slippageFraction: c.stopSlippageFraction ?? o.client.stopSlippageFraction,
}); });
const p = plan(true); const p = plan(true);
if (!p.sizing || p.cost === null) return []; if (!p.sizing || p.cost === null) return [];
+4
View File
@@ -53,6 +53,10 @@ export interface PlatformContract {
* says: no leg starts that wouldn't be done by then (clearsClose), each * says: no leg starts that wouldn't be done by then (clearsClose), each
* account's at its own size (fireLeg). */ * account's at its own size (fireLeg). */
closesAt?: number; 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). */ /** What an order is for, where the caller knows (see sendOrder). */
+12 -5
View File
@@ -53,7 +53,7 @@ import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession }
import { import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop, 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, vestStatusName, awaitingActivation,
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
} from './vest-helpers'; } from './vest-helpers';
@@ -155,6 +155,9 @@ interface SymbolSpec {
marginRate: number; marginRate: number;
tradingStatus: string; tradingStatus: string;
capitalTradingStatus: string; capitalTradingStatus: string;
/** exchangeInfo's asset class: "crypto", "forex", "stock" (indices and
* commodities too). */
asset: string;
} }
export interface VestConfig { export interface VestConfig {
@@ -558,6 +561,7 @@ export class VestClient implements PlatformClient {
marginRate: capitalIm && capitalIm > 0 ? capitalIm : 0, marginRate: capitalIm && capitalIm > 0 ? capitalIm : 0,
tradingStatus: String(s.tradingStatus ?? ''), tradingStatus: String(s.tradingStatus ?? ''),
capitalTradingStatus: String(s.capitalTradingStatus ?? s.tradingStatus ?? ''), capitalTradingStatus: String(s.capitalTradingStatus ?? s.tradingStatus ?? ''),
asset: String(s.asset ?? ''),
}); });
} }
if (next.size > 0) { 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.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)); || x.pos.openedAt - y.pos.openedAt || x.pos.positionId.localeCompare(y.pos.positionId));
let committed = 0; 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) { for (const { pos, currentStop } of held) {
const spec = specs.get(pos.symbol); const spec = specs.get(pos.symbol);
if (!spec) { if (!spec) {
// Can't be fitted without its tick, but it still holds // Can't be fitted without its tick, but it still holds
// its loss against the room the others share. // 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); if (currentStop !== null) committed += lossAt(pos, currentStop);
else if (!guard.unfittable.has(pos.positionId)) { else if (!guard.unfittable.has(pos.positionId)) {
guard.unfittable.add(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 mark = this.marks.get(pos.symbol)?.mark ?? NaN;
const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null; const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null;
const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice); const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice);
notionalSoFar += notional; slippageSoFar += vestStopSlippageFraction(spec.asset) * notional;
const exitFee = spec.takerFee * notional; const exitFee = spec.takerFee * notional;
const decision = resetSafeStop({ const decision = resetSafeStop({
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark, side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit, balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
floorBalance: cash.amount, 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, exitFeeRate: spec.takerFee, currentStop, tick: spec.tick,
}); });
const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null; const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null;
@@ -2022,6 +2028,7 @@ export class VestClient implements PlatformClient {
minContracts: 1, minContracts: 1,
marginRate: spec.marginRate || undefined, marginRate: spec.marginRate || undefined,
closesAt: gateAt ?? undefined, closesAt: gateAt ?? undefined,
stopSlippageFraction: vestStopSlippageFraction(spec.asset),
}, },
}); });
} }
+12
View File
@@ -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 * 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. */ * about 30. The flat 10% of the limit still applies under it. */
export const VEST_STOP_SLIPPAGE_FRACTION = 0.0002; 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 * What to do with an open position before the daily reset so the reset can't
+46 -4
View File
@@ -62,7 +62,7 @@ const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib
const { const {
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles, botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
} = await import('../lib/db.ts'); } = 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 { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts'); const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts');
const { isClientReady, isOrderNotPlaced } = await import('../lib/platform-client.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'), shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'), open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
]; ];
const spec = (symbol, assetId) => ({ 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', 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 /** 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). */ * 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']); assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD'); const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z')); 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. // The survey keeps it up for the dashboard, though nothing is picked.
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null); assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']); 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(); 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();
}
});
+7 -1
View File
@@ -15,7 +15,7 @@ import {
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims, accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName, 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'; } from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.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); refuseVestSession(wallet2, account.replace('acct', 'accu'), now);
assert.equal(vestSessionFor(wallet2, now), null); 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);
});