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:
1 parent
733a182f10
commit
054405dbdf
3 files changed
+108
No files matched your search
@@ -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}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user