Files
Brandon LiandClaude Opus 5.5 8a5b2a64e7 Vest: the bot activates a passed evaluation's funded account, and a passed one no longer holds the sync
A passed Vest evaluation sits at GOAL_PASSED (status 3) until someone presses
"Go to funded account", which sends POST /v3/capital/activate-funded
{eval_account_id} on the evaluation's own token (read from the Vest app's
bundle, 2026-10-29); Vest then opens the funded account and marks the
evaluation CLAIMED (6). The 5 evaluations that passed on 2026-10-29 waited
for a hand activation.

runActivations now asks for it, the way runClaims claims: from the
scheduler's tick (not awaited, so a slow ask doesn't hold the lane's entry)
and from the claim watcher while the scheduler is off, for evaluations with
an account type only, 15 minutes apart after a failure. Off with the
auto_activate setting at 'off' (dashboard: "Auto-activate funded"). The
client asks for an evaluation once at a time, never again after Vest
answered, and after a timeout not until a list read a minute on says either
way; a refusal frees it at once. The rule is the Vest app's own: an
evaluation at GOAL_PASSED, not blocked, on its plan's last step.

Vest's active balance list leaves a passed evaluation out, and a missing
balance made every sync short of good (15:31-15:34Z), which holds and then
quarantines the lane. Only a live account's missing balance does now. Log
lines name Vest's statuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:09:49 -05:00

