Finalize GTL and retire S/R research harness
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
@@ -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
-250680
File diff suppressed because it is too large
Load Diff
-250733
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
@@ -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()
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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):
|
||||||
|
captured.update({
|
||||||
|
"highs": highs,
|
||||||
|
"lows": lows,
|
||||||
|
"closes": closes,
|
||||||
|
"volumes": volumes,
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(bt, "detect_sr_levels", fake_detector)
|
||||||
|
assert bt._window_setups(
|
||||||
|
_flat_window_records(),
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
target_model=bt.STRUCTURAL_SR_TARGET_MODEL,
|
||||||
|
) == []
|
||||||
|
assert captured == {
|
||||||
|
"highs": [101.0] * bt.MIN_LOOKBACK,
|
||||||
|
"lows": [99.0] * bt.MIN_LOOKBACK,
|
||||||
|
"closes": [100.0] * bt.MIN_LOOKBACK,
|
||||||
|
"volumes": [1_000_000] * bt.MIN_LOOKBACK,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_setups_rejects_removed_research_arm():
|
||||||
|
with pytest.raises(ValueError, match="Unknown backtest target model"):
|
||||||
|
bt._window_setups(
|
||||||
|
_flat_window_records(),
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
target_model="production_control",
|
||||||
)
|
)
|
||||||
def test_neutral_strength_is_enforced_after_zone_clustering(variant):
|
|
||||||
levels = [
|
|
||||||
SimpleNamespace(strength=100),
|
|
||||||
SimpleNamespace(strength=75),
|
|
||||||
]
|
|
||||||
result = bt._apply_zone_strength_variant(levels, variant)
|
|
||||||
assert [level.strength for level in result] == [50, 50]
|
|
||||||
|
|
||||||
|
|
||||||
def test_touch_strength_survives_post_cluster_research_hook():
|
|
||||||
levels = [SimpleNamespace(strength=82), SimpleNamespace(strength=37)]
|
|
||||||
result = bt._apply_zone_strength_variant(levels, "legacy_range_grid_touch")
|
|
||||||
assert [level.strength for level in result] == [82, 37]
|
|
||||||
|
|
||||||
|
|
||||||
def test_backtest_entry_bounds_validate_dates(monkeypatch):
|
|
||||||
monkeypatch.setenv("BACKTEST_ENTRY_START", "2024-07-01")
|
|
||||||
monkeypatch.setenv("BACKTEST_ENTRY_END", "2024-12-31")
|
|
||||||
assert bt._backtest_entry_bounds() == (date(2024, 7, 1), date(2024, 12, 31))
|
|
||||||
monkeypatch.setenv("BACKTEST_ENTRY_START", "2025-01-01")
|
|
||||||
with pytest.raises(ValueError, match="on or before"):
|
|
||||||
bt._backtest_entry_bounds()
|
|
||||||
|
|
||||||
|
|
||||||
def test_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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user