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,403 @@
|
||||
/**
|
||||
* 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',
|
||||
] 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<string, unknown>).profit === 'number'
|
||||
&& typeof (s as Record<string, unknown>).consistency === 'number'
|
||||
&& typeof (s as Record<string, unknown>).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<string, unknown>;
|
||||
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),
|
||||
// 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<T extends { openedAt: string | null; closedAt: string }>(
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 '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<string, unknown>).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<CatalogAccountType, RuleField>,
|
||||
live: Pick<CatalogAccountType, RuleField>,
|
||||
): { 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<string, number> = {};
|
||||
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<CatalogAccountType, 'prefix' | 'accountSize'>,
|
||||
): 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);
|
||||
}
|
||||
Reference in New Issue
Block a user