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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5ec5abde6e
commit
96fdd9ca23
+10
-8
@@ -23,6 +23,7 @@ import {
|
||||
} from './trading-logic';
|
||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
||||
import type { LaneTick, TickReason } from './master-protocol';
|
||||
import { isNotionalPlatform } from './master-protocol';
|
||||
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
|
||||
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
||||
import { recordEntry } from './trade-mix';
|
||||
@@ -310,10 +311,11 @@ interface FirmClient { firm: TradableFirm; client: PlatformClient }
|
||||
* multi-hour crypto position never holds up the futures open and a stuck
|
||||
* futures position never freezes a 24/7 firm's weekend. Every CME-futures
|
||||
* firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all
|
||||
* firms traded before lanes existed; each DXTrade firm is a lane of its own.
|
||||
* firms traded before lanes existed; each DXTrade or Vest firm is a lane of
|
||||
* its own.
|
||||
*/
|
||||
export function laneOf(firm: { id: string; platform: string }): string {
|
||||
return firm.platform === 'dxtrade' ? firm.id : FUTURES_LANE;
|
||||
return isNotionalPlatform(firm.platform) ? firm.id : FUTURES_LANE;
|
||||
}
|
||||
const FUTURES_LANE = 'futures';
|
||||
|
||||
@@ -455,9 +457,9 @@ function releaseTradeMutex(lease: number, lane: string): void {
|
||||
|
||||
/** What a firm's tiny legs (drain, extra-day) trade: a futures firm the
|
||||
* micro of what it lists (MNQ when NQ is listed, as always — see
|
||||
* housekeepingRoot); a DXtrade firm its current pick. */
|
||||
* housekeepingRoot); a DXtrade or Vest firm its current pick. */
|
||||
function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string {
|
||||
if (firm.platform === 'dxtrade') return '*';
|
||||
if (isNotionalPlatform(firm.platform)) return '*';
|
||||
return housekeepingRoot(firmInstruments(firm)) ?? HOUSEKEEPING_ROOT;
|
||||
}
|
||||
|
||||
@@ -1830,11 +1832,11 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
||||
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
|
||||
prefix: cfg.prefix, dllRoom: room,
|
||||
};
|
||||
// The instrument: a DXtrade firm ranks its list; a futures firm joins the
|
||||
// held root, else trades an explicit scheduler symbol it lists, else
|
||||
// draws from its list.
|
||||
// The instrument: a DXtrade or Vest firm ranks its list; a futures
|
||||
// firm joins the held root, else trades an explicit scheduler symbol
|
||||
// it lists, else draws from its list.
|
||||
let symbol = '*';
|
||||
if (firm.platform !== 'dxtrade') {
|
||||
if (!isNotionalPlatform(firm.platform)) {
|
||||
const listed = firmInstruments(firm);
|
||||
const state = getState();
|
||||
const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null;
|
||||
|
||||
+9
-1
@@ -1,6 +1,7 @@
|
||||
import { TradovateClient } from './tradovate-class';
|
||||
import { TopstepXClient } from './topstepx-class';
|
||||
import { DXTradeClient } from './dxtrade-class';
|
||||
import { VestClient } from './vest-class';
|
||||
import type { FirmPlatform } from './master-protocol';
|
||||
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||
@@ -102,6 +103,11 @@ export function initClient(
|
||||
case 'tradovate':
|
||||
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
|
||||
break;
|
||||
case 'vest':
|
||||
// The saved "API key" is the Vest login's wallet: whose session,
|
||||
// relayed by the extension, this client trades.
|
||||
client = new VestClient({ firmId: id, firmName, wallet: creds.apiKey }, onSyncComplete);
|
||||
break;
|
||||
default: {
|
||||
const unknown: never = platform;
|
||||
throw new Error(`no client for platform ${String(unknown)}`);
|
||||
@@ -376,10 +382,12 @@ const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
||||
* and fixing the credentials (or the firm's settings) rebuilds them through
|
||||
* reconcileClients anyway. TopstepX with an API key but no username never
|
||||
* tries to log in; DXTrade with no server URL can't, and after refused
|
||||
* credentials it stops trying so as not to lock the account. */
|
||||
* credentials it stops trying so as not to lock the account; Vest waits on
|
||||
* the extension to relay a signed-in tab's session. */
|
||||
function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean {
|
||||
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
|
||||
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
|
||||
if (platform === 'vest') return (client as VestClient).needsAttention === true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+10
-6
@@ -17,7 +17,8 @@
|
||||
* the two are not linked yet.)
|
||||
*/
|
||||
|
||||
import { isFirmPlatform } from './master-protocol';
|
||||
import { isFirmPlatform, isNotionalPlatform } from './master-protocol';
|
||||
import { isVestWalletAddress } from './vest-helpers';
|
||||
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
|
||||
import { normalizeAccountType } from './kept-rules';
|
||||
import type {
|
||||
@@ -170,7 +171,8 @@ export function getCatalogMeta(): {
|
||||
}
|
||||
|
||||
/** Whether stored credentials are what the firm's platform logs in with — a
|
||||
* TopstepX API key, or a username + password (Tradovate, DXTrade). */
|
||||
* TopstepX API key, a username + password (Tradovate, DXTrade), or for Vest
|
||||
* the login's wallet address (its session is relayed by the extension). */
|
||||
export function hasPlatformCredentials(
|
||||
platform: FirmPlatform,
|
||||
creds: { username: string; password: string; apiKey: string } | null | undefined,
|
||||
@@ -178,6 +180,7 @@ export function hasPlatformCredentials(
|
||||
if (!creds) return false;
|
||||
switch (platform) {
|
||||
case 'topstepx': return !!creds.apiKey;
|
||||
case 'vest': return isVestWalletAddress(creds.apiKey);
|
||||
case 'tradovate':
|
||||
case 'dxtrade': return !!creds.username && !!creds.password;
|
||||
}
|
||||
@@ -296,9 +299,10 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||
return;
|
||||
}
|
||||
// Percent commission and every-day trading are DXtrade settings;
|
||||
// futures firms keep measured per-contract fees and the CME calendar.
|
||||
const dx = platform === 'dxtrade';
|
||||
// Percent commission and every-day trading are settings of the
|
||||
// notional platforms (DXtrade, Vest); futures firms keep measured
|
||||
// per-contract fees and the CME calendar.
|
||||
const dx = isNotionalPlatform(platform);
|
||||
const tradesEveryDay = dx && firm.tradesEveryDay === true;
|
||||
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||
|
||||
@@ -317,7 +321,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
// The master's list when it sends one; absent from an older
|
||||
// master, whose bannedSymbols / symbolMap then apply.
|
||||
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||
...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
||||
...(!dx ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||
accountTypes,
|
||||
...(keptAccountRules ? { keptAccountRules } : {}),
|
||||
|
||||
@@ -259,7 +259,9 @@ const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
|
||||
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
|
||||
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const futures = platform !== 'dxtrade';
|
||||
// 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;
|
||||
@@ -281,10 +283,10 @@ export function resolveAllowlist(
|
||||
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') {
|
||||
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('dxtrade', keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
|
||||
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' };
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ import { getCachedContract } from './contract-resolver';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { errorMessage } from './platform-client';
|
||||
import type { InstrumentStats } from './master-protocol';
|
||||
import { isNotionalPlatform } from './master-protocol';
|
||||
import {
|
||||
CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
|
||||
drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate,
|
||||
@@ -56,7 +57,7 @@ export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' {
|
||||
}
|
||||
|
||||
export function isFuturesFirm(firm: { platform: string }): boolean {
|
||||
return firm.platform !== 'dxtrade';
|
||||
return !isNotionalPlatform(firm.platform);
|
||||
}
|
||||
|
||||
/** Every root a futures firm on this machine lists, in CME order — what the
|
||||
|
||||
@@ -409,13 +409,21 @@ export interface KeptAccountRules extends CatalogAccountType {
|
||||
|
||||
/** Where a firm's accounts trade. A build only knows the platforms listed
|
||||
* here; the catalog sync skips a firm on any other rather than guess. */
|
||||
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const;
|
||||
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade', 'vest'] as const;
|
||||
export type FirmPlatform = (typeof FIRM_PLATFORMS)[number];
|
||||
|
||||
export function isFirmPlatform(value: unknown): value is FirmPlatform {
|
||||
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Platforms whose firms trade their own symbols by notional (DXtrade's
|
||||
* CFDs, Vest's perps) rather than CME contracts: the firm's instrument list
|
||||
* is platform symbols, fees are a percent of notional, and each firm is a
|
||||
* scheduler lane of its own. */
|
||||
export function isNotionalPlatform(platform: string | undefined): boolean {
|
||||
return platform === 'dxtrade' || platform === 'vest';
|
||||
}
|
||||
|
||||
/** One symbol a DXTrade firm may trade for a CME root. Position size comes
|
||||
* from volatility and the firm's target hold, and the commission is the
|
||||
* firm's (an older master's notionalPerContract or per-symbol commissionPct
|
||||
|
||||
@@ -281,6 +281,17 @@ export function brokerFacts(
|
||||
if (platform === 'topstepx') {
|
||||
return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) };
|
||||
}
|
||||
if (platform === 'vest') {
|
||||
// Vest's capital account status is a number; 2 is the live one
|
||||
// (vest-helpers VEST_STATUS_ACTIVE). A breached account leaves the
|
||||
// active list, so no status here reads as terminal.
|
||||
const status = meta?.status;
|
||||
return {
|
||||
inactiveReason: status ? `status:${status}` : 'inactive',
|
||||
...(status && status !== '2' ? { brokerStatus: `status:${status}` } : {}),
|
||||
...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}),
|
||||
};
|
||||
}
|
||||
const status = meta?.status || undefined;
|
||||
return {
|
||||
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@ import { currentTradeDateCT } from './trading-logic';
|
||||
import { rootOfContract } from './contract-resolver';
|
||||
import { errorMessage } from './platform-client';
|
||||
import type { InstrumentCount, TradeMix } from './master-protocol';
|
||||
import { isNotionalPlatform } from './master-protocol';
|
||||
|
||||
const TODAY_KEY = 'trade_mix_today';
|
||||
const NAMES_KEY = 'trade_mix_contracts';
|
||||
@@ -78,7 +79,7 @@ export function tradeMixFor(
|
||||
const own = typeof client.contractRoots === 'function' ? (client.contractRoots() as Map<string, string>) : null;
|
||||
for (const [id, root] of own ?? []) names.set(id, root);
|
||||
for (const [id, instrument] of Object.entries(readJson<Record<string, string>>(NAMES_KEY) ?? {})) names.set(id, instrument);
|
||||
const instrumentOf = (contractId: unknown): string => (firm.platform === 'dxtrade'
|
||||
const instrumentOf = (contractId: unknown): string => (isNotionalPlatform(firm.platform)
|
||||
? String(contractId).split('|')[0]
|
||||
: names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`);
|
||||
|
||||
|
||||
+1342
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
|
||||
* answers, naming accounts, Vest's trading day, market hours, volatility.
|
||||
*
|
||||
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
|
||||
* directly (node --experimental-strip-types).
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/** A Vest login's primary wallet: 0x + 40 hex. The instance's saved Vest
|
||||
* "credential" is this address — it says whose relayed session to trade. */
|
||||
export function isVestWalletAddress(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Stable numeric id for a Vest account uuid, in the same range as DXtrade's
|
||||
* (dxAccountId) but keyed apart, so every numeric account table keeps
|
||||
* working. */
|
||||
export function vestAccountId(uuid: string): number {
|
||||
const digest = createHash('sha256').update(`vest|${uuid.toLowerCase()}`).digest();
|
||||
return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4);
|
||||
}
|
||||
|
||||
/** A decimal string or number from the gateway → a number (undefined when
|
||||
* missing or not a number). */
|
||||
export function num(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** Epoch ms from the gateway's epoch ms, epoch seconds or ISO strings; NaN
|
||||
* when unreadable. */
|
||||
export function toMs(raw: unknown): number {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw < 1e11 ? raw * 1000 : raw;
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) return n < 1e11 ? n * 1000 : n;
|
||||
const t = Date.parse(raw);
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** `value` on a decimal grid, as the gateway wants it: a plain string with at
|
||||
* most `decimals` places, trailing zeros trimmed (never exponent notation). */
|
||||
export function formatDecimal(value: number, decimals: number): string {
|
||||
const d = Math.max(0, Math.min(18, Math.floor(decimals)));
|
||||
const fixed = value.toFixed(d);
|
||||
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
|
||||
}
|
||||
|
||||
// ── Vest's trading day ──────────────────────────────────────────────────────
|
||||
|
||||
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York', hourCycle: 'h23',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit',
|
||||
});
|
||||
|
||||
/** Vest's daily reset hour, America/New_York (its docs: 20:00 ET). */
|
||||
export const VEST_RESET_HOUR_ET = 20;
|
||||
|
||||
/**
|
||||
* The trading day a moment belongs to, YYYY-MM-DD. Vest's day runs 20:00 ET
|
||||
* to 20:00 ET and is named for the date it ends on, like the CME session
|
||||
* that opens the evening before — so 21:00 ET on the 26th is the 27th's day.
|
||||
*/
|
||||
export function vestDayOf(ms: number): string {
|
||||
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
if (Number(parts.hour) < VEST_RESET_HOUR_ET) return date;
|
||||
const next = new Date(`${date}T12:00:00Z`);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
return next.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The last 20:00 ET at or before `now`, epoch ms. 20:00 ET is 00:00 UTC
|
||||
* in summer and 01:00 UTC in winter; whichever of those hours, on this or
|
||||
* an earlier UTC day, reads as 20:00 in New York is the boundary. */
|
||||
export function lastVestResetMs(now: number): number {
|
||||
const day = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
|
||||
for (let back = 0; back <= 2; back++) {
|
||||
for (const hour of [1, 0]) {
|
||||
const t = day - back * 86_400_000 + hour * 3_600_000;
|
||||
if (t > now) continue;
|
||||
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(t)).map((p) => [p.type, p.value]));
|
||||
if (Number(parts.hour) === VEST_RESET_HOUR_ET) return t;
|
||||
}
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
// ── accounts ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VestCapitalAccount {
|
||||
id: string;
|
||||
planId: string;
|
||||
planName: string;
|
||||
/** 'eval' | 'instant_funded' | … as the gateway names the product. */
|
||||
productType: string;
|
||||
/** 1-based stage; stages[stage-1] is the current one. */
|
||||
stage: number;
|
||||
stepsTotal: number;
|
||||
/** The current stage: 'eval' or 'funded'. */
|
||||
kind: string;
|
||||
status: number;
|
||||
attemptIndex: number;
|
||||
initialCapital: number;
|
||||
/** The current stage's profit target in dollars (0 = none). */
|
||||
profitTarget: number;
|
||||
targetEquity: number | undefined;
|
||||
maxDrawdownLimit: number | undefined;
|
||||
maxDailyLossPct: number;
|
||||
dailyResetBalance: number | undefined;
|
||||
dailyLossFloor: number | undefined;
|
||||
dailyResetAt: number;
|
||||
activatedAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/capital/accounts/active; null when it isn't one. */
|
||||
export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||||
const a = raw as Record<string, unknown>;
|
||||
if (!a || typeof a.id !== 'string') return null;
|
||||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||||
const stage = num(a.stage) ?? 1;
|
||||
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
||||
return {
|
||||
id: a.id,
|
||||
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
||||
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
||||
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
||||
stage,
|
||||
stepsTotal: num(a.steps_total) ?? 0,
|
||||
kind: typeof current.kind === 'string' ? current.kind : '',
|
||||
status: num(a.status) ?? -1,
|
||||
attemptIndex: num(a.attempt_index) ?? 0,
|
||||
initialCapital: num(a.initial_capital) ?? 0,
|
||||
profitTarget: num(current.profit_target) ?? 0,
|
||||
targetEquity: num(a.target_equity),
|
||||
maxDrawdownLimit: num(a.max_drawdown_limit),
|
||||
maxDailyLossPct: num(a.max_daily_loss_pct) ?? 0,
|
||||
dailyResetBalance: num(a.daily_reset_balance),
|
||||
dailyLossFloor: num(a.daily_loss_floor),
|
||||
dailyResetAt: toMs(a.daily_reset_at),
|
||||
activatedAt: toMs(a.activated_at),
|
||||
createdAt: toMs(a.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** The capital account status every listed live account showed (2026-10-26).
|
||||
* Anything else is treated as unable to trade until seen and named. */
|
||||
export const VEST_STATUS_ACTIVE = 2;
|
||||
|
||||
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
||||
|
||||
/**
|
||||
* What the dashboard calls the account and what account-type prefixes match
|
||||
* against. Vest itself only says "Account 34", so the name carries the rules
|
||||
* that tell its types apart:
|
||||
* V50K-E-D3-T10-34 evaluation, $50k, 3% daily loss, 10% target, attempt 34
|
||||
* V50K-F-D3-34 the funded stage of an evaluation account
|
||||
* V25K-I-7 instant funded (no daily loss limit, no target)
|
||||
* D0 = no daily loss limit. The target is the current stage's (a 2-Step's
|
||||
* second stage reads T5).
|
||||
*/
|
||||
export function vestAccountName(a: VestCapitalAccount): string {
|
||||
const size = a.initialCapital >= 1000 ? `${+(a.initialCapital / 1000).toFixed(1)}K` : `${Math.round(a.initialCapital)}`;
|
||||
const dll = `D${pct(a.maxDailyLossPct)}`;
|
||||
if (/instant/i.test(a.productType)) return `V${size}-I-${a.attemptIndex}`;
|
||||
if (a.kind === 'funded') return `V${size}-F-${dll}-${a.attemptIndex}`;
|
||||
const target = a.initialCapital > 0 && a.profitTarget > 0 ? `-T${pct(a.profitTarget / a.initialCapital)}` : '';
|
||||
return `V${size}-E-${dll}${target}-${a.attemptIndex}`;
|
||||
}
|
||||
|
||||
// ── positions and orders ────────────────────────────────────────────────────
|
||||
|
||||
export interface VestLeg {
|
||||
id: string;
|
||||
triggerPrice: number;
|
||||
executionType: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface VestPosition {
|
||||
positionId: string;
|
||||
accountId: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number;
|
||||
/** Margin the position holds out of the free balance. */
|
||||
collateral: number;
|
||||
leverage: number;
|
||||
openedAt: number;
|
||||
stopLosses: VestLeg[];
|
||||
takeProfits: VestLeg[];
|
||||
}
|
||||
|
||||
function parseLegs(raw: unknown): VestLeg[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.flatMap((l) => {
|
||||
const leg = l as Record<string, unknown>;
|
||||
const trigger = num(leg?.triggerPrice);
|
||||
if (typeof leg?.id !== 'string' || trigger === undefined) return [];
|
||||
return [{ id: leg.id, triggerPrice: trigger, executionType: String(leg.executionType ?? ''), status: String(leg.status ?? '') }];
|
||||
});
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/positions/opened; null when it isn't an open one. */
|
||||
export function parsePosition(raw: unknown): VestPosition | null {
|
||||
const p = raw as Record<string, unknown>;
|
||||
const accountId = p?.accountId ?? p?.account_id;
|
||||
if (!p || typeof p.positionId !== 'string' || typeof accountId !== 'string' || typeof p.symbol !== 'string') return null;
|
||||
const quantity = Math.abs(num(p.quantity) ?? 0);
|
||||
if (!(quantity > 0)) return null;
|
||||
return {
|
||||
positionId: p.positionId,
|
||||
accountId,
|
||||
symbol: p.symbol,
|
||||
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||||
quantity,
|
||||
openPrice: num(p.openPrice) ?? NaN,
|
||||
collateral: num(p.collateral) ?? 0,
|
||||
leverage: num(p.leverage) ?? 0,
|
||||
openedAt: toMs(p.openDate ?? p.createdAt),
|
||||
stopLosses: parseLegs(p.stopLosses),
|
||||
takeProfits: parseLegs(p.takeProfits),
|
||||
};
|
||||
}
|
||||
|
||||
/** Exit legs still waiting to trigger. */
|
||||
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||
}
|
||||
|
||||
export interface VestOpenOrder {
|
||||
orderId: string;
|
||||
accountId: string;
|
||||
positionId: string | undefined;
|
||||
symbol: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/positions/opened-orders (snake or camel case). */
|
||||
export function parseOpenOrder(raw: unknown): VestOpenOrder | null {
|
||||
const o = raw as Record<string, unknown>;
|
||||
const orderId = o?.order_id ?? o?.orderId ?? o?.id;
|
||||
const accountId = o?.account_id ?? o?.accountId;
|
||||
if (typeof orderId !== 'string' || typeof accountId !== 'string') return null;
|
||||
const positionId = o.position_id ?? o.positionId;
|
||||
return {
|
||||
orderId,
|
||||
accountId,
|
||||
positionId: typeof positionId === 'string' ? positionId : undefined,
|
||||
symbol: String(o.symbol ?? ''),
|
||||
createdAt: toMs(o.created_at ?? o.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Unrealized P&L of a position at `mark`. */
|
||||
export function unrealizedPnl(p: Pick<VestPosition, 'side' | 'quantity' | 'openPrice'>, mark: number): number {
|
||||
if (!Number.isFinite(mark) || !Number.isFinite(p.openPrice)) return 0;
|
||||
return (p.side === 'long' ? 1 : -1) * p.quantity * (mark - p.openPrice);
|
||||
}
|
||||
|
||||
// ── transactions ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The gateway's transaction types (its app's own enum, 2026-10-26). Trading
|
||||
* P&L is fees, realized P&L, funding and the adjustments that come with them;
|
||||
* funds are money moved in or out; the rest only moves margin between the
|
||||
* free balance and positions, and leaves the account's total alone.
|
||||
*/
|
||||
export const VEST_TXN = {
|
||||
DEPOSIT: 1, WITHDRAW: 2, OPEN_POSITION: 3, CLOSE_POSITION: 4, CORRECTION: 5, FILL_MARGIN: 6, MARGIN_RELEASE: 7,
|
||||
FEE: 8, REALIZED_PNL: 9, FUNDING: 10, LEVERAGE_RESERVE: 11, LEVERAGE_RELEASE: 12, ADD_COLLATERAL: 13,
|
||||
REMOVE_COLLATERAL: 14, ORDER_MARGIN_RESERVE: 15, ORDER_MARGIN_RELEASE: 16, LIQUIDATION_DEFICIT_COVERAGE: 17,
|
||||
CORPORATE_ACTION_CASH_ADJUSTMENT: 18, FUNDED_PROFIT_CREDIT: 19, AFFILIATE_PAYOUT_CREDIT: 20, COMPENSATION: 22,
|
||||
BALANCE_MIGRATION: 23,
|
||||
} as const;
|
||||
|
||||
const TRADING_TXNS = new Set<number>([
|
||||
VEST_TXN.CORRECTION, VEST_TXN.FEE, VEST_TXN.REALIZED_PNL, VEST_TXN.FUNDING,
|
||||
VEST_TXN.LIQUIDATION_DEFICIT_COVERAGE, VEST_TXN.CORPORATE_ACTION_CASH_ADJUSTMENT,
|
||||
]);
|
||||
const FUND_TXNS = new Set<number>([
|
||||
VEST_TXN.DEPOSIT, VEST_TXN.WITHDRAW, VEST_TXN.FUNDED_PROFIT_CREDIT, VEST_TXN.AFFILIATE_PAYOUT_CREDIT,
|
||||
VEST_TXN.COMPENSATION, VEST_TXN.BALANCE_MIGRATION,
|
||||
]);
|
||||
|
||||
export type VestTxnKind = 'trade' | 'fund' | 'margin';
|
||||
|
||||
export function txnKind(type: number): VestTxnKind {
|
||||
if (TRADING_TXNS.has(type)) return 'trade';
|
||||
if (FUND_TXNS.has(type)) return 'fund';
|
||||
return 'margin';
|
||||
}
|
||||
|
||||
export interface VestTxn {
|
||||
/** action_id, else type|time|amount — what a page overlap dedupes on. */
|
||||
key: string;
|
||||
type: number;
|
||||
kind: VestTxnKind;
|
||||
/** Signed: what it did to the account's balance. */
|
||||
amount: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/transactions; null when unreadable. A withdrawal is
|
||||
* always negative and a deposit positive, whatever sign the gateway used. */
|
||||
export function parseTxn(raw: unknown): VestTxn | null {
|
||||
const t = raw as Record<string, unknown>;
|
||||
const type = num(t?.transaction_type ?? t?.transactionType);
|
||||
const amount = num(t?.amount);
|
||||
const time = toMs(t?.created_at ?? t?.createdAt);
|
||||
if (type === undefined || amount === undefined || !Number.isFinite(time)) return null;
|
||||
const signed = type === VEST_TXN.WITHDRAW ? -Math.abs(amount) : type === VEST_TXN.DEPOSIT ? Math.abs(amount) : amount;
|
||||
const action = t.action_id ?? t.actionId;
|
||||
return {
|
||||
key: typeof action === 'string' || typeof action === 'number' ? `${action}|${type}` : `${type}|${time}|${amount}`,
|
||||
type,
|
||||
kind: txnKind(type),
|
||||
amount: signed,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
/** Daily trading P&L and fund rows by Vest day, from the account's
|
||||
* transactions. */
|
||||
export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; fees: number }>; funds: Map<string, number> } {
|
||||
const daily = new Map<string, { pnl: number; fees: number }>();
|
||||
const funds = new Map<string, number>();
|
||||
for (const t of txns) {
|
||||
const day = vestDayOf(t.time);
|
||||
if (t.kind === 'fund') {
|
||||
funds.set(day, (funds.get(day) ?? 0) + t.amount);
|
||||
} else if (t.kind === 'trade') {
|
||||
const e = daily.get(day) ?? { pnl: 0, fees: 0 };
|
||||
e.pnl += t.amount;
|
||||
if (t.type === VEST_TXN.FEE) e.fees += Math.abs(t.amount);
|
||||
daily.set(day, e);
|
||||
}
|
||||
}
|
||||
return { daily, funds };
|
||||
}
|
||||
|
||||
// ── market data ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fewest hourly returns a volatility is measured from. */
|
||||
export const MIN_HOURLY_RETURNS = 24;
|
||||
|
||||
/**
|
||||
* σ of log returns per √hour from hourly klines ([openTime, o, h, l, close, …],
|
||||
* any order). Only returns between consecutive hours count: an index perp
|
||||
* closes for weekends and the daily maintenance hour, and a return across
|
||||
* such a gap is a gap, not an hour's move. null when too few are left.
|
||||
*/
|
||||
export function hourlyVolatility(klines: unknown[]): number | null {
|
||||
const rows = klines.flatMap((k) => {
|
||||
if (!Array.isArray(k)) return [];
|
||||
const t = num(k[0]);
|
||||
const close = num(k[4]);
|
||||
return t !== undefined && close !== undefined && close > 0 ? [{ t: toMs(t), close }] : [];
|
||||
}).sort((a, b) => a.t - b.t);
|
||||
const returns: number[] = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (Math.abs(rows[i].t - rows[i - 1].t - 3_600_000) > 60_000) continue;
|
||||
returns.push(Math.log(rows[i].close / rows[i - 1].close));
|
||||
}
|
||||
if (returns.length < MIN_HOURLY_RETURNS) return null;
|
||||
const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
|
||||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the market-hours periods (GET /v4/market-hours/{assetId}) have the
|
||||
* symbol open for trading at `now`, and for how long yet. A symbol with no
|
||||
* periods at all (crypto: always open) reads as open with no end. A closure
|
||||
* (weekly, the daily maintenance hour) counts as closed even where the
|
||||
* gateway still says OPEN: no leg should start into one.
|
||||
*/
|
||||
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
||||
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
||||
const list = periods.flatMap((p) => {
|
||||
const r = p as {
|
||||
fromMs?: unknown; toMs?: unknown;
|
||||
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
||||
};
|
||||
const from = num(r?.fromMs);
|
||||
const to = num(r?.toMs);
|
||||
if (from === undefined || to === undefined) return [];
|
||||
const closure = r.state?.closure;
|
||||
const open = r.state?.tradingStatus === 'OPEN' && r.state?.price?.allow !== false
|
||||
&& (closure === undefined || closure === 'unspecified');
|
||||
return [{ from, to, open }];
|
||||
}).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;
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Vest sessions relayed by the extension.
|
||||
*
|
||||
* Vest logs a person in through Google/Privy in the browser, and refreshes
|
||||
* the session with a device key the browser will not export, so this
|
||||
* machine cannot log in by itself. Instead the AutoFirmer extension watches
|
||||
* the signed-in Vest tab: whenever the Vest app renews its own ~15-minute
|
||||
* access token, the extension posts it here (POST /api/vest/session). The
|
||||
* Vest client (lib/vest-class.ts) trades with the latest one for its wallet
|
||||
* and mints per-account tokens from it.
|
||||
*
|
||||
* Kept in memory only — never in the DB, the logs, a report or a backup. A
|
||||
* restart forgets them; the Vest tab's next renewal (or the extension's
|
||||
* next tick) relays a fresh one within minutes.
|
||||
*/
|
||||
|
||||
export interface VestSession {
|
||||
accessToken: string;
|
||||
/** Epoch ms the access token stops working. */
|
||||
expiresAt: number;
|
||||
/** The wallet the Vest login belongs to, lower-case. */
|
||||
primaryAddr: string;
|
||||
userId: string;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
/** What the dashboard and the extension may see of a session: never the token. */
|
||||
export interface VestSessionInfo {
|
||||
primaryAddr: string;
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { __vestSessions?: Map<string, VestSession> };
|
||||
|
||||
function store(): Map<string, VestSession> {
|
||||
return (g.__vestSessions ??= new Map());
|
||||
}
|
||||
|
||||
/** Shortest life a relayed token must have left to be taken. */
|
||||
const MIN_REMAINING_MS = 30_000;
|
||||
/** Longest a relayed token may claim to live — a Vest access token is
|
||||
* ~15 minutes; anything far beyond that is not one. */
|
||||
const MAX_REMAINING_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Take a relayed session. Returns why it was refused, or null. A token for
|
||||
* a wallet only ever replaces one that expires sooner. */
|
||||
export function putVestSession(input: { accessToken: unknown; expiresAt: unknown; primaryAddr: unknown; userId: unknown }, now = Date.now()): string | null {
|
||||
const token = typeof input.accessToken === 'string' ? input.accessToken.trim() : '';
|
||||
const expiresAt = Number(input.expiresAt);
|
||||
const addr = typeof input.primaryAddr === 'string' ? input.primaryAddr.trim().toLowerCase() : '';
|
||||
if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token) || token.length > 8192) return 'accessToken is not a JWT';
|
||||
if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address';
|
||||
if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired';
|
||||
if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away';
|
||||
const prev = store().get(addr);
|
||||
if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one
|
||||
store().set(addr, {
|
||||
accessToken: token,
|
||||
expiresAt,
|
||||
primaryAddr: addr,
|
||||
userId: typeof input.userId === 'string' ? input.userId : '',
|
||||
receivedAt: now,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The live session for a wallet, or null when none has been relayed or it
|
||||
* is about to expire. */
|
||||
export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemainingMs = MIN_REMAINING_MS): VestSession | null {
|
||||
const s = store().get(primaryAddr.trim().toLowerCase());
|
||||
return s && s.expiresAt - now >= minRemainingMs ? s : null;
|
||||
}
|
||||
|
||||
/** Drop a wallet's session — the gateway refused it. */
|
||||
export function dropVestSession(primaryAddr: string, token: string): void {
|
||||
const key = primaryAddr.trim().toLowerCase();
|
||||
if (store().get(key)?.accessToken === token) store().delete(key);
|
||||
}
|
||||
|
||||
/** Every relayed session, newest first, without tokens. */
|
||||
export function listVestSessions(): VestSessionInfo[] {
|
||||
return [...store().values()]
|
||||
.sort((a, b) => b.receivedAt - a.receivedAt)
|
||||
.map(({ primaryAddr, userId, expiresAt, receivedAt }) => ({ primaryAddr, userId, expiresAt, receivedAt }));
|
||||
}
|
||||
Reference in New Issue
Block a user