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
@@ -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
|
||||||
|
|||||||
@@ -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,7 +35,9 @@ 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'
|
||||||
: 'username and password are required';
|
: 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';
|
||||||
return NextResponse.json({ error }, { status: 400 });
|
return NextResponse.json({ error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ───────
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
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 }));
|
||||||
|
}
|
||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user