Two of the cycles a claim ends never got a completion entry. A Vest day's second cycle is one: the auto-claim at the next tick beats the 30 s report, and the payout books into that day's fund row, so the latest fund date never moves for the catch-up. A cycle claimed right after the 20:00 ET reset is the other: the reset puts it back on stage 1, and all of its P&L sits in the previous claim's day row, so endedCycleCompletion reads none of it. Before a claim goes out, claimedCycle (trading-logic) checks whether the cycle stands complete: cycleCompletion 'today' or 'before' on the client's numbers. It keys the cycle the way the final day would (completionPlace on the payout list), so the master dedupes the two. afterClaim keeps the result in account_meta. The reporter names each kept cycle for the catch-up's week. It lists them last in the slice, newest first, under the master's per-slice cap, and adds no counter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
522 lines
27 KiB
JavaScript
522 lines
27 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.
|
|
//
|
|
// 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');
|
|
let gateway = null;
|
|
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
|
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
|
return gateway(method, path, opts);
|
|
};
|
|
const { putVestSession } = 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 } = 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);
|
|
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();
|
|
}
|
|
});
|
|
|
|
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 })));
|
|
};
|
|
const named = () => claimedCycle(client, A, cfg, new Date(Date.now()));
|
|
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 }];
|
|
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();
|
|
}
|
|
});
|