Finalize GTL and retire S/R research harness

This commit is contained in:
2026-07-13 17:58:04 +02:00
parent 9d362bd568
commit bee5a5ce89
35 changed files with 374 additions and 5385779 deletions
+5 -8
View File
@@ -459,10 +459,11 @@ metrics. Keep the SSH tunnel open only while creating the snapshot; the backtest
run itself is local/offline. `backtest_snapshots/` and generated backtest reports
are git-ignored.
Without a research override, both this runner and the scheduled/Admin online
backtest use `explicit_target_ladder`, matching the live scanner's gate-target
path. Clean Structural S/R detector arms must be selected explicitly with
`--sr-variant`; they are not production backtest defaults.
The local runner, scheduled job, and Admin UI all default to
`production_gtl`, matching the live scanner's target path. For a deliberate
comparison, select **Structural S/R (comparison)** in the UI or pass
`--target-model structural_sr` locally. Every report records the selected model
and whether it is the production path.
### Archived GTL tuning decision
@@ -510,10 +511,6 @@ Research-only flags, all off by default (the default report is byte-identical to
|---|---|
| `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` | Adds a `holdout` section: train (entries before) vs test (entries on/after), as disjoint books |
| `BACKTEST_MIN_RR_SWEEP=1` | Sweeps the activation R:R floor against portfolio Sharpe. Combine with `BACKTEST_HOLDOUT_SPLIT` to sweep out-of-sample |
| `BACKTEST_SR_VARIANT=<arm>` | Research-only S/R detector/gate arm; see `docs/research/sr-levels-and-exits.md` for the detector and hidden-feature matrices |
| `BACKTEST_ENTRY_START=YYYY-MM-DD` | Restrict candidate entry dates to a validation window |
| `BACKTEST_ENTRY_END=YYYY-MM-DD` | Restrict candidate entry dates to a training window |
| `BACKTEST_SR_AUDIT=1` | Add momentum-slice candidate rows for paired S/R cohort comparison |
| `BACKTEST_RESEARCH_EXITS=1` | Adds the rejected take-profit exit rows to the exit comparison |
| `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none |
| `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead |
+8 -2
View File
@@ -13,6 +13,7 @@ from app.schemas.admin import (
AlertConfigUpdate,
CreateUserRequest,
DataCleanupRequest,
JobTriggerRequest,
JobToggle,
RecommendationConfigUpdate,
ScheduleConfigUpdate,
@@ -376,11 +377,16 @@ async def get_pipeline_readiness(
@router.post("/admin/jobs/{job_name}/trigger", response_model=APIEnvelope)
async def trigger_job(
job_name: str,
body: JobTriggerRequest | None = None,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Trigger a manual job run (placeholder)."""
result = await admin_service.trigger_job(db, job_name)
"""Trigger a manual job run, optionally with one-run parameters."""
result = await admin_service.trigger_job(
db,
job_name,
target_model=body.target_model if body is not None else None,
)
return APIEnvelope(status="success", data=result)
+44 -4
View File
@@ -35,7 +35,12 @@ from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.providers.protocol import SentimentData
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store
from app.services.alert_service import dispatch_alerts
from app.services.backtest_service import run_and_store as run_backtest_and_store
from app.services.backtest_service import (
BACKTEST_TARGET_MODELS,
PRODUCTION_GTL_TARGET_MODEL,
run_and_store as run_backtest_and_store,
validate_backtest_target_model,
)
from app.services.benchmark_service import refresh_benchmark_prices
from app.services.market_regime_service import update_market_regime
from app.services.regime_monitor_service import update_regime_monitor
@@ -106,6 +111,7 @@ def _idle_runtime() -> dict[str, object]:
_job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in _JOB_NAMES}
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
# ---------------------------------------------------------------------------
@@ -113,6 +119,26 @@ _job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in
# ---------------------------------------------------------------------------
def queue_backtest_target_model(target_model: str | None) -> str:
"""Select the model for the next manual backtest run only.
Scheduled runs and subsequent manual runs return to the production GTL.
"""
global _next_backtest_target_model
selected = validate_backtest_target_model(
target_model or PRODUCTION_GTL_TARGET_MODEL
)
_next_backtest_target_model = selected
return selected
def _consume_backtest_target_model() -> str:
global _next_backtest_target_model
selected = _next_backtest_target_model
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
return selected
def _log_event(level: int, event: str, **fields: object) -> None:
"""Emit a structured JSON log line: {"event": ..., **fields}."""
logger.log(level, json.dumps({"event": event, **fields}))
@@ -939,7 +965,13 @@ async def compute_regime_monitor() -> None:
async def run_backtest_job() -> None:
"""Replay the price-derived engine over history and cache the report."""
job_name = "backtest"
_log_event(logging.INFO, "job_start", job=job_name)
target_model = _consume_backtest_target_model()
_log_event(
logging.INFO,
"job_start",
job=job_name,
target_model=target_model,
)
_runtime_start(job_name)
def _on_progress(done: int, count: int, symbol: str) -> None:
@@ -952,12 +984,20 @@ async def run_backtest_job() -> None:
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
return
report = await run_backtest_and_store(db, _on_progress)
report = await run_backtest_and_store(
db,
_on_progress,
target_model=target_model,
)
_runtime_finish(
job_name, "completed",
processed=report.get("tickers", 0), total=report.get("tickers", 0),
message=f"{report.get('candidates', 0)} setups, {report.get('qualified', 0)} qualified",
message=(
f"{BACKTEST_TARGET_MODELS[target_model]}: "
f"{report.get('candidates', 0)} setups, "
f"{report.get('qualified', 0)} qualified"
),
)
_log_event(logging.INFO, "job_complete", job=job_name, candidates=report.get("candidates"))
except Exception as exc:
+5
View File
@@ -43,6 +43,11 @@ class JobToggle(BaseModel):
enabled: bool
class JobTriggerRequest(BaseModel):
"""Optional parameters for a one-time manual job run."""
target_model: Literal["production_gtl", "structural_sr"] | None = None
class RecommendationConfigUpdate(BaseModel):
high_confidence_threshold: float | None = Field(default=None, ge=0, le=100)
moderate_confidence_threshold: float | None = Field(default=None, ge=0, le=100)
+17 -2
View File
@@ -602,13 +602,20 @@ async def list_jobs(db: AsyncSession) -> list[dict]:
return jobs_out
async def trigger_job(db: AsyncSession, job_name: str) -> dict[str, str]:
async def trigger_job(
db: AsyncSession,
job_name: str,
*,
target_model: str | None = None,
) -> dict[str, str]:
"""Trigger a manual job run via the scheduler.
Runs the job immediately (in addition to its regular schedule).
"""
if job_name not in VALID_JOB_NAMES:
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(VALID_JOB_NAMES))}")
if target_model is not None and job_name != "backtest":
raise ValidationError("target_model is supported only for the backtest job")
from app.scheduler import get_job_runtime_snapshot, scheduler
@@ -635,11 +642,19 @@ async def trigger_job(db: AsyncSession, job_name: str) -> dict[str, str]:
if job is None:
return {"job": job_name, "status": "not_found", "message": f"Job '{job_name}' is not registered in the scheduler"}
if job_name == "backtest":
from app.scheduler import queue_backtest_target_model
target_model = queue_backtest_target_model(target_model)
job.modify(next_run_time=None) # Reset, then trigger immediately
from datetime import datetime, timezone
job.modify(next_run_time=datetime.now(timezone.utc))
return {"job": job_name, "status": "triggered", "message": f"Job '{job_name}' triggered for immediate execution"}
result = {"job": job_name, "status": "triggered", "message": f"Job '{job_name}' triggered for immediate execution"}
if target_model is not None:
result["target_model"] = target_model
return result
async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSetting:
+61 -427
View File
@@ -33,7 +33,7 @@ import statistics
from collections import defaultdict
from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor
from datetime import date, datetime, timedelta, timezone
from datetime import date, datetime, timezone
from types import SimpleNamespace
from typing import Any
@@ -82,12 +82,7 @@ from app.services.scoring_service import (
compute_momentum_from_closes,
compute_technical_from_arrays,
)
from app.services.sr_service import (
MAX_LEVELS,
detect_gate_target_ladder,
detect_sr_levels,
detect_sr_levels_legacy,
)
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
logger = logging.getLogger(__name__)
@@ -97,23 +92,11 @@ STEP_DAYS = 5 # weekly cadence (≈ 5 trading days)
MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51)
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
ATR_MULTIPLIER = 1.5
RANGE_FACTOR_LOOKBACK = 504
RANGE_FACTOR_MIN_LOG = 1.0 # approximately a 2.7x high/low span
STRUCTURAL_OVERLAY_VARIANT = "production_structural_overlay"
STRUCTURAL_OVERLAY_SOURCE_VARIANT = (
"rewrite_range504_structural_legacy_primary"
)
STRUCTURAL_OVERLAY_WEIGHT = 0.05
STRUCTURAL_OVERLAY_SCORE_KEY = "structural_overlay_95_5_score"
EXPLICIT_TARGET_LADDER_VARIANT = "explicit_target_ladder"
RANGE_RESIDUAL_VARIANTS = {
"rewrite_range504_structural_legacy_primary",
"rewrite_range504_structural_primary2",
}
RANGE_FACTOR_VARIANTS = {
"production_range504",
"rewrite_range504_legacy_primary",
*RANGE_RESIDUAL_VARIANTS,
PRODUCTION_GTL_TARGET_MODEL = "production_gtl"
STRUCTURAL_SR_TARGET_MODEL = "structural_sr"
BACKTEST_TARGET_MODELS = {
PRODUCTION_GTL_TARGET_MODEL: "Live GTL (production)",
STRUCTURAL_SR_TARGET_MODEL: "Structural S/R (comparison)",
}
# Cross-sectional signal evaluation (factor IC). Each candidate signal is a
@@ -153,128 +136,13 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
]
SR_RESEARCH_VARIANTS = {
"production_control",
"rr_aligned_control",
"rewrite",
"soft_zones",
"confirmed_rounds",
"gate_v2",
"rewrite_legacy_primary",
"soft_zones_legacy_primary",
"confirmed_rounds_legacy_primary",
"gate_v2_legacy_primary",
"legacy_geometry_neutral",
"legacy_pivots_only",
"legacy_traffic_grid_only",
"legacy_range_grid_touch",
"legacy_range_grid_neutral",
EXPLICIT_TARGET_LADDER_VARIANT,
STRUCTURAL_OVERLAY_VARIANT,
*RANGE_FACTOR_VARIANTS,
}
def _sr_research_variant() -> str:
"""Backtest target policy; research arms require an explicit override.
The unconfigured online/scheduled backtest must replay the same transient
Gate Target Ladder as the live scanner. Clean Structural S/R remains an
opt-in research arm here because it is intentionally chart/alert structure,
not the production gate's target source.
"""
value = os.getenv(
"BACKTEST_SR_VARIANT",
EXPLICIT_TARGET_LADDER_VARIANT,
).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 _sr_detector_variant(sr_variant: str) -> str:
"""Map factor-gated research arms to the detector they hold fixed."""
if sr_variant in {"production_range504", STRUCTURAL_OVERLAY_VARIANT}:
return "production_control"
if (
sr_variant == "rewrite_range504_legacy_primary"
or sr_variant in RANGE_RESIDUAL_VARIANTS
):
return "rewrite"
return sr_variant.removesuffix("_legacy_primary")
def _range_504_log(highs: list[float], lows: list[float]) -> float:
"""Multiplicative high/low range over the last two trading years."""
window_highs = highs[-RANGE_FACTOR_LOOKBACK:]
window_lows = lows[-RANGE_FACTOR_LOOKBACK:]
if not window_highs or not window_lows:
return 0.0
high = max(window_highs)
low = min(window_lows)
if high <= 0 or low <= 0 or high < low:
return 0.0
return math.log(high / low)
def _range_factor_allows(sr_variant: str, range_504_log: float) -> bool:
"""Apply the explicit range factor only in its diagnostic arms."""
return (
sr_variant not in RANGE_FACTOR_VARIANTS
or range_504_log >= RANGE_FACTOR_MIN_LOG
)
def _primary_min_rr_for_variant(sr_variant: str, activation: dict) -> float:
"""Preserve the deployed selector except in explicit primary-2 research."""
if (
sr_variant in {
"production_control",
"production_range504",
EXPLICIT_TARGET_LADDER_VARIANT,
STRUCTURAL_OVERLAY_VARIANT,
}
or sr_variant.endswith("_legacy_primary")
or sr_variant.startswith("legacy_")
):
return 1.5
return float(activation.get("min_rr", 0.0))
def _apply_zone_strength_variant(zone_levels: list[Any], sr_variant: str) -> list[Any]:
"""Apply post-cluster research controls without changing zone geometry."""
if sr_variant in {"legacy_geometry_neutral", "legacy_range_grid_neutral"}:
# Neutrality must be enforced after the shared zone cluster: its legacy
# sum mode can otherwise turn two 50-strength constituents back into a
# 100-strength target and silently invalidate the ablation.
for level in zone_levels:
level.strength = 50
return zone_levels
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 validate_backtest_target_model(value: str) -> str:
"""Validate the small, user-facing set of supported backtest target models."""
normalized = value.strip().lower()
if normalized not in BACKTEST_TARGET_MODELS:
allowed = ", ".join(BACKTEST_TARGET_MODELS)
raise ValueError(f"Unknown backtest target model {value!r}; expected one of {allowed}")
return normalized
def _atr_target_fallback_k() -> float | None:
@@ -346,7 +214,7 @@ def _window_setups(
config: dict,
activation: dict,
*,
sr_variant: str | None = None,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> list[dict]:
"""Rebuild the setup(s) at the last bar of ``window_records`` (the as-of date),
using only those bars. Returns one dict per tradeable direction."""
@@ -373,58 +241,20 @@ def _window_setups(
if atr <= 0:
return []
sr_variant = sr_variant or _sr_research_variant()
detector_variant = _sr_detector_variant(sr_variant)
range_504_log = _range_504_log(highs, lows)
if sr_variant == "legacy_geometry_neutral":
detected_levels = detect_sr_levels_legacy(
highs, lows, closes, volumes, neutral_strength=True
)
elif sr_variant == "legacy_pivots_only":
detected_levels = detect_sr_levels_legacy(
highs, lows, closes, volumes, include_volume_profile=False
)
elif sr_variant == "legacy_traffic_grid_only":
detected_levels = detect_sr_levels_legacy(
highs, lows, closes, volumes, include_pivots=False
)
elif sr_variant == EXPLICIT_TARGET_LADDER_VARIANT:
target_model = validate_backtest_target_model(target_model)
if target_model == PRODUCTION_GTL_TARGET_MODEL:
detected_levels = detect_gate_target_ladder(
highs,
lows,
closes,
)
elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}:
detected_levels = detect_sr_levels_legacy(
highs,
lows,
closes,
volumes,
include_pivots=False,
neutral_strength=sr_variant == "legacy_range_grid_neutral",
explicit_range_grid=True,
)
elif detector_variant in {"production_control", "rr_aligned_control"}:
detected_levels = detect_sr_levels_legacy(highs, lows, closes, volumes)
else:
detector_cap = 0 if detector_variant == "gate_v2" else MAX_LEVELS
detected_levels = detect_sr_levels(
highs, lows, closes, volumes, max_levels=detector_cap
)
detected_levels = detect_sr_levels(highs, lows, closes, volumes)
sr_levels = _wrap_levels(detected_levels)
if not sr_levels:
return []
gate_levels = _gate_eligible_levels(
sr_levels,
confirmed_rounds_only=detector_variant in {"confirmed_rounds", "gate_v2"},
exclude_standalone_rounds=sr_variant in RANGE_RESIDUAL_VARIANTS,
)
zone_strength_mode = (
"soft"
if detector_variant in {"soft_zones", "confirmed_rounds", "gate_v2"}
else "sum"
)
gate_levels = _gate_eligible_levels(sr_levels)
technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0
momentum = (compute_momentum_from_closes(closes)[0]) or 50.0
@@ -443,9 +273,8 @@ def _window_setups(
zone_levels = _zone_representative_levels(
gate_levels,
entry,
strength_mode=zone_strength_mode,
strength_mode="sum",
)
zone_levels = _apply_zone_strength_variant(zone_levels, sr_variant)
targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
if not targets:
fallback_k = _atr_target_fallback_k()
@@ -462,10 +291,9 @@ def _window_setups(
# Collapse duplicate floor-pinned lottery targets (parity with
# enhance_trade_setup).
targets = _prune_floor_pinned_targets(targets)
primary_min_rr = _primary_min_rr_for_variant(sr_variant, activation)
primary = _select_primary_target(
targets,
min_rr=primary_min_rr,
min_rr=1.5,
)
if primary is None:
continue
@@ -507,10 +335,6 @@ def _window_setups(
# week are known. run_backtest ranks momentum and finalizes `qualified`.
core_config = {**activation, "min_momentum_percentile": 0.0}
meets_core = setup_qualifies(setup_ns, core_config)
meets_core = meets_core and _range_factor_allows(
sr_variant,
range_504_log,
)
best_prob = best_target_probability(setup_ns)
out.append({
"direction": direction,
@@ -525,7 +349,7 @@ def _window_setups(
"meets_core": meets_core,
"action": action,
"risk_level": risk_level,
"sr_variant": sr_variant,
"target_model": target_model,
"primary_sources": list(primary.get("sr_sources") or []),
"primary_strength": float(primary.get("sr_strength", 0.0)),
"primary_rejection_count": int(
@@ -537,62 +361,10 @@ def _window_setups(
),
"raw_level_count": len(sr_levels),
"gate_level_count": len(gate_levels),
"range_504_log": range_504_log,
"range_504_ratio": math.exp(range_504_log),
"range_factor_pass": range_504_log >= RANGE_FACTOR_MIN_LOG,
})
return out
def _structural_overlay_window_setups(
window_records: list,
config: dict,
activation: dict,
) -> list[dict]:
"""Production setups tagged by the clean structural/range candidate.
The returned population and setup geometry remain production-identical.
The clean detector is only a point-in-time feature, so this arm can test a
ranking overlay without silently changing admission breadth or targets.
"""
production = _window_setups(
window_records,
config,
activation,
sr_variant="production_control",
)
if not production:
return []
structural = _window_setups(
window_records,
config,
activation,
sr_variant=STRUCTURAL_OVERLAY_SOURCE_VARIANT,
)
structural_by_direction = {row["direction"]: row for row in structural}
tagged: list[dict] = []
for production_row in production:
row = dict(production_row)
structural_row = structural_by_direction.get(row["direction"])
row["sr_variant"] = STRUCTURAL_OVERLAY_VARIANT
row["structural_overlay_pass"] = bool(
structural_row and structural_row.get("meets_core")
)
row["structural_overlay_rr"] = (
float(structural_row["rr"]) if structural_row is not None else None
)
row["structural_overlay_sources"] = (
list(structural_row.get("primary_sources") or [])
if structural_row is not None else []
)
row["structural_overlay_gate_level_count"] = (
int(structural_row.get("gate_level_count", 0) or 0)
if structural_row is not None else 0
)
tagged.append(row)
return tagged
def _stop_fill_r(direction: str, entry: float, stop: float, bar) -> float:
"""Realized R when the stop is hit on ``bar``: filled at the stop, or at the
bar's open when price gapped through it — so a gap can lose more than 1R,
@@ -672,6 +444,7 @@ def _replay_ticker(
config: dict,
activation: dict,
benchmark_closes: dict[date, float] | None = None,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> list[dict]:
"""Walk one ticker's history weekly, building setups and their realized outcomes."""
candidates: list[dict] = []
@@ -679,14 +452,7 @@ def _replay_ticker(
if n < MIN_LOOKBACK + HORIZON:
return candidates
entry_start, entry_end = _backtest_entry_bounds()
sr_variant = _sr_research_variant()
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]
@@ -697,15 +463,11 @@ def _replay_ticker(
)
vol_6m = _realized_vol_6m(closes, len(window) - 1)
setups = (
_structural_overlay_window_setups(window, config, activation)
if sr_variant == STRUCTURAL_OVERLAY_VARIANT
else _window_setups(
window,
config,
activation,
sr_variant=sr_variant,
)
setups = _window_setups(
window,
config,
activation,
target_model=target_model,
)
for s in setups:
outcome, outcome_date = evaluate_setup_against_bars(
@@ -755,7 +517,7 @@ 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"],
"target_model": s["target_model"],
"primary_sources": s["primary_sources"],
"primary_strength": s["primary_strength"],
"primary_rejection_count": s["primary_rejection_count"],
@@ -763,15 +525,6 @@ def _replay_ticker(
"primary_distance_atr": s["primary_distance_atr"],
"raw_level_count": s["raw_level_count"],
"gate_level_count": s["gate_level_count"],
"range_504_log": s["range_504_log"],
"range_504_ratio": s["range_504_ratio"],
"range_factor_pass": s["range_factor_pass"],
"structural_overlay_pass": s.get("structural_overlay_pass"),
"structural_overlay_rr": s.get("structural_overlay_rr"),
"structural_overlay_sources": s.get("structural_overlay_sources"),
"structural_overlay_gate_level_count": s.get(
"structural_overlay_gate_level_count"
),
"outcome": outcome,
"target_hit": target_hit,
"realized_r": realized_r,
@@ -832,8 +585,8 @@ 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."""
def _target_model_diagnostics(candidates: list[dict], target_model: str) -> dict:
"""Compact target-source diagnostics for the selected supported model."""
source_counts: dict[str, int] = defaultdict(int)
round_only = 0
strengths: list[float] = []
@@ -841,9 +594,6 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
rejections: list[int] = []
raw_counts: list[int] = []
gate_counts: list[int] = []
range_logs: list[float] = []
overlay_rows = 0
overlay_pass = 0
for cand in candidates:
sources = list(cand.get("primary_sources") or [])
for source in sources:
@@ -855,16 +605,13 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
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))
range_logs.append(float(cand.get("range_504_log", 0.0) or 0.0))
if cand.get("structural_overlay_pass") is not None:
overlay_rows += 1
overlay_pass += int(bool(cand["structural_overlay_pass"]))
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(),
"target_model": target_model,
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
"candidate_count": len(candidates),
"primary_source_counts": dict(sorted(source_counts.items())),
"primary_round_only": round_only,
@@ -874,80 +621,9 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
"avg_primary_rejection_count": avg(rejections),
"avg_raw_level_count": avg(raw_counts),
"avg_gate_level_count": avg(gate_counts),
"avg_range_504_log": avg(range_logs),
"range_factor_pass": sum(
1 for value in range_logs if value >= RANGE_FACTOR_MIN_LOG
),
"structural_overlay_rows": overlay_rows,
"structural_overlay_pass": overlay_pass,
"structural_overlay_weight": (
STRUCTURAL_OVERLAY_WEIGHT if overlay_rows else None
),
}
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),
"production_rank": round(
float(cand.get(RESIDUAL_HIGH_VOL_BLEND_80_20_KEY, 0.0) or 0.0),
6,
),
"structural_overlay_pass": cand.get("structural_overlay_pass"),
"structural_overlay_score": (
round(float(cand[STRUCTURAL_OVERLAY_SCORE_KEY]), 6)
if cand.get(STRUCTURAL_OVERLAY_SCORE_KEY) is not None else None
),
"structural_overlay_rr": (
round(float(cand["structural_overlay_rr"]), 6)
if cand.get("structural_overlay_rr") is not None else None
),
"structural_overlay_sources": list(
cand.get("structural_overlay_sources") or []
),
"structural_overlay_gate_level_count": int(
cand.get("structural_overlay_gate_level_count", 0) or 0
),
"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),
"range_504_log": round(float(cand.get("range_504_log", 0.0)), 6),
"range_504_ratio": round(float(cand.get("range_504_ratio", 1.0)), 6),
"range_factor_pass": bool(cand.get("range_factor_pass")),
"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
@@ -1280,6 +956,7 @@ def _replay_and_signals(
config: dict,
activation: dict,
benchmark_closes: dict[date, float] | None = None,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> tuple[list[dict], dict]:
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
run in a worker process. Takes primitive column arrays (cheap to pickle),
@@ -1292,7 +969,14 @@ def _replay_and_signals(
for o, op, hi, lo, cl, vo in zip(date_ords, opens, highs, lows, closes, volumes)
]
return (
_replay_ticker(symbol, bars, config, activation, benchmark_closes),
_replay_ticker(
symbol,
bars,
config,
activation,
benchmark_closes,
target_model,
),
_signal_series(bars, benchmark_closes),
)
@@ -1464,23 +1148,6 @@ def _assign_residual_high_vol_blend(candidates: list[dict]) -> None:
)
def _assign_structural_overlay_score(candidates: list[dict]) -> None:
"""Conservative rank nudge for production setups confirmed by clean S/R."""
for cand in candidates:
if cand.get("structural_overlay_pass") is None:
cand[STRUCTURAL_OVERLAY_SCORE_KEY] = None
continue
production_rank = cand.get(RESIDUAL_HIGH_VOL_BLEND_80_20_KEY)
if production_rank is None:
cand[STRUCTURAL_OVERLAY_SCORE_KEY] = None
continue
structural_score = 100.0 if cand["structural_overlay_pass"] else 0.0
cand[STRUCTURAL_OVERLAY_SCORE_KEY] = (
float(production_rank) * (1.0 - STRUCTURAL_OVERLAY_WEIGHT)
+ structural_score * STRUCTURAL_OVERLAY_WEIGHT
)
def _momentum_qualifies(cand: dict, threshold: float) -> bool:
"""Whether a candidate clears the floors (meets_core) and the momentum gate.
Threshold 0 disables the momentum gate (floors only). The gate is long-only:
@@ -1641,17 +1308,9 @@ def _simulate_portfolio(
qualified_fn = _default_qualified
entries_by_ord: dict[int, list[dict]] = defaultdict(list)
configured_start, configured_end = _backtest_entry_bounds()
effective_start = start_date if start_date is not None else configured_start
start_ord = effective_start.toordinal() if effective_start is not None else None
if end_date is not None:
# Explicit simulator/holdout end dates are exclusive split boundaries.
end_ord = end_date.toordinal()
elif configured_end is not None:
# BACKTEST_ENTRY_END is documented and applied as an inclusive bound.
end_ord = (configured_end + timedelta(days=1)).toordinal()
else:
end_ord = None
start_ord = start_date.toordinal() if start_date is not None else None
# Explicit simulator/holdout end dates are exclusive split boundaries.
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
@@ -2333,7 +1992,6 @@ PORTFOLIO_MONITOR_LOOKBACKS: tuple[dict, ...] = (
)
PRODUCTION_PORTFOLIO_STRATEGY = "residual80_highvol80_20_atr3"
STRUCTURAL_OVERLAY_PORTFOLIO_STRATEGY = "production_structural_overlay5_atr3"
PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
{
"strategy": "legacy_residual80_hold",
@@ -2368,22 +2026,7 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
def _portfolio_monitor_strategies() -> tuple[dict, ...]:
"""Add the frozen overlay only inside its explicit research arm."""
if _sr_research_variant() != STRUCTURAL_OVERLAY_VARIANT:
return PORTFOLIO_MONITOR_STRATEGIES
return PORTFOLIO_MONITOR_STRATEGIES + ({
"strategy": STRUCTURAL_OVERLAY_PORTFOLIO_STRATEGY,
"label": "Research: production gate + 5% clean-structure rank overlay",
"description": (
"Production-qualified universe and live exit, ranked by 95% current "
"80/20 strategy rank plus 5% clean structural/range confirmation."
),
"entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3",
"ranking_key": STRUCTURAL_OVERLAY_SCORE_KEY,
"use_live_config": True,
"is_production": False,
},)
return PORTFOLIO_MONITOR_STRATEGIES
def _entry_variant_config(variant: str) -> dict | None:
@@ -3132,8 +2775,11 @@ def _build_recommendation(report: dict) -> dict:
async def run_backtest(
db: AsyncSession,
progress_cb: Callable[[int, int, str], None] | None = None,
*,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> dict:
"""Replay every ticker and aggregate the Phase-1 reports for the current config."""
target_model = validate_backtest_target_model(target_model)
config = await get_recommendation_config(db)
activation = await get_activation_config(db)
@@ -3198,6 +2844,7 @@ async def run_backtest(
futures.append(loop.run_in_executor(
pool, _replay_and_signals, ticker.symbol, columns, config, activation,
benchmark_closes,
target_model,
))
for result in await asyncio.gather(*futures, return_exceptions=True):
if isinstance(result, Exception):
@@ -3219,6 +2866,7 @@ async def run_backtest(
_merge(await asyncio.to_thread(
_replay_and_signals, ticker.symbol, columns, config, activation,
benchmark_closes,
target_model,
))
except Exception:
logger.exception("Backtest replay failed for %s", ticker.symbol)
@@ -3235,7 +2883,6 @@ async def run_backtest(
_assign_activation_momentum_percentiles(candidates)
_assign_residual_low_vol_blend(candidates)
_assign_residual_high_vol_blend(candidates)
_assign_structural_overlay_score(candidates)
current_min_pct = float(activation.get("min_momentum_percentile", 80.0))
for c in candidates:
c["qualified"] = _momentum_qualifies(c, current_min_pct)
@@ -3334,26 +2981,9 @@ 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(),
"range_factor_lookback": RANGE_FACTOR_LOOKBACK,
"range_factor_min_log": RANGE_FACTOR_MIN_LOG,
"range_factor_min_ratio": round(math.exp(RANGE_FACTOR_MIN_LOG), 4),
"structural_overlay_weight": (
STRUCTURAL_OVERLAY_WEIGHT
if _sr_research_variant() == STRUCTURAL_OVERLAY_VARIANT else None
),
"structural_overlay_source_variant": (
STRUCTURAL_OVERLAY_SOURCE_VARIANT
if _sr_research_variant() == STRUCTURAL_OVERLAY_VARIANT else None
),
"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
),
"target_model": target_model,
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
},
"activation": activation,
"overall_qualified": _bucket_stats(qualified),
@@ -3417,8 +3047,10 @@ 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),
"target_model_diagnostics": _target_model_diagnostics(
candidates,
target_model,
),
"signal_eval": _signal_evaluation(collected),
"signal_eval_note": (
"Cross-sectional rank-IC of price-only signals vs the forward "
@@ -3446,9 +3078,11 @@ async def run_backtest(
async def run_and_store(
db: AsyncSession,
progress_cb: Callable[[int, int, str], None] | None = None,
*,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> dict:
"""Run the backtest and cache the report in a SystemSetting. Job entrypoint."""
report = await run_backtest(db, progress_cb)
report = await run_backtest(db, progress_cb, target_model=target_model)
await update_setting(db, KEY_REPORT, json.dumps(report))
return report
+23 -103
View File
@@ -358,55 +358,13 @@ def _extract_candidate_levels(
return candidates
def _legacy_volume_profile_nodes(
highs: list[float],
lows: list[float],
closes: list[float],
volumes: list[int],
num_bins: int = 20,
) -> list[float]:
"""Reproduce the deployed pre-rewrite HVN/LVN grid for a control arm.
This intentionally retains the old span-volume double counting. It exists
only so a local research report can prove that its control still reproduces
the frozen production baseline while the live detector is being rewritten.
"""
if len(closes) < 20:
raise ValidationError(
f"Volume Profile requires at least 20 bars, got {len(closes)}"
)
price_min = min(lows)
price_max = max(highs)
if price_max == price_min:
price_max = price_min + 1.0
bin_width = (price_max - price_min) / num_bins
bins: list[float] = [0.0] * num_bins
prices = [price_min + (i + 0.5) * bin_width for i in range(num_bins)]
for i in range(len(closes)):
for b in range(num_bins):
low_edge = price_min + b * bin_width
high_edge = low_edge + bin_width
if highs[i] >= low_edge and lows[i] <= high_edge:
bins[b] += volumes[i]
average = sum(bins) / num_bins
hvn = [round(prices[i], 4) for i in range(num_bins) if bins[i] > average]
lvn = [round(prices[i], 4) for i in range(num_bins) if bins[i] < average]
return hvn + lvn
def _legacy_range_grid_nodes(
def _gate_target_range_centers(
highs: list[float],
lows: list[float],
closes: list[float],
num_bins: int = 20,
) -> list[float]:
"""Return every deployed grid center without the irrelevant volume pass.
The legacy profile returns both its above-average (HVN) and below-average
(LVN) bins. Their union is therefore the complete range grid except for the
rare bin whose traffic equals the average exactly. This explicit helper
isolates that geometry from the misleading volume-profile label.
"""
"""Return the evenly spaced price proposals used by the production GTL."""
if len(closes) < 20:
raise ValidationError(
f"Range grid requires at least 20 bars, got {len(closes)}"
@@ -422,49 +380,38 @@ def _legacy_range_grid_nodes(
]
def detect_sr_levels_legacy(
def detect_gate_target_ladder(
highs: list[float],
lows: list[float],
closes: list[float],
volumes: list[int],
tolerance: float = DEFAULT_TOLERANCE,
*,
include_volume_profile: bool = True,
include_pivots: bool = True,
neutral_strength: bool = False,
explicit_range_grid: bool = False,
) -> list[dict]:
"""Deployed detector plus source-isolation controls for local research.
"""Build the scanner's internal, volume-free target proposal ladder.
The defaults remain the exact production control. Keyword-only switches
allow a research arm to remove one source or neutralize strength without
copying or subtly changing the legacy implementation.
This is intentionally not human-facing support/resistance. It builds the
production gate's broad 20-bin range grid, adds unfiltered pivots, scores
historical price traffic, and merges nearby proposals. The returned levels
are transient and must not be persisted as chart S/R.
"""
if not closes:
return []
candidates: list[tuple[float, str]] = []
if include_volume_profile:
try:
nodes = (
_legacy_range_grid_nodes(highs, lows, closes)
if explicit_range_grid
else _legacy_volume_profile_nodes(highs, lows, closes, volumes)
)
method = "range_grid" if explicit_range_grid else "volume_profile"
for price in nodes:
candidates.append((float(price), method))
except ValidationError:
pass
if include_pivots:
try:
pivots = compute_pivot_points(highs, lows, closes)
candidates.extend(
(float(price), "pivot_point")
for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", [])
)
except ValidationError:
pass
try:
candidates.extend(
(float(price), "range_grid")
for price in _gate_target_range_centers(highs, lows, closes)
)
except ValidationError:
pass
try:
pivots = compute_pivot_points(highs, lows, closes)
candidates.extend(
(float(price), "pivot_point")
for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", [])
)
except ValidationError:
pass
if not candidates:
return []
@@ -515,37 +462,10 @@ def detect_sr_levels_legacy(
)
_tag_levels(merged, closes[-1])
if neutral_strength:
for level in merged:
level["strength"] = 50
merged.sort(key=lambda row: row["strength"], reverse=True)
return merged
def detect_gate_target_ladder(
highs: list[float],
lows: list[float],
closes: list[float],
tolerance: float = DEFAULT_TOLERANCE,
) -> list[dict]:
"""Build the scanner's internal, volume-free target proposal ladder.
This is intentionally not human-facing support/resistance. It preserves
the production gate's broad 20-bin range grid, unfiltered pivots, touch
strength, and merge geometry without performing or claiming a volume
profile calculation. The returned levels are transient and must not be
persisted as chart S/R.
"""
return detect_sr_levels_legacy(
highs,
lows,
closes,
[0] * len(closes),
tolerance,
explicit_range_grid=True,
)
def _merge_levels(
levels: list[dict],
tolerance: float = DEFAULT_TOLERANCE,
+27 -89
View File
@@ -397,11 +397,10 @@ Note `--allow-spawn` is required on Windows: `_mp_context()` has no `fork`/
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.
## 7. S/R v2 research harness (implementation started 2026-07-12)
## 7. Archived S/R v2 investigation (2026-07-12/13)
The detector rewrite is decomposed into causal, research-only arms. The live
scanner does not read `BACKTEST_SR_VARIANT`; these switches exist only in the
offline snapshot harness:
The detector rewrite was decomposed into the causal arms below. They are names
in the historical experiment record, not supported runtime configuration:
| arm | behavior |
|---|---|
@@ -412,26 +411,10 @@ offline snapshot harness:
| `confirmed_rounds` | soft zones; standalone rounds need two rejection clusters |
| `gate_v2` | confirmed rounds plus uncapped gate evidence |
Detector evidence (`sources`, rejection count, last rejection age) stays in the
pure backtest objects. It is deliberately not migrated into the production DB
schema until a variant passes validation.
The cross-platform matrix runner is only an orchestrator around the existing
`run_backtest_snapshot.py`; it contains no duplicate backtest logic. On macOS:
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py train --workers 14
```
Choose one arm and record that lock before running exactly control and that arm:
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py validate \
--locked-arm confirmed_rounds --workers 14
```
Replace `confirmed_rounds` with the recorded winner. The validation command also
calls `scripts/compare_sr_variants.py` to produce the paired cohort CSV and JSON.
The matrix runner, comparator, environment switch, and candidate-level audit
were removed when the investigation closed. The compact comparison JSONs,
cohort CSVs, and this narrative retain the decisions; Git history retains the
raw implementation and reports for forensic reconstruction.
> Validation result: `confirmed_rounds` is rejected and is no longer a lockable
> arm. It remains in the corrected training matrix only to preserve the causal
@@ -493,14 +476,8 @@ changes one legacy component at a time:
- `legacy_pivots_only`: unfiltered full-history pivots, no VP grid;
- `legacy_traffic_grid_only`: deployed HVN+LVN grid, no pivots.
Run on macOS:
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py traffic --workers 14
```
Do not validate any traffic arm yet. First establish whether geometry, pivots, or
the range-occupancy grid reproduces production on pre-2024 training data.
The traffic matrix first established whether geometry, pivots, or the
range-occupancy grid reproduced production on pre-2024 training data.
Corrected training result:
@@ -535,16 +512,6 @@ Two final training arms isolate the first two items explicitly:
- `legacy_range_grid_neutral`: identical centers, strength fixed at 50 after
clustering.
Run only these new arms on macOS:
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py traffic \
--only-arm legacy_range_grid_touch --workers 14
.venv/bin/python scripts/run_sr_v2_matrix.py traffic \
--only-arm legacy_range_grid_neutral --workers 14
```
The touch arm reproduced `legacy_traffic_grid_only` exactly: all 121,464
candidates, 504 qualified setups, cohort membership, expectancy, and portfolio
metrics match. Volume contributes nothing. Neutral strength won the training
@@ -583,12 +550,6 @@ effect:
- `rewrite_range504_legacy_primary`: clean targets, frozen primary selection,
plus the identical range gate.
Run on pre-2024 training data only:
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py factor --workers 14
```
Result:
| training arm | qualified | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
@@ -620,27 +581,13 @@ two-arm residual matrix therefore holds the clean detector and range factor fixe
- `rewrite_range504_structural_primary2`: identical, but select the primary from
targets clearing 2.0R.
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py residual --workers 14
```
### Full-period production comparison
After freezing `rewrite_range504_structural_legacy_primary`, run it beside a
fresh `production_control` over the complete snapshot with identical portfolio
and exit settings:
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py full --workers 14
```
The command deliberately supplies neither `--entry-start` nor `--entry-end`.
It writes both audited reports plus a paired cohort comparison:
- `reports/backtest-sr-full-production_control.json`
- `reports/backtest-sr-full-rewrite_range504_structural_legacy_primary.json`
- `reports/sr-full-production-vs-candidate-cohorts.csv`
- `reports/sr-full-production-vs-candidate-comparison.json`
The frozen `rewrite_range504_structural_legacy_primary` candidate was run beside
a fresh `production_control` over the complete snapshot with identical
portfolio and exit settings. The retained decision files are
`reports/sr-full-production-vs-candidate-comparison.json` and its cohort CSV;
the redundant full candidate-row reports were removed after consolidation.
This is an apples-to-apples full-history diagnostic against the current live
production path. It is not a new untouched holdout because the post-2024 data
@@ -667,13 +614,7 @@ overlay_rank = 95% * production_80_20_rank + 5% * structural_confirmation
There is deliberately no weight sweep and no union with clean-only setups. The
report must first reproduce the production qualified count and production book;
otherwise the comparison is invalid. Run the one-arm full-period diagnostic:
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py overlay --workers 14
```
Output: `reports/backtest-sr-overlay-full.json`. The candidate advances only if
otherwise the comparison is invalid. The candidate advances only if
the overlay improves full-period Sharpe, does not worsen drawdown, and retains
at least 90% of production CAGR. The 1-year and 6-month rows must not both
deteriorate. This remains contaminated full-history research, not promotion
@@ -764,14 +705,10 @@ crossings, capped strength, side and source. The overlay is off by default,
loaded only on demand from `GET /gate-target-ladder/{symbol}`, and must remain
visually distinct from persisted Structural S/R.
The `explicit_target_ladder` arm therefore replaces only the irrelevant volume
pass with the complete range grid. It retains pivots, touch strength, merge
geometry, primary selection, qualification, ranking, and exit behavior. Grid
levels are labelled `range_grid`, making the internal purpose explicit.
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py ladder --workers 14
```
The production GTL replaces only the irrelevant volume pass with the complete
range grid. It retains pivots, touch strength, merge geometry, primary
selection, qualification, ranking, and exit behavior. Grid levels are labelled
`range_grid`, making the internal purpose explicit.
The arm advances only on exact parity with the full-period production control:
no added or removed qualified setups and identical production-book Sharpe,
@@ -793,7 +730,7 @@ human-facing S/R for charts and alerts. The scanner instead builds
for the current scan, and never writes those proposal levels to `SRLevel`. The
primary-target selector retains its independently researched 1.5 floor; the
later live activation gate remains 2.0, and the ATR-trailing exit is unchanged.
The `explicit_target_ladder` backtest arm calls the same pure helper as the live
The `production_gtl` backtest model calls the same pure helper as the live
scanner, so the final full-period rerun is an implementation-parity check rather
than another detector experiment.
@@ -912,15 +849,16 @@ model. This snapshot is now exhausted for GTL fitting; any future challenger
must be pre-registered and evaluated on genuinely new forward data rather than
another iteration over the same history.
The scheduled/Admin online backtest and the unconfigured local snapshot
backtest therefore default to `explicit_target_ladder`. Structural detector
variants remain explicit research overrides. After deployment, rerun the Admin
backtest once to replace any cached report produced by the former `rewrite`
default.
The scheduled backtest, Admin UI, and local snapshot runner therefore default
to `production_gtl`. A manual UI/local run may select `structural_sr` as an
explicit comparison; every report records the model and whether it is the
production path. After deployment, rerun the Admin backtest once to replace any
cached report produced by the former default.
The temporary GTL matrix scripts, configurable detector branches, and
confirmation hooks were removed after this decision. The normal snapshot
backtester and frozen `explicit_target_ladder` parity arm remain.
backtester now exposes only the production GTL and clean Structural S/R
comparison models.
The post-2024 window has been opened and is now analysis data, not a valid final
promotion holdout. These arms can isolate mechanism, but neither may ship without
+5 -2
View File
@@ -200,8 +200,11 @@ export interface TriggerJobResponse {
job: string;
status: 'triggered' | 'busy' | 'blocked' | 'not_found';
message: string;
target_model?: BacktestTargetModel;
}
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
export function listJobs() {
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
}
@@ -216,9 +219,9 @@ export function toggleJob(jobName: string, enabled: boolean) {
.then((r) => r.data);
}
export function triggerJob(jobName: string) {
export function triggerJob(jobName: string, options?: { target_model?: BacktestTargetModel }) {
return apiClient
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`)
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
.then((r) => r.data);
}
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob } from '../../api/admin';
import type { BacktestTargetModel } from '../../api/admin';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
@@ -143,6 +144,7 @@ export function BacktestPanel() {
const toast = useToast();
const [selectedStrategy, setSelectedStrategy] = useState('');
const [selectedLookback, setSelectedLookback] = useState('');
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
const monitor = report?.portfolio_monitor ?? null;
const activeStrategy =
@@ -159,10 +161,11 @@ export function BacktestPanel() {
);
const run = useMutation({
mutationFn: () => triggerJob('backtest'),
mutationFn: () => triggerJob('backtest', { target_model: targetModel }),
onSuccess: (res) => {
if (res.status === 'triggered') {
toast.addToast('success', 'Backtest started — results appear when it finishes (a minute or two).');
const label = targetModel === 'production_gtl' ? 'Live GTL' : 'Structural S/R comparison';
toast.addToast('success', `${label} backtest started — results appear when it finishes.`);
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['backtest-report'] }), 8000);
} else {
toast.addToast('info', res.message || 'Could not start backtest');
@@ -184,9 +187,61 @@ export function BacktestPanel() {
so read it as directional.
</p>
</Disclosure>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
</Button>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:items-end">
<fieldset className="grid w-full grid-cols-1 gap-2 sm:w-[34rem] sm:grid-cols-2">
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
Target model for this run
</legend>
<label
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
targetModel === 'production_gtl'
? 'border-blue-400/60 bg-blue-500/10'
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
}`}
>
<input
className="sr-only"
type="radio"
name="backtest-target-model"
value="production_gtl"
checked={targetModel === 'production_gtl'}
onChange={() => setTargetModel('production_gtl')}
/>
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
Live GTL
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
Production
</span>
</span>
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
Exact target path used by the live scanner and scheduled backtest.
</span>
</label>
<label
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
targetModel === 'structural_sr'
? 'border-amber-400/50 bg-amber-500/10'
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
}`}
>
<input
className="sr-only"
type="radio"
name="backtest-target-model"
value="structural_sr"
checked={targetModel === 'structural_sr'}
onChange={() => setTargetModel('structural_sr')}
/>
<span className="text-sm font-medium text-gray-200">Structural S/R</span>
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
Comparison only; uses chart structure as the target source.
</span>
</label>
</fieldset>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
</Button>
</div>
</div>
{isLoading && <Callout variant="empty">Loading</Callout>}
@@ -206,6 +261,10 @@ export function BacktestPanel() {
{report.params.cost_per_side_pct != null && (
<> · net of {report.params.cost_per_side_pct}%/side costs</>
)}
{' '}· target model:{' '}
<span className={report.params.is_production_target_model === false ? 'text-amber-300' : 'text-blue-300'}>
{report.params.target_model_label ?? 'Legacy report (model not recorded)'}
</span>
</p>
{monitor && monitorRun ? (
+3
View File
@@ -399,6 +399,9 @@ export interface BacktestReport {
horizon_days: number;
min_lookback: number;
cost_per_side_pct?: number;
target_model?: 'production_gtl' | 'structural_sr';
target_model_label?: string;
is_production_target_model?: boolean;
};
overall_qualified: BacktestBucket;
overall_all: BacktestBucket;
+20 -92
View File
@@ -1,99 +1,27 @@
# Backtest report index
Reports dated 2026-07-11 or earlier are the historical production research
record and are intentionally left untouched.
record and remain untouched.
The retained 2026-07-12/13 S/R reports mark a decision or provide the matching
control for one:
The completed 2026-07-12/13 S/R and Gate Target Ladder research is preserved as
compact decision evidence instead of full per-arm replay output:
- The initial full-period clean-detector regression,
`backtest-20260712-sr-detector-rewrite.json`, remains a local-only report and
is intentionally not added to version control.
- `backtest-sr-v2-train-production_control.json`: pre-July-2024 production
control shared by the detector matrices.
- `backtest-sr-v2-train-rewrite.json` and
`backtest-sr-v2-train-rewrite_legacy_primary.json`: isolate detector geometry
from the primary-target selector.
- `backtest-sr-v2-train-rr_aligned_control.json` and
`backtest-sr-v2-validation-rr_aligned_control.json`: show why primary target
selection must not simply inherit the 2.0 activation floor.
- `backtest-sr-traffic-train-legacy_traffic_grid_only.json`: reproduces the
accidental legacy grid edge.
- `backtest-sr-traffic-train-legacy_range_grid_neutral.json`: proves that the
grid effect does not require volume or touch strength.
- `backtest-sr-v2-validation-production_control.json` and
`backtest-sr-v2-validation-legacy_range_grid_neutral.json`: the post-2024
control and failed range-grid replication. The compact paired result remains
in `sr-v2-validation-comparison.json` and `sr-v2-validation-cohorts.csv`.
- `backtest-sr-range-factor-train-production_range504.json` and
`backtest-sr-range-factor-train-rewrite_range504_legacy_primary.json`: isolate
the explicit 504-day multiplicative-range factor.
- `backtest-sr-range-residual-train-rewrite_range504_structural_legacy_primary.json`
and `backtest-sr-range-residual-train-rewrite_range504_structural_primary2.json`:
the final target-source and primary-floor decision.
- `sr-v2-validation-comparison.json` and `sr-v2-validation-cohorts.csv` record
the held-out detector comparison.
- `sr-full-production-vs-candidate-comparison.json` and its cohort CSV record
the full-period clean-structure replacement decision.
- `sr-explicit-target-ladder-comparison.json` and its cohort CSV record exact
GTL parity: 202,765 candidates, 1,086 qualified setups, 321 book trades,
Sharpe 2.03, CAGR 50.0%, and max drawdown 21.4% in both arms.
- The three `backtest-20260713-gtl-*.json/.md` pairs record the tuning,
confirmation, and strength-sensitivity decisions. No stable improvement was
found, so the production GTL stayed frozen.
The full-period production comparison writes:
The large `backtest-sr-*.json` replay files were removed after consolidation.
They duplicated hundreds of thousands of candidate rows while adding no
decision information beyond the compact comparisons and the narrative in
`docs/research/sr-levels-and-exits.md`. The original raw files remain available
in Git history if a forensic reconstruction is ever necessary.
- `backtest-sr-full-production_control.json`
- `backtest-sr-full-rewrite_range504_structural_legacy_primary.json`
- `sr-full-production-vs-candidate-comparison.json`
- `sr-full-production-vs-candidate-cohorts.csv`
Those outputs should be retained as the final decision point for this research
branch.
The follow-up breadth-preserving ranking experiment writes
`backtest-sr-overlay-full.json`. Retain it only as the decision point for the
pre-registered 5% clean-structure overlay; exploratory weight sweeps should not
be committed.
The Gate Target Ladder (the `explicit_target_ladder` research arm) parity run
writes:
- `backtest-sr-full-explicit_target_ladder.json`
- `sr-explicit-target-ladder-comparison.json`
- `sr-explicit-target-ladder-cohorts.csv`
These establish the final architectural decision: the volume-free explicit
ladder retains all 1,086 qualified setups and exactly reproduces the production
book (Sharpe 2.03, CAGR 50.0%, max drawdown 21.4%, 321 trades). The final rerun
after scanner integration passed: the regenerated report changed only its
`generated_at` timestamp, confirming that the shared helper preserves exact
parity. Nothing in this research branch deploys the change to production.
The archived single-parameter decision point is:
- `backtest-20260713-gtl-tuning-matrix.json`
- `backtest-20260713-gtl-tuning-matrix.md`
These two compact consolidated files are retained; the 20 full per-arm reports
were removed after consolidation.
The completed 2026-07-13 matrix found no single-parameter replacement that
passed all robustness checks. Retain its JSON and Markdown as that decision
point. The retained-versus-added decomposition is retained as:
- `backtest-20260713-gtl-confirmation-matrix.json`
- `backtest-20260713-gtl-confirmation-matrix.md`
Detailed per-arm reports were not retained.
The completed confirmation matrix found one near-hit but no formal winner:
strength-1000 intersection improved full/train/post-2024 Sharpe and CAGR, while
max drawdown worsened from 21.4% to 21.7%. Retain that matrix as the cohort-
decomposition decision point. The final stability check is retained as:
- `backtest-20260713-gtl-strength-sensitivity.json`
- `backtest-20260713-gtl-strength-sensitivity.md`
Its pre-registered decision requires at least two adjacent scales to pass all
six unchanged checks.
Final sensitivity result: exact control and strength-1000 replication passed.
Scale 1500 was the sole 6/6 arm, but no adjacent scale passed, so the stable-
plateau rule rejected it. Retain the consolidated JSON/Markdown as the closing
GTL decision point. No confirmation or tuned strength value should be promoted
from this snapshot.
The temporary matrix runners and their configurable backtest hooks were removed
after consolidation. The normal local snapshot backtester remains.
The initial untracked `backtest-20260712-sr-detector-rewrite.json` is local-only
and is intentionally not part of the repository.
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
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
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
File diff suppressed because it is too large Load Diff
-125
View File
@@ -1,125 +0,0 @@
"""Compare two audited local S/R backtest reports by setup identity.
Reports must be generated with ``--sr-audit``. The comparison is read-only
apart from its explicit CSV/JSON outputs under the caller-selected paths.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("control")
parser.add_argument("variant")
parser.add_argument("--out-csv", required=True)
parser.add_argument("--out-json", required=True)
return parser.parse_args()
def _load(path: str) -> dict:
with Path(path).open(encoding="utf-8") as handle:
report = json.load(handle)
if report.get("sr_candidate_audit") is None:
raise SystemExit(f"Report lacks sr_candidate_audit; rerun with --sr-audit: {path}")
return report
def _key(row: dict) -> tuple[str, str, str]:
return row["symbol"], row["date"], row["direction"]
def _cohort_stats(rows: list[dict]) -> dict:
net = [float(row.get("net_r", 0.0)) for row in rows]
hold = [float(row.get("hold30_r", 0.0)) for row in rows]
trimmed = sorted(net, reverse=True)[math.ceil(len(net) * 0.05):]
return {
"count": len(rows),
"net_avg_r": round(sum(net) / len(net), 4) if net else None,
"net_avg_r_ex_top5": round(sum(trimmed) / len(trimmed), 4) if trimmed else None,
"hold30_avg_r": round(sum(hold) / len(hold), 4) if hold else None,
}
def _production_book(report: dict) -> dict | None:
runs = ((report.get("portfolio_monitor") or {}).get("runs") or [])
row = next(
(
run for run in runs
if run.get("is_production") and run.get("lookback") == "all"
),
None,
)
if row is None:
return None
return {
key: row.get(key)
for key in ("sharpe", "cagr_pct", "max_drawdown_pct", "trades", "skipped_book_full")
}
def main() -> None:
args = _args()
control = _load(args.control)
variant = _load(args.variant)
control_rows = {_key(row): row for row in control["sr_candidate_audit"]}
variant_rows = {_key(row): row for row in variant["sr_candidate_audit"]}
control_q = {key for key, row in control_rows.items() if row.get("qualified")}
variant_q = {key for key, row in variant_rows.items() if row.get("qualified")}
retained = control_q & variant_q
added = variant_q - control_q
removed = control_q - variant_q
union = sorted(control_q | variant_q, key=lambda key: (key[1], key[0], key[2]))
csv_path = Path(args.out_csv)
csv_path.parent.mkdir(parents=True, exist_ok=True)
fields = [
"symbol", "date", "direction", "cohort",
"control_rr", "variant_rr", "control_prob", "variant_prob",
"control_sources", "variant_sources", "control_net_r", "variant_net_r",
"control_hold30_r", "variant_hold30_r",
]
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
for key in union:
c = control_rows.get(key) or {}
v = variant_rows.get(key) or {}
cohort = "retained" if key in retained else "added" if key in added else "removed"
writer.writerow({
"symbol": key[0], "date": key[1], "direction": key[2], "cohort": cohort,
"control_rr": c.get("rr"), "variant_rr": v.get("rr"),
"control_prob": c.get("primary_prob"), "variant_prob": v.get("primary_prob"),
"control_sources": "+".join(c.get("primary_sources") or []),
"variant_sources": "+".join(v.get("primary_sources") or []),
"control_net_r": c.get("net_r"), "variant_net_r": v.get("net_r"),
"control_hold30_r": c.get("hold30_r"), "variant_hold30_r": v.get("hold30_r"),
})
summary = {
"control_report": str(Path(args.control)),
"variant_report": str(Path(args.variant)),
"control_variant": (control.get("params") or {}).get("sr_variant"),
"variant": (variant.get("params") or {}).get("sr_variant"),
"retained": _cohort_stats([variant_rows[key] for key in retained]),
"added": _cohort_stats([variant_rows[key] for key in added]),
"removed": _cohort_stats([control_rows[key] for key in removed]),
"control_book": _production_book(control),
"variant_book": _production_book(variant),
}
json_path = Path(args.out_json)
json_path.parent.mkdir(parents=True, exist_ok=True)
with json_path.open("w", encoding="utf-8") as handle:
json.dump(summary, handle, indent=2)
handle.write("\n")
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()
+12 -44
View File
@@ -47,38 +47,13 @@ def _parse_args() -> argparse.Namespace:
)
parser.add_argument("--quiet", action="store_true", help="Hide progress output.")
parser.add_argument(
"--sr-variant",
choices=(
"production_control", "rr_aligned_control", "rewrite",
"soft_zones", "confirmed_rounds", "gate_v2",
"rewrite_legacy_primary", "soft_zones_legacy_primary",
"confirmed_rounds_legacy_primary", "gate_v2_legacy_primary",
"legacy_geometry_neutral", "legacy_pivots_only",
"legacy_traffic_grid_only", "legacy_range_grid_touch",
"legacy_range_grid_neutral",
"production_range504", "rewrite_range504_legacy_primary",
"rewrite_range504_structural_legacy_primary",
"rewrite_range504_structural_primary2",
"production_structural_overlay",
"explicit_target_ladder",
"--target-model",
choices=("production_gtl", "structural_sr"),
default="production_gtl",
help=(
"Target source: production_gtl matches the live scanner; "
"structural_sr is a comparison-only chart-S/R model."
),
default=None,
help="Research-only S/R detector/gate arm.",
)
parser.add_argument(
"--entry-start",
default=None,
help="Include entries on/after YYYY-MM-DD.",
)
parser.add_argument(
"--entry-end",
default=None,
help="Include entries on/before YYYY-MM-DD.",
)
parser.add_argument(
"--sr-audit",
action="store_true",
help="Include candidate-level S/R audit rows for paired comparison.",
)
parser.add_argument(
"--holdout-split",
@@ -171,10 +146,7 @@ def _print_summary(report: dict) -> None:
row
for row in ((report.get("portfolio_monitor") or {}).get("runs") or [])
if row.get("lookback") == "all"
and (
row.get("is_production")
or row.get("strategy") == "production_structural_overlay5_atr3"
)
and row.get("is_production")
]
if monitor_rows:
print(" live-path full-period comparison:")
@@ -198,14 +170,6 @@ async def _main() -> None:
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
if args.sr_variant:
os.environ["BACKTEST_SR_VARIANT"] = args.sr_variant
if args.entry_start:
os.environ["BACKTEST_ENTRY_START"] = args.entry_start
if args.entry_end:
os.environ["BACKTEST_ENTRY_END"] = args.entry_end
if args.sr_audit:
os.environ["BACKTEST_SR_AUDIT"] = "1"
if args.holdout_split:
try:
date.fromisoformat(args.holdout_split)
@@ -240,7 +204,11 @@ async def _main() -> None:
try:
async with Session() as db:
report = await run_backtest(db, progress_cb=progress)
report = await run_backtest(
db,
progress_cb=progress,
target_model=args.target_model,
)
finally:
await engine.dispose()
-287
View File
@@ -1,287 +0,0 @@
"""Run S/R detector, hidden-feature, or locked validation comparisons.
This is a cross-platform orchestrator around ``run_backtest_snapshot.py``. It
contains no backtest logic; every arm still runs through the production-parity
Python harness.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNNER = ROOT / "scripts" / "run_backtest_snapshot.py"
COMPARE = ROOT / "scripts" / "compare_sr_variants.py"
TRAINING_ARMS = (
"production_control",
"rewrite_legacy_primary",
"soft_zones_legacy_primary",
"confirmed_rounds_legacy_primary",
"gate_v2_legacy_primary",
)
RANGE_GRID_ARMS = (
"legacy_traffic_grid_only",
"legacy_range_grid_touch",
"legacy_range_grid_neutral",
)
LOCKABLE_ARMS = TRAINING_ARMS[1:] + RANGE_GRID_ARMS
TRAFFIC_ARMS = (
"production_control",
"legacy_geometry_neutral",
"legacy_pivots_only",
*RANGE_GRID_ARMS,
)
RANGE_FACTOR_ARMS = (
"production_range504",
"rewrite_range504_legacy_primary",
)
RANGE_RESIDUAL_ARMS = (
"rewrite_range504_structural_legacy_primary",
"rewrite_range504_structural_primary2",
)
FULL_PERIOD_ARMS = (
"production_control",
"rewrite_range504_structural_legacy_primary",
)
def _add_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--snapshot",
default="backtest_snapshots/prod.sqlite",
help="Local SQLite snapshot path.",
)
parser.add_argument("--workers", type=int, default=7)
def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
train = commands.add_parser("train", help="Run all arms before 2024-07-01.")
_add_common(train)
traffic = commands.add_parser(
"traffic",
help="Isolate pivots, range-grid geometry, and touch strength on training data.",
)
_add_common(traffic)
traffic.add_argument(
"--only-arm",
choices=TRAFFIC_ARMS,
default=None,
help="Rerun one hidden-feature arm without repeating the full matrix.",
)
factor = commands.add_parser(
"factor",
help="Test the explicit 504-day range factor with old and clean detectors.",
)
_add_common(factor)
factor.add_argument(
"--only-arm",
choices=RANGE_FACTOR_ARMS,
default=None,
help="Run one range-factor arm without repeating the pair.",
)
residual = commands.add_parser(
"residual",
help="Isolate standalone rounds and the 1.5-2R primary-target veto.",
)
_add_common(residual)
residual.add_argument(
"--only-arm",
choices=RANGE_RESIDUAL_ARMS,
default=None,
help="Run one residual target-geometry arm without repeating the pair.",
)
full = commands.add_parser(
"full",
help="Run current production and the frozen candidate over the full snapshot.",
)
_add_common(full)
overlay = commands.add_parser(
"overlay",
help="Test the frozen 5% clean-structure rank overlay on production breadth.",
)
_add_common(overlay)
ladder = commands.add_parser(
"ladder",
help="Verify the explicit volume-free gate target ladder against production.",
)
_add_common(ladder)
validate = commands.add_parser(
"validate",
help="Run production control and one locked arm from 2024-07-01.",
)
_add_common(validate)
validate.add_argument("--locked-arm", required=True, choices=LOCKABLE_ARMS)
return parser.parse_args()
def _run_arm(
arm: str,
snapshot: str,
workers: int,
*,
entry_flag: str | None = None,
entry_date: str | None = None,
output: Path,
) -> None:
print(f"Running S/R arm: {arm}", flush=True)
command = [
sys.executable,
str(RUNNER),
snapshot,
"--workers", str(workers),
"--allow-spawn",
"--sr-variant", arm,
"--sr-audit",
"--out", str(output),
]
if entry_flag is not None and entry_date is not None:
command.extend((entry_flag, entry_date))
elif entry_flag is not None or entry_date is not None:
raise ValueError("entry_flag and entry_date must be provided together")
subprocess.run(
command,
cwd=ROOT,
check=True,
)
def _train(
args: argparse.Namespace,
arms: tuple[str, ...],
filename_prefix: str,
) -> None:
for arm in arms:
_run_arm(
arm,
args.snapshot,
args.workers,
entry_flag="--entry-end",
entry_date="2024-06-30",
output=ROOT / "reports" / f"{filename_prefix}-{arm}.json",
)
print("Training matrix complete. Review results before running validation.")
def _validate(args: argparse.Namespace) -> None:
reports: dict[str, Path] = {}
for arm in ("production_control", args.locked_arm):
output = ROOT / "reports" / f"backtest-sr-v2-validation-{arm}.json"
reports[arm] = output
_run_arm(
arm,
args.snapshot,
args.workers,
entry_flag="--entry-start",
entry_date="2024-07-01",
output=output,
)
subprocess.run(
[
sys.executable,
str(COMPARE),
str(reports["production_control"]),
str(reports[args.locked_arm]),
"--out-csv", str(ROOT / "reports" / "sr-v2-validation-cohorts.csv"),
"--out-json", str(ROOT / "reports" / "sr-v2-validation-comparison.json"),
],
cwd=ROOT,
check=True,
)
def _full(args: argparse.Namespace) -> None:
reports: dict[str, Path] = {}
for arm in FULL_PERIOD_ARMS:
output = ROOT / "reports" / f"backtest-sr-full-{arm}.json"
reports[arm] = output
_run_arm(
arm,
args.snapshot,
args.workers,
output=output,
)
subprocess.run(
[
sys.executable,
str(COMPARE),
str(reports["production_control"]),
str(reports["rewrite_range504_structural_legacy_primary"]),
"--out-csv", str(ROOT / "reports" / "sr-full-production-vs-candidate-cohorts.csv"),
"--out-json", str(ROOT / "reports" / "sr-full-production-vs-candidate-comparison.json"),
],
cwd=ROOT,
check=True,
)
print("Full-period production comparison complete.")
def _overlay(args: argparse.Namespace) -> None:
_run_arm(
"production_structural_overlay",
args.snapshot,
args.workers,
output=ROOT / "reports" / "backtest-sr-overlay-full.json",
)
print("Full-period structural ranking overlay complete.")
def _ladder(args: argparse.Namespace) -> None:
control = ROOT / "reports" / "backtest-sr-full-production_control.json"
if not control.exists():
raise SystemExit(f"Full-period production control not found: {control}")
variant = ROOT / "reports" / "backtest-sr-full-explicit_target_ladder.json"
_run_arm(
"explicit_target_ladder",
args.snapshot,
args.workers,
output=variant,
)
subprocess.run(
[
sys.executable,
str(COMPARE),
str(control),
str(variant),
"--out-csv", str(
ROOT / "reports" / "sr-explicit-target-ladder-cohorts.csv"
),
"--out-json", str(
ROOT / "reports" / "sr-explicit-target-ladder-comparison.json"
),
],
cwd=ROOT,
check=True,
)
print("Explicit target-ladder production parity comparison complete.")
def main() -> None:
args = _args()
if args.command == "train":
_train(args, TRAINING_ARMS, "backtest-sr-v2-train")
elif args.command == "traffic":
arms = (args.only_arm,) if args.only_arm else TRAFFIC_ARMS
_train(args, arms, "backtest-sr-traffic-train")
elif args.command == "factor":
arms = (args.only_arm,) if args.only_arm else RANGE_FACTOR_ARMS
_train(args, arms, "backtest-sr-range-factor-train")
elif args.command == "residual":
arms = (args.only_arm,) if args.only_arm else RANGE_RESIDUAL_ARMS
_train(args, arms, "backtest-sr-range-residual-train")
elif args.command == "full":
_full(args)
elif args.command == "overlay":
_overlay(args)
elif args.command == "ladder":
_ladder(args)
else:
_validate(args)
if __name__ == "__main__":
main()
+46 -246
View File
@@ -169,40 +169,6 @@ def test_residual_high_vol_blend_is_research_only_rank():
assert cands[0][bt.RESIDUAL_HIGH_VOL_BLEND_60_40_KEY] == 72.0
def test_structural_overlay_is_a_frozen_five_percent_rank_nudge():
cands = [
{
bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY: 80.0,
"structural_overlay_pass": True,
},
{
bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY: 80.0,
"structural_overlay_pass": False,
},
{bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY: 80.0},
]
bt._assign_structural_overlay_score(cands)
assert bt.STRUCTURAL_OVERLAY_WEIGHT == 0.05
assert cands[0][bt.STRUCTURAL_OVERLAY_SCORE_KEY] == pytest.approx(81.0)
assert cands[1][bt.STRUCTURAL_OVERLAY_SCORE_KEY] == pytest.approx(76.0)
assert cands[2][bt.STRUCTURAL_OVERLAY_SCORE_KEY] is None
def test_structural_overlay_monitor_strategy_is_opt_in(monkeypatch):
monkeypatch.setenv("BACKTEST_SR_VARIANT", "production_control")
assert bt._portfolio_monitor_strategies() == bt.PORTFOLIO_MONITOR_STRATEGIES
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.STRUCTURAL_OVERLAY_VARIANT)
strategies = bt._portfolio_monitor_strategies()
overlay = strategies[-1]
assert overlay["strategy"] == bt.STRUCTURAL_OVERLAY_PORTFOLIO_STRATEGY
assert overlay["ranking_key"] == bt.STRUCTURAL_OVERLAY_SCORE_KEY
assert overlay["use_live_config"] is True
assert overlay["is_production"] is False
def test_strategy_variants_keep_only_current_research_candidates():
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
@@ -638,35 +604,6 @@ class TestSimulatePortfolio:
def test_nothing_qualified_returns_none(self):
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None
def test_configured_entry_end_truncates_flat_calendar_tail(self, monkeypatch):
closes = [100.0 + i for i in range(100)]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
monkeypatch.setenv(
"BACKTEST_ENTRY_END", date.fromordinal(self.ORD).isoformat()
)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
assert sim is not None
assert sim["end_date"] == date.fromordinal(self.ORD + 3).isoformat()
def test_configured_entry_start_aligns_book_calendar(self, monkeypatch):
closes = [100.0 + i for i in range(10)]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand(
"AAA", self.ORD + 1, entry=101.0, stop=96.0, target=130.0
)
monkeypatch.setenv(
"BACKTEST_ENTRY_START", date.fromordinal(self.ORD).isoformat()
)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
assert sim is not None
assert sim["start_date"] == date.fromordinal(self.ORD).isoformat()
def test_bucket_stats_counts_and_expectancy():
cands = [
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
@@ -792,138 +729,15 @@ def test_window_setups_too_short_returns_empty():
assert bt._window_setups([], {}, {}) == []
def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
monkeypatch.delenv("BACKTEST_SR_VARIANT", raising=False)
assert bt._sr_research_variant() == bt.EXPLICIT_TARGET_LADDER_VARIANT
for variant in (
"production_control",
"legacy_range_grid_touch",
"legacy_range_grid_neutral",
"production_range504",
"rewrite_range504_legacy_primary",
"rewrite_range504_structural_legacy_primary",
"rewrite_range504_structural_primary2",
"production_structural_overlay",
"explicit_target_ladder",
):
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
assert bt._sr_research_variant() == variant
monkeypatch.setenv("BACKTEST_SR_VARIANT", "not-a-variant")
with pytest.raises(ValueError, match="Unknown BACKTEST_SR_VARIANT"):
bt._sr_research_variant()
def test_backtest_target_model_is_small_and_validated():
assert bt.validate_backtest_target_model(" PRODUCTION_GTL ") == "production_gtl"
assert bt.validate_backtest_target_model("structural_sr") == "structural_sr"
with pytest.raises(ValueError, match="Unknown backtest target model"):
bt.validate_backtest_target_model("legacy_range_grid_touch")
def test_range_factor_detector_mapping_is_explicit():
assert bt._sr_detector_variant("production_range504") == "production_control"
assert bt._sr_detector_variant("rewrite_range504_legacy_primary") == "rewrite"
assert bt._sr_detector_variant(
"rewrite_range504_structural_legacy_primary"
) == "rewrite"
assert bt._sr_detector_variant("rewrite_range504_structural_primary2") == "rewrite"
assert bt._sr_detector_variant("production_structural_overlay") == "production_control"
assert bt._sr_detector_variant("soft_zones_legacy_primary") == "soft_zones"
def test_range_504_log_uses_only_the_bounded_window():
highs = [1_000.0, *([100.0] * bt.RANGE_FACTOR_LOOKBACK)]
lows = [10.0, *([50.0] * bt.RANGE_FACTOR_LOOKBACK)]
assert bt._range_504_log(highs, lows) == pytest.approx(math.log(2.0))
def test_range_factor_gate_is_research_arm_only():
below = bt.RANGE_FACTOR_MIN_LOG - 0.01
assert not bt._range_factor_allows("production_range504", below)
assert not bt._range_factor_allows("rewrite_range504_legacy_primary", below)
assert not bt._range_factor_allows(
"rewrite_range504_structural_primary2",
below,
)
assert bt._range_factor_allows(
"production_range504",
bt.RANGE_FACTOR_MIN_LOG,
)
assert bt._range_factor_allows("production_control", below)
def test_residual_arms_change_only_the_primary_rr_floor():
activation = {"min_rr": 2.0}
assert bt._primary_min_rr_for_variant(
"rewrite_range504_structural_legacy_primary",
activation,
) == 1.5
assert bt._primary_min_rr_for_variant(
"rewrite_range504_structural_primary2",
activation,
) == 2.0
assert bt._primary_min_rr_for_variant(
"production_structural_overlay",
activation,
) == 1.5
assert bt._primary_min_rr_for_variant(
"explicit_target_ladder",
activation,
) == 1.5
def test_structural_overlay_tags_production_geometry_without_replacing_it(monkeypatch):
production = {
"direction": "long",
"meets_core": True,
"rr": 2.2,
"target": 111.0,
"primary_sources": ["volume_profile"],
"gate_level_count": 53,
}
structural = {
"direction": "long",
"meets_core": True,
"rr": 2.8,
"target": 114.0,
"primary_sources": ["pivot_point"],
"gate_level_count": 14,
}
def fake_window_setups(*args, sr_variant=None, **kwargs):
if sr_variant == "production_control":
return [production]
assert sr_variant == bt.STRUCTURAL_OVERLAY_SOURCE_VARIANT
return [structural]
monkeypatch.setattr(bt, "_window_setups", fake_window_setups)
rows = bt._structural_overlay_window_setups([], {}, {})
assert len(rows) == 1
assert rows[0]["target"] == production["target"]
assert rows[0]["rr"] == production["rr"]
assert rows[0]["structural_overlay_pass"] is True
assert rows[0]["structural_overlay_rr"] == structural["rr"]
assert rows[0]["structural_overlay_sources"] == ["pivot_point"]
assert rows[0]["structural_overlay_gate_level_count"] == 14
assert production.get("structural_overlay_pass") is None
@pytest.mark.parametrize(
("variant", "neutral_strength"),
[
("legacy_range_grid_touch", False),
("legacy_range_grid_neutral", True),
],
)
def test_window_setups_routes_explicit_range_grid(
monkeypatch,
variant,
neutral_strength,
):
captured = {}
def fake_detector(*args, **kwargs):
captured.update(kwargs)
return []
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
monkeypatch.setattr(bt, "detect_sr_levels_legacy", fake_detector)
records = [
def _flat_window_records():
return [
SimpleNamespace(
date=date(2024, 1, 1) + timedelta(days=i),
open=100.0,
@@ -934,40 +748,17 @@ def test_window_setups_routes_explicit_range_grid(
)
for i in range(bt.MIN_LOOKBACK)
]
assert bt._window_setups(records, {}, {}) == []
assert captured == {
"include_pivots": False,
"neutral_strength": neutral_strength,
"explicit_range_grid": True,
}
def test_window_setups_routes_full_explicit_target_ladder(monkeypatch):
def test_window_setups_routes_production_gtl_by_default(monkeypatch):
captured = {}
def fake_detector(highs, lows, closes):
captured.update({
"highs": highs,
"lows": lows,
"closes": closes,
})
captured.update({"highs": highs, "lows": lows, "closes": closes})
return []
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.EXPLICIT_TARGET_LADDER_VARIANT)
monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector)
records = [
SimpleNamespace(
date=date(2024, 1, 1) + timedelta(days=i),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000_000,
)
for i in range(bt.MIN_LOOKBACK)
]
assert bt._window_setups(records, {}, {}) == []
assert bt._window_setups(_flat_window_records(), {}, {}) == []
assert captured == {
"highs": [101.0] * bt.MIN_LOOKBACK,
"lows": [99.0] * bt.MIN_LOOKBACK,
@@ -975,32 +766,41 @@ def test_window_setups_routes_full_explicit_target_ladder(monkeypatch):
}
@pytest.mark.parametrize(
"variant",
["legacy_geometry_neutral", "legacy_range_grid_neutral"],
)
def test_neutral_strength_is_enforced_after_zone_clustering(variant):
levels = [
SimpleNamespace(strength=100),
SimpleNamespace(strength=75),
]
result = bt._apply_zone_strength_variant(levels, variant)
assert [level.strength for level in result] == [50, 50]
def test_window_setups_routes_structural_comparison(monkeypatch):
captured = {}
def fake_detector(highs, lows, closes, volumes):
captured.update({
"highs": highs,
"lows": lows,
"closes": closes,
"volumes": volumes,
})
return []
monkeypatch.setattr(bt, "detect_sr_levels", fake_detector)
assert bt._window_setups(
_flat_window_records(),
{},
{},
target_model=bt.STRUCTURAL_SR_TARGET_MODEL,
) == []
assert captured == {
"highs": [101.0] * bt.MIN_LOOKBACK,
"lows": [99.0] * bt.MIN_LOOKBACK,
"closes": [100.0] * bt.MIN_LOOKBACK,
"volumes": [1_000_000] * bt.MIN_LOOKBACK,
}
def test_touch_strength_survives_post_cluster_research_hook():
levels = [SimpleNamespace(strength=82), SimpleNamespace(strength=37)]
result = bt._apply_zone_strength_variant(levels, "legacy_range_grid_touch")
assert [level.strength for level in result] == [82, 37]
def test_backtest_entry_bounds_validate_dates(monkeypatch):
monkeypatch.setenv("BACKTEST_ENTRY_START", "2024-07-01")
monkeypatch.setenv("BACKTEST_ENTRY_END", "2024-12-31")
assert bt._backtest_entry_bounds() == (date(2024, 7, 1), date(2024, 12, 31))
monkeypatch.setenv("BACKTEST_ENTRY_START", "2025-01-01")
with pytest.raises(ValueError, match="on or before"):
bt._backtest_entry_bounds()
def test_window_setups_rejects_removed_research_arm():
with pytest.raises(ValueError, match="Unknown backtest target model"):
bt._window_setups(
_flat_window_records(),
{},
{},
target_model="production_control",
)
def test_replay_ticker_candidates_carry_gate_fields():
@@ -1028,9 +828,7 @@ def test_replay_ticker_candidates_carry_gate_fields():
for c in cands:
assert c.get("action") is not None
assert "risk_level" in c
assert c["range_504_log"] >= 0.0
assert c["range_504_ratio"] >= 1.0
assert isinstance(c["range_factor_pass"], bool)
assert c["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
@@ -1070,6 +868,8 @@ async def test_run_backtest_smoke(session):
# cost assumption is reported, and every bucket carries net numbers
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
assert report["params"]["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
assert report["params"]["is_production_target_model"] is True
assert "net_avg_r" in report["overall_all"]
# ablation baseline reproduces the qualified set exactly, and every row
+21 -131
View File
@@ -6,14 +6,12 @@ from app.services.sr_service import (
MAX_LEVELS,
_bar_respect_weight,
_cap_levels,
_legacy_range_grid_nodes,
_legacy_volume_profile_nodes,
_gate_target_range_centers,
_merge_levels,
_round_number_candidates,
_strength_from_respects,
detect_gate_target_ladder,
detect_sr_levels,
detect_sr_levels_legacy,
)
@@ -237,134 +235,26 @@ class TestDetectSrLevels:
pinned = sum(1 for lvl in levels if lvl["strength"] == 100)
assert pinned < len(levels)
def test_legacy_control_retains_old_uncapped_grid(self):
highs, lows, closes, volumes = _make_series(n=500)
levels = detect_sr_levels_legacy(highs, lows, closes, volumes)
assert levels
assert all(level["sources"] for level in levels)
# The research control intentionally keeps the deployed detector's much
# denser output instead of borrowing the rewrite's presentation cap.
def test_gate_target_range_centers_cover_the_observed_range(self):
highs, lows, closes, _ = _make_series(n=500)
centers = _gate_target_range_centers(highs, lows, closes)
assert len(centers) == 20
assert centers == sorted(centers)
assert min(lows) < centers[0] < centers[-1] < max(highs)
def test_gate_target_ladder_is_dense_transient_price_traffic(self):
highs, lows, closes, _ = _make_series(n=500)
levels = detect_gate_target_ladder(highs, lows, closes)
assert len(levels) > MAX_LEVELS
assert any("range_grid" in level["sources"] for level in levels)
assert all("volume_profile" not in level["sources"] for level in levels)
assert all(0 <= level["strength"] <= 100 for level in levels)
assert all(level["rejection_count"] >= 0 for level in levels)
def test_legacy_geometry_neutral_changes_only_strength(self):
highs, lows, closes, volumes = _make_series(n=500)
control = detect_sr_levels_legacy(highs, lows, closes, volumes)
neutral = detect_sr_levels_legacy(
highs, lows, closes, volumes, neutral_strength=True
def test_gate_target_ladder_is_deterministic(self):
highs, lows, closes, _ = _make_series(n=500)
assert detect_gate_target_ladder(highs, lows, closes) == (
detect_gate_target_ladder(list(highs), list(lows), list(closes))
)
assert [level["price_level"] for level in neutral] == [
level["price_level"] for level in sorted(
control, key=lambda row: row["price_level"]
)
]
assert all(level["strength"] == 50 for level in neutral)
def test_legacy_source_ablation_isolates_candidates(self):
highs, lows, closes, volumes = _make_series(n=500)
pivots = detect_sr_levels_legacy(
highs, lows, closes, volumes, include_volume_profile=False
)
traffic = detect_sr_levels_legacy(
highs, lows, closes, volumes, include_pivots=False
)
assert pivots and traffic
assert all(level["sources"] == ["pivot_point"] for level in pivots)
assert all(level["sources"] == ["volume_profile"] for level in traffic)
def test_legacy_profile_union_matches_explicit_range_grid(self):
highs, lows, closes, volumes = _make_series(n=500)
deployed = _legacy_volume_profile_nodes(highs, lows, closes, volumes)
explicit = _legacy_range_grid_nodes(highs, lows, closes)
assert sorted(deployed) == explicit
assert len(explicit) == 20
def test_explicit_target_ladder_preserves_full_legacy_mechanics(self):
highs, lows, closes, volumes = _make_series(n=500)
deployed = detect_sr_levels_legacy(highs, lows, closes, volumes)
explicit = detect_sr_levels_legacy(
highs,
lows,
closes,
volumes,
explicit_range_grid=True,
)
def mechanics(level):
return {
key: value
for key, value in level.items()
if key not in {"detection_method", "sources"}
}
assert [mechanics(level) for level in explicit] == [
mechanics(level) for level in deployed
]
assert any("range_grid" in level["sources"] for level in explicit)
assert all("volume_profile" not in level["sources"] for level in explicit)
def test_gate_target_ladder_is_the_explicit_volume_free_detector(self):
highs, lows, closes, volumes = _make_series(n=500)
expected = detect_sr_levels_legacy(
highs,
lows,
closes,
volumes,
explicit_range_grid=True,
)
assert detect_gate_target_ladder(highs, lows, closes) == expected
def test_explicit_range_grid_is_volume_independent(self):
highs, lows, closes, volumes = _make_series(n=500)
shifted_volumes = [volume * (i + 1) for i, volume in enumerate(volumes)]
baseline = detect_sr_levels_legacy(
highs,
lows,
closes,
volumes,
include_pivots=False,
explicit_range_grid=True,
)
shifted = detect_sr_levels_legacy(
highs,
lows,
closes,
shifted_volumes,
include_pivots=False,
explicit_range_grid=True,
)
assert shifted == baseline
def test_explicit_range_grid_neutral_changes_only_strength(self):
highs, lows, closes, volumes = _make_series(n=500)
touch = detect_sr_levels_legacy(
highs,
lows,
closes,
volumes,
include_pivots=False,
explicit_range_grid=True,
)
neutral = detect_sr_levels_legacy(
highs,
lows,
closes,
volumes,
include_pivots=False,
neutral_strength=True,
explicit_range_grid=True,
)
touch_by_price = {level["price_level"]: level for level in touch}
neutral_by_price = {level["price_level"]: level for level in neutral}
assert neutral_by_price.keys() == touch_by_price.keys()
for price, neutral_level in neutral_by_price.items():
assert neutral_level["strength"] == 50
assert {
key: value
for key, value in neutral_level.items()
if key != "strength"
} == {
key: value
for key, value in touch_by_price[price].items()
if key != "strength"
}
+13 -1
View File
@@ -3,15 +3,27 @@
import pytest
from app.scheduler import (
_is_job_enabled,
_consume_backtest_target_model,
_parse_frequency,
_resume_tickers,
_last_successful,
configure_scheduler,
queue_backtest_target_model,
scheduler,
)
def test_manual_backtest_target_model_is_one_shot():
assert queue_backtest_target_model("structural_sr") == "structural_sr"
assert _consume_backtest_target_model() == "structural_sr"
assert _consume_backtest_target_model() == "production_gtl"
def test_manual_backtest_target_model_rejects_removed_research_arms():
with pytest.raises(ValueError, match="Unknown backtest target model"):
queue_backtest_target_model("production_control")
class TestParseFrequency:
def test_hourly(self):
assert _parse_frequency("hourly") == {"hours": 1}