Files
autofirmer-expanded/lib/trading-logic.ts
T
Brandon LiandClaude Fable 5 c58e981b1e Place OCO stop brackets for the catalog's obfuscation options
When an account type opts in, legs are placed as an OCO pair via
order/placeoco - the usual limit plus a stop: the first cycle day's stop
keeps the day's net floor at -$1,000, min-day trades stop at 2x their
target, and the day after any red day rides stopless to make it back. A
stop-out books the day: eligibility refuses the account once realized
P&L falls short of the legs fired (or is simply negative on an opted-in
account, which survives restarts). If placeoco is rejected the leg falls
back to the classic lone limit with a loud log rather than leave a
position without an exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 18:28:21 -05:00

463 lines
21 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.
/**
* 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.
* Fallback: returns base profitTarget and consistency unchanged.
*/
export function resolveEffectiveConfig(
profitTarget: number,
consistency: number,
minTradingDays: number,
targetSameEquity: boolean,
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
priorProfit: number,
fundTransactions: { date: string; amount: number }[]
): { profitTarget: number; consistency: number; minTradingDays: number } {
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 };
}
if (withdrawalStages.length > 0) {
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
if (withdrawalCount === 0) {
return { profitTarget, consistency, minTradingDays }; // Stage 1 = base values
}
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
const stage = withdrawalStages[idx];
// 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 };
}
return { profitTarget, consistency, minTradingDays };
}
/** @deprecated Use resolveEffectiveConfig instead */
export function resolveEffectiveProfitTarget(
profitTarget: number,
targetSameEquity: boolean,
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
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);
}
/**
* 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,
};
}
/** 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
): DayPlan | null {
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
const priorDays = dailyPnL.filter((d) => d.date !== tradeDate);
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,
};
}