Files
autofirmer-expanded/lib/trading-logic.ts
T
Brandon LiandClaude Fable 5 de9cbffd83 Randomize daily targets: split big days into legs, bump min-days
Big days (> $1700) now trade as multiple separate legs of random size
(each >= $800, $5-rounded), resumable across pauses since the remaining
target is derived from realized PnL against a day-anchored target.
Min-day targets get a deterministic random $0-25 bump seeded per
(account, trade date) so the dashboard, reporter, and orders agree.
Strict-consistency accounts randomize only their first trade ever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 11:32:48 -05:00

289 lines
14 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 = 800;
/** After at least one trade, a residual at or below this counts as "day done". */
export const LEG_COMPLETION_EPSILON = 25;
/** 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);
}
/**
* 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.
*/
export function pickLegAmount(remainingToday: number, rand: () => number = Math.random): number {
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);
}
/** 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 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
): 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 = consistency === 0 || consistency >= 1 || qualifyingDays.length === 0;
return {
dayTarget: target.amount,
remainingToday: round2(target.amount - realizedPnL),
path: target.path,
splitAllowed,
};
}