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

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

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

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

772 lines
39 KiB
JavaScript

// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
// pure helpers show: the market-close guard closing a leg whose open came back
// unknown, and the candidate board keeping a symbol the close gate holds back;
// a sync older than the bot's own fill, an open a restart cut off, an outside
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
// the completed cycles a Vest day's claims end, named at each claim; a
// refused account-token mint, a new account's history read again within the
// minute, and exits that wait out a lapse in the session.
//
// npm run check
//
// A scratch database, a fake clock, and every gateway call answered here —
// nothing reaches Vest.
import { after, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { registerHooks, stripTypeScriptTypes } from 'node:module';
// Resolve the extensionless local imports used by Next, without a build.
function resolveTypes(specifier, context, nextResolve) {
try {
return nextResolve(specifier, context);
} catch (err) {
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
return nextResolve(`${specifier}.ts`, context);
}
}
// The client's classes use parameter properties, which plain type
// stripping refuses: transform them instead.
function loadTypes(url, context, nextLoad) {
if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context);
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' });
return { format: 'module', source, shortCircuit: true };
}
registerHooks({ resolve: resolveTypes, load: loadTypes });
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-'));
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
delete process.env.NEXT_PHASE;
after(() => rmSync(directory, { recursive: true, force: true }));
// No request leaves this process: axios refuses every one, and each client's
// gateway is a function in this file — from construction on, since the
// constructor starts a sync.
const { default: axios } = await import('../lib/http-client.ts');
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
const { VestClient } = await import('../lib/vest-class.ts');
// The client's own transport, for the checks of what it does with an answer.
const realRequest = VestClient.prototype.request;
let gateway = null;
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
// Calls on an account's token reach the gateway here without one, so its
// mint is answered here too.
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
return gateway(method, path, opts);
};
const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts');
const {
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
} = await import('../lib/db.ts');
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts');
const { isClientReady, isOrderNotPlaced } = await import('../lib/platform-client.ts');
const iso = (s) => Date.parse(s);
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
// The clock the client reads (Date.now), set to an instant and running on.
const realNow = Date.now;
after(() => { Date.now = realNow; });
let tokens = 0;
function clockAt(when) {
const start = realNow();
const t0 = iso(when);
Date.now = () => t0 + (realNow() - start);
tokens += 1;
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
}
// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC
// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday.
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
const week = [
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
];
const spec = (symbol, assetId) => ({
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING',
});
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]);
/** A client with no sync running, its gateway answered by `answer`, holding
* acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */
function testClient(answer, ids = [1, 2]) {
gateway = null;
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
clearInterval(client.syncInterval);
clearInterval(client.watchdogInterval);
gateway = answer;
client.specs = specs;
client.symbolSpecs = async () => specs;
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
client.accountList = ids.map((id, i) => ({ id, name: `V50K-F-D3-${i + 1}`, active: true }));
ids.forEach((id, i) => {
client.uuidById.set(id, `acc-${i + 1}`);
client.capital.set(id, { status: VEST_STATUS_ACTIVE });
});
return client;
}
const A = vestAccountId('acc-1');
const B = vestAccountId('acc-2');
const NDX = encodeContractName('NDX-USD', 0.001);
const rawPosition = (positionId, accountId, openDate, extra = {}) => ({
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
stopLosses: [], takeProfits: [], ...extra,
});
/** What a sync reads (acc-1 and acc-2 funded, $50k each, and the login's
* main account; `positions` open), and the history reads after it. */
function syncReads(method, path, positions) {
if (method !== 'GET') return null;
if (path === '/v3/capital/accounts/active') {
return { data: { accounts: ['acc-1', 'acc-2'].map((id, i) => ({
id, status: VEST_STATUS_ACTIVE, attempt_index: i + 1, initial_capital: '50000', max_daily_loss_pct: '0.03', stage: 1, stages: [{ ordinal: 1, kind: 'funded' }],
})) } };
}
if (path === '/v3/accounts') {
return { data: { accounts: [
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '50000', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
] } };
}
if (path === '/v3/positions/opened') return { data: { positions } };
if (path === '/v3/positions/opened-orders') return { data: { orders: [] } };
if (path === '/v3/ticker/latest') return { data: { tickers: [] } };
if (path === '/v3/positions') return { data: { positions: [] } };
return null;
}
/** testClient on the ids a sync gives, once the constructor's own sync
* (which reaches no gateway) is over, with its history taken as read — so
* each requestSync here is one good sync, and starts nothing else. */
async function syncingClient(answer) {
const client = testClient(answer, [A, B]);
await client.syncRunning;
client.historyLoaded = true;
client.historyLoadedIds = new Set([A, B]);
client.lastDaysFetchStartedAt = Date.now();
client.lastDaysFetchSucceededAt = Infinity;
return client;
}
const unanswered = (method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); };
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
let positions = [];
const closes = [];
const client = testClient((method, path, opts) => {
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up');
if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; }
throw new Error(`no gateway in this test: ${method} ${path}`);
});
try {
// Friday 8:30 AM ET: the entry's send fails in transit, but it filled.
clockAt('2026-10-30T12:30:00Z');
await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/);
const raw = (positionId, accountId, openDate) => ({
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
stopLosses: [], takeProfits: [],
});
positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))];
client.positionsDetail.set(1, []);
client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]);
client.syncComplete = true;
// 4:56 PM ET: four minutes to the weekly close.
clockAt('2026-10-30T20:56:00Z');
await client.guardClose();
assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]);
assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']);
// A close just sent isn't sent again while a read may not show it.
await client.guardClose();
assert.equal(closes.length, 1);
} finally {
client.disconnect();
}
});
test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => {
const client = testClient((method, path) => {
throw new Error(`no gateway in this test: ${method} ${path}`);
});
client.refreshMarks = async () => {};
client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }]));
const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); };
try {
// Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens
// before the reset — no gate.
clockAt('2026-10-29T18:00:00Z');
marked();
await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD');
assert.equal(thursday.closesAt, undefined);
// Friday 2 PM ET: the weekly close is 3 h off; crypto never closes.
clockAt('2026-10-30T18:00:00Z');
marked();
const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
assert.equal(pick.id, 'BTC-PERP');
assert.equal(pick.closesAt, undefined);
assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z'));
// The survey keeps it up for the dashboard, though nothing is picked.
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);
// And the client's day is Vest's.
assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z')));
} finally {
client.disconnect();
}
});
test("a sync whose positions read predates the bot's own fill leaves the fill's record alone", async () => {
let positions = [];
let holdRead = false;
let releaseRead = null;
clockAt('2026-10-28T14:00:00Z');
const client = await syncingClient((method, path) => {
if (method === 'GET' && path === '/v3/positions/opened' && holdRead) {
// Read now, flat — answered once released.
holdRead = false;
const served = positions;
return new Promise((resolve) => { releaseRead = () => resolve({ data: { positions: served } }); });
}
const read = syncReads(method, path, positions);
if (read) return read;
if (method === 'POST' && path === '/v3/positions/open') {
positions = [rawPosition('9101', 'acc-1', Date.now() + 300)];
return { data: { orderId: 'o-9101', positionId: '9101' } };
}
return unanswered(method, path);
});
try {
await client.requestSync();
// A sync reads acc-1 flat; the entry fills while it is still out.
holdRead = true;
const sync = client.requestSync();
await client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open');
// Filled: the note kept against a restart goes.
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
releaseRead();
await sync;
assert.deepEqual(client.positionsDetail.get(A).map((p) => p.positionId), ['9101']);
assert.equal(client.positions[A].netPos, 0.5);
// A read lagging the fill: the leg's flatten is refused as a position
// still out there (FLATTEN FAILED), not taken as nothing to flatten.
positions = [];
await assert.rejects(client.sendOrder(A, NDX, 500, 'Sell', 'Market', undefined, 'close'), /last saw a long one open/);
// A sync that went out after the fill writes what it reads.
await client.requestSync();
assert.deepEqual(client.positionsDetail.get(A), []);
assert.equal(client.positions[A], undefined);
} finally {
client.disconnect();
}
});
test("an open a restart cut off is marked the bot's once a read after the restart shows it", async () => {
let positions = [];
let posted;
const postedAt = new Promise((resolve) => { posted = resolve; });
clockAt('2026-10-28T15:00:00Z');
const before = await syncingClient((method, path) => {
const read = syncReads(method, path, positions);
if (read) return read;
// The app stops with the open out.
if (method === 'POST' && path === '/v3/positions/open') { posted(Date.now()); return new Promise(() => {}); }
return unanswered(method, path);
});
void before.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open').catch(() => {});
const sentAt = await postedAt;
before.disconnect();
// Vest filled it, and an outside long opened on acc-2 with it. acc-2 has
// an older note, of a short that never showed.
positions = [rawPosition('9201', 'acc-1', sentAt + 400), rawPosition('9202', 'acc-2', sentAt + 400)];
saveAccountMeta(B, 'vest_pending_open', JSON.stringify({ accountId: 'acc-2', symbol: 'NDX-USD', side: 'short', sentAt: sentAt - 10 * 60_000 }));
clockAt('2026-10-28T15:02:00Z');
let refuse = false;
const after = await syncingClient((method, path) => {
const read = syncReads(method, path, positions);
if (read) return read;
if (method === 'POST' && path === '/v3/positions/open' && refuse) throw new VestApiError('/v3/positions/open: refused (HTTP 400)', 400, 'REFUSED');
return unanswered(method, path);
});
try {
await after.requestSync();
assert.deepEqual([...botOpenedPositions(['9201', '9202'])], ['9201']);
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
assert.equal(loadAccountMeta(B, 'vest_pending_open'), 'null');
// An open Vest refuses leaves no note behind.
positions = [];
refuse = true;
await assert.rejects(after.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /refused/);
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
} finally {
after.disconnect();
}
});
test("an open of unknown outcome isn't matched to an outside trade after its error, nor to one of several", async () => {
let positions = [];
let open = null;
let onRead = () => {};
clockAt('2026-10-28T16:00:00Z');
const client = await syncingClient((method, path) => {
if (method === 'GET' && path === '/v3/positions/opened') onRead();
const read = syncReads(method, path, positions);
if (read) return read;
if (method === 'POST' && path === '/v3/positions/open') return open();
return unanswered(method, path);
});
try {
await client.requestSync();
// The entry's send fails in transit, and it never went in. Half a
// minute on, an outside copy-trader opens NDX long on every account.
open = () => { throw new Error('socket hang up'); };
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
const errorAt = Date.now();
positions = [rawPosition('9301', 'acc-1', errorAt + 30_000), rawPosition('9302', 'acc-2', errorAt + 30_000)];
clockAt('2026-10-28T16:01:00Z');
await client.requestSync();
assert.deepEqual([...botOpenedPositions(['9301', '9302'])], []);
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
// Another on acc-2, and two positions show from that moment: either
// could be the bot's, so neither is marked.
positions = [];
clockAt('2026-10-28T16:05:00Z');
open = () => {
positions = [rawPosition('9303', 'acc-2', Date.now() + 100), rawPosition('9304', 'acc-2', Date.now() + 150)];
throw new Error('socket hang up');
};
await assert.rejects(client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
await client.requestSync();
assert.deepEqual([...botOpenedPositions(['9303', '9304'])], []);
// An open whose answer named its position, though no read showed it
// within the wait: that position is the bot's, even with a stop a
// hand has put on it since.
positions = [];
clockAt('2026-10-28T16:10:00Z');
let answered = false;
open = () => { answered = true; return { data: { orderId: 'o-9305', positionId: '9305' } }; };
// The wait's first read comes back past its deadline.
onRead = () => { if (answered) { answered = false; clockAt('2026-10-28T16:10:45Z'); } };
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /not seen filled/);
const stop = { id: 'sl-9305', triggerPrice: '19900', executionType: 'market', status: 'awaiting' };
positions = [rawPosition('9305', 'acc-1', iso('2026-10-28T16:10:00Z') + 500, { stopLosses: [stop] })];
await client.requestSync();
assert.deepEqual([...botOpenedPositions(['9305'])], ['9305']);
} finally {
client.disconnect();
}
});
test("the client's Vest day turns with its numbers, and a sync that stalls stops the trading it holds back", async () => {
clockAt('2026-10-27T23:58:00Z');
const client = await syncingClient((method, path) => syncReads(method, path, []) ?? unanswered(method, path));
// The day the engine plans the lane on, at this test's clock.
const engineDay = () => tradeDateFor(client, new Date(Date.now()));
try {
// 7:58 PM ET, before any sync: the day now.
assert.equal(engineDay(), '2026-10-27');
await client.requestSync();
// 8:00:20 PM ET: Vest's day has turned, but the numbers the last sync
// worked out are still the ended day's — and so is the engine's day.
clockAt('2026-10-28T00:00:20Z');
assert.equal(vestDayOf(Date.now()), '2026-10-28');
assert.equal(engineDay(), '2026-10-27');
assert.equal(isClientReady(client), true);
// Nothing opens on the ended day's numbers.
const opening = await client.sendOrder(A, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
assert.equal(isOrderNotPlaced(opening), true);
assert.match(opening.message, /no sync has read it yet/);
await client.requestSync();
assert.equal(engineDay(), '2026-10-28');
// No sync after the next reset: the day holds back only while the
// scheduler still trades on the last sync's numbers.
clockAt('2026-10-28T23:59:00Z');
await client.requestSync();
clockAt('2026-10-29T00:03:00Z');
assert.equal(engineDay(), '2026-10-28');
assert.equal(isClientReady(client), true);
clockAt('2026-10-29T00:05:00Z');
assert.equal(engineDay(), '2026-10-28');
assert.equal(isClientReady(client), false);
} finally {
client.disconnect();
}
});
// 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);
try {
// $1,500 then $3,000 taken on Vest day 10-27: Vest's two WITHDRAWs, one row.
client.withdrawals.set(A, [{ at: iso('2026-10-27T15:00:00Z'), amount: -1500 }, { at: iso('2026-10-27T19:00:00Z'), amount: -3000 }]);
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
assert.deepEqual(client.payoutDays(A), ['2026-10-27', '2026-10-27']);
// A type whose stages don't reset each day trades stage 3's $2,000, not stage 2's again.
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
const inputs = stageInputsFor(client, A, { stagesSameDay: false });
assert.equal(inputs.payouts, 2);
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, stages, 0, client.fundTransactions[A], 0, inputs).profitTarget, 2000);
// Before its history is read: one per negative fund row.
client.historyLoadedIds.delete(A);
assert.equal(stageInputsFor(client, A, {}).payouts, undefined);
} finally {
client.disconnect();
}
});
test('each claim of a Vest day names the completed cycle it ends, the second and the one after the reset too', async () => {
// acc-1, funded $50k, Vest's own numbers: its balance, and its reset balance since 20:00 ET.
let balance = 51500;
let reset = { at: '2026-10-27T00:00:00Z', balance: 50000 };
clockAt('2026-10-27T19:00:00Z'); // 3 PM ET, Vest day 10-27
wipeAccountData(A);
const client = await syncingClient((method, path, opts) => {
const read = syncReads(method, path, []);
if (read && path === '/v3/capital/accounts/active') {
read.data.accounts[0] = { ...read.data.accounts[0], daily_reset_at: reset.at, daily_reset_balance: String(reset.balance) };
}
if (read && path === '/v3/accounts') read.data.accounts[0] = { ...read.data.accounts[0], amount: String(balance) };
if (read) return read;
if (method === 'POST' && path === '/v3/capital/withdraw' && opts.body.account_id === 'acc-1') {
balance = Math.round((balance - Number(opts.body.amount)) * 100) / 100;
return { data: { trader_amount: opts.body.amount } };
}
return unanswered(method, path);
});
// The history read a claim starts isn't this test's: what it books is set here.
client.fetchDaysTraded = async () => ({ failedAccounts: 0 });
const cfg = {
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000, targetSameEquity: false,
splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false, stagesSameDay: true, dailyLossLimit: 1500,
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
};
/** The day's trading so far, as the history read books it, and Vest's WITHDRAWs for the claims made. */
const history = (dayPnl) => {
client.fullDailyPnL[A] = [{ date: '2026-10-27', pnl: dayPnl }];
client.applyCycleSlice(A);
client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount })));
};
/** The cycle a claim now would name, stamped with the claim's time. */
const named = () => {
const now = Date.now();
const c = claimedCycle(client, A, cfg, new Date(now));
if (c) { assert.equal(c.at, now); delete c.at; }
return c;
};
try {
saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]);
saveAccountMeta(A, 'last_fund_date', '2026-10-20');
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }];
client.lastFundDates[A] = '2026-10-20';
history(1500);
await client.requestSync();
// Stage 1's $1,500, claimed: the account's first cycle.
assert.deepEqual(named(), { date: '2026-10-27', stage: 1, drain: false, cycle: '2026-10-20', cycleSeq: 0 });
await client.claimProfit(A, 1500);
await client.syncRunning; // the sync the claim starts
// 6 PM ET: stage 2's $3,000 made, and claimed the same Vest day — its
// payout books into 10-27's row, and the latest fund date stays.
clockAt('2026-10-27T22:00:00Z');
balance = 53000;
history(4500);
await client.requestSync();
assert.deepEqual(named(), { date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
await client.claimProfit(A, 3000);
await client.syncRunning;
assert.equal(client.lastFundDates[A], '2026-10-27');
assert.deepEqual(client.fundTransactions[A].at(-1), { date: '2026-10-27', amount: -4500 });
// 6:30 PM ET: the day's third cycle, $1,500 of stage 2's $3,000 — not complete.
clockAt('2026-10-27T22:30:00Z');
balance = 51500;
history(6000);
await client.requestSync();
assert.equal(named(), null);
// 8:00:30 PM ET: the reset puts it back on stage 1's $1,500, met —
// claimed at the first tick.
clockAt('2026-10-28T00:00:30Z');
reset = { at: '2026-10-28T00:00:00Z', balance: 51500 };
await client.requestSync();
assert.equal(tradeDateFor(client, new Date(Date.now())), '2026-10-28');
assert.deepEqual(named(), { date: '2026-10-28', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
await client.claimProfit(A, 1500);
await client.syncRunning;
assert.equal(client.lastFundDates[A], '2026-10-28');
// The catch-up names none of it: the cycle's P&L sits in 10-27's row,
// which the cycle before owns.
clockAt('2026-10-28T00:10:00Z');
await client.requestSync();
assert.equal(endedCycleCompletion({
accountId: A, rawTradeDate: tradeDateFor(client, new Date(Date.now())), client,
fullDailyPnL: client.fullDailyPnL[A], cycleDailyPnL: client.dailyPnL[A], fundTransactions: client.fundTransactions[A],
lastFundDate: client.lastFundDates[A], endedCycleStart: JSON.parse(loadAccountMeta(A, 'ended_cycle_start')),
rawRealizedPnL: client.accountCashBalances[A].realizedPnL, equityProfit: client.accountCashBalances[A].amount - 50000, cfg,
now: new Date(Date.now()),
}), null);
// Kept as the claims leave them; an entry that doesn't read is left out.
const kept = [{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0, at: iso('2026-10-27T22:00:00Z') }];
saveClaimedCycles(A, [...kept, { date: '2026-10-28', stage: '3', drain: false, cycle: '2026-10-27', cycleSeq: 1 }]);
assert.deepEqual(loadClaimedCycles(A), kept);
saveAccountMeta(A, 'claimed_cycles', '{');
assert.deepEqual(loadClaimedCycles(A), []);
} finally {
client.disconnect();
}
});
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
clockAt('2026-10-29T05:41:00Z');
const client = testClient(unanswered, [A, B]);
await client.syncRunning;
// The client's own transport, on a gateway that refuses acc-1's mint as
// Vest did all night on 2026-10-28 (401, code 1002).
client.request = realRequest;
const adapter = axios.defaults.adapter;
const sent = [];
axios.defaults.adapter = async (config) => {
const path = config.url.replace(/^https:\/\/[^/]+/, '');
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
if (path === '/v3/auth/account-token') {
return body.accountId === 'acc-1'
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
}
if (path === '/v3/transactions') return reply(200, { transactions: [] });
if (path === '/v3/positions') return reply(200, { positions: [] });
return reply(404, {});
};
try {
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
assert.equal(failedAccounts, 1);
assert.notEqual(vestSessionFor(WALLET), null);
assert.equal(client.historyLoadedIds.has(A), false);
assert.equal(client.historyLoadedIds.has(B), true);
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
assert.ok(sent.includes('GET /v3/transactions'));
} finally {
axios.defaults.adapter = adapter;
client.disconnect();
}
});
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
clockAt('2026-10-29T05:41:00Z');
const C = vestAccountId('acc-3');
const client = testClient(unanswered, [A, B, C]);
await client.syncRunning;
// acc-3 isn't active: it waits for a whole pass.
client.capital.set(C, { status: 3 });
client.accountList[2].active = false;
client.historyLoaded = true;
client.historyLoadedIds = new Set([A]);
client.lastDaysFetchStartedAt = Date.now() - 120_000;
// A whole pass failed: its retry is four minutes off.
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
const reads = [];
client.fetchDaysTraded = async (ids) => {
reads.push(ids);
return { failedAccounts: ids ? ids.length : 0 };
};
const settled = () => new Promise((resolve) => setImmediate(resolve));
try {
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B]]);
// It failed again: not before the minute is up, and the whole pass's
// retry stays where it was.
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B]]);
clockAt('2026-10-29T05:42:01Z');
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B], [B]]);
clockAt('2026-10-29T05:45:01Z');
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B], [B], undefined]);
} finally {
client.disconnect();
}
});
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
clockAt('2026-10-29T05:41:00Z');
const C = vestAccountId('acc-3');
const client = testClient(unanswered, [A, B, C]);
await client.syncRunning;
client.capital.set(C, { status: 3 });
client.accountList[2].active = false;
client.historyLoadedIds = new Set([A]);
const order = [];
client.fetchTxns = async (uuid) => {
order.push(uuid);
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
return [];
};
client.fetchTrades = async () => {};
try {
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
assert.equal(failedAccounts, 0);
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
} finally {
client.disconnect();
}
});
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
clockAt('2026-10-29T06:00:00Z');
const legs = [];
const client = testClient((method, path, opts) => {
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
return { data: {} };
}
throw new Error(`no gateway in this test: ${method} ${path}`);
}, [A, B]);
try {
// The session lapses (Vest refused it) just after an entry filled.
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
assert.equal(vestSessionFor(WALLET), null);
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
assert.equal(isOrderNotPlaced(opening), true);
assert.match(opening.message, /no Vest session/);
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
await new Promise((resolve) => setTimeout(resolve, 1_200));
assert.deepEqual(legs, []);
// The extension relays it again: the exits go, the stop first.
tokens += 1;
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
await exits;
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
} finally {
client.disconnect();
}
});