Add single-command GTL tuning matrix
This commit is contained in:
@@ -33,7 +33,9 @@ import statistics
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -71,6 +73,7 @@ from app.services.recommendation_service import (
|
||||
_prune_floor_pinned_targets,
|
||||
_risk_level_from_conflicts,
|
||||
_select_primary_target,
|
||||
_SR_ZONE_TOLERANCE,
|
||||
_zone_representative_levels,
|
||||
direction_analyzer,
|
||||
get_recommendation_config,
|
||||
@@ -83,6 +86,7 @@ from app.services.scoring_service import (
|
||||
compute_technical_from_arrays,
|
||||
)
|
||||
from app.services.sr_service import (
|
||||
GateTargetLadderConfig,
|
||||
MAX_LEVELS,
|
||||
detect_gate_target_ladder,
|
||||
detect_sr_levels,
|
||||
@@ -106,6 +110,7 @@ STRUCTURAL_OVERLAY_SOURCE_VARIANT = (
|
||||
STRUCTURAL_OVERLAY_WEIGHT = 0.05
|
||||
STRUCTURAL_OVERLAY_SCORE_KEY = "structural_overlay_95_5_score"
|
||||
EXPLICIT_TARGET_LADDER_VARIANT = "explicit_target_ladder"
|
||||
GTL_TUNING_VARIANT = "gtl_tuning"
|
||||
RANGE_RESIDUAL_VARIANTS = {
|
||||
"rewrite_range504_structural_legacy_primary",
|
||||
"rewrite_range504_structural_primary2",
|
||||
@@ -116,6 +121,52 @@ RANGE_FACTOR_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
|
||||
# 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
|
||||
@@ -170,6 +221,7 @@ SR_RESEARCH_VARIANTS = {
|
||||
"legacy_range_grid_touch",
|
||||
"legacy_range_grid_neutral",
|
||||
EXPLICIT_TARGET_LADDER_VARIANT,
|
||||
GTL_TUNING_VARIANT,
|
||||
STRUCTURAL_OVERLAY_VARIANT,
|
||||
*RANGE_FACTOR_VARIANTS,
|
||||
}
|
||||
@@ -184,6 +236,31 @@ def _sr_research_variant() -> str:
|
||||
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:
|
||||
"""Map factor-gated research arms to the detector they hold fixed."""
|
||||
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_range504",
|
||||
EXPLICIT_TARGET_LADDER_VARIANT,
|
||||
GTL_TUNING_VARIANT,
|
||||
STRUCTURAL_OVERLAY_VARIANT,
|
||||
}
|
||||
or sr_variant.endswith("_legacy_primary")
|
||||
@@ -366,6 +444,7 @@ def _window_setups(
|
||||
|
||||
sr_variant = sr_variant or _sr_research_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)
|
||||
if sr_variant == "legacy_geometry_neutral":
|
||||
detected_levels = detect_sr_levels_legacy(
|
||||
@@ -385,6 +464,15 @@ def _window_setups(
|
||||
lows,
|
||||
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"}:
|
||||
detected_levels = detect_sr_levels_legacy(
|
||||
highs,
|
||||
@@ -435,9 +523,20 @@ def _window_setups(
|
||||
gate_levels,
|
||||
entry,
|
||||
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)
|
||||
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:
|
||||
fallback_k = _atr_target_fallback_k()
|
||||
if fallback_k is None:
|
||||
@@ -3326,6 +3425,11 @@ async def run_backtest(
|
||||
"min_lookback": MIN_LOOKBACK,
|
||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||
"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_min_log": RANGE_FACTOR_MIN_LOG,
|
||||
"range_factor_min_ratio": round(math.exp(RANGE_FACTOR_MIN_LOG), 4),
|
||||
|
||||
Reference in New Issue
Block a user