build 039: complete Quotes Feed migration foundation
This commit is contained in:
71
app/scripts/get_ticker_24hr.py
Normal file
71
app/scripts/get_ticker_24hr.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# app/scripts/get_ticker_24hr.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
settings = load_settings()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Получить реальный ответ Dzengi ticker/24hr.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"symbol",
|
||||
nargs="?",
|
||||
default=settings.default_symbol,
|
||||
help=(
|
||||
"Торговый символ. "
|
||||
f"По умолчанию: {settings.default_symbol}"
|
||||
),
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
symbol = str(args.symbol).strip()
|
||||
|
||||
if not symbol:
|
||||
print(
|
||||
"Торговый символ не должен быть пустым.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
try:
|
||||
payload = ExchangeRestClient().get_json(
|
||||
"/api/v1/ticker/24hr",
|
||||
params={
|
||||
"symbol": symbol,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Не удалось получить ticker/24hr для {symbol}: "
|
||||
f"{type(exc).__name__}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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",
|
||||
]
|
||||
0
app/src/market_data/__init__.py
Normal file
0
app/src/market_data/__init__.py
Normal file
0
app/src/market_data/acquisition/__init__.py
Normal file
0
app/src/market_data/acquisition/__init__.py
Normal file
316
app/src/market_data/acquisition/adapters/dzengi/mapper.py
Normal file
316
app/src/market_data/acquisition/adapters/dzengi/mapper.py
Normal file
@@ -0,0 +1,316 @@
|
||||
# app/src/market_data/acquisition/adapters/dzengi/mapper.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiExchangeInfoResponse,
|
||||
DzengiExchangeInfoSymbol,
|
||||
DzengiInstrumentFilter,
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRawNumeric,
|
||||
DzengiTicker24hrResponse,
|
||||
DzengiWebSocketQuoteResponse,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceMappingError,
|
||||
QuoteMappingError,
|
||||
)
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
|
||||
|
||||
_DZENGI_SOURCE_NAME = "dzengi"
|
||||
|
||||
|
||||
def map_dzengi_symbol_to_instrument(
|
||||
symbol: DzengiExchangeInfoSymbol,
|
||||
) -> Instrument:
|
||||
"""
|
||||
Преобразовать проверенную raw-модель инструмента Dzengi
|
||||
во внутреннюю source-independent модель Instrument.
|
||||
|
||||
Функция предполагает, что до mapper уже были выполнены:
|
||||
schema validation, parsing и value validation.
|
||||
"""
|
||||
|
||||
lot_size = _find_single_filter(
|
||||
symbol.filters,
|
||||
DzengiLotSizeFilter,
|
||||
filter_name="LOT_SIZE",
|
||||
symbol=symbol.symbol,
|
||||
)
|
||||
min_notional = _find_single_filter(
|
||||
symbol.filters,
|
||||
DzengiMinNotionalFilter,
|
||||
filter_name="MIN_NOTIONAL",
|
||||
symbol=symbol.symbol,
|
||||
)
|
||||
|
||||
return Instrument(
|
||||
symbol=symbol.symbol,
|
||||
name=symbol.name,
|
||||
status=symbol.status,
|
||||
base_asset=symbol.base_asset,
|
||||
quote_asset=symbol.quote_asset,
|
||||
asset_type=_optional_text(symbol.asset_type),
|
||||
market_type=symbol.market_type,
|
||||
market_modes=symbol.market_modes,
|
||||
order_types=symbol.order_types,
|
||||
base_asset_precision=symbol.base_asset_precision,
|
||||
quote_asset_precision=symbol.quote_precision,
|
||||
tick_size=_optional_decimal(
|
||||
symbol.tick_size,
|
||||
field_name="tickSize",
|
||||
symbol=symbol.symbol,
|
||||
),
|
||||
tick_value=_optional_decimal(
|
||||
symbol.tick_value,
|
||||
field_name="tickValue",
|
||||
symbol=symbol.symbol,
|
||||
),
|
||||
step_size=_optional_decimal(
|
||||
lot_size.step_size if lot_size is not None else None,
|
||||
field_name="stepSize",
|
||||
symbol=symbol.symbol,
|
||||
),
|
||||
min_qty=_optional_decimal(
|
||||
lot_size.min_qty if lot_size is not None else None,
|
||||
field_name="minQty",
|
||||
symbol=symbol.symbol,
|
||||
),
|
||||
max_qty=_optional_decimal(
|
||||
lot_size.max_qty if lot_size is not None else None,
|
||||
field_name="maxQty",
|
||||
symbol=symbol.symbol,
|
||||
),
|
||||
min_notional=_optional_decimal(
|
||||
min_notional.min_notional
|
||||
if min_notional is not None
|
||||
else None,
|
||||
field_name="minNotional",
|
||||
symbol=symbol.symbol,
|
||||
),
|
||||
country=_optional_text(symbol.country),
|
||||
sector=_optional_text(symbol.sector),
|
||||
industry=_optional_text(symbol.industry),
|
||||
trading_hours=_optional_text(symbol.trading_hours),
|
||||
)
|
||||
|
||||
|
||||
def map_dzengi_exchange_info_to_instruments(
|
||||
response: DzengiExchangeInfoResponse,
|
||||
) -> tuple[Instrument, ...]:
|
||||
"""
|
||||
Преобразовать все инструменты exchangeInfo
|
||||
во внутренние модели Instrument.
|
||||
"""
|
||||
|
||||
return tuple(
|
||||
map_dzengi_symbol_to_instrument(symbol)
|
||||
for symbol in response.payload.symbols
|
||||
)
|
||||
|
||||
|
||||
def map_dzengi_ticker_to_quote(
|
||||
response: DzengiTicker24hrResponse,
|
||||
*,
|
||||
received_at: datetime,
|
||||
) -> Quote:
|
||||
"""
|
||||
Преобразовать проверенную raw-модель Dzengi ticker/24hr
|
||||
во внутреннюю source-independent модель Quote.
|
||||
|
||||
Функция предполагает, что до mapper уже были выполнены:
|
||||
schema validation, parsing и value validation.
|
||||
"""
|
||||
|
||||
normalized_received_at = _require_aware_datetime(
|
||||
received_at,
|
||||
field_name="received_at",
|
||||
)
|
||||
|
||||
return Quote(
|
||||
symbol=response.symbol.strip(),
|
||||
last_price=_required_quote_decimal(
|
||||
response.last_price,
|
||||
field_name="lastPrice",
|
||||
),
|
||||
bid_price=_required_quote_decimal(
|
||||
response.bid_price,
|
||||
field_name="bidPrice",
|
||||
),
|
||||
ask_price=_required_quote_decimal(
|
||||
response.ask_price,
|
||||
field_name="askPrice",
|
||||
),
|
||||
exchange_timestamp=_timestamp_ms_to_utc_datetime(
|
||||
response.close_time,
|
||||
),
|
||||
received_at=normalized_received_at,
|
||||
source=_DZENGI_SOURCE_NAME,
|
||||
)
|
||||
|
||||
|
||||
def _timestamp_ms_to_utc_datetime(value: int) -> datetime:
|
||||
try:
|
||||
return datetime.fromtimestamp(
|
||||
value / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
except (OverflowError, OSError, ValueError) as exc:
|
||||
raise QuoteMappingError(
|
||||
"Поле closeTime невозможно преобразовать "
|
||||
"в UTC datetime."
|
||||
) from exc
|
||||
|
||||
|
||||
def _required_quote_decimal(
|
||||
value: DzengiRawNumeric,
|
||||
*,
|
||||
field_name: str,
|
||||
) -> Decimal:
|
||||
try:
|
||||
result = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise QuoteMappingError(
|
||||
f"Поле {field_name} котировки невозможно "
|
||||
"преобразовать в Decimal."
|
||||
) from exc
|
||||
|
||||
if not result.is_finite():
|
||||
raise QuoteMappingError(
|
||||
f"Поле {field_name} котировки должно быть "
|
||||
"конечным числом."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _require_aware_datetime(
|
||||
value: datetime,
|
||||
*,
|
||||
field_name: str,
|
||||
) -> datetime:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise QuoteMappingError(
|
||||
f"Поле {field_name} должно содержать timezone-aware datetime."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _find_single_filter[
|
||||
FilterT: DzengiInstrumentFilter
|
||||
](
|
||||
filters: tuple[DzengiInstrumentFilter, ...],
|
||||
filter_type: type[FilterT],
|
||||
*,
|
||||
filter_name: str,
|
||||
symbol: str,
|
||||
) -> FilterT | None:
|
||||
matches = tuple(
|
||||
instrument_filter
|
||||
for instrument_filter in filters
|
||||
if isinstance(instrument_filter, filter_type)
|
||||
)
|
||||
|
||||
if len(matches) > 1:
|
||||
raise InstrumentReferenceMappingError(
|
||||
f"Инструмент '{symbol}' содержит несколько "
|
||||
f"фильтров {filter_name}."
|
||||
)
|
||||
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _optional_decimal(
|
||||
value: DzengiRawNumeric | None,
|
||||
*,
|
||||
field_name: str,
|
||||
symbol: str,
|
||||
) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise InstrumentReferenceMappingError(
|
||||
f"Поле {field_name} инструмента '{symbol}' "
|
||||
f"невозможно преобразовать в Decimal."
|
||||
) from exc
|
||||
|
||||
if not result.is_finite():
|
||||
raise InstrumentReferenceMappingError(
|
||||
f"Поле {field_name} инструмента '{symbol}' "
|
||||
f"должно быть конечным числом."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
normalized = value.strip()
|
||||
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def map_dzengi_websocket_quote_to_quote(
|
||||
response: DzengiWebSocketQuoteResponse,
|
||||
*,
|
||||
received_at: datetime,
|
||||
) -> Quote:
|
||||
"""
|
||||
Преобразовать проверенную WebSocket-модель Dzengi в канонический Quote.
|
||||
|
||||
Depth-сообщение не содержит цену последней сделки, поэтому временно
|
||||
используется midpoint best bid / best ask — так же, как в legacy runtime.
|
||||
"""
|
||||
|
||||
normalized_received_at = _require_aware_datetime(
|
||||
received_at,
|
||||
field_name="received_at",
|
||||
)
|
||||
bid_price = _required_quote_decimal(
|
||||
response.bid_price,
|
||||
field_name="bidPrice",
|
||||
)
|
||||
ask_price = _required_quote_decimal(
|
||||
response.ask_price,
|
||||
field_name="askPrice",
|
||||
)
|
||||
|
||||
exchange_timestamp = None
|
||||
if response.timestamp is not None:
|
||||
try:
|
||||
exchange_timestamp = datetime.fromtimestamp(
|
||||
response.timestamp / 1000,
|
||||
tz=timezone.utc,
|
||||
)
|
||||
except (OverflowError, OSError, ValueError) as exc:
|
||||
raise QuoteMappingError(
|
||||
"Поле timestamp невозможно преобразовать в UTC datetime."
|
||||
) from exc
|
||||
|
||||
return Quote(
|
||||
symbol=response.symbol.strip(),
|
||||
last_price=(bid_price + ask_price) / Decimal("2"),
|
||||
bid_price=bid_price,
|
||||
ask_price=ask_price,
|
||||
exchange_timestamp=exchange_timestamp,
|
||||
received_at=normalized_received_at,
|
||||
source=_DZENGI_SOURCE_NAME,
|
||||
)
|
||||
133
app/src/market_data/acquisition/adapters/dzengi/models.py
Normal file
133
app/src/market_data/acquisition/adapters/dzengi/models.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# app/src/market_data/acquisition/adapters/dzengi/models.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
|
||||
# Число в исходном JSON-ответе Dzengi без предметного преобразования.
|
||||
DzengiJsonNumber: TypeAlias = int | float
|
||||
|
||||
# Числовое значение, которое Dzengi может передать числом или строкой.
|
||||
DzengiRawNumeric: TypeAlias = str | int | float
|
||||
|
||||
# Скалярное значение неизвестного поля транспортного ответа.
|
||||
DzengiJsonScalar: TypeAlias = str | int | float | bool | None
|
||||
|
||||
|
||||
# Лимит запросов из exchangeInfo.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiRateLimit:
|
||||
interval: str
|
||||
interval_num: int
|
||||
limit: int
|
||||
rate_limit_type: str
|
||||
|
||||
|
||||
# Базовый контракт фильтра инструмента Dzengi.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiInstrumentFilter:
|
||||
filter_type: str
|
||||
|
||||
|
||||
# Ограничения размера заявки.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiLotSizeFilter(DzengiInstrumentFilter):
|
||||
min_qty: DzengiRawNumeric | None
|
||||
max_qty: DzengiRawNumeric | None
|
||||
step_size: DzengiRawNumeric | None
|
||||
|
||||
|
||||
# Ограничение минимальной стоимости заявки.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiMinNotionalFilter(DzengiInstrumentFilter):
|
||||
min_notional: DzengiRawNumeric | None
|
||||
|
||||
|
||||
# Неизвестный тип фильтра, который ещё не поддерживается адаптером.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiUnknownFilter(DzengiInstrumentFilter):
|
||||
fields: tuple[tuple[str, DzengiJsonScalar], ...]
|
||||
|
||||
|
||||
# Один инструмент из ответа Dzengi exchangeInfo.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiExchangeInfoSymbol:
|
||||
symbol: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
asset_type: str | None
|
||||
|
||||
base_asset: str
|
||||
base_asset_precision: int | None
|
||||
|
||||
quote_asset: str
|
||||
quote_asset_id: str | None
|
||||
quote_precision: int | None
|
||||
|
||||
order_types: tuple[str, ...]
|
||||
filters: tuple[DzengiInstrumentFilter, ...]
|
||||
|
||||
market_modes: tuple[str, ...]
|
||||
market_type: str
|
||||
|
||||
country: str | None
|
||||
sector: str | None
|
||||
industry: str | None
|
||||
trading_hours: str | None
|
||||
|
||||
tick_size: DzengiJsonNumber | None
|
||||
tick_value: DzengiJsonNumber | None
|
||||
|
||||
trading_fee: DzengiJsonNumber | None
|
||||
exchange_fee: DzengiJsonNumber | None
|
||||
|
||||
long_rate: DzengiJsonNumber | None
|
||||
short_rate: DzengiJsonNumber | None
|
||||
swap_charge_interval: int | None
|
||||
|
||||
min_sl_gap: DzengiJsonNumber | None
|
||||
max_sl_gap: DzengiJsonNumber | None
|
||||
min_tp_gap: DzengiJsonNumber | None
|
||||
max_tp_gap: DzengiJsonNumber | None
|
||||
|
||||
|
||||
# Содержимое exchangeInfo независимо от внешней оболочки API.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiExchangeInfoPayload:
|
||||
timezone: str | None
|
||||
server_time: int | None
|
||||
rate_limits: tuple[DzengiRateLimit, ...]
|
||||
exchange_filters: tuple[DzengiUnknownFilter, ...]
|
||||
symbols: tuple[DzengiExchangeInfoSymbol, ...]
|
||||
|
||||
|
||||
# Нормализованное транспортное представление ответа exchangeInfo.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiExchangeInfoResponse:
|
||||
payload: DzengiExchangeInfoPayload
|
||||
|
||||
# Поля присутствуют в wrapped-формате ответа и отсутствуют
|
||||
# в фактическом unwrapped-ответе публичного REST endpoint.
|
||||
status: str | None = None
|
||||
correlation_id: str | None = None
|
||||
|
||||
# Транспортное представление ответа Dzengi GET /api/v1/ticker/24hr.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiTicker24hrResponse:
|
||||
symbol: str
|
||||
last_price: DzengiRawNumeric
|
||||
bid_price: DzengiRawNumeric
|
||||
ask_price: DzengiRawNumeric
|
||||
close_time: int
|
||||
|
||||
|
||||
# Нормализованное транспортное представление котировки из Dzengi WebSocket.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DzengiWebSocketQuoteResponse:
|
||||
symbol: str
|
||||
bid_price: DzengiRawNumeric
|
||||
ask_price: DzengiRawNumeric
|
||||
timestamp: int | None
|
||||
716
app/src/market_data/acquisition/adapters/dzengi/parser.py
Normal file
716
app/src/market_data/acquisition/adapters/dzengi/parser.py
Normal file
@@ -0,0 +1,716 @@
|
||||
# app/src/market_data/acquisition/adapters/dzengi/parser.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiExchangeInfoPayload,
|
||||
DzengiExchangeInfoResponse,
|
||||
DzengiExchangeInfoSymbol,
|
||||
DzengiInstrumentFilter,
|
||||
DzengiJsonNumber,
|
||||
DzengiJsonScalar,
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRateLimit,
|
||||
DzengiRawNumeric,
|
||||
DzengiUnknownFilter,
|
||||
DzengiTicker24hrResponse,
|
||||
DzengiWebSocketQuoteResponse,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceParseError,
|
||||
QuoteParseError,
|
||||
)
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
ValidatedExchangeInfoDocument,
|
||||
ValidatedQuoteDocument,
|
||||
ValidatedWebSocketQuoteDocument,
|
||||
)
|
||||
|
||||
|
||||
def parse_exchange_info(
|
||||
document: ValidatedExchangeInfoDocument,
|
||||
) -> DzengiExchangeInfoResponse:
|
||||
"""
|
||||
Преобразовать структурно проверенный exchangeInfo в raw-модели Dzengi.
|
||||
|
||||
Функция не выполняет schema validation, предметную валидацию,
|
||||
нормализацию символов или преобразование в Instrument.
|
||||
"""
|
||||
|
||||
payload = document.payload
|
||||
|
||||
return DzengiExchangeInfoResponse(
|
||||
status=_optional_string(
|
||||
document.status,
|
||||
path="$.status",
|
||||
),
|
||||
correlation_id=_optional_string(
|
||||
document.correlation_id,
|
||||
path="$.correlationId",
|
||||
),
|
||||
payload=DzengiExchangeInfoPayload(
|
||||
timezone=_optional_string(
|
||||
payload.get("timezone"),
|
||||
path="$.payload.timezone",
|
||||
),
|
||||
server_time=_optional_int(
|
||||
payload.get("serverTime"),
|
||||
path="$.payload.serverTime",
|
||||
),
|
||||
rate_limits=_parse_rate_limits(
|
||||
payload.get("rateLimits"),
|
||||
path="$.payload.rateLimits",
|
||||
),
|
||||
exchange_filters=_parse_exchange_filters(
|
||||
payload.get("exchangeFilters"),
|
||||
path="$.payload.exchangeFilters",
|
||||
),
|
||||
symbols=_parse_symbols(
|
||||
payload["symbols"],
|
||||
path="$.payload.symbols",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_symbols(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[DzengiExchangeInfoSymbol, ...]:
|
||||
items = _require_sequence(value, path=path)
|
||||
|
||||
symbols: list[DzengiExchangeInfoSymbol] = []
|
||||
|
||||
for index, item in enumerate(items):
|
||||
item_path = f"{path}[{index}]"
|
||||
mapping = _require_mapping(item, path=item_path)
|
||||
symbols.append(_parse_symbol(mapping, path=item_path))
|
||||
|
||||
return tuple(symbols)
|
||||
|
||||
|
||||
def _parse_symbol(
|
||||
item: Mapping[str, object],
|
||||
*,
|
||||
path: str,
|
||||
) -> DzengiExchangeInfoSymbol:
|
||||
return DzengiExchangeInfoSymbol(
|
||||
symbol=_required_string(
|
||||
item.get("symbol"),
|
||||
path=f"{path}.symbol",
|
||||
),
|
||||
name=_required_string(
|
||||
item.get("name"),
|
||||
path=f"{path}.name",
|
||||
),
|
||||
status=_required_string(
|
||||
item.get("status"),
|
||||
path=f"{path}.status",
|
||||
),
|
||||
asset_type=_optional_string(
|
||||
item.get("assetType"),
|
||||
path=f"{path}.assetType",
|
||||
),
|
||||
base_asset=_required_string(
|
||||
item.get("baseAsset"),
|
||||
path=f"{path}.baseAsset",
|
||||
),
|
||||
base_asset_precision=_optional_int(
|
||||
item.get("baseAssetPrecision"),
|
||||
path=f"{path}.baseAssetPrecision",
|
||||
),
|
||||
quote_asset=_required_string(
|
||||
item.get("quoteAsset"),
|
||||
path=f"{path}.quoteAsset",
|
||||
),
|
||||
quote_asset_id=_optional_string(
|
||||
item.get("quoteAssetId"),
|
||||
path=f"{path}.quoteAssetId",
|
||||
),
|
||||
quote_precision=_optional_int(
|
||||
item.get("quotePrecision"),
|
||||
path=f"{path}.quotePrecision",
|
||||
),
|
||||
order_types=_optional_string_tuple(
|
||||
item.get("orderTypes"),
|
||||
path=f"{path}.orderTypes",
|
||||
),
|
||||
filters=_parse_instrument_filters(
|
||||
item.get("filters"),
|
||||
path=f"{path}.filters",
|
||||
),
|
||||
market_modes=_optional_string_tuple(
|
||||
item.get("marketModes"),
|
||||
path=f"{path}.marketModes",
|
||||
),
|
||||
market_type=_required_string(
|
||||
item.get("marketType"),
|
||||
path=f"{path}.marketType",
|
||||
),
|
||||
country=_optional_string(
|
||||
item.get("country"),
|
||||
path=f"{path}.country",
|
||||
),
|
||||
sector=_optional_string(
|
||||
item.get("sector"),
|
||||
path=f"{path}.sector",
|
||||
),
|
||||
industry=_optional_string(
|
||||
item.get("industry"),
|
||||
path=f"{path}.industry",
|
||||
),
|
||||
trading_hours=_optional_string(
|
||||
item.get("tradingHours"),
|
||||
path=f"{path}.tradingHours",
|
||||
),
|
||||
tick_size=_optional_json_number(
|
||||
item.get("tickSize"),
|
||||
path=f"{path}.tickSize",
|
||||
),
|
||||
tick_value=_optional_json_number(
|
||||
item.get("tickValue"),
|
||||
path=f"{path}.tickValue",
|
||||
),
|
||||
trading_fee=_optional_json_number(
|
||||
item.get("tradingFee"),
|
||||
path=f"{path}.tradingFee",
|
||||
),
|
||||
exchange_fee=_optional_json_number(
|
||||
item.get("exchangeFee"),
|
||||
path=f"{path}.exchangeFee",
|
||||
),
|
||||
long_rate=_optional_json_number(
|
||||
item.get("longRate"),
|
||||
path=f"{path}.longRate",
|
||||
),
|
||||
short_rate=_optional_json_number(
|
||||
item.get("shortRate"),
|
||||
path=f"{path}.shortRate",
|
||||
),
|
||||
swap_charge_interval=_optional_int(
|
||||
item.get("swapChargeInterval"),
|
||||
path=f"{path}.swapChargeInterval",
|
||||
),
|
||||
min_sl_gap=_optional_json_number(
|
||||
item.get("minSLGap"),
|
||||
path=f"{path}.minSLGap",
|
||||
),
|
||||
max_sl_gap=_optional_json_number(
|
||||
item.get("maxSLGap"),
|
||||
path=f"{path}.maxSLGap",
|
||||
),
|
||||
min_tp_gap=_optional_json_number(
|
||||
item.get("minTPGap"),
|
||||
path=f"{path}.minTPGap",
|
||||
),
|
||||
max_tp_gap=_optional_json_number(
|
||||
item.get("maxTPGap"),
|
||||
path=f"{path}.maxTPGap",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_rate_limits(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[DzengiRateLimit, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
|
||||
items = _require_sequence(value, path=path)
|
||||
rate_limits: list[DzengiRateLimit] = []
|
||||
|
||||
for index, item in enumerate(items):
|
||||
item_path = f"{path}[{index}]"
|
||||
mapping = _require_mapping(item, path=item_path)
|
||||
|
||||
rate_limits.append(
|
||||
DzengiRateLimit(
|
||||
interval=_required_string(
|
||||
mapping.get("interval"),
|
||||
path=f"{item_path}.interval",
|
||||
),
|
||||
interval_num=_required_int(
|
||||
mapping.get("intervalNum"),
|
||||
path=f"{item_path}.intervalNum",
|
||||
),
|
||||
limit=_required_int(
|
||||
mapping.get("limit"),
|
||||
path=f"{item_path}.limit",
|
||||
),
|
||||
rate_limit_type=_required_string(
|
||||
mapping.get("rateLimitType"),
|
||||
path=f"{item_path}.rateLimitType",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(rate_limits)
|
||||
|
||||
|
||||
def _parse_exchange_filters(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[DzengiUnknownFilter, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
|
||||
items = _require_sequence(value, path=path)
|
||||
filters: list[DzengiUnknownFilter] = []
|
||||
|
||||
for index, item in enumerate(items):
|
||||
item_path = f"{path}[{index}]"
|
||||
mapping = _require_mapping(item, path=item_path)
|
||||
|
||||
filters.append(
|
||||
_parse_unknown_filter(
|
||||
mapping,
|
||||
path=item_path,
|
||||
filter_type_required=False,
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def _parse_instrument_filters(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[DzengiInstrumentFilter, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
|
||||
items = _require_sequence(value, path=path)
|
||||
filters: list[DzengiInstrumentFilter] = []
|
||||
|
||||
for index, item in enumerate(items):
|
||||
item_path = f"{path}[{index}]"
|
||||
mapping = _require_mapping(item, path=item_path)
|
||||
|
||||
filter_type = _required_string(
|
||||
mapping.get("filterType"),
|
||||
path=f"{item_path}.filterType",
|
||||
)
|
||||
|
||||
if filter_type == "LOT_SIZE":
|
||||
filters.append(
|
||||
DzengiLotSizeFilter(
|
||||
filter_type=filter_type,
|
||||
min_qty=_optional_raw_numeric(
|
||||
mapping.get("minQty"),
|
||||
path=f"{item_path}.minQty",
|
||||
),
|
||||
max_qty=_optional_raw_numeric(
|
||||
mapping.get("maxQty"),
|
||||
path=f"{item_path}.maxQty",
|
||||
),
|
||||
step_size=_optional_raw_numeric(
|
||||
mapping.get("stepSize"),
|
||||
path=f"{item_path}.stepSize",
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if filter_type == "MIN_NOTIONAL":
|
||||
filters.append(
|
||||
DzengiMinNotionalFilter(
|
||||
filter_type=filter_type,
|
||||
min_notional=_optional_raw_numeric(
|
||||
mapping.get("minNotional"),
|
||||
path=f"{item_path}.minNotional",
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
filters.append(
|
||||
_parse_unknown_filter(
|
||||
mapping,
|
||||
path=item_path,
|
||||
filter_type_required=True,
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(filters)
|
||||
|
||||
|
||||
def _parse_unknown_filter(
|
||||
mapping: Mapping[str, object],
|
||||
*,
|
||||
path: str,
|
||||
filter_type_required: bool,
|
||||
) -> DzengiUnknownFilter:
|
||||
if filter_type_required:
|
||||
filter_type = _required_string(
|
||||
mapping.get("filterType"),
|
||||
path=f"{path}.filterType",
|
||||
)
|
||||
else:
|
||||
filter_type = _optional_string(
|
||||
mapping.get("filterType"),
|
||||
path=f"{path}.filterType",
|
||||
) or ""
|
||||
|
||||
fields: list[tuple[str, DzengiJsonScalar]] = []
|
||||
|
||||
for key, value in mapping.items():
|
||||
if key == "filterType":
|
||||
continue
|
||||
|
||||
fields.append(
|
||||
(
|
||||
key,
|
||||
_require_json_scalar(
|
||||
value,
|
||||
path=f"{path}.{key}",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return DzengiUnknownFilter(
|
||||
filter_type=filter_type,
|
||||
fields=tuple(fields),
|
||||
)
|
||||
|
||||
|
||||
def _optional_string_tuple(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
|
||||
items = _require_sequence(value, path=path)
|
||||
result: list[str] = []
|
||||
|
||||
for index, item in enumerate(items):
|
||||
result.append(
|
||||
_required_string(
|
||||
item,
|
||||
path=f"{path}[{index}]",
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _required_string(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} должен быть строкой, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _optional_string(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return _required_string(value, path=path)
|
||||
|
||||
|
||||
def _required_int(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} должен быть целым числом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _optional_int(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return _required_int(value, path=path)
|
||||
|
||||
|
||||
def _optional_json_number(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> DzengiJsonNumber | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} должен быть JSON-числом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _optional_raw_numeric(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> DzengiRawNumeric | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} должен быть строкой или JSON-числом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _require_json_scalar(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> DzengiJsonScalar:
|
||||
if value is None or isinstance(value, (str, bool)):
|
||||
return value
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} должен быть скалярным JSON-значением, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
|
||||
def _require_mapping(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} должен быть отображением, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
for key in value:
|
||||
if not isinstance(key, str):
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} содержит нестроковый ключ "
|
||||
f"типа {type(key).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _require_sequence(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> Sequence[object]:
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
||||
raise InstrumentReferenceParseError(
|
||||
f"{path} должен быть последовательностью, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def parse_quote(
|
||||
document: ValidatedQuoteDocument,
|
||||
) -> DzengiTicker24hrResponse:
|
||||
"""
|
||||
Преобразовать структурно проверенный ticker/24hr в raw-модель Dzengi.
|
||||
|
||||
Функция не выполняет schema validation, предметную валидацию
|
||||
или mapping во внутреннюю модель Quote.
|
||||
"""
|
||||
|
||||
payload = document.payload
|
||||
|
||||
return DzengiTicker24hrResponse(
|
||||
symbol=_quote_required_string(
|
||||
payload.get("symbol"),
|
||||
path="$.payload.symbol",
|
||||
),
|
||||
last_price=_quote_required_raw_numeric(
|
||||
payload.get("lastPrice"),
|
||||
path="$.payload.lastPrice",
|
||||
),
|
||||
bid_price=_quote_required_raw_numeric(
|
||||
payload.get("bidPrice"),
|
||||
path="$.payload.bidPrice",
|
||||
),
|
||||
ask_price=_quote_required_raw_numeric(
|
||||
payload.get("askPrice"),
|
||||
path="$.payload.askPrice",
|
||||
),
|
||||
close_time=_quote_required_int(
|
||||
payload.get("closeTime"),
|
||||
path="$.payload.closeTime",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _quote_required_string(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise QuoteParseError(
|
||||
f"{path} должен быть строкой, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _quote_required_raw_numeric(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> DzengiRawNumeric:
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||
raise QuoteParseError(
|
||||
f"{path} должен быть строкой или JSON-числом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _quote_required_int(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise QuoteParseError(
|
||||
f"{path} должен быть целым числом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_dzengi_websocket_quote(
|
||||
document: ValidatedWebSocketQuoteDocument,
|
||||
) -> DzengiWebSocketQuoteResponse:
|
||||
"""Преобразовать проверенное WebSocket-сообщение в raw-модель Dzengi."""
|
||||
|
||||
payload = document.payload
|
||||
symbol_value = (
|
||||
payload.get("symbolName")
|
||||
or payload.get("symbol")
|
||||
or document.root_symbol
|
||||
)
|
||||
|
||||
symbol = _quote_required_string(
|
||||
symbol_value,
|
||||
path="$.payload.symbol",
|
||||
)
|
||||
|
||||
if "bid" in payload:
|
||||
bid_price = _quote_required_raw_numeric(
|
||||
payload.get("bid"),
|
||||
path="$.payload.bid",
|
||||
)
|
||||
ask_key = "ofr" if "ofr" in payload else "ask"
|
||||
ask_price = _quote_required_raw_numeric(
|
||||
payload.get(ask_key),
|
||||
path=f"$.payload.{ask_key}",
|
||||
)
|
||||
else:
|
||||
bid_price = _websocket_depth_price(
|
||||
payload.get("bids"),
|
||||
side="bids",
|
||||
)
|
||||
ask_price = _websocket_depth_price(
|
||||
payload.get("asks"),
|
||||
side="asks",
|
||||
)
|
||||
|
||||
timestamp = _websocket_optional_timestamp(
|
||||
payload.get("timestamp"),
|
||||
path="$.payload.timestamp",
|
||||
)
|
||||
|
||||
return DzengiWebSocketQuoteResponse(
|
||||
symbol=symbol,
|
||||
bid_price=bid_price,
|
||||
ask_price=ask_price,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def _websocket_depth_price(
|
||||
value: object,
|
||||
*,
|
||||
side: str,
|
||||
) -> DzengiRawNumeric:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise QuoteParseError(
|
||||
f"$.payload.{side} должен быть непустым списком."
|
||||
)
|
||||
|
||||
first = value[0]
|
||||
|
||||
if isinstance(first, list):
|
||||
if not first:
|
||||
raise QuoteParseError(
|
||||
f"$.payload.{side}[0] не должен быть пустым."
|
||||
)
|
||||
return _quote_required_raw_numeric(
|
||||
first[0],
|
||||
path=f"$.payload.{side}[0][0]",
|
||||
)
|
||||
|
||||
if isinstance(first, Mapping):
|
||||
for key in ("price", "p", "bidPrice", "askPrice"):
|
||||
if key in first:
|
||||
return _quote_required_raw_numeric(
|
||||
first.get(key),
|
||||
path=f"$.payload.{side}[0].{key}",
|
||||
)
|
||||
|
||||
raise QuoteParseError(
|
||||
f"$.payload.{side}[0] не содержит поле цены."
|
||||
)
|
||||
|
||||
raise QuoteParseError(
|
||||
f"$.payload.{side}[0] должен быть JSON-массивом или объектом."
|
||||
)
|
||||
|
||||
|
||||
def _websocket_optional_timestamp(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return _quote_required_int(value, path=path)
|
||||
106
app/src/market_data/acquisition/adapters/dzengi/rest.py
Normal file
106
app/src/market_data/acquisition/adapters/dzengi/rest.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# app/src/market_data/acquisition/adapters/dzengi/rest.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceTransportError,
|
||||
QuoteTransportError,
|
||||
)
|
||||
|
||||
|
||||
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
|
||||
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
|
||||
|
||||
|
||||
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
|
||||
class _PayloadRestClient(Protocol):
|
||||
def get_payload(
|
||||
self,
|
||||
path: str,
|
||||
params: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
...
|
||||
|
||||
|
||||
class DzengiInstrumentDocumentSource:
|
||||
"""
|
||||
Источник сырого документа Instrument Reference Data через Dzengi REST API.
|
||||
|
||||
На переходном этапе использует legacy ExchangeRestClient.
|
||||
Зависимость должна быть удалена после появления общего transport-клиента
|
||||
или после полного вывода integrations/exchange из эксплуатации.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: _PayloadRestClient | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
|
||||
def fetch_instrument_document(self) -> object:
|
||||
"""
|
||||
Получить декодированный ответ Dzengi exchangeInfo без его обработки.
|
||||
|
||||
Метод не выполняет schema validation, parsing, value validation,
|
||||
mapping или кэширование.
|
||||
"""
|
||||
|
||||
try:
|
||||
client: _PayloadRestClient = (
|
||||
self._client
|
||||
if self._client is not None
|
||||
else ExchangeRestClient()
|
||||
)
|
||||
|
||||
return client.get_payload(_EXCHANGE_INFO_PATH)
|
||||
|
||||
except Exception as exc:
|
||||
raise InstrumentReferenceTransportError(
|
||||
"Не удалось получить Instrument Reference Data "
|
||||
f"от Dzengi: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
class DzengiQuoteDocumentSource:
|
||||
"""Источник сырого документа текущей котировки через Dzengi REST API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: _PayloadRestClient | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
|
||||
def fetch_quote_document(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> object:
|
||||
"""
|
||||
Получить декодированный ответ Dzengi ticker/24hr без его обработки.
|
||||
|
||||
Метод не выполняет нормализацию symbol, schema validation, parsing,
|
||||
value validation, mapping, retry или кэширование.
|
||||
"""
|
||||
|
||||
try:
|
||||
client: _PayloadRestClient = (
|
||||
self._client
|
||||
if self._client is not None
|
||||
else ExchangeRestClient()
|
||||
)
|
||||
|
||||
return client.get_payload(
|
||||
_TICKER_24HR_PATH,
|
||||
params={
|
||||
"symbol": symbol,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
raise QuoteTransportError(
|
||||
"Не удалось получить текущую котировку "
|
||||
f"от Dzengi для символа '{symbol}': {exc}"
|
||||
) from exc
|
||||
37
app/src/market_data/acquisition/adapters/dzengi/websocket.py
Normal file
37
app/src/market_data/acquisition/adapters/dzengi/websocket.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# app/src/market_data/acquisition/adapters/dzengi/websocket.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||
map_dzengi_websocket_quote_to_quote,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||
parse_dzengi_websocket_quote,
|
||||
)
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
validate_dzengi_websocket_quote_schema,
|
||||
)
|
||||
from src.market_data.acquisition.validation.values import (
|
||||
validate_dzengi_websocket_quote_values,
|
||||
)
|
||||
|
||||
|
||||
# Преобразует одно декодированное сообщение Dzengi WebSocket в Quote.
|
||||
class DzengiWebSocketQuoteAdapter:
|
||||
def map_message(
|
||||
self,
|
||||
document: object,
|
||||
*,
|
||||
received_at: datetime | None = None,
|
||||
) -> Quote:
|
||||
validated = validate_dzengi_websocket_quote_schema(document)
|
||||
response = parse_dzengi_websocket_quote(validated)
|
||||
validate_dzengi_websocket_quote_values(response)
|
||||
|
||||
return map_dzengi_websocket_quote_to_quote(
|
||||
response,
|
||||
received_at=received_at or datetime.now(timezone.utc),
|
||||
)
|
||||
67
app/src/market_data/acquisition/exceptions.py
Normal file
67
app/src/market_data/acquisition/exceptions.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# app/src/market_data/acquisition/exceptions.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# Базовая ошибка подсистемы получения рыночных данных.
|
||||
class MarketDataAcquisitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка получения Instrument Reference Data от внешнего источника.
|
||||
class InstrumentReferenceTransportError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка структуры документа Instrument Reference Data.
|
||||
class InstrumentReferenceSchemaError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка преобразования проверенного документа в raw-модели адаптера.
|
||||
class InstrumentReferenceParseError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка допустимости значений Instrument Reference Data.
|
||||
class InstrumentReferenceValueError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка преобразования raw-модели источника во внутреннюю модель Instrument.
|
||||
class InstrumentReferenceMappingError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка регистрации или получения Instrument Feed.
|
||||
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
# Ошибка получения Quotes Feed от внешнего источника.
|
||||
class QuoteTransportError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка структуры документа Quotes Feed.
|
||||
class QuoteSchemaError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка преобразования проверенного документа в raw-модель котировки.
|
||||
class QuoteParseError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка допустимости значений Quotes Feed.
|
||||
class QuoteValueError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка преобразования raw-модели источника во внутреннюю модель Quote.
|
||||
class QuoteMappingError(MarketDataAcquisitionError):
|
||||
pass
|
||||
|
||||
|
||||
# Ошибка регистрации или получения Quotes Feed.
|
||||
class QuoteFeedRegistryError(MarketDataAcquisitionError):
|
||||
pass
|
||||
0
app/src/market_data/acquisition/feeds/__init__.py
Normal file
0
app/src/market_data/acquisition/feeds/__init__.py
Normal file
0
app/src/market_data/acquisition/feeds/index_feed.py
Normal file
0
app/src/market_data/acquisition/feeds/index_feed.py
Normal file
33
app/src/market_data/acquisition/feeds/instrument_feed.py
Normal file
33
app/src/market_data/acquisition/feeds/instrument_feed.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# app/src/market_data/acquisition/feeds/instrument_feed.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.protocol import (
|
||||
InstrumentDocumentHandler,
|
||||
InstrumentDocumentSource,
|
||||
)
|
||||
|
||||
|
||||
# Feed справочника инструментов: получает документ и передаёт его обработчику.
|
||||
class InstrumentFeed:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: InstrumentDocumentSource,
|
||||
handler: InstrumentDocumentHandler,
|
||||
) -> None:
|
||||
self._source = source
|
||||
self._handler = handler
|
||||
|
||||
def load_instruments(self) -> tuple[Instrument, ...]:
|
||||
"""
|
||||
Получить документ от источника и преобразовать его в модели Instrument.
|
||||
|
||||
Feed не выполняет transport, parsing, validation, mapping,
|
||||
кэширование или обработку ошибок самостоятельно.
|
||||
"""
|
||||
|
||||
document = self._source.fetch_instrument_document()
|
||||
|
||||
return self._handler.handle_instrument_document(document)
|
||||
36
app/src/market_data/acquisition/feeds/quotes_feed.py
Normal file
36
app/src/market_data/acquisition/feeds/quotes_feed.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# app/src/market_data/acquisition/feeds/quotes_feed.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.protocol import (
|
||||
QuoteDocumentHandler,
|
||||
QuoteDocumentSource,
|
||||
)
|
||||
|
||||
|
||||
# Feed текущих котировок: получает документ и передаёт его обработчику.
|
||||
class QuotesFeed:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: QuoteDocumentSource,
|
||||
handler: QuoteDocumentHandler,
|
||||
) -> None:
|
||||
self._source = source
|
||||
self._handler = handler
|
||||
|
||||
def load_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> Quote:
|
||||
"""
|
||||
Получить документ котировки и преобразовать его в модель Quote.
|
||||
|
||||
Feed не выполняет transport, parsing, validation, mapping,
|
||||
нормализацию symbol, retry, кэширование или обработку ошибок.
|
||||
"""
|
||||
|
||||
document = self._source.fetch_quote_document(symbol)
|
||||
|
||||
return self._handler.handle_quote_document(document)
|
||||
2
app/src/market_data/acquisition/feeds/status_feed.py
Normal file
2
app/src/market_data/acquisition/feeds/status_feed.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app/src/market_data/acquisition/feeds/status_feed.py
|
||||
|
||||
0
app/src/market_data/acquisition/feeds/time_feed.py
Normal file
0
app/src/market_data/acquisition/feeds/time_feed.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# app/src/market_data/acquisition/handlers/instrument_handler.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||
map_dzengi_exchange_info_to_instruments,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||
parse_exchange_info,
|
||||
)
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
validate_exchange_info_schema,
|
||||
)
|
||||
from src.market_data.acquisition.validation.values import (
|
||||
validate_exchange_info_values,
|
||||
)
|
||||
|
||||
|
||||
# Обработчик документа Instrument Reference Data формата Dzengi exchangeInfo.
|
||||
class DzengiInstrumentDocumentHandler:
|
||||
def handle_instrument_document(
|
||||
self,
|
||||
document: object,
|
||||
) -> tuple[Instrument, ...]:
|
||||
validated_document = validate_exchange_info_schema(document)
|
||||
|
||||
response = parse_exchange_info(validated_document)
|
||||
|
||||
validate_exchange_info_values(response)
|
||||
|
||||
return map_dzengi_exchange_info_to_instruments(response)
|
||||
35
app/src/market_data/acquisition/handlers/quotes_handler.py
Normal file
35
app/src/market_data/acquisition/handlers/quotes_handler.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# app/src/market_data/acquisition/handlers/quotes_handler.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||
map_dzengi_ticker_to_quote,
|
||||
)
|
||||
from src.market_data.acquisition.adapters.dzengi.parser import parse_quote
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.market_data.acquisition.validation.schema import (
|
||||
validate_quote_schema,
|
||||
)
|
||||
from src.market_data.acquisition.validation.values import (
|
||||
validate_quote_values,
|
||||
)
|
||||
|
||||
|
||||
# Обработчик документа Quotes Feed формата Dzengi ticker/24hr.
|
||||
class DzengiQuoteDocumentHandler:
|
||||
def handle_quote_document(
|
||||
self,
|
||||
document: object,
|
||||
) -> Quote:
|
||||
validated_document = validate_quote_schema(document)
|
||||
|
||||
response = parse_quote(validated_document)
|
||||
|
||||
validate_quote_values(response)
|
||||
|
||||
return map_dzengi_ticker_to_quote(
|
||||
response,
|
||||
received_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
# app/src/market_data/acquisition/handlers/status_handler.py
|
||||
|
||||
1
app/src/market_data/acquisition/models/__init__.py
Normal file
1
app/src/market_data/acquisition/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/src/market_data/acquisition/models/__init__.py
|
||||
0
app/src/market_data/acquisition/models/candle.py
Normal file
0
app/src/market_data/acquisition/models/candle.py
Normal file
0
app/src/market_data/acquisition/models/index.py
Normal file
0
app/src/market_data/acquisition/models/index.py
Normal file
38
app/src/market_data/acquisition/models/instrument.py
Normal file
38
app/src/market_data/acquisition/models/instrument.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# app/src/market_data/acquisition/models/instrument.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
# Независимое от источника справочное описание торгового инструмента.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Instrument:
|
||||
symbol: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
base_asset: str
|
||||
quote_asset: str
|
||||
asset_type: str | None
|
||||
|
||||
market_type: str
|
||||
market_modes: tuple[str, ...]
|
||||
order_types: tuple[str, ...]
|
||||
|
||||
base_asset_precision: int | None
|
||||
quote_asset_precision: int | None
|
||||
|
||||
tick_size: Decimal | None
|
||||
tick_value: Decimal | None
|
||||
|
||||
step_size: Decimal | None
|
||||
min_qty: Decimal | None
|
||||
max_qty: Decimal | None
|
||||
min_notional: Decimal | None
|
||||
|
||||
country: str | None
|
||||
sector: str | None
|
||||
industry: str | None
|
||||
trading_hours: str | None
|
||||
0
app/src/market_data/acquisition/models/orderbook.py
Normal file
0
app/src/market_data/acquisition/models/orderbook.py
Normal file
22
app/src/market_data/acquisition/models/quote.py
Normal file
22
app/src/market_data/acquisition/models/quote.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# app/src/market_data/acquisition/models/quote.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
# Независимый от источника снимок текущей рыночной котировки инструмента.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Quote:
|
||||
symbol: str
|
||||
|
||||
last_price: Decimal
|
||||
bid_price: Decimal
|
||||
ask_price: Decimal
|
||||
|
||||
exchange_timestamp: datetime | None
|
||||
received_at: datetime
|
||||
|
||||
source: str
|
||||
88
app/src/market_data/acquisition/models/status.py
Normal file
88
app/src/market_data/acquisition/models/status.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# app/src/market_data/acquisition/models/status.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
# Каноническое состояние торговой доступности инструмента.
|
||||
class InstrumentTradingState(StrEnum):
|
||||
OPEN = "OPEN"
|
||||
BREAK = "BREAK"
|
||||
NOT_TRADABLE = "NOT_TRADABLE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
# Результат классификации сырого статуса инструмента.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InstrumentStatusClassification:
|
||||
state: InstrumentTradingState
|
||||
normalized_status: str | None
|
||||
|
||||
|
||||
_OPEN_STATUSES = frozenset(
|
||||
{
|
||||
"TRADING",
|
||||
"OPEN",
|
||||
"ACTIVE",
|
||||
"ENABLED",
|
||||
"ONLINE",
|
||||
}
|
||||
)
|
||||
|
||||
_NOT_TRADABLE_STATUSES = frozenset(
|
||||
{
|
||||
"NOT_TRADABLE",
|
||||
"TRADING_DISABLED",
|
||||
"MARKET_DISABLED",
|
||||
"UNAVAILABLE_FOR_TRADING",
|
||||
"CLOSE_ONLY",
|
||||
"REDUCE_ONLY",
|
||||
"VIEW_ONLY",
|
||||
}
|
||||
)
|
||||
|
||||
_BREAK_STATUSES = frozenset(
|
||||
{
|
||||
"BREAK",
|
||||
"CLOSED",
|
||||
"HALT",
|
||||
"HALTED",
|
||||
"PAUSED",
|
||||
"SUSPENDED",
|
||||
"DISABLED",
|
||||
"SETTLING",
|
||||
"POST_ONLY",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Классифицировать сырой статус торгового инструмента.
|
||||
def classify_instrument_status(
|
||||
raw_status: str | None,
|
||||
) -> InstrumentStatusClassification:
|
||||
normalized_status = str(raw_status or "").strip().upper()
|
||||
|
||||
if normalized_status in _OPEN_STATUSES:
|
||||
return InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.OPEN,
|
||||
normalized_status=normalized_status,
|
||||
)
|
||||
|
||||
if normalized_status in _NOT_TRADABLE_STATUSES:
|
||||
return InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.NOT_TRADABLE,
|
||||
normalized_status=normalized_status,
|
||||
)
|
||||
|
||||
if normalized_status in _BREAK_STATUSES:
|
||||
return InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.BREAK,
|
||||
normalized_status=normalized_status,
|
||||
)
|
||||
|
||||
return InstrumentStatusClassification(
|
||||
state=InstrumentTradingState.UNKNOWN,
|
||||
normalized_status=normalized_status or None,
|
||||
)
|
||||
0
app/src/market_data/acquisition/models/time.py
Normal file
0
app/src/market_data/acquisition/models/time.py
Normal file
0
app/src/market_data/acquisition/models/trade.py
Normal file
0
app/src/market_data/acquisition/models/trade.py
Normal file
86
app/src/market_data/acquisition/protocol.py
Normal file
86
app/src/market_data/acquisition/protocol.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# app/src/market_data/acquisition/protocol.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
|
||||
|
||||
# Источник сырого документа Instrument Reference Data.
|
||||
@runtime_checkable
|
||||
class InstrumentDocumentSource(Protocol):
|
||||
def fetch_instrument_document(self) -> object:
|
||||
"""
|
||||
Получить декодированный транспортный документ Instrument Reference Data.
|
||||
|
||||
Источник не выполняет schema validation, parsing, value validation
|
||||
или mapping во внутреннюю модель Instrument.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Обработчик сырого документа Instrument Reference Data.
|
||||
@runtime_checkable
|
||||
class InstrumentDocumentHandler(Protocol):
|
||||
def handle_instrument_document(
|
||||
self,
|
||||
document: object,
|
||||
) -> tuple[Instrument, ...]:
|
||||
"""
|
||||
Преобразовать сырой документ в проверенные внутренние модели Instrument.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Источник готового справочника инструментов для Acquisition Service.
|
||||
@runtime_checkable
|
||||
class InstrumentFeedProtocol(Protocol):
|
||||
def load_instruments(self) -> tuple[Instrument, ...]:
|
||||
"""
|
||||
Получить полный immutable-набор внутренних моделей Instrument.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Источник сырого документа Quotes Feed.
|
||||
@runtime_checkable
|
||||
class QuoteDocumentSource(Protocol):
|
||||
def fetch_quote_document(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> object:
|
||||
"""
|
||||
Получить декодированный транспортный документ текущей котировки.
|
||||
|
||||
Источник не выполняет schema validation, parsing, value validation
|
||||
или mapping во внутреннюю модель Quote.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Обработчик сырого документа Quotes Feed.
|
||||
@runtime_checkable
|
||||
class QuoteDocumentHandler(Protocol):
|
||||
def handle_quote_document(
|
||||
self,
|
||||
document: object,
|
||||
) -> Quote:
|
||||
"""
|
||||
Преобразовать сырой документ в проверенную внутреннюю модель Quote.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Источник готовой текущей котировки для Acquisition Service.
|
||||
@runtime_checkable
|
||||
class QuoteFeedProtocol(Protocol):
|
||||
def load_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> Quote:
|
||||
"""
|
||||
Получить внутреннюю модель текущей котировки инструмента.
|
||||
"""
|
||||
...
|
||||
142
app/src/market_data/acquisition/registry.py
Normal file
142
app/src/market_data/acquisition/registry.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# app/src/market_data/acquisition/registry.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentFeedRegistryError,
|
||||
QuoteFeedRegistryError,
|
||||
)
|
||||
from src.market_data.acquisition.protocol import (
|
||||
InstrumentFeedProtocol,
|
||||
QuoteFeedProtocol,
|
||||
)
|
||||
|
||||
|
||||
# Реестр доступных Feed справочника инструментов.
|
||||
class InstrumentFeedRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._feeds: dict[str, InstrumentFeedProtocol] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
source_name: str,
|
||||
feed: InstrumentFeedProtocol,
|
||||
) -> None:
|
||||
"""
|
||||
Зарегистрировать Instrument Feed для указанного источника.
|
||||
|
||||
Повторная регистрация того же имени запрещена, чтобы исключить
|
||||
неявную замену production-зависимости.
|
||||
"""
|
||||
|
||||
normalized_source_name = self._normalize_source_name(source_name)
|
||||
|
||||
if not isinstance(feed, InstrumentFeedProtocol):
|
||||
raise InstrumentFeedRegistryError(
|
||||
f"Объект для источника '{normalized_source_name}' "
|
||||
"не соответствует InstrumentFeedProtocol."
|
||||
)
|
||||
|
||||
if normalized_source_name in self._feeds:
|
||||
raise InstrumentFeedRegistryError(
|
||||
f"Instrument Feed для источника "
|
||||
f"'{normalized_source_name}' уже зарегистрирован."
|
||||
)
|
||||
|
||||
self._feeds[normalized_source_name] = feed
|
||||
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> InstrumentFeedProtocol:
|
||||
"""Вернуть зарегистрированный Instrument Feed по имени источника."""
|
||||
|
||||
normalized_source_name = self._normalize_source_name(source_name)
|
||||
|
||||
feed = self._feeds.get(normalized_source_name)
|
||||
|
||||
if feed is None:
|
||||
raise InstrumentFeedRegistryError(
|
||||
f"Instrument Feed для источника "
|
||||
f"'{normalized_source_name}' не зарегистрирован."
|
||||
)
|
||||
|
||||
return feed
|
||||
|
||||
def _normalize_source_name(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> str:
|
||||
normalized_source_name = source_name.strip()
|
||||
|
||||
if not normalized_source_name:
|
||||
raise InstrumentFeedRegistryError(
|
||||
"Имя источника Instrument Feed не должно быть пустым."
|
||||
)
|
||||
|
||||
return normalized_source_name
|
||||
|
||||
|
||||
# Реестр доступных потоков текущих котировок.
|
||||
class QuoteFeedRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._feeds: dict[str, QuoteFeedProtocol] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
source_name: str,
|
||||
feed: QuoteFeedProtocol,
|
||||
) -> None:
|
||||
"""
|
||||
Зарегистрировать Quotes Feed для указанного источника.
|
||||
|
||||
Повторная регистрация того же имени запрещена, чтобы исключить
|
||||
неявную замену production-зависимости.
|
||||
"""
|
||||
|
||||
normalized_source_name = self._normalize_source_name(source_name)
|
||||
|
||||
if not isinstance(feed, QuoteFeedProtocol):
|
||||
raise QuoteFeedRegistryError(
|
||||
f"Объект для источника '{normalized_source_name}' "
|
||||
"не соответствует QuoteFeedProtocol."
|
||||
)
|
||||
|
||||
if normalized_source_name in self._feeds:
|
||||
raise QuoteFeedRegistryError(
|
||||
f"Quotes Feed для источника "
|
||||
f"'{normalized_source_name}' уже зарегистрирован."
|
||||
)
|
||||
|
||||
self._feeds[normalized_source_name] = feed
|
||||
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> QuoteFeedProtocol:
|
||||
"""Вернуть зарегистрированный Quotes Feed по имени источника."""
|
||||
|
||||
normalized_source_name = self._normalize_source_name(source_name)
|
||||
|
||||
feed = self._feeds.get(normalized_source_name)
|
||||
|
||||
if feed is None:
|
||||
raise QuoteFeedRegistryError(
|
||||
f"Quotes Feed для источника "
|
||||
f"'{normalized_source_name}' не зарегистрирован."
|
||||
)
|
||||
|
||||
return feed
|
||||
|
||||
def _normalize_source_name(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> str:
|
||||
normalized_source_name = source_name.strip()
|
||||
|
||||
if not normalized_source_name:
|
||||
raise QuoteFeedRegistryError(
|
||||
"Имя источника Quotes Feed не должно быть пустым."
|
||||
)
|
||||
|
||||
return normalized_source_name
|
||||
0
app/src/market_data/acquisition/runtime/__init__.py
Normal file
0
app/src/market_data/acquisition/runtime/__init__.py
Normal file
62
app/src/market_data/acquisition/service.py
Normal file
62
app/src/market_data/acquisition/service.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# app/src/market_data/acquisition/service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# Application-level сервис получения справочника инструментов.
|
||||
class InstrumentAcquisitionService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry: InstrumentFeedRegistry,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def load_instruments(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> tuple[Instrument, ...]:
|
||||
"""
|
||||
Получить Instrument Feed из Registry и загрузить справочник инструментов.
|
||||
|
||||
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
||||
mapping, retry, кэширование или преобразование результата.
|
||||
"""
|
||||
|
||||
feed = self._registry.get(source_name)
|
||||
|
||||
return feed.load_instruments()
|
||||
|
||||
|
||||
# Application-level сервис получения текущих котировок.
|
||||
class QuoteAcquisitionService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry: QuoteFeedRegistry,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def load_quote(
|
||||
self,
|
||||
source_name: str,
|
||||
symbol: str,
|
||||
) -> Quote:
|
||||
"""
|
||||
Получить Quotes Feed из Registry и загрузить текущую котировку.
|
||||
|
||||
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
||||
mapping, нормализацию symbol, retry, кэширование или преобразование
|
||||
результата.
|
||||
"""
|
||||
|
||||
feed = self._registry.get(source_name)
|
||||
|
||||
return feed.load_quote(symbol)
|
||||
45
app/src/market_data/acquisition/symbols.py
Normal file
45
app/src/market_data/acquisition/symbols.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# app/src/market_data/acquisition/symbols.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
# Привести идентификатор торгового инструмента к базовой канонической форме.
|
||||
def normalize_symbol(raw_symbol: str) -> str:
|
||||
return (raw_symbol or "").strip().upper()
|
||||
|
||||
|
||||
# Сформировать упорядоченные варианты идентификатора инструмента.
|
||||
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
|
||||
|
||||
|
||||
# Найти индекс первого доступного символа с учётом порядка кандидатов.
|
||||
def resolve_symbol_index(
|
||||
raw_symbol: str,
|
||||
available_symbols: Sequence[str],
|
||||
) -> int | None:
|
||||
for candidate in symbol_candidates(raw_symbol):
|
||||
for index, available_symbol in enumerate(available_symbols):
|
||||
if normalize_symbol(available_symbol) == candidate:
|
||||
return index
|
||||
|
||||
return None
|
||||
400
app/src/market_data/acquisition/validation/schema.py
Normal file
400
app/src/market_data/acquisition/validation/schema.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# app/src/market_data/acquisition/validation/schema.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Mapping
|
||||
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceSchemaError,
|
||||
QuoteSchemaError,
|
||||
)
|
||||
|
||||
|
||||
# Проверенное структурное представление ответа exchangeInfo.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidatedExchangeInfoDocument:
|
||||
payload: Mapping[str, object]
|
||||
is_wrapped: bool
|
||||
status: object | None
|
||||
correlation_id: object | None
|
||||
|
||||
|
||||
def validate_exchange_info_schema(
|
||||
document: object,
|
||||
) -> ValidatedExchangeInfoDocument:
|
||||
"""
|
||||
Проверить структуру ответа exchangeInfo без разбора предметных значений.
|
||||
|
||||
Поддерживаются:
|
||||
|
||||
1. Unwrapped-формат:
|
||||
|
||||
{
|
||||
"symbols": [...]
|
||||
}
|
||||
|
||||
2. Wrapped-формат:
|
||||
|
||||
{
|
||||
"status": "OK",
|
||||
"correlationId": "2",
|
||||
"payload": {
|
||||
"symbols": [...]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
root = _require_mapping(
|
||||
document,
|
||||
path="$",
|
||||
)
|
||||
|
||||
is_wrapped = "payload" in root
|
||||
|
||||
if is_wrapped:
|
||||
payload = _require_mapping(
|
||||
root.get("payload"),
|
||||
path="$.payload",
|
||||
)
|
||||
else:
|
||||
payload = root
|
||||
|
||||
_validate_exchange_info_payload(payload)
|
||||
|
||||
return ValidatedExchangeInfoDocument(
|
||||
payload=MappingProxyType(dict(payload)),
|
||||
is_wrapped=is_wrapped,
|
||||
status=root.get("status") if is_wrapped else None,
|
||||
correlation_id=(
|
||||
root.get("correlationId")
|
||||
if is_wrapped
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _validate_exchange_info_payload(
|
||||
payload: Mapping[str, object],
|
||||
) -> None:
|
||||
symbols = _require_list(
|
||||
payload.get("symbols"),
|
||||
path="$.payload.symbols",
|
||||
)
|
||||
|
||||
for index, symbol in enumerate(symbols):
|
||||
symbol_path = f"$.payload.symbols[{index}]"
|
||||
|
||||
symbol_mapping = _require_mapping(
|
||||
symbol,
|
||||
path=symbol_path,
|
||||
)
|
||||
|
||||
_validate_optional_mapping_list(
|
||||
symbol_mapping,
|
||||
key="filters",
|
||||
path=f"{symbol_path}.filters",
|
||||
)
|
||||
|
||||
_validate_optional_string_list(
|
||||
symbol_mapping,
|
||||
key="marketModes",
|
||||
path=f"{symbol_path}.marketModes",
|
||||
)
|
||||
|
||||
_validate_optional_string_list(
|
||||
symbol_mapping,
|
||||
key="orderTypes",
|
||||
path=f"{symbol_path}.orderTypes",
|
||||
)
|
||||
|
||||
_validate_optional_mapping_list(
|
||||
payload,
|
||||
key="rateLimits",
|
||||
path="$.payload.rateLimits",
|
||||
)
|
||||
|
||||
_validate_optional_mapping_list(
|
||||
payload,
|
||||
key="exchangeFilters",
|
||||
path="$.payload.exchangeFilters",
|
||||
)
|
||||
|
||||
|
||||
def _validate_optional_mapping_list(
|
||||
mapping: Mapping[str, object],
|
||||
*,
|
||||
key: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
if key not in mapping:
|
||||
return
|
||||
|
||||
items = _require_list(
|
||||
mapping.get(key),
|
||||
path=path,
|
||||
)
|
||||
|
||||
for index, item in enumerate(items):
|
||||
_require_mapping(
|
||||
item,
|
||||
path=f"{path}[{index}]",
|
||||
)
|
||||
|
||||
|
||||
def _validate_optional_string_list(
|
||||
mapping: Mapping[str, object],
|
||||
*,
|
||||
key: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
if key not in mapping:
|
||||
return
|
||||
|
||||
items = _require_list(
|
||||
mapping.get(key),
|
||||
path=path,
|
||||
)
|
||||
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, str):
|
||||
raise InstrumentReferenceSchemaError(
|
||||
f"{path}[{index}] должен быть строкой, "
|
||||
f"получен {type(item).__name__}."
|
||||
)
|
||||
|
||||
|
||||
def _require_mapping(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> Mapping[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
raise InstrumentReferenceSchemaError(
|
||||
f"{path} должен быть JSON-объектом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
for key in value:
|
||||
if not isinstance(key, str):
|
||||
raise InstrumentReferenceSchemaError(
|
||||
f"{path} содержит нестроковый ключ "
|
||||
f"типа {type(key).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _require_list(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> list[object]:
|
||||
if not isinstance(value, list):
|
||||
raise InstrumentReferenceSchemaError(
|
||||
f"{path} должен быть JSON-массивом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
# Структурно проверенное представление ответа ticker/24hr.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidatedQuoteDocument:
|
||||
payload: Mapping[str, object]
|
||||
is_wrapped: bool
|
||||
status: object | None
|
||||
correlation_id: object | None
|
||||
|
||||
|
||||
def validate_quote_schema(
|
||||
document: object,
|
||||
) -> ValidatedQuoteDocument:
|
||||
"""
|
||||
Проверить структуру ответа Dzengi ticker/24hr без проверки значений.
|
||||
|
||||
Поддерживаются прямой JSON-объект котировки и wrapped-формат
|
||||
с объектом котировки в поле payload.
|
||||
"""
|
||||
|
||||
root = _require_quote_mapping(
|
||||
document,
|
||||
path="$",
|
||||
)
|
||||
|
||||
is_wrapped = "payload" in root
|
||||
|
||||
if is_wrapped:
|
||||
payload = _require_quote_mapping(
|
||||
root.get("payload"),
|
||||
path="$.payload",
|
||||
)
|
||||
else:
|
||||
payload = root
|
||||
|
||||
_validate_quote_payload(payload)
|
||||
|
||||
return ValidatedQuoteDocument(
|
||||
payload=MappingProxyType(dict(payload)),
|
||||
is_wrapped=is_wrapped,
|
||||
status=root.get("status") if is_wrapped else None,
|
||||
correlation_id=(
|
||||
root.get("correlationId")
|
||||
if is_wrapped
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _validate_quote_payload(
|
||||
payload: Mapping[str, object],
|
||||
) -> None:
|
||||
_require_quote_key(payload, key="symbol", path="$.payload.symbol")
|
||||
_require_quote_key(payload, key="lastPrice", path="$.payload.lastPrice")
|
||||
_require_quote_key(payload, key="bidPrice", path="$.payload.bidPrice")
|
||||
_require_quote_key(payload, key="askPrice", path="$.payload.askPrice")
|
||||
_require_quote_key(payload, key="closeTime", path="$.payload.closeTime")
|
||||
|
||||
|
||||
def _require_quote_key(
|
||||
mapping: Mapping[str, object],
|
||||
*,
|
||||
key: str,
|
||||
path: str,
|
||||
) -> None:
|
||||
if key not in mapping:
|
||||
raise QuoteSchemaError(
|
||||
f"{path} отсутствует в документе ticker/24hr."
|
||||
)
|
||||
|
||||
|
||||
def _require_quote_mapping(
|
||||
value: object,
|
||||
*,
|
||||
path: str,
|
||||
) -> Mapping[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
raise QuoteSchemaError(
|
||||
f"{path} должен быть JSON-объектом, "
|
||||
f"получен {type(value).__name__}."
|
||||
)
|
||||
|
||||
for key in value:
|
||||
if not isinstance(key, str):
|
||||
raise QuoteSchemaError(
|
||||
f"{path} содержит нестроковый ключ "
|
||||
f"типа {type(key).__name__}."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
# Структурно проверенное представление сообщения котировки Dzengi WebSocket.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidatedWebSocketQuoteDocument:
|
||||
payload: Mapping[str, object]
|
||||
root_symbol: object | None
|
||||
|
||||
|
||||
def validate_dzengi_websocket_quote_schema(
|
||||
document: object,
|
||||
) -> ValidatedWebSocketQuoteDocument:
|
||||
"""
|
||||
Проверить структуру одного декодированного сообщения Dzengi WebSocket.
|
||||
|
||||
Поддерживаются сообщения без оболочки и до двух известных оболочек
|
||||
``payload`` / ``Payload``. Проверка не преобразует цены и не выполняет
|
||||
предметную валидацию.
|
||||
"""
|
||||
|
||||
root = _require_quote_mapping(document, path="$")
|
||||
root_symbol = root.get("symbol")
|
||||
payload = _unwrap_websocket_quote_payload(root)
|
||||
|
||||
_validate_websocket_quote_payload(
|
||||
payload,
|
||||
root_symbol=root_symbol,
|
||||
)
|
||||
|
||||
return ValidatedWebSocketQuoteDocument(
|
||||
payload=MappingProxyType(dict(payload)),
|
||||
root_symbol=root_symbol,
|
||||
)
|
||||
|
||||
|
||||
def _unwrap_websocket_quote_payload(
|
||||
root: Mapping[str, object],
|
||||
) -> Mapping[str, object]:
|
||||
payload = root
|
||||
|
||||
for level in range(2):
|
||||
nested: object | None = None
|
||||
nested_path = "$.payload" if level == 0 else "$.payload.payload"
|
||||
|
||||
for key in ("payload", "Payload"):
|
||||
candidate = payload.get(key)
|
||||
if candidate is not None:
|
||||
nested = candidate
|
||||
break
|
||||
|
||||
if nested is None:
|
||||
break
|
||||
|
||||
payload = _require_quote_mapping(
|
||||
nested,
|
||||
path=nested_path,
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _validate_websocket_quote_payload(
|
||||
payload: Mapping[str, object],
|
||||
*,
|
||||
root_symbol: object | None,
|
||||
) -> None:
|
||||
if (
|
||||
"symbolName" not in payload
|
||||
and "symbol" not in payload
|
||||
and root_symbol is None
|
||||
):
|
||||
raise QuoteSchemaError(
|
||||
"$.payload не содержит symbolName или symbol."
|
||||
)
|
||||
|
||||
has_direct_bid = "bid" in payload
|
||||
has_direct_ask = "ask" in payload or "ofr" in payload
|
||||
has_depth_bid = "bids" in payload
|
||||
has_depth_ask = "asks" in payload
|
||||
|
||||
if has_direct_bid or has_direct_ask:
|
||||
if not has_direct_bid or not has_direct_ask:
|
||||
raise QuoteSchemaError(
|
||||
"WebSocket quote должна содержать полный набор bid и ask/ofr."
|
||||
)
|
||||
return
|
||||
|
||||
if has_depth_bid or has_depth_ask:
|
||||
if not has_depth_bid or not has_depth_ask:
|
||||
raise QuoteSchemaError(
|
||||
"WebSocket depth quote должна содержать bids и asks."
|
||||
)
|
||||
|
||||
bids = payload.get("bids")
|
||||
asks = payload.get("asks")
|
||||
|
||||
if not isinstance(bids, list) or not bids:
|
||||
raise QuoteSchemaError(
|
||||
"$.payload.bids должен быть непустым JSON-массивом."
|
||||
)
|
||||
|
||||
if not isinstance(asks, list) or not asks:
|
||||
raise QuoteSchemaError(
|
||||
"$.payload.asks должен быть непустым JSON-массивом."
|
||||
)
|
||||
return
|
||||
|
||||
raise QuoteSchemaError(
|
||||
"WebSocket quote не содержит bid/ask либо bids/asks."
|
||||
)
|
||||
1
app/src/market_data/acquisition/validation/sequence.py
Normal file
1
app/src/market_data/acquisition/validation/sequence.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/src/market_data/acquisition/validation/sequence.py
|
||||
551
app/src/market_data/acquisition/validation/values.py
Normal file
551
app/src/market_data/acquisition/validation/values.py
Normal file
@@ -0,0 +1,551 @@
|
||||
# app/src/market_data/acquisition/validation/values.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
||||
DzengiExchangeInfoResponse,
|
||||
DzengiExchangeInfoSymbol,
|
||||
DzengiInstrumentFilter,
|
||||
DzengiLotSizeFilter,
|
||||
DzengiMinNotionalFilter,
|
||||
DzengiRateLimit,
|
||||
DzengiRawNumeric,
|
||||
DzengiUnknownFilter,
|
||||
DzengiTicker24hrResponse,
|
||||
DzengiWebSocketQuoteResponse,
|
||||
)
|
||||
from src.market_data.acquisition.exceptions import (
|
||||
InstrumentReferenceValueError,
|
||||
QuoteValueError,
|
||||
)
|
||||
|
||||
|
||||
def validate_exchange_info_values(
|
||||
response: DzengiExchangeInfoResponse,
|
||||
) -> None:
|
||||
"""
|
||||
Проверить допустимость значений в raw-моделях Dzengi exchangeInfo.
|
||||
|
||||
Функция не изменяет модели, не выполняет mapping в Instrument
|
||||
и не повторяет schema validation или parsing.
|
||||
"""
|
||||
|
||||
_validate_optional_non_empty_string(
|
||||
response.status,
|
||||
path="$.status",
|
||||
)
|
||||
_validate_optional_non_empty_string(
|
||||
response.correlation_id,
|
||||
path="$.correlationId",
|
||||
)
|
||||
|
||||
payload = response.payload
|
||||
|
||||
_validate_optional_non_empty_string(
|
||||
payload.timezone,
|
||||
path="$.payload.timezone",
|
||||
)
|
||||
|
||||
for index, rate_limit in enumerate(payload.rate_limits):
|
||||
_validate_rate_limit(
|
||||
rate_limit,
|
||||
path=f"$.payload.rateLimits[{index}]",
|
||||
)
|
||||
|
||||
for index, exchange_filter in enumerate(payload.exchange_filters):
|
||||
_validate_unknown_filter(
|
||||
exchange_filter,
|
||||
path=f"$.payload.exchangeFilters[{index}]",
|
||||
allow_empty_filter_type=True,
|
||||
)
|
||||
|
||||
for index, symbol in enumerate(payload.symbols):
|
||||
_validate_symbol(
|
||||
symbol,
|
||||
path=f"$.payload.symbols[{index}]",
|
||||
)
|
||||
|
||||
|
||||
def _validate_symbol(
|
||||
symbol: DzengiExchangeInfoSymbol,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
_validate_required_non_empty_string(
|
||||
symbol.symbol,
|
||||
path=f"{path}.symbol",
|
||||
)
|
||||
_validate_required_non_empty_string(
|
||||
symbol.name,
|
||||
path=f"{path}.name",
|
||||
)
|
||||
_validate_required_non_empty_string(
|
||||
symbol.status,
|
||||
path=f"{path}.status",
|
||||
)
|
||||
_validate_required_non_empty_string(
|
||||
symbol.base_asset,
|
||||
path=f"{path}.baseAsset",
|
||||
)
|
||||
_validate_required_non_empty_string(
|
||||
symbol.quote_asset,
|
||||
path=f"{path}.quoteAsset",
|
||||
)
|
||||
_validate_required_non_empty_string(
|
||||
symbol.market_type,
|
||||
path=f"{path}.marketType",
|
||||
)
|
||||
|
||||
_validate_optional_non_empty_string(
|
||||
symbol.asset_type,
|
||||
path=f"{path}.assetType",
|
||||
)
|
||||
_validate_optional_non_empty_string(
|
||||
symbol.quote_asset_id,
|
||||
path=f"{path}.quoteAssetId",
|
||||
)
|
||||
_validate_optional_non_empty_string(
|
||||
symbol.trading_hours,
|
||||
path=f"{path}.tradingHours",
|
||||
)
|
||||
|
||||
# Dzengi может возвращать пустые строки для country, sector и industry.
|
||||
# Эти значения сохраняются как часть raw-контракта и не считаются ошибкой.
|
||||
|
||||
_validate_non_empty_string_tuple(
|
||||
symbol.order_types,
|
||||
path=f"{path}.orderTypes",
|
||||
)
|
||||
_validate_non_empty_string_tuple(
|
||||
symbol.market_modes,
|
||||
path=f"{path}.marketModes",
|
||||
)
|
||||
|
||||
_validate_optional_non_negative_int(
|
||||
symbol.base_asset_precision,
|
||||
path=f"{path}.baseAssetPrecision",
|
||||
)
|
||||
_validate_optional_non_negative_int(
|
||||
symbol.quote_precision,
|
||||
path=f"{path}.quotePrecision",
|
||||
)
|
||||
_validate_optional_non_negative_int(
|
||||
symbol.swap_charge_interval,
|
||||
path=f"{path}.swapChargeInterval",
|
||||
)
|
||||
|
||||
_validate_optional_positive_number(
|
||||
symbol.tick_size,
|
||||
path=f"{path}.tickSize",
|
||||
)
|
||||
|
||||
_validate_optional_finite_number(
|
||||
symbol.tick_value,
|
||||
path=f"{path}.tickValue",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.trading_fee,
|
||||
path=f"{path}.tradingFee",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.exchange_fee,
|
||||
path=f"{path}.exchangeFee",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.long_rate,
|
||||
path=f"{path}.longRate",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.short_rate,
|
||||
path=f"{path}.shortRate",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.min_sl_gap,
|
||||
path=f"{path}.minSLGap",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.max_sl_gap,
|
||||
path=f"{path}.maxSLGap",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.min_tp_gap,
|
||||
path=f"{path}.minTPGap",
|
||||
)
|
||||
_validate_optional_finite_number(
|
||||
symbol.max_tp_gap,
|
||||
path=f"{path}.maxTPGap",
|
||||
)
|
||||
|
||||
for index, instrument_filter in enumerate(symbol.filters):
|
||||
_validate_instrument_filter(
|
||||
instrument_filter,
|
||||
path=f"{path}.filters[{index}]",
|
||||
)
|
||||
|
||||
|
||||
def _validate_rate_limit(
|
||||
rate_limit: DzengiRateLimit,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
_validate_required_non_empty_string(
|
||||
rate_limit.interval,
|
||||
path=f"{path}.interval",
|
||||
)
|
||||
_validate_required_non_empty_string(
|
||||
rate_limit.rate_limit_type,
|
||||
path=f"{path}.rateLimitType",
|
||||
)
|
||||
_validate_positive_int(
|
||||
rate_limit.interval_num,
|
||||
path=f"{path}.intervalNum",
|
||||
)
|
||||
_validate_positive_int(
|
||||
rate_limit.limit,
|
||||
path=f"{path}.limit",
|
||||
)
|
||||
|
||||
|
||||
def _validate_instrument_filter(
|
||||
instrument_filter: DzengiInstrumentFilter,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
_validate_required_non_empty_string(
|
||||
instrument_filter.filter_type,
|
||||
path=f"{path}.filterType",
|
||||
)
|
||||
|
||||
if isinstance(instrument_filter, DzengiLotSizeFilter):
|
||||
_validate_lot_size_filter(
|
||||
instrument_filter,
|
||||
path=path,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(instrument_filter, DzengiMinNotionalFilter):
|
||||
_validate_min_notional_filter(
|
||||
instrument_filter,
|
||||
path=path,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(instrument_filter, DzengiUnknownFilter):
|
||||
_validate_unknown_filter(
|
||||
instrument_filter,
|
||||
path=path,
|
||||
allow_empty_filter_type=False,
|
||||
)
|
||||
|
||||
|
||||
def _validate_lot_size_filter(
|
||||
lot_size: DzengiLotSizeFilter,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
min_qty = _validate_optional_positive_raw_numeric(
|
||||
lot_size.min_qty,
|
||||
path=f"{path}.minQty",
|
||||
)
|
||||
max_qty = _validate_optional_positive_raw_numeric(
|
||||
lot_size.max_qty,
|
||||
path=f"{path}.maxQty",
|
||||
)
|
||||
_validate_optional_positive_raw_numeric(
|
||||
lot_size.step_size,
|
||||
path=f"{path}.stepSize",
|
||||
)
|
||||
|
||||
if (
|
||||
min_qty is not None
|
||||
and max_qty is not None
|
||||
and min_qty > max_qty
|
||||
):
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path}.minQty не должно превышать {path}.maxQty."
|
||||
)
|
||||
|
||||
|
||||
def _validate_min_notional_filter(
|
||||
min_notional: DzengiMinNotionalFilter,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
_validate_optional_non_negative_raw_numeric(
|
||||
min_notional.min_notional,
|
||||
path=f"{path}.minNotional",
|
||||
)
|
||||
|
||||
|
||||
def _validate_unknown_filter(
|
||||
unknown_filter: DzengiUnknownFilter,
|
||||
*,
|
||||
path: str,
|
||||
allow_empty_filter_type: bool,
|
||||
) -> None:
|
||||
if allow_empty_filter_type:
|
||||
if unknown_filter.filter_type and not unknown_filter.filter_type.strip():
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path}.filterType не должен состоять только из пробелов."
|
||||
)
|
||||
return
|
||||
|
||||
_validate_required_non_empty_string(
|
||||
unknown_filter.filter_type,
|
||||
path=f"{path}.filterType",
|
||||
)
|
||||
|
||||
|
||||
def _validate_required_non_empty_string(
|
||||
value: str,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
if not value.strip():
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} не должен быть пустым."
|
||||
)
|
||||
|
||||
|
||||
def _validate_optional_non_empty_string(
|
||||
value: str | None,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
if value is None:
|
||||
return
|
||||
|
||||
if not value.strip():
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} не должен быть пустым."
|
||||
)
|
||||
|
||||
|
||||
def _validate_non_empty_string_tuple(
|
||||
values: tuple[str, ...],
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
for index, value in enumerate(values):
|
||||
if not value.strip():
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path}[{index}] не должен быть пустым."
|
||||
)
|
||||
|
||||
|
||||
def _validate_optional_non_negative_int(
|
||||
value: int | None,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
if value is None:
|
||||
return
|
||||
|
||||
if value < 0:
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} должно быть больше или равно нулю."
|
||||
)
|
||||
|
||||
|
||||
def _validate_positive_int(
|
||||
value: int,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
if value <= 0:
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} должно быть больше нуля."
|
||||
)
|
||||
|
||||
|
||||
def _validate_optional_positive_number(
|
||||
value: int | float | None,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
if value is None:
|
||||
return
|
||||
|
||||
decimal_value = _to_finite_decimal(
|
||||
value,
|
||||
path=path,
|
||||
)
|
||||
|
||||
if decimal_value <= 0:
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} должно быть больше нуля."
|
||||
)
|
||||
|
||||
|
||||
def _validate_optional_finite_number(
|
||||
value: int | float | None,
|
||||
*,
|
||||
path: str,
|
||||
) -> None:
|
||||
if value is None:
|
||||
return
|
||||
|
||||
_to_finite_decimal(
|
||||
value,
|
||||
path=path,
|
||||
)
|
||||
|
||||
|
||||
def _validate_optional_positive_raw_numeric(
|
||||
value: DzengiRawNumeric | None,
|
||||
*,
|
||||
path: str,
|
||||
) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
decimal_value = _to_finite_decimal(
|
||||
value,
|
||||
path=path,
|
||||
)
|
||||
|
||||
if decimal_value <= 0:
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} должно быть больше нуля."
|
||||
)
|
||||
|
||||
return decimal_value
|
||||
|
||||
|
||||
def _validate_optional_non_negative_raw_numeric(
|
||||
value: DzengiRawNumeric | None,
|
||||
*,
|
||||
path: str,
|
||||
) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
decimal_value = _to_finite_decimal(
|
||||
value,
|
||||
path=path,
|
||||
)
|
||||
|
||||
if decimal_value < 0:
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} должно быть больше или равно нулю."
|
||||
)
|
||||
|
||||
return decimal_value
|
||||
|
||||
|
||||
def _to_finite_decimal(
|
||||
value: str | int | float,
|
||||
*,
|
||||
path: str,
|
||||
) -> Decimal:
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} должно быть корректным числом."
|
||||
) from exc
|
||||
|
||||
if not decimal_value.is_finite():
|
||||
raise InstrumentReferenceValueError(
|
||||
f"{path} должно быть конечным числом."
|
||||
)
|
||||
|
||||
return decimal_value
|
||||
|
||||
def validate_quote_values(
|
||||
response: DzengiTicker24hrResponse,
|
||||
) -> None:
|
||||
"""
|
||||
Проверить допустимость значений raw-модели Dzengi ticker/24hr.
|
||||
|
||||
Функция не изменяет модель и не выполняет mapping в Quote.
|
||||
"""
|
||||
|
||||
if not response.symbol.strip():
|
||||
raise QuoteValueError(
|
||||
"$.payload.symbol не должен быть пустым."
|
||||
)
|
||||
|
||||
last_price = _quote_positive_decimal(
|
||||
response.last_price,
|
||||
path="$.payload.lastPrice",
|
||||
)
|
||||
bid_price = _quote_positive_decimal(
|
||||
response.bid_price,
|
||||
path="$.payload.bidPrice",
|
||||
)
|
||||
ask_price = _quote_positive_decimal(
|
||||
response.ask_price,
|
||||
path="$.payload.askPrice",
|
||||
)
|
||||
|
||||
if response.close_time <= 0:
|
||||
raise QuoteValueError(
|
||||
"$.payload.closeTime должно быть больше нуля."
|
||||
)
|
||||
|
||||
if bid_price > ask_price:
|
||||
raise QuoteValueError(
|
||||
"$.payload.bidPrice не должно превышать $.payload.askPrice."
|
||||
)
|
||||
|
||||
# Явное чтение сохраняет проверку обязательности lastPrice
|
||||
# как самостоятельного положительного рыночного значения.
|
||||
del last_price
|
||||
|
||||
|
||||
def _quote_positive_decimal(
|
||||
value: DzengiRawNumeric,
|
||||
*,
|
||||
path: str,
|
||||
) -> Decimal:
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise QuoteValueError(
|
||||
f"{path} должно быть корректным числом."
|
||||
) from exc
|
||||
|
||||
if not decimal_value.is_finite():
|
||||
raise QuoteValueError(
|
||||
f"{path} должно быть конечным числом."
|
||||
)
|
||||
|
||||
if decimal_value <= 0:
|
||||
raise QuoteValueError(
|
||||
f"{path} должно быть больше нуля."
|
||||
)
|
||||
|
||||
return decimal_value
|
||||
|
||||
|
||||
def validate_dzengi_websocket_quote_values(
|
||||
response: DzengiWebSocketQuoteResponse,
|
||||
) -> None:
|
||||
"""Проверить значения raw-модели котировки Dzengi WebSocket."""
|
||||
|
||||
if not response.symbol.strip():
|
||||
raise QuoteValueError(
|
||||
"$.payload.symbol не должен быть пустым."
|
||||
)
|
||||
|
||||
bid_price = _quote_positive_decimal(
|
||||
response.bid_price,
|
||||
path="$.payload.bidPrice",
|
||||
)
|
||||
ask_price = _quote_positive_decimal(
|
||||
response.ask_price,
|
||||
path="$.payload.askPrice",
|
||||
)
|
||||
|
||||
if bid_price > ask_price:
|
||||
raise QuoteValueError(
|
||||
"$.payload.bidPrice не должно превышать $.payload.askPrice."
|
||||
)
|
||||
|
||||
if response.timestamp is not None and response.timestamp <= 0:
|
||||
raise QuoteValueError(
|
||||
"$.payload.timestamp должно быть больше нуля."
|
||||
)
|
||||
18
app/src/storage/exceptions.py
Normal file
18
app/src/storage/exceptions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# app/src/storage/exceptions.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# Базовая ошибка storage-слоя.
|
||||
class StorageError(Exception):
|
||||
"""Base storage layer error."""
|
||||
|
||||
|
||||
# Ошибка хранилища справочника инструментов.
|
||||
class InstrumentStoreError(StorageError):
|
||||
"""Instrument store contract or operation error."""
|
||||
|
||||
|
||||
# Ошибка хранилища канонических котировок.
|
||||
class QuoteStoreError(StorageError):
|
||||
"""Quote store contract or operation error."""
|
||||
110
app/src/storage/instrument_store.py
Normal file
110
app/src/storage/instrument_store.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# app/src/storage/instrument_store.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
from src.storage.exceptions import InstrumentStoreError
|
||||
|
||||
|
||||
# Контракт runtime-хранилища канонического справочника инструментов.
|
||||
@runtime_checkable
|
||||
class InstrumentStoreProtocol(Protocol):
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> tuple[Instrument, ...] | None:
|
||||
"""
|
||||
Вернуть сохранённый набор инструментов для источника.
|
||||
|
||||
None означает cache miss: данные для источника ещё не сохранялись.
|
||||
Пустой tuple означает успешное сохранение пустого справочника.
|
||||
"""
|
||||
|
||||
def set(
|
||||
self,
|
||||
source_name: str,
|
||||
instruments: tuple[Instrument, ...],
|
||||
) -> None:
|
||||
"""Сохранить полный immutable-набор инструментов источника."""
|
||||
|
||||
def clear(
|
||||
self,
|
||||
source_name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Очистить данные одного источника или всё хранилище.
|
||||
|
||||
source_name=None очищает все сохранённые источники.
|
||||
"""
|
||||
|
||||
|
||||
# In-memory реализация runtime-хранилища справочника инструментов.
|
||||
class InMemoryInstrumentStore:
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[str, tuple[Instrument, ...]] = {}
|
||||
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> tuple[Instrument, ...] | None:
|
||||
normalized_source_name = self._normalize_source_name(
|
||||
source_name
|
||||
)
|
||||
|
||||
return self._items.get(normalized_source_name)
|
||||
|
||||
def set(
|
||||
self,
|
||||
source_name: str,
|
||||
instruments: tuple[Instrument, ...],
|
||||
) -> None:
|
||||
normalized_source_name = self._normalize_source_name(
|
||||
source_name
|
||||
)
|
||||
|
||||
if not isinstance(instruments, tuple):
|
||||
raise InstrumentStoreError(
|
||||
"Справочник инструментов должен быть передан как tuple."
|
||||
)
|
||||
|
||||
if not all(
|
||||
isinstance(instrument, Instrument)
|
||||
for instrument in instruments
|
||||
):
|
||||
raise InstrumentStoreError(
|
||||
"Справочник содержит объект, не являющийся Instrument."
|
||||
)
|
||||
|
||||
self._items[normalized_source_name] = instruments
|
||||
|
||||
def clear(
|
||||
self,
|
||||
source_name: str | None = None,
|
||||
) -> None:
|
||||
if source_name is None:
|
||||
self._items.clear()
|
||||
return
|
||||
|
||||
normalized_source_name = self._normalize_source_name(
|
||||
source_name
|
||||
)
|
||||
|
||||
self._items.pop(
|
||||
normalized_source_name,
|
||||
None,
|
||||
)
|
||||
|
||||
def _normalize_source_name(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> str:
|
||||
normalized_source_name = str(source_name or "").strip()
|
||||
|
||||
if not normalized_source_name:
|
||||
raise InstrumentStoreError(
|
||||
"Имя источника Instrument Store не должно быть пустым."
|
||||
)
|
||||
|
||||
return normalized_source_name
|
||||
215
app/src/storage/quote_store.py
Normal file
215
app/src/storage/quote_store.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# app/src/storage/quote_store.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.storage.exceptions import QuoteStoreError
|
||||
|
||||
|
||||
# Контракт runtime-хранилища канонических котировок.
|
||||
@runtime_checkable
|
||||
class QuoteStoreProtocol(Protocol):
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
symbol: str,
|
||||
*,
|
||||
runtime_key: str = "default",
|
||||
) -> Quote | None:
|
||||
"""Вернуть котировку или None, если запись отсутствует."""
|
||||
|
||||
def set(
|
||||
self,
|
||||
source_name: str,
|
||||
quote: Quote,
|
||||
*,
|
||||
runtime_key: str = "default",
|
||||
) -> None:
|
||||
"""Сохранить каноническую котировку без копирования модели."""
|
||||
|
||||
def clear(
|
||||
self,
|
||||
source_name: str | None = None,
|
||||
symbol: str | None = None,
|
||||
*,
|
||||
runtime_key: str | None = None,
|
||||
) -> None:
|
||||
"""Удалить записи, соответствующие переданным фильтрам."""
|
||||
|
||||
|
||||
# In-memory реализация runtime-хранилища канонических котировок.
|
||||
class InMemoryQuoteStore:
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[tuple[str, str, str], Quote] = {}
|
||||
|
||||
def get(
|
||||
self,
|
||||
source_name: str,
|
||||
symbol: str,
|
||||
*,
|
||||
runtime_key: str = "default",
|
||||
) -> Quote | None:
|
||||
return self._items.get(
|
||||
self._key(
|
||||
source_name=source_name,
|
||||
symbol=symbol,
|
||||
runtime_key=runtime_key,
|
||||
)
|
||||
)
|
||||
|
||||
def set(
|
||||
self,
|
||||
source_name: str,
|
||||
quote: Quote,
|
||||
*,
|
||||
runtime_key: str = "default",
|
||||
) -> None:
|
||||
normalized_source_name = self._normalize_source_name(
|
||||
source_name
|
||||
)
|
||||
normalized_runtime_key = self._normalize_runtime_key(
|
||||
runtime_key
|
||||
)
|
||||
|
||||
if not isinstance(quote, Quote):
|
||||
raise QuoteStoreError(
|
||||
"Quote Store принимает только объект Quote."
|
||||
)
|
||||
|
||||
normalized_symbol = self._normalize_symbol(
|
||||
quote.symbol
|
||||
)
|
||||
|
||||
self._items[
|
||||
(
|
||||
normalized_source_name,
|
||||
normalized_runtime_key,
|
||||
normalized_symbol,
|
||||
)
|
||||
] = quote
|
||||
|
||||
def clear(
|
||||
self,
|
||||
source_name: str | None = None,
|
||||
symbol: str | None = None,
|
||||
*,
|
||||
runtime_key: str | None = None,
|
||||
) -> None:
|
||||
if (
|
||||
source_name is None
|
||||
and symbol is None
|
||||
and runtime_key is None
|
||||
):
|
||||
self._items.clear()
|
||||
return
|
||||
|
||||
normalized_source_name = (
|
||||
self._normalize_source_name(source_name)
|
||||
if source_name is not None
|
||||
else None
|
||||
)
|
||||
normalized_symbol = (
|
||||
self._normalize_symbol(symbol)
|
||||
if symbol is not None
|
||||
else None
|
||||
)
|
||||
normalized_runtime_key = (
|
||||
self._normalize_runtime_key(runtime_key)
|
||||
if runtime_key is not None
|
||||
else None
|
||||
)
|
||||
|
||||
keys_to_delete = [
|
||||
key
|
||||
for key in self._items
|
||||
if self._matches_filters(
|
||||
key,
|
||||
source_name=normalized_source_name,
|
||||
symbol=normalized_symbol,
|
||||
runtime_key=normalized_runtime_key,
|
||||
)
|
||||
]
|
||||
|
||||
for key in keys_to_delete:
|
||||
self._items.pop(key, None)
|
||||
|
||||
def _key(
|
||||
self,
|
||||
*,
|
||||
source_name: str,
|
||||
symbol: str,
|
||||
runtime_key: str,
|
||||
) -> tuple[str, str, str]:
|
||||
return (
|
||||
self._normalize_source_name(source_name),
|
||||
self._normalize_runtime_key(runtime_key),
|
||||
self._normalize_symbol(symbol),
|
||||
)
|
||||
|
||||
def _matches_filters(
|
||||
self,
|
||||
key: tuple[str, str, str],
|
||||
*,
|
||||
source_name: str | None,
|
||||
symbol: str | None,
|
||||
runtime_key: str | None,
|
||||
) -> bool:
|
||||
key_source_name, key_runtime_key, key_symbol = key
|
||||
|
||||
if (
|
||||
source_name is not None
|
||||
and key_source_name != source_name
|
||||
):
|
||||
return False
|
||||
|
||||
if (
|
||||
runtime_key is not None
|
||||
and key_runtime_key != runtime_key
|
||||
):
|
||||
return False
|
||||
|
||||
if symbol is not None and key_symbol != symbol:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _normalize_source_name(
|
||||
self,
|
||||
source_name: str,
|
||||
) -> str:
|
||||
normalized_source_name = str(source_name or "").strip()
|
||||
|
||||
if not normalized_source_name:
|
||||
raise QuoteStoreError(
|
||||
"Имя источника Quote Store не должно быть пустым."
|
||||
)
|
||||
|
||||
return normalized_source_name
|
||||
|
||||
def _normalize_runtime_key(
|
||||
self,
|
||||
runtime_key: str,
|
||||
) -> str:
|
||||
normalized_runtime_key = str(runtime_key or "").strip().lower()
|
||||
|
||||
if not normalized_runtime_key:
|
||||
raise QuoteStoreError(
|
||||
"Runtime key Quote Store не должен быть пустым."
|
||||
)
|
||||
|
||||
return normalized_runtime_key
|
||||
|
||||
def _normalize_symbol(
|
||||
self,
|
||||
symbol: str,
|
||||
) -> str:
|
||||
normalized_symbol = str(symbol or "").strip().upper()
|
||||
|
||||
if not normalized_symbol:
|
||||
raise QuoteStoreError(
|
||||
"Символ Quote Store не должен быть пустым."
|
||||
)
|
||||
|
||||
return normalized_symbol
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/storage/repositories/balance_snapshots.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -57,4 +59,4 @@ class BalanceSnapshotRepository:
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
return items
|
||||
@@ -41,4 +41,4 @@ def check_database_health() -> tuple[bool, str]:
|
||||
except Exception as exc:
|
||||
return False, f"PostgreSQL error: {exc}"
|
||||
|
||||
return True, version
|
||||
return True, version
|
||||
@@ -1 +1,3 @@
|
||||
"""Package marker."""
|
||||
# app/src/telegram/handlers/__init__.py
|
||||
|
||||
"""Package marker."""
|
||||
@@ -10,6 +10,7 @@ from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
|
||||
from src.telegram.ui.common import mode_line
|
||||
from src.trading.auto.service import AutoTradeService
|
||||
@@ -40,10 +41,10 @@ def build_auto_notification_text() -> str:
|
||||
|
||||
|
||||
def _build_signal_notification_text(state, signal: str) -> str:
|
||||
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
||||
quote = _market_quote(getattr(state, "symbol", None))
|
||||
|
||||
bid_price = _price_from_snapshot(snapshot, "bid_price")
|
||||
ask_price = _price_from_snapshot(snapshot, "ask_price")
|
||||
bid_price = _price_from_quote(quote, "bid_price")
|
||||
ask_price = _price_from_quote(quote, "ask_price")
|
||||
|
||||
side = "Long" if signal == "BUY" else "Short"
|
||||
side_icon = _signal_icon(signal)
|
||||
@@ -85,28 +86,28 @@ def _build_signal_notification_text(state, signal: str) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _price_from_snapshot(
|
||||
snapshot: dict[str, object] | None,
|
||||
def _price_from_quote(
|
||||
quote: Quote | None,
|
||||
key: str,
|
||||
) -> float | None:
|
||||
if snapshot is None:
|
||||
if quote is None:
|
||||
return None
|
||||
|
||||
return safe_float(snapshot.get(key))
|
||||
return safe_float(getattr(quote, key, None))
|
||||
|
||||
|
||||
def _position_current_price(state) -> float | None:
|
||||
snapshot = _market_snapshot(getattr(state, "symbol", None))
|
||||
quote = _market_quote(getattr(state, "symbol", None))
|
||||
|
||||
if snapshot is not None:
|
||||
if quote is not None:
|
||||
side = str(getattr(state, "position_side", "") or "").upper()
|
||||
|
||||
if side == "LONG":
|
||||
price = snapshot.get("bid_price") or snapshot.get("last_price")
|
||||
price = quote.bid_price or quote.last_price
|
||||
elif side == "SHORT":
|
||||
price = snapshot.get("ask_price") or snapshot.get("last_price")
|
||||
price = quote.ask_price or quote.last_price
|
||||
else:
|
||||
price = snapshot.get("last_price")
|
||||
price = quote.last_price
|
||||
|
||||
parsed = safe_float(price)
|
||||
if parsed is not None:
|
||||
@@ -720,12 +721,15 @@ def _max_reserved_line(state, price: float | None = None) -> str:
|
||||
return f"Маржа · {_format_usd_compact(own_funds_usd)}"
|
||||
|
||||
|
||||
def _market_snapshot(symbol: str | None) -> dict[str, object] | None:
|
||||
def _market_quote(symbol: str | None) -> Quote | None:
|
||||
if not symbol:
|
||||
return None
|
||||
|
||||
try:
|
||||
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto")
|
||||
return ExchangeService().get_quote(
|
||||
symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -907,10 +911,10 @@ def _commission_lines_for_position(
|
||||
|
||||
|
||||
def _current_price(symbol: str | None) -> float | None:
|
||||
snapshot = _market_snapshot(symbol)
|
||||
quote = _market_quote(symbol)
|
||||
|
||||
if snapshot is not None:
|
||||
price = snapshot.get("last_price")
|
||||
if quote is not None:
|
||||
price = quote.last_price
|
||||
if price is not None:
|
||||
try:
|
||||
parsed = safe_float(price)
|
||||
@@ -922,25 +926,25 @@ def _current_price(symbol: str | None) -> float | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(ExchangeService().get_price(symbol).price)
|
||||
return float(ExchangeService().get_quote(symbol).last_price)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _signal_entry_price(state) -> float | None:
|
||||
snapshot = _market_snapshot(state.symbol)
|
||||
quote = _market_quote(state.symbol)
|
||||
|
||||
if snapshot is None:
|
||||
if quote is None:
|
||||
return _current_price(state.symbol)
|
||||
|
||||
signal = (state.last_signal or "HOLD").upper()
|
||||
|
||||
if signal == "BUY":
|
||||
price = snapshot.get("ask_price")
|
||||
price = quote.ask_price
|
||||
elif signal == "SELL":
|
||||
price = snapshot.get("bid_price")
|
||||
price = quote.bid_price
|
||||
else:
|
||||
price = snapshot.get("last_price")
|
||||
price = quote.last_price
|
||||
|
||||
if price is None:
|
||||
return None
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.debug.service import DebugTradeService
|
||||
|
||||
@@ -113,6 +118,23 @@ def _format_updated_at(value: object) -> str:
|
||||
if not value:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, datetime):
|
||||
current = value
|
||||
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=timezone.utc)
|
||||
|
||||
try:
|
||||
settings = load_settings()
|
||||
|
||||
current = current.astimezone(
|
||||
ZoneInfo(settings.tz),
|
||||
)
|
||||
except Exception:
|
||||
current = current.astimezone()
|
||||
|
||||
return current.strftime("%H:%M:%S")
|
||||
|
||||
text = str(value)
|
||||
|
||||
if " " in text:
|
||||
@@ -121,6 +143,23 @@ def _format_updated_at(value: object) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _quote_age_seconds(quote: object) -> float | None:
|
||||
received_at = getattr(quote, "received_at", None)
|
||||
if not isinstance(received_at, datetime):
|
||||
return None
|
||||
|
||||
if received_at.tzinfo is None:
|
||||
received_at = received_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
return max(
|
||||
0.0,
|
||||
(
|
||||
datetime.now(timezone.utc)
|
||||
- received_at.astimezone(timezone.utc)
|
||||
).total_seconds(),
|
||||
)
|
||||
|
||||
|
||||
def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
if not symbol:
|
||||
return [
|
||||
@@ -141,7 +180,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
error = None
|
||||
|
||||
try:
|
||||
market = ExchangeService().get_market_snapshot(
|
||||
market = ExchangeService().get_quote(
|
||||
symbol,
|
||||
runtime_key="debug_auto",
|
||||
)
|
||||
@@ -167,11 +206,11 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
f"Error · {error or 'unknown'}",
|
||||
]
|
||||
|
||||
last_price = market.get("last_price") if market else getattr(execution, "last_price", None)
|
||||
bid_price = market.get("bid_price") if market else getattr(execution, "bid_price", None)
|
||||
ask_price = market.get("ask_price") if market else getattr(execution, "ask_price", None)
|
||||
market_source = market.get("source") if market else "—"
|
||||
market_age = market.get("age_seconds") if market else None
|
||||
last_price = market.last_price if market else getattr(execution, "last_price", None)
|
||||
bid_price = market.bid_price if market else getattr(execution, "bid_price", None)
|
||||
ask_price = market.ask_price if market else getattr(execution, "ask_price", None)
|
||||
market_source = market.source if market else "—"
|
||||
market_age = _quote_age_seconds(market) if market else None
|
||||
|
||||
execution_source = getattr(execution, "source", "—") if execution else "—"
|
||||
execution_age = getattr(execution, "age_seconds", None) if execution else None
|
||||
@@ -184,7 +223,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
|
||||
f"Ask · {_format_usd_or_dash(ask_price)}",
|
||||
f"Source · {market_source or '—'}",
|
||||
f"Quote age · {_format_age(market_age)}",
|
||||
f"Exchange time · {_format_updated_at(market.get('updated_at') if market else None)}",
|
||||
f"Exchange time · {_format_updated_at(market.exchange_timestamp if market else None)}",
|
||||
"",
|
||||
"<b>Execution</b>",
|
||||
f"Source · {execution_source or '—'}",
|
||||
@@ -274,7 +313,9 @@ def _format_crypto_size(value: float | int | None) -> str:
|
||||
return f"{float(value):.5f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _format_money_compact(value: float | int | None) -> str:
|
||||
def _format_money_compact(
|
||||
value: float | int | Decimal | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -286,21 +327,25 @@ def _format_money_compact(value: float | int | None) -> str:
|
||||
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _format_usd_or_dash(value: float | int | None) -> str:
|
||||
def _format_usd_or_dash(
|
||||
value: float | int | Decimal | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
return f"$ {_format_money_compact(value)}"
|
||||
|
||||
|
||||
def _format_usd_or_off(value: float | int | None) -> str:
|
||||
def _format_usd_or_off(
|
||||
value: float | int | Decimal | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "off"
|
||||
return "Выкл."
|
||||
|
||||
return f"$ {_format_money_compact(value)}"
|
||||
|
||||
|
||||
def _format_signed_usd(value: float | int | None) -> str:
|
||||
def _format_signed_usd(value: float | int | Decimal | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -315,7 +360,7 @@ def _format_signed_usd(value: float | int | None) -> str:
|
||||
return "$ 0"
|
||||
|
||||
|
||||
def _format_age(value: object) -> str:
|
||||
def _format_age(value: NumericLike | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
# app/src/telegram/handlers/market.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InaccessibleMessage,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.exceptions import ExchangeError
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
ExchangeStatusCode,
|
||||
build_exchange_error_status,
|
||||
classify_exchange_error,
|
||||
)
|
||||
from src.telegram.live.active_screen import ActiveScreenManager
|
||||
from src.telegram.live.runner import LiveScreen, LiveScreenRunner, ScreenRegistry
|
||||
from src.telegram.ui.common import mode_line, now_line
|
||||
from src.telegram.ui.currency_ui import format_usd_amount
|
||||
from src.telegram.ui.exchange_error import (
|
||||
show_callback_exchange_error,
|
||||
show_message_exchange_error,
|
||||
)
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
router = Router(name="market")
|
||||
|
||||
_last_market_prices: dict[str, float] = {}
|
||||
_last_market_directions: dict[str, str] = {}
|
||||
|
||||
|
||||
def _require_message(callback: CallbackQuery) -> Message | None:
|
||||
message = callback.message
|
||||
|
||||
if message is None or isinstance(message, InaccessibleMessage):
|
||||
return None
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def _market_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="📊 К мониторингу", callback_data="monitoring:home")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
# собрать текст, когда рынок/биржа недоступны через unified status layer
|
||||
def _build_market_status_text(status: ExchangeRuntimeStatus) -> str:
|
||||
icon = "⏸️" if status.code == ExchangeStatusCode.BREAK else "⛔️"
|
||||
|
||||
return (
|
||||
"<b>📈 Рынок</b>\n"
|
||||
f"{mode_line()}"
|
||||
f"{icon} {status.title}\n\n"
|
||||
f"{status.message}\n\n"
|
||||
f"{now_line()}"
|
||||
)
|
||||
|
||||
|
||||
def _build_market_text(
|
||||
*,
|
||||
ticker_price: NumericLike,
|
||||
name: str,
|
||||
market_type: str,
|
||||
base_asset: str,
|
||||
quote_asset: str,
|
||||
) -> str:
|
||||
price = safe_float(ticker_price)
|
||||
|
||||
if price is None:
|
||||
price = 0.0
|
||||
|
||||
previous_price = _last_market_prices.get(name)
|
||||
price_direction = _last_market_directions.get(name, "▲")
|
||||
|
||||
if previous_price is not None:
|
||||
if price > previous_price:
|
||||
price_direction = "🔺"
|
||||
elif price < previous_price:
|
||||
price_direction = "🔻"
|
||||
|
||||
_last_market_prices[name] = price
|
||||
_last_market_directions[name] = price_direction
|
||||
|
||||
type_map = {
|
||||
"LEVERAGE": "leverage",
|
||||
"SPOT": "spot",
|
||||
}
|
||||
market_type_ru = type_map.get(market_type.upper(), market_type.lower())
|
||||
|
||||
return (
|
||||
"<b>📈 Рынок</b>\n"
|
||||
f"{mode_line()}"
|
||||
"\n"
|
||||
f"<b>{base_asset} / {quote_asset}</b> ({market_type_ru})\n\n"
|
||||
f"<b>$ {format_usd_amount(price)}</b> {price_direction}\n\n"
|
||||
f"{now_line()}"
|
||||
)
|
||||
|
||||
|
||||
# live-render должен сам уметь показать ошибку, иначе runner просто потеряет экран
|
||||
def _build_market_live_text() -> str:
|
||||
service = ExchangeService()
|
||||
requested_symbol = service.settings.default_symbol
|
||||
|
||||
try:
|
||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
except Exception as exc:
|
||||
return _build_market_status_text(build_exchange_error_status(exc))
|
||||
|
||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
||||
return _build_market_status_text(runtime_status)
|
||||
|
||||
symbol = runtime_status.symbol or requested_symbol
|
||||
|
||||
validation = service.validate_symbol(symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
return _build_market_status_text(
|
||||
service.get_symbol_runtime_status(requested_symbol)
|
||||
)
|
||||
|
||||
ticker = service.get_price(validation.normalized_symbol)
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
||||
base_asset = (
|
||||
symbol_info.base_asset
|
||||
if symbol_info and symbol_info.base_asset
|
||||
else "n/a"
|
||||
)
|
||||
quote_asset = (
|
||||
symbol_info.quote_asset
|
||||
if symbol_info and symbol_info.quote_asset
|
||||
else "n/a"
|
||||
)
|
||||
name = (
|
||||
symbol_info.name
|
||||
if symbol_info and symbol_info.name
|
||||
else ticker.symbol
|
||||
)
|
||||
|
||||
return _build_market_text(
|
||||
ticker_price=ticker.price,
|
||||
name=name,
|
||||
market_type=market_type,
|
||||
base_asset=base_asset,
|
||||
quote_asset=quote_asset,
|
||||
)
|
||||
|
||||
|
||||
def _register_market_live_screen(message: Message) -> None:
|
||||
bot = message.bot
|
||||
|
||||
if bot is None:
|
||||
return
|
||||
|
||||
LiveScreenRunner.unregister_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
ScreenRegistry.unregister_message(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
)
|
||||
|
||||
LiveScreenRunner.register_screen(
|
||||
LiveScreen(
|
||||
screen="market",
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
render_text=_build_market_live_text,
|
||||
render_markup=_market_keyboard,
|
||||
interval_seconds=5,
|
||||
)
|
||||
)
|
||||
|
||||
LiveScreenRunner.start("market")
|
||||
|
||||
|
||||
async def _prepare_market_from_message(message: Message) -> bool:
|
||||
bot = message.bot
|
||||
|
||||
if bot is None:
|
||||
return False
|
||||
|
||||
await ActiveScreenManager.prepare_new_screen(
|
||||
screen="market",
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _prepare_market_from_callback(callback: CallbackQuery) -> bool:
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||
return False
|
||||
|
||||
bot = message.bot
|
||||
|
||||
if bot is None:
|
||||
await callback.answer("Bot недоступен", show_alert=True)
|
||||
return False
|
||||
|
||||
await ActiveScreenManager.prepare_new_screen(
|
||||
screen="market",
|
||||
bot=bot,
|
||||
chat_id=message.chat.id,
|
||||
keep_message_id=message.message_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _send_or_edit_market_screen(
|
||||
target_message: Message,
|
||||
*,
|
||||
text: str,
|
||||
edit_mode: bool,
|
||||
) -> None:
|
||||
if edit_mode:
|
||||
await target_message.edit_text(text, reply_markup=_market_keyboard())
|
||||
_register_market_live_screen(target_message)
|
||||
ActiveScreenManager.register(screen="market", message=target_message)
|
||||
return
|
||||
|
||||
sent_message = await target_message.answer(
|
||||
text,
|
||||
reply_markup=_market_keyboard(),
|
||||
)
|
||||
_register_market_live_screen(sent_message)
|
||||
ActiveScreenManager.register(screen="market", message=sent_message)
|
||||
|
||||
|
||||
async def _render_market_screen(
|
||||
target_message: Message,
|
||||
*,
|
||||
user_id: int | None,
|
||||
chat_id: int | None,
|
||||
edit_mode: bool,
|
||||
action: str,
|
||||
) -> None:
|
||||
service = ExchangeService()
|
||||
journal = JournalService()
|
||||
requested_symbol = service.settings.default_symbol
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="market_open_requested",
|
||||
message="Запрошено открытие экрана рынка.",
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={"symbol": requested_symbol},
|
||||
)
|
||||
|
||||
runtime_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
|
||||
if runtime_status.code != ExchangeStatusCode.OPEN:
|
||||
journal.log_ui_warning(
|
||||
event_type="market_status_blocked",
|
||||
message=runtime_status.message,
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload=runtime_status.as_dict(),
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=_build_market_status_text(runtime_status),
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
return
|
||||
|
||||
symbol = runtime_status.symbol or requested_symbol
|
||||
validation = service.validate_symbol(symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
invalid_status = service.get_symbol_runtime_status(requested_symbol)
|
||||
|
||||
journal.log_ui_warning(
|
||||
event_type="market_symbol_invalid",
|
||||
message=invalid_status.message,
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload=invalid_status.as_dict(),
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=_build_market_status_text(invalid_status),
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
return
|
||||
|
||||
ticker = service.get_price(validation.normalized_symbol)
|
||||
|
||||
symbol_info = validation.symbol_info
|
||||
market_type = symbol_info.market_type if symbol_info else "n/a"
|
||||
base_asset = (
|
||||
symbol_info.base_asset
|
||||
if symbol_info and symbol_info.base_asset
|
||||
else "n/a"
|
||||
)
|
||||
quote_asset = (
|
||||
symbol_info.quote_asset
|
||||
if symbol_info and symbol_info.quote_asset
|
||||
else "n/a"
|
||||
)
|
||||
name = (
|
||||
symbol_info.name
|
||||
if symbol_info and symbol_info.name
|
||||
else ticker.symbol
|
||||
)
|
||||
|
||||
text = _build_market_text(
|
||||
ticker_price=ticker.price,
|
||||
name=name,
|
||||
market_type=market_type,
|
||||
base_asset=base_asset,
|
||||
quote_asset=quote_asset,
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="market_open_success",
|
||||
message="Экран рынка загружен.",
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"symbol": ticker.symbol,
|
||||
"price": safe_float(ticker.price),
|
||||
"runtime_status": runtime_status.as_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
await _send_or_edit_market_screen(
|
||||
target_message,
|
||||
text=text,
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text == "📈 Рынок")
|
||||
async def open_market(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
|
||||
if not await _prepare_market_from_message(message):
|
||||
return
|
||||
|
||||
user_id = message.from_user.id if message.from_user else None
|
||||
chat_id = message.chat.id if message.chat else None
|
||||
|
||||
try:
|
||||
await _render_market_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=False,
|
||||
action="open",
|
||||
)
|
||||
except ExchangeError as exc:
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_open_error",
|
||||
message="Не удалось загрузить экран рынка.",
|
||||
screen="market",
|
||||
action="open",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_message_exchange_error(
|
||||
message,
|
||||
title="<b>📈 Рынок</b>",
|
||||
exc=exc,
|
||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
||||
retry_callback_data="market:retry",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "monitoring:market")
|
||||
async def open_market_from_monitoring(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
if not await _prepare_market_from_callback(callback):
|
||||
return
|
||||
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id if callback.from_user else None
|
||||
chat_id = message.chat.id
|
||||
|
||||
try:
|
||||
await _render_market_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=True,
|
||||
action="open_from_monitoring",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except ExchangeError as exc:
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_open_error",
|
||||
message="Не удалось загрузить экран рынка из мониторинга.",
|
||||
screen="market",
|
||||
action="open_from_monitoring",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>📈 Рынок</b>",
|
||||
exc=exc,
|
||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
||||
retry_callback_data="market:retry",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "market:retry")
|
||||
async def retry_market(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
if not await _prepare_market_from_callback(callback):
|
||||
return
|
||||
|
||||
message = _require_message(callback)
|
||||
|
||||
if message is None:
|
||||
await callback.answer("Сообщение недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id if callback.from_user else None
|
||||
chat_id = message.chat.id
|
||||
|
||||
try:
|
||||
await _render_market_screen(
|
||||
message,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=True,
|
||||
action="retry",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except ExchangeError as exc:
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_retry_error",
|
||||
message="Не удалось обновить экран рынка.",
|
||||
screen="market",
|
||||
action="retry",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>📈 Рынок</b>",
|
||||
exc=exc,
|
||||
network_details="Рыночные данные недоступны.\nОбнови экран.",
|
||||
auth_details="Не удалось получить рыночные данные.\nПроверь API ключи.",
|
||||
retry_callback_data="market:retry",
|
||||
)
|
||||
@@ -3,8 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.integrations.exchange.exceptions import ExchangeError
|
||||
from src.integrations.exchange.models import BalanceSummary, ExchangeSymbol
|
||||
from src.integrations.exchange.models import BalanceSummary
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.market_data.acquisition.models.instrument import Instrument
|
||||
|
||||
|
||||
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"}
|
||||
@@ -31,7 +32,10 @@ def is_fiat_currency(currency: str) -> bool:
|
||||
|
||||
|
||||
def get_currency_icon(currency: str) -> str:
|
||||
return CURRENCY_ICONS.get(currency.upper(), currency.upper())
|
||||
return CURRENCY_ICONS.get(
|
||||
currency.upper(),
|
||||
currency.upper(),
|
||||
)
|
||||
|
||||
|
||||
def get_currency_label(currency: str) -> str:
|
||||
@@ -45,6 +49,7 @@ def render_currency_title(currency: str) -> str:
|
||||
def format_amount(currency: str, value: float) -> str:
|
||||
if is_fiat_currency(currency):
|
||||
return f"{value:,.2f}".replace(",", " ")
|
||||
|
||||
return f"{value:,.8f}".replace(",", " ")
|
||||
|
||||
|
||||
@@ -52,7 +57,9 @@ def format_usd_amount(value: float) -> str:
|
||||
return f"{value:,.2f}".replace(",", " ")
|
||||
|
||||
|
||||
def format_usd_price(value: float | int | str | None) -> str:
|
||||
def format_usd_price(
|
||||
value: float | int | str | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -62,7 +69,9 @@ def format_usd_price(value: float | int | str | None) -> str:
|
||||
return "—"
|
||||
|
||||
|
||||
def format_usd_pnl(value: float | int | str | None) -> str:
|
||||
def format_usd_pnl(
|
||||
value: float | int | str | None,
|
||||
) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
@@ -87,7 +96,10 @@ def render_currency_line(
|
||||
show_code: bool = True,
|
||||
) -> str:
|
||||
icon = get_currency_icon(currency)
|
||||
amount = format_amount(currency, value)
|
||||
amount = format_amount(
|
||||
currency,
|
||||
value,
|
||||
)
|
||||
|
||||
if show_code:
|
||||
return f"{icon} {currency.upper()} · {amount}"
|
||||
@@ -100,61 +112,79 @@ def balance_total(item: BalanceSummary) -> float:
|
||||
|
||||
|
||||
def is_zero_balance(item: BalanceSummary) -> bool:
|
||||
return abs(item.available) < 1e-12 and abs(item.locked) < 1e-12
|
||||
return (
|
||||
abs(item.available) < 1e-12
|
||||
and abs(item.locked) < 1e-12
|
||||
)
|
||||
|
||||
|
||||
def _quote_priority(quote_asset: str) -> int:
|
||||
value = (quote_asset or "").upper()
|
||||
|
||||
if value == "USD":
|
||||
return 3
|
||||
|
||||
if value == "USDT":
|
||||
return 2
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _status_priority(status: str) -> int:
|
||||
value = (status or "").upper()
|
||||
|
||||
if value == "TRADING":
|
||||
return 2
|
||||
|
||||
if value in {"HALT", "BREAK"}:
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _market_type_priority(market_type: str) -> int:
|
||||
value = (market_type or "").upper()
|
||||
|
||||
if value == "SPOT":
|
||||
return 3
|
||||
|
||||
if value == "LEVERAGE":
|
||||
return 2
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _symbol_priority(symbol_info: ExchangeSymbol) -> tuple[int, int, int, str]:
|
||||
def _instrument_priority(
|
||||
instrument: Instrument,
|
||||
) -> tuple[int, int, int, str]:
|
||||
return (
|
||||
_quote_priority(symbol_info.quote_asset),
|
||||
_status_priority(symbol_info.status),
|
||||
_market_type_priority(symbol_info.market_type),
|
||||
symbol_info.symbol.upper(),
|
||||
_quote_priority(instrument.quote_asset),
|
||||
_status_priority(instrument.status),
|
||||
_market_type_priority(instrument.market_type),
|
||||
instrument.symbol.upper(),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_asset_quote_symbol(
|
||||
def _resolve_asset_quote_instrument(
|
||||
exchange_service: ExchangeService,
|
||||
asset: str,
|
||||
) -> ExchangeSymbol | None:
|
||||
) -> Instrument | None:
|
||||
asset_upper = asset.upper()
|
||||
|
||||
try:
|
||||
symbols = exchange_service.get_exchange_symbols()
|
||||
instruments = exchange_service.get_instruments()
|
||||
except ExchangeError:
|
||||
return None
|
||||
|
||||
candidates: list[ExchangeSymbol] = []
|
||||
candidates: list[Instrument] = []
|
||||
|
||||
for symbol_info in symbols:
|
||||
base_asset = (symbol_info.base_asset or "").upper()
|
||||
quote_asset = (symbol_info.quote_asset or "").upper()
|
||||
for instrument in instruments:
|
||||
base_asset = (
|
||||
instrument.base_asset or ""
|
||||
).upper()
|
||||
quote_asset = (
|
||||
instrument.quote_asset or ""
|
||||
).upper()
|
||||
|
||||
if base_asset != asset_upper:
|
||||
continue
|
||||
@@ -162,12 +192,16 @@ def _resolve_asset_quote_symbol(
|
||||
if quote_asset not in {"USD", "USDT"}:
|
||||
continue
|
||||
|
||||
candidates.append(symbol_info)
|
||||
candidates.append(instrument)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=_symbol_priority, reverse=True)
|
||||
candidates.sort(
|
||||
key=_instrument_priority,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return candidates[0]
|
||||
|
||||
|
||||
@@ -184,18 +218,26 @@ def get_asset_usd_rate(
|
||||
if asset in price_cache:
|
||||
return price_cache[asset]
|
||||
|
||||
symbol_info = _resolve_asset_quote_symbol(exchange_service, asset)
|
||||
if symbol_info is None:
|
||||
instrument = _resolve_asset_quote_instrument(
|
||||
exchange_service,
|
||||
asset,
|
||||
)
|
||||
|
||||
if instrument is None:
|
||||
price_cache[asset] = None
|
||||
return None
|
||||
|
||||
try:
|
||||
ticker = exchange_service.get_price(symbol_info.symbol)
|
||||
rate = float(ticker.price)
|
||||
quote = exchange_service.get_quote(
|
||||
instrument.symbol
|
||||
)
|
||||
rate = float(quote.last_price)
|
||||
|
||||
# Пока считаем USDT ~= USD
|
||||
# Пока считаем USDT ~= USD.
|
||||
price_cache[asset] = rate
|
||||
|
||||
return rate
|
||||
|
||||
except ExchangeError:
|
||||
price_cache[asset] = None
|
||||
return None
|
||||
@@ -207,10 +249,16 @@ def estimate_balance_usd(
|
||||
price_cache: dict[str, float | None],
|
||||
) -> float | None:
|
||||
total = balance_total(item)
|
||||
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
rate = get_asset_usd_rate(exchange_service, item.currency, price_cache)
|
||||
rate = get_asset_usd_rate(
|
||||
exchange_service,
|
||||
item.currency,
|
||||
price_cache,
|
||||
)
|
||||
|
||||
if rate is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.models import ExecutionPriceSnapshot
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.integrations.exchange.status import (
|
||||
ExchangeRuntimeStatus,
|
||||
@@ -253,34 +254,20 @@ class AutoExecutionQualityMixin:
|
||||
return
|
||||
|
||||
try:
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
snapshot = ExchangeService().get_execution_snapshot(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
|
||||
if (
|
||||
age_seconds is not None
|
||||
and age_seconds > self._warning_snapshot_age_seconds
|
||||
):
|
||||
try:
|
||||
snapshot = ExchangeService().refresh_market_snapshot_cache(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as exc:
|
||||
fallback_price = None
|
||||
|
||||
try:
|
||||
fallback_price = safe_float(
|
||||
ExchangeService().get_price(
|
||||
ExchangeService().get_quote(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
).price
|
||||
).last_price
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -319,12 +306,12 @@ class AutoExecutionQualityMixin:
|
||||
)
|
||||
return
|
||||
|
||||
bid_price = safe_float(snapshot.get("bid_price"))
|
||||
ask_price = safe_float(snapshot.get("ask_price"))
|
||||
last_price = safe_float(snapshot.get("last_price"))
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
is_fresh = bool(snapshot.get("is_fresh", False))
|
||||
source = str(snapshot.get("source") or "")
|
||||
bid_price = safe_float(snapshot.bid_price)
|
||||
ask_price = safe_float(snapshot.ask_price)
|
||||
last_price = safe_float(snapshot.last_price)
|
||||
age_seconds = safe_float(snapshot.age_seconds)
|
||||
is_fresh = snapshot.is_fresh
|
||||
source = snapshot.source
|
||||
|
||||
self._sync_execution_pricing_state(
|
||||
state,
|
||||
@@ -432,15 +419,15 @@ class AutoExecutionQualityMixin:
|
||||
def _sync_execution_pricing_state(
|
||||
self,
|
||||
state: AutoTradeState,
|
||||
snapshot: dict[str, object],
|
||||
snapshot: ExecutionPriceSnapshot,
|
||||
) -> None:
|
||||
age_seconds = safe_float(snapshot.get("age_seconds"))
|
||||
age_seconds = safe_float(snapshot.age_seconds)
|
||||
|
||||
state.execution_price_source = str(snapshot.get("source") or "")
|
||||
state.execution_price_source = snapshot.source
|
||||
state.execution_price_age_seconds = age_seconds
|
||||
state.execution_bid_price = safe_float(snapshot.get("bid_price"))
|
||||
state.execution_ask_price = safe_float(snapshot.get("ask_price"))
|
||||
state.execution_last_price = safe_float(snapshot.get("last_price"))
|
||||
state.execution_bid_price = safe_float(snapshot.bid_price)
|
||||
state.execution_ask_price = safe_float(snapshot.ask_price)
|
||||
state.execution_last_price = safe_float(snapshot.last_price)
|
||||
|
||||
if age_seconds is None:
|
||||
state.execution_price_freshness = "UNKNOWN"
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.core.event_bus import EventBus
|
||||
from src.core.numbers import safe_float
|
||||
from src.core.types import JsonDict, NumericLike
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.market_data.acquisition.models.quote import Quote
|
||||
from src.trading.auto.state import AutoTradeState
|
||||
from src.trading.auto.state_reset import (
|
||||
reset_after_market_runtime_expired,
|
||||
@@ -716,7 +717,7 @@ class AutoSignalRuntimeMixin:
|
||||
self,
|
||||
*,
|
||||
state: AutoTradeState,
|
||||
snapshot: JsonDict,
|
||||
quote: Quote | None,
|
||||
signal: str,
|
||||
signal_intent: str,
|
||||
confidence: float,
|
||||
@@ -787,9 +788,9 @@ class AutoSignalRuntimeMixin:
|
||||
"snapshot_age_seconds": state.snapshot_age_seconds,
|
||||
|
||||
# ---------- Live Snapshot ----------
|
||||
"bid_price": snapshot.get("bid_price"),
|
||||
"ask_price": snapshot.get("ask_price"),
|
||||
"last_price": snapshot.get("last_price"),
|
||||
"bid_price": safe_float(quote.bid_price) if quote is not None else None,
|
||||
"ask_price": safe_float(quote.ask_price) if quote is not None else None,
|
||||
"last_price": safe_float(quote.last_price) if quote is not None else None,
|
||||
|
||||
# ---------- Market Score ----------
|
||||
"market_score": state.market_score,
|
||||
@@ -875,12 +876,12 @@ class AutoSignalRuntimeMixin:
|
||||
return
|
||||
|
||||
try:
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
quote = ExchangeService().get_quote(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
except Exception:
|
||||
snapshot = {}
|
||||
quote = None
|
||||
|
||||
try:
|
||||
JournalService().log_ui_info(
|
||||
@@ -892,7 +893,7 @@ class AutoSignalRuntimeMixin:
|
||||
action="signal_ready",
|
||||
payload=self._build_ready_signal_payload(
|
||||
state=state,
|
||||
snapshot=snapshot,
|
||||
quote=quote,
|
||||
signal=normalized_signal,
|
||||
signal_intent=signal_intent,
|
||||
confidence=confidence,
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.types import NumericLike
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.trading.debug.state import DebugPositionState, DebugTradeState
|
||||
from src.trading.execution.models import ExecutionDecision
|
||||
@@ -389,49 +390,88 @@ class DebugExecutionEngine:
|
||||
return self._market_last_price(state.symbol)
|
||||
|
||||
def _entry_price_for_side(self, symbol: str, side: str) -> float:
|
||||
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
|
||||
snapshot = ExchangeService().get_execution_snapshot(
|
||||
symbol,
|
||||
runtime_key="debug_auto",
|
||||
)
|
||||
|
||||
if side == "LONG":
|
||||
return self._snapshot_price(snapshot, "ask_price", "last_price")
|
||||
return self._execution_price(
|
||||
snapshot.ask_price,
|
||||
snapshot.last_price,
|
||||
price_name="ask_price",
|
||||
)
|
||||
|
||||
if side == "SHORT":
|
||||
return self._snapshot_price(snapshot, "bid_price", "last_price")
|
||||
return self._execution_price(
|
||||
snapshot.bid_price,
|
||||
snapshot.last_price,
|
||||
price_name="bid_price",
|
||||
)
|
||||
|
||||
return self._snapshot_price(snapshot, "last_price")
|
||||
return self._execution_price(
|
||||
snapshot.last_price,
|
||||
price_name="last_price",
|
||||
)
|
||||
|
||||
def _exit_price_for_side(self, symbol: str, side: str) -> float:
|
||||
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
|
||||
snapshot = ExchangeService().get_execution_snapshot(
|
||||
symbol,
|
||||
runtime_key="debug_auto",
|
||||
)
|
||||
|
||||
if side == "LONG":
|
||||
return self._snapshot_price(snapshot, "bid_price", "last_price")
|
||||
return self._execution_price(
|
||||
snapshot.bid_price,
|
||||
snapshot.last_price,
|
||||
price_name="bid_price",
|
||||
)
|
||||
|
||||
if side == "SHORT":
|
||||
return self._snapshot_price(snapshot, "ask_price", "last_price")
|
||||
return self._execution_price(
|
||||
snapshot.ask_price,
|
||||
snapshot.last_price,
|
||||
price_name="ask_price",
|
||||
)
|
||||
|
||||
return self._snapshot_price(snapshot, "last_price")
|
||||
return self._execution_price(
|
||||
snapshot.last_price,
|
||||
price_name="last_price",
|
||||
)
|
||||
|
||||
def _market_last_price(self, symbol: str) -> float:
|
||||
snapshot = ExchangeService().get_fresh_market_snapshot(symbol)
|
||||
return self._snapshot_price(snapshot, "last_price")
|
||||
snapshot = ExchangeService().get_execution_snapshot(
|
||||
symbol,
|
||||
runtime_key="debug_auto",
|
||||
)
|
||||
return self._execution_price(
|
||||
snapshot.last_price,
|
||||
price_name="last_price",
|
||||
)
|
||||
|
||||
def _snapshot_price(
|
||||
def _execution_price(
|
||||
self,
|
||||
snapshot: dict[str, object],
|
||||
primary_key: str,
|
||||
fallback_key: str | None = None,
|
||||
raw_price: NumericLike | None,
|
||||
fallback_price: NumericLike | None = None,
|
||||
*,
|
||||
price_name: str,
|
||||
) -> float:
|
||||
raw_price = snapshot.get(primary_key)
|
||||
value = raw_price
|
||||
|
||||
if raw_price is None and fallback_key is not None:
|
||||
raw_price = snapshot.get(fallback_key)
|
||||
if value is None:
|
||||
value = fallback_price
|
||||
|
||||
if raw_price is None:
|
||||
raise ValueError(f"Market snapshot price '{primary_key}' is missing.")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
f"Execution price '{price_name}' is missing."
|
||||
)
|
||||
|
||||
price = float(raw_price)
|
||||
price = float(value)
|
||||
|
||||
if price <= 0:
|
||||
raise ValueError(f"Market snapshot price '{primary_key}' is invalid: {price}")
|
||||
raise ValueError(
|
||||
f"Execution price '{price_name}' is invalid: {price}"
|
||||
)
|
||||
|
||||
return price
|
||||
|
||||
|
||||
1
app/src/trading/decision/__init__.py
Normal file
1
app/src/trading/decision/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/src/trading/decision/__init__.py
|
||||
19
app/src/trading/decision/exceptions.py
Normal file
19
app/src/trading/decision/exceptions.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# app/src/trading/decision/exceptions.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TradingError(Exception):
|
||||
"""Базовая ошибка Trading Layer."""
|
||||
|
||||
|
||||
class InvalidTradingDecisionError(TradingError):
|
||||
"""Trading Layer сформировал некорректное торговое решение."""
|
||||
|
||||
|
||||
class TradingValidationError(TradingError):
|
||||
"""Ошибка проверки входных данных Trading Layer."""
|
||||
|
||||
|
||||
class TradingExecutionError(TradingError):
|
||||
"""Ошибка выполнения Trading Layer."""
|
||||
67
app/src/trading/decision/models.py
Normal file
67
app/src/trading/decision/models.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# app/src/trading/decision/models.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.trading.market_intelligence.common.enums import EngineStatus
|
||||
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||
from src.trading.market_intelligence.common.scores import (
|
||||
EngineConfidence,
|
||||
EngineScore,
|
||||
)
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
ContextDict,
|
||||
DiagnosticMessages,
|
||||
DurationMs,
|
||||
PayloadDict,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradingDiagnostics:
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
details: ContextDict = field(default_factory=dict)
|
||||
warnings: DiagnosticMessages = field(default_factory=list)
|
||||
errors: DiagnosticMessages = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_warnings(self) -> bool:
|
||||
return bool(self.warnings)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return bool(self.errors)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradingEvaluationMeta:
|
||||
trading_version: str
|
||||
calculated_at: float | None = None
|
||||
duration_ms: DurationMs | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TradingDecision:
|
||||
coordinator_result: CoordinatorResult
|
||||
diagnostics: TradingDiagnostics = field(default_factory=TradingDiagnostics)
|
||||
meta: TradingEvaluationMeta | None = None
|
||||
payload: PayloadDict = field(default_factory=dict)
|
||||
|
||||
status: EngineStatus = EngineStatus.UNKNOWN
|
||||
score: EngineScore = field(default_factory=EngineScore)
|
||||
confidence: EngineConfidence = field(default_factory=EngineConfidence)
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
|
||||
@property
|
||||
def is_usable(self) -> bool:
|
||||
return self.status in {
|
||||
EngineStatus.OK,
|
||||
EngineStatus.PARTIAL,
|
||||
EngineStatus.STALE,
|
||||
}
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return self.diagnostics.has_errors
|
||||
19
app/src/trading/decision/protocol.py
Normal file
19
app/src/trading/decision/protocol.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# app/src/trading/decision/protocol.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||
from src.trading.decision.models import TradingDecision
|
||||
|
||||
|
||||
class TradingProtocol(Protocol):
|
||||
"""Контракт Trading Layer."""
|
||||
|
||||
async def decide(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> TradingDecision:
|
||||
"""Принять торговое решение на основе CoordinatorResult."""
|
||||
...
|
||||
65
app/src/trading/decision/rules.py
Normal file
65
app/src/trading/decision/rules.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# app/src/trading/decision/rules.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_intelligence.common.enums import EngineStatus
|
||||
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||
from src.trading.decision.models import (
|
||||
TradingDecision,
|
||||
TradingDiagnostics,
|
||||
TradingEvaluationMeta,
|
||||
)
|
||||
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||
from src.trading.market_intelligence.common.scores import (
|
||||
EngineConfidence,
|
||||
EngineScore,
|
||||
)
|
||||
|
||||
|
||||
class TradingRules:
|
||||
"""Правила формирования торгового решения."""
|
||||
|
||||
def decide(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> TradingDecision:
|
||||
"""Сформировать TradingDecision."""
|
||||
return TradingDecision(
|
||||
coordinator_result=coordinator_result,
|
||||
diagnostics=TradingDiagnostics(),
|
||||
meta=TradingEvaluationMeta(
|
||||
trading_version="1.0",
|
||||
),
|
||||
status=self._resolve_status(coordinator_result),
|
||||
score=self._resolve_score(coordinator_result),
|
||||
confidence=self._resolve_confidence(coordinator_result),
|
||||
reason=self._resolve_reason(coordinator_result),
|
||||
)
|
||||
|
||||
def _resolve_status(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> EngineStatus:
|
||||
"""Определить итоговый статус Trading."""
|
||||
return coordinator_result.status
|
||||
|
||||
def _resolve_score(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> EngineScore:
|
||||
"""Вычислить итоговый Score."""
|
||||
return EngineScore()
|
||||
|
||||
def _resolve_confidence(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> EngineConfidence:
|
||||
"""Вычислить итоговый Confidence."""
|
||||
return EngineConfidence()
|
||||
|
||||
def _resolve_reason(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> ReasonCode:
|
||||
"""Определить причину принятого решения."""
|
||||
return ReasonCode.UNKNOWN
|
||||
29
app/src/trading/decision/service.py
Normal file
29
app/src/trading/decision/service.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# app/src/trading/decision/service.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||
from src.trading.decision.models import TradingDecision
|
||||
from src.trading.decision.protocol import TradingProtocol
|
||||
from src.trading.decision.rules import TradingRules
|
||||
from src.trading.decision.validation import (
|
||||
TradingValidation,
|
||||
)
|
||||
|
||||
|
||||
class TradingService(TradingProtocol):
|
||||
"""Единая публичная точка входа Trading Layer."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Создать Trading Service."""
|
||||
self._validation = TradingValidation()
|
||||
self._rules = TradingRules()
|
||||
|
||||
async def decide(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> TradingDecision:
|
||||
"""Сформировать торговое решение."""
|
||||
self._validation.validate(coordinator_result)
|
||||
|
||||
return self._rules.decide(coordinator_result)
|
||||
51
app/src/trading/decision/validation.py
Normal file
51
app/src/trading/decision/validation.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# app/src/trading/decision/validation.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.trading.market_intelligence.common.models import CoordinatorResult
|
||||
from src.trading.decision.exceptions import (
|
||||
TradingValidationError,
|
||||
)
|
||||
|
||||
|
||||
class TradingValidation:
|
||||
"""Проверка входного CoordinatorResult для Trading Layer."""
|
||||
|
||||
def validate(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> None:
|
||||
"""Проверить CoordinatorResult перед принятием торгового решения."""
|
||||
self._validate_result_exists(coordinator_result)
|
||||
self._validate_result_usable(coordinator_result)
|
||||
self._validate_result_has_no_errors(coordinator_result)
|
||||
|
||||
def _validate_result_exists(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> None:
|
||||
"""Проверить, что CoordinatorResult передан."""
|
||||
if coordinator_result is None:
|
||||
raise TradingValidationError(
|
||||
"CoordinatorResult is required for Trading."
|
||||
)
|
||||
|
||||
def _validate_result_usable(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> None:
|
||||
"""Проверить пригодность CoordinatorResult для принятия решения."""
|
||||
if not coordinator_result.is_usable:
|
||||
raise TradingValidationError(
|
||||
"CoordinatorResult is not usable."
|
||||
)
|
||||
|
||||
def _validate_result_has_no_errors(
|
||||
self,
|
||||
coordinator_result: CoordinatorResult,
|
||||
) -> None:
|
||||
"""Проверить отсутствие критических ошибок Coordinator."""
|
||||
if coordinator_result.has_errors:
|
||||
raise TradingValidationError(
|
||||
"CoordinatorResult contains errors."
|
||||
)
|
||||
@@ -259,20 +259,20 @@ class SemanticDiagnosticSnapshotBuilder:
|
||||
try:
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
|
||||
snapshot = ExchangeService().get_market_snapshot(
|
||||
quote = ExchangeService().get_quote(
|
||||
state.symbol,
|
||||
runtime_key="auto",
|
||||
)
|
||||
|
||||
side = str(state.position_side or "").upper()
|
||||
|
||||
price = snapshot.get("last_price")
|
||||
price = quote.last_price
|
||||
|
||||
if side == "LONG":
|
||||
price = snapshot.get("bid_price") or price
|
||||
price = quote.bid_price or price
|
||||
|
||||
elif side == "SHORT":
|
||||
price = snapshot.get("ask_price") or price
|
||||
price = quote.ask_price or price
|
||||
|
||||
return safe_float(price)
|
||||
|
||||
|
||||
1
app/src/trading/market_intelligence/__init__.py
Normal file
1
app/src/trading/market_intelligence/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/src/trading/market_intelligence/__init__.py
|
||||
1
app/src/trading/market_intelligence/common/__init__.py
Normal file
1
app/src/trading/market_intelligence/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/src/trading/market_intelligence/common/__init__.py
|
||||
130
app/src/trading/market_intelligence/common/checks.py
Normal file
130
app/src/trading/market_intelligence/common/checks.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# app/src/trading/market_intelligence/common/checks.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.trading.market_intelligence.common.enums import (
|
||||
CheckStatus,
|
||||
ProcessingStage,
|
||||
)
|
||||
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
ContextDict,
|
||||
ReasonText,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineCheck:
|
||||
# Результат одной архитектурной проверки.
|
||||
#
|
||||
# Проверка относится не ко всему Engine, а к одному этапу обработки.
|
||||
# Например:
|
||||
#
|
||||
# INPUT
|
||||
# CALCULATION
|
||||
# VALIDATION
|
||||
# RESULT
|
||||
#
|
||||
# Engine может выполнить несколько независимых проверок,
|
||||
# после чего они объединяются в общий отчёт.
|
||||
stage: ProcessingStage
|
||||
status: CheckStatus
|
||||
reason: ReasonCode
|
||||
message: ReasonText
|
||||
details: ContextDict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_ok(self) -> bool:
|
||||
return self.status == CheckStatus.OK
|
||||
|
||||
@property
|
||||
def is_warning(self) -> bool:
|
||||
return self.status == CheckStatus.WARNING
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return self.status == CheckStatus.ERROR
|
||||
|
||||
@property
|
||||
def is_skipped(self) -> bool:
|
||||
return self.status == CheckStatus.SKIPPED
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineCheckReport:
|
||||
# Общий результат внутренних проверок Engine.
|
||||
#
|
||||
# Отчёт не содержит торговой логики.
|
||||
# Его задача — показать, какие этапы обработки были
|
||||
# успешно выполнены, а какие завершились предупреждением,
|
||||
# ошибкой или были пропущены.
|
||||
checks: tuple[EngineCheck, ...] = ()
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return any(check.is_error for check in self.checks)
|
||||
|
||||
@property
|
||||
def has_warnings(self) -> bool:
|
||||
return any(check.is_warning for check in self.checks)
|
||||
|
||||
@property
|
||||
def has_skipped(self) -> bool:
|
||||
return any(check.is_skipped for check in self.checks)
|
||||
|
||||
@property
|
||||
def overall_status(self) -> CheckStatus:
|
||||
# Общий статус определяется по наиболее серьёзному результату.
|
||||
if self.has_errors:
|
||||
return CheckStatus.ERROR
|
||||
|
||||
if self.has_warnings:
|
||||
return CheckStatus.WARNING
|
||||
|
||||
if self.has_skipped:
|
||||
return CheckStatus.SKIPPED
|
||||
|
||||
return CheckStatus.OK
|
||||
|
||||
@property
|
||||
def completed_checks(self) -> int:
|
||||
return sum(
|
||||
check.status != CheckStatus.SKIPPED
|
||||
for check in self.checks
|
||||
)
|
||||
|
||||
@property
|
||||
def total_checks(self) -> int:
|
||||
return len(self.checks)
|
||||
|
||||
|
||||
def build_check(
|
||||
*,
|
||||
stage: ProcessingStage,
|
||||
status: CheckStatus,
|
||||
reason: ReasonCode,
|
||||
message: ReasonText,
|
||||
details: ContextDict | None = None,
|
||||
) -> EngineCheck:
|
||||
# Создаёт одну архитектурную проверку.
|
||||
#
|
||||
# Используется всеми Engine для формирования единого
|
||||
# формата внутренних проверок.
|
||||
return EngineCheck(
|
||||
stage=stage,
|
||||
status=status,
|
||||
reason=reason,
|
||||
message=message,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
|
||||
def build_check_report(
|
||||
*checks: EngineCheck,
|
||||
) -> EngineCheckReport:
|
||||
# Собирает общий отчёт из набора отдельных проверок.
|
||||
#
|
||||
# Порядок проверок сохраняется.
|
||||
return EngineCheckReport(checks=checks)
|
||||
125
app/src/trading/market_intelligence/common/constants.py
Normal file
125
app/src/trading/market_intelligence/common/constants.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# app/src/trading/market_intelligence/common/constants.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# Минимальная и максимальная оценка движка.
|
||||
# Все аналитические оценки в Market Intelligence должны быть в диапазоне 0...100.
|
||||
MIN_SCORE = 0.0
|
||||
MAX_SCORE = 100.0
|
||||
|
||||
|
||||
# Минимальная и максимальная уверенность движка.
|
||||
# Уверенность показывает не силу сигнала, а насколько движок доверяет своему выводу.
|
||||
MIN_CONFIDENCE = 0.0
|
||||
MAX_CONFIDENCE = 1.0
|
||||
|
||||
|
||||
# Минимальная и максимальная вероятность.
|
||||
# Вероятность используется, например, для оценки продолжения движения или изменения направления.
|
||||
MIN_PROBABILITY = 0.0
|
||||
MAX_PROBABILITY = 100.0
|
||||
|
||||
|
||||
# Минимальный и максимальный вес показателя.
|
||||
# Вес показывает, насколько сильно отдельный показатель влияет на итоговую оценку.
|
||||
MIN_WEIGHT = 0.0
|
||||
MAX_WEIGHT = 1.0
|
||||
|
||||
|
||||
# Значения по умолчанию.
|
||||
# Они используются, когда данных недостаточно или движок безопасно возвращает пустой результат.
|
||||
DEFAULT_SCORE = 0.0
|
||||
DEFAULT_CONFIDENCE = 0.0
|
||||
DEFAULT_PROBABILITY = 0.0
|
||||
DEFAULT_WEIGHT = 1.0
|
||||
|
||||
|
||||
# Границы для словесной оценки качества score.
|
||||
# Эти значения не принимают торговых решений, а только помогают читать диагностику.
|
||||
WEAK_SCORE_THRESHOLD = 30.0
|
||||
NORMAL_SCORE_THRESHOLD = 50.0
|
||||
GOOD_SCORE_THRESHOLD = 70.0
|
||||
EXCELLENT_SCORE_THRESHOLD = 85.0
|
||||
|
||||
|
||||
# Границы для словесной оценки confidence.
|
||||
# Число confidence остаётся основным значением, а эти границы помогают
|
||||
# объяснять его человеку в журнале и диагностике.
|
||||
VERY_LOW_CONFIDENCE_THRESHOLD = 0.15
|
||||
LOW_CONFIDENCE_THRESHOLD = 0.30
|
||||
NORMAL_CONFIDENCE_THRESHOLD = 0.50
|
||||
HIGH_CONFIDENCE_THRESHOLD = 0.70
|
||||
VERY_HIGH_CONFIDENCE_THRESHOLD = 0.85
|
||||
|
||||
|
||||
# Возраст данных по умолчанию.
|
||||
# Если данные старше этого значения, результат можно считать устаревшим.
|
||||
DEFAULT_STALE_AFTER_SECONDS = 180.0
|
||||
|
||||
|
||||
# Время жизни аналитического сигнала по умолчанию.
|
||||
# Старый сигнал постепенно теряет значение для анализа.
|
||||
DEFAULT_SIGNAL_TTL_SECONDS = 180.0
|
||||
|
||||
|
||||
# Ограничение на количество зависимостей одного движка.
|
||||
# Это защитный архитектурный предел, чтобы движки не превращались
|
||||
# в большие модули, зависящие от всей платформы сразу.
|
||||
MAX_ENGINE_DEPENDENCIES = 8
|
||||
|
||||
|
||||
# Ограничение на количество метрик в одном результате.
|
||||
# Если метрик становится слишком много, значит движок, возможно,
|
||||
# начинает выполнять чужую работу и его нужно разделить.
|
||||
MAX_ENGINE_METRICS = 32
|
||||
|
||||
|
||||
# Ограничение на количество предупреждений в диагностике.
|
||||
# Это защищает журнал от слишком шумных записей.
|
||||
MAX_DIAGNOSTIC_WARNINGS = 16
|
||||
|
||||
|
||||
# Ограничение на количество ошибок в диагностике.
|
||||
# Даже если ошибок много, в результате нужно сохранять только полезную часть.
|
||||
MAX_DIAGNOSTIC_ERRORS = 16
|
||||
|
||||
|
||||
# Базовый набор таймфреймов для первого этапа Market Intelligence.
|
||||
# Движки не должны жёстко проверять эти значения внутри своей логики.
|
||||
DEFAULT_TIMEFRAMES = (
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
)
|
||||
|
||||
|
||||
# Таймфреймы, которые архитектура должна поддерживать в будущем.
|
||||
# Они указаны здесь как допустимое расширение, но не обязаны использоваться
|
||||
# на первом этапе реализации.
|
||||
FUTURE_TIMEFRAMES = (
|
||||
"4h",
|
||||
"1d",
|
||||
"1w",
|
||||
)
|
||||
|
||||
|
||||
# Поля, которые запрещены в результатах Market Intelligence.
|
||||
# Аналитический слой не должен принимать торговые решения или создавать заявки.
|
||||
FORBIDDEN_TRADING_FIELDS = frozenset(
|
||||
{
|
||||
"should_buy",
|
||||
"should_sell",
|
||||
"should_enter",
|
||||
"should_exit",
|
||||
"should_close",
|
||||
"should_flip",
|
||||
"open_position",
|
||||
"close_position",
|
||||
"place_order",
|
||||
"cancel_order",
|
||||
"leverage",
|
||||
"order_id",
|
||||
}
|
||||
)
|
||||
152
app/src/trading/market_intelligence/common/enums.py
Normal file
152
app/src/trading/market_intelligence/common/enums.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# app/src/trading/market_intelligence/common/enums.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class MarketDirection(StrEnum):
|
||||
# Направление движения цены.
|
||||
# Это не торговое решение, а только описание того,
|
||||
# куда в основном двигалась цена в анализируемом участке.
|
||||
UNKNOWN = "unknown"
|
||||
UP = "up"
|
||||
DOWN = "down"
|
||||
SIDEWAYS = "sideways"
|
||||
MIXED = "mixed"
|
||||
|
||||
|
||||
class MarketBias(StrEnum):
|
||||
# Общий перекос рынка.
|
||||
# Например, рынок может быть больше в пользу роста,
|
||||
# больше в пользу снижения или противоречивым.
|
||||
UNKNOWN = "unknown"
|
||||
BULLISH = "bullish"
|
||||
BEARISH = "bearish"
|
||||
NEUTRAL = "neutral"
|
||||
CONFLICTED = "conflicted"
|
||||
|
||||
|
||||
class MarketPhase(StrEnum):
|
||||
# Фаза рынка простыми словами:
|
||||
# рынок может ускоряться, откатываться, восстанавливаться,
|
||||
# расширять движение, выдыхаться или разворачиваться.
|
||||
UNKNOWN = "unknown"
|
||||
IMPULSE = "impulse"
|
||||
PULLBACK = "pullback"
|
||||
RECOVERY = "recovery"
|
||||
EXPANSION = "expansion"
|
||||
EXHAUSTION = "exhaustion"
|
||||
REVERSAL = "reversal"
|
||||
CONSOLIDATION = "consolidation"
|
||||
|
||||
|
||||
class MarketRegime(StrEnum):
|
||||
# Режим рынка описывает общий характер поведения цены.
|
||||
# Это помогает понять, рынок сейчас движется направленно,
|
||||
# стоит в диапазоне, резко меняется или ведёт себя нестабильно.
|
||||
UNKNOWN = "unknown"
|
||||
TRENDING = "trending"
|
||||
RANGE = "range"
|
||||
BREAKOUT = "breakout"
|
||||
MEAN_REVERSION = "mean_reversion"
|
||||
HIGH_VOLATILITY = "high_volatility"
|
||||
LOW_VOLATILITY = "low_volatility"
|
||||
PANIC = "panic"
|
||||
EUPHORIA = "euphoria"
|
||||
ACCUMULATION = "accumulation"
|
||||
DISTRIBUTION = "distribution"
|
||||
|
||||
|
||||
class MarketQuality(StrEnum):
|
||||
# Качество рынка показывает, насколько рынок понятен для анализа.
|
||||
# Плохое качество означает много шума и мало надёжных признаков.
|
||||
UNKNOWN = "unknown"
|
||||
POOR = "poor"
|
||||
WEAK = "weak"
|
||||
NORMAL = "normal"
|
||||
GOOD = "good"
|
||||
EXCELLENT = "excellent"
|
||||
|
||||
|
||||
class EngineStatus(StrEnum):
|
||||
# Статус показывает, насколько успешно движок выполнил свою работу.
|
||||
# Даже при ошибке движок должен вернуть понятный статус,
|
||||
# чтобы вся система могла продолжить работу безопасно.
|
||||
UNKNOWN = "unknown"
|
||||
OK = "ok"
|
||||
PARTIAL = "partial"
|
||||
STALE = "stale"
|
||||
INSUFFICIENT_DATA = "insufficient_data"
|
||||
ERROR = "error"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class ConfidenceLevel(StrEnum):
|
||||
# Словесный уровень уверенности.
|
||||
# Число confidence удобно для расчётов, но человеку проще читать
|
||||
# понятный уровень: низкая, нормальная или высокая уверенность.
|
||||
UNKNOWN = "unknown"
|
||||
VERY_LOW = "very_low"
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
VERY_HIGH = "very_high"
|
||||
|
||||
|
||||
class SignalFreshness(StrEnum):
|
||||
# Свежесть сигнала показывает его возраст.
|
||||
# Старый сигнал постепенно теряет значение для анализа.
|
||||
UNKNOWN = "unknown"
|
||||
NEW = "new"
|
||||
ACTIVE = "active"
|
||||
AGING = "aging"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class RiskLevel(StrEnum):
|
||||
# Общий уровень риска рыночной ситуации.
|
||||
# Это не решение закрыть или открыть сделку,
|
||||
# а только оценка сложности текущего рынка.
|
||||
UNKNOWN = "unknown"
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
ELEVATED = "elevated"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class TimeframeRole(StrEnum):
|
||||
# Роль временного интервала в общем анализе.
|
||||
# Например, младший интервал показывает детали,
|
||||
# а старший помогает понять общий фон.
|
||||
UNKNOWN = "unknown"
|
||||
LOWER = "lower"
|
||||
PRIMARY = "primary"
|
||||
HIGHER = "higher"
|
||||
CONFIRMATION = "confirmation"
|
||||
|
||||
|
||||
class CheckStatus(StrEnum):
|
||||
# Статус внутренней проверки блока.
|
||||
# Используется, чтобы проверять движки по частям,
|
||||
# а не ждать завершения всего движка целиком.
|
||||
OK = "ok"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class ProcessingStage(StrEnum):
|
||||
# Этап обработки внутри движка или общего блока.
|
||||
# Это не название файла, а смысловая часть работы:
|
||||
# входные данные, расчёт, оценка, проверка, payload или результат.
|
||||
UNKNOWN = "unknown"
|
||||
INPUT = "input"
|
||||
CALCULATION = "calculation"
|
||||
EVALUATION = "evaluation"
|
||||
VALIDATION = "validation"
|
||||
PAYLOAD = "payload"
|
||||
SNAPSHOT = "snapshot"
|
||||
RESULT = "result"
|
||||
EVENT = "event"
|
||||
110
app/src/trading/market_intelligence/common/events.py
Normal file
110
app/src/trading/market_intelligence/common/events.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# app/src/trading/market_intelligence/common/events.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from time import time
|
||||
|
||||
from src.trading.market_intelligence.common.models import EngineResult
|
||||
from src.trading.market_intelligence.common.payloads import (
|
||||
engine_result_to_payload,
|
||||
)
|
||||
from src.trading.market_intelligence.common.snapshots import (
|
||||
EngineSnapshot,
|
||||
build_engine_snapshot,
|
||||
engine_snapshot_to_payload,
|
||||
)
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
EngineName,
|
||||
PayloadDict,
|
||||
SymbolName,
|
||||
TimeframeName,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MarketIntelligenceEvent:
|
||||
# Событие Market Intelligence описывает факт завершения
|
||||
# аналитического действия.
|
||||
#
|
||||
# Важно: этот объект сам ничего не публикует.
|
||||
# Он только задаёт единый формат события, который позже сможет
|
||||
# использовать EventBus, журнал или Runtime.
|
||||
event_type: str
|
||||
engine_name: EngineName
|
||||
symbol: SymbolName
|
||||
timeframe: TimeframeName
|
||||
created_at: float
|
||||
payload: PayloadDict = field(default_factory=dict)
|
||||
|
||||
|
||||
def build_engine_result_event(
|
||||
result: EngineResult,
|
||||
*,
|
||||
event_type: str = "market_intelligence.engine_result",
|
||||
) -> MarketIntelligenceEvent:
|
||||
# Формирует событие напрямую из EngineResult.
|
||||
#
|
||||
# Используется, когда нужно зафиксировать сам факт получения
|
||||
# результата Engine без отдельного Snapshot.
|
||||
return MarketIntelligenceEvent(
|
||||
event_type=event_type,
|
||||
engine_name=result.engine_name,
|
||||
symbol=result.symbol,
|
||||
timeframe=result.timeframe,
|
||||
created_at=time(),
|
||||
payload=engine_result_to_payload(result),
|
||||
)
|
||||
|
||||
|
||||
def build_engine_snapshot_event(
|
||||
snapshot: EngineSnapshot,
|
||||
*,
|
||||
event_type: str = "market_intelligence.engine_snapshot",
|
||||
) -> MarketIntelligenceEvent:
|
||||
# Формирует событие из уже созданного Snapshot.
|
||||
#
|
||||
# Такой вариант нужен, когда сначала фиксируется состояние результата,
|
||||
# а уже затем это состояние передаётся во внешний слой событий.
|
||||
return MarketIntelligenceEvent(
|
||||
event_type=event_type,
|
||||
engine_name=snapshot.engine_name,
|
||||
symbol=snapshot.symbol,
|
||||
timeframe=snapshot.timeframe,
|
||||
created_at=time(),
|
||||
payload=engine_snapshot_to_payload(snapshot),
|
||||
)
|
||||
|
||||
|
||||
def build_result_snapshot_event(
|
||||
result: EngineResult,
|
||||
*,
|
||||
event_type: str = "market_intelligence.engine_snapshot",
|
||||
) -> MarketIntelligenceEvent:
|
||||
# Удобный безопасный путь:
|
||||
# EngineResult -> EngineSnapshot -> Event.
|
||||
#
|
||||
# Это сохраняет единый порядок фиксации результата анализа.
|
||||
snapshot = build_engine_snapshot(result)
|
||||
|
||||
return build_engine_snapshot_event(
|
||||
snapshot,
|
||||
event_type=event_type,
|
||||
)
|
||||
|
||||
|
||||
def market_intelligence_event_to_payload(
|
||||
event: MarketIntelligenceEvent,
|
||||
) -> PayloadDict:
|
||||
# Преобразует событие в обычный словарь.
|
||||
#
|
||||
# Функция нужна для будущей передачи события в журнал,
|
||||
# EventBus или внешний Runtime-слой.
|
||||
return {
|
||||
"event_type": event.event_type,
|
||||
"engine_name": event.engine_name,
|
||||
"symbol": event.symbol,
|
||||
"timeframe": event.timeframe,
|
||||
"created_at": event.created_at,
|
||||
"payload": event.payload,
|
||||
}
|
||||
333
app/src/trading/market_intelligence/common/models.py
Normal file
333
app/src/trading/market_intelligence/common/models.py
Normal file
@@ -0,0 +1,333 @@
|
||||
# app/src/trading/market_intelligence/common/models.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from src.trading.market_intelligence.common.enums import (
|
||||
EngineStatus,
|
||||
MarketBias,
|
||||
MarketDirection,
|
||||
MarketPhase,
|
||||
MarketRegime,
|
||||
RiskLevel,
|
||||
)
|
||||
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||
from src.trading.market_intelligence.common.scores import (
|
||||
EngineConfidence,
|
||||
EngineScore,
|
||||
)
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
ContextDict,
|
||||
DiagnosticMessages,
|
||||
DiagnosticValue,
|
||||
DurationMs,
|
||||
EngineName,
|
||||
EngineVersion,
|
||||
MarketData,
|
||||
MetricsDict,
|
||||
PayloadDict,
|
||||
SymbolName,
|
||||
TimeframeName,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineMetadata:
|
||||
# Описание Engine как компонента платформы.
|
||||
# Metadata не содержит аналитической логики и не является результатом анализа.
|
||||
# Она нужна Runtime, Registry, Coordinator и документации.
|
||||
name: EngineName
|
||||
version: EngineVersion
|
||||
description: str = ""
|
||||
supported_timeframes: tuple[TimeframeName, ...] = ()
|
||||
required_dependencies: tuple[EngineName, ...] = ()
|
||||
optional_dependencies: tuple[EngineName, ...] = ()
|
||||
enabled_by_default: bool = True
|
||||
|
||||
|
||||
class EngineTypeProtocol(Protocol):
|
||||
# Минимальный контракт класса Engine для хранения в Common Layer.
|
||||
# Common не должен импортировать EngineProtocol из Engine Layer.
|
||||
@classmethod
|
||||
def get_metadata(cls) -> EngineMetadata:
|
||||
# Вернуть metadata Engine без создания экземпляра.
|
||||
...
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
context: EngineContext,
|
||||
) -> EngineResult:
|
||||
# Выполнить анализ рыночного контекста.
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineRegistration:
|
||||
# Атомарная запись регистрации Engine.
|
||||
# Не дублирует данные из EngineMetadata.
|
||||
engine_type: type[EngineTypeProtocol]
|
||||
metadata: EngineMetadata
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineMetric:
|
||||
# Одна измеримая величина внутри движка.
|
||||
# Например: сила движения, ширина спреда, возраст данных или качество структуры.
|
||||
# Метрика не является решением, она только объясняет часть расчёта.
|
||||
name: str
|
||||
value: DiagnosticValue
|
||||
unit: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineDiagnostics:
|
||||
# Диагностика объясняет, что произошло во время работы движка.
|
||||
# Она нужна для журнала, отладки и будущих проверок качества.
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
details: ContextDict = field(default_factory=dict)
|
||||
warnings: DiagnosticMessages = field(default_factory=list)
|
||||
errors: DiagnosticMessages = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_warnings(self) -> bool:
|
||||
# Отдельный признак помогает быстро понять,
|
||||
# были ли у движка некритичные проблемы.
|
||||
return bool(self.warnings)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
# Ошибки не должны ломать всю платформу.
|
||||
# Но результат должен честно сообщать, что они были.
|
||||
return bool(self.errors)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineEvaluationMeta:
|
||||
# Служебная информация о расчёте.
|
||||
# Она помогает понять, какой движок, какой версии и когда сформировал результат.
|
||||
engine_name: EngineName
|
||||
engine_version: EngineVersion
|
||||
calculated_at: float | None = None
|
||||
duration_ms: DurationMs | None = None
|
||||
input_age_seconds: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineDependencyResult:
|
||||
# Краткое описание результата другого движка.
|
||||
# Это позволяет использовать зависимости без прямого вызова соседних Engine.
|
||||
engine_name: EngineName
|
||||
status: EngineStatus = EngineStatus.UNKNOWN
|
||||
score: EngineScore = field(default_factory=EngineScore)
|
||||
confidence: EngineConfidence = field(default_factory=EngineConfidence)
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
updated_at: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineContext:
|
||||
# Единый входной объект для любого Engine.
|
||||
# Важно: здесь нет позиции, плеча, баланса, ордеров или Telegram.
|
||||
# Market Intelligence получает только данные для анализа рынка.
|
||||
symbol: SymbolName
|
||||
timeframe: TimeframeName
|
||||
market_data: MarketData = field(default_factory=dict)
|
||||
previous_snapshot: Any | None = None
|
||||
dependency_results: tuple[EngineDependencyResult, ...] = ()
|
||||
settings: ContextDict = field(default_factory=dict)
|
||||
metadata: ContextDict = field(default_factory=dict)
|
||||
created_at: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineResult:
|
||||
# Единый результат любого аналитического движка.
|
||||
# Это мнение о рынке, а не торговое действие.
|
||||
engine_name: EngineName
|
||||
engine_version: EngineVersion
|
||||
symbol: SymbolName
|
||||
timeframe: TimeframeName
|
||||
|
||||
status: EngineStatus = EngineStatus.UNKNOWN
|
||||
score: EngineScore = field(default_factory=EngineScore)
|
||||
confidence: EngineConfidence = field(default_factory=EngineConfidence)
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
|
||||
metrics: tuple[EngineMetric, ...] = ()
|
||||
diagnostics: EngineDiagnostics = field(default_factory=EngineDiagnostics)
|
||||
dependencies: tuple[EngineDependencyResult, ...] = ()
|
||||
meta: EngineEvaluationMeta | None = None
|
||||
payload: PayloadDict = field(default_factory=dict)
|
||||
|
||||
direction: MarketDirection = MarketDirection.UNKNOWN
|
||||
bias: MarketBias = MarketBias.UNKNOWN
|
||||
phase: MarketPhase = MarketPhase.UNKNOWN
|
||||
regime: MarketRegime = MarketRegime.UNKNOWN
|
||||
risk_level: RiskLevel = RiskLevel.UNKNOWN
|
||||
|
||||
@property
|
||||
def is_ok(self) -> bool:
|
||||
# Удобный признак для Coordinator.
|
||||
# Он показывает, что движок отработал без критической ошибки.
|
||||
return self.status == EngineStatus.OK
|
||||
|
||||
@property
|
||||
def is_usable(self) -> bool:
|
||||
# Результат может быть полезен даже если он частичный.
|
||||
# Например, часть данных отсутствовала, но базовая оценка всё равно рассчитана.
|
||||
return self.status in {
|
||||
EngineStatus.OK,
|
||||
EngineStatus.PARTIAL,
|
||||
EngineStatus.STALE,
|
||||
}
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
# Быстрый доступ к признаку ошибок внутри диагностики.
|
||||
return self.diagnostics.has_errors
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeResult:
|
||||
# Единый результат выполнения Runtime.
|
||||
# RuntimeResult агрегирует результаты нескольких Engine,
|
||||
# но не содержит аналитики и не принимает торговых решений.
|
||||
engine_results: tuple[EngineResult, ...] = ()
|
||||
diagnostics: ContextDict = field(default_factory=dict)
|
||||
started_at: float | None = None
|
||||
finished_at: float | None = None
|
||||
duration_ms: DurationMs | None = None
|
||||
metadata: ContextDict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def successful_results(self) -> tuple[EngineResult, ...]:
|
||||
# Результаты Engine, которые успешно завершили анализ.
|
||||
return tuple(
|
||||
result
|
||||
for result in self.engine_results
|
||||
if result.status == EngineStatus.OK
|
||||
)
|
||||
|
||||
@property
|
||||
def failed_results(self) -> tuple[EngineResult, ...]:
|
||||
# Результаты Engine, которые завершились ошибкой.
|
||||
return tuple(
|
||||
result
|
||||
for result in self.engine_results
|
||||
if result.status == EngineStatus.ERROR
|
||||
)
|
||||
|
||||
@property
|
||||
def partial_results(self) -> tuple[EngineResult, ...]:
|
||||
# Частичные, но потенциально пригодные результаты Engine.
|
||||
return tuple(
|
||||
result
|
||||
for result in self.engine_results
|
||||
if result.status == EngineStatus.PARTIAL
|
||||
)
|
||||
|
||||
@property
|
||||
def stale_results(self) -> tuple[EngineResult, ...]:
|
||||
# Результаты Engine, построенные на устаревших данных.
|
||||
return tuple(
|
||||
result
|
||||
for result in self.engine_results
|
||||
if result.status == EngineStatus.STALE
|
||||
)
|
||||
|
||||
@property
|
||||
def executed_engines(self) -> tuple[EngineName, ...]:
|
||||
# Имена Engine, которые вернули результат.
|
||||
return tuple(result.engine_name for result in self.engine_results)
|
||||
|
||||
@property
|
||||
def failed_engines(self) -> tuple[EngineName, ...]:
|
||||
# Имена Engine, которые завершились ошибкой.
|
||||
return tuple(result.engine_name for result in self.failed_results)
|
||||
|
||||
@property
|
||||
def successful_engines(self) -> tuple[EngineName, ...]:
|
||||
# Имена Engine, которые завершились успешно.
|
||||
return tuple(result.engine_name for result in self.successful_results)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
# Быстрый признак наличия ошибок Runtime-выполнения.
|
||||
return bool(self.failed_results)
|
||||
|
||||
@property
|
||||
def is_successful(self) -> bool:
|
||||
# Runtime считается успешным, если ни один Engine не завершился ERROR.
|
||||
return not self.has_errors
|
||||
|
||||
@property
|
||||
def total_engines(self) -> int:
|
||||
# Количество Engine, вернувших результат.
|
||||
return len(self.engine_results)
|
||||
|
||||
@property
|
||||
def successful_count(self) -> int:
|
||||
# Количество успешно завершённых Engine.
|
||||
return len(self.successful_results)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoordinatorDiagnostics:
|
||||
# Диагностика Coordinator.
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
details: ContextDict = field(default_factory=dict)
|
||||
warnings: DiagnosticMessages = field(default_factory=list)
|
||||
errors: DiagnosticMessages = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_warnings(self) -> bool:
|
||||
return bool(self.warnings)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return bool(self.errors)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoordinatorEvaluationMeta:
|
||||
# Служебная информация о расчёте Coordinator.
|
||||
coordinator_version: str
|
||||
calculated_at: float | None = None
|
||||
duration_ms: DurationMs | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoordinatorResult:
|
||||
# Единый результат Coordinator Layer.
|
||||
# Это итоговая аналитическая интерпретация RuntimeResult,
|
||||
# но ещё не торговое решение.
|
||||
runtime_result: RuntimeResult
|
||||
diagnostics: CoordinatorDiagnostics = field(default_factory=CoordinatorDiagnostics)
|
||||
meta: CoordinatorEvaluationMeta | None = None
|
||||
payload: PayloadDict = field(default_factory=dict)
|
||||
|
||||
status: EngineStatus = EngineStatus.UNKNOWN
|
||||
score: EngineScore = field(default_factory=EngineScore)
|
||||
confidence: EngineConfidence = field(default_factory=EngineConfidence)
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
|
||||
direction: MarketDirection = MarketDirection.UNKNOWN
|
||||
bias: MarketBias = MarketBias.UNKNOWN
|
||||
phase: MarketPhase = MarketPhase.UNKNOWN
|
||||
regime: MarketRegime = MarketRegime.UNKNOWN
|
||||
risk_level: RiskLevel = RiskLevel.UNKNOWN
|
||||
|
||||
@property
|
||||
def is_usable(self) -> bool:
|
||||
return self.status in {
|
||||
EngineStatus.OK,
|
||||
EngineStatus.PARTIAL,
|
||||
EngineStatus.STALE,
|
||||
}
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return self.diagnostics.has_errors
|
||||
173
app/src/trading/market_intelligence/common/payloads.py
Normal file
173
app/src/trading/market_intelligence/common/payloads.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# app/src/trading/market_intelligence/common/payloads.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, cast
|
||||
|
||||
from src.trading.market_intelligence.common.models import (
|
||||
EngineDependencyResult,
|
||||
EngineDiagnostics,
|
||||
EngineMetric,
|
||||
EngineResult,
|
||||
)
|
||||
from src.trading.market_intelligence.common.scores import (
|
||||
EngineConfidence,
|
||||
EngineScore,
|
||||
)
|
||||
from src.trading.market_intelligence.common.types import PayloadDict
|
||||
from src.trading.market_intelligence.common.validation import (
|
||||
validate_payload_has_no_trading_fields,
|
||||
)
|
||||
|
||||
|
||||
def value_to_payload(value: Any) -> Any:
|
||||
# Приводит значение к виду, который безопасно положить в payload.
|
||||
# Payload должен состоять из простых структур: dict, list, str, int,
|
||||
# float, bool или None. Так его можно сохранить в журнал, событие
|
||||
# или будущий snapshot.
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
|
||||
if is_dataclass(value):
|
||||
# is_dataclass() возвращает True и для экземпляров dataclass,
|
||||
# и для самих классов dataclass. asdict() работает только
|
||||
# с экземпляром, поэтому класс пропускаем как обычное значение.
|
||||
if not isinstance(value, type):
|
||||
return mapping_to_payload(asdict(value))
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
return mapping_to_payload(value)
|
||||
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [value_to_payload(item) for item in value]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def mapping_to_payload(mapping: Mapping[str, Any]) -> PayloadDict:
|
||||
# Преобразует словарь в безопасный payload.
|
||||
# Все вложенные enum, dataclass, list и tuple приводятся
|
||||
# к простым значениям.
|
||||
return {
|
||||
str(key): value_to_payload(value)
|
||||
for key, value in mapping.items()
|
||||
}
|
||||
|
||||
|
||||
def engine_score_to_payload(score: EngineScore) -> PayloadDict:
|
||||
# Сериализует оценку Engine.
|
||||
# Оценка не является торговым решением,
|
||||
# это только числовое качество анализа.
|
||||
return {
|
||||
"value": value_to_payload(score.value),
|
||||
"quality": value_to_payload(score.quality),
|
||||
"reason": value_to_payload(score.reason),
|
||||
}
|
||||
|
||||
|
||||
def engine_confidence_to_payload(confidence: EngineConfidence) -> PayloadDict:
|
||||
# Сериализует уверенность Engine.
|
||||
# Уверенность показывает надёжность результата,
|
||||
# а не торговую рекомендацию.
|
||||
return {
|
||||
"value": value_to_payload(confidence.value),
|
||||
"level": value_to_payload(confidence.level),
|
||||
"reason": value_to_payload(confidence.reason),
|
||||
"is_reliable": confidence.is_reliable,
|
||||
}
|
||||
|
||||
|
||||
def engine_metric_to_payload(metric: EngineMetric) -> PayloadDict:
|
||||
# Сериализует одну измеримую метрику Engine.
|
||||
return {
|
||||
"name": metric.name,
|
||||
"value": value_to_payload(metric.value),
|
||||
"unit": metric.unit,
|
||||
"description": metric.description,
|
||||
}
|
||||
|
||||
|
||||
def engine_diagnostics_to_payload(
|
||||
diagnostics: EngineDiagnostics,
|
||||
) -> PayloadDict:
|
||||
# Сериализует диагностику Engine.
|
||||
# Диагностика нужна для журнала и отладки,
|
||||
# но не является пользовательским UI-текстом.
|
||||
return {
|
||||
"reason": value_to_payload(diagnostics.reason),
|
||||
"details": mapping_to_payload(diagnostics.details),
|
||||
"warnings": value_to_payload(diagnostics.warnings),
|
||||
"errors": value_to_payload(diagnostics.errors),
|
||||
"has_warnings": diagnostics.has_warnings,
|
||||
"has_errors": diagnostics.has_errors,
|
||||
}
|
||||
|
||||
|
||||
def engine_dependency_to_payload(
|
||||
dependency: EngineDependencyResult,
|
||||
) -> PayloadDict:
|
||||
# Сериализует краткий результат зависимого Engine.
|
||||
# Это позволяет передавать результат зависимости
|
||||
# без прямого импорта самого Engine.
|
||||
return {
|
||||
"engine_name": dependency.engine_name,
|
||||
"status": value_to_payload(dependency.status),
|
||||
"score": engine_score_to_payload(dependency.score),
|
||||
"confidence": engine_confidence_to_payload(dependency.confidence),
|
||||
"reason": value_to_payload(dependency.reason),
|
||||
"updated_at": dependency.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def engine_result_to_payload(result: EngineResult) -> PayloadDict:
|
||||
# Преобразует EngineResult в единый payload.
|
||||
# Функция не меняет результат Engine и не добавляет торговые поля.
|
||||
payload: PayloadDict = {
|
||||
"engine_name": result.engine_name,
|
||||
"engine_version": result.engine_version,
|
||||
"symbol": result.symbol,
|
||||
"timeframe": result.timeframe,
|
||||
"status": value_to_payload(result.status),
|
||||
"reason": value_to_payload(result.reason),
|
||||
"score": engine_score_to_payload(result.score),
|
||||
"confidence": engine_confidence_to_payload(result.confidence),
|
||||
"metrics": [
|
||||
engine_metric_to_payload(metric)
|
||||
for metric in result.metrics
|
||||
],
|
||||
"diagnostics": engine_diagnostics_to_payload(result.diagnostics),
|
||||
"dependencies": [
|
||||
engine_dependency_to_payload(dependency)
|
||||
for dependency in result.dependencies
|
||||
],
|
||||
"meta": value_to_payload(result.meta),
|
||||
"payload": mapping_to_payload(result.payload),
|
||||
"direction": value_to_payload(result.direction),
|
||||
"bias": value_to_payload(result.bias),
|
||||
"phase": value_to_payload(result.phase),
|
||||
"regime": value_to_payload(result.regime),
|
||||
"risk_level": value_to_payload(result.risk_level),
|
||||
"is_ok": result.is_ok,
|
||||
"is_usable": result.is_usable,
|
||||
"has_errors": result.has_errors,
|
||||
}
|
||||
|
||||
validation = validate_payload_has_no_trading_fields(payload)
|
||||
|
||||
if validation.is_ok:
|
||||
return payload
|
||||
|
||||
# Если итоговый payload нарушил границы Market Intelligence,
|
||||
# мы не скрываем проблему, а добавляем диагностический блок.
|
||||
# Это не исправляет payload автоматически, чтобы нарушение
|
||||
# было видно в журнале и при Engineering Review.
|
||||
payload["payload_validation"] = {
|
||||
"status": value_to_payload(validation.status),
|
||||
"reason": value_to_payload(validation.reason),
|
||||
"issues": value_to_payload(validation.issues),
|
||||
"details": mapping_to_payload(validation.details),
|
||||
}
|
||||
|
||||
return cast(PayloadDict, payload)
|
||||
153
app/src/trading/market_intelligence/common/reasons.py
Normal file
153
app/src/trading/market_intelligence/common/reasons.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# app/src/trading/market_intelligence/common/reasons.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ReasonCode(StrEnum):
|
||||
# Универсальный код причины.
|
||||
# Движки используют эти значения, чтобы объяснить результат
|
||||
# в машинно-читаемом виде. Человекочитаемый текст будет строиться
|
||||
# отдельным слоем диагностики, а не внутри движка.
|
||||
|
||||
# ---------- Common ----------
|
||||
UNKNOWN = "unknown"
|
||||
OK = "ok"
|
||||
NOT_APPLICABLE = "not_applicable"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
# ---------- Data ----------
|
||||
INSUFFICIENT_DATA = "insufficient_data"
|
||||
EMPTY_MARKET_DATA = "empty_market_data"
|
||||
STALE_MARKET_DATA = "stale_market_data"
|
||||
INVALID_MARKET_DATA = "invalid_market_data"
|
||||
MISSING_PRICE_DATA = "missing_price_data"
|
||||
MISSING_VOLUME_DATA = "missing_volume_data"
|
||||
MISSING_TIMEFRAME_DATA = "missing_timeframe_data"
|
||||
|
||||
# ---------- Engine Runtime ----------
|
||||
ENGINE_DISABLED = "engine_disabled"
|
||||
ENGINE_ERROR = "engine_error"
|
||||
ENGINE_PARTIAL_RESULT = "engine_partial_result"
|
||||
ENGINE_DEPENDENCY_MISSING = "engine_dependency_missing"
|
||||
ENGINE_DEPENDENCY_STALE = "engine_dependency_stale"
|
||||
ENGINE_DEPENDENCY_ERROR = "engine_dependency_error"
|
||||
|
||||
# ---------- Validation ----------
|
||||
VALIDATION_PASSED = "validation_passed"
|
||||
VALIDATION_FAILED = "validation_failed"
|
||||
SCORE_OUT_OF_RANGE = "score_out_of_range"
|
||||
CONFIDENCE_OUT_OF_RANGE = "confidence_out_of_range"
|
||||
PROBABILITY_OUT_OF_RANGE = "probability_out_of_range"
|
||||
FORBIDDEN_TRADING_FIELD_FOUND = "forbidden_trading_field_found"
|
||||
REQUIRED_FIELD_MISSING = "required_field_missing"
|
||||
|
||||
# ---------- Market State ----------
|
||||
MARKET_UNDEFINED = "market_undefined"
|
||||
MARKET_CLEAR = "market_clear"
|
||||
MARKET_NOISY = "market_noisy"
|
||||
MARKET_CONFLICTED = "market_conflicted"
|
||||
MARKET_QUALITY_LOW = "market_quality_low"
|
||||
MARKET_QUALITY_NORMAL = "market_quality_normal"
|
||||
MARKET_QUALITY_HIGH = "market_quality_high"
|
||||
|
||||
# ---------- Structure ----------
|
||||
STRUCTURE_UNDEFINED = "structure_undefined"
|
||||
STRUCTURE_UP = "structure_up"
|
||||
STRUCTURE_DOWN = "structure_down"
|
||||
STRUCTURE_SIDEWAYS = "structure_sideways"
|
||||
STRUCTURE_CHANGED = "structure_changed"
|
||||
STRUCTURE_BROKEN = "structure_broken"
|
||||
|
||||
# ---------- Trend ----------
|
||||
TREND_UNDEFINED = "trend_undefined"
|
||||
TREND_UP = "trend_up"
|
||||
TREND_DOWN = "trend_down"
|
||||
TREND_SIDEWAYS = "trend_sideways"
|
||||
TREND_STRONG = "trend_strong"
|
||||
TREND_NORMAL = "trend_normal"
|
||||
TREND_WEAK = "trend_weak"
|
||||
TREND_CLEAN = "trend_clean"
|
||||
TREND_NOISY = "trend_noisy"
|
||||
TREND_OVEREXTENDED = "trend_overextended"
|
||||
|
||||
# ---------- Momentum ----------
|
||||
MOMENTUM_UNDEFINED = "momentum_undefined"
|
||||
MOMENTUM_UP = "momentum_up"
|
||||
MOMENTUM_DOWN = "momentum_down"
|
||||
MOMENTUM_ACCELERATING = "momentum_accelerating"
|
||||
MOMENTUM_DECELERATING = "momentum_decelerating"
|
||||
MOMENTUM_EXHAUSTED = "momentum_exhausted"
|
||||
MOMENTUM_ABSENT = "momentum_absent"
|
||||
|
||||
# ---------- Volatility ----------
|
||||
VOLATILITY_UNDEFINED = "volatility_undefined"
|
||||
VOLATILITY_LOW = "volatility_low"
|
||||
VOLATILITY_NORMAL = "volatility_normal"
|
||||
VOLATILITY_HIGH = "volatility_high"
|
||||
VOLATILITY_EXPANDING = "volatility_expanding"
|
||||
VOLATILITY_COMPRESSING = "volatility_compressing"
|
||||
VOLATILITY_UNSTABLE = "volatility_unstable"
|
||||
|
||||
# ---------- Wave ----------
|
||||
WAVE_UNDEFINED = "wave_undefined"
|
||||
WAVE_IMPULSE = "wave_impulse"
|
||||
WAVE_PULLBACK = "wave_pullback"
|
||||
WAVE_RECOVERY = "wave_recovery"
|
||||
WAVE_MATURE = "wave_mature"
|
||||
WAVE_COMPLETED = "wave_completed"
|
||||
WAVE_TOO_SHORT = "wave_too_short"
|
||||
|
||||
# ---------- Cycle ----------
|
||||
CYCLE_UNDEFINED = "cycle_undefined"
|
||||
CYCLE_IMPULSE = "cycle_impulse"
|
||||
CYCLE_PULLBACK = "cycle_pullback"
|
||||
CYCLE_RECOVERY = "cycle_recovery"
|
||||
CYCLE_EXPANSION = "cycle_expansion"
|
||||
CYCLE_EXHAUSTION = "cycle_exhaustion"
|
||||
CYCLE_REVERSAL = "cycle_reversal"
|
||||
|
||||
# ---------- Liquidity ----------
|
||||
LIQUIDITY_UNDEFINED = "liquidity_undefined"
|
||||
LIQUIDITY_GOOD = "liquidity_good"
|
||||
LIQUIDITY_NORMAL = "liquidity_normal"
|
||||
LIQUIDITY_POOR = "liquidity_poor"
|
||||
SPREAD_NORMAL = "spread_normal"
|
||||
SPREAD_WIDE = "spread_wide"
|
||||
DEPTH_NORMAL = "depth_normal"
|
||||
DEPTH_THIN = "depth_thin"
|
||||
EXECUTION_QUALITY_LOW = "execution_quality_low"
|
||||
|
||||
# ---------- Regime ----------
|
||||
REGIME_UNDEFINED = "regime_undefined"
|
||||
REGIME_TRENDING = "regime_trending"
|
||||
REGIME_RANGE = "regime_range"
|
||||
REGIME_BREAKOUT = "regime_breakout"
|
||||
REGIME_MEAN_REVERSION = "regime_mean_reversion"
|
||||
REGIME_HIGH_VOLATILITY = "regime_high_volatility"
|
||||
REGIME_LOW_VOLATILITY = "regime_low_volatility"
|
||||
REGIME_PANIC = "regime_panic"
|
||||
REGIME_EUPHORIA = "regime_euphoria"
|
||||
REGIME_ACCUMULATION = "regime_accumulation"
|
||||
REGIME_DISTRIBUTION = "regime_distribution"
|
||||
|
||||
# ---------- Confidence ----------
|
||||
CONFIDENCE_UNDEFINED = "confidence_undefined"
|
||||
CONFIDENCE_LOW = "confidence_low"
|
||||
CONFIDENCE_NORMAL = "confidence_normal"
|
||||
CONFIDENCE_HIGH = "confidence_high"
|
||||
CONFIDENCE_UNSTABLE = "confidence_unstable"
|
||||
CONFIDENCE_STABLE = "confidence_stable"
|
||||
|
||||
# ---------- Signal Aging ----------
|
||||
SIGNAL_NEW = "signal_new"
|
||||
SIGNAL_ACTIVE = "signal_active"
|
||||
SIGNAL_AGING = "signal_aging"
|
||||
SIGNAL_EXPIRED = "signal_expired"
|
||||
|
||||
# ---------- Timeframe ----------
|
||||
TIMEFRAME_UNDEFINED = "timeframe_undefined"
|
||||
TIMEFRAME_ALIGNED = "timeframe_aligned"
|
||||
TIMEFRAME_CONFLICTED = "timeframe_conflicted"
|
||||
TIMEFRAME_PRIMARY_MISSING = "timeframe_primary_missing"
|
||||
263
app/src/trading/market_intelligence/common/scores.py
Normal file
263
app/src/trading/market_intelligence/common/scores.py
Normal file
@@ -0,0 +1,263 @@
|
||||
# app/src/trading/market_intelligence/common/scores.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.trading.market_intelligence.common.constants import (
|
||||
DEFAULT_CONFIDENCE,
|
||||
DEFAULT_PROBABILITY,
|
||||
DEFAULT_SCORE,
|
||||
DEFAULT_WEIGHT,
|
||||
EXCELLENT_SCORE_THRESHOLD,
|
||||
GOOD_SCORE_THRESHOLD,
|
||||
HIGH_CONFIDENCE_THRESHOLD,
|
||||
LOW_CONFIDENCE_THRESHOLD,
|
||||
MAX_CONFIDENCE,
|
||||
MAX_PROBABILITY,
|
||||
MAX_SCORE,
|
||||
MAX_WEIGHT,
|
||||
MIN_CONFIDENCE,
|
||||
MIN_PROBABILITY,
|
||||
MIN_SCORE,
|
||||
MIN_WEIGHT,
|
||||
NORMAL_CONFIDENCE_THRESHOLD,
|
||||
NORMAL_SCORE_THRESHOLD,
|
||||
VERY_HIGH_CONFIDENCE_THRESHOLD,
|
||||
VERY_LOW_CONFIDENCE_THRESHOLD,
|
||||
WEAK_SCORE_THRESHOLD,
|
||||
)
|
||||
from src.trading.market_intelligence.common.enums import (
|
||||
ConfidenceLevel,
|
||||
MarketQuality,
|
||||
)
|
||||
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
ConfidenceValue,
|
||||
ProbabilityValue,
|
||||
ReasonCode as ReasonCodeType,
|
||||
ScoreValue,
|
||||
WeightValue,
|
||||
)
|
||||
|
||||
|
||||
def clamp_score(value: float | int | None) -> ScoreValue:
|
||||
# Приводим оценку к безопасному диапазону 0...100.
|
||||
# Это защищает все будущие движки от случайного выхода за границы шкалы.
|
||||
if value is None:
|
||||
return DEFAULT_SCORE
|
||||
|
||||
return max(MIN_SCORE, min(MAX_SCORE, float(value)))
|
||||
|
||||
|
||||
def clamp_confidence(value: float | int | None) -> ConfidenceValue:
|
||||
# Приводим уверенность к безопасному диапазону 0...1.
|
||||
# Уверенность показывает качество вывода, а не силу движения рынка.
|
||||
if value is None:
|
||||
return DEFAULT_CONFIDENCE
|
||||
|
||||
return max(MIN_CONFIDENCE, min(MAX_CONFIDENCE, float(value)))
|
||||
|
||||
|
||||
def clamp_probability(value: float | int | None) -> ProbabilityValue:
|
||||
# Приводим вероятность к безопасному диапазону 0...100.
|
||||
# Это единая шкала для будущих движков вероятности.
|
||||
if value is None:
|
||||
return DEFAULT_PROBABILITY
|
||||
|
||||
return max(MIN_PROBABILITY, min(MAX_PROBABILITY, float(value)))
|
||||
|
||||
|
||||
def clamp_weight(value: float | int | None) -> WeightValue:
|
||||
# Приводим вес показателя к безопасному диапазону 0...1.
|
||||
# Вес показывает, насколько сильно показатель влияет на итоговую оценку.
|
||||
if value is None:
|
||||
return DEFAULT_WEIGHT
|
||||
|
||||
return max(MIN_WEIGHT, min(MAX_WEIGHT, float(value)))
|
||||
|
||||
|
||||
def classify_score_quality(value: float | int | None) -> MarketQuality:
|
||||
# Переводим числовую оценку в понятное качество.
|
||||
# Это нужно для журнала и диагностики, чтобы не читать только сухие числа.
|
||||
score = clamp_score(value)
|
||||
|
||||
if score >= EXCELLENT_SCORE_THRESHOLD:
|
||||
return MarketQuality.EXCELLENT
|
||||
|
||||
if score >= GOOD_SCORE_THRESHOLD:
|
||||
return MarketQuality.GOOD
|
||||
|
||||
if score >= NORMAL_SCORE_THRESHOLD:
|
||||
return MarketQuality.NORMAL
|
||||
|
||||
if score >= WEAK_SCORE_THRESHOLD:
|
||||
return MarketQuality.WEAK
|
||||
|
||||
return MarketQuality.POOR
|
||||
|
||||
|
||||
def classify_confidence_level(value: float | int | None) -> ConfidenceLevel:
|
||||
# Переводим числовую уверенность в понятный уровень.
|
||||
# Это помогает человеку быстро понять, насколько надёжен вывод движка.
|
||||
confidence = clamp_confidence(value)
|
||||
|
||||
if confidence >= VERY_HIGH_CONFIDENCE_THRESHOLD:
|
||||
return ConfidenceLevel.VERY_HIGH
|
||||
|
||||
if confidence >= HIGH_CONFIDENCE_THRESHOLD:
|
||||
return ConfidenceLevel.HIGH
|
||||
|
||||
if confidence >= NORMAL_CONFIDENCE_THRESHOLD:
|
||||
return ConfidenceLevel.NORMAL
|
||||
|
||||
if confidence >= LOW_CONFIDENCE_THRESHOLD:
|
||||
return ConfidenceLevel.LOW
|
||||
|
||||
if confidence >= VERY_LOW_CONFIDENCE_THRESHOLD:
|
||||
return ConfidenceLevel.VERY_LOW
|
||||
|
||||
return ConfidenceLevel.VERY_LOW
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineScore:
|
||||
# Единая модель оценки движка.
|
||||
# Оценка всегда хранится в диапазоне 0...100 и дополнительно имеет
|
||||
# человекочитаемое качество для диагностики.
|
||||
value: ScoreValue = DEFAULT_SCORE
|
||||
quality: MarketQuality = MarketQuality.POOR
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
value: float | int | None,
|
||||
*,
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN,
|
||||
) -> EngineScore:
|
||||
safe_value = clamp_score(value)
|
||||
|
||||
return cls(
|
||||
value=safe_value,
|
||||
quality=classify_score_quality(safe_value),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineConfidence:
|
||||
# Единая модель уверенности движка.
|
||||
# Уверенность показывает, насколько результат можно считать надёжным.
|
||||
value: ConfidenceValue = DEFAULT_CONFIDENCE
|
||||
level: ConfidenceLevel = ConfidenceLevel.VERY_LOW
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
|
||||
@property
|
||||
def is_reliable(self) -> bool:
|
||||
# Считаем результат достаточно надёжным, если уверенность
|
||||
# не ниже нормального уровня.
|
||||
return self.value >= NORMAL_CONFIDENCE_THRESHOLD
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
value: float | int | None,
|
||||
*,
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN,
|
||||
) -> EngineConfidence:
|
||||
safe_value = clamp_confidence(value)
|
||||
|
||||
return cls(
|
||||
value=safe_value,
|
||||
level=classify_confidence_level(safe_value),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProbabilityScore:
|
||||
# Единая модель вероятности.
|
||||
# Используется для будущих движков продолжения и изменения направления.
|
||||
value: ProbabilityValue = DEFAULT_PROBABILITY
|
||||
confidence: EngineConfidence = field(default_factory=EngineConfidence)
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
value: float | int | None,
|
||||
*,
|
||||
confidence: EngineConfidence | None = None,
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN,
|
||||
) -> ProbabilityScore:
|
||||
return cls(
|
||||
value=clamp_probability(value),
|
||||
confidence=confidence or EngineConfidence(),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WeightedScore:
|
||||
# Одна часть итоговой оценки.
|
||||
# Например, общий результат может состоять из оценки тренда,
|
||||
# оценки изменчивости рынка и оценки ликвидности.
|
||||
name: str
|
||||
value: ScoreValue
|
||||
weight: WeightValue = DEFAULT_WEIGHT
|
||||
reason: ReasonCodeType = ReasonCode.UNKNOWN
|
||||
|
||||
@property
|
||||
def weighted_value(self) -> float:
|
||||
# Возвращает вклад этой части в итоговую оценку.
|
||||
return self.value * self.weight
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
value: float | int | None,
|
||||
weight: float | int | None = DEFAULT_WEIGHT,
|
||||
reason: ReasonCode = ReasonCode.UNKNOWN,
|
||||
) -> WeightedScore:
|
||||
return cls(
|
||||
name=name,
|
||||
value=clamp_score(value),
|
||||
weight=clamp_weight(weight),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScoreBreakdown:
|
||||
# Подробная структура итоговой оценки.
|
||||
# Нужна для диагностики: система должна объяснять, из каких частей
|
||||
# получилась итоговая оценка.
|
||||
items: tuple[WeightedScore, ...] = ()
|
||||
|
||||
@property
|
||||
def total_weight(self) -> float:
|
||||
return sum(item.weight for item in self.items)
|
||||
|
||||
@property
|
||||
def total_score(self) -> float:
|
||||
return sum(item.weighted_value for item in self.items)
|
||||
|
||||
@property
|
||||
def final_score(self) -> ScoreValue:
|
||||
# Если веса отсутствуют, безопасно возвращаем нулевую оценку.
|
||||
# Это лучше, чем делить на ноль или создавать ложный результат.
|
||||
if self.total_weight <= 0:
|
||||
return DEFAULT_SCORE
|
||||
|
||||
return clamp_score(self.total_score / self.total_weight)
|
||||
|
||||
@property
|
||||
def quality(self) -> MarketQuality:
|
||||
return classify_score_quality(self.final_score)
|
||||
|
||||
@classmethod
|
||||
def create(cls, items: tuple[WeightedScore, ...]) -> ScoreBreakdown:
|
||||
return cls(items=items)
|
||||
79
app/src/trading/market_intelligence/common/snapshots.py
Normal file
79
app/src/trading/market_intelligence/common/snapshots.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# app/src/trading/market_intelligence/common/snapshots.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from time import time
|
||||
|
||||
from src.trading.market_intelligence.common.models import EngineResult
|
||||
from src.trading.market_intelligence.common.payloads import (
|
||||
engine_result_to_payload,
|
||||
)
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
EngineName,
|
||||
EngineVersion,
|
||||
PayloadDict,
|
||||
SymbolName,
|
||||
TimeframeName,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineSnapshot:
|
||||
# Snapshot представляет собой неизменяемый снимок результата работы
|
||||
# аналитического движка в определённый момент времени.
|
||||
#
|
||||
# Snapshot используется для:
|
||||
#
|
||||
# • журналирования;
|
||||
# • Runtime Diagnostics;
|
||||
# • последующего сравнения состояний;
|
||||
# • формирования Event;
|
||||
# • хранения истории анализа.
|
||||
#
|
||||
# Snapshot не является Runtime-состоянием движка.
|
||||
# После создания его содержимое больше не изменяется.
|
||||
|
||||
engine_name: EngineName
|
||||
engine_version: EngineVersion
|
||||
|
||||
symbol: SymbolName
|
||||
timeframe: TimeframeName
|
||||
|
||||
created_at: float
|
||||
|
||||
payload: PayloadDict = field(default_factory=dict)
|
||||
|
||||
|
||||
def build_engine_snapshot(result: EngineResult) -> EngineSnapshot:
|
||||
# Формирует неизменяемый Snapshot из результата Engine.
|
||||
#
|
||||
# Snapshot всегда строится через общий Payload Layer,
|
||||
# чтобы все движки сохраняли результаты в едином формате.
|
||||
|
||||
return EngineSnapshot(
|
||||
engine_name=result.engine_name,
|
||||
engine_version=result.engine_version,
|
||||
symbol=result.symbol,
|
||||
timeframe=result.timeframe,
|
||||
created_at=time(),
|
||||
payload=engine_result_to_payload(result),
|
||||
)
|
||||
|
||||
|
||||
def engine_snapshot_to_payload(
|
||||
snapshot: EngineSnapshot,
|
||||
) -> PayloadDict:
|
||||
# Преобразует Snapshot в сериализуемый Payload.
|
||||
#
|
||||
# Отдельная функция позволяет в будущем расширять Snapshot,
|
||||
# не изменяя код остальных компонентов платформы.
|
||||
|
||||
return {
|
||||
"engine_name": snapshot.engine_name,
|
||||
"engine_version": snapshot.engine_version,
|
||||
"symbol": snapshot.symbol,
|
||||
"timeframe": snapshot.timeframe,
|
||||
"created_at": snapshot.created_at,
|
||||
"payload": snapshot.payload,
|
||||
}
|
||||
169
app/src/trading/market_intelligence/common/timeframes.py
Normal file
169
app/src/trading/market_intelligence/common/timeframes.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# app/src/trading/market_intelligence/common/timeframes.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.trading.market_intelligence.common.enums import TimeframeRole
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
TimeframeName,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Timeframe:
|
||||
# Единое описание временного интервала.
|
||||
#
|
||||
# Timeframe не знает ничего о бирже, стратегии или торговом решении.
|
||||
# Он только описывает длительность интервала и его базовую роль
|
||||
# внутри многоуровневого анализа рынка.
|
||||
name: TimeframeName
|
||||
duration_minutes: int
|
||||
role: TimeframeRole = TimeframeRole.UNKNOWN
|
||||
description: str | None = None
|
||||
|
||||
|
||||
M1 = Timeframe(
|
||||
name="1m",
|
||||
duration_minutes=1,
|
||||
role=TimeframeRole.LOWER,
|
||||
description="Минутный интервал для детального краткосрочного анализа.",
|
||||
)
|
||||
|
||||
M5 = Timeframe(
|
||||
name="5m",
|
||||
duration_minutes=5,
|
||||
role=TimeframeRole.PRIMARY,
|
||||
description="Основной рабочий интервал первого этапа Market Intelligence.",
|
||||
)
|
||||
|
||||
M15 = Timeframe(
|
||||
name="15m",
|
||||
duration_minutes=15,
|
||||
role=TimeframeRole.CONFIRMATION,
|
||||
description="Интервал подтверждения между локальным и старшим анализом.",
|
||||
)
|
||||
|
||||
H1 = Timeframe(
|
||||
name="1h",
|
||||
duration_minutes=60,
|
||||
role=TimeframeRole.HIGHER,
|
||||
description="Старший интервал, совместимый с текущим HTF-анализом.",
|
||||
)
|
||||
|
||||
H4 = Timeframe(
|
||||
name="4h",
|
||||
duration_minutes=240,
|
||||
role=TimeframeRole.HIGHER,
|
||||
description="Старший интервал для будущего более глубокого анализа.",
|
||||
)
|
||||
|
||||
D1 = Timeframe(
|
||||
name="1d",
|
||||
duration_minutes=1440,
|
||||
role=TimeframeRole.HIGHER,
|
||||
description="Дневной интервал для будущего долгосрочного контекста.",
|
||||
)
|
||||
|
||||
W1 = Timeframe(
|
||||
name="1w",
|
||||
duration_minutes=10080,
|
||||
role=TimeframeRole.HIGHER,
|
||||
description="Недельный интервал для будущего стратегического контекста.",
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_TIMEFRAMES: tuple[Timeframe, ...] = (
|
||||
M1,
|
||||
M5,
|
||||
M15,
|
||||
H1,
|
||||
H4,
|
||||
D1,
|
||||
W1,
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_TIMEFRAME_NAMES: tuple[TimeframeName, ...] = tuple(
|
||||
timeframe.name
|
||||
for timeframe in SUPPORTED_TIMEFRAMES
|
||||
)
|
||||
|
||||
|
||||
# Базовая карта старшего таймфрейма.
|
||||
# Она сохраняет совместимость с текущим MarketAnalysisService,
|
||||
# где для рабочего 5m используется старший 1h.
|
||||
HIGHER_TIMEFRAME_BY_NAME: dict[TimeframeName, TimeframeName] = {
|
||||
"1m": "5m",
|
||||
"5m": "1h",
|
||||
"15m": "1h",
|
||||
"1h": "4h",
|
||||
"4h": "1d",
|
||||
"1d": "1w",
|
||||
}
|
||||
|
||||
|
||||
def get_timeframe(name: TimeframeName) -> Timeframe | None:
|
||||
# Возвращает описание таймфрейма по его имени.
|
||||
# Если интервал пока не поддерживается архитектурой,
|
||||
# возвращается None вместо исключения.
|
||||
normalized_name = str(name).strip().lower()
|
||||
|
||||
for timeframe in SUPPORTED_TIMEFRAMES:
|
||||
if timeframe.name == normalized_name:
|
||||
return timeframe
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def require_timeframe(name: TimeframeName) -> Timeframe:
|
||||
# Возвращает Timeframe или явно сообщает о неподдерживаемом интервале.
|
||||
# Эту функцию следует использовать там, где отсутствие таймфрейма
|
||||
# является ошибкой конфигурации, а не обычным Runtime-состоянием.
|
||||
timeframe = get_timeframe(name)
|
||||
|
||||
if timeframe is None:
|
||||
raise ValueError(f"Unsupported timeframe: {name}")
|
||||
|
||||
return timeframe
|
||||
|
||||
|
||||
def is_supported_timeframe(name: TimeframeName) -> bool:
|
||||
# Проверяет, известен ли интервал архитектуре Market Intelligence.
|
||||
return get_timeframe(name) is not None
|
||||
|
||||
|
||||
def get_higher_timeframe(name: TimeframeName) -> Timeframe | None:
|
||||
# Возвращает старший таймфрейм для указанного интервала.
|
||||
# Если для интервала нет старшего уровня, возвращается None.
|
||||
normalized_name = str(name).strip().lower()
|
||||
higher_name = HIGHER_TIMEFRAME_BY_NAME.get(normalized_name)
|
||||
|
||||
if higher_name is None:
|
||||
return None
|
||||
|
||||
return get_timeframe(higher_name)
|
||||
|
||||
|
||||
def get_timeframes_by_role(
|
||||
role: TimeframeRole,
|
||||
) -> tuple[Timeframe, ...]:
|
||||
# Возвращает все интервалы с указанной архитектурной ролью.
|
||||
# Это пригодится Multi-Timeframe Engine без жёсткой привязки
|
||||
# к конкретным строковым значениям.
|
||||
return tuple(
|
||||
timeframe
|
||||
for timeframe in SUPPORTED_TIMEFRAMES
|
||||
if timeframe.role == role
|
||||
)
|
||||
|
||||
|
||||
def timeframe_to_payload(timeframe: Timeframe) -> dict[str, object]:
|
||||
# Преобразует описание таймфрейма в простой словарь.
|
||||
# Это нужно для диагностики, payload и будущих snapshot.
|
||||
return {
|
||||
"name": timeframe.name,
|
||||
"duration_minutes": timeframe.duration_minutes,
|
||||
"role": timeframe.role.value,
|
||||
"description": timeframe.description,
|
||||
}
|
||||
112
app/src/trading/market_intelligence/common/types.py
Normal file
112
app/src/trading/market_intelligence/common/types.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# app/src/trading/market_intelligence/common/types.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
from src.core.types import JsonDict, JsonList
|
||||
|
||||
|
||||
# Название торгового инструмента.
|
||||
# Например: BTC/USD, ETH/USD.
|
||||
SymbolName: TypeAlias = str
|
||||
|
||||
|
||||
# Название временного интервала.
|
||||
# Например: 1m, 5m, 15m, 1h.
|
||||
TimeframeName: TypeAlias = str
|
||||
|
||||
|
||||
# Название движка Market Intelligence.
|
||||
# Например: trend, momentum, volatility.
|
||||
EngineName: TypeAlias = str
|
||||
|
||||
|
||||
# Версия движка.
|
||||
# Нужна для журнала и диагностики, чтобы понимать,
|
||||
# какая версия логики рассчитала конкретный результат.
|
||||
EngineVersion: TypeAlias = str
|
||||
|
||||
|
||||
# Код причины.
|
||||
# Это короткое машинное имя причины, которое удобно хранить в журнале,
|
||||
# payload и внутренних проверках.
|
||||
ReasonCode: TypeAlias = str
|
||||
|
||||
|
||||
# Человекочитаемое описание причины.
|
||||
# Оно должно быть понятно без глубоких знаний трейдинга.
|
||||
ReasonText: TypeAlias = str
|
||||
|
||||
|
||||
# Оценка от 0 до 100.
|
||||
# 0 означает очень слабое качество или отсутствие признака.
|
||||
# 100 означает максимально сильное качество или признак.
|
||||
ScoreValue: TypeAlias = float
|
||||
|
||||
|
||||
# Уверенность от 0 до 1.
|
||||
# 0 означает, что движок не уверен в своём выводе.
|
||||
# 1 означает, что данных достаточно и вывод считается надёжным.
|
||||
ConfidenceValue: TypeAlias = float
|
||||
|
||||
|
||||
# Вероятность от 0 до 100.
|
||||
# Используется для оценки вероятности события.
|
||||
# Например, продолжения движения или изменения направления.
|
||||
ProbabilityValue: TypeAlias = float
|
||||
|
||||
|
||||
# Вес показателя при сборе общей оценки.
|
||||
# Чем больше вес, тем сильнее показатель влияет на итоговую оценку.
|
||||
WeightValue: TypeAlias = float
|
||||
|
||||
|
||||
# Возраст данных или сигнала в секундах.
|
||||
# Нужен, чтобы понимать, насколько свежий результат использует система.
|
||||
AgeSeconds: TypeAlias = float
|
||||
|
||||
|
||||
# Длительность выполнения в миллисекундах.
|
||||
# Нужна для диагностики производительности движков.
|
||||
DurationMs: TypeAlias = float
|
||||
|
||||
|
||||
# Метрики движка.
|
||||
# Здесь хранятся измеримые значения: наклон, расстояние, скорость,
|
||||
# качество движения, возраст сигнала и другие расчётные данные.
|
||||
MetricsDict: TypeAlias = JsonDict
|
||||
|
||||
|
||||
# Payload движка.
|
||||
# Это единый диагностический словарь для журнала, событий и отладки.
|
||||
PayloadDict: TypeAlias = JsonDict
|
||||
|
||||
|
||||
# Дополнительный контекст.
|
||||
# Используется для редких служебных данных, которые не стоит делать
|
||||
# отдельными полями базовой модели.
|
||||
ContextDict: TypeAlias = JsonDict
|
||||
|
||||
|
||||
# Сырые рыночные данные.
|
||||
# Формат может отличаться в зависимости от источника данных,
|
||||
# поэтому пока оставляем его универсальным.
|
||||
MarketData: TypeAlias = JsonDict
|
||||
|
||||
|
||||
# Результаты зависимых движков.
|
||||
# Ключ — имя движка, значение — его результат или диагностический снимок.
|
||||
# Пока используется Any, поскольку общий результат Engine
|
||||
# будет определён позже в models.py.
|
||||
DependencyResults: TypeAlias = dict[EngineName, Any]
|
||||
|
||||
|
||||
# Список диагностических сообщений.
|
||||
# Используется там, где один блок может вернуть несколько предупреждений.
|
||||
DiagnosticMessages: TypeAlias = JsonList
|
||||
|
||||
|
||||
# Универсальное значение для диагностических деталей.
|
||||
# Например: число, строка, список, словарь или None.
|
||||
DiagnosticValue: TypeAlias = Any
|
||||
258
app/src/trading/market_intelligence/common/validation.py
Normal file
258
app/src/trading/market_intelligence/common/validation.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# app/src/trading/market_intelligence/common/validation.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.trading.market_intelligence.common.constants import (
|
||||
FORBIDDEN_TRADING_FIELDS,
|
||||
MAX_ENGINE_DEPENDENCIES,
|
||||
MAX_ENGINE_METRICS,
|
||||
)
|
||||
from src.trading.market_intelligence.common.enums import CheckStatus, EngineStatus
|
||||
from src.trading.market_intelligence.common.models import (
|
||||
EngineContext,
|
||||
EngineResult,
|
||||
)
|
||||
from src.trading.market_intelligence.common.reasons import ReasonCode
|
||||
from src.trading.market_intelligence.common.types import (
|
||||
ContextDict,
|
||||
PayloadDict,
|
||||
ReasonText,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidationIssue:
|
||||
# Одна проблема, найденная во время проверки.
|
||||
# Это не ошибка Python, а понятное описание нарушения контракта.
|
||||
status: CheckStatus
|
||||
reason: ReasonCode
|
||||
message: ReasonText
|
||||
field_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValidationResult:
|
||||
# Итог проверки одного объекта.
|
||||
# Validation Layer не выбрасывает исключения, а возвращает результат,
|
||||
# который можно безопасно записать в журнал или диагностику.
|
||||
status: CheckStatus = CheckStatus.OK
|
||||
reason: ReasonCode = ReasonCode.VALIDATION_PASSED
|
||||
issues: tuple[ValidationIssue, ...] = ()
|
||||
details: ContextDict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_ok(self) -> bool:
|
||||
# Проверка считается успешной только если нет ошибок.
|
||||
return self.status == CheckStatus.OK
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return any(issue.status == CheckStatus.ERROR for issue in self.issues)
|
||||
|
||||
@property
|
||||
def has_warnings(self) -> bool:
|
||||
return any(issue.status == CheckStatus.WARNING for issue in self.issues)
|
||||
|
||||
|
||||
def _validation_result_from_issues(
|
||||
issues: tuple[ValidationIssue, ...],
|
||||
*,
|
||||
details: ContextDict | None = None,
|
||||
) -> ValidationResult:
|
||||
# Собираем общий статус из списка найденных проблем.
|
||||
# Ошибка важнее предупреждения, предупреждение важнее OK.
|
||||
if any(issue.status == CheckStatus.ERROR for issue in issues):
|
||||
return ValidationResult(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.VALIDATION_FAILED,
|
||||
issues=issues,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
if any(issue.status == CheckStatus.WARNING for issue in issues):
|
||||
return ValidationResult(
|
||||
status=CheckStatus.WARNING,
|
||||
reason=ReasonCode.VALIDATION_PASSED,
|
||||
issues=issues,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
return ValidationResult(
|
||||
status=CheckStatus.OK,
|
||||
reason=ReasonCode.VALIDATION_PASSED,
|
||||
issues=issues,
|
||||
details=details or {},
|
||||
)
|
||||
|
||||
|
||||
def validate_payload_has_no_trading_fields(
|
||||
payload: PayloadDict,
|
||||
) -> ValidationResult:
|
||||
# Market Intelligence не должен отдавать торговые команды.
|
||||
# Поэтому payload проверяется на поля, похожие на действия торговли.
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
for field_name in sorted(FORBIDDEN_TRADING_FIELDS):
|
||||
if field_name in payload:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.FORBIDDEN_TRADING_FIELD_FOUND,
|
||||
message=(
|
||||
"Payload содержит поле, запрещённое для "
|
||||
"аналитического слоя Market Intelligence."
|
||||
),
|
||||
field_name=field_name,
|
||||
)
|
||||
)
|
||||
|
||||
return _validation_result_from_issues(
|
||||
tuple(issues),
|
||||
details={"checked_fields": sorted(FORBIDDEN_TRADING_FIELDS)},
|
||||
)
|
||||
|
||||
|
||||
def validate_engine_context(context: EngineContext) -> ValidationResult:
|
||||
# Проверяем только базовый входной контракт Engine.
|
||||
# Глубокая проверка рыночных данных будет задачей отдельных Engine,
|
||||
# потому что разные движки могут требовать разные данные.
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
if not context.symbol:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.REQUIRED_FIELD_MISSING,
|
||||
message="В EngineContext не указан торговый инструмент.",
|
||||
field_name="symbol",
|
||||
)
|
||||
)
|
||||
|
||||
if not context.timeframe:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.REQUIRED_FIELD_MISSING,
|
||||
message="В EngineContext не указан таймфрейм анализа.",
|
||||
field_name="timeframe",
|
||||
)
|
||||
)
|
||||
|
||||
if not context.market_data:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.EMPTY_MARKET_DATA,
|
||||
message="В EngineContext отсутствуют рыночные данные.",
|
||||
field_name="market_data",
|
||||
)
|
||||
)
|
||||
|
||||
if len(context.dependency_results) > MAX_ENGINE_DEPENDENCIES:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.WARNING,
|
||||
reason=ReasonCode.ENGINE_DEPENDENCY_ERROR,
|
||||
message=(
|
||||
"Количество зависимостей Engine превышает "
|
||||
"архитектурный лимит Common Layer."
|
||||
),
|
||||
field_name="dependency_results",
|
||||
)
|
||||
)
|
||||
|
||||
return _validation_result_from_issues(
|
||||
tuple(issues),
|
||||
details={
|
||||
"symbol": context.symbol,
|
||||
"timeframe": context.timeframe,
|
||||
"dependency_count": len(context.dependency_results),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def validate_engine_result(result: EngineResult) -> ValidationResult:
|
||||
# Проверяем единый результат Engine.
|
||||
# Эта проверка не оценивает качество анализа рынка,
|
||||
# а только подтверждает соблюдение Runtime Contract.
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
if not result.engine_name:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.REQUIRED_FIELD_MISSING,
|
||||
message="В EngineResult не указано имя движка.",
|
||||
field_name="engine_name",
|
||||
)
|
||||
)
|
||||
|
||||
if not result.engine_version:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.REQUIRED_FIELD_MISSING,
|
||||
message="В EngineResult не указана версия движка.",
|
||||
field_name="engine_version",
|
||||
)
|
||||
)
|
||||
|
||||
if not result.symbol:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.REQUIRED_FIELD_MISSING,
|
||||
message="В EngineResult не указан торговый инструмент.",
|
||||
field_name="symbol",
|
||||
)
|
||||
)
|
||||
|
||||
if not result.timeframe:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.ERROR,
|
||||
reason=ReasonCode.REQUIRED_FIELD_MISSING,
|
||||
message="В EngineResult не указан таймфрейм анализа.",
|
||||
field_name="timeframe",
|
||||
)
|
||||
)
|
||||
|
||||
if result.status == EngineStatus.UNKNOWN:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.WARNING,
|
||||
reason=ReasonCode.VALIDATION_FAILED,
|
||||
message="EngineResult вернул неопределённый статус выполнения.",
|
||||
field_name="status",
|
||||
)
|
||||
)
|
||||
|
||||
if len(result.metrics) > MAX_ENGINE_METRICS:
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
status=CheckStatus.WARNING,
|
||||
reason=ReasonCode.VALIDATION_FAILED,
|
||||
message=(
|
||||
"Количество метрик Engine превышает архитектурный лимит. "
|
||||
"Возможно, движок выполняет слишком много задач."
|
||||
),
|
||||
field_name="metrics",
|
||||
)
|
||||
)
|
||||
|
||||
payload_validation = validate_payload_has_no_trading_fields(result.payload)
|
||||
issues.extend(payload_validation.issues)
|
||||
|
||||
return _validation_result_from_issues(
|
||||
tuple(issues),
|
||||
details={
|
||||
"engine_name": result.engine_name,
|
||||
"engine_version": result.engine_version,
|
||||
"symbol": result.symbol,
|
||||
"timeframe": result.timeframe,
|
||||
"status": result.status.value,
|
||||
"metrics_count": len(result.metrics),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
# app/src/trading/market_intelligence/coordinator/__init__.py
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# app/src/trading/market_intelligence/coordinator/exceptions.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class CoordinatorError(Exception):
|
||||
"""Базовая ошибка Coordinator Layer."""
|
||||
|
||||
|
||||
class InvalidCoordinatorResultError(CoordinatorError):
|
||||
"""Coordinator сформировал некорректный результат."""
|
||||
|
||||
|
||||
class CoordinatorValidationError(CoordinatorError):
|
||||
"""Ошибка проверки входных данных Coordinator."""
|
||||
|
||||
|
||||
class CoordinatorExecutionError(CoordinatorError):
|
||||
"""Ошибка выполнения Coordinator."""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user