diff --git a/lib/vest-class.ts b/lib/vest-class.ts index 12c9594..b0af2a9 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -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(); + * 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(); + /** Accounts whose open from before a restart has been looked for. */ + private pendingOpenRestored = new Set(); + /** 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(); + /** 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(); private positionsDetail = new Map(); private openOrders = new Map(); @@ -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); + 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); - const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId); 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 | 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 { diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 9cb526f..c957e7b 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -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 { diff --git a/scripts/check-vest-client.mjs b/scripts/check-vest-client.mjs index 204e5ad..2f7acd5 100644 --- a/scripts/check-vest-client.mjs +++ b/scripts/check-vest-client.mjs @@ -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(); + } +}); diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 380a0bc..f56f527 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -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', () => {