edgekit

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.

The prime unit
The R-multiple is the currency of the whole library. Strategies emit trades priced in R; metrics, validation, and the permutation test all operate on R-streams. Dollars enter only in sizing, from one scalar — never baked into the strategy.

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:

engine.py (excerpt)
# 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 R

gross 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:

costs.py (excerpt)
# 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_unit

Because 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#

r_example.py
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.955

A 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:

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

The 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.

QuantityR-spaceConverted
One trader = +1.955× dollar_per_r → dollars
Annual returnann_rR/yr × dollar_per_r ÷ account → %/yr
Max drawdownmax_dd_r= dd_budget × account by construction
!The size is a ceiling, not a promise
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#