/** * Kept rules — the old rule sets a firm's account type keeps for its older * accounts. * * When the owner changes an account type's rules on the master's Firms page * and picks "New accounts only", the master freezes the old rules as a kept * rule set with a window [openedAt, closedAt). Every account here carries a * rules date (account_assignments.rules_date): an account whose date falls * inside a window trades its type's live fields with that set's RULE fields * on top; every other account trades the current rules. The account keeps * reporting the live type's id — a kept set is never an account type. * * Pure (only `import type`), so scripts/check-kept-rules.mjs loads it * directly. The field lists must stay identical to the master's * lib/kept-rules.ts. */ import type { CatalogAccountType, KeptAccountRules } from './master-protocol'; /** The fields a kept rule set freezes: what the bot trades by. Changing one * is what asks the owner "new accounts only, or all accounts?". */ export const RULE_FIELDS = [ 'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay', ] as const satisfies readonly (keyof CatalogAccountType)[]; /** Always the live type's: accounts are matched to a type by them, so a kept * set can't hold different ones (the master refuses "new accounts only" * when either changes). */ export const IDENTITY_FIELDS = ['prefix', 'accountSize'] as const satisfies readonly (keyof CatalogAccountType)[]; /** The payout calendar's projection inputs: always the live type's, never * frozen. (Per-stage withdrawalPct / payoutCap ride inside withdrawalStages, * so they are rules.) */ export const PROJECTION_FIELDS = [ 'expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit', ] as const satisfies readonly (keyof CatalogAccountType)[]; export type RuleField = (typeof RULE_FIELDS)[number]; /** Which kept rule set an account trades on. */ export interface KeptRulesWindow { id: string; openedAt: string | null; closedAt: string; } /** The rules an account trades on: its live account type, or — when its * rules date falls in a kept set — the live type with that set's rules on * top, marked with `keptRules`. */ export type EffectiveAccountType = CatalogAccountType & { keptRules?: KeptRulesWindow }; // ── Catalog parsing ────────────────────────────────────────────────────────── /** Same as lib/firm-catalog.ts's: a positive finite number, else 0. */ function nonNegative(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; } function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] { return Array.isArray(value) && value.every((s) => typeof s === 'object' && s !== null && typeof (s as Record).profit === 'number' && typeof (s as Record).consistency === 'number' && typeof (s as Record).minTradingDays === 'number'); } /** * One account type off the master's catalog, validated and normalized — the * live types and the kept rule sets alike, so a kept set reads exactly the * way its type did. Throws on anything malformed (`where` names it in the * message): a half-parsed catalog would silently unassign accounts, so the * whole sync is better rejected and the last good cache kept. */ export function normalizeAccountType( raw: unknown, ctx: { tradesEveryDay: boolean; where: string }, ): CatalogAccountType { const { tradesEveryDay, where } = ctx; if (typeof raw !== 'object' || raw === null) throw new Error(`${where} is not an object`); const type = raw as Record; for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) { if (typeof type[key] !== 'string') throw new Error(`${where} is missing ${key}`); } for (const key of [ 'accountSize', 'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', ]) { if (typeof type[key] !== 'number') throw new Error(`${where} is missing ${key}`); } if (!isStages(type.withdrawalStages)) throw new Error(`${where} has bad withdrawalStages`); // Lenient like platform: a master from before the split toggle // doesn't send the field, and those account types all split. Same // for CME holiday combining, where the old behavior was "off". return { ...type, splitEnabled: type.splitEnabled !== false, // An every-day firm has no CME calendar to fold holidays by. combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay, // Lenient: a master from before the min-legs field means "no minimum". minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0, // Lenient: the obfuscation stops are opt-in, so absent means off. firstDayStop: type.firstDayStop === true, minDayStop: type.minDayStop === true, // Lenient: no hard daily limit, no notional cap. dailyLossLimit: nonNegative(type.dailyLossLimit), // Lenient: stages count every payout unless the master says otherwise. stagesSameDay: type.stagesSameDay === true, // The firm's calendar, stamped on every type so the cfg-based // paths (projections, completion, the dashboard) all see it. tradesEveryDay, } as unknown as CatalogAccountType; } // ── Which rules an account trades on ───────────────────────────────────────── /** * The kept set whose window holds the rules date: openedAt (null = no lower * bound) <= date < closedAt — an account dated exactly at a change's cutoff * is on the newer rules. null when no window holds it, when the account has * no rules date, or when the date can't be read: the current rules. */ export function pickKeptRules( sets: T[] | undefined, rulesDate: string | null, ): T | null { if (!sets || sets.length === 0 || !rulesDate) return null; const date = Date.parse(rulesDate); if (!Number.isFinite(date)) return null; for (const set of sets) { // Comparisons written so an unreadable bound (NaN) never matches. if (!(date < Date.parse(set.closedAt))) continue; if (set.openedAt !== null && !(Date.parse(set.openedAt) <= date)) continue; return set; } return null; } /** * The live type with a kept set's rules on top. Everything that isn't a rule * stays the live type's — id, firmId, label, prefix, accountSize, updatedAt, * tradesEveryDay and the projection fields — so the account still matches, * groups and reports as its live type. A rule field the kept set doesn't * carry at all keeps the live value. */ export function overlayKept(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType { const effective: EffectiveAccountType = { ...live, keptRules: { id: kept.id, openedAt: kept.openedAt, closedAt: kept.closedAt }, }; const target = effective as unknown as Record; for (const field of RULE_FIELDS) { if (kept[field] !== undefined) target[field] = kept[field]; } return effective; } /** A stage the way the master's parseAccountTypeInput builds it, so stored * JSON from before drain / minDayPnL existed doesn't read as a change. */ function normalizeStage(stage: unknown): unknown { if (typeof stage !== 'object' || stage === null) return stage; const s = stage as Record; const drain = s.drain === true; return { profit: s.profit, consistency: s.consistency, minTradingDays: s.minTradingDays, drain, minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0), ...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}), ...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}), ...(typeof s.dailyLossLimit === 'number' ? { dailyLossLimit: s.dailyLossLimit } : {}), }; } /** A rule field's value as the bot reads it, for comparing: an absent * optional flag or amount is its default. */ function comparable(field: RuleField, value: unknown): unknown { switch (field) { case 'withdrawalStages': return Array.isArray(value) ? value.map(normalizeStage) : value; case 'splitEnabled': return value !== false; case 'targetSameEquity': case 'firstDayStop': case 'minDayStop': case 'combineCmeHolidays': case 'stagesSameDay': case 'payoutable': return value === true; case 'minLegs': case 'trailingDrawdown': case 'dailyLossLimit': return typeof value === 'number' ? value : 0; default: return value; } } /** JSON with every object's keys sorted, so key order never reads as a change. */ function canonical(value: unknown): string { return JSON.stringify(value, (_key, v: unknown) => ( typeof v === 'object' && v !== null && !Array.isArray(v) ? Object.fromEntries(Object.entries(v as Record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : v )) ?? 'undefined'; } /** The rule fields where an account's effective rules differ from its live * type's, with both raw values (the dashboard formats them). Empty when the * account is on the current rules, or its kept set happens to match them. */ export function ruleDiffs( effective: Pick, live: Pick, ): { field: RuleField; kept: unknown; now: unknown }[] { const diffs: { field: RuleField; kept: unknown; now: unknown }[] = []; for (const field of RULE_FIELDS) { if (canonical(comparable(field, effective[field])) === canonical(comparable(field, live[field]))) continue; diffs.push({ field, kept: effective[field], now: live[field] }); } return diffs; } // ── Rules dates ────────────────────────────────────────────────────────────── /** * The rules date for an account this machine records for the first time: * the latest of this clock, the master's clock (this clock plus the offset * the catalog sync measured, when known) and the catalog's own last change. * A machine whose clock runs behind must never date a brand-new account * inside a rule set the master closed moments ago. */ export function stampRulesDate(nowMs: number, masterOffsetMs: number | null, catalogUpdatedAt: string | null): string { let ms = nowMs; if (masterOffsetMs !== null && Number.isFinite(masterOffsetMs)) ms = Math.max(ms, nowMs + masterOffsetMs); const catalog = catalogUpdatedAt ? Date.parse(catalogUpdatedAt) : NaN; if (Number.isFinite(catalog)) ms = Math.max(ms, catalog); return new Date(ms).toISOString(); } /** 2000-01-01T00:00:00.000Z — no rules date is earlier. */ const EARLIEST_RULES_DATE_MS = Date.UTC(2000, 0, 1); /** The plain toISOString() shape — four-digit year, milliseconds, Z. String * order is time order only between strings of exactly this shape. */ const PLAIN_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; /** * An instant as a storable rules date: its toISOString(), when that is the * plain four-digit-year shape and not before 2000 — so every stored rules * date sorts as a string (lib/db.ts lowerAutoRulesDate compares them so). * null for anything else (NaN, a pre-2000 or a six-digit-year instant). */ export function sortableIso(ms: number): string | null { if (!Number.isFinite(ms) || ms < EARLIEST_RULES_DATE_MS || Math.abs(ms) > 8.64e15) return null; const iso = new Date(ms).toISOString(); return PLAIN_ISO.test(iso) ? iso : null; } // The fund rows' dates — CME trade dates (Tradovate, TopstepX) and DXtrade's // firm days (ending 22:00Z) — are calendar days that end no later than // midnight in Chicago, so that is the calendar evidence is read in. let chicagoClock: Intl.DateTimeFormat | undefined; /** Chicago's wall clock minus UTC at an instant, in ms: −5 h under CDT, * −6 h under CST (Intl knows the DST rules; no dependency needed). */ function chicagoOffsetMs(ms: number): number { chicagoClock ??= new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', }); const p: Record = {}; for (const part of chicagoClock.formatToParts(new Date(ms))) { if (part.type !== 'literal') p[part.type] = Number(part.value); } // Some engines spell midnight "24" even under h23. const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour === 24 ? 0 : p.hour, p.minute, p.second); return wall - (ms - (((ms % 1000) + 1000) % 1000)); } /** Whether `day` is YYYY-MM-DD naming a real calendar day (no Feb 31). */ function isCalendarDay(day: unknown): day is string { if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return false; const ms = Date.parse(`${day}T00:00:00.000Z`); return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === day; } /** The UTC instant of a wall-clock time on a Chicago calendar day. Exact for * any time a DST change neither skips nor repeats (they happen at 2 AM), so * for the day's first and last millisecond. NaN for a bad day. */ function chicagoWallMs(day: string, time: string): number { if (!isCalendarDay(day)) return NaN; const naive = Date.parse(`${day}T${time}Z`); // The offset where the answer lands, which a DST change between the naive // instant and the real one can make differ from the naive instant's. const first = naive - chicagoOffsetMs(naive); return naive - chicagoOffsetMs(first); } /** The Chicago calendar day (YYYY-MM-DD) an instant falls on; null when unreadable. */ export function chicagoDay(iso: string): string | null { const ms = Date.parse(iso); if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null; return new Date(ms + chicagoOffsetMs(ms)).toISOString().slice(0, 10); } /** The first millisecond of a Chicago calendar day, as a rules date — what a * day picked by hand can never be earlier than, so it is never inside the * evidence of the day before it. null for a bad or pre-2000 day. */ export function chicagoDayStart(day: string): string | null { return sortableIso(chicagoWallMs(day, '00:00:00.000')); } /** * The rules date the broker's evidence gives an account: the end of its first * fund day (YYYY-MM-DD, platform-client.ts firstFundDate) in Chicago — * D 23:59:59.999 America/Chicago, so 04:59:59.999Z the next day under CDT and * 05:59:59.999Z under CST. Every source's day ends by then, so the evidence is * never earlier than the moment it stands for, and an account opened on the * day of a rule change — even that evening — counts as opened after it. null * for anything else. */ export function evidenceRulesDate(firstFundDate: string | null | undefined): string | null { if (!isCalendarDay(firstFundDate)) return null; return sortableIso(chicagoWallMs(firstFundDate, '23:59:59.999')); } /** The Chicago day a rules date stands for when it is exactly the end of one * — the shape evidenceRulesDate stores — so the dashboard shows the broker's * day in any time zone rather than the next one. null for any other date. */ export function evidenceDay(rulesDate: string): string | null { const day = chicagoDay(rulesDate); return day !== null && evidenceRulesDate(day) === rulesDate ? day : null; } /** * The rules date a day picked by hand on the dashboard stores. Today is now * (`nowMs`) — the master's default cutoff is "now", and today's start would * sit before a change made earlier today, leaving no way onto the current * rules. Any other day is its start: the browser's midnight * (`localMidnightMs`), but never before Chicago's, so a day picked in a * browser east of Chicago is never inside the evidence of the day before * (which ends at Chicago midnight). null when unreadable. */ export function pickedDayRulesDate( day: string, at: { today: string; nowMs: number; localMidnightMs: number }, ): string | null { if (day === at.today) return sortableIso(at.nowMs); const chicago = chicagoDayStart(day); if (!chicago || !Number.isFinite(at.localMidnightMs)) return null; return sortableIso(Math.max(at.localMidnightMs, Date.parse(chicago))); } /** * Whether ✓ on the very day the editor opened on stores it. An unchanged day * keeps the exact date — storing the day's start can land before a same-day * rule change and put a running account back on old rules — unless storing * it moves the account to other rules, which the editor's preview shows * (today's "now" past a same-day change, say). With the type's windows * unknown, never. `openedDate` is the date the editor opened on, not one a * later poll brought. */ export function unchangedDaySaves( kept: { id: string; openedAt: string | null; closedAt: string }[] | null, openedDate: string, stored: string, ): boolean { if (!kept) return false; return (pickKeptRules(kept, openedDate)?.id ?? null) !== (pickKeptRules(kept, stored)?.id ?? null); } /** * The broker's evidence of when an account opened, once its fund history has * loaded: the end of its earliest positive fund row's day (evidenceRulesDate), * or — with no such row — the DXtrade registration instant itself. Never the * registration's calendar day: that is a Chicago date, and an evening * registration must not read as earlier than it happened. null when neither * says (or a value is unreadable). */ export function openingEvidence(firstFundDay: string | null | undefined, registeredAt: number | null | undefined): string | null { if (typeof firstFundDay === 'string') return evidenceRulesDate(firstFundDay); return typeof registeredAt === 'number' ? sortableIso(registeredAt) : null; } /** * Whether an account bound to a live type still matches that type's identity * — its name carries the type's prefix and the size cached when it matched * equals the type's size (0 = 0 for a $0-start type). An account on a kept * rule set stays bound to its type only while this holds: kept sets take the * live type's prefix and size, so after a size edit a "sticky" account would * trade on a size it doesn't have. */ export function identityStillMatches( accountName: string, cachedSize: string | null, live: Pick, ): boolean { if (!accountName.startsWith(live.prefix)) return false; if (cachedSize === null || cachedSize.trim() === '') return false; const size = Number(cachedSize); return Number.isFinite(size) && Math.abs(size - live.accountSize) < 0.01; } /** Whether broker evidence moves an auto rules date: only earlier, never a * date set by hand. The same test lib/db.ts lowerAutoRulesDate applies in * SQL (string order is time order: every rules date is a sortableIso()). */ export function lowersRulesDate(row: { rules_date: string | null; rules_date_manual: number }, evidence: string): boolean { return row.rules_date_manual === 0 && (row.rules_date === null || row.rules_date > evidence); }