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
@@ -16,7 +16,9 @@ type Params = { params: Promise<{ id: string }> };
|
||||
* 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,
|
||||
* 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) {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -33,7 +35,9 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
? 'apiKey is required for a TopstepX firm'
|
||||
: firm.platform === 'dxtrade'
|
||||
? '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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 'dxtrade': return 'DXtrade 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.
|
||||
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||
* (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 }) {
|
||||
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 [username, setUsername] = 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">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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">
|
||||
{tradeSymbol} not listed
|
||||
</span>
|
||||
@@ -518,6 +593,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
DXtrade
|
||||
</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 && (
|
||||
<span
|
||||
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
||||
|
||||
Reference in New Issue
Block a user