From e90f21ed91c337f39200586ae2b3f15e7b72ee78 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:03:14 -0500 Subject: [PATCH] Vest: an entry never closes a hand trade, and a flatten never opens one sendOrder takes an optional intent. On Vest an 'open' is refused when the account already holds the symbol, where it used to close that position as the "entry"; the leg's exits then found nothing and its flatten opened a fresh position with no TP or stop. A 'close' only closes or reduces, and on a flat account is refused with NothingToCloseError, which flattenAndFail reports as nothing to flatten instead of FLATTEN FAILED. Entries, the extra-day round trip and flattens pass their intent; the manual order route and the other platforms keep today's behaviour. Co-Authored-By: Claude Opus 5.5 --- lib/auto-trade.ts | 23 +++++++++++++++------- lib/platform-client.ts | 31 +++++++++++++++++++++++++++++- lib/vest-class.ts | 22 ++++++++++++--------- lib/vest-helpers.ts | 28 +++++++++++++++++++++++++++ scripts/check-vest.mjs | 43 +++++++++++++++++++++++++++++++++++++++--- 5 files changed, 127 insertions(+), 20 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 0fae444..9a9793a 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -28,7 +28,7 @@ import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futu import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; import { recordEntry } from './trade-mix'; import { sizingHoldHours } from './person-size'; -import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client'; +import { errorMessage, isClientReady, isNothingToClose, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client'; // ── process-wide state ─────────────────────────────────────────────────────── @@ -260,7 +260,9 @@ function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): v * (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout, * keeps the leg: the order may well have filled, and a leg counted that never * happened only costs a leg, while one forgotten that did can trade the - * account twice. + * account twice. Open-only: where the platform checks (Vest), an account that + * already holds the symbol — a hand trade the synced positions don't show + * yet — is refused rather than having that position closed as the "entry". */ async function sendEntry( tag: string, @@ -277,7 +279,7 @@ async function sendEntry( const at = Date.now(); const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0; try { - const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market'); + const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market', undefined, 'open'); outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount }); return fill; } catch (err) { @@ -1237,10 +1239,10 @@ async function runTradeLocked( const mnqContract = await client.findFrontMonthContract(microRoot); if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`); - const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market'); + const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market', undefined, 'open'); recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; - const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market'); + const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market', undefined, 'close'); console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`); @@ -1300,7 +1302,10 @@ const round2 = (n: number) => Math.round(n * 100) / 100; * Close a just-filled position at market because it must not stay open — its * daily-loss stop has no room, or the broker refused its exit bracket — and * report the leg as failed. A flatten that fails too is the worst case: a - * position with no stop, so it is shouted about. + * position with no stop, so it is shouted about. Close-only: on an account + * that turned out flat it must not open a fresh position with no exits, and + * where the platform checks (Vest) that flat account has nothing riding — the + * leg still fails, but without the alarm. */ async function flattenAndFail( tag: string, @@ -1314,9 +1319,13 @@ async function flattenAndFail( why: string, ): Promise { try { - const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market'); + const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market', undefined, 'close'); console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`); } catch (err) { + if (isNothingToClose(err)) { + console.warn(`${tag} ${acc.name}: nothing to flatten — ${errorMessage(err)} (${why})`); + throw new Error(`entry filled @ ${entryPrice}; ${why}; already flat — nothing to flatten`); + } console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`); throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`); } diff --git a/lib/platform-client.ts b/lib/platform-client.ts index f9427fc..293fab4 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -50,6 +50,9 @@ export interface PlatformContract { marginRate?: number; } +/** What an order is for, where the caller knows (see sendOrder). */ +export type OrderIntent = 'open' | 'close'; + export interface PlatformClient { accountList: PlatformAccount[]; /** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows @@ -165,13 +168,20 @@ export interface PlatformClient { findFrontMonthContract(productName: string, pick?: PickContext): Promise; fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>; + /** `intent`, where the caller knows what the order is for: an entry + * ('open') must never close a position it didn't know was there, a + * flatten ('close') must never open one on an account that turned out + * flat. A platform that checks it refuses the order otherwise + * (NothingToCloseError for a close with nothing to close); left out, or + * on a platform that doesn't check, the position decides. */ sendOrder( accountId: number, contractSymbol: string, quantity: number, action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', - price?: number + price?: number, + intent?: OrderIntent, ): Promise>; placeOrderNoWait( @@ -452,6 +462,25 @@ export function isOrderNotPlaced(err: unknown): boolean { return !!err && typeof err === 'object' && (err as { orderNotPlaced?: unknown }).orderNotPlaced === true; } +/** + * A close-only order (sendOrder's 'close') refused because a fresh read + * showed no position in the symbol to close: the account is flat there, so a + * flatten that gets this has nothing left to do — nothing rides without its + * stop. + */ +export class NothingToCloseError extends OrderNotPlacedError { + readonly nothingToClose = true; + constructor(message: string) { + super(message); + this.name = 'NothingToCloseError'; + } +} + +/** Duck-typed, like isOrderNotPlaced. */ +export function isNothingToClose(err: unknown): boolean { + return !!err && typeof err === 'object' && (err as { nothingToClose?: unknown }).nothingToClose === true; +} + /** An error's message without the rest of it. AxiosErrors carry the request * config — Tradovate's login body (the password) and Bearer tokens — so they * must never be logged whole. */ diff --git a/lib/vest-class.ts b/lib/vest-class.ts index dfe8350..b66c943 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -46,14 +46,14 @@ import { } from './db'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; -import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client'; -import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; +import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client'; +import { OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, errorMessage } from './platform-client'; import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers'; import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session'; import { vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop, - parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION, + parseHistoryPosition, jwtClaimStrings, accountScopedToken, vestOrderEffect, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION, } from './vest-helpers'; import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers'; @@ -1605,10 +1605,11 @@ export class VestClient implements PlatformClient { /** * Place an order and wait for its fill: an open when the account is flat, - * a close (or reduce) of the position when it holds the opposite side. - * Adding to a position is refused, and so is an open against another - * account's opposite position in the same symbol (no hedging). Resolves - * with the fill `price`. + * a close (or reduce) of the position when it holds the opposite side — + * only the one `intent` names, when given (vestOrderEffect). Adding to a + * position is refused, and so is an open against another account's + * opposite position in the same symbol (no hedging). Resolves with the + * fill `price`. */ public async sendOrder( accountId: number, @@ -1617,9 +1618,10 @@ export class VestClient implements PlatformClient { action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, + intent?: OrderIntent, ): Promise> { try { - return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price); + return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent); } catch (err) { if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now()); throw err; @@ -1633,6 +1635,7 @@ export class VestClient implements PlatformClient { action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, + intent?: OrderIntent, ): Promise> { const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol); const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short'; @@ -1643,7 +1646,8 @@ export class VestClient implements PlatformClient { throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`); } const existing = positions.find((p) => p.symbol === symbol); - if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`); + const route = vestOrderEffect({ symbol, held: existing?.side ?? null, side, intent }); + if ('refused' in route) throw route.nothingToClose ? new NothingToCloseError(route.refused) : new OrderNotPlacedError(route.refused); const units = roundToStep(contracts * step, step); const qty = (n: number) => formatDecimal(n, spec.sizeDecimals); diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 80e0e4b..62e2a73 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -421,6 +421,34 @@ export function unrealizedPnl(p: Pick Date.parse(s); @@ -142,6 +144,41 @@ test('positions, their legs and unrealized P&L', () => { assert.equal(parseOpenOrder({ symbol: 'x' }), null); }); +test('an entry never closes a position, a flatten never opens one', () => { + const effect = (held, side, intent) => vestOrderEffect({ symbol: 'NDX-USD-PERP', held, side, intent }); + // No intent: the position decides, as before (the manual order route). + assert.deepEqual(effect(null, 'long'), { effect: 'open' }); + assert.deepEqual(effect('short', 'long'), { effect: 'close' }); + assert.match(effect('long', 'long').refused, /adding to an open NDX-USD-PERP position/); + // An entry on an account that already holds the symbol — a hand short the + // engine's synced positions didn't show yet — is refused, not turned into + // a close of that short. + assert.deepEqual(effect(null, 'long', 'open'), { effect: 'open' }); + const entry = effect('short', 'long', 'open'); + assert.match(entry.refused, /short NDX-USD-PERP position is already open/); + assert.equal(entry.nothingToClose, undefined); + assert.match(effect('long', 'long', 'open').refused, /adding to an open/); + // A flatten closes the leg it was sent for; on a flat account it opens + // nothing and says there is nothing to close. + assert.deepEqual(effect('long', 'short', 'close'), { effect: 'close' }); + const flat = effect(null, 'short', 'close'); + assert.match(flat.refused, /no NDX-USD-PERP position to close/); + assert.equal(flat.nothingToClose, true); + // Holding the flatten's own side is not the leg: refused, and not "flat". + const wrong = effect('short', 'short', 'close'); + assert.match(wrong.refused, /holds a short NDX-USD-PERP position, not one to close/); + assert.equal(wrong.nothingToClose, undefined); + + // The refusal the client throws for it: still "never placed" (the leg is + // un-counted), and told apart from every other refusal. + const nothing = new NothingToCloseError('no NDX-USD-PERP position to close — nothing sent'); + assert.equal(isOrderNotPlaced(nothing), true); + assert.equal(isNothingToClose(nothing), true); + assert.equal(isNothingToClose(new OrderNotPlacedError('Vest refused the order: 400')), false); + assert.equal(isNothingToClose(new Error('outcome unknown')), false); + assert.equal(isNothingToClose(undefined), false); +}); + test('transactions book into daily P&L and fund rows by Vest day', () => { assert.equal(txnKind(VEST_TXN.FEE), 'trade'); assert.equal(txnKind(VEST_TXN.REALIZED_PNL), 'trade');