From e90f21ed91c337f39200586ae2b3f15e7b72ee78 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:03:14 -0500 Subject: [PATCH 1/2] 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'); From 378530595eb3cd1a184a1f345fd9e6433da75eec Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:13:07 -0500 Subject: [PATCH 2/2] DXtrade: an entry never closes a hand trade, and a flatten never opens one DXtrade dropped the intent the engine now passes, so on the live Tradeify247 accounts an entry could still close a hand position as the "entry" and the leg's flatten then OPEN a position with no exits. The open/close decision moves out of vest-helpers into platform-client's orderEffect, which both clients now follow to pick OPEN or CLOSE. A flatten's flat read is "nothing to flatten" only when the client's own record agrees. One empty read just after a fill the client saw may be the gateway lagging, so it stays a plain refusal and keeps the FLATTEN FAILED alarm. A check now fails if an engine order drops its intent or a client stops going by orderEffect. Co-Authored-By: Claude Opus 5.5 --- lib/auto-trade.ts | 12 +++--- lib/dxtrade-class.ts | 25 +++++++----- lib/platform-client.ts | 50 ++++++++++++++++++++--- lib/vest-class.ts | 13 +++--- lib/vest-helpers.ts | 28 ------------- scripts/check-trading-logic.mjs | 70 +++++++++++++++++++++++++++++++++ scripts/check-vest.mjs | 43 ++------------------ 7 files changed, 147 insertions(+), 94 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 9a9793a..5c8494b 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -260,9 +260,10 @@ 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. 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". + * account twice. Open-only: where the platform checks (Vest, DXtrade), 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, @@ -1304,8 +1305,9 @@ const round2 = (n: number) => Math.round(n * 100) / 100; * report the leg as failed. A flatten that fails too is the worst case: a * 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. + * where the platform checks (Vest, DXtrade) and its own record agrees, that + * flat account has nothing riding — the leg still fails, but without the + * alarm. A flat read the record contradicts still gets the alarm. */ async function flattenAndFail( tag: string, diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts index c89bee2..d590a1b 100644 --- a/lib/dxtrade-class.ts +++ b/lib/dxtrade-class.ts @@ -42,8 +42,8 @@ import { getAccountConfig } from './account-type-resolver'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; import type { SymbolMapCandidate } from './master-protocol'; -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, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client'; import { dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep, lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode, @@ -1557,10 +1557,10 @@ export class DXTradeClient implements PlatformClient { /** * Place an order and wait for its fill: an OPEN when the account is flat, - * a CLOSE 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 of the position when it holds the opposite side — only the one + * `intent` names, when given (orderEffect). 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, @@ -1569,9 +1569,10 @@ export class DXTradeClient 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; @@ -1585,6 +1586,7 @@ export class DXTradeClient implements PlatformClient { action: 'Buy' | 'Sell', orderType: 'Market' | 'Limit', price?: number, + intent?: OrderIntent, ): Promise> { const { account, symbol, step } = this.orderContext(accountId, contractSymbol); const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL'; @@ -1594,8 +1596,13 @@ export class DXTradeClient implements PlatformClient { } catch (err) { throw new OrderNotPlacedError(`DXtrade 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 held = positions.find((p) => p.symbol === symbol); + const sideOf = (s: 'BUY' | 'SELL'): 'long' | 'short' => (s === 'BUY' ? 'long' : 'short'); + const mine = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol); + // orderEffect picks OPEN or CLOSE; `existing` is the position a CLOSE closes. + const existing = orderEffect({ + symbol, held: held ? sideOf(held.side) : null, recorded: mine ? sideOf(mine.side) : null, side: sideOf(side), intent, + }) === 'close' ? held : undefined; const code = newOrderCode(existing ? 'x' : 'e'); let body: Record; diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 293fab4..4bf73d9 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -171,9 +171,9 @@ export interface PlatformClient { /** `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. */ + * flat. A platform that checks it (orderEffect) 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, @@ -464,9 +464,9 @@ export function isOrderNotPlaced(err: unknown): boolean { /** * 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. + * showed no position in the symbol to close, and the client's own record + * agrees: 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; @@ -481,6 +481,44 @@ export function isNothingToClose(err: unknown): boolean { return !!err && typeof err === 'object' && (err as { nothingToClose?: unknown }).nothingToClose === true; } +/** + * What an order on `side` does on a position-based platform (Vest, DXtrade), + * given a fresh read of the account's position in the symbol (`held`: its + * side, null when flat) and the client's own last record of it (`recorded`): + * open, or close (or reduce) — or it throws why nothing is sent. With no + * intent a flat account opens and the opposite side closes. 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, and a flat read is NothingToCloseError only + * when the record agrees — one empty read just after a fill the client saw + * may be the gateway lagging, and a leg riding without its stop must still be + * shouted about. Adding to a position is never supported. + */ +export function orderEffect(o: { + symbol: string; + held: 'long' | 'short' | null; + recorded: 'long' | 'short' | null; + side: 'long' | 'short'; + intent?: OrderIntent; +}): 'open' | 'close' { + const { symbol, held, recorded, side, intent } = o; + if (held === side) { + throw new OrderNotPlacedError(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') { + throw new OrderNotPlacedError(`a ${held} ${symbol} position is already open — an entry never closes it, order not sent`); + } + if (!held && intent === 'close') { + if (recorded && recorded !== side) { + throw new OrderNotPlacedError(`a fresh read shows no ${symbol} position, but this client last saw a ${recorded} one open — nothing sent`); + } + throw new NothingToCloseError(`no ${symbol} position to close — nothing sent`); + } + return held ? 'close' : 'open'; +} + /** 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 b66c943..1a94d58 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -47,13 +47,13 @@ import { import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client'; -import { OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, errorMessage } from './platform-client'; +import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } 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, vestOrderEffect, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION, + parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION, } from './vest-helpers'; import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers'; @@ -1606,7 +1606,7 @@ 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 — - * only the one `intent` names, when given (vestOrderEffect). Adding to a + * only the one `intent` names, when given (orderEffect). 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`. @@ -1645,9 +1645,10 @@ export class VestClient implements PlatformClient { } catch (err) { throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`); } - const existing = positions.find((p) => p.symbol === symbol); - 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 held = positions.find((p) => p.symbol === symbol); + const recorded = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol)?.side ?? null; + // orderEffect picks open or close; `existing` is the position a close closes. + const existing = orderEffect({ symbol, held: held?.side ?? null, recorded, side, intent }) === 'close' ? held : undefined; 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 62e2a73..80e0e4b 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -421,34 +421,6 @@ export function unrealizedPnl(p: Pick= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`); assert.equal(plan.remainingToday, plan.dayTarget); }); + +// ── what an order is for (lib/platform-client.ts orderEffect) ──────────────── + +import { readFileSync } from 'node:fs'; +import { orderEffect, OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, isNothingToClose } from '../lib/platform-client.ts'; + +test('an entry never closes a position, a flatten never opens one (Vest, DXtrade)', () => { + /** The route, or the refusal thrown. `recorded`: the client's own record + * of the symbol, by default what the fresh read shows. */ + const effect = (held, side, intent, recorded = held) => { + try { + return orderEffect({ symbol: 'NDX-USD-PERP', held, recorded, side, intent }); + } catch (err) { + return err; + } + }; + // No intent: the position decides, as before (the manual order route). + assert.equal(effect(null, 'long'), 'open'); + assert.equal(effect('short', 'long'), 'close'); + assert.match(effect('long', 'long').message, /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 (the leg is + // un-counted), not turned into a close of that short. + assert.equal(effect(null, 'long', 'open'), 'open'); + for (const recorded of ['short', null]) { + const entry = effect('short', 'long', 'open', recorded); + assert.equal(isOrderNotPlaced(entry), true); + assert.equal(isNothingToClose(entry), false); + assert.match(entry.message, /short NDX-USD-PERP position is already open/); + } + assert.match(effect('long', 'long', 'open').message, /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 when the record agrees. + assert.equal(effect('long', 'short', 'close'), 'close'); + const flat = effect(null, 'short', 'close'); + assert.ok(flat instanceof NothingToCloseError); + assert.equal(isOrderNotPlaced(flat), true); + assert.equal(isNothingToClose(flat), true); + assert.match(flat.message, /no NDX-USD-PERP position to close/); + // A flat read just after a fill the client saw (the leg still on record) + // may be the gateway lagging: nothing sent, but not "flat" — the flatten + // keeps its alarm. + const lagging = effect(null, 'short', 'close', 'long'); + assert.ok(lagging instanceof OrderNotPlacedError); + assert.equal(isNothingToClose(lagging), false); + assert.match(lagging.message, /fresh read shows no NDX-USD-PERP position, but this client last saw a long one open/); + // Holding the flatten's own side is not the leg: refused, and not "flat". + const wrong = effect('short', 'short', 'close'); + assert.ok(wrong instanceof OrderNotPlacedError); + assert.equal(isNothingToClose(wrong), false); + assert.match(wrong.message, /holds a short NDX-USD-PERP position, not one to close/); + + 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('the engine says what each order is for, and both position-based clients go by it', () => { + const source = (file) => readFileSync(new URL(`../lib/${file}`, import.meta.url), 'utf8'); + // An order sent without its intent lets the position decide again: an + // entry closing a hand trade, a flatten opening a position with no exits. + const calls = [...source('auto-trade.ts').matchAll(/client\.sendOrder\(([^;]+?)\);/g)].map((m) => m[1].trim()); + assert.ok(calls.length >= 4, `found ${calls.length} sendOrder calls`); + for (const args of calls) assert.match(args, /, '(open|close)'$/, `sendOrder(${args}) names no intent`); + for (const file of ['vest-class.ts', 'dxtrade-class.ts']) { + const src = source(file); + assert.match(src, /return await this\.sendOrderAndWait\([^)]*, intent\);/, `${file}: sendOrder drops the intent`); + assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`); + } +}); diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 2b04bd3..897556c 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -1,6 +1,5 @@ -// 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). +// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the +// relayed-session store (lib/vest-session.ts). // // npm run check // @@ -13,10 +12,9 @@ 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, vestOrderEffect, + accountScopedToken, } 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); @@ -144,41 +142,6 @@ 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');