feat: train forecasts on trade outcomes

This commit is contained in:
Курнат Андрей
2026-07-14 07:49:32 +03:00
parent 668e606ee2
commit 7186acb9a1
18 changed files with 867 additions and 94 deletions
+14 -7
View File
@@ -689,7 +689,7 @@ def _torch_forecast_entry_signal(
sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol)
position_notional = float(sizing["notional_usdt"])
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
probability_up = _forecast_probability(forecast)
skill = _safe_float(forecast.get("skill"), 0.0)
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
min_probability = _clamp(
@@ -945,7 +945,7 @@ def _torch_forecast_exit_signal(
)
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
probability_up = _forecast_probability(forecast)
skill = _safe_float(forecast.get("skill"), 0.0)
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
min_probability = _clamp(
@@ -1140,7 +1140,7 @@ def _dynamic_symbol_position_limit(settings: Settings) -> int:
def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float:
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
probability_up = _forecast_probability(forecast)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_strength = _clamp(expected_return / max(min_edge * 4.0, 0.01), 0.0, 1.0)
@@ -1168,7 +1168,7 @@ def _torch_forecast_position_sizing(
symbol=symbol,
)
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
probability_up = _forecast_probability(forecast)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
@@ -1323,7 +1323,7 @@ def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> f
multiplier = 1.0
forecast = forecast or {}
if forecast.get("usable"):
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
probability_up = _forecast_probability(forecast)
volatility_percent = _safe_float(forecast.get("volatility_percent"), 0.0)
if probability_up < 0.52:
multiplier *= 0.75
@@ -1360,7 +1360,7 @@ def _kelly_position(
probability_source = "confidence"
probability = confidence_probability
if forecast.get("usable"):
probability = _safe_float(forecast.get("probability_up"), confidence_probability)
probability = _forecast_probability(forecast, confidence_probability)
probability_source = "forecast"
probability = _clamp(probability, 0.0, 1.0)
@@ -1661,6 +1661,13 @@ def _rebound_state(
}
def _forecast_probability(forecast: dict, default: float = 0.5) -> float:
value = forecast.get("probability_take_profit_first")
if not isinstance(value, (int, float, str)):
value = forecast.get("probability_up")
return _clamp(_safe_float(value, default), 0.0, 1.0)
def _safe_float(value: object, default: float = 0.0) -> float:
try:
return float(value)
@@ -1783,7 +1790,7 @@ def _forecast_exit_signal(
return None
skill = _safe_float(forecast.get("skill"), 0.0)
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
probability_up = _forecast_probability(forecast)
min_edge = max(0.0, min_edge_percent)
strong_negative = skill > 0.02 and expected_return <= -max(min_edge, 0.03) and probability_up <= 0.44
if not strong_negative:
+138 -9
View File
@@ -163,6 +163,7 @@ class TimeSeriesForecast:
calibrated_min_edge_percent: float = 0.0
calibrated_min_probability_up: float = 0.0
calibrated_min_confidence: float = 0.0
probability_take_profit_first: float | None = None
def as_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -254,6 +255,7 @@ class TimeSeriesForecaster:
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)
target_transform = str(entry.get("target_transform", "net_return_over_volatility"))
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)
@@ -279,12 +281,16 @@ class TimeSeriesForecaster:
or (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,
reason = (
_barrier_reason(model, expected_return_percent, probability_up, skill, block_entry)
if target_transform == "barrier_net_return"
else _reason(
model=model,
expected_return_percent=expected_return_percent,
probability_up=probability_up,
skill=skill,
block_entry=block_entry,
)
)
if not symbol_eligible:
reason = "symbol excluded by train-only calibration"
@@ -292,7 +298,11 @@ class TimeSeriesForecaster:
enabled=True,
usable=True,
model=model,
volatility_model="probabilistic multi-horizon after-cost quantile",
volatility_model=(
"TP-before-SL multi-task after-cost model"
if target_transform == "barrier_net_return"
else "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),
@@ -309,7 +319,7 @@ class TimeSeriesForecaster:
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")),
target_transform=target_transform,
feature_snapshot=feature_snapshot,
horizon_forecasts=_public_horizon_forecasts(prediction),
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
@@ -321,6 +331,11 @@ class TimeSeriesForecaster:
calibrated_min_edge_percent=calibrated["edge"],
calibrated_min_probability_up=calibrated["probability"],
calibrated_min_confidence=calibrated["confidence"],
probability_take_profit_first=(
round(probability_up, 4)
if target_transform == "barrier_net_return"
else None
),
)
direct_horizon = _is_direct_horizon(entry)
@@ -1172,6 +1187,47 @@ def _average_ensemble_predictions(predictions: list[float | dict[str, Any]]) ->
def _torch_head_outputs(context: list[float], entry: dict[str, Any], hidden_size: int) -> list[float]:
context = _apply_context_norm(context, entry)
if entry.get("multitask_head") is True:
hidden_matrix = _float_matrix(entry.get("head_hidden_weight"))
hidden_bias = _float_vector(entry.get("head_hidden_bias"))
if not hidden_matrix or len(hidden_bias) != len(hidden_matrix):
return []
shared = [
_gelu(_dot(row, context) + hidden_bias[index])
for index, row in enumerate(hidden_matrix)
if len(row) == hidden_size
]
if len(shared) != len(hidden_matrix):
return []
return_matrix = _float_matrix(entry.get("return_head_weight"))
return_bias = _float_vector(entry.get("return_head_bias"))
event_matrix = _float_matrix(entry.get("event_head_weight"))
event_bias = _float_vector(entry.get("event_head_bias"))
if (
not return_matrix
or len(return_bias) != len(return_matrix)
or not event_matrix
or len(event_bias) != len(event_matrix)
):
return []
return_values = [
_dot(row, shared) + return_bias[index]
for index, row in enumerate(return_matrix)
if len(row) == len(shared)
]
event_values = [
_dot(row, shared) + event_bias[index]
for index, row in enumerate(event_matrix)
if len(row) == len(shared)
]
if len(return_values) != len(event_values) * 4:
return []
outputs: list[float] = []
for horizon_index, event_value in enumerate(event_values):
base = horizon_index * 4
outputs.extend(return_values[base : base + 4])
outputs.append(event_value)
return outputs
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)
@@ -1242,8 +1298,18 @@ def _decode_multi_horizon_prediction(
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)))
probability_up = _sigmoid(
float(values.get("logit_tp_first", values.get("logit_up", 0.0)))
)
cap = _prediction_cap(closes, horizon, round_trip_cost)
if str(entry.get("target_transform", "")) == "barrier_net_return":
stop_percent = _clamp(_float_entry(entry, "target_stop_loss_percent", 0.04), 0.003, 0.08)
take_percent = _clamp(_float_entry(entry, "target_take_profit_percent", 0.035), 0.003, 0.20)
cap = max(
cap,
abs(math.log(1.0 - stop_percent) - round_trip_cost),
abs(math.log(1.0 + take_percent) - round_trip_cost),
)
expected = _clamp(expected, -cap, cap)
q10 = _clamp(q_values[0], -cap, cap)
q50 = _clamp(q_values[1], -cap, cap)
@@ -1267,6 +1333,11 @@ def _decode_multi_horizon_prediction(
"q50": q50,
"q90": q90,
"probability_up": probability_up,
"probability_take_profit_first": (
probability_up
if str(entry.get("target_transform", "")) == "barrier_net_return"
else None
),
"volatility_scale": vol_scale,
"validation_mae": mae,
"baseline_mae": base_mae,
@@ -1697,6 +1768,10 @@ def _public_horizon_forecasts(prediction: dict[str, Any]) -> dict[str, Any]:
"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),
}
if isinstance(row.get("probability_take_profit_first"), (int, float)):
public[key]["probability_take_profit_first"] = round(
_clamp(float(row["probability_take_profit_first"]), 0.0, 1.0), 4
)
return public
@@ -1728,6 +1803,10 @@ def _dot(left: list[float], right: list[float]) -> float:
return sum(left[index] * right[index] for index in range(min(len(left), len(right))))
def _gelu(value: float) -> float:
return 0.5 * value * (1.0 + math.erf(value / math.sqrt(2.0)))
def _return_scale(returns: list[float]) -> float:
recent = returns[-120:] if len(returns) > 120 else returns
values = sorted(abs(value) for value in recent if math.isfinite(value))
@@ -1772,6 +1851,41 @@ def _prediction_cap(closes: list[float], horizon: int, round_trip_cost: float) -
return max(base * 1.5 + round_trip_cost, 0.0005)
def _barrier_outcome(
candles: list[Candle],
*,
end_index: int,
horizon: int,
stop_loss_percent: float,
take_profit_percent: float,
round_trip_cost: float,
) -> tuple[float, float] | None:
"""Return after-cost log PnL and whether TP was reached before SL."""
entry_index = end_index + 1
exit_index = end_index + max(1, horizon)
if entry_index >= len(candles) or exit_index >= len(candles):
return None
entry = float(candles[entry_index].open)
if entry <= 0:
return None
stop = entry * (1.0 - _clamp(stop_loss_percent, 0.003, 0.08))
take = entry * (1.0 + _clamp(take_profit_percent, 0.003, 0.20))
for index in range(entry_index, exit_index + 1):
candle = candles[index]
stop_hit = float(candle.low) <= stop
take_hit = float(candle.high) >= take
# OHLC data cannot reveal intrabar ordering, so ties are resolved
# conservatively as stop-loss first.
if stop_hit:
return math.log(stop / entry) - round_trip_cost, 0.0
if take_hit:
return math.log(take / entry) - round_trip_cost, 1.0
terminal = float(candles[exit_index].close)
if terminal <= 0:
return None
return math.log(terminal / entry) - round_trip_cost, 0.0
def _sigmoid(value: float) -> float:
if value >= 40:
return 1.0
@@ -1813,6 +1927,21 @@ def _reason(
return f"model {model}: forecast {expected_return_percent:.3f}%, P(up)={probability_up:.2f}, skill={skill:.3f}"
def _barrier_reason(
model: str,
expected_return_percent: float,
probability_take_profit_first: float,
skill: float,
block_entry: bool,
) -> str:
state = "entry blocked" if block_entry else "entry evaluated"
return (
f"model {model}: expected net {expected_return_percent:.3f}%, "
f"P(TP before SL)={probability_take_profit_first:.2f}, "
f"skill={skill:.3f}; {state}"
)
def _normal_cdf(value: float) -> float:
return 0.5 * (1 + math.erf(value / math.sqrt(2)))
+45 -6
View File
@@ -379,11 +379,19 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
"layers",
"dropouts",
"epochs",
"validation_window",
"holdout_window",
"ensemble_seeds",
"selection_folds",
"learning_rate",
"weight_decay",
"horizon",
"horizons",
"patience",
"context_symbols",
"features",
"seed",
"interval",
"pooled",
"resume_candidate",
}
@@ -391,8 +399,12 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
for key, low, high in (
("limit", 500, 20000),
("epochs", 1, 200),
("validation_window", 64, 2000),
("holdout_window", 64, 1000),
("selection_folds", 1, 12),
("horizon", 1, 96),
("patience", 1, 50),
("seed", 1, 2_147_483_647),
):
if key not in result:
continue
@@ -414,9 +426,19 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
if item.strip().lower() in {"lstm", "gru"}
]
result["architectures"] = ",".join(architectures) or "lstm,gru"
for key in ("lookbacks", "hidden_sizes", "layers", "dropouts", "ensemble_seeds"):
for key in (
"lookbacks",
"hidden_sizes",
"layers",
"dropouts",
"ensemble_seeds",
"horizons",
"context_symbols",
"features",
"interval",
):
if key in result:
result[key] = str(result[key])[:200]
result[key] = str(result[key])[: 4000 if key == "features" else 500]
for key, low, high in (
("learning_rate", 0.00001, 0.1),
("weight_decay", 0.0, 0.1),
@@ -471,10 +493,27 @@ def _validate_symbol_models(symbols: dict[str, Any]) -> None:
raise ValueError(f"candidate model dimensions are invalid: {symbol}") from exc
if not 4 <= lookback <= 512 or not 1 <= input_size <= 256 or not 1 <= hidden_size <= 1024:
raise ValueError(f"candidate model dimensions are out of range: {symbol}")
if not isinstance(entry.get("state_dict"), dict):
raise ValueError(f"candidate recurrent state is missing: {symbol}")
if not isinstance(entry.get("head_weight"), list) or not isinstance(entry.get("head_bias"), list):
raise ValueError(f"candidate forecast head is missing: {symbol}")
members = entry.get("ensemble_members")
payloads = members if isinstance(members, list) and members else [entry]
for payload in payloads:
if not isinstance(payload, dict) or not isinstance(payload.get("state_dict"), dict):
raise ValueError(f"candidate recurrent state is missing: {symbol}")
merged = {**entry, **payload}
if merged.get("multitask_head") is True:
required = (
"head_hidden_weight",
"head_hidden_bias",
"return_head_weight",
"return_head_bias",
"event_head_weight",
"event_head_bias",
)
if any(not isinstance(merged.get(name), list) for name in required):
raise ValueError(f"candidate multitask forecast head is missing: {symbol}")
elif not isinstance(merged.get("head_weight"), list) or not isinstance(
merged.get("head_bias"), list
):
raise ValueError(f"candidate forecast head is missing: {symbol}")
def _compact_now() -> str: