Add single-command GTL tuning matrix
This commit is contained in:
@@ -459,6 +459,33 @@ 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.
|
||||||
|
|
||||||
|
### One-command GTL tuning run
|
||||||
|
|
||||||
|
The Gate Target Ladder has a research-only, single-variable matrix for testing
|
||||||
|
whether its useful screening behavior can be made more explicit. It runs 20
|
||||||
|
complete backtests **sequentially** so each arm gets the full worker pool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS/Linux
|
||||||
|
.venv/bin/python scripts/run_gtl_tuning_matrix.py \
|
||||||
|
backtest_snapshots/prod.sqlite --workers 14
|
||||||
|
```
|
||||||
|
|
||||||
|
The arms cover GTL history length, grid density, pivots, price-traffic scoring,
|
||||||
|
proposal merging, target-zone width, candidate count, and maximum target
|
||||||
|
distance. Every arm includes the full-period production book, the fixed
|
||||||
|
`2024-07-01` train/test split, a candidate-level paired audit, and the same
|
||||||
|
pre-registered robustness screen. This is one long command, not a Cartesian
|
||||||
|
parameter search; expect total runtime to be roughly 20 times one full local
|
||||||
|
backtest.
|
||||||
|
|
||||||
|
Progress is checkpointed after every arm to
|
||||||
|
`reports/backtest-YYYYMMDD-gtl-tuning-matrix.json` and the matching `.md` table.
|
||||||
|
The large per-arm reports are removed only after successful consolidation. If
|
||||||
|
the run fails, they remain available for diagnosis; pass `--keep-arm-reports`
|
||||||
|
to retain them after success too. No arm changes live scanner defaults or
|
||||||
|
deploys anything.
|
||||||
|
|
||||||
### Reading a local backtest report
|
### Reading a local backtest report
|
||||||
|
|
||||||
The deployed **Signals → Track Record** page is deliberately trimmed to validation
|
The deployed **Signals → Track Record** page is deliberately trimmed to validation
|
||||||
@@ -497,6 +524,7 @@ 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_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_GTL_CONFIG=<json>` | Parameterizes only the `gtl_tuning` research arm; normally set by `run_gtl_tuning_matrix.py` |
|
||||||
| `BACKTEST_ENTRY_START=YYYY-MM-DD` | Restrict candidate entry dates to a validation window |
|
| `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_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_SR_AUDIT=1` | Add momentum-slice candidate rows for paired S/R cohort comparison |
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ 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 dataclasses import asdict, dataclass
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from functools import lru_cache
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ from app.services.recommendation_service import (
|
|||||||
_prune_floor_pinned_targets,
|
_prune_floor_pinned_targets,
|
||||||
_risk_level_from_conflicts,
|
_risk_level_from_conflicts,
|
||||||
_select_primary_target,
|
_select_primary_target,
|
||||||
|
_SR_ZONE_TOLERANCE,
|
||||||
_zone_representative_levels,
|
_zone_representative_levels,
|
||||||
direction_analyzer,
|
direction_analyzer,
|
||||||
get_recommendation_config,
|
get_recommendation_config,
|
||||||
@@ -83,6 +86,7 @@ from app.services.scoring_service import (
|
|||||||
compute_technical_from_arrays,
|
compute_technical_from_arrays,
|
||||||
)
|
)
|
||||||
from app.services.sr_service import (
|
from app.services.sr_service import (
|
||||||
|
GateTargetLadderConfig,
|
||||||
MAX_LEVELS,
|
MAX_LEVELS,
|
||||||
detect_gate_target_ladder,
|
detect_gate_target_ladder,
|
||||||
detect_sr_levels,
|
detect_sr_levels,
|
||||||
@@ -106,6 +110,7 @@ STRUCTURAL_OVERLAY_SOURCE_VARIANT = (
|
|||||||
STRUCTURAL_OVERLAY_WEIGHT = 0.05
|
STRUCTURAL_OVERLAY_WEIGHT = 0.05
|
||||||
STRUCTURAL_OVERLAY_SCORE_KEY = "structural_overlay_95_5_score"
|
STRUCTURAL_OVERLAY_SCORE_KEY = "structural_overlay_95_5_score"
|
||||||
EXPLICIT_TARGET_LADDER_VARIANT = "explicit_target_ladder"
|
EXPLICIT_TARGET_LADDER_VARIANT = "explicit_target_ladder"
|
||||||
|
GTL_TUNING_VARIANT = "gtl_tuning"
|
||||||
RANGE_RESIDUAL_VARIANTS = {
|
RANGE_RESIDUAL_VARIANTS = {
|
||||||
"rewrite_range504_structural_legacy_primary",
|
"rewrite_range504_structural_legacy_primary",
|
||||||
"rewrite_range504_structural_primary2",
|
"rewrite_range504_structural_primary2",
|
||||||
@@ -116,6 +121,52 @@ RANGE_FACTOR_VARIANTS = {
|
|||||||
*RANGE_RESIDUAL_VARIANTS,
|
*RANGE_RESIDUAL_VARIANTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GTLResearchConfig:
|
||||||
|
"""Research-only controls for one GTL matrix arm.
|
||||||
|
|
||||||
|
The default values reproduce the explicit Gate Target Ladder. Production
|
||||||
|
never reads ``BACKTEST_GTL_CONFIG``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "control"
|
||||||
|
lookback_bars: int | None = None
|
||||||
|
grid_bins: int = 20
|
||||||
|
include_pivots: bool = True
|
||||||
|
pivot_window: int = 2
|
||||||
|
touch_tolerance: float = 0.005
|
||||||
|
merge_tolerance: float = 0.005
|
||||||
|
strength_scale: float = 500.0
|
||||||
|
zone_tolerance: float = 0.02
|
||||||
|
candidate_limit: int | None = 5
|
||||||
|
max_target_atr: float | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.name.strip():
|
||||||
|
raise ValueError("GTL research config name must not be empty")
|
||||||
|
if not isinstance(self.include_pivots, bool):
|
||||||
|
raise ValueError("GTL include_pivots must be a boolean")
|
||||||
|
if not 0.0 <= self.zone_tolerance < 0.10:
|
||||||
|
raise ValueError("GTL zone_tolerance must be in [0, 0.10)")
|
||||||
|
if self.candidate_limit is not None and self.candidate_limit < 1:
|
||||||
|
raise ValueError("GTL candidate_limit must be positive or null")
|
||||||
|
if self.max_target_atr is not None and self.max_target_atr <= 0:
|
||||||
|
raise ValueError("GTL max_target_atr must be positive or null")
|
||||||
|
# Reuse the detector's validation for its subset of fields.
|
||||||
|
self.ladder_config()
|
||||||
|
|
||||||
|
def ladder_config(self) -> GateTargetLadderConfig:
|
||||||
|
return GateTargetLadderConfig(
|
||||||
|
lookback_bars=self.lookback_bars,
|
||||||
|
grid_bins=self.grid_bins,
|
||||||
|
include_pivots=self.include_pivots,
|
||||||
|
pivot_window=self.pivot_window,
|
||||||
|
touch_tolerance=self.touch_tolerance,
|
||||||
|
merge_tolerance=self.merge_tolerance,
|
||||||
|
strength_scale=self.strength_scale,
|
||||||
|
)
|
||||||
|
|
||||||
# Cross-sectional signal evaluation (factor IC). Each candidate signal is a
|
# Cross-sectional signal evaluation (factor IC). Each candidate signal is a
|
||||||
# point-in-time number computed from closes alone (sentiment/fundamentals have no
|
# point-in-time number computed from closes alone (sentiment/fundamentals have no
|
||||||
# history here), sampled one as-of per ISO week, and graded by how its rank
|
# history here), sampled one as-of per ISO week, and graded by how its rank
|
||||||
@@ -170,6 +221,7 @@ SR_RESEARCH_VARIANTS = {
|
|||||||
"legacy_range_grid_touch",
|
"legacy_range_grid_touch",
|
||||||
"legacy_range_grid_neutral",
|
"legacy_range_grid_neutral",
|
||||||
EXPLICIT_TARGET_LADDER_VARIANT,
|
EXPLICIT_TARGET_LADDER_VARIANT,
|
||||||
|
GTL_TUNING_VARIANT,
|
||||||
STRUCTURAL_OVERLAY_VARIANT,
|
STRUCTURAL_OVERLAY_VARIANT,
|
||||||
*RANGE_FACTOR_VARIANTS,
|
*RANGE_FACTOR_VARIANTS,
|
||||||
}
|
}
|
||||||
@@ -184,6 +236,31 @@ def _sr_research_variant() -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def _parse_gtl_research_config(raw: str) -> GTLResearchConfig:
|
||||||
|
"""Parse one auditable JSON configuration passed by the offline runner."""
|
||||||
|
if not raw.strip():
|
||||||
|
return GTLResearchConfig()
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("BACKTEST_GTL_CONFIG must be valid JSON") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("BACKTEST_GTL_CONFIG must be a JSON object")
|
||||||
|
allowed = set(GTLResearchConfig.__dataclass_fields__)
|
||||||
|
unknown = sorted(set(payload) - allowed)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown GTL research config fields: {', '.join(unknown)}")
|
||||||
|
try:
|
||||||
|
return GTLResearchConfig(**payload)
|
||||||
|
except TypeError as exc:
|
||||||
|
raise ValueError(f"Invalid GTL research config: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _gtl_research_config() -> GTLResearchConfig:
|
||||||
|
return _parse_gtl_research_config(os.getenv("BACKTEST_GTL_CONFIG", ""))
|
||||||
|
|
||||||
|
|
||||||
def _sr_detector_variant(sr_variant: str) -> str:
|
def _sr_detector_variant(sr_variant: str) -> str:
|
||||||
"""Map factor-gated research arms to the detector they hold fixed."""
|
"""Map factor-gated research arms to the detector they hold fixed."""
|
||||||
if sr_variant in {"production_range504", STRUCTURAL_OVERLAY_VARIANT}:
|
if sr_variant in {"production_range504", STRUCTURAL_OVERLAY_VARIANT}:
|
||||||
@@ -224,6 +301,7 @@ def _primary_min_rr_for_variant(sr_variant: str, activation: dict) -> float:
|
|||||||
"production_control",
|
"production_control",
|
||||||
"production_range504",
|
"production_range504",
|
||||||
EXPLICIT_TARGET_LADDER_VARIANT,
|
EXPLICIT_TARGET_LADDER_VARIANT,
|
||||||
|
GTL_TUNING_VARIANT,
|
||||||
STRUCTURAL_OVERLAY_VARIANT,
|
STRUCTURAL_OVERLAY_VARIANT,
|
||||||
}
|
}
|
||||||
or sr_variant.endswith("_legacy_primary")
|
or sr_variant.endswith("_legacy_primary")
|
||||||
@@ -366,6 +444,7 @@ def _window_setups(
|
|||||||
|
|
||||||
sr_variant = sr_variant or _sr_research_variant()
|
sr_variant = sr_variant or _sr_research_variant()
|
||||||
detector_variant = _sr_detector_variant(sr_variant)
|
detector_variant = _sr_detector_variant(sr_variant)
|
||||||
|
gtl_config = _gtl_research_config() if sr_variant == GTL_TUNING_VARIANT else None
|
||||||
range_504_log = _range_504_log(highs, lows)
|
range_504_log = _range_504_log(highs, lows)
|
||||||
if sr_variant == "legacy_geometry_neutral":
|
if sr_variant == "legacy_geometry_neutral":
|
||||||
detected_levels = detect_sr_levels_legacy(
|
detected_levels = detect_sr_levels_legacy(
|
||||||
@@ -385,6 +464,15 @@ def _window_setups(
|
|||||||
lows,
|
lows,
|
||||||
closes,
|
closes,
|
||||||
)
|
)
|
||||||
|
elif sr_variant == GTL_TUNING_VARIANT:
|
||||||
|
if gtl_config is None: # pragma: no cover - guarded by the variant above
|
||||||
|
raise RuntimeError("GTL tuning variant requires a research config")
|
||||||
|
detected_levels = detect_gate_target_ladder(
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
config=gtl_config.ladder_config(),
|
||||||
|
)
|
||||||
elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}:
|
elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}:
|
||||||
detected_levels = detect_sr_levels_legacy(
|
detected_levels = detect_sr_levels_legacy(
|
||||||
highs,
|
highs,
|
||||||
@@ -435,9 +523,20 @@ def _window_setups(
|
|||||||
gate_levels,
|
gate_levels,
|
||||||
entry,
|
entry,
|
||||||
strength_mode=zone_strength_mode,
|
strength_mode=zone_strength_mode,
|
||||||
|
tolerance=(
|
||||||
|
gtl_config.zone_tolerance if gtl_config else _SR_ZONE_TOLERANCE
|
||||||
|
),
|
||||||
)
|
)
|
||||||
zone_levels = _apply_zone_strength_variant(zone_levels, sr_variant)
|
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,
|
||||||
|
max_targets=(gtl_config.candidate_limit if gtl_config else 5),
|
||||||
|
max_atr_multiple_override=(gtl_config.max_target_atr if gtl_config else None),
|
||||||
|
)
|
||||||
if not targets:
|
if not targets:
|
||||||
fallback_k = _atr_target_fallback_k()
|
fallback_k = _atr_target_fallback_k()
|
||||||
if fallback_k is None:
|
if fallback_k is None:
|
||||||
@@ -3326,6 +3425,11 @@ async def run_backtest(
|
|||||||
"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(),
|
"sr_variant": _sr_research_variant(),
|
||||||
|
"gtl_config": (
|
||||||
|
asdict(_gtl_research_config())
|
||||||
|
if _sr_research_variant() == GTL_TUNING_VARIANT
|
||||||
|
else None
|
||||||
|
),
|
||||||
"range_factor_lookback": RANGE_FACTOR_LOOKBACK,
|
"range_factor_lookback": RANGE_FACTOR_LOOKBACK,
|
||||||
"range_factor_min_log": RANGE_FACTOR_MIN_LOG,
|
"range_factor_min_log": RANGE_FACTOR_MIN_LOG,
|
||||||
"range_factor_min_ratio": round(math.exp(RANGE_FACTOR_MIN_LOG), 4),
|
"range_factor_min_ratio": round(math.exp(RANGE_FACTOR_MIN_LOG), 4),
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ def _zone_representative_levels(
|
|||||||
entry_price: float,
|
entry_price: float,
|
||||||
*,
|
*,
|
||||||
strength_mode: str = "sum",
|
strength_mode: str = "sum",
|
||||||
|
tolerance: float = _SR_ZONE_TOLERANCE,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
"""Collapse near-duplicate S/R levels into one representative per zone.
|
"""Collapse near-duplicate S/R levels into one representative per zone.
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ def _zone_representative_levels(
|
|||||||
zones = cluster_sr_zones(
|
zones = cluster_sr_zones(
|
||||||
level_dicts,
|
level_dicts,
|
||||||
entry_price,
|
entry_price,
|
||||||
tolerance=_SR_ZONE_TOLERANCE,
|
tolerance=tolerance,
|
||||||
strength_mode=strength_mode,
|
strength_mode=strength_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -315,9 +316,16 @@ class TargetGenerator:
|
|||||||
stop_loss: float,
|
stop_loss: float,
|
||||||
sr_levels: list[SRLevel],
|
sr_levels: list[SRLevel],
|
||||||
atr_value: float,
|
atr_value: float,
|
||||||
|
*,
|
||||||
|
max_targets: int | None = 5,
|
||||||
|
max_atr_multiple_override: float | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
if atr_value <= 0:
|
if atr_value <= 0:
|
||||||
return []
|
return []
|
||||||
|
if max_targets is not None and max_targets < 1:
|
||||||
|
raise ValueError("max_targets must be positive or None")
|
||||||
|
if max_atr_multiple_override is not None and max_atr_multiple_override <= 0:
|
||||||
|
raise ValueError("max_atr_multiple_override must be positive or None")
|
||||||
|
|
||||||
risk = abs(entry_price - stop_loss)
|
risk = abs(entry_price - stop_loss)
|
||||||
if risk <= 0:
|
if risk <= 0:
|
||||||
@@ -326,11 +334,12 @@ class TargetGenerator:
|
|||||||
candidates: list[dict[str, Any]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
atr_pct = atr_value / entry_price if entry_price > 0 else 0.0
|
atr_pct = atr_value / entry_price if entry_price > 0 else 0.0
|
||||||
|
|
||||||
max_atr_multiple: float | None = None
|
max_atr_multiple: float | None = max_atr_multiple_override
|
||||||
if atr_pct > 0.05:
|
if max_atr_multiple is None:
|
||||||
max_atr_multiple = 10.0
|
if atr_pct > 0.05:
|
||||||
elif atr_pct < 0.02:
|
max_atr_multiple = 10.0
|
||||||
max_atr_multiple = 3.0
|
elif atr_pct < 0.02:
|
||||||
|
max_atr_multiple = 3.0
|
||||||
|
|
||||||
for level in sr_levels:
|
for level in sr_levels:
|
||||||
is_candidate = False
|
is_candidate = False
|
||||||
@@ -383,6 +392,12 @@ class TargetGenerator:
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
if max_targets is None:
|
||||||
|
candidates.sort(key=lambda row: row["distance_from_entry"])
|
||||||
|
for target in candidates:
|
||||||
|
target.pop("quality", None)
|
||||||
|
return candidates
|
||||||
|
|
||||||
# Select up to 5 targets that SPAN the distance range, instead of the
|
# Select up to 5 targets that SPAN the distance range, instead of the
|
||||||
# top-5 by quality (which biases toward far, high-R:R levels and buries
|
# top-5 by quality (which biases toward far, high-R:R levels and buries
|
||||||
# every nearby target). Guarantees the nearest level plus a
|
# every nearby target). Guarantees the nearest level plus a
|
||||||
@@ -396,6 +411,8 @@ class TargetGenerator:
|
|||||||
selected_ids: set[int] = set()
|
selected_ids: set[int] = set()
|
||||||
|
|
||||||
def _add(candidate: dict[str, Any] | None) -> None:
|
def _add(candidate: dict[str, Any] | None) -> None:
|
||||||
|
if len(selected) >= max_targets:
|
||||||
|
return
|
||||||
if candidate is not None and candidate["sr_level_id"] not in selected_ids:
|
if candidate is not None and candidate["sr_level_id"] not in selected_ids:
|
||||||
selected.append(candidate)
|
selected.append(candidate)
|
||||||
selected_ids.add(candidate["sr_level_id"])
|
selected_ids.add(candidate["sr_level_id"])
|
||||||
@@ -408,7 +425,7 @@ class TargetGenerator:
|
|||||||
_add(max(bucket, key=lambda c: c["quality"]))
|
_add(max(bucket, key=lambda c: c["quality"]))
|
||||||
# Fill remaining slots with the next-best by quality
|
# Fill remaining slots with the next-best by quality
|
||||||
for candidate in sorted(candidates, key=lambda c: c["quality"], reverse=True):
|
for candidate in sorted(candidates, key=lambda c: c["quality"], reverse=True):
|
||||||
if len(selected) >= 5:
|
if len(selected) >= max_targets:
|
||||||
break
|
break
|
||||||
_add(candidate)
|
_add(candidate)
|
||||||
|
|
||||||
|
|||||||
+146
-8
@@ -10,6 +10,7 @@ and persists to DB.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
@@ -32,6 +33,40 @@ from app.services.price_service import query_ohlcv
|
|||||||
|
|
||||||
DEFAULT_TOLERANCE = 0.005 # fallback when ATR unavailable; also API legacy default
|
DEFAULT_TOLERANCE = 0.005 # fallback when ATR unavailable; also API legacy default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GateTargetLadderConfig:
|
||||||
|
"""Research controls for the transient Gate Target Ladder.
|
||||||
|
|
||||||
|
Live callers omit this object and retain the frozen legacy-parity path.
|
||||||
|
The backtest may pass an explicit configuration to isolate one ladder
|
||||||
|
mechanism at a time without changing Structural S/R.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lookback_bars: int | None = None
|
||||||
|
grid_bins: int = 20
|
||||||
|
include_pivots: bool = True
|
||||||
|
pivot_window: int = 2
|
||||||
|
touch_tolerance: float = DEFAULT_TOLERANCE
|
||||||
|
merge_tolerance: float = DEFAULT_TOLERANCE
|
||||||
|
strength_scale: float = 500.0
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.lookback_bars is not None and self.lookback_bars < 20:
|
||||||
|
raise ValueError("GTL lookback_bars must be at least 20 or None")
|
||||||
|
if self.grid_bins < 2:
|
||||||
|
raise ValueError("GTL grid_bins must be at least 2")
|
||||||
|
if self.pivot_window < 1:
|
||||||
|
raise ValueError("GTL pivot_window must be at least 1")
|
||||||
|
for name, value in (
|
||||||
|
("touch_tolerance", self.touch_tolerance),
|
||||||
|
("merge_tolerance", self.merge_tolerance),
|
||||||
|
):
|
||||||
|
if not 0.0 <= value < 0.10:
|
||||||
|
raise ValueError(f"GTL {name} must be in [0, 0.10)")
|
||||||
|
if self.strength_scale <= 0:
|
||||||
|
raise ValueError("GTL strength_scale must be positive")
|
||||||
|
|
||||||
VP_LOOKBACK = 252
|
VP_LOOKBACK = 252
|
||||||
TOUCH_LOOKBACK = 252
|
TOUCH_LOOKBACK = 252
|
||||||
PIVOT_LOOKBACK = 504
|
PIVOT_LOOKBACK = 504
|
||||||
@@ -527,6 +562,8 @@ def detect_gate_target_ladder(
|
|||||||
lows: list[float],
|
lows: list[float],
|
||||||
closes: list[float],
|
closes: list[float],
|
||||||
tolerance: float = DEFAULT_TOLERANCE,
|
tolerance: float = DEFAULT_TOLERANCE,
|
||||||
|
*,
|
||||||
|
config: GateTargetLadderConfig | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build the scanner's internal, volume-free target proposal ladder.
|
"""Build the scanner's internal, volume-free target proposal ladder.
|
||||||
|
|
||||||
@@ -536,14 +573,115 @@ def detect_gate_target_ladder(
|
|||||||
profile calculation. The returned levels are transient and must not be
|
profile calculation. The returned levels are transient and must not be
|
||||||
persisted as chart S/R.
|
persisted as chart S/R.
|
||||||
"""
|
"""
|
||||||
return detect_sr_levels_legacy(
|
if config is None:
|
||||||
highs,
|
# Frozen live/default path. Keeping the established helper here makes
|
||||||
lows,
|
# the absence of research configuration an exact compatibility promise.
|
||||||
closes,
|
return detect_sr_levels_legacy(
|
||||||
[0] * len(closes),
|
highs,
|
||||||
tolerance,
|
lows,
|
||||||
explicit_range_grid=True,
|
closes,
|
||||||
)
|
[0] * len(closes),
|
||||||
|
tolerance,
|
||||||
|
explicit_range_grid=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tolerance != DEFAULT_TOLERANCE:
|
||||||
|
raise ValueError("Pass either GTL config tolerances or tolerance, not both")
|
||||||
|
if not closes:
|
||||||
|
return []
|
||||||
|
if not (len(highs) == len(lows) == len(closes)):
|
||||||
|
raise ValueError("GTL highs, lows, and closes must have equal lengths")
|
||||||
|
|
||||||
|
if config.lookback_bars is not None:
|
||||||
|
highs = highs[-config.lookback_bars:]
|
||||||
|
lows = lows[-config.lookback_bars:]
|
||||||
|
closes = closes[-config.lookback_bars:]
|
||||||
|
|
||||||
|
candidates: list[tuple[float, str]] = []
|
||||||
|
try:
|
||||||
|
candidates.extend(
|
||||||
|
(float(price), "range_grid")
|
||||||
|
for price in _legacy_range_grid_nodes(
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
num_bins=config.grid_bins,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if config.include_pivots:
|
||||||
|
try:
|
||||||
|
pivots = compute_pivot_points(
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
window=config.pivot_window,
|
||||||
|
)
|
||||||
|
candidates.extend(
|
||||||
|
(float(price), "pivot_point")
|
||||||
|
for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", [])
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
total_bars = len(closes)
|
||||||
|
raw: list[dict] = []
|
||||||
|
for price, method in candidates:
|
||||||
|
touch_band = abs(price) * config.touch_tolerance if price else config.touch_tolerance
|
||||||
|
touches = sum(
|
||||||
|
1
|
||||||
|
for low, high in zip(lows, highs, strict=False)
|
||||||
|
if low - touch_band <= price <= high + touch_band
|
||||||
|
)
|
||||||
|
strength = max(
|
||||||
|
0,
|
||||||
|
min(100, int(round((touches / total_bars) * config.strength_scale))),
|
||||||
|
)
|
||||||
|
raw.append({
|
||||||
|
"price_level": price,
|
||||||
|
"strength": strength,
|
||||||
|
"detection_method": method,
|
||||||
|
"type": "",
|
||||||
|
"sources": [method],
|
||||||
|
"rejection_count": touches,
|
||||||
|
"last_rejection_age": None,
|
||||||
|
"weighted_respects": float(touches),
|
||||||
|
})
|
||||||
|
|
||||||
|
merged: list[dict] = []
|
||||||
|
for level in sorted(raw, key=lambda row: row["price_level"]):
|
||||||
|
if not merged:
|
||||||
|
merged.append(dict(level))
|
||||||
|
continue
|
||||||
|
last = merged[-1]
|
||||||
|
ref = last["price_level"]
|
||||||
|
merge_band = abs(ref) * config.merge_tolerance if ref else config.merge_tolerance
|
||||||
|
if abs(level["price_level"] - ref) > merge_band:
|
||||||
|
merged.append(dict(level))
|
||||||
|
continue
|
||||||
|
last["price_level"] = round(
|
||||||
|
(last["price_level"] + level["price_level"]) / 2.0,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
last["strength"] = min(100, last["strength"] + level["strength"])
|
||||||
|
sources = set(last.get("sources") or [last["detection_method"]])
|
||||||
|
sources |= set(level.get("sources") or [level["detection_method"]])
|
||||||
|
last["sources"] = sorted(sources)
|
||||||
|
last["detection_method"] = (
|
||||||
|
next(iter(sources)) if len(sources) == 1 else "merged"
|
||||||
|
)
|
||||||
|
last["rejection_count"] = max(
|
||||||
|
int(last.get("rejection_count", 0)),
|
||||||
|
int(level.get("rejection_count", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
_tag_levels(merged, closes[-1])
|
||||||
|
merged.sort(key=lambda row: row["strength"], reverse=True)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _merge_levels(
|
def _merge_levels(
|
||||||
|
|||||||
@@ -804,6 +804,47 @@ statistics, and portfolio results are unchanged. This closes the local
|
|||||||
backtest gate for the dual-purpose implementation. It does not itself authorize
|
backtest gate for the dual-purpose implementation. It does not itself authorize
|
||||||
or perform a production deployment.
|
or perform a production deployment.
|
||||||
|
|
||||||
|
#### GTL tuning matrix
|
||||||
|
|
||||||
|
Exact parity establishes a safe, explicit control, but it does not prove that
|
||||||
|
the inherited GTL constants are optimal. The `gtl_tuning` backtest arm exposes
|
||||||
|
only those constants to an offline configuration; the live scanner continues
|
||||||
|
to call the frozen default helper and cannot read this research configuration.
|
||||||
|
|
||||||
|
The single-command matrix contains 20 full-period arms. Each non-control arm
|
||||||
|
changes exactly one input:
|
||||||
|
|
||||||
|
| Knob | Frozen control | Alternatives | Question isolated |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| History | All available bars | 252 / 504 / 756 bars | Is recent or long-cycle range geometry useful? |
|
||||||
|
| Candidate cap | 5 | 8 / unlimited | Does early pruning discard the useful headline? |
|
||||||
|
| Max target distance | Existing volatility-dependent rule | 5.5 / 8 ATR universally | Is the medium-volatility unlimited branch the hidden edge? |
|
||||||
|
| Traffic touch padding | 0.5% | 0 / 0.25% | Does padded price traffic carry information? |
|
||||||
|
| Proposal merge | 0.5% | 0.25% / 1% | Is proposal density or consolidation important? |
|
||||||
|
| Target zones | 2% | 1% / 3% | Does the reachable near edge manufacture the gate geometry? |
|
||||||
|
| Range centers | 20 | 12 / 32 | Is coarse ladder density the useful feature? |
|
||||||
|
| Pivots | Five-bar swings | None / eleven-bar swings | Do pivots add anything beyond the range ladder? |
|
||||||
|
| Traffic strength scale | 500 | 250 / 1000 | Does strength saturation affect probability/selection? |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/run_gtl_tuning_matrix.py \
|
||||||
|
backtest_snapshots/prod.sqlite --workers 14
|
||||||
|
```
|
||||||
|
|
||||||
|
Arms execute sequentially so multiprocessing pools never compete. Each arm
|
||||||
|
produces full-period production metrics, train/test books split at 2024-07-01,
|
||||||
|
robust expectancy after removing the top 5% of setups, and retained/added/
|
||||||
|
removed cohorts against control. The consolidated JSON and Markdown table are
|
||||||
|
checkpointed after every arm; successful runs delete temporary per-arm reports
|
||||||
|
unless `--keep-arm-reports` is set.
|
||||||
|
|
||||||
|
The pre-registered screen requires all of the following versus control:
|
||||||
|
full/train/test Sharpe not worse, full-period drawdown not worse, at least 80%
|
||||||
|
of production trades retained, and positive qualified expectancy after removing
|
||||||
|
the top 5%. Passing identifies a candidate for forward paper validation, not an
|
||||||
|
automatic deployment. The post-2024 interval has already influenced this
|
||||||
|
research, so the split is a robustness check rather than a pristine holdout.
|
||||||
|
|
||||||
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
|
||||||
new future data or a separately pre-registered walk-forward protocol.
|
new future data or a separately pre-registered walk-forward protocol.
|
||||||
|
|||||||
@@ -60,3 +60,12 @@ 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
|
after scanner integration passed: the regenerated report changed only its
|
||||||
`generated_at` timestamp, confirming that the shared helper preserves exact
|
`generated_at` timestamp, confirming that the shared helper preserves exact
|
||||||
parity. Nothing in this research branch deploys the change to production.
|
parity. Nothing in this research branch deploys the change to production.
|
||||||
|
|
||||||
|
The next decision point is generated by the one-command GTL parameter run:
|
||||||
|
|
||||||
|
- `backtest-YYYYMMDD-gtl-tuning-matrix.json`
|
||||||
|
- `backtest-YYYYMMDD-gtl-tuning-matrix.md`
|
||||||
|
|
||||||
|
These two consolidated files are the reports worth retaining. The runner uses
|
||||||
|
a hidden temporary directory for its 20 full per-arm reports and removes it
|
||||||
|
after successful consolidation unless `--keep-arm-reports` is supplied.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -61,10 +61,16 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
"rewrite_range504_structural_primary2",
|
"rewrite_range504_structural_primary2",
|
||||||
"production_structural_overlay",
|
"production_structural_overlay",
|
||||||
"explicit_target_ladder",
|
"explicit_target_ladder",
|
||||||
|
"gtl_tuning",
|
||||||
),
|
),
|
||||||
default=None,
|
default=None,
|
||||||
help="Research-only S/R detector/gate arm.",
|
help="Research-only S/R detector/gate arm.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--gtl-config",
|
||||||
|
default=None,
|
||||||
|
help="Research-only GTL configuration as a JSON object (requires --sr-variant gtl_tuning).",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--entry-start",
|
"--entry-start",
|
||||||
default=None,
|
default=None,
|
||||||
@@ -80,6 +86,11 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Include candidate-level S/R audit rows for paired comparison.",
|
help="Include candidate-level S/R audit rows for paired comparison.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--holdout-split",
|
||||||
|
default=None,
|
||||||
|
help="Add a disjoint train/test portfolio report split at YYYY-MM-DD.",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -195,12 +206,22 @@ async def _main() -> None:
|
|||||||
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||||
if args.sr_variant:
|
if args.sr_variant:
|
||||||
os.environ["BACKTEST_SR_VARIANT"] = args.sr_variant
|
os.environ["BACKTEST_SR_VARIANT"] = args.sr_variant
|
||||||
|
if args.gtl_config:
|
||||||
|
if args.sr_variant != "gtl_tuning":
|
||||||
|
raise SystemExit("--gtl-config requires --sr-variant gtl_tuning")
|
||||||
|
os.environ["BACKTEST_GTL_CONFIG"] = args.gtl_config
|
||||||
if args.entry_start:
|
if args.entry_start:
|
||||||
os.environ["BACKTEST_ENTRY_START"] = args.entry_start
|
os.environ["BACKTEST_ENTRY_START"] = args.entry_start
|
||||||
if args.entry_end:
|
if args.entry_end:
|
||||||
os.environ["BACKTEST_ENTRY_END"] = args.entry_end
|
os.environ["BACKTEST_ENTRY_END"] = args.entry_end
|
||||||
if args.sr_audit:
|
if args.sr_audit:
|
||||||
os.environ["BACKTEST_SR_AUDIT"] = "1"
|
os.environ["BACKTEST_SR_AUDIT"] = "1"
|
||||||
|
if args.holdout_split:
|
||||||
|
try:
|
||||||
|
date.fromisoformat(args.holdout_split)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit("--holdout-split must use YYYY-MM-DD") from exc
|
||||||
|
os.environ["BACKTEST_HOLDOUT_SPLIT"] = args.holdout_split
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services.backtest_service import run_backtest
|
from app.services.backtest_service import run_backtest
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""Run the complete Gate Target Ladder tuning matrix with one command.
|
||||||
|
|
||||||
|
Every arm is a full production-parity backtest. Arms run sequentially so each
|
||||||
|
one can use the requested worker pool without competing with another arm. Large
|
||||||
|
per-arm reports live in a temporary run directory and are removed after a
|
||||||
|
successful consolidation unless ``--keep-arm-reports`` is supplied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RUNNER = ROOT / "scripts" / "run_backtest_snapshot.py"
|
||||||
|
|
||||||
|
BASE_CONFIG: dict[str, Any] = {
|
||||||
|
"lookback_bars": None,
|
||||||
|
"grid_bins": 20,
|
||||||
|
"include_pivots": True,
|
||||||
|
"pivot_window": 2,
|
||||||
|
"touch_tolerance": 0.005,
|
||||||
|
"merge_tolerance": 0.005,
|
||||||
|
"strength_scale": 500.0,
|
||||||
|
"zone_tolerance": 0.02,
|
||||||
|
"candidate_limit": 5,
|
||||||
|
"max_target_atr": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Single-variable arms only. The control value is represented by BASE_CONFIG;
|
||||||
|
# there is deliberately no Cartesian product.
|
||||||
|
GTL_TUNING_ARMS: tuple[dict[str, Any], ...] = (
|
||||||
|
{"name": "control", "description": "Frozen explicit GTL defaults."},
|
||||||
|
{"name": "lookback_252", "description": "One-year GTL history.", "lookback_bars": 252},
|
||||||
|
{"name": "lookback_504", "description": "Two-year GTL history.", "lookback_bars": 504},
|
||||||
|
{"name": "lookback_756", "description": "Three-year GTL history.", "lookback_bars": 756},
|
||||||
|
{"name": "candidates_8", "description": "Retain up to eight candidates before probability.", "candidate_limit": 8},
|
||||||
|
{"name": "candidates_all", "description": "Score every eligible target before primary selection.", "candidate_limit": None},
|
||||||
|
{"name": "max_atr_5_5", "description": "Universal 5.5 ATR maximum target distance.", "max_target_atr": 5.5},
|
||||||
|
{"name": "max_atr_8", "description": "Universal 8 ATR maximum target distance.", "max_target_atr": 8.0},
|
||||||
|
{"name": "touch_0", "description": "Strict candle-range crossings with no touch padding.", "touch_tolerance": 0.0},
|
||||||
|
{"name": "touch_0_25pct", "description": "Use 0.25% padding when counting price traffic.", "touch_tolerance": 0.0025},
|
||||||
|
{"name": "merge_0_25pct", "description": "Merge GTL proposals within 0.25%.", "merge_tolerance": 0.0025},
|
||||||
|
{"name": "merge_1pct", "description": "Merge GTL proposals within 1%.", "merge_tolerance": 0.01},
|
||||||
|
{"name": "zones_1pct", "description": "Cluster target zones within 1%.", "zone_tolerance": 0.01},
|
||||||
|
{"name": "zones_3pct", "description": "Cluster target zones within 3%.", "zone_tolerance": 0.03},
|
||||||
|
{"name": "grid_12", "description": "Use 12 evenly spaced range centers.", "grid_bins": 12},
|
||||||
|
{"name": "grid_32", "description": "Use 32 evenly spaced range centers.", "grid_bins": 32},
|
||||||
|
{"name": "pivots_none", "description": "Range grid only; omit swing pivots.", "include_pivots": False},
|
||||||
|
{"name": "pivots_11bar", "description": "Use an 11-bar swing-pivot window.", "pivot_window": 5},
|
||||||
|
{"name": "strength_250", "description": "Slower traffic-strength saturation.", "strength_scale": 250.0},
|
||||||
|
{"name": "strength_1000", "description": "Faster traffic-strength saturation.", "strength_scale": 1000.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
BOOK_FIELDS = (
|
||||||
|
"sharpe",
|
||||||
|
"cagr_pct",
|
||||||
|
"max_drawdown_pct",
|
||||||
|
"trades",
|
||||||
|
"win_rate",
|
||||||
|
"avg_hold_days",
|
||||||
|
"skipped_book_full",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"snapshot",
|
||||||
|
nargs="?",
|
||||||
|
default="backtest_snapshots/prod.sqlite",
|
||||||
|
help="Local SQLite snapshot path.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--workers", type=int, default=7)
|
||||||
|
parser.add_argument(
|
||||||
|
"--holdout-split",
|
||||||
|
default="2024-07-01",
|
||||||
|
help="Disjoint train/test split included in every arm.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out",
|
||||||
|
default=None,
|
||||||
|
help="Consolidated JSON path. Defaults to reports/backtest-YYYYMMDD-gtl-tuning-matrix.json.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-arm-reports",
|
||||||
|
action="store_true",
|
||||||
|
help="Keep the large temporary per-arm JSON reports after consolidation.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _arm_config(arm: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
config = {**BASE_CONFIG, **{key: value for key, value in arm.items() if key != "description"}}
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _load_report(path: Path) -> dict:
|
||||||
|
with path.open(encoding="utf-8") as handle:
|
||||||
|
report = json.load(handle)
|
||||||
|
if report.get("sr_candidate_audit") is None:
|
||||||
|
raise ValueError(f"Report lacks sr_candidate_audit: {path}")
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_book(row: dict | None) -> dict | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {key: row.get(key) for key in BOOK_FIELDS}
|
||||||
|
|
||||||
|
|
||||||
|
def _full_book(report: dict) -> dict | None:
|
||||||
|
runs = ((report.get("portfolio_monitor") or {}).get("runs") or [])
|
||||||
|
return _compact_book(next(
|
||||||
|
(
|
||||||
|
row
|
||||||
|
for row in runs
|
||||||
|
if row.get("is_production") and row.get("lookback") == "all"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _holdout_books(report: dict) -> dict[str, dict | None]:
|
||||||
|
rows = ((report.get("holdout") or {}).get("rows") or [])
|
||||||
|
return {
|
||||||
|
window: _compact_book(next((row for row in rows if row.get("window") == window), None))
|
||||||
|
for window in ("train", "test")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_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]
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cohort_comparison(control: dict, variant: dict) -> dict:
|
||||||
|
control_rows = {
|
||||||
|
_audit_key(row): row for row in control.get("sr_candidate_audit") or []
|
||||||
|
}
|
||||||
|
variant_rows = {
|
||||||
|
_audit_key(row): row for row in variant.get("sr_candidate_audit") or []
|
||||||
|
}
|
||||||
|
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
|
||||||
|
return {
|
||||||
|
"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]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_arm(report: dict, config: dict, control: dict | None) -> dict:
|
||||||
|
qualified = report.get("overall_qualified") or {}
|
||||||
|
result = {
|
||||||
|
"name": config["name"],
|
||||||
|
"config": config,
|
||||||
|
"candidates": report.get("candidates"),
|
||||||
|
"qualified": report.get("qualified"),
|
||||||
|
"qualified_net_avg_r": qualified.get("net_avg_r"),
|
||||||
|
"qualified_net_avg_r_ex_top5": qualified.get("net_avg_r_ex_top5"),
|
||||||
|
"full_book": _full_book(report),
|
||||||
|
"holdout": _holdout_books(report),
|
||||||
|
"gtl_diagnostics": report.get("sr_variant_diagnostics"),
|
||||||
|
"cohort_vs_control": (
|
||||||
|
_cohort_comparison(control, report) if control is not None else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _screen_arm(arm: dict, control: dict) -> dict:
|
||||||
|
full = arm.get("full_book") or {}
|
||||||
|
base_full = control.get("full_book") or {}
|
||||||
|
train = (arm.get("holdout") or {}).get("train") or {}
|
||||||
|
base_train = (control.get("holdout") or {}).get("train") or {}
|
||||||
|
test = (arm.get("holdout") or {}).get("test") or {}
|
||||||
|
base_test = (control.get("holdout") or {}).get("test") or {}
|
||||||
|
|
||||||
|
def at_least(value: Any, baseline: Any) -> bool:
|
||||||
|
return value is not None and baseline is not None and float(value) >= float(baseline)
|
||||||
|
|
||||||
|
control_trades = float(base_full.get("trades") or 0.0)
|
||||||
|
arm_trades = float(full.get("trades") or 0.0)
|
||||||
|
checks = {
|
||||||
|
"full_sharpe_not_worse": at_least(full.get("sharpe"), base_full.get("sharpe")),
|
||||||
|
"train_sharpe_not_worse": at_least(train.get("sharpe"), base_train.get("sharpe")),
|
||||||
|
"test_sharpe_not_worse": at_least(test.get("sharpe"), base_test.get("sharpe")),
|
||||||
|
"drawdown_not_worse": (
|
||||||
|
full.get("max_drawdown_pct") is not None
|
||||||
|
and base_full.get("max_drawdown_pct") is not None
|
||||||
|
and abs(float(full["max_drawdown_pct"]))
|
||||||
|
<= abs(float(base_full["max_drawdown_pct"]))
|
||||||
|
),
|
||||||
|
"retains_80pct_trades": control_trades > 0 and arm_trades >= control_trades * 0.8,
|
||||||
|
"robust_expectancy_positive": (
|
||||||
|
arm.get("qualified_net_avg_r_ex_top5") is not None
|
||||||
|
and float(arm["qualified_net_avg_r_ex_top5"]) > 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"checks": checks,
|
||||||
|
"passed": sum(checks.values()),
|
||||||
|
"total": len(checks),
|
||||||
|
"advances": all(checks.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, payload: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=2)
|
||||||
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: Any, digits: int = 2) -> str:
|
||||||
|
return "-" if value is None else f"{float(value):.{digits}f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_markdown(path: Path, payload: dict) -> None:
|
||||||
|
rows = [
|
||||||
|
"# GTL tuning matrix",
|
||||||
|
"",
|
||||||
|
f"Status: **{payload['status']}** ",
|
||||||
|
f"Holdout split: `{payload['holdout_split']}` ",
|
||||||
|
f"Completed arms: {len(payload['arms'])}/{payload['arm_count']}",
|
||||||
|
"",
|
||||||
|
"| Arm | Full Sharpe | CAGR | Max DD | Trades | Train Sharpe | Test Sharpe | Ex-top-5% R | Screen |",
|
||||||
|
"|---|---:|---:|---:|---:|---:|---:|---:|---:|",
|
||||||
|
]
|
||||||
|
for arm in payload["arms"]:
|
||||||
|
full = arm.get("full_book") or {}
|
||||||
|
holdout = arm.get("holdout") or {}
|
||||||
|
train = holdout.get("train") or {}
|
||||||
|
test = holdout.get("test") or {}
|
||||||
|
screen = arm.get("screen") or {}
|
||||||
|
rows.append(
|
||||||
|
"| "
|
||||||
|
+ " | ".join((
|
||||||
|
arm["name"],
|
||||||
|
_fmt(full.get("sharpe")),
|
||||||
|
_fmt(full.get("cagr_pct"), 1),
|
||||||
|
_fmt(full.get("max_drawdown_pct"), 1),
|
||||||
|
str(full.get("trades") or "-"),
|
||||||
|
_fmt(train.get("sharpe")),
|
||||||
|
_fmt(test.get("sharpe")),
|
||||||
|
_fmt(arm.get("qualified_net_avg_r_ex_top5"), 3),
|
||||||
|
f"{screen.get('passed', '-')}/{screen.get('total', '-')}",
|
||||||
|
))
|
||||||
|
+ " |"
|
||||||
|
)
|
||||||
|
rows.extend((
|
||||||
|
"",
|
||||||
|
"## Interpretation guardrail",
|
||||||
|
"",
|
||||||
|
"The post-2024 interval has already informed prior research. The train/test columns are robustness checks, not a pristine holdout. A passing arm is a candidate for forward paper validation, not automatic production promotion.",
|
||||||
|
"",
|
||||||
|
))
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("\n".join(rows), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_arm(
|
||||||
|
*,
|
||||||
|
arm: dict[str, Any],
|
||||||
|
snapshot: str,
|
||||||
|
workers: int,
|
||||||
|
holdout_split: str,
|
||||||
|
output: Path,
|
||||||
|
) -> None:
|
||||||
|
config = _arm_config(arm)
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
str(RUNNER),
|
||||||
|
snapshot,
|
||||||
|
"--workers", str(workers),
|
||||||
|
"--allow-spawn",
|
||||||
|
"--sr-variant", "gtl_tuning",
|
||||||
|
"--gtl-config", json.dumps(config, separators=(",", ":")),
|
||||||
|
"--holdout-split", holdout_split,
|
||||||
|
"--sr-audit",
|
||||||
|
"--out", str(output),
|
||||||
|
]
|
||||||
|
subprocess.run(command, cwd=ROOT, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = _args()
|
||||||
|
snapshot = Path(args.snapshot).resolve()
|
||||||
|
if not snapshot.exists():
|
||||||
|
raise SystemExit(f"Snapshot not found: {snapshot}")
|
||||||
|
if args.workers < 1:
|
||||||
|
raise SystemExit("--workers must be at least 1")
|
||||||
|
try:
|
||||||
|
date.fromisoformat(args.holdout_split)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit("--holdout-split must use YYYY-MM-DD") from exc
|
||||||
|
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
default_out = ROOT / "reports" / f"backtest-{stamp[:8]}-gtl-tuning-matrix.json"
|
||||||
|
out_path = Path(args.out) if args.out else default_out
|
||||||
|
if not out_path.is_absolute():
|
||||||
|
out_path = ROOT / out_path
|
||||||
|
markdown_path = out_path.with_suffix(".md")
|
||||||
|
work_dir = ROOT / "reports" / f".gtl-tuning-work-{stamp}"
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"status": "running",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"snapshot": str(snapshot.resolve()),
|
||||||
|
"workers": args.workers,
|
||||||
|
"holdout_split": args.holdout_split,
|
||||||
|
"arm_count": len(GTL_TUNING_ARMS),
|
||||||
|
"arms": [],
|
||||||
|
"caveat": (
|
||||||
|
"The split is a robustness check, not a pristine holdout; post-2024 "
|
||||||
|
"data has already informed earlier research."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_write_json(out_path, payload)
|
||||||
|
_write_markdown(markdown_path, payload)
|
||||||
|
|
||||||
|
control_report: dict | None = None
|
||||||
|
arm_outputs: list[Path] = []
|
||||||
|
try:
|
||||||
|
for index, arm in enumerate(GTL_TUNING_ARMS, start=1):
|
||||||
|
name = str(arm["name"])
|
||||||
|
output = work_dir / f"{index:02d}-{name}.json"
|
||||||
|
arm_outputs.append(output)
|
||||||
|
print(f"\n[{index}/{len(GTL_TUNING_ARMS)}] GTL arm: {name}", flush=True)
|
||||||
|
print(f" {arm['description']}", flush=True)
|
||||||
|
_run_arm(
|
||||||
|
arm=arm,
|
||||||
|
snapshot=str(snapshot),
|
||||||
|
workers=args.workers,
|
||||||
|
holdout_split=args.holdout_split,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
report = _load_report(output)
|
||||||
|
config = _arm_config(arm)
|
||||||
|
compact = _compact_arm(report, config, control_report)
|
||||||
|
compact["description"] = arm["description"]
|
||||||
|
if control_report is None:
|
||||||
|
control_report = report
|
||||||
|
compact["screen"] = {
|
||||||
|
"checks": {},
|
||||||
|
"passed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"advances": False,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
compact["screen"] = _screen_arm(compact, payload["arms"][0])
|
||||||
|
payload["arms"].append(compact)
|
||||||
|
payload["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
_write_json(out_path, payload)
|
||||||
|
_write_markdown(markdown_path, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
payload["status"] = "failed"
|
||||||
|
payload["failed_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
payload["error"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
payload["work_dir"] = str(work_dir)
|
||||||
|
_write_json(out_path, payload)
|
||||||
|
_write_markdown(markdown_path, payload)
|
||||||
|
raise
|
||||||
|
|
||||||
|
payload["status"] = "complete"
|
||||||
|
payload["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
payload["advancing_arms"] = [
|
||||||
|
arm["name"] for arm in payload["arms"] if (arm.get("screen") or {}).get("advances")
|
||||||
|
]
|
||||||
|
payload["ranking_by_full_sharpe"] = [
|
||||||
|
arm["name"]
|
||||||
|
for arm in sorted(
|
||||||
|
payload["arms"],
|
||||||
|
key=lambda row: float((row.get("full_book") or {}).get("sharpe") or -math.inf),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if args.keep_arm_reports:
|
||||||
|
payload["arm_report_directory"] = str(work_dir)
|
||||||
|
_write_json(out_path, payload)
|
||||||
|
_write_markdown(markdown_path, payload)
|
||||||
|
|
||||||
|
if not args.keep_arm_reports:
|
||||||
|
for path in arm_outputs:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
work_dir.rmdir()
|
||||||
|
|
||||||
|
print("\nGTL tuning matrix complete.")
|
||||||
|
print(f" JSON: {out_path}")
|
||||||
|
print(f" Markdown: {markdown_path}")
|
||||||
|
if payload["advancing_arms"]:
|
||||||
|
print(f" Arms passing every pre-registered screen: {', '.join(payload['advancing_arms'])}")
|
||||||
|
else:
|
||||||
|
print(" No arm passed every pre-registered screen.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -803,6 +804,7 @@ def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
|
|||||||
"rewrite_range504_structural_primary2",
|
"rewrite_range504_structural_primary2",
|
||||||
"production_structural_overlay",
|
"production_structural_overlay",
|
||||||
"explicit_target_ladder",
|
"explicit_target_ladder",
|
||||||
|
"gtl_tuning",
|
||||||
):
|
):
|
||||||
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
|
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
|
||||||
assert bt._sr_research_variant() == variant
|
assert bt._sr_research_variant() == variant
|
||||||
@@ -861,6 +863,24 @@ def test_residual_arms_change_only_the_primary_rr_floor():
|
|||||||
"explicit_target_ladder",
|
"explicit_target_ladder",
|
||||||
activation,
|
activation,
|
||||||
) == 1.5
|
) == 1.5
|
||||||
|
assert bt._primary_min_rr_for_variant("gtl_tuning", activation) == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_gtl_research_config_parses_and_rejects_unknown_fields():
|
||||||
|
config = bt._parse_gtl_research_config(json.dumps({
|
||||||
|
"name": "lookback_504",
|
||||||
|
"lookback_bars": 504,
|
||||||
|
"candidate_limit": None,
|
||||||
|
}))
|
||||||
|
assert config.name == "lookback_504"
|
||||||
|
assert config.lookback_bars == 504
|
||||||
|
assert config.candidate_limit is None
|
||||||
|
assert config.ladder_config().grid_bins == 20
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unknown GTL research config fields"):
|
||||||
|
bt._parse_gtl_research_config('{"mystery_knob": 1}')
|
||||||
|
with pytest.raises(ValueError, match="valid JSON"):
|
||||||
|
bt._parse_gtl_research_config("{")
|
||||||
|
|
||||||
|
|
||||||
def test_structural_overlay_tags_production_geometry_without_replacing_it(monkeypatch):
|
def test_structural_overlay_tags_production_geometry_without_replacing_it(monkeypatch):
|
||||||
@@ -972,6 +992,45 @@ def test_window_setups_routes_full_explicit_target_ladder(monkeypatch):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_setups_routes_gtl_tuning_config(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_detector(highs, lows, closes, *, config):
|
||||||
|
captured.update({
|
||||||
|
"highs": highs,
|
||||||
|
"lows": lows,
|
||||||
|
"closes": closes,
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.GTL_TUNING_VARIANT)
|
||||||
|
monkeypatch.setenv("BACKTEST_GTL_CONFIG", json.dumps({
|
||||||
|
"name": "lookback_252",
|
||||||
|
"lookback_bars": 252,
|
||||||
|
"grid_bins": 12,
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector)
|
||||||
|
records = [
|
||||||
|
SimpleNamespace(
|
||||||
|
date=date(2024, 1, 1) + timedelta(days=i),
|
||||||
|
open=100.0,
|
||||||
|
high=101.0,
|
||||||
|
low=99.0,
|
||||||
|
close=100.0,
|
||||||
|
volume=1_000_000,
|
||||||
|
)
|
||||||
|
for i in range(bt.MIN_LOOKBACK)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert bt._window_setups(records, {}, {}) == []
|
||||||
|
assert captured["highs"] == [101.0] * bt.MIN_LOOKBACK
|
||||||
|
assert captured["lows"] == [99.0] * bt.MIN_LOOKBACK
|
||||||
|
assert captured["closes"] == [100.0] * bt.MIN_LOOKBACK
|
||||||
|
assert captured["config"].lookback_bars == 252
|
||||||
|
assert captured["config"].grid_bins == 12
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"variant",
|
"variant",
|
||||||
["legacy_geometry_neutral", "legacy_range_grid_neutral"],
|
["legacy_geometry_neutral", "legacy_range_grid_neutral"],
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.services.sr_service import (
|
from app.services.sr_service import (
|
||||||
|
GateTargetLadderConfig,
|
||||||
MAX_LEVELS,
|
MAX_LEVELS,
|
||||||
_bar_respect_weight,
|
_bar_respect_weight,
|
||||||
_cap_levels,
|
_cap_levels,
|
||||||
@@ -314,6 +317,70 @@ class TestDetectSrLevels:
|
|||||||
|
|
||||||
assert detect_gate_target_ladder(highs, lows, closes) == expected
|
assert detect_gate_target_ladder(highs, lows, closes) == expected
|
||||||
|
|
||||||
|
def test_configured_gate_target_ladder_defaults_preserve_parity(self):
|
||||||
|
highs, lows, closes, volumes = _make_series(n=500)
|
||||||
|
expected = detect_sr_levels_legacy(
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
volumes,
|
||||||
|
explicit_range_grid=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
configured = detect_gate_target_ladder(
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
config=GateTargetLadderConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert configured == expected
|
||||||
|
|
||||||
|
def test_configured_gate_target_ladder_bounds_history(self):
|
||||||
|
old_highs = [1_000.0] * 40
|
||||||
|
old_lows = [10.0] * 40
|
||||||
|
old_closes = [100.0] * 40
|
||||||
|
recent_highs = [110.0] * 40
|
||||||
|
recent_lows = [90.0] * 40
|
||||||
|
recent_closes = [100.0] * 40
|
||||||
|
config = GateTargetLadderConfig(
|
||||||
|
lookback_bars=40,
|
||||||
|
include_pivots=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
levels = detect_gate_target_ladder(
|
||||||
|
old_highs + recent_highs,
|
||||||
|
old_lows + recent_lows,
|
||||||
|
old_closes + recent_closes,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert levels
|
||||||
|
assert all(90.0 <= level["price_level"] <= 110.0 for level in levels)
|
||||||
|
|
||||||
|
def test_configured_gate_target_ladder_separates_merge_tolerance(self):
|
||||||
|
highs, lows, closes, _ = _make_series(n=300)
|
||||||
|
tight = detect_gate_target_ladder(
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
config=GateTargetLadderConfig(merge_tolerance=0.0025),
|
||||||
|
)
|
||||||
|
wide = detect_gate_target_ladder(
|
||||||
|
highs,
|
||||||
|
lows,
|
||||||
|
closes,
|
||||||
|
config=GateTargetLadderConfig(merge_tolerance=0.01),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(wide) <= len(tight)
|
||||||
|
|
||||||
|
def test_gate_target_ladder_config_rejects_invalid_values(self):
|
||||||
|
with pytest.raises(ValueError, match="lookback_bars"):
|
||||||
|
GateTargetLadderConfig(lookback_bars=19)
|
||||||
|
with pytest.raises(ValueError, match="touch_tolerance"):
|
||||||
|
GateTargetLadderConfig(touch_tolerance=-0.1)
|
||||||
|
|
||||||
def test_explicit_range_grid_is_volume_independent(self):
|
def test_explicit_range_grid_is_volume_independent(self):
|
||||||
highs, lows, closes, volumes = _make_series(n=500)
|
highs, lows, closes, volumes = _make_series(n=500)
|
||||||
shifted_volumes = [volume * (i + 1) for i, volume in enumerate(volumes)]
|
shifted_volumes = [volume * (i + 1) for i, volume in enumerate(volumes)]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for the single-command GTL research matrix."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scripts import run_gtl_tuning_matrix as matrix
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_is_single_variable_and_has_unique_names():
|
||||||
|
assert len(matrix.GTL_TUNING_ARMS) == 20
|
||||||
|
assert matrix.GTL_TUNING_ARMS[0]["name"] == "control"
|
||||||
|
names = [arm["name"] for arm in matrix.GTL_TUNING_ARMS]
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
|
||||||
|
for arm in matrix.GTL_TUNING_ARMS[1:]:
|
||||||
|
config = matrix._arm_config(arm)
|
||||||
|
changed = [
|
||||||
|
key
|
||||||
|
for key, value in matrix.BASE_CONFIG.items()
|
||||||
|
if config[key] != value
|
||||||
|
]
|
||||||
|
assert len(changed) == 1, arm["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_result(*, sharpe: float, drawdown: float, trades: int, robust_r: float) -> dict:
|
||||||
|
return {
|
||||||
|
"full_book": {
|
||||||
|
"sharpe": sharpe,
|
||||||
|
"max_drawdown_pct": drawdown,
|
||||||
|
"trades": trades,
|
||||||
|
},
|
||||||
|
"holdout": {
|
||||||
|
"train": {"sharpe": sharpe},
|
||||||
|
"test": {"sharpe": sharpe},
|
||||||
|
},
|
||||||
|
"qualified_net_avg_r_ex_top5": robust_r,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_requires_every_pre_registered_guardrail():
|
||||||
|
control = _compact_result(sharpe=2.0, drawdown=20.0, trades=100, robust_r=0.1)
|
||||||
|
passing = _compact_result(sharpe=2.1, drawdown=19.0, trades=90, robust_r=0.05)
|
||||||
|
failing = _compact_result(sharpe=2.1, drawdown=19.0, trades=79, robust_r=0.05)
|
||||||
|
|
||||||
|
passed = matrix._screen_arm(passing, control)
|
||||||
|
failed = matrix._screen_arm(failing, control)
|
||||||
|
|
||||||
|
assert passed["advances"] is True
|
||||||
|
assert passed["passed"] == passed["total"] == 6
|
||||||
|
assert failed["advances"] is False
|
||||||
|
assert failed["checks"]["retains_80pct_trades"] is False
|
||||||
@@ -252,6 +252,52 @@ def test_generate_targets_spreads_across_distance_bands():
|
|||||||
assert any(m > 4.6 for m in multiples), "expected an aggressive (far) target"
|
assert any(m > 4.6 for m in multiples), "expected an aggressive (far) target"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_targets_research_candidate_limit_can_expand_or_disable_cap():
|
||||||
|
levels = [
|
||||||
|
_SRLevelStub(
|
||||||
|
id=index,
|
||||||
|
price_level=100.0 + index * 2.0,
|
||||||
|
type="resistance",
|
||||||
|
strength=50 + index,
|
||||||
|
)
|
||||||
|
for index in range(1, 9)
|
||||||
|
]
|
||||||
|
|
||||||
|
default = target_generator.generate_targets(
|
||||||
|
"long", 100.0, 97.0, levels, 2.0 # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
expanded = target_generator.generate_targets(
|
||||||
|
"long", 100.0, 97.0, levels, 2.0, # type: ignore[arg-type]
|
||||||
|
max_targets=8,
|
||||||
|
)
|
||||||
|
uncapped = target_generator.generate_targets(
|
||||||
|
"long", 100.0, 97.0, levels, 2.0, # type: ignore[arg-type]
|
||||||
|
max_targets=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(default) == 5
|
||||||
|
assert len(expanded) == 8
|
||||||
|
assert len(uncapped) == 8
|
||||||
|
assert [row["price"] for row in uncapped] == sorted(
|
||||||
|
row["price"] for row in uncapped
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_targets_research_max_atr_override_is_universal():
|
||||||
|
levels = [
|
||||||
|
_SRLevelStub(id=1, price_level=110.0, type="resistance", strength=60),
|
||||||
|
_SRLevelStub(id=2, price_level=112.0, type="resistance", strength=60),
|
||||||
|
]
|
||||||
|
|
||||||
|
targets = target_generator.generate_targets(
|
||||||
|
"long", 100.0, 97.0, levels, 2.0, # type: ignore[arg-type]
|
||||||
|
max_targets=None,
|
||||||
|
max_atr_multiple_override=5.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [row["price"] for row in targets] == [110.0]
|
||||||
|
|
||||||
|
|
||||||
def test_probability_decreases_with_distance():
|
def test_probability_decreases_with_distance():
|
||||||
"""A far target must be far less likely than a near one — no 90% at +39%."""
|
"""A far target must be far less likely than a near one — no 90% at +39%."""
|
||||||
config = {
|
config = {
|
||||||
@@ -331,6 +377,23 @@ def test_zone_representative_levels_singletons_unchanged():
|
|||||||
assert {round(r.price_level) for r in reps} == {120, 150}
|
assert {round(r.price_level) for r in reps} == {120, 150}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_representative_levels_accepts_research_tolerance():
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from app.services.recommendation_service import _zone_representative_levels
|
||||||
|
|
||||||
|
levels = [
|
||||||
|
SimpleNamespace(id=1, price_level=110.0, type="resistance", strength=50),
|
||||||
|
SimpleNamespace(id=2, price_level=111.5, type="resistance", strength=50),
|
||||||
|
]
|
||||||
|
|
||||||
|
tight = _zone_representative_levels(levels, 100.0, tolerance=0.01)
|
||||||
|
wide = _zone_representative_levels(levels, 100.0, tolerance=0.02)
|
||||||
|
|
||||||
|
assert len(tight) == 2
|
||||||
|
assert len(wide) == 1
|
||||||
|
assert wide[0].price_level == 110.0
|
||||||
|
|
||||||
|
|
||||||
def test_zone_representative_levels_soft_strength_avoids_resaturation():
|
def test_zone_representative_levels_soft_strength_avoids_resaturation():
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from app.services.recommendation_service import _zone_representative_levels
|
from app.services.recommendation_service import _zone_representative_levels
|
||||||
|
|||||||
Reference in New Issue
Block a user