Add honest Torch validation gate
This commit is contained in:
@@ -806,12 +806,14 @@ HTML = r"""
|
||||
const rec = backtest.recommended || {};
|
||||
const replay = backtest.full_replay || {};
|
||||
const walk = backtest.walk_forward?.summary || {};
|
||||
const validation = backtest.validation || {};
|
||||
const benchmark = backtest.benchmark?.summary || {};
|
||||
return `
|
||||
<div class="grid cols-4">
|
||||
${metric('Recommended edge', `${num(rec.edge, 4)}%`, `P(up) ${num((rec.probability || 0) * 100, 1)}%`)}
|
||||
${metric('Entry replay', `${replay.trades || 0} trades`, `${signed(replay.avg_net_percent, 4)}% avg · PF ${num(replay.profit_factor, 3)}`)}
|
||||
${metric('Walk-forward', `${walk.trades || 0} trades`, `${signed(walk.avg_net_percent, 4)}% avg · ${walk.status || ''}`)}
|
||||
${metric('Retrain guard', retrain.available ? (retrain.accepted ? 'accepted' : 'rejected') : 'no report', retrain.reason || '')}
|
||||
${metric('Quality gate', validation.status || 'missing', validation.passed ? 'Torch allowed' : 'Torch blocked')}
|
||||
</div>
|
||||
<div class="grid cols-2" style="margin-top:14px">
|
||||
${panel('Threshold calibration', kvTable([
|
||||
@@ -832,6 +834,32 @@ HTML = r"""
|
||||
['Profit factor', num(replay.profit_factor, 4)]
|
||||
]))}
|
||||
</div>
|
||||
<div class="grid cols-2" style="margin-top:14px">
|
||||
${panel('Honest validation', kvTable([
|
||||
['Status', validation.status || 'missing'],
|
||||
['OOS trades', String(validation.oos_summary?.trades || 0)],
|
||||
['OOS avg net', signed(validation.oos_summary?.avg_net_percent, 4) + '%'],
|
||||
['OOS PF', num(validation.oos_summary?.profit_factor, 4)],
|
||||
['Folds with trades', String(backtest.walk_forward?.folds_with_trades || 0)],
|
||||
['Benchmark total', signed(benchmark.total_net_percent, 4) + '%'],
|
||||
['Benchmark trades', String(benchmark.trades || 0)],
|
||||
['Retrain guard', retrain.available ? (retrain.accepted ? 'accepted' : 'rejected') : 'no report']
|
||||
]))}
|
||||
${panel('Gate checks', simpleTable((validation.checks || []).map(row => ({
|
||||
check: row.name,
|
||||
value: row.value,
|
||||
required: row.required,
|
||||
passed: row.passed ? 'yes' : 'no'
|
||||
})), ['check', 'value', 'required', 'passed']))}
|
||||
</div>
|
||||
${panel('OOS by symbol', simpleTable((backtest.walk_forward?.symbol_breakdown || []).map(row => ({
|
||||
symbol: row.symbol,
|
||||
trades: row.trades,
|
||||
share: num((row.trade_share || 0) * 100, 1) + '%',
|
||||
avg: signed(row.avg_net_percent, 4) + '%',
|
||||
total: signed(row.total_net_percent, 4) + '%',
|
||||
pf: num(row.profit_factor, 3)
|
||||
})), ['symbol', 'trades', 'share', 'avg', 'total', 'pf']))}
|
||||
${panel('Walk-forward folds', simpleTable((backtest.walk_forward?.folds || []).map(row => ({
|
||||
fold: row.fold,
|
||||
train: row.train_records,
|
||||
|
||||
@@ -665,8 +665,10 @@ def _torch_forecast_entry_signal(
|
||||
spread_ok = ticker.spread_percent <= settings.max_spread_percent
|
||||
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
|
||||
model_ok = _is_torch_forecast(forecast)
|
||||
quality_gate_ok = forecast.get("quality_gate_passed") is not False
|
||||
checks = {
|
||||
"torch_model_ok": model_ok,
|
||||
"quality_gate_ok": quality_gate_ok,
|
||||
"forecast_usable": bool(forecast.get("usable", False)),
|
||||
"forecast_not_blocked": not bool(forecast.get("block_entry", False)),
|
||||
"expected_edge_ok": full_edge_ok or probe_edge_ok,
|
||||
@@ -695,6 +697,8 @@ def _torch_forecast_entry_signal(
|
||||
"min_probability_up": min_probability,
|
||||
"min_confidence": settings.time_series_min_confidence,
|
||||
"skill": skill,
|
||||
"quality_gate": forecast.get("quality_gate", {}),
|
||||
"quality_gate_passed": forecast.get("quality_gate_passed"),
|
||||
"spread_percent": round(ticker.spread_percent, 5),
|
||||
"turnover_24h": ticker.turnover_24h,
|
||||
"checks": checks,
|
||||
|
||||
@@ -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))]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user