feat: take-profit exit sweep in the backtest (alongside target-vs-stop)
The target-vs-stop model counts a near-miss of a far S/R target as a full loss and ignores the partial gains you actually bank — so it measures a different strategy than "scalp the early pop, take +8%". Add a realistic take-profit exit model next to it (original untouched). Per setup the replay now also records risk%, whether the stop was hit, the favourable excursion reachable before the stop (MFE), and the horizon-close move. From those a fixed-take-profit sweep (4/6/8/10/12/15%) is scored in R: bank +X% if reached before the stop, else -1R, else the horizon close. Hit rate = how often +X% was banked (the MFE CDF), so you can pick the EV-optimal TP without top-ticking fantasy. Shown as a new table in the Backtest panel; the IC, calibration and momentum sweep are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -38,6 +39,62 @@ def _cand(prob: float, outcome: str, rr: float, qualified: bool = True, directio
|
||||
}
|
||||
|
||||
|
||||
def _bar(high: float, low: float, close: float) -> SimpleNamespace:
|
||||
return SimpleNamespace(high=high, low=low, close=close)
|
||||
|
||||
|
||||
class TestTakeProfitPrimitives:
|
||||
def test_long_tp_reachable_before_stop(self):
|
||||
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, [_bar(109, 101, 108)], 30)
|
||||
assert risk == pytest.approx(0.05)
|
||||
assert stopped is False
|
||||
assert mfe == pytest.approx(0.09)
|
||||
assert close_pct == pytest.approx(0.08)
|
||||
|
||||
def test_long_stop_zeroes_mfe(self):
|
||||
# Low pierces the stop on the only bar → loss, nothing banked before it.
|
||||
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, [_bar(101, 94, 96)], 30)
|
||||
assert stopped is True
|
||||
assert mfe == pytest.approx(0.0)
|
||||
assert close_pct == pytest.approx(-0.04)
|
||||
|
||||
def test_long_drift_no_trigger(self):
|
||||
bars = [_bar(102, 99, 101), _bar(103, 100, 102)]
|
||||
risk, stopped, mfe, close_pct = bt._tp_primitives("long", 100.0, 95.0, bars, 30)
|
||||
assert stopped is False
|
||||
assert mfe == pytest.approx(0.03)
|
||||
assert close_pct == pytest.approx(0.02)
|
||||
|
||||
def test_short_direction(self):
|
||||
# short entry 100, stop 105; price falls → favourable = (entry - low)/entry
|
||||
risk, stopped, mfe, close_pct = bt._tp_primitives("short", 100.0, 105.0, [_bar(101, 92, 93)], 30)
|
||||
assert risk == pytest.approx(0.05)
|
||||
assert stopped is False
|
||||
assert mfe == pytest.approx(0.08)
|
||||
assert close_pct == pytest.approx(0.07)
|
||||
|
||||
|
||||
class TestTakeProfitBucket:
|
||||
def test_bucket_mix(self):
|
||||
cands = [
|
||||
{"risk_pct": 0.05, "mfe_pct": 0.09, "tp_stopped": False, "tp_close_pct": 0.08}, # +1.6R win
|
||||
{"risk_pct": 0.05, "mfe_pct": 0.02, "tp_stopped": True, "tp_close_pct": -0.04}, # -1R stop
|
||||
{"risk_pct": 0.05, "mfe_pct": 0.03, "tp_stopped": False, "tp_close_pct": 0.01}, # +0.2R timeout
|
||||
]
|
||||
b = bt._take_profit_bucket(cands, 0.08)
|
||||
assert b["total"] == 3
|
||||
assert b["wins"] == 1
|
||||
assert b["hit_rate"] == pytest.approx(33.3, abs=0.1)
|
||||
assert b["total_r"] == pytest.approx(0.8, abs=0.01)
|
||||
assert b["avg_r"] == pytest.approx(0.267, abs=0.01)
|
||||
|
||||
def test_zero_risk_skipped(self):
|
||||
cands = [{"risk_pct": 0.0, "mfe_pct": 0.2, "tp_stopped": False, "tp_close_pct": 0.1}]
|
||||
b = bt._take_profit_bucket(cands, 0.08)
|
||||
assert b["total"] == 0
|
||||
assert b["avg_r"] is None
|
||||
|
||||
|
||||
def test_bucket_stats_counts_and_expectancy():
|
||||
cands = [
|
||||
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
|
||||
|
||||
Reference in New Issue
Block a user