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');