Count CME holiday sessions as one combined trading day when the type opts in
Account types with the catalog's new combineCmeHolidays flag are read through a combined-day view: holiday-session P&L entries fold into the next full trading day (computed from the federal-holiday rules, so any year works), the day count matches how the firm records it (Apex: Sun/ Mon/Tue around a Monday holiday is ONE day), and on the later sessions of the stretch the earlier sessions' booked P&L carries into realized — so day targets, already-traded gates, target-hit status and the panel's day P&L all treat the stretch as a single day. Off by default; older masters that don't send the flag behave exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
57846d2b5f
commit
f07721019b
+21
-10
@@ -12,7 +12,7 @@ import { getClients } from './clients';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import {
|
||||
computeDayPlan, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||||
combineCmeSessions, computeDayPlan, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||||
LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
|
||||
type DayPlan,
|
||||
} from './trading-logic';
|
||||
@@ -62,8 +62,18 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
||||
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
|
||||
|
||||
// A combining firm records holiday sessions with the next full trading day,
|
||||
// so the plan and the already-traded gates below must see the whole stretch
|
||||
// as one day: Sunday's booked P&L folds into Tuesday's realized, and the
|
||||
// day target stays anchored to the combined (clearing) trade date. `cash`
|
||||
// itself stays raw — the commission delta in the fire path compares live
|
||||
// Tradovate values.
|
||||
const combined = combineCmeSessions(
|
||||
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL,
|
||||
);
|
||||
const dailyPnL = combined.dailyPnL;
|
||||
const daysTraded = combined.daysTraded;
|
||||
|
||||
const base = { plan: null, isExtraDay: false, cash, dailyPnL, daysTraded };
|
||||
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
|
||||
@@ -78,8 +88,8 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
|
||||
const equityProfit = cash.amount - cfg.accountSize;
|
||||
const plan = computeDayPlan(
|
||||
acc.id, tradeDate, effective.profitTarget, effective.consistency,
|
||||
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL,
|
||||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||||
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
||||
cfg.splitEnabled
|
||||
);
|
||||
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
|
||||
@@ -92,9 +102,10 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded };
|
||||
|
||||
if (isExtraDay || !plan.splitAllowed) {
|
||||
// Legacy one-and-done gate: any realized PnL today means this account is finished.
|
||||
if (cash.realizedPnL !== 0) {
|
||||
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${cash.realizedPnL})` };
|
||||
// Legacy one-and-done gate: any realized PnL today means this account is
|
||||
// finished — for a combining account, "today" spans the holiday stretch.
|
||||
if (combined.realizedPnL !== 0) {
|
||||
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
|
||||
}
|
||||
if (!isExtraDay && plan.remainingToday <= 0) {
|
||||
return { ...evaluated, eligible: false, reason: `target=$${plan.dayTarget} (challenge complete)` };
|
||||
@@ -102,9 +113,9 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
||||
} else {
|
||||
// Split-leg mode: keep trading until the day's remaining target is used up.
|
||||
// The epsilon only applies once a leg has completed, so tiny day-start targets still trade.
|
||||
const doneThreshold = cash.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
|
||||
const doneThreshold = combined.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
|
||||
if (plan.remainingToday <= doneThreshold) {
|
||||
const reason = cash.realizedPnL === 0
|
||||
const reason = combined.realizedPnL === 0
|
||||
? `target=$${plan.dayTarget} (challenge complete)`
|
||||
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
|
||||
return { ...evaluated, eligible: false, reason };
|
||||
|
||||
+7
-2
@@ -155,8 +155,13 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
}
|
||||
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.
|
||||
return { ...type, splitEnabled: type.splitEnabled !== false } as unknown as CatalogAccountType;
|
||||
// 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,
|
||||
combineCmeHolidays: type.combineCmeHolidays === true,
|
||||
} as unknown as CatalogAccountType;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -86,6 +86,10 @@ export interface CatalogAccountType {
|
||||
targetSameEquity: boolean;
|
||||
/** False = big days trade as one unsplit leg instead of random ≥$800 pieces. */
|
||||
splitEnabled: boolean;
|
||||
/** True = CME holiday sessions count with the next full trading day, the way
|
||||
* firms that follow CME clearing report them (e.g. Apex: Sun/Mon/Tue around
|
||||
* a Monday holiday is ONE trading day, recorded on the Tuesday). */
|
||||
combineCmeHolidays: boolean;
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
|
||||
/** ISO. Ties between two matching account types are broken by this. */
|
||||
updatedAt: string;
|
||||
|
||||
+12
-8
@@ -17,7 +17,7 @@ import { getClients } from './clients';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON } from './trading-logic';
|
||||
import { combineCmeSessions, computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON } from './trading-logic';
|
||||
import type { AccountTypeStats } from './master-protocol';
|
||||
|
||||
function collectFirmStats() {
|
||||
@@ -90,23 +90,27 @@ function collectFirmStats() {
|
||||
|
||||
// Check if target was hit today
|
||||
if (cfg) {
|
||||
const dailyPnL = client.dailyPnL[acc.id] ?? [];
|
||||
// Same combined-day view as the trade path: for a combining
|
||||
// firm, "today" spans the holiday stretch.
|
||||
const combined = combineCmeSessions(
|
||||
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], currentTradeDateCT(), cash.realizedPnL,
|
||||
);
|
||||
const equityProfit = cash.amount - cfg.accountSize;
|
||||
const plan = computeDayPlan(
|
||||
acc.id, currentTradeDateCT(), cfg.profitTarget, cfg.consistency,
|
||||
dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, cash.realizedPnL,
|
||||
acc.id, combined.tradeDate, cfg.profitTarget, cfg.consistency,
|
||||
combined.dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, combined.realizedPnL,
|
||||
cfg.splitEnabled
|
||||
);
|
||||
const targetHit = !plan ? false :
|
||||
((plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) ||
|
||||
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
|
||||
((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= cfg.minTradingDays) ||
|
||||
(combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
|
||||
|
||||
if (targetHit || cash.realizedPnL !== 0) {
|
||||
if (targetHit || combined.realizedPnL !== 0) {
|
||||
accountsTraded++;
|
||||
slice.accountsTraded++;
|
||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||
// negative day — a loss is still a loss, so red wins.
|
||||
if (cash.realizedPnL < 0) {
|
||||
if (combined.realizedPnL < 0) {
|
||||
lossCount++;
|
||||
slice.loss = (slice.loss ?? 0) + 1;
|
||||
} else if (targetHit) {
|
||||
|
||||
@@ -105,6 +105,139 @@ export function pickLegAmount(remainingToday: number, rand: () => number = Math.
|
||||
return Math.min(Math.max(leg, lo), hi);
|
||||
}
|
||||
|
||||
// ── CME holiday session combining ────────────────────────────────────────────
|
||||
|
||||
const fmtUTC = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
/** The nth given weekday of a month, e.g. (2026, 8, 1, 1) = first Monday of September. */
|
||||
function nthWeekday(year: number, month: number, weekday: number, n: number): string {
|
||||
const first = new Date(Date.UTC(year, month, 1));
|
||||
const offset = (weekday - first.getUTCDay() + 7) % 7;
|
||||
return fmtUTC(new Date(Date.UTC(year, month, 1 + offset + (n - 1) * 7)));
|
||||
}
|
||||
|
||||
/** The last given weekday of a month, e.g. (2026, 4, 1) = last Monday of May. */
|
||||
function lastWeekday(year: number, month: number, weekday: number): string {
|
||||
const last = new Date(Date.UTC(year, month + 1, 0));
|
||||
const offset = (last.getUTCDay() - weekday + 7) % 7;
|
||||
return fmtUTC(new Date(Date.UTC(year, month + 1, 0 - offset)));
|
||||
}
|
||||
|
||||
/** A fixed-date holiday as observed: Saturday moves to Friday, Sunday to Monday. */
|
||||
function observed(year: number, month: number, day: number): string {
|
||||
const d = new Date(Date.UTC(year, month, day));
|
||||
if (d.getUTCDay() === 6) d.setUTCDate(d.getUTCDate() - 1);
|
||||
else if (d.getUTCDay() === 0) d.setUTCDate(d.getUTCDate() + 1);
|
||||
return fmtUTC(d);
|
||||
}
|
||||
|
||||
const holidayCache = new Map<number, Set<string>>();
|
||||
|
||||
/**
|
||||
* Trade dates in the given year whose CME session does not clear on its own:
|
||||
* the exchange runs a shortened holiday session (opened the prior evening) and
|
||||
* clearing folds it into the next full trading day. Firms that follow CME
|
||||
* reporting count the whole stretch as ONE trading day — e.g. Apex on Labor
|
||||
* Day 2026: "Sunday, Monday, and Tuesday trading activity will be combined
|
||||
* and recorded as a single trading day on Tuesday, September 8."
|
||||
*
|
||||
* Computed from the federal-holiday rules rather than a hand-kept table so
|
||||
* every year works. Full-closure days (Good Friday, Christmas, New Year's
|
||||
* Day) have no session and produce no P&L rows, so they aren't included.
|
||||
*/
|
||||
function cmeUnsettledDates(year: number): Set<string> {
|
||||
let dates = holidayCache.get(year);
|
||||
if (!dates) {
|
||||
dates = new Set([
|
||||
nthWeekday(year, 0, 1, 3), // MLK Day — 3rd Monday of January
|
||||
nthWeekday(year, 1, 1, 3), // Presidents' Day — 3rd Monday of February
|
||||
lastWeekday(year, 4, 1), // Memorial Day — last Monday of May
|
||||
observed(year, 5, 19), // Juneteenth
|
||||
observed(year, 6, 4), // Independence Day
|
||||
nthWeekday(year, 8, 1, 1), // Labor Day — 1st Monday of September
|
||||
nthWeekday(year, 10, 4, 4), // Thanksgiving — 4th Thursday of November
|
||||
]);
|
||||
holidayCache.set(year, dates);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function isUnsettledTradeDate(date: string): boolean {
|
||||
return cmeUnsettledDates(Number(date.slice(0, 4))).has(date);
|
||||
}
|
||||
|
||||
/** The trade date a session clears under: holiday sessions roll forward, past
|
||||
* weekends and any consecutive holiday, to the next date that settles. */
|
||||
export function combinedTradeDate(date: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !isUnsettledTradeDate(date)) return date;
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
const next = fmtUTC(d);
|
||||
const dow = d.getUTCDay();
|
||||
if (dow === 0 || dow === 6) continue; // weekends never settle
|
||||
if (!isUnsettledTradeDate(next)) return next;
|
||||
}
|
||||
return date; // unreachable: no holiday stretch spans a week
|
||||
}
|
||||
|
||||
export interface CombinedSessionView {
|
||||
/** Daily P&L with holiday-session entries folded into their clearing date. */
|
||||
dailyPnL: { date: string; pnl: number }[];
|
||||
/** The trade date mapped to its clearing date. */
|
||||
tradeDate: string;
|
||||
/** Live realizedPnL plus P&L already booked on earlier sessions of the same combined day. */
|
||||
realizedPnL: number;
|
||||
/** Trading days as the firm counts them (nonzero combined entries). */
|
||||
daysTraded: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's day-based state as its firm records it. With `enabled` false
|
||||
* this is the identity view, so callers can apply it unconditionally.
|
||||
*
|
||||
* On the later sessions of a combined day (e.g. the Tuesday after a Monday
|
||||
* holiday), Tradovate's live realizedPnL covers only the current session; the
|
||||
* earlier sessions' booked P&L is added back so day targets, already-traded
|
||||
* gates and the day's displayed P&L all treat the stretch as one day. Entries
|
||||
* dated `tradeDate` itself are never added — computeDayPlan excludes that row
|
||||
* and live realizedPnL already covers it.
|
||||
*/
|
||||
export function combineCmeSessions(
|
||||
enabled: boolean,
|
||||
dailyPnL: { date: string; pnl: number }[],
|
||||
tradeDate: string,
|
||||
realizedPnL: number,
|
||||
): CombinedSessionView {
|
||||
if (!enabled) {
|
||||
return {
|
||||
dailyPnL, tradeDate, realizedPnL,
|
||||
daysTraded: dailyPnL.filter((d) => d.pnl !== 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
const merged = new Map<string, number>();
|
||||
for (const entry of dailyPnL) {
|
||||
const date = combinedTradeDate(entry.date);
|
||||
merged.set(date, round2((merged.get(date) ?? 0) + entry.pnl));
|
||||
}
|
||||
const combined = [...merged.entries()]
|
||||
.map(([date, pnl]) => ({ date, pnl }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
const effectiveTradeDate = combinedTradeDate(tradeDate);
|
||||
const carry = dailyPnL
|
||||
.filter((d) => d.date !== tradeDate && combinedTradeDate(d.date) === effectiveTradeDate)
|
||||
.reduce((sum, d) => sum + d.pnl, 0);
|
||||
|
||||
return {
|
||||
dailyPnL: combined,
|
||||
tradeDate: effectiveTradeDate,
|
||||
realizedPnL: round2(realizedPnL + carry),
|
||||
daysTraded: combined.filter((d) => d.pnl !== 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dollar-per-point value for common futures products. */
|
||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||
|
||||
Reference in New Issue
Block a user