feat: pyramid_1r_v1 experiment — add-into-strength, pre-registered

New 'pyramid' intervention kind in the replay engine: at +add_at_r on
initial risk, add add_frac x size (+adverse fill), whole-position stop to
blended breakeven; scratch there if a later bar trades through, else both
legs exit at the baseline exit. variant_r is per unit DEPLOYED
(P&L / ((1+add_frac) x risk)) so leverage alone cannot fake a pass.

Spec pyramid_1r_v1: add 0.5x at +1R, bar expR_delta >= +0.05 at n >= 300,
futility kill at n >= 500 below -0.02. Hypothesis notes the known failure
mode (median winners scratched at blended BE on first pullback). Shadow
only — no live-path changes; cohort self-registers on the next nightly
experiments run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-31 11:33:59 -05:00
1 parent 733a182f10
commit 054405dbdf
3 files changed
+108

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
{
"id": "pyramid_1r_v1",
"hypothesis": "Adding 0.5x size when an open trade reaches +1R (whole-position stop to blended breakeven) beats hold-initial-size per unit of deployed risk. The trader's 'add into strength' pattern; known failure mode is converting median winners into breakeven scratches on the first pullback (his NWE giveback, 2026-07-30).",
"population": {"min_entry_gap_pct": 0.10, "min_day_volume": 300000, "price_min": 2.0, "price_max": 20.0},
"intervention": {"kind": "pyramid", "add_at_r": 1.0, "add_frac": 0.5},
"bars": {"expR_delta_min": 0.05, "min_n": 300},
"futility": {"min_n": 500, "expR_delta_max": -0.02},
"fidelity": {"atr_frac": 0.25, "spread_mult": 3, "adverse_ticks": 1}
}
+45
View File
@@ -298,6 +298,49 @@ def iv_probe_add(fire, bars_after, trigger_high, params, fidelity):
return r, {"kind": "probe_add", "added": add_fill is not None, "add_fill": add_fill}
def iv_pyramid(fire, bars_after, params, fidelity):
"""Add into strength (the trader's 'double dip'): when the trade reaches
+add_at_r on the initial risk, add add_frac x size at that level (+adverse);
the WHOLE position's stop moves to the blended breakeven. If a later bar
trades through blended BE, everything exits there (-adverse); otherwise both
legs exit at the baseline exit. variant_r is per unit DEPLOYED
(total P&L / ((1+add_frac) x risk)) so leverage alone can't fake a pass —
the add must beat the baseline per dollar at risk."""
if fire.exit_price is None:
return None, {"kind": "pyramid", "reason": "no_exit"}
risk = fire.entry_price - fire.stop_price
if risk <= 0 or bars_after is None or not len(bars_after):
return None, {"kind": "pyramid", "reason": "no_bars_or_risk"}
add_at_r = float(params.get("add_at_r", 1.0))
add_frac = float(params.get("add_frac", 0.5))
adv = _adverse(fidelity)
entry_fill = fire.entry_price + adv
add_level = entry_fill + add_at_r * risk
add_idx = None
for i in range(len(bars_after)):
if float(bars_after.iloc[i]["high"]) >= add_level:
add_idx = i
break
exit_fill = fire.exit_price - adv
if add_idx is None: # never reached +add_at_r — identical to baseline
return ((exit_fill - entry_fill) / risk,
{"kind": "pyramid", "added": False})
add_fill = add_level + adv
blended_be = (entry_fill + add_frac * add_fill) / (1.0 + add_frac)
# conservative: the add bar's own low counts (touched the high first is the
# optimistic read; we take the adverse one)
stopped = None
for i in range(add_idx, len(bars_after)):
if float(bars_after.iloc[i]["low"]) <= blended_be:
stopped = i
break
px = (blended_be - adv) if stopped is not None else exit_fill
pnl = (px - entry_fill) + add_frac * (px - add_fill)
return (pnl / ((1.0 + add_frac) * risk),
{"kind": "pyramid", "added": True, "add_fill": round(add_fill, 4),
"blended_be": round(blended_be, 4), "stopped_at_be": stopped is not None})
_LIVE_CFG = None
@@ -848,6 +891,8 @@ def _compute_fire(con, exp: dict, fire: Fire, favorable: dict | None = None,
v, d = iv_time_stop(fire, bars_after, trigger_high, params, fidelity)
elif kind == "probe_add":
v, d = iv_probe_add(fire, bars_after, trigger_high, params, fidelity)
elif kind == "pyramid":
v, d = iv_pyramid(fire, bars_after, params, fidelity)
elif kind == "asym_exit":
v, d = iv_asym_exit(con, fire, bars, bars_after, params, fidelity)
elif kind == "volume_fade":
+54
View File
@@ -0,0 +1,54 @@
"""iv_pyramid arithmetic (pure function, no DB)."""
import pandas as pd
from research.experiments.replay import Fire, iv_pyramid
FID0 = {"atr_frac": 0.25, "spread_mult": 3, "adverse_ticks": 0}
P = {"add_at_r": 1.0, "add_frac": 0.5}
def _bars(rows):
ts = pd.date_range("2026-06-11 13:31", periods=len(rows), freq="1min")
return pd.DataFrame(
[{"timestamp": ts[i], "open": o, "high": h, "low": low, "close": c, "volume": 1000}
for i, (o, h, low, c) in enumerate(rows)])
def _fire(entry=10.0, stop=9.0, exit_price=None):
return Fire(fire_id="o1", symbol="TST", session_date=None, fill_ts=None,
entry_price=entry, stop_price=stop, exit_price=exit_price,
baseline_r=None, trigger_price=entry)
def test_never_reaches_add_level_equals_baseline():
fire = _fire(exit_price=10.50) # +0.5R winner, never +1R
bars = _bars([(10.0, 10.6, 9.9, 10.5)] * 3) # high 10.6 < 11.0 add level
v, d = iv_pyramid(fire, bars, P, FID0)
assert not d["added"]
assert abs(v - 0.5) < 1e-9 # identical to baseline R
def test_runner_add_pays_per_deployed_unit():
# entry 10, stop 9 (risk 1). Runs to 13, baseline exit 12.80.
fire = _fire(exit_price=12.80)
bars = _bars([(10.0, 11.2, 10.4, 11.1), # crosses 11.00 -> add
(11.1, 12.4, 11.0, 12.3), # never back to blended BE 10.33
(12.3, 13.0, 12.1, 12.8)])
v, d = iv_pyramid(fire, bars, P, FID0)
assert d["added"] and not d["stopped_at_be"]
# per-deployed: ((12.8-10) + 0.5*(12.8-11)) / (1.5*1) = 2.4667 > baseline 2.8/1.5? No:
# baseline per-deployed on 1 unit = 2.80; pyramid dilutes to 2.467 — the metric
# is honest: the add only wins when the post-add run is big relative to add price.
assert abs(v - ((12.8 - 10) + 0.5 * (12.8 - 11.0)) / 1.5) < 1e-9
def test_pullback_after_add_scratches_at_blended_be():
# add at 11, then knife to 10 — blended BE (10+0.5*11)/1.5 = 10.3333 hit.
fire = _fire(exit_price=11.50) # baseline would have exited 11.50
bars = _bars([(10.0, 11.2, 10.5, 11.1), # add fires (high >= 11)
(11.1, 11.3, 10.0, 10.1)]) # low 10.0 <= 10.3333 -> BE stop
v, d = iv_pyramid(fire, bars, P, FID0)
assert d["added"] and d["stopped_at_be"]
assert abs(v) < 1e-9 # whole position out at blended BE = 0R
# baseline was +1.5R -> the winner-clipping failure mode is visible as a negative delta