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
+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. */