edgekit

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.

size.py
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 haircut

The returned dict:

KeyMeaning
sizedthe dollar P&L series after applying the scalar
dollar_per_rdollars risked per 1R — the sizing decision
binding"max-dd" or "daily" — which limit set the size
max_dd_rthe 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.

Size against the honest drawdown
The realised historical max drawdown is one lucky sample. For a conservative size, compute 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:

Presetphase_targetsdaily_lossmax_lossmin_days
FTMO_1STEP[10_000]5_00010_0004
CFT_2PHASE[8_000, 5_000]5_00010_0004
BRIGHTFUNDED[8_000, 5_000]5_00010_0003
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.

Build a custom rules object
Any evaluation fits the same dataclass: 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:

OverlayWhat it doesUse when
risk_parity(M)inverse-vol weights across a book of R-streamscombining multiple strategies
vol_target(port, cap=1.5)scale toward constant volatility, leverage-cappedsmoothing a single lumpy book
cppi(pnl, account, mstop)risk in proportion to the cushion above a trailing flooraggressive challenge push with a hard floor
dd_throttle(ret, thresh=0.04)halve risk while more than 4% below peaksurviving 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)
!The forward haircut is not optional
A drawdown-matched backtest is a ceiling, not an expectation. The number you plan around is roughly 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#