Files
autofirmer-expanded/lib/vest-class.ts
T
Brandon LiandClaude Opus 5.5 769d96883f Vest: the relayed login binds itself to an unset Vest firm
A Vest firm's credential is the wallet of the login it trades. It no longer
has to be typed or clicked in: when the extension relays a Vest tab's
session and a Vest firm here has no credential, that wallet is saved as the
credential (lib/vest-wallet.ts), the client starts, and the backup is
pushed.

- A saved wallet is never replaced. When the tab is signed in to another
  login, the firm now says which one and how to switch: sign in as the
  saved wallet, or remove the credential.
- With two logins relayed at once nothing binds itself; the one-click form
  decides.
- Extension 0.17.1 re-posts the session every minute even when it hasn't
  changed. The dashboard holds it in memory only, so a restart there was
  unauthenticated until the tab's next renewal (up to ~15 min); now it is
  back within a minute, and a firm created later binds within a minute too.

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

1346 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Vest client — Vest Markets' perps prop program (next.vestmarkets.com): US
* index, metal and energy perps on CME hours, stocks, FX and crypto.
*
* REST only: https://api-gateway.hz.vestmarkets.com. Mapped from the Vest web
* app's own traffic and code (2026-10-26); there is no published API.
*
* Login is not this file's: Vest signs people in through Google/Privy in the
* browser and binds the session to a key the browser won't export. The
* extension relays the signed-in Vest tab's ~15-minute access token to this
* machine (lib/vest-session.ts), keyed by the login's wallet — the firm's
* saved credential here. With no live token for that wallet the client sits
* unauthenticated and says so; it never tries to log in.
*
* - The user token reads everything account-wide: the capital accounts (each
* eval or funded account with its floors), balances, open positions and
* open orders. Orders, transactions and exits need a per-account token,
* minted from the user token (POST /v3/auth/account-token) and cached until
* it nears expiry.
* - Account ids are uuids; ids are a stable hash of them (vestAccountId) so
* every numeric account table keeps working. Names carry the account's rules
* (vestAccountName) for the account-type prefixes to match.
* - A balance is the free `amount` plus the collateral held by open positions
* — what Vest's daily reset and the DLL docs call the balance. Equity adds
* unrealized P&L at the mark.
* - Vest states each account's floors: max_drawdown_limit is the auto-liq
* threshold; the daily-loss floor, held against the account type's own
* limit, gives dayStartBalance — so the engine's room never exceeds Vest's.
* - No CME products: the firm's instrument list (master Firms page) names the
* Vest symbols it trades, and findFrontMonthContract picks among them as
* DXtrade does. One engine "contract" is one size step of the symbol, and the
* step rides in the contract name.
* - Exits are legs of the position (take-profit, stop-loss) that Vest itself
* cancels when the other triggers or the position closes: a native OCO, with
* nothing for this client to babysit.
* - Fees are each symbol's taker fee from exchangeInfo, a percent of notional.
*/
import axios from './http-client';
import { currentTradeDateCT, rankForTarget } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart,
} from './db';
import { getAccountConfig } from './account-type-resolver';
import { getCachedFirm } from './firm-catalog';
import { firmInstruments } from './instruments';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session';
import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal,
VEST_STATUS_ACTIVE,
} from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } from './vest-helpers';
const BASE = 'https://api-gateway.hz.vestmarkets.com';
/** The web app's origin: the gateway serves it, so requests say they are its. */
const APP_ORIGIN = 'https://next.vestmarkets.com';
// ── timings ──────────────────────────────────────────────────────────────────
const SYNC_INTERVAL_MS = 60_000;
const SYNC_WEDGED_MS = 3 * 60_000;
const WATCHDOG_INTERVAL_MS = 15_000;
/** Request spacing: the gateway's limits aren't published, so stay well under
* what its own app does. */
const SPACING_MS = { read: 150, trade: 100, history: 400 } as const;
type Bucket = keyof typeof SPACING_MS;
const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const;
/** A cached account token is renewed this long before it expires. */
const ACCOUNT_TOKEN_SKEW_MS = 90_000;
/** The relayed user token must have this long left to start a request. */
const USER_TOKEN_MIN_MS = 20_000;
const FILL_POLL_MS = 1_000;
const FILL_TIMEOUT_MS = 30_000;
const PICK_TTL_MS = 45_000;
const SURVEY_INTERVAL_MS = 30 * 60_000;
const EXCHANGE_INFO_TTL_MS = 60 * 60_000;
const MARKET_HOURS_TTL_MS = 15 * 60_000;
const CANDLE_RETRY_MS = 15 * 60_000;
/** Volatility lookback: a week of hourly candles, as on DXtrade. */
const CANDLE_HOURS = 7 * 24;
/** Transactions per page, and the most pages one account's history reads. */
const TXN_PAGE = 100;
const TXN_MAX_PAGES = 200;
/** Balance the books can't explain by this much holds the account. */
const BOOKS_TOLERANCE = 200;
const SUSPECT_SETTLE_MS = 90_000;
/** A position this old with no awaiting exit leg is flagged as unprotected. */
const NAKED_AFTER_MS = 2 * 60_000;
/** Vest's own spread isn't on its REST surface (only the depth socket): a
* candidate is costed as crossing this many ticks. */
const ASSUMED_SPREAD_TICKS = 1;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const round2 = (n: number) => Math.round(n * 100) / 100;
/** The gateway answered with an error status. */
class VestApiError extends Error {
constructor(message: string, readonly status: number, readonly code: string | undefined) {
super(message);
this.name = 'VestApiError';
}
}
interface SymbolSpec {
symbol: string;
assetId: number;
sizeDecimals: number;
priceDecimals: number;
tick: number;
takerFee: number;
/** Initial margin per dollar of notional on a capital (prop) account. */
marginRate: number;
tradingStatus: string;
capitalTradingStatus: string;
}
export interface VestConfig {
firmId: string;
firmName: string;
/** The Vest login's primary wallet — whose relayed session this client
* trades (the firm's saved credential on this machine). */
wallet: string;
}
export class VestClient implements PlatformClient {
private readonly cfg: VestConfig;
private readonly wallet: string;
public accountList: PlatformAccount[] = [];
public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number; availableFunds?: number } } = {};
public positions: {
[accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date };
} = {};
public daysTraded: { [accountId: number]: number } = {};
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
public lastFundDates: { [accountId: number]: string | null } = {};
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
public priorProfit: { [accountId: number]: number } = {};
public autoLiqThresholds: { [accountId: number]: number } = {};
public fetchDaysComplete = false;
public syncComplete = false;
public lastSyncAt = 0;
public lastSyncStartedAt = 0;
public lastFetchErrors: Record<string, string> = {};
public lastFetchRaw: Record<string, string> = {};
public recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[] = [];
/** Tradovate-only concept. */
public products: { id: number; name: string }[] = [];
/** Every listed symbol that could trade at the last full look — what the
* dashboard ranks each account's next leg across. */
public lastCandidates: PlatformContract[] = [];
private readonly onSync: () => Promise<void>;
private uuidById = new Map<number, string>();
private capital = new Map<number, VestCapitalAccount>();
private positionsDetail = new Map<number, VestPosition[]>();
private openOrders = new Map<number, VestOpenOrder[]>();
private seenOpenAt = new Map<number, { positionId: string; at: number; naggedAt: number }>();
private accountTokens = new Map<string, { token: string; expiresAt: number }>();
private accountTokenFlights = new Map<string, Promise<string>>();
private syncInterval: ReturnType<typeof setInterval> | null = null;
private watchdogInterval: ReturnType<typeof setInterval> | null = null;
private intentionalDisconnect = false;
private lastResponseAt = 0;
private lastAuthAt = 0;
private lastAuthError: string | null = null;
private consecutiveAuthFailures = 0;
private syncInFlight = false;
private syncInFlightSince = 0;
private syncGeneration = 0;
private syncRunning: Promise<void> = Promise.resolve();
private daysFetchInFlight = false;
private daysFetchInFlightSince = 0;
private lastDaysFetchStartedAt = 0;
private lastDaysFetchSucceededAt = 0;
private nextDaysFetchRetryAt = 0;
private historyLoaded = false;
private historyLoadedIds = new Set<number>();
private booksHeld = new Set<number>();
private fetchingIds = new Set<number>();
private gateNext: Record<Bucket, number> = { read: 0, trade: 0, history: 0 };
private suspectAccounts = new Map<number, number>();
/** Opens in flight per symbol, for the hedge guard. */
private openingSides = new Map<string, { side: 'long' | 'short'; count: number }>();
private fillWaiters: { orderId: number; callback: (fill: Record<string, unknown>) => void }[] = [];
private pendingFills = 0;
private nextOrderNumber = 1;
private specs = new Map<string, SymbolSpec>();
private specsAt = 0;
private marks = new Map<string, { mark: number; at: number }>();
private hoursCache = new Map<number, { periods: unknown; at: number }>();
private candleCache = new Map<string, { vol: number | null; candles: number; at: number; tradeDate: string }>();
private pickCache = new Map<string, { contract: PlatformContract; at: number }>();
private lastSurveyTradeDate = '';
private lastSurveyAt = 0;
private unlistedLoggedAt = 0;
private unknownStatusLogged = new Set<string>();
constructor(cfg: VestConfig, onSync: () => Promise<void>) {
this.cfg = cfg;
this.wallet = cfg.wallet.trim().toLowerCase();
this.onSync = onSync;
this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS);
this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
void this.requestSync();
}
private get tag(): string {
return `[vest] ${this.cfg.firmName}:`;
}
// ── session ──────────────────────────────────────────────────────────────
/** The relayed user token, or null with the reason noted. */
private userToken(): string | null {
const session = vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS);
if (session) return session.accessToken;
const other = listVestSessions().find((s) => s.primaryAddr !== this.wallet && s.expiresAt > Date.now());
const msg = other
? `no Vest session for ${this.wallet} — the Vest tab here is signed in as ${other.primaryAddr}; sign in as ${this.wallet} there, or remove this firm's credential on the dashboard to switch to the tab's login`
: `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`;
if (this.lastFetchErrors['login'] !== msg) console.warn(`${this.tag} ${msg}`);
this.lastAuthError = this.lastFetchErrors['login'] = msg;
return null;
}
/** The extension relayed a session (POST /api/vest/session): a client
* that was waiting on its wallet syncs now. */
public onVestSession(wallet: string): void {
if (wallet !== this.wallet || this.intentionalDisconnect) return;
if (this.lastSyncAt === 0 || Date.now() - this.lastSyncAt > SYNC_INTERVAL_MS) void this.requestSync();
}
/** A token for one trading account, minted from the user token and cached. */
private async accountToken(uuid: string): Promise<string> {
const cached = this.accountTokens.get(uuid);
if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token;
const flight = this.accountTokenFlights.get(uuid);
if (flight) return flight;
const p = (async () => {
const { data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
body: { accountId: uuid },
});
const token = typeof data?.accessToken === 'string' ? data.accessToken : '';
const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000;
if (!token) throw new Error('/v3/auth/account-token answered without a token');
this.accountTokens.set(uuid, { token, expiresAt });
return token;
})().finally(() => this.accountTokenFlights.delete(uuid));
this.accountTokenFlights.set(uuid, p);
return p;
}
// ── transport ────────────────────────────────────────────────────────────
private async gate(bucket: Bucket): Promise<void> {
const now = Date.now();
const at = Math.max(now, this.gateNext[bucket]);
this.gateNext[bucket] = at + SPACING_MS[bucket];
if (at > now) await sleep(at - now);
}
/**
* One REST call: with the user token, an account's token (`account`), or
* none (`auth: false`, public data). Non-2xx answers become VestApiError
* with the gateway's own code; transport failures a plain Error carrying
* only the message — never the axios error, whose config holds the token.
* A refused account token is re-minted once; a refused user token is
* dropped, so nothing keeps knocking with it until a fresh one arrives.
*/
private async request<T>(
bucket: Bucket,
method: 'GET' | 'POST' | 'DELETE',
path: string,
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {},
): Promise<{ data: T }> {
let token: string | null = null;
if (opts.auth !== false) {
token = opts.account ? await this.accountToken(opts.account) : this.userToken();
if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION');
}
await this.gate(bucket);
let res;
try {
res = await axios.request({
method,
url: `${BASE}${path}`,
params: opts.params,
data: opts.body,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
Accept: 'application/json',
Origin: APP_ORIGIN,
Referer: `${APP_ORIGIN}/`,
},
timeout: TIMEOUT_MS[bucket],
signal: AbortSignal.timeout(TIMEOUT_MS[bucket] + 5_000),
validateStatus: () => true,
});
} catch (err) {
if (axios.isCancel(err)) throw new Error(`${path} timed out after ${TIMEOUT_MS[bucket] / 1000}s`);
throw new Error(`${path}: ${errorMessage(err)}`);
}
this.lastResponseAt = Date.now();
if (res.status >= 200 && res.status < 300) return { data: res.data as T };
const body = (res.data && typeof res.data === 'object' ? res.data : {}) as { code?: unknown; msg?: unknown; message?: unknown; detail?: { code?: unknown; msg?: unknown } };
const code = body.detail?.code ?? body.code;
const msg = body.detail?.msg ?? body.msg ?? body.message;
const error = new VestApiError(
`${path}: ${typeof msg === 'string' ? msg : `HTTP ${res.status}`} (HTTP ${res.status}${code !== undefined ? `, code ${String(code)}` : ''})`,
res.status, code !== undefined ? String(code) : undefined,
);
if (res.status === 401 && token) {
if (opts.account) {
this.accountTokens.delete(opts.account);
if (!opts.retried) return this.request<T>(bucket, method, path, { ...opts, retried: true });
} else {
// The user token itself was refused: forget it, and wait for the
// tab's next renewal rather than retrying it.
dropVestSession(this.wallet, token);
this.accountTokens.clear();
this.consecutiveAuthFailures++;
this.lastAuthError = this.lastFetchErrors['login'] = `Vest refused the relayed session (${error.message}) — waiting for the Vest tab to renew it`;
}
}
if (!opts.retried && res.status === 429) {
await sleep(2_000);
return this.request<T>(bucket, method, path, { ...opts, retried: true });
}
throw error;
}
/** No sync when there is no session to sync with, and a sync that hung
* is started over. */
private watchdog(): void {
if (this.intentionalDisconnect) return;
const now = Date.now();
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
}
// ── public market data ───────────────────────────────────────────────────
private async symbolSpecs(): Promise<Map<string, SymbolSpec>> {
if (this.specs.size > 0 && Date.now() - this.specsAt < EXCHANGE_INFO_TTL_MS) return this.specs;
const { data } = await this.request<{ symbols?: Record<string, unknown>[] }>('read', 'GET', '/v3/exchangeInfo', { auth: false });
const next = new Map<string, SymbolSpec>();
for (const s of data?.symbols ?? []) {
if (typeof s.symbol !== 'string') continue;
const capitalIm = num(s.capitalInitMarginRatio) ?? num(s.fundedInitMarginRatio) ?? num(s.initMarginRatio);
next.set(s.symbol, {
symbol: s.symbol,
assetId: num(s.assetId) ?? -1,
sizeDecimals: num(s.sizeDecimals) ?? 0,
priceDecimals: num(s.priceDecimals) ?? 2,
tick: num(s.minTickSize) ?? num(s.defaultTickSize) ?? 0,
takerFee: num(s.takerFee) ?? 0,
marginRate: capitalIm && capitalIm > 0 ? capitalIm : 0,
tradingStatus: String(s.tradingStatus ?? ''),
capitalTradingStatus: String(s.capitalTradingStatus ?? s.tradingStatus ?? ''),
});
}
if (next.size > 0) {
this.specs = next;
this.specsAt = Date.now();
}
return this.specs;
}
/** Mark prices for the symbols given (every listed one when none given). */
private async refreshMarks(symbols?: string[]): Promise<void> {
const params = symbols && symbols.length > 0 ? { symbols: symbols.join(',') } : undefined;
const { data } = await this.request<{ tickers?: Record<string, unknown>[] }>('read', 'GET', '/v3/ticker/latest', { auth: false, params });
const now = Date.now();
for (const t of data?.tickers ?? []) {
const mark = num(t.markPrice);
if (typeof t.symbol === 'string' && mark !== undefined && mark > 0) this.marks.set(t.symbol, { mark, at: now });
}
}
private async marketHours(assetId: number): Promise<unknown> {
const cached = this.hoursCache.get(assetId);
if (cached && Date.now() - cached.at < MARKET_HOURS_TTL_MS) return cached.periods;
const { data } = await this.request<{ periods?: unknown }[]>('read', 'GET', `/v4/market-hours/${assetId}`, { auth: false });
const periods = Array.isArray(data) ? data.flatMap((d) => (Array.isArray(d?.periods) ? d.periods : [])) : [];
this.hoursCache.set(assetId, { periods, at: Date.now() });
return periods;
}
/** Measured once per trade date, so a day's sizing and hold estimate stay
* put; an empty measurement retries after a while. */
private async volatility(symbols: string[]): Promise<Map<string, { vol: number | null; candles: number }>> {
const now = Date.now();
const today = currentTradeDateCT();
for (const s of symbols) {
const c = this.candleCache.get(s);
if (c && c.tradeDate === today && (c.vol !== null || now - c.at < CANDLE_RETRY_MS)) continue;
const rows: unknown[] = [];
let endTime: number | undefined;
// Newest first, a page at a time, until the lookback is covered.
for (let page = 0; page < 4 && rows.length < CANDLE_HOURS + 1; page++) {
const { data } = await this.request<{ data?: unknown[] }>('read', 'GET', endTime ? '/v2/klines/history' : '/v2/klines', {
auth: false, params: { symbol: s, interval: '1h', ...(endTime ? { endTime } : {}) },
});
const list = Array.isArray(data?.data) ? data.data : [];
if (list.length === 0) break;
rows.push(...list);
const oldest = Math.min(...list.map((k) => (Array.isArray(k) ? num(k[0]) ?? Infinity : Infinity)));
if (!Number.isFinite(oldest)) break;
endTime = oldest - 1;
}
const recent = rows.filter((k) => Array.isArray(k) && (num(k[0]) ?? 0) >= now - (CANDLE_HOURS + 1) * 3_600_000);
const vol = hourlyVolatility(recent);
this.candleCache.set(s, { vol, candles: recent.length, at: now, tradeDate: today });
if (vol !== null) console.log(`${this.tag} ${s} volatility for ${today}: ${(vol * Math.sqrt(24) * 100).toFixed(2)}%/day (${recent.length} hourly candles)`);
}
return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }]));
}
// ── sync ─────────────────────────────────────────────────────────────────
/**
* One REST sync. Single-flight, with the same wedge recovery as the other
* clients. lastSyncAt — what the scheduler trades on — is certified only
* when the accounts, balances, positions and open orders all came back and
* the first history pass has landed.
*/
private async requestSync(): Promise<void> {
if (this.intentionalDisconnect) return;
if (!this.userToken()) return;
const startedAt = Date.now();
if (this.syncInFlight) {
const runningMs = startedAt - this.syncInFlightSince;
if (runningMs < SYNC_WEDGED_MS) return this.syncRunning;
console.warn(`${this.tag} sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`);
}
const gen = ++this.syncGeneration;
const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect;
this.syncInFlight = true;
this.syncInFlightSince = startedAt;
let finished!: () => void;
this.syncRunning = new Promise<void>((resolve) => { finished = resolve; });
try {
const [cap, bal, pos, ord] = await Promise.all([
this.request<{ accounts?: unknown[] }>('read', 'GET', '/v3/capital/accounts/active'),
this.request<{ accounts?: Record<string, unknown>[] }>('read', 'GET', '/v3/accounts', { params: { active: true, limit: 500 } }),
this.request<{ positions?: unknown[] }>('read', 'GET', '/v3/positions/opened'),
this.request<{ orders?: unknown[] | null }>('read', 'GET', '/v3/positions/opened-orders'),
]);
if (superseded()) return;
this.lastAuthAt = this.lastAuthAt || Date.now();
this.consecutiveAuthFailures = 0;
this.lastAuthError = null;
delete this.lastFetchErrors['login'];
const accounts = (cap.data?.accounts ?? []).map(parseCapitalAccount).filter((a): a is VestCapitalAccount => a !== null);
const free = new Map<string, number>();
for (const b of bal.data?.accounts ?? []) {
const amount = num(b.amount);
if (typeof b.account_id === 'string' && amount !== undefined) free.set(b.account_id, amount);
}
const positions = (pos.data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
const orders = (ord.data?.orders ?? []).map(parseOpenOrder).filter((o): o is VestOpenOrder => o !== null);
const held = [...new Set(positions.map((p) => p.symbol))];
if (held.length > 0) {
try {
await this.refreshMarks(held);
} catch (err) {
console.warn(`${this.tag} mark prices failed: ${errorMessage(err)}`);
}
}
if (superseded()) return;
const list: PlatformAccount[] = [];
let complete = true;
const now = Date.now();
for (const a of accounts) {
const id = vestAccountId(a.id);
const clash = this.uuidById.get(id);
if (clash && clash !== a.id) {
console.error(`${this.tag} account id collision between ${clash} and ${a.id} — leaving ${a.id} out`);
continue;
}
this.uuidById.set(id, a.id);
this.capital.set(id, a);
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`);
}
const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE };
list.push(acc);
const mine = positions.filter((p) => p.accountId === a.id);
this.positionsDetail.set(id, mine);
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
this.applyPosition(acc, mine);
const amount = free.get(a.id);
if (amount === undefined) { complete = false; continue; }
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
const resetBalance = this.resetBalanceOf(a, balance, now);
this.accountCashBalances[id] = {
amount: round2(balance),
realizedPnL: resetBalance !== undefined ? round2(balance - resetBalance) : 0,
equity: round2(balance + upnl),
availableFunds: round2(amount),
};
this.autoLiqThresholds[id] = a.maxDrawdownLimit ?? 0;
this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}`
+ `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`;
}
this.accountList = list;
this.settleSuspects();
this.checkNakedPositions();
if (superseded()) return;
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
this.maybeFetchDaysTraded();
if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) {
this.lastSurveyTradeDate = currentTradeDateCT();
this.lastSurveyAt = Date.now();
void this.surveyCandidates().catch((err) =>
console.warn(`${this.tag} candidate survey failed: ${errorMessage(err)}`));
}
this.syncComplete = true;
if (!complete) {
console.warn(`${this.tag} sync incomplete (a balance was missing) — not counted as a good sync`);
} else if (this.historyLoaded) {
this.lastSyncAt = Date.now();
this.lastSyncStartedAt = startedAt;
}
try {
await this.onSync();
} catch (err) {
console.error(`${this.tag} post-sync callback failed: ${errorMessage(err)}`);
}
} catch (err) {
console.error(`${this.tag} sync failed: ${errorMessage(err)}`);
} finally {
if (gen === this.syncGeneration) {
this.syncInFlight = false;
this.syncInFlightSince = 0;
}
finished();
}
}
private applyPosition(acc: PlatformAccount, list: VestPosition[]): void {
if (list.length > 1) console.warn(`${this.tag} ${acc.name} holds ${list.length} positions — tracking the first`);
const pos = list[0];
if (!pos) {
const seen = this.seenOpenAt.get(acc.id);
if (seen) {
console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`);
this.seenOpenAt.delete(acc.id);
}
delete this.positions[acc.id];
return;
}
const seen = this.seenOpenAt.get(acc.id);
if (!seen || seen.positionId !== pos.positionId) {
this.seenOpenAt.set(acc.id, { positionId: pos.positionId, at: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(), naggedAt: 0 });
}
this.positions[acc.id] = {
contractId: pos.symbol,
netPos: pos.side === 'long' ? pos.quantity : -pos.quantity,
netPrice: pos.openPrice,
timestamp: new Date(Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now()),
};
}
/**
* The balance Vest measures today's daily loss from. Vest captures it on
* an account's first update after 20:00 ET, so an account that has not
* moved since the boundary still shows yesterday's — its next update
* (our own order, say) will reset it to the balance it has now.
*/
private resetBalanceOf(a: VestCapitalAccount, balance: number, now: number): number | undefined {
if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyResetBalance;
return balance;
}
/** The equity floor of Vest's daily loss limit as it stands (or will, on
* the account's next update); undefined when the account has none. */
private dailyFloorOf(a: VestCapitalAccount, balance: number, now: number): number | undefined {
if (!(a.maxDailyLossPct > 0)) return undefined;
if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyLossFloor;
return round2(balance * (1 - a.maxDailyLossPct));
}
/**
* See PlatformClient: what the engine measures the day's loss from. Vest
* states the floor itself (reset balance × (1 − rate), moved by transfers),
* so this is that floor plus the account type's own daily limit — the
* engine's room, limit + min(0, balance − dayStart), is then
* min(limit, balance − floor): never more than Vest leaves. null until
* the account has been read.
*/
public dayStartBalance(accountId: number): number | null {
const a = this.capital.get(accountId);
const cash = this.accountCashBalances[accountId];
if (!a || !cash) return null;
const floor = this.dailyFloorOf(a, cash.amount, Date.now());
const limit = getAccountConfig(accountId).accountType?.dailyLossLimit ?? 0;
if (floor === undefined) return this.resetBalanceOf(a, cash.amount, Date.now()) ?? null;
return round2(floor + limit);
}
private settleSuspects(): void {
const now = Date.now();
for (const [id, since] of this.suspectAccounts) {
if (now - since >= SUSPECT_SETTLE_MS && (this.openOrders.get(id) ?? []).length === 0) this.suspectAccounts.delete(id);
}
}
/** An order of unknown outcome, a pending order, or exit legs waiting on
* a position. */
public hasWorkingOrders(accountId: number): boolean {
return this.suspectAccounts.has(accountId)
|| (this.openOrders.get(accountId) ?? []).length > 0
|| (this.positionsDetail.get(accountId) ?? []).some((p) => awaitingLegs(p).length > 0);
}
/** A position with no exit leg waiting — a restart mid-entry, or a leg
* whose exits failed. Not closed automatically (it may be a manual
* trade), but loud, and on the account's fetch-error note. */
private checkNakedPositions(): void {
const now = Date.now();
for (const acc of this.accountList) {
const seen = this.seenOpenAt.get(acc.id);
const pos = this.positionsDetail.get(acc.id)?.[0];
if (!seen || !pos || awaitingLegs(pos).length > 0 || now - seen.at < NAKED_AFTER_MS) continue;
if (now - seen.naggedAt < 15 * 60_000) continue;
seen.naggedAt = now;
console.error(`${this.tag} ${acc.name}: position ${pos.positionId} has NO take-profit or stop-loss — close it or add exits on Vest`);
}
}
// ── history ──────────────────────────────────────────────────────────────
private maybeFetchDaysTraded(): void {
if (this.daysFetchInFlight || this.intentionalDisconnect) return;
const now = Date.now();
const initialFetchDue = this.lastDaysFetchStartedAt === 0;
const retryPending = this.nextDaysFetchRetryAt > 0;
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000;
const waiting = !initialFetchDue && !retryPending
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id)
: [];
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
this.daysFetchInFlight = true;
this.daysFetchInFlightSince = now;
this.lastDaysFetchStartedAt = now;
if (retryDue) this.nextDaysFetchRetryAt = 0;
this.fetchDaysTraded(targeted ? waiting : undefined)
.then(({ failedAccounts }) => {
if (!this.historyLoaded && !this.intentionalDisconnect) {
this.historyLoaded = true;
void this.requestSync();
}
if (failedAccounts > 0) {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`);
return;
}
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
})
.catch((err) => {
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`);
})
.finally(() => {
this.daysFetchInFlight = false;
this.daysFetchInFlightSince = 0;
});
}
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
const ids = this.accountList.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id)).map((a) => a.id);
for (const id of ids) this.fetchingIds.add(id);
this.fetchDaysComplete = false;
let failed = 0;
try {
for (const id of ids) {
if (this.intentionalDisconnect) break;
const account = this.accountList.find((a) => a.id === id);
if (!account) continue;
try {
this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!));
} catch (err) {
failed++;
this.fallBackToCache(account, errorMessage(err));
}
}
} finally {
for (const id of ids) this.fetchingIds.delete(id);
this.fetchDaysComplete = true;
}
return { failedAccounts: failed };
}
/** Every transaction on the account, newest page first, deduplicated. */
private async fetchTxns(uuid: string): Promise<VestTxn[]> {
const all = new Map<string, VestTxn>();
for (let page = 0; page < TXN_MAX_PAGES; page++) {
const { data } = await this.request<{ transactions?: unknown[] | null }>('history', 'GET', '/v3/transactions', {
account: uuid, params: { limit: TXN_PAGE, offset: page * TXN_PAGE },
});
const rows = data?.transactions ?? [];
for (const r of rows) {
const t = parseTxn(r);
if (t) all.set(t.key, t);
}
if (rows.length < TXN_PAGE) break;
}
return [...all.values()].sort((a, b) => a.time - b.time);
}
private bookHistory(account: PlatformAccount, txns: VestTxn[]): void {
const a = this.capital.get(account.id);
const { daily, funds } = bookTxns(txns);
const types = [...new Set(txns.map((t) => t.type))].sort((x, y) => x - y);
this.lastFetchRaw[account.name] = `${txns.length} transaction(s), types ${types.join(',') || 'none'}`;
// An account Vest funded without a deposit row opens with its capital
// on the day it was activated — the resolver infers its size from it,
// and its first cycle starts there.
const hasDeposit = [...funds.values()].some((v) => v > 0);
if (!hasDeposit && a && a.initialCapital > 0) {
const opened = vestDayOf(Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt);
funds.set(opened, (funds.get(opened) ?? 0) + a.initialCapital);
}
const fresh = [...daily.entries()]
.map(([date, e]) => ({ date, pnl: round2(e.pnl), fees: Math.round(e.fees * 10000) / 10000 }))
.sort((x, y) => x.date.localeCompare(y.date));
const merged = new Map<string, number>();
for (const e of loadDailyPnL(account.id)) merged.set(e.date, e.pnl);
for (const e of fresh) merged.set(e.date, e.pnl);
this.fullDailyPnL[account.id] = [...merged.entries()]
.map(([date, pnl]) => ({ date, pnl }))
.sort((x, y) => x.date.localeCompare(y.date));
saveDailyPnL(account.id, account.name, fresh);
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
const fundRows = [...funds.entries()]
.map(([date, amount]) => ({ date, amount: round2(amount) }))
.filter((f) => f.amount !== 0)
.sort((x, y) => x.date.localeCompare(y.date));
if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows);
this.fundTransactions[account.id] = loadFundTransactions(account.id);
const lastFundDate = fundRows.length > 0 ? fundRows[fundRows.length - 1].date : storedFundDate;
if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) {
const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null;
saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) });
}
if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
this.lastFundDates[account.id] = lastFundDate ?? null;
this.applyCycleSlice(account.id);
// Books check: the balance must be what the transactions add up to
// (the opening capital included). A payout or credit booked under a
// type this client doesn't count would otherwise trade a finished
// cycle as if nothing happened.
const cash = this.accountCashBalances[account.id];
const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0);
if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
this.booksHeld.add(account.id);
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`;
console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`);
} else {
this.booksHeld.delete(account.id);
delete this.lastFetchErrors[account.name];
}
this.historyLoadedIds.add(account.id);
}
private fallBackToCache(account: PlatformAccount, msg: string): void {
const cached = loadDailyPnL(account.id);
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);
this.lastFundDates[account.id] ??= loadAccountMeta(account.id, 'last_fund_date');
if (cached.length > 0) {
this.fullDailyPnL[account.id] = cached;
this.applyCycleSlice(account.id);
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
} else {
this.daysTraded[account.id] ??= 0;
this.lastFetchErrors[account.name] = msg;
}
}
/** The active cycle out of the full history: a withdrawal's day belongs to
* the OLD cycle, a deposit's day to the new one (as on DXtrade). */
private applyCycleSlice(accountId: number): void {
const merged = this.fullDailyPnL[accountId] ?? [];
const fundDate = this.lastFundDates[accountId];
const txns = this.fundTransactions[accountId] ?? [];
const lastAmt = fundDate ? (txns.find((f) => f.date === fundDate)?.amount ?? null) : null;
const isWithdrawal = lastAmt !== null && lastAmt < 0;
const active = fundDate ? merged.filter((d) => (isWithdrawal ? d.date > fundDate : d.date >= fundDate)) : merged;
const prior = fundDate ? merged.filter((d) => (isWithdrawal ? d.date <= fundDate : d.date < fundDate)) : [];
this.priorProfit[accountId] = round2(prior.reduce((s, d) => s + d.pnl, 0));
this.dailyPnL[accountId] = active;
this.daysTraded[accountId] = active.filter((d) => d.pnl !== 0).length;
}
public historyReady(accountId: number): boolean {
return this.historyLoadedIds.has(accountId)
&& !this.booksHeld.has(accountId)
&& this.dailyPnL[accountId] !== undefined
&& this.fundTransactions[accountId] !== undefined;
}
/** The account's Vest status and activation time, for the report. */
public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined {
const a = this.capital.get(accountId);
return a ? { status: String(a.status), registrationTime: Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt } : undefined;
}
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
this.booksHeld.delete(accountId);
delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId];
delete this.priorProfit[accountId];
delete this.lastFundDates[accountId];
delete this.fundTransactions[accountId];
delete this.lastFetchErrors[accountName];
delete this.lastFetchRaw[accountName];
}
// ── instrument selector ──────────────────────────────────────────────────
/**
* The contract this firm trades right now, from its instrument list — the
* root is ignored, as on DXtrade. With a pick context (the batch's leg),
* each candidate is sized as the leg would be and the cheapest that
* reaches the target hold wins (else the quickest); without one, the
* cheapest per unit of volatility. Sticky while an account holds one of
* the candidates; a batch's pick is reused briefly so its lookups agree.
*/
public async findFrontMonthContract(_root: string, ctx?: PickContext): Promise<PlatformContract | null> {
if (!this.userToken()) return null;
const root = '*';
const firm = getCachedFirm(this.cfg.firmId);
const candidates = firm ? firmInstruments(firm) : [];
const now = Date.now();
if (candidates.length === 0) {
if (now - this.unlistedLoggedAt > 10 * 60_000) {
this.unlistedLoggedAt = now;
console.log(`${this.tag} no instruments listed — skipping; add Vest symbols (e.g. NDX-USD-PERP) on the master Firms page`);
}
return null;
}
const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol));
const sticky = candidates.find((s) => held.has(s));
if (!sticky && !ctx) {
const cached = this.pickCache.get(root);
if (cached && now - cached.at < PICK_TTL_MS) return cached.contract;
}
const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined, ctx);
if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now });
return pick;
}
/** Every listed symbol, looked at afresh whatever the accounts hold: the
* day's volatility, and the candidates the dashboard ranks. */
private async surveyCandidates(): Promise<void> {
const firm = getCachedFirm(this.cfg.firmId);
const list = firm ? firmInstruments(firm) : [];
if (list.length > 0) await this.pickCandidate('survey', list, false);
}
private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
const firm = getCachedFirm(this.cfg.firmId);
const targetHold = sizingHoldHours(firm);
const specs = await this.symbolSpecs();
const known = symbols.filter((s) => specs.has(s));
if (known.length > 0) await this.refreshMarks(known);
const vols = await this.volatility(known);
const now = Date.now();
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
for (const symbol of symbols) {
const spec = specs.get(symbol);
if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; }
const mark = this.marks.get(symbol);
const vol = vols.get(symbol);
let hours: { open: boolean; closesAt: number | null };
try {
hours = marketOpen(await this.marketHours(spec.assetId), now);
} catch (err) {
board.push({ symbol, score: null, note: `market hours unknown (${errorMessage(err)})` });
continue;
}
const step = 10 ** -spec.sizeDecimals;
const stepNotional = mark ? step * mark.mark : NaN;
const spread = mark && spec.tick > 0 ? (ASSUMED_SPREAD_TICKS * spec.tick) / mark.mark : NaN;
const rejection = spec.capitalTradingStatus !== 'TRADING' ? `capital trading ${spec.capitalTradingStatus || 'unknown'}`
: !hours.open ? 'market closed'
: !mark || now - mark.at > 5 * 60_000 ? 'no mark price'
: !(spec.tick > 0) ? 'no tick size'
: (vol?.candles ?? 0) < 25 ? `only ${vol?.candles ?? 0} hourly candles`
: null;
if (rejection) { board.push({ symbol, score: null, note: rejection }); continue; }
const pct = spec.takerFee > 0 ? spec.takerFee * 100 : firm?.commissionPct ?? 0;
const score = scoreCandidate({
spreadFraction: spread,
commissionPct: pct,
financingPctPerDay: firm?.financingPctPerDay ?? 0,
targetHoldHours: targetHold,
volatilityPerHour: vol?.vol ?? null,
});
if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; }
board.push({
symbol,
score,
note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`
+ (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '')
+ (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
contract: {
id: symbol,
name: encodeContractName(symbol, step),
tickSize: spec.tick,
pointValue: step,
commissionPct: pct,
stepNotional,
volatilityPerHour: vol?.vol ?? undefined,
spreadFraction: spread,
minContracts: 1,
marginRate: spec.marginRate || undefined,
},
});
}
let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!);
if (ctx) {
const sized = ranked.flatMap((b) => {
const ev = ctx.evaluate(b.contract!);
if (!ev) { b.note += ', can\'t size'; return []; }
b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`;
return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
});
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
}
const winner = ranked[0];
const summary = board.map((b) => `${b.symbol}${b.score !== null ? ` ${b.score.toFixed(4)}` : ''} (${b.note})`).join('; ');
this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`;
console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`);
if (!sticky) this.lastCandidates = board.flatMap((b) => (b.contract ? [b.contract] : []));
return winner?.contract ?? null;
}
public async fetchInstrumentFees(): Promise<{ [symbol: string]: number }> {
// A percent of notional per symbol, carried on each picked contract.
return {};
}
// ── orders ───────────────────────────────────────────────────────────────
private async freshPositions(uuid: string): Promise<VestPosition[]> {
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
return (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null && p.accountId === uuid);
}
/** What an order needs before it is sent. Failing here reached nothing. */
private orderContext(accountId: number, contractName: string): { uuid: string; symbol: string; step: number; spec: SymbolSpec } {
if (!this.userToken()) throw new OrderNotPlacedError('no Vest session — order not sent');
const uuid = this.uuidById.get(accountId);
if (!uuid) throw new OrderNotPlacedError(`Vest account ${accountId} unknown — order not sent`);
const decoded = decodeContractName(contractName);
if (!decoded) throw new OrderNotPlacedError(`Vest contract ${contractName} unreadable — order not sent`);
const spec = this.specs.get(decoded.symbol);
if (!spec) throw new OrderNotPlacedError(`Vest symbol ${decoded.symbol} has no spec loaded — order not sent`);
return { uuid, symbol: decoded.symbol, step: decoded.step, spec };
}
/** A price on the symbol's tick grid as the gateway wants it, rounded
* toward the position's safe side: an exit selling (closing a long) up,
* one buying (closing a short) down — the take-profit never nets less,
* the stop never sits further out. */
private exitPrice(spec: SymbolSpec, price: number, exitSide: 'long' | 'short'): string {
const rounded = spec.tick > 0 ? roundToStep(price, spec.tick, exitSide === 'short' ? 'up' : 'down') : price;
return formatDecimal(rounded, spec.priceDecimals);
}
/** The leverage an entry asks for: the most the symbol allows a capital
* account (cross margin, so it only sets the collateral held, not the
* liquidation point — the prop floors sit far above that). */
private leverageFor(spec: SymbolSpec): string {
return formatDecimal(spec.marginRate > 0 ? Math.floor(1 / spec.marginRate) : 1, 2);
}
/**
* Place an order and wait for its fill: an open when the account is flat,
* a close (or reduce) of the position when it holds the opposite side.
* Adding to a position is refused, and so is an open against another
* account's opposite position in the same symbol (no hedging). Resolves
* with the fill `price`.
*/
public async sendOrder(
accountId: number,
contractSymbol: string,
quantity: number,
action: 'Buy' | 'Sell',
orderType: 'Market' | 'Limit',
price?: number,
): Promise<Record<string, unknown>> {
try {
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
} catch (err) {
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
throw err;
}
}
private async sendOrderAndWait(
accountId: number,
contractSymbol: string,
contracts: number,
action: 'Buy' | 'Sell',
orderType: 'Market' | 'Limit',
price?: number,
): Promise<Record<string, unknown>> {
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
let positions: VestPosition[];
try {
positions = await this.freshPositions(uuid);
} catch (err) {
throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`);
}
const existing = positions.find((p) => p.symbol === symbol);
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
const units = roundToStep(contracts * step, step);
const qty = (n: number) => formatDecimal(n, spec.sizeDecimals);
const tif = orderType === 'Market' ? 'IOC' : 'GTC';
const priced = orderType === 'Limit' && price !== undefined;
let path: string;
let body: Record<string, unknown>;
let unitsOrdered: number;
if (existing) {
unitsOrdered = Math.min(existing.quantity, units);
const full = unitsOrdered >= existing.quantity - step / 2;
path = full ? '/v3/positions/close' : '/v3/positions/reduce';
body = full
? {
positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit',
...(priced ? { limitPrice: this.exitPrice(spec, price!, side) } : {}),
}
: {
positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit',
leverage: this.leverageFor(spec), quantity: qty(unitsOrdered), timeInForce: tif, reduceOnly: true,
...(priced ? { price: this.exitPrice(spec, price!, side) } : {}),
};
} else {
const meta = this.capital.get(accountId);
if (!meta || meta.status !== VEST_STATUS_ACTIVE) {
throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`);
}
const opposite = side === 'long' ? 'short' : 'long';
const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite)
|| this.openingSides.get(symbol)?.side === opposite;
if (hedged) throw new OrderNotPlacedError(`another account holds the opposite side of ${symbol} — no hedging`);
unitsOrdered = units;
path = '/v3/positions/open';
body = {
orderType: orderType === 'Market' ? 'market' : 'limit',
leverage: this.leverageFor(spec),
side,
symbol,
quantity: qty(units),
timeInForce: tif,
takeProfits: [],
stopLosses: [],
...(priced ? { price: formatDecimal(roundToStep(price!, spec.tick || 1e-9), spec.priceDecimals) } : {}),
};
}
if (!(unitsOrdered > 0)) throw new OrderNotPlacedError(`${contracts} contract(s) of ${symbol} round to nothing — order not sent`);
const opening = !existing;
if (opening) {
const cur = this.openingSides.get(symbol);
this.openingSides.set(symbol, { side, count: (cur?.side === side ? cur.count : 0) + 1 });
}
this.pendingFills++;
const orderNumber = this.nextOrderNumber++;
const sentAt = Date.now();
try {
const placed = await this.submit(uuid, path, body);
console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`);
const fill = await this.waitForFill(uuid, placed, symbol, side, existing ?? null, unitsOrdered, sentAt);
if (opening) {
const pos = fill.position ?? {
positionId: placed.positionId ?? '', accountId: uuid, symbol, side, quantity: unitsOrdered,
openPrice: fill.price, collateral: 0, leverage: 0, openedAt: Date.now(), stopLosses: [], takeProfits: [],
};
this.positionsDetail.set(accountId, [pos]);
this.positions[accountId] = {
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
};
} else if (unitsOrdered >= existing.quantity - step / 2) {
this.positionsDetail.set(accountId, []);
delete this.positions[accountId];
}
const result = {
orderId: orderNumber, vestOrderId: placed.orderId, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol,
};
for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result);
this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber);
return result;
} finally {
this.pendingFills--;
if (opening) {
const cur = this.openingSides.get(symbol);
if (cur && cur.count <= 1) this.openingSides.delete(symbol);
else if (cur) this.openingSides.set(symbol, { ...cur, count: cur.count - 1 });
}
}
}
/**
* POST an order. Only an answer that proves nothing was placed becomes
* OrderNotPlacedError (a 4xx with the gateway's refusal); a transport
* failure or a 5xx stays a plain Error — it may have gone in.
*/
private async submit(uuid: string, path: string, body: Record<string, unknown>): Promise<{ orderId?: string; positionId?: string }> {
try {
const { data } = await this.request<{ orderId?: unknown; positionId?: unknown }>('trade', 'POST', path, { account: uuid, body });
return {
orderId: typeof data?.orderId === 'string' ? data.orderId : undefined,
positionId: typeof data?.positionId === 'string' ? data.positionId : undefined,
};
} catch (err) {
if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) {
throw new OrderNotPlacedError(`Vest refused the order: ${err.message}`);
}
throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`);
}
}
/**
* Poll until the order fills: an open shows as the position it created
* (by positionId, else the account's only one in the symbol), a close as
* the position gone or shrunk. Still unfilled after FILL_TIMEOUT_MS is
* reported as unknown — a market order is IOC, so it can't fill later,
* but a limit may.
*/
private async waitForFill(
uuid: string,
placed: { orderId?: string; positionId?: string },
symbol: string,
side: 'long' | 'short',
closing: VestPosition | null,
units: number,
sentAt: number,
): Promise<{ price: number; position?: VestPosition }> {
const deadline = Date.now() + FILL_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(FILL_POLL_MS);
try {
const positions = await this.freshPositions(uuid);
if (closing) {
const still = positions.find((p) => p.positionId === closing.positionId);
if (!still || still.quantity < closing.quantity - units / 2) {
return { price: await this.exitFillPrice(uuid, symbol, side, sentAt) };
}
} else {
const pos = positions.find((p) => (placed.positionId ? p.positionId === placed.positionId : p.symbol === symbol && p.side === side));
if (pos) return { price: pos.openPrice, position: pos };
}
} catch { /* transient — the next poll tries again */ }
}
throw new Error(`order ${placed.orderId ?? '?'} on ${symbol} not seen filled within ${FILL_TIMEOUT_MS / 1000}s — outcome unknown`);
}
/** What a close filled at: the volume-weighted price of the account's
* executions in the symbol, on the closing side, since the order went
* out — else the mark, as an estimate. */
private async exitFillPrice(uuid: string, symbol: string, side: 'long' | 'short', sentAt: number): Promise<number> {
try {
const { data } = await this.request<{ items?: Record<string, unknown>[] }>('trade', 'GET', '/v3/executions', {
account: uuid, params: { account_id: uuid, symbol, limit: 20 },
});
let qty = 0;
let value = 0;
for (const e of data?.items ?? []) {
const q = num(e.quantity);
const price = num(e.price);
if (q === undefined || price === undefined || toMs(e.executedAt) < sentAt - 5_000) continue;
if ((e.isBuy === true) !== (side === 'long')) continue;
qty += q;
value += q * price;
}
if (qty > 0) return value / qty;
} catch (err) {
console.warn(`${this.tag} executions read failed — taking the mark as the exit price: ${errorMessage(err)}`);
}
await this.refreshMarks([symbol]).catch(() => { /* NaN below then */ });
return this.marks.get(symbol)?.mark ?? NaN;
}
/** One exit leg on the open position: POST /v3/positions/take-profit or
* /stop-loss. A take-profit rests as a limit at its price; a stop goes
* out at market when it triggers. */
private async addLeg(uuid: string, spec: SymbolSpec, pos: VestPosition, kind: 'take-profit' | 'stop-loss', price: number): Promise<{ orderId?: string }> {
const exitSide = pos.side === 'long' ? 'short' : 'long';
const trigger = this.exitPrice(spec, price, exitSide);
const body = kind === 'take-profit'
? { positionId: pos.positionId, executionType: 'limit', triggerPrice: trigger, limitPrice: trigger }
: { positionId: pos.positionId, executionType: 'market', triggerPrice: trigger };
return this.submit(uuid, `/v3/positions/${kind}`, body);
}
private async heldPosition(uuid: string, symbol: string, exitSide: 'long' | 'short'): Promise<VestPosition> {
const pos = (await this.freshPositions(uuid).catch((err) => {
throw new OrderNotPlacedError(`Vest position check failed — exit not sent: ${errorMessage(err)}`);
})).find((p) => p.symbol === symbol && p.side !== exitSide);
if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to exit — nothing sent`);
return pos;
}
/** A take-profit on the whole open position. Exit only: with no opposite
* position there is nothing to close, so nothing is sent. */
public async placeOrderNoWait(
accountId: number,
contractSymbol: string,
_quantity: number,
action: 'Buy' | 'Sell',
orderType: 'Market' | 'Limit',
price?: number,
): Promise<{ orderId?: number }> {
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
const pos = await this.heldPosition(uuid, symbol, exitSide);
if (orderType !== 'Limit' || price === undefined) {
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol, orderType: 'market' });
return {};
}
await this.addLeg(uuid, spec, pos, 'take-profit', price);
return {};
}
/**
* Take-profit and stop on the open position. Vest holds them as legs of
* the position and cancels the other when one triggers (a native OCO) or
* the position closes. The stop goes first, so the position is never
* without it.
*
* What the caller may do next depends on what is left at the broker:
* OrderNotPlacedError = nothing of the pair is working; a plain Error =
* something may be (a stop, at least) — place nothing more.
*/
public async placeOcoExit(
accountId: number,
contractSymbol: string,
_quantity: number,
exitAction: 'Buy' | 'Sell',
limitPrice: number,
stopPrice: number,
): Promise<{ orderId?: number }> {
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short';
const pos = await this.heldPosition(uuid, symbol, exitSide);
await this.addLeg(uuid, spec, pos, 'stop-loss', stopPrice); // throws OrderNotPlacedError when nothing went in
try {
await this.addLeg(uuid, spec, pos, 'take-profit', limitPrice);
} catch (err) {
console.error(`${this.tag} ${symbol}: stop is working but the take-profit failed (${errorMessage(err)}) — place it by hand`);
throw new Error(`stop working, take-profit not placed: ${errorMessage(err)}`);
}
console.log(`${this.tag} exits on ${pos.positionId} — take-profit @ ${this.exitPrice(spec, limitPrice, exitSide)}, stop @ ${this.exitPrice(spec, stopPrice, exitSide)}`);
return {};
}
public onFill(orderId: number, callback: (fill: Record<string, unknown>) => void): void {
this.fillWaiters.push({ orderId, callback });
}
// ── lifecycle ────────────────────────────────────────────────────────────
public health(): ClientHealth {
const session = vestSessionFor(this.wallet, Date.now(), 0);
return {
platform: 'vest',
authenticated: session !== null,
transport: 'rest',
syncComplete: this.syncComplete,
lastSyncAt: this.lastSyncAt,
lastLoginAt: session?.receivedAt ?? this.lastAuthAt,
lastLoginError: this.lastAuthError,
consecutiveLoginFailures: this.consecutiveAuthFailures,
nextReconnectAt: 0,
tokenExpiresAt: session?.expiresAt ?? 0,
lastInboundAt: this.lastResponseAt,
pendingRequests: this.pendingFills,
syncInFlightSince: this.syncInFlight ? this.syncInFlightSince : 0,
daysFetchInFlightSince: this.daysFetchInFlight ? this.daysFetchInFlightSince : 0,
};
}
/** No session relayed for this wallet: a rebuild can't fix that — only a
* signed-in Vest tab with the extension can — so the supervisor leaves
* the client alone. */
public get needsAttention(): boolean {
return vestSessionFor(this.wallet, Date.now(), 0) === null;
}
/** Final: nothing this client scheduled fires afterwards. There is no
* session of its own to log out — the browser's stays the browser's. */
public disconnect(): void {
this.intentionalDisconnect = true;
for (const t of [this.syncInterval, this.watchdogInterval]) if (t) clearInterval(t);
this.syncInterval = this.watchdogInterval = null;
this.daysFetchInFlight = false;
this.daysFetchInFlightSince = 0;
this.accountTokens.clear();
}
}