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:
co-authored by
Claude Opus 5.5
parent
beddbdcf6c
commit
ec3e6ea388
+137
-11
@@ -38,11 +38,11 @@
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import axios from './http-client';
|
||||
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from './trading-logic';
|
||||
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop, clearsClose, RESET_CLEAR_HOLDS } from './trading-logic';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import {
|
||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
|
||||
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, botOpenedPositions,
|
||||
} from './db';
|
||||
import { getCachedFirm } from './firm-catalog';
|
||||
import { firmInstruments } from './instruments';
|
||||
@@ -52,7 +52,7 @@ import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } f
|
||||
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
|
||||
import {
|
||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
||||
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
|
||||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION,
|
||||
} from './vest-helpers';
|
||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers';
|
||||
@@ -103,7 +103,8 @@ const NAKED_AFTER_MS = 2 * 60_000;
|
||||
* candidate is costed as crossing this many ticks. */
|
||||
const ASSUMED_SPREAD_TICKS = 1;
|
||||
/** How long before the daily reset open positions are fitted to the room
|
||||
* the reset will leave (guardReset). */
|
||||
* the reset will leave (guardReset) — and before a market closure that runs
|
||||
* past one, the bot's own legs are closed (guardClose). */
|
||||
const RESET_GUARD_MS = 5 * 60_000;
|
||||
/** No new position opens this close to the reset: the guard's window, and a
|
||||
* couple of minutes before it for an entry in flight to place its exits. */
|
||||
@@ -215,6 +216,13 @@ export class VestClient implements PlatformClient {
|
||||
noSessionLogged: false, unfittable: new Set<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;
|
||||
private capital = new Map<number, VestCapitalAccount>();
|
||||
private positionsDetail = new Map<number, VestPosition[]>();
|
||||
private openOrders = new Map<number, VestOpenOrder[]>();
|
||||
@@ -424,6 +432,7 @@ export class VestClient implements PlatformClient {
|
||||
const now = Date.now();
|
||||
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
|
||||
void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`));
|
||||
void this.guardClose().catch((err) => console.error(`${this.tag} market-close guard failed: ${errorMessage(err)}`));
|
||||
}
|
||||
|
||||
// ── public market data ───────────────────────────────────────────────────
|
||||
@@ -733,6 +742,12 @@ export class VestClient implements PlatformClient {
|
||||
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||
}
|
||||
|
||||
/** See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
|
||||
* which its daily rows, realized P&L (realizedSince) and floors follow. */
|
||||
public tradeDate(now: number): string {
|
||||
return vestDayOf(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* See PlatformClient: the gain since the day's base that is still in the
|
||||
* account — since this client's own claim today, else since the reset,
|
||||
@@ -743,7 +758,7 @@ export class VestClient implements PlatformClient {
|
||||
*/
|
||||
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
|
||||
const now = Date.now();
|
||||
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId));
|
||||
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId), this.tradeDate(now));
|
||||
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
|
||||
if (!(allowed > 0)) return 0;
|
||||
const a = this.capital.get(accountId);
|
||||
@@ -1058,6 +1073,103 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a long market closure — one that keeps a symbol's market shut
|
||||
* across an 8 PM ET reset: the weekend, a holiday (longClosure) — close
|
||||
* the bot's own legs in it still open in the last RESET_GUARD_MS, while
|
||||
* the market takes orders. Inside the closure nothing exits, the reset
|
||||
* guard's stops and closes are refused, and the reopen's gap jumps a
|
||||
* stop. No leg starts under RESET_CLEAR_HOLDS holds of a close
|
||||
* (pickCandidate), so this only meets one that outlived them.
|
||||
*
|
||||
* Only positions the bot's own order opened (markOwn): one without that
|
||||
* mark — a hand trade, or one from before marking began — is left as it
|
||||
* is and named once in the log. The daily maintenance hour reopens
|
||||
* before the reset, and is ridden.
|
||||
*/
|
||||
private async guardClose(): Promise<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 {
|
||||
const now = Date.now();
|
||||
for (const [id, since] of this.suspectAccounts) {
|
||||
@@ -1478,14 +1590,23 @@ export class VestClient implements PlatformClient {
|
||||
|
||||
private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<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 its symbol's market close (the weekend, the daily maintenance
|
||||
// hour) catches open can't be exited until the market reopens, and
|
||||
// meets the reopen's gap with its stop: none starts under
|
||||
// RESET_CLEAR_HOLDS holds of the close — the daily reset's clearance.
|
||||
const tooSoon = (closesAt: number | null, holdHours: number) =>
|
||||
closesAt !== null && !clearsClose({ closeInMs: closesAt - now, holdHours });
|
||||
const closingNote = (closesAt: number, holdHours: number) =>
|
||||
`closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`;
|
||||
const hold = ctx?.targetHoldHours ?? targetHold;
|
||||
|
||||
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
|
||||
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closesAt?: number | null }[] = [];
|
||||
// No new position this close to the reset: nothing picked, and the
|
||||
// last survey's candidates stay up for the dashboard.
|
||||
const toReset = nextVestResetMs(now) - now;
|
||||
@@ -1510,10 +1631,11 @@ export class VestClient implements PlatformClient {
|
||||
const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN;
|
||||
const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}`
|
||||
: !hours.open ? 'market closed'
|
||||
: !mark || now - mark.at > 5 * 60_000 ? 'no mark price'
|
||||
: !(spec.tick > 0) ? 'no tick size'
|
||||
: (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles`
|
||||
: null;
|
||||
: tooSoon(hours.closesAt, hold) ? closingNote(hours.closesAt!, hold)
|
||||
: !mark || now - mark.at > 5 * 60_000 ? 'no mark price'
|
||||
: !(spec.tick > 0) ? 'no tick size'
|
||||
: (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles`
|
||||
: null;
|
||||
if (rejection) { board.push({ symbol, score: null, note: rejection }); continue; }
|
||||
const pct = spec.takerFee > 0 ? spec.takerFee * 100 : firm?.commissionPct ?? 0;
|
||||
const score = scoreCandidate({
|
||||
@@ -1530,6 +1652,7 @@ export class VestClient implements PlatformClient {
|
||||
note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`
|
||||
+ (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '')
|
||||
+ (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
|
||||
closesAt: hours.closesAt,
|
||||
contract: {
|
||||
id: symbol,
|
||||
name: encodeContractName(symbol, step),
|
||||
@@ -1551,6 +1674,9 @@ export class VestClient implements PlatformClient {
|
||||
const ev = ctx.evaluate(b.contract!);
|
||||
if (!ev) { b.note += ', can\'t size'; return []; }
|
||||
b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`;
|
||||
// Held short of the target hold (a leverage cap), the leg
|
||||
// runs longer: it has to clear the close as well.
|
||||
if (tooSoon(b.closesAt ?? null, ev.holdHours)) { b.note += `, ${closingNote(b.closesAt!, ev.holdHours)}`; return []; }
|
||||
return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
|
||||
});
|
||||
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
|
||||
|
||||
Reference in New Issue
Block a user