Add probabilistic multi-horizon Torch forecaster

This commit is contained in:
Codex
2026-06-22 22:02:38 +03:00
parent 8ae6d4e3a5
commit a548c0e890
8 changed files with 1114 additions and 91 deletions
+9 -1
View File
@@ -3,9 +3,11 @@ param(
[string]$TaskName = "TradeBot PyTorch Forecaster Retrainer",
[int]$EveryHours = 6,
[string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT",
[int]$Limit = 1000,
[int]$Limit = 3000,
[int]$Horizon = 0,
[string]$Horizons = "",
[string]$Features = "",
[string]$ContextSymbols = "",
[int]$FirstRunMinutes = 0
)
@@ -35,9 +37,15 @@ if ($Limit -gt 0) {
if ($Horizon -gt 0) {
$actionArgs += " -Horizon $Horizon"
}
if ($Horizons) {
$actionArgs += " -Horizons `"$Horizons`""
}
if ($Features) {
$actionArgs += " -Features `"$Features`""
}
if ($ContextSymbols) {
$actionArgs += " -ContextSymbols `"$ContextSymbols`""
}
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArgs -WorkingDirectory $RepoRoot
$trigger = New-ScheduledTaskTrigger `
-Once `
+11 -5
View File
@@ -8,7 +8,9 @@ param(
[string]$Layers = "",
[string]$Dropouts = "",
[int]$Horizon = 0,
[string]$Horizons = "",
[string]$Features = "",
[string]$ContextSymbols = "",
[int]$Epochs = 0,
[int]$Patience = 0,
[string]$Interval = "",
@@ -51,17 +53,19 @@ function Resolve-Python {
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
if ($Limit -le 0) {
$Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 1000 }
$Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 3000 }
}
if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "32,64" } }
if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "64" } }
if (-not $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm,gru" } }
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "32,64" } }
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64,96" } }
if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "2" } }
if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.15" } }
if ($Horizon -le 0 -and $env:TORCH_RETRAIN_HORIZON) { $Horizon = [int]$env:TORCH_RETRAIN_HORIZON }
if (-not $Horizons -and $env:TORCH_RETRAIN_HORIZONS) { $Horizons = $env:TORCH_RETRAIN_HORIZONS }
if (-not $Features -and $env:TORCH_RETRAIN_FEATURES) { $Features = $env:TORCH_RETRAIN_FEATURES }
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 60 } }
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 10 } }
if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS }
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
@@ -94,7 +98,9 @@ try {
if ($Interval) { $trainerArgs += @("--interval", $Interval) }
if ($EnvFile) { $trainerArgs += @("--env", $EnvFile) }
if ($Horizon -gt 0) { $trainerArgs += @("--horizon", $Horizon.ToString()) }
if ($Horizons) { $trainerArgs += @("--horizons", $Horizons) }
if ($Features) { $trainerArgs += @("--features", $Features) }
if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
Push-Location $RepoRoot
$pushedLocation = $true
+381 -66
View File
@@ -4,6 +4,7 @@ import argparse
import json
import math
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -30,22 +31,40 @@ from crypto_spot_bot.models import Candle
from crypto_spot_bot.time_series import DEFAULT_TORCH_FEATURES, _feature_matrix, _log_returns
OUTPUT_LAYOUT = ("mean", "q10", "q50", "q90", "logit_up")
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_targets: list[float]
validation_up: torch.Tensor
validation_targets: list[list[float]]
validation_volatility_scales: list[list[float]]
feature_names: list[str]
feature_means: list[float]
feature_scales: list[float]
target_mean: float
target_scale: 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
@dataclass(slots=True)
class TrainingSample:
window: list[list[float]]
normalized_targets: list[float]
raw_targets: list[float]
volatility_scales: list[float]
class RecurrentReturnModel(nn.Module):
def __init__(
self,
@@ -55,6 +74,9 @@ class RecurrentReturnModel(nn.Module):
hidden_size: int,
num_layers: int,
dropout: float,
output_size: int,
attention_pooling: bool,
context_norm: bool,
) -> None:
super().__init__()
recurrent_cls = nn.LSTM if architecture == "lstm" else nn.GRU
@@ -65,11 +87,19 @@ class RecurrentReturnModel(nn.Module):
dropout=dropout if num_layers > 1 else 0.0,
batch_first=True,
)
self.head = nn.Linear(hidden_size, 1)
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.head = nn.Linear(hidden_size, output_size)
def forward(self, values: torch.Tensor) -> torch.Tensor:
output, _state = self.rnn(values)
return self.head(output[:, -1, :]).squeeze(-1)
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, :]
return self.head(self.context_norm(context))
def main() -> None:
@@ -84,19 +114,27 @@ def main() -> None:
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)
horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon)
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)
round_trip_cost = max(0.0, 2.0 * (float(settings.taker_fee_rate) + float(settings.slippage_rate)))
artifact: dict[str, Any] = {
"version": 3,
"version": 4,
"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,
"target_horizon": horizon,
"target_horizon": decision_horizon,
"target_horizons": target_horizons,
"direct_horizon": True,
"target_transform": "net_return_over_volatility",
"target_return": "round_trip_after_cost_log_return",
"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),
@@ -110,8 +148,11 @@ def main() -> None:
interval=interval,
limit=args.limit,
validation_window=args.validation_window,
target_horizon=horizon,
target_horizons=target_horizons,
decision_horizon=decision_horizon,
feature_names=feature_names,
round_trip_cost=round_trip_cost,
context_symbols=_strings(args.context_symbols),
architectures=_strings(args.architectures),
lookbacks=_ints(args.lookbacks),
hidden_sizes=_ints(args.hidden_sizes),
@@ -123,6 +164,8 @@ def main() -> None:
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,
seed=args.seed,
)
@@ -133,10 +176,11 @@ def main() -> None:
print(
f"{symbol}: model={result['model']} lookback={result['lookback']} "
f"features={result['input_size']} hidden={result['hidden_size']} "
f"layers={result['num_layers']} horizon={result['target_horizon']} "
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} dir={result['directional_accuracy']:.3f}"
f"skill={result['skill']:.4f} dir={result['directional_accuracy']:.3f} "
f"p_brier={result['probability_brier']:.4f}"
)
output.parent.mkdir(parents=True, exist_ok=True)
@@ -154,7 +198,9 @@ def _parse_args() -> argparse.Namespace:
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("--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.")
@@ -166,6 +212,8 @@ def _parse_args() -> argparse.Namespace:
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("--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.")
@@ -188,8 +236,11 @@ def _train_symbol(
interval: str,
limit: int,
validation_window: int,
target_horizon: int,
target_horizons: list[int],
decision_horizon: int,
feature_names: list[str],
round_trip_cost: float,
context_symbols: list[str],
architectures: list[str],
lookbacks: list[int],
hidden_sizes: list[int],
@@ -201,15 +252,31 @@ def _train_symbol(
learning_rate: float,
weight_decay: float,
clip: float,
attention_pooling: bool,
context_norm: bool,
device: torch.device,
seed: int,
) -> dict[str, Any] | None:
candles = client.klines(symbol, interval, limit)
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)
if len(candles) < max(140, validation_window + max(lookbacks) + target_horizon + 16):
max_horizon = max(target_horizons)
if len(candles) < max(180, validation_window + max(lookbacks) + max_horizon + 16):
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:
print(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:
@@ -217,14 +284,21 @@ def _train_symbol(
candles=candles,
feature_names=feature_names,
lookback=lookback,
target_horizon=target_horizon,
target_horizons=target_horizons,
decision_horizon=decision_horizon,
round_trip_cost=round_trip_cost,
market_candles=market_candles,
trend_candles=trend_candles,
validation_window=validation_window,
clip=clip,
device=device,
)
if prepared is None:
continue
baseline_mae = sum(abs(value) for value in prepared.validation_targets) / len(prepared.validation_targets)
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
@@ -237,6 +311,7 @@ def _train_symbol(
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,
@@ -246,6 +321,8 @@ def _train_symbol(
learning_rate=learning_rate,
weight_decay=weight_decay,
clip=clip,
attention_pooling=attention_pooling,
context_norm=context_norm,
device=device,
seed=seed,
)
@@ -256,19 +333,30 @@ def _train_symbol(
"model": f"torch_{architecture}",
"architecture": architecture,
"lookback": lookback,
"target_horizon": target_horizon,
"target_horizon": prepared.decision_horizon,
"target_horizons": prepared.target_horizons,
"direct_horizon": True,
"target_transform": "net_return_over_volatility",
"target_return": "round_trip_after_cost_log_return",
"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_mean": prepared.target_mean,
"target_scale": prepared.target_scale,
"mean": prepared.target_mean,
"scale": prepared.target_scale,
"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,
"clip": clip,
"validation_mae_percent": validation_mae * 100,
"baseline_mae_percent": baseline_mae * 100,
@@ -292,23 +380,47 @@ def _prepare_data(
candles: list[Candle],
feature_names: list[str],
lookback: int,
target_horizon: int,
target_horizons: list[int],
decision_horizon: int,
round_trip_cost: float,
market_candles: dict[str, list[Candle]],
trend_candles: list[Candle],
validation_window: int,
clip: float,
device: torch.device,
) -> PreparedData | None:
closes = [float(candle.close) for candle in candles]
feature_rows = _feature_matrix(candles, feature_names)
samples: list[tuple[list[list[float]], float]] = []
for end_index in range(lookback - 1, len(candles) - target_horizon):
feature_rows = _feature_matrix(
candles,
feature_names,
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]
future = closes[end_index + target_horizon]
if current <= 0 or future <= 0:
if current <= 0:
continue
window = feature_rows[end_index - lookback + 1 : end_index + 1]
if len(window) != lookback:
continue
samples.append((window, math.log(future / current)))
raw_targets: list[float] = []
volatility_scales: list[float] = []
normalized_targets: list[float] = []
valid = True
for horizon in target_horizons:
future = closes[end_index + horizon]
if future <= 0:
valid = False
break
net_return = math.log(future / current) - round_trip_cost
volatility_scale = _target_volatility_scale(candles, closes, end_index, horizon)
raw_targets.append(net_return)
volatility_scales.append(volatility_scale)
normalized_targets.append(net_return / max(volatility_scale, 1e-8))
if valid:
samples.append(TrainingSample(window, normalized_targets, raw_targets, volatility_scales))
if len(samples) < 48:
return None
@@ -319,45 +431,55 @@ def _prepare_data(
return None
feature_means, feature_scales = _feature_stats(train_samples, len(feature_names))
train_targets = [target for _, target in train_samples]
target_mean = sum(train_targets) / len(train_targets)
target_scale = _return_scale(train_targets)
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 = _normalize_samples(
train_x, train_y, train_up = _normalize_samples(
train_samples,
feature_means=feature_means,
feature_scales=feature_scales,
target_mean=target_mean,
target_scale=target_scale,
target_means=target_means,
target_scales=target_scales,
clip=clip,
)
validation_x, validation_y = _normalize_samples(
validation_x, validation_y, validation_up = _normalize_samples(
validation_samples,
feature_means=feature_means,
feature_scales=feature_scales,
target_mean=target_mean,
target_scale=target_scale,
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_targets=[target for _, target in validation_samples],
validation_up=torch.tensor(validation_up, dtype=torch.float32, device=device),
validation_targets=[sample.raw_targets for sample in validation_samples],
validation_volatility_scales=[sample.volatility_scales for sample in validation_samples],
feature_names=feature_names,
feature_means=feature_means,
feature_scales=feature_scales,
target_mean=target_mean,
target_scale=target_scale,
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),
)
def _feature_stats(samples: list[tuple[list[list[float]], float]], input_size: int) -> tuple[list[float], list[float]]:
def _feature_stats(samples: list[TrainingSample], input_size: int) -> tuple[list[float], list[float]]:
columns = [[] for _ in range(input_size)]
for window, _target in samples:
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))
@@ -377,19 +499,32 @@ def _feature_stats(samples: list[tuple[list[list[float]], float]], input_size: i
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[tuple[list[list[float]], float]],
samples: list[TrainingSample],
*,
feature_means: list[float],
feature_scales: list[float],
target_mean: float,
target_scale: float,
target_means: list[float],
target_scales: list[float],
clip: float,
) -> tuple[list[list[list[float]]], list[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[float] = []
for window, target in samples:
y_values: list[list[float]] = []
up_values: list[list[float]] = []
for sample in samples:
window = sample.window
x_values.append(
[
[
@@ -404,8 +539,18 @@ def _normalize_samples(
for row in window
]
)
y_values.append(_clamp((target - target_mean) / max(target_scale, 1e-8), -clip, clip))
return x_values, y_values
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([1.0 if target > 0 else 0.0 for target in sample.raw_targets])
return x_values, y_values, up_values
def _fit_candidate(
@@ -413,6 +558,7 @@ def _fit_candidate(
prepared: PreparedData,
architecture: str,
input_size: int,
output_size: int,
hidden_size: int,
num_layers: int,
dropout: float,
@@ -422,6 +568,8 @@ def _fit_candidate(
learning_rate: float,
weight_decay: float,
clip: float,
attention_pooling: bool,
context_norm: bool,
device: torch.device,
seed: int,
) -> dict[str, Any]:
@@ -432,12 +580,14 @@ def _fit_candidate(
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
output_size=output_size,
attention_pooling=attention_pooling,
context_norm=context_norm,
).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
criterion = nn.SmoothL1Loss(beta=0.5)
generator = torch.Generator(device="cpu").manual_seed(seed)
loader = DataLoader(
TensorDataset(prepared.train_x, prepared.train_y),
TensorDataset(prepared.train_x, prepared.train_y, prepared.train_up),
batch_size=max(1, batch_size),
shuffle=True,
generator=generator,
@@ -449,9 +599,9 @@ def _fit_candidate(
stale_epochs = 0
for epoch in range(1, max(1, epochs) + 1):
model.train()
for batch_x, batch_y in loader:
for batch_x, batch_y, batch_up in loader:
optimizer.zero_grad(set_to_none=True)
loss = criterion(model(batch_x), batch_y)
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()
@@ -474,40 +624,79 @@ def _fit_candidate(
"best_epoch": best_epoch,
"epochs_trained": best_epoch + stale_epochs,
"state_dict": _export_recurrent_state(model),
"head_weight": _round_list(model.head.weight.detach().cpu().squeeze(0).tolist()),
"head_bias": round(float(model.head.bias.detach().cpu().item()), 10),
"head_weight": _round_nested(model.head.weight.detach().cpu().tolist()),
"head_bias": _round_list(model.head.bias.detach().cpu().tolist()),
**_export_context_state(model),
}
def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, float]:
model.eval()
with torch.no_grad():
normalized_predictions = model(prepared.validation_x).detach().cpu().tolist()
predictions = [
_clamp(float(prediction), -clip, clip) * prepared.target_scale + prepared.target_mean
for prediction in normalized_predictions
]
errors = [abs(prediction - actual) for prediction, actual in zip(predictions, prepared.validation_targets)]
raw_outputs = model(prepared.validation_x).detach().cpu()
outputs = raw_outputs.view(len(prepared.validation_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 * prepared.validation_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 prepared.validation_targets]
errors = [abs(prediction - actual) for prediction, actual in zip(decision_predictions, decision_targets)]
correct = [
1.0
for prediction, actual in zip(predictions, prepared.validation_targets)
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(predictions, prepared.validation_targets)
for prediction, actual in zip(decision_predictions, decision_targets)
if prediction != 0 and actual != 0
]
buy_predictions = [
actual
for prediction, actual in zip(predictions, prepared.validation_targets)
for prediction, actual in zip(decision_predictions, decision_targets)
if prediction > 0
]
buy_wins = [actual for actual in buy_predictions if actual > 0]
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, prepared.validation_targets)
]
horizon_baseline = [abs(actual[horizon_index]) for actual in prepared.validation_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] - (1.0 if target > 0 else 0.0)) ** 2
for row_index, target in enumerate(decision_targets)
]
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": sum(probability_errors) / len(probability_errors) if probability_errors else 1.0,
}
@@ -516,9 +705,26 @@ def _candidate_score(row: dict[str, Any]) -> float:
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))
return mae * (1.0 - max(0.0, skill) * 0.05) * (1.0 - max(0.0, directional - 0.5) * 0.03) * (
1.0 - max(0.0, buy_precision - 0.5) * 0.02
)
) * (1.0 + max(0.0, probability_brier - 0.25) * 0.02)
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]
bce = nn.functional.binary_cross_entropy_with_logits(logits, up_targets, reduction="none")
probabilities = torch.sigmoid(logits)
pt = probabilities * up_targets + (1.0 - probabilities) * (1.0 - up_targets)
focal = ((1.0 - pt) ** 2.0 * bce).mean()
return mean_loss + 0.35 * sum(quantile_losses) / len(quantile_losses) + 0.15 * focal
def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]:
@@ -528,6 +734,23 @@ def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]:
}
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":
@@ -554,10 +777,90 @@ def _return_scale(returns: list[float]) -> float:
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]
@@ -580,6 +883,18 @@ 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)