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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 14:03:14 -05:00
co-authored by Claude Opus 5.5
parent beddbdcf6c
commit e90f21ed91
5 changed files with 127 additions and 20 deletions
+16 -7
View File
@@ -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<never> {
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)}`);
}
+30 -1
View File
@@ -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<PlatformContract | null>;
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<Record<string, unknown>>;
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. */
+13 -9
View File
@@ -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<Record<string, unknown>> {
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<Record<string, unknown>> {
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);
+28
View File
@@ -421,6 +421,34 @@ export function unrealizedPnl(p: Pick<VestPosition, 'side' | 'quantity' | 'openP
return (p.side === 'long' ? 1 : -1) * p.quantity * (mark - p.openPrice);
}
/**
* What an order on `side` does to the account's position in the symbol
* (`held`: its side, null when flat), or why nothing is sent. With no intent
* a flat account opens and the opposite side closes or reduces. An entry
* ('open') never closes: a hand trade the engine didn't know about would be
* closed in its place, and the leg's flatten would then open a position with
* no exits. A flatten ('close') never opens: nothingToClose says the account
* is flat in the symbol. Adding to a position is never supported.
*/
export function vestOrderEffect(o: {
symbol: string;
held: 'long' | 'short' | null;
side: 'long' | 'short';
intent?: 'open' | 'close';
}): { effect: 'open' | 'close' } | { refused: string; nothingToClose?: true } {
const { symbol, held, side, intent } = o;
if (held === side) {
return {
refused: intent === 'close'
? `the account holds a ${held} ${symbol} position, not one to close — nothing sent`
: `adding to an open ${symbol} position is not supported`,
};
}
if (held && intent === 'open') return { refused: `a ${held} ${symbol} position is already open — an entry never closes it, order not sent` };
if (!held && intent === 'close') return { refused: `no ${symbol} position to close — nothing sent`, nothingToClose: true };
return { effect: held ? 'close' : 'open' };
}
// ── transactions ────────────────────────────────────────────────────────────
/**
+40 -3
View File
@@ -1,5 +1,6 @@
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the
// relayed-session store (lib/vest-session.ts).
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the
// relayed-session store (lib/vest-session.ts) and the order refusals it
// throws (lib/platform-client.ts).
//
// npm run check
//
@@ -12,9 +13,10 @@ import {
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
accountScopedToken,
accountScopedToken, vestOrderEffect,
} from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
import { OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, isNothingToClose } from '../lib/platform-client.ts';
const iso = (s) => 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');