From 378530595eb3cd1a184a1f345fd9e6433da75eec Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:13:07 -0500 Subject: [PATCH] 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');