Fix remote training and model validation pipeline

This commit is contained in:
Курнат Андрей
2026-07-12 22:56:49 +03:00
parent 18936cf8b1
commit da53483164
22 changed files with 1691 additions and 246 deletions
+256 -16
View File
@@ -6,7 +6,7 @@ import json
import math
import sys
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
@@ -130,13 +130,15 @@ def main() -> None:
if not records:
raise SystemExit("No forecast records could be built for calibration.")
results = _calibrate(
results = _calibrate_strategy(
records,
edges=_float_grid(args.edge_grid),
probabilities=_float_grid(args.probability_grid),
confidences=_float_grid(args.confidence_grid),
min_trades=args.min_trades,
horizon=horizon,
round_trip_cost=round_trip_cost,
settings=settings,
)
if not results:
raise SystemExit("No calibration result produced trades. Use wider grids or more history.")
@@ -156,6 +158,33 @@ def main() -> None:
round_trip_cost=round_trip_cost,
settings=settings,
)
symbol_recommendations: dict[str, dict[str, Any]] = {}
for symbol in symbols:
symbol_records = [record for record in records if record.symbol == symbol]
symbol_results = _calibrate_strategy(
symbol_records,
edges=_float_grid(args.edge_grid),
probabilities=_float_grid(args.probability_grid),
confidences=_float_grid(args.confidence_grid),
min_trades=max(3, min(args.min_trades, len(symbol_records) // 8)),
horizon=horizon,
round_trip_cost=round_trip_cost,
settings=settings,
)
symbol_selected = _choose_recommendation(
symbol_results,
min_trades=max(3, min(args.min_trades, len(symbol_records) // 8)),
) if symbol_results else None
if symbol_selected is not None:
symbol_recommendations[symbol] = _result_dict(symbol_selected)
calibration_insufficient = recommended is None
if recommended is None:
recommended = _empty_recommendation(
_float_grid(args.edge_grid),
_float_grid(args.probability_grid),
_float_grid(args.confidence_grid),
)
full_backtest = {**_stats([]), "trades_detail": [], "symbol_breakdown": []}
print("\nRECOMMENDED")
print(_result_line(recommended))
print("\nFULL_REPLAY")
@@ -188,6 +217,16 @@ def main() -> None:
min_profit_factor=args.min_oos_profit_factor,
min_benchmark_edge=args.min_benchmark_edge_percent,
)
deployment_recommended = recommended
deployment_symbol_recommendations = symbol_recommendations
if walk_forward.get("folds"):
last_fold = walk_forward["folds"][-1]
fold_thresholds = last_fold.get("thresholds")
if isinstance(fold_thresholds, dict):
deployment_recommended = _result_from_dict(fold_thresholds)
fold_symbols = last_fold.get("symbol_thresholds")
if isinstance(fold_symbols, dict):
deployment_symbol_recommendations = fold_symbols
print("\nWALK_FORWARD")
print(json.dumps(walk_forward["summary"], ensure_ascii=False, sort_keys=True))
print("\nBENCHMARK")
@@ -206,7 +245,9 @@ def main() -> None:
"artifact_sha256": artifact_sha256,
"artifact": _artifact_summary(artifact),
"records_by_symbol": per_symbol_counts,
"recommended": _result_dict(recommended),
"recommended": _result_dict(deployment_recommended),
"calibration_insufficient": calibration_insufficient,
"symbol_recommendations": deployment_symbol_recommendations,
"full_replay": full_backtest,
"walk_forward": walk_forward,
"benchmark": benchmark,
@@ -306,7 +347,9 @@ def _forecast_records(
return batched_records
records: list[ForecastRecord] = []
skill = float(entry.get("skill", 0.0) or 0.0)
# Entry eligibility may use validation-derived quality only. Holdout metrics
# belong exclusively to the final quality gate and cannot influence replay.
skill = _entry_validation_skill(entry)
for index in range(start, max(start, end)):
prediction = _torch_recurrent_predict(
_log_returns(closes[: index + 1]),
@@ -394,7 +437,7 @@ def _batch_forecast_records(
return []
records: list[ForecastRecord] = []
skill = float(entry.get("skill", 0.0) or 0.0)
skill = _entry_validation_skill(entry)
model.eval()
with torch.no_grad():
for offset in range(0, len(indices), max(1, batch_size)):
@@ -460,6 +503,10 @@ def _batch_forecast_records(
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 ""
@@ -590,6 +637,7 @@ def _full_backtest(
round_trip_cost: float,
settings: Any,
detail_limit: int = 50,
symbol_thresholds: dict[str, CalibrationResult] | None = None,
) -> dict[str, Any]:
positions: dict[str, dict[str, Any]] = {}
trades: list[float] = []
@@ -600,6 +648,7 @@ def _full_backtest(
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
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)):
active_thresholds = (symbol_thresholds or {}).get(record.symbol, thresholds)
position = positions.get(record.symbol)
if position is not None:
position["highest"] = max(position["highest"], record.high)
@@ -616,8 +665,8 @@ def _full_backtest(
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
)
weak_forecast = (
record.expected_percent < thresholds.edge
or record.probability_up < thresholds.probability
record.expected_percent < active_thresholds.edge
or record.probability_up < active_thresholds.probability
or record.skill <= 0.0
)
exit_reason = ""
@@ -633,7 +682,7 @@ def _full_backtest(
elif atr_stop:
exit_reason = "atr_trailing_stop"
exit_price = float(atr_stop_level)
elif (record.expected_percent <= 0.0 or record.probability_up <= 0.50 or _candidate_blocks(record, thresholds.edge)):
elif (record.expected_percent <= 0.0 or record.probability_up <= 0.50 or _candidate_blocks(record, active_thresholds.edge)):
exit_reason = "forecast_negative"
elif weak_forecast and net_percent >= 0:
exit_reason = "forecast_weak_profit_lock"
@@ -659,7 +708,7 @@ def _full_backtest(
if record.symbol in positions:
continue
if _candidate_allows(record, thresholds.edge, thresholds.probability, thresholds.confidence):
if _candidate_allows(record, active_thresholds.edge, active_thresholds.probability, active_thresholds.confidence):
positions[record.symbol] = {
"entry_price": record.next_open,
"entry_index": record.index + 1,
@@ -815,24 +864,49 @@ def _walk_forward(
test_end = timestamps[(fold + 1) * fold_size - 1] if fold < folds - 1 else timestamps[-1]
train = [record for record in ordered if record.timestamp < test_start]
test = [record for record in ordered if test_start <= record.timestamp <= test_end]
train_results = _calibrate(
train,
probability_calibration = _fit_platt_calibration(train)
calibrated_train = _apply_platt_calibration(train, probability_calibration)
calibrated_test = _apply_platt_calibration(test, probability_calibration)
train_results = _calibrate_strategy(
calibrated_train,
edges=edges,
probabilities=probabilities,
confidences=confidences,
min_trades=max(4, min_trades // 2),
horizon=horizon,
round_trip_cost=round_trip_cost,
settings=settings,
)
if not train_results:
continue
selected = _choose_recommendation(train_results, min_trades=max(4, min_trades // 2))
if selected is None:
continue
symbol_thresholds: dict[str, CalibrationResult] = {}
train_symbols = sorted({record.symbol for record in calibrated_train})
symbol_min_trades = max(3, min_trades // max(2, len(train_symbols) * 2))
for symbol in train_symbols:
symbol_results = _calibrate_strategy(
[record for record in calibrated_train if record.symbol == symbol],
edges=edges,
probabilities=probabilities,
confidences=confidences,
min_trades=symbol_min_trades,
horizon=horizon,
round_trip_cost=round_trip_cost,
settings=settings,
)
symbol_selected = _choose_recommendation(symbol_results, min_trades=symbol_min_trades) if symbol_results else None
if symbol_selected is not None:
symbol_thresholds[symbol] = symbol_selected
test_backtest = _full_backtest(
test,
calibrated_test,
selected,
horizon=horizon,
round_trip_cost=round_trip_cost,
settings=settings,
detail_limit=0,
symbol_thresholds=symbol_thresholds,
)
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)]
@@ -844,6 +918,10 @@ def _walk_forward(
"train_records": len(train),
"test_records": len(test),
"thresholds": _result_dict(selected),
"symbol_thresholds": {
symbol: _result_dict(value) for symbol, value in symbol_thresholds.items()
},
"probability_calibration": probability_calibration,
"test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
}
)
@@ -980,6 +1058,11 @@ def _candidate_blocks(record: ForecastRecord, edge: float) -> bool:
)
def _entry_validation_skill(entry: dict[str, Any]) -> float:
value = entry.get("validation_skill")
return float(value) if isinstance(value, (int, float)) and math.isfinite(float(value)) else 0.0
def _candidate_allows(record: ForecastRecord, edge: float, probability: float, confidence: float) -> bool:
dynamic_confidence = _forecast_confidence(record.expected_percent, record.probability_up, record.skill, edge)
return (
@@ -1146,6 +1229,101 @@ def _calibrate(
return results
def _calibrate_strategy(
records: list[ForecastRecord],
*,
edges: list[float],
probabilities: list[float],
confidences: list[float],
min_trades: int,
horizon: int,
round_trip_cost: float,
settings: Any,
) -> list[CalibrationResult]:
results: list[CalibrationResult] = []
for edge in edges:
for probability in probabilities:
for confidence in confidences:
thresholds = CalibrationResult(
edge=edge,
probability=probability,
confidence=confidence,
trades=0,
wins=0,
win_rate=0.0,
total_net_percent=0.0,
average_net_percent=0.0,
max_drawdown_percent=0.0,
profit_factor=0.0,
score=0.0,
)
replay = _full_backtest(
records,
thresholds,
horizon=horizon,
round_trip_cost=round_trip_cost,
settings=settings,
detail_limit=0,
)
trades = int(replay.get("trades", 0) or 0)
if trades <= 0:
continue
wins = int(replay.get("wins", 0) or 0)
total = float(replay.get("total_net_percent", 0.0) or 0.0)
average = float(replay.get("avg_net_percent", 0.0) or 0.0)
drawdown = float(replay.get("max_drawdown_percent", 0.0) or 0.0)
profit_factor = float(replay.get("profit_factor", 0.0) or 0.0)
trade_factor = min(1.0, trades / max(1, min_trades))
score = (
average * trade_factor
+ total * 0.015
- drawdown * 0.03
+ (wins / trades) * 0.04
)
results.append(
CalibrationResult(
edge=edge,
probability=probability,
confidence=confidence,
trades=trades,
wins=wins,
win_rate=wins / trades,
total_net_percent=total,
average_net_percent=average,
max_drawdown_percent=drawdown,
profit_factor=profit_factor,
score=score,
)
)
results.sort(
key=lambda item: (
item.score,
item.average_net_percent,
item.total_net_percent,
item.profit_factor,
item.trades,
),
reverse=True,
)
return results
def _result_from_dict(value: dict[str, Any]) -> CalibrationResult:
return CalibrationResult(
edge=float(value.get("edge", 0.1) or 0.1),
probability=float(value.get("probability", 0.7) or 0.7),
confidence=float(value.get("confidence", 0.4) or 0.4),
trades=int(value.get("trades", 0) or 0),
wins=int(value.get("wins", 0) or 0),
win_rate=float(value.get("win_rate", 0.0) or 0.0),
total_net_percent=float(value.get("total_net_percent", 0.0) or 0.0),
average_net_percent=float(value.get("average_net_percent", 0.0) or 0.0),
max_drawdown_percent=float(value.get("max_drawdown_percent", 0.0) or 0.0),
profit_factor=float(value.get("profit_factor", 0.0) or 0.0),
score=float(value.get("score", 0.0) or 0.0),
)
def _selected_trades(
records: list[ForecastRecord],
edge: float,
@@ -1164,7 +1342,7 @@ def _selected_trades(
return trades
def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int) -> CalibrationResult:
def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int) -> CalibrationResult | None:
viable = [
result
for result in results
@@ -1173,7 +1351,67 @@ def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int)
and result.total_net_percent > 0
and result.profit_factor >= 1.05
]
return viable[0] if viable else results[0]
return viable[0] if viable else None
def _empty_recommendation(
edges: list[float], probabilities: list[float], confidences: list[float]
) -> CalibrationResult:
return CalibrationResult(
edge=max(edges or [1.0]),
probability=max(probabilities or [0.95]),
confidence=max(confidences or [1.0]),
trades=0,
wins=0,
win_rate=0.0,
total_net_percent=0.0,
average_net_percent=0.0,
max_drawdown_percent=0.0,
profit_factor=0.0,
score=-1.0,
)
def _fit_platt_calibration(records: list[ForecastRecord]) -> dict[str, float]:
samples = [
(
math.log(_clamp(record.probability_up, 1e-5, 1.0 - 1e-5) / (1.0 - _clamp(record.probability_up, 1e-5, 1.0 - 1e-5))),
1.0 if record.future_net_percent > 0 else 0.0,
)
for record in records
]
if len(samples) < 30:
return {"slope": 1.0, "intercept": 0.0, "samples": float(len(samples))}
slope = 1.0
intercept = 0.0
learning_rate = 0.05
for _ in range(300):
grad_slope = 0.0
grad_intercept = 0.0
for logit, target in samples:
probability = 1.0 / (1.0 + math.exp(-_clamp(slope * logit + intercept, -30.0, 30.0)))
error = probability - target
grad_slope += error * logit
grad_intercept += error
grad_slope = grad_slope / len(samples) + 0.001 * (slope - 1.0)
grad_intercept /= len(samples)
slope -= learning_rate * grad_slope
intercept -= learning_rate * grad_intercept
return {"slope": round(slope, 8), "intercept": round(intercept, 8), "samples": float(len(samples))}
def _apply_platt_calibration(
records: list[ForecastRecord], calibration: dict[str, float]
) -> list[ForecastRecord]:
slope = float(calibration.get("slope", 1.0))
intercept = float(calibration.get("intercept", 0.0))
output: list[ForecastRecord] = []
for record in records:
probability = _clamp(record.probability_up, 1e-5, 1.0 - 1e-5)
logit = math.log(probability / (1.0 - probability))
calibrated = 1.0 / (1.0 + math.exp(-_clamp(slope * logit + intercept, -30.0, 30.0)))
output.append(replace(record, probability_up=calibrated))
return output
def _choose_replay_recommendation(
@@ -1185,8 +1423,10 @@ def _choose_replay_recommendation(
horizon: int,
round_trip_cost: float,
settings: Any,
) -> tuple[CalibrationResult, dict[str, Any]]:
) -> tuple[CalibrationResult | None, dict[str, Any]]:
fallback = _choose_recommendation(results, min_trades=min_trades)
if fallback is None:
return None, {**_stats([]), "trades_detail": [], "symbol_breakdown": []}
fallback_replay = _full_backtest(records, fallback, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings)
if min_full_replay_trades <= 0:
return fallback, fallback_replay
@@ -1207,7 +1447,7 @@ def _choose_replay_recommendation(
viable.append((result, replay))
if not viable:
return fallback, fallback_replay
return None, fallback_replay
viable.sort(
key=lambda item: (
item[0].score,