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 run itself is local/offline. `backtest_snapshots/` and generated backtest reports
are git-ignored. are git-ignored.
Without a research override, both this runner and the scheduled/Admin online The local runner, scheduled job, and Admin UI all default to
backtest use `explicit_target_ladder`, matching the live scanner's gate-target `production_gtl`, matching the live scanner's target path. For a deliberate
path. Clean Structural S/R detector arms must be selected explicitly with comparison, select **Structural S/R (comparison)** in the UI or pass
`--sr-variant`; they are not production backtest defaults. `--target-model structural_sr` locally. Every report records the selected model
and whether it is the production path.
### Archived GTL tuning decision ### 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_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_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_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_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 | | `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, AlertConfigUpdate,
CreateUserRequest, CreateUserRequest,
DataCleanupRequest, DataCleanupRequest,
JobTriggerRequest,
JobToggle, JobToggle,
RecommendationConfigUpdate, RecommendationConfigUpdate,
ScheduleConfigUpdate, ScheduleConfigUpdate,
@@ -376,11 +377,16 @@ async def get_pipeline_readiness(
@router.post("/admin/jobs/{job_name}/trigger", response_model=APIEnvelope) @router.post("/admin/jobs/{job_name}/trigger", response_model=APIEnvelope)
async def trigger_job( async def trigger_job(
job_name: str, job_name: str,
body: JobTriggerRequest | None = None,
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Trigger a manual job run (placeholder).""" """Trigger a manual job run, optionally with one-run parameters."""
result = await admin_service.trigger_job(db, job_name) 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) 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.providers.protocol import SentimentData
from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store from app.services import fundamental_service, ingestion_service, sentiment_service, settings_store
from app.services.alert_service import dispatch_alerts 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.benchmark_service import refresh_benchmark_prices
from app.services.market_regime_service import update_market_regime from app.services.market_regime_service import update_market_regime
from app.services.regime_monitor_service import update_regime_monitor 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} _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: def _log_event(level: int, event: str, **fields: object) -> None:
"""Emit a structured JSON log line: {"event": ..., **fields}.""" """Emit a structured JSON log line: {"event": ..., **fields}."""
logger.log(level, json.dumps({"event": 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: async def run_backtest_job() -> None:
"""Replay the price-derived engine over history and cache the report.""" """Replay the price-derived engine over history and cache the report."""
job_name = "backtest" 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) _runtime_start(job_name)
def _on_progress(done: int, count: int, symbol: str) -> None: 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") _runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
return 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( _runtime_finish(
job_name, "completed", job_name, "completed",
processed=report.get("tickers", 0), total=report.get("tickers", 0), 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")) _log_event(logging.INFO, "job_complete", job=job_name, candidates=report.get("candidates"))
except Exception as exc: except Exception as exc:
+5
View File
@@ -43,6 +43,11 @@ class JobToggle(BaseModel):
enabled: bool 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): class RecommendationConfigUpdate(BaseModel):
high_confidence_threshold: float | None = Field(default=None, ge=0, le=100) high_confidence_threshold: float | None = Field(default=None, ge=0, le=100)
moderate_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 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. """Trigger a manual job run via the scheduler.
Runs the job immediately (in addition to its regular schedule). Runs the job immediately (in addition to its regular schedule).
""" """
if job_name not in VALID_JOB_NAMES: if job_name not in VALID_JOB_NAMES:
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(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 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: if job is None:
return {"job": job_name, "status": "not_found", "message": f"Job '{job_name}' is not registered in the scheduler"} 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 job.modify(next_run_time=None) # Reset, then trigger immediately
from datetime import datetime, timezone from datetime import datetime, timezone
job.modify(next_run_time=datetime.now(timezone.utc)) 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: async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSetting:
+56 -422
View File
@@ -33,7 +33,7 @@ import statistics
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -82,12 +82,7 @@ from app.services.scoring_service import (
compute_momentum_from_closes, compute_momentum_from_closes,
compute_technical_from_arrays, compute_technical_from_arrays,
) )
from app.services.sr_service import ( from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
MAX_LEVELS,
detect_gate_target_ladder,
detect_sr_levels,
detect_sr_levels_legacy,
)
logger = logging.getLogger(__name__) 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) MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51)
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator) HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
ATR_MULTIPLIER = 1.5 ATR_MULTIPLIER = 1.5
RANGE_FACTOR_LOOKBACK = 504 PRODUCTION_GTL_TARGET_MODEL = "production_gtl"
RANGE_FACTOR_MIN_LOG = 1.0 # approximately a 2.7x high/low span STRUCTURAL_SR_TARGET_MODEL = "structural_sr"
STRUCTURAL_OVERLAY_VARIANT = "production_structural_overlay" BACKTEST_TARGET_MODELS = {
STRUCTURAL_OVERLAY_SOURCE_VARIANT = ( PRODUCTION_GTL_TARGET_MODEL: "Live GTL (production)",
"rewrite_range504_structural_legacy_primary" STRUCTURAL_SR_TARGET_MODEL: "Structural S/R (comparison)",
)
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,
} }
# Cross-sectional signal evaluation (factor IC). Each candidate signal is a # 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 = { def validate_backtest_target_model(value: str) -> str:
"production_control", """Validate the small, user-facing set of supported backtest target models."""
"rr_aligned_control", normalized = value.strip().lower()
"rewrite", if normalized not in BACKTEST_TARGET_MODELS:
"soft_zones", allowed = ", ".join(BACKTEST_TARGET_MODELS)
"confirmed_rounds", raise ValueError(f"Unknown backtest target model {value!r}; expected one of {allowed}")
"gate_v2", return normalized
"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 _atr_target_fallback_k() -> float | None: def _atr_target_fallback_k() -> float | None:
@@ -346,7 +214,7 @@ def _window_setups(
config: dict, config: dict,
activation: dict, activation: dict,
*, *,
sr_variant: str | None = None, target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> list[dict]: ) -> list[dict]:
"""Rebuild the setup(s) at the last bar of ``window_records`` (the as-of date), """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.""" using only those bars. Returns one dict per tradeable direction."""
@@ -373,58 +241,20 @@ def _window_setups(
if atr <= 0: if atr <= 0:
return [] return []
sr_variant = sr_variant or _sr_research_variant() target_model = validate_backtest_target_model(target_model)
detector_variant = _sr_detector_variant(sr_variant) if target_model == PRODUCTION_GTL_TARGET_MODEL:
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:
detected_levels = detect_gate_target_ladder( detected_levels = detect_gate_target_ladder(
highs, highs,
lows, lows,
closes, 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: else:
detector_cap = 0 if detector_variant == "gate_v2" else MAX_LEVELS detected_levels = detect_sr_levels(highs, lows, closes, volumes)
detected_levels = detect_sr_levels(
highs, lows, closes, volumes, max_levels=detector_cap
)
sr_levels = _wrap_levels(detected_levels) sr_levels = _wrap_levels(detected_levels)
if not sr_levels: if not sr_levels:
return [] return []
gate_levels = _gate_eligible_levels( gate_levels = _gate_eligible_levels(sr_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"
)
technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0 technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0
momentum = (compute_momentum_from_closes(closes)[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( zone_levels = _zone_representative_levels(
gate_levels, gate_levels,
entry, 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) targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
if not targets: if not targets:
fallback_k = _atr_target_fallback_k() fallback_k = _atr_target_fallback_k()
@@ -462,10 +291,9 @@ def _window_setups(
# Collapse duplicate floor-pinned lottery targets (parity with # Collapse duplicate floor-pinned lottery targets (parity with
# enhance_trade_setup). # enhance_trade_setup).
targets = _prune_floor_pinned_targets(targets) targets = _prune_floor_pinned_targets(targets)
primary_min_rr = _primary_min_rr_for_variant(sr_variant, activation)
primary = _select_primary_target( primary = _select_primary_target(
targets, targets,
min_rr=primary_min_rr, min_rr=1.5,
) )
if primary is None: if primary is None:
continue continue
@@ -507,10 +335,6 @@ def _window_setups(
# week are known. run_backtest ranks momentum and finalizes `qualified`. # week are known. run_backtest ranks momentum and finalizes `qualified`.
core_config = {**activation, "min_momentum_percentile": 0.0} core_config = {**activation, "min_momentum_percentile": 0.0}
meets_core = setup_qualifies(setup_ns, core_config) 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) best_prob = best_target_probability(setup_ns)
out.append({ out.append({
"direction": direction, "direction": direction,
@@ -525,7 +349,7 @@ def _window_setups(
"meets_core": meets_core, "meets_core": meets_core,
"action": action, "action": action,
"risk_level": risk_level, "risk_level": risk_level,
"sr_variant": sr_variant, "target_model": target_model,
"primary_sources": list(primary.get("sr_sources") or []), "primary_sources": list(primary.get("sr_sources") or []),
"primary_strength": float(primary.get("sr_strength", 0.0)), "primary_strength": float(primary.get("sr_strength", 0.0)),
"primary_rejection_count": int( "primary_rejection_count": int(
@@ -537,62 +361,10 @@ def _window_setups(
), ),
"raw_level_count": len(sr_levels), "raw_level_count": len(sr_levels),
"gate_level_count": len(gate_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 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: 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 """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, 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, config: dict,
activation: dict, activation: dict,
benchmark_closes: dict[date, float] | None = None, benchmark_closes: dict[date, float] | None = None,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> list[dict]: ) -> list[dict]:
"""Walk one ticker's history weekly, building setups and their realized outcomes.""" """Walk one ticker's history weekly, building setups and their realized outcomes."""
candidates: list[dict] = [] candidates: list[dict] = []
@@ -679,14 +452,7 @@ def _replay_ticker(
if n < MIN_LOOKBACK + HORIZON: if n < MIN_LOOKBACK + HORIZON:
return candidates 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): 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] window = records[: i + 1]
forward = records[i + 1 :] forward = records[i + 1 :]
forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward] 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) vol_6m = _realized_vol_6m(closes, len(window) - 1)
setups = ( setups = _window_setups(
_structural_overlay_window_setups(window, config, activation)
if sr_variant == STRUCTURAL_OVERLAY_VARIANT
else _window_setups(
window, window,
config, config,
activation, activation,
sr_variant=sr_variant, target_model=target_model,
)
) )
for s in setups: for s in setups:
outcome, outcome_date = evaluate_setup_against_bars( outcome, outcome_date = evaluate_setup_against_bars(
@@ -755,7 +517,7 @@ def _replay_ticker(
# every candidate looks NEUTRAL and the ablation rows collapse. # every candidate looks NEUTRAL and the ablation rows collapse.
"action": s["action"], "action": s["action"],
"risk_level": s["risk_level"], "risk_level": s["risk_level"],
"sr_variant": s["sr_variant"], "target_model": s["target_model"],
"primary_sources": s["primary_sources"], "primary_sources": s["primary_sources"],
"primary_strength": s["primary_strength"], "primary_strength": s["primary_strength"],
"primary_rejection_count": s["primary_rejection_count"], "primary_rejection_count": s["primary_rejection_count"],
@@ -763,15 +525,6 @@ def _replay_ticker(
"primary_distance_atr": s["primary_distance_atr"], "primary_distance_atr": s["primary_distance_atr"],
"raw_level_count": s["raw_level_count"], "raw_level_count": s["raw_level_count"],
"gate_level_count": s["gate_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, "outcome": outcome,
"target_hit": target_hit, "target_hit": target_hit,
"realized_r": realized_r, "realized_r": realized_r,
@@ -832,8 +585,8 @@ def _robustness_stats(net_rs: list[float]) -> dict:
} }
def _sr_variant_diagnostics(candidates: list[dict]) -> dict: def _target_model_diagnostics(candidates: list[dict], target_model: str) -> dict:
"""Compact evidence audit for the active local S/R research arm.""" """Compact target-source diagnostics for the selected supported model."""
source_counts: dict[str, int] = defaultdict(int) source_counts: dict[str, int] = defaultdict(int)
round_only = 0 round_only = 0
strengths: list[float] = [] strengths: list[float] = []
@@ -841,9 +594,6 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
rejections: list[int] = [] rejections: list[int] = []
raw_counts: list[int] = [] raw_counts: list[int] = []
gate_counts: list[int] = [] gate_counts: list[int] = []
range_logs: list[float] = []
overlay_rows = 0
overlay_pass = 0
for cand in candidates: for cand in candidates:
sources = list(cand.get("primary_sources") or []) sources = list(cand.get("primary_sources") or [])
for source in sources: 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)) rejections.append(int(cand.get("primary_rejection_count", 0) or 0))
raw_counts.append(int(cand.get("raw_level_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)) 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: def avg(values: list[float] | list[int]) -> float | None:
return round(sum(values) / len(values), 3) if values else None return round(sum(values) / len(values), 3) if values else None
return { return {
"variant": _sr_research_variant(), "target_model": target_model,
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
"candidate_count": len(candidates), "candidate_count": len(candidates),
"primary_source_counts": dict(sorted(source_counts.items())), "primary_source_counts": dict(sorted(source_counts.items())),
"primary_round_only": round_only, "primary_round_only": round_only,
@@ -874,80 +621,9 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict:
"avg_primary_rejection_count": avg(rejections), "avg_primary_rejection_count": avg(rejections),
"avg_raw_level_count": avg(raw_counts), "avg_raw_level_count": avg(raw_counts),
"avg_gate_level_count": avg(gate_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 # 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) # 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 # 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, config: dict,
activation: dict, activation: dict,
benchmark_closes: dict[date, float] | None = None, benchmark_closes: dict[date, float] | None = None,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> tuple[list[dict], dict]: ) -> tuple[list[dict], dict]:
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can """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), 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) for o, op, hi, lo, cl, vo in zip(date_ords, opens, highs, lows, closes, volumes)
] ]
return ( return (
_replay_ticker(symbol, bars, config, activation, benchmark_closes), _replay_ticker(
symbol,
bars,
config,
activation,
benchmark_closes,
target_model,
),
_signal_series(bars, benchmark_closes), _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: def _momentum_qualifies(cand: dict, threshold: float) -> bool:
"""Whether a candidate clears the floors (meets_core) and the momentum gate. """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: Threshold 0 disables the momentum gate (floors only). The gate is long-only:
@@ -1641,17 +1308,9 @@ def _simulate_portfolio(
qualified_fn = _default_qualified qualified_fn = _default_qualified
entries_by_ord: dict[int, list[dict]] = defaultdict(list) entries_by_ord: dict[int, list[dict]] = defaultdict(list)
configured_start, configured_end = _backtest_entry_bounds() start_ord = start_date.toordinal() if start_date is not None else None
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. # Explicit simulator/holdout end dates are exclusive split boundaries.
end_ord = end_date.toordinal() end_ord = end_date.toordinal() if end_date is not None else None
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
for c in candidates: for c in candidates:
if not qualified_fn(c) or c.get("direction") != "long": if not qualified_fn(c) or c.get("direction") != "long":
continue continue
@@ -2333,7 +1992,6 @@ PORTFOLIO_MONITOR_LOOKBACKS: tuple[dict, ...] = (
) )
PRODUCTION_PORTFOLIO_STRATEGY = "residual80_highvol80_20_atr3" PRODUCTION_PORTFOLIO_STRATEGY = "residual80_highvol80_20_atr3"
STRUCTURAL_OVERLAY_PORTFOLIO_STRATEGY = "production_structural_overlay5_atr3"
PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = ( PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
{ {
"strategy": "legacy_residual80_hold", "strategy": "legacy_residual80_hold",
@@ -2368,22 +2026,7 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
def _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
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,
},)
def _entry_variant_config(variant: str) -> dict | None: def _entry_variant_config(variant: str) -> dict | None:
@@ -3132,8 +2775,11 @@ def _build_recommendation(report: dict) -> dict:
async def run_backtest( async def run_backtest(
db: AsyncSession, db: AsyncSession,
progress_cb: Callable[[int, int, str], None] | None = None, progress_cb: Callable[[int, int, str], None] | None = None,
*,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> dict: ) -> dict:
"""Replay every ticker and aggregate the Phase-1 reports for the current config.""" """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) config = await get_recommendation_config(db)
activation = await get_activation_config(db) activation = await get_activation_config(db)
@@ -3198,6 +2844,7 @@ async def run_backtest(
futures.append(loop.run_in_executor( futures.append(loop.run_in_executor(
pool, _replay_and_signals, ticker.symbol, columns, config, activation, pool, _replay_and_signals, ticker.symbol, columns, config, activation,
benchmark_closes, benchmark_closes,
target_model,
)) ))
for result in await asyncio.gather(*futures, return_exceptions=True): for result in await asyncio.gather(*futures, return_exceptions=True):
if isinstance(result, Exception): if isinstance(result, Exception):
@@ -3219,6 +2866,7 @@ async def run_backtest(
_merge(await asyncio.to_thread( _merge(await asyncio.to_thread(
_replay_and_signals, ticker.symbol, columns, config, activation, _replay_and_signals, ticker.symbol, columns, config, activation,
benchmark_closes, benchmark_closes,
target_model,
)) ))
except Exception: except Exception:
logger.exception("Backtest replay failed for %s", ticker.symbol) logger.exception("Backtest replay failed for %s", ticker.symbol)
@@ -3235,7 +2883,6 @@ async def run_backtest(
_assign_activation_momentum_percentiles(candidates) _assign_activation_momentum_percentiles(candidates)
_assign_residual_low_vol_blend(candidates) _assign_residual_low_vol_blend(candidates)
_assign_residual_high_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)) current_min_pct = float(activation.get("min_momentum_percentile", 80.0))
for c in candidates: for c in candidates:
c["qualified"] = _momentum_qualifies(c, current_min_pct) c["qualified"] = _momentum_qualifies(c, current_min_pct)
@@ -3334,26 +2981,9 @@ async def run_backtest(
"horizon_days": HORIZON, "horizon_days": HORIZON,
"min_lookback": MIN_LOOKBACK, "min_lookback": MIN_LOOKBACK,
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3), "cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
"sr_variant": _sr_research_variant(), "target_model": target_model,
"range_factor_lookback": RANGE_FACTOR_LOOKBACK, "target_model_label": BACKTEST_TARGET_MODELS[target_model],
"range_factor_min_log": RANGE_FACTOR_MIN_LOG, "is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
"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
),
}, },
"activation": activation, "activation": activation,
"overall_qualified": _bucket_stats(qualified), "overall_qualified": _bucket_stats(qualified),
@@ -3417,8 +3047,10 @@ async def run_backtest(
"portfolio_monitor": portfolio_monitor_report, "portfolio_monitor": portfolio_monitor_report,
"holdout": holdout_report, "holdout": holdout_report,
"min_rr_sweep": min_rr_sweep_report, "min_rr_sweep": min_rr_sweep_report,
"sr_variant_diagnostics": _sr_variant_diagnostics(candidates), "target_model_diagnostics": _target_model_diagnostics(
"sr_candidate_audit": _sr_candidate_audit(candidates, current_min_pct), candidates,
target_model,
),
"signal_eval": _signal_evaluation(collected), "signal_eval": _signal_evaluation(collected),
"signal_eval_note": ( "signal_eval_note": (
"Cross-sectional rank-IC of price-only signals vs the forward " "Cross-sectional rank-IC of price-only signals vs the forward "
@@ -3446,9 +3078,11 @@ async def run_backtest(
async def run_and_store( async def run_and_store(
db: AsyncSession, db: AsyncSession,
progress_cb: Callable[[int, int, str], None] | None = None, progress_cb: Callable[[int, int, str], None] | None = None,
*,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
) -> dict: ) -> dict:
"""Run the backtest and cache the report in a SystemSetting. Job entrypoint.""" """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)) await update_setting(db, KEY_REPORT, json.dumps(report))
return report return report
+11 -91
View File
@@ -358,55 +358,13 @@ def _extract_candidate_levels(
return candidates return candidates
def _legacy_volume_profile_nodes( def _gate_target_range_centers(
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(
highs: list[float], highs: list[float],
lows: list[float], lows: list[float],
closes: list[float], closes: list[float],
num_bins: int = 20, num_bins: int = 20,
) -> list[float]: ) -> list[float]:
"""Return every deployed grid center without the irrelevant volume pass. """Return the evenly spaced price proposals used by the production GTL."""
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.
"""
if len(closes) < 20: if len(closes) < 20:
raise ValidationError( raise ValidationError(
f"Range grid requires at least 20 bars, got {len(closes)}" f"Range grid requires at least 20 bars, got {len(closes)}"
@@ -422,41 +380,30 @@ def _legacy_range_grid_nodes(
] ]
def detect_sr_levels_legacy( def detect_gate_target_ladder(
highs: list[float], highs: list[float],
lows: list[float], lows: list[float],
closes: list[float], closes: list[float],
volumes: list[int],
tolerance: float = DEFAULT_TOLERANCE, tolerance: float = DEFAULT_TOLERANCE,
*,
include_volume_profile: bool = True,
include_pivots: bool = True,
neutral_strength: bool = False,
explicit_range_grid: bool = False,
) -> list[dict]: ) -> 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 This is intentionally not human-facing support/resistance. It builds the
allow a research arm to remove one source or neutralize strength without production gate's broad 20-bin range grid, adds unfiltered pivots, scores
copying or subtly changing the legacy implementation. historical price traffic, and merges nearby proposals. The returned levels
are transient and must not be persisted as chart S/R.
""" """
if not closes: if not closes:
return [] return []
candidates: list[tuple[float, str]] = [] candidates: list[tuple[float, str]] = []
if include_volume_profile:
try: try:
nodes = ( candidates.extend(
_legacy_range_grid_nodes(highs, lows, closes) (float(price), "range_grid")
if explicit_range_grid for price in _gate_target_range_centers(highs, lows, closes)
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: except ValidationError:
pass pass
if include_pivots:
try: try:
pivots = compute_pivot_points(highs, lows, closes) pivots = compute_pivot_points(highs, lows, closes)
candidates.extend( candidates.extend(
@@ -515,37 +462,10 @@ def detect_sr_levels_legacy(
) )
_tag_levels(merged, closes[-1]) _tag_levels(merged, closes[-1])
if neutral_strength:
for level in merged:
level["strength"] = 50
merged.sort(key=lambda row: row["strength"], reverse=True) merged.sort(key=lambda row: row["strength"], reverse=True)
return merged 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( def _merge_levels(
levels: list[dict], levels: list[dict],
tolerance: float = DEFAULT_TOLERANCE, 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. 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. 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 The detector rewrite was decomposed into the causal arms below. They are names
scanner does not read `BACKTEST_SR_VARIANT`; these switches exist only in the in the historical experiment record, not supported runtime configuration:
offline snapshot harness:
| arm | behavior | | arm | behavior |
|---|---| |---|---|
@@ -412,26 +411,10 @@ offline snapshot harness:
| `confirmed_rounds` | soft zones; standalone rounds need two rejection clusters | | `confirmed_rounds` | soft zones; standalone rounds need two rejection clusters |
| `gate_v2` | confirmed rounds plus uncapped gate evidence | | `gate_v2` | confirmed rounds plus uncapped gate evidence |
Detector evidence (`sources`, rejection count, last rejection age) stays in the The matrix runner, comparator, environment switch, and candidate-level audit
pure backtest objects. It is deliberately not migrated into the production DB were removed when the investigation closed. The compact comparison JSONs,
schema until a variant passes validation. cohort CSVs, and this narrative retain the decisions; Git history retains the
raw implementation and reports for forensic reconstruction.
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.
> Validation result: `confirmed_rounds` is rejected and is no longer a lockable > 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 > 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_pivots_only`: unfiltered full-history pivots, no VP grid;
- `legacy_traffic_grid_only`: deployed HVN+LVN grid, no pivots. - `legacy_traffic_grid_only`: deployed HVN+LVN grid, no pivots.
Run on macOS: The traffic matrix first established whether geometry, pivots, or the
range-occupancy grid reproduced production on pre-2024 training data.
```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.
Corrected training result: 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 - `legacy_range_grid_neutral`: identical centers, strength fixed at 50 after
clustering. 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 The touch arm reproduced `legacy_traffic_grid_only` exactly: all 121,464
candidates, 504 qualified setups, cohort membership, expectancy, and portfolio candidates, 504 qualified setups, cohort membership, expectancy, and portfolio
metrics match. Volume contributes nothing. Neutral strength won the training metrics match. Volume contributes nothing. Neutral strength won the training
@@ -583,12 +550,6 @@ effect:
- `rewrite_range504_legacy_primary`: clean targets, frozen primary selection, - `rewrite_range504_legacy_primary`: clean targets, frozen primary selection,
plus the identical range gate. 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: Result:
| training arm | qualified | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% | | 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 - `rewrite_range504_structural_primary2`: identical, but select the primary from
targets clearing 2.0R. targets clearing 2.0R.
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py residual --workers 14
```
### Full-period production comparison ### Full-period production comparison
After freezing `rewrite_range504_structural_legacy_primary`, run it beside a The frozen `rewrite_range504_structural_legacy_primary` candidate was run beside
fresh `production_control` over the complete snapshot with identical portfolio a fresh `production_control` over the complete snapshot with identical
and exit settings: portfolio and exit settings. The retained decision files are
`reports/sr-full-production-vs-candidate-comparison.json` and its cohort CSV;
```bash the redundant full candidate-row reports were removed after consolidation.
.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`
This is an apples-to-apples full-history diagnostic against the current live 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 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 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; report must first reproduce the production qualified count and production book;
otherwise the comparison is invalid. Run the one-arm full-period diagnostic: otherwise the comparison is invalid. The candidate advances only if
```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
the overlay improves full-period Sharpe, does not worsen drawdown, and retains 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 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 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 loaded only on demand from `GET /gate-target-ladder/{symbol}`, and must remain
visually distinct from persisted Structural S/R. visually distinct from persisted Structural S/R.
The `explicit_target_ladder` arm therefore replaces only the irrelevant volume The production GTL replaces only the irrelevant volume pass with the complete
pass with the complete range grid. It retains pivots, touch strength, merge range grid. It retains pivots, touch strength, merge geometry, primary
geometry, primary selection, qualification, ranking, and exit behavior. Grid selection, qualification, ranking, and exit behavior. Grid levels are labelled
levels are labelled `range_grid`, making the internal purpose explicit. `range_grid`, making the internal purpose explicit.
```bash
.venv/bin/python scripts/run_sr_v2_matrix.py ladder --workers 14
```
The arm advances only on exact parity with the full-period production control: The arm advances only on exact parity with the full-period production control:
no added or removed qualified setups and identical production-book Sharpe, 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 for the current scan, and never writes those proposal levels to `SRLevel`. The
primary-target selector retains its independently researched 1.5 floor; 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. 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 scanner, so the final full-period rerun is an implementation-parity check rather
than another detector experiment. 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 must be pre-registered and evaluated on genuinely new forward data rather than
another iteration over the same history. another iteration over the same history.
The scheduled/Admin online backtest and the unconfigured local snapshot The scheduled backtest, Admin UI, and local snapshot runner therefore default
backtest therefore default to `explicit_target_ladder`. Structural detector to `production_gtl`. A manual UI/local run may select `structural_sr` as an
variants remain explicit research overrides. After deployment, rerun the Admin explicit comparison; every report records the model and whether it is the
backtest once to replace any cached report produced by the former `rewrite` production path. After deployment, rerun the Admin backtest once to replace any
default. cached report produced by the former default.
The temporary GTL matrix scripts, configurable detector branches, and The temporary GTL matrix scripts, configurable detector branches, and
confirmation hooks were removed after this decision. The normal snapshot 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 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 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; job: string;
status: 'triggered' | 'busy' | 'blocked' | 'not_found'; status: 'triggered' | 'busy' | 'blocked' | 'not_found';
message: string; message: string;
target_model?: BacktestTargetModel;
} }
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
export function listJobs() { export function listJobs() {
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data); 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); .then((r) => r.data);
} }
export function triggerJob(jobName: string) { export function triggerJob(jobName: string, options?: { target_model?: BacktestTargetModel }) {
return apiClient return apiClient
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`) .post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
.then((r) => r.data); .then((r) => r.data);
} }
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useBacktestReport } from '../../hooks/useMarketRegime'; import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob } from '../../api/admin'; import { triggerJob } from '../../api/admin';
import type { BacktestTargetModel } from '../../api/admin';
import { Button } from '../ui/Button'; import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout'; import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure'; import { Disclosure } from '../ui/Disclosure';
@@ -143,6 +144,7 @@ export function BacktestPanel() {
const toast = useToast(); const toast = useToast();
const [selectedStrategy, setSelectedStrategy] = useState(''); const [selectedStrategy, setSelectedStrategy] = useState('');
const [selectedLookback, setSelectedLookback] = useState(''); const [selectedLookback, setSelectedLookback] = useState('');
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
const monitor = report?.portfolio_monitor ?? null; const monitor = report?.portfolio_monitor ?? null;
const activeStrategy = const activeStrategy =
@@ -159,10 +161,11 @@ export function BacktestPanel() {
); );
const run = useMutation({ const run = useMutation({
mutationFn: () => triggerJob('backtest'), mutationFn: () => triggerJob('backtest', { target_model: targetModel }),
onSuccess: (res) => { onSuccess: (res) => {
if (res.status === 'triggered') { 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); setTimeout(() => queryClient.invalidateQueries({ queryKey: ['backtest-report'] }), 8000);
} else { } else {
toast.addToast('info', res.message || 'Could not start backtest'); toast.addToast('info', res.message || 'Could not start backtest');
@@ -184,10 +187,62 @@ export function BacktestPanel() {
so read it as directional. so read it as directional.
</p> </p>
</Disclosure> </Disclosure>
<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"> <Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'} {run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
</Button> </Button>
</div> </div>
</div>
{isLoading && <Callout variant="empty">Loading</Callout>} {isLoading && <Callout variant="empty">Loading</Callout>}
@@ -206,6 +261,10 @@ export function BacktestPanel() {
{report.params.cost_per_side_pct != null && ( {report.params.cost_per_side_pct != null && (
<> · net of {report.params.cost_per_side_pct}%/side costs</> <> · 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> </p>
{monitor && monitorRun ? ( {monitor && monitorRun ? (
+3
View File
@@ -399,6 +399,9 @@ export interface BacktestReport {
horizon_days: number; horizon_days: number;
min_lookback: number; min_lookback: number;
cost_per_side_pct?: 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_qualified: BacktestBucket;
overall_all: BacktestBucket; overall_all: BacktestBucket;
+20 -92
View File
@@ -1,99 +1,27 @@
# Backtest report index # Backtest report index
Reports dated 2026-07-11 or earlier are the historical production research 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 The completed 2026-07-12/13 S/R and Gate Target Ladder research is preserved as
control for one: compact decision evidence instead of full per-arm replay output:
- The initial full-period clean-detector regression, - `sr-v2-validation-comparison.json` and `sr-v2-validation-cohorts.csv` record
`backtest-20260712-sr-detector-rewrite.json`, remains a local-only report and the held-out detector comparison.
is intentionally not added to version control. - `sr-full-production-vs-candidate-comparison.json` and its cohort CSV record
- `backtest-sr-v2-train-production_control.json`: pre-July-2024 production the full-period clean-structure replacement decision.
control shared by the detector matrices. - `sr-explicit-target-ladder-comparison.json` and its cohort CSV record exact
- `backtest-sr-v2-train-rewrite.json` and GTL parity: 202,765 candidates, 1,086 qualified setups, 321 book trades,
`backtest-sr-v2-train-rewrite_legacy_primary.json`: isolate detector geometry Sharpe 2.03, CAGR 50.0%, and max drawdown 21.4% in both arms.
from the primary-target selector. - The three `backtest-20260713-gtl-*.json/.md` pairs record the tuning,
- `backtest-sr-v2-train-rr_aligned_control.json` and confirmation, and strength-sensitivity decisions. No stable improvement was
`backtest-sr-v2-validation-rr_aligned_control.json`: show why primary target found, so the production GTL stayed frozen.
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.
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` The initial untracked `backtest-20260712-sr-detector-rewrite.json` is local-only
- `backtest-sr-full-rewrite_range504_structural_legacy_primary.json` and is intentionally not part of the repository.
- `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.
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("--quiet", action="store_true", help="Hide progress output.")
parser.add_argument( parser.add_argument(
"--sr-variant", "--target-model",
choices=( choices=("production_gtl", "structural_sr"),
"production_control", "rr_aligned_control", "rewrite", default="production_gtl",
"soft_zones", "confirmed_rounds", "gate_v2", help=(
"rewrite_legacy_primary", "soft_zones_legacy_primary", "Target source: production_gtl matches the live scanner; "
"confirmed_rounds_legacy_primary", "gate_v2_legacy_primary", "structural_sr is a comparison-only chart-S/R model."
"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",
), ),
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( parser.add_argument(
"--holdout-split", "--holdout-split",
@@ -171,10 +146,7 @@ def _print_summary(report: dict) -> None:
row row
for row in ((report.get("portfolio_monitor") or {}).get("runs") or []) for row in ((report.get("portfolio_monitor") or {}).get("runs") or [])
if row.get("lookback") == "all" if row.get("lookback") == "all"
and ( and row.get("is_production")
row.get("is_production")
or row.get("strategy") == "production_structural_overlay5_atr3"
)
] ]
if monitor_rows: if monitor_rows:
print(" live-path full-period comparison:") print(" live-path full-period comparison:")
@@ -198,14 +170,6 @@ async def _main() -> None:
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1" os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
if args.allow_spawn: if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1" 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: if args.holdout_split:
try: try:
date.fromisoformat(args.holdout_split) date.fromisoformat(args.holdout_split)
@@ -240,7 +204,11 @@ async def _main() -> None:
try: try:
async with Session() as db: 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: finally:
await engine.dispose() 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 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(): def test_strategy_variants_keep_only_current_research_candidates():
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS} variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
@@ -638,35 +604,6 @@ class TestSimulatePortfolio:
def test_nothing_qualified_returns_none(self): def test_nothing_qualified_returns_none(self):
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None 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(): def test_bucket_stats_counts_and_expectancy():
cands = [ cands = [
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win _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([], {}, {}) == [] assert bt._window_setups([], {}, {}) == []
def test_sr_research_variant_is_explicit_and_validated(monkeypatch): def test_backtest_target_model_is_small_and_validated():
monkeypatch.delenv("BACKTEST_SR_VARIANT", raising=False) assert bt.validate_backtest_target_model(" PRODUCTION_GTL ") == "production_gtl"
assert bt._sr_research_variant() == bt.EXPLICIT_TARGET_LADDER_VARIANT assert bt.validate_backtest_target_model("structural_sr") == "structural_sr"
with pytest.raises(ValueError, match="Unknown backtest target model"):
for variant in ( bt.validate_backtest_target_model("legacy_range_grid_touch")
"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_range_factor_detector_mapping_is_explicit(): def _flat_window_records():
assert bt._sr_detector_variant("production_range504") == "production_control" return [
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 = [
SimpleNamespace( SimpleNamespace(
date=date(2024, 1, 1) + timedelta(days=i), date=date(2024, 1, 1) + timedelta(days=i),
open=100.0, open=100.0,
@@ -934,40 +748,17 @@ def test_window_setups_routes_explicit_range_grid(
) )
for i in range(bt.MIN_LOOKBACK) 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 = {} captured = {}
def fake_detector(highs, lows, closes): def fake_detector(highs, lows, closes):
captured.update({ captured.update({"highs": highs, "lows": lows, "closes": closes})
"highs": highs,
"lows": lows,
"closes": closes,
})
return [] return []
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.EXPLICIT_TARGET_LADDER_VARIANT)
monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector) monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector)
records = [ assert bt._window_setups(_flat_window_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 captured == { assert captured == {
"highs": [101.0] * bt.MIN_LOOKBACK, "highs": [101.0] * bt.MIN_LOOKBACK,
"lows": [99.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( def test_window_setups_routes_structural_comparison(monkeypatch):
"variant", captured = {}
["legacy_geometry_neutral", "legacy_range_grid_neutral"],
) def fake_detector(highs, lows, closes, volumes):
def test_neutral_strength_is_enforced_after_zone_clustering(variant): captured.update({
levels = [ "highs": highs,
SimpleNamespace(strength=100), "lows": lows,
SimpleNamespace(strength=75), "closes": closes,
] "volumes": volumes,
result = bt._apply_zone_strength_variant(levels, variant) })
assert [level.strength for level in result] == [50, 50] 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(): def test_window_setups_rejects_removed_research_arm():
levels = [SimpleNamespace(strength=82), SimpleNamespace(strength=37)] with pytest.raises(ValueError, match="Unknown backtest target model"):
result = bt._apply_zone_strength_variant(levels, "legacy_range_grid_touch") bt._window_setups(
assert [level.strength for level in result] == [82, 37] _flat_window_records(),
{},
{},
def test_backtest_entry_bounds_validate_dates(monkeypatch): target_model="production_control",
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_replay_ticker_candidates_carry_gate_fields(): def test_replay_ticker_candidates_carry_gate_fields():
@@ -1028,9 +828,7 @@ def test_replay_ticker_candidates_carry_gate_fields():
for c in cands: for c in cands:
assert c.get("action") is not None assert c.get("action") is not None
assert "risk_level" in c assert "risk_level" in c
assert c["range_504_log"] >= 0.0 assert c["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
assert c["range_504_ratio"] >= 1.0
assert isinstance(c["range_factor_pass"], bool)
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None: 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 # 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"]["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"] assert "net_avg_r" in report["overall_all"]
# ablation baseline reproduces the qualified set exactly, and every row # 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, MAX_LEVELS,
_bar_respect_weight, _bar_respect_weight,
_cap_levels, _cap_levels,
_legacy_range_grid_nodes, _gate_target_range_centers,
_legacy_volume_profile_nodes,
_merge_levels, _merge_levels,
_round_number_candidates, _round_number_candidates,
_strength_from_respects, _strength_from_respects,
detect_gate_target_ladder, detect_gate_target_ladder,
detect_sr_levels, detect_sr_levels,
detect_sr_levels_legacy,
) )
@@ -237,134 +235,26 @@ class TestDetectSrLevels:
pinned = sum(1 for lvl in levels if lvl["strength"] == 100) pinned = sum(1 for lvl in levels if lvl["strength"] == 100)
assert pinned < len(levels) assert pinned < len(levels)
def test_legacy_control_retains_old_uncapped_grid(self): def test_gate_target_range_centers_cover_the_observed_range(self):
highs, lows, closes, volumes = _make_series(n=500) highs, lows, closes, _ = _make_series(n=500)
levels = detect_sr_levels_legacy(highs, lows, closes, volumes) centers = _gate_target_range_centers(highs, lows, closes)
assert levels
assert all(level["sources"] for level in levels) assert len(centers) == 20
# The research control intentionally keeps the deployed detector's much assert centers == sorted(centers)
# denser output instead of borrowing the rewrite's presentation cap. 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 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): def test_gate_target_ladder_is_deterministic(self):
highs, lows, closes, volumes = _make_series(n=500) highs, lows, closes, _ = _make_series(n=500)
control = detect_sr_levels_legacy(highs, lows, closes, volumes) assert detect_gate_target_ladder(highs, lows, closes) == (
neutral = detect_sr_levels_legacy( detect_gate_target_ladder(list(highs), list(lows), list(closes))
highs, lows, closes, volumes, neutral_strength=True
) )
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 import pytest
from app.scheduler import ( from app.scheduler import (
_is_job_enabled, _consume_backtest_target_model,
_parse_frequency, _parse_frequency,
_resume_tickers, _resume_tickers,
_last_successful, _last_successful,
configure_scheduler, configure_scheduler,
queue_backtest_target_model,
scheduler, 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: class TestParseFrequency:
def test_hourly(self): def test_hourly(self):
assert _parse_frequency("hourly") == {"hours": 1} assert _parse_frequency("hourly") == {"hours": 1}