Merge branch 'vest-fix-intent' into vest-fixes
This commit is contained in:
+18
-7
@@ -28,7 +28,7 @@ import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futu
|
|||||||
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
||||||
import { recordEntry } from './trade-mix';
|
import { recordEntry } from './trade-mix';
|
||||||
import { sizingHoldHours } from './person-size';
|
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 ───────────────────────────────────────────────────────
|
// ── process-wide state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -260,7 +260,10 @@ function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): v
|
|||||||
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
|
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
|
||||||
* keeps the leg: the order may well have filled, and a leg counted that never
|
* 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
|
* happened only costs a leg, while one forgotten that did can trade the
|
||||||
* account twice.
|
* 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(
|
async function sendEntry(
|
||||||
tag: string,
|
tag: string,
|
||||||
@@ -277,7 +280,7 @@ async function sendEntry(
|
|||||||
const at = Date.now();
|
const at = Date.now();
|
||||||
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
|
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
|
||||||
try {
|
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 });
|
outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount });
|
||||||
return fill;
|
return fill;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1240,10 +1243,10 @@ async function runTradeLocked(
|
|||||||
const mnqContract = await client.findFrontMonthContract(microRoot);
|
const mnqContract = await client.findFrontMonthContract(microRoot);
|
||||||
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`);
|
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);
|
recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id);
|
||||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
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)`);
|
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`);
|
||||||
|
|
||||||
@@ -1303,7 +1306,11 @@ const round2 = (n: number) => Math.round(n * 100) / 100;
|
|||||||
* Close a just-filled position at market because it must not stay open — its
|
* 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
|
* 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
|
* 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, 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(
|
async function flattenAndFail(
|
||||||
tag: string,
|
tag: string,
|
||||||
@@ -1317,9 +1324,13 @@ async function flattenAndFail(
|
|||||||
why: string,
|
why: string,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
try {
|
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}`);
|
console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`);
|
||||||
} catch (err) {
|
} 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})`);
|
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)}`);
|
throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-9
@@ -42,8 +42,8 @@ import { getAccountConfig } from './account-type-resolver';
|
|||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
import { firmInstruments } from './instruments';
|
import { firmInstruments } from './instruments';
|
||||||
import type { SymbolMapCandidate } from './master-protocol';
|
import type { SymbolMapCandidate } from './master-protocol';
|
||||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
|
||||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client';
|
||||||
import {
|
import {
|
||||||
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
||||||
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
|
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,
|
* 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
|
* a CLOSE of the position when it holds the opposite side — only the one
|
||||||
* position is refused, and so is an OPEN against another account's
|
* `intent` names, when given (orderEffect). Adding to a position is
|
||||||
* opposite position in the same symbol (no hedging). Resolves with the
|
* refused, and so is an OPEN against another account's opposite position
|
||||||
* fill `price`.
|
* in the same symbol (no hedging). Resolves with the fill `price`.
|
||||||
*/
|
*/
|
||||||
public async sendOrder(
|
public async sendOrder(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
@@ -1569,9 +1569,10 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||||||
throw err;
|
throw err;
|
||||||
@@ -1585,6 +1586,7 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const { account, symbol, step } = this.orderContext(accountId, contractSymbol);
|
const { account, symbol, step } = this.orderContext(accountId, contractSymbol);
|
||||||
const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL';
|
const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL';
|
||||||
@@ -1594,8 +1596,13 @@ export class DXTradeClient implements PlatformClient {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`);
|
throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
const existing = positions.find((p) => p.symbol === symbol);
|
const held = positions.find((p) => p.symbol === symbol);
|
||||||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
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');
|
const code = newOrderCode(existing ? 'x' : 'e');
|
||||||
let body: Record<string, unknown>;
|
let body: Record<string, unknown>;
|
||||||
|
|||||||
+68
-1
@@ -55,6 +55,9 @@ export interface PlatformContract {
|
|||||||
closesAt?: number;
|
closesAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What an order is for, where the caller knows (see sendOrder). */
|
||||||
|
export type OrderIntent = 'open' | 'close';
|
||||||
|
|
||||||
export interface PlatformClient {
|
export interface PlatformClient {
|
||||||
accountList: PlatformAccount[];
|
accountList: PlatformAccount[];
|
||||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
||||||
@@ -181,13 +184,20 @@ export interface PlatformClient {
|
|||||||
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
||||||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
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 (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(
|
sendOrder(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
contractSymbol: string,
|
contractSymbol: string,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>>;
|
): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
placeOrderNoWait(
|
placeOrderNoWait(
|
||||||
@@ -468,6 +478,63 @@ export function isOrderNotPlaced(err: unknown): boolean {
|
|||||||
return !!err && typeof err === 'object' && (err as { orderNotPlaced?: unknown }).orderNotPlaced === true;
|
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, 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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
/** 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
|
* config — Tradovate's login body (the password) and Bearer tokens — so they
|
||||||
* must never be logged whole. */
|
* must never be logged whole. */
|
||||||
|
|||||||
+14
-9
@@ -46,8 +46,8 @@ import {
|
|||||||
} from './db';
|
} from './db';
|
||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
import { firmInstruments } from './instruments';
|
import { firmInstruments } from './instruments';
|
||||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
|
||||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client';
|
||||||
import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
|
import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
|
||||||
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
|
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
|
||||||
import {
|
import {
|
||||||
@@ -1792,10 +1792,11 @@ export class VestClient implements PlatformClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Place an order and wait for its fill: an open when the account is flat,
|
* 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.
|
* 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
|
* only the one `intent` names, when given (orderEffect). Adding to a
|
||||||
* account's opposite position in the same symbol (no hedging). Resolves
|
* position is refused, and so is an open against another account's
|
||||||
* with the fill `price`.
|
* opposite position in the same symbol (no hedging). Resolves with the
|
||||||
|
* fill `price`.
|
||||||
*/
|
*/
|
||||||
public async sendOrder(
|
public async sendOrder(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
@@ -1804,9 +1805,10 @@ export class VestClient implements PlatformClient {
|
|||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||||||
throw err;
|
throw err;
|
||||||
@@ -1820,6 +1822,7 @@ export class VestClient implements PlatformClient {
|
|||||||
action: 'Buy' | 'Sell',
|
action: 'Buy' | 'Sell',
|
||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
||||||
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||||
@@ -1829,8 +1832,10 @@ export class VestClient implements PlatformClient {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`);
|
throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
const existing = positions.find((p) => p.symbol === symbol);
|
const held = positions.find((p) => p.symbol === symbol);
|
||||||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
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 units = roundToStep(contracts * step, step);
|
||||||
const qty = (n: number) => formatDecimal(n, spec.sizeDecimals);
|
const qty = (n: number) => formatDecimal(n, spec.sizeDecimals);
|
||||||
|
|||||||
@@ -1198,3 +1198,73 @@ test('the day after a claim: a fresh cycle that aims at the next stage\'s target
|
|||||||
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
|
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
|
||||||
assert.equal(plan.remainingToday, 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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user