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}`;