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:
Brandon Li
2026-10-18 22:21:55 -05:00
co-authored by Claude Opus 5.5
parent 98016b3eb3
commit c0899620d4
24 changed files with 2127 additions and 153 deletions
+20 -7
View File
@@ -17,14 +17,14 @@ import { getBuildVersion } from './version';
import { tradeMixFor } from './trade-mix';
import { updaterStatus } from './updater-status';
import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade';
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
import { accountNextLeg, accountLegShape } from './leg-estimate';
import { instrumentStatsFor, isFuturesFirm } from './instruments';
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh } from './platform-client';
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField } from './platform-client';
function collectFirmStats() {
// Only firms this machine has credentials for. A firm it cannot log into
@@ -44,8 +44,10 @@ function collectFirmStats() {
return firms.map((f) => {
const client = clients.get(f.id);
// Absent (null) while this machine's catalog can't vouch for them.
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
// accounts: [] only once synced (see emptyFirmStats).
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client);
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
let totalAccounts = 0;
let accountsTraded = 0;
@@ -63,16 +65,19 @@ function collectFirmStats() {
// Per account type, so the master can say what the accounts still to
// trade actually are. Keyed by account type id; unassigned accounts
// share the null bucket — they inflate "left to trade" indefinitely,
// which is precisely the thing worth being able to see.
// which is precisely the thing worth being able to see. Always the
// live type's id, label and size: accounts on a kept rule set count
// in their live type's slice (and in its keptRules).
const byType = new Map<string | null, AccountTypeStats>();
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
const key = cfg?.id ?? null;
let entry = byType.get(key);
if (!entry) {
const live = key ? getAccountTypeById(key) ?? cfg : null;
entry = {
accountTypeId: key,
label: cfg?.label ?? 'Unassigned',
accountSize: cfg?.accountSize ?? 0,
label: live?.label ?? 'Unassigned',
accountSize: live?.accountSize ?? 0,
totalAccounts: 0,
accountsTraded: 0,
inTrade: 0,
@@ -89,7 +94,8 @@ function collectFirmStats() {
const reportedCash = client.accountCashBalances[acc.id];
const cash = reportedCash ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const cfg = getAccountConfig(acc.id).accountType;
const resolved = getAccountConfig(acc.id);
const cfg = resolved.accountType;
// undefined until the account's fund history has loaded. The master
// clears a completed cycle once the withdrawal it was waiting for
// shows up here, so an account that hasn't loaded reports no count
@@ -122,6 +128,12 @@ function collectFirmStats() {
totalAccounts++;
slice.totalAccounts++;
if (resolved.keptRules) {
const keptId = resolved.keptRules.id;
const kept = (slice.keptRules ??= []).find((k) => k.id === keptId);
if (kept) kept.accounts++;
else slice.keptRules.push({ id: keptId, accounts: 1 });
}
// Same combined-day view as the trade path: for a combining firm,
// "today" spans the holiday stretch. Computed for every live
@@ -399,6 +411,7 @@ function collectFirmStats() {
...instrumentStatsBlock(f, client, rawToday),
mix: tradeMixFor(f, client),
...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt),
...keptRuleIdsField(keptRuleIds),
};
});
}