build 039: complete Quotes Feed migration foundation

This commit is contained in:
2026-07-14 09:58:16 +03:00
parent 26deb861bc
commit 7b62873832
443 changed files with 80452 additions and 1335 deletions

View File

@@ -2,85 +2,42 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.market_data.acquisition.models.quote import Quote
from src.storage.quote_store import InMemoryQuoteStore, QuoteStoreProtocol
@dataclass(slots=True)
class MarketPriceSnapshot:
symbol: str
price: float
bid_price: float | None
ask_price: float | None
updated_at: str
source: str = "market-cache"
runtime_key: str = "default"
received_monotonic: float = 0.0
def age_seconds(self) -> float:
if self.received_monotonic <= 0:
return 999999.0
return max(0.0, time.monotonic() - self.received_monotonic)
def has_bid_ask(self) -> bool:
return (
self.bid_price is not None
and self.ask_price is not None
and self.bid_price > 0
and self.ask_price > 0
)
_MARKET_PRICE_CACHE_SOURCE_NAME = "legacy-market-price-cache"
class MarketPriceCache:
_prices: dict[tuple[str, str], MarketPriceSnapshot] = {}
# Временный compatibility facade над каноническим Quote Store.
_store: QuoteStoreProtocol = InMemoryQuoteStore()
@classmethod
def _key(cls, *, symbol: str, runtime_key: str = "default") -> tuple[str, str]:
return runtime_key.strip().lower(), symbol.upper()
@classmethod
def set_price(
def set_quote(
cls,
quote: Quote,
*,
symbol: str,
price: float,
bid_price: float | None = None,
ask_price: float | None = None,
updated_at: str | None = None,
source: str = "market-polling",
runtime_key: str = "default",
) -> None:
settings = load_settings()
if updated_at is None:
updated_at = datetime.now(ZoneInfo(settings.tz)).strftime("%d.%m.%Y %H:%M:%S")
normalized_runtime_key = runtime_key.strip().lower()
cls._prices[cls._key(symbol=symbol, runtime_key=normalized_runtime_key)] = MarketPriceSnapshot(
symbol=symbol.upper(),
price=float(price),
bid_price=float(bid_price) if bid_price is not None else None,
ask_price=float(ask_price) if ask_price is not None else None,
updated_at=updated_at,
source=source,
runtime_key=normalized_runtime_key,
received_monotonic=time.monotonic(),
cls._store.set(
_MARKET_PRICE_CACHE_SOURCE_NAME,
quote,
runtime_key=cls._normalize_runtime_key(runtime_key),
)
@classmethod
def get_price(
def get_quote(
cls,
symbol: str,
*,
runtime_key: str = "default",
) -> MarketPriceSnapshot | None:
return cls._prices.get(cls._key(symbol=symbol, runtime_key=runtime_key))
) -> Quote | None:
return cls._store.get(
_MARKET_PRICE_CACHE_SOURCE_NAME,
cls._normalize_symbol(symbol),
runtime_key=cls._normalize_runtime_key(runtime_key),
)
@classmethod
def clear(
@@ -89,23 +46,24 @@ class MarketPriceCache:
*,
runtime_key: str | None = None,
) -> None:
if symbol is None and runtime_key is None:
cls._prices.clear()
return
cls._store.clear(
source_name=_MARKET_PRICE_CACHE_SOURCE_NAME,
symbol=(
cls._normalize_symbol(symbol)
if symbol is not None
else None
),
runtime_key=(
cls._normalize_runtime_key(runtime_key)
if runtime_key is not None
else None
),
)
if symbol is not None and runtime_key is not None:
cls._prices.pop(cls._key(symbol=symbol, runtime_key=runtime_key), None)
return
@staticmethod
def _normalize_symbol(symbol: str) -> str:
return str(symbol).strip().upper()
keys_to_delete = []
for key_runtime, key_symbol in cls._prices.keys():
if runtime_key is not None and key_runtime == runtime_key.strip().lower():
keys_to_delete.append((key_runtime, key_symbol))
continue
if symbol is not None and key_symbol == symbol.upper():
keys_to_delete.append((key_runtime, key_symbol))
for key in keys_to_delete:
cls._prices.pop(key, None)
@staticmethod
def _normalize_runtime_key(runtime_key: str) -> str:
return str(runtime_key).strip().lower()

View File

@@ -13,6 +13,12 @@ from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
from src.market_data.acquisition.adapters.dzengi.websocket import (
DzengiWebSocketQuoteAdapter,
)
from src.market_data.acquisition.exceptions import (
MarketDataAcquisitionError,
)
from src.trading.journal.service import JournalService
@@ -297,6 +303,7 @@ class MarketDataRunner:
valid_payload_count = 0
invalid_payload_count = 0
adapter = DzengiWebSocketQuoteAdapter()
async for payload in ExchangeWebSocketClient().stream_depth(
ws_symbol,
@@ -306,20 +313,31 @@ class MarketDataRunner:
if current_symbol and current_symbol != symbol:
break
best_bid = cls._extract_best_price(payload, "bids")
best_ask = cls._extract_best_price(payload, "asks")
if best_bid is None or best_ask is None:
try:
quote = adapter.map_message(payload)
except MarketDataAcquisitionError:
invalid_payload_count += 1
if invalid_payload_count >= 5:
raise RuntimeError(
"WebSocket depth stream does not contain valid bids/asks."
"WebSocket depth stream does not contain valid quotes."
)
continue
if quote.symbol.strip().upper() != cache_symbol.strip().upper():
invalid_payload_count += 1
if invalid_payload_count >= 5:
raise RuntimeError(
"WebSocket depth stream returned another symbol."
)
continue
invalid_payload_count = 0
best_bid = float(quote.bid_price)
best_ask = float(quote.ask_price)
if valid_payload_count == 0:
should_log_connected = (
@@ -354,12 +372,8 @@ class MarketDataRunner:
valid_payload_count += 1
MarketPriceCache.set_price(
symbol=cache_symbol,
price=(best_bid + best_ask) / 2,
bid_price=best_bid,
ask_price=best_ask,
source=f"ws_depth:{context.runtime_key}",
MarketPriceCache.set_quote(
quote,
runtime_key=context.runtime_key,
)

View File

@@ -12,6 +12,12 @@ from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
from src.market_data.acquisition.adapters.dzengi.websocket import (
DzengiWebSocketQuoteAdapter,
)
from src.market_data.acquisition.exceptions import (
MarketDataAcquisitionError,
)
from src.trading.journal.service import JournalService
@@ -145,6 +151,7 @@ async def start_market_stream() -> None:
symbol = validation.normalized_symbol
client = ExchangeWebSocketClient()
adapter = DzengiWebSocketQuoteAdapter()
journal.log_info(
"market_ws_started",
@@ -153,29 +160,16 @@ async def start_market_stream() -> None:
)
async for message in client.stream_depth(symbol):
event = _extract_market_event(message)
if event is None:
try:
quote = adapter.map_message(message)
except MarketDataAcquisitionError:
continue
price = safe_float(event.get("price"))
bid_price = safe_float(event.get("bid_price"))
ask_price = safe_float(event.get("ask_price"))
if price is None or bid_price is None or ask_price is None:
if quote.symbol.strip().upper() != symbol.strip().upper():
continue
MarketPriceCache.set_price(
symbol=symbol,
price=price,
bid_price=bid_price,
ask_price=ask_price,
updated_at=(
str(event.get("updated_at"))
if event.get("updated_at") is not None
else None
),
source="ws_market_stream",
MarketPriceCache.set_quote(
quote,
runtime_key="default",
)

View File

@@ -1,8 +1,12 @@
# app/src/integrations/exchange/mock_data.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth
from src.market_data.acquisition.models.quote import Quote
def mock_exchange_health() -> ExchangeHealth:
@@ -13,20 +17,23 @@ def mock_exchange_health() -> ExchangeHealth:
)
def mock_ticker_price(symbol: str) -> TickerPrice:
symbol = symbol.upper().strip()
def mock_quote(symbol: str) -> Quote:
normalized_symbol = symbol.upper().strip()
fake_prices = {
"BTCUSDT": 68425.10,
"ETHUSDT": 3521.44,
"BNBUSDT": 612.33,
"BTCUSDT": Decimal("68425.10"),
"ETHUSDT": Decimal("3521.44"),
"BNBUSDT": Decimal("612.33"),
}
price = fake_prices.get(symbol, 100.00)
updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return TickerPrice(
symbol=symbol,
price=price,
price = fake_prices.get(normalized_symbol, Decimal("100.00"))
return Quote(
symbol=normalized_symbol,
last_price=price,
bid_price=price,
ask_price=price,
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="mock",
updated_at=updated_at,
)

View File

@@ -3,6 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.market_data.acquisition.models.instrument import Instrument
# Состояние публичного API биржи.
@@ -25,13 +30,6 @@ class TimeSyncStatus:
message: str
# Текущая рыночная цена инструмента.
@dataclass(slots=True)
class TickerPrice:
symbol: str
price: float
source: str
updated_at: str
# Snapshot цен для execution layer.
@@ -62,26 +60,7 @@ class BalanceSummary:
source: str
# Информация о торговом инструменте биржи.
@dataclass(slots=True)
class ExchangeSymbol:
symbol: str
name: str
status: str
base_asset: str
quote_asset: str
market_modes: list[str]
market_type: str
tick_size: float | None
step_size: float | None
min_qty: float | None
min_notional: float | None
# Результат проверки символа.
# Результат проверки торгового символа по каноническому справочнику Instrument.
@dataclass(slots=True)
class SymbolValidationResult:
requested_symbol: str
@@ -90,7 +69,7 @@ class SymbolValidationResult:
is_valid: bool
message: str
symbol_info: ExchangeSymbol | None
symbol_info: Instrument | None
# Состояние приватного API аккаунта.
@@ -134,6 +113,7 @@ class KlineBatch:
candles: list[Kline]
source: str
# Информация о торговой комиссии для инструмента.
@dataclass(slots=True)
class TradingFee:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import time
import socket
from datetime import datetime
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from src.core.config import load_settings
@@ -16,18 +16,16 @@ from src.integrations.exchange.market_cache import MarketPriceCache
from src.integrations.exchange.mock_data import (
mock_balance_summary,
mock_exchange_health,
mock_ticker_price,
mock_quote,
)
from src.integrations.exchange.models import (
BalanceSummary,
ExchangeHealth,
ExchangeSymbol,
ExecutionPriceSnapshot,
Kline,
KlineBatch,
PrivateAuthHealth,
SymbolValidationResult,
TickerPrice,
TimeSyncStatus,
TradingFee,
)
@@ -43,12 +41,46 @@ from src.integrations.exchange.status import (
build_mock_exchange_status,
classify_exchange_error,
)
from src.integrations.exchange.symbol_utils import normalize_symbol, symbol_candidates
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiInstrumentDocumentSource,
DzengiQuoteDocumentSource,
)
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
from src.market_data.acquisition.handlers.instrument_handler import (
DzengiInstrumentDocumentHandler,
)
from src.market_data.acquisition.handlers.quotes_handler import (
DzengiQuoteDocumentHandler,
)
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.registry import (
InstrumentFeedRegistry,
QuoteFeedRegistry,
)
from src.market_data.acquisition.service import (
InstrumentAcquisitionService,
QuoteAcquisitionService,
)
from src.market_data.acquisition.symbols import (
normalize_symbol,
resolve_symbol_index,
)
from src.storage.instrument_store import (
InMemoryInstrumentStore,
InstrumentStoreProtocol,
)
from src.trading.journal.service import JournalService
_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
_QUOTE_SOURCE_NAME = "dzengi"
class ExchangeService:
_exchange_symbols_cache: list[ExchangeSymbol] | None = None
_instrument_store: InstrumentStoreProtocol = InMemoryInstrumentStore()
_execution_cache_max_age_seconds = 2.0
_default_runtime_key = "auto"
@@ -108,17 +140,28 @@ class ExchangeService:
return status
try:
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
quote = self._get_fresh_quote(
validation.normalized_symbol,
)
except Exception:
return status
age_seconds = safe_float(snapshot.get("age_seconds"))
exchange_timestamp_ms = (
int(quote.exchange_timestamp.timestamp() * 1000)
if quote.exchange_timestamp is not None
else None
)
age_seconds = self._exchange_timestamp_age_seconds(
exchange_timestamp_ms
)
if age_seconds is not None and age_seconds > 60:
return build_market_stale_status(
symbol=validation.normalized_symbol,
age_seconds=age_seconds,
updated_at=str(snapshot.get("updated_at") or ""),
updated_at=self._format_exchange_time(
exchange_timestamp_ms
),
)
return status
@@ -668,7 +711,9 @@ class ExchangeService:
)
try:
ticker = self._get_real_price(str(status.symbol or self.settings.default_symbol))
quote = self._get_fresh_quote(
str(status.symbol or self.settings.default_symbol)
)
except ExchangeError as exc:
return ExchangeHealth(
ok=False,
@@ -679,7 +724,10 @@ class ExchangeService:
return ExchangeHealth(
ok=True,
mode="real_public_api",
message=f"Public API OK. Цена {ticker.symbol}: {ticker.price:.2f}",
message=(
f"Public API OK. Цена {quote.symbol}: "
f"{float(quote.last_price):.2f}"
),
)
# Проверить доступность приватного API и валидность ключей аккаунта.
@@ -722,149 +770,41 @@ class ExchangeService:
message=f"Private API OK. Балансов получено: {len(balances)}",
)
# Обновить price cache и вернуть TickerPrice.
def refresh_price_cache(
# Получить каноническую текущую котировку из Store или REST Quotes Feed.
def get_quote(
self,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> TickerPrice:
snapshot = self.refresh_market_snapshot_cache(
symbol,
runtime_key=runtime_key,
)
price = safe_float(snapshot.get("last_price"))
if price is None:
raise ExchangeError("Field 'last_price' is missing in market snapshot.")
return TickerPrice(
symbol=str(snapshot["symbol"]),
price=price,
source=str(snapshot.get("source") or self._source_name()),
updated_at=str(snapshot["updated_at"]),
)
# Обновить market snapshot cache через свежий REST-запрос.
def refresh_market_snapshot_cache(
self,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> dict[str, object]:
normalized_runtime_key = self._runtime_key(runtime_key)
snapshot = self.get_fresh_market_snapshot(symbol)
last_price = safe_float(snapshot.get("last_price"))
bid_price = safe_float(snapshot.get("bid_price"))
ask_price = safe_float(snapshot.get("ask_price"))
if last_price is None or bid_price is None or ask_price is None:
raise ExchangeError("Market snapshot contains invalid price fields.")
MarketPriceCache.set_price(
symbol=str(snapshot["symbol"]),
price=last_price,
bid_price=bid_price,
ask_price=ask_price,
updated_at=str(snapshot["updated_at"]),
source=str(snapshot.get("source") or "rest_polling"),
runtime_key=normalized_runtime_key,
)
return snapshot
# Получить последнюю цену инструмента из cache или REST API.
def get_price(
self,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> TickerPrice:
) -> Quote:
symbol_to_use = symbol or self.settings.default_symbol
normalized_runtime_key = self._runtime_key(runtime_key)
if not self.settings.exchange_enabled:
return mock_ticker_price(symbol_to_use)
return mock_quote(symbol_to_use)
validation = self.validate_symbol(symbol_to_use)
if not validation.is_valid:
raise ExchangeError(validation.message)
cached_price = MarketPriceCache.get_price(
cached_quote = MarketPriceCache.get_quote(
validation.normalized_symbol,
runtime_key=normalized_runtime_key,
)
if cached_price is not None:
return TickerPrice(
symbol=cached_price.symbol,
price=cached_price.price,
source=cached_price.source,
updated_at=cached_price.updated_at,
)
if (
cached_quote is not None
and self._quote_age_seconds(cached_quote)
<= self._execution_cache_max_age_seconds
):
return cached_quote
return self._get_real_price(validation.normalized_symbol)
# Получить market snapshot: last/bid/ask/source/age/freshness.
def get_market_snapshot(
self,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> dict[str, object]:
symbol_to_use = symbol or self.settings.default_symbol
normalized_runtime_key = self._runtime_key(runtime_key)
if not self.settings.exchange_enabled:
ticker = mock_ticker_price(symbol_to_use)
return {
"symbol": ticker.symbol,
"last_price": ticker.price,
"bid_price": ticker.price,
"ask_price": ticker.price,
"updated_at": ticker.updated_at,
"source": ticker.source,
"runtime_key": normalized_runtime_key,
"age_seconds": 0.0,
"is_fresh": True,
}
validation = self.validate_symbol(symbol_to_use)
if not validation.is_valid:
raise ExchangeError(validation.message)
cached_price = MarketPriceCache.get_price(
validation.normalized_symbol,
quote = self._get_fresh_quote(validation.normalized_symbol)
MarketPriceCache.set_quote(
quote,
runtime_key=normalized_runtime_key,
)
if cached_price is not None:
age = cached_price.age_seconds()
if age <= self._execution_cache_max_age_seconds:
return {
"symbol": cached_price.symbol,
"last_price": cached_price.price,
"bid_price": cached_price.bid_price or cached_price.price,
"ask_price": cached_price.ask_price or cached_price.price,
"updated_at": cached_price.updated_at,
"source": cached_price.source,
"runtime_key": cached_price.runtime_key,
"age_seconds": round(age, 3),
"is_fresh": True,
}
snapshot = self.refresh_market_snapshot_cache(
validation.normalized_symbol,
runtime_key=normalized_runtime_key,
)
snapshot["runtime_key"] = normalized_runtime_key
snapshot["age_seconds"] = 0.0
snapshot["is_fresh"] = True
return snapshot
return quote
# Получить snapshot, пригодный для execution layer.
def get_execution_snapshot(
@@ -877,15 +817,10 @@ class ExchangeService:
normalized_runtime_key = self._runtime_key(runtime_key)
if not self.settings.exchange_enabled:
ticker = mock_ticker_price(symbol_to_use)
return ExecutionPriceSnapshot(
symbol=ticker.symbol,
last_price=ticker.price,
bid_price=ticker.price,
ask_price=ticker.price,
updated_at=ticker.updated_at,
source=ticker.source,
is_fresh=True,
quote = mock_quote(symbol_to_use)
return self._execution_snapshot_from_quote(
quote,
source=quote.source,
age_seconds=0.0,
)
@@ -893,125 +828,96 @@ class ExchangeService:
if not validation.is_valid:
raise ExchangeError(validation.message)
cached_price = MarketPriceCache.get_price(
quote = MarketPriceCache.get_quote(
validation.normalized_symbol,
runtime_key=normalized_runtime_key,
)
if cached_price is not None:
age = cached_price.age_seconds()
if quote is not None:
age_seconds = self._quote_age_seconds(quote)
if (
age <= self._execution_cache_max_age_seconds
and cached_price.has_bid_ask()
):
bid_price = safe_float(cached_price.bid_price)
ask_price = safe_float(cached_price.ask_price)
last_price = safe_float(cached_price.price)
if age_seconds <= self._execution_cache_max_age_seconds:
return self._execution_snapshot_from_quote(
quote,
source=f"{quote.source}:fresh_cache",
age_seconds=round(age_seconds, 3),
)
if (
last_price is not None
and bid_price is not None
and ask_price is not None
):
return ExecutionPriceSnapshot(
symbol=cached_price.symbol,
last_price=last_price,
bid_price=bid_price,
ask_price=ask_price,
updated_at=cached_price.updated_at,
source=f"{cached_price.source}:fresh_cache",
is_fresh=True,
age_seconds=round(age, 3),
)
quote = self._get_fresh_quote(
validation.normalized_symbol
)
MarketPriceCache.set_quote(
quote,
runtime_key=normalized_runtime_key,
)
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol)
return self._execution_snapshot_from_quote(
quote,
source="rest_fallback",
age_seconds=round(
self._quote_age_seconds(quote),
3,
),
)
last_price = safe_float(snapshot.get("last_price"))
bid_price = safe_float(snapshot.get("bid_price"))
ask_price = safe_float(snapshot.get("ask_price"))
def _execution_snapshot_from_quote(
self,
quote: Quote,
*,
source: str,
age_seconds: float,
) -> ExecutionPriceSnapshot:
timestamp = (
quote.exchange_timestamp
if quote.exchange_timestamp is not None
else quote.received_at
)
if last_price is None or bid_price is None or ask_price is None:
raise ExchangeError("Market snapshot contains invalid execution prices.")
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
age_seconds = safe_float(snapshot.get("age_seconds"))
updated_at = timestamp.astimezone(
ZoneInfo(self.settings.tz)
).strftime("%d.%m.%Y %H:%M:%S")
return ExecutionPriceSnapshot(
symbol=str(snapshot["symbol"]),
last_price=last_price,
bid_price=bid_price,
ask_price=ask_price,
updated_at=str(snapshot["updated_at"]),
source="rest_fallback",
is_fresh=bool(snapshot.get("is_fresh")),
symbol=quote.symbol,
last_price=float(quote.last_price),
bid_price=float(quote.bid_price),
ask_price=float(quote.ask_price),
updated_at=updated_at,
source=source,
is_fresh=(
age_seconds
<= self._execution_cache_max_age_seconds
),
age_seconds=age_seconds,
)
# Получить свежий snapshot напрямую из REST API.
def get_fresh_market_snapshot(self, symbol: str | None = None) -> dict[str, object]:
symbol_to_use = symbol or self.settings.default_symbol
def _quote_age_seconds(self, quote: Quote) -> float:
received_at = quote.received_at
if received_at.tzinfo is None:
received_at = received_at.replace(tzinfo=timezone.utc)
if not self.settings.exchange_enabled:
ticker = mock_ticker_price(symbol_to_use)
return {
"symbol": ticker.symbol,
"last_price": ticker.price,
"bid_price": ticker.price,
"ask_price": ticker.price,
"updated_at": ticker.updated_at,
"source": "mock",
"age_seconds": 0.0,
"is_fresh": True,
}
validation = self.validate_symbol(symbol_to_use)
if not validation.is_valid:
raise ExchangeError(validation.message)
client = ExchangeRestClient()
return max(
0.0,
(
datetime.now(timezone.utc)
- received_at.astimezone(timezone.utc)
).total_seconds(),
)
def _get_fresh_quote(self, normalized_symbol: str) -> Quote:
try:
payload = client.get_json(
"/api/v1/ticker/24hr",
params={"symbol": validation.normalized_symbol},
)
return self._load_quote_via_acquisition(normalized_symbol)
except Exception as exc:
self._log_exchange_error(
endpoint="ticker/24hr",
exc=exc,
symbol=validation.normalized_symbol,
symbol=normalized_symbol,
)
raise ExchangeError(str(exc)) from exc
last_price = safe_float(payload.get("lastPrice"))
if last_price is None:
exc = ExchangeError("Field 'lastPrice' is missing in ticker response.")
self._log_exchange_error(
endpoint="ticker/24hr",
exc=exc,
symbol=validation.normalized_symbol,
)
raise exc
bid_price = safe_float(payload.get("bidPrice")) or last_price
ask_price = safe_float(payload.get("askPrice")) or last_price
close_time = payload.get("closeTime") or payload.get("eventTime")
age_seconds = self._exchange_timestamp_age_seconds(close_time)
is_fresh = age_seconds is not None and age_seconds <= 60
return {
"symbol": validation.normalized_symbol,
"last_price": last_price,
"bid_price": bid_price,
"ask_price": ask_price,
"updated_at": self._format_exchange_time(close_time),
"source": "fresh_rest",
"age_seconds": age_seconds,
"is_fresh": is_fresh,
}
# Получить live-балансы аккаунта.
def get_balance_summary(self) -> list[BalanceSummary]:
if not self.settings.exchange_enabled:
@@ -1056,20 +962,22 @@ class ExchangeService:
return balances
# Получить и распарсить список инструментов биржи.
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
# Получить канонический справочник инструментов через Instrument Store.
def get_instruments(self) -> tuple[Instrument, ...]:
if not self.settings.exchange_enabled:
return []
return ()
cached_symbols = type(self)._exchange_symbols_cache
instrument_store = type(self)._instrument_store
if cached_symbols is not None:
return cached_symbols
instruments = instrument_store.get(
_INSTRUMENT_REFERENCE_SOURCE_NAME
)
client = ExchangeRestClient()
if instruments is not None:
return instruments
try:
payload = client.get_json("/api/v1/exchangeInfo")
instruments = self._load_instruments_via_acquisition()
except Exception as exc:
self._log_exchange_error(
endpoint="exchangeInfo",
@@ -1077,98 +985,65 @@ class ExchangeService:
)
raise ExchangeError(str(exc)) from exc
symbols_raw = self._extract_exchange_symbols_raw(payload)
items: list[ExchangeSymbol] = []
for item in symbols_raw:
if not isinstance(item, dict):
continue
symbol = self._parse_exchange_symbol(item)
if symbol.symbol:
items.append(symbol)
type(self)._exchange_symbols_cache = items
return items
# Извлечь сырой список symbols из exchangeInfo.
def _extract_exchange_symbols_raw(
self,
payload: dict[str, object],
) -> list[object]:
symbols = payload.get("symbols")
if isinstance(symbols, list):
return symbols
inner = payload.get("payload")
if isinstance(inner, dict):
nested_symbols = inner.get("symbols")
if isinstance(nested_symbols, list):
return nested_symbols
exc = ExchangeError("Field 'symbols' is missing in exchangeInfo response.")
self._log_exchange_error(
endpoint="exchangeInfo",
exc=exc,
instrument_store.set(
_INSTRUMENT_REFERENCE_SOURCE_NAME,
instruments,
)
raise exc
# Преобразовать один сырой symbol item в ExchangeSymbol.
def _parse_exchange_symbol(
return instruments
# Собрать Quotes acquisition pipeline и вернуть каноническую модель Quote.
def _load_quote_via_acquisition(
self,
item: dict[object, object],
) -> ExchangeSymbol:
filters = item.get("filters")
symbol: str,
) -> Quote:
source = DzengiQuoteDocumentSource()
handler = DzengiQuoteDocumentHandler()
tick_size = safe_float(item.get("tickSize"))
if tick_size is None:
tick_size = self._extract_filter_value(
filters,
filter_names=["PRICE_FILTER"],
keys=["tickSize"],
)
feed = QuotesFeed(
source=source,
handler=handler,
)
step_size = safe_float(item.get("stepSize"))
if step_size is None:
step_size = self._extract_filter_value(
filters,
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
keys=["stepSize"],
)
registry = QuoteFeedRegistry()
registry.register(
_QUOTE_SOURCE_NAME,
feed,
)
min_qty = safe_float(item.get("minQty"))
if min_qty is None:
min_qty = self._extract_filter_value(
filters,
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
keys=["minQty"],
)
acquisition_service = QuoteAcquisitionService(
registry=registry,
)
min_notional = safe_float(item.get("minNotional"))
if min_notional is None:
min_notional = self._extract_filter_value(
filters,
filter_names=["MIN_NOTIONAL", "NOTIONAL"],
keys=["minNotional", "notional"],
)
return acquisition_service.load_quote(
_QUOTE_SOURCE_NAME,
symbol,
)
return ExchangeSymbol(
symbol=self._safe_str(item.get("symbol")),
name=self._safe_str(item.get("name")),
status=self._parse_exchange_symbol_status(item),
base_asset=self._safe_str(item.get("baseAsset")),
quote_asset=self._safe_str(item.get("quoteAsset")),
market_modes=self._parse_market_modes(item.get("marketModes")),
market_type=self._safe_str(item.get("marketType"), "unknown"),
tick_size=tick_size,
step_size=step_size,
min_qty=min_qty,
min_notional=min_notional,
# Собрать acquisition pipeline и вернуть канонические модели Instrument.
def _load_instruments_via_acquisition(
self,
) -> tuple[Instrument, ...]:
source = DzengiInstrumentDocumentSource()
handler = DzengiInstrumentDocumentHandler()
feed = InstrumentFeed(
source=source,
handler=handler,
)
registry = InstrumentFeedRegistry()
registry.register(
_INSTRUMENT_REFERENCE_SOURCE_NAME,
feed,
)
acquisition_service = InstrumentAcquisitionService(
registry=registry,
)
return acquisition_service.load_instruments(
_INSTRUMENT_REFERENCE_SOURCE_NAME
)
# Безопасно привести значение к строке.
@@ -1178,91 +1053,6 @@ class ExchangeService:
return str(value).strip()
def _parse_exchange_symbol_status(self, item: dict[object, object]) -> str:
status = self._safe_str(item.get("status"), "unknown")
false_flags = {
"isTradingAllowed",
"tradingAllowed",
"availableForTrading",
"isTradable",
"tradable",
"isMarketOpen",
"marketOpen",
"isOpen",
"enabled",
}
for key in false_flags:
if key not in item:
continue
value = item.get(key)
if isinstance(value, bool) and not value:
return "NOT_TRADABLE"
if str(value).strip().lower() in {"false", "0", "no", "disabled"}:
return "NOT_TRADABLE"
for key in ("tradingMode", "tradeMode", "mode", "state"):
value = str(item.get(key) or "").strip().upper()
if value in {
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
}:
return value
return status
# Привести marketModes к list[str].
def _parse_market_modes(self, value: object) -> list[str]:
if isinstance(value, list):
return [
str(item).strip()
for item in value
if str(item).strip()
]
if isinstance(value, str) and value.strip():
return [value.strip()]
return []
# Извлечь числовое значение из filters exchangeInfo.
def _extract_filter_value(
self,
filters: object,
*,
filter_names: list[str],
keys: list[str],
) -> float | None:
if not isinstance(filters, list):
return None
normalized_filter_names = {name.upper() for name in filter_names}
for entry in filters:
if not isinstance(entry, dict):
continue
filter_type = str(entry.get("filterType", "")).strip().upper()
if filter_type not in normalized_filter_names:
continue
for key in keys:
value = safe_float(entry.get(key))
if value is not None:
return value
return None
# Проверить, существует ли инструмент на бирже.
def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult:
requested = normalize_symbol(raw_symbol)
@@ -1285,43 +1075,40 @@ class ExchangeService:
symbol_info=None,
)
symbols = self.get_exchange_symbols()
candidates = symbol_candidates(requested)
instruments = self.get_instruments()
for candidate in candidates:
for symbol_info in symbols:
if normalize_symbol(symbol_info.symbol) == candidate:
return SymbolValidationResult(
requested_symbol=requested,
normalized_symbol=normalize_symbol(symbol_info.symbol),
is_valid=True,
message="Символ найден в exchangeInfo.",
symbol_info=symbol_info,
)
matched_index = resolve_symbol_index(
requested,
[
instrument.symbol
for instrument in instruments
],
)
if matched_index is not None:
instrument = instruments[matched_index]
return SymbolValidationResult(
requested_symbol=requested,
normalized_symbol=normalize_symbol(
instrument.symbol
),
is_valid=True,
message="Символ найден в exchangeInfo.",
symbol_info=instrument,
)
return SymbolValidationResult(
requested_symbol=requested,
normalized_symbol=requested,
is_valid=False,
message=f"Символ '{requested}' не найден в exchangeInfo.",
message=(
f"Символ '{requested}' "
"не найден в exchangeInfo."
),
symbol_info=None,
)
# Получить реальную цену инструмента через свежий REST snapshot.
def _get_real_price(self, symbol: str) -> TickerPrice:
snapshot = self.get_fresh_market_snapshot(symbol)
price = safe_float(snapshot.get("last_price"))
if price is None:
raise ExchangeError("Field 'last_price' is missing in market snapshot.")
return TickerPrice(
symbol=str(snapshot["symbol"]),
price=price,
source=self._source_name(),
updated_at=str(snapshot["updated_at"]),
)
def get_exchange_server_time_ms(self) -> int:
payload = ExchangeRestClient().get_json("/api/v1/time")

View File

@@ -9,6 +9,10 @@ from src.integrations.exchange.exceptions import (
ExchangeConnectionError,
ExchangeResponseError,
)
from src.market_data.acquisition.models.status import (
InstrumentTradingState,
classify_instrument_status,
)
class ExchangeStatusCode(StrEnum):
@@ -35,7 +39,7 @@ class ExchangeRuntimeStatus:
raw_status: str | None = None
raw_error: str | None = None
# вернуть статус в dict для старого UI-кода на время миграции
# Вернуть статус в dict для старого UI-кода на время миграции.
def as_dict(self) -> dict[str, object]:
return {
"code": self.code.value,
@@ -79,7 +83,7 @@ def build_market_stale_status(
)
# собрать статус mock-режима
# Собрать статус mock-режима.
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN,
@@ -95,48 +99,21 @@ def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
)
# собрать статус ошибки авторизации аккаунта
# Собрать статус ошибки авторизации аккаунта.
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
return build_exchange_error_status(exc)
OPEN_STATUSES = {
"TRADING",
"OPEN",
"ACTIVE",
"ENABLED",
"ONLINE",
}
BREAK_STATUSES = {
"BREAK",
"CLOSED",
"HALT",
"HALTED",
"PAUSED",
"SUSPENDED",
"DISABLED",
"SETTLING",
"POST_ONLY",
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
}
# определить единый runtime-статус по статусу инструмента биржи
# Собрать legacy runtime-статус по канонической классификации инструмента.
def build_market_status_from_symbol_status(
*,
raw_status: str | None,
symbol: str,
) -> ExchangeRuntimeStatus:
normalized_status = str(raw_status or "").strip().upper()
classification = classify_instrument_status(raw_status)
normalized_status = classification.normalized_status
if normalized_status in OPEN_STATUSES:
if classification.state == InstrumentTradingState.OPEN:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN,
is_open=True,
@@ -150,15 +127,7 @@ def build_market_status_from_symbol_status(
symbol=symbol,
)
if normalized_status in {
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
}:
if classification.state == InstrumentTradingState.NOT_TRADABLE:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
@@ -171,8 +140,8 @@ def build_market_status_from_symbol_status(
raw_status=normalized_status,
symbol=symbol,
)
if normalized_status in BREAK_STATUSES:
if classification.state == InstrumentTradingState.BREAK:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
@@ -198,12 +167,12 @@ def build_market_status_from_symbol_status(
),
ui_line="⚠️ Статус торгов неизвестен",
reason="market_status_unknown",
raw_status=normalized_status or None,
raw_status=normalized_status,
symbol=symbol,
)
# собрать единый статус для неверного торгового инструмента
# Собрать единый статус для неверного торгового инструмента.
def build_invalid_symbol_status(
*,
symbol: str,
@@ -223,7 +192,7 @@ def build_invalid_symbol_status(
)
# собрать единый статус по ошибке exchange/API
# Собрать единый статус по ошибке exchange/API.
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
error_type = classify_exchange_error(exc)
raw_error = str(exc)
@@ -270,7 +239,7 @@ def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
)
# классифицировать ошибку биржи для единого UI и логов
# Классифицировать ошибку биржи для единого UI и логов.
def classify_exchange_error(exc: Exception) -> str:
text = str(exc).lower()
@@ -326,7 +295,7 @@ def classify_exchange_error(exc: Exception) -> str:
return "generic"
# проверить, относится ли reason к unified exchange status layer
# Проверить, относится ли reason к unified exchange status layer.
def is_exchange_status_reason(reason: str | None) -> bool:
if not reason:
return False

View File

@@ -2,24 +2,13 @@
from __future__ import annotations
def normalize_symbol(raw_symbol: str) -> str:
return (raw_symbol or "").strip().upper()
from src.market_data.acquisition.symbols import (
normalize_symbol,
symbol_candidates,
)
def symbol_candidates(raw_symbol: str) -> list[str]:
value = normalize_symbol(raw_symbol)
if not value:
return []
candidates = [value]
compact = value.replace("%2F", "/")
if compact not in candidates:
candidates.append(compact)
no_spaces = compact.replace(" ", "")
if no_spaces not in candidates:
candidates.append(no_spaces)
return candidates
__all__ = [
"normalize_symbol",
"symbol_candidates",
]