diff --git a/.env b/.env
index c2fd8fc..a4593d5 100644
--- a/.env
+++ b/.env
@@ -62,7 +62,7 @@ TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.10
-TIME_SERIES_MIN_PROBABILITY_UP=0.57
+TIME_SERIES_MIN_PROBABILITY_UP=0.56
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py
index 46f42a3..a448bc9 100644
--- a/crypto_spot_bot/dashboard.py
+++ b/crypto_spot_bot/dashboard.py
@@ -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 `
${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')}
${panel('Threshold calibration', kvTable([
@@ -832,6 +834,32 @@ HTML = r"""
['Profit factor', num(replay.profit_factor, 4)]
]))}
+
+ ${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']))}
+
+ ${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,
diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py
index 9b3a48a..bac9ceb 100644
--- a/crypto_spot_bot/strategy.py
+++ b/crypto_spot_bot/strategy.py
@@ -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,
diff --git a/crypto_spot_bot/time_series.py b/crypto_spot_bot/time_series.py
index cc05e06..062ea78 100644
--- a/crypto_spot_bot/time_series.py
+++ b/crypto_spot_bot/time_series.py
@@ -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))]
diff --git a/runtime/torch_threshold_calibration.json b/runtime/torch_threshold_calibration.json
index 773a6a7..78c4052 100644
--- a/runtime/torch_threshold_calibration.json
+++ b/runtime/torch_threshold_calibration.json
@@ -50,25 +50,25 @@
},
"recommended": {
"edge": 0.1,
- "probability": 0.57,
+ "probability": 0.56,
"confidence": 0.4,
- "trades": 38,
- "wins": 20,
- "win_rate": 0.5263157894736842,
- "total_net_percent": 10.8553482857082,
- "average_net_percent": 0.28566706015021576,
- "max_drawdown_percent": 3.0444999196004408,
- "profit_factor": 2.166318971301155,
- "score": 0.3782149184267729
+ "trades": 42,
+ "wins": 23,
+ "win_rate": 0.5476190476190477,
+ "total_net_percent": 11.314072641951345,
+ "average_net_percent": 0.2693826819512225,
+ "max_drawdown_percent": 3.2186862081818535,
+ "profit_factor": 2.134577631464616,
+ "score": 0.364437947239799
},
"full_replay": {
"trades": 9,
"wins": 8,
"win_rate": 0.8889,
- "total_net_percent": 18.8986,
- "avg_net_percent": 2.0998,
- "max_drawdown_percent": 0.2996,
- "profit_factor": 64.0689,
+ "total_net_percent": 18.647,
+ "avg_net_percent": 2.0719,
+ "max_drawdown_percent": 0.6796,
+ "profit_factor": 28.4362,
"trades_detail": [
{
"symbol": "ETHUSDT",
@@ -77,102 +77,129 @@
"net_percent": 0.2649,
"reason": "forecast_weak_profit_lock",
"held_bars": 8,
- "entry_probability": 0.5763,
- "entry_expected_percent": 0.3499
+ "entry_probability": 0.5772,
+ "entry_expected_percent": 0.3525
},
{
"symbol": "ETHUSDT",
- "entry_timestamp": 1779930000000,
+ "entry_timestamp": 1779926400000,
"exit_timestamp": 1779987600000,
- "net_percent": -0.2996,
+ "net_percent": -0.6796,
"reason": "forecast_negative",
- "held_bars": 16,
- "entry_probability": 0.5855,
- "entry_expected_percent": 0.1752
+ "held_bars": 17,
+ "entry_probability": 0.5716,
+ "entry_expected_percent": 0.1155
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1780297200000,
- "exit_timestamp": 1780340400000,
- "net_percent": 1.163,
+ "exit_timestamp": 1780344000000,
+ "net_percent": 1.2211,
"reason": "forecast_weak_profit_lock",
- "held_bars": 12,
- "entry_probability": 0.578,
- "entry_expected_percent": 0.2182
+ "held_bars": 13,
+ "entry_probability": 0.5793,
+ "entry_expected_percent": 0.2214
},
{
"symbol": "ETHUSDT",
- "entry_timestamp": 1780776000000,
- "exit_timestamp": 1780794000000,
- "net_percent": 1.8208,
+ "entry_timestamp": 1780768800000,
+ "exit_timestamp": 1780797600000,
+ "net_percent": 2.0598,
"reason": "forecast_weak_profit_lock",
- "held_bars": 5,
- "entry_probability": 0.5736,
- "entry_expected_percent": 0.6405
+ "held_bars": 8,
+ "entry_probability": 0.5637,
+ "entry_expected_percent": 0.5713
},
{
"symbol": "ETHUSDT",
- "entry_timestamp": 1781136000000,
+ "entry_timestamp": 1781132400000,
"exit_timestamp": 1781197200000,
- "net_percent": 3.4248,
+ "net_percent": 3.8719,
"reason": "forecast_weak_profit_lock",
- "held_bars": 17,
- "entry_probability": 0.5767,
- "entry_expected_percent": 0.282
+ "held_bars": 18,
+ "entry_probability": 0.5691,
+ "entry_expected_percent": 0.2522
},
{
"symbol": "ETHUSDT",
- "entry_timestamp": 1781434800000,
- "exit_timestamp": 1781521200000,
- "net_percent": 5.1663,
+ "entry_timestamp": 1781431200000,
+ "exit_timestamp": 1781517600000,
+ "net_percent": 3.6087,
"reason": "max_hold",
"held_bars": 24,
- "entry_probability": 0.5737,
- "entry_expected_percent": 0.2332
+ "entry_probability": 0.5636,
+ "entry_expected_percent": 0.2261
},
{
"symbol": "ETHUSDT",
- "entry_timestamp": 1781524800000,
+ "entry_timestamp": 1781521200000,
"exit_timestamp": 1781535600000,
- "net_percent": 3.5713,
+ "net_percent": 4.3178,
"reason": "forecast_weak_profit_lock",
- "held_bars": 3,
- "entry_probability": 0.689,
- "entry_expected_percent": 0.4931
+ "held_bars": 4,
+ "entry_probability": 0.7034,
+ "entry_expected_percent": 0.5491
},
{
"symbol": "ETHUSDT",
- "entry_timestamp": 1781586000000,
+ "entry_timestamp": 1781582400000,
"exit_timestamp": 1781596800000,
- "net_percent": 2.0739,
+ "net_percent": 2.1644,
"reason": "forecast_weak_profit_lock",
- "held_bars": 3,
- "entry_probability": 0.5721,
- "entry_expected_percent": 0.1205
+ "held_bars": 4,
+ "entry_probability": 0.5705,
+ "entry_expected_percent": 0.1058
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1781863200000,
- "exit_timestamp": 1781931600000,
- "net_percent": 1.7132,
+ "exit_timestamp": 1781935200000,
+ "net_percent": 1.8181,
"reason": "forecast_weak_profit_lock",
- "held_bars": 19,
- "entry_probability": 0.5705,
- "entry_expected_percent": 0.1482
+ "held_bars": 20,
+ "entry_probability": 0.5711,
+ "entry_expected_percent": 0.1502
+ }
+ ],
+ "symbol_breakdown": [
+ {
+ "symbol": "ETHUSDT",
+ "trades": 9,
+ "wins": 8,
+ "win_rate": 0.8889,
+ "total_net_percent": 18.6471,
+ "avg_net_percent": 2.0719,
+ "max_drawdown_percent": 0.6796,
+ "profit_factor": 28.4383,
+ "trade_share": 1.0
}
]
},
"walk_forward": {
"summary": {
- "trades": 17,
- "wins": 8,
- "win_rate": 0.4706,
- "total_net_percent": 10.3038,
- "avg_net_percent": 0.6061,
- "max_drawdown_percent": 1.5511,
- "profit_factor": 3.6814,
- "status": "ok"
+ "trades": 4,
+ "wins": 4,
+ "win_rate": 1.0,
+ "total_net_percent": 13.9628,
+ "avg_net_percent": 3.4907,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 999.0,
+ "status": "warn"
},
+ "symbol_breakdown": [
+ {
+ "symbol": "ETHUSDT",
+ "trades": 4,
+ "wins": 4,
+ "win_rate": 1.0,
+ "total_net_percent": 13.9628,
+ "avg_net_percent": 3.4907,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 999.0,
+ "trade_share": 1.0
+ }
+ ],
+ "folds_with_trades": 1,
"folds": [
{
"fold": 1,
@@ -180,16 +207,16 @@
"test_records": 720,
"thresholds": {
"edge": 0.1,
- "probability": 0.6,
+ "probability": 0.62,
"confidence": 0.4,
- "trades": 6,
- "wins": 3,
- "win_rate": 0.5,
- "total_net_percent": -0.04776569264114405,
- "average_net_percent": -0.00796094877352401,
- "max_drawdown_percent": 1.812991648733242,
- "profit_factor": 0.9736536609672094,
- "score": -0.04306718362513842
+ "trades": 3,
+ "wins": 1,
+ "win_rate": 0.3333333333333333,
+ "total_net_percent": -0.5232258119866717,
+ "average_net_percent": -0.17440860399555724,
+ "max_drawdown_percent": 1.4887951136316468,
+ "profit_factor": 0.6485575434820195,
+ "score": -0.12638320925319477
},
"test": {
"trades": 0,
@@ -198,7 +225,8 @@
"total_net_percent": 0,
"avg_net_percent": 0.0,
"max_drawdown_percent": 0.0,
- "profit_factor": 0.0
+ "profit_factor": 0.0,
+ "symbol_breakdown": []
}
},
{
@@ -207,25 +235,38 @@
"test_records": 720,
"thresholds": {
"edge": 0.1,
- "probability": 0.57,
+ "probability": 0.56,
"confidence": 0.4,
- "trades": 14,
+ "trades": 17,
"wins": 9,
- "win_rate": 0.6428571428571429,
- "total_net_percent": 0.4316747127586451,
- "average_net_percent": 0.030833908054188935,
- "max_drawdown_percent": 3.0444999196004408,
- "profit_factor": 1.0927846710457836,
- "score": -0.02831168312815889
+ "win_rate": 0.5294117647058824,
+ "total_net_percent": 0.05666907902904805,
+ "average_net_percent": 0.0033334752370028265,
+ "max_drawdown_percent": 3.2186862081818535,
+ "profit_factor": 1.0111053980425537,
+ "score": -0.07120060423478176
},
"test": {
- "trades": 17,
- "wins": 8,
- "win_rate": 0.4706,
- "total_net_percent": 10.3038,
- "avg_net_percent": 0.6061,
- "max_drawdown_percent": 1.5511,
- "profit_factor": 3.6814
+ "trades": 4,
+ "wins": 4,
+ "win_rate": 1.0,
+ "total_net_percent": 13.9628,
+ "avg_net_percent": 3.4907,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 999.0,
+ "symbol_breakdown": [
+ {
+ "symbol": "ETHUSDT",
+ "trades": 4,
+ "wins": 4,
+ "win_rate": 1.0,
+ "total_net_percent": 13.9628,
+ "avg_net_percent": 3.4907,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 999.0,
+ "trade_share": 1.0
+ }
+ ]
}
},
{
@@ -252,74 +293,215 @@
"total_net_percent": 0,
"avg_net_percent": 0.0,
"max_drawdown_percent": 0.0,
- "profit_factor": 0.0
+ "profit_factor": 0.0,
+ "symbol_breakdown": []
}
}
]
},
+ "benchmark": {
+ "name": "trend_macd_baseline",
+ "summary": {
+ "trades": 0,
+ "wins": 0,
+ "win_rate": 0.0,
+ "total_net_percent": 0,
+ "avg_net_percent": 0.0,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 0.0,
+ "status": "no_trades"
+ },
+ "symbol_breakdown": [],
+ "folds_with_trades": 0,
+ "folds": [
+ {
+ "fold": 1,
+ "test_records": 720,
+ "test": {
+ "trades": 0,
+ "wins": 0,
+ "win_rate": 0.0,
+ "total_net_percent": 0,
+ "avg_net_percent": 0.0,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 0.0,
+ "symbol_breakdown": []
+ }
+ },
+ {
+ "fold": 2,
+ "test_records": 720,
+ "test": {
+ "trades": 0,
+ "wins": 0,
+ "win_rate": 0.0,
+ "total_net_percent": 0,
+ "avg_net_percent": 0.0,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 0.0,
+ "symbol_breakdown": []
+ }
+ },
+ {
+ "fold": 3,
+ "test_records": 720,
+ "test": {
+ "trades": 0,
+ "wins": 0,
+ "win_rate": 0.0,
+ "total_net_percent": 0,
+ "avg_net_percent": 0.0,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 0.0,
+ "symbol_breakdown": []
+ }
+ }
+ ]
+ },
+ "validation": {
+ "status": "fail",
+ "passed": false,
+ "checks": [
+ {
+ "name": "oos_trades",
+ "value": 4,
+ "required": 30,
+ "passed": false
+ },
+ {
+ "name": "oos_symbols",
+ "value": 1,
+ "required": 2,
+ "passed": false
+ },
+ {
+ "name": "max_symbol_share",
+ "value": 1.0,
+ "required": 0.75,
+ "passed": false
+ },
+ {
+ "name": "folds_with_trades",
+ "value": 1,
+ "required": 2,
+ "passed": false
+ },
+ {
+ "name": "oos_avg_net_positive",
+ "value": 3.4907,
+ "required": "> 0",
+ "passed": true
+ },
+ {
+ "name": "oos_profit_factor",
+ "value": 999.0,
+ "required": 1.1,
+ "passed": true
+ },
+ {
+ "name": "beats_benchmark_total",
+ "value": 13.9628,
+ "required": 0.0,
+ "passed": true
+ }
+ ],
+ "oos_summary": {
+ "trades": 4,
+ "wins": 4,
+ "win_rate": 1.0,
+ "total_net_percent": 13.9628,
+ "avg_net_percent": 3.4907,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 999.0,
+ "status": "warn"
+ },
+ "benchmark_summary": {
+ "trades": 0,
+ "wins": 0,
+ "win_rate": 0.0,
+ "total_net_percent": 0,
+ "avg_net_percent": 0.0,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 0.0,
+ "status": "no_trades"
+ },
+ "symbol_breakdown": [
+ {
+ "symbol": "ETHUSDT",
+ "trades": 4,
+ "wins": 4,
+ "win_rate": 1.0,
+ "total_net_percent": 13.9628,
+ "avg_net_percent": 3.4907,
+ "max_drawdown_percent": 0.0,
+ "profit_factor": 999.0,
+ "trade_share": 1.0
+ }
+ ]
+ },
"probability_calibration": {
"samples": 2880,
"buckets": [
{
"bucket": "0.30-0.35",
- "samples": 38,
- "avg_probability": 0.3361,
- "actual_win_rate": 0.1053,
- "avg_future_net_percent": -0.6575
+ "samples": 37,
+ "avg_probability": 0.3359,
+ "actual_win_rate": 0.1081,
+ "avg_future_net_percent": -0.6726
},
{
"bucket": "0.35-0.40",
- "samples": 406,
- "avg_probability": 0.3838,
- "actual_win_rate": 0.2192,
- "avg_future_net_percent": -0.641
+ "samples": 392,
+ "avg_probability": 0.3839,
+ "actual_win_rate": 0.227,
+ "avg_future_net_percent": -0.6306
},
{
"bucket": "0.40-0.45",
- "samples": 1043,
- "avg_probability": 0.4276,
- "actual_win_rate": 0.2809,
- "avg_future_net_percent": -0.4529
+ "samples": 1038,
+ "avg_probability": 0.4277,
+ "actual_win_rate": 0.29,
+ "avg_future_net_percent": -0.4258
},
{
"bucket": "0.45-0.50",
- "samples": 918,
- "avg_probability": 0.4744,
- "actual_win_rate": 0.3301,
- "avg_future_net_percent": -0.3166
+ "samples": 922,
+ "avg_probability": 0.4742,
+ "actual_win_rate": 0.3308,
+ "avg_future_net_percent": -0.3125
},
{
"bucket": "0.50-0.55",
- "samples": 312,
- "avg_probability": 0.5195,
- "actual_win_rate": 0.3846,
- "avg_future_net_percent": -0.1107
+ "samples": 327,
+ "avg_probability": 0.5196,
+ "actual_win_rate": 0.3914,
+ "avg_future_net_percent": -0.0857
},
{
"bucket": "0.55-0.60",
- "samples": 108,
- "avg_probability": 0.5753,
- "actual_win_rate": 0.4259,
- "avg_future_net_percent": 0.0115
+ "samples": 107,
+ "avg_probability": 0.5751,
+ "actual_win_rate": 0.4299,
+ "avg_future_net_percent": 0.0174
},
{
"bucket": "0.60-0.65",
- "samples": 42,
+ "samples": 44,
"avg_probability": 0.6136,
- "actual_win_rate": 0.4762,
- "avg_future_net_percent": 0.0003
+ "actual_win_rate": 0.4545,
+ "avg_future_net_percent": -0.0248
},
{
"bucket": "0.65-0.70",
"samples": 7,
- "avg_probability": 0.666,
+ "avg_probability": 0.6664,
"actual_win_rate": 0.2857,
"avg_future_net_percent": 0.538
},
{
"bucket": "0.70-0.75",
"samples": 6,
- "avg_probability": 0.7106,
+ "avg_probability": 0.7107,
"actual_win_rate": 0.8333,
"avg_future_net_percent": 2.1268
}
@@ -694,27 +876,27 @@
"edge": 0.1,
"probability": 0.6,
"confidence": 0.4,
- "trades": 16,
+ "trades": 18,
"wins": 9,
- "win_rate": 0.5625,
- "total_net_percent": 7.917141995065846,
- "average_net_percent": 0.49482137469161536,
- "max_drawdown_percent": 1.812991648733242,
- "profit_factor": 3.629214989861449,
- "score": 0.5816887551556057
+ "win_rate": 0.5,
+ "total_net_percent": 6.354067302186417,
+ "average_net_percent": 0.3530037390103565,
+ "max_drawdown_percent": 2.512166737458932,
+ "profit_factor": 2.5447559325558493,
+ "score": 0.3929497464193848
},
{
"edge": 0.08,
"probability": 0.6,
"confidence": 0.4,
- "trades": 16,
+ "trades": 18,
"wins": 9,
- "win_rate": 0.5625,
- "total_net_percent": 7.917141995065846,
- "average_net_percent": 0.49482137469161536,
- "max_drawdown_percent": 1.812991648733242,
- "profit_factor": 3.629214989861449,
- "score": 0.5816887551556057
+ "win_rate": 0.5,
+ "total_net_percent": 6.354067302186417,
+ "average_net_percent": 0.3530037390103565,
+ "max_drawdown_percent": 2.512166737458932,
+ "profit_factor": 2.5447559325558493,
+ "score": 0.3929497464193848
}
]
}
diff --git a/tests/test_strategy.py b/tests/test_strategy.py
index bee4adf..12321e8 100644
--- a/tests/test_strategy.py
+++ b/tests/test_strategy.py
@@ -284,6 +284,40 @@ def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path
assert signal.diagnostics["checks"]["torch_model_ok"] is False
+def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None:
+ settings = make_settings(
+ tmp_path,
+ strategy_mode="torch_forecast",
+ time_series_min_edge_percent=0.10,
+ time_series_min_probability_up=0.57,
+ max_position_usdt=25,
+ stop_loss_percent=0.04,
+ )
+ strategy = SpotStrategy(settings)
+ ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
+
+ signal = strategy.entry_signal(
+ "BTCUSDT",
+ [],
+ ticker,
+ open_positions_for_symbol=0,
+ forecast={
+ "usable": True,
+ "model": "torch_gru",
+ "expected_return_percent": 0.36,
+ "probability_up": 0.66,
+ "skill": 0.22,
+ "block_entry": False,
+ "quality_gate_passed": False,
+ "quality_gate": {"status": "fail"},
+ },
+ account={"equity": 100.0},
+ )
+
+ assert signal.action == "HOLD"
+ assert signal.diagnostics["checks"]["quality_gate_ok"] is False
+
+
def test_torch_forecast_probe_buys_on_positive_high_probability(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
diff --git a/tests/test_time_series.py b/tests/test_time_series.py
index 1c95de8..9c0a516 100644
--- a/tests/test_time_series.py
+++ b/tests/test_time_series.py
@@ -282,6 +282,28 @@ def test_time_series_forecaster_reads_torch_gru_artifact(make_settings, tmp_path
assert forecast.probability_up > 0.5
+def test_time_series_forecaster_attaches_quality_gate(make_settings, tmp_path) -> None:
+ artifact_path = tmp_path / "lstm_forecaster.json"
+ _write_torch_gru_artifact(artifact_path, head_bias=0.2)
+ (tmp_path / "torch_threshold_calibration.json").write_text(
+ json.dumps({"validation": {"status": "fail", "passed": False, "checks": []}}),
+ encoding="utf-8",
+ )
+ settings = make_settings(
+ tmp_path,
+ time_series_min_candles=80,
+ time_series_lstm_enabled=True,
+ time_series_lstm_model_path=artifact_path,
+ )
+ returns = [0.00015 if index % 4 else -0.00005 for index in range(140)]
+
+ forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
+
+ assert forecast.usable is True
+ assert forecast.quality_gate_passed is False
+ assert forecast.quality_gate["status"] == "fail"
+
+
def test_time_series_forecaster_reads_multifeature_direct_horizon_artifact(make_settings, tmp_path) -> None:
artifact_path = tmp_path / "lstm_forecaster.json"
_write_multifeature_torch_gru_artifact(artifact_path, head_bias=0.2)
diff --git a/tests/test_torch_candidate_guard.py b/tests/test_torch_candidate_guard.py
new file mode 100644
index 0000000..bc2521d
--- /dev/null
+++ b/tests/test_torch_candidate_guard.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from tools.accept_torch_candidate import _decision
+
+
+def _report(*, validation_passed: bool = True, trades: int = 30, total: float = 10.0) -> dict:
+ return {
+ "recommended": {"score": 0.5},
+ "full_replay": {
+ "trades": trades,
+ "avg_net_percent": 0.4,
+ "total_net_percent": total,
+ "profit_factor": 2.0,
+ "max_drawdown_percent": 1.0,
+ },
+ "walk_forward": {"summary": {"trades": trades, "avg_net_percent": 0.3}},
+ "validation": {"passed": validation_passed, "status": "pass" if validation_passed else "fail"},
+ }
+
+
+def test_guard_rejects_candidate_without_honest_validation() -> None:
+ decision = _decision(
+ _report(),
+ _report(validation_passed=False),
+ min_trades=8,
+ min_profit_factor=1.05,
+ min_avg_net_percent=0.0,
+ max_score_regression=0.05,
+ )
+
+ assert decision == {"accepted": False, "reason": "candidate_failed_honest_validation"}
+
+
+def test_guard_accepts_candidate_that_passes_honest_validation() -> None:
+ decision = _decision(
+ _report(total=9.0),
+ _report(total=12.0),
+ min_trades=8,
+ min_profit_factor=1.05,
+ min_avg_net_percent=0.0,
+ max_score_regression=0.05,
+ )
+
+ assert decision == {"accepted": True, "reason": "candidate_passed_guard"}
diff --git a/tools/accept_torch_candidate.py b/tools/accept_torch_candidate.py
index dd574b2..c91ee43 100644
--- a/tools/accept_torch_candidate.py
+++ b/tools/accept_torch_candidate.py
@@ -64,6 +64,8 @@ def _decision(
candidate_replay = candidate.get("full_replay") if isinstance(candidate.get("full_replay"), dict) else {}
candidate_walk = candidate.get("walk_forward") if isinstance(candidate.get("walk_forward"), dict) else {}
walk_summary = candidate_walk.get("summary") if isinstance(candidate_walk.get("summary"), dict) else {}
+ if not _validation_passed(candidate):
+ return {"accepted": False, "reason": "candidate_failed_honest_validation"}
if int(candidate_replay.get("trades", 0) or 0) < min_trades:
return {"accepted": False, "reason": "candidate_has_too_few_full_replay_trades"}
if float(candidate_replay.get("profit_factor", 0.0) or 0.0) < min_profit_factor:
@@ -77,6 +79,15 @@ def _decision(
return {"accepted": True, "reason": "candidate_passed_guard"}
+def _validation_passed(report: dict[str, Any]) -> bool:
+ validation = report.get("validation")
+ if not isinstance(validation, dict):
+ return False
+ if "passed" in validation:
+ return bool(validation.get("passed"))
+ return str(validation.get("status", "")).strip().lower() in {"pass", "passed", "ok"}
+
+
def _score(report: dict[str, Any]) -> float:
replay = report.get("full_replay") if isinstance(report.get("full_replay"), dict) else {}
recommended = report.get("recommended") if isinstance(report.get("recommended"), dict) else {}
@@ -97,6 +108,10 @@ def _summary(report: dict[str, Any]) -> dict[str, Any]:
"walk_forward_summary": (report.get("walk_forward") or {}).get("summary", {})
if isinstance(report.get("walk_forward"), dict)
else {},
+ "benchmark_summary": (report.get("benchmark") or {}).get("summary", {})
+ if isinstance(report.get("benchmark"), dict)
+ else {},
+ "validation": report.get("validation", {}),
}
diff --git a/tools/calibrate_torch_thresholds.py b/tools/calibrate_torch_thresholds.py
index 7f29c35..d76d1fe 100644
--- a/tools/calibrate_torch_thresholds.py
+++ b/tools/calibrate_torch_thresholds.py
@@ -57,6 +57,8 @@ class ForecastRecord:
q50_percent: float
block_entry: bool
future_net_percent: float
+ benchmark_entry: bool
+ benchmark_exit: bool
@dataclass(slots=True)
@@ -159,9 +161,32 @@ def main() -> None:
min_trades=args.min_trades,
horizon=horizon,
folds=args.walk_forward_folds,
+ round_trip_cost=round_trip_cost,
+ settings=settings,
+ )
+ benchmark = _benchmark_walk_forward(
+ records,
+ horizon=horizon,
+ folds=args.walk_forward_folds,
+ round_trip_cost=round_trip_cost,
+ settings=settings,
+ )
+ validation = _quality_gate(
+ walk_forward=walk_forward,
+ benchmark=benchmark,
+ min_oos_trades=args.min_oos_trades,
+ min_oos_symbols=args.min_oos_symbols,
+ max_symbol_share=args.max_oos_symbol_share,
+ min_oos_folds=args.min_oos_folds_with_trades,
+ min_profit_factor=args.min_oos_profit_factor,
+ min_benchmark_edge=args.min_benchmark_edge_percent,
)
print("\nWALK_FORWARD")
print(json.dumps(walk_forward["summary"], ensure_ascii=False, sort_keys=True))
+ print("\nBENCHMARK")
+ print(json.dumps(benchmark["summary"], ensure_ascii=False, sort_keys=True))
+ print("\nQUALITY_GATE")
+ print(json.dumps(validation, ensure_ascii=False, sort_keys=True))
print(
"env "
f"TIME_SERIES_MIN_EDGE_PERCENT={recommended.edge:.4f} "
@@ -176,6 +201,8 @@ def main() -> None:
"recommended": _result_dict(recommended),
"full_replay": full_backtest,
"walk_forward": walk_forward,
+ "benchmark": benchmark,
+ "validation": validation,
"probability_calibration": _probability_calibration(records),
"top_results": [_result_dict(result) for result in results[: args.top]],
}
@@ -202,6 +229,12 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--batch-size", type=int, default=256, help="Torch inference batch size.")
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
parser.add_argument("--walk-forward-folds", type=int, default=4, help="Threshold walk-forward folds.")
+ parser.add_argument("--min-oos-trades", type=int, default=30, help="Minimum out-of-sample walk-forward trades for a valid model.")
+ parser.add_argument("--min-oos-symbols", type=int, default=2, help="Minimum symbols with out-of-sample trades.")
+ parser.add_argument("--max-oos-symbol-share", type=float, default=0.75, help="Reject if one symbol contributes more than this share of out-of-sample trades.")
+ parser.add_argument("--min-oos-folds-with-trades", type=int, default=2, help="Minimum walk-forward folds that must produce trades.")
+ parser.add_argument("--min-oos-profit-factor", type=float, default=1.10, help="Minimum out-of-sample profit factor.")
+ parser.add_argument("--min-benchmark-edge-percent", type=float, default=0.0, help="Required total-net percent advantage over the benchmark.")
return parser.parse_args()
@@ -245,6 +278,7 @@ def _forecast_records(
batched_records = _batch_forecast_records(
symbol=symbol,
candles=candles,
+ trend_candles=trend_candles,
feature_rows=feature_rows,
closes=closes,
entry=entry,
@@ -295,6 +329,8 @@ def _forecast_records(
q50_percent=q50_percent,
block_entry=False,
future_net_percent=future_net_percent,
+ benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
+ benchmark_exit=_benchmark_exit_signal(candles, index),
)
)
return records
@@ -304,6 +340,7 @@ def _batch_forecast_records(
*,
symbol: str,
candles: list[Candle],
+ trend_candles: list[Candle],
feature_rows: list[list[float]],
closes: list[float],
entry: dict[str, Any],
@@ -388,6 +425,8 @@ def _batch_forecast_records(
q50_percent=q50_percent,
block_entry=False,
future_net_percent=future_net_percent,
+ benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
+ benchmark_exit=_benchmark_exit_signal(candles, index),
)
)
return records
@@ -523,6 +562,7 @@ def _full_backtest(
horizon: int,
round_trip_cost: float,
settings: Any,
+ detail_limit: int = 50,
) -> dict[str, Any]:
positions: dict[str, dict[str, Any]] = {}
trades: list[float] = []
@@ -603,7 +643,92 @@ def _full_backtest(
"entry_expected_percent": round(float(position["expected_percent"]), 4),
}
)
- return {**_stats(trades), "trades_detail": rows[-50:]}
+ return {
+ **_stats(trades),
+ "trades_detail": _limited_rows(rows, detail_limit),
+ "symbol_breakdown": _symbol_breakdown(rows),
+ }
+
+
+def _benchmark_backtest(
+ records: list[ForecastRecord],
+ *,
+ horizon: int,
+ round_trip_cost: float,
+ settings: Any,
+ detail_limit: int = 50,
+) -> dict[str, Any]:
+ positions: dict[str, dict[str, Any]] = {}
+ trades: list[float] = []
+ rows: list[dict[str, Any]] = []
+ max_hold = max(12, horizon * 8)
+ stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
+ atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
+ for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
+ position = positions.get(record.symbol)
+ if position is not None:
+ position["highest"] = max(position["highest"], record.close)
+ net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
+ held = record.index - int(position["entry_index"])
+ atr_stop = (
+ record.close <= position["highest"] - record.atr * atr_multiplier
+ if record.atr > 0 and position["highest"] > position["entry_price"]
+ else False
+ )
+ exit_reason = ""
+ if net_percent <= -stop_loss_percent:
+ exit_reason = "stop_loss"
+ elif atr_stop:
+ exit_reason = "atr_trailing_stop"
+ elif record.benchmark_exit:
+ exit_reason = "benchmark_exit"
+ elif held >= max_hold:
+ exit_reason = "max_hold"
+ if exit_reason:
+ trades.append(net_percent)
+ rows.append(
+ {
+ "symbol": record.symbol,
+ "entry_timestamp": position["timestamp"],
+ "exit_timestamp": record.timestamp,
+ "net_percent": round(net_percent, 4),
+ "reason": exit_reason,
+ "held_bars": held,
+ }
+ )
+ positions.pop(record.symbol, None)
+ continue
+
+ if record.symbol in positions:
+ continue
+ if record.benchmark_entry:
+ positions[record.symbol] = {
+ "entry_price": record.close,
+ "entry_index": record.index,
+ "timestamp": record.timestamp,
+ "highest": record.close,
+ }
+ for symbol, position in list(positions.items()):
+ tail = next((record for record in reversed(records) if record.symbol == symbol), None)
+ if tail is None:
+ continue
+ net_percent = _net_percent(position["entry_price"], tail.close, round_trip_cost)
+ trades.append(net_percent)
+ rows.append(
+ {
+ "symbol": symbol,
+ "entry_timestamp": position["timestamp"],
+ "exit_timestamp": tail.timestamp,
+ "net_percent": round(net_percent, 4),
+ "reason": "end_of_replay",
+ "held_bars": tail.index - int(position["entry_index"]),
+ }
+ )
+ return {
+ **_stats(trades),
+ "trades_detail": _limited_rows(rows, detail_limit),
+ "symbol_breakdown": _symbol_breakdown(rows),
+ }
def _walk_forward(
@@ -615,6 +740,8 @@ def _walk_forward(
min_trades: int,
horizon: int,
folds: int,
+ round_trip_cost: float,
+ settings: Any,
) -> dict[str, Any]:
ordered = sorted(records, key=lambda item: item.timestamp)
if folds < 2 or len(ordered) < folds * 20:
@@ -623,6 +750,7 @@ def _walk_forward(
fold_size = max(1, len(timestamps) // folds)
rows = []
all_test_trades: list[float] = []
+ all_test_rows: list[dict[str, Any]] = []
for fold in range(1, folds):
test_start = timestamps[fold * fold_size]
test_end = timestamps[(fold + 1) * fold_size - 1] if fold < folds - 1 else timestamps[-1]
@@ -639,20 +767,124 @@ def _walk_forward(
if not train_results:
continue
selected = _choose_recommendation(train_results, min_trades=max(4, min_trades // 2))
- test_trades = _selected_trades(test, selected.edge, selected.probability, selected.confidence, horizon)
+ test_backtest = _full_backtest(
+ test,
+ selected,
+ horizon=horizon,
+ round_trip_cost=round_trip_cost,
+ settings=settings,
+ detail_limit=0,
+ )
+ test_rows = test_backtest.get("trades_detail", [])
+ test_trades = [float(row.get("net_percent", 0.0) or 0.0) for row in test_rows if isinstance(row, dict)]
all_test_trades.extend(test_trades)
+ all_test_rows.extend(test_rows)
rows.append(
{
"fold": fold,
"train_records": len(train),
"test_records": len(test),
"thresholds": _result_dict(selected),
- "test": _stats(test_trades),
+ "test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
}
)
summary = _stats(all_test_trades)
summary["status"] = "ok" if summary["trades"] >= min_trades and summary["avg_net_percent"] > 0 else "warn"
- return {"summary": summary, "folds": rows}
+ return {
+ "summary": summary,
+ "symbol_breakdown": _symbol_breakdown(all_test_rows),
+ "folds_with_trades": sum(1 for row in rows if int((row.get("test") or {}).get("trades", 0) or 0) > 0),
+ "folds": rows,
+ }
+
+
+def _benchmark_walk_forward(
+ records: list[ForecastRecord],
+ *,
+ horizon: int,
+ folds: int,
+ round_trip_cost: float,
+ settings: Any,
+) -> dict[str, Any]:
+ ordered = sorted(records, key=lambda item: item.timestamp)
+ if folds < 2 or len(ordered) < folds * 20:
+ return {"summary": {"status": "insufficient"}, "symbol_breakdown": [], "folds_with_trades": 0, "folds": []}
+ timestamps = sorted({record.timestamp for record in ordered})
+ fold_size = max(1, len(timestamps) // folds)
+ rows = []
+ all_test_rows: list[dict[str, Any]] = []
+ for fold in range(1, folds):
+ test_start = timestamps[fold * fold_size]
+ test_end = timestamps[(fold + 1) * fold_size - 1] if fold < folds - 1 else timestamps[-1]
+ test = [record for record in ordered if test_start <= record.timestamp <= test_end]
+ test_backtest = _benchmark_backtest(
+ test,
+ horizon=horizon,
+ round_trip_cost=round_trip_cost,
+ settings=settings,
+ detail_limit=0,
+ )
+ test_rows = test_backtest.get("trades_detail", [])
+ all_test_rows.extend(test_rows)
+ rows.append(
+ {
+ "fold": fold,
+ "test_records": len(test),
+ "test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
+ }
+ )
+ trades = [float(row.get("net_percent", 0.0) or 0.0) for row in all_test_rows if isinstance(row, dict)]
+ summary = _stats(trades)
+ summary["status"] = "ok" if summary["trades"] > 0 else "no_trades"
+ return {
+ "name": "trend_macd_baseline",
+ "summary": summary,
+ "symbol_breakdown": _symbol_breakdown(all_test_rows),
+ "folds_with_trades": sum(1 for row in rows if int((row.get("test") or {}).get("trades", 0) or 0) > 0),
+ "folds": rows,
+ }
+
+
+def _quality_gate(
+ *,
+ walk_forward: dict[str, Any],
+ benchmark: dict[str, Any],
+ min_oos_trades: int,
+ min_oos_symbols: int,
+ max_symbol_share: float,
+ min_oos_folds: int,
+ min_profit_factor: float,
+ min_benchmark_edge: float,
+) -> dict[str, Any]:
+ summary = walk_forward.get("summary") if isinstance(walk_forward.get("summary"), dict) else {}
+ benchmark_summary = benchmark.get("summary") if isinstance(benchmark.get("summary"), dict) else {}
+ breakdown = walk_forward.get("symbol_breakdown") if isinstance(walk_forward.get("symbol_breakdown"), list) else []
+ symbols_with_trades = sum(1 for row in breakdown if int(row.get("trades", 0) or 0) > 0)
+ max_share = max((float(row.get("trade_share", 0.0) or 0.0) for row in breakdown), default=0.0)
+ oos_total = float(summary.get("total_net_percent", 0.0) or 0.0)
+ benchmark_total = float(benchmark_summary.get("total_net_percent", 0.0) or 0.0)
+ checks = [
+ _gate_check("oos_trades", int(summary.get("trades", 0) or 0), min_oos_trades, int(summary.get("trades", 0) or 0) >= min_oos_trades),
+ _gate_check("oos_symbols", symbols_with_trades, min_oos_symbols, symbols_with_trades >= min_oos_symbols),
+ _gate_check("max_symbol_share", round(max_share, 4), max_symbol_share, max_share <= max_symbol_share if breakdown else False),
+ _gate_check("folds_with_trades", int(walk_forward.get("folds_with_trades", 0) or 0), min_oos_folds, int(walk_forward.get("folds_with_trades", 0) or 0) >= min_oos_folds),
+ _gate_check("oos_avg_net_positive", float(summary.get("avg_net_percent", 0.0) or 0.0), "> 0", float(summary.get("avg_net_percent", 0.0) or 0.0) > 0),
+ _gate_check("oos_profit_factor", float(summary.get("profit_factor", 0.0) or 0.0), min_profit_factor, float(summary.get("profit_factor", 0.0) or 0.0) >= min_profit_factor),
+ _gate_check("beats_benchmark_total", round(oos_total - benchmark_total, 4), min_benchmark_edge, (oos_total - benchmark_total) > min_benchmark_edge),
+ ]
+ passed = all(bool(row["passed"]) for row in checks)
+ return {
+ "status": "pass" if passed else "fail",
+ "passed": passed,
+ "checks": checks,
+ "oos_summary": summary,
+ "benchmark_summary": benchmark_summary,
+ "symbol_breakdown": breakdown,
+ }
+
+
+def _gate_check(name: str, value: Any, required: Any, passed: bool) -> dict[str, Any]:
+ return {"name": name, "value": value, "required": required, "passed": bool(passed)}
def _probability_calibration(records: list[ForecastRecord]) -> dict[str, Any]:
@@ -699,12 +931,89 @@ def _candidate_allows(record: ForecastRecord, edge: float, probability: float, c
)
+def _benchmark_entry_signal(candles: list[Candle], trend_candles: list[Candle], index: int) -> bool:
+ if index <= 0 or index >= len(candles):
+ return False
+ previous = candles[index - 1]
+ current = candles[index]
+ rsi = current.rsi_14
+ return bool(
+ _daily_trend_ok(trend_candles, current.timestamp)
+ and _macd_crossed_up(previous, current)
+ and current.ema_50 is not None
+ and current.close > current.ema_50
+ and rsi is not None
+ and 45.0 <= rsi <= 65.0
+ )
+
+
+def _benchmark_exit_signal(candles: list[Candle], index: int) -> bool:
+ if index <= 0 or index >= len(candles):
+ return False
+ previous = candles[index - 1]
+ current = candles[index]
+ return bool(_macd_crossed_down(previous, current) or (current.ema_50 is not None and current.close < current.ema_50))
+
+
+def _daily_trend_ok(trend_candles: list[Candle], timestamp: int) -> bool:
+ for candle in reversed(trend_candles):
+ if candle.timestamp > timestamp:
+ continue
+ return bool(
+ candle.ema_50 is not None
+ and candle.ema_200 is not None
+ and candle.close > candle.ema_200
+ and candle.ema_50 > candle.ema_200
+ )
+ return False
+
+
+def _macd_crossed_up(previous: Candle, current: Candle) -> bool:
+ if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
+ return False
+ return bool(previous.macd <= previous.macd_signal and current.macd > current.macd_signal)
+
+
+def _macd_crossed_down(previous: Candle, current: Candle) -> bool:
+ if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
+ return False
+ return bool(previous.macd >= previous.macd_signal and current.macd < current.macd_signal)
+
+
def _net_percent(entry_price: float, exit_price: float, round_trip_cost: float) -> float:
if entry_price <= 0 or exit_price <= 0:
return 0.0
return (math.exp(math.log(exit_price / entry_price) - round_trip_cost) - 1.0) * 100.0
+def _limited_rows(rows: list[dict[str, Any]], detail_limit: int) -> list[dict[str, Any]]:
+ if detail_limit <= 0:
+ return rows
+ return rows[-detail_limit:]
+
+
+def _symbol_breakdown(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ by_symbol: dict[str, list[float]] = {}
+ for row in rows:
+ symbol = str(row.get("symbol", ""))
+ if not symbol:
+ continue
+ by_symbol.setdefault(symbol, []).append(float(row.get("net_percent", 0.0) or 0.0))
+ total_trades = sum(len(values) for values in by_symbol.values())
+ result = []
+ for symbol in sorted(by_symbol):
+ values = by_symbol[symbol]
+ stats = _stats(values)
+ result.append(
+ {
+ "symbol": symbol,
+ **stats,
+ "trade_share": round(len(values) / total_trades, 4) if total_trades else 0.0,
+ }
+ )
+ return result
+
+
def _stats(values: list[float]) -> dict[str, Any]:
wins = sum(1 for value in values if value > 0)
total = sum(values)
diff --git a/tools/run_retrain_until_replay8.ps1 b/tools/run_retrain_until_replay8.ps1
index 39a2821..7865dfa 100644
--- a/tools/run_retrain_until_replay8.ps1
+++ b/tools/run_retrain_until_replay8.ps1
@@ -85,11 +85,24 @@ function Read-ActiveReplayTrades {
}
}
+function Read-ActiveValidationPassed {
+ if (-not (Test-Path $ActiveCalibration)) {
+ return $false
+ }
+ try {
+ $payload = Get-Content -Raw -LiteralPath $ActiveCalibration | ConvertFrom-Json
+ return [bool]$payload.validation.passed
+ }
+ catch {
+ return $false
+ }
+}
+
$attempt = 0
while ($true) {
$activeReplayTrades = Read-ActiveReplayTrades
- if ($activeReplayTrades -ge $MinReplayTrades) {
- Write-LoopLog "Stop condition reached: active calibration full_replay.trades=$activeReplayTrades >= $MinReplayTrades."
+ if (Read-ActiveValidationPassed) {
+ Write-LoopLog "Stop condition reached: active calibration passed honest validation with full_replay.trades=$activeReplayTrades."
exit 0
}
@@ -123,12 +136,8 @@ while ($true) {
$summary = Read-GuardSummary
Write-LoopLog "Attempt $attempt finished; runner_exit=$runnerExit accepted=$($summary.Accepted) reason=$($summary.Reason) candidate_full_replay.trades=$($summary.CandidateReplayTrades) current_full_replay.trades=$($summary.CurrentReplayTrades) walk_forward.trades=$($summary.WalkForwardTrades)."
- if ($summary.Accepted -and $summary.CandidateReplayTrades -ge $MinReplayTrades) {
- Write-LoopLog "Stop condition reached: accepted candidate full_replay.trades=$($summary.CandidateReplayTrades) >= $MinReplayTrades."
- exit 0
- }
- if ($summary.CurrentReplayTrades -ge $MinReplayTrades) {
- Write-LoopLog "Stop condition reached: current artifact full_replay.trades=$($summary.CurrentReplayTrades) >= $MinReplayTrades."
+ if ($summary.Accepted -and (Read-ActiveValidationPassed)) {
+ Write-LoopLog "Stop condition reached: accepted candidate passed honest validation with full_replay.trades=$($summary.CandidateReplayTrades)."
exit 0
}