Files
autofirmer-expanded/lib/trading-logic.ts
T
Brandon LiandClaude Fable 5 9597db08f7 Anchor payouts to the trading day the cycle finishes, not the report day
A completed cycle's payout now lands on its last traded day (requestable
after that session's close) instead of whatever day the projection ran —
a Friday finish viewed over the weekend stayed pinned to Sunday. Also
stop inserting the platform's stale weekend realizedPnL as a Sat/Sun
P&L row, which fabricated an extra qualifying day and made accounts one
day short look already complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 14:39:14 -05:00

806 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export interface WithdrawalStage {
profit: number;
consistency: number;
minTradingDays: number;
/** Spam-withdraw stage: no profit target — book minTradingDays qualifying
* days of ~minDayPnL (tiny MNQ trades), then wait for the withdrawal. */
drain?: boolean;
/** Drain stages only: the per-day booked amount (default $5; randomized
* the fixed $4–$15 half-dollar grid by drainDayAmount; this field is
* kept for the wire format but no longer drives the amount). */
minDayPnL?: number;
/** Payout overrides for the withdrawal that completes THIS stage; absent
* fields fall back to the account type's defaults. */
withdrawalPct?: number;
payoutCap?: number;
}
export interface EffectiveConfig {
profitTarget: number;
consistency: number;
minTradingDays: number;
minDayPnL: number;
drain: boolean;
}
/**
* Resolve the effective profit target and consistency for an account based on its withdrawal strategy.
*
* Mode A (targetSameEquity=true): the account must reach the same cumulative equity level.
* remainingProfit = priorProfit + totalWithdrawals (profit still in the account after payouts)
* effectiveProfitTarget = profitTarget − remainingProfit
*
* Mode B (withdrawalStages non-empty): Stage 1 (no withdrawals yet) uses base profitTarget/consistency.
* After the Nth withdrawal, use withdrawalStages[N-1]; last stage repeats. A drain stage carries no
* profit target — it resolves to target 0 with drain=true and its own min-day amount.
* Fallback: returns base values unchanged.
*/
export function resolveEffectiveConfig(
profitTarget: number,
consistency: number,
minTradingDays: number,
targetSameEquity: boolean,
withdrawalStages: WithdrawalStage[],
priorProfit: number,
fundTransactions: { date: string; amount: number }[],
minDayPnL = 0,
): EffectiveConfig {
if (targetSameEquity) {
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
// no adjustment needed here — just return the base values.
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
}
if (withdrawalStages.length > 0) {
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
if (withdrawalCount === 0) {
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values
}
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
const stage = withdrawalStages[idx];
if (stage.drain === true) {
return {
profitTarget: 0,
consistency: 0,
minTradingDays: stage.minTradingDays || minTradingDays,
// Drain amounts come from the fixed $4–$15 grid, so the
// qualifying bar sits at the grid floor — every draw counts.
minDayPnL: DRAIN_DAY_MIN,
drain: true,
};
}
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, minDayPnL, drain: false };
}
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
}
/** @deprecated Use resolveEffectiveConfig instead */
export function resolveEffectiveProfitTarget(
profitTarget: number,
targetSameEquity: boolean,
withdrawalStages: WithdrawalStage[],
priorProfit: number,
fundTransactions: { date: string; amount: number }[]
): number {
return resolveEffectiveConfig(profitTarget, 0, 0, targetSameEquity, withdrawalStages, priorProfit, fundTransactions).profitTarget;
}
// ── randomized targets / split legs ──────────────────────────────────────────
/** Split a day's target into multiple legs when it exceeds this. */
export const BIG_DAY_THRESHOLD = 1700;
/** Every split leg must be at least this. */
export const MIN_LEG = 500;
/** After at least one trade, a residual at or below this counts as "day done". */
export const LEG_COMPLETION_EPSILON = 25;
/** firstDayStop: the first cycle day books at this loss and stops. */
export const FIRST_DAY_STOP = 1000;
/** minDayStop: a min-day trade's stop sits at this multiple of its target. */
export const MIN_DAY_STOP_MULT = 2;
/** Safety valve: never fire more than this many legs per account per trade date. */
export const MAX_LEGS_PER_DAY = 8;
const round2 = (n: number) => Math.round(n * 100) / 100;
/**
* Deterministic random bump in {0, 5, 10, 15, 20, 25} for an (account, trade date) pair.
* Seeded (FNV-1a) rather than Math.random() so the dashboard, reporter, and trade path —
* which each compute the target independently — always agree on the same number.
*/
export function dailyBump(accountId: number, tradeDate: string): number {
const key = `${accountId}|${tradeDate}`;
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return (hash % 6) * 5;
}
/**
* Current Tradovate trade date as YYYY-MM-DD: the Chicago calendar date, rolled to the
* next day once the evening session opens (≥ 5:00 PM CT) — matching when Tradovate
* resets realizedPnL.
*/
export function currentTradeDateCT(now: Date = new Date()): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false,
}).formatToParts(now);
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0';
let date = Date.UTC(Number(get('year')), Number(get('month')) - 1, Number(get('day')));
if (Number(get('hour')) % 24 >= 17) date += 24 * 60 * 60 * 1000;
return new Date(date).toISOString().slice(0, 10);
}
/**
* Drain-day target: the stage's min-day amount plus a deterministic bump of
* $0–$10 in $0.50 steps — MNQ's per-contract tick value, so every possible
* target lands on a realizable exit ($5 min day → $5.00, $5.50, … $15.00
* depending on the (account, trade date) pair). Seeded like dailyBump so
* every consumer of the plan computes the same number.
*/
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values), always traded
* on MNQ. Drain days qualify at ≥ DRAIN_DAY_MIN, so every draw counts. */
export const DRAIN_DAY_MIN = 4;
export const DRAIN_DAY_MAX = 15;
const DRAIN_DAY_STEPS = Math.round((DRAIN_DAY_MAX - DRAIN_DAY_MIN) / 0.5) + 1;
export function drainDayAmount(accountId: number, tradeDate: string): number {
const key = `${accountId}|drain|${tradeDate}`;
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return round2(DRAIN_DAY_MIN + (hash % DRAIN_DAY_STEPS) * 0.5);
}
/**
* Deterministic fraction in [0, 1) for a (prefix group, trade date, leg index) triple.
* Like accounts (same account-type prefix) carving the same leg of the same day draw the
* same fraction, so pickLegAmount splits their targets identically when their remaining
* amounts match and proportionally close when they differ (bumps, epsilon leftovers).
*/
export function groupLegFraction(prefix: string, tradeDate: string, legIndex: number): number {
const key = `${prefix}|${tradeDate}|${legIndex}`;
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash / 0x100000000;
}
/**
* Pick the next leg to trade toward today's remaining target.
* remaining ≤ BIG_DAY_THRESHOLD → trade it all in one final leg; otherwise carve a random
* $5-rounded piece from [MIN_LEG, remaining − MIN_LEG] so the remainder also stays ≥ MIN_LEG.
*
* minLegsLeft (an account's min-legs setting minus the legs already fired
* today) forces a carve even below the big-day threshold and reserves MIN_LEG
* for each leg still owed after this one. It is clamped to what MIN_LEG-sized
* pieces make possible, so a small coast day is never forced into impossible
* splits.
*/
export function pickLegAmount(remainingToday: number, rand: () => number = Math.random, minLegsLeft = 0): number {
const owedAfter = Math.min(minLegsLeft, Math.floor(remainingToday / MIN_LEG)) - 1;
if (owedAfter > 0) {
const lo = MIN_LEG;
const hi = remainingToday - owedAfter * MIN_LEG;
if (hi <= lo) return lo;
const leg = Math.round((lo + rand() * (hi - lo)) / 5) * 5;
return Math.min(Math.max(leg, lo), hi);
}
if (remainingToday <= BIG_DAY_THRESHOLD) return round2(remainingToday);
const lo = MIN_LEG;
const hi = remainingToday - MIN_LEG;
const leg = Math.round((lo + rand() * (hi - lo)) / 5) * 5;
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;
}
/** Weekends never clear either — a weekend-dated Cash History row (Sunday
* evening activity attributed to its calendar date) belongs to the next
* settling day, which is precisely why Apex's holiday notices say "Sunday,
* Monday, and Tuesday" and not just the holiday itself. */
function isUnsettledTradeDate(date: string, dayOfWeek: number): boolean {
if (dayOfWeek === 0 || dayOfWeek === 6) return true;
return cmeUnsettledDates(Number(date.slice(0, 4))).has(date);
}
/** The trade date a session clears under: weekend- and holiday-dated activity
* rolls forward, through any consecutive unsettled dates, to the next date
* that settles. */
export function combinedTradeDate(date: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return date;
const d = new Date(`${date}T00:00:00Z`);
if (!isUnsettledTradeDate(date, d.getUTCDay())) return date;
for (let i = 0; i < 7; i++) {
d.setUTCDate(d.getUTCDate() + 1);
const next = fmtUTC(d);
if (!isUnsettledTradeDate(next, d.getUTCDay())) return next;
}
return date; // unreachable: no unsettled 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,
};
}
// ── perfect-path projection ──────────────────────────────────────────────────
export interface ProjectedDay {
date: string;
/** The day's booked P&L on the perfect path (0 on withdrawal/payout days). */
pnl: number;
/** Stage the account is on while trading this day. */
stage: number;
drain: boolean;
/** A simulated withdrawal (negative) lands on this day — cycle resets after it. */
withdrawal?: number;
/** Non-staged account completed here; the projection stops. */
payoutReady?: boolean;
/** Cumulative probability of the path having survived through this day,
* under the fair-walk ruin model: each trading day survives with
* room/(room + dayTarget), where room is the distance to the trailing
* liquidation threshold (the per-leg product telescopes to exactly
* this). 1 when no drawdown applies. */
prob: number;
/** Withdrawal entries: gross amount leaving the account
* (min(withdrawalPct × balance, cap)) and the net the person receives
* (gross × profitSplit). */
gross?: number;
net?: number;
}
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
function nextTradingDate(d: string): string {
const dt = new Date(`${d}T00:00:00Z`);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
return dt.toISOString().slice(0, 10);
}
/**
* Simulate the account's future assuming every day goes perfectly: each
* trading day books exactly its planned target (deterministic bumps included,
* so the numbers match what the trader would actually aim for), stages
* complete, a 50% withdrawal fires the day a cycle finishes, and drain cycles
* repeat. Built on the same computeDayPlan/resolveEffectiveConfig the live
* trade path uses — the projection can't drift from reality's rules.
*
* An account without withdrawal stages stops at its first completion, marked
* payoutReady. Projection starts on the next trading day (today's cell shows
* live data); today's remaining target is booked silently first.
*/
export function projectPerfectPath(opts: {
accountId: number;
cfg: {
profitTarget: number; consistency: number; minDayPnL: number;
minTradingDays: number; accountSize: number; targetSameEquity: boolean;
withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
/** True = completing a cycle pays out and the account keeps going
* (withdraw, reset, re-target). False = it just PASSES once (an
* eval) and the projection stops. */
payoutable?: boolean;
};
/** Current-cycle daily P&L (account.dailyPnL). */
cycleDailyPnL: { date: string; pnl: number }[];
fundTransactions: { date: string; amount: number }[];
balance: number;
realizedPnL: number;
/** Trading days to look ahead. */
horizonDays?: number;
maxWithdrawals?: number;
/** Payout + risk model. Absent fields fall back to: withdraw 50%,
* uncapped, 90% split, no drawdown (prob 1). */
payout?: {
withdrawalPct?: number;
payoutCap?: number;
profitSplit?: number;
/** Trailing drawdown in dollars for the ruin probabilities. */
drawdown?: number;
/** Current cycle's end-of-day equity-profit high-water mark. */
startHigh?: number;
/** The account's REAL current liquidation floor on the equity-profit
* scale (platform floor − account size), when the platform reports
* one (Tradovate auto-liq). The modeled trail never sits below it,
* so today's room is exact and only tightens going forward. */
startThreshold?: number;
};
}): ProjectedDay[] {
const { accountId, cfg } = opts;
const horizon = opts.horizonDays ?? 40;
const maxWithdrawals = opts.maxWithdrawals ?? 6;
const basePct = opts.payout?.withdrawalPct ?? 0.5;
const baseCap = opts.payout?.payoutCap ?? 0;
const profitSplit = opts.payout?.profitSplit ?? 0.9;
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
const today = currentTradeDateCT();
// A weekend "today" (Saturday, or Sunday before the evening open) has no
// session — the platform's realizedPnL is the LAST session's stale number,
// already booked in cycleDailyPnL under its own date. Adding it as a
// weekend row would fabricate an extra qualifying day.
const todayDow = new Date(`${today}T00:00:00Z`).getUTCDay();
const todayHasSession = todayDow !== 0 && todayDow !== 6;
let sim = opts.cycleDailyPnL.filter((d) => d.date !== today);
if (todayHasSession && opts.realizedPnL !== 0) sim = [...sim, { date: today, pnl: opts.realizedPnL }];
else sim = [...sim];
const funds = [...opts.fundTransactions];
let balance = opts.balance;
let withdrawals = 0;
const out: ProjectedDay[] = [];
// Fair-walk ruin model, mirroring the master's payout sim: the trailing
// liquidation threshold follows the end-of-day equity-profit high until
// profit reaches the drawdown, then locks at breakeven — and a past
// withdrawal means it locked long ago. Within a day the per-leg odds
// telescope, so a whole day survives with exactly room/(room + target).
let high = Math.max(0, opts.payout?.startHigh ?? 0, balance - cfg.accountSize);
if (funds.some((f) => f.amount < 0)) high = Math.max(high, dd);
// The platform's real floor (when reported) is the baseline the modeled
// trail can never sit below — a floor that already trailed higher than
// our history explains stays exact.
const startThreshold = opts.payout?.startThreshold;
let prob = 1;
const dayProb = (dayTarget: number): number => {
if (dayTarget <= 0) return 1;
if (dd <= 0 && startThreshold === undefined) return 1;
const modeled = dd > 0 ? (high >= dd ? 0 : high - dd) : -Infinity;
const threshold = Math.max(modeled, startThreshold ?? -Infinity);
const room = (balance - cfg.accountSize) - threshold;
if (room <= 0) return 0;
return room / (room + dayTarget);
};
const effectiveNow = () => resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
);
/** The payout parameters of the stage currently being traded: per-stage
* overrides when set, else the type defaults. */
const stagePayout = (): { pct: number; cap: number } => {
const count = funds.filter((f) => f.amount < 0).length;
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
return { pct: stage.withdrawalPct ?? basePct, cap: stage.payoutCap ?? baseCap };
};
const qualifyingDays = (minDay: number, excludeDate: string) => (
minDay > 0
? sim.filter((d) => d.date !== excludeDate && d.pnl >= minDay).length
: sim.filter((d) => d.date !== excludeDate && d.pnl !== 0).length
);
// Book the rest of today silently — the perfect path finishes today too.
// Skipped when "today" is a weekend date (Saturday, or Friday evening
// after the roll): there is no session to finish.
if (todayHasSession) {
const eff = effectiveNow();
const plan = computeDayPlan(
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
);
if (plan && plan.dayTarget > 0 && plan.remainingToday > 0) {
prob *= dayProb(plan.remainingToday);
const idx = sim.findIndex((d) => d.date === today);
const pnl = round2(opts.realizedPnL + plan.remainingToday);
if (idx >= 0) sim[idx] = { date: today, pnl };
else sim.push({ date: today, pnl });
balance = round2(balance + plan.remainingToday);
high = Math.max(high, balance - cfg.accountSize);
}
}
// Fires the payout ON the given date — the trading day the cycle's final
// required day books (the last traded day, when the cycle already stands
// complete), the payout becomes requestable after that session. The withdrawal takes the completed stage's
// share of the EQUITY PROFIT (for a $0-start account that is the whole
// balance), capped. Prop payouts come out of profit, never the funded
// principal. Returns true when the projection should stop (no further
// stages).
// A cycle that completes keeps looping when the account has withdrawal
// stages OR is simply payoutable — a same-equity payout account (no
// explicit stages) withdraws its share and re-targets the same equity,
// over and over. Only a non-payoutable eval stops after one completion.
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
const { pct, cap } = stagePayout();
const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
const net = round2(gross * profitSplit);
if (loops && withdrawals < maxWithdrawals && gross > 1) {
funds.push({ date: onDate, amount: -gross });
balance = round2(balance - gross);
sim = []; // withdrawal excludes its day — fresh cycle
withdrawals++;
high = Math.max(high, dd); // a payout locks the trail at breakeven
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
return false;
}
out.push({ date: onDate, pnl: dayPnl, stage, drain, payoutReady: true, prob, gross, net });
return true;
};
// Already complete right now — target hit and min days done? The payout
// anchors to the trading day the cycle finished: the last traded day,
// requestable after that session's close. Never "today" — a projection
// computed over the weekend must not slide a Friday finish to Sunday.
{
const eff = effectiveNow();
const probe = nextTradingDate(today);
const plan = computeDayPlan(
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
);
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
const stage = 1 + funds.filter((f) => f.amount < 0).length;
const traded = sim.filter((d) => d.pnl !== 0);
const doneOn = traded.length > 0
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
: today;
if (finishCycle(doneOn, 0, stage, eff.drain)) return out;
}
}
let date = today;
for (let step = 0; step < horizon; step++) {
date = nextTradingDate(date);
const eff = effectiveNow();
const stage = 1 + funds.filter((f) => f.amount < 0).length;
const plan = computeDayPlan(
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
);
if (!plan) break;
const daysNeeded = eff.minTradingDays > qualifyingDays(eff.minDayPnL, date);
if (plan.dayTarget <= 0 && !daysNeeded) {
// The cycle completed mid-projection with nothing left to trade —
// the pending payout lands on this day.
if (finishCycle(date, 0, stage, eff.drain)) break;
continue;
}
// Extra days (min-day 0, days still owed) book a token dollar so the
// day counts, matching the real in-and-out MNQ trade — riskless.
const pnl = plan.dayTarget > 0 ? plan.remainingToday : 1;
if (plan.dayTarget > 0) prob *= dayProb(plan.remainingToday);
sim.push({ date, pnl });
balance = round2(balance + pnl);
high = Math.max(high, balance - cfg.accountSize);
// Did this day complete the cycle? Then it IS the payout day.
const nd = nextTradingDate(date);
const eff2 = effectiveNow();
const plan2 = computeDayPlan(
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
);
const doneAfter = plan2 !== null
&& plan2.dayTarget <= 0
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
if (doneAfter) {
if (finishCycle(date, pnl, stage, eff.drain)) break;
} else {
out.push({ date, pnl, stage, drain: eff.drain, prob });
}
}
return out;
}
/** Dollar-per-point value for common futures products. */
export const POINT_VALUES: { [symbol: string]: number } = {
NQ: 20, MNQ: 2, ES: 50, MES: 5,
YM: 5, MYM: 0.5, RTY: 50, M2K: 10,
GC: 100, MGC: 10, SI: 50, CL: 1000,
MCL: 100, NG: 10000, ZB: 1000, ZN: 1000,
ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500,
};
/**
* Compute the next trading day's profit target for an account.
*
* Path 1 – No positive trading days yet:
* target = profitTarget × consistency
*
* Path 2 – At least one positive day exists:
* maxDay = highest single-day P&L so far
* realTarget = maxDay / consistency (the total profit at which maxDay ≤ consistency% of total)
* needed = realTarget − totalProfit
*
* if needed > maxDay → target maxDay (still a long way from the real target; trade a normal day)
* else → target needed (close to the real target; aim for exactly what's left)
*
* Min-day reservation (only when minDayPnL > 0):
* When there are still mandatory trading days remaining, today's target is capped so that
* enough profit is reserved for each future mandatory day to meet minDayPnL.
* Cap = (profitTarget - totalProfit) − (remainingDaysAfterToday × minDayPnL)
* Floor = minDayPnL (we must make at least this today)
*/
export function computeDailyTarget(
profitTarget: number,
consistency: number,
dailyPnL: { date: string; pnl: number }[],
minDayPnL: number, // 0 = no minimum per day
minTradingDays: number, // 0 = no minimum trading days
equityProfit: number | null | undefined, // amount − accountSize. Null/undefined → skip (return null). 0 is valid.
bump: number = 0 // random extra target. No consistency rule: applied when the min-day floor binds. Strict consistency: first trade ever only.
): { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null {
// Don't run if we don't have a real account balance to compute against — 0 is valid.
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) {
return null;
}
const qualifyingDays = minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= minDayPnL);
const daysTraded = qualifyingDays.length;
const effectiveMinDay = Math.max(0, minDayPnL);
const tradingProfit = dailyPnL.reduce((s, d) => s + d.pnl, 0); // cycle-local trading profit
// Shortfall to hit profitTarget (measured in equity terms — accounts for deposits/withdrawals)
const equityShortfall = Math.max(0, profitTarget - equityProfit);
// Shortfall to satisfy consistency rule: total trading profit must be ≥ maxDay / consistency,
// otherwise the biggest day would exceed the consistency ratio.
let consistencyShortfall = 0;
if (consistency > 0 && consistency < 1 && qualifyingDays.length > 0) {
const maxDay = Math.max(...qualifyingDays.map((d) => d.pnl));
const realTarget = maxDay / consistency;
consistencyShortfall = Math.max(0, realTarget - tradingProfit);
}
const remaining = Math.max(equityShortfall, consistencyShortfall);
const noConsistencyRule = consistency === 0 || consistency >= 1;
// 1. Both target and consistency already satisfied — coast on min-day if mandatory days remain.
if (remaining <= 0) {
const needsMoreDays = minTradingDays > daysTraded;
let coastAmount = needsMoreDays ? effectiveMinDay : 0;
if (coastAmount > 0 && noConsistencyRule) coastAmount += bump;
return { amount: coastAmount, path: 'reduced_day' };
}
// 2. Min-day reservation: future mandatory days each reserve minDayPnL.
// cappedByFuture = remaining profit available after reserving.
const daysLeft = Math.max(1, minTradingDays - daysTraded); // includes today
const futureReserve = (daysLeft - 1) * effectiveMinDay;
const cappedByFuture = remaining - futureReserve;
// 3. Consistency cap.
// cycleStartEquity = equityProfit − tradingProfit (equity at the start of this cycle).
// cycleNetTarget = profitTarget − cycleStartEquity (cycle's net P&L needed to hit target).
// maxConsistencyDay = cycleNetTarget × consistency — the largest day the rule allows
// measured against the cycle's net total (not against profitTarget directly), so prior
// losses don't artificially expand the cap.
//
// Day 1: min(remaining × consistency, maxConsistencyDay).
// Day 2+: max(maxDay, maxConsistencyDay).
// - If maxDay ≤ maxConsistencyDay: room to grow days up to that ceiling.
// - If maxDay > maxConsistencyDay: consistency already broken at target, total must
// grow to at least maxDay / consistency. Each day can be up to maxDay (going higher
// creates a new maxDay requiring even more total).
let consistencyCap: number;
let path: 'first_day' | 'normal_day' | 'reduced_day';
if (noConsistencyRule) {
consistencyCap = Infinity;
path = daysTraded === 0 ? 'first_day' : 'reduced_day';
} else {
const cycleStartEquity = equityProfit - tradingProfit;
const cycleNetTarget = Math.max(0, profitTarget - cycleStartEquity);
const maxConsistencyDay = cycleNetTarget * consistency;
if (daysTraded === 0) {
consistencyCap = Math.min(remaining * consistency, maxConsistencyDay);
path = 'first_day';
} else {
const maxDay = Math.max(...qualifyingDays.map((d) => d.pnl));
consistencyCap = Math.max(maxDay, maxConsistencyDay);
path = 'normal_day';
}
}
// 4. Combine caps and apply min-day floor when mandatory days remain.
const raw = Math.min(consistencyCap, cappedByFuture);
const mustFloor = minTradingDays > daysTraded;
let amount = mustFloor ? Math.max(effectiveMinDay, raw) : Math.max(0, raw);
// 5. Random bump. Without a consistency rule, bump the days where the min-day floor is
// what set the target (big days get their randomness from leg splitting instead).
// With a strict consistency rule, only the first trade ever gets a bump — a slight
// overshoot there just raises consistencyShortfall, which later days self-correct.
if (bump > 0) {
if (noConsistencyRule) {
if (mustFloor && raw <= effectiveMinDay && effectiveMinDay > 0) amount = effectiveMinDay + bump;
} else if (daysTraded === 0) {
amount += bump;
}
}
return { amount: Math.round(amount * 100) / 100, path };
}
export interface DayPlan {
/** Full target for this trade date (bump included) — a fixed anchor all day. */
dayTarget: number;
/** dayTarget minus what's already realized today; what's left to trade. */
remainingToday: number;
path: 'first_day' | 'normal_day' | 'reduced_day';
/** False when the account type disables splitting, or for strict-consistency
* accounts past their first trade ever — single unsplit trade. */
splitAllowed: boolean;
}
/**
* Compute today's full target and what's still left of it, as of right now.
*
* Unlike calling computeDailyTarget directly, this anchors the day: today's PnL row (which
* the hourly Cash History refresh may write mid-day) is excluded, and day-start equity is
* reconstructed as equityProfit − realizedPnL. So the same (account, trade date) always
* yields the same dayTarget no matter when or where it's computed — the dashboard, the
* reporter, and the trade path agree, and completed legs only move remainingToday.
*/
export function computeDayPlan(
accountId: number,
tradeDate: string,
profitTarget: number,
consistency: number,
dailyPnL: { date: string; pnl: number }[],
minDayPnL: number,
minTradingDays: number,
equityProfit: number | null | undefined,
realizedPnL: number,
splitEnabled: boolean = true,
drain: boolean = false
): DayPlan | null {
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
const priorDays = dailyPnL.filter((d) => d.date !== tradeDate);
// Drain stage: no profit target at all. Book minTradingDays qualifying
// days at the randomized min-day amount, then go quiet until the next
// withdrawal resets the cycle. One unsplit tiny trade per day.
if (drain) {
const qualifying = minDayPnL > 0
? priorDays.filter((d) => d.pnl >= minDayPnL)
: priorDays.filter((d) => d.pnl !== 0);
const dayTarget = qualifying.length >= minTradingDays
? 0
: drainDayAmount(accountId, tradeDate);
return {
dayTarget,
remainingToday: round2(dayTarget - realizedPnL),
path: 'reduced_day',
splitAllowed: false,
};
}
const dayStartEquity = equityProfit - realizedPnL;
const target = computeDailyTarget(
profitTarget, consistency, priorDays, minDayPnL, minTradingDays,
dayStartEquity, dailyBump(accountId, tradeDate)
);
if (!target) return null;
const qualifyingDays = minDayPnL === 0 ? priorDays : priorDays.filter((d) => d.pnl >= minDayPnL);
const splitAllowed = splitEnabled && (consistency === 0 || consistency >= 1 || qualifyingDays.length === 0);
return {
dayTarget: target.amount,
remainingToday: round2(target.amount - realizedPnL),
path: target.path,
splitAllowed,
};
}