Add S/R v2 research and validation harness
This commit is contained in:
@@ -67,6 +67,7 @@ from app.services.qualification import (
|
||||
from app.services.recommendation_service import (
|
||||
_choose_recommended_action,
|
||||
_classify_by_probability,
|
||||
_gate_eligible_levels,
|
||||
_prune_floor_pinned_targets,
|
||||
_risk_level_from_conflicts,
|
||||
_select_primary_target,
|
||||
@@ -81,7 +82,7 @@ from app.services.scoring_service import (
|
||||
compute_momentum_from_closes,
|
||||
compute_technical_from_arrays,
|
||||
)
|
||||
from app.services.sr_service import detect_sr_levels
|
||||
from app.services.sr_service import MAX_LEVELS, detect_sr_levels, detect_sr_levels_legacy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,11 +121,58 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
|
||||
price_level=float(d["price_level"]),
|
||||
type=d["type"],
|
||||
strength=int(d["strength"]),
|
||||
detection_method=d.get("detection_method", "unknown"),
|
||||
sources=list(d.get("sources") or [d.get("detection_method", "unknown")]),
|
||||
rejection_count=int(d.get("rejection_count", 0) or 0),
|
||||
last_rejection_age=d.get("last_rejection_age"),
|
||||
)
|
||||
for i, d in enumerate(level_dicts)
|
||||
]
|
||||
|
||||
|
||||
SR_RESEARCH_VARIANTS = {
|
||||
"production_control",
|
||||
"rr_aligned_control",
|
||||
"rewrite",
|
||||
"soft_zones",
|
||||
"confirmed_rounds",
|
||||
"gate_v2",
|
||||
}
|
||||
|
||||
|
||||
def _sr_research_variant() -> str:
|
||||
"""S/R policy arm for local research; never read by the live scanner."""
|
||||
value = os.getenv("BACKTEST_SR_VARIANT", "rewrite").strip().lower()
|
||||
if value not in SR_RESEARCH_VARIANTS:
|
||||
allowed = ", ".join(sorted(SR_RESEARCH_VARIANTS))
|
||||
raise ValueError(f"Unknown BACKTEST_SR_VARIANT={value!r}; expected one of {allowed}")
|
||||
return value
|
||||
|
||||
|
||||
def _backtest_entry_bounds() -> tuple[date | None, date | None]:
|
||||
"""Optional research-only entry bounds used to protect validation data."""
|
||||
parsed: list[date | None] = []
|
||||
for key in ("BACKTEST_ENTRY_START", "BACKTEST_ENTRY_END"):
|
||||
raw = os.getenv(key, "").strip()
|
||||
if not raw:
|
||||
parsed.append(None)
|
||||
continue
|
||||
try:
|
||||
parsed.append(date.fromisoformat(raw))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{key} must be YYYY-MM-DD, got {raw!r}") from exc
|
||||
start, end = parsed
|
||||
if start is not None and end is not None and start > end:
|
||||
raise ValueError("BACKTEST_ENTRY_START must be on or before BACKTEST_ENTRY_END")
|
||||
return start, end
|
||||
|
||||
|
||||
def _sr_audit_enabled() -> bool:
|
||||
return os.getenv("BACKTEST_SR_AUDIT", "").strip().lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
|
||||
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 —
|
||||
@@ -219,10 +267,28 @@ def _window_setups(
|
||||
if atr <= 0:
|
||||
return []
|
||||
|
||||
sr_levels = _wrap_levels(detect_sr_levels(highs, lows, closes, volumes))
|
||||
sr_variant = _sr_research_variant()
|
||||
if sr_variant in {"production_control", "rr_aligned_control"}:
|
||||
detected_levels = detect_sr_levels_legacy(highs, lows, closes, volumes)
|
||||
else:
|
||||
detector_cap = 0 if sr_variant == "gate_v2" else MAX_LEVELS
|
||||
detected_levels = detect_sr_levels(
|
||||
highs, lows, closes, volumes, max_levels=detector_cap
|
||||
)
|
||||
sr_levels = _wrap_levels(detected_levels)
|
||||
if not sr_levels:
|
||||
return []
|
||||
|
||||
gate_levels = _gate_eligible_levels(
|
||||
sr_levels,
|
||||
confirmed_rounds_only=sr_variant in {"confirmed_rounds", "gate_v2"},
|
||||
)
|
||||
zone_strength_mode = (
|
||||
"soft"
|
||||
if sr_variant in {"soft_zones", "confirmed_rounds", "gate_v2"}
|
||||
else "sum"
|
||||
)
|
||||
|
||||
technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0
|
||||
momentum = (compute_momentum_from_closes(closes)[0]) or 50.0
|
||||
dim_scores = {"technical": technical, "momentum": momentum}
|
||||
@@ -237,7 +303,11 @@ def _window_setups(
|
||||
per_dir: dict[str, dict] = {}
|
||||
for direction in ("long", "short"):
|
||||
stop = entry - atr * ATR_MULTIPLIER if direction == "long" else entry + atr * ATR_MULTIPLIER
|
||||
zone_levels = _zone_representative_levels(sr_levels, entry)
|
||||
zone_levels = _zone_representative_levels(
|
||||
gate_levels,
|
||||
entry,
|
||||
strength_mode=zone_strength_mode,
|
||||
)
|
||||
targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
|
||||
if not targets:
|
||||
fallback_k = _atr_target_fallback_k()
|
||||
@@ -254,7 +324,15 @@ def _window_setups(
|
||||
# Collapse duplicate floor-pinned lottery targets (parity with
|
||||
# enhance_trade_setup).
|
||||
targets = _prune_floor_pinned_targets(targets)
|
||||
primary = _select_primary_target(targets)
|
||||
primary_min_rr = (
|
||||
1.5
|
||||
if sr_variant == "production_control"
|
||||
else float(activation.get("min_rr", 0.0))
|
||||
)
|
||||
primary = _select_primary_target(
|
||||
targets,
|
||||
min_rr=primary_min_rr,
|
||||
)
|
||||
if primary is None:
|
||||
continue
|
||||
# Flag the primary so qualification's EV uses the primary target's
|
||||
@@ -309,6 +387,18 @@ def _window_setups(
|
||||
"meets_core": meets_core,
|
||||
"action": action,
|
||||
"risk_level": risk_level,
|
||||
"sr_variant": sr_variant,
|
||||
"primary_sources": list(primary.get("sr_sources") or []),
|
||||
"primary_strength": float(primary.get("sr_strength", 0.0)),
|
||||
"primary_rejection_count": int(
|
||||
primary.get("sr_rejection_count", 0) or 0
|
||||
),
|
||||
"primary_last_rejection_age": primary.get("sr_last_rejection_age"),
|
||||
"primary_distance_atr": float(
|
||||
primary.get("distance_atr_multiple", 0.0)
|
||||
),
|
||||
"raw_level_count": len(sr_levels),
|
||||
"gate_level_count": len(gate_levels),
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -399,7 +489,13 @@ def _replay_ticker(
|
||||
if n < MIN_LOOKBACK + HORIZON:
|
||||
return candidates
|
||||
|
||||
entry_start, entry_end = _backtest_entry_bounds()
|
||||
for i in range(MIN_LOOKBACK - 1, n - HORIZON, STEP_DAYS):
|
||||
as_of = records[i].date
|
||||
if entry_start is not None and as_of < entry_start:
|
||||
continue
|
||||
if entry_end is not None and as_of > entry_end:
|
||||
continue
|
||||
window = records[: i + 1]
|
||||
forward = records[i + 1 :]
|
||||
forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward]
|
||||
@@ -458,6 +554,14 @@ def _replay_ticker(
|
||||
# every candidate looks NEUTRAL and the ablation rows collapse.
|
||||
"action": s["action"],
|
||||
"risk_level": s["risk_level"],
|
||||
"sr_variant": s["sr_variant"],
|
||||
"primary_sources": s["primary_sources"],
|
||||
"primary_strength": s["primary_strength"],
|
||||
"primary_rejection_count": s["primary_rejection_count"],
|
||||
"primary_last_rejection_age": s["primary_last_rejection_age"],
|
||||
"primary_distance_atr": s["primary_distance_atr"],
|
||||
"raw_level_count": s["raw_level_count"],
|
||||
"gate_level_count": s["gate_level_count"],
|
||||
"outcome": outcome,
|
||||
"target_hit": target_hit,
|
||||
"realized_r": realized_r,
|
||||
@@ -518,6 +622,84 @@ def _robustness_stats(net_rs: list[float]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
|
||||
"""Compact evidence audit for the active local S/R research arm."""
|
||||
source_counts: dict[str, int] = defaultdict(int)
|
||||
round_only = 0
|
||||
strengths: list[float] = []
|
||||
distances: list[float] = []
|
||||
rejections: list[int] = []
|
||||
raw_counts: list[int] = []
|
||||
gate_counts: list[int] = []
|
||||
for cand in candidates:
|
||||
sources = list(cand.get("primary_sources") or [])
|
||||
for source in sources:
|
||||
source_counts[str(source)] += 1
|
||||
if set(sources) == {"round_number"}:
|
||||
round_only += 1
|
||||
strengths.append(float(cand.get("primary_strength", 0.0)))
|
||||
distances.append(float(cand.get("primary_distance_atr", 0.0)))
|
||||
rejections.append(int(cand.get("primary_rejection_count", 0) or 0))
|
||||
raw_counts.append(int(cand.get("raw_level_count", 0) or 0))
|
||||
gate_counts.append(int(cand.get("gate_level_count", 0) or 0))
|
||||
|
||||
def avg(values: list[float] | list[int]) -> float | None:
|
||||
return round(sum(values) / len(values), 3) if values else None
|
||||
|
||||
return {
|
||||
"variant": _sr_research_variant(),
|
||||
"candidate_count": len(candidates),
|
||||
"primary_source_counts": dict(sorted(source_counts.items())),
|
||||
"primary_round_only": round_only,
|
||||
"primary_strength_100": sum(1 for value in strengths if value >= 100.0),
|
||||
"avg_primary_strength": avg(strengths),
|
||||
"avg_primary_distance_atr": avg(distances),
|
||||
"avg_primary_rejection_count": avg(rejections),
|
||||
"avg_raw_level_count": avg(raw_counts),
|
||||
"avg_gate_level_count": avg(gate_counts),
|
||||
}
|
||||
|
||||
|
||||
def _sr_candidate_audit(candidates: list[dict], min_percentile: float) -> list[dict] | None:
|
||||
"""Candidate-level audit for paired S/R variant comparisons.
|
||||
|
||||
Limit the sidecar population to the long momentum slice that could reach
|
||||
production qualification. This keeps reports reviewable while retaining
|
||||
gate failures, additions, removals, and portfolio-relevant near misses.
|
||||
"""
|
||||
if not _sr_audit_enabled():
|
||||
return None
|
||||
rows: list[dict] = []
|
||||
for cand in candidates:
|
||||
percentile = cand.get(PRODUCTION_PERCENTILE_KEY)
|
||||
if cand.get("direction") != "long" or percentile is None:
|
||||
continue
|
||||
if float(percentile) < min_percentile:
|
||||
continue
|
||||
rows.append({
|
||||
"symbol": cand["symbol"],
|
||||
"date": cand["date"],
|
||||
"direction": cand["direction"],
|
||||
"qualified": bool(cand.get("qualified")),
|
||||
"meets_core": bool(cand.get("meets_core")),
|
||||
"momentum_percentile": round(float(percentile), 6),
|
||||
"strategy_rank": round(float(cand.get(RESIDUAL_HIGH_VOL_BLEND_KEY, 0.0) or 0.0), 6),
|
||||
"rr": round(float(cand.get("rr", 0.0)), 6),
|
||||
"primary_prob": round(float(cand.get("primary_prob", 0.0)), 6),
|
||||
"primary_sources": list(cand.get("primary_sources") or []),
|
||||
"primary_strength": round(float(cand.get("primary_strength", 0.0)), 3),
|
||||
"primary_rejection_count": int(cand.get("primary_rejection_count", 0) or 0),
|
||||
"primary_distance_atr": round(float(cand.get("primary_distance_atr", 0.0)), 6),
|
||||
"raw_level_count": int(cand.get("raw_level_count", 0) or 0),
|
||||
"gate_level_count": int(cand.get("gate_level_count", 0) or 0),
|
||||
"outcome": cand.get("outcome"),
|
||||
"net_r": round(float(cand.get("realized_r", 0.0)) - _cost_r(cand), 6),
|
||||
"hold30_r": round(float((cand.get("time_r") or {}).get(30, 0.0)), 6),
|
||||
})
|
||||
rows.sort(key=lambda row: (row["date"], row["symbol"], row["direction"]))
|
||||
return rows
|
||||
|
||||
|
||||
# The fixed take-profit and trailing-stop sweeps were retired 2026-07: swept
|
||||
# TPs never found an interior optimum (momentum's edge lives in the right tail)
|
||||
# and wide trails converged to the hold-to-horizon exit, so the time-exit sweep
|
||||
@@ -2851,6 +3033,15 @@ async def run_backtest(
|
||||
"horizon_days": HORIZON,
|
||||
"min_lookback": MIN_LOOKBACK,
|
||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||
"sr_variant": _sr_research_variant(),
|
||||
"entry_start": (
|
||||
_backtest_entry_bounds()[0].isoformat()
|
||||
if _backtest_entry_bounds()[0] is not None else None
|
||||
),
|
||||
"entry_end": (
|
||||
_backtest_entry_bounds()[1].isoformat()
|
||||
if _backtest_entry_bounds()[1] is not None else None
|
||||
),
|
||||
},
|
||||
"activation": activation,
|
||||
"overall_qualified": _bucket_stats(qualified),
|
||||
@@ -2914,6 +3105,8 @@ async def run_backtest(
|
||||
"portfolio_monitor": portfolio_monitor_report,
|
||||
"holdout": holdout_report,
|
||||
"min_rr_sweep": min_rr_sweep_report,
|
||||
"sr_variant_diagnostics": _sr_variant_diagnostics(candidates),
|
||||
"sr_candidate_audit": _sr_candidate_audit(candidates, current_min_pct),
|
||||
"signal_eval": _signal_evaluation(collected),
|
||||
"signal_eval_note": (
|
||||
"Cross-sectional rank-IC of price-only signals vs the forward "
|
||||
|
||||
Reference in New Issue
Block a user