// 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. // // 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 } = await import('../lib/db.ts'); const { vestDayOf, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts'); const { encodeContractName } = await import('../lib/dxtrade-helpers.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`. */ function testClient(answer) { 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 = [{ id: 1, name: 'V50K-F-D3-1', active: true }, { id: 2, name: 'V50K-F-D3-2', active: true }]; client.uuidById.set(1, 'acc-1'); client.uuidById.set(2, 'acc-2'); client.capital.set(1, { status: VEST_STATUS_ACTIVE }); client.capital.set(2, { status: VEST_STATUS_ACTIVE }); return client; } 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', 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 }])); try { // Thursday 2 PM ET: NDX's maintenance hour is 3 h off, crypto never closes. clockAt('2026-10-29T18:00:00Z'); for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); 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-29T21: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(); } });