1630 lines
68 KiB
Python
1630 lines
68 KiB
Python
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.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}
|
|
|
|
|
|
@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:
|
|
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.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.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.")
|
|
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,
|
|
)
|
|
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
|
|
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
|
|
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()
|