Files
autofirmer-expanded/lib/instrument-specs.ts
T
Brandon LiandClaude Opus 5.5 96fdd9ca23 Vest platform: a client for Vest Markets, traded on a session the extension relays
Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy
perps on CME hours, plus stocks, FX and crypto. It joins as a fourth
platform, 'vest', on the DXtrade engine paths. Those are now keyed by
isNotionalPlatform() instead of === 'dxtrade': the firm's own symbols,
percent fees, notional sizing from volatility, and a scheduler lane of its
own.

- Login is relayed, not stored. Vest signs people in with Google through
  Privy and renews the session with a device key the browser won't export.
  Extension 0.17.0's vest-relay.js reads the token the Vest app renews for
  itself (~15 min). It renews nothing, so the tab is never logged out. The
  background script posts that token to POST /api/vest/session, which holds
  it in memory only. With no Vest tab open on a machine that trades Vest,
  the extension opens one.
- The firm's credential is the Vest login's wallet address. The dashboard's
  form offers the relayed wallet with one click.
- VestClient (lib/vest-class.ts), mapped from the web app's traffic and code
  since Vest publishes no API:
  - Reads the capital accounts, balances (free amount plus position
    collateral), positions and open orders with the user token.
  - Mints per-account tokens for orders and transactions.
  - Names accounts for their rules (V50K-E-D3-T10-34, V50K-F-D3-34,
    V25K-I-7) so account-type prefixes can match.
  - Takes Vest's own max-drawdown and daily-loss floors. dayStartBalance =
    floor + the type's limit, so the engine's room is never more than
    Vest's.
  - Uses each symbol's taker fee and margin rate from exchangeInfo, and
    hourly volatility over in-session hours only.
  - Places take-profit and stop as position legs, which Vest cancels as an
    OCO pair.
- Daily P&L and fund rows come from /v3/transactions by Vest's 20:00 ET
  day. A books check holds an account, naming the transaction types, when
  the balance and the transactions disagree.
- scripts/check-vest.mjs (in npm run check): Vest's day across DST, account
  names, parsing, volatility gaps, market hours, the session store.

Not yet run against Vest with a live session: the order and exit bodies
come from the app's code, and the transaction classification from its enum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:19:31 -05:00

