Skip to content

fix: kelly_criterion missing avg-loss scaling, rf routed via inspect.stack() - #542

Open
aleks-drozy wants to merge 1 commit into
ranaroussi:mainfrom
aleks-drozy:fix/kelly-criterion-rf-stack-inspection-537
Open

fix: kelly_criterion missing avg-loss scaling, rf routed via inspect.stack()#542
aleks-drozy wants to merge 1 commit into
ranaroussi:mainfrom
aleks-drozy:fix/kelly-criterion-rf-stack-inspection-537

Conversation

@aleks-drozy

Copy link
Copy Markdown

Fixes #537

Root cause

1. kelly_criterion() was off by a factor of the average-loss magnitude.

win_loss_ratio = payoff_ratio(returns)   # avg_win / |avg_loss|
win_prob = win_rate(returns)
lose_prob = 1 - win_prob
return ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio

This simplifies to win_prob - lose_prob / win_loss_ratio, a function of the win/loss ratio only, so it is scale-invariant by construction. The textbook growth-optimal Kelly fraction for a two-outcome return series is f* = win_prob/|avg_loss| - lose_prob/avg_win, which factors to (win_prob - lose_prob/win_loss_ratio) / |avg_loss| — the code was missing the final division by |avg_loss()|.

2. rf was routed by inspecting the call stack, and cagr was unconditionally excluded regardless of the rf the caller passed.

_prepare_returns() used function = inspect.stack()[1][3] to get the caller's function name and hardcoded "cagr" into a unnecessary_function_calls skip-list, so rf was silently ignored whenever _prepare_returns was called from cagr — even though cagr takes an rf parameter and is documented as computing CAGR "of excess returns", and reports.py already calls cagr(df, rf, ...) expecting rf to matter. cagr(r, rf=0.0) and cagr(r, rf=0.5) returned identical values. Separately, if rf > 0 silently dropped any negative rf.

Fix

  • kelly_criterion: divide the existing (ratio-only) result by abs(avg_loss(returns)) in both the scalar and DataFrame branches, with the same zero/NaN guarding already used for win_loss_ratio.
  • _prepare_returns: replace the inspect.stack()-based dispatch with an explicit apply_rf: bool = True parameter. Call sites that previously relied on being named "_prepare_benchmark", "gain_to_pain_ratio", or "rolling_volatility" now pass apply_rf=False explicitly, preserving their existing (rf-not-applied) behavior. cagr's call site is left at the default (apply_rf=True), so rf is now actually subtracted to compute excess returns, matching its docstring and how reports.py already calls it. Changed the guard from if rf > 0 to if apply_rf and rf != 0 so negative rf is honored too. The _prepare_returns result cache key now also includes apply_rf, since two calls with identical (data, rf, nperiods) can legitimately want different treatment depending on the caller — this was a latent cache-poisoning bug in the old mechanism as well.

Testing

Added to tests/test_stats.py::TestRatios:

  • test_kelly_criterion: deterministic two-outcome fixture (60 wins @ +2%, 40 losses @ -1%) with a closed-form expected value of 40.0, plus a scale-dependence check (kelly(r*0.5) == 2*kelly(r), kelly(r*0.1) == 10*kelly(r)).
  • test_cagr_with_rf: asserts cagr(r, rf=0.02) != cagr(r, rf=0.0) and that a positive rf reduces the excess-return CAGR.

Both tests were confirmed to fail against the pre-fix code (verified by stashing the fix and re-running) and pass after.

Ran the directly-touched test slice locally:

  • tests/test_stats.py + tests/test_utils.py: 49 passed, 0 failed (both before-fix baseline minus the 2 new tests, and after-fix full run).
  • Also spot-checked end-to-end via reports.metrics(returns, benchmark=..., rf=0.03, mode='full'), cagr with rf of 0, 0.03, and -0.01 (all now differ correctly), gain_to_pain_ratio, and rolling_volatility — all compute without error and match prior (unapplied-rf) behavior where intended.

Note: tests/test_reports.py shows pre-existing flakiness in this sandboxed Windows environment unrelated to this change — different tests intermittently fail/hang with a Tk/GUI backend error ("This probably means that tk wasn't installed properly") both with and without this fix applied (confirmed by re-running against the unmodified code). The specific test in isolation passes reliably.

…stack()

kelly_criterion() computed ((win_loss_ratio * win_prob) - lose_prob) /
win_loss_ratio, which simplifies to a function of the win/loss *ratio*
only, making it scale-invariant and off by a factor of |avg_loss()|
versus the textbook growth-optimal Kelly fraction
f* = win_prob/|avg_loss| - lose_prob/avg_win. Fix divides the existing
result by abs(avg_loss(returns)), guarded the same way win_loss_ratio
already is.

Separately, _prepare_returns() decided whether to apply `rf` by
inspecting inspect.stack()[1][3] (the caller's function name) against a
hardcoded list, and only excluded "cagr" unconditionally regardless of
what rf the caller passed - so cagr(r, rf=0.5) silently equalled
cagr(r, rf=0.0). `if rf > 0` also silently dropped negative rf. Replaced
the stack inspection with an explicit apply_rf parameter set at each call
site (_prepare_benchmark, gain_to_pain_ratio, rolling_volatility keep
rf unapplied as before; cagr now applies it, matching its documented
"excess returns" behavior and how reports.py already calls it), and
changed the guard to `rf != 0`. The cache key now includes apply_rf to
avoid caching collisions between callers with the same (data, rf,
nperiods) but different intent.

Adds test_cagr_with_rf and test_kelly_criterion to TestRatios; both
fail on the pre-fix code and pass after.

Fixes ranaroussi#537
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kelly_criterion is off by a factor of the average loss, and rf is routed by inspecting the call stack

1 participant