Add single-command GTL tuning matrix

This commit is contained in:
2026-07-13 13:13:18 +02:00
parent 0873176f64
commit 3999c5efc1
12 changed files with 1032 additions and 17 deletions
+28
View File
@@ -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 |
+105 -1
View File
@@ -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),
+24 -7
View File
@@ -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
View File
@@ -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(
+41
View File
@@ -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.
+9
View File
@@ -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.
+22 -1
View File
@@ -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
+418
View File
@@ -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()
+59
View File
@@ -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"],
+67
View File
@@ -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)]
+50
View File
@@ -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
+63
View File
@@ -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