From f07721019bc763ee384c511e36abf82ac8d96855 Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 8 Sep 2026 21:12:37 -0500 Subject: [PATCH] Count CME holiday sessions as one combined trading day when the type opts in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/accounts/[id]/daily-pnl/route.ts | 7 +- app/api/firms/route.ts | 1 + app/api/state/route.ts | 37 +++++-- lib/auto-trade.ts | 31 ++++-- lib/firm-catalog.ts | 9 +- lib/master-protocol.ts | 4 + lib/reporter.ts | 20 ++-- lib/trading-logic.ts | 133 +++++++++++++++++++++++ types.ts | 1 + 9 files changed, 212 insertions(+), 31 deletions(-) diff --git a/app/api/accounts/[id]/daily-pnl/route.ts b/app/api/accounts/[id]/daily-pnl/route.ts index 369bdfd..cbc21a3 100644 --- a/app/api/accounts/[id]/daily-pnl/route.ts +++ b/app/api/accounts/[id]/daily-pnl/route.ts @@ -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); } } diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index cef3cb0..2972a2c 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -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, diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 0ea099d..0eb6fe8 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -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, diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index dc14550..f6882ec 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -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 }; diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index 22296d3..d1a5f1c 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -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 { diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 52a19b2..b41aef5 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -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; diff --git a/lib/reporter.ts b/lib/reporter.ts index 7001c6e..766b924 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -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) { diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 5e9e4c9..123f68f 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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>(); + +/** + * 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 { + 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(); + 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, diff --git a/types.ts b/types.ts index 1b11794..5b203ee 100644 --- a/types.ts +++ b/types.ts @@ -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) }