// Prop-account simulator — rules and overlays frozen in PREREG-2026-09-16.md. // Reads out/trades-.json from engine.mjs, writes out/prop-summary.json. // Implementation notes (not rule changes): funded accounts start fresh at $50,000 the trading day // after a pass (as both firms do); attempts start on every trading day of a half and use only that // half's days — an attempt still unresolved at the half's last day is CENSORED and excluded from rates. import fs from "node:fs"; import path from "node:path"; const OUT = path.join(path.dirname(new URL(import.meta.url).pathname), "out"); const START = 50_000, TARGET = 3_000; const OVERLAYS = process.env.SIZES ? process.env.SIZES.split(",").map((k) => `S${k}`) : ["O0", "O1", "O2", "O3", "O4", "O5"]; function sizeFor(ov, tr, st) { if (ov.startsWith("S")) return +ov.slice(1); // POST-HOC size sensitivity (addendum): fixed micros per trade const profit = st.balance - START; if (ov === "O1") { const k = Math.floor(25 / tr.stopRiskUsd); return k > 0 ? k : 0; } let k = 1; if (ov === "O2" || ov === "O5") k = st.phase === "eval" && profit >= 1_000 && profit < TARGET - 500 ? 10 : 1; if ((ov === "O3" || ov === "O5") && st.dayPnl >= 1_500) return 0; if ((ov === "O4" || ov === "O5") && (st.dayLosses >= 2 || st.dayPnl >= 300)) return 0; return k; } // One attempt from day index d0. Returns { status: pass/fail/timeout/censored, payout, fees, daysEval, daysFunded } function attempt(firm, ov, days, d0) { const topstep = firm === "topstep"; const trail = topstep ? 2_000 : 2_500, lock = topstep ? START : START + 100; const evalTimeout = 60, fundedTimeout = 120; let st = { phase: "eval", balance: START, high: START, eodHigh: START, bestDay: 0, dayPnl: 0, dayLosses: 0, daysTraded: 0, winDays150: 0, days50: 0, maxDay: 0, dayIdx: 0 }; const threshold = () => (topstep ? Math.min(st.eodHigh - trail, lock) : Math.min(st.high - trail, lock)); let passDay = null, evalDays = 0, fundedDays = 0; for (let d = d0; d < days.length; d++) { const phaseDays = st.phase === "eval" ? ++evalDays : ++fundedDays; if (st.phase === "eval" && phaseDays > evalTimeout) return { status: "timeout", payout: 0, evalDays: evalDays - 1, fundedDays: 0 }; if (st.phase === "funded" && phaseDays > fundedTimeout) return { status: "funded-timeout", payout: 0, evalDays, fundedDays: fundedDays - 1 }; st.dayPnl = 0; st.dayLosses = 0; let traded = false; for (const tr of days[d].trades) { const k = sizeFor(ov, tr, st); if (!k) continue; traded = true; const lowEq = st.balance - k * tr.maeUsd, peakEq = st.balance + k * tr.mfeUsd; if (topstep) { if (lowEq <= threshold()) return { status: st.phase === "eval" ? "fail" : "funded-fail", payout: 0, evalDays, fundedDays }; } else { if (tr.mfeFirst) { st.high = Math.max(st.high, peakEq); if (lowEq <= threshold()) return { status: st.phase === "eval" ? "fail" : "funded-fail", payout: 0, evalDays, fundedDays }; } else { if (lowEq <= threshold()) return { status: st.phase === "eval" ? "fail" : "funded-fail", payout: 0, evalDays, fundedDays }; st.high = Math.max(st.high, peakEq); } } const pnl = k * tr.net; st.balance += pnl; st.dayPnl += pnl; if (pnl < 0) st.dayLosses++; st.high = Math.max(st.high, st.balance); } // end of day if (traded) st.daysTraded++; st.eodHigh = Math.max(st.eodHigh, st.balance); if (st.balance <= threshold()) return { status: st.phase === "eval" ? "fail" : "funded-fail", payout: 0, evalDays, fundedDays }; st.bestDay = Math.max(st.bestDay, st.dayPnl); if (st.dayPnl >= 150) st.winDays150++; if (st.dayPnl >= 50) st.days50++; const profit = st.balance - START; if (st.phase === "eval") { const consistencyOk = topstep ? st.bestDay <= 0.5 * profit : true; const minDays = topstep ? 2 : 1; if (profit >= TARGET && st.daysTraded >= minDays && consistencyOk) { passDay = d; st = { phase: "funded", balance: START, high: START, eodHigh: START, bestDay: 0, dayPnl: 0, dayLosses: 0, daysTraded: 0, winDays150: 0, days50: 0 }; } } else { if (topstep && st.winDays150 >= 5 && profit > 0) return { status: "payout", payout: Math.min(0.5 * profit, 5_000), evalDays, fundedDays, passDay }; if (!topstep && st.daysTraded >= 8 && st.days50 >= 5 && st.balance >= 52_600 && st.bestDay <= 0.3 * profit) { const p = Math.min(st.balance - 52_600, 2_000); if (p > 0) return { status: "payout", payout: p, evalDays, fundedDays, passDay }; } } } return { status: "censored", payout: 0, evalDays, fundedDays }; } const summary = {}; for (const root of ["NQ", "ES"]) { const f = `${OUT}/trades-${root}.json`; if (!fs.existsSync(f)) { console.error(`missing ${f}`); continue; } const trades = JSON.parse(fs.readFileSync(f, "utf8")); const calendar = { IS: new Set(), OOS: new Set() }; for (const t of trades) calendar[t.half].add(t.date); const arms = [...new Set(trades.map((t) => t.arm))].sort(); summary[root] = {}; for (const arm of arms) { const mine = trades.filter((t) => t.arm === arm).sort((a, b) => a.entryMs - b.entryMs); summary[root][arm] = {}; for (const half of ["IS", "OOS"]) { const cal = [...calendar[half]].sort(); const byDate = new Map(cal.map((d) => [d, []])); for (const t of mine) if (t.half === half) byDate.get(t.date)?.push(t); const days = cal.map((d) => ({ date: d, trades: byDate.get(d) })); for (const firm of ["topstep", "apex"]) { for (const ov of OVERLAYS) { let n = 0, passes = 0, payouts = 0, payoutSum = 0, evalDaysSum = 0, censored = 0; for (let d0 = 0; d0 < days.length; d0++) { const r = attempt(firm, ov, days, d0); if (r.status === "censored") { censored++; continue; } n++; if (r.status === "payout" || r.status === "funded-fail" || r.status === "funded-timeout") passes++; if (r.status === "payout") { payouts++; payoutSum += r.payout; } evalDaysSum += r.evalDays; } const pPass = passes / (n || 1), pPay = payouts / (n || 1), ePay = payouts ? payoutSum / payouts : 0; const monthsEval = evalDaysSum / (n || 1) / 21; const evalFeeMonths = Math.max(1, Math.ceil(monthsEval)); // at least one billing period const ev = topstepOrApex(firm, { pPass, pPay, ePay, evalFeeMonths: evalDaysSum / (n || 1) / 21 }); summary[root][arm][`${half}|${firm}|${ov}`] = { n, censored, pPass: +pPass.toFixed(4), pPayoutPerAttempt: +pPay.toFixed(4), pPayoutGivenPass: +(passes ? payouts / passes : 0).toFixed(4), meanPayout: +ePay.toFixed(0), meanEvalDays: +(evalDaysSum / (n || 1)).toFixed(1), ev }; } } } process.stdout.write(`${root} ${arm} done\n`); } } function topstepOrApex(firm, { pPass, pPay, ePay, evalFeeMonths }) { const periods = Math.max(1, Math.ceil(evalFeeMonths)); if (firm === "topstep") return { act0: +(pPay * ePay - 49 * periods).toFixed(1), act149: +(pPay * ePay - 49 * periods - pPass * 149).toFixed(1), }; const out = {}; for (const fee of [35, 167]) for (const act of [0, 85, 140]) out[`fee${fee}_act${act}`] = +(pPay * ePay - fee * periods - pPass * act).toFixed(1); return out; } fs.writeFileSync(process.env.SIZES ? `${OUT}/prop-size-sensitivity.json` : `${OUT}/prop-summary.json`, JSON.stringify(summary, null, 1)); console.log("wrote prop-summary.json");