On Vest, a claim lowers the daily-loss floor by the amount claimed. Winning $1,500 and claiming it leaves $3,000 of room the same day, the full drawdown. The bot now uses that. - Payout claims: a scheduler step (runClaims, under the lane mutex, before the flat gate) claims every payoutable account that reached its stage's target, via a platform that takes payouts by API. The claim is all profit above the starting balance. It then clears the account's legs and entry outcome for the day so it can trade its next stage. Failures retry after 5 minutes. The auto_claim setting 'off' disables it. - VestClient.claimProfit: POST /v3/capital/withdraw to the login's main account (account_type 1 on /v3/accounts). The payout is booked at once: today's fund row and the cycle cut, with today's P&L counted from the post-claim balance. The history read later rebooks it from Vest's WITHDRAW row, and the books check counts a claim until that row shows. - Same-day stages: an account type can pick its stage by the payouts since the firm's last daily reset (stagesSameDay, sameDayClaims). A stage can carry its own daily loss limit. - That limit replaces the type's everywhere room, stops and last stands are worked out: the batch, the manual leg, copy trade, the dashboard and the report. - Room from the platform's own floor: dailyLossFloor (Vest) makes the room min(stage limit, balance − Vest's floor). A same-day claim that lowers the floor therefore frees room, and the room is never more than Vest allows. VestClient.dayStartBalance is gone. - Before the 8 PM ET reset: from 7:55 PM ET, each open Vest position on an account with a daily limit is fitted to the room the reset will leave (resetSafeStop). Its stop comes in via PUT/POST stop-loss, or the position closes when there is no room. - Checks for same-day stages, the stage limit, floorRoom, the post-claim plan, the next reset across both time changes, and resetSafeStop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
408 lines
20 KiB
TypeScript
408 lines
20 KiB
TypeScript
/**
|
||
* Kept rules — the old rule sets a firm's account type keeps for its older
|
||
* accounts.
|
||
*
|
||
* When the owner changes an account type's rules on the master's Firms page
|
||
* and picks "New accounts only", the master freezes the old rules as a kept
|
||
* rule set with a window [openedAt, closedAt). Every account here carries a
|
||
* rules date (account_assignments.rules_date): an account whose date falls
|
||
* inside a window trades its type's live fields with that set's RULE fields
|
||
* on top; every other account trades the current rules. The account keeps
|
||
* reporting the live type's id — a kept set is never an account type.
|
||
*
|
||
* Pure (only `import type`), so scripts/check-kept-rules.mjs loads it
|
||
* directly. The field lists must stay identical to the master's
|
||
* lib/kept-rules.ts.
|
||
*/
|
||
|
||
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
|
||
|
||
/** The fields a kept rule set freezes: what the bot trades by. Changing one
|
||
* is what asks the owner "new accounts only, or all accounts?". */
|
||
export const RULE_FIELDS = [
|
||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss',
|
||
'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop',
|
||
'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||
] as const satisfies readonly (keyof CatalogAccountType)[];
|
||
|
||
/** Always the live type's: accounts are matched to a type by them, so a kept
|
||
* set can't hold different ones (the master refuses "new accounts only"
|
||
* when either changes). */
|
||
export const IDENTITY_FIELDS = ['prefix', 'accountSize'] as const satisfies readonly (keyof CatalogAccountType)[];
|
||
|
||
/** The payout calendar's projection inputs: always the live type's, never
|
||
* frozen. (Per-stage withdrawalPct / payoutCap ride inside withdrawalStages,
|
||
* so they are rules.) */
|
||
export const PROJECTION_FIELDS = [
|
||
'expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit',
|
||
] as const satisfies readonly (keyof CatalogAccountType)[];
|
||
|
||
export type RuleField = (typeof RULE_FIELDS)[number];
|
||
|
||
/** Which kept rule set an account trades on. */
|
||
export interface KeptRulesWindow {
|
||
id: string;
|
||
openedAt: string | null;
|
||
closedAt: string;
|
||
}
|
||
|
||
/** The rules an account trades on: its live account type, or — when its
|
||
* rules date falls in a kept set — the live type with that set's rules on
|
||
* top, marked with `keptRules`. */
|
||
export type EffectiveAccountType = CatalogAccountType & { keptRules?: KeptRulesWindow };
|
||
|
||
// ── Catalog parsing ──────────────────────────────────────────────────────────
|
||
|
||
/** Same as lib/firm-catalog.ts's: a positive finite number, else 0. */
|
||
function nonNegative(value: unknown): number {
|
||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||
}
|
||
|
||
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||
return Array.isArray(value) && value.every((s) =>
|
||
typeof s === 'object' && s !== null
|
||
&& typeof (s as Record<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),
|
||
// Lenient: stages count every payout unless the master says otherwise.
|
||
stagesSameDay: type.stagesSameDay === true,
|
||
// The firm's calendar, stamped on every type so the cfg-based
|
||
// paths (projections, completion, the dashboard) all see it.
|
||
tradesEveryDay,
|
||
} as unknown as CatalogAccountType;
|
||
}
|
||
|
||
// ── Which rules an account trades on ─────────────────────────────────────────
|
||
|
||
/**
|
||
* The kept set whose window holds the rules date: openedAt (null = no lower
|
||
* bound) <= date < closedAt — an account dated exactly at a change's cutoff
|
||
* is on the newer rules. null when no window holds it, when the account has
|
||
* no rules date, or when the date can't be read: the current rules.
|
||
*/
|
||
export function pickKeptRules<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 } : {}),
|
||
...(typeof s.dailyLossLimit === 'number' ? { dailyLossLimit: s.dailyLossLimit } : {}),
|
||
};
|
||
}
|
||
|
||
/** A rule field's value as the bot reads it, for comparing: an absent
|
||
* optional flag or amount is its default. */
|
||
function comparable(field: RuleField, value: unknown): unknown {
|
||
switch (field) {
|
||
case 'withdrawalStages': return Array.isArray(value) ? value.map(normalizeStage) : value;
|
||
case 'splitEnabled': return value !== false;
|
||
case 'targetSameEquity':
|
||
case 'firstDayStop':
|
||
case 'minDayStop':
|
||
case 'combineCmeHolidays':
|
||
case 'stagesSameDay':
|
||
case 'payoutable': return value === true;
|
||
case 'minLegs':
|
||
case 'trailingDrawdown':
|
||
case 'dailyLossLimit': return typeof value === 'number' ? value : 0;
|
||
default: return value;
|
||
}
|
||
}
|
||
|
||
/** JSON with every object's keys sorted, so key order never reads as a change. */
|
||
function canonical(value: unknown): string {
|
||
return JSON.stringify(value, (_key, v: unknown) => (
|
||
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||
? Object.fromEntries(Object.entries(v as Record<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);
|
||
}
|