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:
Brandon Li
2026-10-29 12:09:49 -05:00
co-authored by Claude Opus 5.5
parent 0eb56b3949
commit 8a5b2a64e7
8 changed files with 380 additions and 18 deletions
+94 -2
View File
@@ -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