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 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 14:12:27 -05:00
co-authored by Claude Opus 5.5
parent beddbdcf6c
commit ec3e6ea388
8 changed files with 290 additions and 32 deletions
+11 -9
View File
@@ -11,7 +11,7 @@ import { getClients } from './clients';
import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { getCachedFirm, getTradableFirms } from './firm-catalog';
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver'; import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
import { import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, tradeDateFor,
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
isNoTradeTime, isPastSessionClose, isNoTradeTime, isPastSessionClose,
@@ -1043,7 +1043,10 @@ async function runTradeLocked(
// operator's pick; 'Auto' is drawn per batch from the batch firm's list. // operator's pick; 'Auto' is drawn per batch from the batch firm's list.
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null; 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(); const maxConcurrent = maxConcurrentAccounts();
// ── Phase 1: the eligible accounts across the lane's firms ── // ── Phase 1: the eligible accounts across the lane's firms ──
@@ -1742,7 +1745,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
return []; 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) // Collect eligible accounts (same logic as Phase 1 of runTrade)
const eligible: LegItem[] = []; const eligible: LegItem[] = [];
@@ -1860,7 +1864,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
const lease = acquireTradeMutex('[manual-leg]', lane); const lease = acquireTradeMutex('[manual-leg]', lane);
if (lease === null) throw new Error('another batch is in flight in this lane'); if (lease === null) throw new Error('another batch is in flight in this lane');
try { try {
const tradeDate = currentTradeDateCT(); const tradeDate = tradeDateFor(client);
const ev = evaluateAccountForTrade(client, acc, tradeDate); const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type'); 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); 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<void> {
const lease = acquireTradeMutex('[auto-claim]', lane); const lease = acquireTradeMutex('[auto-claim]', lane);
if (lease === null) return; if (lease === null) return;
try { try {
const tradeDate = currentTradeDateCT();
for (const { firm, client, acc } of due) { for (const { firm, client, acc } of due) {
if (!holdsTradeMutex(lease, lane)) return; if (!holdsTradeMutex(lease, lane)) return;
// Settled since, or a different amount now: claim what is due now. // Settled since, or a different amount now: claim what is due now.
@@ -1996,7 +1999,7 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
// In flight while it claims: a batch that takes over a wedged // In flight while it claims: a batch that takes over a wedged
// lane, or a Claim now, leaves the account alone. // lane, or a Claim now, leaves the account alone.
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount)); 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) { } catch (err) {
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now()); (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`); 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 >= 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`); 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 { 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 }; return { claimed, received, stage };
} finally { } finally {
releaseTradeMutex(lease, lane); 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). */ /** 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 { function hasRemainingConfiguredAccounts(): boolean {
const { ready, unready } = firmClients(); const { ready, unready } = firmClients();
const tradeDate = currentTradeDateCT();
// A client that isn't ready can't say whether its accounts are done — its // 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 // 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) // Order being placed right now = still in play (evaluation calls it ineligible)
if (runtime().accountsInFlight.has(acc.id)) return true; 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; if (ev?.eligible) return true;
// Waiting on its history (or its day-start balance), not done: it may // 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 // still owe today's trade — during the session and before it opens
+9
View File
@@ -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()); `).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<string> {
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 /** 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). */ * ids are bare numbers; the fill-built trades below carry a prefix). */
export function openTradeAccounts(): { accountId: number; accountName: string }[] { export function openTradeAccounts(): { accountId: number; accountName: string }[] {
+5
View File
@@ -145,6 +145,11 @@ export interface PlatformClient {
* before the platform's daily reset (resetClearStop). * before the platform's daily reset (resetClearStop).
*/ */
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number; 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 /** Payouts made since the firm's last daily reset — what picks the stage
* of an account type whose stages reset each day. */ * of an account type whose stages reset each day. */
sameDayClaims?(accountId: number): number; sameDayClaims?(accountId: number): number;
+21
View File
@@ -165,6 +165,18 @@ export function currentTradeDateCT(now: Date = new Date()): string {
return new Date(date).toISOString().slice(0, 10); 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 /** 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 ≥ * per-contract tick), always traded on MNQ. Drain days qualify at ≥
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like * 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; 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 * 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 * equity the account may not fall below today, already moved by the day's
+133 -7
View File
@@ -38,11 +38,11 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import axios from './http-client'; 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 { sizingHoldHours } from './person-size';
import { import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, 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'; } from './db';
import { getCachedFirm } from './firm-catalog'; import { getCachedFirm } from './firm-catalog';
import { firmInstruments } from './instruments'; import { firmInstruments } from './instruments';
@@ -52,7 +52,7 @@ import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } f
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session'; import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
import { import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, 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, parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION,
} from './vest-helpers'; } from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } 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. */ * candidate is costed as crossing this many ticks. */
const ASSUMED_SPREAD_TICKS = 1; const ASSUMED_SPREAD_TICKS = 1;
/** How long before the daily reset open positions are fitted to the room /** 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; const RESET_GUARD_MS = 5 * 60_000;
/** No new position opens this close to the reset: the guard's window, and a /** 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. */ * 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<string>(), noSessionLogged: false, unfittable: new Set<string>(),
}; };
private resetGuardInFlight = false; 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<string, number>(), named: new Map<string, number>(), hoursWarnedAt: new Map<string, number>(), noSessionFor: 0,
};
private closeGuardInFlight = false;
private capital = new Map<number, VestCapitalAccount>(); private capital = new Map<number, VestCapitalAccount>();
private positionsDetail = new Map<number, VestPosition[]>(); private positionsDetail = new Map<number, VestPosition[]>();
private openOrders = new Map<number, VestOpenOrder[]>(); private openOrders = new Map<number, VestOpenOrder[]>();
@@ -424,6 +432,7 @@ export class VestClient implements PlatformClient {
const now = Date.now(); const now = Date.now();
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync(); 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.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 ─────────────────────────────────────────────────── // ── public market data ───────────────────────────────────────────────────
@@ -733,6 +742,12 @@ export class VestClient implements PlatformClient {
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null; 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 * 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, * 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 { public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
const now = Date.now(); 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; const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
if (!(allowed > 0)) return 0; if (!(allowed > 0)) return 0;
const a = this.capital.get(accountId); 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<void> {
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<string, SymbolSpec>;
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<string, number>();
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 { private settleSuspects(): void {
const now = Date.now(); const now = Date.now();
for (const [id, since] of this.suspectAccounts) { 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<PlatformContract | null> { private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
const firm = getCachedFirm(this.cfg.firmId); const firm = getCachedFirm(this.cfg.firmId);
const targetHold = sizingHoldHours(firm); const targetHold = sizingHoldHours(firm, this.tradeDate(Date.now()));
const specs = await this.symbolSpecs(); const specs = await this.symbolSpecs();
const known = symbols.filter((s) => specs.has(s)); const known = symbols.filter((s) => specs.has(s));
if (known.length > 0) await this.refreshMarks(known); if (known.length > 0) await this.refreshMarks(known);
const vols = await this.volatility(known); const vols = await this.volatility(known);
const now = Date.now(); 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 // No new position this close to the reset: nothing picked, and the
// last survey's candidates stay up for the dashboard. // last survey's candidates stay up for the dashboard.
const toReset = nextVestResetMs(now) - now; const toReset = nextVestResetMs(now) - now;
@@ -1510,6 +1631,7 @@ export class VestClient implements PlatformClient {
const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN; const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN;
const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}` const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}`
: !hours.open ? 'market closed' : !hours.open ? 'market closed'
: tooSoon(hours.closesAt, hold) ? closingNote(hours.closesAt!, hold)
: !mark || now - mark.at > 5 * 60_000 ? 'no mark price' : !mark || now - mark.at > 5 * 60_000 ? 'no mark price'
: !(spec.tick > 0) ? 'no tick size' : !(spec.tick > 0) ? 'no tick size'
: (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles` : (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles`
@@ -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` : ''}` 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` : '') + (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '')
+ (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''), + (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
closesAt: hours.closesAt,
contract: { contract: {
id: symbol, id: symbol,
name: encodeContractName(symbol, step), name: encodeContractName(symbol, step),
@@ -1551,6 +1674,9 @@ export class VestClient implements PlatformClient {
const ev = ctx.evaluate(b.contract!); const ev = ctx.evaluate(b.contract!);
if (!ev) { b.note += ', can\'t size'; return []; } 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)}`; 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 }]; return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
}); });
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b); ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
+29 -7
View File
@@ -540,7 +540,19 @@ export function hourlyVolatility(klines: unknown[]): number | null {
*/ */
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: 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 }; 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 { const r = p as {
fromMs?: unknown; toMs?: unknown; fromMs?: unknown; toMs?: unknown;
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: 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'); && (closure === undefined || closure === 'unspecified');
return [{ from, to, open }]; return [{ from, to, open }];
}).sort((a, b) => a.from - b.from); }).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; * Whether the closure that starts at `closesAt` (marketOpen's) keeps the
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to; * market shut across one of Vest's 8 PM ET resets — a weekend, a holiday —
return { open: true, closesAt: end }; * 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;
} }
+20 -1
View File
@@ -14,7 +14,7 @@ import {
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide, 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'; } from '../lib/trading-logic.ts';
// Deterministic PRNG so a failure reproduces. // 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); 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', () => { 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 // 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. // pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
+58 -4
View File
@@ -1,9 +1,10 @@
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the // Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the
// relayed-session store (lib/vest-session.ts). // relayed-session store (lib/vest-session.ts), and the trade date the engine
// plans Vest accounts on (lib/trading-logic.ts).
// //
// npm run check // 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 // 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). // shaped like the gateway's real answers (captured 2026-10-26).
import { test } from 'node:test'; import { test } from 'node:test';
@@ -12,9 +13,10 @@ import {
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount, isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
accountScopedToken, accountScopedToken, longClosure,
} from '../lib/vest-helpers.ts'; } from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.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); 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 }); 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', () => { test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
const now = iso('2026-10-26T18:40:00Z'); const now = iso('2026-10-26T18:40:00Z');
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`; const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;