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
+165 -19
View File
@@ -9,31 +9,58 @@
*
* An Unassigned account does not trade: every caller already guards on a
* missing config, which is the behaviour we want.
*
* Which of its type's rule sets an account trades on is the other half (see
* lib/kept-rules.ts): each assignment row carries a rules date, and an
* account dated inside one of the type's kept rule sets trades that set's
* rules — getAccountConfig applies it, so every trading read sees it.
*/
import type { CatalogAccountType } from './master-protocol';
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
import type { PlatformClient } from './platform-client';
import { firstFundDate } from './platform-client';
import {
getAssignment,
setAssignment,
setAutoAssignment,
clearAssignment,
unpinAssignment,
lowerAutoRulesDate,
stampMissingRulesDate,
listAssignments,
loadAccountMeta,
saveAccountMeta,
} from './db';
import { getAccountTypeById, getCachedFirm } from './firm-catalog';
import { getAccountTypeById, getCachedFirm, getCatalogUpdatedAt, getKeptRulesFor, getMasterOffsetMs } from './firm-catalog';
import {
identityStillMatches, lowersRulesDate, openingEvidence, overlayKept, pickKeptRules, stampRulesDate,
type EffectiveAccountType, type KeptRulesWindow,
} from './kept-rules';
const INFERRED_SIZE_KEY = 'inferred_account_size';
/** Accounts this process has run the evidence step on with their fund
* history loaded (see ensureRulesEvidence). On `global` like the clients,
* so the trade path and the resolver share it whichever bundle loads them. */
const ge = global as typeof globalThis & { __rulesEvidenceApplied?: Set<number> };
function evidenceApplied(): Set<number> {
return (ge.__rulesEvidenceApplied ??= new Set());
}
export type AssignmentSource = 'manual' | 'auto' | 'unassigned';
export interface ResolvedAssignment {
/** Always the live account type's id — a kept rule set's never is one. */
accountTypeId: string | null;
accountType: CatalogAccountType | null;
/** The rules the account trades on: its live type, or the live type with
* a kept rule set's rules on top (then keptRules says which). */
accountType: EffectiveAccountType | null;
source: AssignmentSource;
/** True when more than one account type matched and the tie-break decided. */
ambiguous: boolean;
/** The account's rules date (ISO); null = none recorded, current rules. */
rulesDate: string | null;
/** The kept rule set the account trades on; null = its type's current rules. */
keptRules: KeptRulesWindow | null;
}
const UNASSIGNED: ResolvedAssignment = {
@@ -41,8 +68,23 @@ const UNASSIGNED: ResolvedAssignment = {
accountType: null,
source: 'unassigned',
ambiguous: false,
rulesDate: null,
keptRules: null,
};
/** One overlay per kept set and live type, so a tick's many lookups share one
* object the way they share the live type. Both come from the same catalog
* index, so a sync replaces them together. */
const overlays = new WeakMap<KeptAccountRules, { live: CatalogAccountType; effective: EffectiveAccountType }>();
function overlaid(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType {
const hit = overlays.get(kept);
if (hit && hit.live === live) return hit.effective;
const effective = overlayKept(live, kept);
overlays.set(kept, { live, effective });
return effective;
}
/** The earliest positive fund transaction — the account's opening balance.
* Returns null while the reports fetch hasn't produced one yet, which is the
* "wait until it is findable" case rather than an error. */
@@ -93,13 +135,20 @@ export function matchAccountType(
}
/** THE account→config lookup. Reads the assignment table; never writes.
* Replaces the longest-prefix helper that used to be copied into five files. */
* Replaces the longest-prefix helper that used to be copied into five files.
* Every trading read of an account's rules comes through here, so this is
* where an account dated inside a kept rule set gets that set's rules —
* manual pins and auto assignments alike. One row read, the rest from the
* in-memory catalog: it runs per account per tick. */
export function getAccountConfig(accountId: number): ResolvedAssignment {
const row = getAssignment(accountId);
if (!row?.account_type_id) return UNASSIGNED;
if (!row) return UNASSIGNED;
// `?? null`: a row read before the rules-date migration ran has no column.
const rulesDate = row.rules_date ?? null;
if (!row.account_type_id) return { ...UNASSIGNED, rulesDate };
const accountType = getAccountTypeById(row.account_type_id);
if (!accountType) {
const live = getAccountTypeById(row.account_type_id);
if (!live) {
// The account type was deleted on the master. A manual pin is kept so
// that a master flap which recreates it restores the user's intent, but
// it reports as unassigned until then, so the account stops trading.
@@ -108,14 +157,20 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
accountType: null,
source: 'unassigned',
ambiguous: false,
rulesDate,
keptRules: null,
};
}
const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate);
const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live;
return {
accountTypeId: accountType.id,
accountTypeId: live.id,
accountType,
source: row.manual === 1 ? 'manual' : 'auto',
ambiguous: false,
rulesDate,
keptRules: accountType.keptRules ?? null,
};
}
@@ -130,28 +185,106 @@ export function getInferredSize(accountId: number, client: PlatformClient): numb
return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
}
/** Re-resolves every account on one firm's client. Skips manual overrides.
* Called after each Tradovate sync and after the catalog changes. */
/** The rules date a row first written now gets — on the master's clock when
* this one runs behind, and never before the catalog's last change. */
function freshRulesDate(): string {
return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt());
}
/** The broker's evidence of when the account opened (the report's
* firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined
* until its fund history has loaded, null when that history doesn't say. */
function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined {
const funds = client.fundTransactions?.[accountId];
if (!funds) return undefined;
return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime);
}
/**
* The evidence step for one account, for the trade path: once its fund
* history has loaded, lower an auto rules date to the broker's evidence
* before anything reads the account's rules. The resolver does the same on
* every pass, but only after a sync — and history lands between syncs, when
* an account turns tradable (historyReady needs the fund rows loaded, on
* every platform) and could trade on its first-seen date for up to a sync.
* Once per account per process; a no-op until the history has loaded and the
* account has an assignment row.
*/
export function ensureRulesEvidence(client: PlatformClient, accountId: number): void {
if (evidenceApplied().has(accountId)) return;
const evidence = brokerEvidence(client, accountId);
if (evidence === undefined) return;
const row = getAssignment(accountId);
if (!row) return;
if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence);
evidenceApplied().add(accountId);
}
/** Re-resolves every account on one firm's client. Skips manual overrides,
* and auto ones on a kept rule set (see below). Lowers rules dates to the
* broker's evidence on the way. Called after each sync and after the
* catalog changes. */
export function resolveFirmAssignments(
firmId: string,
client: PlatformClient,
): { assigned: number; unassigned: number; manual: number } {
const firm = getCachedFirm(firmId);
const types = firm?.accountTypes ?? [];
const stamp = freshRulesDate();
let assigned = 0;
let unassigned = 0;
let manual = 0;
for (const account of client.accountList) {
if (getAssignment(account.id)?.manual === 1) {
let row = getAssignment(account.id);
// A row an older build wrote without a rules date (between a build's
// migration and its restart, or after a rollback): dated now, as the
// migration dates rows, before the evidence below can lower it.
if (row && !row.rules_date) {
stampMissingRulesDate(account.id, stamp);
row = { ...row, rules_date: stamp };
}
// Evidence next, for every account, pinned or not: once its fund
// history has loaded, the broker shows when it opened (the report's
// firstFundDate), and an auto rules date later than that comes down
// to it. Ahead of the kept-set check below, which reads the date.
const loadedFunds = client.fundTransactions?.[account.id];
const evidence = brokerEvidence(client, account.id) ?? null;
if (row && evidence && lowersRulesDate(row, evidence)) {
lowerAutoRulesDate(account.id, evidence);
row = { ...row, rules_date: evidence };
}
if (row && loadedFunds) evidenceApplied().add(account.id);
if (row?.manual === 1) {
manual++;
continue;
}
const fundTxns = client.fundTransactions?.[account.id] ?? [];
const fundTxns = loadedFunds ?? [];
const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY);
// Sticky: an account on one of its type's kept rule sets stays bound
// to that type, so a tie-break flip to another matching type can never
// move it off its old rules. Only while it still matches the type's
// prefix and size, though: a kept set takes both from the live type,
// so after a size edit it would trade on a size it doesn't have.
// Unstuck, it re-matches as it did before kept rules existed —
// usually to Unassigned, which stops it trading.
if (row?.account_type_id) {
const live = getAccountTypeById(row.account_type_id);
if (
live && live.firmId === firmId
&& identityStillMatches(account.name, cached, live)
&& pickKeptRules(getKeptRulesFor(live.id), row.rules_date ?? null)
) {
assigned++;
continue;
}
}
let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false };
let matchedSize: number | null = null;
@@ -194,24 +327,37 @@ export function resolveFirmAssignments(
if (matchedSize !== null && cached === null) {
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
}
setAutoAssignment(account.id, firmId, matched.type.id);
setAutoAssignment(account.id, firmId, matched.type.id, stamp);
assigned++;
} else {
setAutoAssignment(account.id, firmId, null);
setAutoAssignment(account.id, firmId, null, stamp);
unassigned++;
}
// A row this pass created is dated "first seen now"; the evidence may
// already date it earlier.
if (!row && evidence) lowerAutoRulesDate(account.id, evidence);
if (!row && loadedFunds) evidenceApplied().add(account.id);
}
return { assigned, unassigned, manual };
}
export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
setAssignment(accountId, firmId, accountTypeId, true);
/** insertRulesDate dates the row when the pin creates it (default: now, as
* stampRulesDate gives it); re-pinning keeps the account's rules date. */
export function setManualAssignment(
accountId: number,
firmId: string,
accountTypeId: string | null,
insertRulesDate: string = freshRulesDate(),
): void {
setAssignment(accountId, firmId, accountTypeId, true, insertRulesDate);
}
/** Drops the override so the auto-resolver takes the account back over. */
/** Drops the override so the auto-resolver takes the account back over. The
* row and its rules date stay: unpinning must not make an old account look
* new. */
export function clearManualAssignment(accountId: number): boolean {
return clearAssignment(accountId);
return unpinAssignment(accountId);
}
export { listAssignments };
+19 -7
View File
@@ -9,7 +9,7 @@
import { getClients } from './clients';
import { getCachedFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
@@ -18,7 +18,7 @@ import {
isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
CME_EVENING_OPEN_MIN, foldReason,
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
fundRowsKey, markFinishedCycle, unbookedPayoutDrop,
type DayPlan, type FinishedCycleMark,
} from './trading-logic';
@@ -666,6 +666,11 @@ function finishedCycleDrop(
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null {
// Which rule set the account trades on hangs on its rules date: bring it
// down to the broker's evidence before reading the rules, so an account
// whose history landed since the last sync never trades a leg on the
// date this machine first saw it.
ensureRulesEvidence(client, acc.id);
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return null;
@@ -1037,7 +1042,9 @@ async function runTradeLocked(
// (largest group first), never mixed across prefixes — a group's remainder
// fires as a smaller batch on a later tick instead of being padded from
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
// batches of 5, 5, 4, 2. A group that can't trade anything right now
// batches of 5, 5, 4, 2. A firm's own cap on accounts at once (the
// master's Firms page) trims its share first: 6 Topstep accounts capped
// at 3 fire as 3, 3. A group that can't trade anything right now
// (nothing listed fits, no contract resolves) yields to the next.
const groups = new Map<string, Candidate[]>();
for (const item of allEligible) {
@@ -1051,7 +1058,8 @@ async function runTradeLocked(
let batchPrefix = '';
let resolvedSymbol = '';
for (const [prefix, members] of ordered) {
const candidates = members.slice(0, maxConcurrent);
// The lane is flat (see above), so no firm holds anything yet.
const candidates = withinFirmCaps(members).slice(0, maxConcurrent);
const lead = candidates.find((it) => !it.isExtraDay && !it.drain);
const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()];
@@ -1566,7 +1574,8 @@ async function fireLeg(
// ── copy trade ───────────────────────────────────────────────────────────────
/**
* Copy the current trade direction to up to maxConcurrent accounts.
* Copy the current trade direction to up to maxConcurrent accounts (and no
* more of a firm's than its cap on accounts at once).
* Finds accounts with open positions, determines direction, then fires
* orders for eligible accounts that haven't traded yet — within the lane of
* the first open position found, never across lanes.
@@ -1617,12 +1626,15 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let positionedClient: any = null;
const positionedPrefixes = new Set<string>();
// Open positions by firm, against each firm's cap on accounts at once.
const heldByFirm = new Map<string, number>();
for (const { client } of ready) {
for (const { firm, client } of ready) {
for (const acc of client.accountList) {
const pos = client.positions[acc.id];
if (!pos) continue;
positionedCount++;
heldByFirm.set(firm.id, (heldByFirm.get(firm.id) ?? 0) + 1);
const prefix = getAccountConfig(acc.id).accountType?.prefix;
if (prefix) positionedPrefixes.add(prefix);
if (!resolvedAction) {
@@ -1718,7 +1730,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
// accounts before falling back to mismatched prefixes.
const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix);
const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))];
const batch = ordered.slice(0, slotsAvailable);
const batch = withinFirmCaps(ordered, heldByFirm).slice(0, slotsAvailable);
if (batch.length === 0) {
console.log('[copy-trade] no eligible accounts to copy to');
return [];
+4 -2
View File
@@ -19,7 +19,7 @@ import {
getSetting,
setCredentials,
setInstrumentEnabled,
setAssignment,
restoreAssignment,
runInTransaction,
} from './db';
import type { BackupPayload } from './backup-sync';
@@ -116,8 +116,10 @@ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: bool
setInstrumentEnabled(i.symbol, i.enabled);
counts.instruments++;
}
// Rules dates travel with them: a restored machine must not date
// its accounts afresh, or old ones would leave their kept rules.
for (const a of payload.assignments ?? []) {
setAssignment(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual === 1);
restoreAssignment(a);
counts.assignments++;
}
});
+4 -2
View File
@@ -26,7 +26,7 @@ import {
getInstruments,
listAllAssignments,
type FirmCredentialRow,
type AssignmentRow,
type BackupAssignmentRow,
} from './db';
export interface BackupPayload {
@@ -35,7 +35,9 @@ export interface BackupPayload {
settings: Record<string, string>;
credentials: FirmCredentialRow[];
instruments: { symbol: string; enabled: boolean }[];
assignments: AssignmentRow[];
/** Whole rows (SELECT *), rules dates included — absent from a backup
* an older build pushed. */
assignments: BackupAssignmentRow[];
}
const BACKUP_PATH = '/api/instances/backup';
+158 -8
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import path from 'path';
import { sortableIso, stampRulesDate } from './kept-rules';
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
// database. It is read once, at module load — schema creation and seeding are
@@ -78,6 +79,62 @@ try {
// Column already exists
}
// Migration: each account's rules date — which of its account type's rule
// sets it trades on (lib/kept-rules.ts): an account dated before a firm's
// rule change keeps the rules from before it. Written when this machine first
// records the account (or from a restored backup), lowered to the broker's
// evidence of when the account opened, or set by hand (rules_date_manual,
// which evidence never moves). Every writer stores a toISOString() UTC
// string, so string order is time order.
//
// The run that adds the column dates every existing row at the migration
// time, never NULL: no account changes rules merely because its machine
// updated. The resolver then lowers those auto dates to the broker's
// evidence, so an account the evidence shows opened before a rule change
// goes back to the old rules. Column and backfill commit together, so
// `next build` workers racing the migration can't leave rows undated.
/** The date an undated row gets: now, but never before the catalog's last
* change — lib/kept-rules.ts stampRulesDate's floor (every build keeps
* settings.catalog_updated_at current), so a machine whose clock runs
* behind can't backdate an account first seen after a rule change into the
* rule set that change closed. Call inside the writing transaction. */
function undatedRowsDate(): string {
let catalogUpdatedAt: string | null = null;
try {
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get() as { value?: unknown } | undefined;
if (typeof row?.value === 'string') catalogUpdatedAt = row.value;
} catch {
// No settings table yet: a fresh database, with no rows to date.
}
return stampRulesDate(Date.now(), null, catalogUpdatedAt);
}
try {
db.transaction(() => {
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
}).immediate();
} catch {
// Column already exists
}
try {
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
} catch {
// Column already exists
}
// Rows an older build wrote after that run have no date: it keeps serving
// between a build's migration and the restart, and comes back in a rollback,
// and its INSERT has no rules_date. Dated the same way at every start, so no
// row stays undated (current rules forever, with no date to set by hand).
try {
db.transaction(() => {
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
}).immediate();
} catch {
// No rules_date column — the migration above failed; nothing to date.
}
// ── Firm credentials ─────────────────────────────────────────────────────────
export interface FirmCredentialRow {
@@ -133,8 +190,18 @@ export interface AssignmentRow {
account_type_id: string | null;
manual: number; // 0 | 1
resolved_at: string;
/** ISO (toISOString). Which of the type's rule sets the account trades
* on; null only on a row an older build wrote after the migration —
* the current rules until evidence dates it. */
rules_date: string | null;
rules_date_manual: number; // 0 | 1 — set by hand; evidence leaves it alone
}
/** An assignment as a backup carries it: one from a build before rules
* dates has neither column. */
export type BackupAssignmentRow = Omit<AssignmentRow, 'rules_date' | 'rules_date_manual'>
& Partial<Pick<AssignmentRow, 'rules_date' | 'rules_date_manual'>>;
export function getAssignment(accountId: number): AssignmentRow | undefined {
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined;
}
@@ -148,33 +215,116 @@ export function listAllAssignments(): AssignmentRow[] {
return db.prepare('SELECT * FROM account_assignments').all() as AssignmentRow[];
}
/** insertRulesDate dates a row this call creates (default now — callers pass
* lib/kept-rules.ts stampRulesDate). An existing row keeps its rules date
* and how it was set: re-resolving or re-pinning an account never makes it
* look new. Only a row with no date at all (an older build's) takes
* insertRulesDate. */
export function setAssignment(
accountId: number,
firmId: string,
accountTypeId: string | null,
manual: boolean,
insertRulesDate?: string,
): void {
const now = new Date().toISOString();
db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString());
resolved_at = excluded.resolved_at,
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, now, insertRulesDate ?? now);
}
/** Writes an auto-resolved assignment, leaving any manual override alone.
* The `manual = 0` guard is what makes a user's pick stick across syncs. */
export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
export function setAutoAssignment(
accountId: number,
firmId: string,
accountTypeId: string | null,
insertRulesDate?: string,
): void {
const existing = getAssignment(accountId);
if (existing?.manual === 1) return;
setAssignment(accountId, firmId, accountTypeId, false);
setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate);
}
export function clearAssignment(accountId: number): boolean {
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0;
/** Drops a manual pin but keeps the row — and with it the rules date, so
* unpinning can't make an old account look new. The auto-resolver takes the
* account back on its next pass. False when there is no row. */
export function unpinAssignment(accountId: number): boolean {
return db.prepare('UPDATE account_assignments SET manual = 0, account_type_id = NULL, resolved_at = ? WHERE account_id = ?')
.run(new Date().toISOString(), accountId).changes > 0;
}
/** Sets an account's rules date (an ISO toISOString() string); manual marks
* it set by hand, which broker evidence never moves. False when the account
* has no row. */
export function setRulesDate(accountId: number, iso: string, manual: boolean): boolean {
return db.prepare('UPDATE account_assignments SET rules_date = ?, rules_date_manual = ? WHERE account_id = ?')
.run(iso, manual ? 1 : 0, accountId).changes > 0;
}
/** Dates a row that has none (one an older build wrote) at `iso`; a dated
* row is left alone. */
export function stampMissingRulesDate(accountId: number, iso: string): void {
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE account_id = ? AND rules_date IS NULL').run(iso, accountId);
}
/** Moves an auto rules date earlier to `iso` (the broker's evidence of when
* the account opened, lib/kept-rules.ts openingEvidence); never later and
* never a date set by hand. Compared as strings, which is time order only
* because every rules date is a plain four-digit-year UTC toISOString()
* string (lib/kept-rules.ts sortableIso) — keep it that way. */
export function lowerAutoRulesDate(accountId: number, iso: string): void {
db.prepare(`
UPDATE account_assignments SET rules_date = ?
WHERE account_id = ? AND rules_date_manual = 0 AND (rules_date IS NULL OR rules_date > ?)
`).run(iso, accountId, iso);
}
/**
* One backed-up assignment onto this machine, every column (both restore
* paths: lib/backup-restore.ts, and scripts/seed-settings.js by hand). The
* backup's rules date and its manual flag win when the backup has one; a
* backup from an older build has none, so an existing row keeps its own and
* a new row is dated now, at the restore.
*/
export function restoreAssignment(row: BackupAssignmentRow): void {
const now = new Date().toISOString();
const resolvedAt = typeof row.resolved_at === 'string' ? row.resolved_at : now;
// Only a date that sorts as a string counts as the backup's (anything
// else is restored like an older build's undated row).
const rulesDate = typeof row.rules_date === 'string' ? sortableIso(Date.parse(row.rules_date)) : null;
if (rulesDate) {
db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at,
rules_date = excluded.rules_date,
rules_date_manual = excluded.rules_date_manual
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt,
rulesDate, row.rules_date_manual === 1 ? 1 : 0);
return;
}
db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at,
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt, now);
}
// ── Settings ─────────────────────────────────────────────────────────────────
+152 -47
View File
@@ -19,8 +19,10 @@
import { isFirmPlatform } from './master-protocol';
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
import { normalizeAccountType } from './kept-rules';
import type {
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, KeptAccountRules, SymbolMap,
SymbolMapCandidate,
} from './master-protocol';
import {
getSetting,
@@ -39,24 +41,50 @@ const FETCH_TIMEOUT_MS = 10_000;
const COLD_RETRY_MIN_MS = 10_000;
const COLD_RETRY_MAX_MS = 60_000;
/** The version of validatePayload below, stamped on the payload it caches.
* Bump it whenever the parser starts keeping a field an older one dropped:
* a cache from before then lacks the field however often it is re-read, and
* `?since=` would answer "unchanged" until the master next changes, so the
* first sync on a newer parser fetches the whole catalog.
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
* Stamped only on a payload from a master that sends keptAccountRules: one
* from an older master can't vouch for kept sets it never sent. */
export const CATALOG_PARSER_VERSION = 2;
/** A Date header further off than this is somebody else's clock, not the master's. */
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
interface CatalogCache {
payload: FirmCatalogPayload | null;
/** Index over every firm's account types, for the id lookup. */
typesById: Map<string, CatalogAccountType>;
/** Each live account type's kept rule sets, by closedAt (oldest window
* first). Kept ids are deliberately not in typesById: nothing can be
* pinned to one. */
keptByLiveId: Map<string, KeptAccountRules[]>;
}
const g = global as typeof globalThis & {
__firmCatalog?: CatalogCache;
__firmCatalogTimer?: ReturnType<typeof setTimeout>;
__firmCatalogColdDelay?: number;
/** The master's clock minus this one, from the last sync's Date header. */
__masterOffsetMs?: number;
};
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
const typesById = new Map<string, CatalogAccountType>();
const keptByLiveId = new Map<string, KeptAccountRules[]>();
for (const firm of payload?.firms ?? []) {
for (const type of firm.accountTypes) typesById.set(type.id, type);
for (const kept of firm.keptAccountRules ?? []) {
const list = keptByLiveId.get(kept.keptFrom);
if (list) list.push(kept);
else keptByLiveId.set(kept.keptFrom, [kept]);
}
}
return { payload, typesById };
for (const list of keptByLiveId.values()) list.sort((a, b) => Date.parse(a.closedAt) - Date.parse(b.closedAt));
return { payload, typesById, keptByLiveId };
}
function cache(): CatalogCache {
@@ -66,7 +94,14 @@ function cache(): CatalogCache {
const raw = getSetting('catalog_payload');
if (raw) {
try {
payload = validatePayload(JSON.parse(raw));
const stored = JSON.parse(raw);
payload = validatePayload(stored);
// Whatever parsed the blob when it was stored, not this parser: a
// field an older build dropped on the way in can't come back from
// re-reading the blob.
const parser = (stored as { parser?: unknown }).parser;
if (typeof parser === 'number') payload.parser = parser;
else delete payload.parser;
} catch {
console.error('[catalog] cached payload is unreadable — ignoring it');
}
@@ -87,6 +122,37 @@ export function getAccountTypeById(typeId: string): CatalogAccountType | undefin
return cache().typesById.get(typeId);
}
/** The kept rule sets of one live account type, oldest window first ([] when
* it keeps none). lib/account-type-resolver.ts picks an account's by its
* rules date. */
export function getKeptRulesFor(liveTypeId: string): KeptAccountRules[] {
return cache().keptByLiveId.get(liveTypeId) ?? [];
}
/** The kept rule set ids loaded for a firm, for the report — null while the
* cached catalog came from a parser older than CATALOG_PARSER_VERSION, which
* can't be trusted to hold them (the master then reads this machine as not
* applying kept rules yet). */
export function getKeptRuleIdsForFirm(firmId: string): string[] | null {
const payload = cache().payload;
if (!payload || (payload.parser ?? 0) < CATALOG_PARSER_VERSION) return null;
return (payload.firms.find((f) => f.id === firmId)?.keptAccountRules ?? []).map((k) => k.id);
}
/** The master's clock minus this machine's, as of the last good sync (null
* before one this process). Dates new accounts on the master's clock when
* this one runs behind (lib/kept-rules.ts stampRulesDate). */
export function getMasterOffsetMs(): number | null {
return g.__masterOffsetMs ?? null;
}
/** When the cached catalog last changed on the master (its payload's
* updatedAt); null with no cache. No account first seen after a change can
* be dated before it. */
export function getCatalogUpdatedAt(): string | null {
return cache().payload?.updatedAt ?? null;
}
export function getCatalogMeta(): {
version: number;
updatedAt: string | null;
@@ -131,7 +197,10 @@ export function getTradableFirms(): (CatalogFirm & { username: string; password:
// ── Sync ─────────────────────────────────────────────────────────────────────
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set<string> };
const g2 = global as typeof globalThis & {
__catalogUnknownPlatformWarned?: Set<string>;
__catalogKeptSkipWarned?: Set<string>;
};
/** Once per firm per process — the catalog is re-validated every 60s. */
function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
@@ -164,12 +233,41 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
return map;
}
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');
function isDateString(value: unknown): value is string {
return typeof value === 'string' && Number.isFinite(Date.parse(value));
}
/**
* A firm's kept rule sets, each as strict as an account type: a malformed
* entry throws and the whole sync is rejected. One that names no live type
* of this firm is skipped instead (warned once) — no account can map to it.
*/
function parseKeptRules(
firm: { id: string; name: string },
raw: unknown[],
accountTypes: CatalogAccountType[],
tradesEveryDay: boolean,
): KeptAccountRules[] {
const liveIds = new Set(accountTypes.map((t) => t.id));
const kept: KeptAccountRules[] = [];
raw.forEach((k: unknown, j: number) => {
const where = `kept rules ${j} of ${firm.name}`;
const type = normalizeAccountType(k, { tradesEveryDay, where });
const entry = k as Record<string, unknown>;
if (typeof entry.keptFrom !== 'string') throw new Error(`${where} is missing keptFrom`);
if (entry.openedAt !== null && !isDateString(entry.openedAt)) throw new Error(`${where} has bad openedAt`);
if (!isDateString(entry.closedAt)) throw new Error(`${where} has bad closedAt`);
if (!liveIds.has(entry.keptFrom) || type.firmId !== firm.id) {
const warned = (g2.__catalogKeptSkipWarned ??= new Set());
if (!warned.has(type.id)) {
warned.add(type.id);
console.warn(`[catalog] skipping kept rules ${type.id} of "${firm.name}": it names no account type of this firm (keptFrom ${entry.keptFrom}, firmId ${type.firmId})`);
}
return;
}
kept.push({ ...type, keptFrom: entry.keptFrom, openedAt: entry.openedAt, closedAt: entry.closedAt });
});
return kept;
}
/** Rejects anything that isn't a complete catalog. A half-parsed payload would
@@ -204,39 +302,13 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
const tradesEveryDay = dx && firm.tradesEveryDay === true;
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
const type = t as Record<string, unknown>;
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`);
// 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;
});
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) =>
normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` }));
// Only when the master sent the list: absent = a master from before
// kept rules, [] = every account on the current rules.
const keptAccountRules = Array.isArray(firm.keptAccountRules)
? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay)
: undefined;
firms.push({
id: firm.id,
@@ -248,6 +320,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
...(platform !== 'dxtrade' ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
accountTypes,
...(keptAccountRules ? { keptAccountRules } : {}),
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
@@ -255,12 +328,24 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
symbolMap: sanitizeSymbolMap(firm.symbolMap),
targetHoldHours: nonNegative(firm.targetHoldHours),
financingPctPerDay: nonNegative(firm.financingPctPerDay),
// Lenient: absent or malformed = no firm cap.
maxConcurrentAccounts: Math.floor(nonNegative(firm.maxConcurrentAccounts)),
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
});
});
const trading = parseTradingSettings(body.trading);
return { version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}) };
// A kept-aware master sends keptAccountRules on every firm (even []). A
// payload without it is a master from before kept rules: cached without
// a parser version, so the cache isn't trusted to hold kept sets — every
// sync fetches in full (no `since`), and the first one after a kept-aware
// master is back brings them, though its catalog version never moved.
const keptAware = body.firms.every((f: unknown) =>
typeof f === 'object' && f !== null && Array.isArray((f as Record<string, unknown>).keptAccountRules));
return {
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
};
}
/** The master's trading settings; undefined when it sent none (or nothing
@@ -293,6 +378,17 @@ function recordError(message: string): void {
setSetting('catalog_last_sync_at', new Date().toISOString());
}
/** The master's clock off a response's Date header (whole seconds, read on
* arrival — close enough to date accounts by). An unreadable header, or one
* a year off, leaves the last offset standing. */
function recordMasterClock(header: string | null): void {
const at = header ? Date.parse(header) : NaN;
if (!Number.isFinite(at)) return;
const offset = at - Date.now();
if (Math.abs(offset) > MAX_MASTER_OFFSET_MS) return;
g.__masterOffsetMs = offset;
}
/** If the master's database was rebuilt, its firms come back with fresh ids and
* every credential row orphans. Match the survivors up by name (once) rather
* than making someone retype every password. */
@@ -323,14 +419,17 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
// before anyone has got round to naming it.
// `since` only against the master the cache came from: two masters'
// version numbers can collide, and an 'unchanged' from the wrong one
// would keep the other's catalog.
// would keep the other's catalog. Nor for a cache an older parser wrote:
// it lacks what that parser dropped, and 'unchanged' would keep it so.
const hasCache = getCachedCatalog().length > 0;
const source = url.origin;
if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
const cacheCurrent = (cache().payload?.parser ?? 0) >= CATALOG_PARSER_VERSION;
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) throw new Error(`master returned ${res.status}`);
recordMasterClock(res.headers.get('date'));
const body = await res.json();
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
@@ -353,13 +452,19 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
}
// Same reasoning one level down: a firm losing every account type
// unassigns all of its accounts. Keep the previous types and flag it.
// unassigns all of its accounts. Keep the previous types and flag it,
// with the kept rule sets that went with them (whose keptFrom named
// types this payload lacks, so none of its own survived parsing). An
// empty kept list on its own is never suspicious: it is every account
// on the current rules.
const previous = new Map(getCachedCatalog().map((f) => [f.id, f]));
const suspicious: string[] = [];
for (const firm of payload.firms) {
const before = previous.get(firm.id);
if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) {
firm.accountTypes = before.accountTypes;
if (before.keptAccountRules) firm.keptAccountRules = before.keptAccountRules;
else delete firm.keptAccountRules;
suspicious.push(firm.name);
}
}
+403
View File
@@ -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);
}
+36
View File
@@ -81,6 +81,10 @@ export interface AccountTypeStats {
stage: number; drain?: boolean;
}[];
}[];
/** Live accounts of this slice on each kept rule set — the slice itself
* stays keyed by the live account type, whose id is the only one ever
* reported as an accountTypeId. Optional. */
keptRules?: { id: string; accounts: number }[];
}
export interface FirmStats {
@@ -139,6 +143,10 @@ export interface FirmStats {
/** The catalog firm's uuid (statusVersion builds), so the master needn't
* resolve the name. Optional. */
firmId?: string;
/** Kept rule set ids this machine has loaded for the firm. Present (even
* []) = this build applies kept rules and its cached catalog was parsed
* by a parser that understands them. Absent = old build or stale cache. */
keptRuleIds?: string[];
/** ISO time the firm's last good sync finished (statusVersion builds), sent
* once synced. The statuses count only while it is recent. Optional. */
syncedAt?: string;
@@ -381,6 +389,22 @@ export interface CatalogAccountType {
updatedAt: string;
}
/** Old rules an account type keeps for its accounts whose rules date is
* inside [openedAt, closedAt) — set when the owner changes the type's rules
* for new accounts only. Built as: live type X's fields, overwritten by the
* snapshot's RULE fields, plus these. Never an account type itself: nothing
* is pinned or reported by its id as an accountTypeId (lib/kept-rules.ts). */
export interface KeptAccountRules extends CatalogAccountType {
/** This kept set's own uuid — never a live account type id. */
id: string;
/** The live account type id (X). */
keptFrom: string;
/** ISO; inclusive lower bound on the rules date; null = none. */
openedAt: string | null;
/** ISO; exclusive upper bound. */
closedAt: string;
}
/** Where a firm's accounts trade. A build only knows the platforms listed
* here; the catalog sync skips a firm on any other rather than guess. */
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const;
@@ -421,10 +445,17 @@ export interface CatalogFirm {
/** The firm's website ('' = none; absent from older masters). The master
* matches the extension's tab against it to find a person's 2FA code. */
siteUrl?: string;
/** The most of the firm's accounts this machine trades at once, on top of
* its own max_concurrent_accounts (0 = no firm cap; absent from older
* masters). */
maxConcurrentAccounts?: number;
/** Older masters' way to exclude roots; newer ones derive it from
* `instruments`. */
bannedSymbols: string[];
accountTypes: CatalogAccountType[];
/** Old rule sets the firm's account types keep for older accounts.
* Optional; old builds drop it. */
keptAccountRules?: KeptAccountRules[];
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
serverUrl?: string;
/** DXTrade login domain ('' = 'default'). */
@@ -458,6 +489,11 @@ export interface FirmCatalogPayload {
firms: CatalogFirm[];
/** Absent from a master from before its Settings page. */
trading?: CatalogTradingSettings;
/** Instance-local, never sent by the master: the catalog parser version
* (lib/firm-catalog.ts CATALOG_PARSER_VERSION) that produced a cached
* payload. An older parser dropped fields it didn't know, so a cache
* without it, or below the current one, isn't trusted to hold them. */
parser?: number;
}
export interface FirmCatalogUnchanged {
+35 -12
View File
@@ -302,30 +302,43 @@ const cents = (n: number) => Math.round(n * 100) / 100;
/** Epoch ms a Date can hold — formatting anything else throws. */
const validTime = (ms: number | undefined): ms is number => ms !== undefined && Number.isFinite(ms) && Math.abs(ms) <= 8.64e15;
/**
* When the account opened, as far as the broker shows: its earliest positive
* fund row's date (YYYY-MM-DD), or for a DXTrade account with none its
* registration date, in Chicago. Undefined until the fund history has loaded
* (`fundTransactions` undefined) and when neither says. The report's
* firstFundDate. The resolver's evidence for an account's rules date reads
* the fund rows through here (registeredAt undefined) but takes the
* registration instant itself, not its day (lib/kept-rules.ts openingEvidence).
*/
export function firstFundDate(
fundTransactions: { date: string; amount: number }[] | undefined,
registeredAt: number | undefined,
): string | undefined {
if (!fundTransactions) return undefined;
const positive = fundTransactions.filter((f) => f.amount > 0);
if (positive.length > 0) return positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
return validTime(registeredAt) ? CT_DATE.format(new Date(registeredAt)) : undefined;
}
/**
* The status fields one accounts[] entry adds. Every one is left out when it
* is null or at its default — 2000 accounts must stay well under the master
* socket's 1 MB frame. firstFundDate is the earliest positive fund row (a
* DXTrade account with none falls back to its registration date, in Chicago);
* positiveFunds counts those rows, and both wait for the fund history.
* socket's 1 MB frame. firstFundDate: see the function of that name;
* positiveFunds counts the positive fund rows, and both wait for the fund
* history.
*/
export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
const { status, reason } = accountStatus(input);
const funds = input.fundTransactions;
const positive = funds?.filter((f) => f.amount > 0);
let firstFundDate: string | undefined;
if (positive && positive.length > 0) {
firstFundDate = positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
} else if (funds && validTime(input.registeredAt)) {
firstFundDate = CT_DATE.format(new Date(input.registeredAt));
}
const positive = input.fundTransactions?.filter((f) => f.amount > 0);
const firstFund = firstFundDate(input.fundTransactions, input.registeredAt);
return {
status,
...(reason ? { reason } : {}),
...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}),
...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}),
...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}),
...(firstFundDate ? { firstFundDate } : {}),
...(firstFund ? { firstFundDate: firstFund } : {}),
...(positive ? { positiveFunds: positive.length } : {}),
...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}),
};
@@ -342,6 +355,14 @@ export function firmStatusFields(firmId: string, syncComplete: boolean, lastSync
};
}
/** FirmStats.keptRuleIds: the firm's kept rule set ids when this machine's
* catalog can be trusted to hold them (lib/firm-catalog.ts
* getKeptRuleIdsForFirm), else left out — the master then counts this
* machine as trading every account on the newest rules. */
export function keptRuleIdsField(keptRuleIds: string[] | null): Pick<FirmStats, 'keptRuleIds'> {
return keptRuleIds ? { keptRuleIds } : {};
}
/**
* FirmStats for a firm with no accounts to walk — no client yet, or an empty
* broker list. accounts is [] only once synced: before that, a firm whose
@@ -352,6 +373,7 @@ export function firmStatusFields(firmId: string, syncComplete: boolean, lastSync
export function emptyFirmStats(
firm: { id: string; name: string },
client: { syncComplete: boolean; lastSyncAt: number } | undefined,
keptRuleIds: string[] | null = null,
): FirmStats {
const synced = client?.syncComplete === true;
return {
@@ -360,6 +382,7 @@ export function emptyFirmStats(
...(synced ? { accounts: [] } : {}),
accountTypes: [],
...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0),
...keptRuleIdsField(keptRuleIds),
};
}
+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),
};
});
}
+21
View File
@@ -1750,3 +1750,24 @@ export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
if (day === 'Sat' || day === 'Sun') return false;
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
/**
* A firm's cap on how many of its accounts trade at once, on top of the
* machine's own max concurrent accounts: keeps `items` in order, at most
* `firm.maxConcurrentAccounts` per firm less what that firm already holds
* open (`held`, by firm id). A cap of 0 (or none) means no firm cap.
*/
export function withinFirmCaps<T extends { firm: { id: string; maxConcurrentAccounts?: number } }>(
items: readonly T[],
held: ReadonlyMap<string, number> = new Map(),
): T[] {
const taken = new Map(held);
return items.filter(({ firm }) => {
const cap = firm.maxConcurrentAccounts ?? 0;
if (cap <= 0) return true;
const count = taken.get(firm.id) ?? 0;
if (count >= cap) return false;
taken.set(firm.id, count + 1);
return true;
});
}