build 039: complete Quotes Feed migration foundation
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user