feat: add orderbook shadow training pipeline

This commit is contained in:
Курнат Андрей
2026-07-15 09:44:29 +03:00
parent f7a625586e
commit 5d8ad1437e
19 changed files with 1486 additions and 23 deletions
+115 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import logging
import math
import sqlite3
from datetime import datetime
@@ -14,7 +15,7 @@ from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now
from crypto_spot_bot.patterns import PatternAnalyzer
from crypto_spot_bot.strategy import SpotStrategy, torch_model_readiness_reasons
from crypto_spot_bot.storage import Storage
from crypto_spot_bot.time_series import TimeSeriesForecaster
from crypto_spot_bot.time_series import TimeSeriesForecaster, _barrier_outcome
logger = logging.getLogger(__name__)
@@ -31,6 +32,7 @@ class CryptoSpotBot:
pattern_analyzer: PatternAnalyzer,
learner: TradeLearner,
forecaster: TimeSeriesForecaster | None = None,
shadow_forecaster: TimeSeriesForecaster | None = None,
llm_advisor=None,
):
self.settings = settings
@@ -41,6 +43,7 @@ class CryptoSpotBot:
self.pattern_analyzer = pattern_analyzer
self.learner = learner
self.forecaster = forecaster
self.shadow_forecaster = shadow_forecaster
self.llm_advisor = llm_advisor
self.running = False
self.started_at: datetime | None = None
@@ -52,6 +55,8 @@ class CryptoSpotBot:
self._last_reconciliation_at: datetime | None = None
self._last_prune_at: datetime | None = None
self._consecutive_loop_errors = 0
self._orderbook_feature_cache_key: tuple[tuple[str, int], ...] = ()
self._orderbook_feature_cache: dict[str, dict[int, dict[str, float]]] = {}
async def start(self) -> None:
if self.running:
@@ -416,6 +421,25 @@ class CryptoSpotBot:
self.market.patterns = patterns
def _update_forecasts(self) -> None:
cache_key = tuple(
(symbol, rows[-1].timestamp if rows else 0)
for symbol, rows in sorted(self.market.candles.items())
)
earliest_timestamp = min(
(rows[0].timestamp for rows in self.market.candles.values() if rows),
default=0,
)
if cache_key != self._orderbook_feature_cache_key:
orderbook_features, _manifest = self.storage.recent_aggregated_orderbook_features(
interval=self.settings.base_interval,
symbols=self.market.symbols,
after_timestamp_ms=earliest_timestamp,
min_samples_per_bucket=20,
)
self._orderbook_feature_cache = orderbook_features
self._orderbook_feature_cache_key = cache_key
else:
orderbook_features = self._orderbook_feature_cache
if (
self.forecaster is None
or not self.settings.time_series_forecast_enabled
@@ -429,8 +453,98 @@ class CryptoSpotBot:
symbol=symbol,
market_candles=self.market.candles,
trend_candles=self.market.trend_candles.get(symbol, []),
orderbook_features=orderbook_features,
).as_dict()
self.market.forecasts = forecasts
self._update_shadow_forecasts(orderbook_features)
def _update_shadow_forecasts(
self,
orderbook_features: dict[str, dict[int, dict[str, float]]],
) -> None:
if self.shadow_forecaster is None:
self.market.shadow_forecasts = {}
return
model_sha256 = self.shadow_forecaster.artifact_sha256()
if not model_sha256:
self.market.shadow_forecasts = {}
return
forecasts: dict[str, dict] = {}
for symbol in self.market.symbols:
candles = self.market.candles.get(symbol, [])
forecast = self.shadow_forecaster.forecast(
candles,
symbol=symbol,
market_candles=self.market.candles,
trend_candles=self.market.trend_candles.get(symbol, []),
orderbook_features=orderbook_features,
).as_dict()
forecast["shadow"] = True
forecast["model_sha256"] = model_sha256
forecasts[symbol] = forecast
self._record_and_settle_shadow(symbol, candles, forecast, model_sha256)
self.market.shadow_forecasts = forecasts
def _record_and_settle_shadow(
self,
symbol: str,
candles: list,
forecast: dict,
model_sha256: str,
) -> None:
if candles and forecast.get("usable"):
probability = float(
forecast.get("probability_take_profit_first")
if forecast.get("probability_take_profit_first") is not None
else forecast.get("probability_up", 0.5)
)
expected = float(forecast.get("expected_return_percent", 0.0) or 0.0)
eligible = bool(
not forecast.get("block_entry")
and expected >= float(forecast.get("calibrated_min_edge_percent", 0.0) or 0.0)
and probability >= float(forecast.get("calibrated_min_probability_up", 0.5) or 0.5)
)
self.storage.insert_shadow_prediction(
model_sha256=model_sha256,
symbol=symbol,
forecast_timestamp_ms=candles[-1].timestamp,
horizon=max(1, int(forecast.get("horizon", 1) or 1)),
reference_price=float(candles[-1].close),
expected_return_percent=expected,
probability_up=probability,
eligible_signal=eligible,
)
if not candles:
return
indexes = {candle.timestamp: index for index, candle in enumerate(candles)}
round_trip_cost = 2.0 * (
float(self.settings.taker_fee_rate) + float(self.settings.slippage_rate)
)
for row in self.storage.pending_shadow_predictions(
model_sha256=model_sha256,
symbol=symbol,
):
index = indexes.get(int(row.get("forecast_timestamp_ms", 0) or 0))
horizon = max(1, int(row.get("horizon", 1) or 1))
if index is None or index + horizon >= len(candles):
continue
outcome = _barrier_outcome(
candles,
end_index=index,
horizon=horizon,
stop_loss_percent=float(self.settings.stop_loss_percent),
take_profit_percent=float(self.settings.take_profit_percent),
round_trip_cost=round_trip_cost,
)
if outcome is None:
continue
actual_log_return, take_profit_first = outcome
actual_return_percent = (math.exp(actual_log_return) - 1.0) * 100.0
self.storage.settle_shadow_prediction(
int(row["id"]),
actual_return_percent=actual_return_percent,
take_profit_first=take_profit_first >= 0.5,
)
def status(self) -> BotStatus:
live_ready = self.settings.live_ready