Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection

- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
  Trade/search daily P&L, emulated OCO exits, books-invariant
  withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
  credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
  configured days, then wait for the 50% withdrawal; stage pills
  show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
  planned targets, simulated 50% withdrawals, payout marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-12 16:25:52 -05:00
co-authored by Claude Fable 5
parent 1bca441247
commit 50a5eb4713
21 changed files with 1854 additions and 142 deletions
+208 -11
View File
@@ -1,3 +1,23 @@
export interface WithdrawalStage {
profit: number;
consistency: number;
minTradingDays: number;
/** Spam-withdraw stage: no profit target — book minTradingDays qualifying
* days of ~minDayPnL (tiny MNQ trades), then wait for the withdrawal. */
drain?: boolean;
/** Drain stages only: the per-day booked amount (default $5; randomized
* $5–$15 by drainDayAmount). */
minDayPnL?: 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.
*
@@ -6,42 +26,53 @@
* 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.
* 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: { profit: number; consistency: number; minTradingDays: number }[],
withdrawalStages: WithdrawalStage[],
priorProfit: number,
fundTransactions: { date: string; amount: number }[]
): { profitTarget: number; consistency: number; minTradingDays: 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 };
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 }; // Stage 1 = base values
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values
}
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
const stage = withdrawalStages[idx];
if (stage.drain === true) {
return {
profitTarget: 0,
consistency: 0,
minTradingDays: stage.minTradingDays || minTradingDays,
minDayPnL: stage.minDayPnL || 5,
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 };
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, minDayPnL, drain: false };
}
return { profitTarget, consistency, minTradingDays };
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
}
/** @deprecated Use resolveEffectiveConfig instead */
export function resolveEffectiveProfitTarget(
profitTarget: number,
targetSameEquity: boolean,
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[],
withdrawalStages: WithdrawalStage[],
priorProfit: number,
fundTransactions: { date: string; amount: number }[]
): number {
@@ -96,6 +127,22 @@ export function currentTradeDateCT(now: Date = new Date()): string {
return new Date(date).toISOString().slice(0, 10);
}
/**
* Drain-day target: the stage's min-day amount plus a deterministic bump of
* $0/$5/$10 — so a $5 min day trades as $5, $10 or $15 depending on the
* (account, trade date) pair. Seeded like dailyBump so every consumer of the
* plan computes the same number.
*/
export function drainDayAmount(accountId: number, tradeDate: string, minDay: number): 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 minDay + (hash % 3) * 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
@@ -277,6 +324,137 @@ export function combineCmeSessions(
};
}
// ── 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;
}
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
function nextTradingDate(d: string): string {
const dt = new Date(`${d}T00:00:00Z`);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
return dt.toISOString().slice(0, 10);
}
/**
* Simulate the account's future assuming every day goes perfectly: each
* trading day books exactly its planned target (deterministic bumps included,
* so the numbers match what the trader would actually aim for), stages
* complete, a 50% withdrawal fires the day a cycle finishes, and drain cycles
* repeat. Built on the same computeDayPlan/resolveEffectiveConfig the live
* trade path uses — the projection can't drift from reality's rules.
*
* An account without withdrawal stages stops at its first completion, marked
* payoutReady. Projection starts on the next trading day (today's cell shows
* live data); today's remaining target is booked silently first.
*/
export function projectPerfectPath(opts: {
accountId: number;
cfg: {
profitTarget: number; consistency: number; minDayPnL: number;
minTradingDays: number; accountSize: number; targetSameEquity: boolean;
withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
};
/** 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;
}): ProjectedDay[] {
const { accountId, cfg } = opts;
const horizon = opts.horizonDays ?? 40;
const maxWithdrawals = opts.maxWithdrawals ?? 6;
const today = currentTradeDateCT();
let sim = opts.cycleDailyPnL.filter((d) => d.date !== today);
if (opts.realizedPnL !== 0) sim = [...sim, { date: today, pnl: opts.realizedPnL }];
else sim = [...sim];
const funds = [...opts.fundTransactions];
let balance = opts.balance;
let withdrawals = 0;
const out: ProjectedDay[] = [];
const effectiveNow = () => resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
);
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.
const todayDow = new Date(`${today}T00:00:00Z`).getUTCDay();
if (todayDow !== 0 && todayDow !== 6) {
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) {
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);
}
}
let date = today;
for (let step = 0; step < horizon; step++) {
date = nextTradingDate(date);
const eff = effectiveNow();
const stage = 1 + funds.filter((f) => f.amount < 0).length;
const plan = computeDayPlan(
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
);
if (!plan) break;
const daysNeeded = eff.minTradingDays > qualifyingDays(eff.minDayPnL, date);
if (plan.dayTarget <= 0 && !daysNeeded) {
// Cycle complete — nothing left to trade.
const withdrawal = -round2(balance / 2);
if (cfg.withdrawalStages.length > 0 && withdrawals < maxWithdrawals && withdrawal < -1) {
funds.push({ date, amount: withdrawal });
balance = round2(balance + withdrawal);
sim = []; // withdrawal excludes its day — fresh cycle
withdrawals++;
out.push({ date, pnl: 0, stage, drain: eff.drain, withdrawal });
continue;
}
out.push({ date, pnl: 0, stage, drain: eff.drain, payoutReady: true });
break;
}
// Extra days (min-day 0, days still owed) book a token dollar so the
// day counts, matching the real in-and-out MNQ trade.
const pnl = plan.dayTarget > 0 ? plan.remainingToday : 1;
sim.push({ date, pnl });
balance = round2(balance + pnl);
out.push({ date, pnl, stage, drain: eff.drain });
}
return out;
}
/** Dollar-per-point value for common futures products. */
export const POINT_VALUES: { [symbol: string]: number } = {
NQ: 20, MNQ: 2, ES: 50, MES: 5,
@@ -438,11 +616,30 @@ export function computeDayPlan(
minTradingDays: number,
equityProfit: number | null | undefined,
realizedPnL: number,
splitEnabled: boolean = true
splitEnabled: boolean = true,
drain: boolean = false
): DayPlan | null {
if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null;
const priorDays = dailyPnL.filter((d) => d.date !== tradeDate);
// Drain stage: no profit target at all. Book minTradingDays qualifying
// days at the randomized min-day amount, then go quiet until the next
// withdrawal resets the cycle. One unsplit tiny trade per day.
if (drain) {
const qualifying = minDayPnL > 0
? priorDays.filter((d) => d.pnl >= minDayPnL)
: priorDays.filter((d) => d.pnl !== 0);
const dayTarget = qualifying.length >= minTradingDays
? 0
: drainDayAmount(accountId, tradeDate, minDayPnL || 5);
return {
dayTarget,
remainingToday: round2(dayTarget - realizedPnL),
path: 'reduced_day',
splitAllowed: false,
};
}
const dayStartEquity = equityProfit - realizedPnL;
const target = computeDailyTarget(
profitTarget, consistency, priorDays, minDayPnL, minTradingDays,