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
+6 -1
View File
@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { combineCmeSessions, currentTradeDateCT } from '@/lib/trading-logic';
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -9,7 +11,10 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
for (const client of clients.values()) {
const daily = client.dailyPnL?.[accountId];
if (daily !== undefined) {
return NextResponse.json(daily);
// Serve the same combined-day view the rest of the app computes
// with, so a combining firm's holiday stretch shows as one row.
const combine = getAccountConfig(accountId).accountType?.combineCmeHolidays === true;
return NextResponse.json(combineCmeSessions(combine, daily, currentTradeDateCT(), 0).dailyPnL);
}
}
+1
View File
@@ -37,6 +37,7 @@ export async function GET() {
maxPositionSize: a.maxPositionSize,
targetSameEquity: a.targetSameEquity,
splitEnabled: a.splitEnabled,
combineCmeHolidays: a.combineCmeHolidays,
withdrawalStages: a.withdrawalStages,
})),
catalogUpdatedAt: firm.updatedAt,
+27 -10
View File
@@ -3,7 +3,7 @@ import { loadDailyPnL } from '@/lib/db';
import { getClients } from '@/lib/clients';
import { getCachedCatalog } from '@/lib/firm-catalog';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
import { computeDayPlan, resolveEffectiveConfig, currentTradeDateCT, LEG_COMPLETION_EPSILON } from '@/lib/trading-logic';
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT, LEG_COMPLETION_EPSILON } from '@/lib/trading-logic';
export async function GET() {
try {
@@ -22,8 +22,6 @@ export async function GET() {
}
const accounts = client.accountList.map((acc) => {
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
// One lookup against the assignment table — no prefix matching
// here any more. An unassigned account gets no targets, which
@@ -31,11 +29,23 @@ export async function GET() {
const resolved = getAccountConfig(acc.id);
const cfg = resolved.accountType;
const inferredAccountSize = getInferredSize(acc.id, client);
// A combining firm records holiday sessions with the next full
// trading day — everything below reads the account through that
// view (the identity view when the account type doesn't combine).
const combined = combineCmeSessions(
cfg?.combineCmeHolidays === true,
client.dailyPnL[acc.id] ?? [],
tradeDate,
cash.realizedPnL,
);
const dailyPnL = combined.dailyPnL;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
// Days traded counts only days that hit minDayPnL (when set) — that's what
// the firm requires toward the min trading day rule.
const daysTraded: number = cfg && cfg.minDayPnL > 0
? dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL).length
: (client.daysTraded[acc.id] ?? 0);
: combined.daysTraded;
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
@@ -68,8 +78,8 @@ export async function GET() {
);
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
);
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
@@ -87,8 +97,8 @@ export async function GET() {
// Condition 2: target > 0 → all legs done (day's remaining target used up)
if (plan) {
targetHit =
(plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON);
(plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.minTradingDays) ||
(combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON);
}
}
@@ -97,7 +107,9 @@ export async function GET() {
name: acc.name,
active: acc.active,
amount: cash.amount,
realizedPnL: cash.realizedPnL,
// The combined day's P&L, not just the current session's —
// on the Tuesday after a holiday this includes Sun/Mon.
realizedPnL: combined.realizedPnL,
daysTraded,
positionDirection: client.positions[acc.id]
? (client.positions[acc.id].netPos > 0 ? 'long' as const : 'short' as const)
@@ -110,7 +122,12 @@ export async function GET() {
remainingToday,
effectiveProfitTarget,
dailyPnL,
fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
fullDailyPnL: combineCmeSessions(
cfg?.combineCmeHolidays === true,
client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
tradeDate,
0,
).dailyPnL,
fundTransactions: displayFundTxns,
accountTypeId: resolved.accountTypeId,
accountTypeLabel: cfg?.label ?? null,
+21 -10
View File
@@ -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
View File
@@ -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 {
+4
View File
@@ -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
View File
@@ -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) {
+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,
+1
View File
@@ -12,6 +12,7 @@ export interface AccountConfig {
maxPositionSize: number; // 0 = no limit; positive = max contracts per trade
targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal
splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$800 pieces
combineCmeHolidays: boolean; // true = CME holiday sessions count with the next full trading day (Apex-style)
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[]; // per-stage fresh profit + consistency (empty = use base values)
}