294 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Contract specs for the CME futures the bot trades, and the pure maths of
* choosing between them — the session-aware Auto draw, the 12-hour hold cap,
* the legs that always trade a micro — plus reading a firm's instrument list
* off the catalog.
*
* Specs live here, not on the master: point value and tick size size real
* orders, so they aren't editable remotely. The master only knows the names.
*
* No imports: scripts/check-instruments.mjs loads this file directly.
*/
export interface CmeSpec {
name: string;
/** Dollars per 1.0 of price, per contract. */
pointValue: number;
tickSize: number;
/** Typical bid/ask spread in ticks — a liquid front month is one tick
* wide. Used when no live quote is at hand (lib/live-quotes.ts). */
spreadTicks: number;
/** Yahoo's continuous-contract ticker, for hourly candles. */
yahoo: string;
micro?: boolean;
}
const spec = (name: string, pointValue: number, tickSize: number, yahoo: string, micro = false): CmeSpec =>
({ name, pointValue, tickSize, spreadTicks: 1, yahoo, ...(micro ? { micro } : {}) });
export const CME_SPECS: Readonly<Record<string, CmeSpec>> = {
NQ: spec('Nasdaq-100', 20, 0.25, 'NQ=F'),
MNQ: spec('Micro Nasdaq-100', 2, 0.25, 'MNQ=F', true),
ES: spec('S&P 500', 50, 0.25, 'ES=F'),
MES: spec('Micro S&P 500', 5, 0.25, 'MES=F', true),
YM: spec('Dow', 5, 1, 'YM=F'),
MYM: spec('Micro Dow', 0.5, 1, 'MYM=F', true),
RTY: spec('Russell 2000', 50, 0.1, 'RTY=F'),
M2K: spec('Micro Russell 2000', 5, 0.1, 'M2K=F', true),
GC: spec('Gold', 100, 0.1, 'GC=F'),
MGC: spec('Micro Gold', 10, 0.1, 'MGC=F', true),
SI: spec('Silver', 5000, 0.005, 'SI=F'),
CL: spec('Crude Oil', 1000, 0.01, 'CL=F'),
MCL: spec('Micro Crude Oil', 100, 0.01, 'MCL=F', true),
NG: spec('Natural Gas', 10000, 0.001, 'NG=F'),
ZB: spec('30-Year T-Bond', 1000, 1 / 32, 'ZB=F'),
ZN: spec('10-Year T-Note', 1000, 1 / 64, 'ZN=F'),
ZF: spec('5-Year T-Note', 1000, 1 / 128, 'ZF=F'),
'6E': spec('Euro FX', 125000, 0.00005, '6E=F'),
'6J': spec('Japanese Yen', 12500000, 0.0000005, '6J=F'),
'6B': spec('British Pound', 62500, 0.0001, '6B=F'),
};
export const CME_ROOTS: readonly string[] = Object.keys(CME_SPECS);
/** $ per point, per contract — the table the engine has always used. */
export const POINT_VALUES: Readonly<Record<string, number>> = Object.fromEntries(
CME_ROOTS.map((root) => [root, CME_SPECS[root].pointValue]),
);
/** Each full-size contract's micro. */
export const MICRO_OF: Readonly<Record<string, string>> = { NQ: 'MNQ', ES: 'MES', YM: 'MYM', RTY: 'M2K', GC: 'MGC', CL: 'MCL' };
export function tickValue(root: string): number {
const s = CME_SPECS[root];
return s ? s.tickSize * s.pointValue : NaN;
}
/** One contract crossing the typical spread, in dollars. */
export function spreadDollars(root: string): number {
const s = CME_SPECS[root];
return s ? s.spreadTicks * s.tickSize * s.pointValue : NaN;
}
/** The most an entry may pay to cross the spread, per contract, when the
* master's catalog doesn't say (its Settings page sets it). */
export const DEFAULT_MAX_ENTRY_SPREAD = 15;
/** Whether a spread is over the entry limit. A limit of 0 or less is off. */
export function spreadTooWide(spreadDollars: number, maxDollars: number): boolean {
return maxDollars > 0 && Number.isFinite(spreadDollars) && spreadDollars > maxDollars + 1e-9;
}
/** Hold cap: an instrument whose next leg is expected to take this long or
* longer is out of the draw. */
export const MAX_HOLD_HOURS = 12;
/** Every drawable instrument's minimum share at the session's open; it fades
* to nothing by the last entry. */
export const DRAW_FLOOR = 0.10;
/** How hard the end of the session leans on the fastest instruments. */
export const LATE_SHARPNESS = 8;
/** Hourly bars in a CME trading day (23 of 24 hours trade). */
export const BARS_PER_DAY = 23;
const MIN_CLOSES = 25;
/**
* One contract's typical daily move (1σ), in dollars, from hourly closes
* oldest first: σ of the hourly log returns × √(bars per day) × the last
* price × the point value. null on too few closes.
*/
export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDay = BARS_PER_DAY): number | null {
const clean = closes.filter((c) => Number.isFinite(c) && c > 0);
if (clean.length < MIN_CLOSES || !(pointValue > 0)) return null;
const r: number[] = [];
for (let i = 1; i < clean.length; i++) r.push(Math.log(clean[i] / clean[i - 1]));
const mean = r.reduce((s, x) => s + x, 0) / r.length;
const sd = Math.sqrt(r.reduce((s, x) => s + (x - mean) ** 2, 0) / (r.length - 1));
return sd * Math.sqrt(barsPerDay) * clean[clean.length - 1] * pointValue;
}
/** Contracts a futures leg trades: one per $1,000, capped by the account
* type's max contracts and the firm's limit on the instrument (0 = no
* cap) — the rule fireLeg sizes by. */
export function futuresContracts(leg: number, maxPositionSize: number, instrumentLimit = 0): number {
const raw = Math.max(1, Math.ceil(leg / 1000));
const caps = [maxPositionSize, instrumentLimit].filter((c) => c > 0);
return caps.length > 0 ? Math.min(raw, ...caps) : raw;
}
/** The most contracts a firm allows on one instrument, as its catalog entry
* says: CME roots only, whole numbers from 1 to 1000. Anything else is
* dropped — no limit beyond the account type's max contracts. */
export function sanitizeInstrumentLimits(raw: unknown): Record<string, number> {
const out: Record<string, number> = {};
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out;
for (const [root, value] of Object.entries(raw as Record<string, unknown>)) {
if (CME_SPECS[root] && typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000) out[root] = value;
}
return out;
}
/**
* Expected hours for a leg to reach its take-profit or stop, as a fair walk:
* (A / move)(B / move) days, with the move scaled by the contracts. A
* stopless leg is taken as B = A. null without a move.
*/
export function expectedHoldHours(o: { leg: number; stopDollars: number; contracts: number; dailyMove: number | null }): number | null {
if (o.dailyMove === null || !(o.dailyMove > 0) || !(o.leg > 0)) return null;
const move = o.dailyMove * Math.max(1, o.contracts);
const b = Number.isFinite(o.stopDollars) && o.stopDollars > 0 ? o.stopDollars : o.leg;
return (o.leg / move) * (b / move) * 24;
}
function centralMinutes(now: Date): { day: string; minutes: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
return { day: get('weekday'), minutes: (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10) };
}
/**
* How far through the trading session `now` is: 0 at the open, 1 at the last
* entry. full_cme runs 17:45 → 14:55 CT the next day (trading-logic's
* CME_EVENING_OPEN_MIN — no imports here); equity_hours 8:35 → 14:55. null
* outside the session.
*/
export function sessionProgress(now: Date, mode: string): number | null {
const { day, minutes } = centralMinutes(now);
if (mode === 'equity_hours') {
if (day === 'Sat' || day === 'Sun' || minutes < 515 || minutes > 895) return null;
return (minutes - 515) / (895 - 515);
}
const open = 17 * 60 + 45;
const length = 24 * 60 - open + 14 * 60 + 55; // 21h10m
const since = (minutes - open + 1440) % 1440;
if (since > length) return null;
if (day === 'Sat' || (day === 'Fri' && minutes > 895) || (day === 'Sun' && minutes < open)) return null;
return since / length;
}
export interface DrawCandidate {
symbol: string;
spreadDollars: number;
/** One contract's daily move in dollars; null = not measured. */
dailyMove: number | null;
/** The next leg's expected hold on it; null = unknown. */
holdHours: number | null;
micro?: boolean;
/** Its live spread is over the entry limit right now. */
wide?: boolean;
}
/**
* Each instrument's share of the Auto draw at session progress `p`.
*
* Efficiency = daily move ÷ spread: movement per dollar paid to cross the
* spread (commissions don't count). Speed ∝ move², since a fair walk's hold
* time goes with 1/move². With q = p², the weight slides from efficiency
* early to a sharp preference for the fastest late:
* weight = efficiency^(1−q) × move^(sharpness·q)
* and every drawable instrument gets a floor of DRAW_FLOOR × (1−q).
*
* Out of the draw: anything whose expected hold is MAX_HOLD_HOURS or more
* (`disabled`), anything whose live spread is over the entry limit (`wide`),
* and micros unless nothing else is left. An unmeasured instrument gets the
* median weight; if none are measured, the draw is even.
*/
export function drawChances(
candidates: DrawCandidate[],
p: number | null,
opts: { floor?: number; sharpness?: number; maxHoldHours?: number } = {},
): { chances: { symbol: string; chance: number }[]; disabled: string[]; wide: string[] } {
const floorBase = opts.floor ?? DRAW_FLOOR;
const sharpness = opts.sharpness ?? LATE_SHARPNESS;
const maxHold = opts.maxHoldHours ?? MAX_HOLD_HOURS;
const progress = p === null ? 0 : Math.min(1, Math.max(0, p));
const q = progress * progress;
const disabled = candidates.filter((c) => c.holdHours !== null && c.holdHours >= maxHold).map((c) => c.symbol);
const wide = candidates.filter((c) => c.wide === true).map((c) => c.symbol);
let pool = candidates.filter((c) => !disabled.includes(c.symbol) && !wide.includes(c.symbol));
if (pool.some((c) => !c.micro)) pool = pool.filter((c) => !c.micro);
if (pool.length === 0) return { chances: [], disabled, wide };
const logWeight = (c: DrawCandidate): number | null => (c.dailyMove !== null && c.dailyMove > 0 && c.spreadDollars > 0
? (1 - q) * Math.log(c.dailyMove / c.spreadDollars) + sharpness * q * Math.log(c.dailyMove)
: null);
const measured = pool.map(logWeight).filter((w): w is number => w !== null).sort((a, b) => a - b);
const median = measured.length === 0 ? 0 : measured.length % 2
? measured[(measured.length - 1) / 2]
: (measured[measured.length / 2 - 1] + measured[measured.length / 2]) / 2;
const logs = pool.map((c) => logWeight(c) ?? median);
const top = Math.max(...logs);
const weights = logs.map((l) => Math.exp(l - top));
const total = weights.reduce((s, w) => s + w, 0);
const n = pool.length;
const floor = Math.min(floorBase * (1 - q), 1 / n);
return {
chances: pool.map((c, i) => ({ symbol: c.symbol, chance: floor + (1 - n * floor) * (weights[i] / total) })),
disabled,
wide,
};
}
/** The instrument a uniform random number in [0, 1) lands on. */
export function pickByChance(chances: { symbol: string; chance: number }[], rand: number): string | null {
let acc = 0;
for (const c of chances) {
acc += c.chance;
if (rand < acc) return c.symbol;
}
return chances.length > 0 ? chances[chances.length - 1].symbol : null;
}
/** What every futures firm's drain and extra-day legs trade. */
export const HOUSEKEEPING_ROOT = 'MNQ';
/**
* What a firm's drain and extra-day legs trade: always MNQ — tiny targets
* belong on a micro, whatever the firm lists for its full-size legs. null
* for an empty list: that firm trades nothing.
*/
export function housekeepingRoot(listed: readonly string[]): string | null {
return listed.some((r) => CME_SPECS[r]) ? HOUSEKEEPING_ROOT : null;
}
const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
/** A catalog list, cleaned for the firm's family: CME roots this build has
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
// Notional platforms (DXtrade, Vest) list their own symbols. Spelled out
// here: this file has no imports (see master-protocol isNotionalPlatform).
const futures = platform !== 'dxtrade' && platform !== 'vest';
const out: string[] = [];
for (const value of raw) {
if (typeof value !== 'string') continue;
const symbol = futures ? value.trim().toUpperCase() : value.trim();
const ok = futures ? Boolean(CME_SPECS[symbol]) : DXTRADE_SYMBOL.test(symbol);
if (ok && !out.includes(symbol)) out.push(symbol);
}
return out;
}
/**
* The instruments a firm trades. The master's list when the catalog carries
* one; otherwise (a master from before the lists) the old rules: a futures
* firm trades this machine's enabled instruments less its banned symbols; a
* DXtrade firm every symbol in its map, the '*' row first.
*/
export function resolveAllowlist(
firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> },
localEnabled: readonly string[],
): { roots: string[]; source: 'master' | 'legacy' } {
if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' };
if (firm.platform === 'dxtrade' || firm.platform === 'vest') {
const map = firm.symbolMap ?? {};
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
return { roots: sanitizeInstruments(firm.platform, keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
}
const banned = new Set(firm.bannedSymbols ?? []);
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
}