From ec3e6ea388f333c216fd5a479e80904a42fb817c Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:12:20 -0500 Subject: [PATCH 1/3] Vest: no leg opens into a market close, and accounts plan on Vest's day Market close: a Vest entry now needs its symbol's market to stay open for RESET_CLEAR_HOLDS holds of the leg (clearsClose), the clearance the 8 PM ET reset already gets: at the target hold, and at the leg's sized hold when a leverage cap stretches it. That covers the weekly close and the daily maintenance hour as Vest's market-hours periods list them. Before a closure that runs past an 8 PM ET reset (the weekend, a holiday: longClosure), the bot's own legs still open in the last 5 minutes are closed at market, loudly; a position the bot didn't open is named in the log and left alone. Vest's day: Vest accounts are planned, and their legs and entries counted, on Vest's day (20:00 ET) instead of the Central date (tradeDateFor, PlatformClient.tradeDate). Between 5 PM CT and the reset the Central date counted the open Vest day as a prior day and as today's realized P&L both ($3,000 to go, $800 made: $2,050 left, not $1,900) and started the leg count over. Other platforms keep the Central date. Co-Authored-By: Claude Opus 5.5 --- lib/auto-trade.ts | 20 +++-- lib/db.ts | 9 ++ lib/platform-client.ts | 5 ++ lib/trading-logic.ts | 21 +++++ lib/vest-class.ts | 148 +++++++++++++++++++++++++++++--- lib/vest-helpers.ts | 36 ++++++-- scripts/check-trading-logic.mjs | 21 ++++- scripts/check-vest.mjs | 62 ++++++++++++- 8 files changed, 290 insertions(+), 32 deletions(-) diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 0fae444..90fd071 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -11,7 +11,7 @@ import { getClients } from './clients'; import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver'; import { - combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, + combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, tradeDateFor, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, isNoTradeTime, isPastSessionClose, @@ -1043,7 +1043,10 @@ async function runTradeLocked( // operator's pick; 'Auto' is drawn per batch from the batch firm's list. const explicit = futuresLane && symbol !== 'Auto' ? symbol : null; - const tradeDate = currentTradeDateCT(); + // The lane's own day (tradeDateFor): a notional lane is one firm — a + // Vest lane's day turns at 20:00 ET, its rows and realized P&L with it — + // and the futures lane's clients all keep the Central trade date. + const tradeDate = tradeDateFor(open[0]?.client); const maxConcurrent = maxConcurrentAccounts(); // ── Phase 1: the eligible accounts across the lane's firms ── @@ -1742,7 +1745,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise return []; } - const tradeDate = currentTradeDateCT(); + // The lane's own day, as in runTrade. + const tradeDate = tradeDateFor(ready[0]?.client); // Collect eligible accounts (same logic as Phase 1 of runTrade) const eligible: LegItem[] = []; @@ -1860,7 +1864,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | const lease = acquireTradeMutex('[manual-leg]', lane); if (lease === null) throw new Error('another batch is in flight in this lane'); try { - const tradeDate = currentTradeDateCT(); + const tradeDate = tradeDateFor(client); const ev = evaluateAccountForTrade(client, acc, tradeDate); if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type'); if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason); @@ -1986,7 +1990,6 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise { const lease = acquireTradeMutex('[auto-claim]', lane); if (lease === null) return; try { - const tradeDate = currentTradeDateCT(); for (const { firm, client, acc } of due) { if (!holdsTradeMutex(lease, lane)) return; // Settled since, or a different amount now: claim what is due now. @@ -1996,7 +1999,7 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise { // In flight while it claims: a batch that takes over a wedged // lane, or a Claim now, leaves the account alone. const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount)); - afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate); + afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDateFor(client)); } catch (err) { (runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now()); console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`); @@ -2130,7 +2133,7 @@ export async function claimAccountNow(accountId: number, amount: number): Promis if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`); if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`); const { claimed, received } = await withAccountInFlight(c.acc.id, () => c.client.claimProfit!(c.acc.id, amount)); - const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT()); + const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, tradeDateFor(c.client)); return { claimed, received, stage }; } finally { releaseTradeMutex(lease, lane); @@ -2142,7 +2145,6 @@ export async function claimAccountNow(accountId: number, amount: number): Promis /** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */ function hasRemainingConfiguredAccounts(): boolean { const { ready, unready } = firmClients(); - const tradeDate = currentTradeDateCT(); // A client that isn't ready can't say whether its accounts are done — its // snapshot is missing or stale — so while one might hold any of ours, the @@ -2175,7 +2177,7 @@ function hasRemainingConfiguredAccounts(): boolean { // Order being placed right now = still in play (evaluation calls it ineligible) if (runtime().accountsInFlight.has(acc.id)) return true; - const ev = evaluateAccountForTrade(client, acc, tradeDate); + const ev = evaluateAccountForTrade(client, acc, tradeDateFor(client)); if (ev?.eligible) return true; // Waiting on its history (or its day-start balance), not done: it may // still owe today's trade — during the session and before it opens diff --git a/lib/db.ts b/lib/db.ts index d107176..590d6f4 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -684,6 +684,15 @@ export function markTrade( `).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now()); } +/** Which of these positions this instance's own order opened (markTrade). */ +export function botOpenedPositions(positionIds: string[]): Set { + if (positionIds.length === 0) return new Set(); + const rows = db.prepare(`SELECT position_id AS positionId FROM trades + WHERE opened_by = 'bot' AND position_id IN (${positionIds.map(() => '?').join(', ')})`) + .all(...positionIds) as { positionId: string }[]; + return new Set(rows.map((r) => r.positionId)); +} + /** Vest accounts with a trade the table still shows open (Vest's position * ids are bare numbers; the fill-built trades below carry a prefix). */ export function openTradeAccounts(): { accountId: number; accountName: string }[] { diff --git a/lib/platform-client.ts b/lib/platform-client.ts index f9427fc..46c636b 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -145,6 +145,11 @@ export interface PlatformClient { * before the platform's daily reset (resetClearStop). */ dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number; + /** The trade date at `now` (epoch ms) that this platform's daily rows and + * realized P&L follow, where its day turns at another hour than the + * 5 PM CT roll (Vest: 20:00 ET). The engine plans the accounts and + * counts their legs by it (tradeDateFor); absent = currentTradeDateCT. */ + tradeDate?(now: number): string; /** Payouts made since the firm's last daily reset — what picks the stage * of an account type whose stages reset each day. */ sameDayClaims?(accountId: number): number; diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 9c40fac..574b488 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -165,6 +165,18 @@ export function currentTradeDateCT(now: Date = new Date()): string { return new Date(date).toISOString().slice(0, 10); } +/** + * The trade date a client's accounts are planned on and count their legs by: + * the platform's own day where its daily rows and realized P&L follow one + * that turns at another hour (Vest: 20:00 ET, two hours after the 5 PM CT + * roll), else the Central trade date. Between the two rolls the Central date + * would count the platform's still-open day both as a prior day and as + * today's realized P&L, and start the day's leg count over mid-day. + */ +export function tradeDateFor(client: { tradeDate?: (now: number) => string } | null | undefined, now: Date = new Date()): string { + return typeof client?.tradeDate === 'function' ? client.tradeDate(now.getTime()) : currentTradeDateCT(now); +} + /** Drain-day target grid: $4–$15 in $0.50 steps (23 values, MNQ's * per-contract tick), always traded on MNQ. Drain days qualify at ≥ * DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like @@ -1387,6 +1399,15 @@ export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: n return (o.leg * (c + Math.sqrt(c * c - 4))) / 2; } +/** Whether a leg of `holdHours` starting now is all but sure to be done + * before its market closes `closeInMs` from now: the RESET_CLEAR_HOLDS + * holds a daily reset is given (resetClearStop). A leg still open at the + * close can't be exited until the market reopens, and meets the reopen's + * gap with its stop. */ +export function clearsClose(o: { closeInMs: number; holdHours: number }): boolean { + return o.closeInMs >= RESET_CLEAR_HOLDS * o.holdHours * 3_600_000; +} + /** * dllRoom where the platform states its daily-loss floor itself (Vest: the * equity the account may not fall below today, already moved by the day's diff --git a/lib/vest-class.ts b/lib/vest-class.ts index dfe8350..75fdc76 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -38,11 +38,11 @@ import { randomUUID } from 'crypto'; import axios from './http-client'; -import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from './trading-logic'; +import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop, clearsClose, RESET_CLEAR_HOLDS } from './trading-logic'; import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, - saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, + saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, botOpenedPositions, } from './db'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; @@ -52,7 +52,7 @@ import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } f 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, + parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop, 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'; @@ -103,7 +103,8 @@ const NAKED_AFTER_MS = 2 * 60_000; * candidate is costed as crossing this many ticks. */ const ASSUMED_SPREAD_TICKS = 1; /** How long before the daily reset open positions are fitted to the room - * the reset will leave (guardReset). */ + * the reset will leave (guardReset) — and before a market closure that runs + * past one, the bot's own legs are closed (guardClose). */ const RESET_GUARD_MS = 5 * 60_000; /** No new position opens this close to the reset: the guard's window, and a * couple of minutes before it for an entry in flight to place its exits. */ @@ -215,6 +216,13 @@ export class VestClient implements PlatformClient { noSessionLogged: false, unfittable: new Set(), }; private resetGuardInFlight = false; + /** guardClose's closes just sent (position → when), the hand trades it + * has named (position → the close they were named for), and when it + * last said it couldn't read a symbol's hours or had no session. */ + private closeGuard = { + closes: new Map(), named: new Map(), hoursWarnedAt: new Map(), noSessionFor: 0, + }; + private closeGuardInFlight = false; private capital = new Map(); private positionsDetail = new Map(); private openOrders = new Map(); @@ -424,6 +432,7 @@ export class VestClient implements PlatformClient { const now = Date.now(); if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync(); void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`)); + void this.guardClose().catch((err) => console.error(`${this.tag} market-close guard failed: ${errorMessage(err)}`)); } // ── public market data ─────────────────────────────────────────────────── @@ -733,6 +742,12 @@ 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. */ + public tradeDate(now: number): string { + return vestDayOf(now); + } + /** * See PlatformClient: the gain since the day's base that is still in the * account — since this client's own claim today, else since the reset, @@ -743,7 +758,7 @@ export class VestClient implements PlatformClient { */ public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number { const now = Date.now(); - const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId)); + const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId), this.tradeDate(now)); const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit; if (!(allowed > 0)) return 0; const a = this.capital.get(accountId); @@ -1058,6 +1073,103 @@ export class VestClient implements PlatformClient { } } + /** + * Before a long market closure — one that keeps a symbol's market shut + * across an 8 PM ET reset: the weekend, a holiday (longClosure) — close + * the bot's own legs in it still open in the last RESET_GUARD_MS, while + * the market takes orders. Inside the closure nothing exits, the reset + * guard's stops and closes are refused, and the reopen's gap jumps a + * stop. No leg starts under RESET_CLEAR_HOLDS holds of a close + * (pickCandidate), so this only meets one that outlived them. + * + * Only positions the bot's own order opened (markOwn): one without that + * mark — a hand trade, or one from before marking began — is left as it + * is and named once in the log. The daily maintenance hour reopens + * before the reset, and is ridden. + */ + private async guardClose(): Promise { + if (!this.syncComplete || this.intentionalDisconnect || this.closeGuardInFlight) return; + const held = [...new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol))]; + if (held.length === 0) return; + this.closeGuardInFlight = true; + try { + const guard = this.closeGuard; + const now = Date.now(); + for (const [id, at] of guard.closes) if (now - at >= GUARD_ECHO_MS) guard.closes.delete(id); + for (const [id, closesAt] of guard.named) if (closesAt < now) guard.named.delete(id); + // This runs all day while anything is open: hours it can't read + // are said once in a while, not every tick. + const unreadable = (what: string, err: unknown) => { + if (now - (guard.hoursWarnedAt.get(what) ?? 0) < MARKET_HOURS_TTL_MS) return; + guard.hoursWarnedAt.set(what, now); + console.warn(`${this.tag} ${what}: market hours unreadable — can't tell whether its market is about to close (${errorMessage(err)})`); + }; + let specs: Map; + try { + specs = await this.symbolSpecs(); + } catch (err) { + unreadable(held.join(', '), err); + return; + } + // Each held symbol whose long closure is this near, and when. + const closing = new Map(); + for (const symbol of held) { + const spec = specs.get(symbol); + if (!spec) continue; + let periods: unknown; + try { + periods = await this.marketHours(spec.assetId); + } catch (err) { + unreadable(symbol, err); + continue; + } + const hours = marketOpen(periods, now); + if (hours.open && hours.closesAt !== null && hours.closesAt - now <= RESET_GUARD_MS && longClosure(periods, hours.closesAt)) { + closing.set(symbol, hours.closesAt); + } + } + if (closing.size === 0) return; + if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) { + const first = Math.min(...closing.values()); + if (guard.noSessionFor !== first) { + guard.noSessionFor = first; + console.error(`${this.tag} the market closes in ${Math.ceil((first - now) / 60_000)} min until past an 8 PM ET reset, and the bot's open legs can't be closed — no Vest session (open the Vest tab)`); + } + return; + } + const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened'); + const open = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null && closing.has(p.symbol)); + const mine = botOpenedPositions(open.map((p) => p.positionId)); + for (const acc of this.accountList) { + const uuid = this.uuidById.get(acc.id); + if (!uuid) continue; + for (const pos of open.filter((p) => p.accountId === uuid)) { + const closesAt = closing.get(pos.symbol)!; + const inMin = Math.max(0, Math.ceil((closesAt - Date.now()) / 60_000)); + if (!mine.has(pos.positionId)) { + if (guard.named.get(pos.positionId) !== closesAt) { + guard.named.set(pos.positionId, closesAt); + console.error(`${this.tag} ${acc.name}: ${pos.side} ${pos.symbol} wasn't opened by the bot and stays open — its market closes in ${inMin} min until past an 8 PM ET reset; close it on Vest if it shouldn't carry the closure and the reopen's gap`); + } + continue; + } + // A close just sent counts until a read of open positions shows it. + if (guard.closes.has(pos.positionId)) continue; + try { + await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' }); + guard.closes.set(pos.positionId, Date.now()); + this.markOwn('closed', acc.id, pos); + console.error(`${this.tag} ${acc.name}: CLOSED the bot's ${pos.side} ${pos.quantity} ${pos.symbol} at market — its market closes in ${inMin} min until past an 8 PM ET reset, and nothing could exit it there`); + } catch (err) { + console.error(`${this.tag} ${acc.name}: couldn't close the bot's ${pos.symbol} before its market closes in ${inMin} min: ${errorMessage(err)}`); + } + } + } + } finally { + this.closeGuardInFlight = false; + } + } + private settleSuspects(): void { const now = Date.now(); for (const [id, since] of this.suspectAccounts) { @@ -1478,14 +1590,23 @@ export class VestClient implements PlatformClient { private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise { const firm = getCachedFirm(this.cfg.firmId); - const targetHold = sizingHoldHours(firm); + const targetHold = sizingHoldHours(firm, this.tradeDate(Date.now())); const specs = await this.symbolSpecs(); const known = symbols.filter((s) => specs.has(s)); if (known.length > 0) await this.refreshMarks(known); const vols = await this.volatility(known); const now = Date.now(); + // A leg its symbol's market close (the weekend, the daily maintenance + // hour) catches open can't be exited until the market reopens, and + // meets the reopen's gap with its stop: none starts under + // RESET_CLEAR_HOLDS holds of the close — the daily reset's clearance. + const tooSoon = (closesAt: number | null, holdHours: number) => + closesAt !== null && !clearsClose({ closeInMs: closesAt - now, holdHours }); + const closingNote = (closesAt: number, holdHours: number) => + `closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`; + const hold = ctx?.targetHoldHours ?? targetHold; - const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = []; + const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closesAt?: number | null }[] = []; // No new position this close to the reset: nothing picked, and the // last survey's candidates stay up for the dashboard. const toReset = nextVestResetMs(now) - now; @@ -1510,10 +1631,11 @@ export class VestClient implements PlatformClient { const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN; const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}` : !hours.open ? 'market closed' - : !mark || now - mark.at > 5 * 60_000 ? 'no mark price' - : !(spec.tick > 0) ? 'no tick size' - : (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles` - : null; + : tooSoon(hours.closesAt, hold) ? closingNote(hours.closesAt!, hold) + : !mark || now - mark.at > 5 * 60_000 ? 'no mark price' + : !(spec.tick > 0) ? 'no tick size' + : (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles` + : null; if (rejection) { board.push({ symbol, score: null, note: rejection }); continue; } const pct = spec.takerFee > 0 ? spec.takerFee * 100 : firm?.commissionPct ?? 0; const score = scoreCandidate({ @@ -1530,6 +1652,7 @@ export class VestClient implements PlatformClient { note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}` + (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '') + (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''), + closesAt: hours.closesAt, contract: { id: symbol, name: encodeContractName(symbol, step), @@ -1551,6 +1674,9 @@ export class VestClient implements PlatformClient { const ev = ctx.evaluate(b.contract!); if (!ev) { b.note += ', can\'t size'; return []; } b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`; + // Held short of the target hold (a leverage cap), the leg + // runs longer: it has to clear the close as well. + if (tooSoon(b.closesAt ?? null, ev.holdHours)) { b.note += `, ${closingNote(b.closesAt!, ev.holdHours)}`; return []; } return [{ b, holdHours: ev.holdHours, cost: ev.cost }]; }); ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b); diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 80e0e4b..8a179cf 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -540,7 +540,19 @@ export function hourlyVolatility(klines: unknown[]): number | null { */ export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } { if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null }; - const list = periods.flatMap((p) => { + const list = hoursPeriods(periods); + const i = list.findIndex((p) => p.from <= now && now < p.to); + if (i < 0 || !list[i].open) return { open: false, closesAt: null }; + // Consecutive open periods (overnight → pre-market → regular …) are one stretch. + let end = list[i].to; + for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to; + return { open: true, closesAt: end }; +} + +/** The market-hours periods, read and in order; open = trading, with no + * closure (see marketOpen). */ +function hoursPeriods(periods: unknown[]): { from: number; to: number; open: boolean }[] { + return periods.flatMap((p) => { const r = p as { fromMs?: unknown; toMs?: unknown; state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } }; @@ -553,10 +565,20 @@ export function marketOpen(periods: unknown, now: number): { open: boolean; clos && (closure === undefined || closure === 'unspecified'); return [{ from, to, open }]; }).sort((a, b) => a.from - b.from); - const i = list.findIndex((p) => p.from <= now && now < p.to); - if (i < 0 || !list[i].open) return { open: false, closesAt: null }; - // Consecutive open periods (overnight → pre-market → regular …) are one stretch. - let end = list[i].to; - for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to; - return { open: true, closesAt: end }; +} + +/** + * Whether the closure that starts at `closesAt` (marketOpen's) keeps the + * market shut across one of Vest's 8 PM ET resets — a weekend, a holiday — + * or for as far as the periods go. A position held into it can't be exited + * or fitted to that reset until the market reopens, and meets the reopen's + * gap with its stop; the daily maintenance hour reopens before the reset. + * A close the periods don't show (they just end there) isn't one. + */ +export function longClosure(periods: unknown, closesAt: number): boolean { + if (!Array.isArray(periods)) return false; + const list = hoursPeriods(periods); + if (!list.some((p) => p.to > closesAt)) return false; + const reopensAt = list.find((p) => p.open && p.from >= closesAt)?.from; + return reopensAt === undefined || nextVestResetMs(closesAt) < reopensAt; } diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 5d828bd..f180a9b 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -14,7 +14,7 @@ import { projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide, - resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, + resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -527,6 +527,25 @@ test('resetClearStop: the widest stop a leg may carry and still close before the assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24); }); +test('clearsClose: no leg starts under four holds of its market\'s close', () => { + const h = 3_600_000; + assert.equal(RESET_CLEAR_HOLDS, 4); + // The weekly close 7.5 h off, legs sized for 2 h: no entry. + assert.equal(clearsClose({ closeInMs: 7.5 * h, holdHours: 2 }), false); + // Exactly four holds off, or more: the clearance the daily reset gets. + assert.equal(clearsClose({ closeInMs: 8 * h, holdHours: 2 }), true); + assert.equal(clearsClose({ closeInMs: 8 * h - 1, holdHours: 2 }), false); + assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: 2 }), true); + // A leg its leverage cap holds to 3.5 h needs 14 h, where a 2 h one is clear. + assert.equal(clearsClose({ closeInMs: 10 * h, holdHours: 3.5 }), false); + // It agrees with resetClearStop, which gives no stop at all under four holds. + for (const off of [5.9, 6, 6.1, 12]) { + assert.equal(clearsClose({ closeInMs: off * h, holdHours: 1.5 }), resetClearStop({ leg: 500, resetInMs: off * h, holdHours: 1.5 }) > 0); + } + // An unknown hold never clears. + assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: NaN }), false); +}); + test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => { // Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to // pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h. diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 897556c..0344bea 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -1,9 +1,10 @@ -// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the -// relayed-session store (lib/vest-session.ts). +// Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the +// relayed-session store (lib/vest-session.ts), and the trade date the engine +// plans Vest accounts on (lib/trading-logic.ts). // // npm run check // -// Both files import only Node built-ins, so Node strips their types and loads +// These files import only Node built-ins, so Node strips their types and loads // them directly; .mjs keeps this file out of Next's type check. The samples are // shaped like the gateway's real answers (captured 2026-10-26). import { test } from 'node:test'; @@ -12,9 +13,10 @@ import { isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount, vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, - accountScopedToken, + accountScopedToken, longClosure, } from '../lib/vest-helpers.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts'; +import { computeDayPlan, currentTradeDateCT, tradeDateFor } from '../lib/trading-logic.ts'; const iso = (s) => Date.parse(s); @@ -205,6 +207,58 @@ test('market hours: open stretches, closures and crypto', () => { assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null }); }); +test('market hours: a closure past an 8 PM ET reset is a long one, the maintenance hour is not', () => { + const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } }); + const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } }); + const week = [ + open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'), + shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'), + open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'), + shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'), + open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'), + shut('2026-11-02T22:00:00Z', '2026-11-02T23:00:00Z', 'maintenance'), + open('2026-11-02T23:00:00Z', '2026-11-03T22:00:00Z'), + ]; + // The weekend: shut through Friday's and Saturday's 8 PM ET resets. + const friday = marketOpen(week, iso('2026-10-30T20:57:00Z')); + assert.deepEqual(friday, { open: true, closesAt: iso('2026-10-30T21:00:00Z') }); + assert.equal(longClosure(week, friday.closesAt), true); + // The maintenance hour reopens before the evening's reset, in summer and winter. + const thursday = marketOpen(week, iso('2026-10-29T20:57:00Z')); + assert.equal(thursday.closesAt, iso('2026-10-29T21:00:00Z')); + assert.equal(longClosure(week, thursday.closesAt), false); + assert.equal(longClosure(week, iso('2026-11-02T22:00:00Z')), false); + // A gap in the periods is shut too (a holiday), and this one outlasts the reset. + const early = [open('2026-11-25T14:00:00Z', '2026-11-25T18:00:00Z'), open('2026-11-26T23:00:00Z', '2026-11-27T18:00:00Z')]; + assert.equal(longClosure(early, iso('2026-11-25T18:00:00Z')), true); + // Shut as far as the periods go: long. Periods that just end: no close known. + assert.equal(longClosure(week.slice(0, 4), iso('2026-10-30T21:00:00Z')), true); + assert.equal(longClosure(week.slice(0, 3), iso('2026-10-30T21:00:00Z')), false); + assert.equal(longClosure(undefined, iso('2026-10-30T21:00:00Z')), false); +}); + +test("a Vest account plans on Vest's day: the 5 PM CT roll doesn't count today's P&L twice", () => { + const vest = { tradeDate: vestDayOf }; + // 3:30 and 5:30 PM CT (4:30 / 6:30 PM ET): the Central date rolls at + // 5 PM CT, Vest's day only at 8 PM ET. + const before = new Date('2026-10-27T20:30:00Z'); + const after = new Date('2026-10-27T22:30:00Z'); + assert.equal(currentTradeDateCT(after), '2026-10-28'); + assert.equal(tradeDateFor(vest, after), '2026-10-27'); + assert.equal(tradeDateFor(vest, new Date('2026-10-28T00:00:00Z')), '2026-10-28'); + // Every other platform keeps the Central date. + assert.equal(tradeDateFor(undefined, after), '2026-10-28'); + assert.equal(tradeDateFor({}, after), '2026-10-28'); + // $3,000 to go, 3 min days of $150, $800 made today: Vest's row for its + // day, and the realized P&L since its reset. + const rows = [{ date: '2026-10-27', pnl: 800 }]; + const plan = (date) => computeDayPlan(7, date, 3000, 0, rows, 150, 3, 800, 800); + assert.equal(plan(currentTradeDateCT(before)).remainingToday, 1900); + assert.deepEqual(plan(tradeDateFor(vest, after)), plan(currentTradeDateCT(before))); + // On the Central date the $800 is a prior min day and today's P&L both. + assert.equal(plan(currentTradeDateCT(after)).remainingToday, 2050); +}); + test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => { const now = iso('2026-10-26T18:40:00Z'); const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`; From ef15200e5e3a9796c569ddd3efefe54b0cb9f790 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:41:20 -0500 Subject: [PATCH 2/3] Vest: report on Vest's day, and close a leg whose open came back unknown The reporter and the dashboard take each firm's own trade date (tradeDateFor), as the engine does: between 5 PM CT and Vest's 8 PM ET reset the Central date counted the open Vest day as a prior day and as today's P&L both, and could report a pass the master keeps for good. The completion check's settle window now follows the client's own roll. An open whose outcome came back unknown is marked the bot's once a read shows the position it made, so the market-close guard closes it before a long closure; an unmarked leg is no longer logged as a hand trade. A symbol the close gate holds back stays on the candidate board for the dashboard and the report, and fireLeg holds each account's leg to the close at its own size, not just the batch lead's. check-vest-client.mjs drives the guard and the board with every gateway call answered locally. Co-Authored-By: Claude Opus 5.5 --- app/api/state/route.ts | 7 +- lib/auto-trade.ts | 6 +- lib/platform-client.ts | 4 + lib/reporter.ts | 12 ++- lib/trading-logic.ts | 38 +++++--- lib/vest-class.ts | 81 ++++++++++++---- lib/vest-helpers.ts | 14 +++ package.json | 2 +- scripts/check-trading-logic.mjs | 15 +++ scripts/check-vest-client.mjs | 164 ++++++++++++++++++++++++++++++++ scripts/check-vest.mjs | 44 ++++++++- 11 files changed, 345 insertions(+), 42 deletions(-) create mode 100644 scripts/check-vest-client.mjs diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 7654ae1..c80931b 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -9,7 +9,7 @@ import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate'; import { obfuscationDayBooked } from '@/lib/auto-trade'; import type { AccountState } from '@/types'; import { - combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, + combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor, obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, maxLossRoom, lastStand as isLastStand, shortestNextLeg, LEG_COMPLETION_EPSILON, @@ -19,7 +19,6 @@ export async function GET() { try { const firms = getCachedCatalog(); const clients = getClients(); - const tradeDate = currentTradeDateCT(); const state = firms.map((f) => { const client = clients.get(f.id); @@ -30,6 +29,9 @@ export async function GET() { if (!client || client.accountList.length === 0) { return { firmId: f.id, firm: f.name, connected, accounts: [] }; } + // The day the firm's accounts trade on (tradeDateFor), as the + // engine plans them — Vest's turns at 20:00 ET, not 5 PM CT. + const tradeDate = tradeDateFor(client); // Accounts whose rules date was set by hand — one read per firm // rather than one per account. const rulesDateByHand = new Set(listAssignments(f.id) @@ -251,6 +253,7 @@ export async function GET() { completed = cycleCompletion({ accountId: acc.id, rawTradeDate: tradeDate, + client, cycleDailyPnL: client.dailyPnL[acc.id], rawRealizedPnL: cash.realizedPnL, combineCmeHolidays: cfg.combineCmeHolidays, diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 90fd071..291c3df 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -13,7 +13,7 @@ import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, tradeDateFor, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, - expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, + expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, legCloseNote, isNoTradeTime, isPastSessionClose, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, @@ -1491,6 +1491,10 @@ async function fireLeg( if (!plan.sizing) { throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`); } + // The pick held the batch's lead leg to its market's close; this + // account's leg can size longer (another stage, a leverage cap). + const closeNote = legCloseNote(legContract.closesAt, plan.sizing.predictedHoldHours, Date.now()); + if (closeNote) throw new Error(`${legContract.name}: ${closeNote} — not entering`); contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional)); if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional))); if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!); diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 46c636b..5f7230a 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -48,6 +48,10 @@ export interface PlatformContract { /** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the * broker says — caps a position at available funds ÷ this. */ marginRate?: number; + /** When the instrument's market next closes (epoch ms), where the + * platform says: no leg starts that wouldn't be done by then + * (clearsClose), each account's at its own size (fireLeg). */ + closesAt?: number; } export interface PlatformClient { diff --git a/lib/reporter.ts b/lib/reporter.ts index ef52e02..cfddbde 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -20,7 +20,7 @@ import { getClients } from './clients'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; -import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic'; +import { botStopsActive, combineCmeSessions, computeDayPlan, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor } from './trading-logic'; import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; import { accountNextLeg, accountLegShape } from './leg-estimate'; import { sizingHoldHours } from './person-size'; @@ -33,9 +33,6 @@ function collectFirmStats() { // the master's table with rows for firms it does not run. const firms = getTradableFirms(); const clients = getClients(); - // One raw trade date for the whole report: the combined views below roll - // it forward, the completion check needs it as is. - const rawToday = currentTradeDateCT(); // projectPerfectPath's `loops` test: a type whose completed cycle keeps // going pays out (a funded account), one that stops just passes. const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean => @@ -49,6 +46,11 @@ function collectFirmStats() { const keptRuleIds = getKeptRuleIdsForFirm(f.id); // accounts: [] only once synced (see emptyFirmStats). if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds); + // One raw trade date per firm, the one its accounts trade on + // (tradeDateFor — Vest's turns at 20:00 ET, not 5 PM CT): the + // combined views below roll it forward, the completion check needs + // it as is. + const rawToday = tradeDateFor(client); let totalAccounts = 0; let accountsTraded = 0; @@ -271,6 +273,7 @@ function collectFirmStats() { const ended = endedCycleCompletion({ accountId: acc.id, rawTradeDate: rawToday, + client, fullDailyPnL: client.fullDailyPnL[acc.id] ?? [], cycleDailyPnL: client.dailyPnL[acc.id], fundTransactions: fundTxns, @@ -307,6 +310,7 @@ function collectFirmStats() { const completion = cycleCompletion({ accountId: acc.id, rawTradeDate: rawToday, + client, cycleDailyPnL: client.dailyPnL[acc.id], rawRealizedPnL: cash.realizedPnL, combineCmeHolidays: cfg.combineCmeHolidays, diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 574b488..9a32e63 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -684,8 +684,9 @@ export type CycleCompletion = 'none' | 'today' | 'before'; /** How long a new trade date waits before it can read 'today': the platforms * reset realizedPnL on their own clock (a Tradovate cashBalance event, the - * TopstepX 60s sync), not at 5 PM sharp, and until then the last session's - * number — already booked under its own date — would count as a second day. */ + * TopstepX 60s sync, Vest's next sync after 20:00 ET), not at the roll + * sharp, and until then the last session's number — already booked under + * its own date — would count as a second day. */ const ROLL_SETTLE_MS = 5 * 60 * 1000; /** How long after a fund transaction starts a new cycle the cycle it ended is @@ -716,13 +717,14 @@ function cycleOwesNothing( * weekend (it is the last session's stale number, already booked under its * own date), a Monday with a weekend-dated row — the live session filed under * a weekend date, which would count it twice — and the first minutes after - * the 5 PM roll. An every-day firm's weekends are real sessions, so only the - * roll window applies to it. */ + * the roll: the client's own (tradeDateFor), else 5 PM CT. An every-day + * firm's weekends are real sessions, so only the roll window applies to it. */ function sessionDoubtful( rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date, everyDay = false, + client?: { tradeDate?: (now: number) => string }, ): boolean { const day = new Date(`${rawTradeDate}T00:00:00Z`); const dow = day.getUTCDay(); @@ -733,7 +735,7 @@ function sessionDoubtful( if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true; } // Settle window: a few minutes ago it was still an earlier trade date. - return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate; + return tradeDateFor(client, new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate; } /** @@ -746,14 +748,18 @@ function sessionDoubtful( * 'none' instead: a weekend (realizedPnL is the last session's stale number, * already booked under its own date), a Monday with a weekend-dated row — the * live session filed under a weekend date, which would count it twice — and - * the first minutes after the 5 PM roll. 'before' skips those guards: it only + * the first minutes after the roll. 'before' skips those guards: it only * keeps a locked, untraded pass out of the reporter's losses. */ export function cycleCompletion(opts: { accountId: number; - /** currentTradeDateCT() — NOT the combined date, which rolls a weekend - * forward to Monday. */ + /** tradeDateFor(client) — NOT the combined date, which rolls a weekend + * forward to Monday. On a platform whose day turns later than 5 PM CT + * (Vest), the Central date would count its open day twice. */ rawTradeDate: string; + /** The account's client: the settle window follows its day's roll + * (tradeDateFor). */ + client?: { tradeDate?: (now: number) => string }; /** Current-cycle daily P&L as the client holds it (client.dailyPnL). */ cycleDailyPnL: { date: string; pnl: number }[]; rawRealizedPnL: number; @@ -776,7 +782,7 @@ export function cycleCompletion(opts: { const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate); if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before'; - if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none'; + if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return 'none'; const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }]; const probe = nextTradingDate(view.tradeDate, everyDay); @@ -811,8 +817,10 @@ export function cycleCompletion(opts: { */ export function endedCycleCompletion(opts: { accountId: number; - /** currentTradeDateCT(). */ + /** tradeDateFor(client). */ rawTradeDate: string; + /** As in cycleCompletion. */ + client?: { tradeDate?: (now: number) => string }; /** Every cycle's daily P&L, raw (client.fullDailyPnL). */ fullDailyPnL: { date: string; pnl: number }[]; /** Current-cycle daily P&L, raw (client.dailyPnL). */ @@ -841,7 +849,7 @@ export function endedCycleCompletion(opts: { if (lastFundDate === null || start === null) return null; const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000); if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date - if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null; + if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null; const latest = (a: string, b: string) => (b > a ? b : a); const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate); @@ -1408,6 +1416,14 @@ export function clearsClose(o: { closeInMs: number; holdHours: number }): boolea return o.closeInMs >= RESET_CLEAR_HOLDS * o.holdHours * 3_600_000; } +/** Why a leg of `holdHours` starting at `now` doesn't open: its market + * closes at `closesAt` (epoch ms) before it clears (clearsClose). null when + * it clears, or no close is known (a futures contract). */ +export function legCloseNote(closesAt: number | null | undefined, holdHours: number, now: number): string | null { + if (closesAt === null || closesAt === undefined || clearsClose({ closeInMs: closesAt - now, holdHours })) return null; + return `closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`; +} + /** * dllRoom where the platform states its daily-loss floor itself (Vest: the * equity the account may not fall below today, already moved by the day's diff --git a/lib/vest-class.ts b/lib/vest-class.ts index 75fdc76..96739d1 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -38,7 +38,7 @@ import { randomUUID } from 'crypto'; import axios from './http-client'; -import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop, clearsClose, RESET_CLEAR_HOLDS } from './trading-logic'; +import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop, legCloseNote } from './trading-logic'; import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, @@ -52,7 +52,7 @@ import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } f import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session'; import { vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, - parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop, + parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop, 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'; @@ -112,6 +112,9 @@ 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; /** account_meta key of the profit claims this client made, per account. */ const CLAIMS_META = 'vest_claims'; /** A claim shows in Vest's transactions as a WITHDRAW of the same amount @@ -223,6 +226,10 @@ export class VestClient implements PlatformClient { closes: new Map(), named: new Map(), hoursWarnedAt: new Map(), noSessionFor: 0, }; 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(); private capital = new Map(); private positionsDetail = new Map(); private openOrders = new Map(); @@ -603,6 +610,7 @@ export class VestClient implements PlatformClient { this.positionsDetail.set(id, mine); this.openOrders.set(id, orders.filter((o) => o.accountId === a.id)); this.applyPosition(acc, mine); + this.adoptUnknownOpen(id, mine, startedAt); const amount = free.get(a.id); if (amount === undefined) { complete = false; continue; } @@ -1082,9 +1090,10 @@ export class VestClient implements PlatformClient { * stop. No leg starts under RESET_CLEAR_HOLDS holds of a close * (pickCandidate), so this only meets one that outlived them. * - * Only positions the bot's own order opened (markOwn): one without that - * mark — a hand trade, or one from before marking began — is left as it - * is and named once in the log. The daily maintenance hour reopens + * Only positions the bot's own order opened (markOwn, or an open of + * unknown outcome that turned out filled: adoptUnknownOpen): one without + * that mark — a hand trade, or one from before marking began — is left + * as it is and named once in the log. The daily maintenance hour reopens * before the reset, and is ridden. */ private async guardClose(): Promise { @@ -1137,8 +1146,11 @@ export class VestClient implements PlatformClient { } return; } + const readAt = Date.now(); const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened'); - const open = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null && closing.has(p.symbol)); + const all = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null); + for (const id of [...this.unknownOpens.keys()]) this.adoptUnknownOpen(id, all, readAt); + const open = all.filter((p) => closing.has(p.symbol)); const mine = botOpenedPositions(open.map((p) => p.positionId)); for (const acc of this.accountList) { const uuid = this.uuidById.get(acc.id); @@ -1149,7 +1161,7 @@ export class VestClient implements PlatformClient { if (!mine.has(pos.positionId)) { if (guard.named.get(pos.positionId) !== closesAt) { guard.named.set(pos.positionId, closesAt); - console.error(`${this.tag} ${acc.name}: ${pos.side} ${pos.symbol} wasn't opened by the bot and stays open — its market closes in ${inMin} min until past an 8 PM ET reset; close it on Vest if it shouldn't carry the closure and the reopen's gap`); + console.error(`${this.tag} ${acc.name}: ${pos.side} ${pos.symbol} isn't marked as the bot's (a hand trade, or a bot leg from before marking or a restart mid-entry) and stays open — its market closes in ${inMin} min until past an 8 PM ET reset; close it on Vest if it shouldn't carry the closure and the reopen's gap`); } continue; } @@ -1170,6 +1182,23 @@ 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. */ + 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 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 (readAt > sent.until) { + this.unknownOpens.delete(accountId); + } + } + private settleSuspects(): void { const now = Date.now(); for (const [id, since] of this.suspectAccounts) { @@ -1600,13 +1629,12 @@ export class VestClient implements PlatformClient { // hour) catches open can't be exited until the market reopens, and // meets the reopen's gap with its stop: none starts under // RESET_CLEAR_HOLDS holds of the close — the daily reset's clearance. - const tooSoon = (closesAt: number | null, holdHours: number) => - closesAt !== null && !clearsClose({ closeInMs: closesAt - now, holdHours }); - const closingNote = (closesAt: number, holdHours: number) => - `closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`; + // Such a symbol stays on the board (the dashboard's candidates) but + // isn't picked (legCloseNote); fireLeg holds each account's own leg + // to it too. const hold = ctx?.targetHoldHours ?? targetHold; - const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closesAt?: number | null }[] = []; + const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closing?: boolean }[] = []; // No new position this close to the reset: nothing picked, and the // last survey's candidates stay up for the dashboard. const toReset = nextVestResetMs(now) - now; @@ -1631,11 +1659,10 @@ export class VestClient implements PlatformClient { const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN; const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}` : !hours.open ? 'market closed' - : tooSoon(hours.closesAt, hold) ? closingNote(hours.closesAt!, hold) - : !mark || now - mark.at > 5 * 60_000 ? 'no mark price' - : !(spec.tick > 0) ? 'no tick size' - : (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles` - : null; + : !mark || now - mark.at > 5 * 60_000 ? 'no mark price' + : !(spec.tick > 0) ? 'no tick size' + : (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles` + : null; if (rejection) { board.push({ symbol, score: null, note: rejection }); continue; } const pct = spec.takerFee > 0 ? spec.takerFee * 100 : firm?.commissionPct ?? 0; const score = scoreCandidate({ @@ -1646,13 +1673,14 @@ export class VestClient implements PlatformClient { volatilityPerHour: vol?.vol ?? null, }); if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; } + const closing = legCloseNote(hours.closesAt, hold, now); board.push({ symbol, score, note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}` + (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '') - + (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''), - closesAt: hours.closesAt, + + (closing ? `, ${closing}` : hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''), + closing: closing !== null, contract: { id: symbol, name: encodeContractName(symbol, step), @@ -1664,11 +1692,12 @@ export class VestClient implements PlatformClient { spreadFraction: spread, minContracts: 1, marginRate: spec.marginRate || undefined, + closesAt: hours.closesAt ?? undefined, }, }); } - let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!); + let ranked = board.filter((b) => b.contract && !b.closing).sort((a, b) => a.score! - b.score!); if (ctx) { const sized = ranked.flatMap((b) => { const ev = ctx.evaluate(b.contract!); @@ -1676,7 +1705,8 @@ export class VestClient implements PlatformClient { b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`; // Held short of the target hold (a leverage cap), the leg // runs longer: it has to clear the close as well. - if (tooSoon(b.closesAt ?? null, ev.holdHours)) { b.note += `, ${closingNote(b.closesAt!, ev.holdHours)}`; return []; } + const closing = legCloseNote(b.contract!.closesAt, ev.holdHours, now); + if (closing) { b.note += `, ${closing}`; return []; } return [{ b, holdHours: ev.holdHours, cost: ev.cost }]; }); ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b); @@ -1854,6 +1884,15 @@ export class VestClient implements PlatformClient { for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result); this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber); 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. + 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, + }); + } + throw err; } finally { this.pendingFills--; if (opening) { diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 8a179cf..3a77774 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -391,6 +391,20 @@ export function awaitingLegs(p: VestPosition): VestLeg[] { return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting'); } +/** + * 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. + */ +export function unknownOpenFill( + sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number }, + 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; +} + export interface VestOpenOrder { orderId: string; accountId: string; diff --git a/package.json b/package.json index 90e5328..3e3fed7 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-trades.mjs" + "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index f180a9b..f7f4c87 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -15,6 +15,7 @@ import { dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide, resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS, + legCloseNote, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -546,6 +547,20 @@ test('clearsClose: no leg starts under four holds of its market\'s close', () => assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: NaN }), false); }); +test('legCloseNote: each account\'s leg is held to its market\'s close at its own size', () => { + const h = 3_600_000; + const now = Date.parse('2026-10-30T12:30:00Z'); + const close = now + 9 * h; + // The batch's lead leg, 2 h: clear. Another account's, sized to 3.5 h: not. + assert.equal(legCloseNote(close, 2, now), null); + assert.equal(legCloseNote(close, 3.5, now), 'closes in 9.0h, under 4 holds of 3.5h'); + // A contract that names no close (futures, crypto) never holds a leg back. + assert.equal(legCloseNote(undefined, 3.5, now), null); + assert.equal(legCloseNote(null, 3.5, now), null); + // An unknown hold doesn't open into a known close. + assert.notEqual(legCloseNote(close, NaN, now), null); +}); + test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => { // Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to // pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h. diff --git a/scripts/check-vest-client.mjs b/scripts/check-vest-client.mjs new file mode 100644 index 0000000..03a832f --- /dev/null +++ b/scripts/check-vest-client.mjs @@ -0,0 +1,164 @@ +// 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. +// +// npm run check +// +// A scratch database, a fake clock, and every gateway call answered here — +// nothing reaches Vest. +import { after, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { registerHooks, stripTypeScriptTypes } from 'node:module'; + +// Resolve the extensionless local imports used by Next, without a build. +function resolveTypes(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (err) { + if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err; + return nextResolve(`${specifier}.ts`, context); + } +} +// The client's classes use parameter properties, which plain type +// stripping refuses: transform them instead. +function loadTypes(url, context, nextLoad) { + if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context); + const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' }); + return { format: 'module', source, shortCircuit: true }; +} +registerHooks({ resolve: resolveTypes, load: loadTypes }); + +const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-')); +process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite'); +delete process.env.NEXT_PHASE; +after(() => rmSync(directory, { recursive: true, force: true })); + +// No request leaves this process: axios refuses every one, and each client's +// gateway is a function in this file — from construction on, since the +// constructor starts a sync. +const { default: axios } = await import('../lib/http-client.ts'); +axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`)); +const { VestClient } = await import('../lib/vest-class.ts'); +let gateway = null; +VestClient.prototype.request = async function (bucket, method, path, opts = {}) { + if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`); + 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 { encodeContractName } = await import('../lib/dxtrade-helpers.ts'); + +const iso = (s) => Date.parse(s); +const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451'; + +// The clock the client reads (Date.now), set to an instant and running on. +const realNow = Date.now; +after(() => { Date.now = realNow; }); +let tokens = 0; +function clockAt(when) { + const start = realNow(); + const t0 = iso(when); + Date.now = () => t0 + (realNow() - start); + tokens += 1; + assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null); +} + +// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC +// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday. +const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } }); +const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } }); +const week = [ + open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'), + shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'), + open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'), + shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'), + open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'), +]; +const spec = (symbol, assetId) => ({ + symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING', +}); +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) { + gateway = null; + const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {}); + clearInterval(client.syncInterval); + clearInterval(client.watchdogInterval); + gateway = 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 }); + return client; +} + +test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => { + let positions = []; + const closes = []; + const client = testClient((method, path, opts) => { + if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } }; + if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up'); + if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; } + throw new Error(`no gateway in this test: ${method} ${path}`); + }); + try { + // Friday 8:30 AM ET: the entry's send fails in transit, but it filled. + clockAt('2026-10-30T12:30:00Z'); + await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/); + const raw = (positionId, accountId, openDate) => ({ + positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate, + stopLosses: [], takeProfits: [], + }); + positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))]; + client.positionsDetail.set(1, []); + client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]); + client.syncComplete = true; + + // 4:56 PM ET: four minutes to the weekly close. + clockAt('2026-10-30T20:56:00Z'); + await client.guardClose(); + assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]); + assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']); + // A close just sent isn't sent again while a read may not show it. + await client.guardClose(); + assert.equal(closes.length, 1); + } finally { + client.disconnect(); + } +}); + +test('a symbol the close gate holds back stays on the board, and isn\'t picked', async () => { + const client = testClient((method, path) => { + throw new Error(`no gateway in this test: ${method} ${path}`); + }); + client.refreshMarks = async () => {}; + client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }])); + try { + // Thursday 2 PM ET: NDX's maintenance hour is 3 h off, crypto never closes. + clockAt('2026-10-29T18:00:00Z'); + for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); + const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false); + assert.equal(pick.id, 'BTC-PERP'); + assert.equal(pick.closesAt, undefined); + assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']); + const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD'); + assert.equal(ndx.closesAt, iso('2026-10-29T21:00:00Z')); + // The survey keeps it up for the dashboard, though nothing is picked. + assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null); + assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']); + // And the client's day is Vest's. + assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z'))); + } finally { + client.disconnect(); + } +}); diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 0344bea..1100bd4 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -13,10 +13,10 @@ import { isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount, vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, - accountScopedToken, longClosure, + accountScopedToken, longClosure, unknownOpenFill, } from '../lib/vest-helpers.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts'; -import { computeDayPlan, currentTradeDateCT, tradeDateFor } from '../lib/trading-logic.ts'; +import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts'; const iso = (s) => Date.parse(s); @@ -128,6 +128,23 @@ const openPosition = { takeProfits: [], }; +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 sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:28:00Z'), until: iso('2026-10-30T12:32:15Z') }; + 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); + // 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); + // No open time: can't be told from a hand trade. + assert.equal(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), null); +}); + test('positions, their legs and unrealized P&L', () => { const p = parsePosition(openPosition); assert.equal(p.side, 'short'); @@ -259,6 +276,29 @@ test("a Vest account plans on Vest's day: the 5 PM CT roll doesn't count today's assert.equal(plan(currentTradeDateCT(after)).remainingToday, 2050); }); +test("a Vest account's completion is judged on Vest's day, and waits out Vest's own roll", () => { + const vest = { tradeDate: vestDayOf }; + // $3,000 in 2 min days: day one made $3,100 and is flat. At 5:30 PM CT + // its row is booked and its P&L is still today's realized P&L. + const eff = { profitTarget: 3000, consistency: 0, minTradingDays: 2, minDayPnL: 0, drain: false, dailyLossLimit: 0 }; + const opts = { + accountId: 7, cycleDailyPnL: [{ date: '2026-10-27', pnl: 3100 }], rawRealizedPnL: 3100, combineCmeHolidays: false, + tradesEveryDay: true, effective: eff, equityProfit: 3100, splitEnabled: true, client: vest, + }; + const evening = new Date('2026-10-27T22:30:00Z'); + // The Central date counts the one day twice and reports a pass. + assert.equal(cycleCompletion({ ...opts, client: undefined, rawTradeDate: currentTradeDateCT(evening), now: evening }), 'today'); + assert.equal(cycleCompletion({ ...opts, rawTradeDate: tradeDateFor(vest, evening), now: evening }), 'none'); + // Two minutes past Vest's 8 PM ET roll the last sync's realized P&L can + // still be the ended day's: no 'today' until the settle window passes. + const justRolled = new Date('2026-10-28T00:02:00Z'); + assert.equal(tradeDateFor(vest, justRolled), '2026-10-28'); + assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', now: justRolled }), 'none'); + // A real second day, once settled, finishes it. + const settled = new Date('2026-10-28T00:10:00Z'); + assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', rawRealizedPnL: 50, equityProfit: 3150, now: settled }), 'today'); +}); + test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => { const now = iso('2026-10-26T18:40:00Z'); const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`; From b5939a06a034a5bcb244e48aa36f2399b20fe1a9 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:51:00 -0500 Subject: [PATCH 3/3] Vest: only a long closure holds entries back; the maintenance hour is ridden The close gate held every Vest entry within four holds of any close, which with a 2 h hold meant no weekday entry from about 9 AM to 5 PM ET for the one-hour maintenance break. That hour reopens before the reset, as CME's daily break does for futures legs, so only a weekend or holiday closure gates an entry now. Co-Authored-By: Claude Opus 5.5 --- lib/platform-client.ts | 7 ++++--- lib/vest-class.ts | 18 +++++++++++------- scripts/check-vest-client.mjs | 17 +++++++++++++---- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 5f7230a..fe617d3 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -48,9 +48,10 @@ export interface PlatformContract { /** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the * broker says — caps a position at available funds ÷ this. */ marginRate?: number; - /** When the instrument's market next closes (epoch ms), where the - * platform says: no leg starts that wouldn't be done by then - * (clearsClose), each account's at its own size (fireLeg). */ + /** When the instrument's market next closes for long (epoch ms; Vest: a + * weekend or holiday, not the maintenance hour), where the platform + * says: no leg starts that wouldn't be done by then (clearsClose), each + * account's at its own size (fireLeg). */ closesAt?: number; } diff --git a/lib/vest-class.ts b/lib/vest-class.ts index 96739d1..fceefe4 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -1625,10 +1625,11 @@ export class VestClient implements PlatformClient { if (known.length > 0) await this.refreshMarks(known); const vols = await this.volatility(known); const now = Date.now(); - // A leg its symbol's market close (the weekend, the daily maintenance - // hour) catches open can't be exited until the market reopens, and - // meets the reopen's gap with its stop: none starts under - // RESET_CLEAR_HOLDS holds of the close — the daily reset's clearance. + // A leg a long closure (a weekend, a holiday) catches open can't be + // exited until the market reopens, and meets the reopen's gap with + // its stop: none starts under RESET_CLEAR_HOLDS holds of one — the + // daily reset's clearance. The daily maintenance hour reopens before + // the reset and is ridden, as futures legs ride CME's daily break. // Such a symbol stays on the board (the dashboard's candidates) but // isn't picked (legCloseNote); fireLeg holds each account's own leg // to it too. @@ -1648,8 +1649,11 @@ export class VestClient implements PlatformClient { const mark = this.marks.get(symbol); const vol = vols.get(symbol); let hours: { open: boolean; closesAt: number | null }; + let gateAt: number | null; try { - hours = marketOpen(await this.marketHours(spec.assetId), now); + const periods = await this.marketHours(spec.assetId); + hours = marketOpen(periods, now); + gateAt = hours.closesAt !== null && longClosure(periods, hours.closesAt) ? hours.closesAt : null; } catch (err) { board.push({ symbol, score: null, note: `market hours unknown (${errorMessage(err)})` }); continue; @@ -1673,7 +1677,7 @@ export class VestClient implements PlatformClient { volatilityPerHour: vol?.vol ?? null, }); if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; } - const closing = legCloseNote(hours.closesAt, hold, now); + const closing = legCloseNote(gateAt, hold, now); board.push({ symbol, score, @@ -1692,7 +1696,7 @@ export class VestClient implements PlatformClient { spreadFraction: spread, minContracts: 1, marginRate: spec.marginRate || undefined, - closesAt: hours.closesAt ?? undefined, + closesAt: gateAt ?? undefined, }, }); } diff --git a/scripts/check-vest-client.mjs b/scripts/check-vest-client.mjs index 03a832f..204e5ad 100644 --- a/scripts/check-vest-client.mjs +++ b/scripts/check-vest-client.mjs @@ -137,22 +137,31 @@ test('the market-close guard closes a bot leg whose open came back unknown, not } }); -test('a symbol the close gate holds back stays on the board, and isn\'t picked', async () => { +test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => { const client = testClient((method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); }); client.refreshMarks = async () => {}; client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }])); + const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); }; try { - // Thursday 2 PM ET: NDX's maintenance hour is 3 h off, crypto never closes. + // Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens + // before the reset — no gate. clockAt('2026-10-29T18:00:00Z'); - for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); + marked(); + await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false); + const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD'); + assert.equal(thursday.closesAt, undefined); + + // Friday 2 PM ET: the weekly close is 3 h off; crypto never closes. + clockAt('2026-10-30T18:00:00Z'); + marked(); const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false); assert.equal(pick.id, 'BTC-PERP'); assert.equal(pick.closesAt, undefined); assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']); const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD'); - assert.equal(ndx.closesAt, iso('2026-10-29T21:00:00Z')); + assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z')); // The survey keeps it up for the dashboard, though nothing is picked. assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null); assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);