Keep bot operational when forecast model is unavailable

This commit is contained in:
Курнат Андрей
2026-07-13 11:57:25 +03:00
parent da53483164
commit 668e606ee2
22 changed files with 706 additions and 73 deletions
+65 -19
View File
@@ -159,6 +159,7 @@ def main() -> None:
settings=settings,
)
symbol_recommendations: dict[str, dict[str, Any]] = {}
symbol_threshold_results: dict[str, CalibrationResult] = {}
for symbol in symbols:
symbol_records = [record for record in records if record.symbol == symbol]
symbol_results = _calibrate_strategy(
@@ -177,7 +178,8 @@ def main() -> None:
) if symbol_results else None
if symbol_selected is not None:
symbol_recommendations[symbol] = _result_dict(symbol_selected)
calibration_insufficient = recommended is None
symbol_threshold_results[symbol] = symbol_selected
calibration_insufficient = recommended is None or not symbol_threshold_results
if recommended is None:
recommended = _empty_recommendation(
_float_grid(args.edge_grid),
@@ -185,6 +187,16 @@ def main() -> None:
_float_grid(args.confidence_grid),
)
full_backtest = {**_stats([]), "trades_detail": [], "symbol_breakdown": []}
elif symbol_threshold_results:
full_backtest = _full_backtest(
records,
recommended,
horizon=horizon,
round_trip_cost=round_trip_cost,
settings=settings,
symbol_thresholds=symbol_threshold_results,
require_symbol_thresholds=True,
)
print("\nRECOMMENDED")
print(_result_line(recommended))
print("\nFULL_REPLAY")
@@ -248,6 +260,7 @@ def main() -> None:
"recommended": _result_dict(deployment_recommended),
"calibration_insufficient": calibration_insufficient,
"symbol_recommendations": deployment_symbol_recommendations,
"eligible_symbols": sorted(deployment_symbol_recommendations),
"full_replay": full_backtest,
"walk_forward": walk_forward,
"benchmark": benchmark,
@@ -419,8 +432,8 @@ def _batch_forecast_records(
horizons = _entry_target_horizons(entry)
if not horizons:
return None
model = _build_torch_model(entry, model_name)
if model is None:
models = _build_torch_models(entry, model_name)
if not models:
return None
lookback = int(_clamp(_float_entry(entry, "lookback", 64.0), 4.0, 512.0))
@@ -438,7 +451,8 @@ def _batch_forecast_records(
records: list[ForecastRecord] = []
skill = _entry_validation_skill(entry)
model.eval()
for model in models:
model.eval()
with torch.no_grad():
for offset in range(0, len(indices), max(1, batch_size)):
batch_indices = indices[offset : offset + max(1, batch_size)]
@@ -453,17 +467,25 @@ def _batch_forecast_records(
for index in batch_indices
]
batch = torch.tensor(windows, dtype=torch.float32)
outputs = model(batch).detach().cpu().tolist()
for index, output in zip(batch_indices, outputs):
selected = _decode_selected_output(
output,
entry=entry,
candles=candles,
closes=closes,
index=index,
horizon=decision_horizon,
clip=clip,
round_trip_cost=round_trip_cost,
outputs_by_model = [model(batch).detach().cpu().tolist() for model in models]
for batch_offset, index in enumerate(batch_indices):
selected = _average_selected_predictions(
[
decoded
for outputs in outputs_by_model
if (
decoded := _decode_selected_output(
outputs[batch_offset],
entry=entry,
candles=candles,
closes=closes,
index=index,
horizon=decision_horizon,
clip=clip,
round_trip_cost=round_trip_cost,
)
) is not None
]
)
if selected is None:
continue
@@ -502,11 +524,21 @@ def _batch_forecast_records(
return records
def _build_torch_models(entry: dict[str, Any], model_name: str) -> list[Any]:
members = entry.get("ensemble_members")
if isinstance(members, list) and members:
base = {key: value for key, value in entry.items() if key != "ensemble_members"}
models = [
_build_torch_model({**base, **member}, model_name)
for member in members
if isinstance(member, dict)
]
return [model for model in models if model is not None]
model = _build_torch_model(entry, model_name)
return [model] if model is not None else []
def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
if isinstance(entry.get("ensemble_members"), list) and entry["ensemble_members"]:
# Ensemble inference is handled by the shared pure-Python runtime so
# calibration and production use the exact same averaging path.
return None
if torch is None or RecurrentReturnModel is None:
return None
architecture = "lstm" if model_name == "torch_lstm" else "gru" if model_name == "torch_gru" else ""
@@ -560,6 +592,15 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
return model
def _average_selected_predictions(rows: list[dict[str, float]]) -> dict[str, float] | None:
if not rows:
return None
return {
name: sum(float(row[name]) for row in rows) / len(rows)
for name in ("expected_return", "q50", "probability_up")
}
def _decode_selected_output(
output: list[float],
*,
@@ -638,6 +679,7 @@ def _full_backtest(
settings: Any,
detail_limit: int = 50,
symbol_thresholds: dict[str, CalibrationResult] | None = None,
require_symbol_thresholds: bool = False,
) -> dict[str, Any]:
positions: dict[str, dict[str, Any]] = {}
trades: list[float] = []
@@ -708,6 +750,8 @@ def _full_backtest(
if record.symbol in positions:
continue
if require_symbol_thresholds and record.symbol not in (symbol_thresholds or {}):
continue
if _candidate_allows(record, active_thresholds.edge, active_thresholds.probability, active_thresholds.confidence):
positions[record.symbol] = {
"entry_price": record.next_open,
@@ -907,6 +951,7 @@ def _walk_forward(
settings=settings,
detail_limit=0,
symbol_thresholds=symbol_thresholds,
require_symbol_thresholds=True,
)
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)]
@@ -921,6 +966,7 @@ def _walk_forward(
"symbol_thresholds": {
symbol: _result_dict(value) for symbol, value in symbol_thresholds.items()
},
"eligible_symbols": sorted(symbol_thresholds),
"probability_calibration": probability_calibration,
"test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
}