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>
This commit is contained in:
Brandon Li
2026-09-02 11:32:48 -05:00
co-authored by Claude Fable 5
parent 8350601cf0
commit de9cbffd83
4 changed files with 299 additions and 143 deletions
+129 -4
View File
@@ -48,6 +48,63 @@ export function resolveEffectiveProfitTarget(
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,
@@ -83,7 +140,8 @@ export function computeDailyTarget(
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.
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)) {
@@ -109,10 +167,14 @@ export function computeDailyTarget(
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;
return { amount: needsMoreDays ? effectiveMinDay : 0, path: 'reduced_day' };
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.
@@ -137,7 +199,7 @@ export function computeDailyTarget(
let consistencyCap: number;
let path: 'first_day' | 'normal_day' | 'reduced_day';
if (consistency === 0 || consistency >= 1) {
if (noConsistencyRule) {
consistencyCap = Infinity;
path = daysTraded === 0 ? 'first_day' : 'reduced_day';
} else {
@@ -157,7 +219,70 @@ export function computeDailyTarget(
// 4. Combine caps and apply min-day floor when mandatory days remain.
const raw = Math.min(consistencyCap, cappedByFuture);
const mustFloor = minTradingDays > daysTraded;
const amount = mustFloor ? Math.max(effectiveMinDay, raw) : Math.max(0, raw);
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,
};
}