Vest: a stale sync keeps the bot's fill, a cut-off open is still the bot's, and the day turns with the numbers

A sync whose positions read went out before the bot's own open or close
finished no longer writes that account's positions back (ownOrderSeq, as
claimSeq guards balances): the record a flatten goes by stays, so a read
lagging the fill still gives FLATTEN FAILED, not "nothing to flatten".

An open is noted in account_meta before its POST and dropped once its
outcome is known. After a restart the first sync takes the note up as an
open of unknown outcome, so the position it made is marked the bot's and
guardClose sees it. Such an open is matched by the position id its answer
named, else only to a position with no exit legs opened between the send
and a few seconds past the error (was two minutes): an outside
copy-trader's open half a minute on isn't taken for the bot's, and
several matches adopt none, loudly.

VestClient.tradeDate follows the last sync's numbers: past 8 PM ET the
day turns once a sync has worked out the fresh day's realized P&L, so
the engine, reporter and dashboard never pair the new day with the ended
day's numbers. A stalled sync holds the day back only while isClientReady
still trades on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 15:31:28 -05:00
co-authored by Claude Opus 5.5
parent ab588adbac
commit 032ba1ca9c
4 changed files with 403 additions and 49 deletions
+131 -21
View File
@@ -113,11 +113,18 @@ const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
/** How long a stop or close the guard just sent counts before a read of
* open positions shows it. */
const GUARD_ECHO_MS = 30_000;
/** An open of unknown outcome is the position opened this close to its send
* and its error — the gateway's clock and lag (unknownOpenFill). */
const UNKNOWN_OPEN_SLACK_MS = 2 * 60_000;
/** An open of unknown outcome is the position opened between its send and
* its error, widened this much for the gateway's clock against this
* machine's (unknownOpenFill). */
const UNKNOWN_OPEN_SLACK_MS = 5_000;
/** The longest an open's POST can be out — an account token minted first,
* a retry (request()) — for one a restart cut off with no error to time. */
const OPEN_IN_FLIGHT_MS = 2 * 60_000;
/** account_meta key of the profit claims this client made, per account. */
const CLAIMS_META = 'vest_claims';
/** account_meta key of the open in flight, or of unknown outcome, per
* account (savePendingOpen). */
const PENDING_OPEN_META = 'vest_pending_open';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const round2 = (n: number) => Math.round(n * 100) / 100;
@@ -210,8 +217,21 @@ export class VestClient implements PlatformClient {
private closeGuardInFlight = false;
/** Opens whose outcome came back unknown, by account: the position one
* made is the bot's once a read shows it (adoptUnknownOpen) — no exits
* went on it, so it's the leg guardClose most needs to see. */
private unknownOpens = new Map<number, { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number }>();
* went on it, so it's the leg guardClose most needs to see. Kept in
* account_meta too, from before the send (savePendingOpen), so a
* restart takes them up again (restorePendingOpen). */
private unknownOpens = new Map<number, {
accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; from: number; until: number; positionId?: string;
}>();
/** Accounts whose open from before a restart has been looked for. */
private pendingOpenRestored = new Set<number>();
/** The sync generation current when each account's own open or close
* last finished: a sync of that generation or older may have read
* positions before the fill. */
private ownOrderSeq = new Map<number, number>();
/** When the last sync worked out the accounts' numbers (realizedSince's
* `now`): the Vest day they hold (tradeDate). */
private numbersAt = 0;
private capital = new Map<number, VestCapitalAccount>();
private positionsDetail = new Map<number, VestPosition[]>();
private openOrders = new Map<number, VestOpenOrder[]>();
@@ -584,6 +604,7 @@ export class VestClient implements PlatformClient {
}
this.uuidById.set(id, a.id);
this.capital.set(id, a);
this.restorePendingOpen(id, vestAccountName(a));
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`);
@@ -592,9 +613,16 @@ export class VestClient implements PlatformClient {
list.push(acc);
const mine = positions.filter((p) => p.accountId === a.id);
this.positionsDetail.set(id, mine);
// An open or close of this client's that finished while this
// sync was out: what it read may predate the fill, and writing
// it back would lose the position the order saw (or bring back
// the one it closed) — the record a flatten goes by
// (orderEffect). The order's own stands until a sync that
// started after it.
const ownSince = (this.ownOrderSeq.get(id) ?? -1) >= gen;
if (!ownSince) this.positionsDetail.set(id, mine);
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
this.applyPosition(acc, mine);
if (!ownSince) this.applyPosition(acc, mine);
this.adoptUnknownOpen(id, mine, startedAt);
const amount = free.get(a.id);
@@ -624,6 +652,7 @@ export class VestClient implements PlatformClient {
this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}`
+ `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`;
}
this.numbersAt = now;
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
this.accountList = list;
if (shrank) void this.closeOutDropped();
@@ -735,10 +764,17 @@ export class VestClient implements PlatformClient {
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
}
/** See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
* which its daily rows, realized P&L (realizedSince) and floors follow. */
/**
* See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
* which its daily rows, realized P&L (realizedSince) and floors follow —
* as of the last sync's numbers. Past 20:00 ET they still hold the day
* that ended until a sync works them out again, and the fresh day would
* count it as a prior day and as today's P&L both. A sync that stalls
* holds the day back no longer than the scheduler trades on its numbers
* (isClientReady). Before any sync, the day now.
*/
public tradeDate(now: number): string {
return vestDayOf(now);
return vestDayOf(this.numbersAt > 0 ? Math.min(now, this.numbersAt) : now);
}
/**
@@ -1192,21 +1228,81 @@ export class VestClient implements PlatformClient {
/** An open of unknown outcome that a read (started at `readAt`) shows
* filled after all: the position is the bot's (markOwn). One the read
* doesn't show by then never filled — a market open is IOC. */
* doesn't show by then never filled — a market open is IOC. Several
* that could be it: none is marked, and it's said loudly. */
private adoptUnknownOpen(accountId: number, positions: VestPosition[], readAt: number): void {
const sent = this.unknownOpens.get(accountId);
if (!sent) return;
const pos = unknownOpenFill(sent, positions);
if (pos) {
this.unknownOpens.delete(accountId);
this.markOwn('opened', accountId, pos);
const found = unknownOpenFill(sent, positions);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
if (found.length === 1) {
const pos = found[0];
this.forgetUnknownOpen(accountId, sent.sentAt);
this.markOwn('opened', accountId, pos);
console.warn(`${this.tag} ${name}: the open of unknown outcome filled after all — ${pos.side} ${pos.quantity} ${pos.symbol} (${pos.positionId}) marked as the bot's`);
} else if (found.length > 1) {
this.forgetUnknownOpen(accountId, sent.sentAt);
console.error(`${this.tag} ${name}: the ${sent.side} ${sent.symbol} open of unknown outcome could be any of ${found.length} positions opened with it`
+ ` (${found.map((p) => p.positionId).join(', ')}) — none marked as the bot's, so none is closed before a market closure; check them on Vest`);
} else if (readAt > sent.until) {
this.unknownOpens.delete(accountId);
this.forgetUnknownOpen(accountId, sent.sentAt);
}
}
private forgetUnknownOpen(accountId: number, sentAt: number): void {
this.unknownOpens.delete(accountId);
this.dropPendingOpen(accountId, sentAt);
}
/** Note an open before it goes out, and what is learned of it (its
* position id, an unknown outcome's window): a restart before its
* outcome is known still finds the position it made. */
private savePendingOpen(accountId: number, open: { accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; positionId?: string; until?: number }): void {
// This run's own record from here: nothing from before to take up.
this.pendingOpenRestored.add(accountId);
try {
saveAccountMeta(accountId, PENDING_OPEN_META, JSON.stringify(open));
} catch (err) {
console.warn(`${this.tag} couldn't note the open in flight: ${errorMessage(err)}`);
}
}
/** The open sent at `sentAt` has an outcome: its note goes — not a later
* open's. */
private dropPendingOpen(accountId: number, sentAt: number): void {
try {
const open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
if (open?.sentAt === sentAt) saveAccountMeta(accountId, PENDING_OPEN_META, 'null');
} catch (err) {
console.warn(`${this.tag} couldn't drop the note of an open: ${errorMessage(err)}`);
}
}
/** An open still out when the app last stopped (a restart mid-entry):
* taken up as one of unknown outcome — its window the send to the most
* its POST could be out, when no error timed it — once per account per
* run. */
private restorePendingOpen(accountId: number, name: string): void {
if (this.pendingOpenRestored.has(accountId)) return;
this.pendingOpenRestored.add(accountId);
let open: Record<string, unknown> | null;
try {
open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
} catch {
return;
}
if (!open || typeof open.accountId !== 'string' || typeof open.symbol !== 'string'
|| (open.side !== 'long' && open.side !== 'short') || typeof open.sentAt !== 'number' || !Number.isFinite(open.sentAt)) return;
const sentAt = open.sentAt;
this.unknownOpens.set(accountId, {
accountId: open.accountId, symbol: open.symbol, side: open.side, sentAt,
from: sentAt - UNKNOWN_OPEN_SLACK_MS,
until: typeof open.until === 'number' ? open.until : sentAt + OPEN_IN_FLIGHT_MS + UNKNOWN_OPEN_SLACK_MS,
...(typeof open.positionId === 'string' ? { positionId: open.positionId } : {}),
});
console.warn(`${this.tag} ${name}: a ${open.side} ${open.symbol} open was out when the app last stopped — a position it made is marked the bot's once a read shows it`);
}
private settleSuspects(): void {
const now = Date.now();
for (const [id, since] of this.suspectAccounts) {
@@ -1895,9 +1991,14 @@ export class VestClient implements PlatformClient {
this.pendingFills++;
const orderNumber = this.nextOrderNumber++;
const sentAt = Date.now();
// An open is noted before it goes out: a restart before its outcome
// is known still finds the position it made (restorePendingOpen).
if (opening) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt });
let placed: { orderId?: string; positionId?: string } | undefined;
try {
const placed = await this.submit(uuid, path, body);
placed = await this.submit(uuid, path, body);
console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`);
if (opening && placed.positionId) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt, positionId: placed.positionId });
const fill = await this.waitForFill(uuid, placed, symbol, side, existing ?? null, unitsOrdered, sentAt);
if (opening) {
const pos = fill.position ?? {
@@ -1908,10 +2009,13 @@ export class VestClient implements PlatformClient {
this.positions[accountId] = {
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
};
this.ownOrderSeq.set(accountId, this.syncGeneration);
this.markOwn('opened', accountId, pos);
this.dropPendingOpen(accountId, sentAt);
} else if (unitsOrdered >= existing.quantity - step / 2) {
this.positionsDetail.set(accountId, []);
delete this.positions[accountId];
this.ownOrderSeq.set(accountId, this.syncGeneration);
this.markOwn('closed', accountId, existing);
}
const result = {
@@ -1922,11 +2026,17 @@ export class VestClient implements PlatformClient {
return result;
} catch (err) {
// An open that may have gone in: no exits will go on it, so the
// position it made, once a read shows it, is marked the bot's.
// position it made, once a read shows it, is marked the bot's —
// the one its answer named, else one opened by now (IOC).
if (opening && !isOrderNotPlaced(err)) {
this.unknownOpens.set(accountId, {
accountId: uuid, symbol, side, from: sentAt - UNKNOWN_OPEN_SLACK_MS, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
});
const open = {
accountId: uuid, symbol, side, sentAt, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
...(placed?.positionId ? { positionId: placed.positionId } : {}),
};
this.unknownOpens.set(accountId, { ...open, from: sentAt - UNKNOWN_OPEN_SLACK_MS });
this.savePendingOpen(accountId, open);
} else if (opening) {
this.dropPendingOpen(accountId, sentAt);
}
throw err;
} finally {
+15 -8
View File
@@ -434,17 +434,24 @@ export function awaitingLegs(p: VestPosition): VestLeg[] {
}
/**
* The position an open of unknown outcome (a timeout, a reset, a 5xx after
* the send) made, once a read shows it: on its account, in its symbol and
* side, opened between `from` and `until` — the send and the error, widened
* for the gateway's clock. A market open is IOC, so a later one isn't it.
* The positions an open of unknown outcome (a timeout, a reset, a 5xx after
* the send) may have made, as a read shows them — the bot's only when there
* is exactly one. The one the open's answer named, when it named one. Else
* on its account, in its symbol and side, with no exit legs (none went on
* it), opened between `from` and `until` — the send and the error, widened
* for the gateway's clock: a market open is IOC, so a later one (an outside
* copy-trader's, which opens the same symbol on every account at once) isn't
* it. More than one of those can't be told apart.
*/
export function unknownOpenFill(
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number },
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number; positionId?: string },
positions: VestPosition[],
): VestPosition | null {
return positions.find((p) => p.accountId === sent.accountId && p.symbol === sent.symbol && p.side === sent.side
&& p.openedAt >= sent.from && p.openedAt <= sent.until) ?? null;
): VestPosition[] {
const mine = positions.filter((p) => p.accountId === sent.accountId);
if (sent.positionId) return mine.filter((p) => p.positionId === sent.positionId);
return mine.filter((p) => p.symbol === sent.symbol && p.side === sent.side
&& p.stopLosses.length === 0 && p.takeProfits.length === 0
&& p.openedAt >= sent.from && p.openedAt <= sent.until);
}
export interface VestOpenOrder {
+235 -10
View File
@@ -1,6 +1,8 @@
// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
// pure helpers show: the market-close guard closing a leg whose open came back
// unknown, and the candidate board keeping a symbol the close gate holds back.
// unknown, and the candidate board keeping a symbol the close gate holds back;
// a sync older than the bot's own fill, an open a restart cut off, an outside
// trade beside an open of unknown outcome, and the client's day at 8 PM ET.
//
// npm run check
//
@@ -49,9 +51,11 @@ VestClient.prototype.request = async function (bucket, method, path, opts = {})
return gateway(method, path, opts);
};
const { putVestSession } = await import('../lib/vest-session.ts');
const { botOpenedPositions } = await import('../lib/db.ts');
const { vestDayOf, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
const { botOpenedPositions, loadAccountMeta, saveAccountMeta } = await import('../lib/db.ts');
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
const { tradeDateFor } = await import('../lib/trading-logic.ts');
const { isClientReady } = await import('../lib/platform-client.ts');
const iso = (s) => Date.parse(s);
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
@@ -84,8 +88,9 @@ const spec = (symbol, assetId) => ({
});
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]);
/** A client with no sync running, its gateway answered by `answer`. */
function testClient(answer) {
/** A client with no sync running, its gateway answered by `answer`, holding
* acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */
function testClient(answer, ids = [1, 2]) {
gateway = null;
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
clearInterval(client.syncInterval);
@@ -94,14 +99,58 @@ function testClient(answer) {
client.specs = specs;
client.symbolSpecs = async () => specs;
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
client.accountList = [{ id: 1, name: 'V50K-F-D3-1', active: true }, { id: 2, name: 'V50K-F-D3-2', active: true }];
client.uuidById.set(1, 'acc-1');
client.uuidById.set(2, 'acc-2');
client.capital.set(1, { status: VEST_STATUS_ACTIVE });
client.capital.set(2, { status: VEST_STATUS_ACTIVE });
client.accountList = ids.map((id, i) => ({ id, name: `V50K-F-D3-${i + 1}`, active: true }));
ids.forEach((id, i) => {
client.uuidById.set(id, `acc-${i + 1}`);
client.capital.set(id, { status: VEST_STATUS_ACTIVE });
});
return client;
}
const A = vestAccountId('acc-1');
const B = vestAccountId('acc-2');
const NDX = encodeContractName('NDX-USD', 0.001);
const rawPosition = (positionId, accountId, openDate, extra = {}) => ({
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
stopLosses: [], takeProfits: [], ...extra,
});
/** What a sync reads (acc-1 and acc-2 funded, $50k each, and the login's
* main account; `positions` open), and the history reads after it. */
function syncReads(method, path, positions) {
if (method !== 'GET') return null;
if (path === '/v3/capital/accounts/active') {
return { data: { accounts: ['acc-1', 'acc-2'].map((id, i) => ({
id, status: VEST_STATUS_ACTIVE, attempt_index: i + 1, initial_capital: '50000', max_daily_loss_pct: '0.03', stage: 1, stages: [{ ordinal: 1, kind: 'funded' }],
})) } };
}
if (path === '/v3/accounts') {
return { data: { accounts: [
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '50000', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
] } };
}
if (path === '/v3/positions/opened') return { data: { positions } };
if (path === '/v3/positions/opened-orders') return { data: { orders: [] } };
if (path === '/v3/ticker/latest') return { data: { tickers: [] } };
if (path === '/v3/positions') return { data: { positions: [] } };
return null;
}
/** testClient on the ids a sync gives, once the constructor's own sync
* (which reaches no gateway) is over, with its history taken as read — so
* each requestSync here is one good sync, and starts nothing else. */
async function syncingClient(answer) {
const client = testClient(answer, [A, B]);
await client.syncRunning;
client.historyLoaded = true;
client.historyLoadedIds = new Set([A, B]);
client.lastDaysFetchStartedAt = Date.now();
client.lastDaysFetchSucceededAt = Infinity;
return client;
}
const unanswered = (method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); };
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
let positions = [];
const closes = [];
@@ -171,3 +220,179 @@ test('a symbol the close gate holds back stays on the board, and isn\'t picked;
client.disconnect();
}
});
test("a sync whose positions read predates the bot's own fill leaves the fill's record alone", async () => {
let positions = [];
let holdRead = false;
let releaseRead = null;
clockAt('2026-10-28T14:00:00Z');
const client = await syncingClient((method, path) => {
if (method === 'GET' && path === '/v3/positions/opened' && holdRead) {
// Read now, flat — answered once released.
holdRead = false;
const served = positions;
return new Promise((resolve) => { releaseRead = () => resolve({ data: { positions: served } }); });
}
const read = syncReads(method, path, positions);
if (read) return read;
if (method === 'POST' && path === '/v3/positions/open') {
positions = [rawPosition('9101', 'acc-1', Date.now() + 300)];
return { data: { orderId: 'o-9101', positionId: '9101' } };
}
return unanswered(method, path);
});
try {
await client.requestSync();
// A sync reads acc-1 flat; the entry fills while it is still out.
holdRead = true;
const sync = client.requestSync();
await client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open');
// Filled: the note kept against a restart goes.
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
releaseRead();
await sync;
assert.deepEqual(client.positionsDetail.get(A).map((p) => p.positionId), ['9101']);
assert.equal(client.positions[A].netPos, 0.5);
// A read lagging the fill: the leg's flatten is refused as a position
// still out there (FLATTEN FAILED), not taken as nothing to flatten.
positions = [];
await assert.rejects(client.sendOrder(A, NDX, 500, 'Sell', 'Market', undefined, 'close'), /last saw a long one open/);
// A sync that went out after the fill writes what it reads.
await client.requestSync();
assert.deepEqual(client.positionsDetail.get(A), []);
assert.equal(client.positions[A], undefined);
} finally {
client.disconnect();
}
});
test("an open a restart cut off is marked the bot's once a read after the restart shows it", async () => {
let positions = [];
let posted;
const postedAt = new Promise((resolve) => { posted = resolve; });
clockAt('2026-10-28T15:00:00Z');
const before = await syncingClient((method, path) => {
const read = syncReads(method, path, positions);
if (read) return read;
// The app stops with the open out.
if (method === 'POST' && path === '/v3/positions/open') { posted(Date.now()); return new Promise(() => {}); }
return unanswered(method, path);
});
void before.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open').catch(() => {});
const sentAt = await postedAt;
before.disconnect();
// Vest filled it, and an outside long opened on acc-2 with it. acc-2 has
// an older note, of a short that never showed.
positions = [rawPosition('9201', 'acc-1', sentAt + 400), rawPosition('9202', 'acc-2', sentAt + 400)];
saveAccountMeta(B, 'vest_pending_open', JSON.stringify({ accountId: 'acc-2', symbol: 'NDX-USD', side: 'short', sentAt: sentAt - 10 * 60_000 }));
clockAt('2026-10-28T15:02:00Z');
let refuse = false;
const after = await syncingClient((method, path) => {
const read = syncReads(method, path, positions);
if (read) return read;
if (method === 'POST' && path === '/v3/positions/open' && refuse) throw new VestApiError('/v3/positions/open: refused (HTTP 400)', 400, 'REFUSED');
return unanswered(method, path);
});
try {
await after.requestSync();
assert.deepEqual([...botOpenedPositions(['9201', '9202'])], ['9201']);
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
assert.equal(loadAccountMeta(B, 'vest_pending_open'), 'null');
// An open Vest refuses leaves no note behind.
positions = [];
refuse = true;
await assert.rejects(after.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /refused/);
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
} finally {
after.disconnect();
}
});
test("an open of unknown outcome isn't matched to an outside trade after its error, nor to one of several", async () => {
let positions = [];
let open = null;
let onRead = () => {};
clockAt('2026-10-28T16:00:00Z');
const client = await syncingClient((method, path) => {
if (method === 'GET' && path === '/v3/positions/opened') onRead();
const read = syncReads(method, path, positions);
if (read) return read;
if (method === 'POST' && path === '/v3/positions/open') return open();
return unanswered(method, path);
});
try {
await client.requestSync();
// The entry's send fails in transit, and it never went in. Half a
// minute on, an outside copy-trader opens NDX long on every account.
open = () => { throw new Error('socket hang up'); };
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
const errorAt = Date.now();
positions = [rawPosition('9301', 'acc-1', errorAt + 30_000), rawPosition('9302', 'acc-2', errorAt + 30_000)];
clockAt('2026-10-28T16:01:00Z');
await client.requestSync();
assert.deepEqual([...botOpenedPositions(['9301', '9302'])], []);
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
// Another on acc-2, and two positions show from that moment: either
// could be the bot's, so neither is marked.
positions = [];
clockAt('2026-10-28T16:05:00Z');
open = () => {
positions = [rawPosition('9303', 'acc-2', Date.now() + 100), rawPosition('9304', 'acc-2', Date.now() + 150)];
throw new Error('socket hang up');
};
await assert.rejects(client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
await client.requestSync();
assert.deepEqual([...botOpenedPositions(['9303', '9304'])], []);
// An open whose answer named its position, though no read showed it
// within the wait: that position is the bot's, even with a stop a
// hand has put on it since.
positions = [];
clockAt('2026-10-28T16:10:00Z');
let answered = false;
open = () => { answered = true; return { data: { orderId: 'o-9305', positionId: '9305' } }; };
// The wait's first read comes back past its deadline.
onRead = () => { if (answered) { answered = false; clockAt('2026-10-28T16:10:45Z'); } };
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /not seen filled/);
const stop = { id: 'sl-9305', triggerPrice: '19900', executionType: 'market', status: 'awaiting' };
positions = [rawPosition('9305', 'acc-1', iso('2026-10-28T16:10:00Z') + 500, { stopLosses: [stop] })];
await client.requestSync();
assert.deepEqual([...botOpenedPositions(['9305'])], ['9305']);
} finally {
client.disconnect();
}
});
test("the client's Vest day turns with its numbers, and a sync that stalls stops the trading it holds back", async () => {
clockAt('2026-10-27T23:58:00Z');
const client = await syncingClient((method, path) => syncReads(method, path, []) ?? unanswered(method, path));
// The day the engine plans the lane on, at this test's clock.
const engineDay = () => tradeDateFor(client, new Date(Date.now()));
try {
// 7:58 PM ET, before any sync: the day now.
assert.equal(engineDay(), '2026-10-27');
await client.requestSync();
// 8:00:20 PM ET: Vest's day has turned, but the numbers the last sync
// worked out are still the ended day's — and so is the engine's day.
clockAt('2026-10-28T00:00:20Z');
assert.equal(vestDayOf(Date.now()), '2026-10-28');
assert.equal(engineDay(), '2026-10-27');
assert.equal(isClientReady(client), true);
await client.requestSync();
assert.equal(engineDay(), '2026-10-28');
// No sync after the next reset: the day holds back only while the
// scheduler still trades on the last sync's numbers.
clockAt('2026-10-28T23:59:00Z');
await client.requestSync();
clockAt('2026-10-29T00:03:00Z');
assert.equal(engineDay(), '2026-10-28');
assert.equal(isClientReady(client), true);
clockAt('2026-10-29T00:05:00Z');
assert.equal(engineDay(), '2026-10-28');
assert.equal(isClientReady(client), false);
} finally {
client.disconnect();
}
});
+22 -10
View File
@@ -130,20 +130,32 @@ const openPosition = {
};
test('an open of unknown outcome is the position on its account, symbol and side opened around it', () => {
const pos = (positionId, accountId, side, openedAt, symbol = 'NDX-USD') => ({
positionId, accountId, symbol, side, quantity: 0.5, openPrice: 20000, collateral: 500, leverage: 20, openedAt, stopLosses: [], takeProfits: [],
const pos = (positionId, accountId, side, openedAt, symbol = 'NDX-USD', stopLosses = []) => ({
positionId, accountId, symbol, side, quantity: 0.5, openPrice: 20000, collateral: 500, leverage: 20, openedAt, stopLosses, takeProfits: [],
});
const sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:28:00Z'), until: iso('2026-10-30T12:32:15Z') };
// Sent at 12:30:00, its error at 12:30:15: the window is a few seconds of clock either side.
const sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:29:55Z'), until: iso('2026-10-30T12:30:20Z') };
const filled = pos('9001', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'));
assert.equal(unknownOpenFill(sent, [pos('9002', 'acc-2', 'long', iso('2026-10-30T12:30:01Z')), filled]), filled);
assert.deepEqual(unknownOpenFill(sent, [pos('9002', 'acc-2', 'long', iso('2026-10-30T12:30:01Z')), filled]), [filled]);
// Another side or symbol, a position from before it, or one opened after
// the window (a hand trade later on): not it.
assert.equal(unknownOpenFill(sent, [pos('9003', 'acc-1', 'short', iso('2026-10-30T12:30:01Z'))]), null);
assert.equal(unknownOpenFill(sent, [pos('9004', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'SPX-USD')]), null);
assert.equal(unknownOpenFill(sent, [pos('9005', 'acc-1', 'long', iso('2026-10-30T11:00:00Z'))]), null);
assert.equal(unknownOpenFill(sent, [pos('9006', 'acc-1', 'long', iso('2026-10-30T14:00:00Z'))]), null);
// the window (a hand trade, or an outside copy-trader, half a minute on): not it.
assert.deepEqual(unknownOpenFill(sent, [pos('9003', 'acc-1', 'short', iso('2026-10-30T12:30:01Z'))]), []);
assert.deepEqual(unknownOpenFill(sent, [pos('9004', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'SPX-USD')]), []);
assert.deepEqual(unknownOpenFill(sent, [pos('9005', 'acc-1', 'long', iso('2026-10-30T11:00:00Z'))]), []);
assert.deepEqual(unknownOpenFill(sent, [pos('9006', 'acc-1', 'long', iso('2026-10-30T12:30:45Z'))]), []);
// No open time: can't be told from a hand trade.
assert.equal(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), null);
assert.deepEqual(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), []);
// One with exit legs isn't the bot's open — none went on it.
const leg = { id: 'sl-1', triggerPrice: 19900, executionType: 'market', status: 'awaiting' };
assert.deepEqual(unknownOpenFill(sent, [pos('9008', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'NDX-USD', [leg])]), []);
// Two inside the window: both come back, for the caller to adopt neither.
const copy = pos('9009', 'acc-1', 'long', iso('2026-10-30T12:30:02Z'));
assert.deepEqual(unknownOpenFill(sent, [filled, copy]), [filled, copy]);
// The position id the open's answer named settles it, legs and window aside.
const named = pos('9010', 'acc-1', 'long', iso('2026-10-30T12:31:00Z'), 'NDX-USD', [leg]);
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy, named]), [named]);
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy]), []);
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [{ ...named, accountId: 'acc-2' }]), []);
});
test('positions, their legs and unrealized P&L', () => {