Tradovate moves each account's cash record to the new trade date on its own schedule, an idle one perhaps not until it trades, and the bot holds any account still on the old day until 5:45. Opening at 5:05 fired the ready accounts alone (TopstepX, the early rollers) while the rest, released at 5:45, waited an hour or more for them to go flat. A 5:45 open puts every account in the first batch and skips the open's widest spreads. The day's 0–180 minute delay still comes on top: first entries land 5:45–8:45 PM. Every-day firms keep their own 5:05 PM end of the roll guard; the equity hours open is unchanged. The draw's session progress now starts at 5:45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1618 lines
78 KiB
TypeScript
1618 lines
78 KiB
TypeScript
export interface WithdrawalStage {
|
||
profit: number;
|
||
consistency: number;
|
||
minTradingDays: number;
|
||
/** Spam-withdraw stage: no profit target — book minTradingDays qualifying
|
||
* days of a few dollars (tiny MNQ trades), then wait for the withdrawal.
|
||
* The stage's consistency still applies: the first day is a random draw,
|
||
* later days are sized so the cycle's biggest day fits inside that share
|
||
* of the cycle total (see computeDayPlan). */
|
||
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,
|
||
// The firm's consistency rule doesn't pause for the drain
|
||
// (Topstep still holds its ratio at payout), so carry the
|
||
// stage's value instead of zeroing it.
|
||
consistency: stage.consistency,
|
||
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 grid: $4–$15 in $0.50 steps (23 values, MNQ's
|
||
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
||
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
||
* dailyBump so every consumer of the plan computes the same number. Under a
|
||
* consistency rule only the cycle's first drain day draws from it. */
|
||
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 trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
|
||
* where trade dates never fall on Sat/Sun — or simply the next calendar day
|
||
* for a firm that trades every day (24/7 crypto/CFD, e.g. Tradeify247). */
|
||
export function nextTradingDate(d: string, everyDay = false): string {
|
||
const dt = new Date(`${d}T00:00:00Z`);
|
||
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (!everyDay && (dt.getUTCDay() === 0 || dt.getUTCDay() === 6));
|
||
return dt.toISOString().slice(0, 10);
|
||
}
|
||
|
||
/** Whether CME holiday sessions fold into the next day for this account type.
|
||
* Never for an every-day firm: it has no CME calendar to fold by. The catalog
|
||
* sync already clears the flag for those firms; this is the backstop. */
|
||
export function effectiveCombine(cfg: { combineCmeHolidays?: boolean; tradesEveryDay?: boolean }): boolean {
|
||
return cfg.combineCmeHolidays === true && cfg.tradesEveryDay !== true;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
/** Firm counts CME holiday sessions with the next full day — the
|
||
* projection then folds P&L rows the same way and never books a
|
||
* projected day on a holiday session. */
|
||
combineCmeHolidays?: 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;
|
||
/** The firm trades every calendar day (24/7): weekends and CME
|
||
* holidays are ordinary projected days, and nothing combines. */
|
||
tradesEveryDay?: 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;
|
||
};
|
||
/** Defaults to the current time. */
|
||
now?: Date;
|
||
}): 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 everyDay = cfg.tradesEveryDay === true;
|
||
|
||
const rawToday = currentTradeDateCT(opts.now);
|
||
// The firm's own day view: a combining firm folds holiday-session P&L
|
||
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
||
// ONE trading day, exactly as the firm counts min days. Identity view
|
||
// when combining is off.
|
||
const combine = effectiveCombine(cfg);
|
||
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
|
||
// 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
|
||
// today row would fabricate an extra qualifying day. An every-day firm
|
||
// trades weekends, so its weekend today is a live session like any other.
|
||
const todayDow = new Date(`${rawToday}T00:00:00Z`).getUTCDay();
|
||
const todayHasSession = everyDay || (todayDow !== 0 && todayDow !== 6);
|
||
// Today's clearing date when a session exists; on weekends keep the raw
|
||
// date so the projection's first day is Monday, not the day after it
|
||
// (combinedTradeDate rolls weekend dates forward).
|
||
const today = todayHasSession ? view.tradeDate : rawToday;
|
||
let sim = view.dailyPnL.filter((d) => d.date !== today);
|
||
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
|
||
/** Next date the firm settles a session on: weekends skip (except for an
|
||
* every-day firm), and a combining firm's holiday sessions clear into
|
||
* the next full day. */
|
||
const nextDay = (d: string): string => {
|
||
let n = nextTradingDate(d, everyDay);
|
||
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
|
||
return n;
|
||
};
|
||
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 = nextDay(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 = nextDay(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 = nextDay(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;
|
||
}
|
||
|
||
/** Where the current cycle stands against today's P&L: 'today' = today's
|
||
* trading finished it (the final day), 'before' = it already stood complete
|
||
* without today, 'none' = profit or days still owed. */
|
||
export type CycleCompletion = 'none' | 'today' | 'before';
|
||
|
||
/** How long a new trade date waits before it can read 'today': the platforms
|
||
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
||
* TopstepX 60s sync), not at 5 PM sharp, and until then the last session's
|
||
* number — already booked under its own date — would count as a second day. */
|
||
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||
|
||
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||
* still checked for a missed completion. */
|
||
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
||
|
||
/** Nothing left to trade AND the days booked: with no min-day amount the
|
||
* plan coasts at 0 while days are still owed, so count them too. */
|
||
function cycleOwesNothing(
|
||
accountId: number,
|
||
eff: EffectiveConfig,
|
||
rows: { date: string; pnl: number }[],
|
||
date: string,
|
||
equityProfit: number,
|
||
realized: number,
|
||
splitEnabled: boolean,
|
||
): boolean {
|
||
const plan = computeDayPlan(
|
||
accountId, date, eff.profitTarget, eff.consistency, rows, eff.minDayPnL,
|
||
eff.minTradingDays, equityProfit, realized, splitEnabled, eff.drain,
|
||
);
|
||
const qualifying = rows.filter((d) => d.date !== date
|
||
&& (eff.minDayPnL > 0 ? d.pnl >= eff.minDayPnL : d.pnl !== 0)).length;
|
||
return plan !== null && plan.dayTarget <= 0 && qualifying >= eff.minTradingDays;
|
||
}
|
||
|
||
/** Is the live realizedPnL too doubtful to count as today's session? A
|
||
* weekend (it is the last session's stale number, already booked under its
|
||
* own date), a Monday with a weekend-dated row — the live session filed under
|
||
* a weekend date, which would count it twice — and the first minutes after
|
||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||
* roll window applies to it. */
|
||
function sessionDoubtful(
|
||
rawTradeDate: string,
|
||
cycleDailyPnL: { date: string; pnl: number }[],
|
||
now: Date,
|
||
everyDay = false,
|
||
): boolean {
|
||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||
const dow = day.getUTCDay();
|
||
if (!everyDay && (dow === 0 || dow === 6)) return true;
|
||
if (!everyDay && dow === 1) {
|
||
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
||
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
|
||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
||
}
|
||
// Settle window: a few minutes ago it was still an earlier trade date.
|
||
return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||
}
|
||
|
||
/**
|
||
* Did today's trading finish the account's current cycle — profit target,
|
||
* consistency and minimum days all met? projectPerfectPath's "already
|
||
* complete" probe, run once without today's P&L and once with it, so only
|
||
* the final day reads 'today'.
|
||
*
|
||
* The master keeps a 'today' permanently, so every doubtful case reads
|
||
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
||
* already booked under its own date), a Monday with a weekend-dated row — the
|
||
* live session filed under a weekend date, which would count it twice — and
|
||
* the first minutes after the 5 PM roll. 'before' skips those guards: it only
|
||
* keeps a locked, untraded pass out of the reporter's losses.
|
||
*/
|
||
export function cycleCompletion(opts: {
|
||
accountId: number;
|
||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
||
* forward to Monday. */
|
||
rawTradeDate: string;
|
||
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||
cycleDailyPnL: { date: string; pnl: number }[];
|
||
rawRealizedPnL: number;
|
||
combineCmeHolidays: boolean;
|
||
/** The firm trades every calendar day — weekends are real sessions. */
|
||
tradesEveryDay?: boolean;
|
||
effective: EffectiveConfig;
|
||
/** amount − accountSize — today's P&L included. */
|
||
equityProfit: number;
|
||
splitEnabled: boolean;
|
||
/** Defaults to the current time. */
|
||
now?: Date;
|
||
}): CycleCompletion {
|
||
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
|
||
const everyDay = opts.tradesEveryDay === true;
|
||
const view = combineCmeSessions(
|
||
effectiveCombine({ combineCmeHolidays: opts.combineCmeHolidays, tradesEveryDay: everyDay }),
|
||
opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL,
|
||
);
|
||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||
|
||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
|
||
|
||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
|
||
return 'none';
|
||
}
|
||
|
||
/**
|
||
* Catch-up for a missed final day: did the cycle the latest fund transaction
|
||
* just ended stand complete — profit target, consistency and minimum days all
|
||
* met? cycleCompletion reads 'today' on the final day only, so a completion
|
||
* its guards held back (or that the instance was offline for) would vanish
|
||
* once the payout, erasure or reset starts a new cycle and the days revert
|
||
* to 0. Checked for a week after that fund transaction.
|
||
*
|
||
* The ended cycle is judged on its own rows — from the previous fund date
|
||
* (its own row too when the client counted it in, as it does for a deposit),
|
||
* up to the latest one, less any the current cycle holds — against the
|
||
* equity it ended on: live equity less the current cycle's P&L and the fund
|
||
* transaction itself. A failed account's reset (a positive adjustment after
|
||
* a cycle that fell short) correctly reads null: the ended cycle never met
|
||
* the rule. Null on a doubtful session too — on a weekend the live
|
||
* realizedPnL is stale and would come off equity twice — and when this
|
||
* instance never saw the ended cycle start (a new instance or a wipe, whose
|
||
* fetch window cuts it short; TopstepX's first-check lump, which isn't a
|
||
* cycle at all).
|
||
*
|
||
* `cycle` is the fund date that STARTED the ended cycle ('' for the
|
||
* account's first), which is what client.lastFundDates held while it was
|
||
* current — the same key the final-day path reported for this cycle, so the
|
||
* master dedupes the two.
|
||
*/
|
||
export function endedCycleCompletion(opts: {
|
||
accountId: number;
|
||
/** currentTradeDateCT(). */
|
||
rawTradeDate: string;
|
||
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||
fullDailyPnL: { date: string; pnl: number }[];
|
||
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||
cycleDailyPnL: { date: string; pnl: number }[];
|
||
fundTransactions: { date: string; amount: number }[];
|
||
/** client.lastFundDates — the fund transaction that started the current cycle. */
|
||
lastFundDate: string | null;
|
||
/** loadEndedCycleStart() — where the client saw the ended cycle begin,
|
||
* null when it never did. */
|
||
endedCycleStart: { fundDate: string; includesDay: boolean } | null;
|
||
rawRealizedPnL: number;
|
||
/** amount − accountSize — today's P&L included. */
|
||
equityProfit: number;
|
||
cfg: {
|
||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||
tradesEveryDay?: boolean;
|
||
};
|
||
/** Defaults to the current time. */
|
||
now?: Date;
|
||
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
||
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
|
||
const everyDay = cfg.tradesEveryDay === true;
|
||
const combine = effectiveCombine(cfg);
|
||
if (lastFundDate === null || start === null) return null;
|
||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
||
|
||
const latest = (a: string, b: string) => (b > a ? b : a);
|
||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||
const prevFundDate = earlierFunds.length > 0 ? earlierFunds.map((f) => f.date).reduce(latest) : null;
|
||
// Saved for another transition — two fund dates since the client last
|
||
// looked — so the start it saw isn't this cycle's.
|
||
if (start.fundDate !== (prevFundDate ?? '')) return null;
|
||
const current = new Set(opts.cycleDailyPnL.map((d) => d.date));
|
||
const endedRaw = opts.fullDailyPnL.filter((d) => d.date <= lastFundDate
|
||
&& (prevFundDate === null || d.date > prevFundDate || (start.includesDay && d.date === prevFundDate))
|
||
&& !current.has(d.date));
|
||
if (!endedRaw.some((d) => d.pnl !== 0)) return null;
|
||
|
||
// Equity the ended cycle finished on, worked back from live equity: less
|
||
// everything the current cycle booked (today's live P&L included) and
|
||
// less the fund transaction between the two.
|
||
const cur = combineCmeSessions(combine, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
|
||
+ cur.realizedPnL;
|
||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
||
|
||
// The stage the ended cycle traded on: only the withdrawals before it.
|
||
const eff = resolveEffectiveConfig(
|
||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||
);
|
||
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
||
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||
const traded = rows.filter((d) => d.pnl !== 0);
|
||
if (traded.length === 0) return null;
|
||
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest), everyDay);
|
||
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
|
||
|
||
return {
|
||
date: traded.map((d) => d.date).reduce(latest),
|
||
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||
drain: eff.drain,
|
||
cycle: prevFundDate ?? '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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, then go quiet until the next withdrawal resets the cycle. One
|
||
// unsplit tiny trade per day. The cycle's first day books a randomized
|
||
// grid amount (every day does when there is no consistency rule).
|
||
//
|
||
// The stage's consistency rule outlives the profit target: the cycle's
|
||
// biggest day must fit inside `consistency` of the cycle total. Past the
|
||
// random first day the drain follows the standard consistency math, so
|
||
// accounts drained together finish together whatever their first draw:
|
||
// a day that can close the cycle aims at the MIDPOINT of the amounts
|
||
// that close it (a fill a little over or under still lands inside), and
|
||
// earlier days size like computeDailyTarget with the target at 0.
|
||
if (drain) {
|
||
const qualifying = minDayPnL > 0
|
||
? priorDays.filter((d) => d.pnl >= minDayPnL)
|
||
: priorDays.filter((d) => d.pnl !== 0);
|
||
const daysDone = qualifying.length >= minTradingDays;
|
||
let dayTarget: number;
|
||
if (consistency <= 0 || consistency >= 1 || qualifying.length === 0) {
|
||
dayTarget = daysDone ? 0 : drainDayAmount(accountId, tradeDate);
|
||
} else {
|
||
const maxDay = Math.max(...qualifying.map((d) => d.pnl));
|
||
const total = round2(priorDays.reduce((s, d) => s + d.pnl, 0));
|
||
const needed = round2(maxDay / consistency - total);
|
||
// A day of x closes the rule when it covers the shortfall without
|
||
// topping maxDay (x ≥ needed), or tops it but still fits as the
|
||
// new biggest day (total + x ≥ x / consistency).
|
||
const lo = Math.max(needed, minDayPnL);
|
||
const hi = round2((total * consistency) / (1 - consistency));
|
||
if (daysDone && needed <= 0) dayTarget = 0;
|
||
else if (qualifying.length + 1 >= minTradingDays && lo <= hi) dayTarget = round2((lo + hi) / 2);
|
||
else dayTarget = computeDailyTarget(0, consistency, priorDays, minDayPnL, minTradingDays, total)?.amount ?? 0;
|
||
}
|
||
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,
|
||
};
|
||
}
|
||
|
||
// ── fees ─────────────────────────────────────────────────────────────────────
|
||
|
||
/** An entry's fee when the balance update hasn't shown it yet — at least one
|
||
* extra tick on the exit. */
|
||
export const FALLBACK_COMMISSION_PER_CONTRACT = 2.5;
|
||
/** No real per-contract entry fee comes near this. Also what a daily-loss
|
||
* stop reserves per contract and side when the fee couldn't be measured —
|
||
* the $2.50 fallback is fine for a take-profit, but it would under-reserve
|
||
* a hard stop on NQ-class fees. */
|
||
export const MAX_COMMISSION_PER_CONTRACT = 10;
|
||
|
||
/** One side's commission on a percent-of-notional firm (e.g. Tradeify247's
|
||
* 0.04%), rounded UP to the cent so every consumer errs toward paying more.
|
||
* pointValue × contracts is the position's dollars per 1.0 of price, so
|
||
* price × that is its notional. */
|
||
export function percentFee(pct: number, price: number, pointValue: number, contracts: number): number {
|
||
if (!(pct > 0)) return 0;
|
||
const raw = (pct / 100) * Math.abs(price) * pointValue * contracts;
|
||
return Math.ceil(raw * 100 - 1e-9) / 100;
|
||
}
|
||
|
||
/**
|
||
* Where to put the take-profit so the leg nets at least `leg` after fees.
|
||
*
|
||
* Measured-fee firms (commissionPct 0): the exit fee is taken to equal the
|
||
* entry fee — the same formula the fire path always used, reproduced exactly.
|
||
*
|
||
* Percent-fee firms: the exit fee depends on the exit price itself, so solve
|
||
* for it. With r = pct/100 and k = pointValue × contracts, a long nets
|
||
* (X − entry)·k − entryFee − r·X·k at exit price X, which reaches `leg` at
|
||
* X = ((leg + entryFee)/k + entry) / (1 − r); a short mirrors it at
|
||
* X = (entry − (leg + entryFee)/k) / (1 + r). Rounded out to whole ticks and
|
||
* re-checked with the cent-rounded exit fee.
|
||
*/
|
||
export function takeProfitPlan(o: {
|
||
side: 'Buy' | 'Sell';
|
||
entry: number;
|
||
leg: number;
|
||
pointValue: number;
|
||
contracts: number;
|
||
tickSize: number;
|
||
commissionPct: number;
|
||
/** The entry fee: percentFee() on percent firms, the measured one otherwise. */
|
||
entryCommission: number;
|
||
}): { exitPrice: number; ticks: number; entryFee: number; exitFee: number; totalCommission: number; grossTarget: number } {
|
||
const k = o.pointValue * o.contracts;
|
||
const dir = o.side === 'Buy' ? 1 : -1;
|
||
if (!(o.commissionPct > 0)) {
|
||
const totalCommission = o.entryCommission * 2; // entry + exit round-trip
|
||
const grossTarget = o.leg + totalCommission;
|
||
const ticks = Math.ceil(grossTarget / k / o.tickSize);
|
||
return {
|
||
exitPrice: o.entry + dir * ticks * o.tickSize,
|
||
ticks,
|
||
entryFee: o.entryCommission,
|
||
exitFee: o.entryCommission,
|
||
totalCommission,
|
||
grossTarget,
|
||
};
|
||
}
|
||
|
||
const r = o.commissionPct / 100;
|
||
const need = (o.leg + o.entryCommission) / k;
|
||
const target = o.side === 'Buy' ? (need + o.entry) / (1 - r) : (o.entry - need) / (1 + r);
|
||
let ticks = Math.max(1, Math.ceil(Math.abs(target - o.entry) / o.tickSize - 1e-9));
|
||
const priceAt = (t: number) => Math.round((o.entry + dir * t * o.tickSize) * 1e10) / 1e10;
|
||
const netAt = (t: number) => t * o.tickSize * k - o.entryCommission - percentFee(o.commissionPct, priceAt(t), o.pointValue, o.contracts);
|
||
// Cent rounding on the fees can leave the closed form short — by several
|
||
// ticks when a tick is worth a fraction of a cent. Step out by the shortfall.
|
||
for (let i = 0; i < 10; i++) {
|
||
const short = o.leg - netAt(ticks);
|
||
if (short <= 1e-9) break;
|
||
ticks += Math.max(1, Math.ceil(short / (o.tickSize * k * (1 - r)) - 1e-9));
|
||
}
|
||
const exitPrice = priceAt(ticks);
|
||
const exitFee = percentFee(o.commissionPct, exitPrice, o.pointValue, o.contracts);
|
||
const totalCommission = Math.round((o.entryCommission + exitFee) * 100) / 100;
|
||
return { exitPrice, ticks, entryFee: o.entryCommission, exitFee, totalCommission, grossTarget: round2(o.leg + totalCommission) };
|
||
}
|
||
|
||
// ── stops ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
|
||
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
|
||
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
|
||
* day always rides stopless, the make-back day. A hard daily loss limit is
|
||
* separate (dllStopDollars) and never rides stopless. */
|
||
export function obfuscationStopDollars(
|
||
cfg: { firstDayStop: boolean; minDayStop: boolean },
|
||
dailyPnL: { date: string; pnl: number }[],
|
||
plan: DayPlan,
|
||
tradeDate: string,
|
||
legAmount: number,
|
||
): number {
|
||
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
|
||
const prior = dailyPnL
|
||
.filter((d) => d.date < tradeDate && d.pnl !== 0)
|
||
.sort((a, b) => a.date.localeCompare(b.date));
|
||
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
|
||
let stop = Infinity;
|
||
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
|
||
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday);
|
||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
|
||
return stop;
|
||
}
|
||
|
||
/** Share of a hard daily loss limit the bot never spends — room for a stop
|
||
* that fills worse than its price (gaps, thin weekend books). */
|
||
export const DLL_RESERVE_FRACTION = 0.10;
|
||
/** …or this share of the position's notional, when that is more: a stop's
|
||
* slippage grows with the position, a flat reserve doesn't. */
|
||
export const DLL_SLIPPAGE_FRACTION = 0.001;
|
||
|
||
/** The part of the day's room a stop leaves unspent. */
|
||
export function dllReserve(dailyLossLimit: number, notional = 0): number {
|
||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
|
||
}
|
||
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
|
||
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
|
||
* anything tighter just spends the day's room on fees. */
|
||
export const DLL_MIN_STOP = 50;
|
||
|
||
/**
|
||
* How much the firm's day may still lose before a hard daily-loss breach.
|
||
* Infinity when the account type has no limit.
|
||
*
|
||
* The day's P&L is the closed balance against the platform's snapshot at the
|
||
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
|
||
* broker's realized P&L for the session. Only a red day counts: a green day
|
||
* does NOT widen the room past the limit, because a position still open at
|
||
* the next reset has its whole move counted against the NEW day — room
|
||
* borrowed from today's profit would be a breach tomorrow.
|
||
*/
|
||
export function dllRoom(o: {
|
||
dailyLossLimit: number;
|
||
closedBalance: number;
|
||
/** Platform snapshot at the firm's daily reset; undefined/null = unknown. */
|
||
dayStartBalance?: number | null;
|
||
/** Fallback when there's no snapshot: the day's realized P&L (pass the
|
||
* worse of the raw and combined figures). */
|
||
realizedToday: number;
|
||
}): number {
|
||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||
const dayPnL = typeof o.dayStartBalance === 'number' ? o.closedBalance - o.dayStartBalance : o.realizedToday;
|
||
return round2(o.dailyLossLimit + Math.min(0, dayPnL));
|
||
}
|
||
|
||
/**
|
||
* The widest stop, in dollars of price movement, that keeps a loss inside
|
||
* room − reserve with both fees paid. The exit fee at the stop is at most
|
||
* entryFee + r × (stop distance) — a short's stop sits above its entry — so
|
||
* the worst loss is d(1 + r) + 2·entryFee, plus a cent of fee rounding.
|
||
* Infinity when there is no limit. Can go negative: no room at all.
|
||
*
|
||
* Overnight swap needs no reserve of its own while a day's swap is below the
|
||
* commission rate (Tradeify: 0.033% < 0.04%): a position carried past the
|
||
* reset has its entry fee on the old day's books, and the swap takes no more
|
||
* than that slot on the new day's.
|
||
*/
|
||
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number }): number {
|
||
if (!Number.isFinite(o.room)) return Infinity;
|
||
const r = Math.max(0, o.commissionPct) / 100;
|
||
return (o.room - dllReserve(o.dailyLossLimit, o.notional) - 2 * o.entryFee - 0.01) / (1 + r);
|
||
}
|
||
|
||
/**
|
||
* How far the account's balance is above its max-drawdown floor, or null when
|
||
* it isn't known: the broker's liquidation floor where it reports one, else
|
||
* the account type's static max loss below the starting size — whichever is
|
||
* higher.
|
||
*/
|
||
export function maxLossRoom(o: { balance: number; accountSize: number; maxLoss: number; autoLiqThreshold?: number }): number | null {
|
||
const floors: number[] = [];
|
||
if ((o.autoLiqThreshold ?? 0) > 0) floors.push(o.autoLiqThreshold!);
|
||
if (Math.abs(o.maxLoss) > 0 && o.accountSize > 0) floors.push(o.accountSize - Math.abs(o.maxLoss));
|
||
return floors.length > 0 ? round2(o.balance - Math.max(...floors)) : null;
|
||
}
|
||
|
||
/** A daily-loss stop is kept only while the max-drawdown floor lies more than
|
||
* this share of a full daily loss beyond it. */
|
||
export const LAST_STAND_FRACTION = 0.15;
|
||
|
||
/**
|
||
* Whether a leg should ride without its daily-loss stop. The stop is there to
|
||
* keep the account alive for another day; when hitting the day's loss limit
|
||
* would leave it within 15% of that loss of its max-drawdown floor — or the
|
||
* floor is nearer than the limit — there is no useful day after, and the stop
|
||
* only gives up room (the reserve, and whatever the floor allows past it)
|
||
* for nothing. The leg then runs until its take-profit or the firm's own
|
||
* breach. E.g. $3,000 of daily room with $3,294.70 to the floor: $294.70
|
||
* left, 9.8% — a last stand.
|
||
*/
|
||
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
|
||
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
|
||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
|
||
}
|
||
|
||
/** On a last stand: how far price can move against the leg before the firm's
|
||
* nearer floor breaches the account — its equity carries the entry fee, not
|
||
* the exit's. What sizing and the swap estimate take as the walk's far side. */
|
||
export function breachDistance(o: { dllRoom: number; maxLossRoom: number | null; entryFee: number }): number {
|
||
return Math.min(o.dllRoom, o.maxLossRoom ?? Infinity) - o.entryFee;
|
||
}
|
||
|
||
/** Is there enough daily-loss room left to trade at all? */
|
||
export function dllViable(room: number, dailyLossLimit: number, roundTripFeeEstimate: number): boolean {
|
||
if (!Number.isFinite(room)) return true;
|
||
return room - DLL_RESERVE_FRACTION * dailyLossLimit >= DLL_MIN_STOP + roundTripFeeEstimate;
|
||
}
|
||
|
||
/**
|
||
* The stop distance in ticks: the tighter of the obfuscation stop (rounded
|
||
* out, as it always was — it is a soft target) and the daily-loss stop
|
||
* (rounded IN — it is a hard limit). null = no stop at all (lone take-profit);
|
||
* 0 = the daily-loss room doesn't cover a single tick, so the position must
|
||
* not stay open. An obfuscation value of 0 or Infinity means no obfuscation stop.
|
||
*/
|
||
export function stopTicks(o: {
|
||
obfuscationDollars: number;
|
||
dllDollars: number;
|
||
pointValue: number;
|
||
contracts: number;
|
||
tickSize: number;
|
||
}): number | null {
|
||
const perTick = o.pointValue * o.contracts * o.tickSize;
|
||
let ticks: number | null = null;
|
||
if (Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0) {
|
||
ticks = Math.max(1, Math.ceil(o.obfuscationDollars / (o.pointValue * o.contracts) / o.tickSize));
|
||
}
|
||
if (Number.isFinite(o.dllDollars)) {
|
||
const dllTicks = Math.max(0, Math.floor(o.dllDollars / perTick + 1e-9));
|
||
ticks = ticks === null ? dllTicks : Math.min(ticks, dllTicks);
|
||
}
|
||
return ticks;
|
||
}
|
||
|
||
/** Whether the bot places its own stops for this account type — the
|
||
* obfuscation options or a hard daily loss limit. A red day on such an
|
||
* account is the bot's own stop (the personal DLL), not a firm lockout. */
|
||
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
|
||
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
|
||
}
|
||
|
||
// ── position size ────────────────────────────────────────────────────────────
|
||
|
||
/** The hold a leg is sized for when the firm doesn't set one. */
|
||
export const DEFAULT_TARGET_HOLD_HOURS = 12;
|
||
|
||
/** Share of the broker's margin headroom a new position may use: the rest
|
||
* absorbs the price moving between sizing and the fill, and the entry fee. */
|
||
export const MARGIN_HEADROOM = 0.97;
|
||
|
||
/**
|
||
* The largest position an account may open: what its available funds can
|
||
* margin at the broker's rate for the instrument — the firm's leverage per
|
||
* symbol (5:1 crypto, 2:1 stocks at Tradeify247). 0 = no cap known.
|
||
*/
|
||
export function notionalCap(o: { marginRate?: number; availableFunds?: number }): number {
|
||
if (!((o.marginRate ?? 0) > 0) || !Number.isFinite(o.availableFunds) || !(o.availableFunds! > 0)) return 0;
|
||
return (MARGIN_HEADROOM * o.availableFunds!) / o.marginRate!;
|
||
}
|
||
|
||
/** The swap reserve never grows past this share of the take-profit distance
|
||
* — beyond it the leg is sized too small to hold at all. */
|
||
const MAX_SWAP_RESERVE_FRACTION = 0.25;
|
||
|
||
/**
|
||
* The overnight swap a winning leg is expected to pay before its take-profit
|
||
* fills, so the take-profit can carry it and a win still clears the leg.
|
||
*
|
||
* Same driftless walk as sizeNotional, in dollars: σ$ = notional·σ per √hour,
|
||
* the take-profit A away, the stop B away. Given it wins, the walk takes
|
||
* (A² + 2AB)/(3σ$²) hours on average — longer than the unconditional hold,
|
||
* since the quick endings are mostly stop-outs. The swap is charged at each
|
||
* daily reset the position is held over, about one per 24 h held, on the
|
||
* notional. A includes the reserve itself, so it is solved as a fixed point.
|
||
* A stopless leg is taken as B = A.
|
||
*
|
||
* `distance` = the take-profit distance before swap: leg + round-trip fees.
|
||
*/
|
||
export function expectedSwapToWin(o: {
|
||
distance: number;
|
||
stopDollars: number;
|
||
notional: number;
|
||
volatilityPerHour?: number;
|
||
financingPctPerDay?: number;
|
||
}): number {
|
||
const daily = Math.max(0, o.financingPctPerDay ?? 0) / 100;
|
||
const sigma = o.volatilityPerHour ?? 0;
|
||
if (!(daily > 0) || !(sigma > 0) || !(o.notional > 0) || !(o.distance > 0)) return 0;
|
||
const variance = (o.notional * sigma) ** 2;
|
||
const stopped = Number.isFinite(o.stopDollars) && o.stopDollars > 0;
|
||
const cap = o.distance * MAX_SWAP_RESERVE_FRACTION;
|
||
let swap = 0;
|
||
for (let i = 0; i < 50; i++) {
|
||
const a = o.distance + swap;
|
||
const b = stopped ? o.stopDollars : a;
|
||
const winHours = (a * a + 2 * a * b) / (3 * variance);
|
||
const next = Math.min(cap, daily * o.notional * (winHours / 24));
|
||
const done = Math.abs(next - swap) < 0.005;
|
||
swap = next;
|
||
if (done) break;
|
||
}
|
||
return Math.ceil(swap * 100) / 100;
|
||
}
|
||
|
||
/**
|
||
* How much notional a leg should carry, on platforms that price it (DXTrade).
|
||
*
|
||
* Model price as a driftless random walk with σ per √hour. With the
|
||
* take-profit A dollars away (leg + fees + expected swap) and the stop B
|
||
* dollars away, a position of notional N wins B/(A+B) of the time whatever N
|
||
* is, its expected hold is (A/N)(B/N)/σ² hours, and its expected P&L is minus
|
||
* fees and spread — proportional to N. Smaller is cheaper, bigger is faster,
|
||
* so the knob is the target hold T: N = √(A·B/(σ²·T)). A firm that charges
|
||
* overnight financing adds a floor: below √(f·A·B/(σ²·(2c + s))), a bigger
|
||
* position is both cheaper and faster.
|
||
*
|
||
* A stopless leg (no daily loss limit, no obfuscation stop) is sized as if
|
||
* its stop sat as far away as its target. Returns null without a volatility.
|
||
*/
|
||
export function sizeNotional(o: {
|
||
leg: number;
|
||
/** Stop distance in dollars at a given notional (fees move with it). */
|
||
stopDollarsAt: (notional: number) => number;
|
||
volatilityPerHour?: number;
|
||
/** 0 = DEFAULT_TARGET_HOLD_HOURS. */
|
||
targetHoldHours: number;
|
||
/** Per side, percent. */
|
||
commissionPct: number;
|
||
spreadFraction?: number;
|
||
financingPctPerDay?: number;
|
||
/** Hard cap on the position (see notionalCap); 0 = none. */
|
||
maxNotional: number;
|
||
/** The smallest order the broker takes; 0 = none. It wins over the cap,
|
||
* as the order does. */
|
||
minNotional?: number;
|
||
}): { notional: number; predictedHoldHours: number; winProbability: number | null; swapReserve: number } | null {
|
||
const sigma = o.volatilityPerHour ?? 0;
|
||
if (!(sigma > 0)) return null;
|
||
const hold = o.targetHoldHours > 0 ? o.targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
|
||
const c = Math.max(0, o.commissionPct) / 100;
|
||
const s = Math.max(0, o.spreadFraction ?? 0);
|
||
const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
|
||
const distances = (n: number) => {
|
||
const stop = o.stopDollarsAt(n);
|
||
const stopped = Number.isFinite(stop) && stop > 0;
|
||
const swap = expectedSwapToWin({
|
||
distance: o.leg + 2 * c * n, stopDollars: stop, notional: n,
|
||
volatilityPerHour: sigma, financingPctPerDay: o.financingPctPerDay,
|
||
});
|
||
const a = o.leg + 2 * c * n + swap;
|
||
return { a, b: stopped ? stop : a, stopped, swap };
|
||
};
|
||
|
||
// Fees and swap depend on N and the daily-loss stop depends on fees: a
|
||
// few fixed-point passes settle it (the correction is second order).
|
||
let n = o.leg * 20;
|
||
for (let pass = 0; pass < 4; pass++) {
|
||
const { a, b } = distances(n);
|
||
const nTime = Math.sqrt((a * b) / (sigma * sigma * hold));
|
||
// No per-trade cost (a zero-commission firm on a zero spread): no
|
||
// floor — there is nothing for a bigger position to save.
|
||
const nCost = fh > 0 && 2 * c + s > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
|
||
n = Math.max(nTime, nCost);
|
||
}
|
||
if (!Number.isFinite(n) || n <= 0) return null;
|
||
const notional = Math.max(o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n, o.minNotional ?? 0);
|
||
const { a, b, stopped, swap } = distances(notional);
|
||
return {
|
||
notional,
|
||
predictedHoldHours: (a / notional) * (b / notional) / (sigma * sigma),
|
||
winProbability: stopped ? b / (a + b) : null,
|
||
swapReserve: swap,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One leg's size on one instrument for one account — the rule fireLeg
|
||
* trades, the instrument pick ranks by and the dashboard shows.
|
||
*
|
||
* The walk's far side (what sizing, the win chance and the swap estimate
|
||
* use) is the stop the leg carries: the tighter of the obfuscation stop and
|
||
* the hard daily-loss stop. On a last stand there is no daily-loss stop and
|
||
* the far side is the firm's own breach. `cost` is what the leg is expected
|
||
* to spend: spread and both commissions on its notional, plus swap over its
|
||
* expected hold.
|
||
*
|
||
* The size is held to what the order can be — the margin cap and the
|
||
* broker's largest order, then its smallest — so the pick ranks each
|
||
* instrument by the hold it can actually reach. `cap` is the margin cap
|
||
* alone.
|
||
*/
|
||
export function planLegSize(o: {
|
||
leg: number;
|
||
contract: {
|
||
stepNotional?: number; volatilityPerHour?: number; spreadFraction?: number; marginRate?: number;
|
||
/** The broker's largest and smallest order, in steps. */
|
||
maxContracts?: number; minContracts?: number;
|
||
};
|
||
/** Per side, percent. */
|
||
commissionPct: number;
|
||
targetHoldHours: number;
|
||
financingPctPerDay: number;
|
||
dailyLossLimit: number;
|
||
/** Infinity = no hard daily limit. */
|
||
dllRoom: number;
|
||
maxLossRoom: number | null;
|
||
/** Infinity (or ≤ 0) = no obfuscation stop. */
|
||
obfuscationDollars: number;
|
||
availableFunds?: number;
|
||
}): {
|
||
lastStand: boolean;
|
||
cap: number;
|
||
walkAt: (notional: number) => number;
|
||
sizing: ReturnType<typeof sizeNotional>;
|
||
cost: number | null;
|
||
} {
|
||
const dllOn = Number.isFinite(o.dllRoom) && o.dailyLossLimit > 0;
|
||
const stand = dllOn && lastStand({ dllRoom: o.dllRoom, dailyLossLimit: o.dailyLossLimit, maxLossRoom: o.maxLossRoom });
|
||
const obf = Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0 ? o.obfuscationDollars : Infinity;
|
||
const r = Math.max(0, o.commissionPct) / 100;
|
||
const walkAt = (notional: number) => Math.min(
|
||
obf,
|
||
!dllOn ? Infinity
|
||
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
|
||
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional }),
|
||
);
|
||
const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
|
||
const step = o.contract.stepNotional ?? 0;
|
||
const orderMax = (o.contract.maxContracts ?? 0) > 0 ? o.contract.maxContracts! * step : 0;
|
||
const sizing = step > 0
|
||
? sizeNotional({
|
||
leg: o.leg,
|
||
stopDollarsAt: walkAt,
|
||
volatilityPerHour: o.contract.volatilityPerHour,
|
||
targetHoldHours: o.targetHoldHours,
|
||
commissionPct: o.commissionPct,
|
||
spreadFraction: o.contract.spreadFraction,
|
||
financingPctPerDay: o.financingPctPerDay,
|
||
maxNotional: cap > 0 && orderMax > 0 ? Math.min(cap, orderMax) : cap || orderMax,
|
||
minNotional: Math.max(1, o.contract.minContracts ?? 1) * step,
|
||
})
|
||
: null;
|
||
const cost = sizing
|
||
? sizing.notional * (Math.max(0, o.contract.spreadFraction ?? 0) + 2 * r)
|
||
+ (Math.max(0, o.financingPctPerDay) / 100) * sizing.notional * (sizing.predictedHoldHours / 24)
|
||
: null;
|
||
return { lastStand: stand, cap, walkAt, sizing, cost };
|
||
}
|
||
|
||
/**
|
||
* Order candidates for a leg: those that reach the target hold (within a
|
||
* few percent) first, cheapest first; then those whose leverage cap keeps
|
||
* them short of it, quickest first. Returns indices into `options`.
|
||
*/
|
||
export function rankForTarget(options: { holdHours: number; cost: number }[], targetHoldHours: number): number[] {
|
||
const reaches = (h: number) => h <= targetHoldHours * 1.02 + 0.01;
|
||
return options
|
||
.map((opt, i) => ({ ...opt, i, reach: reaches(opt.holdHours) }))
|
||
.sort((a, b) => (a.reach === b.reach
|
||
? (a.reach ? a.cost - b.cost : a.holdHours - b.holdHours) || a.i - b.i
|
||
: a.reach ? -1 : 1))
|
||
.map((x) => x.i);
|
||
}
|
||
|
||
// ── trading hours ────────────────────────────────────────────────────────────
|
||
|
||
/** Tradeify247 (and DXtrade brokers generally) roll the trading day at 22:00
|
||
* UTC — 5 PM Central in summer, 4 PM in winter. */
|
||
export const FIRM_RESET_UTC_HOUR = 22;
|
||
/** Every-day firms stop opening new trades this long before the earlier of
|
||
* their reset and the 5 PM CT roll. */
|
||
const ROLL_GUARD_MIN = 5;
|
||
|
||
function centralClock(now: Date): { day: string; minutes: number; resetMinutes: number } {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
|
||
}).formatToParts(now);
|
||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
||
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
|
||
// Central's offset from UTC right now (−300 in CDT, −360 in CST) places
|
||
// the firm's 22:00 UTC reset on the Central clock.
|
||
const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
|
||
const offset = ((minutes - utcMinutes + 720 + 1440) % 1440) - 720;
|
||
const resetMinutes = (FIRM_RESET_UTC_HOUR * 60 + offset + 1440) % 1440;
|
||
return { day: get('weekday'), minutes, resetMinutes };
|
||
}
|
||
|
||
/**
|
||
* The CME evening session's first entry, in Central minutes: 5:45 PM, 45
|
||
* minutes after the 5 PM roll. Tradovate moves each account's cash record to
|
||
* the new trade date on its own schedule — an idle one perhaps not until it
|
||
* trades — and the bot holds any account still on the old day until 5:45
|
||
* (tradovate-class ROLL_WINDOW_END_MIN). Opening then puts every account in
|
||
* the first batch, instead of the ready ones firing alone and the rest
|
||
* waiting for them to go flat, and skips the open's widest spreads.
|
||
*/
|
||
export const CME_EVENING_OPEN_MIN = 17 * 60 + 45;
|
||
/** Every-day firms' roll guard ends here (plus the day's delay): they have
|
||
* no Tradovate roll to wait for. */
|
||
const EVERY_DAY_OPEN_MIN = 17 * 60 + 5;
|
||
|
||
/**
|
||
* Whether no new trade may open right now.
|
||
*
|
||
* CME firms follow the trading_hours setting:
|
||
* "full_cme" — Sun 5:45 PM – Fri 2:55 PM Central (see CME_EVENING_OPEN_MIN)
|
||
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
|
||
* with the open pushed back by the day's 0–180 minute delay.
|
||
*
|
||
* Every-day firms ignore trading_hours and close only around the day's roll:
|
||
* from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to
|
||
* 5:05 PM CT + jitter. In CST that is 3:55 → 5:05 PM, which keeps new entries
|
||
* out of the hour where the firm's day (reset at 4 PM CT) and our trade date
|
||
* (5 PM CT) disagree.
|
||
*/
|
||
export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean {
|
||
const { day, minutes, resetMinutes } = centralClock(now);
|
||
const cmeOpen = CME_EVENING_OPEN_MIN + o.jitterMin;
|
||
|
||
if (o.everyDay) {
|
||
const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN;
|
||
return minutes >= guardStart && minutes < EVERY_DAY_OPEN_MIN + o.jitterMin;
|
||
}
|
||
|
||
if (o.mode === 'equity_hours') {
|
||
if (day === 'Sat' || day === 'Sun') return true;
|
||
const open = 8 * 60 + 35 + o.jitterMin;
|
||
const close = 14 * 60 + 55;
|
||
return minutes < open || minutes >= close;
|
||
}
|
||
|
||
// Full CME: Sun 5:45 PM (plus the delay) – Fri 2:55 PM Central.
|
||
if (day === 'Sat') return true;
|
||
if (day === 'Sun') return minutes < cmeOpen;
|
||
const cmeClose = 14 * 60 + 55;
|
||
if (day === 'Fri' && minutes >= cmeClose) return true;
|
||
// Mon–Thu: block 2:55 PM – 5:45 PM Central (early stop, daily halt, Tradovate's roll).
|
||
return minutes >= cmeClose && minutes < cmeOpen;
|
||
}
|
||
|
||
/** The trade date's session has closed and the 5 PM roll hasn't opened the
|
||
* next one: weekdays 2:55–5:00 PM CT on the CME calendar, the pre-roll guard
|
||
* for an every-day firm. */
|
||
export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
|
||
const { day, minutes, resetMinutes } = centralClock(now);
|
||
if (everyDay) return minutes >= Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN && minutes < 17 * 60;
|
||
if (day === 'Sat' || day === 'Sun') return false;
|
||
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
|
||
}
|