Sizing for a prop-firm challenge
A validated edge is priced in R. A prop-firm challenge is priced in dollars, with a daily-loss limit and a max-drawdown limit that both hard-fail you. This guide bridges the two: build a daily-R stream, size it to a dual-constraint drawdown budget, then Monte-Carlo the actual challenge to a pass rate — and applies the honest forward haircut before you believe any of it.
Step 1 — from trades to a daily-R stream#
Sizing operates on a daily-R series: one row per calendar day, holding that day's summed trade R, indexed by date. Days with no trades contribute 0.
import edgekit as ek
# 'trades' is the canonical trade frame from a strategy that PASSED the gauntlet.
# Sizing is the step you only reach once the edge is real - never size an unproven one.
trades = strat.backtest(bars)
stats = ek.trade_stats(trades.r.to_numpy(), dates=trades.date)
daily_r = trades.set_index("date").r.groupby(lambda t: t.normalize()).sum()
print(daily_r.describe())Step 2 — size to a drawdown budget#
sizing.size_to_ddfinds the single dollars-per-R scalar that makes the strategy's worst historical drawdown equal a fraction of the account — the drawdown budget. A 10% prop limit is usually sized to 0.095 to leave a buffer. When a daily_cap is also given (e.g. 0.045 under a 5% daily limit), it is a dual-constraint problem — the tighter of the two constraints binds and wins.
sz = ek.sizing.size_to_dd(daily_r, dd_budget=0.095, account=100_000, daily_cap=0.045)
print(f"${sz['dollar_per_r']:,.0f}/R binds on {sz['binding']} max_dd_r={sz['max_dd_r']:.1f}")
# illustrative: $940/R binds on max-dd max_dd_r=9.9
daily_pnl = sz["sized"] # the dollar P&L series, one value per day
ann_pct = daily_r.sum() * sz["dollar_per_r"] / stats["years"] / 100_000
print(f"{ann_pct:+.1%}/yr (backtest ceiling)") # the sized ceiling, before the haircutThe returned dict:
| Key | Meaning |
|---|---|
sized | the dollar P&L series after applying the scalar |
dollar_per_r | dollars risked per 1R — the sizing decision |
binding | "max-dd" or "daily" — which limit set the size |
max_dd_r | the historical max drawdown in R the sizing was matched to |
Here binding = "max-dd": the 10% overall drawdown is the tighter constraint, so the daily cap has slack. If binding came back "daily", the strategy has a lumpy worst-day and you are being throttled by the daily limit — a sign to smooth the P&L (fewer, smaller-risk trades) before you can use your full drawdown budget.
ek.validation.dd95(daily_r) — the 95th-percentile drawdown from a block-bootstrap — and size against that instead of the single path history happened to take.Step 3 — simulate the actual challenge#
A prop challenge is a path problem, not an average-return problem: you pass only if no path breaches the daily or max-loss limit before hitting the profit target. The only honest answer is Monte-Carlo over resampled paths. challenge.simulate block-bootstraps the sized daily-P&L stream through a rules object and returns the fraction of paths that clear every phase.
edgekit ships three real presets, all $100k accounts:
| Preset | phase_targets | daily_loss | max_loss | min_days |
|---|---|---|---|---|
FTMO_1STEP | [10_000] | 5_000 | 10_000 | 4 |
CFT_2PHASE | [8_000, 5_000] | 5_000 | 10_000 | 4 |
BRIGHTFUNDED | [8_000, 5_000] | 5_000 | 10_000 | 3 |
from edgekit import challenge
rate = challenge.simulate(daily_pnl, challenge.FTMO_1STEP)
print(f"FTMO 1-step pass rate: {rate:.0%}")
# distribution of how long a pass takes, over the paths that pass
d = challenge.days_to_pass(daily_pnl, challenge.BRIGHTFUNDED)
print(f"pass {d['pass_rate']:.0%} median {d['median']:.0f} days "
f"p25 {d['p25']:.0f} p75 {d['p75']:.0f}")simulate returns a single pass-rate float; days_to_pass returns median, p25, p75, mean, pass_rate, n_pass. Both take daily_pnl_dollars — P&L after sizing, in dollars — because the breach limits are absolute dollars. The block bootstrap (default block=5) keeps losing streaks intact; i.i.d. resampling would understate the risk of a bad run tripping the daily limit.
challenge.ChallengeRules(account=50_000, phase_targets=[4_000], daily_loss=2_500, max_loss=5_000, min_days=5). Presets are just pre-filled instances.Sizing overlays — governing the equity path#
size_to_dd sets a constant dollars-per-R. On top of that, the sizing module has path-dependent governors that press or cut risk as the equity curve moves. They take a return / P&L series and return a scaled one:
| Overlay | What it does | Use when |
|---|---|---|
risk_parity(M) | inverse-vol weights across a book of R-streams | combining multiple strategies |
vol_target(port, cap=1.5) | scale toward constant volatility, leverage-capped | smoothing a single lumpy book |
cppi(pnl, account, mstop) | risk in proportion to the cushion above a trailing floor | aggressive challenge push with a hard floor |
dd_throttle(ret, thresh=0.04) | halve risk while more than 4% below peak | surviving drawdowns during an evaluation |
# example: throttle risk 50% whenever the curve is >4% below its high-water mark
returns = daily_pnl / 100_000
throttled = ek.sizing.dd_throttle(returns, thresh=0.04, factor=0.5)
# or a CPPI cushion for an aggressive challenge run with a 10% floor
cppi_pnl = ek.sizing.cppi(daily_pnl, account=100_000, mstop=0.10, slope=2.0, cap=1.5)
rate = challenge.simulate(cppi_pnl, challenge.CFT_2PHASE)0.85 × the sized backtest return: forward slippage, regime drift, and the simple fact that you optimised towardthis history all eat into it. edgekit's reports carry this haircut explicitly — never quote the ceiling as if it were the plan.Next#
- API · sizing — size_to_dd, vol_target, cppi, dd_throttle, risk_parity, hrp.
- API · challenge — presets, simulate, days_to_pass, ChallengeRules.
- Reports & charts — render the Challenge / Live / Realistic trio.
- Proving an edge — do this first; never size an unproven edge.