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:
Brandon Li
2026-10-26 14:19:31 -05:00
co-authored by Claude Opus 5.5
parent 5ec5abde6e
commit 96fdd9ca23
22 changed files with 2520 additions and 28 deletions
+44
View File
@@ -203,6 +203,50 @@ Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for bro
sockets, and `directWebSocket` for control sockets. All share the same user agent; sockets, and `directWebSocket` for control sockets. All share the same user agent;
the broker proxy still applies only to broker traffic. the broker proxy still applies only to broker traffic.
## Vest firms (Vest Markets)
Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy perps
on CME hours, plus stocks, FX and crypto. Set it up on the master's **Firms**
page with platform **Vest**. It shares the DXtrade engine: its own symbols
(new firms start on `NDX-USD-PERP`, `SPX-USD-PERP`, `GC-PERP`, standing in for
NQ, ES and GC), notional sizing from volatility, percent fees, hard daily-loss
stops, and a scheduler lane of its own. Leave **Trades every day** off for the
index and metal perps: they close for weekends and 5–6 PM ET.
**Login is relayed, not stored.** Vest signs people in with Google (through
Privy) and renews the session with a device key the browser keeps, so this
instance cannot log in by itself. Instead:
1. In Chrome on this machine (with the AutoFirmer extension, 0.17.0+), open
next.vestmarkets.com and sign in. The person signs in once; Vest keeps the
session for 30 days.
2. The extension relays the tab's short-lived access token to this dashboard
(`POST /api/vest/session`, held in memory only) every time the Vest app
renews it. If no Vest tab is open, the extension opens one in the background.
3. On the dashboard, the Vest firm's credential is the **wallet address** of
that login. The credential form offers the relayed wallet with one click.
With no live session for the wallet, the client sits unauthenticated and says
so; it never tries to log in.
What the client reads from Vest (lib/vest-class.ts, mapped from the web app's
own traffic since Vest publishes no API):
- Accounts: every eval and funded account on the login, named for their rules,
e.g. `V50K-E-D3-T10-34` (50K eval, 3% daily loss, 10% target, attempt 34),
`V50K-F-D3-34` (funded) and `V25K-I-7` (instant). Account-type prefixes on the
master match those names.
- Floors: Vest states each account's max-drawdown floor (the auto-liq threshold
here) and daily-loss floor. The engine's daily room is never more than Vest
leaves.
- Fees and leverage: each symbol's taker fee (0.25 bp on the index and metal
perps) and margin rate, from Vest's exchange info. Set the firm's
**Commission %** to the same (`0.0025`) for the pre-trade checks.
- Exits: the take-profit and stop are legs of the position on Vest's side,
and Vest cancels the other when one triggers.
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
--- ---
## The master dashboard ## The master dashboard
+5 -1
View File
@@ -16,7 +16,9 @@ type Params = { params: Promise<{ id: string }> };
* and login domain are set on the master, per firm). TopstepX firms need the * and login domain are set on the master, per firm). TopstepX firms need the
* API key; username is optional for now (the loginKey call still wants it, * API key; username is optional for now (the loginKey call still wants it,
* so the client sits idle with a clear error until one is added) and * so the client sits idle with a clear error until one is added) and
* password is unused. */ * password is unused. Vest firms need the login's wallet address in the API
* key slot: Vest's session is relayed by the extension from a signed-in Vest
* tab (lib/vest-session.ts), and the address says whose to trade. */
export async function PUT(req: NextRequest, { params }: Params) { export async function PUT(req: NextRequest, { params }: Params) {
const { id } = await params; const { id } = await params;
@@ -33,6 +35,8 @@ export async function PUT(req: NextRequest, { params }: Params) {
? 'apiKey is required for a TopstepX firm' ? 'apiKey is required for a TopstepX firm'
: firm.platform === 'dxtrade' : firm.platform === 'dxtrade'
? 'username and password are required for a DXtrade firm' ? 'username and password are required for a DXtrade firm'
: firm.platform === 'vest'
? 'a Vest firm needs the wallet address of the Vest login (0x followed by 40 hex characters)'
: 'username and password are required'; : 'username and password are required';
return NextResponse.json({ error }, { status: 400 }); return NextResponse.json({ error }, { status: 400 });
} }
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest } from 'next/server';
import { putVestSession, listVestSessions } from '@/lib/vest-session';
import { getTradableFirms } from '@/lib/firm-catalog';
import { getClients } from '@/lib/clients';
import { corsJson, corsPreflight } from '../../autobuyer/cors';
/** The wallets this machine's Vest firms trade (their saved credential), lower-case. */
function wantedWallets(): string[] {
const wallets = getTradableFirms()
.filter((f) => f.platform === 'vest')
.map((f) => f.apiKey.trim().toLowerCase());
return [...new Set(wallets)];
}
/**
* The extension's Vest relay (lib/vest-session.ts): POST the access token the
* signed-in Vest tab just renewed — { accessToken, expiresAt, primaryAddr,
* userId } — and it is held in memory for this machine's Vest client. GET
* lists what has been relayed (never the tokens) and which wallets this
* machine wants, so the extension knows whether to keep a Vest tab open and
* the dashboard can offer the address as the firm's credential.
*
* Same LAN-only, any-origin CORS as the AutoBuyer routes: the extension's
* service worker calls it.
*/
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json() as Record<string, unknown>;
} catch {
return corsJson({ error: 'JSON body required' }, { status: 400 });
}
const refused = putVestSession({
accessToken: body.accessToken,
expiresAt: body.expiresAt,
primaryAddr: body.primaryAddr,
userId: body.userId,
});
if (refused) return corsJson({ ok: false, reason: refused }, { status: 400 });
const wanted = wantedWallets();
const addr = String(body.primaryAddr).toLowerCase();
// A client waiting on this wallet's session syncs now, not at its next poll.
for (const client of getClients().values()) {
const vest = client as { onVestSession?: (wallet: string) => void };
vest.onVestSession?.(addr);
}
return corsJson({ ok: true, used: wanted.includes(addr), wanted });
}
export async function GET() {
return corsJson({ sessions: listVestSessions(), wanted: wantedWallets() });
}
export async function OPTIONS() {
return corsPreflight();
}
+82 -2
View File
@@ -195,14 +195,89 @@ function credentialNoun(platform: FirmConfig['platform']): string {
case 'topstepx': return 'TopstepX API key'; case 'topstepx': return 'TopstepX API key';
case 'dxtrade': return 'DXtrade login'; case 'dxtrade': return 'DXtrade login';
case 'tradovate': return 'Tradovate login'; case 'tradovate': return 'Tradovate login';
case 'vest': return 'Vest wallet address';
} }
} }
/** Inline credential entry, shown to the right of a firm that has none. /** Inline credential entry, shown to the right of a firm that has none.
* Tradovate and DXtrade want username + password; TopstepX wants the API key * Tradovate and DXtrade want username + password; TopstepX wants the API key
* (username optional for now, though login needs it eventually — password * (username optional for now, though login needs it eventually — password
* unused). */ * unused); Vest the login's wallet address, whose session the extension
* relays from a signed-in Vest tab. */
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) { function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
if (firm.platform === 'vest') return <VestWalletForm firm={firm} onSaved={onSaved} />;
return <LoginForm firm={firm} onSaved={onSaved} />;
}
/** A Vest firm's credential: the wallet its Vest login belongs to. The
* extension has relayed the session of any Vest tab signed in on this
* machine, so those wallets are offered with one click. */
function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const [wallet, setWallet] = useState('');
const [seen, setSeen] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/vest/session', { cache: 'no-store' })
.then((r) => r.json())
.then((d: { sessions?: { primaryAddr: string }[] }) => setSeen([...new Set((d.sessions ?? []).map((x) => x.primaryAddr))]))
.catch(() => setSeen([]));
}, []);
async function save(address: string) {
setSaving(true);
setError('');
try {
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: address.trim() }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
setWallet('');
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
} finally {
setSaving(false);
}
}
const valid = /^0x[0-9a-fA-F]{40}$/.test(wallet.trim());
return (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{seen.map((address) => (
<button
key={address}
onClick={() => void save(address)}
disabled={saving}
title="The wallet of the Vest tab signed in on this machine"
className="px-2 py-1 bg-emerald-100 hover:bg-emerald-200 disabled:opacity-50 text-emerald-800 text-xs font-mono rounded-md"
>
Use {address.slice(0, 6)}…{address.slice(-4)}
</button>
))}
<input
className="border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-72 font-mono"
placeholder={seen.length > 0 ? 'or another wallet 0x…' : 'Vest wallet 0x… (sign in to Vest in Chrome first)'}
value={wallet}
onChange={(e) => setWallet(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && valid) void save(wallet); }}
/>
<button
onClick={() => void save(wallet)}
disabled={saving || !valid}
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
>
{saving ? '…' : 'Save'}
</button>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
);
}
function LoginForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const isTopstepX = firm.platform === 'topstepx'; const isTopstepX = firm.platform === 'topstepx';
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -503,7 +578,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="font-bold text-sm text-slate-800">{firm.firm}</span> <span className="font-bold text-sm text-slate-800">{firm.firm}</span>
{firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && ( {firm.platform !== 'dxtrade' && firm.platform !== 'vest' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out"> <span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
{tradeSymbol} not listed {tradeSymbol} not listed
</span> </span>
@@ -518,6 +593,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
DXtrade DXtrade
</span> </span>
)} )}
{firm.platform === 'vest' && (
<span className="text-xs bg-violet-100 text-violet-700 px-1.5 py-0.5 rounded font-semibold" title="Trades through the session the extension relays from a signed-in Vest tab">
Vest
</span>
)}
{firm.platform === 'topstepx' && hasCredentials && !firm.username && ( {firm.platform === 'topstepx' && hasCredentials && !firm.username && (
<span <span
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold" className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
+25
View File
@@ -79,6 +79,31 @@ A new 2FA site needs its host in `host_permissions` (not the captcha
`content_scripts` list), a Site link on its firm on the master, and its codes `content_scripts` list), a Site link on its firm on the master, and its codes
assigned on the master's Codes page. assigned on the master's Codes page.
## Vest session relay
Vest signs people in with Google (through Privy) in the browser and renews the
session with a device key the browser will not export, so the dashboard cannot
log in to Vest by itself. The extension relays the browser's session instead:
```
vest-relay.js (Vest tab, MAIN world) keeps the access token the Vest app last renewed
background.js relayVestSession lifts it every 10s, posts it when it changes
dashboard POST /api/vest/session holds it in memory for the firm's Vest client
```
- The hook only reads the Vest app's own `/v3/auth/refresh` and `/register`
answers. It renews nothing and sends nothing; the session stays the app's,
so the bot never logs the tab out.
- The token lives ~15 minutes and the app renews it by itself while the tab is
open. The tab is marked not auto-discardable, since a discarded tab stops
renewing.
- When the dashboard has a Vest firm (its credential is the login's wallet)
and no Vest tab is open, the extension opens one in the background. The app
signs back in from its 30-day session. After that the person signs in again
in that tab.
- This is the one place the extension reads a tab you are not looking at. It
only reads `next.vestmarkets.com` tabs, and only that one value.
## Notes ## Notes
- The extension never captures the dashboard's own pages — otherwise it would - The extension never captures the dashboard's own pages — otherwise it would
+106
View File
@@ -12,6 +12,9 @@
* ready at all times; a run happens because someone asked for one. * ready at all times; a run happens because someone asked for one.
* *
* On a firm site, the person's 2FA code for it shows in a corner chip. * On a firm site, the person's 2FA code for it shows in a corner chip.
*
* A signed-in Vest tab's session is relayed to the dashboard, whose Vest client
* trades with it (relayVestSession).
*/ */
const DEFAULTS = { const DEFAULTS = {
@@ -1185,6 +1188,109 @@ async function tick() {
} catch (err) { } catch (err) {
await setState({ connected: true, error: `2FA chip: ${err.message}` }); await setState({ connected: true, error: `2FA chip: ${err.message}` });
} }
try {
await relayVestSession(cfg);
} catch (err) {
await setState({ connected: true, vest: `Vest relay: ${err.message}` });
}
}
// ── Vest: relay the signed-in tab's session to the dashboard ────────────────
//
// Vest signs people in through Google in the browser and renews the session
// with a device key the browser won't hand out, so the dashboard cannot log in
// by itself. vest-relay.js, in the Vest tab, keeps the latest access token the
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
// the dashboard whenever it changes. Nothing here renews or signs anything: the
// session stays the app's.
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
const VEST_EVERY_MS = 10_000;
/** A Vest tab opened for the relay isn't opened again for this long. */
const VEST_REOPEN_MS = 10 * 60_000;
let vestLastCheck = 0;
let vestLastRelayed = '';
let vestOpenedAt = 0;
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
* it has life left. Self-contained. */
function pageVestSession() {
const s = window.__afVestSession;
return { hooked: window.__afVestHook === true, session: s && s.expiresAt > Date.now() + 30000 ? s : null };
}
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
async function fetchVestWanted(cfg) {
try {
const res = await fetch(`${cfg.apiBase}/api/vest/session`, { cache: 'no-store' });
if (!res.ok) return [];
const body = await res.json();
return Array.isArray(body.wanted) ? body.wanted : [];
} catch {
return [];
}
}
async function relayVestSession(cfg) {
const now = Date.now();
if (now - vestLastCheck < VEST_EVERY_MS) return;
vestLastCheck = now;
const tabs = (await chrome.tabs.query({ url: VEST_PATTERN })).filter((t) => /^https:/.test(t.url || ''));
if (tabs.length === 0) {
// This machine trades Vest: open the app in the background. It signs
// itself back in from its 30-day session, and the hook catches that.
const wanted = await fetchVestWanted(cfg);
if (wanted.length === 0) return;
if (now - vestOpenedAt > VEST_REOPEN_MS) {
vestOpenedAt = now;
await chrome.tabs.create({ url: VEST_URL, active: false });
await setState({ vest: 'Opened a Vest tab to relay its session' });
} else {
await setState({ vest: 'No Vest tab open' });
}
return;
}
let found = null;
for (const tab of tabs) {
// A discarded tab stops renewing the session; keep Chrome from it.
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id }, world: 'MAIN', func: pageVestSession,
}).catch(() => [null]);
const out = injection?.result;
if (out && !out.hooked) {
// A tab open from before this build: hook it now, and its app's
// next renewal is caught.
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
}
if (out?.session) { found = out.session; break; }
}
if (!found) {
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
return;
}
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
if (fingerprint === vestLastRelayed) return;
const res = await fetch(`${cfg.apiBase}/api/vest/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessToken: found.accessToken, expiresAt: found.expiresAt, primaryAddr: found.primaryAddr, userId: found.userId,
}),
});
const body = await res.json().catch(() => ({}));
if (res.status === 404) throw new Error('this dashboard is too old for the Vest relay');
if (!res.ok) throw new Error(`dashboard refused the session: ${body.reason || body.error || `HTTP ${res.status}`}`);
vestLastRelayed = fingerprint;
const short = `${found.primaryAddr.slice(0, 6)}…${found.primaryAddr.slice(-4)}`;
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
await setState({ vest: `Relayed ${short}${body.used ? '' : ' (no Vest firm here uses this wallet)'} · good ${mins}m` });
} }
// ── 2FA: the person's authenticator code for the firm site you are on ─────── // ── 2FA: the person's authenticator code for the firm site you are on ───────
+9 -2
View File
@@ -1,18 +1,19 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "AutoFirmer Capture", "name": "AutoFirmer Capture",
"version": "0.16.0", "version": "0.17.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
"update_url": "https://ext.juicerroom.com/updates.xml", "update_url": "https://ext.juicerroom.com/updates.xml",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, and shows the 2FA code for the firm site you are on.", "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, shows the 2FA code for the firm site you are on, and relays a signed-in Vest tab's session to the dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"], "permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [ "host_permissions": [
"https://*.tradeify.co/*", "https://*.tradeify.co/*",
"https://*.lucidtrading.com/*", "https://*.lucidtrading.com/*",
"https://*.topstep.com/*", "https://*.topstep.com/*",
"https://*.apextraderfunding.com/*", "https://*.apextraderfunding.com/*",
"https://*.vestmarkets.com/*",
"http://localhost/*", "http://localhost/*",
"http://127.0.0.1/*" "http://127.0.0.1/*"
], ],
@@ -29,6 +30,12 @@
"run_at": "document_start", "run_at": "document_start",
"all_frames": true, "all_frames": true,
"world": "MAIN" "world": "MAIN"
},
{
"matches": ["https://next.vestmarkets.com/*"],
"js": ["vest-relay.js"],
"run_at": "document_start",
"world": "MAIN"
} }
], ],
"action": { "action": {
+1
View File
@@ -29,6 +29,7 @@ async function render() {
document.getElementById('status').innerHTML = document.getElementById('status').innerHTML =
`<span class="dot" style="background:${color}"></span><strong>${label}</strong>` + `<span class="dot" style="background:${color}"></span><strong>${label}</strong>` +
detail + detail +
(state.vest ? `<div class="muted">Vest: ${esc(state.vest)}</div>` : '') +
(state.error ? `<div class="err">${esc(state.error)}</div>` : ''); (state.error ? `<div class="err">${esc(state.error)}</div>` : '');
} }
+68
View File
@@ -0,0 +1,68 @@
/* AutoFirmer — Vest session hook.
*
* Runs in the page's MAIN world on next.vestmarkets.com, at document_start
* (manifest content script) or injected later into a tab that was already open
* (background.js relayVestSession). Installs once per page.
*
* Vest's app renews its own ~15-minute access token by itself: at load, then
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
* sign-in). Those answers carry the token, its expiry and the login's wallet.
* This hook reads them off the app's own XHRs and keeps the latest on
* `window.__afVestSession`, where background.js collects it and relays it to
* the local dashboard, whose Vest client trades with it.
*
* It sends nothing anywhere, changes no request and never renews anything
* itself: the session stays the app's, refreshed by the app with its own
* device key, so the tab is never logged out by the bot.
*/
(() => {
if (window.__afVestHook) return;
window.__afVestHook = true;
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
function take(url, status, text) {
if (status < 200 || status >= 300 || !AUTH.test(url)) return;
let body;
try { body = JSON.parse(text); } catch { return; }
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
const expiresAt = Number(body.accessExpiresAtMs);
if (!Number.isFinite(expiresAt)) return;
window.__afVestSession = {
accessToken: body.accessToken,
expiresAt,
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
userId: typeof body.userId === 'string' ? body.userId : '',
at: Date.now(),
};
}
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
try { this.__afVestUrl = String(url); } catch { /* ignore */ }
return open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (this.__afVestUrl && AUTH.test(this.__afVestUrl)) {
this.addEventListener('loadend', () => {
try {
if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText);
else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response));
} catch { /* never break the app */ }
});
}
return send.apply(this, arguments);
};
const fetch0 = window.fetch;
window.fetch = async function (input) {
const res = await fetch0.apply(this, arguments);
try {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t)).catch(() => {});
} catch { /* never break the app */ }
return res;
};
})();
+10 -8
View File
@@ -23,6 +23,7 @@ import {
} from './trading-logic'; } from './trading-logic';
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db'; import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
import type { LaneTick, TickReason } from './master-protocol'; 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 { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
import { recordEntry } from './trade-mix'; 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 * 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 * futures position never freezes a 24/7 firm's weekend. Every CME-futures
* firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all * 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 { 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'; 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 /** 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 * 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 { 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; 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, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
prefix: cfg.prefix, dllRoom: room, prefix: cfg.prefix, dllRoom: room,
}; };
// The instrument: a DXtrade firm ranks its list; a futures firm joins the // The instrument: a DXtrade or Vest firm ranks its list; a futures
// held root, else trades an explicit scheduler symbol it lists, else // firm joins the held root, else trades an explicit scheduler symbol
// draws from its list. // it lists, else draws from its list.
let symbol = '*'; let symbol = '*';
if (firm.platform !== 'dxtrade') { if (!isNotionalPlatform(firm.platform)) {
const listed = firmInstruments(firm); const listed = firmInstruments(firm);
const state = getState(); const state = getState();
const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null; const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null;
+9 -1
View File
@@ -1,6 +1,7 @@
import { TradovateClient } from './tradovate-class'; import { TradovateClient } from './tradovate-class';
import { TopstepXClient } from './topstepx-class'; import { TopstepXClient } from './topstepx-class';
import { DXTradeClient } from './dxtrade-class'; import { DXTradeClient } from './dxtrade-class';
import { VestClient } from './vest-class';
import type { FirmPlatform } from './master-protocol'; import type { FirmPlatform } from './master-protocol';
import { errorMessage, isClientReady, type PlatformClient } from './platform-client'; import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
import { getTradableFirms, startCatalogSync } from './firm-catalog'; import { getTradableFirms, startCatalogSync } from './firm-catalog';
@@ -102,6 +103,11 @@ export function initClient(
case 'tradovate': case 'tradovate':
client = new TradovateClient(creds.username, creds.password, onSyncComplete); client = new TradovateClient(creds.username, creds.password, onSyncComplete);
break; 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: { default: {
const unknown: never = platform; const unknown: never = platform;
throw new Error(`no client for platform ${String(unknown)}`); 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 * and fixing the credentials (or the firm's settings) rebuilds them through
* reconcileClients anyway. TopstepX with an API key but no username never * 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 * 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 { function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean {
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? ''); if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true; if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
if (platform === 'vest') return (client as VestClient).needsAttention === true;
return false; return false;
} }
+10 -6
View File
@@ -17,7 +17,8 @@
* the two are not linked yet.) * 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 { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
import { normalizeAccountType } from './kept-rules'; import { normalizeAccountType } from './kept-rules';
import type { import type {
@@ -170,7 +171,8 @@ export function getCatalogMeta(): {
} }
/** Whether stored credentials are what the firm's platform logs in with — a /** 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( export function hasPlatformCredentials(
platform: FirmPlatform, platform: FirmPlatform,
creds: { username: string; password: string; apiKey: string } | null | undefined, creds: { username: string; password: string; apiKey: string } | null | undefined,
@@ -178,6 +180,7 @@ export function hasPlatformCredentials(
if (!creds) return false; if (!creds) return false;
switch (platform) { switch (platform) {
case 'topstepx': return !!creds.apiKey; case 'topstepx': return !!creds.apiKey;
case 'vest': return isVestWalletAddress(creds.apiKey);
case 'tradovate': case 'tradovate':
case 'dxtrade': return !!creds.username && !!creds.password; case 'dxtrade': return !!creds.username && !!creds.password;
} }
@@ -296,9 +299,10 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
warnUnknownPlatform(firm.id, firm.name, platform); warnUnknownPlatform(firm.id, firm.name, platform);
return; return;
} }
// Percent commission and every-day trading are DXtrade settings; // Percent commission and every-day trading are settings of the
// futures firms keep measured per-contract fees and the CME calendar. // notional platforms (DXtrade, Vest); futures firms keep measured
const dx = platform === 'dxtrade'; // per-contract fees and the CME calendar.
const dx = isNotionalPlatform(platform);
const tradesEveryDay = dx && firm.tradesEveryDay === true; const tradesEveryDay = dx && firm.tradesEveryDay === true;
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`); 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 // The master's list when it sends one; absent from an older
// master, whose bannedSymbols / symbolMap then apply. // master, whose bannedSymbols / symbolMap then apply.
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}), ...(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[]) : [], bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
accountTypes, accountTypes,
...(keptAccountRules ? { keptAccountRules } : {}), ...(keptAccountRules ? { keptAccountRules } : {}),
+5 -3
View File
@@ -259,7 +259,9 @@ const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
* specs for, or valid DXtrade symbols. Deduplicated, in order. */ * specs for, or valid DXtrade symbols. Deduplicated, in order. */
export function sanitizeInstruments(platform: string, raw: unknown): string[] { export function sanitizeInstruments(platform: string, raw: unknown): string[] {
if (!Array.isArray(raw)) return []; 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[] = []; const out: string[] = [];
for (const value of raw) { for (const value of raw) {
if (typeof value !== 'string') continue; if (typeof value !== 'string') continue;
@@ -281,10 +283,10 @@ export function resolveAllowlist(
localEnabled: readonly string[], localEnabled: readonly string[],
): { roots: string[]; source: 'master' | 'legacy' } { ): { roots: string[]; source: 'master' | 'legacy' } {
if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' }; 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 map = firm.symbolMap ?? {};
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b))); 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 ?? []); const banned = new Set(firm.bannedSymbols ?? []);
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' }; return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
+2 -1
View File
@@ -27,6 +27,7 @@ import { getCachedContract } from './contract-resolver';
import { currentTradeDateCT } from './trading-logic'; import { currentTradeDateCT } from './trading-logic';
import { errorMessage } from './platform-client'; import { errorMessage } from './platform-client';
import type { InstrumentStats } from './master-protocol'; import type { InstrumentStats } from './master-protocol';
import { isNotionalPlatform } from './master-protocol';
import { import {
CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress, CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate, 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 { 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 /** Every root a futures firm on this machine lists, in CME order — what the
+9 -1
View File
@@ -409,13 +409,21 @@ export interface KeptAccountRules extends CatalogAccountType {
/** Where a firm's accounts trade. A build only knows the platforms listed /** 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. */ * 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 type FirmPlatform = (typeof FIRM_PLATFORMS)[number];
export function isFirmPlatform(value: unknown): value is FirmPlatform { export function isFirmPlatform(value: unknown): value is FirmPlatform {
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value); 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 /** 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 * from volatility and the firm's target hold, and the commission is the
* firm's (an older master's notionalPerContract or per-symbol commissionPct * firm's (an older master's notionalPerContract or per-symbol commissionPct
+11
View File
@@ -281,6 +281,17 @@ export function brokerFacts(
if (platform === 'topstepx') { if (platform === 'topstepx') {
return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) }; 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; const status = meta?.status || undefined;
return { return {
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive', inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
+2 -1
View File
@@ -16,6 +16,7 @@ import { currentTradeDateCT } from './trading-logic';
import { rootOfContract } from './contract-resolver'; import { rootOfContract } from './contract-resolver';
import { errorMessage } from './platform-client'; import { errorMessage } from './platform-client';
import type { InstrumentCount, TradeMix } from './master-protocol'; import type { InstrumentCount, TradeMix } from './master-protocol';
import { isNotionalPlatform } from './master-protocol';
const TODAY_KEY = 'trade_mix_today'; const TODAY_KEY = 'trade_mix_today';
const NAMES_KEY = 'trade_mix_contracts'; 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; 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, root] of own ?? []) names.set(id, root);
for (const [id, instrument] of Object.entries(readJson<Record<string, string>>(NAMES_KEY) ?? {})) names.set(id, instrument); 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] ? String(contractId).split('|')[0]
: names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`); : names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`);
+1342
View File
File diff suppressed because it is too large Load Diff
+407
View File
@@ -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 };
}
+87
View File
@@ -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 }));
}
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs" "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs"
}, },
"dependencies": { "dependencies": {
"@microsoft/signalr": "^10.0.11", "@microsoft/signalr": "^10.0.11",
+227
View File
@@ -0,0 +1,227 @@
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the
// relayed-session store (lib/vest-session.ts).
//
// npm run check
//
// Both 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
// shaped like the gateway's real answers (captured 2026-10-26).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS,
} from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts';
const iso = (s) => Date.parse(s);
test('wallet addresses and account ids', () => {
assert.equal(isVestWalletAddress('0x7ce10771819f80130e204ddb5ca1522e48a9b451'), true);
assert.equal(isVestWalletAddress('0x7CE10771819F80130E204DDB5CA1522E48A9B451'), true);
assert.equal(isVestWalletAddress('0x7ce1'), false);
assert.equal(isVestWalletAddress(''), false);
const id = vestAccountId('843f60e2-e013-5a0e-8b6c-face84bc5509');
assert.equal(id, vestAccountId('843F60E2-E013-5A0E-8B6C-FACE84BC5509'));
assert.notEqual(id, vestAccountId('102566b2-515a-51c5-9ad7-4a7eae0a6a2a'));
assert.ok(id >= 2 ** 50 && Number.isSafeInteger(id));
});
test('numbers, times and decimals as the gateway writes them', () => {
assert.equal(num('49968.511115'), 49968.511115);
assert.equal(num(''), undefined);
assert.equal(num(null), undefined);
assert.equal(toMs(1793035089), 1793035089000);
assert.equal(toMs(1793035089277), 1793035089277);
assert.equal(toMs('2026-10-26T18:41:02.886Z'), iso('2026-10-26T18:41:02.886Z'));
assert.ok(Number.isNaN(toMs(undefined)));
assert.equal(formatDecimal(29554.5, 2), '29554.5');
assert.equal(formatDecimal(50, 2), '50');
assert.equal(formatDecimal(3 * 0.0001, 4), '0.0003');
assert.equal(formatDecimal(42.65449999, 4), '42.6545');
assert.equal(formatDecimal(1e-7, 4), '0');
});
test("Vest's day turns at 20:00 New York, summer and winter", () => {
// EDT (UTC−4): 20:00 ET is 00:00 UTC.
assert.equal(vestDayOf(iso('2026-10-26T23:59:00Z')), '2026-10-26');
assert.equal(vestDayOf(iso('2026-10-27T00:00:00Z')), '2026-10-27');
assert.equal(vestDayOf(iso('2026-10-26T22:05:00Z')), '2026-10-26'); // the bots' 6:05 PM ET open
// EST (UTC−5): 20:00 ET is 01:00 UTC.
assert.equal(vestDayOf(iso('2026-12-01T00:30:00Z')), '2026-11-30');
assert.equal(vestDayOf(iso('2026-12-01T01:00:00Z')), '2026-12-01');
// Month and year ends.
assert.equal(vestDayOf(iso('2026-12-31T23:00:00Z')), '2026-12-31');
assert.equal(vestDayOf(iso('2027-01-01T01:30:00Z')), '2027-01-01');
assert.equal(lastVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-26T00:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-27T00:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-11-30T01:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-12-01T01:00:01Z')), iso('2026-12-01T01:00:00Z'));
// Across the November switch back (Nov 1 2026, 2 AM ET).
assert.equal(lastVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-01T00:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-11-02T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
});
const evalAccount = {
payment_id: 'tailoredpay:vest-markets:production:12727557177',
id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
plan_id: '86bb03f9-7422-513c-8d5c-c546cb8d221d',
account_type: 2, stage: 1, status: 2,
initial_capital: '50000.00000000', max_drawdown_pct: '0.060000', max_leverage: '5.0000', max_profit_split_pct: '0.8000',
target_equity: '55000.00000000', max_drawdown_limit: '47000.00000000', max_daily_loss_pct: '0.030000',
daily_reset_balance: '50000.00000000000000', daily_reset_equity: '50000.00000000000000',
daily_reset_base_limit: '1500.000000000000000', daily_loss_floor: '48500.00000000000000', daily_reset_at: 1793035043842,
origin: 'eval_purchase', plan_name: 'Vest Capital Evaluation Fee | 10% Goal | 3% Daily Loss | 80/20 | Emerald',
plan_product_type: 'eval', step: 1, steps_total: 1, root_account_id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
stages: [
{ ordinal: 1, kind: 'eval', initial_capital: '50000.00000000', profit_target: '5000.00000000', max_drawdown_pct: '0.060000' },
{ ordinal: 2, kind: 'funded', initial_capital: '50000.00000000', profit_target: '', max_drawdown_pct: '0.060000' },
],
attempt_index: 34, activated_at: 1793035043834, created_at: 1793035043756, updated_at: 1793035043843,
};
test('capital accounts parse, and are named for their rules', () => {
const a = parseCapitalAccount(evalAccount);
assert.equal(a.id, evalAccount.id);
assert.equal(a.kind, 'eval');
assert.equal(a.status, 2);
assert.equal(a.initialCapital, 50000);
assert.equal(a.profitTarget, 5000);
assert.equal(a.maxDrawdownLimit, 47000);
assert.equal(a.dailyLossFloor, 48500);
assert.equal(a.maxDailyLossPct, 0.03);
assert.equal(a.dailyResetAt, 1793035043842);
assert.equal(vestAccountName(a), 'V50K-E-D3-T10-34');
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, stage: 2 })), 'V50K-F-D3-34');
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, max_daily_loss_pct: '0' })), 'V50K-E-D0-T10-34');
assert.equal(vestAccountName(parseCapitalAccount({
...evalAccount, initial_capital: '5000.00000000', attempt_index: 12, stage: 2, steps_total: 2,
stages: [
{ ordinal: 1, kind: 'eval', profit_target: '500.00000000' },
{ ordinal: 2, kind: 'eval', profit_target: '250.00000000' },
{ ordinal: 3, kind: 'funded', profit_target: '' },
],
})), 'V5K-E-D3-T5-12');
assert.equal(vestAccountName(parseCapitalAccount({
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '25000.00000000', max_daily_loss_pct: '0', attempt_index: 7,
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
})), 'V25K-I-7');
assert.equal(vestAccountName(parseCapitalAccount({
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '500.00000000', attempt_index: 1,
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
})), 'V500-I-1');
assert.equal(parseCapitalAccount({ nope: true }), null);
});
const openPosition = {
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
openDate: 1793035089277, pnl: '0', fee: '31.488885', funding: '-5.423271000000000000', collateral: '24944.027426', leverage: '50',
createdAt: 1793035089244, updatedAt: 1793037621104,
stopLosses: [{ id: 'e22311d2-fbd9-4ab8-b65f-c2921f58fefc', triggerPrice: '29554.500000000', sizeMode: 'full_position', executionType: 'market', status: 'awaiting' }],
takeProfits: [],
};
test('positions, their legs and unrealized P&L', () => {
const p = parsePosition(openPosition);
assert.equal(p.side, 'short');
assert.equal(p.quantity, 42.6545);
assert.equal(p.collateral, 24944.027426);
assert.equal(p.openedAt, 1793035089277);
assert.equal(awaitingLegs(p).length, 1);
assert.equal(awaitingLegs({ ...p, stopLosses: [{ ...p.stopLosses[0], status: 'triggered' }] }).length, 0);
// Short 42.6545 from 29529.25, marked at 29477.50: +51.75 points each.
assert.ok(Math.abs(unrealizedPnl(p, 29477.5) - 42.6545 * 51.75) < 1e-6);
assert.equal(unrealizedPnl(p, NaN), 0);
assert.equal(parsePosition({ ...openPosition, quantity: '0' }), null);
assert.equal(parseOpenOrder({ order_id: 'o1', account_id: 'a1', position_id: 'p1', symbol: 'GC-PERP', created_at: 1 }).positionId, 'p1');
assert.equal(parseOpenOrder({ symbol: 'x' }), null);
});
test('transactions book into daily P&L and fund rows by Vest day', () => {
assert.equal(txnKind(VEST_TXN.FEE), 'trade');
assert.equal(txnKind(VEST_TXN.REALIZED_PNL), 'trade');
assert.equal(txnKind(VEST_TXN.FUNDING), 'trade');
assert.equal(txnKind(VEST_TXN.WITHDRAW), 'fund');
assert.equal(txnKind(VEST_TXN.FILL_MARGIN), 'margin');
assert.equal(txnKind(VEST_TXN.ORDER_MARGIN_RELEASE), 'margin');
const w = parseTxn({ account_type: 3, action_id: 'a9', amount: '1200', created_at: iso('2026-10-26T15:00:00Z'), transaction_type: 2 });
assert.equal(w.amount, -1200);
assert.equal(w.kind, 'fund');
assert.equal(parseTxn({ amount: 'x', transaction_type: 9, created_at: 1 }), null);
const rows = [
{ action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 },
{ action_id: 'o', amount: '-24944.02', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 6 },
{ action_id: 'f1', amount: '-31.49', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 8 },
// After 20:00 ET on the 26th: the 27th's day.
{ action_id: 'r1', amount: '812.5', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 9 },
{ action_id: 'f2', amount: '-31.2', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 8 },
{ action_id: 'u1', amount: '-5.42', created_at: iso('2026-10-27T01:00:00Z'), transaction_type: 10 },
].map(parseTxn);
const { daily, funds } = bookTxns(rows);
assert.deepEqual([...funds.entries()], [['2026-10-20', 50000]]);
assert.ok(Math.abs(daily.get('2026-10-26').pnl - -31.49) < 1e-9);
assert.ok(Math.abs(daily.get('2026-10-27').pnl - (812.5 - 31.2 - 5.42)) < 1e-9);
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
});
test('hourly volatility leaves out the gaps', () => {
const start = iso('2026-10-19T00:00:00Z');
const kl = [];
let price = 29000;
for (let h = 0; h < 60; h++) {
price *= h % 2 === 0 ? 1.001 : 1 / 1.001;
kl.push([start + h * 3_600_000, '0', '0', '0', String(price), '1']);
}
const base = hourlyVolatility(kl);
assert.ok(base > 0.0009 && base < 0.0011, `σ ${base}`);
// A weekend gap with a 3% jump: not an hour's move, so σ doesn't change.
const gapped = [...kl, [start + 120 * 3_600_000, '0', '0', '0', String(price * 1.03), '1']];
assert.ok(Math.abs(hourlyVolatility(gapped) - base) < 1e-12);
// Any order in, the same σ out.
assert.equal(hourlyVolatility([...kl].reverse()), base);
assert.equal(hourlyVolatility(kl.slice(0, MIN_HOURLY_RETURNS)), null);
});
test('market hours: open stretches, closures and crypto', () => {
const periods = [
{ fromMs: 0, toMs: 100, state: { session: 'unspecified', closure: 'weekly', tradingStatus: 'CLOSE', price: { allow: false } } },
{ fromMs: 100, toMs: 200, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
{ fromMs: 200, toMs: 300, state: { session: 'regular', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
{ fromMs: 300, toMs: 400, state: { session: 'unspecified', closure: 'maintenance', tradingStatus: 'OPEN', price: { allow: true } } },
{ fromMs: 400, toMs: 500, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
];
assert.deepEqual(marketOpen(periods, 50), { open: false, closesAt: null });
assert.deepEqual(marketOpen(periods, 150), { open: true, closesAt: 300 });
assert.deepEqual(marketOpen(periods, 350), { open: false, closesAt: null });
assert.deepEqual(marketOpen(periods, 450), { open: true, closesAt: 500 });
assert.deepEqual(marketOpen(periods, 600), { open: false, closesAt: null });
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
});
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
const now = iso('2026-10-26T18:40:00Z');
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
const wallet = '0x7CE10771819F80130E204DDB5CA1522E48A9B451';
assert.equal(putVestSession({ accessToken: 'nope', expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), 'accessToken is not a JWT');
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 10_000, primaryAddr: wallet, userId: 'u' }, now), 'token already expired');
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: '0x12', userId: 'u' }, now), 'primaryAddr is not a wallet address');
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
// An older token relayed late doesn't replace the fresher one.
assert.equal(putVestSession({ accessToken: jwt(0), expiresAt: now + 300_000, primaryAddr: wallet, userId: 'u' }, now), null);
assert.equal(vestSessionFor(wallet.toLowerCase(), now).accessToken, jwt(1));
assert.equal(vestSessionFor(wallet, now + 890_000), null); // under 30s left
const listed = listVestSessions();
assert.equal(listed.length, 1);
assert.equal(listed[0].primaryAddr, wallet.toLowerCase());
assert.equal('accessToken' in listed[0], false);
dropVestSession(wallet, jwt(0)); // not the held one: kept
assert.ok(vestSessionFor(wallet, now));
dropVestSession(wallet, jwt(1));
assert.equal(vestSessionFor(wallet, now), null);
});