build 039: complete Quotes Feed migration foundation

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View 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,
)

View 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

View 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)

View 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

View 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),
)

View 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

View 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)

View 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)

View File

@@ -0,0 +1,2 @@
# app/src/market_data/acquisition/feeds/status_feed.py

View 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)

View 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),
)

View File

@@ -0,0 +1,2 @@
# app/src/market_data/acquisition/handlers/status_handler.py

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/models/__init__.py

View 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

View 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

View 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,
)

View 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:
"""
Получить внутреннюю модель текущей котировки инструмента.
"""
...

View 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

View 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)

View 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

View 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."
)

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/validation/sequence.py

View 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 должно быть больше нуля."
)

View 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."""

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1,3 @@
"""Package marker."""
# app/src/telegram/handlers/__init__.py
"""Package marker."""

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -0,0 +1 @@
# app/src/trading/decision/__init__.py

View 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."""

View 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

View 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."""
...

View 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

View 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)

View 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."
)

View File

@@ -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)

View File

@@ -0,0 +1 @@
# app/src/trading/market_intelligence/__init__.py

View File

@@ -0,0 +1 @@
# app/src/trading/market_intelligence/common/__init__.py

View 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)

View 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",
}
)

View 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"

View 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,
}

View 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

View 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)

View 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"

View 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)

View 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,
}

View 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,
}

View 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

View 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),
},
)

View File

@@ -0,0 +1,2 @@
# app/src/trading/market_intelligence/coordinator/__init__.py

View File

@@ -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."""

View File

@@ -0,0 +1,21 @@
# app/src/trading/market_intelligence/coordinator/protocol.py
from __future__ import annotations
from typing import Protocol
from src.trading.market_intelligence.common.models import (
CoordinatorResult,
RuntimeResult,
)
class CoordinatorProtocol(Protocol):
"""Контракт Coordinator Layer."""
async def coordinate(
self,
runtime_result: RuntimeResult,
) -> CoordinatorResult:
"""Согласовать результаты Runtime и вернуть итоговый результат Coordinator."""
...

Some files were not shown because too many files have changed in this diff Show More