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:
Brandon Li
2026-09-08 21:12:37 -05:00
co-authored by Claude Fable 5
parent 57846d2b5f
commit f07721019b
9 changed files with 212 additions and 31 deletions
+133
View File
@@ -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,