The close gate held every Vest entry within four holds of any close, which with a 2 h hold meant no weekday entry from about 9 AM to 5 PM ET for the one-hour maintenance break. That hour reopens before the reset, as CME's daily break does for futures legs, so only a weekend or holiday closure gates an entry now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2108 lines
116 KiB
TypeScript
2108 lines
116 KiB
TypeScript
/**
|
||
* 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, 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 } from './platform-client';
|
||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||
import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
|
||
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
|
||
import {
|
||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
|
||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION,
|
||
} from './vest-helpers';
|
||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers';
|
||
|
||
const BASE = 'https://api-gateway.hz.vestmarkets.com';
|
||
/** The web app's origin: the gateway serves it, so requests say they are its. */
|
||
const APP_ORIGIN = 'https://next.vestmarkets.com';
|
||
|
||
// ── timings ──────────────────────────────────────────────────────────────────
|
||
|
||
const SYNC_INTERVAL_MS = 60_000;
|
||
const SYNC_WEDGED_MS = 3 * 60_000;
|
||
const WATCHDOG_INTERVAL_MS = 15_000;
|
||
/** Request spacing: the gateway's limits aren't published, so stay well under
|
||
* what its own app does. */
|
||
const SPACING_MS = { read: 150, trade: 100, history: 400 } as const;
|
||
type Bucket = keyof typeof SPACING_MS;
|
||
const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const;
|
||
/** A cached account token is renewed this long before it expires. */
|
||
const ACCOUNT_TOKEN_SKEW_MS = 90_000;
|
||
/** The relayed user token must have this long left to start a request. */
|
||
const USER_TOKEN_MIN_MS = 20_000;
|
||
const FILL_POLL_MS = 1_000;
|
||
const FILL_TIMEOUT_MS = 30_000;
|
||
const PICK_TTL_MS = 45_000;
|
||
const SURVEY_INTERVAL_MS = 30 * 60_000;
|
||
const EXCHANGE_INFO_TTL_MS = 60 * 60_000;
|
||
const MARKET_HOURS_TTL_MS = 15 * 60_000;
|
||
const CANDLE_RETRY_MS = 15 * 60_000;
|
||
/** Volatility lookback: a week of hourly candles, as on DXtrade. */
|
||
const CANDLE_HOURS = 7 * 24;
|
||
/** Transactions per page, and the most pages one account's history reads. */
|
||
const TXN_PAGE = 100;
|
||
const TXN_MAX_PAGES = 200;
|
||
/** Position history (GET /v3/positions) pages, for the trades table. */
|
||
const TRADES_PAGE = 50;
|
||
const TRADES_MAX_PAGES = 20;
|
||
/** Closed-account history (GET /v3/capital/accounts/history) pages: the
|
||
* app's own size, and how far back to look for one. */
|
||
const CLOSED_PAGE = 8;
|
||
const CLOSED_MAX_PAGES = 30;
|
||
/** Balance the books can't explain by this much holds the account. */
|
||
const BOOKS_TOLERANCE = 200;
|
||
const SUSPECT_SETTLE_MS = 90_000;
|
||
/** A position this old with no awaiting exit leg is flagged as unprotected. */
|
||
const NAKED_AFTER_MS = 2 * 60_000;
|
||
/** Vest's own spread isn't on its REST surface (only the depth socket): a
|
||
* candidate is costed as crossing this many ticks. */
|
||
const ASSUMED_SPREAD_TICKS = 1;
|
||
/** How long before the daily reset open positions are fitted to the room
|
||
* the reset will leave (guardReset) — 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 this close to its send
|
||
* and its error — the gateway's clock and lag (unknownOpenFill). */
|
||
const UNKNOWN_OPEN_SLACK_MS = 2 * 60_000;
|
||
/** account_meta key of the profit claims this client made, per account. */
|
||
const CLAIMS_META = 'vest_claims';
|
||
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||
* within this long of it. */
|
||
const CLAIM_MATCH_MS = 30 * 60_000;
|
||
/** A claim of unknown outcome (the request failed in transit) that no
|
||
* WITHDRAW matches in a history read this long after it never happened. */
|
||
const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||
|
||
/** A claim this client sent: the amount, when, and the balance it left.
|
||
* unknown = the request failed in transit, so it may or may not have gone
|
||
* in — it holds further claims, but nothing is booked on it. */
|
||
interface OwnClaim { at: number; amount: number; base: number; unknown?: true }
|
||
|
||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||
|
||
/** The gateway answered with an error status. */
|
||
class VestApiError extends Error {
|
||
constructor(message: string, readonly status: number, readonly code: string | undefined) {
|
||
super(message);
|
||
this.name = 'VestApiError';
|
||
}
|
||
}
|
||
|
||
interface SymbolSpec {
|
||
symbol: string;
|
||
assetId: number;
|
||
sizeDecimals: number;
|
||
priceDecimals: number;
|
||
tick: number;
|
||
takerFee: number;
|
||
/** Initial margin per dollar of notional on a capital (prop) account. */
|
||
marginRate: number;
|
||
tradingStatus: string;
|
||
capitalTradingStatus: string;
|
||
}
|
||
|
||
export interface VestConfig {
|
||
firmId: string;
|
||
firmName: string;
|
||
/** The Vest login's primary wallet — whose relayed session this client
|
||
* trades (the firm's saved credential on this machine). */
|
||
wallet: string;
|
||
}
|
||
|
||
export class VestClient implements PlatformClient {
|
||
private readonly cfg: VestConfig;
|
||
private readonly wallet: string;
|
||
|
||
public accountList: PlatformAccount[] = [];
|
||
public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number; availableFunds?: number } } = {};
|
||
public positions: {
|
||
[accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date };
|
||
} = {};
|
||
|
||
public daysTraded: { [accountId: number]: number } = {};
|
||
public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
|
||
public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {};
|
||
public lastFundDates: { [accountId: number]: string | null } = {};
|
||
public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {};
|
||
public priorProfit: { [accountId: number]: number } = {};
|
||
public autoLiqThresholds: { [accountId: number]: number } = {};
|
||
public 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. */
|
||
private unknownOpens = new Map<number, { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number }>();
|
||
private capital = new Map<number, VestCapitalAccount>();
|
||
private positionsDetail = new Map<number, VestPosition[]>();
|
||
private openOrders = new Map<number, VestOpenOrder[]>();
|
||
private seenOpenAt = new Map<number, { positionId: string; at: number; naggedAt: number }>();
|
||
private accountTokens = new Map<string, { token: string; expiresAt: number }>();
|
||
private accountTokenFlights = new Map<string, Promise<string>>();
|
||
|
||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||
private watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
||
private intentionalDisconnect = false;
|
||
|
||
private lastResponseAt = 0;
|
||
private lastAuthAt = 0;
|
||
private lastAuthError: string | null = null;
|
||
private consecutiveAuthFailures = 0;
|
||
|
||
private syncInFlight = false;
|
||
private syncInFlightSince = 0;
|
||
private syncGeneration = 0;
|
||
private syncRunning: Promise<void> = Promise.resolve();
|
||
|
||
private daysFetchInFlight = false;
|
||
private daysFetchInFlightSince = 0;
|
||
private lastDaysFetchStartedAt = 0;
|
||
private lastDaysFetchSucceededAt = 0;
|
||
private nextDaysFetchRetryAt = 0;
|
||
private historyLoaded = false;
|
||
private historyLoadedIds = new Set<number>();
|
||
private booksHeld = new Set<number>();
|
||
private fetchingIds = new Set<number>();
|
||
/** Accounts whose position history to read again soon: a position
|
||
* closed, or a flat balance moved (a round trip between two syncs). */
|
||
private tradesDue = new Set<number>();
|
||
private tradesFetchInFlight = false;
|
||
/** Open positions already in the trades table, by id → size stored. */
|
||
private recordedOpen = new Map<string, number>();
|
||
private flatAmounts = new Map<number, number>();
|
||
/** Accounts off Vest's active list whose leftover open trades were
|
||
* read once more this run (closeOutDropped). */
|
||
private closeOutTried = new Set<number>();
|
||
private closeOutInFlight = false;
|
||
/** When a relayed token last turned out to be one account's own. */
|
||
private scopedRefusedAt = 0;
|
||
|
||
private gateNext: Record<Bucket, number> = { read: 0, trade: 0, history: 0 };
|
||
private suspectAccounts = new Map<number, number>();
|
||
/** Opens in flight per symbol, for the hedge guard. */
|
||
private openingSides = new Map<string, { side: 'long' | 'short'; count: number }>();
|
||
private fillWaiters: { orderId: number; callback: (fill: Record<string, unknown>) => void }[] = [];
|
||
private pendingFills = 0;
|
||
private nextOrderNumber = 1;
|
||
|
||
private specs = new Map<string, SymbolSpec>();
|
||
private specsAt = 0;
|
||
private marks = new Map<string, { mark: number; at: number }>();
|
||
private hoursCache = new Map<number, { periods: unknown; at: number }>();
|
||
private candleCache = new Map<string, { vol: number | null; candles: number; at: number; tradeDate: string }>();
|
||
private pickCache = new Map<string, { contract: PlatformContract; at: number }>();
|
||
private lastSurveyTradeDate = '';
|
||
private lastSurveyAt = 0;
|
||
private unlistedLoggedAt = 0;
|
||
private unknownStatusLogged = new Set<string>();
|
||
|
||
constructor(cfg: VestConfig, onSync: () => Promise<void>) {
|
||
this.cfg = cfg;
|
||
this.wallet = cfg.wallet.trim().toLowerCase();
|
||
this.onSync = onSync;
|
||
getOrCreateSetting(TRADES_SINCE_SETTING, () => String(Date.now()));
|
||
this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS);
|
||
this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
|
||
void this.requestSync();
|
||
}
|
||
|
||
private get tag(): string {
|
||
return `[vest] ${this.cfg.firmName}:`;
|
||
}
|
||
|
||
// ── session ──────────────────────────────────────────────────────────────
|
||
|
||
/** The relayed user token, or null with the reason noted. */
|
||
private userToken(): string | null {
|
||
const session = vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS);
|
||
if (session) return session.accessToken;
|
||
const other = listVestSessions().find((s) => s.primaryAddr !== this.wallet && s.expiresAt > Date.now());
|
||
const msg = other
|
||
? `no Vest session for ${this.wallet} — the Vest tab here is signed in as ${other.primaryAddr}; sign in as ${this.wallet} there, or remove this firm's credential on the dashboard to switch to the tab's login`
|
||
: Date.now() - this.scopedRefusedAt < 30 * 60_000
|
||
? `no Vest session for ${this.wallet} — the extension relayed one account's own token, not the login's; reload the Vest tab (or update the extension) to relay the login's`
|
||
: `no Vest session for ${this.wallet} — keep a Vest tab signed in to that wallet open in Chrome with the AutoFirmer extension`;
|
||
if (this.lastFetchErrors['login'] !== msg) console.warn(`${this.tag} ${msg}`);
|
||
this.lastAuthError = this.lastFetchErrors['login'] = msg;
|
||
return null;
|
||
}
|
||
|
||
/** The extension relayed a session (POST /api/vest/session): a client
|
||
* that was waiting on its wallet syncs now. */
|
||
public onVestSession(wallet: string): void {
|
||
if (wallet !== this.wallet || this.intentionalDisconnect) return;
|
||
if (this.lastSyncAt === 0 || Date.now() - this.lastSyncAt > SYNC_INTERVAL_MS) void this.requestSync();
|
||
}
|
||
|
||
/** A token for one trading account, minted from the user token and cached. */
|
||
private async accountToken(uuid: string): Promise<string> {
|
||
const cached = this.accountTokens.get(uuid);
|
||
if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token;
|
||
const flight = this.accountTokenFlights.get(uuid);
|
||
if (flight) return flight;
|
||
const p = (async () => {
|
||
const { data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
|
||
body: { accountId: uuid },
|
||
});
|
||
const token = typeof data?.accessToken === 'string' ? data.accessToken : '';
|
||
const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000;
|
||
if (!token) throw new Error('/v3/auth/account-token answered without a token');
|
||
this.accountTokens.set(uuid, { token, expiresAt });
|
||
return token;
|
||
})().finally(() => this.accountTokenFlights.delete(uuid));
|
||
this.accountTokenFlights.set(uuid, p);
|
||
return p;
|
||
}
|
||
|
||
// ── transport ────────────────────────────────────────────────────────────
|
||
|
||
private async gate(bucket: Bucket): Promise<void> {
|
||
const now = Date.now();
|
||
const at = Math.max(now, this.gateNext[bucket]);
|
||
this.gateNext[bucket] = at + SPACING_MS[bucket];
|
||
if (at > now) await sleep(at - now);
|
||
}
|
||
|
||
/**
|
||
* One REST call: with the user token, an account's token (`account`), or
|
||
* none (`auth: false`, public data). Non-2xx answers become VestApiError
|
||
* with the gateway's own code; transport failures a plain Error carrying
|
||
* only the message — never the axios error, whose config holds the token.
|
||
* A refused account token is re-minted once; a refused user token is
|
||
* dropped, so nothing keeps knocking with it until a fresh one arrives.
|
||
*/
|
||
private async request<T>(
|
||
bucket: Bucket,
|
||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||
path: string,
|
||
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {},
|
||
): Promise<{ data: T }> {
|
||
let token: string | null = null;
|
||
if (opts.auth !== false) {
|
||
token = opts.account ? await this.accountToken(opts.account) : this.userToken();
|
||
if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION');
|
||
}
|
||
await this.gate(bucket);
|
||
let res;
|
||
try {
|
||
res = await axios.request({
|
||
method,
|
||
url: `${BASE}${path}`,
|
||
params: opts.params,
|
||
data: opts.body,
|
||
headers: {
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||
Accept: 'application/json',
|
||
Origin: APP_ORIGIN,
|
||
Referer: `${APP_ORIGIN}/`,
|
||
},
|
||
timeout: TIMEOUT_MS[bucket],
|
||
signal: AbortSignal.timeout(TIMEOUT_MS[bucket] + 5_000),
|
||
validateStatus: () => true,
|
||
});
|
||
} catch (err) {
|
||
if (axios.isCancel(err)) throw new Error(`${path} timed out after ${TIMEOUT_MS[bucket] / 1000}s`);
|
||
throw new Error(`${path}: ${errorMessage(err)}`);
|
||
}
|
||
this.lastResponseAt = Date.now();
|
||
if (res.status >= 200 && res.status < 300) return { data: res.data as T };
|
||
|
||
const body = (res.data && typeof res.data === 'object' ? res.data : {}) as { code?: unknown; msg?: unknown; message?: unknown; detail?: { code?: unknown; msg?: unknown } };
|
||
const code = body.detail?.code ?? body.code;
|
||
const msg = body.detail?.msg ?? body.msg ?? body.message;
|
||
const error = new VestApiError(
|
||
`${path}: ${typeof msg === 'string' ? msg : `HTTP ${res.status}`} (HTTP ${res.status}${code !== undefined ? `, code ${String(code)}` : ''})`,
|
||
res.status, code !== undefined ? String(code) : undefined,
|
||
);
|
||
if (res.status === 401 && token) {
|
||
if (opts.account) {
|
||
this.accountTokens.delete(opts.account);
|
||
if (!opts.retried) return this.request<T>(bucket, method, path, { ...opts, retried: true });
|
||
} else {
|
||
// The user token itself was refused: forget it, and wait for the
|
||
// tab's next renewal rather than retrying it.
|
||
dropVestSession(this.wallet, token);
|
||
this.accountTokens.clear();
|
||
this.consecutiveAuthFailures++;
|
||
this.lastAuthError = this.lastFetchErrors['login'] = `Vest refused the relayed session (${error.message}) — waiting for the Vest tab to renew it`;
|
||
}
|
||
}
|
||
if (!opts.retried && res.status === 429) {
|
||
await sleep(2_000);
|
||
return this.request<T>(bucket, method, path, { ...opts, retried: true });
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
/** No sync when there is no session to sync with, and a sync that hung
|
||
* is started over. */
|
||
private watchdog(): void {
|
||
if (this.intentionalDisconnect) return;
|
||
const now = Date.now();
|
||
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
|
||
void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`));
|
||
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);
|
||
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
|
||
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
|
||
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`);
|
||
}
|
||
const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE };
|
||
list.push(acc);
|
||
|
||
const mine = positions.filter((p) => p.accountId === a.id);
|
||
this.positionsDetail.set(id, mine);
|
||
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
|
||
this.applyPosition(acc, mine);
|
||
this.adoptUnknownOpen(id, mine, startedAt);
|
||
|
||
const amount = free.get(a.id);
|
||
if (amount === undefined) { complete = false; continue; }
|
||
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
|
||
this.recordOpenTrades(acc, mine);
|
||
if (mine.length === 0) {
|
||
const was = this.flatAmounts.get(id);
|
||
if (was !== undefined && Math.abs(was - amount) >= 0.01) this.tradesDue.add(id);
|
||
this.flatAmounts.set(id, amount);
|
||
} else {
|
||
this.flatAmounts.delete(id);
|
||
}
|
||
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
|
||
// 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}` : ''}`;
|
||
}
|
||
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
|
||
this.accountList = list;
|
||
if (shrank) void this.closeOutDropped();
|
||
// An account gone from Vest's list (breached, closed) takes its
|
||
// position with it — a stale one here would hold the lane.
|
||
for (const key of Object.keys(this.positions)) {
|
||
const id = Number(key);
|
||
if (!list.some((a) => a.id === id)) {
|
||
delete this.positions[id];
|
||
this.positionsDetail.delete(id);
|
||
this.seenOpenAt.delete(id);
|
||
}
|
||
}
|
||
|
||
this.settleSuspects();
|
||
this.checkNakedPositions();
|
||
if (superseded()) return;
|
||
|
||
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
|
||
this.maybeFetchDaysTraded();
|
||
this.maybeRefreshTrades();
|
||
if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) {
|
||
this.lastSurveyTradeDate = currentTradeDateCT();
|
||
this.lastSurveyAt = Date.now();
|
||
void this.surveyCandidates().catch((err) =>
|
||
console.warn(`${this.tag} candidate survey failed: ${errorMessage(err)}`));
|
||
}
|
||
|
||
this.syncComplete = true;
|
||
if (!complete) {
|
||
console.warn(`${this.tag} sync incomplete (a balance was missing) — not counted as a good sync`);
|
||
} else if (this.historyLoaded) {
|
||
this.lastSyncAt = Date.now();
|
||
this.lastSyncStartedAt = startedAt;
|
||
}
|
||
try {
|
||
await this.onSync();
|
||
} catch (err) {
|
||
console.error(`${this.tag} post-sync callback failed: ${errorMessage(err)}`);
|
||
}
|
||
} catch (err) {
|
||
console.error(`${this.tag} sync failed: ${errorMessage(err)}`);
|
||
} finally {
|
||
if (gen === this.syncGeneration) {
|
||
this.syncInFlight = false;
|
||
this.syncInFlightSince = 0;
|
||
}
|
||
finished();
|
||
}
|
||
}
|
||
|
||
private applyPosition(acc: PlatformAccount, list: VestPosition[]): void {
|
||
if (list.length > 1) console.warn(`${this.tag} ${acc.name} holds ${list.length} positions — tracking the first`);
|
||
const pos = list[0];
|
||
if (!pos) {
|
||
const seen = this.seenOpenAt.get(acc.id);
|
||
if (seen) {
|
||
console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`);
|
||
this.seenOpenAt.delete(acc.id);
|
||
this.recordedOpen.delete(seen.positionId);
|
||
this.tradesDue.add(acc.id);
|
||
}
|
||
delete this.positions[acc.id];
|
||
return;
|
||
}
|
||
const seen = this.seenOpenAt.get(acc.id);
|
||
if (!seen || seen.positionId !== pos.positionId) {
|
||
this.seenOpenAt.set(acc.id, { positionId: pos.positionId, at: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(), naggedAt: 0 });
|
||
}
|
||
this.positions[acc.id] = {
|
||
contractId: pos.symbol,
|
||
netPos: pos.side === 'long' ? pos.quantity : -pos.quantity,
|
||
netPrice: pos.openPrice,
|
||
timestamp: new Date(Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now()),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The balance Vest measures today's daily loss from. Vest captures it on
|
||
* an account's first update after 20:00 ET, so an account that has not
|
||
* moved since the boundary still shows yesterday's — its next update
|
||
* (our own order, say) will reset it to the balance it has now.
|
||
*/
|
||
private resetBalanceOf(a: VestCapitalAccount, balance: number, now: number): number | undefined {
|
||
if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyResetBalance;
|
||
return balance;
|
||
}
|
||
|
||
/** The equity floor of Vest's daily loss limit as it stands (or will, on
|
||
* the account's next update); undefined when the account has none. */
|
||
private dailyFloorOf(a: VestCapitalAccount, balance: number, now: number): number | undefined {
|
||
if (!(a.maxDailyLossPct > 0)) return undefined;
|
||
if (Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now)) return a.dailyLossFloor;
|
||
return round2(balance * (1 - a.maxDailyLossPct));
|
||
}
|
||
|
||
/**
|
||
* See PlatformClient: Vest states the day's floor itself — reset balance
|
||
* × (1 − rate), lowered by a payout the same day — so the engine's room
|
||
* is 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. */
|
||
public tradeDate(now: number): string {
|
||
return 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, ...(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));
|
||
}
|
||
|
||
/** Vest's transactions show the claim: a WITHDRAW of its amount near it. */
|
||
private claimShown(accountId: number, c: OwnClaim): boolean {
|
||
return (this.withdrawals.get(accountId) ?? [])
|
||
.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.at - c.at) < CLAIM_MATCH_MS);
|
||
}
|
||
|
||
/**
|
||
* 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, or the WITHDRAW transactions Vest shows, whichever counts
|
||
* more (a claim made by hand shows only there). */
|
||
public sameDayClaims(accountId: number): number {
|
||
const reset = lastVestResetMs(Date.now());
|
||
const mine = this.madeClaims(accountId).filter((c) => c.at >= reset).length;
|
||
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
|
||
return Math.max(mine, seen);
|
||
}
|
||
|
||
/**
|
||
* See PlatformClient. Vest's Claim Profit: POST /v3/capital/withdraw from
|
||
* the funded account to the login's main account, which pays the profit
|
||
* split within 24 hours. The account must be flat with no orders, and the
|
||
* claim at least $1 of the profit above its starting balance.
|
||
*
|
||
* The payout is booked here at once — today's fund row, the cycle cut,
|
||
* the P&L base — so the account can trade its next stage without waiting
|
||
* for the hourly history read, which then rebooks it from Vest's own
|
||
* transactions (the same day's row, the same amount).
|
||
*/
|
||
public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> {
|
||
const uuid = this.uuidById.get(accountId);
|
||
const account = this.accountList.find((a) => a.id === accountId);
|
||
const cash = this.accountCashBalances[accountId];
|
||
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}`);
|
||
}
|
||
|
||
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 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 (!(err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408)) {
|
||
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), 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, 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: 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 readAt = this.historyReadAt.get(accountId) ?? 0;
|
||
const list = this.claimsOf(accountId);
|
||
const lapsed = list.filter((c) => c.unknown && !this.claimShown(accountId, c) && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS);
|
||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||
if (lapsed.length > 0) {
|
||
this.saveClaims(accountId, list.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`);
|
||
}
|
||
const pending = this.claimsOf(accountId).filter((c) => !this.claimShown(accountId, c));
|
||
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. */
|
||
private adoptUnknownOpen(accountId: number, positions: VestPosition[], readAt: number): void {
|
||
const sent = this.unknownOpens.get(accountId);
|
||
if (!sent) return;
|
||
const pos = unknownOpenFill(sent, positions);
|
||
if (pos) {
|
||
this.unknownOpens.delete(accountId);
|
||
this.markOwn('opened', accountId, pos);
|
||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
|
||
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 (readAt > sent.until) {
|
||
this.unknownOpens.delete(accountId);
|
||
}
|
||
}
|
||
|
||
private settleSuspects(): void {
|
||
const now = Date.now();
|
||
for (const [id, since] of this.suspectAccounts) {
|
||
if (now - since >= SUSPECT_SETTLE_MS && (this.openOrders.get(id) ?? []).length === 0) this.suspectAccounts.delete(id);
|
||
}
|
||
}
|
||
|
||
/** An order of unknown outcome, a pending order, or exit legs waiting on
|
||
* a position. */
|
||
public hasWorkingOrders(accountId: number): boolean {
|
||
return this.suspectAccounts.has(accountId)
|
||
|| (this.openOrders.get(accountId) ?? []).length > 0
|
||
|| (this.positionsDetail.get(accountId) ?? []).some((p) => awaitingLegs(p).length > 0);
|
||
}
|
||
|
||
/** A position with no exit leg waiting — a restart mid-entry, or a leg
|
||
* whose exits failed. Not closed automatically (it may be a manual
|
||
* trade), but loud, and on the account's fetch-error note. */
|
||
private checkNakedPositions(): void {
|
||
const now = Date.now();
|
||
for (const acc of this.accountList) {
|
||
const seen = this.seenOpenAt.get(acc.id);
|
||
const pos = this.positionsDetail.get(acc.id)?.[0];
|
||
if (!seen || !pos || awaitingLegs(pos).length > 0 || now - seen.at < NAKED_AFTER_MS) continue;
|
||
if (now - seen.naggedAt < 15 * 60_000) continue;
|
||
seen.naggedAt = now;
|
||
console.error(`${this.tag} ${acc.name}: position ${pos.positionId} has NO take-profit or stop-loss — close it or add exits on Vest`);
|
||
}
|
||
}
|
||
|
||
// ── history ──────────────────────────────────────────────────────────────
|
||
|
||
private maybeFetchDaysTraded(): void {
|
||
if (this.daysFetchInFlight || this.intentionalDisconnect) return;
|
||
const now = Date.now();
|
||
const initialFetchDue = this.lastDaysFetchStartedAt === 0;
|
||
const retryPending = this.nextDaysFetchRetryAt > 0;
|
||
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
|
||
const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000;
|
||
const waiting = !initialFetchDue && !retryPending
|
||
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id)
|
||
: [];
|
||
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
|
||
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
||
|
||
this.daysFetchInFlight = true;
|
||
this.daysFetchInFlightSince = now;
|
||
this.lastDaysFetchStartedAt = now;
|
||
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
||
|
||
this.fetchDaysTraded(targeted ? waiting : undefined)
|
||
.then(({ failedAccounts }) => {
|
||
if (!this.historyLoaded && !this.intentionalDisconnect) {
|
||
this.historyLoaded = true;
|
||
void this.requestSync();
|
||
}
|
||
if (failedAccounts > 0) {
|
||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||
console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`);
|
||
return;
|
||
}
|
||
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
|
||
})
|
||
.catch((err) => {
|
||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||
console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`);
|
||
})
|
||
.finally(() => {
|
||
this.daysFetchInFlight = false;
|
||
this.daysFetchInFlightSince = 0;
|
||
});
|
||
}
|
||
|
||
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
|
||
const ids = this.accountList.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id)).map((a) => a.id);
|
||
for (const id of ids) this.fetchingIds.add(id);
|
||
this.fetchDaysComplete = false;
|
||
let failed = 0;
|
||
try {
|
||
for (const id of ids) {
|
||
if (this.intentionalDisconnect) break;
|
||
const account = this.accountList.find((a) => a.id === id);
|
||
if (!account) continue;
|
||
try {
|
||
this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!));
|
||
} catch (err) {
|
||
failed++;
|
||
this.fallBackToCache(account, errorMessage(err));
|
||
}
|
||
try {
|
||
await this.fetchTrades(account);
|
||
} catch (err) {
|
||
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
} finally {
|
||
for (const id of ids) this.fetchingIds.delete(id);
|
||
this.fetchDaysComplete = true;
|
||
}
|
||
if (!onlyAccountIds) await this.closeOutDropped();
|
||
return { failedAccounts: failed };
|
||
}
|
||
|
||
/**
|
||
* Trades the table still shows open on accounts Vest no longer lists
|
||
* (breached, closed): read those accounts' position history once more,
|
||
* so they close out with how they ended. Their ids come from memory, or
|
||
* — after a restart — from Vest's closed-account history. Once per
|
||
* account per run; a failure is logged and left.
|
||
*/
|
||
private async closeOutDropped(): Promise<void> {
|
||
if (this.closeOutInFlight || this.intentionalDisconnect || !this.userToken()) return;
|
||
const listed = new Set(this.accountList.map((a) => a.id));
|
||
const stale = openTradeAccounts().filter((a) => !listed.has(a.accountId) && !this.closeOutTried.has(a.accountId));
|
||
if (stale.length === 0) return;
|
||
this.closeOutInFlight = true;
|
||
try {
|
||
let cursor: string | null = null;
|
||
for (let page = 0; page < CLOSED_MAX_PAGES && stale.some((a) => !this.uuidById.has(a.accountId)); page++) {
|
||
const { data } = await this.request<{ accounts?: unknown[] | null; next?: unknown }>('history', 'GET', '/v3/capital/accounts/history', {
|
||
params: { limit: CLOSED_PAGE, ...(cursor ? { cursor } : {}) },
|
||
});
|
||
for (const raw of data?.accounts ?? []) {
|
||
const a = parseCapitalAccount(raw);
|
||
if (a && !this.uuidById.has(vestAccountId(a.id))) this.uuidById.set(vestAccountId(a.id), a.id);
|
||
}
|
||
cursor = typeof data?.next === 'string' && data.next ? data.next : null;
|
||
if (!cursor) break;
|
||
}
|
||
for (const a of stale) {
|
||
this.closeOutTried.add(a.accountId);
|
||
if (!this.uuidById.has(a.accountId)) {
|
||
console.warn(`${this.tag} ${a.accountName}: trades left open, but Vest lists no such account, active or closed — left as they are`);
|
||
continue;
|
||
}
|
||
try {
|
||
const acc = { id: a.accountId, name: a.accountName, active: false };
|
||
// A closed account may get no token of its own: the
|
||
// login's reads it as the app's history tab does.
|
||
await this.fetchTrades(acc).catch(() => this.fetchTrades(acc, true));
|
||
console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`);
|
||
} catch (err) {
|
||
console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn(`${this.tag} closed-account history read failed: ${errorMessage(err)}`);
|
||
} finally {
|
||
this.closeOutInFlight = false;
|
||
}
|
||
}
|
||
|
||
/** Every transaction on the account, newest page first, deduplicated. */
|
||
private async fetchTxns(uuid: string): Promise<VestTxn[]> {
|
||
const all = new Map<string, VestTxn>();
|
||
for (let page = 0; page < TXN_MAX_PAGES; page++) {
|
||
const { data } = await this.request<{ transactions?: unknown[] | null }>('history', 'GET', '/v3/transactions', {
|
||
account: uuid, params: { limit: TXN_PAGE, offset: page * TXN_PAGE },
|
||
});
|
||
const rows = data?.transactions ?? [];
|
||
for (const r of rows) {
|
||
const t = parseTxn(r);
|
||
if (t) all.set(t.key, t);
|
||
}
|
||
if (rows.length < TXN_PAGE) break;
|
||
}
|
||
return [...all.values()].sort((a, b) => a.time - b.time);
|
||
}
|
||
|
||
/** The account's position history — GET /v3/positions, the app's History
|
||
* tab — into the trades table: every position, whoever opened it, with
|
||
* how it closed. */
|
||
private async fetchTrades(account: PlatformAccount, viaLogin = false): Promise<void> {
|
||
const uuid = this.uuidById.get(account.id);
|
||
if (!uuid) return;
|
||
const trades: VestTrade[] = [];
|
||
for (let page = 0; page < TRADES_MAX_PAGES; page++) {
|
||
const { data } = await this.request<{ positions?: unknown[] | null; total?: unknown }>('history', 'GET', '/v3/positions', {
|
||
...(viaLogin ? {} : { account: uuid }), params: { account_id: uuid, limit: TRADES_PAGE, offset: page * TRADES_PAGE },
|
||
});
|
||
const rows = data?.positions ?? [];
|
||
for (const r of rows) {
|
||
const t = parseHistoryPosition(r);
|
||
if (t) trades.push(t);
|
||
}
|
||
const total = num(data?.total);
|
||
if (rows.length < TRADES_PAGE || (total !== undefined && (page + 1) * TRADES_PAGE >= total)) break;
|
||
}
|
||
if (trades.length > 0) saveTrades(trades.map((t) => ({ ...t, accountId: account.id, accountName: account.name })));
|
||
}
|
||
|
||
/** Reads the history of the accounts tradesDue names, one pass at a time.
|
||
* A failed read waits for the hourly history pass. */
|
||
private maybeRefreshTrades(): void {
|
||
if (this.tradesFetchInFlight || this.tradesDue.size === 0 || this.intentionalDisconnect) return;
|
||
const ids = [...this.tradesDue];
|
||
this.tradesDue.clear();
|
||
this.tradesFetchInFlight = true;
|
||
void (async () => {
|
||
for (const id of ids) {
|
||
const account = this.accountList.find((a) => a.id === id);
|
||
if (!account || this.intentionalDisconnect) continue;
|
||
try {
|
||
await this.fetchTrades(account);
|
||
} catch (err) {
|
||
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
})().finally(() => {
|
||
this.tradesFetchInFlight = false;
|
||
});
|
||
}
|
||
|
||
/** Open positions into the trades table as they appear (or change size),
|
||
* so a trade shows before it closes. */
|
||
private recordOpenTrades(acc: PlatformAccount, list: VestPosition[]): void {
|
||
const fresh = list.filter((p) => this.recordedOpen.get(p.positionId) !== p.quantity);
|
||
if (fresh.length === 0) return;
|
||
try {
|
||
saveTrades(fresh.map((p) => ({
|
||
positionId: p.positionId, accountId: acc.id, accountName: acc.name, symbol: p.symbol, side: p.side, quantity: p.quantity,
|
||
leverage: p.leverage > 0 ? p.leverage : null, openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, closePrice: null,
|
||
pnl: null, fee: null, funding: null, openedAt: Number.isFinite(p.openedAt) ? p.openedAt : Date.now(), closedAt: null, closeReason: null,
|
||
})));
|
||
for (const p of fresh) this.recordedOpen.set(p.positionId, p.quantity);
|
||
} catch (err) {
|
||
console.warn(`${this.tag} ${acc.name}: couldn't record the open position: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
/** This client's own order opened or closed the position: say so in the
|
||
* trades table (Vest shows an API close as "self-requested", same as a
|
||
* click). */
|
||
private markOwn(by: 'opened' | 'closed', accountId: number, pos: VestPosition): void {
|
||
if (!pos.positionId) return;
|
||
try {
|
||
markTrade(by, {
|
||
positionId: pos.positionId, accountId, accountName: this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId),
|
||
symbol: pos.symbol, side: pos.side, quantity: pos.quantity, openPrice: Number.isFinite(pos.openPrice) ? pos.openPrice : null,
|
||
openedAt: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(),
|
||
});
|
||
} catch (err) {
|
||
console.warn(`${this.tag} couldn't mark ${pos.positionId} as the bot's: ${errorMessage(err)}`);
|
||
}
|
||
if (by === 'closed') this.tradesDue.add(accountId);
|
||
}
|
||
|
||
private bookHistory(account: PlatformAccount, txns: VestTxn[]): void {
|
||
const a = this.capital.get(account.id);
|
||
const { daily, funds } = bookTxns(txns);
|
||
const types = [...new Set(txns.map((t) => t.type))].sort((x, y) => x - y);
|
||
this.lastFetchRaw[account.name] = `${txns.length} transaction(s), types ${types.join(',') || 'none'}`;
|
||
|
||
// An account Vest funded without a deposit row opens with its capital
|
||
// on the day it was activated — the resolver infers its size from it,
|
||
// and its first cycle starts there.
|
||
const hasDeposit = [...funds.values()].some((v) => v > 0);
|
||
if (!hasDeposit && a && a.initialCapital > 0) {
|
||
const opened = vestDayOf(Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt);
|
||
funds.set(opened, (funds.get(opened) ?? 0) + a.initialCapital);
|
||
}
|
||
|
||
const fresh = [...daily.entries()]
|
||
.map(([date, e]) => ({ date, pnl: round2(e.pnl), fees: Math.round(e.fees * 10000) / 10000 }))
|
||
.sort((x, y) => x.date.localeCompare(y.date));
|
||
const merged = new Map<string, number>();
|
||
for (const e of loadDailyPnL(account.id)) merged.set(e.date, e.pnl);
|
||
for (const e of fresh) merged.set(e.date, e.pnl);
|
||
this.fullDailyPnL[account.id] = [...merged.entries()]
|
||
.map(([date, pnl]) => ({ date, pnl }))
|
||
.sort((x, y) => x.date.localeCompare(y.date));
|
||
saveDailyPnL(account.id, account.name, fresh);
|
||
|
||
const fundRows = [...funds.entries()]
|
||
.map(([date, amount]) => ({ date, amount: round2(amount) }))
|
||
.filter((f) => f.amount !== 0)
|
||
.sort((x, y) => x.date.localeCompare(y.date));
|
||
this.applyFundRows(account, fundRows);
|
||
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
|
||
this.historyReadAt.set(account.id, Date.now());
|
||
|
||
// Books check: the balance must be what the transactions add up to
|
||
// (the opening capital included). A payout or credit booked under a
|
||
// type this client doesn't count would otherwise trade a finished
|
||
// cycle as if nothing happened.
|
||
const cash = this.accountCashBalances[account.id];
|
||
// A claim this client just made may not show in Vest's transactions
|
||
// yet: count it until its WITHDRAW row does (same amount, near time).
|
||
const shown = txns.filter((t) => t.type === VEST_TXN.WITHDRAW);
|
||
const unbooked = this.claimsOf(account.id)
|
||
.filter((c) => !shown.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.time - c.at) < 30 * 60_000))
|
||
.reduce((s, c) => s + c.amount, 0);
|
||
const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0) - unbooked;
|
||
if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
|
||
this.booksHeld.add(account.id);
|
||
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`;
|
||
console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`);
|
||
} else {
|
||
this.booksHeld.delete(account.id);
|
||
delete this.lastFetchErrors[account.name];
|
||
}
|
||
this.historyLoadedIds.add(account.id);
|
||
}
|
||
|
||
/** Store fund rows (one per Vest day), note where a cycle ended when the
|
||
* latest fund day moves forward, and recut the active cycle. */
|
||
private applyFundRows(account: PlatformAccount, fundRows: { date: string; amount: number }[]): void {
|
||
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
|
||
if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows);
|
||
this.fundTransactions[account.id] = loadFundTransactions(account.id);
|
||
const latest = fundRows.length > 0 ? fundRows.map((f) => f.date).reduce((x, y) => (y > x ? y : x)) : null;
|
||
const lastFundDate = latest && (!storedFundDate || latest > storedFundDate) ? latest : storedFundDate ?? latest;
|
||
if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) {
|
||
const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null;
|
||
saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) });
|
||
}
|
||
if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
|
||
this.lastFundDates[account.id] = lastFundDate ?? null;
|
||
this.applyCycleSlice(account.id);
|
||
}
|
||
|
||
private fallBackToCache(account: PlatformAccount, msg: string): void {
|
||
const cached = loadDailyPnL(account.id);
|
||
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);
|
||
this.lastFundDates[account.id] ??= loadAccountMeta(account.id, 'last_fund_date');
|
||
if (cached.length > 0) {
|
||
this.fullDailyPnL[account.id] = cached;
|
||
this.applyCycleSlice(account.id);
|
||
this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`;
|
||
} else {
|
||
this.daysTraded[account.id] ??= 0;
|
||
this.lastFetchErrors[account.name] = msg;
|
||
}
|
||
}
|
||
|
||
/** The active cycle out of the full history: a withdrawal's day belongs to
|
||
* the OLD cycle, a deposit's day to the new one (as on DXtrade). */
|
||
private applyCycleSlice(accountId: number): void {
|
||
const merged = this.fullDailyPnL[accountId] ?? [];
|
||
const fundDate = this.lastFundDates[accountId];
|
||
const txns = this.fundTransactions[accountId] ?? [];
|
||
const lastAmt = fundDate ? (txns.find((f) => f.date === fundDate)?.amount ?? null) : null;
|
||
const isWithdrawal = lastAmt !== null && lastAmt < 0;
|
||
const active = fundDate ? merged.filter((d) => (isWithdrawal ? d.date > fundDate : d.date >= fundDate)) : merged;
|
||
const prior = fundDate ? merged.filter((d) => (isWithdrawal ? d.date <= fundDate : d.date < fundDate)) : [];
|
||
this.priorProfit[accountId] = round2(prior.reduce((s, d) => s + d.pnl, 0));
|
||
this.dailyPnL[accountId] = active;
|
||
this.daysTraded[accountId] = active.filter((d) => d.pnl !== 0).length;
|
||
}
|
||
|
||
public historyReady(accountId: number): boolean {
|
||
return this.historyLoadedIds.has(accountId)
|
||
&& !this.booksHeld.has(accountId)
|
||
&& this.dailyPnL[accountId] !== undefined
|
||
&& this.fundTransactions[accountId] !== undefined;
|
||
}
|
||
|
||
/** The account's Vest status and activation time, for the report. */
|
||
public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined {
|
||
const a = this.capital.get(accountId);
|
||
return a ? { status: String(a.status), registrationTime: Number.isFinite(a.activatedAt) ? a.activatedAt : a.createdAt } : undefined;
|
||
}
|
||
|
||
public forgetHistory(accountId: number, accountName: string): void {
|
||
this.historyLoadedIds.delete(accountId);
|
||
this.booksHeld.delete(accountId);
|
||
delete this.dailyPnL[accountId];
|
||
delete this.fullDailyPnL[accountId];
|
||
delete this.daysTraded[accountId];
|
||
delete this.priorProfit[accountId];
|
||
delete this.lastFundDates[accountId];
|
||
delete this.fundTransactions[accountId];
|
||
delete this.lastFetchErrors[accountName];
|
||
delete this.lastFetchRaw[accountName];
|
||
}
|
||
|
||
// ── instrument selector ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The contract this firm trades right now, from its instrument list — the
|
||
* root is ignored, as on DXtrade. With a pick context (the batch's leg),
|
||
* each candidate is sized as the leg would be and the cheapest that
|
||
* reaches the target hold wins (else the quickest); without one, the
|
||
* cheapest per unit of volatility. Sticky while an account holds one of
|
||
* the candidates; a batch's pick is reused briefly so its lookups agree.
|
||
*/
|
||
public async findFrontMonthContract(_root: string, ctx?: PickContext): Promise<PlatformContract | null> {
|
||
if (!this.userToken()) return null;
|
||
const root = '*';
|
||
const firm = getCachedFirm(this.cfg.firmId);
|
||
const candidates = firm ? firmInstruments(firm) : [];
|
||
const now = Date.now();
|
||
if (candidates.length === 0) {
|
||
if (now - this.unlistedLoggedAt > 10 * 60_000) {
|
||
this.unlistedLoggedAt = now;
|
||
console.log(`${this.tag} no instruments listed — skipping; add Vest symbols (e.g. NDX-USD-PERP) on the master Firms page`);
|
||
}
|
||
return null;
|
||
}
|
||
const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol));
|
||
const sticky = candidates.find((s) => held.has(s));
|
||
if (!sticky && !ctx) {
|
||
const cached = this.pickCache.get(root);
|
||
if (cached && now - cached.at < PICK_TTL_MS) return cached.contract;
|
||
}
|
||
const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined, ctx);
|
||
if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now });
|
||
return pick;
|
||
}
|
||
|
||
/** Every listed symbol, looked at afresh whatever the accounts hold: the
|
||
* day's volatility, and the candidates the dashboard ranks. */
|
||
private async surveyCandidates(): Promise<void> {
|
||
const firm = getCachedFirm(this.cfg.firmId);
|
||
const list = firm ? firmInstruments(firm) : [];
|
||
if (list.length > 0) await this.pickCandidate('survey', list, false);
|
||
}
|
||
|
||
private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
|
||
const firm = getCachedFirm(this.cfg.firmId);
|
||
const targetHold = sizingHoldHours(firm, 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;
|
||
}
|
||
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.
|
||
* Adding to a position is refused, and so is an open against another
|
||
* account's opposite position in the same symbol (no hedging). Resolves
|
||
* with the fill `price`.
|
||
*/
|
||
public async sendOrder(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
quantity: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number,
|
||
): Promise<Record<string, unknown>> {
|
||
try {
|
||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
||
} catch (err) {
|
||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
private async sendOrderAndWait(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
contracts: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number,
|
||
): Promise<Record<string, unknown>> {
|
||
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
||
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||
let positions: VestPosition[];
|
||
try {
|
||
positions = await this.freshPositions(uuid);
|
||
} catch (err) {
|
||
throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`);
|
||
}
|
||
const existing = positions.find((p) => p.symbol === symbol);
|
||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
||
|
||
const units = roundToStep(contracts * step, step);
|
||
const qty = (n: number) => formatDecimal(n, spec.sizeDecimals);
|
||
const tif = orderType === 'Market' ? 'IOC' : 'GTC';
|
||
const priced = orderType === 'Limit' && price !== undefined;
|
||
let path: string;
|
||
let body: Record<string, unknown>;
|
||
let unitsOrdered: number;
|
||
if (existing) {
|
||
unitsOrdered = Math.min(existing.quantity, units);
|
||
const full = unitsOrdered >= existing.quantity - step / 2;
|
||
path = full ? '/v3/positions/close' : '/v3/positions/reduce';
|
||
body = full
|
||
? {
|
||
positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit',
|
||
...(priced ? { limitPrice: this.exitPrice(spec, price!, side) } : {}),
|
||
}
|
||
: {
|
||
positionId: existing.positionId, symbol, orderType: orderType === 'Market' ? 'market' : 'limit',
|
||
leverage: this.leverageFor(spec), quantity: qty(unitsOrdered), timeInForce: tif, reduceOnly: true,
|
||
...(priced ? { price: this.exitPrice(spec, price!, side) } : {}),
|
||
};
|
||
} else {
|
||
const meta = this.capital.get(accountId);
|
||
if (!meta || meta.status !== VEST_STATUS_ACTIVE) {
|
||
throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`);
|
||
}
|
||
const 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`);
|
||
}
|
||
const opposite = side === 'long' ? 'short' : 'long';
|
||
const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite)
|
||
|| this.openingSides.get(symbol)?.side === opposite;
|
||
if (hedged) throw new OrderNotPlacedError(`another account holds the opposite side of ${symbol} — no hedging`);
|
||
unitsOrdered = units;
|
||
path = '/v3/positions/open';
|
||
body = {
|
||
orderType: orderType === 'Market' ? 'market' : 'limit',
|
||
leverage: this.leverageFor(spec),
|
||
side,
|
||
symbol,
|
||
quantity: qty(units),
|
||
timeInForce: tif,
|
||
takeProfits: [],
|
||
stopLosses: [],
|
||
...(priced ? { price: formatDecimal(roundToStep(price!, spec.tick || 1e-9), spec.priceDecimals) } : {}),
|
||
};
|
||
}
|
||
if (!(unitsOrdered > 0)) throw new OrderNotPlacedError(`${contracts} contract(s) of ${symbol} round to nothing — order not sent`);
|
||
|
||
const opening = !existing;
|
||
if (opening) {
|
||
const cur = this.openingSides.get(symbol);
|
||
this.openingSides.set(symbol, { side, count: (cur?.side === side ? cur.count : 0) + 1 });
|
||
}
|
||
this.pendingFills++;
|
||
const orderNumber = this.nextOrderNumber++;
|
||
const sentAt = Date.now();
|
||
try {
|
||
const placed = await this.submit(uuid, path, body);
|
||
console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`);
|
||
const fill = await this.waitForFill(uuid, placed, symbol, side, existing ?? null, unitsOrdered, sentAt);
|
||
if (opening) {
|
||
const pos = fill.position ?? {
|
||
positionId: placed.positionId ?? '', accountId: uuid, symbol, side, quantity: unitsOrdered,
|
||
openPrice: fill.price, collateral: 0, leverage: 0, openedAt: Date.now(), stopLosses: [], takeProfits: [],
|
||
};
|
||
this.positionsDetail.set(accountId, [pos]);
|
||
this.positions[accountId] = {
|
||
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
|
||
};
|
||
this.markOwn('opened', accountId, pos);
|
||
} else if (unitsOrdered >= existing.quantity - step / 2) {
|
||
this.positionsDetail.set(accountId, []);
|
||
delete this.positions[accountId];
|
||
this.markOwn('closed', accountId, existing);
|
||
}
|
||
const result = {
|
||
orderId: orderNumber, vestOrderId: placed.orderId, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol,
|
||
};
|
||
for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result);
|
||
this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber);
|
||
return result;
|
||
} 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.
|
||
if (opening && !isOrderNotPlaced(err)) {
|
||
this.unknownOpens.set(accountId, {
|
||
accountId: uuid, symbol, side, from: sentAt - UNKNOWN_OPEN_SLACK_MS, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
|
||
});
|
||
}
|
||
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 an answer that proves nothing was placed becomes
|
||
* OrderNotPlacedError (a 4xx with the gateway's refusal); a transport
|
||
* failure or a 5xx stays a plain Error — it may have gone in.
|
||
*/
|
||
private async submit(uuid: string, path: string, body: Record<string, unknown>): Promise<{ orderId?: string; positionId?: string }> {
|
||
try {
|
||
const { data } = await this.request<{ orderId?: unknown; positionId?: unknown }>('trade', 'POST', path, { account: uuid, body });
|
||
return {
|
||
orderId: typeof data?.orderId === 'string' ? data.orderId : undefined,
|
||
positionId: typeof data?.positionId === 'string' ? data.positionId : undefined,
|
||
};
|
||
} catch (err) {
|
||
if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) {
|
||
throw new OrderNotPlacedError(`Vest refused the order: ${err.message}`);
|
||
}
|
||
throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Poll until the order fills: an open shows as the position it created
|
||
* (by positionId, else the account's only one in the symbol), a close as
|
||
* the position gone or shrunk. Still unfilled after FILL_TIMEOUT_MS is
|
||
* reported as unknown — a market order is IOC, so it can't fill later,
|
||
* but a limit may.
|
||
*/
|
||
private async waitForFill(
|
||
uuid: string,
|
||
placed: { orderId?: string; positionId?: string },
|
||
symbol: string,
|
||
side: 'long' | 'short',
|
||
closing: VestPosition | null,
|
||
units: number,
|
||
sentAt: number,
|
||
): Promise<{ price: number; position?: VestPosition }> {
|
||
const deadline = Date.now() + FILL_TIMEOUT_MS;
|
||
while (Date.now() < deadline) {
|
||
await sleep(FILL_POLL_MS);
|
||
try {
|
||
const positions = await this.freshPositions(uuid);
|
||
if (closing) {
|
||
const still = positions.find((p) => p.positionId === closing.positionId);
|
||
if (!still || still.quantity < closing.quantity - units / 2) {
|
||
return { price: await this.exitFillPrice(uuid, symbol, side, sentAt) };
|
||
}
|
||
} else {
|
||
const pos = positions.find((p) => (placed.positionId ? p.positionId === placed.positionId : p.symbol === symbol && p.side === side));
|
||
if (pos) return { price: pos.openPrice, position: pos };
|
||
}
|
||
} catch { /* transient — the next poll tries again */ }
|
||
}
|
||
throw new Error(`order ${placed.orderId ?? '?'} on ${symbol} not seen filled within ${FILL_TIMEOUT_MS / 1000}s — outcome unknown`);
|
||
}
|
||
|
||
/** What a close filled at: the volume-weighted price of the account's
|
||
* executions in the symbol, on the closing side, since the order went
|
||
* out — else the mark, as an estimate. */
|
||
private async exitFillPrice(uuid: string, symbol: string, side: 'long' | 'short', sentAt: number): Promise<number> {
|
||
try {
|
||
const { data } = await this.request<{ items?: Record<string, unknown>[] }>('trade', 'GET', '/v3/executions', {
|
||
account: uuid, params: { account_id: uuid, symbol, limit: 20 },
|
||
});
|
||
let qty = 0;
|
||
let value = 0;
|
||
for (const e of data?.items ?? []) {
|
||
const q = num(e.quantity);
|
||
const price = num(e.price);
|
||
if (q === undefined || price === undefined || toMs(e.executedAt) < sentAt - 5_000) continue;
|
||
if ((e.isBuy === true) !== (side === 'long')) continue;
|
||
qty += q;
|
||
value += q * price;
|
||
}
|
||
if (qty > 0) return value / qty;
|
||
} catch (err) {
|
||
console.warn(`${this.tag} executions read failed — taking the mark as the exit price: ${errorMessage(err)}`);
|
||
}
|
||
await this.refreshMarks([symbol]).catch(() => { /* NaN below then */ });
|
||
return this.marks.get(symbol)?.mark ?? NaN;
|
||
}
|
||
|
||
/** One exit leg on the open position: POST /v3/positions/take-profit or
|
||
* /stop-loss. A take-profit rests as a limit at its price; a stop goes
|
||
* out at market when it triggers. */
|
||
private async addLeg(uuid: string, spec: SymbolSpec, pos: VestPosition, kind: 'take-profit' | 'stop-loss', price: number): Promise<{ orderId?: string }> {
|
||
const exitSide = pos.side === 'long' ? 'short' : 'long';
|
||
const trigger = this.exitPrice(spec, price, exitSide);
|
||
const body = kind === 'take-profit'
|
||
? { positionId: pos.positionId, executionType: 'limit', triggerPrice: trigger, limitPrice: trigger }
|
||
: { positionId: pos.positionId, executionType: 'market', triggerPrice: trigger };
|
||
return this.submit(uuid, `/v3/positions/${kind}`, body);
|
||
}
|
||
|
||
private async heldPosition(uuid: string, symbol: string, exitSide: 'long' | 'short'): Promise<VestPosition> {
|
||
const pos = (await this.freshPositions(uuid).catch((err) => {
|
||
throw new OrderNotPlacedError(`Vest position check failed — exit not sent: ${errorMessage(err)}`);
|
||
})).find((p) => p.symbol === symbol && p.side !== exitSide);
|
||
if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to exit — nothing sent`);
|
||
return pos;
|
||
}
|
||
|
||
/** A take-profit on the whole open position. Exit only: with no opposite
|
||
* position there is nothing to close, so nothing is sent. */
|
||
public async placeOrderNoWait(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
_quantity: number,
|
||
action: 'Buy' | 'Sell',
|
||
orderType: 'Market' | 'Limit',
|
||
price?: number,
|
||
): Promise<{ orderId?: number }> {
|
||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||
const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||
if (orderType !== 'Limit' || price === undefined) {
|
||
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol, orderType: 'market' });
|
||
return {};
|
||
}
|
||
await this.addLeg(uuid, spec, pos, 'take-profit', price);
|
||
return {};
|
||
}
|
||
|
||
/**
|
||
* Take-profit and stop on the open position. Vest holds them as legs of
|
||
* the position and cancels the other when one triggers (a native OCO) or
|
||
* the position closes. The stop goes first, so the position is never
|
||
* without it.
|
||
*
|
||
* What the caller may do next depends on what is left at the broker:
|
||
* OrderNotPlacedError = nothing of the pair is working; a plain Error =
|
||
* something may be (a stop, at least) — place nothing more.
|
||
*/
|
||
public async placeOcoExit(
|
||
accountId: number,
|
||
contractSymbol: string,
|
||
_quantity: number,
|
||
exitAction: 'Buy' | 'Sell',
|
||
limitPrice: number,
|
||
stopPrice: number,
|
||
): Promise<{ orderId?: number }> {
|
||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||
const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short';
|
||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||
await this.addLeg(uuid, spec, pos, 'stop-loss', stopPrice); // throws OrderNotPlacedError when nothing went in
|
||
try {
|
||
await this.addLeg(uuid, spec, pos, 'take-profit', limitPrice);
|
||
} catch (err) {
|
||
console.error(`${this.tag} ${symbol}: stop is working but the take-profit failed (${errorMessage(err)}) — place it by hand`);
|
||
throw new Error(`stop working, take-profit not placed: ${errorMessage(err)}`);
|
||
}
|
||
console.log(`${this.tag} exits on ${pos.positionId} — take-profit @ ${this.exitPrice(spec, limitPrice, exitSide)}, stop @ ${this.exitPrice(spec, stopPrice, exitSide)}`);
|
||
return {};
|
||
}
|
||
|
||
public onFill(orderId: number, callback: (fill: Record<string, unknown>) => void): void {
|
||
this.fillWaiters.push({ orderId, callback });
|
||
}
|
||
|
||
// ── lifecycle ────────────────────────────────────────────────────────────
|
||
|
||
public health(): ClientHealth {
|
||
const session = vestSessionFor(this.wallet, Date.now(), 0);
|
||
return {
|
||
platform: 'vest',
|
||
authenticated: session !== null,
|
||
transport: 'rest',
|
||
syncComplete: this.syncComplete,
|
||
lastSyncAt: this.lastSyncAt,
|
||
lastLoginAt: session?.receivedAt ?? this.lastAuthAt,
|
||
lastLoginError: this.lastAuthError,
|
||
consecutiveLoginFailures: this.consecutiveAuthFailures,
|
||
nextReconnectAt: 0,
|
||
tokenExpiresAt: session?.expiresAt ?? 0,
|
||
lastInboundAt: this.lastResponseAt,
|
||
pendingRequests: this.pendingFills,
|
||
syncInFlightSince: this.syncInFlight ? this.syncInFlightSince : 0,
|
||
daysFetchInFlightSince: this.daysFetchInFlight ? this.daysFetchInFlightSince : 0,
|
||
};
|
||
}
|
||
|
||
/** No session relayed for this wallet: a rebuild can't fix that — only a
|
||
* signed-in Vest tab with the extension can — so the supervisor leaves
|
||
* the client alone. */
|
||
public get needsAttention(): boolean {
|
||
return vestSessionFor(this.wallet, Date.now(), 0) === null;
|
||
}
|
||
|
||
/** Final: nothing this client scheduled fires afterwards. There is no
|
||
* session of its own to log out — the browser's stays the browser's. */
|
||
public disconnect(): void {
|
||
this.intentionalDisconnect = true;
|
||
for (const t of [this.syncInterval, this.watchdogInterval]) if (t) clearInterval(t);
|
||
this.syncInterval = this.watchdogInterval = null;
|
||
this.daysFetchInFlight = false;
|
||
this.daysFetchInFlightSince = 0;
|
||
this.accountTokens.clear();
|
||
}
|
||
}
|