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
+35
-12
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user