2460 lines
136 KiB
TypeScript
Raw Permalink 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, resetClearStop, legCloseNote } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, botOpenedPositions,
} from './db';
import { getCachedFirm } from './firm-catalog';
import { firmInstruments } from './instruments';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } 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, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
vestStatusName, awaitingActivation,
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
} from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } 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;
/** An activation whose answer never came may still land at Vest: only a
* capital list read from this long after it gave up is taken as Vest's
* word on it. */
const ACTIVATION_UNKNOWN_HOLD_MS = 60_000;
/** The relayed user token must have this long left to start a request. */
const USER_TOKEN_MIN_MS = 20_000;
/** How long an exit waits for the login's session and its account's token
* — the extension relays the session again within a minute — before it
* gives up as not sent. */
const EXIT_SESSION_WAIT_MS = 90_000;
const EXIT_SESSION_POLL_MS = 2_000;
/** A refused account-token mint is logged again after this long. */
const MINT_REFUSED_LOG_MS = 5 * 60_000;
/** How soon an active account with no history yet is read again, apart
* from a whole pass's retry. */
const NEW_HISTORY_RETRY_MS = 60_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) — and before a market closure that runs
* past one, the bot's own legs are closed (guardClose). */
const RESET_GUARD_MS = 5 * 60_000;
/** No new position opens this close to the reset: the guard's window, and a
* couple of minutes before it for an entry in flight to place its exits. */
const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
/** How long a stop or close the guard just sent counts before a read of
* open positions shows it. */
const GUARD_ECHO_MS = 30_000;
/** An open of unknown outcome is the position opened between its send and
* its error, widened this much for the gateway's clock against this
* machine's (unknownOpenFill). */
const UNKNOWN_OPEN_SLACK_MS = 5_000;
/** The longest an open's POST can be out — an account token minted first,
* a retry (request()) — for one a restart cut off with no error to time. */
const OPEN_IN_FLIGHT_MS = 2 * 60_000;
/** account_meta key of the profit claims this client made, per account. */
const CLAIMS_META = 'vest_claims';
/** account_meta key of the open in flight, or of unknown outcome, per
* account (savePendingOpen). */
const PENDING_OPEN_META = 'vest_pending_open';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const round2 = (n: number) => Math.round(n * 100) / 100;
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 readonly stopSlippageFraction = VEST_STOP_SLIPPAGE_FRACTION;
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, OwnClaim[]>();
/** The sync generation current when each account last claimed: a sync
* of that generation or older read the balance before the claim. */
private claimSeq = new Map<number, number>();
/** When each account's transactions were last read. */
private historyReadAt = new Map<number, number>();
private unmatchedClaimLogged = new Set<string>();
/** 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 is fitting positions for (epoch ms), and the
* stops it placed and closes it sent, by position, with when — a read
* just after may not show them yet. */
private resetGuard = {
reset: 0, stops: new Map<string, { price: number; at: number }>(), closes: new Map<string, number>(),
noSessionLogged: false, unfittable: new Set<string>(),
};
private resetGuardInFlight = false;
/** guardClose's closes just sent (position → when), the hand trades it
* has named (position → the close they were named for), and when it
* last said it couldn't read a symbol's hours or had no session. */
private closeGuard = {
closes: new Map<string, number>(), named: new Map<string, number>(), hoursWarnedAt: new Map<string, number>(), noSessionFor: 0,
};
private closeGuardInFlight = false;
/** Opens whose outcome came back unknown, by account: the position one
* made is the bot's once a read shows it (adoptUnknownOpen) — no exits
* went on it, so it's the leg guardClose most needs to see. Kept in
* account_meta too, from before the send (savePendingOpen), so a
* restart takes them up again (restorePendingOpen). */
private unknownOpens = new Map<number, {
accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; from: number; until: number; positionId?: string;
}>();
/** Accounts whose open from before a restart has been looked for. */
private pendingOpenRestored = new Set<number>();
/** The sync generation current when each account's own open or close
* last finished: a sync of that generation or older may have read
* positions before the fill. */
private ownOrderSeq = new Map<number, number>();
/** When the last sync worked out the accounts' numbers (realizedSince's
* `now`): the Vest day they hold (tradeDate). */
private numbersAt = 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>>();
/** The account-token refusal last logged, and when. */
private mintRefusedNote: { msg: string; at: number } | null = null;
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;
/** When the last read of only the accounts with no history yet began. */
private lastNewHistoryFetchAt = 0;
private newHistoryWarnedAt = 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>();
/** Passed evaluations whose activation is out right now. */
private activationsOut = new Set<number>();
/** Passed evaluations Vest answered an activation for: never asked
* again, even while Vest's list is slow to stop showing them. */
private activated = new Set<number>();
/** Account id → when an activation of unknown outcome gave up; held
* until a list read ACTIVATION_UNKNOWN_HOLD_MS on says either way. */
private activationUnknownAt = new Map<number, number>();
/** When the sync whose capital list this client holds started. */
private capitalReadStartedAt = 0;
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.
* Vest may refuse the mint for one account (401, code 1002 — closed ones,
* and active ones for a while) while the login's own reads go through:
* that fails this account's call alone (request's `mint`). */
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 () => {
let data: { accessToken?: unknown; accessExpiresAtMs?: unknown } | undefined;
try {
({ data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
body: { accountId: uuid }, mint: true,
}));
} catch (err) {
if (err instanceof VestApiError && err.status === 401 && err.code !== 'NO_SESSION') this.noteMintRefused(uuid, err.message);
throw err;
}
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;
}
/** A refused mint, said once in a while — with how long the login's
* token had left, the clue to why Vest refuses them — not once per
* account per pass. */
private noteMintRefused(uuid: string, msg: string): void {
const now = Date.now();
const last = this.mintRefusedNote;
if (last && now - last.at < MINT_REFUSED_LOG_MS && last.msg === msg) return;
this.mintRefusedNote = { msg, at: now };
const name = this.accountList.find((a) => this.uuidById.get(a.id) === uuid)?.name ?? uuid;
const session = vestSessionFor(this.wallet, now, 0);
const left = session ? `${Math.round((session.expiresAt - now) / 1000)}s` : 'none';
console.warn(`${this.tag} ${name}: Vest refused its account token (${msg}; the login's token has ${left} left) — only that account's calls fail, the session stays`);
}
/**
* Before an exit: wait for the login's session (the extension relays it
* again within a minute) and the account's own token, up to
* EXIT_SESSION_WAIT_MS. An exit given up on leaves a position riding
* with no stop, so it waits rather than failing at once; one still
* without them is OrderNotPlacedError — nothing went out.
*/
private async readyForExit(accountId: number, what: string): Promise<void> {
const uuid = this.uuidById.get(accountId);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
const startedAt = Date.now();
let held = false;
let lastError = 'no Vest session';
for (;;) {
if (vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS)) {
try {
if (uuid) await this.accountToken(uuid);
if (held) console.log(`${this.tag} ${name}: ${what} — ready after ${Math.round((Date.now() - startedAt) / 1000)}s, sending`);
return;
} catch (err) {
lastError = errorMessage(err);
}
} else {
lastError = 'no Vest session';
}
const waited = Date.now() - startedAt;
if (this.intentionalDisconnect || waited + EXIT_SESSION_POLL_MS > EXIT_SESSION_WAIT_MS) {
throw new OrderNotPlacedError(`${what} not sent — ${lastError}${held ? ` (waited ${Math.round(waited / 1000)}s)` : ''}`);
}
if (!held) console.warn(`${this.tag} ${name}: ${what} held — ${lastError}; waiting up to ${EXIT_SESSION_WAIT_MS / 1000}s before sending`);
held = true;
await sleep(EXIT_SESSION_POLL_MS);
}
}
// ── 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.
* An account token that can't be minted is VestNotSentError: the call
* itself never went out. 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 — but not on a refused mint (`mint`), which is one
* account's: the login's own reads go on through on the same token.
*/
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; mint?: boolean } = {},
): Promise<{ data: T }> {
let token: string | null = null;
if (opts.auth !== false) {
const account = opts.account;
token = account ? await tokenForRequest(path, () => this.accountToken(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 if (!opts.mint) {
// The user token itself was refused: forget it, and wait for the
// tab's next renewal rather than retrying it. (A refused mint
// dropped it too until 2026-10-29, and every account after it
// in a history pass failed "no Vest session" until the
// extension relayed it again.)
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)}`));
void this.guardClose().catch((err) => console.error(`${this.tag} market-close 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);
this.restorePendingOpen(id, vestAccountName(a));
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
if (awaitingActivation(a)) console.log(`${this.tag} ${vestAccountName(a)} passed — its funded account waits on activation`);
else console.warn(`${this.tag} ${vestAccountName(a)} is ${vestStatusName(a.status)} — not trading it`);
}
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);
// An open or close of this client's that finished while this
// sync was out: what it read may predate the fill, and writing
// it back would lose the position the order saw (or bring back
// the one it closed) — the record a flatten goes by
// (orderEffect). The order's own stands until a sync that
// started after it.
const ownSince = (this.ownOrderSeq.get(id) ?? -1) >= gen;
if (!ownSince) this.positionsDetail.set(id, mine);
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
if (!ownSince) this.applyPosition(acc, mine);
this.adoptUnknownOpen(id, mine, startedAt);
const amount = free.get(a.id);
// Only a live account's balance is owed: Vest's active list
// leaves out a passed evaluation's (seen 2026-10-29), and
// waiting on it kept every sync short of good — the lane held,
// and nothing ran that would activate it.
if (amount === undefined) {
if (a.status === VEST_STATUS_ACTIVE) 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);
// A claim made while this sync was out: what it read predates
// the payout, and writing it back would show the profit again —
// a second claim of it. The claim's own booking stands until a
// sync that started after it.
if ((this.claimSeq.get(id) ?? -1) >= gen && this.accountCashBalances[id]) continue;
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}` : ''}`;
}
this.numbersAt = now;
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
this.accountList = list;
this.capitalReadStartedAt = startedAt;
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 what's left of the stage's limit, widened by dayGainForRoom, never
* more than balance − floor (floorRoom). 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;
}
/**
* See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
* which its daily rows, realized P&L (realizedSince) and floors follow —
* as of the last sync's numbers. Past 20:00 ET they still hold the day
* that ended until a sync works them out again, and the fresh day would
* count it as a prior day and as today's P&L both. A sync that stalls
* holds the day back no longer than the scheduler trades on its numbers
* (isClientReady). Before any sync, the day now.
*/
public tradeDate(now: number): string {
return vestDayOf(this.numbersAt > 0 ? Math.min(now, this.numbersAt) : now);
}
/** Past 8 PM ET with the numbers still the ended day's: a leg opened now
* would be planned and counted on that day, so none opens until a sync
* has read the new one. */
private newDayUnread(now: number): boolean {
return this.tradeDate(now) !== vestDayOf(now);
}
/**
* See PlatformClient: the gain since the day's base that is still in the
* account — since this client's own claim today, else since the reset,
* with no add-back for a claim made by hand (that profit is gone) — up to
* the stop that keeps the leg all but sure to close before the 8 PM ET
* reset at the firm's target hold (resetClearStop); none before Vest has
* captured today's reset balance.
*/
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
const now = Date.now();
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId), this.tradeDate(now));
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
if (!(allowed > 0)) return 0;
const a = this.capital.get(accountId);
const cash = this.accountCashBalances[accountId];
if (!a || !cash) return 0;
const mine = this.madeClaims(accountId).filter((c) => c.at >= lastVestResetMs(now));
const base = mine.length > 0 ? mine[mine.length - 1].base
: Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now) ? a.dailyResetBalance : undefined;
return base === undefined || !Number.isFinite(base) ? 0 : Math.max(0, Math.min(round2(cash.amount - base), round2(allowed)));
}
private claimsOf(accountId: number): OwnClaim[] {
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))
.map((c): OwnClaim => ({
at: c.at, amount: c.amount, base: c.base,
...(Number.isFinite(c.sentAt) ? { sentAt: c.sentAt } : {}),
...(c.unknown === true ? { unknown: true as const } : {}),
}))
: [];
} catch {
list = [];
}
this.claims.set(accountId, list);
}
return list;
}
/** The claims this client knows went in (the platform said so). */
private madeClaims(accountId: number): OwnClaim[] {
return this.claimsOf(accountId).filter((c) => !c.unknown);
}
private saveClaims(accountId: number, list: OwnClaim[]): void {
const now = Date.now();
const kept = list.filter((c) => now - c.at < 7 * 86_400_000);
this.claims.set(accountId, kept);
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept));
}
/** The claims Vest's transactions, as last read (at `readAt`), don't
* show yet. One of unknown outcome still not shown an hour on never went
* in: it's dropped here (settleClaims). */
private outstandingClaims(accountId: number, readAt: number): OwnClaim[] {
const { pending, lapsed } = settleClaims(this.claimsOf(accountId), this.withdrawals.get(accountId) ?? [], readAt);
this.forgetLapsed(accountId, lapsed);
return pending;
}
/** Drop claims of unknown outcome that never went in (settleClaims). */
private forgetLapsed(accountId: number, lapsed: OwnClaim[]): void {
if (lapsed.length === 0) return;
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
this.saveClaims(accountId, this.claimsOf(accountId).filter((c) => !lapsed.includes(c)));
for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`);
}
/**
* 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.madeClaims(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, on Vest's day once its transactions show them, or the
* WITHDRAW transactions Vest shows, whichever counts more (a claim made
* by hand shows only there). */
public sameDayClaims(accountId: number): number {
return payoutsSince(this.withdrawals.get(accountId) ?? [], this.claimsOf(accountId), lastVestResetMs(Date.now()));
}
/** See PlatformClient: one per payout, on its Vest day — Vest's WITHDRAW
* transactions, and this client's claims they don't show yet
* (payoutDaysOf) — where a fund row holds a whole Vest day's. Undefined
* until the account's history is read. */
public payoutDays(accountId: number): string[] | undefined {
const withdrawals = this.withdrawals.get(accountId);
const fundRows = this.fundTransactions[accountId];
if (!withdrawals || !fundRows || !this.historyLoadedIds.has(accountId)) return undefined;
return payoutDaysOf(withdrawals, this.claimsOf(accountId), fundRows);
}
/**
* 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 — or the day Vest
* dates it, when a claim at 20:00 ET lands on the other side).
*/
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];
const meta = this.capital.get(accountId);
if (!uuid || !account || !cash || !meta) 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');
if (this.claimPending(accountId)) throw new Error("the last claim hasn't shown in Vest's transactions yet");
// Vest's own word over the account type assigned here: only a funded
// (or instant) account pays out, and only above its starting capital.
if (meta.kind !== 'funded' && !/instant/i.test(meta.productType)) {
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not funded'} — only a funded account's profit can be claimed`);
}
const claimed = Math.floor(amount * 100) / 100;
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
if (meta.initialCapital > 0 && claimed > cash.amount - meta.initialCapital + 0.01) {
throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`);
}
// Taken before it goes out, which Vest dates it no earlier than: a
// claim answered after 20:00 ET isn't the fresh day's payout until
// Vest says so (payoutsSince).
const sentAt = Date.now();
let data: { trader_amount?: unknown } | undefined;
try {
({ 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(),
},
}));
} catch (err) {
// Only a refusal, or no account token to send it with, proves
// nothing went in. A timeout, a reset or a 5xx may have: hold
// every further claim on the account until Vest's transactions
// say either way (claimPending).
if (!nothingWentIn(err)) {
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), sentAt, amount: claimed, base: round2(cash.amount - claimed), unknown: true }]);
this.claimSeq.set(accountId, this.syncGeneration);
console.error(`${this.tag} ${account.name}: claim of $${claimed} — outcome unknown (${errorMessage(err)}); no further claim on it until Vest's transactions settle it`);
void this.fetchDaysTraded([accountId]).catch(() => { /* the hourly read retries */ });
}
throw err;
}
const received = num(data?.trader_amount) ?? null;
const now = Date.now();
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, sentAt, amount: claimed, base: round2(cash.amount - claimed) }]);
this.claimSeq.set(accountId, this.syncGeneration);
// 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)}`));
// A fresh sync — after any one already out, whose reads predate this.
void this.syncRunning.then(() => this.requestSync());
return { claimed, received };
}
/** See PlatformClient: the passed evaluations on Vest's list whose funded
* account waits on activation, less any this client's asks hold. */
public activatable(): number[] {
return this.accountList.flatMap((acc) => {
const meta = this.capital.get(acc.id);
return meta && awaitingActivation(meta) && !this.activationHeld(acc.id) ? [acc.id] : [];
});
}
/** An ask of this client's settles the evaluation until Vest's list can
* speak for it: one out now, one Vest answered (for good), or one whose
* answer never came, until a list read a while after it gave up. The
* list a sync reads while an ask is out predates Vest's answer. */
private activationHeld(accountId: number): boolean {
if (this.activationsOut.has(accountId) || this.activated.has(accountId)) return true;
const unknownAt = this.activationUnknownAt.get(accountId);
return unknownAt !== undefined && this.capitalReadStartedAt < unknownAt + ACTIVATION_UNKNOWN_HOLD_MS;
}
/**
* See PlatformClient. Vest's "Go to funded account": POST
* /v3/capital/activate-funded with the evaluation's own token, as the Vest
* app sends it. Vest answers with the funded account it opened, and marks
* the evaluation CLAIMED. Nothing to pay and no terms to accept; Vest
* refuses one past its cap on funded accounts.
*
* One ask at a time per evaluation, and none after Vest answered one. One
* that timed out may have gone in: none until a list read a minute on
* says either way. A refusal frees it at once.
*/
public async activateFunded(accountId: number): Promise<{ funded: string | null }> {
const uuid = this.uuidById.get(accountId);
const account = this.accountList.find((a) => a.id === accountId);
const meta = this.capital.get(accountId);
if (!uuid || !account || !meta) throw new Error('account not read yet');
if (!awaitingActivation(meta)) {
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not an evaluation'}, ${vestStatusName(meta.status)} — only a passed evaluation's funded account is activated`);
}
if (this.activationHeld(accountId)) throw new Error("the last activation hasn't been read back from Vest yet");
// Taken before the first await: a second caller meanwhile is refused.
this.activationsOut.add(accountId);
let data: { account?: unknown } | undefined;
try {
({ data } = await this.request<{ account?: unknown }>('trade', 'POST', '/v3/capital/activate-funded', {
account: uuid,
body: { eval_account_id: uuid },
}));
} catch (err) {
if (!nothingWentIn(err)) {
this.activationUnknownAt.set(accountId, Date.now());
console.error(`${this.tag} ${account.name}: activation — outcome unknown (${errorMessage(err)}); not asked again until Vest's list says either way`);
void this.syncRunning.then(() => this.requestSync());
}
throw err;
} finally {
this.activationsOut.delete(accountId);
}
this.activationUnknownAt.delete(accountId);
this.activated.add(accountId);
const opened = parseCapitalAccount(data?.account);
const funded = opened ? vestAccountName(opened) : null;
console.log(`${this.tag} ${account.name}: activated its funded account${funded ? ` ${funded}` : ''}`);
// A fresh sync lists it — after any one already out, whose reads predate this.
void this.syncRunning.then(() => this.requestSync());
return { funded };
}
/**
* See PlatformClient: a claim of this client's that Vest's transactions
* don't show yet — until they do, the balance on hand can't be trusted
* not to show the claimed profit again. One whose outcome was unknown,
* and that a history read an hour on still doesn't show, never went in:
* it's dropped. One Vest accepted holds for as long as it doesn't show
* (noted once in the log after an hour).
*/
public claimPending(accountId: number): boolean {
const now = Date.now();
const pending = this.outstandingClaims(accountId, this.historyReadAt.get(accountId) ?? 0);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
for (const c of pending) {
const key = `${accountId}:${c.at}`;
if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) {
this.unmatchedClaimLogged.add(key);
console.error(`${this.tag} ${name}: the claim of $${c.amount} an hour ago still doesn't show in Vest's transactions — no further claim on this account until it does`);
}
}
return pending.length > 0;
}
/**
* 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, a split day's leg widened by its gain, a
* hand trade — would otherwise breach the moment the floor moves up to
* 97% of the realized balance. Its stop comes in — on the profit side of
* the entry when the account's other positions took the room — or the
* position closes when the mark is already past that price. An account's
* positions share the room: those with a stop first, least at risk
* first, then those with none, oldest first.
*
* Every tick of the last RESET_GUARD_MS, on positions read afresh, so one
* opened by hand after the first pass is fitted too; no new position
* opens in that window (RESET_ENTRY_BLOCK_MS).
*/
private async guardReset(): Promise<void> {
if (!this.syncComplete || this.intentionalDisconnect || this.resetGuardInFlight) return;
const now = Date.now();
const next = nextVestResetMs(now);
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS) return;
if (this.resetGuard.reset !== next) {
this.resetGuard = { reset: next, stops: new Map(), closes: new Map(), noSessionLogged: false, unfittable: new Set() };
}
const guard = this.resetGuard;
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) {
const open = [...this.positionsDetail.values()].flat().length;
if (open > 0 && !guard.noSessionLogged) {
guard.noSessionLogged = true;
console.error(`${this.tag} ${open} open position(s) can't be fitted to the 8 PM ET reset — no Vest session (open the Vest tab)`);
}
return;
}
this.resetGuardInFlight = true;
try {
const specs = await this.symbolSpecs();
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
const open = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
const now2 = Date.now();
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;
// The account's positions share the room: each is fitted to
// what the ones before it leave — their loss at their stops,
// and a reserve for the slippage of all of them together.
// Those with a stop go first, the least at risk first, so one
// that already fits keeps it; those with none get the rest.
// The stop Vest shows wins (moved by hand after the guard, it
// is fitted again); one the guard just placed counts only
// while a read may not show it yet. A close just sent is left
// for the same while.
const stopOf = (p: VestPosition): number | null => {
const placed = guard.stops.get(p.positionId);
return p.stopLosses.find((l) => l.status === 'awaiting')?.triggerPrice
?? (placed && now2 - placed.at < GUARD_ECHO_MS ? placed.price : null);
};
// What the position loses at its stop — negative when the stop
// locks in a profit, which the others may then use.
const lossAt = (p: VestPosition, stop: number) => (p.side === 'long' ? p.openPrice - stop : stop - p.openPrice) * p.quantity;
const held = open
.filter((p) => p.accountId === uuid && now2 - (guard.closes.get(p.positionId) ?? 0) >= GUARD_ECHO_MS)
.map((p) => ({ pos: p, currentStop: stopOf(p) }))
.sort((x, y) => (x.currentStop === null ? 1 : 0) - (y.currentStop === null ? 1 : 0)
|| (x.currentStop !== null && y.currentStop !== null ? lossAt(x.pos, x.currentStop) - lossAt(y.pos, y.currentStop) : 0)
|| x.pos.openedAt - y.pos.openedAt || x.pos.positionId.localeCompare(y.pos.positionId));
let committed = 0;
let notionalSoFar = 0;
for (const { pos, currentStop } of held) {
const spec = specs.get(pos.symbol);
if (!spec) {
// Can't be fitted without its tick, but it still holds
// its loss against the room the others share.
notionalSoFar += pos.quantity * pos.openPrice;
if (currentStop !== null) committed += lossAt(pos, currentStop);
else if (!guard.unfittable.has(pos.positionId)) {
guard.unfittable.add(pos.positionId);
console.error(`${this.tag} ${acc.name}: ${pos.symbol} has no stop and no symbol spec — it can't be fitted to the 8 PM ET reset; close it or add a stop on Vest`);
}
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);
notionalSoFar += notional;
const exitFee = spec.takerFee * notional;
const decision = resetSafeStop({
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
floorBalance: cash.amount,
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notionalSoFar, this.stopSlippageFraction),
exitFeeRate: spec.takerFee, currentStop, tick: spec.tick,
});
const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null;
if (stopAt !== null) committed += lossAt(pos, stopAt) + exitFee;
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' });
guard.closes.set(pos.positionId, Date.now());
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 });
}
guard.stops.set(pos.positionId, { price: decision.price, at: Date.now() });
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${currentStop ?? 'none'})`);
}
} catch (err) {
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
// A stop on the profit side that Vest won't take: the
// position can't carry its old one past the reset.
if (decision.action === 'stop' && lossAt(pos, decision.price) < 0) {
try {
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
guard.closes.set(pos.positionId, Date.now());
this.markOwn('closed', acc.id, pos);
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — its profit-side stop was refused`);
} catch (closeErr) {
console.error(`${this.tag} ${acc.name}: couldn't close ${pos.symbol} either: ${errorMessage(closeErr)}`);
}
}
}
}
}
} finally {
this.resetGuardInFlight = false;
}
}
/**
* Before a long market closure — one that keeps a symbol's market shut
* across an 8 PM ET reset: the weekend, a holiday (longClosure) — close
* the bot's own legs in it still open in the last RESET_GUARD_MS, while
* the market takes orders. Inside the closure nothing exits, the reset
* guard's stops and closes are refused, and the reopen's gap jumps a
* stop. No leg starts under RESET_CLEAR_HOLDS holds of a close
* (pickCandidate), so this only meets one that outlived them.
*
* Only positions the bot's own order opened (markOwn, or an open of
* unknown outcome that turned out filled: adoptUnknownOpen): one without
* that mark — a hand trade, or one from before marking began — is left
* as it is and named once in the log. The daily maintenance hour reopens
* before the reset, and is ridden.
*/
private async guardClose(): Promise<void> {
if (!this.syncComplete || this.intentionalDisconnect || this.closeGuardInFlight) return;
const held = [...new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol))];
if (held.length === 0) return;
this.closeGuardInFlight = true;
try {
const guard = this.closeGuard;
const now = Date.now();
for (const [id, at] of guard.closes) if (now - at >= GUARD_ECHO_MS) guard.closes.delete(id);
for (const [id, closesAt] of guard.named) if (closesAt < now) guard.named.delete(id);
// This runs all day while anything is open: hours it can't read
// are said once in a while, not every tick.
const unreadable = (what: string, err: unknown) => {
if (now - (guard.hoursWarnedAt.get(what) ?? 0) < MARKET_HOURS_TTL_MS) return;
guard.hoursWarnedAt.set(what, now);
console.warn(`${this.tag} ${what}: market hours unreadable — can't tell whether its market is about to close (${errorMessage(err)})`);
};
let specs: Map<string, SymbolSpec>;
try {
specs = await this.symbolSpecs();
} catch (err) {
unreadable(held.join(', '), err);
return;
}
// Each held symbol whose long closure is this near, and when.
const closing = new Map<string, number>();
for (const symbol of held) {
const spec = specs.get(symbol);
if (!spec) continue;
let periods: unknown;
try {
periods = await this.marketHours(spec.assetId);
} catch (err) {
unreadable(symbol, err);
continue;
}
const hours = marketOpen(periods, now);
if (hours.open && hours.closesAt !== null && hours.closesAt - now <= RESET_GUARD_MS && longClosure(periods, hours.closesAt)) {
closing.set(symbol, hours.closesAt);
}
}
if (closing.size === 0) return;
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) {
const first = Math.min(...closing.values());
if (guard.noSessionFor !== first) {
guard.noSessionFor = first;
console.error(`${this.tag} the market closes in ${Math.ceil((first - now) / 60_000)} min until past an 8 PM ET reset, and the bot's open legs can't be closed — no Vest session (open the Vest tab)`);
}
return;
}
const readAt = Date.now();
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
const all = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
for (const id of [...this.unknownOpens.keys()]) this.adoptUnknownOpen(id, all, readAt);
const open = all.filter((p) => closing.has(p.symbol));
const mine = botOpenedPositions(open.map((p) => p.positionId));
for (const acc of this.accountList) {
const uuid = this.uuidById.get(acc.id);
if (!uuid) continue;
for (const pos of open.filter((p) => p.accountId === uuid)) {
const closesAt = closing.get(pos.symbol)!;
const inMin = Math.max(0, Math.ceil((closesAt - Date.now()) / 60_000));
if (!mine.has(pos.positionId)) {
if (guard.named.get(pos.positionId) !== closesAt) {
guard.named.set(pos.positionId, closesAt);
console.error(`${this.tag} ${acc.name}: ${pos.side} ${pos.symbol} isn't marked as the bot's (a hand trade, or a bot leg from before marking or a restart mid-entry) and stays open — its market closes in ${inMin} min until past an 8 PM ET reset; close it on Vest if it shouldn't carry the closure and the reopen's gap`);
}
continue;
}
// A close just sent counts until a read of open positions shows it.
if (guard.closes.has(pos.positionId)) continue;
try {
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
guard.closes.set(pos.positionId, Date.now());
this.markOwn('closed', acc.id, pos);
console.error(`${this.tag} ${acc.name}: CLOSED the bot's ${pos.side} ${pos.quantity} ${pos.symbol} at market — its market closes in ${inMin} min until past an 8 PM ET reset, and nothing could exit it there`);
} catch (err) {
console.error(`${this.tag} ${acc.name}: couldn't close the bot's ${pos.symbol} before its market closes in ${inMin} min: ${errorMessage(err)}`);
}
}
}
} finally {
this.closeGuardInFlight = false;
}
}
/** An open of unknown outcome that a read (started at `readAt`) shows
* filled after all: the position is the bot's (markOwn). One the read
* doesn't show by then never filled — a market open is IOC. Several
* that could be it: none is marked, and it's said loudly. */
private adoptUnknownOpen(accountId: number, positions: VestPosition[], readAt: number): void {
const sent = this.unknownOpens.get(accountId);
if (!sent) return;
const found = unknownOpenFill(sent, positions);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
if (found.length === 1) {
const pos = found[0];
this.forgetUnknownOpen(accountId, sent.sentAt);
this.markOwn('opened', accountId, pos);
console.warn(`${this.tag} ${name}: the open of unknown outcome filled after all — ${pos.side} ${pos.quantity} ${pos.symbol} (${pos.positionId}) marked as the bot's`);
} else if (found.length > 1) {
this.forgetUnknownOpen(accountId, sent.sentAt);
console.error(`${this.tag} ${name}: the ${sent.side} ${sent.symbol} open of unknown outcome could be any of ${found.length} positions opened with it`
+ ` (${found.map((p) => p.positionId).join(', ')}) — none marked as the bot's, so none is closed before a market closure; check them on Vest`);
} else if (readAt > sent.until) {
this.forgetUnknownOpen(accountId, sent.sentAt);
}
}
private forgetUnknownOpen(accountId: number, sentAt: number): void {
this.unknownOpens.delete(accountId);
this.dropPendingOpen(accountId, sentAt);
}
/** Note an open before it goes out, and what is learned of it (its
* position id, an unknown outcome's window): a restart before its
* outcome is known still finds the position it made. */
private savePendingOpen(accountId: number, open: { accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; positionId?: string; until?: number }): void {
// This run's own record from here: nothing from before to take up.
this.pendingOpenRestored.add(accountId);
try {
saveAccountMeta(accountId, PENDING_OPEN_META, JSON.stringify(open));
} catch (err) {
console.warn(`${this.tag} couldn't note the open in flight: ${errorMessage(err)}`);
}
}
/** The open sent at `sentAt` has an outcome: its note goes — not a later
* open's. */
private dropPendingOpen(accountId: number, sentAt: number): void {
try {
const open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
if (open?.sentAt === sentAt) saveAccountMeta(accountId, PENDING_OPEN_META, 'null');
} catch (err) {
console.warn(`${this.tag} couldn't drop the note of an open: ${errorMessage(err)}`);
}
}
/** An open still out when the app last stopped (a restart mid-entry):
* taken up as one of unknown outcome — its window the send to the most
* its POST could be out, when no error timed it — once per account per
* run. */
private restorePendingOpen(accountId: number, name: string): void {
if (this.pendingOpenRestored.has(accountId)) return;
this.pendingOpenRestored.add(accountId);
let open: Record<string, unknown> | null;
try {
open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
} catch {
return;
}
if (!open || typeof open.accountId !== 'string' || typeof open.symbol !== 'string'
|| (open.side !== 'long' && open.side !== 'short') || typeof open.sentAt !== 'number' || !Number.isFinite(open.sentAt)) return;
const sentAt = open.sentAt;
this.unknownOpens.set(accountId, {
accountId: open.accountId, symbol: open.symbol, side: open.side, sentAt,
from: sentAt - UNKNOWN_OPEN_SLACK_MS,
until: typeof open.until === 'number' ? open.until : sentAt + OPEN_IN_FLIGHT_MS + UNKNOWN_OPEN_SLACK_MS,
...(typeof open.positionId === 'string' ? { positionId: open.positionId } : {}),
});
console.warn(`${this.tag} ${name}: a ${open.side} ${open.symbol} open was out when the app last stopped — a position it made is marked the bot's once a read shows it`);
}
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;
// An active account with no history yet can't trade: it is read on
// its own every NEW_HISTORY_RETRY_MS, a whole pass's retry pending or
// not — on 2026-10-29 five new accounts sat out retries of passes
// that failed. An inactive one waits for a whole pass.
const waiting = !initialFetchDue && now - this.lastNewHistoryFetchAt >= NEW_HISTORY_RETRY_MS
? this.accountList.filter((a) => a.active && !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 (targeted) this.lastNewHistoryFetchAt = 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 && targeted) {
// Read again on their own within the minute: said now and then.
if (Date.now() - this.newHistoryWarnedAt >= 5 * 60_000) {
this.newHistoryWarnedAt = Date.now();
console.warn(`${this.tag} ${failedAccounts} new account(s) failed history fetch — reading them again every minute`);
}
return;
}
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) => {
if (!targeted) this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
console.error(`${this.tag} history fetch failed — ${targeted ? 'reading the new account(s) again within a minute' : 'retrying in 5 minutes'}: ${errorMessage(err)}`);
})
.finally(() => {
this.daysFetchInFlight = false;
this.daysFetchInFlightSince = 0;
});
}
/** Read the accounts' history: those with none yet first (they can't
* trade until it's read), then the other active ones, the inactive
* last. Only an active account's failure counts — an inactive one's is
* noted, and waits for the hourly pass. */
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
const rank = (a: PlatformAccount) => (!a.active ? 2 : this.historyLoadedIds.has(a.id) ? 1 : 0);
const ids = this.accountList
.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id))
.sort((x, y) => rank(x) - rank(y))
.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) {
if (account.active) 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 gets no token of its own (Vest refuses
// the mint): the login's reads it as the app's history
// tab does, and its own token is the fallback.
await this.fetchTrades(acc, true).catch(() => this.fetchTrades(acc));
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 readAt = Date.now();
const read = readClaims({
txns, funds, claims: this.claimsOf(account.id), stored: loadFundTransactions(account.id), readAt,
opening: hasDeposit ? 0 : a?.initialCapital ?? 0,
});
this.withdrawals.set(account.id, read.withdrawals);
this.historyReadAt.set(account.id, readAt);
// Settled here, not only in claimPending: its callers wait on
// historyReady, which a claim that never went in would hold below
// for good.
this.forgetLapsed(account.id, read.lapsed);
// A claim Vest dates on the other side of 20:00 ET than it was booked
// here leaves that day's row to this client alone: taken back, so the
// payout counts once, on Vest's day.
if (read.dropped.length > 0) this.dropClaimDays(account, read.dropped);
this.applyFundRows(account, read.rows);
// 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: readClaims counts it until its WITHDRAW row does (same amount,
// near time).
const expected = read.expected;
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);
}
/** Take back fund rows only a claim of this client's put there (see
* claimFundRows), and the latest fund date with them when it was one —
* back to the latest row left. The ended-cycle start that date saved
* stays: the next fund day saves the same one. */
private dropClaimDays(account: PlatformAccount, days: string[]): void {
const stored = loadAccountMeta(account.id, 'last_fund_date');
const left = loadFundTransactions(account.id).map((f) => f.date).filter((d) => !days.includes(d));
const last = stored !== null && !days.includes(stored) ? stored : left.reduce<string | null>((x, y) => (x === null || y > x ? y : x), null);
for (const day of days) dropFundTransaction(account.id, day, { last_fund_date: last });
console.warn(`${this.tag} ${account.name}: the claim booked on ${days.join(', ')} shows on another Vest day — that row taken back`);
}
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, this.tradeDate(Date.now()));
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();
// A leg a long closure (a weekend, a holiday) catches open can't be
// exited until the market reopens, and meets the reopen's gap with
// its stop: none starts under RESET_CLEAR_HOLDS holds of one — the
// daily reset's clearance. The daily maintenance hour reopens before
// the reset and is ridden, as futures legs ride CME's daily break.
// Such a symbol stays on the board (the dashboard's candidates) but
// isn't picked (legCloseNote); fireLeg holds each account's own leg
// to it too.
const hold = ctx?.targetHoldHours ?? targetHold;
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closing?: boolean }[] = [];
// No new position this close to the reset: nothing picked, and the
// last survey's candidates stay up for the dashboard.
const toReset = nextVestResetMs(now) - now;
if (root !== 'survey' && toReset <= RESET_ENTRY_BLOCK_MS) {
console.log(`${this.tag} ${root} → nothing tradable — Vest's 8 PM ET reset in ${Math.ceil(toReset / 60_000)} min`);
return null;
}
if (root !== 'survey' && this.newDayUnread(now)) {
console.log(`${this.tag} ${root} → nothing tradable — Vest's day has turned and no sync has read it yet`);
return null;
}
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 };
let gateAt: number | null;
try {
const periods = await this.marketHours(spec.assetId);
hours = marketOpen(periods, now);
gateAt = hours.closesAt !== null && longClosure(periods, hours.closesAt) ? hours.closesAt : null;
} 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; }
const closing = legCloseNote(gateAt, hold, now);
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` : '')
+ (closing ? `, ${closing}` : hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
closing: closing !== null,
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,
closesAt: gateAt ?? undefined,
},
});
}
let ranked = board.filter((b) => b.contract && !b.closing).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)}`;
// Held short of the target hold (a leverage cap), the leg
// runs longer: it has to clear the close as well.
const closing = legCloseNote(b.contract!.closesAt, ev.holdHours, now);
if (closing) { b.note += `, ${closing}`; return []; }
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 —
* only the one `intent` names, when given (orderEffect). 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,
intent?: OrderIntent,
): Promise<Record<string, unknown>> {
try {
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
} 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,
intent?: OrderIntent,
): Promise<Record<string, unknown>> {
// A close (a flatten, most of all) waits out a lapse in the session;
// an open goes now or not at all.
if (intent === 'close') await this.readyForExit(accountId, 'close');
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 held = positions.find((p) => p.symbol === symbol);
const recorded = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol)?.side ?? null;
// orderEffect picks open or close; `existing` is the position a close closes.
const existing = orderEffect({ symbol, held: held?.side ?? null, recorded, side, intent }) === 'close' ? held : undefined;
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 toReset = nextVestResetMs(Date.now()) - Date.now();
if (toReset <= RESET_ENTRY_BLOCK_MS) {
throw new OrderNotPlacedError(`Vest's 8 PM ET reset is ${Math.ceil(toReset / 60_000)} min away — no new position until it passes`);
}
if (this.newDayUnread(Date.now())) {
throw new OrderNotPlacedError(`Vest's day has turned and no sync has read it yet — no new position until one has`);
}
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();
// An open is noted before it goes out: a restart before its outcome
// is known still finds the position it made (restorePendingOpen).
if (opening) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt });
let placed: { orderId?: string; positionId?: string } | undefined;
try {
placed = await this.submit(uuid, path, body);
console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`);
if (opening && placed.positionId) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt, positionId: placed.positionId });
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.ownOrderSeq.set(accountId, this.syncGeneration);
this.markOwn('opened', accountId, pos);
this.dropPendingOpen(accountId, sentAt);
} else if (unitsOrdered >= existing.quantity - step / 2) {
this.positionsDetail.set(accountId, []);
delete this.positions[accountId];
this.ownOrderSeq.set(accountId, this.syncGeneration);
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;
} catch (err) {
// An open that may have gone in: no exits will go on it, so the
// position it made, once a read shows it, is marked the bot's —
// the one its answer named, else one opened by now (IOC).
if (opening && !isOrderNotPlaced(err)) {
const open = {
accountId: uuid, symbol, side, sentAt, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
...(placed?.positionId ? { positionId: placed.positionId } : {}),
};
this.unknownOpens.set(accountId, { ...open, from: sentAt - UNKNOWN_OPEN_SLACK_MS });
this.savePendingOpen(accountId, open);
} else if (opening) {
this.dropPendingOpen(accountId, sentAt);
}
throw err;
} 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 a failure that proves nothing was placed becomes
* OrderNotPlacedError (a 4xx with the gateway's refusal, or no account
* token to send it with); 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 VestNotSentError) throw new OrderNotPlacedError(`Vest order not sent: ${err.message}`);
if (nothingWentIn(err)) throw new OrderNotPlacedError(`Vest refused the order: ${errorMessage(err)}`);
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 }> {
await this.readyForExit(accountId, orderType === 'Limit' && price !== undefined ? 'take-profit' : 'close');
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 }> {
await this.readyForExit(accountId, 'exits');
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();
}
}