Old rules for older accounts, a rules date per account, and a firm cap
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
98016b3eb3
commit
c0899620d4
@@ -0,0 +1,516 @@
|
||||
// Checks for kept rules — which of an account type's rule sets an account
|
||||
// trades on (lib/kept-rules.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// lib/kept-rules.ts has only `import type`s, so Node strips its types and
|
||||
// loads it directly; .mjs keeps this file out of Next's type check.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RULE_FIELDS, IDENTITY_FIELDS, PROJECTION_FIELDS, normalizeAccountType, pickKeptRules, overlayKept, ruleDiffs,
|
||||
stampRulesDate, evidenceRulesDate, lowersRulesDate, sortableIso, chicagoDay, chicagoDayStart, evidenceDay,
|
||||
openingEvidence, identityStillMatches, pickedDayRulesDate, unchangedDaySaves,
|
||||
} from '../lib/kept-rules.ts';
|
||||
|
||||
const LIVE = {
|
||||
id: 'live-x', firmId: 'firm-1', label: '50K Eval', prefix: 'TDFY', accountSize: 50_000,
|
||||
profitTarget: 2500, consistency: 0.4, minDayPnL: 150, minTradingDays: 3, maxLoss: 2000, maxPositionSize: 5,
|
||||
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
|
||||
withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }],
|
||||
payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9,
|
||||
dailyLossLimit: 1000, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
|
||||
};
|
||||
|
||||
/** `obj` without `keys`. */
|
||||
const omit = (obj, keys) => Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k)));
|
||||
|
||||
/** A kept set as the master builds it: the live type, the old rules on top. */
|
||||
function keptOf(rules, window, id = 'kept-1') {
|
||||
return {
|
||||
...LIVE, ...rules, id, keptFrom: LIVE.id, createdAt: '2026-01-01T00:00:00.000Z',
|
||||
openedAt: window.openedAt, closedAt: window.closedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── field classes ───────────────────────────────────────────────────────────
|
||||
|
||||
test('field classes: the spec\'s lists, identical to the master\'s, and disjoint', () => {
|
||||
assert.deepEqual([...RULE_FIELDS], [
|
||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
|
||||
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
|
||||
'trailingDrawdown', 'dailyLossLimit',
|
||||
]);
|
||||
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
|
||||
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
|
||||
const all = [...RULE_FIELDS, ...IDENTITY_FIELDS, ...PROJECTION_FIELDS, 'label'];
|
||||
assert.equal(new Set(all).size, all.length);
|
||||
// Every field of a catalog account type is in exactly one class, or is
|
||||
// bookkeeping the live type always owns.
|
||||
const bookkeeping = ['id', 'firmId', 'updatedAt', 'tradesEveryDay'];
|
||||
assert.deepEqual(Object.keys(LIVE).filter((k) => !all.includes(k) && !bookkeeping.includes(k)), []);
|
||||
});
|
||||
|
||||
// ── normalizeAccountType: exactly the old validatePayload callback ──────────
|
||||
|
||||
/** lib/firm-catalog.ts validatePayload's per-type callback before the move
|
||||
* (git show HEAD:lib/firm-catalog.ts), verbatim but for the types. */
|
||||
function legacyNormalize(t, j, tradesEveryDay) {
|
||||
const nonNegative = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
|
||||
const isStages = (value) => Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof s.profit === 'number'
|
||||
&& typeof s.consistency === 'number'
|
||||
&& typeof s.minTradingDays === 'number');
|
||||
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
|
||||
const type = t;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
|
||||
return {
|
||||
...type,
|
||||
splitEnabled: type.splitEnabled !== false,
|
||||
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
|
||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||
firstDayStop: type.firstDayStop === true,
|
||||
minDayStop: type.minDayStop === true,
|
||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||
tradesEveryDay,
|
||||
};
|
||||
}
|
||||
|
||||
function outcome(fn) {
|
||||
try {
|
||||
return { ok: JSON.stringify(fn()) };
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
test('normalizeAccountType is the old per-type validation, byte for byte (values, key order, errors)', () => {
|
||||
const wire = omit(LIVE, ['tradesEveryDay']);
|
||||
const variants = [
|
||||
wire,
|
||||
// An old master: none of the lenient fields.
|
||||
omit(wire, ['splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'dailyLossLimit']),
|
||||
{ ...wire, splitEnabled: false, combineCmeHolidays: true, minLegs: '3', firstDayStop: 'yes', dailyLossLimit: -5 },
|
||||
{ ...wire, dailyLossLimit: NaN, minDayStop: true, extra: { nested: [1, 2] } },
|
||||
{ ...wire, withdrawalStages: [] },
|
||||
// Malformed: each must throw the same message.
|
||||
null, 'type', 42,
|
||||
{ ...wire, id: 7 }, { ...wire, label: undefined }, { ...wire, updatedAt: null },
|
||||
{ ...wire, accountSize: '50000' }, { ...wire, maxPositionSize: undefined },
|
||||
{ ...wire, withdrawalStages: [{ profit: 1, consistency: 0.5 }] }, { ...wire, withdrawalStages: 'none' },
|
||||
{ ...wire, withdrawalStages: [null] },
|
||||
];
|
||||
let errors = 0;
|
||||
for (const tradesEveryDay of [false, true]) {
|
||||
variants.forEach((v, j) => {
|
||||
const legacy = outcome(() => legacyNormalize(v, j, tradesEveryDay));
|
||||
const now = outcome(() => normalizeAccountType(v, { tradesEveryDay, where: `account type ${j}` }));
|
||||
assert.deepEqual(now, legacy, `variant ${j}, every day ${tradesEveryDay}`);
|
||||
if (legacy.error) errors++;
|
||||
});
|
||||
}
|
||||
assert.equal(errors, 2 * 11);
|
||||
// The kept-set wording only changes the name in the message.
|
||||
assert.throws(() => normalizeAccountType({ ...wire, prefix: 1 }, { tradesEveryDay: false, where: 'kept rules 0 of Tradeify' }),
|
||||
{ message: 'kept rules 0 of Tradeify is missing prefix' });
|
||||
});
|
||||
|
||||
// ── pickKeptRules ───────────────────────────────────────────────────────────
|
||||
|
||||
const G1 = { id: 'g1', openedAt: null, closedAt: '2026-10-02T15:00:00.000Z' };
|
||||
const G2 = { id: 'g2', openedAt: '2026-10-02T15:00:00.000Z', closedAt: '2026-10-18T14:05:00.000Z' };
|
||||
const G3 = { id: 'g3', openedAt: '2026-10-18T14:05:00.000Z', closedAt: '2026-10-18T19:30:00.000Z' };
|
||||
const GENERATIONS = [G1, G2, G3];
|
||||
|
||||
test('pickKeptRules: the window holding the date, [openedAt, closedAt)', () => {
|
||||
const pick = (d, sets = GENERATIONS) => pickKeptRules(sets, d)?.id ?? null;
|
||||
// No lower bound on the first generation.
|
||||
assert.equal(pick('2020-01-01T00:00:00.000Z'), 'g1');
|
||||
assert.equal(pick('2026-10-02T14:59:59.999Z'), 'g1');
|
||||
// A date exactly at a cutoff is on the newer side: closedAt excluded,
|
||||
// openedAt included.
|
||||
assert.equal(pick('2026-10-02T15:00:00.000Z'), 'g2');
|
||||
assert.equal(pick('2026-10-18T14:04:59.999Z'), 'g2');
|
||||
assert.equal(pick('2026-10-18T14:05:00.000Z'), 'g3');
|
||||
assert.equal(pick('2026-10-18T19:29:59.999Z'), 'g3');
|
||||
// From the last cutoff on: the current rules.
|
||||
assert.equal(pick('2026-10-18T19:30:00.000Z'), null);
|
||||
assert.equal(pick('2027-01-01T00:00:00.000Z'), null);
|
||||
// Compared as times, not strings: other ISO spellings of the same instant.
|
||||
assert.equal(pick('2026-10-02T10:00:00-05:00'), 'g2');
|
||||
assert.equal(pick('2026-10-02T09:59:59.999-05:00'), 'g1');
|
||||
// Order of the list doesn't matter for disjoint windows.
|
||||
assert.equal(pick('2026-10-10T00:00:00.000Z', [G3, G1, G2]), 'g2');
|
||||
// Returns the set itself (the generic carries through).
|
||||
assert.equal(pickKeptRules(GENERATIONS, '2026-10-10T00:00:00.000Z'), G2);
|
||||
});
|
||||
|
||||
test('pickKeptRules: no date, no sets, or an unreadable date — the current rules', () => {
|
||||
assert.equal(pickKeptRules(GENERATIONS, null), null);
|
||||
assert.equal(pickKeptRules(GENERATIONS, ''), null);
|
||||
assert.equal(pickKeptRules(GENERATIONS, 'not a date'), null);
|
||||
assert.equal(pickKeptRules(undefined, '2026-10-10T00:00:00.000Z'), null);
|
||||
assert.equal(pickKeptRules([], '2026-10-10T00:00:00.000Z'), null);
|
||||
// An unreadable bound never matches (the catalog parse rejects them anyway).
|
||||
assert.equal(pickKeptRules([{ id: 'bad', openedAt: null, closedAt: 'soon' }], '2020-01-01T00:00:00.000Z'), null);
|
||||
assert.equal(pickKeptRules([{ id: 'bad', openedAt: 'once', closedAt: '2030-01-01T00:00:00.000Z' }], '2026-01-01T00:00:00.000Z'), null);
|
||||
// An empty window (openedAt == closedAt) holds nothing.
|
||||
assert.equal(pickKeptRules([{ id: 'empty', openedAt: G2.closedAt, closedAt: G2.closedAt }], G2.closedAt), null);
|
||||
});
|
||||
|
||||
// ── overlayKept ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('overlayKept: the kept rules on top; identity, label, projections and bookkeeping stay live', () => {
|
||||
const oldRules = {
|
||||
profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3,
|
||||
targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true,
|
||||
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0,
|
||||
};
|
||||
// A master that sent other identity/projection values on the kept set:
|
||||
// they must not leak through.
|
||||
const kept = keptOf({ ...oldRules, label: 'OLD', prefix: 'OLD', accountSize: 1, expectedPayout: 1, withdrawalPct: 1,
|
||||
payoutCap: 1, profitSplit: 1, firmId: 'other', updatedAt: '1999-01-01T00:00:00.000Z', tradesEveryDay: true }, G2);
|
||||
const liveBefore = structuredClone(LIVE);
|
||||
const keptBefore = structuredClone(kept);
|
||||
const eff = overlayKept(LIVE, kept);
|
||||
|
||||
for (const field of RULE_FIELDS) assert.deepEqual(eff[field], oldRules[field], field);
|
||||
for (const field of ['id', 'firmId', 'label', 'prefix', 'accountSize', 'updatedAt', 'tradesEveryDay', ...PROJECTION_FIELDS]) {
|
||||
assert.deepEqual(eff[field], LIVE[field], field);
|
||||
}
|
||||
assert.deepEqual(eff.keptRules, { id: 'kept-1', openedAt: G2.openedAt, closedAt: G2.closedAt });
|
||||
// Nothing of the kept set's own bookkeeping comes along.
|
||||
for (const key of ['keptFrom', 'openedAt', 'closedAt', 'createdAt']) assert.equal(key in eff, false, key);
|
||||
assert.deepEqual(Object.keys(eff).sort(), [...Object.keys(LIVE), 'keptRules'].sort());
|
||||
// Inputs untouched.
|
||||
assert.deepEqual(LIVE, liveBefore);
|
||||
assert.deepEqual(kept, keptBefore);
|
||||
});
|
||||
|
||||
test('overlayKept: a rule field the kept set lacks keeps the live value', () => {
|
||||
const kept = keptOf({ consistency: 0.5 }, G1);
|
||||
delete kept.payoutable;
|
||||
kept.trailingDrawdown = undefined;
|
||||
const eff = overlayKept(LIVE, kept);
|
||||
assert.equal(eff.consistency, 0.5);
|
||||
assert.equal(eff.payoutable, LIVE.payoutable);
|
||||
assert.equal(eff.trailingDrawdown, LIVE.trailingDrawdown);
|
||||
});
|
||||
|
||||
// ── ruleDiffs ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('ruleDiffs: the rule fields that differ, raw values both sides', () => {
|
||||
assert.deepEqual(ruleDiffs(LIVE, LIVE), []);
|
||||
const eff = overlayKept(LIVE, keptOf({ consistency: 0.5, profitTarget: 3000 }, G1));
|
||||
assert.deepEqual(ruleDiffs(eff, LIVE), [
|
||||
{ field: 'profitTarget', kept: 3000, now: 2500 },
|
||||
{ field: 'consistency', kept: 0.5, now: 0.4 },
|
||||
]);
|
||||
// Non-rule fields never count.
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, label: 'x', prefix: 'y', profitSplit: 0.1 }, LIVE), []);
|
||||
const stages = [{ profit: 3000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }];
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, withdrawalStages: stages }, LIVE), [
|
||||
{ field: 'withdrawalStages', kept: stages, now: LIVE.withdrawalStages },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ruleDiffs: stored JSON from before a field existed is not a change', () => {
|
||||
// A stage without drain / minDayPnL is what parseAccountTypeInput fills
|
||||
// with drain false, minDayPnL 0 (5 for a drain stage); key order is free.
|
||||
const bare = { ...LIVE, withdrawalStages: [{ minTradingDays: 5, consistency: 0.4, profit: 2000 }] };
|
||||
assert.deepEqual(ruleDiffs(bare, LIVE), []);
|
||||
const drainFilled = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true, minDayPnL: 5 }] };
|
||||
const drainBare = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true }] };
|
||||
assert.deepEqual(ruleDiffs(drainBare, drainFilled), []);
|
||||
// A per-stage payout override is a rule.
|
||||
assert.equal(ruleDiffs({ ...LIVE, withdrawalStages: [{ ...LIVE.withdrawalStages[0], payoutCap: 1500 }] }, LIVE).length, 1);
|
||||
// An absent optional flag or amount is its default.
|
||||
const noOptionals = omit(LIVE, ['payoutable', 'trailingDrawdown']);
|
||||
assert.deepEqual(ruleDiffs(noOptionals, { ...LIVE, payoutable: false, trailingDrawdown: 0 }), []);
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, LIVE), []);
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, { ...LIVE, splitEnabled: false }).map((d) => d.field), ['splitEnabled']);
|
||||
});
|
||||
|
||||
// ── rules dates ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('stampRulesDate: the latest of this clock, the master\'s and the catalog\'s last change', () => {
|
||||
const now = Date.parse('2026-10-18T14:00:00.000Z');
|
||||
assert.equal(stampRulesDate(now, null, null), '2026-10-18T14:00:00.000Z');
|
||||
// This clock 5 minutes behind the master's.
|
||||
assert.equal(stampRulesDate(now, 5 * 60_000, null), '2026-10-18T14:05:00.000Z');
|
||||
// This clock ahead: its own time stands.
|
||||
assert.equal(stampRulesDate(now, -5 * 60_000, null), '2026-10-18T14:00:00.000Z');
|
||||
// The catalog changed after both: no new account dates before it.
|
||||
assert.equal(stampRulesDate(now, 60_000, '2026-10-18T14:07:30.000Z'), '2026-10-18T14:07:30.000Z');
|
||||
assert.equal(stampRulesDate(now, null, '2026-10-01T00:00:00.000Z'), '2026-10-18T14:00:00.000Z');
|
||||
// Unreadable inputs are ignored.
|
||||
assert.equal(stampRulesDate(now, NaN, 'never'), '2026-10-18T14:00:00.000Z');
|
||||
assert.equal(stampRulesDate(now, null, ''), '2026-10-18T14:00:00.000Z');
|
||||
});
|
||||
|
||||
test('stampRulesDate: a machine whose clock is behind never dates a new account inside the set just closed', () => {
|
||||
// The owner saves "new accounts only" at 14:05 master time; this machine's
|
||||
// clock reads 14:01 when it first sees a brand-new account at 14:05.
|
||||
const cutoff = '2026-10-18T14:05:00.000Z';
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: cutoff }];
|
||||
const local = Date.parse('2026-10-18T14:01:00.000Z');
|
||||
// Unclamped, the account would land in the old rules.
|
||||
assert.equal(pickKeptRules(kept, new Date(local).toISOString())?.id, 'g');
|
||||
// With the master's offset, or just the catalog's updatedAt, it doesn't.
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, 4 * 60_000, null)), null);
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, cutoff)), null);
|
||||
// A machine whose master offset isn't known yet still clamps to the catalog.
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, '2026-10-18T14:05:00.250Z')), null);
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: the end of the first fund day in Chicago — CDT and CST', () => {
|
||||
// CDT (UTC−5): 23:59:59.999 CDT is 04:59:59.999Z the next day.
|
||||
assert.equal(evidenceRulesDate('2026-10-18'), '2026-10-19T04:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-07-04'), '2026-07-05T04:59:59.999Z');
|
||||
// CST (UTC−6): 05:59:59.999Z the next day.
|
||||
assert.equal(evidenceRulesDate('2026-01-15'), '2026-01-16T05:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-12-31'), '2027-01-01T05:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2028-02-29'), '2028-03-01T05:59:59.999Z');
|
||||
for (const bad of [null, undefined, '', '2026-10-18T00:00:00Z', '18/10/2026', '2026-1-8', '2026-02-31', '2026-13-01', 20261018]) {
|
||||
assert.equal(evidenceRulesDate(bad), null, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: DST boundary days end on the right side of the change', () => {
|
||||
// 2026: CST→CDT at 2 AM on Sunday Mar 8, CDT→CST at 2 AM on Sunday Nov 1.
|
||||
assert.equal(evidenceRulesDate('2026-03-07'), '2026-03-08T05:59:59.999Z'); // last CST day
|
||||
assert.equal(evidenceRulesDate('2026-03-08'), '2026-03-09T04:59:59.999Z'); // springs forward: ends in CDT
|
||||
assert.equal(evidenceRulesDate('2026-03-09'), '2026-03-10T04:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-10-31'), '2026-11-01T04:59:59.999Z'); // last CDT day
|
||||
assert.equal(evidenceRulesDate('2026-11-01'), '2026-11-02T05:59:59.999Z'); // falls back: ends in CST
|
||||
assert.equal(evidenceRulesDate('2026-11-02'), '2026-11-03T05:59:59.999Z');
|
||||
// The day before each boundary and the boundary day are 23 h / 25 h long
|
||||
// in Chicago, measured end to end.
|
||||
const len = (a, b) => (Date.parse(evidenceRulesDate(b)) - Date.parse(evidenceRulesDate(a))) / 3_600_000;
|
||||
assert.equal(len('2026-03-07', '2026-03-08'), 23);
|
||||
assert.equal(len('2026-10-31', '2026-11-01'), 25);
|
||||
assert.equal(len('2026-06-01', '2026-06-02'), 24);
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: every day of 2026–2027 ends 1 ms before the next one starts, in Chicago', () => {
|
||||
for (let ms = Date.UTC(2026, 0, 1); ms < Date.UTC(2028, 0, 1); ms += 86_400_000) {
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const next = new Date(ms + 86_400_000).toISOString().slice(0, 10);
|
||||
const end = evidenceRulesDate(day);
|
||||
assert.equal(Date.parse(chicagoDayStart(next)) - Date.parse(end), 1, day);
|
||||
// Both read back as their own Chicago day, and the end as evidence.
|
||||
assert.equal(chicagoDay(end), day);
|
||||
assert.equal(chicagoDay(chicagoDayStart(next)), next);
|
||||
assert.equal(evidenceDay(end), day);
|
||||
// Under either offset the end is 04:59:59.999Z or 05:59:59.999Z the next UTC day.
|
||||
assert.match(end, new RegExp(`^${next}T0[45]:59:59\\.999Z$`));
|
||||
}
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: an account opened on the day of a change — even that evening — counts as after it', () => {
|
||||
// A change at 14:05 CDT on Oct 18: an account first funded that day is
|
||||
// after it; one funded the day before keeps the old rules.
|
||||
const afternoon = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
|
||||
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-18')), null);
|
||||
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-17'))?.id, 'g');
|
||||
// A change at 8 PM CDT (01:00Z the next UTC day): a UTC end of day
|
||||
// (Oct 18 23:59:59.999Z = 6:59 PM CDT) would have put an account that
|
||||
// opened that evening before it.
|
||||
const evening = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
|
||||
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-18')), null);
|
||||
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-17'))?.id, 'g');
|
||||
// Same under CST: a change at 11:30 PM CST Jan 15 (05:30Z Jan 16).
|
||||
const winter = [{ id: 'g', openedAt: null, closedAt: '2026-01-16T05:30:00.000Z' }];
|
||||
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-15')), null);
|
||||
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-14'))?.id, 'g');
|
||||
});
|
||||
|
||||
test('chicagoDayStart / chicagoDay / evidenceDay', () => {
|
||||
assert.equal(chicagoDayStart('2026-10-18'), '2026-10-18T05:00:00.000Z');
|
||||
assert.equal(chicagoDayStart('2026-01-15'), '2026-01-15T06:00:00.000Z');
|
||||
assert.equal(chicagoDayStart('2026-03-08'), '2026-03-08T06:00:00.000Z'); // midnight is still CST
|
||||
assert.equal(chicagoDayStart('2026-11-01'), '2026-11-01T05:00:00.000Z'); // midnight is still CDT
|
||||
assert.equal(chicagoDayStart('2026-02-31'), null);
|
||||
assert.equal(chicagoDayStart('1999-12-31'), null); // before 2000: not a rules date
|
||||
assert.equal(chicagoDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
|
||||
assert.equal(chicagoDay('2026-10-19T05:00:00.000Z'), '2026-10-19');
|
||||
assert.equal(chicagoDay('nope'), null);
|
||||
// Only the exact end of a Chicago day reads as evidence.
|
||||
assert.equal(evidenceDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
|
||||
assert.equal(evidenceDay('2026-10-19T04:59:59.998Z'), null);
|
||||
assert.equal(evidenceDay('2026-10-18T23:59:59.999Z'), null); // the old UTC shape
|
||||
assert.equal(evidenceDay('2026-10-18T19:05:00.000Z'), null);
|
||||
});
|
||||
|
||||
test('sortableIso: plain four-digit-year ISO from 2000 on, else null', () => {
|
||||
assert.equal(sortableIso(Date.parse('2026-10-18T14:05:00Z')), '2026-10-18T14:05:00.000Z');
|
||||
assert.equal(sortableIso(Date.UTC(2000, 0, 1)), '2000-01-01T00:00:00.000Z');
|
||||
assert.equal(sortableIso(Date.UTC(2000, 0, 1) - 1), null);
|
||||
assert.equal(sortableIso(0), null);
|
||||
assert.equal(sortableIso(Date.UTC(9999, 11, 31, 23, 59, 59, 999)), '9999-12-31T23:59:59.999Z');
|
||||
assert.equal(sortableIso(Date.parse('+010000-01-01T00:00:00Z')), null); // would print as +010000-…
|
||||
assert.equal(sortableIso(8.64e15 + 1), null);
|
||||
assert.equal(sortableIso(NaN), null);
|
||||
assert.equal(sortableIso(Infinity), null);
|
||||
});
|
||||
|
||||
test('openingEvidence: a fund day ends in Chicago; the registration fallback is the instant itself', () => {
|
||||
// A positive fund row's day: the end of it in Chicago, whatever the registration.
|
||||
assert.equal(openingEvidence('2026-10-02', Date.parse('2026-09-01T00:00:00Z')), '2026-10-03T04:59:59.999Z');
|
||||
// Positive rows present but unreadable: no evidence (never the registration).
|
||||
assert.equal(openingEvidence('2026-02-31', Date.parse('2026-09-01T00:00:00Z')), null);
|
||||
// No positive rows: the registration instant, exactly.
|
||||
assert.equal(openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z')), '2026-10-19T02:00:00.000Z');
|
||||
// Unreadable or implausible registrations: none.
|
||||
for (const bad of [undefined, null, NaN, 0, 5, -1, Infinity, Date.parse('1999-12-31T23:00:00Z'), 9e15]) {
|
||||
assert.equal(openingEvidence(undefined, bad), null, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('openingEvidence: a DXtrade account registered after an evening cutoff is never lowered before it', () => {
|
||||
// "New accounts only" saved at 8 PM CDT Oct 18 (01:00Z Oct 19); a DX
|
||||
// account registered at 9 PM CDT (02:00Z) with no DEPOSIT row.
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
|
||||
const stamped = '2026-10-19T02:00:05.000Z';
|
||||
const evidence = openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z'));
|
||||
const lowered = lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, evidence) ? evidence : stamped;
|
||||
assert.equal(pickKeptRules(kept, lowered), null);
|
||||
// And at 9 PM CDT Oct 20 against an 8 PM cutoff that day.
|
||||
const kept2 = [{ id: 'g', openedAt: null, closedAt: '2026-10-21T01:00:00.000Z' }];
|
||||
assert.equal(pickKeptRules(kept2, openingEvidence(undefined, Date.parse('2026-10-21T02:00:00Z'))), null);
|
||||
// One registered before the cutoff, first seen after it, goes back to the old rules.
|
||||
const before = openingEvidence(undefined, Date.parse('2026-10-19T00:30:00Z'));
|
||||
assert.equal(lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, before), true);
|
||||
assert.equal(pickKeptRules(kept, before)?.id, 'g');
|
||||
});
|
||||
|
||||
test('identityStillMatches: prefix and the cached size against the live type', () => {
|
||||
const live = { prefix: 'TDFY', accountSize: 50_000 };
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', live), true);
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000.004', live), true);
|
||||
// A size edit on the type (50K → 100K, or → $0-start): no longer its size.
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 100_000 }), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 0 }), false);
|
||||
// A prefix edit.
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, prefix: 'TDFX' }), false);
|
||||
// A $0-start type caches 0 when it matches.
|
||||
assert.equal(identityStillMatches('XFA77', '0', { prefix: 'XFA', accountSize: 0 }), true);
|
||||
assert.equal(identityStillMatches('XFA77', '150000', { prefix: 'XFA', accountSize: 0 }), false);
|
||||
// Nothing cached, or junk: not provably the same account.
|
||||
assert.equal(identityStillMatches('TDFY1234', null, live), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', 'abc', live), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', '', { ...live, accountSize: 0 }), false);
|
||||
});
|
||||
|
||||
test('pickedDayRulesDate: today is now; another day its start, never before Chicago\'s', () => {
|
||||
const nowMs = Date.parse('2026-10-18T19:30:00.000Z');
|
||||
const at = (localMidnightMs, today = '2026-10-18') => ({ today, nowMs, localMidnightMs });
|
||||
// Today: now, whatever the zone.
|
||||
assert.equal(pickedDayRulesDate('2026-10-18', at(Date.parse('2026-10-18T05:00:00Z'))), '2026-10-18T19:30:00.000Z');
|
||||
// Chicago browser: its own midnight.
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T05:00:00Z'))), '2026-10-15T05:00:00.000Z');
|
||||
// Pacific browser: local midnight is later than Chicago's — stands.
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T07:00:00Z'))), '2026-10-15T07:00:00.000Z');
|
||||
// London (BST), Eastern and Karachi browsers: raised to Chicago's midnight.
|
||||
for (const local of ['2026-10-14T23:00:00Z', '2026-10-15T04:00:00Z', '2026-10-14T19:00:00Z']) {
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse(local))), '2026-10-15T05:00:00.000Z', local);
|
||||
}
|
||||
assert.equal(pickedDayRulesDate('2026-02-31', at(0)), null);
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(NaN)), null);
|
||||
});
|
||||
|
||||
test('pickedDayRulesDate: a picked day never lands inside the evidence of the day before', () => {
|
||||
// A browser at UTC+1 picks Oct 15 for a set closing at Chicago midnight
|
||||
// of Oct 15: an account first funded Oct 14 stays on the old rules, the
|
||||
// hand-dated one on Oct 15 does not.
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: chicagoDayStart('2026-10-15') }];
|
||||
const picked = pickedDayRulesDate('2026-10-15', {
|
||||
today: '2026-10-18', nowMs: Date.parse('2026-10-18T12:00:00Z'), localMidnightMs: Date.parse('2026-10-14T23:00:00Z'),
|
||||
});
|
||||
assert.equal(pickKeptRules(kept, evidenceRulesDate('2026-10-14'))?.id, 'g');
|
||||
assert.equal(pickKeptRules(kept, picked), null);
|
||||
for (let ms = Date.UTC(2026, 0, 2); ms < Date.UTC(2027, 0, 1); ms += 86_400_000) {
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const prev = new Date(ms - 86_400_000).toISOString().slice(0, 10);
|
||||
for (const offsetH of [-10, -8, -6, -5, -4, 0, 1, 5, 9, 14]) {
|
||||
const localMidnightMs = Date.parse(`${day}T00:00:00Z`) - offsetH * 3_600_000;
|
||||
const stored = pickedDayRulesDate(day, { today: '2030-01-01', nowMs: 0, localMidnightMs });
|
||||
assert.ok(stored > evidenceRulesDate(prev), `${day} at UTC${offsetH}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('unchangedDaySaves: an untouched day saves only when it moves the account to other rules', () => {
|
||||
// A change at 2:05 PM CDT today (19:05Z) with the default cutoff "now".
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
|
||||
const at = { today: '2026-10-18', nowMs: Date.parse('2026-10-18T19:30:00Z'), localMidnightMs: Date.parse('2026-10-18T05:00:00Z') };
|
||||
// Dated this morning (old rules): ✓ on today stores now → current rules.
|
||||
const now = pickedDayRulesDate('2026-10-18', at);
|
||||
assert.equal(unchangedDaySaves(kept, '2026-10-18T15:00:00.000Z', now), true);
|
||||
assert.equal(pickKeptRules(kept, now), null);
|
||||
// Already after the change: nothing to save.
|
||||
assert.equal(unchangedDaySaves(kept, '2026-10-18T19:10:00.000Z', now), false);
|
||||
// A past day's start lands where the date already is: nothing to save.
|
||||
const oct2 = pickedDayRulesDate('2026-10-02', at);
|
||||
assert.equal(unchangedDaySaves(kept, evidenceRulesDate('2026-10-02'), oct2), false);
|
||||
// Windows not known (still loading, no firm): never.
|
||||
assert.equal(unchangedDaySaves(null, '2026-10-18T15:00:00.000Z', now), false);
|
||||
// The editor opened on Oct 18 (current rules) and a poll since lowered the
|
||||
// date to Oct 2: an untouched ✓ compares against the opened date, so it
|
||||
// doesn't pin Oct 18 by hand over the evidence.
|
||||
const sets = [{ id: 'g2', openedAt: null, closedAt: '2026-10-10T00:00:00.000Z' }];
|
||||
const oct18 = pickedDayRulesDate('2026-10-18', { ...at, today: '2026-10-19' });
|
||||
assert.equal(unchangedDaySaves(sets, '2026-10-18T15:00:00.000Z', oct18), false);
|
||||
});
|
||||
|
||||
test('lowersRulesDate: only earlier, only auto dates — the test lowerAutoRulesDate runs in SQL', () => {
|
||||
const evidence = '2026-10-01T23:59:59.999Z';
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 0 }, evidence), true);
|
||||
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 0 }, evidence), true);
|
||||
assert.equal(lowersRulesDate({ rules_date: evidence, rules_date_manual: 0 }, evidence), false);
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-09-01T00:00:00.000Z', rules_date_manual: 0 }, evidence), false);
|
||||
// Set by hand: never moved.
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 1 }, evidence), false);
|
||||
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 1 }, evidence), false);
|
||||
// String order is time order for the two formats that are ever stored.
|
||||
assert.equal('2026-10-01T23:59:59.999Z' < '2026-10-02T00:00:00.000Z', true);
|
||||
assert.equal(evidenceRulesDate('2026-10-02') > new Date(Date.parse('2026-10-02T23:59:59.998Z')).toISOString(), true);
|
||||
});
|
||||
|
||||
// ── the whole selection, as getAccountConfig runs it ────────────────────────
|
||||
|
||||
test('selection: generations, boundaries and clamps end to end', () => {
|
||||
const sets = [
|
||||
keptOf({ profitTarget: 3000, consistency: 0.5 }, G1, 'g1'),
|
||||
keptOf({ profitTarget: 2800, consistency: 0.45 }, G2, 'g2'),
|
||||
];
|
||||
const rulesFor = (rulesDate) => {
|
||||
const kept = pickKeptRules(sets, rulesDate);
|
||||
return kept ? overlayKept(LIVE, kept) : LIVE;
|
||||
};
|
||||
const cases = [
|
||||
[null, 2500, undefined],
|
||||
['2026-09-01T00:00:00.000Z', 3000, 'g1'],
|
||||
[G1.closedAt, 2800, 'g2'],
|
||||
[G2.closedAt, 2500, undefined],
|
||||
[evidenceRulesDate('2026-10-02'), 2800, 'g2'],
|
||||
[evidenceRulesDate('2026-10-01'), 3000, 'g1'],
|
||||
];
|
||||
for (const [date, target, keptId] of cases) {
|
||||
const eff = rulesFor(date);
|
||||
assert.equal(eff.profitTarget, target, String(date));
|
||||
assert.equal(eff.keptRules?.id, keptId, String(date));
|
||||
// Always reported as the live type.
|
||||
assert.equal(eff.id, LIVE.id);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user