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
+97
View File
@@ -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
View File
@@ -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',