When the master changes an account type with "New accounts only", it now serves the type's old rules as kept rule sets (firm.keptAccountRules), each for accounts dated inside [openedAt, closedAt). This build applies them: - getAccountConfig, the one rules lookup trading reads, puts a kept set's rule fields over the live type when the account's rules date falls in its window. Id, label, prefix, size and the projection fields stay the live type's, so every report still names the live type. - account_assignments gains rules_date and rules_date_manual. A new row is dated when the machine first sees the account, on the master's clock when this one runs behind and never before the catalog's last change. Auto dates come down to the broker's evidence: the end of the first fund day in Chicago, or a DXtrade registration instant. Evidence is applied before an account's first trade. Rows from before the migration get its time; unpinning keeps the row and its date. Backups carry both columns. - An account on a kept set stays on its type while its prefix and cached size still match, so a tie-break flip can't move it off its old rules. - Kept entries parse through the same normalizer as live types (now normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the sync like a malformed type. The cached catalog records the parser that stored it, and a cache from an older parser is fetched in full rather than trusted with ?since= (this also brings in maxConcurrentAccounts). - Reports add keptRuleIds per firm and live counts per kept set per type. - The dashboard row and the account page show the rules date next to the account name, with an "old rules" chip saying what differs. Clicking the date edits it inline (PUT /api/accounts/:id/rules-date), previewing which rules the date lands on. Also: the master's per-firm cap on accounts traded at once (maxConcurrentAccounts), applied on top of the machine's own max concurrent when a batch is drawn and in copy trading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
17 KiB
JavaScript
284 lines
17 KiB
JavaScript
// Checks for the account status the reporter sends the master's ledger
|
||
// (lib/platform-client.ts accountStatus and the helpers around it).
|
||
//
|
||
// npm run check
|
||
//
|
||
// .mjs on purpose: Next's tsconfig includes every .ts file, and a .ts test
|
||
// importing another .ts by extension would trip the build's type check. Node
|
||
// strips the types from lib/platform-client.ts at import time (its only
|
||
// imports are `import type`).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, firstFundDate, keptRuleIdsField, legacyDead,
|
||
REPORT_STATUS_VERSION, SYNC_STALE_MS,
|
||
} from '../lib/platform-client.ts';
|
||
|
||
const FLOORS = { negative: -2000, zero: 0, positive: 48_000 };
|
||
const OFFSETS = { above: 1500, at: 0, below: -1500 };
|
||
|
||
/** Every case of {cash present/missing, floor <0/0/>0, cash above/at/below the
|
||
* floor, active, history ready}. */
|
||
function* matrix() {
|
||
for (const [floorName, floor] of Object.entries(FLOORS)) {
|
||
for (const [offsetName, offset] of Object.entries(OFFSETS)) {
|
||
for (const missing of [false, true]) {
|
||
for (const active of [true, false]) {
|
||
for (const historyReady of [true, false]) {
|
||
const cash = missing ? undefined : floor + offset;
|
||
yield { label: `${floorName} floor, cash ${missing ? 'missing' : offsetName}, active ${active}, history ${historyReady}`, cash, floor, active, historyReady };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
test('legacyDead is the test reporter.ts:114 used before', () => {
|
||
for (const c of matrix()) {
|
||
const cashOrZero = c.cash ?? 0;
|
||
// Copied from reporter.ts at 8747dd6: autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold
|
||
assert.equal(legacyDead(cashOrZero, c.floor), c.floor !== 0 && cashOrZero <= c.floor, c.label);
|
||
}
|
||
});
|
||
|
||
test('dead is exactly the legacy test whenever there is a balance', () => {
|
||
let cases = 0;
|
||
for (const c of matrix()) {
|
||
if (c.cash === undefined) continue;
|
||
cases++;
|
||
const { status } = accountStatus(c);
|
||
assert.equal(status === 'dead', legacyDead(c.cash, c.floor), c.label);
|
||
}
|
||
assert.equal(cases, 36);
|
||
// A balance of exactly 0 is a balance, not a missing one.
|
||
assert.deepEqual(accountStatus({ cash: 0, floor: 48_000, active: true, historyReady: true }), { status: 'dead' });
|
||
assert.deepEqual(accountStatus({ cash: 0, floor: 0, active: true, historyReady: true }), { status: 'live' });
|
||
});
|
||
|
||
test('a missing balance is unknown, never dead and never 0', () => {
|
||
for (const c of matrix()) {
|
||
if (c.cash !== undefined) continue;
|
||
assert.deepEqual(accountStatus(c), { status: 'unknown', reason: 'cash_missing' }, c.label);
|
||
}
|
||
assert.deepEqual(accountStatus({ cash: null, floor: 48_000, active: true, historyReady: true }), { status: 'unknown', reason: 'cash_missing' });
|
||
});
|
||
|
||
test('the legacy invariant: the old dead count = dead + cash-missing with a positive floor', () => {
|
||
for (const c of matrix()) {
|
||
const { status, reason } = accountStatus(c);
|
||
const oldDead = legacyDead(c.cash ?? 0, c.floor);
|
||
const newDead = status === 'dead' || (status === 'unknown' && reason === 'cash_missing' && c.floor > 0);
|
||
assert.equal(newDead, oldDead, c.label);
|
||
}
|
||
});
|
||
|
||
test('priority: dead, then history pending, then locked, then live', () => {
|
||
const base = { cash: 50_000, floor: 48_000, active: true, historyReady: true };
|
||
assert.deepEqual(accountStatus(base), { status: 'live' });
|
||
assert.deepEqual(accountStatus({ ...base, active: false }), { status: 'locked', reason: 'inactive' });
|
||
assert.deepEqual(accountStatus({ ...base, active: false, inactiveReason: 'liquidate_only' }), { status: 'locked', reason: 'liquidate_only' });
|
||
assert.deepEqual(accountStatus({ ...base, active: false, historyReady: false }), { status: 'history_pending' });
|
||
assert.deepEqual(accountStatus({ ...base, cash: 47_000, active: false, historyReady: false }), { status: 'dead' });
|
||
assert.deepEqual(accountStatus({ ...base, cash: 47_000, deadReason: 'terminal:TERMINATED' }), { status: 'dead', reason: 'terminal:TERMINATED' });
|
||
// A floor of 0 is no floor: never dead, whatever the balance.
|
||
assert.equal(accountStatus({ ...base, cash: -10, floor: 0 }).status, 'live');
|
||
});
|
||
|
||
test('broker facts per platform', () => {
|
||
assert.deepEqual(brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }), { inactiveReason: 'liquidate_only' });
|
||
assert.deepEqual(brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: false }),
|
||
{ inactiveReason: 'liquidate_only', brokerStatus: 'closed,archived' });
|
||
assert.deepEqual(brokerFacts('topstepx', { active: true }), { inactiveReason: 'cannot_trade' });
|
||
assert.deepEqual(brokerFacts('topstepx', { active: false }), { inactiveReason: 'cannot_trade', brokerStatus: 'canTrade:false' });
|
||
const registered = Date.parse('2026-10-03T03:00:00Z');
|
||
assert.deepEqual(brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: registered }),
|
||
{ inactiveReason: 'status:FULL_TRADING', deadReason: 'terminal:FULL_TRADING', registeredAt: registered });
|
||
assert.deepEqual(brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }),
|
||
{ inactiveReason: 'close_only', deadReason: 'terminal:CLOSE_ONLY', brokerStatus: 'CLOSE_ONLY' });
|
||
assert.deepEqual(brokerFacts('dxtrade', { active: false }, undefined), { inactiveReason: 'inactive' });
|
||
});
|
||
|
||
test('a DXtrade account the firm terminated reads dead with the terminal status', () => {
|
||
// dxtrade-class.ts sets the floor to the balance for DEAD_STATUSES.
|
||
const facts = brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: NaN });
|
||
const fields = accountStatusFields({ cash: 94_210.5, floor: 94_210.5, active: false, historyReady: true, accountTypeId: null, fundTransactions: [], ...facts });
|
||
assert.equal(fields.status, 'dead');
|
||
assert.equal(fields.reason, 'terminal:TERMINATED');
|
||
assert.equal(fields.brokerStatus, 'TERMINATED');
|
||
// A CLOSE_ONLY account is inactive (floor 0 there), not dead.
|
||
const closeOnly = accountStatusFields({ cash: 94_210.5, floor: 0, active: false, historyReady: true, accountTypeId: 't', fundTransactions: [],
|
||
...brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }) });
|
||
assert.deepEqual([closeOnly.status, closeOnly.reason], ['locked', 'close_only']);
|
||
});
|
||
|
||
test('per-account fields: defaults and nulls are left out', () => {
|
||
const fields = accountStatusFields({ cash: undefined, floor: 0, active: true, historyReady: false, accountTypeId: null, fundTransactions: undefined, inactiveReason: 'liquidate_only' });
|
||
assert.deepEqual(fields, { status: 'unknown', reason: 'cash_missing' });
|
||
|
||
const live = accountStatusFields({
|
||
cash: 50_123.456, floor: 47_623.451, active: true, historyReady: true, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
|
||
fundTransactions: [{ date: '2026-09-30', amount: -1000 }, { date: '2026-09-25', amount: 50_000 }, { date: '2026-10-02', amount: 25 }],
|
||
inactiveReason: 'liquidate_only',
|
||
});
|
||
assert.deepEqual(live, {
|
||
status: 'live', balance: 50_123.46, floor: 47_623.45, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
|
||
firstFundDate: '2026-09-25', positiveFunds: 2,
|
||
});
|
||
|
||
// Fund history loaded with no positive rows: a count of 0, no date.
|
||
const none = accountStatusFields({ cash: 10, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: [] });
|
||
assert.deepEqual([none.positiveFunds, 'firstFundDate' in none], [0, false]);
|
||
});
|
||
|
||
test('DXtrade: the registration date (Chicago) stands in for the first fund date', () => {
|
||
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
|
||
const input = { cash: 100_000, floor: 0, active: true, historyReady: true, accountTypeId: 't', registeredAt };
|
||
assert.equal(accountStatusFields({ ...input, fundTransactions: [] }).firstFundDate, '2026-10-02');
|
||
// A real fund row wins; and nothing until the fund history has loaded.
|
||
assert.equal(accountStatusFields({ ...input, fundTransactions: [{ date: '2026-10-05', amount: 100_000 }] }).firstFundDate, '2026-10-05');
|
||
assert.equal('firstFundDate' in accountStatusFields({ ...input, fundTransactions: undefined }), false);
|
||
// A time no Date can hold is no date — and must not throw.
|
||
for (const bad of [1.7e18, -1e16, NaN]) {
|
||
assert.equal('registeredAt' in brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: bad }), false);
|
||
assert.equal('firstFundDate' in accountStatusFields({ ...input, registeredAt: bad, fundTransactions: [] }), false);
|
||
}
|
||
assert.deepEqual(firmStatusFields('f', true, 1e17), { statusVersion: 1, firmId: 'f' });
|
||
});
|
||
|
||
test('firstFundDate: the helper the report and the rules-date evidence share', () => {
|
||
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
|
||
assert.equal(firstFundDate(undefined, registeredAt), undefined);
|
||
assert.equal(firstFundDate([], registeredAt), '2026-10-02');
|
||
assert.equal(firstFundDate([], undefined), undefined);
|
||
assert.equal(firstFundDate([{ date: '2026-10-05T00:00:00', amount: 100_000 }, { date: '2026-10-04', amount: -50 }], registeredAt), '2026-10-05');
|
||
assert.equal(firstFundDate([{ date: '2026-10-09', amount: 25 }, { date: '2026-10-05', amount: 100_000 }], undefined), '2026-10-05');
|
||
// Always what accountStatusFields reports.
|
||
for (const funds of [undefined, [], [{ date: '2026-09-25', amount: 50_000 }], [{ date: '2026-09-30', amount: -1 }]]) {
|
||
for (const reg of [undefined, registeredAt, NaN]) {
|
||
const fields = accountStatusFields({ cash: 1, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: funds, registeredAt: reg });
|
||
assert.equal(fields.firstFundDate, firstFundDate(funds, reg));
|
||
}
|
||
}
|
||
});
|
||
|
||
test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => {
|
||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||
assert.deepEqual(firmStatusFields('f-1', true, at), { statusVersion: 1, firmId: 'f-1', syncedAt: '2026-10-06T14:00:00.000Z' });
|
||
assert.deepEqual(firmStatusFields('f-1', true, 0), { statusVersion: 1, firmId: 'f-1' });
|
||
assert.deepEqual(firmStatusFields('f-1', false, at), { statusVersion: 1, firmId: 'f-1' });
|
||
assert.equal(REPORT_STATUS_VERSION, 1);
|
||
assert.equal(SYNC_STALE_MS, 5 * 60 * 1000);
|
||
});
|
||
|
||
test('keptRuleIds: present (even empty) when the catalog vouches for them, absent otherwise', () => {
|
||
assert.deepEqual(keptRuleIdsField(null), {});
|
||
assert.deepEqual(keptRuleIdsField([]), { keptRuleIds: [] });
|
||
assert.deepEqual(keptRuleIdsField(['k1', 'k2']), { keptRuleIds: ['k1', 'k2'] });
|
||
});
|
||
|
||
test('the no-accounts branch keeps every existing key and value', () => {
|
||
const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' };
|
||
// What reporter.ts sent before (its :46-56 branch), for each client state.
|
||
const legacy = (client) => ({
|
||
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||
...(client?.syncComplete ? { accounts: [] } : {}),
|
||
accountTypes: [],
|
||
});
|
||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||
const states = [undefined, { syncComplete: false, lastSyncAt: 0 }, { syncComplete: false, lastSyncAt: at }, { syncComplete: true, lastSyncAt: 0 }, { syncComplete: true, lastSyncAt: at }];
|
||
// No kept-rule ids (an older cache: null, or not passed), none, some.
|
||
const keptCases = [undefined, null, [], ['3f0c2b7e-0000-4000-8000-000000000001']];
|
||
for (const client of states) {
|
||
for (const kept of keptCases) {
|
||
const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept);
|
||
const old = legacy(client);
|
||
const label = `${JSON.stringify(client)} kept ${JSON.stringify(kept)}`;
|
||
for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${label}: ${key}`);
|
||
const added = Object.keys(got).filter((k) => !(k in old)).sort();
|
||
const synced = client?.syncComplete === true;
|
||
assert.equal(got.statusVersion, 1);
|
||
assert.equal(got.firmId, firm.id);
|
||
assert.equal('accounts' in got, synced);
|
||
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
|
||
assert.deepEqual(got.keptRuleIds, Array.isArray(kept) ? kept : undefined, label);
|
||
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : []), ...(Array.isArray(kept) ? ['keptRuleIds'] : [])].sort(), label);
|
||
// The old keys stay first and in their order: only new keys follow.
|
||
assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
|
||
}
|
||
}
|
||
});
|
||
|
||
// ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ──────
|
||
|
||
const TYPE_ID = 'f5ad1527-8bd7-4ca0-9e35-bcadf78e52e9';
|
||
const pad = (n, w) => String(n).padStart(w, '0');
|
||
|
||
/** The entry reporter.ts sent before, with a realistic 14-character name. */
|
||
function legacyEntry(i) {
|
||
return { name: `TDFY${pad(i, 10)}`, size: 150_000, payoutable: false, withdrawals: 2 };
|
||
}
|
||
|
||
/** Every new field populated, worst realistic case per platform. */
|
||
function fullFields(platform, i) {
|
||
const balance = 149_823.45 - (i % 97);
|
||
if (platform === 'tradovate') {
|
||
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }, { date: '2026-10-01', amount: 25 }],
|
||
...brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: true }) });
|
||
}
|
||
if (platform === 'dxtrade') {
|
||
return accountStatusFields({ cash: balance, floor: balance, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
|
||
...brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: Date.parse('2026-09-25T15:00:00Z') }) });
|
||
}
|
||
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
|
||
...brokerFacts('topstepx', { active: false }) });
|
||
}
|
||
|
||
/** A typical live account: no reason, no broker status. */
|
||
function typicalFields(i) {
|
||
return accountStatusFields({ cash: 50_123.45 + (i % 89), floor: 47_623.45, active: true, historyReady: true, accountTypeId: TYPE_ID,
|
||
fundTransactions: [{ date: '2026-09-25', amount: 50_000 }], ...brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }) });
|
||
}
|
||
|
||
const added = (fields, i) => JSON.stringify({ ...legacyEntry(i), ...fields }).length - JSON.stringify(legacyEntry(i)).length;
|
||
const PLATFORMS = ['tradovate', 'tradovate', 'topstepx', 'dxtrade'];
|
||
|
||
const ALL_FIELDS = ['status', 'reason', 'balance', 'floor', 'accountTypeId', 'firstFundDate', 'positiveFunds', 'brokerStatus'];
|
||
|
||
test('size: the new fields add < 230 bytes per fully populated account, < 180 per typical live one', () => {
|
||
let worst = 0;
|
||
for (const platform of new Set(PLATFORMS)) {
|
||
assert.deepEqual(Object.keys(fullFields(platform, 0)).sort(), [...ALL_FIELDS].sort(), `${platform} entry is fully populated`);
|
||
assert.equal(fullFields(platform, 0).accountTypeId.length, 36);
|
||
for (let i = 0; i < 500; i++) worst = Math.max(worst, added(fullFields(platform, i), i));
|
||
}
|
||
assert.deepEqual(Object.keys(typicalFields(0)).sort(), ['accountTypeId', 'balance', 'firstFundDate', 'floor', 'positiveFunds', 'status']);
|
||
let typical = 0;
|
||
for (let i = 0; i < 500; i++) typical = Math.max(typical, added(typicalFields(i), i));
|
||
console.log(` # new fields per account: ${worst} B fully populated (worst platform), ${typical} B typical live`);
|
||
assert.ok(worst < 230, `fully populated adds ${worst} B`);
|
||
assert.ok(typical < 180, `typical live adds ${typical} B`);
|
||
});
|
||
|
||
test('size: 2000 fully populated accounts < 600 KB, the whole report body < 900 KB', () => {
|
||
const firms = PLATFORMS.map((platform, f) => ({
|
||
firm: `Firm ${f}`, totalAccounts: 500, accountsTraded: 500, inTrade: 0,
|
||
targetHit: 0, partial: 0, loss: 500, personalDll: 0, completed: 0,
|
||
accounts: Array.from({ length: 500 }, (_, i) => ({ ...legacyEntry(f * 500 + i), ...fullFields(platform, i) })),
|
||
accountTypes: [{ accountTypeId: TYPE_ID, label: '150K Eval', accountSize: 150_000, totalAccounts: 500, accountsTraded: 500, inTrade: 0, targetHit: 0, partial: 0, loss: 500 }],
|
||
...firmStatusFields(`00000000-0000-4000-8000-00000000000${f}`, true, Date.parse('2026-10-06T14:00:00Z')),
|
||
}));
|
||
const accountsBytes = JSON.stringify(firms.flatMap((f) => f.accounts)).length;
|
||
const body = JSON.stringify({
|
||
instance: 'Someone', id: '00000000-0000-4000-8000-000000000000', firms,
|
||
scheduler: { running: true, lastTicks: [] }, version: '8747dd6', updater: null,
|
||
}).length;
|
||
console.log(` # 2000 accounts: ${accountsBytes} B; report body: ${body} B`);
|
||
assert.ok(accountsBytes < 600_000, `accounts serialize to ${accountsBytes} B`);
|
||
assert.ok(body < 900_000, `report body is ${body} B`);
|
||
});
|