feat: net-of-cost backtest, gate ablation + time-exit sweeps, longer tails
Phase 1 of the strategy-measurement plan — report-only, no production trading behavior changes: - Cost haircut: every bucket/sweep now reports net_avg_r/net_total_r alongside gross (COST_PER_SIDE=0.1% of notional, converted to R via each setup's stop distance); params carry cost_per_side_pct. - Gate ablation table: re-qualifies candidates at the current momentum cutoff with one floor removed per row (confidence / R:R / NEUTRAL / momentum-only) to show which floors earn their keep. - Time-based exit sweep: hold 5/10/21/30 days with the initial ATR stop, exit at the day-N close — the classic momentum implementation, to disambiguate the wide-trailing result. - TP sweep extended to +40/+50%, trailing to 25/30% so the optima are interior instead of starred at the sweep edge. - BacktestPanel: Net Avg R columns everywhere, gate-ablation and time-exit tables, stars now mark best net avg R; stale cached reports still render (all new fields optional/guarded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ from app.services.outcome_service import (
|
||||
)
|
||||
from app.services.price_service import query_ohlcv
|
||||
from app.services.qualification import (
|
||||
HIGH_CONVICTION_ACTIONS,
|
||||
best_target_probability,
|
||||
setup_qualifies,
|
||||
)
|
||||
@@ -304,6 +305,47 @@ def _trailing_exits(
|
||||
return result
|
||||
|
||||
|
||||
def _time_exits(
|
||||
direction: str, entry: float, stop: float, forward: list, horizons
|
||||
) -> dict[int, float]:
|
||||
"""Realized R per hold-N-days exit, in one pass over the post-entry bars.
|
||||
|
||||
The initial stop stays active (fill at the stop level → −1R); otherwise the
|
||||
trade exits at the day-N close (the last available close when history ends
|
||||
early). No target, no trailing — the classic momentum implementation: buy,
|
||||
hold ~N days, re-rank. Same conservative bar logic as ``_tp_primitives``: a
|
||||
bar that pierces the stop is a loss before that bar's close counts.
|
||||
"""
|
||||
long = direction == "long"
|
||||
risk = abs(entry - stop) / entry if entry else 0.0
|
||||
if risk <= 0:
|
||||
return {int(n): 0.0 for n in horizons}
|
||||
bars = forward[: max(int(n) for n in horizons)]
|
||||
if not bars:
|
||||
return {int(n): 0.0 for n in horizons}
|
||||
|
||||
stop_day: int | None = None # 1-based trading day the stop was pierced
|
||||
closes: list[float] = []
|
||||
for i, r in enumerate(bars):
|
||||
if (r.low <= stop) if long else (r.high >= stop):
|
||||
stop_day = i + 1
|
||||
break
|
||||
closes.append(r.close)
|
||||
|
||||
result: dict[int, float] = {}
|
||||
for h in horizons:
|
||||
n = int(h)
|
||||
if stop_day is not None and stop_day <= n:
|
||||
result[n] = -1.0
|
||||
else:
|
||||
# closes can't be empty here: an empty closes means the stop hit on
|
||||
# day 1, which the branch above catches for every n >= 1.
|
||||
c = closes[min(n, len(closes)) - 1]
|
||||
move = (c - entry) / entry if long else (entry - c) / entry
|
||||
result[n] = move / risk
|
||||
return result
|
||||
|
||||
|
||||
def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -> list[dict]:
|
||||
"""Walk one ticker's history weekly, building setups and their realized outcomes."""
|
||||
candidates: list[dict] = []
|
||||
@@ -337,6 +379,9 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
|
||||
trail_r = _trailing_exits(
|
||||
s["direction"], s["entry"], s["stop"], TRAIL_LEVELS, forward, HORIZON
|
||||
)
|
||||
time_r = _time_exits(
|
||||
s["direction"], s["entry"], s["stop"], forward, TIME_EXIT_DAYS
|
||||
)
|
||||
iso = records[i].date.isocalendar()
|
||||
candidates.append({
|
||||
"symbol": symbol,
|
||||
@@ -357,6 +402,7 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
|
||||
"mfe_pct": mfe_pct,
|
||||
"tp_close_pct": tp_close_pct,
|
||||
"trail_r": trail_r,
|
||||
"time_r": time_r,
|
||||
})
|
||||
return candidates
|
||||
|
||||
@@ -367,6 +413,7 @@ def _bucket_stats(cands: list[dict]) -> dict:
|
||||
expired = sum(1 for c in cands if c["outcome"] not in (OUTCOME_TARGET_HIT, OUTCOME_STOP_HIT, OUTCOME_AMBIGUOUS))
|
||||
decided = wins + losses
|
||||
rs = [c["realized_r"] for c in cands]
|
||||
net_rs = [c["realized_r"] - _cost_r(c) for c in cands]
|
||||
return {
|
||||
"total": len(cands),
|
||||
"wins": wins,
|
||||
@@ -375,6 +422,8 @@ def _bucket_stats(cands: list[dict]) -> dict:
|
||||
"hit_rate": round(wins / decided * 100, 1) if decided else None,
|
||||
"avg_r": round(sum(rs) / len(rs), 3) if rs else None,
|
||||
"total_r": round(sum(rs), 2) if rs else None,
|
||||
"net_avg_r": round(sum(net_rs) / len(net_rs), 3) if net_rs else None,
|
||||
"net_total_r": round(sum(net_rs), 2) if net_rs else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -382,10 +431,26 @@ def _bucket_stats(cands: list[dict]) -> dict:
|
||||
# Extended into the tail so the avg-R peak/plateau is visible (it's where letting
|
||||
# winners run stops paying). Note: this model ignores the setup's S/R target —
|
||||
# it's a standalone fixed-% exit; exiting at the target is the target model.
|
||||
TP_LEVELS = (0.04, 0.06, 0.08, 0.10, 0.12, 0.15, 0.20, 0.25, 0.30)
|
||||
TP_LEVELS = (0.04, 0.06, 0.08, 0.10, 0.12, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50)
|
||||
|
||||
# Trailing-stop widths (give-back from the peak) swept for the trailing exit model.
|
||||
TRAIL_LEVELS = (0.03, 0.05, 0.07, 0.10, 0.15, 0.20)
|
||||
TRAIL_LEVELS = (0.03, 0.05, 0.07, 0.10, 0.15, 0.20, 0.25, 0.30)
|
||||
|
||||
# Hold-N-days exits (initial stop stays active, exit at the day-N close) — the
|
||||
# classic cross-sectional momentum implementation: buy, hold ~a month, re-rank.
|
||||
TIME_EXIT_DAYS = (5, 10, 21, 30)
|
||||
|
||||
# Assumed transaction cost per side as a fraction of notional (commission +
|
||||
# slippage). Aggregates report gross and net side by side; net subtracts a full
|
||||
# round trip, converted into R via the setup's stop distance (the 1R unit).
|
||||
COST_PER_SIDE = 0.001
|
||||
|
||||
|
||||
def _cost_r(cand: dict) -> float:
|
||||
"""Round-trip transaction cost in R units: two sides over the 1R stop
|
||||
distance. 0 when the candidate carries no usable risk_pct."""
|
||||
risk = cand.get("risk_pct") or 0.0
|
||||
return (2.0 * COST_PER_SIDE) / risk if risk > 0 else 0.0
|
||||
|
||||
|
||||
def _take_profit_bucket(cands: list[dict], tp: float) -> dict:
|
||||
@@ -394,18 +459,21 @@ def _take_profit_bucket(cands: list[dict], tp: float) -> dict:
|
||||
Results are in R (gain% / risk%) so they're comparable to the target model.
|
||||
``hit_rate`` here = share that reached +tp before the stop (the MFE CDF)."""
|
||||
rs: list[float] = []
|
||||
net_rs: list[float] = []
|
||||
wins = 0
|
||||
for c in cands:
|
||||
risk = c.get("risk_pct") or 0.0
|
||||
if risk <= 0:
|
||||
continue
|
||||
if c.get("mfe_pct", 0.0) >= tp:
|
||||
rs.append(tp / risk)
|
||||
r = tp / risk
|
||||
wins += 1
|
||||
elif c.get("tp_stopped"):
|
||||
rs.append(-1.0)
|
||||
r = -1.0
|
||||
else:
|
||||
rs.append((c.get("tp_close_pct", 0.0)) / risk)
|
||||
r = (c.get("tp_close_pct", 0.0)) / risk
|
||||
rs.append(r)
|
||||
net_rs.append(r - _cost_r(c))
|
||||
total = len(rs)
|
||||
return {
|
||||
"tp_pct": round(tp * 100, 1),
|
||||
@@ -414,6 +482,8 @@ def _take_profit_bucket(cands: list[dict], tp: float) -> dict:
|
||||
"hit_rate": round(wins / total * 100, 1) if total else None,
|
||||
"avg_r": round(sum(rs) / total, 3) if total else None,
|
||||
"total_r": round(sum(rs), 2) if total else None,
|
||||
"net_avg_r": round(sum(net_rs) / total, 3) if total else None,
|
||||
"net_total_r": round(sum(net_rs), 2) if total else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -421,12 +491,14 @@ def _trailing_bucket(cands: list[dict], trail_pct: int) -> dict:
|
||||
"""Stats for a trailing-stop exit of width ``trail_pct`` (integer percent).
|
||||
Each candidate carries its realized R for this width in ``trail_r``; a "win"
|
||||
is simply an exit in profit (R > 0)."""
|
||||
rs = [
|
||||
c["trail_r"][trail_pct]
|
||||
pairs = [
|
||||
(c["trail_r"][trail_pct], _cost_r(c))
|
||||
for c in cands
|
||||
if c.get("trail_r", {}).get(trail_pct) is not None
|
||||
]
|
||||
total = len(rs)
|
||||
total = len(pairs)
|
||||
rs = [r for r, _ in pairs]
|
||||
net_rs = [r - cost for r, cost in pairs]
|
||||
wins = sum(1 for r in rs if r > 0)
|
||||
return {
|
||||
"trail_pct": trail_pct,
|
||||
@@ -435,6 +507,33 @@ def _trailing_bucket(cands: list[dict], trail_pct: int) -> dict:
|
||||
"win_rate": round(wins / total * 100, 1) if total else None,
|
||||
"avg_r": round(sum(rs) / total, 3) if total else None,
|
||||
"total_r": round(sum(rs), 2) if total else None,
|
||||
"net_avg_r": round(sum(net_rs) / total, 3) if total else None,
|
||||
"net_total_r": round(sum(net_rs), 2) if total else None,
|
||||
}
|
||||
|
||||
|
||||
def _time_exit_bucket(cands: list[dict], hold_days: int) -> dict:
|
||||
"""Stats for the hold-``hold_days`` exit: initial stop active, otherwise out
|
||||
at the day-N close. Each candidate carries its realized R per hold length in
|
||||
``time_r``; a "win" is an exit in profit (R > 0)."""
|
||||
pairs = [
|
||||
(c["time_r"][hold_days], _cost_r(c))
|
||||
for c in cands
|
||||
if c.get("time_r", {}).get(hold_days) is not None
|
||||
]
|
||||
total = len(pairs)
|
||||
rs = [r for r, _ in pairs]
|
||||
net_rs = [r - cost for r, cost in pairs]
|
||||
wins = sum(1 for r in rs if r > 0)
|
||||
return {
|
||||
"hold_days": hold_days,
|
||||
"total": total,
|
||||
"wins": wins,
|
||||
"win_rate": round(wins / total * 100, 1) if total else None,
|
||||
"avg_r": round(sum(rs) / total, 3) if total else None,
|
||||
"total_r": round(sum(rs), 2) if total else None,
|
||||
"net_avg_r": round(sum(net_rs) / total, 3) if total else None,
|
||||
"net_total_r": round(sum(net_rs), 2) if total else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -754,6 +853,72 @@ def _momentum_qualifies(cand: dict, threshold: float) -> bool:
|
||||
return mp is not None and mp >= threshold
|
||||
|
||||
|
||||
def _gate_ablation(candidates: list[dict], activation: dict, threshold: float) -> list[dict]:
|
||||
"""Which floors earn their keep: re-qualify the same candidates at the
|
||||
current momentum cutoff with one floor removed per row (long-only
|
||||
throughout, matching the live gate).
|
||||
|
||||
``all_floors`` uses the stored ``meets_core`` so it reproduces the qualified
|
||||
set exactly; the ablation rows recompute the remaining floors from stored
|
||||
candidate fields with the same comparisons as
|
||||
``qualification.setup_qualifies``. Optional tighteners (high-conviction /
|
||||
conflict exclusion), when enabled, stay applied in every ablation row so
|
||||
only the named floor varies.
|
||||
"""
|
||||
min_rr = float(activation.get("min_rr", 0.0))
|
||||
min_conf = float(activation.get("min_confidence", 0.0))
|
||||
exclude_neutral = bool(activation.get("exclude_neutral", False))
|
||||
require_high = bool(activation.get("require_high_conviction", False))
|
||||
exclude_conflicts = bool(activation.get("exclude_conflicts", False))
|
||||
|
||||
def momentum_ok(c: dict) -> bool:
|
||||
# Mirrors the momentum part of _momentum_qualifies: long-only while the
|
||||
# gate is active; threshold 0 disables it (shorts pass too).
|
||||
if threshold <= 0:
|
||||
return True
|
||||
if c["direction"] == "short":
|
||||
return False
|
||||
mp = c.get("momentum_percentile")
|
||||
return mp is not None and mp >= threshold
|
||||
|
||||
def rr_ok(c: dict) -> bool:
|
||||
return c["rr"] >= min_rr
|
||||
|
||||
def conf_ok(c: dict) -> bool:
|
||||
return (c["confidence"] or 0.0) >= min_conf
|
||||
|
||||
def neutral_ok(c: dict) -> bool:
|
||||
return not exclude_neutral or (c.get("action") or "NEUTRAL") != "NEUTRAL"
|
||||
|
||||
def tighteners_ok(c: dict) -> bool:
|
||||
if require_high and (c.get("action") or "") not in HIGH_CONVICTION_ACTIONS:
|
||||
return False
|
||||
if exclude_conflicts and (c.get("risk_level") or "") != "Low":
|
||||
return False
|
||||
return True
|
||||
|
||||
def core_ok(c: dict) -> bool:
|
||||
return bool(c["meets_core"])
|
||||
|
||||
variants: list[tuple[str, list]] = [
|
||||
("all_floors", [core_ok]),
|
||||
("no_confidence_floor", [rr_ok, neutral_ok, tighteners_ok]),
|
||||
("no_rr_floor", [conf_ok, neutral_ok, tighteners_ok]),
|
||||
("no_neutral_exclusion", [rr_ok, conf_ok, tighteners_ok]),
|
||||
("momentum_only", []),
|
||||
]
|
||||
return [
|
||||
{
|
||||
"variant": name,
|
||||
**_bucket_stats([
|
||||
c for c in candidates
|
||||
if momentum_ok(c) and all(check(c) for check in checks)
|
||||
]),
|
||||
}
|
||||
for name, checks in variants
|
||||
]
|
||||
|
||||
|
||||
async def run_backtest(
|
||||
db: AsyncSession,
|
||||
progress_cb: Callable[[int, int, str], None] | None = None,
|
||||
@@ -862,7 +1027,12 @@ async def run_backtest(
|
||||
"tickers": total,
|
||||
"candidates": len(candidates),
|
||||
"qualified": len(qualified),
|
||||
"params": {"step_days": STEP_DAYS, "horizon_days": HORIZON, "min_lookback": MIN_LOOKBACK},
|
||||
"params": {
|
||||
"step_days": STEP_DAYS,
|
||||
"horizon_days": HORIZON,
|
||||
"min_lookback": MIN_LOOKBACK,
|
||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||
},
|
||||
"activation": activation,
|
||||
"overall_qualified": _bucket_stats(qualified),
|
||||
"overall_all": _bucket_stats(candidates),
|
||||
@@ -872,8 +1042,16 @@ async def run_backtest(
|
||||
},
|
||||
"min_momentum_percentile": current_min_pct,
|
||||
"sweep": sweep,
|
||||
"gate_ablation": _gate_ablation(candidates, activation, current_min_pct),
|
||||
"gate_ablation_note": (
|
||||
"Each row re-qualifies the same candidates at the current momentum "
|
||||
f"cutoff ({current_min_pct:.0f}) with one floor removed (long-only "
|
||||
"while the momentum gate is active). If dropping a floor doesn't "
|
||||
"hurt net expectancy, that floor isn't pulling its weight."
|
||||
),
|
||||
"take_profit_sweep": [_take_profit_bucket(qualified, tp) for tp in TP_LEVELS],
|
||||
"trailing_sweep": [_trailing_bucket(qualified, round(f * 100)) for f in TRAIL_LEVELS],
|
||||
"time_exit_sweep": [_time_exit_bucket(qualified, n) for n in TIME_EXIT_DAYS],
|
||||
"calibration": _calibration(candidates),
|
||||
"signal_eval": _signal_evaluation(collected),
|
||||
"signal_eval_note": (
|
||||
|
||||
Reference in New Issue
Block a user