from __future__ import annotations import argparse import json import math import sys import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) try: import torch from torch import nn from torch.utils.data import DataLoader, TensorDataset except ImportError as exc: # pragma: no cover - exercised on machines without training deps. raise SystemExit( "PyTorch is not installed. Install local training deps with: " "python -m pip install torch --index-url https://download.pytorch.org/whl/cpu" ) from exc from crypto_spot_bot.bybit import BybitClient from crypto_spot_bot.config import load_settings from crypto_spot_bot.indicators import add_indicators from crypto_spot_bot.models import Candle from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES, load_orderbook_feature_map from crypto_spot_bot.time_series import ( DEFAULT_TORCH_FEATURES, _barrier_outcome, _feature_matrix, _log_returns, ) RETURN_OUTPUT_LAYOUT = ("mean", "q10", "q50", "q90") EVENT_OUTPUT_NAME = "logit_tp_first" OUTPUT_LAYOUT = (*RETURN_OUTPUT_LAYOUT, EVENT_OUTPUT_NAME) TARGET_TRANSFORM = "barrier_net_return" QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90} _ORDERBOOK_FEATURES_BY_SYMBOL: dict[str, dict[int, dict[str, float]]] = {} _ORDERBOOK_MANIFEST: dict[str, dict[str, Any]] = {} @dataclass(slots=True) class PreparedData: train_x: torch.Tensor train_y: torch.Tensor train_up: torch.Tensor validation_x: torch.Tensor validation_y: torch.Tensor validation_up: torch.Tensor validation_targets: list[list[float]] validation_event_targets: list[list[float]] validation_volatility_scales: list[list[float]] holdout_x: torch.Tensor holdout_y: torch.Tensor holdout_up: torch.Tensor holdout_targets: list[list[float]] holdout_event_targets: list[list[float]] holdout_volatility_scales: list[list[float]] holdout_start_timestamp: int feature_names: list[str] feature_means: list[float] feature_scales: list[float] target_means: list[float] target_scales: list[float] target_horizons: list[int] decision_horizon: int decision_horizon_index: int train_samples: int validation_samples: int holdout_samples: int @dataclass(slots=True) class TrainingSample: window: list[list[float]] normalized_targets: list[float] raw_targets: list[float] event_targets: list[float] volatility_scales: list[float] timestamp: int class RecurrentReturnModel(nn.Module): def __init__( self, *, architecture: str, input_size: int, hidden_size: int, num_layers: int, dropout: float, output_size: int, attention_pooling: bool, context_norm: bool, multitask_head: bool = False, head_hidden_size: int = 0, ) -> None: super().__init__() recurrent_cls = nn.LSTM if architecture == "lstm" else nn.GRU self.rnn = recurrent_cls( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, dropout=dropout if num_layers > 1 else 0.0, batch_first=True, ) self.attention = nn.Linear(hidden_size, 1) if attention_pooling else None self.context_norm = nn.LayerNorm(hidden_size) if context_norm else nn.Identity() self.multitask_head = bool(multitask_head) self.output_size = output_size if self.multitask_head: if output_size % len(OUTPUT_LAYOUT) != 0: raise ValueError("multitask output size must align with OUTPUT_LAYOUT") self.horizon_count = output_size // len(OUTPUT_LAYOUT) projected_size = max(8, int(head_hidden_size or hidden_size)) self.head_hidden = nn.Linear(hidden_size, projected_size) self.head_activation = nn.GELU() self.head_dropout = nn.Dropout(dropout) self.return_head = nn.Linear( projected_size, self.horizon_count * len(RETURN_OUTPUT_LAYOUT), ) self.event_head = nn.Linear(projected_size, self.horizon_count) self.head = None else: self.horizon_count = 0 self.head_hidden = None self.head_activation = None self.head_dropout = None self.return_head = None self.event_head = None self.head = nn.Linear(hidden_size, output_size) def forward(self, values: torch.Tensor) -> torch.Tensor: output, _state = self.rnn(values) if self.attention is not None: scores = self.attention(output).squeeze(-1) weights = torch.softmax(scores, dim=1).unsqueeze(-1) context = (output * weights).sum(dim=1) else: context = output[:, -1, :] context = self.context_norm(context) if not self.multitask_head: assert self.head is not None return self.head(context) assert self.head_hidden is not None assert self.head_activation is not None assert self.head_dropout is not None assert self.return_head is not None assert self.event_head is not None shared = self.head_dropout(self.head_activation(self.head_hidden(context))) returns = self.return_head(shared).view( values.shape[0], self.horizon_count, len(RETURN_OUTPUT_LAYOUT) ) events = self.event_head(shared).view(values.shape[0], self.horizon_count, 1) return torch.cat((returns, events), dim=2).reshape(values.shape[0], self.output_size) def main() -> None: global _ORDERBOOK_FEATURES_BY_SYMBOL, _ORDERBOOK_MANIFEST args = _parse_args() if args.threads > 0: torch.set_num_threads(args.threads) _seed(args.seed) settings = load_settings(args.env) client = BybitClient(settings) symbols = _symbols(args.symbols, settings, client) interval = args.interval or settings.base_interval output = Path(args.output) if args.output else settings.time_series_lstm_model_path device = _device(args.device) decision_horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon) target_horizons = _horizons(args.horizons, decision_horizon) feature_names = _feature_names_arg(args.features) if args.orderbook_db: _ORDERBOOK_FEATURES_BY_SYMBOL, _ORDERBOOK_MANIFEST = load_orderbook_feature_map( args.orderbook_db, interval=interval, symbols=symbols, min_samples_per_bucket=args.orderbook_min_samples_per_bucket, ) eligible_symbols = [ symbol for symbol in symbols if int(_ORDERBOOK_MANIFEST.get(symbol, {}).get("covered_buckets", 0) or 0) >= args.orderbook_min_covered_buckets ] if len(eligible_symbols) < max(1, args.orderbook_min_symbols): coverage = ", ".join( f"{symbol}={int(_ORDERBOOK_MANIFEST.get(symbol, {}).get('covered_buckets', 0) or 0)}" for symbol in symbols ) raise SystemExit( "Orderbook coverage is below the training minimum: " f"need {args.orderbook_min_covered_buckets} buckets for " f"{args.orderbook_min_symbols} symbols; got {coverage or 'no data'}" ) symbols = eligible_symbols for feature_name in ORDERBOOK_FEATURES: if feature_name not in feature_names: feature_names.append(feature_name) if args.pooled: feature_names.extend(f"symbol_is_{symbol}" for symbol in symbols) ensemble_seeds = _ints(args.ensemble_seeds) or [args.seed] round_trip_cost = max(0.0, 2.0 * (float(settings.taker_fee_rate) + float(settings.slippage_rate))) stop_loss_percent = _clamp(float(settings.stop_loss_percent), 0.003, 0.08) take_profit_percent = _clamp(float(settings.take_profit_percent), 0.003, 0.20) _progress( f"training started: symbols={len(symbols)} interval={interval} " f"limit={args.limit} epochs={args.epochs}" ) artifact: dict[str, Any] = { "version": 6, "type": "pytorch_recurrent_forecaster", "created_at": datetime.now(timezone.utc).isoformat(), "trainer": Path(__file__).name, "interval": interval, "limit": args.limit, "validation_window": args.validation_window, "holdout_window": args.holdout_window, "target_horizon": decision_horizon, "target_horizons": target_horizons, "direct_horizon": True, "target_transform": TARGET_TRANSFORM, "target_return": "first_barrier_or_horizon_after_cost_log_return", "event_target": "take_profit_before_stop_loss", "target_stop_loss_percent": round(stop_loss_percent, 8), "target_take_profit_percent": round(take_profit_percent, 8), "barrier_tie_policy": "stop_loss_first", "round_trip_cost": round(round_trip_cost, 10), "output_layout": list(OUTPUT_LAYOUT), "quantiles": list(QUANTILES.values()), "feature_names": feature_names, "feature_count": len(feature_names), "device": str(device), "ensemble_seeds": ensemble_seeds, "selection_folds": args.selection_folds, "symbols": {}, } if args.orderbook_db: artifact["orderbook_features"] = { "source": "forward_collected_bybit_l1", "interval": interval, "min_samples_per_bucket": args.orderbook_min_samples_per_bucket, "min_covered_buckets": args.orderbook_min_covered_buckets, "features": list(ORDERBOOK_FEATURES), "coverage": {symbol: _ORDERBOOK_MANIFEST.get(symbol, {}) for symbol in symbols}, } if args.pooled: artifact["version"] = 7 artifact["pooled_multi_asset"] = True artifact["symbol_embedding"] = "learned_one_hot_projection" artifact["symbols"] = _train_pooled_symbols( client=client, symbols=symbols, interval=interval, limit=args.limit, validation_window=args.validation_window, holdout_window=args.holdout_window, target_horizons=target_horizons, decision_horizon=decision_horizon, feature_names=feature_names, round_trip_cost=round_trip_cost, stop_loss_percent=stop_loss_percent, take_profit_percent=take_profit_percent, context_symbols=_strings(args.context_symbols), architectures=_strings(args.architectures), lookbacks=_ints(args.lookbacks), hidden_sizes=_ints(args.hidden_sizes), layers_values=_ints(args.layers), dropouts=_floats(args.dropouts), epochs=args.epochs, patience=args.patience, batch_size=args.batch_size, learning_rate=args.learning_rate, weight_decay=args.weight_decay, clip=args.clip, attention_pooling=args.attention_pooling, context_norm=args.context_norm, device=device, seeds=ensemble_seeds, selection_folds=args.selection_folds, ) else: artifact["symbols"] = _train_independent_symbols( client=client, symbols=symbols, interval=interval, args=args, target_horizons=target_horizons, decision_horizon=decision_horizon, feature_names=feature_names, round_trip_cost=round_trip_cost, stop_loss_percent=stop_loss_percent, take_profit_percent=take_profit_percent, device=device, ensemble_seeds=ensemble_seeds, ) for symbol, result in artifact["symbols"].items(): _progress( f"{symbol}: model={result['model']} lookback={result['lookback']} " f"features={result['input_size']} hidden={result['hidden_size']} " f"layers={result['num_layers']} horizons={','.join(map(str, result['target_horizons']))} " f"mae={result['validation_mae_percent']:.5f}% " f"baseline={result['baseline_mae_percent']:.5f}% " f"skill={result['skill']:.4f} tp_precision={result.get('take_profit_first_precision', 0.0):.3f} " f"tp_brier={result['probability_brier']:.4f}" ) output.parent.mkdir(parents=True, exist_ok=True) tmp_output = output.with_name(f"{output.name}.tmp") tmp_output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") tmp_output.replace(output) _progress(f"saved {output}") def _train_independent_symbols( *, client: BybitClient, symbols: list[str], interval: str, args: argparse.Namespace, target_horizons: list[int], decision_horizon: int, feature_names: list[str], round_trip_cost: float, stop_loss_percent: float, take_profit_percent: float, device: torch.device, ensemble_seeds: list[int], ) -> dict[str, Any]: results: dict[str, Any] = {} total_symbols = len(symbols) for index, symbol in enumerate(symbols, start=1): _progress(f"{symbol}: training started ({index}/{total_symbols})") result = _train_symbol( client=client, symbol=symbol, interval=interval, limit=args.limit, validation_window=args.validation_window, holdout_window=args.holdout_window, target_horizons=target_horizons, decision_horizon=decision_horizon, feature_names=feature_names, round_trip_cost=round_trip_cost, stop_loss_percent=stop_loss_percent, take_profit_percent=take_profit_percent, context_symbols=_strings(args.context_symbols), architectures=_strings(args.architectures), lookbacks=_ints(args.lookbacks), hidden_sizes=_ints(args.hidden_sizes), layers_values=_ints(args.layers), dropouts=_floats(args.dropouts), epochs=args.epochs, patience=args.patience, batch_size=args.batch_size, learning_rate=args.learning_rate, weight_decay=args.weight_decay, clip=args.clip, attention_pooling=args.attention_pooling, context_norm=args.context_norm, device=device, seeds=ensemble_seeds, selection_folds=args.selection_folds, ) if result is None: _progress(f"{symbol}: skipped, not enough candles or train/validation samples") continue results[symbol] = result return results def _train_pooled_symbols( *, client: BybitClient, symbols: list[str], interval: str, limit: int, validation_window: int, holdout_window: int, target_horizons: list[int], decision_horizon: int, feature_names: list[str], round_trip_cost: float, stop_loss_percent: float, take_profit_percent: float, context_symbols: list[str], architectures: list[str], lookbacks: list[int], hidden_sizes: list[int], layers_values: list[int], dropouts: list[float], epochs: int, patience: int, batch_size: int, learning_rate: float, weight_decay: float, clip: float, attention_pooling: bool, context_norm: bool, device: torch.device, seeds: list[int], selection_folds: int, ) -> dict[str, Any]: market_candles: dict[str, list[Candle]] = {} for symbol in sorted({item.upper() for item in symbols + context_symbols}): rows = _historical_klines(client, symbol, interval, limit) add_indicators(rows) market_candles[symbol] = rows _progress(f"{symbol}: pooled data loaded ({len(rows)} candles)") trend_by_symbol: dict[str, list[Candle]] = {} for symbol in symbols: rows = _historical_klines(client, symbol, "D", min(max(260, limit // 24 + 260), 1000)) add_indicators(rows) trend_by_symbol[symbol] = rows best: dict[str, Any] | None = None best_prepared: dict[str, PreparedData] = {} for lookback in lookbacks: prepared_by_symbol: dict[str, PreparedData] = {} for symbol in symbols: prepared = _prepare_data( symbol=symbol, candles=market_candles[symbol], feature_names=feature_names, lookback=lookback, target_horizons=target_horizons, decision_horizon=decision_horizon, round_trip_cost=round_trip_cost, stop_loss_percent=stop_loss_percent, take_profit_percent=take_profit_percent, market_candles=market_candles, trend_candles=trend_by_symbol[symbol], validation_window=validation_window, holdout_window=holdout_window, clip=clip, device=device, ) if prepared is not None: prepared_by_symbol[symbol] = prepared if len(prepared_by_symbol) < 2: continue for architecture in architectures: if architecture not in {"lstm", "gru"}: continue for hidden_size in hidden_sizes: for num_layers in layers_values: for dropout in dropouts: if num_layers <= 1 and dropout != 0.0: continue _progress( f"pooled: fitting {architecture} lookback={lookback} hidden={hidden_size} " f"layers={num_layers} dropout={dropout} symbols={len(prepared_by_symbol)}" ) members = [ _fit_pooled_candidate( prepared_by_symbol=prepared_by_symbol, architecture=architecture, input_size=len(feature_names), output_size=len(target_horizons) * len(OUTPUT_LAYOUT), hidden_size=hidden_size, num_layers=num_layers, dropout=dropout, epochs=epochs, patience=patience, batch_size=batch_size, learning_rate=learning_rate, weight_decay=weight_decay, clip=clip, attention_pooling=attention_pooling, context_norm=context_norm, device=device, seed=member_seed, selection_folds=selection_folds, ) for member_seed in seeds ] candidate = _ensemble_candidate(members, seeds) candidate.update( model=f"torch_{architecture}", architecture=architecture, lookback=lookback, hidden_size=hidden_size, num_layers=num_layers, dropout=dropout if num_layers > 1 else 0.0, attention_pooling=attention_pooling, context_norm=context_norm, input_size=len(feature_names), output_size=len(target_horizons) * len(OUTPUT_LAYOUT), multitask_head=True, head_hidden_size=hidden_size, ) if best is None or _candidate_score(candidate) < _candidate_score(best): best = candidate best_prepared = prepared_by_symbol if best is None: return {} results: dict[str, Any] = {} symbol_metrics = best.get("symbol_metrics", {}) common = {key: value for key, value in best.items() if key != "symbol_metrics"} for symbol, prepared in best_prepared.items(): metrics = symbol_metrics.get(symbol, {}) if isinstance(symbol_metrics, dict) else {} baseline = sum(abs(row[prepared.decision_horizon_index]) for row in prepared.validation_targets) / len(prepared.validation_targets) validation_mae = float(metrics.get("validation_mae", baseline)) results[symbol] = { **common, **metrics, "pooled_multi_asset": True, "target_horizon": prepared.decision_horizon, "target_horizons": prepared.target_horizons, "direct_horizon": True, "target_transform": TARGET_TRANSFORM, "event_target": "take_profit_before_stop_loss", "target_stop_loss_percent": stop_loss_percent, "target_take_profit_percent": take_profit_percent, "round_trip_cost": round(round_trip_cost, 10), "output_layout": list(OUTPUT_LAYOUT), "feature_names": feature_names, "feature_means": prepared.feature_means, "feature_scales": prepared.feature_scales, "target_means": prepared.target_means, "target_scales": prepared.target_scales, "target_mean": prepared.target_means[prepared.decision_horizon_index], "target_scale": prepared.target_scales[prepared.decision_horizon_index], "clip": clip, "validation_mae_percent": validation_mae * 100, "baseline_mae_percent": baseline * 100, "validation_skill": (baseline - validation_mae) / baseline if baseline > 0 else 0.0, # Runtime and calibration may use validation skill. Untouched # holdout skill is report-only and must never gate individual entries. "skill": (baseline - validation_mae) / baseline if baseline > 0 else 0.0, "train_samples": prepared.train_samples, "validation_samples": prepared.validation_samples, "holdout_samples": prepared.holdout_samples, "holdout_start_timestamp": prepared.holdout_start_timestamp, } return results def _fit_pooled_candidate( *, prepared_by_symbol: dict[str, PreparedData], architecture: str, input_size: int, output_size: int, hidden_size: int, num_layers: int, dropout: float, epochs: int, patience: int, batch_size: int, learning_rate: float, weight_decay: float, clip: float, attention_pooling: bool, context_norm: bool, device: torch.device, seed: int, selection_folds: int, ) -> dict[str, Any]: _seed(seed) model = RecurrentReturnModel( architecture=architecture, input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, dropout=dropout, output_size=output_size, attention_pooling=attention_pooling, context_norm=context_norm, multitask_head=True, head_hidden_size=hidden_size, ).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay) loader = DataLoader( TensorDataset( torch.cat([row.train_x for row in prepared_by_symbol.values()]), torch.cat([row.train_y for row in prepared_by_symbol.values()]), torch.cat([row.train_up for row in prepared_by_symbol.values()]), ), batch_size=max(1, batch_size), shuffle=True, generator=torch.Generator(device="cpu").manual_seed(seed), ) best_state: dict[str, torch.Tensor] | None = None best_score = math.inf stale = 0 best_epoch = 0 for epoch in range(1, max(1, epochs) + 1): model.train() for batch_x, batch_y, batch_up in loader: optimizer.zero_grad(set_to_none=True) loss = _forecast_loss(model(batch_x), batch_y, batch_up, len(next(iter(prepared_by_symbol.values())).target_horizons)) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() symbol_rows = { symbol: _validation_metrics(model, prepared, clip) for symbol, prepared in prepared_by_symbol.items() } score = sum(_candidate_score(row) for row in symbol_rows.values()) / len(symbol_rows) if score + 1e-12 < best_score: best_score = score best_epoch = epoch best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()} stale = 0 else: stale += 1 if stale >= max(1, patience): break if best_state: model.load_state_dict(best_state) per_symbol: dict[str, dict[str, Any]] = {} for symbol, prepared in prepared_by_symbol.items(): metrics = _validation_metrics(model, prepared, clip) metrics.update(_validation_stability_metrics(model, prepared, clip, selection_folds)) metrics.update(_holdout_metrics(model, prepared, clip)) per_symbol[symbol] = metrics aggregate: dict[str, Any] = {"symbol_metrics": per_symbol} for name in ( "validation_mae", "directional_accuracy", "buy_precision", "probability_brier", "holdout_skill", "validation_fold_mae_std", "validation_trade_mean", "validation_trade_win_rate", "take_profit_first_accuracy", "take_profit_first_precision", "take_profit_first_brier", ): values = [float(row[name]) for row in per_symbol.values() if isinstance(row.get(name), (int, float))] aggregate[name] = sum(values) / len(values) if values else 0.0 aggregate.update( best_epoch=best_epoch, epochs_trained=best_epoch + stale, state_dict=_export_recurrent_state(model), **_export_head_state(model), **_export_context_state(model), ) return aggregate def _progress(message: str) -> None: print(message, flush=True) def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Train PyTorch LSTM/GRU forecast models on Bybit spot candles.") parser.add_argument("--env", default=None, help="Path to .env file.") parser.add_argument("--symbols", default="", help="Comma-separated symbols. Defaults to configured or popular pairs.") parser.add_argument("--interval", default="", help="Bybit kline interval. Defaults to BASE_INTERVAL.") parser.add_argument("--limit", type=int, default=1000, help="Kline limit per symbol.") parser.add_argument("--validation-window", type=int, default=120, help="Held-out tail targets used for validation.") parser.add_argument("--holdout-window", type=int, default=240, help="Final untouched samples reserved for model/threshold evaluation.") parser.add_argument("--horizon", type=int, default=0, help="Direct forecast horizon in candles. Defaults to TIME_SERIES_FORECAST_HORIZON.") parser.add_argument("--horizons", default="1,3,6,12", help="Comma-separated direct forecast horizons.") parser.add_argument("--features", default=",".join(DEFAULT_TORCH_FEATURES), help="Comma-separated feature names.") parser.add_argument("--context-symbols", default="BTCUSDT,ETHUSDT", help="Cross-asset context symbols.") parser.add_argument("--architectures", default="lstm,gru", help="Comma-separated recurrent types: lstm,gru.") parser.add_argument("--lookbacks", default="32,64", help="Comma-separated sequence lengths.") parser.add_argument("--hidden-sizes", default="32,64", help="Comma-separated hidden sizes.") parser.add_argument("--layers", default="2", help="Comma-separated recurrent layer counts.") parser.add_argument("--dropouts", default="0.15", help="Comma-separated dropout values; only used with layers > 1.") parser.add_argument("--epochs", type=int, default=60, help="Maximum epochs per hyperparameter candidate.") parser.add_argument("--patience", type=int, default=10, help="Early stopping patience in epochs.") parser.add_argument("--batch-size", type=int, default=64, help="Training batch size.") parser.add_argument("--learning-rate", type=float, default=0.001, help="AdamW learning rate.") parser.add_argument("--weight-decay", type=float, default=0.0001, help="AdamW weight decay.") parser.add_argument("--clip", type=float, default=8.0, help="Clamp normalized features, targets and predictions.") parser.add_argument("--attention-pooling", action=argparse.BooleanOptionalAction, default=True, help="Use exportable attention pooling over recurrent states.") parser.add_argument("--context-norm", action=argparse.BooleanOptionalAction, default=True, help="Use exportable LayerNorm before the forecast head.") parser.add_argument("--seed", type=int, default=7, help="Random seed.") parser.add_argument("--ensemble-seeds", default="7,19,43", help="Comma-separated seeds averaged at inference time.") parser.add_argument("--selection-folds", type=int, default=3, help="Validation slices used to penalize unstable candidates.") parser.add_argument("--pooled", action=argparse.BooleanOptionalAction, default=True, help="Train shared multi-asset recurrent weights with learned symbol one-hot projection.") parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.") parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or mps.") parser.add_argument("--output", default="", help="Output JSON path. Defaults to TIME_SERIES_LSTM_MODEL_PATH.") parser.add_argument("--orderbook-db", default="", help="SQLite cache containing forward-collected L1 observations.") parser.add_argument("--orderbook-min-samples-per-bucket", type=int, default=20) parser.add_argument("--orderbook-min-covered-buckets", type=int, default=240) parser.add_argument("--orderbook-min-symbols", type=int, default=2) return parser.parse_args() def _symbols(raw: str, settings: Any, client: BybitClient) -> list[str]: if raw.strip(): return [item.strip().upper() for item in raw.split(",") if item.strip()] if settings.symbols: return list(settings.symbols) return client.popular_spot_symbols(settings.top_symbols_count) def _train_symbol( *, client: BybitClient, symbol: str, interval: str, limit: int, validation_window: int, holdout_window: int, target_horizons: list[int], decision_horizon: int, feature_names: list[str], round_trip_cost: float, stop_loss_percent: float, take_profit_percent: float, context_symbols: list[str], architectures: list[str], lookbacks: list[int], hidden_sizes: list[int], layers_values: list[int], dropouts: list[float], epochs: int, patience: int, batch_size: int, learning_rate: float, weight_decay: float, clip: float, attention_pooling: bool, context_norm: bool, device: torch.device, seeds: list[int], selection_folds: int, ) -> dict[str, Any] | None: candles = _historical_klines(client, symbol, interval, limit) add_indicators(candles) closes = [float(candle.close) for candle in candles if candle.close > 0] returns = _log_returns(closes) max_horizon = max(target_horizons) if len(candles) < max( 240, validation_window + holdout_window + max(lookbacks) + max_horizon * 2 + 32, ): return None market_candles: dict[str, list[Candle]] = {symbol.upper(): candles} for context_symbol in context_symbols: context_symbol = context_symbol.upper() if context_symbol in market_candles: continue try: rows = _historical_klines(client, context_symbol, interval, limit) add_indicators(rows) market_candles[context_symbol] = rows except Exception as exc: _progress(f"{symbol}: context {context_symbol} skipped: {exc}") trend_candles = _historical_klines(client, symbol, "D", min(max(260, limit // 24 + 260), 1000)) add_indicators(trend_candles) best: dict[str, Any] | None = None for lookback in lookbacks: _progress(f"{symbol}: preparing lookback={lookback}") prepared = _prepare_data( symbol=symbol, candles=candles, feature_names=feature_names, lookback=lookback, target_horizons=target_horizons, decision_horizon=decision_horizon, round_trip_cost=round_trip_cost, stop_loss_percent=stop_loss_percent, take_profit_percent=take_profit_percent, market_candles=market_candles, trend_candles=trend_candles, validation_window=validation_window, holdout_window=holdout_window, clip=clip, device=device, ) if prepared is None: continue baseline_mae = ( sum(abs(value[prepared.decision_horizon_index]) for value in prepared.validation_targets) / len(prepared.validation_targets) ) for architecture in architectures: if architecture not in {"lstm", "gru"}: continue for hidden_size in hidden_sizes: for num_layers in layers_values: for dropout in dropouts: if num_layers <= 1 and dropout != 0.0: continue _progress( f"{symbol}: fitting {architecture} " f"lookback={lookback} hidden={hidden_size} " f"layers={num_layers} dropout={dropout}" ) members = [ _fit_candidate( prepared=prepared, architecture=architecture, input_size=len(feature_names), output_size=len(target_horizons) * len(OUTPUT_LAYOUT), hidden_size=hidden_size, num_layers=num_layers, dropout=dropout, epochs=epochs, patience=patience, batch_size=batch_size, learning_rate=learning_rate, weight_decay=weight_decay, clip=clip, attention_pooling=attention_pooling, context_norm=context_norm, multitask_head=True, head_hidden_size=hidden_size, device=device, seed=member_seed, selection_folds=selection_folds, ) for member_seed in seeds ] candidate = _ensemble_candidate(members, seeds) validation_mae = float(candidate["validation_mae"]) skill = (baseline_mae - validation_mae) / baseline_mae if baseline_mae > 0 else 0.0 row = { **candidate, "model": f"torch_{architecture}", "architecture": architecture, "lookback": lookback, "target_horizon": prepared.decision_horizon, "target_horizons": prepared.target_horizons, "direct_horizon": True, "target_transform": TARGET_TRANSFORM, "target_return": "first_barrier_or_horizon_after_cost_log_return", "event_target": "take_profit_before_stop_loss", "target_stop_loss_percent": stop_loss_percent, "target_take_profit_percent": take_profit_percent, "barrier_tie_policy": "stop_loss_first", "round_trip_cost": round(round_trip_cost, 10), "output_layout": list(OUTPUT_LAYOUT), "quantiles": list(QUANTILES.values()), "input_size": len(feature_names), "output_size": len(target_horizons) * len(OUTPUT_LAYOUT), "feature_names": feature_names, "feature_means": prepared.feature_means, "feature_scales": prepared.feature_scales, "target_means": prepared.target_means, "target_scales": prepared.target_scales, "target_mean": prepared.target_means[prepared.decision_horizon_index], "target_scale": prepared.target_scales[prepared.decision_horizon_index], "mean": prepared.target_means[prepared.decision_horizon_index], "scale": prepared.target_scales[prepared.decision_horizon_index], "hidden_size": hidden_size, "num_layers": num_layers, "dropout": dropout if num_layers > 1 else 0.0, "attention_pooling": attention_pooling, "context_norm": context_norm, "multitask_head": True, "head_hidden_size": hidden_size, "clip": clip, "validation_mae_percent": validation_mae * 100, "baseline_mae_percent": baseline_mae * 100, "holdout_mae_percent": float(candidate.get("holdout_mae", 0.0)) * 100, "holdout_baseline_mae_percent": float(candidate.get("holdout_baseline_mae", 0.0)) * 100, "skill": skill, "candles": len(candles), "returns": len(returns), "train_samples": prepared.train_samples, "validation_samples": prepared.validation_samples, "holdout_samples": prepared.holdout_samples, "holdout_start_timestamp": prepared.holdout_start_timestamp, } score = _candidate_score(row) if best is None or score < _candidate_score(best): best = row if best is None: return None best["validation_skill"] = best.get("skill", 0.0) best["skill"] = best["validation_skill"] best.pop("validation_mae", None) return best def _prepare_data( *, symbol: str, candles: list[Candle], feature_names: list[str], lookback: int, target_horizons: list[int], decision_horizon: int, round_trip_cost: float, stop_loss_percent: float, take_profit_percent: float, market_candles: dict[str, list[Candle]], trend_candles: list[Candle], validation_window: int, holdout_window: int, clip: float, device: torch.device, ) -> PreparedData | None: closes = [float(candle.close) for candle in candles] feature_rows = _feature_matrix( candles, feature_names, symbol=symbol, market_candles=market_candles, trend_candles=trend_candles, orderbook_features=_ORDERBOOK_FEATURES_BY_SYMBOL, ) max_horizon = max(target_horizons) samples: list[TrainingSample] = [] for end_index in range(lookback - 1, len(candles) - max_horizon): current = closes[end_index] if current <= 0: continue window = feature_rows[end_index - lookback + 1 : end_index + 1] if len(window) != lookback: continue if any(name in ORDERBOOK_FEATURES for name in feature_names): symbol_orderbook = _ORDERBOOK_FEATURES_BY_SYMBOL.get(symbol.upper(), {}) window_candles = candles[end_index - lookback + 1 : end_index + 1] if any(row.timestamp not in symbol_orderbook for row in window_candles): continue raw_targets: list[float] = [] event_targets: list[float] = [] volatility_scales: list[float] = [] normalized_targets: list[float] = [] valid = True for horizon in target_horizons: outcome = _barrier_outcome( candles, end_index=end_index, horizon=horizon, stop_loss_percent=stop_loss_percent, take_profit_percent=take_profit_percent, round_trip_cost=round_trip_cost, ) if outcome is None: valid = False break net_return, take_profit_first = outcome volatility_scale = 1.0 raw_targets.append(net_return) event_targets.append(take_profit_first) volatility_scales.append(volatility_scale) normalized_targets.append(net_return) if valid: samples.append( TrainingSample( window, normalized_targets, raw_targets, event_targets, volatility_scales, candles[end_index].timestamp, ) ) if len(samples) < 48: return None max_horizon = max(target_horizons) holdout_window = min(max(32, holdout_window), max(32, len(samples) // 4)) holdout_start = len(samples) - holdout_window validation_end = holdout_start - max_horizon validation_window = min(max(16, validation_window), max(16, validation_end // 3)) validation_start = validation_end - validation_window train_end = validation_start - max_horizon if validation_start < 0 or train_end <= 0: return None train_samples = samples[:train_end] validation_samples = samples[validation_start:validation_end] holdout_samples = samples[holdout_start:] if len(train_samples) < 24 or len(validation_samples) < 8 or len(holdout_samples) < 16: return None feature_means, feature_scales = _feature_stats(train_samples, feature_names) target_means, target_scales = _target_stats(train_samples, len(target_horizons)) decision_horizon = decision_horizon if decision_horizon in target_horizons else min( target_horizons, key=lambda value: abs(value - decision_horizon), ) decision_horizon_index = target_horizons.index(decision_horizon) train_x, train_y, train_up = _normalize_samples( train_samples, feature_means=feature_means, feature_scales=feature_scales, target_means=target_means, target_scales=target_scales, clip=clip, ) validation_x, validation_y, validation_up = _normalize_samples( validation_samples, feature_means=feature_means, feature_scales=feature_scales, target_means=target_means, target_scales=target_scales, clip=clip, ) holdout_x, holdout_y, holdout_up = _normalize_samples( holdout_samples, feature_means=feature_means, feature_scales=feature_scales, target_means=target_means, target_scales=target_scales, clip=clip, ) return PreparedData( train_x=torch.tensor(train_x, dtype=torch.float32, device=device), train_y=torch.tensor(train_y, dtype=torch.float32, device=device), train_up=torch.tensor(train_up, dtype=torch.float32, device=device), validation_x=torch.tensor(validation_x, dtype=torch.float32, device=device), validation_y=torch.tensor(validation_y, dtype=torch.float32, device=device), validation_up=torch.tensor(validation_up, dtype=torch.float32, device=device), validation_targets=[sample.raw_targets for sample in validation_samples], validation_event_targets=[sample.event_targets for sample in validation_samples], validation_volatility_scales=[sample.volatility_scales for sample in validation_samples], holdout_x=torch.tensor(holdout_x, dtype=torch.float32, device=device), holdout_y=torch.tensor(holdout_y, dtype=torch.float32, device=device), holdout_up=torch.tensor(holdout_up, dtype=torch.float32, device=device), holdout_targets=[sample.raw_targets for sample in holdout_samples], holdout_event_targets=[sample.event_targets for sample in holdout_samples], holdout_volatility_scales=[sample.volatility_scales for sample in holdout_samples], holdout_start_timestamp=holdout_samples[0].timestamp, feature_names=feature_names, feature_means=feature_means, feature_scales=feature_scales, target_means=target_means, target_scales=target_scales, target_horizons=target_horizons, decision_horizon=decision_horizon, decision_horizon_index=decision_horizon_index, train_samples=len(train_x), validation_samples=len(validation_x), holdout_samples=len(holdout_x), ) def _feature_stats(samples: list[TrainingSample], feature_names: list[str]) -> tuple[list[float], list[float]]: input_size = len(feature_names) columns = [[] for _ in range(input_size)] for sample in samples: window = sample.window for row in window: for index in range(input_size): columns[index].append(float(row[index] if index < len(row) else 0.0)) means: list[float] = [] scales: list[float] = [] for index, values in enumerate(columns): if feature_names[index].startswith("symbol_is_"): means.append(0.0) scales.append(1.0) continue if not values: means.append(0.0) scales.append(1.0) continue mean = sum(values) / len(values) deviations = sorted(abs(value - mean) for value in values) mad = deviations[len(deviations) // 2] if deviations else 0.0 mean_abs = sum(deviations) / len(deviations) if deviations else 0.0 means.append(mean) scales.append(max(mad, mean_abs * 0.5, 1e-6)) return means, scales def _target_stats(samples: list[TrainingSample], output_size: int) -> tuple[list[float], list[float]]: means: list[float] = [] scales: list[float] = [] for index in range(output_size): values = [sample.normalized_targets[index] for sample in samples] mean = sum(values) / len(values) if values else 0.0 means.append(mean) scales.append(_return_scale([value - mean for value in values])) return means, scales def _normalize_samples( samples: list[TrainingSample], *, feature_means: list[float], feature_scales: list[float], target_means: list[float], target_scales: list[float], clip: float, ) -> tuple[list[list[list[float]]], list[list[float]], list[list[float]]]: input_size = len(feature_means) x_values: list[list[list[float]]] = [] y_values: list[list[float]] = [] up_values: list[list[float]] = [] for sample in samples: window = sample.window x_values.append( [ [ _clamp( ((row[index] if index < len(row) else 0.0) - feature_means[index]) / max(feature_scales[index], 1e-8), -clip, clip, ) for index in range(input_size) ] for row in window ] ) y_values.append( [ _clamp( (target - target_means[index]) / max(target_scales[index], 1e-8), -clip, clip, ) for index, target in enumerate(sample.normalized_targets) ] ) up_values.append(list(sample.event_targets)) return x_values, y_values, up_values def _fit_candidate( *, prepared: PreparedData, architecture: str, input_size: int, output_size: int, hidden_size: int, num_layers: int, dropout: float, epochs: int, patience: int, batch_size: int, learning_rate: float, weight_decay: float, clip: float, attention_pooling: bool, context_norm: bool, multitask_head: bool, head_hidden_size: int, device: torch.device, seed: int, selection_folds: int, ) -> dict[str, Any]: _seed(seed) model = RecurrentReturnModel( architecture=architecture, input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, dropout=dropout, output_size=output_size, attention_pooling=attention_pooling, context_norm=context_norm, multitask_head=multitask_head, head_hidden_size=head_hidden_size, ).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay) generator = torch.Generator(device="cpu").manual_seed(seed) loader = DataLoader( TensorDataset(prepared.train_x, prepared.train_y, prepared.train_up), batch_size=max(1, batch_size), shuffle=True, generator=generator, ) best_state: dict[str, torch.Tensor] | None = None best_metrics: dict[str, float] = { "validation_mae": math.inf, "directional_accuracy": 0.0, "buy_precision": 0.0, "probability_brier": 1.0, "validation_trade_mean": -math.inf, } best_score = math.inf best_epoch = 0 stale_epochs = 0 for epoch in range(1, max(1, epochs) + 1): model.train() for batch_x, batch_y, batch_up in loader: optimizer.zero_grad(set_to_none=True) loss = _forecast_loss(model(batch_x), batch_y, batch_up, len(prepared.target_horizons)) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() metrics = _validation_metrics(model, prepared, clip) metrics.update(_validation_stability_metrics(model, prepared, clip, selection_folds)) score = _candidate_score(metrics) if score + 1e-12 < best_score: best_metrics = metrics best_score = score best_epoch = epoch best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()} stale_epochs = 0 else: stale_epochs += 1 if stale_epochs >= max(1, patience): break if best_state: model.load_state_dict(best_state) holdout_metrics = _holdout_metrics(model, prepared, clip) return { **best_metrics, **holdout_metrics, "best_epoch": best_epoch, "epochs_trained": best_epoch + stale_epochs, "state_dict": _export_recurrent_state(model), **_export_head_state(model), **_export_context_state(model), } def _ensemble_candidate(members: list[dict[str, Any]], seeds: list[int]) -> dict[str, Any]: if not members: raise ValueError("ensemble requires at least one member") result = dict(members[0]) metric_names = ( "validation_mae", "directional_accuracy", "buy_precision", "probability_brier", "holdout_mae", "holdout_baseline_mae", "holdout_skill", "holdout_directional_accuracy", "holdout_buy_precision", "holdout_probability_brier", "validation_fold_mae_std", "validation_fold_mae_worst", "validation_trade_mean", "validation_trade_win_rate", "take_profit_first_accuracy", "take_profit_first_precision", "take_profit_first_brier", "holdout_take_profit_first_accuracy", "holdout_take_profit_first_precision", "holdout_take_profit_first_brier", ) for name in metric_names: values = [float(member[name]) for member in members if isinstance(member.get(name), (int, float))] if values: result[name] = sum(values) / len(values) export_names = ( "state_dict", "head_weight", "head_bias", "head_hidden_weight", "head_hidden_bias", "return_head_weight", "return_head_bias", "event_head_weight", "event_head_bias", "attention_weight", "attention_bias", "context_norm_weight", "context_norm_bias", ) result["ensemble_size"] = len(members) result["ensemble_seeds"] = [seeds[index] if index < len(seeds) else index for index in range(len(members))] if len(members) > 1: result["ensemble_members"] = [ {name: member[name] for name in export_names if name in member} | {"seed": seeds[index] if index < len(seeds) else index} for index, member in enumerate(members) ] # Ensemble inference uses the member payloads. Keeping the first # member at the top level duplicated a complete network in every # exported symbol and could push an otherwise valid artifact over # the server upload limit. for name in export_names: result.pop(name, None) else: result.pop("ensemble_members", None) symbol_names = sorted( { symbol for member in members for symbol in (member.get("symbol_metrics") or {}) } ) if symbol_names: result["symbol_metrics"] = {} for symbol in symbol_names: rows = [member.get("symbol_metrics", {}).get(symbol, {}) for member in members] keys = {key for row in rows if isinstance(row, dict) for key in row} averaged: dict[str, float] = {} for key in keys: values = [float(row[key]) for row in rows if isinstance(row.get(key), (int, float))] if values: averaged[key] = sum(values) / len(values) result["symbol_metrics"][symbol] = averaged return result def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, float]: return _evaluation_metrics( model, values=prepared.validation_x, targets=prepared.validation_targets, event_targets=prepared.validation_event_targets, volatility_scales=prepared.validation_volatility_scales, prepared=prepared, clip=clip, ) def _validation_stability_metrics( model: nn.Module, prepared: PreparedData, clip: float, folds: int, ) -> dict[str, float]: fold_count = max(1, min(int(folds), len(prepared.validation_targets))) fold_size = max(1, len(prepared.validation_targets) // fold_count) maes: list[float] = [] for fold in range(fold_count): start = fold * fold_size end = len(prepared.validation_targets) if fold == fold_count - 1 else min(len(prepared.validation_targets), start + fold_size) if end <= start: continue metrics = _evaluation_metrics( model, values=prepared.validation_x[start:end], targets=prepared.validation_targets[start:end], event_targets=prepared.validation_event_targets[start:end], volatility_scales=prepared.validation_volatility_scales[start:end], prepared=prepared, clip=clip, ) maes.append(float(metrics["validation_mae"])) if not maes: return {"validation_fold_mae_std": 0.0, "validation_fold_mae_worst": math.inf} mean = sum(maes) / len(maes) variance = sum((value - mean) ** 2 for value in maes) / len(maes) return { "validation_fold_mae_std": math.sqrt(variance), "validation_fold_mae_worst": max(maes), } def _holdout_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, Any]: metrics = _evaluation_metrics( model, values=prepared.holdout_x, targets=prepared.holdout_targets, event_targets=prepared.holdout_event_targets, volatility_scales=prepared.holdout_volatility_scales, prepared=prepared, clip=clip, ) baseline_by_horizon = metrics.get("baseline_mae_by_horizon", {}) holdout_baseline = float( baseline_by_horizon.get(str(prepared.decision_horizon), metrics["validation_mae"]) ) holdout_mae = float(metrics["validation_mae"]) return { "holdout_mae": holdout_mae, "holdout_baseline_mae": holdout_baseline, "holdout_skill": ( (holdout_baseline - holdout_mae) / holdout_baseline if holdout_baseline > 0 else 0.0 ), "holdout_directional_accuracy": metrics["directional_accuracy"], "holdout_buy_precision": metrics["buy_precision"], "holdout_probability_brier": metrics["probability_brier"], "holdout_take_profit_first_accuracy": metrics["take_profit_first_accuracy"], "holdout_take_profit_first_precision": metrics["take_profit_first_precision"], "holdout_take_profit_first_brier": metrics["take_profit_first_brier"], } def _evaluation_metrics( model: nn.Module, *, values: torch.Tensor, targets: list[list[float]], event_targets: list[list[float]], volatility_scales: list[list[float]], prepared: PreparedData, clip: float, ) -> dict[str, float]: model.eval() with torch.no_grad(): raw_outputs = model(values).detach().cpu() outputs = raw_outputs.view(len(targets), len(prepared.target_horizons), len(OUTPUT_LAYOUT)) mean_predictions = outputs[:, :, 0].tolist() logit_predictions = outputs[:, :, 4].tolist() predictions: list[list[float]] = [] probabilities: list[list[float]] = [] for row_index, row in enumerate(mean_predictions): predicted_row: list[float] = [] probability_row: list[float] = [] for horizon_index, normalized_prediction in enumerate(row): transformed = ( _clamp(float(normalized_prediction), -clip, clip) * prepared.target_scales[horizon_index] + prepared.target_means[horizon_index] ) predicted_row.append(transformed * volatility_scales[row_index][horizon_index]) probability_row.append(_sigmoid(float(logit_predictions[row_index][horizon_index]))) predictions.append(predicted_row) probabilities.append(probability_row) decision = prepared.decision_horizon_index decision_predictions = [row[decision] for row in predictions] decision_targets = [row[decision] for row in targets] errors = [abs(prediction - actual) for prediction, actual in zip(decision_predictions, decision_targets)] correct = [ 1.0 for prediction, actual in zip(decision_predictions, decision_targets) if (prediction > 0 and actual > 0) or (prediction < 0 and actual < 0) ] non_zero = [ 1.0 for prediction, actual in zip(decision_predictions, decision_targets) if prediction != 0 and actual != 0 ] decision_events = [row[decision] for row in event_targets] buy_predictions = [ event for prediction, event in zip(decision_predictions, decision_events) if prediction > 0 ] buy_wins = [event for event in buy_predictions if event >= 0.5] ranked = sorted( zip(decision_predictions, [row[decision] for row in probabilities], decision_targets), key=lambda item: item[0] * max(0.0, item[1] - 0.5), reverse=True, ) selected = ranked[: max(8, len(ranked) // 5)] selected_targets = [row[2] for row in selected] by_horizon = {} baseline_by_horizon = {} for horizon_index, horizon in enumerate(prepared.target_horizons): horizon_errors = [ abs(row[horizon_index] - actual[horizon_index]) for row, actual in zip(predictions, targets) ] horizon_baseline = [abs(actual[horizon_index]) for actual in targets] by_horizon[str(horizon)] = sum(horizon_errors) / len(horizon_errors) if horizon_errors else math.inf baseline_by_horizon[str(horizon)] = ( sum(horizon_baseline) / len(horizon_baseline) if horizon_baseline else math.inf ) probability_errors = [ (probabilities[row_index][decision] - decision_events[row_index]) ** 2 for row_index in range(len(decision_events)) ] event_predictions = [1.0 if row[decision] >= 0.5 else 0.0 for row in probabilities] event_correct = sum( 1 for prediction, actual in zip(event_predictions, decision_events) if prediction == actual ) event_precision_denominator = sum(1 for value in event_predictions if value >= 0.5) event_true_positives = sum( 1 for prediction, actual in zip(event_predictions, decision_events) if prediction >= 0.5 and actual >= 0.5 ) event_accuracy = event_correct / len(decision_events) if decision_events else 0.0 event_precision = ( event_true_positives / event_precision_denominator if event_precision_denominator else 0.0 ) event_brier = sum(probability_errors) / len(probability_errors) if probability_errors else 1.0 return { "validation_mae": sum(errors) / len(errors) if errors else math.inf, "validation_mae_by_horizon": by_horizon, "baseline_mae_by_horizon": baseline_by_horizon, "directional_accuracy": len(correct) / len(non_zero) if non_zero else 0.0, "buy_precision": len(buy_wins) / len(buy_predictions) if buy_predictions else 0.0, "probability_brier": event_brier, "take_profit_first_accuracy": event_accuracy, "take_profit_first_precision": event_precision, "take_profit_first_brier": event_brier, "validation_trade_mean": sum(selected_targets) / len(selected_targets) if selected_targets else 0.0, "validation_trade_win_rate": ( sum(1 for value in selected_targets if value > 0) / len(selected_targets) if selected_targets else 0.0 ), } def _candidate_score(row: dict[str, Any]) -> float: mae = float(row["validation_mae"]) skill = float(row.get("skill", 0.0)) directional = float(row.get("directional_accuracy", 0.0)) buy_precision = float(row.get("buy_precision", 0.0)) probability_brier = float(row.get("probability_brier", 1.0)) fold_std = max(0.0, float(row.get("validation_fold_mae_std", 0.0))) stability_penalty = 1.0 + min(1.0, fold_std / max(mae, 1e-9)) * 0.25 trade_mean = float(row.get("validation_trade_mean", 0.0)) event_precision = float(row.get("take_profit_first_precision", buy_precision)) trade_penalty = max(0.0, -trade_mean) * 4.0 - max(0.0, trade_mean) * 0.75 probability_penalty = mae * max(0.0, probability_brier - 0.20) * 0.5 event_bonus = mae * max(0.0, event_precision - 0.5) * 0.10 return ( mae * stability_penalty * (1.0 - max(0.0, skill) * 0.05) * (1.0 - max(0.0, directional - 0.5) * 0.03) + probability_penalty + trade_penalty - event_bonus ) def _forecast_loss(outputs: torch.Tensor, targets: torch.Tensor, up_targets: torch.Tensor, horizon_count: int) -> torch.Tensor: values = outputs.view(outputs.shape[0], horizon_count, len(OUTPUT_LAYOUT)) mean_loss = nn.functional.smooth_l1_loss(values[:, :, 0], targets, beta=0.5) quantile_losses = [] for offset, name in enumerate(("q10", "q50", "q90"), start=1): quantile = QUANTILES[name] errors = targets - values[:, :, offset] quantile_losses.append(torch.maximum((quantile - 1.0) * errors, quantile * errors).mean()) logits = values[:, :, 4] positive_rate = up_targets.mean().detach().clamp(0.05, 0.95) positive_weight = ((1.0 - positive_rate) / positive_rate).clamp(0.5, 5.0) bce = nn.functional.binary_cross_entropy_with_logits( logits, up_targets, reduction="none", pos_weight=positive_weight, ) probabilities = torch.sigmoid(logits) pt = probabilities * up_targets + (1.0 - probabilities) * (1.0 - up_targets) focal = ((1.0 - pt) ** 2.0 * bce).mean() soft_long = torch.sigmoid(values[:, :, 0] * 2.0) * probabilities after_cost_utility = -(soft_long * targets).mean() prediction_centered = values[:, :, 0] - values[:, :, 0].mean(dim=0, keepdim=True) target_centered = targets - targets.mean(dim=0, keepdim=True) cosine = nn.functional.cosine_similarity(prediction_centered, target_centered, dim=0).mean() ranking_loss = 1.0 - cosine return ( mean_loss + 0.35 * sum(quantile_losses) / len(quantile_losses) + 0.35 * focal + 0.20 * after_cost_utility + 0.05 * ranking_loss ) def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]: return { key: _round_nested(value.detach().cpu().tolist()) for key, value in model.rnn.state_dict().items() } def _export_head_state(model: RecurrentReturnModel) -> dict[str, Any]: if not model.multitask_head: assert model.head is not None return { "head_weight": _round_nested(model.head.weight.detach().cpu().tolist()), "head_bias": _round_list(model.head.bias.detach().cpu().tolist()), } assert model.head_hidden is not None assert model.return_head is not None assert model.event_head is not None return { "head_hidden_weight": _round_nested(model.head_hidden.weight.detach().cpu().tolist()), "head_hidden_bias": _round_list(model.head_hidden.bias.detach().cpu().tolist()), "return_head_weight": _round_nested(model.return_head.weight.detach().cpu().tolist()), "return_head_bias": _round_list(model.return_head.bias.detach().cpu().tolist()), "event_head_weight": _round_nested(model.event_head.weight.detach().cpu().tolist()), "event_head_bias": _round_list(model.event_head.bias.detach().cpu().tolist()), } def _export_context_state(model: RecurrentReturnModel) -> dict[str, Any]: exported: dict[str, Any] = {} if model.attention is not None: exported["attention_pooling"] = True exported["attention_weight"] = _round_list(model.attention.weight.detach().cpu().squeeze(0).tolist()) exported["attention_bias"] = round(float(model.attention.bias.detach().cpu().item()), 10) else: exported["attention_pooling"] = False if isinstance(model.context_norm, nn.LayerNorm): exported["context_norm"] = True exported["context_norm_weight"] = _round_list(model.context_norm.weight.detach().cpu().tolist()) exported["context_norm_bias"] = _round_list(model.context_norm.bias.detach().cpu().tolist()) else: exported["context_norm"] = False return exported def _device(raw: str) -> torch.device: value = raw.strip().lower() if value == "auto": if torch.cuda.is_available(): return torch.device("cuda") if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available(): return torch.device("mps") return torch.device("cpu") return torch.device(value) def _seed(seed: int) -> None: torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def _return_scale(returns: list[float]) -> float: values = sorted(abs(value) for value in returns if math.isfinite(value)) if not values: return 0.0005 median = values[len(values) // 2] mean = sum(values) / len(values) return max(max(median, mean * 0.5), 1e-5) def _target_volatility_scale(candles: list[Candle], closes: list[float], end_index: int, horizon: int) -> float: horizon = max(1, horizon) close = max(closes[end_index], 1e-12) candle = candles[end_index] atr_scale = (candle.atr_14 / close) * math.sqrt(horizon) if candle.atr_14 is not None else 0.0 start = max(1, end_index - 96) returns = [ math.log(closes[index] / closes[index - 1]) for index in range(start, end_index + 1) if closes[index] > 0 and closes[index - 1] > 0 ] realized = math.sqrt(sum(value * value for value in returns) / len(returns)) * math.sqrt(horizon) if returns else 0.0 return max(atr_scale * 0.7, realized, 0.0005) def _historical_klines(client: BybitClient, symbol: str, interval: str, limit: int) -> list[Candle]: limit = max(1, limit) rows_by_timestamp: dict[int, Candle] = {} end: int | None = None while len(rows_by_timestamp) < limit: page_limit = min(1000, limit - len(rows_by_timestamp)) params: dict[str, Any] = { "category": "spot", "symbol": symbol, "interval": interval, "limit": page_limit, } if end is not None: params["end"] = end result = client.public_get("/v5/market/kline", params) page = _parse_kline_rows(result.get("list", [])) if not page: break for candle in page: rows_by_timestamp[candle.timestamp] = candle oldest = min(candle.timestamp for candle in page) if end is not None and oldest >= end: break end = oldest - 1 if len(page) < page_limit: break time.sleep(0.05) return sorted(rows_by_timestamp.values(), key=lambda item: item.timestamp)[-limit:] def _parse_kline_rows(rows: Any) -> list[Candle]: candles: list[Candle] = [] for row in rows or []: if len(row) < 7: continue candles.append( Candle( timestamp=int(row[0]), open=_float(row[1]), high=_float(row[2]), low=_float(row[3]), close=_float(row[4]), volume=_float(row[5]), turnover=_float(row[6]), ) ) candles.sort(key=lambda item: item.timestamp) return candles def _float(value: Any, default: float = 0.0) -> float: try: return float(value) except (TypeError, ValueError): return default def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) def _sigmoid(value: float) -> float: if value >= 40: return 1.0 if value <= -40: return 0.0 return 1 / (1 + math.exp(-value)) def _round_nested(value: Any) -> Any: if isinstance(value, list): return [_round_nested(item) for item in value] return round(float(value), 10) def _round_list(values: list[float]) -> list[float]: return [round(float(value), 10) for value in values] def _ints(raw: str) -> list[int]: return [int(item.strip()) for item in raw.split(",") if item.strip()] def _floats(raw: str) -> list[float]: return [float(item.strip()) for item in raw.split(",") if item.strip()] def _strings(raw: str) -> list[str]: return [item.strip().lower() for item in raw.split(",") if item.strip()] def _horizons(raw: str, decision_horizon: int) -> list[int]: values = [] for value in _ints(raw or ""): if 1 <= value <= 96 and value not in values: values.append(value) decision_horizon = max(1, min(96, int(decision_horizon))) if decision_horizon not in values: values.append(decision_horizon) values.sort() return values def _feature_names_arg(raw: str) -> list[str]: names = [item.strip() for item in raw.split(",") if item.strip()] return names or list(DEFAULT_TORCH_FEATURES) if __name__ == "__main__": main()