Research: S/R levels, the target exit, and the entry gate
Investigated whether our support/resistance detection follows best practice and whether we actually use it that way. Three findings, all backed by runs against the prod snapshot and written up in docs/research/sr-levels-and-exits.md: - The S/R target must NOT become an exit. Honoring it as a take-profit on top of the 3x ATR trail drops Sharpe 2.04 -> 1.47 and halves CAGR. Win rate rises (37.5% -> 40.0%), which is the tell: it truncates the right tail where momentum's edge lives. - The clear-air fallback (synthesize a 3xATR target where no resistance exists, so 52-week-high breakouts stop being vetoed) looked strictly better in-sample (Sharpe 2.04 -> 2.07, CAGR 50.4% -> 62.3%, DD 21.4% -> 20.1%) but FAILED a real out-of-sample holdout: on entries after 2024-07-01 it is worse on Sharpe (2.78 -> 2.45) and Calmar, better only on raw CAGR. Not shipped. - The detector itself is weak vs best practice (POC/VAH/VAL computed then discarded, HVN = any above-mean bin, 1.48x volume double-counting, "touch" counts pass-throughs, no round numbers), but its only causal path to P&L is the entry gate. Fix it for the displayed levels, not for returns. Method note: nested lookback windows are NOT out-of-sample. The in-sample result was clean, large, and consistent across five windows, and still did not survive a proper entry-date split. All research paths are off by default and the default report is unchanged: BACKTEST_RESEARCH_EXITS=1 take-profit exit rows BACKTEST_ATR_TARGET_FALLBACK=k synthetic k*ATR target when S/R offers none BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1 restrict that to genuinely clear air BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD train/test split by entry date Also fixes two reproducibility holes found while reconciling our local baseline against the live report: - create_backtest_snapshot.py now copies paper_% settings. The production monitor row replays the runtime exit policy via get_exit_policy(); without those keys a snapshot silently falls back to code defaults, so a live-tuned exit would never be reflected. - Migration 020 drops activation_min_expected_value and activation_min_target_probability. Both are orphans of the June EV-gate redesign, read by no code path, but prod carries min_target_probability = 50.0 which implies a probability floor that is not enforced (the real floor is the 20% constant in qualification.py). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""Drop the orphaned EV-gate activation settings.
|
||||
|
||||
``activation_min_expected_value`` and ``activation_min_target_probability`` are
|
||||
leftovers from the June 2026 EV-gate redesign (migration 009). That gate was
|
||||
superseded by the residual-momentum gate, and the current code reads neither key:
|
||||
``admin_service._ACTIVATION_FLOAT_KEYS`` exposes only ``min_momentum_percentile``,
|
||||
``min_rr`` and ``min_confidence``, and ``qualification.setup_qualifies`` gates on
|
||||
those plus the hardcoded ``MIN_TARGET_PROBABILITY`` floor.
|
||||
|
||||
The rows are therefore inert but actively misleading: prod carries
|
||||
``activation_min_target_probability = 50.0``, so anyone reading the DB (or an
|
||||
Admin screen rendering it) would reasonably believe a 50% probability floor is
|
||||
enforced. It is not — the real floor is the 20% constant in ``qualification.py``.
|
||||
|
||||
Reads never recreate them (``settings_store.get_value`` returns a default without
|
||||
persisting), and the current Admin write path no longer emits these keys, so the
|
||||
delete is permanent. Follows the precedent of migrations 009, 015 and 018.
|
||||
|
||||
Revision ID: 020
|
||||
Revises: 019
|
||||
Create Date: 2026-07-12 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "020"
|
||||
down_revision = "019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
ORPHANED_KEYS = (
|
||||
"activation_min_expected_value",
|
||||
"activation_min_target_probability",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM system_settings WHERE key IN "
|
||||
"('activation_min_expected_value', 'activation_min_target_probability')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore the values prod carried before the delete. They are inert either
|
||||
# way — no code path reads them — but this keeps the downgrade faithful.
|
||||
# ``updated_at`` is NOT NULL with only a Python-side default, so raw SQL must
|
||||
# supply it explicitly.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"INSERT INTO system_settings (key, value, updated_at) VALUES "
|
||||
"('activation_min_expected_value', '0.01', CURRENT_TIMESTAMP), "
|
||||
"('activation_min_target_probability', '50.0', CURRENT_TIMESTAMP) "
|
||||
"ON CONFLICT (key) DO NOTHING"
|
||||
)
|
||||
)
|
||||
@@ -123,6 +123,70 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
|
||||
]
|
||||
|
||||
|
||||
def _atr_target_fallback_k() -> float | None:
|
||||
"""Research ablation: k for a synthetic k*ATR target when a direction has no
|
||||
S/R level to aim at. Off (None) by default, which is production behavior —
|
||||
no resistance above means no long setup at all. That veto lands hardest on
|
||||
names at 52-week highs (clear air above), i.e. exactly what the momentum gate
|
||||
selects, so this flag exists to measure what the veto costs. Set
|
||||
BACKTEST_ATR_TARGET_FALLBACK=3 to enable. See docs/research/sr-levels-and-exits.md."""
|
||||
raw = os.getenv("BACKTEST_ATR_TARGET_FALLBACK", "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
k = float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return k if k > 0 else None
|
||||
|
||||
|
||||
def _fallback_clear_air_only() -> bool:
|
||||
"""Restrict the fallback to setups with genuinely NO structure ahead.
|
||||
|
||||
Without this, the fallback also fires when levels DO exist ahead but
|
||||
``TargetGenerator``'s distance filters rejected them (nearer than 1 ATR, or
|
||||
past ``max_atr_multiple``). Measured on the snapshot, that's 65% of what the
|
||||
fallback admits — a different population from the clear-air breakouts, which
|
||||
confounds the famine test. Set BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1."""
|
||||
return os.getenv("BACKTEST_FALLBACK_CLEAR_AIR_ONLY", "").strip().lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
|
||||
def _has_structure_ahead(direction: str, entry: float, sr_levels: list[Any]) -> bool:
|
||||
"""Is there any S/R level in the direction of the trade? (Resistance above for
|
||||
a long, support below for a short.) False == clear air."""
|
||||
if direction == "long":
|
||||
return any(
|
||||
lv.type == "resistance" and float(lv.price_level) > entry for lv in sr_levels
|
||||
)
|
||||
return any(
|
||||
lv.type == "support" and float(lv.price_level) < entry for lv in sr_levels
|
||||
)
|
||||
|
||||
|
||||
def _atr_fallback_target(
|
||||
direction: str, entry: float, stop: float, atr: float, k: float
|
||||
) -> dict:
|
||||
"""A synthetic target k*ATR from entry, shaped like a TargetGenerator row.
|
||||
|
||||
``sr_strength`` is 50 (neutral) so the probability model's strength magnet
|
||||
contributes nothing — the target stands on distance alone.
|
||||
"""
|
||||
price = entry + k * atr if direction == "long" else entry - k * atr
|
||||
distance = abs(price - entry)
|
||||
risk = abs(entry - stop)
|
||||
return {
|
||||
"price": float(price),
|
||||
"distance_from_entry": float(distance),
|
||||
"distance_atr_multiple": float(k),
|
||||
"rr_ratio": float(distance / risk) if risk > 0 else 0.0,
|
||||
"classification": "Moderate",
|
||||
"sr_level_id": -1, # synthetic: no S/R level behind it
|
||||
"sr_strength": 50.0,
|
||||
}
|
||||
|
||||
|
||||
def _window_setups(
|
||||
window_records: list,
|
||||
config: dict,
|
||||
@@ -174,7 +238,12 @@ def _window_setups(
|
||||
zone_levels = _zone_representative_levels(sr_levels, entry)
|
||||
targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
|
||||
if not targets:
|
||||
fallback_k = _atr_target_fallback_k()
|
||||
if fallback_k is None:
|
||||
continue
|
||||
if _fallback_clear_air_only() and _has_structure_ahead(direction, entry, sr_levels):
|
||||
continue # structure exists ahead; the distance filters rejected it, not the famine
|
||||
targets = [_atr_fallback_target(direction, entry, stop, atr, fallback_k)]
|
||||
for t in targets:
|
||||
t["probability"] = probability_estimator.estimate_probability(
|
||||
t, dim_scores, None, direction, config
|
||||
@@ -1099,6 +1168,7 @@ def _simulate_portfolio(
|
||||
risk_per_trade: float = SIM_RISK_PER_TRADE,
|
||||
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
include_curve: bool = False,
|
||||
) -> dict | None:
|
||||
"""Replay the qualified setups as ONE capital-constrained book and report
|
||||
@@ -1109,9 +1179,11 @@ def _simulate_portfolio(
|
||||
``exit_policy``: "target" races the S/R target against the stop with a
|
||||
timeout at ``hold_days``; "hold" keeps only the initial stop and exits at
|
||||
the ``hold_days``-th close. Research exits add price-derived early exits:
|
||||
"sma50", "low20", "technical40", and "atr_trail3". Stops fill at the
|
||||
worse of stop or open (gaps modeled); positions still open at the end are
|
||||
closed at their last mark. Returns None when there is nothing to trade.
|
||||
"sma50", "low20", "technical40", and "atr_trail3". "atr_trail3_target"
|
||||
runs the ATR trail *and* the S/R take-profit together — the trade ends at
|
||||
whichever comes first. Stops fill at the worse of stop or open (gaps
|
||||
modeled); positions still open at the end are closed at their last mark.
|
||||
Returns None when there is nothing to trade.
|
||||
"""
|
||||
if qualified_fn is None:
|
||||
def _default_qualified(c: dict) -> bool:
|
||||
@@ -1121,12 +1193,15 @@ def _simulate_portfolio(
|
||||
|
||||
entries_by_ord: dict[int, list[dict]] = defaultdict(list)
|
||||
start_ord = start_date.toordinal() if start_date is not None else None
|
||||
end_ord = end_date.toordinal() if end_date is not None else None
|
||||
for c in candidates:
|
||||
if not qualified_fn(c) or c.get("direction") != "long":
|
||||
continue
|
||||
entry_ord = date.fromisoformat(c["date"]).toordinal()
|
||||
if start_ord is not None and entry_ord < start_ord:
|
||||
continue
|
||||
if end_ord is not None and entry_ord >= end_ord:
|
||||
continue # holdout: entries strictly before the split
|
||||
if not c.get("entry") or not c.get("stop"):
|
||||
continue
|
||||
entries_by_ord[entry_ord].append(c)
|
||||
@@ -1248,7 +1323,7 @@ def _simulate_portfolio(
|
||||
)
|
||||
_close_trade(sym, min(pos["stop"], bar.open), reason)
|
||||
continue
|
||||
if exit_policy == "target" and pos["target"] and bar.high >= pos["target"]:
|
||||
if exit_policy in ("target", "atr_trail3_target") and pos["target"] and bar.high >= pos["target"]:
|
||||
_close_trade(sym, pos["target"], "target")
|
||||
continue
|
||||
if exit_policy == "sma50":
|
||||
@@ -1269,7 +1344,7 @@ def _simulate_portfolio(
|
||||
if pos["bars_held"] >= hold_days:
|
||||
_close_trade(sym, bar.close, "time")
|
||||
continue
|
||||
if exit_policy == "atr_trail3":
|
||||
if exit_policy in ("atr_trail3", "atr_trail3_target"):
|
||||
pos["highest_close"] = max(pos["highest_close"], bar.close)
|
||||
atr = _atr(sym, bar.idx)
|
||||
if atr is not None:
|
||||
@@ -1752,6 +1827,32 @@ EXIT_POLICY_VARIANTS: tuple[dict, ...] = (
|
||||
},
|
||||
)
|
||||
|
||||
# Take-profit exits, tested 2026-07-12 and REJECTED — see
|
||||
# docs/research/sr-levels-and-exits.md. Honoring the S/R target is the worst
|
||||
# exit of the seven: it lifts the win rate but truncates the right tail where
|
||||
# momentum's edge lives. Kept so the result stays reproducible, off by default.
|
||||
# Set BACKTEST_RESEARCH_EXITS=1 to include them in the exit comparison.
|
||||
RESEARCH_EXIT_POLICY_VARIANTS: tuple[dict, ...] = (
|
||||
{
|
||||
"exit_policy": "target",
|
||||
"label": "80/20 entry + S/R target take-profit (no trail)",
|
||||
"description": "Take profit at the S/R target; initial stop only, no trailing.",
|
||||
},
|
||||
{
|
||||
"exit_policy": "atr_trail3_target",
|
||||
"label": "80/20 entry + 3x ATR trail AND S/R target take-profit",
|
||||
"description": "Production trail plus a take-profit at the S/R target, first one wins.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _exit_policy_variants() -> tuple[dict, ...]:
|
||||
"""The exit book. Research take-profit rows only when explicitly opted in, so
|
||||
the default report stays identical to the shipped baseline."""
|
||||
if os.getenv("BACKTEST_RESEARCH_EXITS", "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
return EXIT_POLICY_VARIANTS + RESEARCH_EXIT_POLICY_VARIANTS
|
||||
return EXIT_POLICY_VARIANTS
|
||||
|
||||
|
||||
PORTFOLIO_MONITOR_LOOKBACKS: tuple[dict, ...] = (
|
||||
{"lookback": "6m", "label": "6 months", "days": 183},
|
||||
@@ -1812,7 +1913,7 @@ def _exit_policy_sims(
|
||||
|
||||
rows: list[dict] = []
|
||||
ranking_key = str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"])
|
||||
for cfg in EXIT_POLICY_VARIANTS:
|
||||
for cfg in _exit_policy_variants():
|
||||
sim = _simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
@@ -1843,6 +1944,96 @@ def _lookback_start(max_ord: int | None, days: int | None) -> date | None:
|
||||
return date.fromordinal(max_ord - days)
|
||||
|
||||
|
||||
def _holdout_split() -> date | None:
|
||||
"""Train/test split date for out-of-sample validation, e.g.
|
||||
BACKTEST_HOLDOUT_SPLIT=2024-07-01. Off by default."""
|
||||
raw = os.getenv("BACKTEST_HOLDOUT_SPLIT", "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _holdout_evaluation(
|
||||
candidates: list[dict],
|
||||
prices: dict[str, tuple],
|
||||
_spy_closes: dict[date, float] | None,
|
||||
hold_days: int,
|
||||
split: date,
|
||||
live_exit_policy: dict | None = None,
|
||||
) -> dict:
|
||||
"""The production strategy simulated on entries BEFORE the split (train) and
|
||||
on entries ON/AFTER it (test), as separate books.
|
||||
|
||||
The lookback rows in ``portfolio_monitor`` are NOT a holdout — they are nested
|
||||
windows that all end today, so every one of them overlaps the data a rule was
|
||||
chosen on. This does the real thing: the test window is disjoint from the
|
||||
train window, so a rule settled on train has never seen it.
|
||||
"""
|
||||
strategy = next(
|
||||
(s for s in PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production")),
|
||||
None,
|
||||
)
|
||||
if strategy is None:
|
||||
return {}
|
||||
entry_cfg = _entry_variant_config(str(strategy["entry_variant"]))
|
||||
if entry_cfg is None:
|
||||
return {}
|
||||
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
if strategy.get("use_live_config") and live_exit_policy is not None:
|
||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||
)
|
||||
row_hold_days = int(live_exit_policy.get("hold_days", hold_days))
|
||||
trail_multiplier = float(
|
||||
live_exit_policy.get("atr_multiplier", ATR_TRAIL_MULTIPLIER)
|
||||
)
|
||||
qualified_fn = (
|
||||
None if strategy.get("use_live_config")
|
||||
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
||||
)
|
||||
|
||||
rows: list[dict] = []
|
||||
for window, start, end in (
|
||||
("train", None, split),
|
||||
("test", split, None),
|
||||
):
|
||||
sim = _simulate_portfolio(
|
||||
candidates,
|
||||
prices,
|
||||
_spy_closes,
|
||||
exit_policy,
|
||||
row_hold_days,
|
||||
qualified_fn=qualified_fn,
|
||||
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
|
||||
max_positions=int(entry_cfg["max_positions"]),
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
atr_trail_multiplier=trail_multiplier,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
include_curve=True,
|
||||
)
|
||||
if sim is None:
|
||||
continue
|
||||
rows.append({"window": window, "exit_policy": exit_policy, **sim})
|
||||
|
||||
return {
|
||||
"split_date": split.isoformat(),
|
||||
"strategy": strategy["strategy"],
|
||||
"rows": rows,
|
||||
"note": (
|
||||
"Train = entries before the split; test = entries on/after it. The two "
|
||||
"books are disjoint in entry date. Compare the TEST row across arms: a "
|
||||
"rule chosen by looking at full history has already seen train."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _portfolio_monitor(
|
||||
candidates: list[dict],
|
||||
prices: dict[str, tuple],
|
||||
@@ -2433,6 +2624,7 @@ async def run_backtest(
|
||||
strategy_variant_rows: list[dict] = []
|
||||
exit_policy_rows: list[dict] = []
|
||||
portfolio_monitor_report: dict | None = None
|
||||
holdout_report: dict | None = None
|
||||
try:
|
||||
qual_symbols = sorted({
|
||||
c["symbol"]
|
||||
@@ -2481,6 +2673,12 @@ async def run_backtest(
|
||||
candidates, price_columns, spy_closes, hold_horizon,
|
||||
live_exit_policy=live_exit_policy,
|
||||
)
|
||||
split = _holdout_split()
|
||||
if split is not None:
|
||||
holdout_report = _holdout_evaluation(
|
||||
candidates, price_columns, spy_closes, hold_horizon, split,
|
||||
live_exit_policy=live_exit_policy,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Portfolio simulation failed")
|
||||
|
||||
@@ -2555,6 +2753,7 @@ async def run_backtest(
|
||||
),
|
||||
},
|
||||
"portfolio_monitor": portfolio_monitor_report,
|
||||
"holdout": holdout_report,
|
||||
"signal_eval": _signal_evaluation(collected),
|
||||
"signal_eval_note": (
|
||||
"Cross-sectional rank-IC of price-only signals vs the forward "
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
# S/R levels: detection quality, the target exit, and the entry gate
|
||||
|
||||
**Date:** 2026-07-12
|
||||
**Question that started it:** are our support/resistance levels built the way best
|
||||
practice says they should be, and do we actually use them that way?
|
||||
|
||||
Short answer: the detector is weak against best practice, but its reach into P&L
|
||||
runs entirely through the **entry gate** — not the exit. Honoring the target as a
|
||||
take-profit was tested and is decisively worse. Whether the S/R-derived gate is
|
||||
net-positive is the open question, tracked below.
|
||||
|
||||
---
|
||||
|
||||
## 1. How the levels are built today
|
||||
|
||||
`app/services/sr_service.py::detect_sr_levels`, over **all stored history**
|
||||
(`query_ohlcv` with no date range — 5 years / ~1260 daily bars per ticker):
|
||||
|
||||
1. Candidates = volume-profile **HVN and LVN** bins + **pivot** swing highs/lows.
|
||||
2. Strength = share of bars that "touched" the level, scaled so ~20% of bars → 100.
|
||||
3. Nearby levels merged within 0.5%; tagged `support` if below spot, else `resistance`.
|
||||
|
||||
### Where that departs from best practice
|
||||
|
||||
Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
|
||||
|
||||
| Gap | Evidence |
|
||||
|---|---|
|
||||
| **POC / VAH / VAL are computed then discarded.** `sr_service` reads only `hvn`/`lvn`. The canonical volume-profile levels never become S/R. | POC $148.32, VAH $230.45 — unused |
|
||||
| **HVN = "any bin above the mean"**, so nearly every bin is a candidate. A real HVN is a *local peak* in the histogram. | 8 of 20 bins HVN, other 12 LVN → 73 levels, median spacing $2.44 (0.79% of spot) — a price grid, not detected structure |
|
||||
| **HVN and LVN are scored and used identically**, though they encode opposite dynamics (acceptance vs. rejection). | both appended as plain candidates |
|
||||
| **Volume is double-counted**: a bar's full volume is added to *every* bin it spans rather than distributed. | binned total = 1.48× true volume |
|
||||
| **"Touch" = level fell inside the bar's range** — a pass-through counts the same as a rejection. Strength therefore measures *how central a price is in the 5-year range*, not how often price reversed there. | 21 of 73 levels pin at exactly 100 → the `sr_strength` magnet in the probability model is near-constant |
|
||||
| **No recency decay, unbounded lookback.** | 35 AAPL "support" levels sit >35% below spot |
|
||||
| **Round-number levels absent** — the mechanism with the best empirical support ([Osler 2000](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=888805)). | not implemented |
|
||||
|
||||
Not a defect: the pivot `window=2` is the standard 5-bar Williams fractal. What it
|
||||
lacks is a **prominence filter** — AAPL yields 338 pivots over 1261 bars, one every
|
||||
~3.7 bars.
|
||||
|
||||
### The structural problem: resistance famine
|
||||
|
||||
Levels are tagged relative to spot, so a stock near its highs has almost nothing
|
||||
above it. Across all 504 tickers in the snapshot, grouped by proximity to the
|
||||
52-week high:
|
||||
|
||||
| Population | Median resistance levels | % with <3 |
|
||||
|---|---|---|
|
||||
| **≥98% of 52w high — what the momentum gate buys** | **1** | **64%** |
|
||||
| 90–98% | 7 | 12% |
|
||||
| <90% | 22 | 0% |
|
||||
|
||||
AAPL at $308: **71 support levels, 2 resistance levels.** 26 tickers have *zero*.
|
||||
|
||||
This matters because `_build_setups_at` does `if not targets: continue` — **no
|
||||
resistance above ⇒ no long setup at all**, and `<3 targets` adds a
|
||||
`target-availability` conflict. The detector is structurally most blind exactly
|
||||
where the momentum edge is strongest. `recommendation_service.py:521` already says
|
||||
so out loud: *"price is extended near highs (no resistance target above), so no
|
||||
high-conviction long setup is available."*
|
||||
|
||||
---
|
||||
|
||||
## 2. Does the target even act as an exit? No.
|
||||
|
||||
Production runs `paper_exit_mode = "atr_trailing"` (`DEFAULT_EXIT_MODE`), and
|
||||
`_atr_trailing_close(direction, entry, init_stop, atr_multiplier, hold_days, ...)`
|
||||
**does not take `target` as a parameter**. Only the non-default `mode == "target"`
|
||||
branch consults it.
|
||||
|
||||
So the S/R target's real causal role is:
|
||||
|
||||
1. the **entry gate** — `rr ≥ 1.5`, primary-target `prob ≥ 20%`, `≥3 targets`, and
|
||||
no-resistance-above ⇒ no setup;
|
||||
2. the **displayed** target table.
|
||||
|
||||
It decides *whether you enter* and plays no part in *how you exit*.
|
||||
|
||||
---
|
||||
|
||||
## 3. Experiment: should the target be honored as a take-profit? — REJECTED
|
||||
|
||||
Dennis's proposal: keep the 3× ATR trail, but also take profit when the S/R target
|
||||
is hit. This was not representable in the simulator (`exit_policy` is one string;
|
||||
the existing `"target"` policy *replaces* the trail). Added `atr_trail3_target`,
|
||||
which runs both — trade ends at whichever comes first.
|
||||
|
||||
Identical 80/20 momentum entry and qualification for every row — the exit is the only
|
||||
policy that changes. (Trade counts still vary 303–427, because exits free portfolio
|
||||
slots at different times; the conclusion is robust to that.)
|
||||
Report: `reports/backtest-20260712-sr-target-exit.json`
|
||||
|
||||
| exit policy | Sharpe | CAGR | MaxDD | trades | win% |
|
||||
|---|---|---|---|---|---|
|
||||
| `low20` | 2.04 | 53.2% | 21.9% | 305 | 37.7 |
|
||||
| **`atr_trail3` (production)** | **2.04** | **50.4%** | **21.4%** | 320 | 37.5 |
|
||||
| `hold` | 2.00 | 51.9% | 22.2% | 303 | 38.6 |
|
||||
| `technical40` | 2.00 | 51.9% | 22.2% | 303 | 38.6 |
|
||||
| `sma50` | 1.76 | 40.8% | 24.4% | 385 | 33.8 |
|
||||
| `target` (take-profit, no trail) | 1.59 | 32.4% | 25.0% | 415 | 41.9 |
|
||||
| **`atr_trail3_target` (trail + take-profit)** | **1.47** | **28.9%** | 23.5% | 427 | 40.0 |
|
||||
|
||||
**Decision: do not ship. The target must not become an exit.**
|
||||
|
||||
- Adding the take-profit to the trail: Sharpe **2.04 → 1.47**, CAGR **halved**
|
||||
(50.4% → 28.9%), and drawdown got *worse* (21.4% → 23.5%). No risk compensation.
|
||||
- **Win rate rose** (37.5% → 40.0%) — the tell. You win more often and earn far
|
||||
less: the take-profit converts the few 5R/8R/15R runners into 1.5R wins while
|
||||
every loser still costs a full −1R. Momentum's edge is that right tail.
|
||||
- The combination (1.47) is worse than the take-profit alone (1.59): once upside is
|
||||
capped at the target, the trail's benefit (riding a winner far past any target)
|
||||
is gone but its cost (shakeouts on pullbacks) remains. Worst of both.
|
||||
|
||||
This confirms and extends the note at `backtest_service.py:450` — swept *fixed*
|
||||
take-profits never found an interior optimum; the S/R target is no better.
|
||||
|
||||
Caveat: single in-sample run over full history. The effect is large (CAGR halved),
|
||||
not marginal.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open: is the S/R entry gate net-positive?
|
||||
|
||||
The target is now proven useless as an exit, so the gate is its **only**
|
||||
justification — and the gate is what starves the momentum names.
|
||||
|
||||
Evidence *for* keeping it (`gate_ablation`, prod baseline): dropping the R:R floor
|
||||
**halves per-setup expectancy**, 0.583 → 0.301 net avg R (`momentum_only` = 0.345).
|
||||
Inside the tradeable pool the S/R-derived R:R floor is doing real selection — it
|
||||
favors names whose nearest resistance is far away, i.e. clear air above.
|
||||
|
||||
But that ablation **cannot see the famine**: it re-qualifies candidates that already
|
||||
exist, and starved names never enter the candidate set (`if not targets: continue`).
|
||||
So "remove the floors" is the wrong test — it just reproduces the rows above.
|
||||
|
||||
**The right test changes target *generation***: when a direction has no S/R target,
|
||||
synthesize one at k×ATR so the name becomes a candidate. One variable moves; the
|
||||
previously-vetoed names now trade. Implemented behind
|
||||
`BACKTEST_ATR_TARGET_FALLBACK=<k>` (k=3 matches the trail; rr = 3/1.5 = 2.0, which
|
||||
clears the 1.5 floor, and an aligned momentum name lands ~34% probability, clearing
|
||||
the 20% floor).
|
||||
|
||||
Read the result against the production baseline (`atr_trail3`, Sharpe **2.04**):
|
||||
|
||||
- **> 2.04** → the veto costs money; let the breakouts in.
|
||||
- **≈ 2.04** → famine is a wash; a detector rewrite is cosmetic.
|
||||
- **< 2.04** → the veto earns its keep by keeping us out of over-extended names, and
|
||||
S/R gating is vindicated.
|
||||
|
||||
### Result: the veto EARNS ITS KEEP. Keep S/R in the gate.
|
||||
|
||||
Treatment `reports/backtest-20260712-sr-gate-ablation-treatment.json` vs control
|
||||
`reports/backtest-20260711-prod-baseline.json`. Admitting the vetoed names is a big
|
||||
change: **qualified setups go 1089 → 4230 (~4×)**.
|
||||
|
||||
Production exit (`atr_trail3`), full history:
|
||||
|
||||
| | Sharpe | CAGR | MaxDD | Calmar | trades |
|
||||
|---|---|---|---|---|---|
|
||||
| control (veto ON) | **2.04** | 50.4% | 21.4% | 2.36 | 320 |
|
||||
| treatment (veto OFF) | **1.82** | **58.6%** | 21.0% | **2.79** | 404 |
|
||||
|
||||
Full history alone looks like a genuine trade-off — more return, more volatility,
|
||||
Sharpe down but Calmar up. **The lookback split is what settles it:**
|
||||
|
||||
| window | control (veto ON) | treatment (veto OFF) |
|
||||
|---|---|---|
|
||||
| **6m** | **Sharpe 2.87, CAGR 76.6%, DD 8.1%** | Sharpe 1.50, CAGR 53.6%, **DD 15.8%** |
|
||||
| **1y** | **Sharpe 2.47, CAGR 66.8%, DD 8.8%** | Sharpe 1.69, CAGR 66.0%, **DD 15.8%** |
|
||||
| 3y | Sharpe 2.12, CAGR 52.3%, DD 17.7% | Sharpe 2.11, CAGR **75.9%**, DD 21.0% |
|
||||
| 5y | Sharpe 1.83, CAGR 38.8%, DD 21.4% | Sharpe 1.62, CAGR 44.9%, DD 21.0% |
|
||||
| all | Sharpe 2.04, CAGR 50.4%, DD 21.4% | Sharpe 1.82, CAGR 58.6%, DD 21.0% |
|
||||
|
||||
Per-setup expectancy: `all_floors` net avg R **0.583 → 0.280**.
|
||||
|
||||
**Decision: do not ship the fallback. Keep the gate as it is.** The flat 3×ATR
|
||||
fallback is worse on Sharpe and on per-setup expectancy, and production needs no
|
||||
further defense than that.
|
||||
|
||||
### But be careful what this run does and does not prove
|
||||
|
||||
**It does not isolate the famine hypothesis.** The fallback fires on *any* empty
|
||||
`generate_targets` result — and that includes the ATR/R:R distance filters in
|
||||
`TargetGenerator` (target closer than 1 ATR, or beyond `max_atr_multiple`), not just
|
||||
"no resistance above." Measured at the last bar across 502 tickers, of the long
|
||||
setups the fallback admits:
|
||||
|
||||
- **26 (35%)** have genuinely *no resistance above* — the clear-air famine case
|
||||
- **49 (65%)** *do* have resistance above; the ATR/R:R filters rejected it — **a
|
||||
different population entirely**
|
||||
|
||||
That matches the report's own tell: qualified setups exploded **1089 → 4230 (~4×)**
|
||||
while candidates rose only ~16%. So the degradation may be driven mostly by that
|
||||
65%, and the clear-air breakouts this investigation was *about* are a minority of
|
||||
what was admitted.
|
||||
|
||||
**What the run actually supports:** *"a flat 3×ATR fallback for all S/R-starved
|
||||
setups degrades performance."* It does **not** support the stronger claim that a
|
||||
stock in clear air is a worse risk-adjusted buy, or that the veto is functioning as
|
||||
an over-extension filter. That mechanism is unproven.
|
||||
|
||||
**The window split is also less clean than it first looks.** The verdict rests on the
|
||||
two *smallest* samples — 6m (n=30) and 1y (n=72) — where Sharpe 2.87 is
|
||||
noise-dominated. The statistically sturdier 3y window (n=230 → 303) shows
|
||||
**equal Sharpe (2.12 vs 2.11) with substantially higher treatment CAGR (52.3% →
|
||||
75.9%)**. Full-history Calmar also favors the treatment (2.79 vs 2.36). So the result
|
||||
is metric- and window-dependent; only the flat-fallback rejection is solid.
|
||||
|
||||
**Second contamination (by design):** the fallback gives every admitted name the same
|
||||
`rr = 3/1.5 = 2.0`, so there is no R:R discrimination *within* the admitted set.
|
||||
|
||||
**To actually test the famine**, the fallback must fire *only* when there is no
|
||||
resistance above (not on ATR/R:R filter misses). That is the clear-air run below.
|
||||
|
||||
---
|
||||
|
||||
## 4b. The clean test: fire the fallback ONLY in clear air — **the veto DOES cost money**
|
||||
|
||||
`BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` restricts the fallback to setups with no S/R
|
||||
level ahead at all, excluding the 65% that were merely ATR/R:R distance-filter
|
||||
misses. Same whole-portfolio simulation, same 10-slot book, same momentum ranking.
|
||||
|
||||
Report: `reports/backtest-20260712-sr-gate-ablation-clearair.json`
|
||||
|
||||
**Production exit (`atr_trail3`), full history:**
|
||||
|
||||
| arm | Sharpe | CAGR | MaxDD | Calmar | trades |
|
||||
|---|---|---|---|---|---|
|
||||
| control — veto ON (production) | 2.04 | 50.4% | 21.4% | 2.36 | 320 |
|
||||
| blanket fallback (contaminated) | 1.82 | 58.6% | 21.0% | 2.79 | 404 |
|
||||
| **clear-air-only fallback** | **2.07** | **62.3%** | **20.1%** | **3.10** | 363 |
|
||||
|
||||
**Strictly better than production on all three headline metrics at once** — higher
|
||||
Sharpe, ~12 points more CAGR, *and* lower drawdown. Qualified setups 1089 → 2072
|
||||
(vs. 4230 for the blanket version).
|
||||
|
||||
**By lookback** (production strategy):
|
||||
|
||||
| window | control (veto ON) | clear-air fallback |
|
||||
|---|---|---|
|
||||
| 6m (n=30→38) | Sharpe 2.87, CAGR 76.6%, DD **8.1%** | Sharpe 2.21, CAGR 78.8%, DD 13.0% |
|
||||
| 1y (n=72→86) | Sharpe 2.47, CAGR 66.8%, DD **8.8%** | Sharpe 2.28, CAGR **87.6%**, DD 12.9% |
|
||||
| **3y** (n=230→270) | Sharpe 2.12, CAGR 52.3%, DD 17.7% | **Sharpe 2.18, CAGR 68.6%, DD 14.3%** |
|
||||
| **5y** (n=320→363) | Sharpe 1.83, CAGR 38.8%, DD 21.4% | **Sharpe 1.85, CAGR 47.6%, DD 20.1%** |
|
||||
| **all** | Sharpe 2.04, CAGR 50.4%, DD 21.4% | **Sharpe 2.07, CAGR 62.3%, DD 20.1%** |
|
||||
|
||||
In every statistically sturdy window (3y, 5y, all) the clear-air fallback wins on
|
||||
Sharpe, CAGR **and** drawdown. The short windows (6m n=30, 1y n=72 — noise-dominated)
|
||||
favor control on Sharpe/DD while the treatment still earns more (1y CAGR 66.8% →
|
||||
87.6%).
|
||||
|
||||
**This reverses §4 and confirms the hypothesis that opened the investigation.** The
|
||||
S/R veto on clear-air names *was* costing money; the blanket run masked it because
|
||||
the 65% loophole population (ATR/R:R filter misses) is genuinely bad and dominated
|
||||
the result. Isolate the two, and they pull in opposite directions:
|
||||
|
||||
- clear-air names (no resistance above): **portfolio-accretive**
|
||||
- ATR/R:R distance-filter misses: **portfolio-destructive**
|
||||
|
||||
Per-setup expectancy is consistent with this: net avg R `all_floors` — control 0.583,
|
||||
clear-air 0.402, blanket 0.280. The admitted clear-air setups are individually a bit
|
||||
weaker, but they carry the highest momentum ranks, so they win book slots and deliver
|
||||
outsized portfolio returns.
|
||||
|
||||
**Caveats before shipping:** in-sample, single snapshot; the fallback still assigns a
|
||||
constant `rr = 2.0` to every admitted name (no discrimination within the set). Needs
|
||||
an out-of-sample run before production — see §4c, which is where it comes undone.
|
||||
|
||||
---
|
||||
|
||||
## 4c. Out-of-sample holdout — **the §4b result does NOT survive**
|
||||
|
||||
Everything in §4b is in-sample: the rule was chosen by looking at the same 5 years it
|
||||
was then graded on. The `portfolio_monitor` lookbacks (6m/1y/3y/5y) are **not** a
|
||||
holdout — they are nested windows all ending today, so each one overlaps the data the
|
||||
idea came from.
|
||||
|
||||
Real split (`BACKTEST_HOLDOUT_SPLIT=2024-07-01`, production strategy, disjoint books):
|
||||
|
||||
- **train** = entries before 2024-07-01 (~3y)
|
||||
- **test** = entries on/after 2024-07-01 (~2y, never informed the rule)
|
||||
|
||||
Reports: `reports/backtest-20260712-holdout-control.json`,
|
||||
`reports/backtest-20260712-holdout-clearair.json`
|
||||
|
||||
| window | arm | Sharpe | CAGR | MaxDD | Calmar | trades |
|
||||
|---|---|---|---|---|---|---|
|
||||
| train | control | 0.95 | 14.6% | 21.4% | 0.68 | 174 |
|
||||
| train | **clear-air** | **1.18** | **20.5%** | **20.1%** | **1.02** | 191 |
|
||||
| **test** | **control** | **2.78** | 73.3% | **11.7%** | **6.26** | 150 |
|
||||
| **test** | clear-air | 2.45 | **83.0%** | 14.3% | 5.80 | 176 |
|
||||
|
||||
**In train the clear-air rule wins on every metric. Out of sample it does not.** On
|
||||
the held-out two years it delivers **more raw return (+9.7pp CAGR)** but at
|
||||
**lower Sharpe (2.78 → 2.45), higher drawdown (11.7% → 14.3%) and lower Calmar
|
||||
(6.26 → 5.80)**.
|
||||
|
||||
So the §4b headline — *"strictly better on all three metrics"* — was **an in-sample
|
||||
artifact.** Out of sample the rule is not a free win; it is a **risk/return trade**:
|
||||
it buys extra return by taking more risk, and on a risk-adjusted basis it is slightly
|
||||
*worse* than production.
|
||||
|
||||
**Decision: do NOT ship the clear-air fallback.** This project's decision metric is
|
||||
Sharpe throughout (every ranking in the report, and the 2026-07-10 primary-target A/B
|
||||
was accepted on Sharpe 1.51 → 2.00). By that standard the honest read of the only
|
||||
uncontaminated evidence is *no improvement*.
|
||||
|
||||
Notes for anyone revisiting:
|
||||
- Both arms show a large regime shift (train Sharpe ~1, test Sharpe ~2.5–2.8) — the
|
||||
test window was simply a much better market. That is why *relative* comparison
|
||||
within a window is the only valid read.
|
||||
- n = 150/176 in test is decent but not large; the Sharpe gap (0.33) is not
|
||||
overwhelming. This is "not confirmed," not "definitively refuted."
|
||||
- The famine hypothesis is therefore **real but not exploitable as tried**: the
|
||||
clear-air names do add return (consistently, in both train and test), but the flat
|
||||
3×ATR target admits them at a risk cost that eats the risk-adjusted benefit. A
|
||||
better target model for those names (§ next runs) is the remaining avenue.
|
||||
|
||||
A wholesale "ATR target for everyone" variant was deliberately *not* run as the
|
||||
headline: with a fixed k×ATR target and a 1.5×ATR stop, `rr = k/1.5` is constant
|
||||
across every name, which erases the very selection the 0.301 credits. A loss there
|
||||
would be uninterpretable.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reproducing
|
||||
|
||||
Both research paths are **off by default** — the default report is byte-identical to
|
||||
the shipped baseline (5 exit rows, no fallback), and the full unit suite including
|
||||
the backtest↔prod parity guard passes.
|
||||
|
||||
```bash
|
||||
# Exit book incl. the rejected take-profit rows
|
||||
BACKTEST_RESEARCH_EXITS=1 python scripts/run_backtest_snapshot.py \
|
||||
backtest_snapshots/prod.sqlite --workers 7 --allow-spawn
|
||||
|
||||
# S/R gate ablation: synthesize a 3xATR target where S/R offers none
|
||||
BACKTEST_ATR_TARGET_FALLBACK=3 python scripts/run_backtest_snapshot.py \
|
||||
backtest_snapshots/prod.sqlite --workers 7 --allow-spawn
|
||||
```
|
||||
|
||||
Note `--allow-spawn` is required on Windows: `_mp_context()` has no `fork`/
|
||||
`forkserver` there and silently falls back to a single thread without it.
|
||||
|
||||
---
|
||||
|
||||
## 6. Standing decisions
|
||||
|
||||
**Measured:**
|
||||
|
||||
1. **The target must not be an exit.** Tested, rejected, decisively — Sharpe
|
||||
2.04 → 1.47, CAGR halved. Momentum's edge is the right tail; a take-profit
|
||||
truncates it. (§3)
|
||||
2. **Do NOT ship the clear-air fallback — it failed out-of-sample.** In-sample it
|
||||
looked strictly better (Sharpe 2.04 → 2.07, CAGR 50.4% → 62.3%, DD 21.4% → 20.1%),
|
||||
but on a genuine holdout (entries after 2024-07-01, never seen by the rule) it is
|
||||
**worse on Sharpe (2.78 → 2.45) and Calmar, better only on raw CAGR (+9.7pp)**. The
|
||||
in-sample "free win" was an artifact. **Production gate stays as-is.** (§4b, §4c)
|
||||
3. **The famine is real, but not exploitable as tried.** Clear-air names *do* add
|
||||
return consistently (train and test) — the veto genuinely leaves money on the
|
||||
table. But a flat 3×ATR target admits them at a risk cost that cancels the
|
||||
risk-adjusted benefit. (§4c)
|
||||
4. **Do NOT relax the veto indiscriminately.** The ATR/R:R distance-filter misses
|
||||
(65% of a blanket fallback) are portfolio-destructive and swamp everything —
|
||||
Sharpe 1.82, net avg R 0.280. The two populations pull in opposite directions and
|
||||
must be separated. (§4)
|
||||
|
||||
**Reasoned, not measured — treat as hypotheses:**
|
||||
|
||||
5. **The detector's flaws probably don't reach P&L directly.** *No run ever varied
|
||||
detection quality* — "good S/R vs bad S/R → P&L" has never been measured. Fix the
|
||||
§1 gaps for the *displayed* levels and the UX; do not promise a return improvement.
|
||||
|
||||
**Method note (the expensive lesson):** the in-sample result in §4b was clean,
|
||||
large, consistent across five nested windows — and still didn't survive a holdout.
|
||||
Nested lookbacks are not out-of-sample. Split by entry date before believing anything.
|
||||
|
||||
**Next runs, if picked back up:**
|
||||
|
||||
- A **per-name target model** for clear-air setups instead of a constant k×ATR. This
|
||||
is the one avenue left: the return is demonstrably there (§4c), it's the flat target
|
||||
that makes it too expensive in risk. Grade on the §4c holdout, not full history.
|
||||
- Sweep **k** (fallback distance); only k=3 was tried. Grade on the holdout.
|
||||
- A **volatility-aware** admission rule for clear-air names — the OOS failure is a
|
||||
drawdown/vol story (11.7% → 14.3%), so sizing them down may recover the Sharpe.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
"""Create a minimal local SQLite snapshot for offline backtest research.
|
||||
|
||||
Copies only the data required by app.services.backtest_service.run_backtest:
|
||||
tickers, OHLCV bars, SPY benchmark closes, and activation/recommendation
|
||||
settings. Other system settings are intentionally skipped to avoid copying
|
||||
secrets into local snapshot files.
|
||||
tickers, OHLCV bars, SPY benchmark closes, and the activation / recommendation /
|
||||
paper-exit settings the run reads. Other system settings are intentionally
|
||||
skipped to avoid copying secrets into local snapshot files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -144,6 +144,12 @@ async def _main() -> None:
|
||||
where=or_(
|
||||
SystemSetting.key.like("activation_%"),
|
||||
SystemSetting.key.like("recommendation_%"),
|
||||
# The production portfolio-monitor row replays the RUNTIME
|
||||
# exit policy via get_exit_policy(). Without these keys a
|
||||
# snapshot silently falls back to the code defaults, so a
|
||||
# live-tuned exit would not be reflected — the snapshot run
|
||||
# would disagree with prod and give no hint why.
|
||||
SystemSetting.key.like("paper_%"),
|
||||
),
|
||||
),
|
||||
"benchmark_prices": await _copy_table(source, dest, BenchmarkPrice, batch_size=args.batch_size),
|
||||
|
||||
Reference in New Issue
Block a user