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

@@ -0,0 +1,71 @@
# app/scripts/get_ticker_24hr.py
from __future__ import annotations
import argparse
import json
import sys
from src.core.config import load_settings
from src.integrations.exchange.rest_client import ExchangeRestClient
def parse_args() -> argparse.Namespace:
settings = load_settings()
parser = argparse.ArgumentParser(
description="Получить реальный ответ Dzengi ticker/24hr.",
)
parser.add_argument(
"symbol",
nargs="?",
default=settings.default_symbol,
help=(
"Торговый символ. "
f"По умолчанию: {settings.default_symbol}"
),
)
return parser.parse_args()
def main() -> int:
args = parse_args()
symbol = str(args.symbol).strip()
if not symbol:
print(
"Торговый символ не должен быть пустым.",
file=sys.stderr,
)
return 2
try:
payload = ExchangeRestClient().get_json(
"/api/v1/ticker/24hr",
params={
"symbol": symbol,
},
)
except Exception as exc:
print(
f"Не удалось получить ticker/24hr для {symbol}: "
f"{type(exc).__name__}: {exc}",
file=sys.stderr,
)
return 1
print(
json.dumps(
payload,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

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

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.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient 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 from src.trading.journal.service import JournalService
@@ -297,6 +303,7 @@ class MarketDataRunner:
valid_payload_count = 0 valid_payload_count = 0
invalid_payload_count = 0 invalid_payload_count = 0
adapter = DzengiWebSocketQuoteAdapter()
async for payload in ExchangeWebSocketClient().stream_depth( async for payload in ExchangeWebSocketClient().stream_depth(
ws_symbol, ws_symbol,
@@ -306,20 +313,31 @@ class MarketDataRunner:
if current_symbol and current_symbol != symbol: if current_symbol and current_symbol != symbol:
break break
best_bid = cls._extract_best_price(payload, "bids") try:
best_ask = cls._extract_best_price(payload, "asks") quote = adapter.map_message(payload)
except MarketDataAcquisitionError:
if best_bid is None or best_ask is None:
invalid_payload_count += 1 invalid_payload_count += 1
if invalid_payload_count >= 5: if invalid_payload_count >= 5:
raise RuntimeError( 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 continue
invalid_payload_count = 0 invalid_payload_count = 0
best_bid = float(quote.bid_price)
best_ask = float(quote.ask_price)
if valid_payload_count == 0: if valid_payload_count == 0:
should_log_connected = ( should_log_connected = (
@@ -354,12 +372,8 @@ class MarketDataRunner:
valid_payload_count += 1 valid_payload_count += 1
MarketPriceCache.set_price( MarketPriceCache.set_quote(
symbol=cache_symbol, quote,
price=(best_bid + best_ask) / 2,
bid_price=best_bid,
ask_price=best_ask,
source=f"ws_depth:{context.runtime_key}",
runtime_key=context.runtime_key, 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.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient 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 from src.trading.journal.service import JournalService
@@ -145,6 +151,7 @@ async def start_market_stream() -> None:
symbol = validation.normalized_symbol symbol = validation.normalized_symbol
client = ExchangeWebSocketClient() client = ExchangeWebSocketClient()
adapter = DzengiWebSocketQuoteAdapter()
journal.log_info( journal.log_info(
"market_ws_started", "market_ws_started",
@@ -153,29 +160,16 @@ async def start_market_stream() -> None:
) )
async for message in client.stream_depth(symbol): async for message in client.stream_depth(symbol):
event = _extract_market_event(message) try:
quote = adapter.map_message(message)
if event is None: except MarketDataAcquisitionError:
continue continue
price = safe_float(event.get("price")) if quote.symbol.strip().upper() != symbol.strip().upper():
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:
continue continue
MarketPriceCache.set_price( MarketPriceCache.set_quote(
symbol=symbol, quote,
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",
runtime_key="default", runtime_key="default",
) )

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import time import time
import socket import socket
from datetime import datetime from datetime import datetime, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from src.core.config import load_settings 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 ( from src.integrations.exchange.mock_data import (
mock_balance_summary, mock_balance_summary,
mock_exchange_health, mock_exchange_health,
mock_ticker_price, mock_quote,
) )
from src.integrations.exchange.models import ( from src.integrations.exchange.models import (
BalanceSummary, BalanceSummary,
ExchangeHealth, ExchangeHealth,
ExchangeSymbol,
ExecutionPriceSnapshot, ExecutionPriceSnapshot,
Kline, Kline,
KlineBatch, KlineBatch,
PrivateAuthHealth, PrivateAuthHealth,
SymbolValidationResult, SymbolValidationResult,
TickerPrice,
TimeSyncStatus, TimeSyncStatus,
TradingFee, TradingFee,
) )
@@ -43,12 +41,46 @@ from src.integrations.exchange.status import (
build_mock_exchange_status, build_mock_exchange_status,
classify_exchange_error, 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 from src.trading.journal.service import JournalService
_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
_QUOTE_SOURCE_NAME = "dzengi"
class ExchangeService: class ExchangeService:
_exchange_symbols_cache: list[ExchangeSymbol] | None = None _instrument_store: InstrumentStoreProtocol = InMemoryInstrumentStore()
_execution_cache_max_age_seconds = 2.0 _execution_cache_max_age_seconds = 2.0
_default_runtime_key = "auto" _default_runtime_key = "auto"
@@ -108,17 +140,28 @@ class ExchangeService:
return status return status
try: try:
snapshot = self.get_fresh_market_snapshot(validation.normalized_symbol) quote = self._get_fresh_quote(
validation.normalized_symbol,
)
except Exception: except Exception:
return status 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: if age_seconds is not None and age_seconds > 60:
return build_market_stale_status( return build_market_stale_status(
symbol=validation.normalized_symbol, symbol=validation.normalized_symbol,
age_seconds=age_seconds, age_seconds=age_seconds,
updated_at=str(snapshot.get("updated_at") or ""), updated_at=self._format_exchange_time(
exchange_timestamp_ms
),
) )
return status return status
@@ -668,7 +711,9 @@ class ExchangeService:
) )
try: 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: except ExchangeError as exc:
return ExchangeHealth( return ExchangeHealth(
ok=False, ok=False,
@@ -679,7 +724,10 @@ class ExchangeService:
return ExchangeHealth( return ExchangeHealth(
ok=True, ok=True,
mode="real_public_api", 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 и валидность ключей аккаунта. # Проверить доступность приватного API и валидность ключей аккаунта.
@@ -722,149 +770,41 @@ class ExchangeService:
message=f"Private API OK. Балансов получено: {len(balances)}", message=f"Private API OK. Балансов получено: {len(balances)}",
) )
# Обновить price cache и вернуть TickerPrice. # Получить каноническую текущую котировку из Store или REST Quotes Feed.
def refresh_price_cache( def get_quote(
self, self,
symbol: str | None = None, symbol: str | None = None,
*, *,
runtime_key: str | None = None, runtime_key: str | None = None,
) -> TickerPrice: ) -> Quote:
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:
symbol_to_use = symbol or self.settings.default_symbol symbol_to_use = symbol or self.settings.default_symbol
normalized_runtime_key = self._runtime_key(runtime_key) normalized_runtime_key = self._runtime_key(runtime_key)
if not self.settings.exchange_enabled: 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) validation = self.validate_symbol(symbol_to_use)
if not validation.is_valid: if not validation.is_valid:
raise ExchangeError(validation.message) raise ExchangeError(validation.message)
cached_price = MarketPriceCache.get_price( cached_quote = MarketPriceCache.get_quote(
validation.normalized_symbol, validation.normalized_symbol,
runtime_key=normalized_runtime_key, runtime_key=normalized_runtime_key,
) )
if cached_price is not None: if (
return TickerPrice( cached_quote is not None
symbol=cached_price.symbol, and self._quote_age_seconds(cached_quote)
price=cached_price.price, <= self._execution_cache_max_age_seconds
source=cached_price.source, ):
updated_at=cached_price.updated_at, return cached_quote
)
return self._get_real_price(validation.normalized_symbol) quote = self._get_fresh_quote(validation.normalized_symbol)
MarketPriceCache.set_quote(
# Получить market snapshot: last/bid/ask/source/age/freshness. quote,
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,
runtime_key=normalized_runtime_key, runtime_key=normalized_runtime_key,
) )
return quote
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
# Получить snapshot, пригодный для execution layer. # Получить snapshot, пригодный для execution layer.
def get_execution_snapshot( def get_execution_snapshot(
@@ -877,15 +817,10 @@ class ExchangeService:
normalized_runtime_key = self._runtime_key(runtime_key) normalized_runtime_key = self._runtime_key(runtime_key)
if not self.settings.exchange_enabled: if not self.settings.exchange_enabled:
ticker = mock_ticker_price(symbol_to_use) quote = mock_quote(symbol_to_use)
return ExecutionPriceSnapshot( return self._execution_snapshot_from_quote(
symbol=ticker.symbol, quote,
last_price=ticker.price, source=quote.source,
bid_price=ticker.price,
ask_price=ticker.price,
updated_at=ticker.updated_at,
source=ticker.source,
is_fresh=True,
age_seconds=0.0, age_seconds=0.0,
) )
@@ -893,125 +828,96 @@ class ExchangeService:
if not validation.is_valid: if not validation.is_valid:
raise ExchangeError(validation.message) raise ExchangeError(validation.message)
cached_price = MarketPriceCache.get_price( quote = MarketPriceCache.get_quote(
validation.normalized_symbol, validation.normalized_symbol,
runtime_key=normalized_runtime_key, runtime_key=normalized_runtime_key,
) )
if cached_price is not None: if quote is not None:
age = cached_price.age_seconds() age_seconds = self._quote_age_seconds(quote)
if ( if age_seconds <= self._execution_cache_max_age_seconds:
age <= self._execution_cache_max_age_seconds return self._execution_snapshot_from_quote(
and cached_price.has_bid_ask() quote,
): source=f"{quote.source}:fresh_cache",
bid_price = safe_float(cached_price.bid_price) age_seconds=round(age_seconds, 3),
ask_price = safe_float(cached_price.ask_price) )
last_price = safe_float(cached_price.price)
if ( quote = self._get_fresh_quote(
last_price is not None validation.normalized_symbol
and bid_price is not None )
and ask_price is not None MarketPriceCache.set_quote(
): quote,
return ExecutionPriceSnapshot( runtime_key=normalized_runtime_key,
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),
)
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")) def _execution_snapshot_from_quote(
bid_price = safe_float(snapshot.get("bid_price")) self,
ask_price = safe_float(snapshot.get("ask_price")) 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: if timestamp.tzinfo is None:
raise ExchangeError("Market snapshot contains invalid execution prices.") 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( return ExecutionPriceSnapshot(
symbol=str(snapshot["symbol"]), symbol=quote.symbol,
last_price=last_price, last_price=float(quote.last_price),
bid_price=bid_price, bid_price=float(quote.bid_price),
ask_price=ask_price, ask_price=float(quote.ask_price),
updated_at=str(snapshot["updated_at"]), updated_at=updated_at,
source="rest_fallback", source=source,
is_fresh=bool(snapshot.get("is_fresh")), is_fresh=(
age_seconds
<= self._execution_cache_max_age_seconds
),
age_seconds=age_seconds, age_seconds=age_seconds,
) )
# Получить свежий snapshot напрямую из REST API. def _quote_age_seconds(self, quote: Quote) -> float:
def get_fresh_market_snapshot(self, symbol: str | None = None) -> dict[str, object]: received_at = quote.received_at
symbol_to_use = symbol or self.settings.default_symbol if received_at.tzinfo is None:
received_at = received_at.replace(tzinfo=timezone.utc)
if not self.settings.exchange_enabled: return max(
ticker = mock_ticker_price(symbol_to_use) 0.0,
return { (
"symbol": ticker.symbol, datetime.now(timezone.utc)
"last_price": ticker.price, - received_at.astimezone(timezone.utc)
"bid_price": ticker.price, ).total_seconds(),
"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()
def _get_fresh_quote(self, normalized_symbol: str) -> Quote:
try: try:
payload = client.get_json( return self._load_quote_via_acquisition(normalized_symbol)
"/api/v1/ticker/24hr",
params={"symbol": validation.normalized_symbol},
)
except Exception as exc: except Exception as exc:
self._log_exchange_error( self._log_exchange_error(
endpoint="ticker/24hr", endpoint="ticker/24hr",
exc=exc, exc=exc,
symbol=validation.normalized_symbol, symbol=normalized_symbol,
) )
raise ExchangeError(str(exc)) from exc 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-балансы аккаунта. # Получить live-балансы аккаунта.
def get_balance_summary(self) -> list[BalanceSummary]: def get_balance_summary(self) -> list[BalanceSummary]:
if not self.settings.exchange_enabled: if not self.settings.exchange_enabled:
@@ -1056,20 +962,22 @@ class ExchangeService:
return balances return balances
# Получить и распарсить список инструментов биржи. # Получить канонический справочник инструментов через Instrument Store.
def get_exchange_symbols(self) -> list[ExchangeSymbol]: def get_instruments(self) -> tuple[Instrument, ...]:
if not self.settings.exchange_enabled: 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: instruments = instrument_store.get(
return cached_symbols _INSTRUMENT_REFERENCE_SOURCE_NAME
)
client = ExchangeRestClient() if instruments is not None:
return instruments
try: try:
payload = client.get_json("/api/v1/exchangeInfo") instruments = self._load_instruments_via_acquisition()
except Exception as exc: except Exception as exc:
self._log_exchange_error( self._log_exchange_error(
endpoint="exchangeInfo", endpoint="exchangeInfo",
@@ -1077,98 +985,65 @@ class ExchangeService:
) )
raise ExchangeError(str(exc)) from exc raise ExchangeError(str(exc)) from exc
symbols_raw = self._extract_exchange_symbols_raw(payload) instrument_store.set(
items: list[ExchangeSymbol] = [] _INSTRUMENT_REFERENCE_SOURCE_NAME,
instruments,
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,
) )
raise exc
# Преобразовать один сырой symbol item в ExchangeSymbol. return instruments
def _parse_exchange_symbol(
# Собрать Quotes acquisition pipeline и вернуть каноническую модель Quote.
def _load_quote_via_acquisition(
self, self,
item: dict[object, object], symbol: str,
) -> ExchangeSymbol: ) -> Quote:
filters = item.get("filters") source = DzengiQuoteDocumentSource()
handler = DzengiQuoteDocumentHandler()
tick_size = safe_float(item.get("tickSize")) feed = QuotesFeed(
if tick_size is None: source=source,
tick_size = self._extract_filter_value( handler=handler,
filters, )
filter_names=["PRICE_FILTER"],
keys=["tickSize"],
)
step_size = safe_float(item.get("stepSize")) registry = QuoteFeedRegistry()
if step_size is None: registry.register(
step_size = self._extract_filter_value( _QUOTE_SOURCE_NAME,
filters, feed,
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"], )
keys=["stepSize"],
)
min_qty = safe_float(item.get("minQty")) acquisition_service = QuoteAcquisitionService(
if min_qty is None: registry=registry,
min_qty = self._extract_filter_value( )
filters,
filter_names=["LOT_SIZE", "MARKET_LOT_SIZE"],
keys=["minQty"],
)
min_notional = safe_float(item.get("minNotional")) return acquisition_service.load_quote(
if min_notional is None: _QUOTE_SOURCE_NAME,
min_notional = self._extract_filter_value( symbol,
filters, )
filter_names=["MIN_NOTIONAL", "NOTIONAL"],
keys=["minNotional", "notional"],
)
return ExchangeSymbol( # Собрать acquisition pipeline и вернуть канонические модели Instrument.
symbol=self._safe_str(item.get("symbol")), def _load_instruments_via_acquisition(
name=self._safe_str(item.get("name")), self,
status=self._parse_exchange_symbol_status(item), ) -> tuple[Instrument, ...]:
base_asset=self._safe_str(item.get("baseAsset")), source = DzengiInstrumentDocumentSource()
quote_asset=self._safe_str(item.get("quoteAsset")), handler = DzengiInstrumentDocumentHandler()
market_modes=self._parse_market_modes(item.get("marketModes")),
market_type=self._safe_str(item.get("marketType"), "unknown"), feed = InstrumentFeed(
tick_size=tick_size, source=source,
step_size=step_size, handler=handler,
min_qty=min_qty, )
min_notional=min_notional,
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() 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: def validate_symbol(self, raw_symbol: str) -> SymbolValidationResult:
requested = normalize_symbol(raw_symbol) requested = normalize_symbol(raw_symbol)
@@ -1285,43 +1075,40 @@ class ExchangeService:
symbol_info=None, symbol_info=None,
) )
symbols = self.get_exchange_symbols() instruments = self.get_instruments()
candidates = symbol_candidates(requested)
for candidate in candidates: matched_index = resolve_symbol_index(
for symbol_info in symbols: requested,
if normalize_symbol(symbol_info.symbol) == candidate: [
return SymbolValidationResult( instrument.symbol
requested_symbol=requested, for instrument in instruments
normalized_symbol=normalize_symbol(symbol_info.symbol), ],
is_valid=True, )
message="Символ найден в exchangeInfo.",
symbol_info=symbol_info, 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( return SymbolValidationResult(
requested_symbol=requested, requested_symbol=requested,
normalized_symbol=requested, normalized_symbol=requested,
is_valid=False, is_valid=False,
message=f"Символ '{requested}' не найден в exchangeInfo.", message=(
f"Символ '{requested}' "
"не найден в exchangeInfo."
),
symbol_info=None, 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: def get_exchange_server_time_ms(self) -> int:
payload = ExchangeRestClient().get_json("/api/v1/time") payload = ExchangeRestClient().get_json("/api/v1/time")

View File

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

View File

@@ -2,24 +2,13 @@
from __future__ import annotations from __future__ import annotations
from src.market_data.acquisition.symbols import (
def normalize_symbol(raw_symbol: str) -> str: normalize_symbol,
return (raw_symbol or "").strip().upper() symbol_candidates,
)
def symbol_candidates(raw_symbol: str) -> list[str]: __all__ = [
value = normalize_symbol(raw_symbol) "normalize_symbol",
if not value: "symbol_candidates",
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

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 from __future__ import annotations
import json 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: except Exception as exc:
return False, f"PostgreSQL error: {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 aiogram.utils.keyboard import InlineKeyboardBuilder
from src.integrations.exchange.service import ExchangeService 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.integrations.exchange.runtime_ui import build_runtime_exchange_alert_lines
from src.telegram.ui.common import mode_line from src.telegram.ui.common import mode_line
from src.trading.auto.service import AutoTradeService 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: 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") bid_price = _price_from_quote(quote, "bid_price")
ask_price = _price_from_snapshot(snapshot, "ask_price") ask_price = _price_from_quote(quote, "ask_price")
side = "Long" if signal == "BUY" else "Short" side = "Long" if signal == "BUY" else "Short"
side_icon = _signal_icon(signal) side_icon = _signal_icon(signal)
@@ -85,28 +86,28 @@ def _build_signal_notification_text(state, signal: str) -> str:
return "\n".join(lines) return "\n".join(lines)
def _price_from_snapshot( def _price_from_quote(
snapshot: dict[str, object] | None, quote: Quote | None,
key: str, key: str,
) -> float | None: ) -> float | None:
if snapshot is None: if quote is None:
return None return None
return safe_float(snapshot.get(key)) return safe_float(getattr(quote, key, None))
def _position_current_price(state) -> float | 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() side = str(getattr(state, "position_side", "") or "").upper()
if side == "LONG": if side == "LONG":
price = snapshot.get("bid_price") or snapshot.get("last_price") price = quote.bid_price or quote.last_price
elif side == "SHORT": elif side == "SHORT":
price = snapshot.get("ask_price") or snapshot.get("last_price") price = quote.ask_price or quote.last_price
else: else:
price = snapshot.get("last_price") price = quote.last_price
parsed = safe_float(price) parsed = safe_float(price)
if parsed is not None: 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)}" 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: if not symbol:
return None return None
try: try:
return ExchangeService().get_market_snapshot(symbol, runtime_key="auto") return ExchangeService().get_quote(
symbol,
runtime_key="auto",
)
except Exception: except Exception:
return None return None
@@ -907,10 +911,10 @@ def _commission_lines_for_position(
def _current_price(symbol: str | None) -> float | None: def _current_price(symbol: str | None) -> float | None:
snapshot = _market_snapshot(symbol) quote = _market_quote(symbol)
if snapshot is not None: if quote is not None:
price = snapshot.get("last_price") price = quote.last_price
if price is not None: if price is not None:
try: try:
parsed = safe_float(price) parsed = safe_float(price)
@@ -922,25 +926,25 @@ def _current_price(symbol: str | None) -> float | None:
return None return None
try: try:
return float(ExchangeService().get_price(symbol).price) return float(ExchangeService().get_quote(symbol).last_price)
except Exception: except Exception:
return None return None
def _signal_entry_price(state) -> float | 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) return _current_price(state.symbol)
signal = (state.last_signal or "HOLD").upper() signal = (state.last_signal or "HOLD").upper()
if signal == "BUY": if signal == "BUY":
price = snapshot.get("ask_price") price = quote.ask_price
elif signal == "SELL": elif signal == "SELL":
price = snapshot.get("bid_price") price = quote.bid_price
else: else:
price = snapshot.get("last_price") price = quote.last_price
if price is None: if price is None:
return None return None

View File

@@ -3,10 +3,15 @@
from __future__ import annotations from __future__ import annotations
import time import time
from datetime import datetime, timezone
from decimal import Decimal
from zoneinfo import ZoneInfo
from aiogram.types import InlineKeyboardMarkup from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder 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.integrations.exchange.service import ExchangeService
from src.trading.debug.service import DebugTradeService from src.trading.debug.service import DebugTradeService
@@ -113,6 +118,23 @@ def _format_updated_at(value: object) -> str:
if not value: if not value:
return "" 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) text = str(value)
if " " in text: if " " in text:
@@ -121,6 +143,23 @@ def _format_updated_at(value: object) -> str:
return text 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]: def _market_snapshot_lines(symbol: str | None) -> list[str]:
if not symbol: if not symbol:
return [ return [
@@ -141,7 +180,7 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
error = None error = None
try: try:
market = ExchangeService().get_market_snapshot( market = ExchangeService().get_quote(
symbol, symbol,
runtime_key="debug_auto", runtime_key="debug_auto",
) )
@@ -167,11 +206,11 @@ def _market_snapshot_lines(symbol: str | None) -> list[str]:
f"Error · {error or 'unknown'}", f"Error · {error or 'unknown'}",
] ]
last_price = market.get("last_price") if market else getattr(execution, "last_price", None) last_price = market.last_price if market else getattr(execution, "last_price", None)
bid_price = market.get("bid_price") if market else getattr(execution, "bid_price", None) bid_price = market.bid_price if market else getattr(execution, "bid_price", None)
ask_price = market.get("ask_price") if market else getattr(execution, "ask_price", None) ask_price = market.ask_price if market else getattr(execution, "ask_price", None)
market_source = market.get("source") if market else "" market_source = market.source if market else ""
market_age = market.get("age_seconds") if market else None market_age = _quote_age_seconds(market) if market else None
execution_source = getattr(execution, "source", "") if execution else "" execution_source = getattr(execution, "source", "") if execution else ""
execution_age = getattr(execution, "age_seconds", None) if execution else None 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"Ask · {_format_usd_or_dash(ask_price)}",
f"Source · {market_source or ''}", f"Source · {market_source or ''}",
f"Quote age · {_format_age(market_age)}", 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>", "<b>Execution</b>",
f"Source · {execution_source or ''}", 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(".") 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: if value is None:
return "" return ""
@@ -286,21 +327,25 @@ def _format_money_compact(value: float | int | None) -> str:
return f"{number:,.2f}".replace(",", " ").rstrip("0").rstrip(".") 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: if value is None:
return "" return ""
return f"$ {_format_money_compact(value)}" 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: if value is None:
return "off" return "Выкл."
return f"$ {_format_money_compact(value)}" 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: if value is None:
return "" return ""
@@ -315,7 +360,7 @@ def _format_signed_usd(value: float | int | None) -> str:
return "$ 0" return "$ 0"
def _format_age(value: object) -> str: def _format_age(value: NumericLike | None) -> str:
if value is None: if value is None:
return "" 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 __future__ import annotations
from src.integrations.exchange.exceptions import ExchangeError 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.integrations.exchange.service import ExchangeService
from src.market_data.acquisition.models.instrument import Instrument
FIAT_CURRENCIES = {"USD", "USDT", "EUR", "RUB", "BYN"} 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: 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: 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: def format_amount(currency: str, value: float) -> str:
if is_fiat_currency(currency): if is_fiat_currency(currency):
return f"{value:,.2f}".replace(",", " ") return f"{value:,.2f}".replace(",", " ")
return f"{value:,.8f}".replace(",", " ") return f"{value:,.8f}".replace(",", " ")
@@ -52,7 +57,9 @@ def format_usd_amount(value: float) -> str:
return f"{value:,.2f}".replace(",", " ") 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: if value is None:
return "" return ""
@@ -62,7 +69,9 @@ def format_usd_price(value: float | int | str | None) -> str:
return "" 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: if value is None:
return "" return ""
@@ -87,7 +96,10 @@ def render_currency_line(
show_code: bool = True, show_code: bool = True,
) -> str: ) -> str:
icon = get_currency_icon(currency) icon = get_currency_icon(currency)
amount = format_amount(currency, value) amount = format_amount(
currency,
value,
)
if show_code: if show_code:
return f"{icon} {currency.upper()} · {amount}" return f"{icon} {currency.upper()} · {amount}"
@@ -100,61 +112,79 @@ def balance_total(item: BalanceSummary) -> float:
def is_zero_balance(item: BalanceSummary) -> bool: 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: def _quote_priority(quote_asset: str) -> int:
value = (quote_asset or "").upper() value = (quote_asset or "").upper()
if value == "USD": if value == "USD":
return 3 return 3
if value == "USDT": if value == "USDT":
return 2 return 2
return 0 return 0
def _status_priority(status: str) -> int: def _status_priority(status: str) -> int:
value = (status or "").upper() value = (status or "").upper()
if value == "TRADING": if value == "TRADING":
return 2 return 2
if value in {"HALT", "BREAK"}: if value in {"HALT", "BREAK"}:
return 0 return 0
return 1 return 1
def _market_type_priority(market_type: str) -> int: def _market_type_priority(market_type: str) -> int:
value = (market_type or "").upper() value = (market_type or "").upper()
if value == "SPOT": if value == "SPOT":
return 3 return 3
if value == "LEVERAGE": if value == "LEVERAGE":
return 2 return 2
return 1 return 1
def _symbol_priority(symbol_info: ExchangeSymbol) -> tuple[int, int, int, str]: def _instrument_priority(
instrument: Instrument,
) -> tuple[int, int, int, str]:
return ( return (
_quote_priority(symbol_info.quote_asset), _quote_priority(instrument.quote_asset),
_status_priority(symbol_info.status), _status_priority(instrument.status),
_market_type_priority(symbol_info.market_type), _market_type_priority(instrument.market_type),
symbol_info.symbol.upper(), instrument.symbol.upper(),
) )
def _resolve_asset_quote_symbol( def _resolve_asset_quote_instrument(
exchange_service: ExchangeService, exchange_service: ExchangeService,
asset: str, asset: str,
) -> ExchangeSymbol | None: ) -> Instrument | None:
asset_upper = asset.upper() asset_upper = asset.upper()
try: try:
symbols = exchange_service.get_exchange_symbols() instruments = exchange_service.get_instruments()
except ExchangeError: except ExchangeError:
return None return None
candidates: list[ExchangeSymbol] = [] candidates: list[Instrument] = []
for symbol_info in symbols: for instrument in instruments:
base_asset = (symbol_info.base_asset or "").upper() base_asset = (
quote_asset = (symbol_info.quote_asset or "").upper() instrument.base_asset or ""
).upper()
quote_asset = (
instrument.quote_asset or ""
).upper()
if base_asset != asset_upper: if base_asset != asset_upper:
continue continue
@@ -162,12 +192,16 @@ def _resolve_asset_quote_symbol(
if quote_asset not in {"USD", "USDT"}: if quote_asset not in {"USD", "USDT"}:
continue continue
candidates.append(symbol_info) candidates.append(instrument)
if not candidates: if not candidates:
return None return None
candidates.sort(key=_symbol_priority, reverse=True) candidates.sort(
key=_instrument_priority,
reverse=True,
)
return candidates[0] return candidates[0]
@@ -184,18 +218,26 @@ def get_asset_usd_rate(
if asset in price_cache: if asset in price_cache:
return price_cache[asset] return price_cache[asset]
symbol_info = _resolve_asset_quote_symbol(exchange_service, asset) instrument = _resolve_asset_quote_instrument(
if symbol_info is None: exchange_service,
asset,
)
if instrument is None:
price_cache[asset] = None price_cache[asset] = None
return None return None
try: try:
ticker = exchange_service.get_price(symbol_info.symbol) quote = exchange_service.get_quote(
rate = float(ticker.price) instrument.symbol
)
rate = float(quote.last_price)
# Пока считаем USDT ~= USD # Пока считаем USDT ~= USD.
price_cache[asset] = rate price_cache[asset] = rate
return rate return rate
except ExchangeError: except ExchangeError:
price_cache[asset] = None price_cache[asset] = None
return None return None
@@ -207,10 +249,16 @@ def estimate_balance_usd(
price_cache: dict[str, float | None], price_cache: dict[str, float | None],
) -> float | None: ) -> float | None:
total = balance_total(item) total = balance_total(item)
if total <= 0: if total <= 0:
return None 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: if rate is None:
return None return None

View File

@@ -6,6 +6,7 @@ import time
from src.core.numbers import safe_float from src.core.numbers import safe_float
from src.core.types import NumericLike from src.core.types import NumericLike
from src.integrations.exchange.models import ExecutionPriceSnapshot
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import ( from src.integrations.exchange.status import (
ExchangeRuntimeStatus, ExchangeRuntimeStatus,
@@ -253,34 +254,20 @@ class AutoExecutionQualityMixin:
return return
try: try:
snapshot = ExchangeService().get_market_snapshot( snapshot = ExchangeService().get_execution_snapshot(
state.symbol, state.symbol,
runtime_key="auto", 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: except Exception as exc:
fallback_price = None fallback_price = None
try: try:
fallback_price = safe_float( fallback_price = safe_float(
ExchangeService().get_price( ExchangeService().get_quote(
state.symbol, state.symbol,
runtime_key="auto", runtime_key="auto",
).price ).last_price
) )
except Exception: except Exception:
pass pass
@@ -319,12 +306,12 @@ class AutoExecutionQualityMixin:
) )
return return
bid_price = safe_float(snapshot.get("bid_price")) bid_price = safe_float(snapshot.bid_price)
ask_price = safe_float(snapshot.get("ask_price")) ask_price = safe_float(snapshot.ask_price)
last_price = safe_float(snapshot.get("last_price")) last_price = safe_float(snapshot.last_price)
age_seconds = safe_float(snapshot.get("age_seconds")) age_seconds = safe_float(snapshot.age_seconds)
is_fresh = bool(snapshot.get("is_fresh", False)) is_fresh = snapshot.is_fresh
source = str(snapshot.get("source") or "") source = snapshot.source
self._sync_execution_pricing_state( self._sync_execution_pricing_state(
state, state,
@@ -432,15 +419,15 @@ class AutoExecutionQualityMixin:
def _sync_execution_pricing_state( def _sync_execution_pricing_state(
self, self,
state: AutoTradeState, state: AutoTradeState,
snapshot: dict[str, object], snapshot: ExecutionPriceSnapshot,
) -> None: ) -> 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_price_age_seconds = age_seconds
state.execution_bid_price = safe_float(snapshot.get("bid_price")) state.execution_bid_price = safe_float(snapshot.bid_price)
state.execution_ask_price = safe_float(snapshot.get("ask_price")) state.execution_ask_price = safe_float(snapshot.ask_price)
state.execution_last_price = safe_float(snapshot.get("last_price")) state.execution_last_price = safe_float(snapshot.last_price)
if age_seconds is None: if age_seconds is None:
state.execution_price_freshness = "UNKNOWN" 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.numbers import safe_float
from src.core.types import JsonDict, NumericLike from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.service import ExchangeService 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 import AutoTradeState
from src.trading.auto.state_reset import ( from src.trading.auto.state_reset import (
reset_after_market_runtime_expired, reset_after_market_runtime_expired,
@@ -716,7 +717,7 @@ class AutoSignalRuntimeMixin:
self, self,
*, *,
state: AutoTradeState, state: AutoTradeState,
snapshot: JsonDict, quote: Quote | None,
signal: str, signal: str,
signal_intent: str, signal_intent: str,
confidence: float, confidence: float,
@@ -787,9 +788,9 @@ class AutoSignalRuntimeMixin:
"snapshot_age_seconds": state.snapshot_age_seconds, "snapshot_age_seconds": state.snapshot_age_seconds,
# ---------- Live Snapshot ---------- # ---------- Live Snapshot ----------
"bid_price": snapshot.get("bid_price"), "bid_price": safe_float(quote.bid_price) if quote is not None else None,
"ask_price": snapshot.get("ask_price"), "ask_price": safe_float(quote.ask_price) if quote is not None else None,
"last_price": snapshot.get("last_price"), "last_price": safe_float(quote.last_price) if quote is not None else None,
# ---------- Market Score ---------- # ---------- Market Score ----------
"market_score": state.market_score, "market_score": state.market_score,
@@ -875,12 +876,12 @@ class AutoSignalRuntimeMixin:
return return
try: try:
snapshot = ExchangeService().get_market_snapshot( quote = ExchangeService().get_quote(
state.symbol, state.symbol,
runtime_key="auto", runtime_key="auto",
) )
except Exception: except Exception:
snapshot = {} quote = None
try: try:
JournalService().log_ui_info( JournalService().log_ui_info(
@@ -892,7 +893,7 @@ class AutoSignalRuntimeMixin:
action="signal_ready", action="signal_ready",
payload=self._build_ready_signal_payload( payload=self._build_ready_signal_payload(
state=state, state=state,
snapshot=snapshot, quote=quote,
signal=normalized_signal, signal=normalized_signal,
signal_intent=signal_intent, signal_intent=signal_intent,
confidence=confidence, confidence=confidence,

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import math import math
from datetime import datetime from datetime import datetime
from src.core.types import NumericLike
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.trading.debug.state import DebugPositionState, DebugTradeState from src.trading.debug.state import DebugPositionState, DebugTradeState
from src.trading.execution.models import ExecutionDecision from src.trading.execution.models import ExecutionDecision
@@ -389,49 +390,88 @@ class DebugExecutionEngine:
return self._market_last_price(state.symbol) return self._market_last_price(state.symbol)
def _entry_price_for_side(self, symbol: str, side: str) -> float: 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": 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": 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: 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": 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": 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: def _market_last_price(self, symbol: str) -> float:
snapshot = ExchangeService().get_fresh_market_snapshot(symbol) snapshot = ExchangeService().get_execution_snapshot(
return self._snapshot_price(snapshot, "last_price") symbol,
runtime_key="debug_auto",
)
return self._execution_price(
snapshot.last_price,
price_name="last_price",
)
def _snapshot_price( def _execution_price(
self, self,
snapshot: dict[str, object], raw_price: NumericLike | None,
primary_key: str, fallback_price: NumericLike | None = None,
fallback_key: str | None = None, *,
price_name: str,
) -> float: ) -> float:
raw_price = snapshot.get(primary_key) value = raw_price
if raw_price is None and fallback_key is not None: if value is None:
raw_price = snapshot.get(fallback_key) value = fallback_price
if raw_price is None: if value is None:
raise ValueError(f"Market snapshot price '{primary_key}' is missing.") raise ValueError(
f"Execution price '{price_name}' is missing."
)
price = float(raw_price) price = float(value)
if price <= 0: 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 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: try:
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
snapshot = ExchangeService().get_market_snapshot( quote = ExchangeService().get_quote(
state.symbol, state.symbol,
runtime_key="auto", runtime_key="auto",
) )
side = str(state.position_side or "").upper() side = str(state.position_side or "").upper()
price = snapshot.get("last_price") price = quote.last_price
if side == "LONG": if side == "LONG":
price = snapshot.get("bid_price") or price price = quote.bid_price or price
elif side == "SHORT": elif side == "SHORT":
price = snapshot.get("ask_price") or price price = quote.ask_price or price
return safe_float(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."""

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