Vest: the bot activates a passed evaluation's funded account, and a passed one no longer holds the sync
A passed Vest evaluation sits at GOAL_PASSED (status 3) until someone presses
"Go to funded account", which sends POST /v3/capital/activate-funded
{eval_account_id} on the evaluation's own token (read from the Vest app's
bundle, 2026-10-29); Vest then opens the funded account and marks the
evaluation CLAIMED (6). The 5 evaluations that passed on 2026-10-29 waited
for a hand activation.
runActivations now asks for it, the way runClaims claims: from the
scheduler's tick (not awaited, so a slow ask doesn't hold the lane's entry)
and from the claim watcher while the scheduler is off, for evaluations with
an account type only, 15 minutes apart after a failure. Off with the
auto_activate setting at 'off' (dashboard: "Auto-activate funded"). The
client asks for an evaluation once at a time, never again after Vest
answered, and after a timeout not until a list read a minute on says either
way; a refusal frees it at once. The rule is the Vest app's own: an
evaluation at GOAL_PASSED, not blocked, on its plan's last step.
Vest's active balance list leaves a passed evaluation out, and a missing
balance made every sync short of good (15:31-15:34Z), which holds and then
quarantines the lane. Only a live account's missing balance does now. Log
lines name Vest's statuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0eb56b3949
commit
8a5b2a64e7
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
||||
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim'] as const;
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
|
||||
type SettingKey = typeof VALID_KEYS[number];
|
||||
|
||||
export async function GET() {
|
||||
@@ -25,10 +25,13 @@ export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
||||
|
||||
// Claims run unless it reads exactly 'off' — say so exactly, and
|
||||
// before anything is written.
|
||||
if (body.auto_claim !== undefined && body.auto_claim !== null && body.auto_claim !== 'on' && body.auto_claim !== 'off') {
|
||||
return NextResponse.json({ error: "auto_claim is 'on' or 'off'" }, { status: 400 });
|
||||
// Claims and activations run unless it reads exactly 'off' — say so
|
||||
// exactly, and before anything is written.
|
||||
for (const key of ['auto_claim', 'auto_activate'] as const) {
|
||||
const v = body[key];
|
||||
if (v !== undefined && v !== null && v !== 'on' && v !== 'off') {
|
||||
return NextResponse.json({ error: `${key} is 'on' or 'off'` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of VALID_KEYS) {
|
||||
|
||||
+25
-1
@@ -744,6 +744,7 @@ export default function Home() {
|
||||
const [tradeLoading, setTradeLoading] = useState(false);
|
||||
const [stopAfterAll, setStopAfterAll] = useState(false);
|
||||
const [autoClaim, setAutoClaim] = useState(true);
|
||||
const [autoActivate, setAutoActivate] = useState(true);
|
||||
|
||||
const handleSort = (col: SortKey) => {
|
||||
if (sortKey === col) {
|
||||
@@ -813,9 +814,10 @@ export default function Home() {
|
||||
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null }) => {
|
||||
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null; auto_activate?: string | null }) => {
|
||||
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
||||
setAutoClaim(s.auto_claim !== 'off');
|
||||
setAutoActivate(s.auto_activate !== 'off');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1024,6 +1026,28 @@ export default function Home() {
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
|
||||
</label>
|
||||
)}
|
||||
{config.some((f) => f.platform === 'vest') && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||
title="Activate a passed evaluation's funded account as soon as it passes (Vest) — whether or not the scheduler is running"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoActivate}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setAutoActivate(on);
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_activate: on ? 'on' : 'off' }),
|
||||
}).then((r) => { if (!r.ok) setAutoActivate(!on); }).catch(() => setAutoActivate(!on));
|
||||
}}
|
||||
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-activate funded</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasAnyPosition && (
|
||||
<button
|
||||
|
||||
+60
-5
@@ -72,6 +72,11 @@ interface TradeRuntime {
|
||||
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
|
||||
/** Account id → when its last payout claim failed (runClaims backoff). */
|
||||
claimFailedAt?: Map<number, number>;
|
||||
/** Account id → when its last funded-account activation failed
|
||||
* (runActivations backoff). */
|
||||
activateFailedAt?: Map<number, number>;
|
||||
/** Lanes whose runActivations is out (one at a time per lane). */
|
||||
activationsRunning?: Set<string>;
|
||||
/** Accounts whose exit the broker refused — a bracket while a hard daily
|
||||
* loss limit was on, or a lone take-profit: the position was flattened
|
||||
* and the account sits out the trade date rather than bleed a round trip
|
||||
@@ -2038,6 +2043,49 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a failed activation waits before it's tried again — a refusal
|
||||
* (Vest's cap on funded accounts, say) holds for a while. */
|
||||
const ACTIVATE_RETRY_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* Activate the funded account of every passed evaluation in the lane that
|
||||
* the bot trades (one with an account type, as runClaims goes by — Vest caps
|
||||
* funded accounts, and one kept by hand isn't the bot's to spend a slot on),
|
||||
* where the platform does that by API (Vest: a passed evaluation waits until
|
||||
* it's asked). It opens and closes nothing — the evaluation no longer
|
||||
* trades, and the funded account shows on the next sync and trades from
|
||||
* there — so it takes no lane mutex, and the scheduler doesn't wait on it.
|
||||
* One run per lane at a time. Run by the scheduler's tick, or by the claim
|
||||
* watcher while the scheduler is off. Off with the auto_activate setting at
|
||||
* 'off'.
|
||||
*/
|
||||
async function runActivations(lane: string, ready: FirmClient[]): Promise<void> {
|
||||
if (getSetting('auto_activate') === 'off') return;
|
||||
const running = (runtime().activationsRunning ??= new Set());
|
||||
if (running.has(lane)) return;
|
||||
running.add(lane);
|
||||
try {
|
||||
for (const { firm, client } of inLane(ready, lane)) {
|
||||
if (typeof client.activatable !== 'function' || typeof client.activateFunded !== 'function') continue;
|
||||
for (const id of client.activatable()) {
|
||||
const acc = client.accountList.find((a) => a.id === id);
|
||||
if (!acc || !getAccountConfig(id).accountType) continue;
|
||||
if (Date.now() - (runtime().activateFailedAt?.get(id) ?? 0) < ACTIVATE_RETRY_MS) continue;
|
||||
try {
|
||||
const { funded } = await client.activateFunded(id);
|
||||
runtime().activateFailedAt?.delete(id);
|
||||
console.log(`[auto-activate] ${firm.name} ${acc.name}: passed — funded account ${funded ?? '(listed on the next sync)'} activated`);
|
||||
} catch (err) {
|
||||
(runtime().activateFailedAt ??= new Map()).set(id, Date.now());
|
||||
console.error(`[auto-activate] ${firm.name} ${acc.name}: activation failed — ${errorMessage(err)}; retrying in ${ACTIVATE_RETRY_MS / 60_000}m`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
running.delete(lane);
|
||||
}
|
||||
}
|
||||
|
||||
/** How often auto-claims run while the scheduler is off — its tick's pace. */
|
||||
const CLAIM_WATCH_MS = 60_000;
|
||||
|
||||
@@ -2049,8 +2097,9 @@ const _claimWatch = globalThis as typeof globalThis & { __claimWatcher?: ReturnT
|
||||
* at its target while the bot is off is claimed all the same, before
|
||||
* whatever trades it next can take the profit back. While the scheduler
|
||||
* runs, its own tick claims (first thing in each lane), and this stays out
|
||||
* of the way. Off with the auto_claim setting at 'off'. Started once, with
|
||||
* the clients.
|
||||
* of the way. Off with the auto_claim setting at 'off'. Activations of passed
|
||||
* evaluations' funded accounts ride along the same way (auto_activate).
|
||||
* Started once, with the clients.
|
||||
*/
|
||||
export function startClaimWatcher(): void {
|
||||
if (_claimWatch.__claimWatcher) return;
|
||||
@@ -2058,15 +2107,18 @@ export function startClaimWatcher(): void {
|
||||
}
|
||||
|
||||
async function claimTick(): Promise<void> {
|
||||
if (getState().running || getSetting('auto_claim') === 'off' || _claimWatch.__claimTickBusy) return;
|
||||
if (getState().running || _claimWatch.__claimTickBusy) return;
|
||||
if (getSetting('auto_claim') === 'off' && getSetting('auto_activate') === 'off') return;
|
||||
_claimWatch.__claimTickBusy = true;
|
||||
try {
|
||||
// Ready clients only, as the tick: a stale snapshot may hide a
|
||||
// position. runClaims takes each lane's mutex itself.
|
||||
// position. runClaims takes each lane's mutex itself; each step
|
||||
// checks its own setting.
|
||||
const { ready } = firmClients();
|
||||
for (const lane of tradableLanes()) {
|
||||
if (getState().running) return; // the scheduler took over
|
||||
await runClaims(lane, ready);
|
||||
await runActivations(lane, ready);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[auto-claim] ${errorMessage(err)}`);
|
||||
@@ -2343,8 +2395,11 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
||||
continue;
|
||||
}
|
||||
// Payouts first, account by account — a claim doesn't wait
|
||||
// for the rest of the lane to go flat.
|
||||
// for the rest of the lane to go flat. Nor does a passed
|
||||
// evaluation's funded account, and the batch doesn't wait
|
||||
// on it: a slow one would hold back the lane's entry.
|
||||
await runClaims(lane, ready);
|
||||
void runActivations(lane, ready).catch((err) => console.error(`[auto-activate] ${laneName(lane)}: ${errorMessage(err)}`));
|
||||
// Skip this lane if any of its accounts still has an open
|
||||
// position from its previous batch — a batch fires only
|
||||
// when every (ready) account in the lane is flat.
|
||||
|
||||
@@ -175,6 +175,14 @@ export interface PlatformClient {
|
||||
/** A claim this client made that no good sync has read past yet: the
|
||||
* balance on hand may not show it, so no claim may be judged on it. */
|
||||
claimPending?(accountId: number): boolean;
|
||||
/** Passed evaluations whose funded account the platform activates by
|
||||
* API (Vest's "Go to funded account"), by account id — less any this
|
||||
* client already asked for that no sync has read back yet. */
|
||||
activatable?(): number[];
|
||||
/** Activate the funded account of a passed evaluation. Resolves once the
|
||||
* platform opened it, with its name when the answer gives one (the next
|
||||
* sync lists it); rejects with the platform's reason otherwise. */
|
||||
activateFunded?(accountId: number): Promise<{ funded: string | null }>;
|
||||
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||
* unloaded until the next fetch rebuilds it. */
|
||||
forgetHistory(accountId: number, accountName: string): void;
|
||||
|
||||
+94
-2
@@ -54,6 +54,7 @@ import {
|
||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
|
||||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
|
||||
vestStatusName, awaitingActivation,
|
||||
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
|
||||
} from './vest-helpers';
|
||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers';
|
||||
@@ -74,6 +75,10 @@ type Bucket = keyof typeof SPACING_MS;
|
||||
const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const;
|
||||
/** A cached account token is renewed this long before it expires. */
|
||||
const ACCOUNT_TOKEN_SKEW_MS = 90_000;
|
||||
/** An activation whose answer never came may still land at Vest: only a
|
||||
* capital list read from this long after it gave up is taken as Vest's
|
||||
* word on it. */
|
||||
const ACTIVATION_UNKNOWN_HOLD_MS = 60_000;
|
||||
/** The relayed user token must have this long left to start a request. */
|
||||
const USER_TOKEN_MIN_MS = 20_000;
|
||||
/** How long an exit waits for the login's session and its account's token
|
||||
@@ -309,6 +314,16 @@ export class VestClient implements PlatformClient {
|
||||
private lastSurveyAt = 0;
|
||||
private unlistedLoggedAt = 0;
|
||||
private unknownStatusLogged = new Set<string>();
|
||||
/** Passed evaluations whose activation is out right now. */
|
||||
private activationsOut = new Set<number>();
|
||||
/** Passed evaluations Vest answered an activation for: never asked
|
||||
* again, even while Vest's list is slow to stop showing them. */
|
||||
private activated = new Set<number>();
|
||||
/** Account id → when an activation of unknown outcome gave up; held
|
||||
* until a list read ACTIVATION_UNKNOWN_HOLD_MS on says either way. */
|
||||
private activationUnknownAt = new Map<number, number>();
|
||||
/** When the sync whose capital list this client holds started. */
|
||||
private capitalReadStartedAt = 0;
|
||||
|
||||
constructor(cfg: VestConfig, onSync: () => Promise<void>) {
|
||||
this.cfg = cfg;
|
||||
@@ -684,7 +699,8 @@ export class VestClient implements PlatformClient {
|
||||
this.restorePendingOpen(id, vestAccountName(a));
|
||||
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
|
||||
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
|
||||
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`);
|
||||
if (awaitingActivation(a)) console.log(`${this.tag} ${vestAccountName(a)} passed — its funded account waits on activation`);
|
||||
else console.warn(`${this.tag} ${vestAccountName(a)} is ${vestStatusName(a.status)} — not trading it`);
|
||||
}
|
||||
const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE };
|
||||
list.push(acc);
|
||||
@@ -703,7 +719,14 @@ export class VestClient implements PlatformClient {
|
||||
this.adoptUnknownOpen(id, mine, startedAt);
|
||||
|
||||
const amount = free.get(a.id);
|
||||
if (amount === undefined) { complete = false; continue; }
|
||||
// Only a live account's balance is owed: Vest's active list
|
||||
// leaves out a passed evaluation's (seen 2026-10-29), and
|
||||
// waiting on it kept every sync short of good — the lane held,
|
||||
// and nothing ran that would activate it.
|
||||
if (amount === undefined) {
|
||||
if (a.status === VEST_STATUS_ACTIVE) complete = false;
|
||||
continue;
|
||||
}
|
||||
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
|
||||
this.recordOpenTrades(acc, mine);
|
||||
if (mine.length === 0) {
|
||||
@@ -732,6 +755,7 @@ export class VestClient implements PlatformClient {
|
||||
this.numbersAt = now;
|
||||
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
|
||||
this.accountList = list;
|
||||
this.capitalReadStartedAt = startedAt;
|
||||
if (shrank) void this.closeOutDropped();
|
||||
// An account gone from Vest's list (breached, closed) takes its
|
||||
// position with it — a stale one here would hold the lane.
|
||||
@@ -1048,6 +1072,74 @@ export class VestClient implements PlatformClient {
|
||||
return { claimed, received };
|
||||
}
|
||||
|
||||
/** See PlatformClient: the passed evaluations on Vest's list whose funded
|
||||
* account waits on activation, less any this client's asks hold. */
|
||||
public activatable(): number[] {
|
||||
return this.accountList.flatMap((acc) => {
|
||||
const meta = this.capital.get(acc.id);
|
||||
return meta && awaitingActivation(meta) && !this.activationHeld(acc.id) ? [acc.id] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** An ask of this client's settles the evaluation until Vest's list can
|
||||
* speak for it: one out now, one Vest answered (for good), or one whose
|
||||
* answer never came, until a list read a while after it gave up. The
|
||||
* list a sync reads while an ask is out predates Vest's answer. */
|
||||
private activationHeld(accountId: number): boolean {
|
||||
if (this.activationsOut.has(accountId) || this.activated.has(accountId)) return true;
|
||||
const unknownAt = this.activationUnknownAt.get(accountId);
|
||||
return unknownAt !== undefined && this.capitalReadStartedAt < unknownAt + ACTIVATION_UNKNOWN_HOLD_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* See PlatformClient. Vest's "Go to funded account": POST
|
||||
* /v3/capital/activate-funded with the evaluation's own token, as the Vest
|
||||
* app sends it. Vest answers with the funded account it opened, and marks
|
||||
* the evaluation CLAIMED. Nothing to pay and no terms to accept; Vest
|
||||
* refuses one past its cap on funded accounts.
|
||||
*
|
||||
* One ask at a time per evaluation, and none after Vest answered one. One
|
||||
* that timed out may have gone in: none until a list read a minute on
|
||||
* says either way. A refusal frees it at once.
|
||||
*/
|
||||
public async activateFunded(accountId: number): Promise<{ funded: string | null }> {
|
||||
const uuid = this.uuidById.get(accountId);
|
||||
const account = this.accountList.find((a) => a.id === accountId);
|
||||
const meta = this.capital.get(accountId);
|
||||
if (!uuid || !account || !meta) throw new Error('account not read yet');
|
||||
if (!awaitingActivation(meta)) {
|
||||
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not an evaluation'}, ${vestStatusName(meta.status)} — only a passed evaluation's funded account is activated`);
|
||||
}
|
||||
if (this.activationHeld(accountId)) throw new Error("the last activation hasn't been read back from Vest yet");
|
||||
|
||||
// Taken before the first await: a second caller meanwhile is refused.
|
||||
this.activationsOut.add(accountId);
|
||||
let data: { account?: unknown } | undefined;
|
||||
try {
|
||||
({ data } = await this.request<{ account?: unknown }>('trade', 'POST', '/v3/capital/activate-funded', {
|
||||
account: uuid,
|
||||
body: { eval_account_id: uuid },
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!nothingWentIn(err)) {
|
||||
this.activationUnknownAt.set(accountId, Date.now());
|
||||
console.error(`${this.tag} ${account.name}: activation — outcome unknown (${errorMessage(err)}); not asked again until Vest's list says either way`);
|
||||
void this.syncRunning.then(() => this.requestSync());
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.activationsOut.delete(accountId);
|
||||
}
|
||||
this.activationUnknownAt.delete(accountId);
|
||||
this.activated.add(accountId);
|
||||
const opened = parseCapitalAccount(data?.account);
|
||||
const funded = opened ? vestAccountName(opened) : null;
|
||||
console.log(`${this.tag} ${account.name}: activated its funded account${funded ? ` ${funded}` : ''}`);
|
||||
// A fresh sync lists it — after any one already out, whose reads predate this.
|
||||
void this.syncRunning.then(() => this.requestSync());
|
||||
return { funded };
|
||||
}
|
||||
|
||||
/**
|
||||
* See PlatformClient: a claim of this client's that Vest's transactions
|
||||
* don't show yet — until they do, the balance on hand can't be trusted
|
||||
|
||||
+51
-4
@@ -211,7 +211,15 @@ export interface VestCapitalAccount {
|
||||
productType: string;
|
||||
/** 1-based stage; stages[stage-1] is the current one. */
|
||||
stage: number;
|
||||
/** Evaluation steps the plan has (a 1-Step's is 1, a 2-Step's 2). */
|
||||
stepsTotal: number;
|
||||
/** The evaluation step this account is (1-based; a funded one's is
|
||||
* stepsTotal + 1, as the Vest app counts it when Vest leaves it out). */
|
||||
step: number;
|
||||
/** Vest's account_type: 2 an evaluation, 3 a funded account. */
|
||||
accountType: number;
|
||||
/** Vest blocked the account (blocked_at set): nothing it offers applies. */
|
||||
blocked: boolean;
|
||||
/** The current stage: 'eval' or 'funded'. */
|
||||
kind: string;
|
||||
status: number;
|
||||
@@ -236,13 +244,18 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||||
const stage = num(a.stage) ?? 1;
|
||||
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
||||
// The Vest app's own fallbacks, for an answer that leaves these out.
|
||||
const stepsTotal = num(a.steps_total) ?? (a.plan_product_type === 'eval' ? 1 : 0);
|
||||
return {
|
||||
id: a.id,
|
||||
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
||||
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
||||
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
||||
stage,
|
||||
stepsTotal: num(a.steps_total) ?? 0,
|
||||
stepsTotal,
|
||||
step: num(a.step) ?? (stage === VEST_STAGE_FUNDED ? stepsTotal + 1 : 1),
|
||||
accountType: num(a.account_type) ?? 0,
|
||||
blocked: Boolean(a.blocked_at),
|
||||
kind: typeof current.kind === 'string' ? current.kind : '',
|
||||
status: num(a.status) ?? -1,
|
||||
attemptIndex: num(a.attempt_index) ?? 0,
|
||||
@@ -259,9 +272,43 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** The capital account status every listed live account showed (2026-10-26).
|
||||
* Anything else is treated as unable to trade until seen and named. */
|
||||
export const VEST_STATUS_ACTIVE = 2;
|
||||
/** Vest's capital account statuses, as the Vest app names them (its bundle,
|
||||
* 2026-10-29). Only an ACTIVE account trades. A passed evaluation sits at
|
||||
* GOAL_PASSED until its funded account is activated, and is CLAIMED after. */
|
||||
export const VEST_STATUS = {
|
||||
CREATED: 1,
|
||||
ACTIVE: 2,
|
||||
GOAL_PASSED: 3,
|
||||
FAILED_MAX_DRAWDOWN: 4,
|
||||
FAILED_MAX_DAILY_LOSS: 5,
|
||||
CLAIMED: 6,
|
||||
EXPIRED: 7,
|
||||
} as const;
|
||||
export const VEST_STATUS_ACTIVE = VEST_STATUS.ACTIVE;
|
||||
|
||||
/** Vest's stage field: an evaluation's, then the funded account's. */
|
||||
const VEST_STAGE_EVAL = 1;
|
||||
const VEST_STAGE_FUNDED = 2;
|
||||
/** Vest's account_type of an evaluation. */
|
||||
const VEST_ACCOUNT_TYPE_EVAL = 2;
|
||||
|
||||
/** A status as the log says it: Vest's own name, with its number. */
|
||||
export function vestStatusName(status: number): string {
|
||||
const name = Object.entries(VEST_STATUS).find(([, v]) => v === status)?.[0];
|
||||
return `${name ? name.toLowerCase().replace(/_/g, ' ') : 'unknown'} (status ${status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A passed evaluation whose funded account Vest activates on request (POST
|
||||
* /v3/capital/activate-funded) — the Vest app's own test for its "Go to
|
||||
* funded account" button: an evaluation at GOAL_PASSED, not blocked, on its
|
||||
* plan's last step. An earlier step of a 2-Step passes on to the next step
|
||||
* by itself.
|
||||
*/
|
||||
export function awaitingActivation(a: VestCapitalAccount): boolean {
|
||||
const evaluation = a.accountType === VEST_ACCOUNT_TYPE_EVAL || a.stage === VEST_STAGE_EVAL;
|
||||
return evaluation && a.status === VEST_STATUS.GOAL_PASSED && !a.blocked && a.stepsTotal > 0 && a.step === a.stepsTotal;
|
||||
}
|
||||
|
||||
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
||||
|
||||
|
||||
@@ -411,6 +411,103 @@ test("the client's Vest day turns with its numbers, and a sync that stalls stops
|
||||
}
|
||||
});
|
||||
|
||||
// A timeout: an ask let through twice leaves the first one waiting forever.
|
||||
test("a passed evaluation doesn't hold the sync, and its funded account is asked for once", { timeout: 10_000 }, async () => {
|
||||
clockAt('2026-10-29T15:31:00Z');
|
||||
// Vest's status for each account (acc-2 passed), and the balance Vest's
|
||||
// active list leaves out — a passed one's.
|
||||
const status = { 'acc-1': 2, 'acc-2': 3 };
|
||||
let missingBalance = 'acc-2';
|
||||
let activation = null;
|
||||
const sent = [];
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: Object.keys(status).map((id, i) => ({
|
||||
id, status: status[id], account_type: 2, stage: 1, step: 1, steps_total: 1, plan_product_type: 'eval',
|
||||
attempt_index: i + 80, initial_capital: '50000', max_daily_loss_pct: '0.03',
|
||||
stages: [{ ordinal: 1, kind: 'eval', profit_target: '5000' }, { ordinal: 2, kind: 'funded', profit_target: '' }],
|
||||
})) } };
|
||||
}
|
||||
if (method === 'GET' && path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '55100', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
].filter((b) => b.account_id !== missingBalance) } };
|
||||
}
|
||||
if (method === 'POST' && path === '/v3/capital/activate-funded') {
|
||||
sent.push({ account: opts.account, body: opts.body });
|
||||
return activation();
|
||||
}
|
||||
return syncReads(method, path, []) ?? unanswered(method, path);
|
||||
});
|
||||
const funded = { id: 'acc-2-funded', status: 2, account_type: 3, stage: 2, attempt_index: 81, initial_capital: '50000', max_daily_loss_pct: '0.03', stages: [{ ordinal: 1, kind: 'eval' }, { ordinal: 2, kind: 'funded' }] };
|
||||
// The sync an ask starts behind it, run to its end.
|
||||
const settled = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await client.syncRunning;
|
||||
};
|
||||
try {
|
||||
await client.requestSync();
|
||||
assert.ok(client.lastSyncAt > 0, "the passed account's missing balance didn't make the sync short of good");
|
||||
assert.equal(client.accountList.find((a) => a.id === B).active, false);
|
||||
assert.deepEqual(client.activatable(), [B]);
|
||||
await assert.rejects(client.activateFunded(A), /only a passed evaluation/);
|
||||
|
||||
// While the ask is out, a second caller is refused, and a sync reads
|
||||
// a list from before Vest's answer: it isn't asked again on it.
|
||||
let answer;
|
||||
activation = () => new Promise((resolve) => { answer = resolve; });
|
||||
const asking = client.activateFunded(B);
|
||||
await assert.rejects(client.activateFunded(B), /hasn't been read back/);
|
||||
clockAt('2026-10-29T15:31:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
answer({ data: { account: funded } });
|
||||
assert.deepEqual(await asking, { funded: 'V50K-F-D3-81' });
|
||||
assert.deepEqual(sent, [{ account: 'acc-2', body: { eval_account_id: 'acc-2' } }]);
|
||||
|
||||
// Answered: never asked again, while Vest's list is slow to move it
|
||||
// or once it's claimed.
|
||||
await settled();
|
||||
clockAt('2026-10-29T15:40:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
status['acc-2'] = 6;
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
// acc-1 passes too. A timeout may have gone in: held until a list
|
||||
// read a minute after it. A refusal went nowhere: free at once.
|
||||
status['acc-1'] = 3;
|
||||
missingBalance = 'acc-1';
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new Error('/v3/capital/activate-funded timed out after 15s'); };
|
||||
await assert.rejects(client.activateFunded(A), /timed out/);
|
||||
await settled();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:40:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:41:10Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new VestApiError('/v3/capital/activate-funded: funded limit reached (HTTP 400)', 400); };
|
||||
await assert.rejects(client.activateFunded(A), /funded limit/);
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
assert.equal(sent.length, 3);
|
||||
|
||||
// A live account's missing balance still holds the sync.
|
||||
status['acc-1'] = 2;
|
||||
const good = client.lastSyncAt;
|
||||
clockAt('2026-10-29T15:45:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(client.lastSyncAt, good);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
|
||||
clockAt('2026-10-27T23:00:00Z');
|
||||
const client = await syncingClient(unanswered);
|
||||
|
||||
+37
-1
@@ -14,7 +14,8 @@ import {
|
||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill,
|
||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName,
|
||||
VEST_STATUS,
|
||||
} from '../lib/vest-helpers.ts';
|
||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
||||
@@ -120,6 +121,41 @@ test('capital accounts parse, and are named for their rules', () => {
|
||||
assert.equal(parseCapitalAccount({ nope: true }), null);
|
||||
});
|
||||
|
||||
test('a passed evaluation waits on its funded account, as the Vest app decides', () => {
|
||||
const passed = { ...evalAccount, status: VEST_STATUS.GOAL_PASSED };
|
||||
const a = parseCapitalAccount(passed);
|
||||
assert.equal(a.step, 1);
|
||||
assert.equal(a.stepsTotal, 1);
|
||||
assert.equal(a.accountType, 2);
|
||||
assert.equal(a.blocked, false);
|
||||
assert.equal(awaitingActivation(a), true);
|
||||
|
||||
// Still trading, already claimed, failed or expired: nothing to activate.
|
||||
for (const status of [1, 2, 4, 5, 6, 7]) assert.equal(awaitingActivation(parseCapitalAccount({ ...evalAccount, status })), false, `status ${status}`);
|
||||
// Blocked by Vest.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, blocked_at: 1793287000000 })), false);
|
||||
// A funded account that reached its goal isn't an evaluation — on the
|
||||
// last step's number too, so only the evaluation test can say so.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3, stage: 2, step: 1 })), false);
|
||||
// Either of Vest's marks makes it one.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3 })), true);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, stage: 2, step: 1 })), true);
|
||||
// A 2-Step's first step passes on to its second by itself; the last one waits.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 1, steps_total: 2 })), false);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 2, steps_total: 2 })), true);
|
||||
|
||||
// Without step or steps_total, the Vest app's own fallbacks.
|
||||
const { step, steps_total, ...bare } = passed;
|
||||
assert.equal(awaitingActivation(parseCapitalAccount(bare)), true);
|
||||
assert.equal(parseCapitalAccount({ ...bare, stage: 2 }).step, 2);
|
||||
assert.equal(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' }).stepsTotal, 0);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' })), false);
|
||||
|
||||
assert.equal(vestStatusName(3), 'goal passed (status 3)');
|
||||
assert.equal(vestStatusName(6), 'claimed (status 6)');
|
||||
assert.equal(vestStatusName(9), 'unknown (status 9)');
|
||||
});
|
||||
|
||||
const openPosition = {
|
||||
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
|
||||
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
|
||||
|
||||
Reference in New Issue
Block a user