feat: collect Bybit orderbook observations for training
This commit is contained in:
@@ -30,6 +30,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
|
|||||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
FAST_ENTRY_COOLDOWN_SECONDS=20
|
||||||
MAX_ENTRIES_PER_MINUTE=12
|
MAX_ENTRIES_PER_MINUTE=12
|
||||||
WEBSOCKET_ENABLED=true
|
WEBSOCKET_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_SAMPLE_SECONDS=30
|
||||||
MIN_SIGNAL_CONFIDENCE=0.64
|
MIN_SIGNAL_CONFIDENCE=0.64
|
||||||
MAX_SPREAD_PERCENT=0.18
|
MAX_SPREAD_PERCENT=0.18
|
||||||
MIN_24H_TURNOVER_USDT=1000000
|
MIN_24H_TURNOVER_USDT=1000000
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Spot-бот для демо-торговли криптовалютой на р
|
|||||||
## Что реализовано
|
## Что реализовано
|
||||||
|
|
||||||
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
|
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
|
||||||
|
- Сэмплированный L1-стакан Bybit сохраняется в SQLite: bid/ask size, spread, imbalance и microprice доступны обучающему агенту через защищённый постраничный API. Частота по умолчанию — один sample на пару каждые 30 секунд, а хранилище ограничено 1 200 000 строк.
|
||||||
- Торговый universe автоматически строится из актуальных Bybit Spot-инструментов: выбираются до 12 ликвидных USDT-пар по `turnover24h`, исключаются stablecoin-to-stablecoin и leveraged-token пары; фиксированный список можно задать только явным `SYMBOLS`.
|
- Торговый universe автоматически строится из актуальных Bybit Spot-инструментов: выбираются до 12 ликвидных USDT-пар по `turnover24h`, исключаются stablecoin-to-stablecoin и leveraged-token пары; фиксированный список можно задать только явным `SYMBOLS`.
|
||||||
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
|
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
|
||||||
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
|
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
|
||||||
@@ -144,6 +145,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
|
|||||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
FAST_ENTRY_COOLDOWN_SECONDS=20
|
||||||
MAX_ENTRIES_PER_MINUTE=12
|
MAX_ENTRIES_PER_MINUTE=12
|
||||||
WEBSOCKET_ENABLED=true
|
WEBSOCKET_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_SAMPLE_SECONDS=30
|
||||||
MIN_SIGNAL_CONFIDENCE=0.64
|
MIN_SIGNAL_CONFIDENCE=0.64
|
||||||
PATTERN_ANALYSIS_ENABLED=true
|
PATTERN_ANALYSIS_ENABLED=true
|
||||||
PATTERN_SCORE_WEIGHT=0.18
|
PATTERN_SCORE_WEIGHT=0.18
|
||||||
@@ -243,6 +246,7 @@ Live-исполнение ведет журнал order intent до отправ
|
|||||||
- `GET /api/health` — healthcheck.
|
- `GET /api/health` — healthcheck.
|
||||||
- `GET /api/status` — статус бота, account snapshot, позиции.
|
- `GET /api/status` — статус бота, account snapshot, позиции.
|
||||||
- `GET /api/markets` — пары, ticker, свечи, инструменты.
|
- `GET /api/markets` — пары, ticker, свечи, инструменты.
|
||||||
|
- `GET /api/training/market-observations?symbol=BTCUSDT&after_id=0&limit=5000` — защищённая training-token выгрузка L1-наблюдений.
|
||||||
- `GET /api/trades` — последние сделки.
|
- `GET /api/trades` — последние сделки.
|
||||||
- `GET /api/signals` — последние сигналы стратегии.
|
- `GET /api/signals` — последние сигналы стратегии.
|
||||||
- `GET /api/events` — события.
|
- `GET /api/events` — события.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Crypto spot trading bot package."""
|
"""Crypto spot trading bot package."""
|
||||||
|
|
||||||
__version__ = "1.0.1"
|
__version__ = "1.0.2"
|
||||||
|
|||||||
@@ -220,6 +220,10 @@ class BybitClient:
|
|||||||
return candles
|
return candles
|
||||||
|
|
||||||
def orderbook_top(self, symbol: str) -> tuple[float, float]:
|
def orderbook_top(self, symbol: str) -> tuple[float, float]:
|
||||||
|
bid, _bid_size, ask, _ask_size = self.orderbook_level_one(symbol)
|
||||||
|
return bid, ask
|
||||||
|
|
||||||
|
def orderbook_level_one(self, symbol: str) -> tuple[float, float, float, float]:
|
||||||
result = self.public_get(
|
result = self.public_get(
|
||||||
"/v5/market/orderbook",
|
"/v5/market/orderbook",
|
||||||
{"category": "spot", "symbol": symbol, "limit": 1},
|
{"category": "spot", "symbol": symbol, "limit": 1},
|
||||||
@@ -227,8 +231,10 @@ class BybitClient:
|
|||||||
bids = result.get("b") or []
|
bids = result.get("b") or []
|
||||||
asks = result.get("a") or []
|
asks = result.get("a") or []
|
||||||
bid = _float(bids[0][0]) if bids else 0.0
|
bid = _float(bids[0][0]) if bids else 0.0
|
||||||
|
bid_size = _float(bids[0][1]) if bids and len(bids[0]) > 1 else 0.0
|
||||||
ask = _float(asks[0][0]) if asks else 0.0
|
ask = _float(asks[0][0]) if asks else 0.0
|
||||||
return bid, ask
|
ask_size = _float(asks[0][1]) if asks and len(asks[0]) > 1 else 0.0
|
||||||
|
return bid, bid_size, ask, ask_size
|
||||||
|
|
||||||
def place_spot_market_order(
|
def place_spot_market_order(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ class Settings:
|
|||||||
bybit_rest_base_url_override: str = ""
|
bybit_rest_base_url_override: str = ""
|
||||||
bybit_websocket_url_override: str = ""
|
bybit_websocket_url_override: str = ""
|
||||||
time_series_fallback_mode: str = "trend_macd"
|
time_series_fallback_mode: str = "trend_macd"
|
||||||
|
market_observation_enabled: bool = True
|
||||||
|
market_observation_sample_seconds: float = 30.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rest_base_url(self) -> str:
|
def rest_base_url(self) -> str:
|
||||||
@@ -358,6 +360,10 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
time_series_fallback_mode=os.getenv(
|
time_series_fallback_mode=os.getenv(
|
||||||
"TIME_SERIES_FALLBACK_MODE", "trend_macd"
|
"TIME_SERIES_FALLBACK_MODE", "trend_macd"
|
||||||
).strip().lower(),
|
).strip().lower(),
|
||||||
|
market_observation_enabled=_bool_env("MARKET_OBSERVATION_ENABLED", True),
|
||||||
|
market_observation_sample_seconds=_float_env(
|
||||||
|
"MARKET_OBSERVATION_SAMPLE_SECONDS", 30.0
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_validate_settings(settings)
|
_validate_settings(settings)
|
||||||
if settings.trading_mode == "live" and not settings.live_ready:
|
if settings.trading_mode == "live" and not settings.live_ready:
|
||||||
@@ -396,6 +402,8 @@ def _validate_settings(settings: Settings) -> None:
|
|||||||
errors.append("LIVE_RECONCILIATION_INTERVAL_SECONDS must be positive")
|
errors.append("LIVE_RECONCILIATION_INTERVAL_SECONDS must be positive")
|
||||||
if settings.time_series_fallback_mode not in {"trend_macd", "legacy"}:
|
if settings.time_series_fallback_mode not in {"trend_macd", "legacy"}:
|
||||||
errors.append("TIME_SERIES_FALLBACK_MODE must be trend_macd or legacy")
|
errors.append("TIME_SERIES_FALLBACK_MODE must be trend_macd or legacy")
|
||||||
|
if settings.market_observation_sample_seconds <= 0:
|
||||||
|
errors.append("MARKET_OBSERVATION_SAMPLE_SECONDS must be positive")
|
||||||
if errors:
|
if errors:
|
||||||
raise ValueError("; ".join(errors))
|
raise ValueError("; ".join(errors))
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,27 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return training.status()
|
return training.status()
|
||||||
|
|
||||||
|
@app.get("/api/training/market-observations")
|
||||||
|
async def training_market_observations(
|
||||||
|
symbol: str,
|
||||||
|
after_id: int = 0,
|
||||||
|
limit: int = 5000,
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
normalized_symbol = symbol.strip().upper()
|
||||||
|
if not normalized_symbol:
|
||||||
|
raise HTTPException(status_code=400, detail="symbol is required")
|
||||||
|
items = storage.market_observations_after(
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
after_id=max(0, after_id),
|
||||||
|
limit=max(1, min(limit, 5000)),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"symbol": normalized_symbol,
|
||||||
|
"items": items,
|
||||||
|
"next_after_id": int(items[-1]["id"]) if items else max(0, after_id),
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/training/retrain")
|
@app.post("/api/training/retrain")
|
||||||
async def training_retrain(
|
async def training_retrain(
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
@@ -407,6 +428,8 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"time_series_require_fresh_model": settings.time_series_require_fresh_model,
|
"time_series_require_fresh_model": settings.time_series_require_fresh_model,
|
||||||
"time_series_model_max_age_hours": settings.time_series_model_max_age_hours,
|
"time_series_model_max_age_hours": settings.time_series_model_max_age_hours,
|
||||||
"market_ticker_max_age_seconds": settings.market_ticker_max_age_seconds,
|
"market_ticker_max_age_seconds": settings.market_ticker_max_age_seconds,
|
||||||
|
"market_observation_enabled": settings.market_observation_enabled,
|
||||||
|
"market_observation_sample_seconds": settings.market_observation_sample_seconds,
|
||||||
"time_series_model_artifact": _time_series_model_artifact(settings),
|
"time_series_model_artifact": _time_series_model_artifact(settings),
|
||||||
"stop_loss_percent": settings.stop_loss_percent,
|
"stop_loss_percent": settings.stop_loss_percent,
|
||||||
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
|
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
|
||||||
|
|||||||
+116
-15
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -51,6 +52,7 @@ class MarketData:
|
|||||||
self.candles: dict[str, list[Candle]] = {}
|
self.candles: dict[str, list[Candle]] = {}
|
||||||
self.trend_candles: dict[str, list[Candle]] = {}
|
self.trend_candles: dict[str, list[Candle]] = {}
|
||||||
self.orderbook_top: dict[str, tuple[float, float]] = {}
|
self.orderbook_top: dict[str, tuple[float, float]] = {}
|
||||||
|
self.orderbook_metrics: dict[str, dict[str, Any]] = {}
|
||||||
self.patterns: dict[str, dict[str, Any]] = {}
|
self.patterns: dict[str, dict[str, Any]] = {}
|
||||||
self.forecasts: dict[str, dict[str, Any]] = {}
|
self.forecasts: dict[str, dict[str, Any]] = {}
|
||||||
self.last_rest_refresh_at: datetime | None = None
|
self.last_rest_refresh_at: datetime | None = None
|
||||||
@@ -60,6 +62,11 @@ class MarketData:
|
|||||||
self._refresh_lock = threading.Lock()
|
self._refresh_lock = threading.Lock()
|
||||||
self.rest_error_count = 0
|
self.rest_error_count = 0
|
||||||
self.last_rest_error = ""
|
self.last_rest_error = ""
|
||||||
|
self.observation_samples = 0
|
||||||
|
self.last_observation_at: datetime | None = None
|
||||||
|
self.observation_error_count = 0
|
||||||
|
self.last_observation_error = ""
|
||||||
|
self._last_observation_monotonic: dict[str, float] = {}
|
||||||
|
|
||||||
async def bootstrap(self) -> None:
|
async def bootstrap(self) -> None:
|
||||||
self.instruments = await asyncio.to_thread(self.client.instruments)
|
self.instruments = await asyncio.to_thread(self.client.instruments)
|
||||||
@@ -112,19 +119,8 @@ class MarketData:
|
|||||||
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
|
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
|
||||||
add_indicators(trend_candles)
|
add_indicators(trend_candles)
|
||||||
self.trend_candles[symbol] = trend_candles
|
self.trend_candles[symbol] = trend_candles
|
||||||
bid, ask = self.client.orderbook_top(symbol)
|
bid, bid_size, ask, ask_size = self.client.orderbook_level_one(symbol)
|
||||||
self.orderbook_top[symbol] = (bid, ask)
|
self._update_orderbook(symbol, bid, bid_size, ask, ask_size)
|
||||||
if symbol in self.tickers:
|
|
||||||
current = self.tickers[symbol]
|
|
||||||
self.tickers[symbol] = Ticker(
|
|
||||||
symbol=current.symbol,
|
|
||||||
last_price=current.last_price,
|
|
||||||
bid=bid or current.bid,
|
|
||||||
ask=ask or current.ask,
|
|
||||||
turnover_24h=current.turnover_24h,
|
|
||||||
volume_24h=current.volume_24h,
|
|
||||||
change_24h=current.change_24h,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.rest_error_count += 1
|
self.rest_error_count += 1
|
||||||
self.last_rest_error = str(exc)
|
self.last_rest_error = str(exc)
|
||||||
@@ -180,7 +176,11 @@ class MarketData:
|
|||||||
elif topic.startswith("orderbook.") and isinstance(data, dict):
|
elif topic.startswith("orderbook.") and isinstance(data, dict):
|
||||||
parts = topic.split(".")
|
parts = topic.split(".")
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
self._handle_orderbook(parts[2], data)
|
self._handle_orderbook(
|
||||||
|
parts[2],
|
||||||
|
data,
|
||||||
|
source_timestamp_ms=int(_float(message.get("ts"))),
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None:
|
def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None:
|
||||||
current = self.tickers.get(symbol)
|
current = self.tickers.get(symbol)
|
||||||
@@ -222,14 +222,66 @@ class MarketData:
|
|||||||
add_indicators(candles)
|
add_indicators(candles)
|
||||||
self.candles[symbol] = candles
|
self.candles[symbol] = candles
|
||||||
|
|
||||||
def _handle_orderbook(self, symbol: str, data: dict[str, Any]) -> None:
|
def _handle_orderbook(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
source_timestamp_ms: int = 0,
|
||||||
|
) -> None:
|
||||||
bids = data.get("b") or []
|
bids = data.get("b") or []
|
||||||
asks = data.get("a") or []
|
asks = data.get("a") or []
|
||||||
bid = _float(bids[0][0]) if bids else 0.0
|
bid = _float(bids[0][0]) if bids else 0.0
|
||||||
|
bid_size = _float(bids[0][1]) if bids and len(bids[0]) > 1 else 0.0
|
||||||
ask = _float(asks[0][0]) if asks else 0.0
|
ask = _float(asks[0][0]) if asks else 0.0
|
||||||
|
ask_size = _float(asks[0][1]) if asks and len(asks[0]) > 1 else 0.0
|
||||||
|
self._update_orderbook(
|
||||||
|
symbol,
|
||||||
|
bid,
|
||||||
|
bid_size,
|
||||||
|
ask,
|
||||||
|
ask_size,
|
||||||
|
source_timestamp_ms=source_timestamp_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_orderbook(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
bid: float,
|
||||||
|
bid_size: float,
|
||||||
|
ask: float,
|
||||||
|
ask_size: float,
|
||||||
|
*,
|
||||||
|
source_timestamp_ms: int = 0,
|
||||||
|
) -> None:
|
||||||
if bid > 0 and ask > 0:
|
if bid > 0 and ask > 0:
|
||||||
self.orderbook_top[symbol] = (bid, ask)
|
self.orderbook_top[symbol] = (bid, ask)
|
||||||
current = self.tickers.get(symbol)
|
current = self.tickers.get(symbol)
|
||||||
|
size_total = max(0.0, bid_size) + max(0.0, ask_size)
|
||||||
|
mid_price = (bid + ask) / 2.0
|
||||||
|
imbalance = (
|
||||||
|
(max(0.0, bid_size) - max(0.0, ask_size)) / size_total
|
||||||
|
if size_total > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
microprice = (
|
||||||
|
(ask * max(0.0, bid_size) + bid * max(0.0, ask_size)) / size_total
|
||||||
|
if size_total > 0
|
||||||
|
else mid_price
|
||||||
|
)
|
||||||
|
observed_at = utc_now()
|
||||||
|
metrics = {
|
||||||
|
"bid_price": bid,
|
||||||
|
"bid_size": max(0.0, bid_size),
|
||||||
|
"ask_price": ask,
|
||||||
|
"ask_size": max(0.0, ask_size),
|
||||||
|
"mid_price": mid_price,
|
||||||
|
"microprice": microprice,
|
||||||
|
"spread_bps": ((ask - bid) / mid_price) * 10_000 if mid_price > 0 else 0.0,
|
||||||
|
"imbalance": imbalance,
|
||||||
|
"source_timestamp_ms": max(0, source_timestamp_ms),
|
||||||
|
"observed_at": observed_at.isoformat(),
|
||||||
|
}
|
||||||
|
self.orderbook_metrics[symbol] = metrics
|
||||||
if current:
|
if current:
|
||||||
self.tickers[symbol] = Ticker(
|
self.tickers[symbol] = Ticker(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
@@ -240,6 +292,44 @@ class MarketData:
|
|||||||
volume_24h=current.volume_24h,
|
volume_24h=current.volume_24h,
|
||||||
change_24h=current.change_24h,
|
change_24h=current.change_24h,
|
||||||
)
|
)
|
||||||
|
self._sample_orderbook(symbol, metrics, current.last_price if current else mid_price, observed_at)
|
||||||
|
|
||||||
|
def _sample_orderbook(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
metrics: dict[str, Any],
|
||||||
|
last_price: float,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
if not self.settings.market_observation_enabled:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
previous = self._last_observation_monotonic.get(symbol)
|
||||||
|
if previous is not None and now - previous < self.settings.market_observation_sample_seconds:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.storage.insert_market_observation(
|
||||||
|
symbol=symbol,
|
||||||
|
bid_price=float(metrics["bid_price"]),
|
||||||
|
bid_size=float(metrics["bid_size"]),
|
||||||
|
ask_price=float(metrics["ask_price"]),
|
||||||
|
ask_size=float(metrics["ask_size"]),
|
||||||
|
mid_price=float(metrics["mid_price"]),
|
||||||
|
microprice=float(metrics["microprice"]),
|
||||||
|
spread_bps=float(metrics["spread_bps"]),
|
||||||
|
imbalance=float(metrics["imbalance"]),
|
||||||
|
last_price=last_price,
|
||||||
|
source_timestamp_ms=int(metrics["source_timestamp_ms"]),
|
||||||
|
created_at=observed_at,
|
||||||
|
)
|
||||||
|
except Exception as exc: # Storage errors must not disconnect market data.
|
||||||
|
self.observation_error_count += 1
|
||||||
|
self.last_observation_error = str(exc)
|
||||||
|
return
|
||||||
|
self._last_observation_monotonic[symbol] = now
|
||||||
|
self.observation_samples += 1
|
||||||
|
self.last_observation_at = observed_at
|
||||||
|
self.last_observation_error = ""
|
||||||
|
|
||||||
def prices(self) -> dict[str, float]:
|
def prices(self) -> dict[str, float]:
|
||||||
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
|
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
|
||||||
@@ -286,6 +376,16 @@ class MarketData:
|
|||||||
"last_ws_message_at": self.last_ws_message_at.isoformat()
|
"last_ws_message_at": self.last_ws_message_at.isoformat()
|
||||||
if self.last_ws_message_at
|
if self.last_ws_message_at
|
||||||
else None,
|
else None,
|
||||||
|
"observation_collector": {
|
||||||
|
"enabled": self.settings.market_observation_enabled,
|
||||||
|
"sample_seconds": self.settings.market_observation_sample_seconds,
|
||||||
|
"samples_since_start": self.observation_samples,
|
||||||
|
"last_observation_at": self.last_observation_at.isoformat()
|
||||||
|
if self.last_observation_at
|
||||||
|
else None,
|
||||||
|
"error_count": self.observation_error_count,
|
||||||
|
"last_error": self.last_observation_error,
|
||||||
|
},
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None,
|
"ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None,
|
||||||
@@ -293,6 +393,7 @@ class MarketData:
|
|||||||
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
|
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
|
||||||
"pattern": self.patterns.get(symbol),
|
"pattern": self.patterns.get(symbol),
|
||||||
"forecast": self.forecasts.get(symbol),
|
"forecast": self.forecasts.get(symbol),
|
||||||
|
"orderbook": self.orderbook_metrics.get(symbol),
|
||||||
"quality": analyze_symbol_quality(
|
"quality": analyze_symbol_quality(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
candles=self.candles.get(symbol, []),
|
candles=self.candles.get(symbol, []),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ MAX_RUNTIME_ROWS = {
|
|||||||
"equity": 100_000,
|
"equity": 100_000,
|
||||||
"events": 20_000,
|
"events": 20_000,
|
||||||
"llm_advice": 20_000,
|
"llm_advice": 20_000,
|
||||||
|
"market_observations": 1_200_000,
|
||||||
}
|
}
|
||||||
_STORED_FORECAST_KEYS = {
|
_STORED_FORECAST_KEYS = {
|
||||||
"enabled",
|
"enabled",
|
||||||
@@ -171,6 +172,21 @@ class Storage:
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS market_observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
bid_price REAL NOT NULL,
|
||||||
|
bid_size REAL NOT NULL,
|
||||||
|
ask_price REAL NOT NULL,
|
||||||
|
ask_size REAL NOT NULL,
|
||||||
|
mid_price REAL NOT NULL,
|
||||||
|
microprice REAL NOT NULL,
|
||||||
|
spread_bps REAL NOT NULL,
|
||||||
|
imbalance REAL NOT NULL,
|
||||||
|
last_price REAL NOT NULL,
|
||||||
|
source_timestamp_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_positions_status_opened
|
CREATE INDEX IF NOT EXISTS idx_positions_status_opened
|
||||||
ON positions(status, opened_at);
|
ON positions(status, opened_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_trades_closed
|
CREATE INDEX IF NOT EXISTS idx_trades_closed
|
||||||
@@ -183,6 +199,10 @@ class Storage:
|
|||||||
ON events(created_at DESC);
|
ON events(created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_status_updated
|
CREATE INDEX IF NOT EXISTS idx_orders_status_updated
|
||||||
ON orders(status, updated_at DESC);
|
ON orders(status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_observations_symbol_id
|
||||||
|
ON market_observations(symbol, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_observations_created
|
||||||
|
ON market_observations(created_at);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
columns = {
|
columns = {
|
||||||
@@ -458,6 +478,70 @@ class Storage:
|
|||||||
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def insert_market_observation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
bid_price: float,
|
||||||
|
bid_size: float,
|
||||||
|
ask_price: float,
|
||||||
|
ask_size: float,
|
||||||
|
mid_price: float,
|
||||||
|
microprice: float,
|
||||||
|
spread_bps: float,
|
||||||
|
imbalance: float,
|
||||||
|
last_price: float,
|
||||||
|
source_timestamp_ms: int = 0,
|
||||||
|
created_at: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
timestamp = (created_at or utc_now()).isoformat()
|
||||||
|
with self.connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_observations (
|
||||||
|
symbol, bid_price, bid_size, ask_price, ask_size,
|
||||||
|
mid_price, microprice, spread_bps, imbalance, last_price,
|
||||||
|
source_timestamp_ms, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
symbol.upper(),
|
||||||
|
bid_price,
|
||||||
|
bid_size,
|
||||||
|
ask_price,
|
||||||
|
ask_size,
|
||||||
|
mid_price,
|
||||||
|
microprice,
|
||||||
|
spread_bps,
|
||||||
|
imbalance,
|
||||||
|
last_price,
|
||||||
|
max(0, source_timestamp_ms),
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def market_observations_after(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
after_id: int = 0,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
row_limit = max(1, min(limit, 5000))
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM market_observations
|
||||||
|
WHERE symbol = ? AND id > ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(symbol.upper(), max(0, after_id), row_limit),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def insert_equity(
|
def insert_equity(
|
||||||
self,
|
self,
|
||||||
equity: float,
|
equity: float,
|
||||||
@@ -635,7 +719,7 @@ class Storage:
|
|||||||
return {}
|
return {}
|
||||||
cutoff = (utc_now() - timedelta(days=retention_days)).isoformat()
|
cutoff = (utc_now() - timedelta(days=retention_days)).isoformat()
|
||||||
deleted: dict[str, int] = {}
|
deleted: dict[str, int] = {}
|
||||||
for table in ("signals", "equity", "events", "llm_advice"):
|
for table in ("signals", "equity", "events", "llm_advice", "market_observations"):
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
max_id_row = conn.execute(f"SELECT MAX(id) AS value FROM {table}").fetchone()
|
max_id_row = conn.execute(f"SELECT MAX(id) AS value FROM {table}").fetchone()
|
||||||
max_id = int(max_id_row["value"] or 0) if max_id_row else 0
|
max_id = int(max_id_row["value"] or 0) if max_id_row else 0
|
||||||
@@ -676,7 +760,17 @@ class Storage:
|
|||||||
|
|
||||||
def clear_all(self) -> None:
|
def clear_all(self) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice", "orders"):
|
for table in (
|
||||||
|
"positions",
|
||||||
|
"trades",
|
||||||
|
"signals",
|
||||||
|
"equity",
|
||||||
|
"events",
|
||||||
|
"runtime",
|
||||||
|
"llm_advice",
|
||||||
|
"orders",
|
||||||
|
"market_observations",
|
||||||
|
):
|
||||||
conn.execute(f"DELETE FROM {table}")
|
conn.execute(f"DELETE FROM {table}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,3 +125,14 @@ def test_websocket_subscribe_uses_configured_kline_interval() -> None:
|
|||||||
|
|
||||||
assert "kline.60.BTCUSDT" in payload
|
assert "kline.60.BTCUSDT" in payload
|
||||||
assert "kline.1.BTCUSDT" not in payload
|
assert "kline.1.BTCUSDT" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_orderbook_level_one_preserves_sizes(make_settings, tmp_path) -> None:
|
||||||
|
client = BybitClient(make_settings(tmp_path))
|
||||||
|
client.public_get = lambda *_args, **_kwargs: {
|
||||||
|
"b": [["100.5", "2.25"]],
|
||||||
|
"a": [["100.7", "1.75"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert client.orderbook_level_one("BTCUSDT") == (100.5, 2.25, 100.7, 1.75)
|
||||||
|
assert client.orderbook_top("BTCUSDT") == (100.5, 100.7)
|
||||||
|
|||||||
@@ -180,3 +180,12 @@ def test_load_settings_rejects_unknown_fallback_mode(tmp_path, monkeypatch) -> N
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="TIME_SERIES_FALLBACK_MODE"):
|
with pytest.raises(ValueError, match="TIME_SERIES_FALLBACK_MODE"):
|
||||||
load_settings(env_file)
|
load_settings(env_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_settings_rejects_non_positive_observation_interval(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("MARKET_OBSERVATION_SAMPLE_SECONDS", raising=False)
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("MARKET_OBSERVATION_SAMPLE_SECONDS=0\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="MARKET_OBSERVATION_SAMPLE_SECONDS"):
|
||||||
|
load_settings(env_file)
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
|
|||||||
assert config["time_series_probe_size_multiplier"] == 0.40
|
assert config["time_series_probe_size_multiplier"] == 0.40
|
||||||
assert config["time_series_rebound_fallback_enabled"] is True
|
assert config["time_series_rebound_fallback_enabled"] is True
|
||||||
assert config["time_series_fallback_mode"] == "trend_macd"
|
assert config["time_series_fallback_mode"] == "trend_macd"
|
||||||
|
assert config["market_observation_enabled"] is True
|
||||||
|
assert config["market_observation_sample_seconds"] == 30.0
|
||||||
assert config["time_series_model_artifact"] == {
|
assert config["time_series_model_artifact"] == {
|
||||||
"available": True,
|
"available": True,
|
||||||
"type": "pytorch_recurrent_forecaster",
|
"type": "pytorch_recurrent_forecaster",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from crypto_spot_bot.market_data import _candles_due, _closed_candles, _is_closed_kline_row
|
from crypto_spot_bot.market_data import MarketData, _candles_due, _closed_candles, _is_closed_kline_row
|
||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
|
from crypto_spot_bot.storage import Storage
|
||||||
|
|
||||||
|
|
||||||
def test_closed_candles_excludes_current_open_interval() -> None:
|
def test_closed_candles_excludes_current_open_interval() -> None:
|
||||||
@@ -26,3 +27,37 @@ def test_rest_candles_refresh_only_after_next_bar_closes() -> None:
|
|||||||
|
|
||||||
assert _candles_due([candle], "1", now_ms=11 * 60_000 + 30_000) is False
|
assert _candles_due([candle], "1", now_ms=11 * 60_000 + 30_000) is False
|
||||||
assert _candles_due([candle], "1", now_ms=12 * 60_000) is True
|
assert _candles_due([candle], "1", now_ms=12 * 60_000) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_orderbook_handler_samples_sizes_and_microstructure(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
market_observation_enabled=True,
|
||||||
|
market_observation_sample_seconds=30.0,
|
||||||
|
)
|
||||||
|
storage = Storage(settings.database_path)
|
||||||
|
market = MarketData(settings, object(), storage)
|
||||||
|
|
||||||
|
market._handle_orderbook(
|
||||||
|
"BTCUSDT",
|
||||||
|
{"b": [["100", "3"]], "a": [["101", "1"]]},
|
||||||
|
source_timestamp_ms=1_789_000_000_000,
|
||||||
|
)
|
||||||
|
market._handle_orderbook(
|
||||||
|
"BTCUSDT",
|
||||||
|
{"b": [["100", "4"]], "a": [["101", "1"]]},
|
||||||
|
source_timestamp_ms=1_789_000_001_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = market.orderbook_metrics["BTCUSDT"]
|
||||||
|
rows = storage.market_observations_after(symbol="BTCUSDT")
|
||||||
|
assert metrics["bid_size"] == 4.0
|
||||||
|
assert metrics["ask_size"] == 1.0
|
||||||
|
assert metrics["imbalance"] == 0.6
|
||||||
|
assert metrics["microprice"] == 100.8
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["bid_size"] == 3.0
|
||||||
|
assert rows[0]["ask_size"] == 1.0
|
||||||
|
assert rows[0]["imbalance"] == 0.5
|
||||||
|
assert rows[0]["microprice"] == 100.75
|
||||||
|
assert rows[0]["source_timestamp_ms"] == 1_789_000_000_000
|
||||||
|
|||||||
@@ -62,6 +62,57 @@ def test_prune_deletes_only_one_bounded_batch_per_table(tmp_path) -> None:
|
|||||||
assert len(storage.recent_signals(PRUNE_BATCH_SIZE + 10)) == 5
|
assert len(storage.recent_signals(PRUNE_BATCH_SIZE + 10)) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_market_observation_export_is_symbol_scoped_and_paginated(tmp_path) -> None:
|
||||||
|
storage = Storage(tmp_path / "tradebot.sqlite3")
|
||||||
|
first_id = storage.insert_market_observation(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
bid_price=100.0,
|
||||||
|
bid_size=2.0,
|
||||||
|
ask_price=101.0,
|
||||||
|
ask_size=1.0,
|
||||||
|
mid_price=100.5,
|
||||||
|
microprice=100.6666666667,
|
||||||
|
spread_bps=99.50248756,
|
||||||
|
imbalance=1 / 3,
|
||||||
|
last_price=100.4,
|
||||||
|
source_timestamp_ms=1_789_000_000_000,
|
||||||
|
)
|
||||||
|
second_id = storage.insert_market_observation(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
bid_price=101.0,
|
||||||
|
bid_size=1.0,
|
||||||
|
ask_price=102.0,
|
||||||
|
ask_size=1.0,
|
||||||
|
mid_price=101.5,
|
||||||
|
microprice=101.5,
|
||||||
|
spread_bps=98.52216749,
|
||||||
|
imbalance=0.0,
|
||||||
|
last_price=101.4,
|
||||||
|
source_timestamp_ms=1_789_000_030_000,
|
||||||
|
)
|
||||||
|
storage.insert_market_observation(
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
bid_price=10.0,
|
||||||
|
bid_size=1.0,
|
||||||
|
ask_price=11.0,
|
||||||
|
ask_size=1.0,
|
||||||
|
mid_price=10.5,
|
||||||
|
microprice=10.5,
|
||||||
|
spread_bps=952.38095238,
|
||||||
|
imbalance=0.0,
|
||||||
|
last_price=10.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = storage.market_observations_after(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
after_id=first_id,
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [row["id"] for row in rows] == [second_id]
|
||||||
|
assert rows[0]["source_timestamp_ms"] == 1_789_000_030_000
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_compaction_preserves_durable_state_and_bounds_telemetry(tmp_path) -> None:
|
def test_runtime_compaction_preserves_durable_state_and_bounds_telemetry(tmp_path) -> None:
|
||||||
database = tmp_path / "tradebot.sqlite3"
|
database = tmp_path / "tradebot.sqlite3"
|
||||||
storage = Storage(database)
|
storage = Storage(database)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ DEFAULT_RECENT_ROWS = {
|
|||||||
"equity": 5_000,
|
"equity": 5_000,
|
||||||
"events": 2_000,
|
"events": 2_000,
|
||||||
"llm_advice": 1_000,
|
"llm_advice": 1_000,
|
||||||
|
"market_observations": 100_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +115,11 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--equity", type=int, default=DEFAULT_RECENT_ROWS["equity"])
|
parser.add_argument("--equity", type=int, default=DEFAULT_RECENT_ROWS["equity"])
|
||||||
parser.add_argument("--events", type=int, default=DEFAULT_RECENT_ROWS["events"])
|
parser.add_argument("--events", type=int, default=DEFAULT_RECENT_ROWS["events"])
|
||||||
parser.add_argument("--llm-advice", type=int, default=DEFAULT_RECENT_ROWS["llm_advice"])
|
parser.add_argument("--llm-advice", type=int, default=DEFAULT_RECENT_ROWS["llm_advice"])
|
||||||
|
parser.add_argument(
|
||||||
|
"--market-observations",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_RECENT_ROWS["market_observations"],
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +133,7 @@ def main() -> None:
|
|||||||
"equity": args.equity,
|
"equity": args.equity,
|
||||||
"events": args.events,
|
"events": args.events,
|
||||||
"llm_advice": args.llm_advice,
|
"llm_advice": args.llm_advice,
|
||||||
|
"market_observations": args.market_observations,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
|||||||
Reference in New Issue
Block a user