feat: train forecasts on trade outcomes
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user