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
+81 -10
View File
@@ -28,6 +28,7 @@ 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,
_barrier_outcome,
_current_volatility_scale,
_entry_horizon,
_entry_output_layout,
@@ -64,6 +65,7 @@ class ForecastRecord:
future_net_percent: float
benchmark_entry: bool
benchmark_exit: bool
take_profit_first: bool | None = None
@dataclass(slots=True)
@@ -385,7 +387,22 @@ def _forecast_records(
next_open = float(candles[index + 1].open)
if next_open <= 0:
continue
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
take_profit_first: bool | None = None
if str(entry.get("target_transform", "")) == "barrier_net_return":
outcome = _barrier_outcome(
candles,
end_index=index,
horizon=decision_horizon,
stop_loss_percent=_float_entry(entry, "target_stop_loss_percent", 0.04),
take_profit_percent=_float_entry(entry, "target_take_profit_percent", 0.035),
round_trip_cost=round_trip_cost,
)
if outcome is None:
continue
future_log_return, event = outcome
take_profit_first = event >= 0.5
else:
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
records.append(
ForecastRecord(
@@ -407,6 +424,7 @@ def _forecast_records(
future_net_percent=future_net_percent,
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
benchmark_exit=_benchmark_exit_signal(candles, index),
take_profit_first=take_profit_first,
)
)
return records
@@ -497,7 +515,22 @@ def _batch_forecast_records(
next_open = float(candles[index + 1].open)
if next_open <= 0:
continue
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
take_profit_first: bool | None = None
if str(entry.get("target_transform", "")) == "barrier_net_return":
outcome = _barrier_outcome(
candles,
end_index=index,
horizon=decision_horizon,
stop_loss_percent=_float_entry(entry, "target_stop_loss_percent", 0.04),
take_profit_percent=_float_entry(entry, "target_take_profit_percent", 0.035),
round_trip_cost=round_trip_cost,
)
if outcome is None:
continue
future_log_return, event = outcome
take_profit_first = event >= 0.5
else:
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
records.append(
ForecastRecord(
@@ -519,6 +552,7 @@ def _batch_forecast_records(
future_net_percent=future_net_percent,
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
benchmark_exit=_benchmark_exit_signal(candles, index),
take_profit_first=take_profit_first,
)
)
return records
@@ -557,6 +591,10 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
output_size=output_size,
attention_pooling=bool(entry.get("attention_pooling")),
context_norm=bool(entry.get("context_norm")),
multitask_head=bool(entry.get("multitask_head")),
head_hidden_size=int(
_clamp(_float_entry(entry, "head_hidden_size", float(hidden_size)), 8.0, 1024.0)
),
)
raw_state = entry.get("state_dict")
if not isinstance(raw_state, dict):
@@ -566,12 +604,26 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
for key, value in raw_state.items()
if isinstance(value, list)
}
head_weight = entry.get("head_weight")
head_bias = entry.get("head_bias")
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
return None
state["head.weight"] = torch.tensor(head_weight, dtype=torch.float32)
state["head.bias"] = torch.tensor(head_bias, dtype=torch.float32)
if bool(entry.get("multitask_head")):
for artifact_name, state_name in (
("head_hidden_weight", "head_hidden.weight"),
("head_hidden_bias", "head_hidden.bias"),
("return_head_weight", "return_head.weight"),
("return_head_bias", "return_head.bias"),
("event_head_weight", "event_head.weight"),
("event_head_bias", "event_head.bias"),
):
value = entry.get(artifact_name)
if not isinstance(value, list):
return None
state[state_name] = torch.tensor(value, dtype=torch.float32)
else:
head_weight = entry.get("head_weight")
head_bias = entry.get("head_bias")
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
return None
state["head.weight"] = torch.tensor(head_weight, dtype=torch.float32)
state["head.bias"] = torch.tensor(head_bias, dtype=torch.float32)
if bool(entry.get("attention_pooling")):
attention_weight = entry.get("attention_weight")
if not isinstance(attention_weight, list):
@@ -639,10 +691,20 @@ def _decode_selected_output(
expected = decode("mean")
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
cap = _prediction_cap(history_closes, selected_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),
)
return {
"expected_return": _clamp(expected, -cap, cap),
"q50": _clamp(q_values[1], -cap, cap),
"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)))
),
}
@@ -1422,7 +1484,7 @@ def _fit_platt_calibration(records: list[ForecastRecord]) -> dict[str, float]:
samples = [
(
math.log(_clamp(record.probability_up, 1e-5, 1.0 - 1e-5) / (1.0 - _clamp(record.probability_up, 1e-5, 1.0 - 1e-5))),
1.0 if record.future_net_percent > 0 else 0.0,
_record_event_target(record),
)
for record in records
]
@@ -1446,6 +1508,12 @@ def _fit_platt_calibration(records: list[ForecastRecord]) -> dict[str, float]:
return {"slope": round(slope, 8), "intercept": round(intercept, 8), "samples": float(len(samples))}
def _record_event_target(record: ForecastRecord) -> float:
if record.take_profit_first is not None:
return 1.0 if record.take_profit_first else 0.0
return 1.0 if record.future_net_percent > 0 else 0.0
def _apply_platt_calibration(
records: list[ForecastRecord], calibration: dict[str, float]
) -> list[ForecastRecord]:
@@ -1544,6 +1612,9 @@ def _artifact_summary(artifact: dict[str, Any]) -> dict[str, Any]:
"target_horizon": artifact.get("target_horizon"),
"target_horizons": artifact.get("target_horizons"),
"target_transform": artifact.get("target_transform"),
"event_target": artifact.get("event_target"),
"target_stop_loss_percent": artifact.get("target_stop_loss_percent"),
"target_take_profit_percent": artifact.get("target_take_profit_percent"),
"symbols": {
symbol: {
"model": row.get("model"),