Add multifeature direct horizon Torch forecaster

This commit is contained in:
Codex
2026-06-22 07:29:50 +03:00
parent 544b0f4409
commit 42f96f0a39
8 changed files with 537 additions and 93 deletions
@@ -4,6 +4,8 @@ param(
[int]$EveryHours = 6,
[string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT",
[int]$Limit = 1000,
[int]$Horizon = 0,
[string]$Features = "",
[int]$FirstRunMinutes = 0
)
@@ -30,6 +32,12 @@ if ($Symbols) {
if ($Limit -gt 0) {
$actionArgs += " -Limit $Limit"
}
if ($Horizon -gt 0) {
$actionArgs += " -Horizon $Horizon"
}
if ($Features) {
$actionArgs += " -Features `"$Features`""
}
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArgs -WorkingDirectory $RepoRoot
$trigger = New-ScheduledTaskTrigger `
-Once `
+9 -3
View File
@@ -7,6 +7,8 @@ param(
[string]$HiddenSizes = "",
[string]$Layers = "",
[string]$Dropouts = "",
[int]$Horizon = 0,
[string]$Features = "",
[int]$Epochs = 0,
[int]$Patience = 0,
[string]$Interval = "",
@@ -53,9 +55,11 @@ if ($Limit -le 0) {
}
if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "32,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 { "16,32" } }
if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "1" } }
if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.0" } }
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "32,64" } }
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 $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 $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
@@ -89,6 +93,8 @@ try {
if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) }
if ($Interval) { $trainerArgs += @("--interval", $Interval) }
if ($EnvFile) { $trainerArgs += @("--env", $EnvFile) }
if ($Horizon -gt 0) { $trainerArgs += @("--horizon", $Horizon.ToString()) }
if ($Features) { $trainerArgs += @("--features", $Features) }
Push-Location $RepoRoot
$pushedLocation = $true
+213 -58
View File
@@ -25,7 +25,9 @@ except ImportError as exc: # pragma: no cover - exercised on machines without t
from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import load_settings
from crypto_spot_bot.time_series import _log_returns
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, _feature_matrix, _log_returns
@dataclass(slots=True)
@@ -34,9 +36,12 @@ class PreparedData:
train_y: torch.Tensor
validation_x: torch.Tensor
validation_y: torch.Tensor
validation_returns: list[float]
mean: float
scale: float
validation_targets: list[float]
feature_names: list[str]
feature_means: list[float]
feature_scales: list[float]
target_mean: float
target_scale: float
train_samples: int
validation_samples: int
@@ -46,6 +51,7 @@ class RecurrentReturnModel(nn.Module):
self,
*,
architecture: str,
input_size: int,
hidden_size: int,
num_layers: int,
dropout: float,
@@ -53,7 +59,7 @@ class RecurrentReturnModel(nn.Module):
super().__init__()
recurrent_cls = nn.LSTM if architecture == "lstm" else nn.GRU
self.rnn = recurrent_cls(
input_size=1,
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout if num_layers > 1 else 0.0,
@@ -78,15 +84,21 @@ 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)
feature_names = _feature_names_arg(args.features)
artifact: dict[str, Any] = {
"version": 2,
"version": 3,
"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,
"direct_horizon": True,
"feature_names": feature_names,
"feature_count": len(feature_names),
"device": str(device),
"symbols": {},
}
@@ -98,6 +110,8 @@ def main() -> None:
interval=interval,
limit=args.limit,
validation_window=args.validation_window,
target_horizon=horizon,
feature_names=feature_names,
architectures=_strings(args.architectures),
lookbacks=_ints(args.lookbacks),
hidden_sizes=_ints(args.hidden_sizes),
@@ -118,9 +132,11 @@ def main() -> None:
artifact["symbols"][symbol] = result
print(
f"{symbol}: model={result['model']} lookback={result['lookback']} "
f"hidden={result['hidden_size']} layers={result['num_layers']} "
f"features={result['input_size']} hidden={result['hidden_size']} "
f"layers={result['num_layers']} horizon={result['target_horizon']} "
f"mae={result['validation_mae_percent']:.5f}% "
f"baseline={result['baseline_mae_percent']:.5f}% skill={result['skill']:.4f}"
f"baseline={result['baseline_mae_percent']:.5f}% "
f"skill={result['skill']:.4f} dir={result['directional_accuracy']:.3f}"
)
output.parent.mkdir(parents=True, exist_ok=True)
@@ -136,18 +152,20 @@ def _parse_args() -> argparse.Namespace:
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 returns used for validation.")
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("--features", default=",".join(DEFAULT_TORCH_FEATURES), help="Comma-separated feature names.")
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="16,32", help="Comma-separated hidden sizes.")
parser.add_argument("--layers", default="1", help="Comma-separated recurrent layer counts.")
parser.add_argument("--dropouts", default="0.0", help="Comma-separated dropout values; only used with layers > 1.")
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 returns and predictions to this range.")
parser.add_argument("--clip", type=float, default=8.0, help="Clamp normalized features, targets and predictions.")
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.")
@@ -170,6 +188,8 @@ def _train_symbol(
interval: str,
limit: int,
validation_window: int,
target_horizon: int,
feature_names: list[str],
architectures: list[str],
lookbacks: list[int],
hidden_sizes: list[int],
@@ -185,26 +205,38 @@ def _train_symbol(
seed: int,
) -> dict[str, Any] | None:
candles = client.klines(symbol, interval, limit)
add_indicators(candles)
closes = [float(candle.close) for candle in candles if candle.close > 0]
returns = _log_returns(closes)
if len(returns) < max(100, validation_window + 80):
if len(candles) < max(140, validation_window + max(lookbacks) + target_horizon + 16):
return None
best: dict[str, Any] | None = None
for lookback in lookbacks:
prepared = _prepare_data(returns, lookback, validation_window, clip, device)
prepared = _prepare_data(
candles=candles,
feature_names=feature_names,
lookback=lookback,
target_horizon=target_horizon,
validation_window=validation_window,
clip=clip,
device=device,
)
if prepared is None:
continue
baseline_mae = sum(abs(value) for value in prepared.validation_returns) / len(prepared.validation_returns)
baseline_mae = sum(abs(value) 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
candidate = _fit_candidate(
prepared=prepared,
architecture=architecture,
input_size=len(feature_names),
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
@@ -224,11 +256,19 @@ def _train_symbol(
"model": f"torch_{architecture}",
"architecture": architecture,
"lookback": lookback,
"target_horizon": target_horizon,
"direct_horizon": True,
"input_size": len(feature_names),
"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,
"hidden_size": hidden_size,
"num_layers": num_layers,
"dropout": dropout,
"mean": prepared.mean,
"scale": prepared.scale,
"dropout": dropout if num_layers > 1 else 0.0,
"clip": clip,
"validation_mae_percent": validation_mae * 100,
"baseline_mae_percent": baseline_mae * 100,
@@ -238,7 +278,8 @@ def _train_symbol(
"train_samples": prepared.train_samples,
"validation_samples": prepared.validation_samples,
}
if best is None or validation_mae < float(best["validation_mae"]):
score = _candidate_score(row)
if best is None or score < _candidate_score(best):
best = row
if best is None:
return None
@@ -247,54 +288,131 @@ def _train_symbol(
def _prepare_data(
returns: list[float],
*,
candles: list[Candle],
feature_names: list[str],
lookback: int,
target_horizon: int,
validation_window: int,
clip: float,
device: torch.device,
) -> PreparedData | None:
validation_window = min(max(16, validation_window), max(16, len(returns) // 3))
split = len(returns) - validation_window
if split <= lookback + 16:
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):
current = closes[end_index]
future = closes[end_index + target_horizon]
if current <= 0 or future <= 0:
continue
window = feature_rows[end_index - lookback + 1 : end_index + 1]
if len(window) != lookback:
continue
samples.append((window, math.log(future / current)))
if len(samples) < 48:
return None
train_returns = returns[:split]
mean = sum(train_returns) / len(train_returns)
scale = _return_scale(train_returns)
normalized = [_clamp((value - mean) / scale, -clip, clip) for value in returns]
train_x: list[list[list[float]]] = []
train_y: list[float] = []
validation_x: list[list[list[float]]] = []
validation_y: list[float] = []
validation_returns: list[float] = []
for target_index in range(lookback, len(returns)):
row = [[value] for value in normalized[target_index - lookback : target_index]]
target = normalized[target_index]
if target_index < split:
train_x.append(row)
train_y.append(target)
else:
validation_x.append(row)
validation_y.append(target)
validation_returns.append(returns[target_index])
if len(train_x) < 24 or len(validation_x) < 8:
validation_window = min(max(16, validation_window), max(16, len(samples) // 3))
train_samples = samples[:-validation_window]
validation_samples = samples[-validation_window:]
if len(train_samples) < 24 or len(validation_samples) < 8:
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)
train_x, train_y = _normalize_samples(
train_samples,
feature_means=feature_means,
feature_scales=feature_scales,
target_mean=target_mean,
target_scale=target_scale,
clip=clip,
)
validation_x, validation_y = _normalize_samples(
validation_samples,
feature_means=feature_means,
feature_scales=feature_scales,
target_mean=target_mean,
target_scale=target_scale,
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),
validation_x=torch.tensor(validation_x, dtype=torch.float32, device=device),
validation_y=torch.tensor(validation_y, dtype=torch.float32, device=device),
validation_returns=validation_returns,
mean=mean,
scale=scale,
validation_targets=[target for _, target in validation_samples],
feature_names=feature_names,
feature_means=feature_means,
feature_scales=feature_scales,
target_mean=target_mean,
target_scale=target_scale,
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]]:
columns = [[] for _ in range(input_size)]
for window, _target in samples:
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 values in columns:
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 _normalize_samples(
samples: list[tuple[list[list[float]], float]],
*,
feature_means: list[float],
feature_scales: list[float],
target_mean: float,
target_scale: float,
clip: float,
) -> tuple[list[list[list[float]]], list[float]]:
input_size = len(feature_means)
x_values: list[list[list[float]]] = []
y_values: list[float] = []
for window, target in samples:
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_mean) / max(target_scale, 1e-8), -clip, clip))
return x_values, y_values
def _fit_candidate(
*,
prepared: PreparedData,
architecture: str,
input_size: int,
hidden_size: int,
num_layers: int,
dropout: float,
@@ -310,6 +428,7 @@ def _fit_candidate(
_seed(seed)
model = RecurrentReturnModel(
architecture=architecture,
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
dropout=dropout,
@@ -325,7 +444,7 @@ def _fit_candidate(
)
best_state: dict[str, torch.Tensor] | None = None
best_mae = math.inf
best_metrics: dict[str, float] = {"validation_mae": math.inf, "directional_accuracy": 0.0, "buy_precision": 0.0}
best_epoch = 0
stale_epochs = 0
for epoch in range(1, max(1, epochs) + 1):
@@ -337,9 +456,9 @@ def _fit_candidate(
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
validation_mae = _validation_mae(model, prepared, clip)
if validation_mae + 1e-12 < best_mae:
best_mae = validation_mae
metrics = _validation_metrics(model, prepared, clip)
if metrics["validation_mae"] + 1e-12 < best_metrics["validation_mae"]:
best_metrics = metrics
best_epoch = epoch
best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()}
stale_epochs = 0
@@ -351,7 +470,7 @@ def _fit_candidate(
if best_state:
model.load_state_dict(best_state)
return {
"validation_mae": best_mae,
**best_metrics,
"best_epoch": best_epoch,
"epochs_trained": best_epoch + stale_epochs,
"state_dict": _export_recurrent_state(model),
@@ -360,15 +479,46 @@ def _fit_candidate(
}
def _validation_mae(model: nn.Module, prepared: PreparedData, clip: float) -> float:
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()
errors = []
for prediction, actual in zip(normalized_predictions, prepared.validation_returns):
raw_prediction = _clamp(float(prediction), -clip, clip) * prepared.scale + prepared.mean
errors.append(abs(raw_prediction - actual))
return sum(errors) / len(errors) if errors else math.inf
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)]
correct = [
1.0
for prediction, actual in zip(predictions, prepared.validation_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)
if prediction != 0 and actual != 0
]
buy_predictions = [
actual
for prediction, actual in zip(predictions, prepared.validation_targets)
if prediction > 0
]
buy_wins = [actual for actual in buy_predictions if actual > 0]
return {
"validation_mae": sum(errors) / len(errors) if errors else math.inf,
"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,
}
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))
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
)
def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]:
@@ -430,5 +580,10 @@ def _strings(raw: str) -> list[str]:
return [item.strip().lower() for item in raw.split(",") if item.strip()]
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()