Files
autofirmer-expanded/lib/vest-class.ts
T
Brandon LiandClaude Opus 5.5 a03bfb5605 Vest: close out trades left open on an account Vest stopped listing
An account that leaves Vest's active list (breached, closed) was never
read again, so a position it had open stayed open in the trades table.
When the list shrinks, and on every full history pass, the client reads
such an account's position history once more per run — its id from
memory or, after a restart, from Vest's closed-account history
(GET /v3/capital/accounts/history) — falling back to the login's token
if the closed account gets none of its own.

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

1720 lines
91 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 { randomUUID } from 'crypto';
import axios from './http-client';
import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
} from './db';
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, refuseVestSession } from './vest-session';
import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN,
} from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } 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;
/** Position history (GET /v3/positions) pages, for the trades table. */
const TRADES_PAGE = 50;
const TRADES_MAX_PAGES = 20;
/** Closed-account history (GET /v3/capital/accounts/history) pages: the
* app's own size, and how far back to look for one. */
const CLOSED_PAGE = 8;
const CLOSED_MAX_PAGES = 30;
/** 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;
/** How long before the daily reset open positions are fitted to the room
* the reset will leave (guardReset). */
const RESET_GUARD_MS = 5 * 60_000;
/** account_meta key of the profit claims this client made, per account. */
const CLAIMS_META = 'vest_claims';
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>();
/** The login's main (primary) account — where a claimed profit goes. */
private primaryAccountId: string | null = null;
/** Profit claims this client made, per account: when, how much, and the
* balance right after — today's P&L counts from there (realizedSince).
* Loaded from account_meta on first use, so a restart keeps them. */
private claims = new Map<number, { at: number; amount: number; base: number }[]>();
/** Each account's WITHDRAW transactions at the last history read, so a
* claim made by hand on Vest counts toward today's stage too. */
private withdrawals = new Map<number, { at: number; amount: number }[]>();
/** The reset guardReset last finished for (epoch ms). */
private resetGuardedFor = 0;
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>();
/** Accounts whose position history to read again soon: a position
* closed, or a flat balance moved (a round trip between two syncs). */
private tradesDue = new Set<number>();
private tradesFetchInFlight = false;
/** Open positions already in the trades table, by id → size stored. */
private recordedOpen = new Map<string, number>();
private flatAmounts = new Map<number, number>();
/** Accounts off Vest's active list whose leftover open trades were
* read once more this run (closeOutDropped). */
private closeOutTried = new Set<number>();
private closeOutInFlight = false;
/** When a relayed token last turned out to be one account's own. */
private scopedRefusedAt = 0;
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;
getOrCreateSetting(TRADES_SINCE_SETTING, () => String(Date.now()));
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`
: Date.now() - this.scopedRefusedAt < 30 * 60_000
? `no Vest session for ${this.wallet} — the extension relayed one account's own token, not the login's; reload the Vest tab (or update the extension) to relay the login's`
: `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' | 'PUT' | '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();
void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`));
}
// ── 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;
const token = this.userToken();
if (!token) 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);
// One account's own token (the Vest app renews those through the
// same endpoint the extension watches) sees that account alone:
// syncing on it would drop every other account from the list.
const hasPrimary = (bal.data?.accounts ?? []).some((b) => num(b.account_type) === 1);
if (accountScopedToken({ capitalIds: accounts.map((a) => a.id), hasPrimary, claims: jwtClaimStrings(token) })) {
refuseVestSession(this.wallet, token);
this.scopedRefusedAt = Date.now();
console.warn(`${this.tag} the relayed token is one Vest account's own (it sees ${accounts.length} account, no primary), not the login's — refused; keeping the last account list until the login's token comes`);
return;
}
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);
// The login's own wallet account: where Claim Profit sends a payout.
if (typeof b.account_id === 'string' && num(b.account_type) === 1) this.primaryAccountId = b.account_id;
}
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);
this.recordOpenTrades(acc, mine);
if (mine.length === 0) {
const was = this.flatAmounts.get(id);
if (was !== undefined && Math.abs(was - amount) >= 0.01) this.tradesDue.add(id);
this.flatAmounts.set(id, amount);
} else {
this.flatAmounts.delete(id);
}
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
this.accountCashBalances[id] = {
amount: round2(balance),
realizedPnL: this.realizedSince(id, a, balance, now),
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}` : ''}`;
}
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
this.accountList = list;
if (shrank) void this.closeOutDropped();
// An account gone from Vest's list (breached, closed) takes its
// position with it — a stale one here would hold the lane.
for (const key of Object.keys(this.positions)) {
const id = Number(key);
if (!list.some((a) => a.id === id)) {
delete this.positions[id];
this.positionsDetail.delete(id);
this.seenOpenAt.delete(id);
}
}
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();
this.maybeRefreshTrades();
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);
this.recordedOpen.delete(seen.positionId);
this.tradesDue.add(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: Vest states the day's floor itself — reset balance
* × (1 − rate), lowered by a payout the same day — so the engine's room
* is min(the stage's limit, balance − floor), never more than Vest
* leaves. null until the account has been read; undefined for an account
* Vest gives no daily limit.
*/
public dailyLossFloor(accountId: number): number | null | undefined {
const a = this.capital.get(accountId);
const cash = this.accountCashBalances[accountId];
if (!a || !cash) return null;
if (!(a.maxDailyLossPct > 0)) return undefined;
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
}
private claimsOf(accountId: number): { at: number; amount: number; base: number }[] {
let list = this.claims.get(accountId);
if (!list) {
try {
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
list = Array.isArray(raw) ? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base)) : [];
} catch {
list = [];
}
this.claims.set(accountId, list);
}
return list;
}
/**
* Today's realized P&L as the day plan wants it: since the latest claim
* this client made since the last reset — the plan's new cycle starts
* there — else since the reset. A claim made by hand on Vest, whose
* after-balance this client never saw, is added back to the reset balance
* instead (Vest's reset balance doesn't take the payout out).
*/
private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number {
const reset = lastVestResetMs(now);
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset);
if (mine.length > 0) return round2(balance - mine[mine.length - 1].base);
const resetBalance = this.resetBalanceOf(a, balance, now);
if (resetBalance === undefined) return 0;
const byHand = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).reduce((s, w) => s + Math.abs(w.amount), 0);
return round2(balance - resetBalance + byHand);
}
/** See PlatformClient: the payouts since the last 20:00 ET — this client's
* own claims, or the WITHDRAW transactions Vest shows, whichever counts
* more (a claim made by hand shows only there). */
public sameDayClaims(accountId: number): number {
const reset = lastVestResetMs(Date.now());
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset).length;
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
return Math.max(mine, seen);
}
/**
* See PlatformClient. Vest's Claim Profit: POST /v3/capital/withdraw from
* the funded account to the login's main account, which pays the profit
* split within 24 hours. The account must be flat with no orders, and the
* claim at least $1 of the profit above its starting balance.
*
* The payout is booked here at once — today's fund row, the cycle cut,
* the P&L base — so the account can trade its next stage without waiting
* for the hourly history read, which then rebooks it from Vest's own
* transactions (the same day's row, the same amount).
*/
public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> {
const uuid = this.uuidById.get(accountId);
const account = this.accountList.find((a) => a.id === accountId);
const cash = this.accountCashBalances[accountId];
if (!uuid || !account || !cash) throw new Error('account not read yet');
if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it');
if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open');
const claimed = Math.floor(amount * 100) / 100;
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
const { data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
account: uuid,
body: {
account_id: uuid,
amount: formatDecimal(claimed, 6),
target_account_id: this.primaryAccountId,
idempotency_key: randomUUID(),
},
});
const received = num(data?.trader_amount) ?? null;
const now = Date.now();
const list = [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]
.filter((c) => now - c.at < 7 * 86_400_000);
this.claims.set(accountId, list);
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(list));
// Book it now: today's fund row (with whatever else that day holds),
// the cycle cut, and the balance until the next sync reads Vest's.
const day = vestDayOf(now);
const sameDay = (this.fundTransactions[accountId] ?? []).find((f) => f.date === day)?.amount ?? 0;
this.applyFundRows(account, [{ date: day, amount: round2(sameDay - claimed) }]);
this.accountCashBalances[accountId] = { ...cash, amount: round2(cash.amount - claimed), realizedPnL: 0 };
console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`);
void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`));
void this.requestSync();
return { claimed, received };
}
/**
* Before each 20:00 ET reset, fit every open position on an account with
* a daily limit into the room the reset will leave (resetSafeStop): a
* leg carrying more room than that — a same-day stage on the room a
* payout freed, a last stand — would otherwise breach the moment the
* floor moves up to 97% of the realized balance. Its stop comes in, or
* the position closes when there is no room or the mark is already past.
* Runs in the last RESET_GUARD_MS; a pass that fails retries next tick.
*/
private async guardReset(): Promise<void> {
if (!this.syncComplete || this.intentionalDisconnect) return;
const now = Date.now();
const next = nextVestResetMs(now);
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS || this.resetGuardedFor === next) return;
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) return;
const specs = await this.symbolSpecs();
let failed = 0;
for (const acc of this.accountList) {
const a = this.capital.get(acc.id);
const cash = this.accountCashBalances[acc.id];
const uuid = this.uuidById.get(acc.id);
if (!a || !cash || !uuid || !(a.maxDailyLossPct > 0)) continue;
for (const pos of this.positionsDetail.get(acc.id) ?? []) {
const spec = specs.get(pos.symbol);
if (!spec) continue;
await this.refreshMarks([pos.symbol]).catch(() => { /* the last mark stands */ });
const mark = this.marks.get(pos.symbol)?.mark ?? NaN;
const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null;
const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice);
const decision = resetSafeStop({
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
balance: cash.amount, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notional),
exitFeeRate: spec.takerFee, currentStop: stopLeg?.triggerPrice ?? null, tick: spec.tick,
});
if (decision.action === 'none') continue;
try {
if (decision.action === 'close') {
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
this.markOwn('closed', acc.id, pos);
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
} else {
const triggerPrice = formatDecimal(decision.price, spec.priceDecimals);
if (stopLeg) {
await this.request('trade', 'PUT', '/v3/positions/stop-loss', {
account: uuid,
body: { positionId: pos.positionId, executionType: 'market', triggerPrice, stopLossId: stopLeg.id },
});
} else {
await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice });
}
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${stopLeg?.triggerPrice ?? 'none'})`);
}
} catch (err) {
failed++;
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
}
}
}
if (failed === 0) this.resetGuardedFor = next;
}
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));
}
try {
await this.fetchTrades(account);
} catch (err) {
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
}
}
} finally {
for (const id of ids) this.fetchingIds.delete(id);
this.fetchDaysComplete = true;
}
if (!onlyAccountIds) await this.closeOutDropped();
return { failedAccounts: failed };
}
/**
* Trades the table still shows open on accounts Vest no longer lists
* (breached, closed): read those accounts' position history once more,
* so they close out with how they ended. Their ids come from memory, or
* — after a restart — from Vest's closed-account history. Once per
* account per run; a failure is logged and left.
*/
private async closeOutDropped(): Promise<void> {
if (this.closeOutInFlight || this.intentionalDisconnect || !this.userToken()) return;
const listed = new Set(this.accountList.map((a) => a.id));
const stale = openTradeAccounts().filter((a) => !listed.has(a.accountId) && !this.closeOutTried.has(a.accountId));
if (stale.length === 0) return;
this.closeOutInFlight = true;
try {
let cursor: string | null = null;
for (let page = 0; page < CLOSED_MAX_PAGES && stale.some((a) => !this.uuidById.has(a.accountId)); page++) {
const { data } = await this.request<{ accounts?: unknown[] | null; next?: unknown }>('history', 'GET', '/v3/capital/accounts/history', {
params: { limit: CLOSED_PAGE, ...(cursor ? { cursor } : {}) },
});
for (const raw of data?.accounts ?? []) {
const a = parseCapitalAccount(raw);
if (a && !this.uuidById.has(vestAccountId(a.id))) this.uuidById.set(vestAccountId(a.id), a.id);
}
cursor = typeof data?.next === 'string' && data.next ? data.next : null;
if (!cursor) break;
}
for (const a of stale) {
this.closeOutTried.add(a.accountId);
if (!this.uuidById.has(a.accountId)) {
console.warn(`${this.tag} ${a.accountName}: trades left open, but Vest lists no such account, active or closed — left as they are`);
continue;
}
try {
const acc = { id: a.accountId, name: a.accountName, active: false };
// A closed account may get no token of its own: the
// login's reads it as the app's history tab does.
await this.fetchTrades(acc).catch(() => this.fetchTrades(acc, true));
console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`);
} catch (err) {
console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(err)}`);
}
}
} catch (err) {
console.warn(`${this.tag} closed-account history read failed: ${errorMessage(err)}`);
} finally {
this.closeOutInFlight = false;
}
}
/** 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);
}
/** The account's position history — GET /v3/positions, the app's History
* tab — into the trades table: every position, whoever opened it, with
* how it closed. */
private async fetchTrades(account: PlatformAccount, viaLogin = false): Promise<void> {
const uuid = this.uuidById.get(account.id);
if (!uuid) return;
const trades: VestTrade[] = [];
for (let page = 0; page < TRADES_MAX_PAGES; page++) {
const { data } = await this.request<{ positions?: unknown[] | null; total?: unknown }>('history', 'GET', '/v3/positions', {
...(viaLogin ? {} : { account: uuid }), params: { account_id: uuid, limit: TRADES_PAGE, offset: page * TRADES_PAGE },
});
const rows = data?.positions ?? [];
for (const r of rows) {
const t = parseHistoryPosition(r);
if (t) trades.push(t);
}
const total = num(data?.total);
if (rows.length < TRADES_PAGE || (total !== undefined && (page + 1) * TRADES_PAGE >= total)) break;
}
if (trades.length > 0) saveTrades(trades.map((t) => ({ ...t, accountId: account.id, accountName: account.name })));
}
/** Reads the history of the accounts tradesDue names, one pass at a time.
* A failed read waits for the hourly history pass. */
private maybeRefreshTrades(): void {
if (this.tradesFetchInFlight || this.tradesDue.size === 0 || this.intentionalDisconnect) return;
const ids = [...this.tradesDue];
this.tradesDue.clear();
this.tradesFetchInFlight = true;
void (async () => {
for (const id of ids) {
const account = this.accountList.find((a) => a.id === id);
if (!account || this.intentionalDisconnect) continue;
try {
await this.fetchTrades(account);
} catch (err) {
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
}
}
})().finally(() => {
this.tradesFetchInFlight = false;
});
}
/** Open positions into the trades table as they appear (or change size),
* so a trade shows before it closes. */
private recordOpenTrades(acc: PlatformAccount, list: VestPosition[]): void {
const fresh = list.filter((p) => this.recordedOpen.get(p.positionId) !== p.quantity);
if (fresh.length === 0) return;
try {
saveTrades(fresh.map((p) => ({
positionId: p.positionId, accountId: acc.id, accountName: acc.name, symbol: p.symbol, side: p.side, quantity: p.quantity,
leverage: p.leverage > 0 ? p.leverage : null, openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, closePrice: null,
pnl: null, fee: null, funding: null, openedAt: Number.isFinite(p.openedAt) ? p.openedAt : Date.now(), closedAt: null, closeReason: null,
})));
for (const p of fresh) this.recordedOpen.set(p.positionId, p.quantity);
} catch (err) {
console.warn(`${this.tag} ${acc.name}: couldn't record the open position: ${errorMessage(err)}`);
}
}
/** This client's own order opened or closed the position: say so in the
* trades table (Vest shows an API close as "self-requested", same as a
* click). */
private markOwn(by: 'opened' | 'closed', accountId: number, pos: VestPosition): void {
if (!pos.positionId) return;
try {
markTrade(by, {
positionId: pos.positionId, accountId, accountName: this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId),
symbol: pos.symbol, side: pos.side, quantity: pos.quantity, openPrice: Number.isFinite(pos.openPrice) ? pos.openPrice : null,
openedAt: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(),
});
} catch (err) {
console.warn(`${this.tag} couldn't mark ${pos.positionId} as the bot's: ${errorMessage(err)}`);
}
if (by === 'closed') this.tradesDue.add(accountId);
}
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 fundRows = [...funds.entries()]
.map(([date, amount]) => ({ date, amount: round2(amount) }))
.filter((f) => f.amount !== 0)
.sort((x, y) => x.date.localeCompare(y.date));
this.applyFundRows(account, fundRows);
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
// 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];
// A claim this client just made may not show in Vest's transactions
// yet: count it until its WITHDRAW row does (same amount, near time).
const shown = txns.filter((t) => t.type === VEST_TXN.WITHDRAW);
const unbooked = this.claimsOf(account.id)
.filter((c) => !shown.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.time - c.at) < 30 * 60_000))
.reduce((s, c) => s + c.amount, 0);
const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0) - unbooked;
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);
}
/** Store fund rows (one per Vest day), note where a cycle ended when the
* latest fund day moves forward, and recut the active cycle. */
private applyFundRows(account: PlatformAccount, fundRows: { date: string; amount: number }[]): void {
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows);
this.fundTransactions[account.id] = loadFundTransactions(account.id);
const latest = fundRows.length > 0 ? fundRows.map((f) => f.date).reduce((x, y) => (y > x ? y : x)) : null;
const lastFundDate = latest && (!storedFundDate || latest > storedFundDate) ? latest : storedFundDate ?? latest;
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);
}
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(),
};
this.markOwn('opened', accountId, pos);
} else if (unitsOrdered >= existing.quantity - step / 2) {
this.positionsDetail.set(accountId, []);
delete this.positions[accountId];
this.markOwn('closed', accountId, existing);
}
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();
}
}