Add honest Torch validation gate

This commit is contained in:
Codex
2026-06-25 06:19:14 +03:00
parent bb3b4070f6
commit 4a24419a30
11 changed files with 839 additions and 147 deletions
+45
View File
@@ -154,6 +154,8 @@ class TimeSeriesForecast:
feature_snapshot: list[dict[str, Any]] = field(default_factory=list)
horizon_forecasts: dict[str, Any] = field(default_factory=dict)
candidates: list[dict[str, Any]] = field(default_factory=list)
quality_gate_passed: bool | None = None
quality_gate: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -164,6 +166,8 @@ class TimeSeriesForecaster:
self.settings = settings
self._lstm_artifact_mtime: float | None = None
self._lstm_artifact: dict[str, Any] = {}
self._calibration_mtime: float | None = None
self._quality_gate: dict[str, Any] = {}
def forecast(
self,
@@ -184,6 +188,8 @@ class TimeSeriesForecaster:
return _empty_forecast(True, "not enough returns for PyTorch forecast")
artifact = self._load_lstm_artifact()
quality_gate = self._load_quality_gate()
quality_gate_passed = _quality_gate_passed(quality_gate)
entry = _torch_recurrent_entry(symbol, artifact)
model = _torch_recurrent_model_name(symbol, artifact)
clip = _clamp(_float_entry(entry or {}, "clip", 8.0), 1.0, 50.0)
@@ -280,6 +286,8 @@ class TimeSeriesForecaster:
feature_snapshot=feature_snapshot,
horizon_forecasts=_public_horizon_forecasts(prediction),
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
quality_gate_passed=quality_gate_passed,
quality_gate=quality_gate,
)
direct_horizon = _is_direct_horizon(entry)
@@ -340,6 +348,8 @@ class TimeSeriesForecaster:
feature_snapshot=feature_snapshot,
horizon_forecasts={},
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
quality_gate_passed=quality_gate_passed,
quality_gate=quality_gate,
)
def _load_lstm_artifact(self) -> dict[str, Any]:
@@ -362,6 +372,25 @@ class TimeSeriesForecaster:
self._lstm_artifact_mtime = stat.st_mtime
return self._lstm_artifact
def _load_quality_gate(self) -> dict[str, Any]:
path = self.settings.time_series_lstm_model_path.parent / "torch_threshold_calibration.json"
try:
stat = path.stat()
except OSError:
self._calibration_mtime = None
self._quality_gate = {}
return {}
if self._calibration_mtime == stat.st_mtime:
return self._quality_gate
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
validation = data.get("validation") if isinstance(data, dict) else {}
self._quality_gate = validation if isinstance(validation, dict) else {}
self._calibration_mtime = stat.st_mtime
return self._quality_gate
def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
return TimeSeriesForecast(
@@ -388,9 +417,25 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
target_transform="none",
feature_snapshot=[],
horizon_forecasts={},
candidates=[],
quality_gate_passed=None,
quality_gate={},
)
def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None:
if not quality_gate:
return None
if "passed" in quality_gate:
return bool(quality_gate.get("passed"))
status = str(quality_gate.get("status", "")).strip().lower()
if status in {"pass", "passed", "ok"}:
return True
if status in {"fail", "failed", "warn"}:
return False
return None
def _log_returns(closes: list[float]) -> list[float]:
return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))]