Merge branch 'vest-fix-timing' into vest-fixes

# Conflicts:
#	lib/reporter.ts
#	lib/vest-class.ts
#	scripts/check-vest.mjs
This commit is contained in:
Brandon Li
2026-10-27 14:51:33 -05:00
12 changed files with 625 additions and 50 deletions
+179 -10
View File
@@ -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, legCloseNote } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
saveEndedCycleStart, dropFundTransaction, 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, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
} from './vest-helpers';
@@ -104,7 +104,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. */
@@ -112,6 +113,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';
@@ -197,6 +201,17 @@ export class VestClient implements PlatformClient {
noSessionLogged: false, unfittable: new Set<string>(),
};
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;
/** Opens whose outcome came back unknown, by account: the position one
* made is the bot's once a read shows it (adoptUnknownOpen) — no exits
* went on it, so it's the leg guardClose most needs to see. */
private unknownOpens = new Map<number, { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number }>();
private capital = new Map<number, VestCapitalAccount>();
private positionsDetail = new Map<number, VestPosition[]>();
private openOrders = new Map<number, VestOpenOrder[]>();
@@ -409,6 +424,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 ───────────────────────────────────────────────────
@@ -579,6 +595,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; }
@@ -718,6 +735,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,
@@ -728,7 +751,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);
@@ -1066,6 +1089,124 @@ 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, 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<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 readAt = Date.now();
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
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);
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} 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;
}
// 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;
}
}
/** 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) {
@@ -1505,14 +1646,23 @@ export class VestClient implements PlatformClient {
private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
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 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.
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; 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;
@@ -1526,8 +1676,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;
@@ -1551,12 +1704,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(gateAt, 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` : ''),
+ (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),
@@ -1568,16 +1723,21 @@ export class VestClient implements PlatformClient {
spreadFraction: spread,
minContracts: 1,
marginRate: spec.marginRate || undefined,
closesAt: gateAt ?? 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!);
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.
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);
@@ -1755,6 +1915,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) {