diff --git a/experiments/pyramid_1r_v1.json b/experiments/pyramid_1r_v1.json new file mode 100644 index 0000000..4983c52 --- /dev/null +++ b/experiments/pyramid_1r_v1.json @@ -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} +} diff --git a/research/experiments/replay.py b/research/experiments/replay.py index 106700a..5431b4e 100644 --- a/research/experiments/replay.py +++ b/research/experiments/replay.py @@ -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": diff --git a/tests/unit/experiments/test_pyramid.py b/tests/unit/experiments/test_pyramid.py new file mode 100644 index 0000000..65b5320 --- /dev/null +++ b/tests/unit/experiments/test_pyramid.py @@ -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