R-multiples
edgekit prices every trade in R — profit measured in units of the risk it put up. A strategy never knows how many dollars it is trading; dollars are applied once, at the very end, from a single sizing scalar. This one convention is what makes strategies comparable and sizing honest.
R is the trade's profit or loss divided by the distance to its initial stop. If you enter at 100 with a stop at 96, your risk unit is 4 price-points. Exit at 108 and you made +2R; get stopped at 96 and you lost -1R. The dollar value of one R is a decision you make later and separately — the strategy is indifferent to it.
Why R, and why sizing is a separate layer#
Baking dollars (or a fixed lot size, or a fixed percent-of-equity) into a backtest quietly couples two things that must stay separate: the edge (does this pattern make money per unit of risk?) and the bet size (how much do I risk given my drawdown budget?). Keeping the strategy in R means:
- Strategies are comparable. A +0.4R/trade system on one instrument and a +0.15R/trade system on another live on the same axis, regardless of price level, volatility, or account size.
- Sizing is honest. You size the R-stream to a real drawdown budget once (see size_to_dd), instead of discovering after the fact that the backtest secretly assumed 3× leverage.
- Costs are in the same unit as P&L. Spread and swap are charged in R too (below), so a trade's net R already reflects what it actually costs to hold.
How a Trade's r is computed#
The engine (run_bar_loop) fills an entry, tracks the stop distance as the R denominator, and on exit computes gross R, subtracts cost in R, and stores the net figure on the Trade dataclass. The core of the loop is exactly:
# on exit at bar i, with position pos = {"d": dir, "e": entry, "rpx": stop_dist, "ei": entry_i}
days = (i - pos["ei"]) / bars_per_day
gross = pos["d"] * (ex - pos["e"]) / pos["rpx"] # signed P&L in stop-distance units = R
r = gross - cost.r_cost(pos["e"], pos["rpx"], days) # net of cost, still in Rgross is the directional price move (exit − entry, signed by direction) divided by stop_dist — the same denominator the stop is set from, so a full adverse move to the stop is exactly -1R by construction. cost.r_cost(...) converts the round-trip spread and per-day swap into R using that identical denominator:
# CostModel.r_cost — dollar cost divided by the risk unit gives cost in R
def r_cost(self, entry_price, risk_per_unit, days):
if risk_per_unit <= 0:
return 0.0
return (self.spread_rt * entry_price
+ self.swap_day * entry_price * days) / risk_per_unitBecause both the gross P&L and the cost are divided by risk_per_unit (the stop distance), the stored r is a pure, dollar-free number. The default CostModeluses the library's default convention: spread_rt=0.0012 (12 bps round trip) and swap_day=0.0002 per day held.
A worked example#
import edgekit as ek
entry = 100.0
stop_dist = 4.0 # e.g. 2 * ATR — the R denominator
exit_px = 108.0
direction = 1 # long
gross_r = direction * (exit_px - entry) / stop_dist # (108-100)/4 = +2.0 R
cost = ek.costs.CostModel() # 12 bps spread, 2 bps/day swap
c_r = cost.r_cost(entry_price=entry, risk_per_unit=stop_dist, days=3)
# (0.0012*100 + 0.0002*100*3) / 4 = (0.12 + 0.06)/4 = 0.045 R
net_r = gross_r - c_r # +2.0 - 0.045 = +1.955 R
print(round(net_r, 3)) # 1.955A clean +2R idea nets +1.955R after three days of holding cost. Note what did not appear anywhere: an account size, a lot size, or a dollar figure. That trade is +1.955R whether you eventually risk $50 or $5,000 per R.
From R to dollars and percent#
The one place dollars enter is sizing. A stream of daily-summed R is handed to dd_matched_size (or its wrapper size_to_dd), which solves for the single dollar_per_rscalar that makes the strategy's historical max drawdown equal to your drawdown budget:
import edgekit as ek
# trades.r is the per-trade net-R series from the engine; collapse to daily R
daily_r = trades.set_index("date")["r"].groupby(lambda t: t.normalize()).sum()
sized = ek.sizing.size_to_dd(
daily_r,
dd_budget = 0.095, # let the worst historical drawdown be 9.5% of the account
account = 100_000,
daily_cap = 0.045, # ...but never risk more than a 4.5% worst-day either
)
print(sized["dollar_per_r"]) # dollars to risk per 1R
print(sized["binding"]) # "max-dd" or "daily" — which constraint set the size
print(sized["sized"]) # the daily P&L series in dollarsThe math is deliberately simple and lives in exactly one place. dd_matched_size takes the realised max drawdown of the cumulative-R curve and scales so that dd_budget × account / max_dd_r dollars are risked per R. When a daily_cap is given, the tighter of the two constraints (worst-day vs. max-drawdown) wins, and the returned binding tells you which one bound. To read the result as a percent, divide the sized dollar P&L by the account.
| Quantity | R-space | Converted |
|---|---|---|
| One trade | r = +1.955 | × dollar_per_r → dollars |
| Annual return | ann_r | R/yr × dollar_per_r ÷ account → %/yr |
| Max drawdown | max_dd_r | = dd_budget × account by construction |
dd_matched_size fits the size to the single realised historical drawdown — the luckiest possible number. A live drawdown almost always runs deeper. Prefer sizing against the block-bootstrap dd95 (a bad-but-not-tail drawdown), and apply the honest forward haircut from the gauntlet before you trust the dollar figure.See also#
- edgekit.core — the
Trade/Signaldataclasses and the R contract. - edgekit.metrics —
trade_statsanddd_matched_sizeoperate directly on R-streams. - edgekit.sizing — the only layer that turns R into dollars.
- The validation gauntlet — how you decide the R-stream is real before you size it.