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
+611 -12
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import math
from bisect import bisect_right
from dataclasses import asdict, dataclass, field
from typing import Any
@@ -13,17 +14,46 @@ DEFAULT_TORCH_FEATURES = (
"return_1",
"return_3",
"return_6",
"return_12",
"return_24",
"range_percent",
"body_percent",
"upper_wick_percent",
"lower_wick_percent",
"volume_change",
"volume_ratio",
"volume_percentile_20",
"atr_percent",
"atr_ratio_20",
"realized_volatility_12",
"realized_volatility_24",
"rsi_centered",
"rsi_slope_6",
"macd_hist_percent",
"macd_hist_slope_3",
"ema50_gap_percent",
"ema200_gap_percent",
"ema20_slope_6",
"ema50_slope_12",
"ema200_slope_24",
"ema50_ema200_gap_percent",
"range_position_50",
"trend_return_4h",
"trend_return_24h",
"daily_close_ema200_gap_percent",
"daily_ema50_ema200_gap_percent",
"daily_ema50_slope",
"btc_return_1",
"btc_return_3",
"btc_return_6",
"btc_return_24",
"eth_return_1",
"eth_return_3",
"eth_return_6",
"eth_return_24",
"relative_btc_return_3",
"relative_eth_return_3",
"btc_eth_return_spread_3",
"pattern_score",
"pattern_bullish",
"pattern_bearish",
@@ -56,6 +86,13 @@ class TimeSeriesForecast:
skill: float
horizon: int
reason: str
expected_gross_return_percent: float
quantile_10_percent: float
quantile_50_percent: float
quantile_90_percent: float
conservative_return_percent: float
target_transform: str
horizon_forecasts: dict[str, Any] = field(default_factory=dict)
candidates: list[dict[str, Any]] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
@@ -68,7 +105,14 @@ class TimeSeriesForecaster:
self._lstm_artifact_mtime: float | None = None
self._lstm_artifact: dict[str, Any] = {}
def forecast(self, candles: list[Candle], symbol: str | None = None) -> TimeSeriesForecast:
def forecast(
self,
candles: list[Candle],
symbol: str | None = None,
*,
market_candles: dict[str, list[Candle]] | None = None,
trend_candles: list[Candle] | None = None,
) -> TimeSeriesForecast:
if not self.settings.time_series_forecast_enabled:
return _empty_forecast(False, "time-series forecast is disabled")
closes = [float(candle.close) for candle in candles if candle.close > 0]
@@ -82,7 +126,17 @@ class TimeSeriesForecaster:
artifact = self._load_lstm_artifact()
entry = _torch_recurrent_entry(symbol, artifact)
model = _torch_recurrent_model_name(symbol, artifact)
feature_rows = _feature_matrix(candles, _feature_names(entry)) if entry else []
feature_rows = (
_feature_matrix(
candles,
_feature_names(entry),
symbol=symbol,
market_candles=market_candles,
trend_candles=trend_candles,
)
if entry
else []
)
if not model or not _can_use_torch_recurrent(returns, symbol, artifact, feature_rows):
return _empty_forecast(True, "no valid PyTorch LSTM/GRU model for symbol")
@@ -92,10 +146,79 @@ class TimeSeriesForecaster:
artifact,
feature_rows=feature_rows,
closes=closes,
candles=candles,
)
if entry is None or prediction is None:
return _empty_forecast(True, "PyTorch LSTM/GRU model could not build a forecast")
if isinstance(prediction, dict):
selected = _select_horizon_prediction(
prediction,
_entry_horizon(entry, self.settings.time_series_forecast_horizon),
)
if not selected:
return _empty_forecast(True, "PyTorch LSTM/GRU model could not select a forecast horizon")
expected_return = float(selected["expected_return"])
expected_gross_return = float(selected.get("expected_gross_return", expected_return))
expected_price = closes[-1] * math.exp(expected_gross_return)
probability_up = _clamp(float(selected.get("probability_up", 0.5)), 0.0, 1.0)
model_mae = max(float(selected.get("validation_mae", 0.0)), 1e-9)
baseline_mae = max(float(selected.get("baseline_mae", model_mae)), model_mae)
uncertainty = max(float(selected.get("uncertainty", model_mae)), 1e-9)
volatility_percent = uncertainty * 100
expected_return_percent = (math.exp(expected_return) - 1) * 100
expected_gross_return_percent = (math.exp(expected_gross_return) - 1) * 100
q10_percent = (math.exp(float(selected.get("q10", expected_return))) - 1) * 100
q50_percent = (math.exp(float(selected.get("q50", expected_return))) - 1) * 100
q90_percent = (math.exp(float(selected.get("q90", expected_return))) - 1) * 100
skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0)
horizon = int(selected.get("horizon", _entry_horizon(entry, self.settings.time_series_forecast_horizon)))
min_edge = max(0.0, self.settings.time_series_min_edge_percent)
confidence_adjustment = _confidence_adjustment(
expected_return_percent=expected_return_percent,
probability_up=probability_up,
skill=skill,
min_edge=min_edge,
max_adjustment=self.settings.time_series_max_adjustment,
)
conservative_return_percent = min(expected_return_percent, q50_percent)
block_entry = bool(
(expected_return_percent <= -min_edge and probability_up <= 0.45)
or (q50_percent <= -min_edge and probability_up <= 0.48)
)
reason = _reason(
model=model,
expected_return_percent=expected_return_percent,
probability_up=probability_up,
skill=skill,
block_entry=block_entry,
)
return TimeSeriesForecast(
enabled=True,
usable=True,
model=model,
volatility_model="probabilistic multi-horizon after-cost quantile",
expected_return_percent=round(expected_return_percent, 4),
expected_price=round(expected_price, 8),
volatility_percent=round(volatility_percent, 4),
probability_up=round(probability_up, 4),
confidence_adjustment=round(confidence_adjustment, 4),
block_entry=block_entry,
validation_mae_percent=round(model_mae * 100, 4),
baseline_mae_percent=round(baseline_mae * 100, 4),
skill=round(skill, 4),
horizon=horizon,
reason=reason,
expected_gross_return_percent=round(expected_gross_return_percent, 4),
quantile_10_percent=round(q10_percent, 4),
quantile_50_percent=round(q50_percent, 4),
quantile_90_percent=round(q90_percent, 4),
conservative_return_percent=round(conservative_return_percent, 4),
target_transform=str(entry.get("target_transform", "net_return_over_volatility")),
horizon_forecasts=_public_horizon_forecasts(prediction),
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
)
direct_horizon = _is_direct_horizon(entry)
horizon = _entry_horizon(entry, self.settings.time_series_forecast_horizon)
expected_return = prediction if direct_horizon else prediction * horizon
@@ -145,6 +268,13 @@ class TimeSeriesForecaster:
skill=round(skill, 4),
horizon=horizon,
reason=reason,
expected_gross_return_percent=round(expected_return_percent, 4),
quantile_10_percent=round(expected_return_percent - volatility_percent, 4),
quantile_50_percent=round(expected_return_percent, 4),
quantile_90_percent=round(expected_return_percent + volatility_percent, 4),
conservative_return_percent=round(expected_return_percent, 4),
target_transform=str(entry.get("target_transform", "direct_log_return")),
horizon_forecasts={},
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
)
@@ -186,6 +316,13 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
skill=0.0,
horizon=0,
reason=reason,
expected_gross_return_percent=0.0,
quantile_10_percent=0.0,
quantile_50_percent=0.0,
quantile_90_percent=0.0,
conservative_return_percent=0.0,
target_transform="none",
horizon_forecasts={},
)
@@ -193,15 +330,58 @@ def _log_returns(closes: list[float]) -> list[float]:
return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))]
def _feature_matrix(candles: list[Candle], feature_names: list[str] | tuple[str, ...] | None = None) -> list[list[float]]:
def _feature_matrix(
candles: list[Candle],
feature_names: list[str] | tuple[str, ...] | None = None,
*,
symbol: str | None = None,
market_candles: dict[str, list[Candle]] | None = None,
trend_candles: list[Candle] | None = None,
) -> list[list[float]]:
names = list(feature_names or DEFAULT_TORCH_FEATURES)
context = _feature_context(
candles,
symbol=symbol,
market_candles=market_candles,
trend_candles=trend_candles,
)
rows: list[list[float]] = []
for index, candle in enumerate(candles):
rows.append([_feature_value(name, candles, index, candle) for name in names])
rows.append([_feature_value(name, candles, index, candle, context) for name in names])
return rows
def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle) -> float:
def _feature_context(
candles: list[Candle],
*,
symbol: str | None,
market_candles: dict[str, list[Candle]] | None,
trend_candles: list[Candle] | None,
) -> dict[str, Any]:
market_candles = market_candles or {}
normalized_market = {key.upper(): value for key, value in market_candles.items()}
context_indexes = {
key: {candle.timestamp: index for index, candle in enumerate(rows)}
for key, rows in normalized_market.items()
}
trend_rows = trend_candles or []
trend_timestamps = [candle.timestamp for candle in trend_rows]
trend_positions = [
bisect_right(trend_timestamps, candle.timestamp) - 1
if trend_timestamps
else -1
for candle in candles
]
return {
"symbol": (symbol or "").upper(),
"market_candles": normalized_market,
"context_indexes": context_indexes,
"trend_candles": trend_rows,
"trend_positions": trend_positions,
}
def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle, context: dict[str, Any]) -> float:
close = max(float(candle.close), 1e-12)
previous = candles[index - 1] if index >= 1 else candle
if name == "return_1":
@@ -210,6 +390,10 @@ def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle)
return _log_change(candle.close, candles[index - 3].close) if index >= 3 else 0.0
if name == "return_6":
return _log_change(candle.close, candles[index - 6].close) if index >= 6 else 0.0
if name == "return_12":
return _log_change(candle.close, candles[index - 12].close) if index >= 12 else 0.0
if name == "return_24":
return _log_change(candle.close, candles[index - 24].close) if index >= 24 else 0.0
if name == "range_percent":
return _safe_feature((candle.high - candle.low) / close)
if name == "body_percent":
@@ -222,16 +406,52 @@ def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle)
return _log_change(max(candle.volume, 1e-12), max(previous.volume, 1e-12))
if name == "volume_ratio":
return _safe_feature((candle.volume / candle.volume_ma_20) - 1.0) if candle.volume_ma_20 else 0.0
if name == "volume_percentile_20":
return _rolling_percentile([row.volume for row in candles], index, 20)
if name == "atr_percent":
return _safe_feature(candle.atr_14 / close) if candle.atr_14 is not None else 0.0
if name == "atr_ratio_20":
return _ratio_to_recent_mean(
[row.atr_14 if row.atr_14 is not None else 0.0 for row in candles],
index,
20,
)
if name == "realized_volatility_12":
return _realized_volatility(candles, index, 12)
if name == "realized_volatility_24":
return _realized_volatility(candles, index, 24)
if name == "rsi_centered":
return _safe_feature((candle.rsi_14 - 50.0) / 50.0) if candle.rsi_14 is not None else 0.0
if name == "rsi_slope_6":
return _indicator_slope(candles, index, "rsi_14", 6, divisor=50.0)
if name == "macd_hist_percent":
return _safe_feature(candle.macd_hist / close) if candle.macd_hist is not None else 0.0
if name == "macd_hist_slope_3":
return _indicator_price_slope(candles, index, "macd_hist", 3)
if name == "ema50_gap_percent":
return _safe_feature((candle.close - candle.ema_50) / close) if candle.ema_50 is not None else 0.0
if name == "ema200_gap_percent":
return _safe_feature((candle.close - candle.ema_200) / close) if candle.ema_200 is not None else 0.0
if name == "ema20_slope_6":
return _ema_slope(candles, index, "ema_20", 6)
if name == "ema50_slope_12":
return _ema_slope(candles, index, "ema_50", 12)
if name == "ema200_slope_24":
return _ema_slope(candles, index, "ema_200", 24)
if name == "ema50_ema200_gap_percent":
if candle.ema_50 is not None and candle.ema_200 is not None and candle.ema_200 > 0:
return _safe_feature((candle.ema_50 - candle.ema_200) / candle.ema_200)
return 0.0
if name == "range_position_50":
return _range_position(candles, index, 50)
if name == "trend_return_4h":
return _log_change(candle.close, candles[index - 4].close) if index >= 4 else 0.0
if name == "trend_return_24h":
return _log_change(candle.close, candles[index - 24].close) if index >= 24 else 0.0
if name.startswith("daily_"):
return _daily_feature_value(name, context, index)
if name.startswith("btc_") or name.startswith("eth_") or name.startswith("relative_"):
return _cross_asset_feature_value(name, candles, index, candle, context)
if name.startswith("pattern_"):
return _pattern_feature_value(name, candles, index)
return 0.0
@@ -266,6 +486,143 @@ def _pattern_feature_value(name: str, candles: list[Candle], index: int) -> floa
return 0.0
def _rolling_percentile(values: list[float], index: int, window: int) -> float:
start = max(0, index - window + 1)
sample = [float(value) for value in values[start : index + 1] if math.isfinite(float(value))]
if not sample:
return 0.5
current = float(values[index])
below_or_equal = sum(1 for value in sample if value <= current)
return _clamp(below_or_equal / len(sample), 0.0, 1.0)
def _ratio_to_recent_mean(values: list[float], index: int, window: int) -> float:
current = float(values[index]) if index < len(values) else 0.0
start = max(0, index - window + 1)
sample = [float(value) for value in values[start : index + 1] if math.isfinite(float(value)) and value > 0]
if current <= 0 or not sample:
return 0.0
mean = sum(sample) / len(sample)
return _safe_feature((current / mean) - 1.0) if mean > 0 else 0.0
def _realized_volatility(candles: list[Candle], index: int, window: int) -> float:
if index < 1:
return 0.0
start = max(1, index - window + 1)
returns = [
_log_change(candles[position].close, candles[position - 1].close)
for position in range(start, index + 1)
]
if not returns:
return 0.0
return _safe_feature(math.sqrt(sum(value * value for value in returns) / len(returns)))
def _indicator_slope(candles: list[Candle], index: int, attr: str, steps: int, *, divisor: float) -> float:
if index < steps:
return 0.0
current = getattr(candles[index], attr)
previous = getattr(candles[index - steps], attr)
if current is None or previous is None or divisor <= 0:
return 0.0
return _safe_feature((float(current) - float(previous)) / divisor)
def _indicator_price_slope(candles: list[Candle], index: int, attr: str, steps: int) -> float:
if index < steps:
return 0.0
current = getattr(candles[index], attr)
previous = getattr(candles[index - steps], attr)
close = max(float(candles[index].close), 1e-12)
if current is None or previous is None:
return 0.0
return _safe_feature((float(current) - float(previous)) / close)
def _ema_slope(candles: list[Candle], index: int, attr: str, steps: int) -> float:
if index < steps:
return 0.0
current = getattr(candles[index], attr)
previous = getattr(candles[index - steps], attr)
if current is None or previous is None or previous <= 0:
return 0.0
return _safe_feature(math.log(float(current) / float(previous)))
def _range_position(candles: list[Candle], index: int, window: int) -> float:
start = max(0, index - window + 1)
rows = candles[start : index + 1]
if not rows:
return 0.5
high = max(row.high for row in rows)
low = min(row.low for row in rows)
width = high - low
if width <= 0:
return 0.5
return _clamp((candles[index].close - low) / width, 0.0, 1.0)
def _daily_feature_value(name: str, context: dict[str, Any], index: int) -> float:
trend_rows: list[Candle] = context.get("trend_candles") or []
trend_positions: list[int] = context.get("trend_positions") or []
if index >= len(trend_positions):
return 0.0
trend_index = trend_positions[index]
if trend_index < 0 or trend_index >= len(trend_rows):
return 0.0
trend = trend_rows[trend_index]
if name == "daily_close_ema200_gap_percent":
if trend.ema_200 is not None and trend.ema_200 > 0:
return _safe_feature((trend.close - trend.ema_200) / trend.ema_200)
return 0.0
if name == "daily_ema50_ema200_gap_percent":
if trend.ema_50 is not None and trend.ema_200 is not None and trend.ema_200 > 0:
return _safe_feature((trend.ema_50 - trend.ema_200) / trend.ema_200)
return 0.0
if name == "daily_ema50_slope":
previous_index = trend_index - 5
if previous_index >= 0 and trend.ema_50 is not None and trend_rows[previous_index].ema_50:
return _safe_feature(math.log(trend.ema_50 / trend_rows[previous_index].ema_50))
return 0.0
def _cross_asset_feature_value(
name: str,
candles: list[Candle],
index: int,
candle: Candle,
context: dict[str, Any],
) -> float:
if name == "btc_eth_return_spread_3":
return _safe_feature(
_context_return("BTCUSDT", 3, candle.timestamp, context)
- _context_return("ETHUSDT", 3, candle.timestamp, context)
)
if name.startswith("btc_return_"):
steps = int(name.rsplit("_", 1)[1])
return _context_return("BTCUSDT", steps, candle.timestamp, context)
if name.startswith("eth_return_"):
steps = int(name.rsplit("_", 1)[1])
return _context_return("ETHUSDT", steps, candle.timestamp, context)
if name == "relative_btc_return_3":
return _safe_feature((_log_change(candle.close, candles[index - 3].close) if index >= 3 else 0.0) - _context_return("BTCUSDT", 3, candle.timestamp, context))
if name == "relative_eth_return_3":
return _safe_feature((_log_change(candle.close, candles[index - 3].close) if index >= 3 else 0.0) - _context_return("ETHUSDT", 3, candle.timestamp, context))
return 0.0
def _context_return(symbol: str, steps: int, timestamp: int, context: dict[str, Any]) -> float:
rows = (context.get("market_candles") or {}).get(symbol)
indexes = (context.get("context_indexes") or {}).get(symbol)
if not rows or not indexes:
return 0.0
index = indexes.get(timestamp)
if index is None or index < steps:
return 0.0
return _log_change(rows[index].close, rows[index - steps].close)
def _pattern_snapshot(candles: list[Candle], index: int) -> dict[str, float]:
if index < 29:
return {
@@ -486,7 +843,8 @@ def _torch_recurrent_predict(
*,
feature_rows: list[list[float]] | None = None,
closes: list[float] | None = None,
) -> float | None:
candles: list[Candle] | None = None,
) -> float | dict[str, Any] | None:
entry = _torch_recurrent_entry(symbol, artifact)
model_name = _torch_recurrent_model_name(symbol, artifact)
if not entry or not model_name:
@@ -523,11 +881,19 @@ def _torch_recurrent_predict(
)
if hidden is None:
return None
head_weight = _float_vector(entry.get("head_weight"))
head_bias = _float_entry(entry, "head_bias", 0.0)
if len(head_weight) != hidden_size:
head_outputs = _torch_head_outputs(hidden, entry, hidden_size)
if not head_outputs:
return None
normalized_prediction = sum(weight * value for weight, value in zip(head_weight, hidden)) + head_bias
if _entry_target_horizons(entry):
return _decode_multi_horizon_prediction(
outputs=head_outputs,
entry=entry,
returns=returns,
closes=closes or [],
candles=candles or [],
clip=clip,
)
normalized_prediction = head_outputs[0]
if not math.isfinite(normalized_prediction):
return None
prediction = _clamp(normalized_prediction, -clip, clip) * target_scale + target_mean
@@ -543,6 +909,111 @@ def _torch_recurrent_predict(
return _clamp(prediction, -cap, cap)
def _torch_head_outputs(context: list[float], entry: dict[str, Any], hidden_size: int) -> list[float]:
context = _apply_context_norm(context, entry)
raw_weight = entry.get("head_weight")
if isinstance(raw_weight, list) and raw_weight and isinstance(raw_weight[0], list):
matrix = _float_matrix(raw_weight)
bias = _float_vector(entry.get("head_bias"))
if len(bias) != len(matrix):
bias = [0.0 for _ in matrix]
return [
_dot(row, context) + bias[index]
for index, row in enumerate(matrix)
if len(row) == hidden_size
]
head_weight = _float_vector(raw_weight)
head_bias = _float_entry(entry, "head_bias", 0.0)
if len(head_weight) != hidden_size:
return []
return [sum(weight * value for weight, value in zip(head_weight, context)) + head_bias]
def _apply_context_norm(context: list[float], entry: dict[str, Any]) -> list[float]:
weight = _float_vector(entry.get("context_norm_weight"))
bias = _float_vector(entry.get("context_norm_bias"))
if not weight or len(weight) != len(context):
return context
if len(bias) != len(context):
bias = [0.0 for _ in context]
mean = sum(context) / len(context)
variance = sum((value - mean) ** 2 for value in context) / len(context)
denominator = math.sqrt(variance + 1e-5)
return [
((value - mean) / denominator) * weight[index] + bias[index]
for index, value in enumerate(context)
]
def _decode_multi_horizon_prediction(
*,
outputs: list[float],
entry: dict[str, Any],
returns: list[float],
closes: list[float],
candles: list[Candle],
clip: float,
) -> dict[str, Any] | None:
horizons = _entry_target_horizons(entry)
if not horizons:
return None
layout = _entry_output_layout(entry)
group_size = len(layout)
if len(outputs) < len(horizons) * group_size:
return None
target_means = _target_vector(entry, "target_means", "target_mean", len(horizons), 0.0)
target_scales = _target_vector(entry, "target_scales", "target_scale", len(horizons), _return_scale(returns))
validation_mae = _target_vector(entry, "validation_mae_by_horizon", "validation_mae_percent", len(horizons), 0.0)
baseline_mae = _target_vector(entry, "baseline_mae_by_horizon", "baseline_mae_percent", len(horizons), 0.0)
round_trip_cost = max(0.0, _float_entry(entry, "round_trip_cost", 0.0))
result: dict[str, Any] = {"horizons": {}}
for horizon_index, horizon in enumerate(horizons):
base = horizon_index * group_size
values = {layout[offset]: outputs[base + offset] for offset in range(group_size)}
vol_scale = _current_volatility_scale(candles, closes, horizon)
def decode(name: str, fallback: float = 0.0) -> float:
normalized = _clamp(float(values.get(name, fallback)), -clip, clip)
transformed = normalized * max(target_scales[horizon_index], 1e-8) + target_means[horizon_index]
if str(entry.get("target_transform", "")) == "net_return_over_volatility":
return transformed * vol_scale
return transformed
expected = decode("mean")
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
probability_up = _sigmoid(float(values.get("logit_up", 0.0)))
cap = _prediction_cap(closes, horizon, round_trip_cost)
expected = _clamp(expected, -cap, cap)
q10 = _clamp(q_values[0], -cap, cap)
q50 = _clamp(q_values[1], -cap, cap)
q90 = _clamp(q_values[2], -cap, cap)
mae = validation_mae[horizon_index]
if mae > 1.0:
mae = mae / 100
base_mae = baseline_mae[horizon_index]
if base_mae > 1.0:
base_mae = base_mae / 100
if mae <= 0:
mae = max(_horizon_return_scale(closes, horizon), 1e-9)
if base_mae <= 0:
base_mae = max(mae, _horizon_return_scale(closes, horizon))
uncertainty = max(abs(q90 - q10) * 0.5, mae, 1e-9)
result["horizons"][str(horizon)] = {
"horizon": horizon,
"expected_return": expected,
"expected_gross_return": expected + round_trip_cost,
"q10": q10,
"q50": q50,
"q90": q90,
"probability_up": probability_up,
"volatility_scale": vol_scale,
"validation_mae": mae,
"baseline_mae": base_mae,
"uncertainty": uncertainty,
}
return result
def _normalize_feature_rows(rows: list[list[float]], entry: dict[str, Any], clip: float) -> list[list[float]]:
means = _float_vector(entry.get("feature_means"))
scales = _float_vector(entry.get("feature_scales"))
@@ -575,6 +1046,7 @@ def _torch_recurrent_hidden(
return None
h_layers = [[0.0 for _ in range(hidden_size)] for _ in range(num_layers)]
c_layers = [[0.0 for _ in range(hidden_size)] for _ in range(num_layers)]
top_outputs: list[list[float]] = []
for row in sequence:
layer_input = list(row)
for layer in range(num_layers):
@@ -587,7 +1059,32 @@ def _torch_recurrent_hidden(
else:
return None
layer_input = h_layers[layer]
return h_layers[-1]
top_outputs.append(list(layer_input))
if not top_outputs:
return None
if bool(entry.get("attention_pooling")):
return _attention_context(top_outputs, entry, hidden_size)
return top_outputs[-1]
def _attention_context(outputs: list[list[float]], entry: dict[str, Any], hidden_size: int) -> list[float] | None:
weight = _float_vector(entry.get("attention_weight"))
if len(weight) != hidden_size:
return outputs[-1] if outputs else None
bias = _float_entry(entry, "attention_bias", 0.0)
scores = [_dot(weight, row) + bias for row in outputs]
if not scores:
return None
max_score = max(scores)
exps = [math.exp(_clamp(score - max_score, -50.0, 50.0)) for score in scores]
total = sum(exps)
if total <= 0:
return outputs[-1]
attention = [value / total for value in exps]
return [
sum(attention[row_index] * outputs[row_index][hidden_index] for row_index in range(len(outputs)))
for hidden_index in range(hidden_size)
]
def _torch_lstm_step(
@@ -705,7 +1202,91 @@ def _is_direct_horizon(entry: dict[str, Any]) -> bool:
def _entry_horizon(entry: dict[str, Any], default: int) -> int:
return int(_clamp(_float_entry(entry, "target_horizon", float(max(1, default))), 1.0, 96.0))
horizons = _entry_target_horizons(entry)
requested = int(_clamp(_float_entry(entry, "target_horizon", float(max(1, default))), 1.0, 96.0))
if horizons:
if requested in horizons:
return requested
return min(horizons, key=lambda value: abs(value - requested))
return requested
def _entry_target_horizons(entry: dict[str, Any]) -> list[int]:
raw = entry.get("target_horizons")
if not isinstance(raw, list):
return []
horizons = []
for value in raw:
try:
horizon = int(value)
except (TypeError, ValueError):
continue
if 1 <= horizon <= 96 and horizon not in horizons:
horizons.append(horizon)
return horizons
def _entry_output_layout(entry: dict[str, Any]) -> list[str]:
raw = entry.get("output_layout")
if isinstance(raw, list) and raw:
return [str(value) for value in raw]
return ["mean", "q10", "q50", "q90", "logit_up"]
def _target_vector(
entry: dict[str, Any],
plural_key: str,
scalar_key: str,
size: int,
default: float,
) -> list[float]:
raw = entry.get(plural_key)
if isinstance(raw, dict):
values = []
for horizon in _entry_target_horizons(entry):
values.append(float(raw.get(str(horizon), default)))
if len(values) == size:
return values
if isinstance(raw, list) and len(raw) == size:
return [float(value) for value in raw]
scalar = _float_entry(entry, scalar_key, default)
return [scalar for _ in range(size)]
def _select_horizon_prediction(prediction: dict[str, Any], horizon: int) -> dict[str, Any] | None:
horizons = prediction.get("horizons")
if not isinstance(horizons, dict) or not horizons:
return None
key = str(horizon)
selected = horizons.get(key)
if isinstance(selected, dict):
return selected
numeric = []
for raw_key, value in horizons.items():
try:
numeric.append((abs(int(raw_key) - horizon), value))
except (TypeError, ValueError):
continue
numeric.sort(key=lambda item: item[0])
return numeric[0][1] if numeric and isinstance(numeric[0][1], dict) else None
def _public_horizon_forecasts(prediction: dict[str, Any]) -> dict[str, Any]:
horizons = prediction.get("horizons")
if not isinstance(horizons, dict):
return {}
public: dict[str, Any] = {}
for key, row in horizons.items():
if not isinstance(row, dict):
continue
public[key] = {
"expected_return_percent": round((math.exp(float(row.get("expected_return", 0.0))) - 1) * 100, 4),
"probability_up": round(_clamp(float(row.get("probability_up", 0.5)), 0.0, 1.0), 4),
"quantile_10_percent": round((math.exp(float(row.get("q10", 0.0))) - 1) * 100, 4),
"quantile_50_percent": round((math.exp(float(row.get("q50", 0.0))) - 1) * 100, 4),
"quantile_90_percent": round((math.exp(float(row.get("q90", 0.0))) - 1) * 100, 4),
}
return public
def _float_entry(data: dict[str, Any], key: str, default: float) -> float:
@@ -762,6 +1343,24 @@ def _horizon_return_scale(closes: list[float], horizon: int) -> float:
return _return_scale(values) if values else 0.0005
def _current_volatility_scale(candles: list[Candle], closes: list[float], horizon: int) -> float:
horizon = max(1, horizon)
latest = candles[-1] if candles else None
close = closes[-1] if closes else (latest.close if latest else 0.0)
atr_scale = 0.0
if latest and latest.atr_14 is not None and close > 0:
atr_scale = (latest.atr_14 / close) * math.sqrt(horizon)
realized = _horizon_return_scale(closes, horizon)
one_step = _return_scale(_log_returns(closes)) * math.sqrt(horizon) if len(closes) > 2 else 0.0
return max(atr_scale * 0.7, realized, one_step, 0.0005)
def _prediction_cap(closes: list[float], horizon: int, round_trip_cost: float) -> float:
values = sorted(abs(value) for value in _horizon_log_returns(closes, horizon)[-96:])
base = values[int(len(values) * 0.9)] if values else 0.0
return max(base * 1.5 + round_trip_cost, 0.0005)
def _sigmoid(value: float) -> float:
if value >= 40:
return 1.0