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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e90f21ed91
commit
378530595e
+7
-5
@@ -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,
|
||||
|
||||
+16
-9
@@ -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<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;
|
||||
@@ -1585,6 +1586,7 @@ export class DXTradeClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
|
||||
+44
-6
@@ -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. */
|
||||
|
||||
+7
-6
@@ -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);
|
||||
|
||||
@@ -421,34 +421,6 @@ 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user