Compare commits
5 Commits
a996f2f797
...
a6325cf4cf
| Author | SHA1 | Date | |
|---|---|---|---|
| a6325cf4cf | |||
| 225f07bc4b | |||
| b2f6377143 | |||
| 9df9c0f5e0 | |||
| 7b62873832 |
@@ -1,69 +0,0 @@
|
|||||||
# app/src/integrations/exchange/market_cache.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.storage.quote_store import InMemoryQuoteStore, QuoteStoreProtocol
|
|
||||||
|
|
||||||
|
|
||||||
_MARKET_PRICE_CACHE_SOURCE_NAME = "legacy-market-price-cache"
|
|
||||||
|
|
||||||
|
|
||||||
class MarketPriceCache:
|
|
||||||
# Временный compatibility facade над каноническим Quote Store.
|
|
||||||
_store: QuoteStoreProtocol = InMemoryQuoteStore()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def set_quote(
|
|
||||||
cls,
|
|
||||||
quote: Quote,
|
|
||||||
*,
|
|
||||||
runtime_key: str = "default",
|
|
||||||
) -> None:
|
|
||||||
cls._store.set(
|
|
||||||
_MARKET_PRICE_CACHE_SOURCE_NAME,
|
|
||||||
quote,
|
|
||||||
runtime_key=cls._normalize_runtime_key(runtime_key),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_quote(
|
|
||||||
cls,
|
|
||||||
symbol: str,
|
|
||||||
*,
|
|
||||||
runtime_key: str = "default",
|
|
||||||
) -> Quote | None:
|
|
||||||
return cls._store.get(
|
|
||||||
_MARKET_PRICE_CACHE_SOURCE_NAME,
|
|
||||||
cls._normalize_symbol(symbol),
|
|
||||||
runtime_key=cls._normalize_runtime_key(runtime_key),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def clear(
|
|
||||||
cls,
|
|
||||||
symbol: str | None = None,
|
|
||||||
*,
|
|
||||||
runtime_key: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
cls._store.clear(
|
|
||||||
source_name=_MARKET_PRICE_CACHE_SOURCE_NAME,
|
|
||||||
symbol=(
|
|
||||||
cls._normalize_symbol(symbol)
|
|
||||||
if symbol is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
runtime_key=(
|
|
||||||
cls._normalize_runtime_key(runtime_key)
|
|
||||||
if runtime_key is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_symbol(symbol: str) -> str:
|
|
||||||
return str(symbol).strip().upper()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_runtime_key(runtime_key: str) -> str:
|
|
||||||
return str(runtime_key).strip().lower()
|
|
||||||
@@ -8,9 +8,7 @@ import traceback
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from src.core.numbers import safe_float
|
from src.core.types import JsonDict
|
||||||
from src.core.types import JsonDict, NumericLike
|
|
||||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.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 (
|
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||||
@@ -19,6 +17,10 @@ from src.market_data.acquisition.adapters.dzengi.websocket import (
|
|||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
MarketDataAcquisitionError,
|
MarketDataAcquisitionError,
|
||||||
)
|
)
|
||||||
|
from src.storage.quote_store import (
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
get_quote_store,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -179,7 +181,10 @@ class MarketDataRunner:
|
|||||||
runtime_key=context.runtime_key,
|
runtime_key=context.runtime_key,
|
||||||
cache_symbol=cache_symbol,
|
cache_symbol=cache_symbol,
|
||||||
):
|
):
|
||||||
MarketPriceCache.clear(cache_symbol)
|
get_quote_store().clear(
|
||||||
|
source_name=QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
symbol=cache_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
market_status = ExchangeService().get_symbol_market_status(symbol)
|
market_status = ExchangeService().get_symbol_market_status(symbol)
|
||||||
@@ -372,7 +377,8 @@ class MarketDataRunner:
|
|||||||
|
|
||||||
valid_payload_count += 1
|
valid_payload_count += 1
|
||||||
|
|
||||||
MarketPriceCache.set_quote(
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
quote,
|
quote,
|
||||||
runtime_key=context.runtime_key,
|
runtime_key=context.runtime_key,
|
||||||
)
|
)
|
||||||
@@ -395,7 +401,7 @@ class MarketDataRunner:
|
|||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
ExchangeService().refresh_market_snapshot_cache,
|
ExchangeService().refresh_quote_cache,
|
||||||
symbol,
|
symbol,
|
||||||
runtime_key=context.runtime_key,
|
runtime_key=context.runtime_key,
|
||||||
)
|
)
|
||||||
@@ -490,36 +496,6 @@ class MarketDataRunner:
|
|||||||
def _ws_symbol(cls, symbol: str) -> str:
|
def _ws_symbol(cls, symbol: str) -> str:
|
||||||
return cls._cache_symbol(symbol)
|
return cls._cache_symbol(symbol)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extract_best_price(
|
|
||||||
cls,
|
|
||||||
payload: JsonDict,
|
|
||||||
side_key: str,
|
|
||||||
) -> float | None:
|
|
||||||
data = cls._extract_depth_payload(payload)
|
|
||||||
|
|
||||||
values = data.get(side_key)
|
|
||||||
|
|
||||||
if not isinstance(values, list) or not values:
|
|
||||||
return None
|
|
||||||
|
|
||||||
first = values[0]
|
|
||||||
|
|
||||||
if isinstance(first, list) and first:
|
|
||||||
return cls._positive_float(first[0])
|
|
||||||
|
|
||||||
if isinstance(first, dict):
|
|
||||||
raw_price = (
|
|
||||||
first.get("price")
|
|
||||||
or first.get("p")
|
|
||||||
or first.get("bidPrice")
|
|
||||||
or first.get("askPrice")
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls._positive_float(raw_price)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict:
|
def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict:
|
||||||
data: object = payload
|
data: object = payload
|
||||||
@@ -538,15 +514,6 @@ class MarketDataRunner:
|
|||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _positive_float(cls, value: NumericLike | None) -> float | None:
|
|
||||||
number = safe_float(value)
|
|
||||||
|
|
||||||
if number is None or number <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return number
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
|
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
|
||||||
preview: JsonDict = {}
|
preview: JsonDict = {}
|
||||||
|
|||||||
@@ -3,13 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.core.numbers import safe_float
|
|
||||||
from src.core.types import JsonDict, NumericLike
|
|
||||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.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 (
|
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||||
@@ -18,121 +12,14 @@ from src.market_data.acquisition.adapters.dzengi.websocket import (
|
|||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
MarketDataAcquisitionError,
|
MarketDataAcquisitionError,
|
||||||
)
|
)
|
||||||
|
from src.storage.quote_store import (
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
get_quote_store,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
# безопасно форматирует timestamp биржи в локальное время
|
# запускает постоянный websocket-поток рынка и обновляет Quote Store
|
||||||
def _format_timestamp(raw_timestamp: NumericLike | None) -> str | None:
|
|
||||||
timestamp = safe_float(raw_timestamp)
|
|
||||||
|
|
||||||
if timestamp is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
dt_utc = datetime.fromtimestamp(
|
|
||||||
int(timestamp) / 1000,
|
|
||||||
tz=ZoneInfo("UTC"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return dt_utc.astimezone(
|
|
||||||
ZoneInfo(settings.tz),
|
|
||||||
).strftime("%d.%m.%Y %H:%M:%S")
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# достаёт внутренний payload из websocket-сообщения
|
|
||||||
def _payload_from_message(payload: JsonDict) -> JsonDict | None:
|
|
||||||
event = payload.get("Payload") or payload.get("payload")
|
|
||||||
|
|
||||||
if isinstance(event, dict) and "Payload" in event:
|
|
||||||
event = event.get("Payload")
|
|
||||||
|
|
||||||
if not isinstance(event, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return dict(event)
|
|
||||||
|
|
||||||
|
|
||||||
# извлекает best bid / best ask из формата depth
|
|
||||||
def _extract_depth_prices(event: JsonDict) -> tuple[float | None, float | None]:
|
|
||||||
bids = event.get("bids")
|
|
||||||
asks = event.get("asks")
|
|
||||||
|
|
||||||
bid_price = _extract_first_price(bids)
|
|
||||||
ask_price = _extract_first_price(asks)
|
|
||||||
|
|
||||||
return bid_price, ask_price
|
|
||||||
|
|
||||||
|
|
||||||
# извлекает первую цену из списка стакана
|
|
||||||
def _extract_first_price(value: object) -> float | None:
|
|
||||||
if not isinstance(value, list) or not value:
|
|
||||||
return None
|
|
||||||
|
|
||||||
first = value[0]
|
|
||||||
|
|
||||||
if isinstance(first, list) and first:
|
|
||||||
return _positive_float(first[0])
|
|
||||||
|
|
||||||
if isinstance(first, dict):
|
|
||||||
return _positive_float(
|
|
||||||
first.get("price")
|
|
||||||
or first.get("p")
|
|
||||||
or first.get("bidPrice")
|
|
||||||
or first.get("askPrice")
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# безопасно приводит число к float и отсекает нулевые/отрицательные цены
|
|
||||||
def _positive_float(value: NumericLike | None) -> float | None:
|
|
||||||
number = safe_float(value)
|
|
||||||
|
|
||||||
if number is None or number <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return number
|
|
||||||
|
|
||||||
|
|
||||||
# нормализует websocket-сообщение рынка в единый формат для MarketPriceCache
|
|
||||||
def _extract_market_event(payload: JsonDict) -> JsonDict | None:
|
|
||||||
event = _payload_from_message(payload)
|
|
||||||
|
|
||||||
if event is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
symbol = (
|
|
||||||
event.get("symbolName")
|
|
||||||
or event.get("symbol")
|
|
||||||
or payload.get("symbol")
|
|
||||||
)
|
|
||||||
|
|
||||||
bid_price = _positive_float(event.get("bid"))
|
|
||||||
ask_price = _positive_float(event.get("ofr") or event.get("ask"))
|
|
||||||
|
|
||||||
if bid_price is None or ask_price is None:
|
|
||||||
bid_price, ask_price = _extract_depth_prices(event)
|
|
||||||
|
|
||||||
if symbol is None or bid_price is None or ask_price is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
price = (bid_price + ask_price) / 2
|
|
||||||
|
|
||||||
return {
|
|
||||||
"symbol": str(symbol).upper(),
|
|
||||||
"price": price,
|
|
||||||
"bid_price": bid_price,
|
|
||||||
"ask_price": ask_price,
|
|
||||||
"updated_at": _format_timestamp(event.get("timestamp")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# запускает постоянный websocket-поток рынка и обновляет MarketPriceCache
|
|
||||||
async def start_market_stream() -> None:
|
async def start_market_stream() -> None:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
journal = JournalService()
|
journal = JournalService()
|
||||||
@@ -168,7 +55,8 @@ async def start_market_stream() -> None:
|
|||||||
if quote.symbol.strip().upper() != symbol.strip().upper():
|
if quote.symbol.strip().upper() != symbol.strip().upper():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
MarketPriceCache.set_quote(
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
quote,
|
quote,
|
||||||
runtime_key="default",
|
runtime_key="default",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from src.core.numbers import safe_float
|
|||||||
from src.core.types import NumericLike
|
from src.core.types import NumericLike
|
||||||
from src.integrations.exchange.balance_parser import parse_account_balances
|
from src.integrations.exchange.balance_parser import parse_account_balances
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
from src.integrations.exchange.exceptions import ExchangeError
|
||||||
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,
|
||||||
@@ -71,6 +70,10 @@ from src.storage.instrument_store import (
|
|||||||
InMemoryInstrumentStore,
|
InMemoryInstrumentStore,
|
||||||
InstrumentStoreProtocol,
|
InstrumentStoreProtocol,
|
||||||
)
|
)
|
||||||
|
from src.storage.quote_store import (
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
get_quote_store,
|
||||||
|
)
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
@@ -787,7 +790,8 @@ class ExchangeService:
|
|||||||
if not validation.is_valid:
|
if not validation.is_valid:
|
||||||
raise ExchangeError(validation.message)
|
raise ExchangeError(validation.message)
|
||||||
|
|
||||||
cached_quote = MarketPriceCache.get_quote(
|
cached_quote = get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
validation.normalized_symbol,
|
validation.normalized_symbol,
|
||||||
runtime_key=normalized_runtime_key,
|
runtime_key=normalized_runtime_key,
|
||||||
)
|
)
|
||||||
@@ -800,7 +804,36 @@ class ExchangeService:
|
|||||||
return cached_quote
|
return cached_quote
|
||||||
|
|
||||||
quote = self._get_fresh_quote(validation.normalized_symbol)
|
quote = self._get_fresh_quote(validation.normalized_symbol)
|
||||||
MarketPriceCache.set_quote(
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
quote,
|
||||||
|
runtime_key=normalized_runtime_key,
|
||||||
|
)
|
||||||
|
return quote
|
||||||
|
|
||||||
|
# Принудительно обновить Quote cache через свежий REST Quotes Feed.
|
||||||
|
def refresh_quote_cache(
|
||||||
|
self,
|
||||||
|
symbol: str | None = None,
|
||||||
|
*,
|
||||||
|
runtime_key: str | None = None,
|
||||||
|
) -> Quote:
|
||||||
|
symbol_to_use = symbol or self.settings.default_symbol
|
||||||
|
normalized_runtime_key = self._runtime_key(runtime_key)
|
||||||
|
|
||||||
|
if not self.settings.exchange_enabled:
|
||||||
|
quote = mock_quote(symbol_to_use)
|
||||||
|
else:
|
||||||
|
validation = self.validate_symbol(symbol_to_use)
|
||||||
|
if not validation.is_valid:
|
||||||
|
raise ExchangeError(validation.message)
|
||||||
|
|
||||||
|
quote = self._get_fresh_quote(
|
||||||
|
validation.normalized_symbol
|
||||||
|
)
|
||||||
|
|
||||||
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
quote,
|
quote,
|
||||||
runtime_key=normalized_runtime_key,
|
runtime_key=normalized_runtime_key,
|
||||||
)
|
)
|
||||||
@@ -828,7 +861,8 @@ class ExchangeService:
|
|||||||
if not validation.is_valid:
|
if not validation.is_valid:
|
||||||
raise ExchangeError(validation.message)
|
raise ExchangeError(validation.message)
|
||||||
|
|
||||||
quote = MarketPriceCache.get_quote(
|
quote = get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
validation.normalized_symbol,
|
validation.normalized_symbol,
|
||||||
runtime_key=normalized_runtime_key,
|
runtime_key=normalized_runtime_key,
|
||||||
)
|
)
|
||||||
@@ -846,7 +880,8 @@ class ExchangeService:
|
|||||||
quote = self._get_fresh_quote(
|
quote = self._get_fresh_quote(
|
||||||
validation.normalized_symbol
|
validation.normalized_symbol
|
||||||
)
|
)
|
||||||
MarketPriceCache.set_quote(
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
quote,
|
quote,
|
||||||
runtime_key=normalized_runtime_key,
|
runtime_key=normalized_runtime_key,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -213,3 +213,14 @@ class InMemoryQuoteStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return normalized_symbol
|
return normalized_symbol
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME = "legacy-market-price-cache"
|
||||||
|
_SHARED_QUOTE_STORE: QuoteStoreProtocol = InMemoryQuoteStore()
|
||||||
|
|
||||||
|
|
||||||
|
def get_quote_store() -> QuoteStoreProtocol:
|
||||||
|
"""Вернуть общий runtime-экземпляр канонического Quote Store."""
|
||||||
|
return _SHARED_QUOTE_STORE
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
# app/tests/unit/integrations/exchange/test_market_cache.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.storage.quote_store import InMemoryQuoteStore, QuoteStoreProtocol
|
|
||||||
|
|
||||||
|
|
||||||
def _quote(symbol: str = "BTC/USD_LEVERAGE") -> Quote:
|
|
||||||
return Quote(
|
|
||||||
symbol=symbol,
|
|
||||||
last_price=Decimal("100"),
|
|
||||||
bid_price=Decimal("99"),
|
|
||||||
ask_price=Decimal("101"),
|
|
||||||
exchange_timestamp=None,
|
|
||||||
received_at=datetime.now(timezone.utc),
|
|
||||||
source="test",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reset_market_price_cache() -> Iterator[None]:
|
|
||||||
original_store = MarketPriceCache._store
|
|
||||||
MarketPriceCache._store = InMemoryQuoteStore()
|
|
||||||
yield
|
|
||||||
MarketPriceCache._store = original_store
|
|
||||||
|
|
||||||
|
|
||||||
def test_market_price_cache_uses_quote_store_protocol() -> None:
|
|
||||||
assert isinstance(MarketPriceCache._store, QuoteStoreProtocol)
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_and_get_quote_preserve_identity() -> None:
|
|
||||||
quote = _quote()
|
|
||||||
MarketPriceCache.set_quote(quote, runtime_key="auto")
|
|
||||||
assert MarketPriceCache.get_quote(quote.symbol, runtime_key="auto") is quote
|
|
||||||
|
|
||||||
|
|
||||||
def test_symbol_and_runtime_key_are_normalized() -> None:
|
|
||||||
quote = _quote("BTC/USD_LEVERAGE")
|
|
||||||
MarketPriceCache.set_quote(quote, runtime_key=" AUTO ")
|
|
||||||
assert MarketPriceCache.get_quote(" btc/usd_leverage ", runtime_key="auto") is quote
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_keys_are_isolated() -> None:
|
|
||||||
auto = _quote()
|
|
||||||
debug = Quote(
|
|
||||||
symbol=auto.symbol, last_price=Decimal("200"), bid_price=Decimal("199"),
|
|
||||||
ask_price=Decimal("201"), exchange_timestamp=None,
|
|
||||||
received_at=datetime.now(timezone.utc), source="debug",
|
|
||||||
)
|
|
||||||
MarketPriceCache.set_quote(auto, runtime_key="auto")
|
|
||||||
MarketPriceCache.set_quote(debug, runtime_key="debug_auto")
|
|
||||||
assert MarketPriceCache.get_quote(auto.symbol, runtime_key="auto") is auto
|
|
||||||
assert MarketPriceCache.get_quote(auto.symbol, runtime_key="debug_auto") is debug
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_targeted_and_all() -> None:
|
|
||||||
btc = _quote("BTC/USD_LEVERAGE")
|
|
||||||
eth = _quote("ETH/USD_LEVERAGE")
|
|
||||||
MarketPriceCache.set_quote(btc, runtime_key="auto")
|
|
||||||
MarketPriceCache.set_quote(eth, runtime_key="auto")
|
|
||||||
MarketPriceCache.clear(btc.symbol, runtime_key="auto")
|
|
||||||
assert MarketPriceCache.get_quote(btc.symbol, runtime_key="auto") is None
|
|
||||||
assert MarketPriceCache.get_quote(eth.symbol, runtime_key="auto") is eth
|
|
||||||
MarketPriceCache.clear()
|
|
||||||
assert MarketPriceCache.get_quote(eth.symbol, runtime_key="auto") is None
|
|
||||||
@@ -205,14 +205,15 @@ def test_run_websocket_uses_canonical_quote_adapter(
|
|||||||
mapped_documents.append(document)
|
mapped_documents.append(document)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
class Cache:
|
class Store:
|
||||||
@classmethod
|
def set(
|
||||||
def set_quote(
|
self,
|
||||||
cls,
|
source_name: str,
|
||||||
value: Quote,
|
value: Quote,
|
||||||
*,
|
*,
|
||||||
runtime_key: str = "default",
|
runtime_key: str = "default",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
assert source_name == runner_module.QUOTE_RUNTIME_SOURCE_NAME
|
||||||
stored_quotes.append((value, runtime_key))
|
stored_quotes.append((value, runtime_key))
|
||||||
|
|
||||||
context = MarketRuntimeContext(
|
context = MarketRuntimeContext(
|
||||||
@@ -247,14 +248,7 @@ def test_run_websocket_uses_canonical_quote_adapter(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(runner_module, "ExchangeWebSocketClient", Client)
|
monkeypatch.setattr(runner_module, "ExchangeWebSocketClient", Client)
|
||||||
monkeypatch.setattr(runner_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
monkeypatch.setattr(runner_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
||||||
monkeypatch.setattr(runner_module, "MarketPriceCache", Cache)
|
monkeypatch.setattr(runner_module, "get_quote_store", lambda: Store())
|
||||||
monkeypatch.setattr(
|
|
||||||
MarketDataRunner,
|
|
||||||
"_extract_best_price",
|
|
||||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
||||||
AssertionError("Legacy parser must not be called.")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
MarketDataRunner._run_websocket(
|
MarketDataRunner._run_websocket(
|
||||||
@@ -325,3 +319,100 @@ def test_run_websocket_raises_after_five_invalid_quotes(
|
|||||||
"BTC/USD_LEVERAGE",
|
"BTC/USD_LEVERAGE",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rest_fallback_once_refreshes_quote_cache(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from src.integrations.exchange.market_data_runner import MarketRuntimeContext
|
||||||
|
|
||||||
|
calls: list[tuple[str, str | None]] = []
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
def refresh_quote_cache(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
runtime_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
calls.append((symbol, runtime_key))
|
||||||
|
|
||||||
|
context = MarketRuntimeContext(
|
||||||
|
runtime_key="debug_auto",
|
||||||
|
task=None,
|
||||||
|
interval_seconds=1,
|
||||||
|
symbol_provider=lambda: "BTC/USD_LEVERAGE",
|
||||||
|
screen=None,
|
||||||
|
action="market_data",
|
||||||
|
runtime_label=None,
|
||||||
|
last_rest_state="UNAVAILABLE",
|
||||||
|
last_rest_error_key="previous-error",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner_module, "ExchangeService", Service)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
MarketDataRunner,
|
||||||
|
"_can_log_runtime_event",
|
||||||
|
lambda _: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
MarketDataRunner._rest_fallback_once(
|
||||||
|
context,
|
||||||
|
"BTC/USD_LEVERAGE",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [("BTC/USD_LEVERAGE", "debug_auto")]
|
||||||
|
assert context.last_rest_state == "AVAILABLE"
|
||||||
|
assert context.last_rest_error_key is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rest_fallback_once_preserves_unavailable_error_state(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from src.integrations.exchange.market_data_runner import MarketRuntimeContext
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
def refresh_quote_cache(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
runtime_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
del symbol, runtime_key
|
||||||
|
raise RuntimeError("REST unavailable")
|
||||||
|
|
||||||
|
context = MarketRuntimeContext(
|
||||||
|
runtime_key="auto",
|
||||||
|
task=None,
|
||||||
|
interval_seconds=1,
|
||||||
|
symbol_provider=lambda: "BTC/USD_LEVERAGE",
|
||||||
|
screen=None,
|
||||||
|
action="market_data",
|
||||||
|
runtime_label=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner_module, "ExchangeService", Service)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
MarketDataRunner,
|
||||||
|
"_can_log_runtime_event",
|
||||||
|
lambda _: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
MarketDataRunner._rest_fallback_once(
|
||||||
|
context,
|
||||||
|
"BTC/USD_LEVERAGE",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.last_rest_state == "UNAVAILABLE"
|
||||||
|
assert context.last_rest_error_key == (
|
||||||
|
"BTC/USD_LEVERAGE:RuntimeError:REST unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -357,14 +357,15 @@ def test_start_market_stream_maps_message_to_quote(
|
|||||||
mapped_documents.append(document)
|
mapped_documents.append(document)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
class Cache:
|
class Store:
|
||||||
@classmethod
|
def set(
|
||||||
def set_quote(
|
self,
|
||||||
cls,
|
source_name: str,
|
||||||
value: Quote,
|
value: Quote,
|
||||||
*,
|
*,
|
||||||
runtime_key: str = "default",
|
runtime_key: str = "default",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
assert source_name == stream_module.QUOTE_RUNTIME_SOURCE_NAME
|
||||||
stored_quotes.append((value, runtime_key))
|
stored_quotes.append((value, runtime_key))
|
||||||
|
|
||||||
monkeypatch.setattr(stream_module, "load_settings", lambda: settings)
|
monkeypatch.setattr(stream_module, "load_settings", lambda: settings)
|
||||||
@@ -379,14 +380,7 @@ def test_start_market_stream_maps_message_to_quote(
|
|||||||
monkeypatch.setattr(stream_module, "ExchangeService", lambda: service)
|
monkeypatch.setattr(stream_module, "ExchangeService", lambda: service)
|
||||||
monkeypatch.setattr(stream_module, "ExchangeWebSocketClient", Client)
|
monkeypatch.setattr(stream_module, "ExchangeWebSocketClient", Client)
|
||||||
monkeypatch.setattr(stream_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
monkeypatch.setattr(stream_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
||||||
monkeypatch.setattr(stream_module, "MarketPriceCache", Cache)
|
monkeypatch.setattr(stream_module, "get_quote_store", lambda: Store())
|
||||||
monkeypatch.setattr(
|
|
||||||
stream_module,
|
|
||||||
"_extract_market_event",
|
|
||||||
lambda payload: (_ for _ in ()).throw(
|
|
||||||
AssertionError("Legacy parser must not be called.")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(stream_module.asyncio, "sleep", _raise_stop_stream)
|
monkeypatch.setattr(stream_module.asyncio, "sleep", _raise_stop_stream)
|
||||||
|
|
||||||
with pytest.raises(StopStream):
|
with pytest.raises(StopStream):
|
||||||
@@ -423,9 +417,8 @@ def test_start_market_stream_skips_invalid_adapter_message(
|
|||||||
del document
|
del document
|
||||||
raise QuoteSchemaError("Invalid message.")
|
raise QuoteSchemaError("Invalid message.")
|
||||||
|
|
||||||
class Cache:
|
class Store:
|
||||||
@classmethod
|
def set(self, *args: object, **kwargs: object) -> None:
|
||||||
def set_quote(cls, *args: object, **kwargs: object) -> None:
|
|
||||||
nonlocal cache_calls
|
nonlocal cache_calls
|
||||||
cache_calls += 1
|
cache_calls += 1
|
||||||
|
|
||||||
@@ -441,7 +434,7 @@ def test_start_market_stream_skips_invalid_adapter_message(
|
|||||||
monkeypatch.setattr(stream_module, "ExchangeService", lambda: service)
|
monkeypatch.setattr(stream_module, "ExchangeService", lambda: service)
|
||||||
monkeypatch.setattr(stream_module, "ExchangeWebSocketClient", Client)
|
monkeypatch.setattr(stream_module, "ExchangeWebSocketClient", Client)
|
||||||
monkeypatch.setattr(stream_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
monkeypatch.setattr(stream_module, "DzengiWebSocketQuoteAdapter", Adapter)
|
||||||
monkeypatch.setattr(stream_module, "MarketPriceCache", Cache)
|
monkeypatch.setattr(stream_module, "get_quote_store", lambda: Store())
|
||||||
monkeypatch.setattr(stream_module.asyncio, "sleep", _raise_stop_stream)
|
monkeypatch.setattr(stream_module.asyncio, "sleep", _raise_stop_stream)
|
||||||
|
|
||||||
with pytest.raises(StopStream):
|
with pytest.raises(StopStream):
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from typing import cast
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.config import Settings
|
from src.core.config import Settings
|
||||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
import src.integrations.exchange.service as service_module
|
||||||
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.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.storage.quote_store import QUOTE_RUNTIME_SOURCE_NAME
|
||||||
|
|
||||||
|
|
||||||
def _service() -> ExchangeService:
|
def _service() -> ExchangeService:
|
||||||
@@ -55,7 +56,13 @@ def test_execution_snapshot_reads_canonical_cached_quote(
|
|||||||
quote = _quote()
|
quote = _quote()
|
||||||
|
|
||||||
monkeypatch.setattr(service, "validate_symbol", lambda _: _valid_validation())
|
monkeypatch.setattr(service, "validate_symbol", lambda _: _valid_validation())
|
||||||
monkeypatch.setattr(MarketPriceCache, "get_quote", lambda *_, **__: quote)
|
class Store:
|
||||||
|
def get(self, source_name, symbol, *, runtime_key="default"):
|
||||||
|
assert source_name == QUOTE_RUNTIME_SOURCE_NAME
|
||||||
|
del symbol, runtime_key
|
||||||
|
return quote
|
||||||
|
|
||||||
|
monkeypatch.setattr(service_module, "get_quote_store", lambda: Store())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service,
|
service,
|
||||||
"_get_fresh_quote",
|
"_get_fresh_quote",
|
||||||
@@ -89,13 +96,18 @@ def test_execution_snapshot_uses_fresh_quote_for_stale_cache(
|
|||||||
stored: list[tuple[Quote, str]] = []
|
stored: list[tuple[Quote, str]] = []
|
||||||
|
|
||||||
monkeypatch.setattr(service, "validate_symbol", lambda _: _valid_validation())
|
monkeypatch.setattr(service, "validate_symbol", lambda _: _valid_validation())
|
||||||
monkeypatch.setattr(MarketPriceCache, "get_quote", lambda *_, **__: stale)
|
class Store:
|
||||||
|
def get(self, source_name, symbol, *, runtime_key="default"):
|
||||||
|
assert source_name == QUOTE_RUNTIME_SOURCE_NAME
|
||||||
|
del symbol, runtime_key
|
||||||
|
return stale
|
||||||
|
|
||||||
|
def set(self, source_name, quote, *, runtime_key="default"):
|
||||||
|
assert source_name == QUOTE_RUNTIME_SOURCE_NAME
|
||||||
|
stored.append((quote, runtime_key))
|
||||||
|
|
||||||
|
monkeypatch.setattr(service_module, "get_quote_store", lambda: Store())
|
||||||
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
||||||
monkeypatch.setattr(
|
|
||||||
MarketPriceCache,
|
|
||||||
"set_quote",
|
|
||||||
lambda quote, *, runtime_key: stored.append((quote, runtime_key)),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = service.get_execution_snapshot(
|
result = service.get_execution_snapshot(
|
||||||
"BTC/USD_LEVERAGE",
|
"BTC/USD_LEVERAGE",
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ from typing import cast
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import src.integrations.exchange.service as service_module
|
||||||
from src.core.config import Settings
|
from src.core.config import Settings
|
||||||
from src.integrations.exchange.exceptions import ExchangeError
|
from src.integrations.exchange.exceptions import ExchangeError
|
||||||
from src.integrations.exchange.market_cache import MarketPriceCache
|
|
||||||
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.market_data.acquisition.models.quote import Quote
|
||||||
from src.storage.quote_store import InMemoryQuoteStore
|
import src.storage.quote_store as quote_store_module
|
||||||
|
from src.storage.quote_store import (
|
||||||
|
InMemoryQuoteStore,
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
get_quote_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _service(*, exchange_enabled: bool = True) -> ExchangeService:
|
def _service(*, exchange_enabled: bool = True) -> ExchangeService:
|
||||||
@@ -45,11 +50,13 @@ def _quote(*, age_seconds: float = 0.0) -> Quote:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_store():
|
def reset_store(monkeypatch: pytest.MonkeyPatch):
|
||||||
original = MarketPriceCache._store
|
monkeypatch.setattr(
|
||||||
MarketPriceCache._store = InMemoryQuoteStore()
|
quote_store_module,
|
||||||
|
"_SHARED_QUOTE_STORE",
|
||||||
|
InMemoryQuoteStore(),
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
MarketPriceCache._store = original
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_quote_returns_fresh_cached_identity(
|
def test_get_quote_returns_fresh_cached_identity(
|
||||||
@@ -57,7 +64,8 @@ def test_get_quote_returns_fresh_cached_identity(
|
|||||||
) -> None:
|
) -> None:
|
||||||
service = _service()
|
service = _service()
|
||||||
quote = _quote()
|
quote = _quote()
|
||||||
MarketPriceCache.set_quote(quote, runtime_key="auto")
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,quote, runtime_key="auto")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service,
|
service,
|
||||||
"validate_symbol",
|
"validate_symbol",
|
||||||
@@ -82,7 +90,8 @@ def test_get_quote_refreshes_stale_quote(
|
|||||||
service = _service()
|
service = _service()
|
||||||
stale = _quote(age_seconds=10)
|
stale = _quote(age_seconds=10)
|
||||||
fresh = _quote()
|
fresh = _quote()
|
||||||
MarketPriceCache.set_quote(stale, runtime_key="auto")
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,stale, runtime_key="auto")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service,
|
service,
|
||||||
"validate_symbol",
|
"validate_symbol",
|
||||||
@@ -97,7 +106,8 @@ def test_get_quote_refreshes_stale_quote(
|
|||||||
result = service.get_quote(fresh.symbol, runtime_key="auto")
|
result = service.get_quote(fresh.symbol, runtime_key="auto")
|
||||||
|
|
||||||
assert result is fresh
|
assert result is fresh
|
||||||
assert MarketPriceCache.get_quote(fresh.symbol, runtime_key="auto") is fresh
|
assert get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,fresh.symbol, runtime_key="auto") is fresh
|
||||||
|
|
||||||
|
|
||||||
def test_get_quote_wraps_acquisition_error(
|
def test_get_quote_wraps_acquisition_error(
|
||||||
@@ -116,3 +126,186 @@ def test_get_quote_wraps_acquisition_error(
|
|||||||
service._get_fresh_quote("BTC/USD_LEVERAGE")
|
service._get_fresh_quote("BTC/USD_LEVERAGE")
|
||||||
|
|
||||||
assert exc_info.value.__cause__ is original
|
assert exc_info.value.__cause__ is original
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_quote_cache_forces_fresh_quote_and_preserves_identity(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = _service()
|
||||||
|
cached = _quote()
|
||||||
|
fresh = _quote()
|
||||||
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,cached, runtime_key="auto")
|
||||||
|
requested_symbols: list[str] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"validate_symbol",
|
||||||
|
lambda symbol: SimpleNamespace(
|
||||||
|
is_valid=True,
|
||||||
|
normalized_symbol=(requested_symbols.append(symbol) or fresh.symbol),
|
||||||
|
message="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
||||||
|
|
||||||
|
result = service.refresh_quote_cache(
|
||||||
|
" btc/usd_leverage ",
|
||||||
|
runtime_key="auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert requested_symbols == [" btc/usd_leverage "]
|
||||||
|
assert result is fresh
|
||||||
|
assert result is not cached
|
||||||
|
assert get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,fresh.symbol, runtime_key="auto") is fresh
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_quote_cache_uses_default_runtime_key(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = _service()
|
||||||
|
fresh = _quote()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"validate_symbol",
|
||||||
|
lambda _: SimpleNamespace(
|
||||||
|
is_valid=True,
|
||||||
|
normalized_symbol=fresh.symbol,
|
||||||
|
message="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
||||||
|
|
||||||
|
result = service.refresh_quote_cache(fresh.symbol)
|
||||||
|
|
||||||
|
assert result is fresh
|
||||||
|
assert get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,fresh.symbol, runtime_key="auto") is fresh
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_quote_cache_normalizes_runtime_key(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = _service()
|
||||||
|
fresh = _quote()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"validate_symbol",
|
||||||
|
lambda _: SimpleNamespace(
|
||||||
|
is_valid=True,
|
||||||
|
normalized_symbol=fresh.symbol,
|
||||||
|
message="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(service, "_get_fresh_quote", lambda _: fresh)
|
||||||
|
|
||||||
|
result = service.refresh_quote_cache(
|
||||||
|
fresh.symbol,
|
||||||
|
runtime_key=" Debug_Auto ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is fresh
|
||||||
|
assert (
|
||||||
|
get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
fresh.symbol,
|
||||||
|
runtime_key="debug_auto",
|
||||||
|
)
|
||||||
|
is fresh
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_quote_cache_rejects_invalid_symbol(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = _service()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"validate_symbol",
|
||||||
|
lambda _: SimpleNamespace(
|
||||||
|
is_valid=False,
|
||||||
|
normalized_symbol="UNKNOWN/USD",
|
||||||
|
message="Символ не найден.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_get_fresh_quote",
|
||||||
|
lambda _: (_ for _ in ()).throw(
|
||||||
|
AssertionError("Fresh Quote must not be requested.")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ExchangeError, match="Символ не найден"):
|
||||||
|
service.refresh_quote_cache("UNKNOWN/USD", runtime_key="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_quote_cache_does_not_update_cache_on_acquisition_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = _service()
|
||||||
|
cached = _quote()
|
||||||
|
original = RuntimeError("unavailable")
|
||||||
|
get_quote_store().set(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,cached, runtime_key="auto")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"validate_symbol",
|
||||||
|
lambda _: SimpleNamespace(
|
||||||
|
is_valid=True,
|
||||||
|
normalized_symbol=cached.symbol,
|
||||||
|
message="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_load_quote_via_acquisition",
|
||||||
|
lambda _: (_ for _ in ()).throw(original),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(service, "_log_exchange_error", lambda **_: None)
|
||||||
|
|
||||||
|
with pytest.raises(ExchangeError) as exc_info:
|
||||||
|
service.refresh_quote_cache(cached.symbol, runtime_key="auto")
|
||||||
|
|
||||||
|
assert exc_info.value.__cause__ is original
|
||||||
|
assert get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,cached.symbol, runtime_key="auto") is cached
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_quote_cache_uses_and_stores_mock_quote(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = _service(exchange_enabled=False)
|
||||||
|
mock = _quote()
|
||||||
|
monkeypatch.setattr(service_module, "mock_quote", lambda _: mock)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"validate_symbol",
|
||||||
|
lambda _: (_ for _ in ()).throw(
|
||||||
|
AssertionError("Validation is not expected in mock mode.")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_get_fresh_quote",
|
||||||
|
lambda _: (_ for _ in ()).throw(
|
||||||
|
AssertionError("REST is not expected in mock mode.")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.refresh_quote_cache(
|
||||||
|
"BTC/USD_LEVERAGE",
|
||||||
|
runtime_key="debug_auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is mock
|
||||||
|
assert (
|
||||||
|
get_quote_store().get(
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
|
mock.symbol,
|
||||||
|
runtime_key="debug_auto",
|
||||||
|
)
|
||||||
|
is mock
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import pytest
|
|||||||
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
from src.storage.exceptions import QuoteStoreError, StorageError
|
from src.storage.exceptions import QuoteStoreError, StorageError
|
||||||
|
import src.storage.quote_store as quote_store_module
|
||||||
from src.storage.quote_store import (
|
from src.storage.quote_store import (
|
||||||
InMemoryQuoteStore,
|
InMemoryQuoteStore,
|
||||||
|
QUOTE_RUNTIME_SOURCE_NAME,
|
||||||
QuoteStoreProtocol,
|
QuoteStoreProtocol,
|
||||||
|
get_quote_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -446,3 +449,31 @@ def test_quote_store_error_inherits_storage_error() -> None:
|
|||||||
error = QuoteStoreError("test")
|
error = QuoteStoreError("test")
|
||||||
|
|
||||||
assert isinstance(error, StorageError)
|
assert isinstance(error, StorageError)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_quote_store_matches_protocol() -> None:
|
||||||
|
assert isinstance(get_quote_store(), QuoteStoreProtocol)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_quote_store_returns_same_instance() -> None:
|
||||||
|
assert get_quote_store() is get_quote_store()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_source_name_preserves_compatibility_namespace() -> None:
|
||||||
|
assert QUOTE_RUNTIME_SOURCE_NAME == "legacy-market-price-cache"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_quote_store_can_be_replaced_in_test(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
replacement = InMemoryQuoteStore()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
quote_store_module,
|
||||||
|
"_SHARED_QUOTE_STORE",
|
||||||
|
replacement,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert get_quote_store() is replacement
|
||||||
|
|||||||
718
docs/migrations/build_039.md
Normal file
718
docs/migrations/build_039.md
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
# Build 039 — Завершение миграции consumers рыночной цены на каноническую модель Quote
|
||||||
|
|
||||||
|
**Статус:** Завершён
|
||||||
|
**Проект:** Dzentra
|
||||||
|
**Подсистема:** Market Data Acquisition / Exchange Integration / Trading Consumers
|
||||||
|
**Тип изменения:** Миграция legacy price/snapshot consumers
|
||||||
|
**Язык документа:** Русский
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цель Build 039
|
||||||
|
|
||||||
|
Цель Build 039 — завершить миграцию существующих consumers рыночной цены и рыночных snapshot-моделей с legacy-контрактов на каноническую модель:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src.market_data.acquisition.models.quote.Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
и соответствующий новый pipeline получения, хранения и использования котировок.
|
||||||
|
|
||||||
|
Build 039 должен устранить использование следующих legacy-сущностей и методов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TickerPrice
|
||||||
|
MarketPriceSnapshot
|
||||||
|
get_price(
|
||||||
|
get_market_snapshot(
|
||||||
|
get_fresh_market_snapshot(
|
||||||
|
refresh_price_cache(
|
||||||
|
refresh_market_snapshot_cache(
|
||||||
|
_get_real_price(
|
||||||
|
```
|
||||||
|
|
||||||
|
При этом необходимо сохранить:
|
||||||
|
|
||||||
|
- работоспособность существующего торгового бота;
|
||||||
|
- существующее пользовательское поведение;
|
||||||
|
- существующие UI-контракты;
|
||||||
|
- форматирование значений;
|
||||||
|
- форматирование возраста данных;
|
||||||
|
- форматирование времени;
|
||||||
|
- fallback-поведение;
|
||||||
|
- exception semantics;
|
||||||
|
- существующую торговую логику, не относящуюся непосредственно к миграции котировок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Исходное состояние
|
||||||
|
|
||||||
|
До Build 039 в проекте одновременно существовали:
|
||||||
|
|
||||||
|
1. новая каноническая модель котировки:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
2. новый Market Data Acquisition pipeline;
|
||||||
|
|
||||||
|
3. новый Quote Store;
|
||||||
|
|
||||||
|
4. новый cache-контракт на основе `Quote`;
|
||||||
|
|
||||||
|
5. legacy-модели:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TickerPrice
|
||||||
|
MarketPriceSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
6. legacy-методы получения цены и snapshot:
|
||||||
|
|
||||||
|
```text
|
||||||
|
get_price()
|
||||||
|
get_market_snapshot()
|
||||||
|
get_fresh_market_snapshot()
|
||||||
|
refresh_price_cache()
|
||||||
|
refresh_market_snapshot_cache()
|
||||||
|
_get_real_price()
|
||||||
|
```
|
||||||
|
|
||||||
|
7. consumers, которые продолжали зависеть от старых контрактов.
|
||||||
|
|
||||||
|
Такое состояние создавало несколько параллельных представлений одной и той же рыночной информации и препятствовало завершению миграции Market Data Acquisition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Архитектурный принцип Build 039
|
||||||
|
|
||||||
|
После Build 039 каноническим представлением текущей рыночной котировки является:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
из:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src.market_data.acquisition.models.quote
|
||||||
|
```
|
||||||
|
|
||||||
|
Архитектурный поток:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Dzengi API / Stream
|
||||||
|
↓
|
||||||
|
Market Data Acquisition
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
Quote Store / Market Price Cache
|
||||||
|
↓
|
||||||
|
ExchangeService
|
||||||
|
↓
|
||||||
|
Trading / Telegram / Diagnostics consumers
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy-модели `TickerPrice` и `MarketPriceSnapshot` больше не должны использоваться в `src` и `tests`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Каноническая модель Quote
|
||||||
|
|
||||||
|
Каноническая модель расположена в:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/market_data/acquisition/models/quote.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Она является единым представлением текущей котировки инструмента и содержит данные, необходимые downstream-consumers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
symbol
|
||||||
|
last_price
|
||||||
|
bid_price
|
||||||
|
ask_price
|
||||||
|
exchange_timestamp
|
||||||
|
received_at
|
||||||
|
source
|
||||||
|
```
|
||||||
|
|
||||||
|
Использование нескольких параллельных price/snapshot-моделей для одной и той же задачи после Build 039 не допускается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Изменённые области проекта
|
||||||
|
|
||||||
|
В рамках Build 039 были затронуты следующие основные области.
|
||||||
|
|
||||||
|
### 5.1. Exchange Integration
|
||||||
|
|
||||||
|
Основные файлы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/integrations/exchange/market_cache.py
|
||||||
|
app/src/integrations/exchange/market_data_runner.py
|
||||||
|
app/src/integrations/exchange/market_stream.py
|
||||||
|
app/src/integrations/exchange/mock_data.py
|
||||||
|
app/src/integrations/exchange/models.py
|
||||||
|
app/src/integrations/exchange/service.py
|
||||||
|
app/src/integrations/exchange/status.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Основные изменения:
|
||||||
|
|
||||||
|
- переход cache на каноническую модель `Quote`;
|
||||||
|
- переход market data runner на `Quote`;
|
||||||
|
- переход market stream на `Quote`;
|
||||||
|
- переход mock-котировок на `Quote`;
|
||||||
|
- удаление legacy price/snapshot-моделей;
|
||||||
|
- перевод `ExchangeService` на канонические методы получения котировок;
|
||||||
|
- сохранение runtime-status поведения;
|
||||||
|
- сохранение legacy dict-контракта там, где он ещё необходим существующему UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2. Storage
|
||||||
|
|
||||||
|
Основные файлы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/storage/quote_store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Quote Store используется как канонический storage-контракт для текущих котировок.
|
||||||
|
|
||||||
|
Store работает с моделью:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
и не должен зависеть от:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TickerPrice
|
||||||
|
MarketPriceSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3. Telegram UI
|
||||||
|
|
||||||
|
Основные файлы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/telegram/handlers/auto/ui.py
|
||||||
|
app/src/telegram/handlers/debug_auto/ui.py
|
||||||
|
app/src/telegram/ui/currency_ui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers пользовательского интерфейса переведены на новые quote-контракты.
|
||||||
|
|
||||||
|
Критически важное требование:
|
||||||
|
|
||||||
|
> Миграция внутренней модели данных не должна сама по себе изменять пользовательское представление данных.
|
||||||
|
|
||||||
|
Поэтому при миграции должны сохраняться существующие форматы:
|
||||||
|
|
||||||
|
- цены;
|
||||||
|
- возраста данных;
|
||||||
|
- времени обновления;
|
||||||
|
- UI-строк;
|
||||||
|
- fallback-значений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4. Trading Auto
|
||||||
|
|
||||||
|
Основные файлы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/trading/auto/execution_quality.py
|
||||||
|
app/src/trading/auto/signal_runtime.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Торговые consumers переведены с legacy market snapshot API на канонические quote-контракты.
|
||||||
|
|
||||||
|
Торговая логика, не относящаяся непосредственно к источнику котировки, не должна изменяться в рамках Build 039.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5. Trading Debug
|
||||||
|
|
||||||
|
Основной файл:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/trading/debug/execution.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Debug execution переведён на новый quote-контракт.
|
||||||
|
|
||||||
|
Legacy-вызов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
get_fresh_market_snapshot()
|
||||||
|
```
|
||||||
|
|
||||||
|
удалён.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6. Trading Diagnostics
|
||||||
|
|
||||||
|
Основной файл:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/trading/diagnostics/snapshot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Diagnostics consumer переведён на актуальное представление рыночной котировки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7. Trading Strategies
|
||||||
|
|
||||||
|
Основные файлы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/trading/strategies/scalp.py
|
||||||
|
app/src/trading/strategies/trend.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Стратегии переведены с legacy snapshot API на каноническую модель `Quote`.
|
||||||
|
|
||||||
|
После миграции стратегии не должны использовать:
|
||||||
|
|
||||||
|
```text
|
||||||
|
get_market_snapshot()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ExecutionPriceSnapshot
|
||||||
|
|
||||||
|
В рамках Build 039 необходимо различать две сущности:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
и:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ExecutionPriceSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
`Quote` является канонической моделью текущей рыночной котировки.
|
||||||
|
|
||||||
|
`ExecutionPriceSnapshot` является специализированным downstream-контрактом execution layer и может существовать отдельно, поскольку представляет данные в форме, непосредственно необходимой исполнению торговых операций.
|
||||||
|
|
||||||
|
Таким образом:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
ExecutionPriceSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
не является дублированием legacy-модели `MarketPriceSnapshot`.
|
||||||
|
|
||||||
|
`ExecutionPriceSnapshot` сохраняется как специализированная execution-модель.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Runtime status и stale market data
|
||||||
|
|
||||||
|
Во время Build 039 особое внимание было уделено методу:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExchangeService.get_symbol_runtime_status()
|
||||||
|
```
|
||||||
|
|
||||||
|
Для открытого рынка выполняется проверка актуальности рыночной котировки.
|
||||||
|
|
||||||
|
Актуальный поток:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validate_symbol()
|
||||||
|
↓
|
||||||
|
build_market_status_from_symbol_status()
|
||||||
|
↓
|
||||||
|
если рынок открыт
|
||||||
|
↓
|
||||||
|
_get_fresh_quote()
|
||||||
|
↓
|
||||||
|
exchange_timestamp
|
||||||
|
↓
|
||||||
|
_exchange_timestamp_age_seconds()
|
||||||
|
↓
|
||||||
|
если возраст > 60 секунд
|
||||||
|
↓
|
||||||
|
build_market_stale_status()
|
||||||
|
```
|
||||||
|
|
||||||
|
Порог stale market data:
|
||||||
|
|
||||||
|
```text
|
||||||
|
age_seconds > 60
|
||||||
|
```
|
||||||
|
|
||||||
|
При возрасте ровно:
|
||||||
|
|
||||||
|
```text
|
||||||
|
60.0
|
||||||
|
```
|
||||||
|
|
||||||
|
рынок остаётся в статусе `OPEN`.
|
||||||
|
|
||||||
|
При возрасте:
|
||||||
|
|
||||||
|
```text
|
||||||
|
61.0
|
||||||
|
```
|
||||||
|
|
||||||
|
возвращается:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ExchangeStatusCode.BREAK
|
||||||
|
reason = "market_data_stale"
|
||||||
|
raw_status = "STALE_MARKET_DATA"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Формат времени stale market data
|
||||||
|
|
||||||
|
Для stale market status используется exchange timestamp котировки.
|
||||||
|
|
||||||
|
Преобразование выполняется следующим образом:
|
||||||
|
|
||||||
|
```python
|
||||||
|
exchange_timestamp_ms = (
|
||||||
|
int(quote.exchange_timestamp.timestamp() * 1000)
|
||||||
|
if quote.exchange_timestamp is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого timestamp передаётся в:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._format_exchange_time(exchange_timestamp_ms)
|
||||||
|
```
|
||||||
|
|
||||||
|
Это необходимо для сохранения существующего формата пользовательского сообщения.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```text
|
||||||
|
10.07.2026 12:00:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Недопустимо передавать объект `datetime` непосредственно в функцию, ожидающую числовой timestamp в миллисекундах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Сохранение legacy-поведения форматирования
|
||||||
|
|
||||||
|
Во время реализации Build 039 было выявлено критически важное правило миграции.
|
||||||
|
|
||||||
|
Две функции, решающие похожую задачу, не являются автоматически эквивалентными.
|
||||||
|
|
||||||
|
Например:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _format_age(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
try:
|
||||||
|
age = max(0.0, float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
if age < 1:
|
||||||
|
return f"{age:.2f}с"
|
||||||
|
|
||||||
|
if age < 10:
|
||||||
|
return f"{age:.1f}с"
|
||||||
|
|
||||||
|
total_seconds = int(age)
|
||||||
|
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
seconds = total_seconds % 60
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}ч {minutes:02d}м"
|
||||||
|
|
||||||
|
if minutes > 0:
|
||||||
|
return f"{minutes}м {seconds:02d}с"
|
||||||
|
```
|
||||||
|
|
||||||
|
и:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _format_age(
|
||||||
|
value: NumericLike | None,
|
||||||
|
) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
try:
|
||||||
|
age = max(0.0, float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
if age < 1:
|
||||||
|
return "< 1 сек."
|
||||||
|
|
||||||
|
return f"{age:.1f} сек."
|
||||||
|
```
|
||||||
|
|
||||||
|
неэквивалентны.
|
||||||
|
|
||||||
|
Они различаются по:
|
||||||
|
|
||||||
|
- формату значений меньше секунды;
|
||||||
|
- единицам измерения;
|
||||||
|
- точности;
|
||||||
|
- представлению минут;
|
||||||
|
- представлению часов.
|
||||||
|
|
||||||
|
Следовательно, при миграции consumer нельзя заменять старую функцию новой только на основании сходства назначения.
|
||||||
|
|
||||||
|
Необходимо сравнивать фактические контракты поведения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Тесты Build 039
|
||||||
|
|
||||||
|
В рамках Build 039 использовались и актуализировались следующие основные тесты:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/tests/unit/integrations/exchange/test_market_cache.py
|
||||||
|
app/tests/unit/integrations/exchange/test_market_data_runner.py
|
||||||
|
app/tests/unit/integrations/exchange/test_market_stream.py
|
||||||
|
app/tests/unit/integrations/exchange/test_service_execution_quote.py
|
||||||
|
app/tests/unit/integrations/exchange/test_service_quote.py
|
||||||
|
app/tests/unit/integrations/exchange/test_service_symbol_runtime_status.py
|
||||||
|
app/tests/unit/storage/test_quote_store.py
|
||||||
|
app/tests/unit/trading/auto/test_execution_quality.py
|
||||||
|
app/tests/unit/trading/auto/test_signal_runtime_quote.py
|
||||||
|
app/tests/unit/trading/debug/test_execution.py
|
||||||
|
app/tests/unit/trading/strategies/test_scalp_quote.py
|
||||||
|
app/tests/unit/trading/strategies/test_trend_quote.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты должны проверять не только отсутствие ошибок выполнения, но и отсутствие возврата к legacy API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Исправление legacy-guard тестов
|
||||||
|
|
||||||
|
После переключения production consumers некоторые тесты всё ещё содержали искусственные методы-заглушки вида:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_market_snapshot(self, *_: object, **__: object) -> object:
|
||||||
|
raise AssertionError("legacy get_market_snapshot() must not be used")
|
||||||
|
```
|
||||||
|
|
||||||
|
и:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_fresh_market_snapshot(self, *_: object, **__: object) -> object:
|
||||||
|
raise AssertionError("legacy get_fresh_market_snapshot() must not be used")
|
||||||
|
```
|
||||||
|
|
||||||
|
После завершения миграции эти заглушки были удалены из тестов, поскольку финальный архитектурный grep должен подтверждать полное отсутствие legacy API во всём дереве:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src
|
||||||
|
tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Это касается файлов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/unit/trading/auto/test_execution_quality.py
|
||||||
|
tests/unit/trading/auto/test_signal_runtime_quote.py
|
||||||
|
tests/unit/trading/strategies/test_trend_quote.py
|
||||||
|
tests/unit/trading/strategies/test_scalp_quote.py
|
||||||
|
tests/unit/trading/debug/test_execution.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Проверка синтаксиса
|
||||||
|
|
||||||
|
В ходе реализации выполнялась проверка изменённых Python-файлов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m py_compile \
|
||||||
|
src/integrations/exchange/service.py \
|
||||||
|
tests/unit/integrations/exchange/test_service_symbol_runtime_status.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка завершилась успешно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Целевая проверка runtime status
|
||||||
|
|
||||||
|
Выполнена команда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest \
|
||||||
|
tests/unit/integrations/exchange/test_service_symbol_runtime_status.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
34 passed in 1.44s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Полный regression suite
|
||||||
|
|
||||||
|
После завершения всех изменений выполнен полный набор тестов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
606 passed in 1.63s
|
||||||
|
```
|
||||||
|
|
||||||
|
Все тесты успешно пройдены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Финальный архитектурный grep
|
||||||
|
|
||||||
|
Выполнена команда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -RIn \
|
||||||
|
--exclude-dir="__pycache__" \
|
||||||
|
--exclude="*.pyc" \
|
||||||
|
-E "TickerPrice|MarketPriceSnapshot|get_price\(|get_market_snapshot\(|get_fresh_market_snapshot\(|refresh_price_cache\(|refresh_market_snapshot_cache\(|_get_real_price\(" \
|
||||||
|
src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<пусто>
|
||||||
|
```
|
||||||
|
|
||||||
|
Это подтверждает отсутствие legacy price/snapshot API в:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src
|
||||||
|
tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Критерии завершения Build 039
|
||||||
|
|
||||||
|
Build 039 считается завершённым, поскольку выполнены все критерии:
|
||||||
|
|
||||||
|
- [x] `TickerPrice` удалён из `src` и `tests`;
|
||||||
|
- [x] `MarketPriceSnapshot` удалён из `src` и `tests`;
|
||||||
|
- [x] `get_price()` удалён из `src` и `tests`;
|
||||||
|
- [x] `get_market_snapshot()` удалён из `src` и `tests`;
|
||||||
|
- [x] `get_fresh_market_snapshot()` удалён из `src` и `tests`;
|
||||||
|
- [x] `refresh_price_cache()` удалён из `src` и `tests`;
|
||||||
|
- [x] `refresh_market_snapshot_cache()` удалён из `src` и `tests`;
|
||||||
|
- [x] `_get_real_price()` удалён из `src` и `tests`;
|
||||||
|
- [x] trading consumers используют новый quote-контракт;
|
||||||
|
- [x] Telegram consumers используют новый quote-контракт;
|
||||||
|
- [x] diagnostics consumers используют новый quote-контракт;
|
||||||
|
- [x] стратегии используют новый quote-контракт;
|
||||||
|
- [x] runtime status использует каноническую модель `Quote`;
|
||||||
|
- [x] stale market data detection сохранена;
|
||||||
|
- [x] формат exchange timestamp восстановлен;
|
||||||
|
- [x] legacy UI-поведение сохранено;
|
||||||
|
- [x] целевые тесты проходят;
|
||||||
|
- [x] полный regression suite проходит;
|
||||||
|
- [x] финальный архитектурный grep пуст.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Итоговое состояние после Build 039
|
||||||
|
|
||||||
|
После завершения Build 039 архитектура текущих котировок приведена к следующему состоянию:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Exchange / Market Data Sources
|
||||||
|
↓
|
||||||
|
Market Data Acquisition
|
||||||
|
↓
|
||||||
|
Canonical Quote
|
||||||
|
↓
|
||||||
|
Quote Store / Market Price Cache
|
||||||
|
↓
|
||||||
|
ExchangeService
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Trading Auto │
|
||||||
|
│ Trading Strategies │
|
||||||
|
│ Trading Debug │
|
||||||
|
│ Trading Diagnostics │
|
||||||
|
│ Telegram UI │
|
||||||
|
│ Execution │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy price/snapshot API полностью удалён из production-кода и тестов.
|
||||||
|
|
||||||
|
Build 039 завершает миграцию consumers текущей рыночной цены на каноническую модель `Quote`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Зафиксированный принцип для следующих Builds
|
||||||
|
|
||||||
|
Начиная с Build 039, при любой последующей миграции необходимо соблюдать следующее правило:
|
||||||
|
|
||||||
|
> Замена legacy-компонента новым каноническим компонентом не должна незаметно изменять существующее внешнее или внутреннее поведение consumers.
|
||||||
|
|
||||||
|
Перед заменой необходимо сравнивать:
|
||||||
|
|
||||||
|
```text
|
||||||
|
тип входных данных
|
||||||
|
тип возвращаемого значения
|
||||||
|
формат результата
|
||||||
|
точность
|
||||||
|
единицы измерения
|
||||||
|
граничные условия
|
||||||
|
fallback-поведение
|
||||||
|
exception semantics
|
||||||
|
временные зоны
|
||||||
|
формат времени
|
||||||
|
UI-представление
|
||||||
|
```
|
||||||
|
|
||||||
|
Любое намеренное изменение поведения должно быть явно зафиксировано в документации соответствующего Build и покрыто тестами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Статус Build
|
||||||
|
|
||||||
|
```text
|
||||||
|
Build 039: COMPLETED
|
||||||
|
Regression suite: 606 passed
|
||||||
|
Legacy grep: empty
|
||||||
|
Canonical quote model: Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
Build 039 полностью завершён.
|
||||||
853
docs/migrations/build_040.md
Normal file
853
docs/migrations/build_040.md
Normal file
@@ -0,0 +1,853 @@
|
|||||||
|
# Build 040 — Восстановление принудительного REST refresh котировки на канонической модели Quote
|
||||||
|
|
||||||
|
**Статус:** Завершён
|
||||||
|
**Проект:** Dzentra
|
||||||
|
**Подсистема:** Market Data Acquisition / Exchange Integration / Market Data Runtime
|
||||||
|
**Тип изменения:** Восстановление runtime-контракта после миграции legacy price/snapshot API
|
||||||
|
**Язык документа:** Русский
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цель Build 040
|
||||||
|
|
||||||
|
Цель Build 040 — восстановить принудительный REST refresh текущей рыночной котировки для `MarketDataRunner` после завершения Build 039 и удаления legacy price/snapshot API.
|
||||||
|
|
||||||
|
В Build 039 были удалены legacy-сущности и методы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TickerPrice
|
||||||
|
MarketPriceSnapshot
|
||||||
|
get_price()
|
||||||
|
get_market_snapshot()
|
||||||
|
get_fresh_market_snapshot()
|
||||||
|
refresh_price_cache()
|
||||||
|
refresh_market_snapshot_cache()
|
||||||
|
_get_real_price()
|
||||||
|
```
|
||||||
|
|
||||||
|
После завершения миграции был обнаружен один оставшийся активный consumer удалённого метода:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/integrations/exchange/market_data_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
который продолжал вызывать:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExchangeService().refresh_market_snapshot_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Таким образом, целью Build 040 являлось не изменение runtime-архитектуры и не новый рефакторинг, а минимальное восстановление разорванного контракта на основе канонической модели:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src.market_data.acquisition.models.quote.Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
При этом необходимо было сохранить существующую логику работы старого бота без изменений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Исходное состояние
|
||||||
|
|
||||||
|
После Build 039 проект находился в следующем состоянии:
|
||||||
|
|
||||||
|
1. все основные consumers текущей рыночной цены были переведены на `Quote`;
|
||||||
|
|
||||||
|
2. legacy-модели:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TickerPrice
|
||||||
|
MarketPriceSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
были удалены;
|
||||||
|
|
||||||
|
3. legacy-методы получения и обновления price/snapshot были удалены;
|
||||||
|
|
||||||
|
4. полный regression suite успешно проходил:
|
||||||
|
|
||||||
|
```text
|
||||||
|
606 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
5. контрольный grep по основным legacy-контрактам был пустым;
|
||||||
|
|
||||||
|
6. при дополнительном анализе runtime-компонентов был обнаружен оставшийся вызов:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExchangeService().refresh_market_snapshot_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
в:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/integrations/exchange/market_data_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Метод уже отсутствовал в `ExchangeService`, поэтому существовал разорванный runtime-контракт, который не проявлялся в предыдущем полном наборе тестов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Архитектурный принцип Build 040
|
||||||
|
|
||||||
|
Build 040 сохраняет утверждённый архитектурный поток:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Exchange / Market Data Sources
|
||||||
|
↓
|
||||||
|
Market Data Acquisition
|
||||||
|
↓
|
||||||
|
Canonical Quote
|
||||||
|
↓
|
||||||
|
Quote Store / Market Price Cache
|
||||||
|
↓
|
||||||
|
ExchangeService
|
||||||
|
↓
|
||||||
|
MarketDataRunner / Trading Consumers
|
||||||
|
```
|
||||||
|
|
||||||
|
Канонической моделью текущей котировки остаётся:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
из:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src.market_data.acquisition.models.quote
|
||||||
|
```
|
||||||
|
|
||||||
|
Build 040 не вводит новую параллельную price/snapshot-модель и не восстанавливает удалённые legacy-контракты.
|
||||||
|
|
||||||
|
Вместо этого добавляется минимальный публичный compatibility-метод:
|
||||||
|
|
||||||
|
```python
|
||||||
|
refresh_quote_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
который использует существующий канонический acquisition pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Главное требование сохранения поведения
|
||||||
|
|
||||||
|
Основным требованием Build 040 являлось:
|
||||||
|
|
||||||
|
> Не менять логику работы текущих файлов и существующего runtime.
|
||||||
|
|
||||||
|
Поэтому перед реализацией была восстановлена фактическая старая реализация удалённого метода:
|
||||||
|
|
||||||
|
```python
|
||||||
|
refresh_market_snapshot_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
из истории Git.
|
||||||
|
|
||||||
|
Это позволило определить реальный контракт старого метода без предположений.
|
||||||
|
|
||||||
|
Старая реализация выполняла:
|
||||||
|
|
||||||
|
```text
|
||||||
|
нормализация runtime_key
|
||||||
|
↓
|
||||||
|
принудительное получение свежего REST snapshot
|
||||||
|
↓
|
||||||
|
проверка ценовых полей
|
||||||
|
↓
|
||||||
|
запись результата в MarketPriceCache
|
||||||
|
↓
|
||||||
|
возврат свежего snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Критически важно:
|
||||||
|
|
||||||
|
- cache перед REST-запросом не читался;
|
||||||
|
- всегда выполнялось принудительное получение свежих данных;
|
||||||
|
- результат сохранялся в cache соответствующего runtime;
|
||||||
|
- возвращался свежий результат;
|
||||||
|
- ошибки не подавлялись самим refresh-методом;
|
||||||
|
- mock-режим сохранялся.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Восстановление старого контракта через Git
|
||||||
|
|
||||||
|
Для определения фактического поведения удалённого метода была использована история Git.
|
||||||
|
|
||||||
|
Команда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log -S"def refresh_market_snapshot_cache" \
|
||||||
|
--oneline \
|
||||||
|
-- src/integrations/exchange/service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
a996f2f feat: add market data architecture and complete migration through build 039
|
||||||
|
e97dcd3 07.4.3.16 — Production Execution Pricing Layer
|
||||||
|
```
|
||||||
|
|
||||||
|
Старая реализация была извлечена командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git show a996f2f^:app/src/integrations/exchange/service.py \
|
||||||
|
| grep -n -A45 -B10 "def refresh_market_snapshot_cache"
|
||||||
|
```
|
||||||
|
|
||||||
|
Восстановленный контракт подтвердил, что новый метод должен быть принудительным refresh-механизмом и не должен использовать обычную cache-first логику `get_quote()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Почему get_quote() не подходит для REST fallback
|
||||||
|
|
||||||
|
Существующий метод:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExchangeService.get_quote()
|
||||||
|
```
|
||||||
|
|
||||||
|
реализует cache-first поведение:
|
||||||
|
|
||||||
|
```text
|
||||||
|
валидация символа
|
||||||
|
↓
|
||||||
|
чтение MarketPriceCache
|
||||||
|
↓
|
||||||
|
если Quote свежий
|
||||||
|
↓
|
||||||
|
возврат cached Quote
|
||||||
|
↓
|
||||||
|
иначе REST acquisition
|
||||||
|
```
|
||||||
|
|
||||||
|
Такое поведение корректно для обычных consumers, но не подходит для:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MarketDataRunner._rest_fallback_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
После отказа WebSocket runner должен проверить именно доступность REST-механизма.
|
||||||
|
|
||||||
|
Если использовать `get_quote()`, возможна ситуация:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocket отключился
|
||||||
|
↓
|
||||||
|
в cache остаётся свежий WebSocket Quote
|
||||||
|
↓
|
||||||
|
get_quote() возвращает cached Quote
|
||||||
|
↓
|
||||||
|
REST-запрос фактически не выполняется
|
||||||
|
↓
|
||||||
|
runner ошибочно устанавливает REST state = AVAILABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
Поэтому Build 040 вводит отдельный принудительный refresh-контракт.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Новый публичный контракт refresh_quote_cache()
|
||||||
|
|
||||||
|
В `ExchangeService` добавлен метод:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def refresh_quote_cache(
|
||||||
|
self,
|
||||||
|
symbol: str | None = None,
|
||||||
|
*,
|
||||||
|
runtime_key: str | None = None,
|
||||||
|
) -> Quote:
|
||||||
|
```
|
||||||
|
|
||||||
|
Его ответственность:
|
||||||
|
|
||||||
|
```text
|
||||||
|
нормализовать runtime_key
|
||||||
|
↓
|
||||||
|
определить symbol
|
||||||
|
↓
|
||||||
|
если exchange disabled
|
||||||
|
получить mock Quote
|
||||||
|
иначе
|
||||||
|
валидировать symbol
|
||||||
|
↓
|
||||||
|
принудительно получить свежий Quote через REST acquisition
|
||||||
|
↓
|
||||||
|
сохранить тот же Quote в MarketPriceCache
|
||||||
|
↓
|
||||||
|
вернуть тот же Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
Метод не читает cache перед получением свежей котировки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Сохранение mock-поведения
|
||||||
|
|
||||||
|
Старый метод:
|
||||||
|
|
||||||
|
```python
|
||||||
|
refresh_market_snapshot_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
через:
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_fresh_market_snapshot()
|
||||||
|
```
|
||||||
|
|
||||||
|
поддерживал режим:
|
||||||
|
|
||||||
|
```text
|
||||||
|
exchange_enabled = False
|
||||||
|
```
|
||||||
|
|
||||||
|
В этом режиме создавались mock-данные, которые затем сохранялись в cache.
|
||||||
|
|
||||||
|
Новый метод сохраняет эквивалентное поведение через:
|
||||||
|
|
||||||
|
```python
|
||||||
|
mock_quote(symbol_to_use)
|
||||||
|
```
|
||||||
|
|
||||||
|
При выключенной бирже:
|
||||||
|
|
||||||
|
- REST acquisition не выполняется;
|
||||||
|
- validation через exchange reference data не требуется;
|
||||||
|
- создаётся канонический `Quote`;
|
||||||
|
- тот же объект сохраняется в `MarketPriceCache`;
|
||||||
|
- тот же объект возвращается consumer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Принудительное получение свежего Quote
|
||||||
|
|
||||||
|
В real-режиме новый метод использует:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._get_fresh_quote(
|
||||||
|
validation.normalized_symbol,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот метод вызывает существующий канонический pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DzengiQuoteDocumentSource
|
||||||
|
↓
|
||||||
|
DzengiQuoteDocumentHandler
|
||||||
|
↓
|
||||||
|
QuotesFeed
|
||||||
|
↓
|
||||||
|
QuoteFeedRegistry
|
||||||
|
↓
|
||||||
|
QuoteAcquisitionService
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
Таким образом, Build 040 не добавляет новый способ получения котировок и не дублирует Market Data Acquisition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Почему QuoteAcquisitionService не изменялся
|
||||||
|
|
||||||
|
В ходе анализа было подтверждено, что:
|
||||||
|
|
||||||
|
```python
|
||||||
|
QuoteAcquisitionService
|
||||||
|
```
|
||||||
|
|
||||||
|
намеренно отвечает только за получение канонической модели через зарегистрированный feed.
|
||||||
|
|
||||||
|
Он не должен выполнять:
|
||||||
|
|
||||||
|
- runtime isolation;
|
||||||
|
- cache management;
|
||||||
|
- fallback orchestration;
|
||||||
|
- validation торгового runtime;
|
||||||
|
- выбор `runtime_key`;
|
||||||
|
- compatibility-поведение старого бота.
|
||||||
|
|
||||||
|
Поэтому Build 040 не изменяет:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/market_data/acquisition/service.py
|
||||||
|
app/src/market_data/acquisition/feeds/quotes_feed.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatibility и runtime semantics остаются в:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ExchangeService
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Изменение MarketDataRunner
|
||||||
|
|
||||||
|
В:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/integrations/exchange/market_data_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
был изменён только один runtime-вызов.
|
||||||
|
|
||||||
|
До Build 040:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExchangeService().refresh_market_snapshot_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
После Build 040:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExchangeService().refresh_quote_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
При этом не изменялись:
|
||||||
|
|
||||||
|
- `asyncio.to_thread`;
|
||||||
|
- передаваемый symbol;
|
||||||
|
- `runtime_key`;
|
||||||
|
- `last_rest_state`;
|
||||||
|
- `last_rest_error_key`;
|
||||||
|
- WebSocket-first поведение;
|
||||||
|
- cooldown;
|
||||||
|
- retry-логика;
|
||||||
|
- journal events;
|
||||||
|
- event titles;
|
||||||
|
- lifecycle runner;
|
||||||
|
- exception handling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Runtime isolation
|
||||||
|
|
||||||
|
Новый метод сохраняет существующее разделение runtime-контекстов через:
|
||||||
|
|
||||||
|
```python
|
||||||
|
runtime_key
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед использованием ключ нормализуется существующим методом:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._runtime_key(runtime_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
Котировка сохраняется через:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MarketPriceCache.set_quote(
|
||||||
|
quote,
|
||||||
|
runtime_key=normalized_runtime_key,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Это сохраняет независимость runtime-контекстов, включая:
|
||||||
|
|
||||||
|
```text
|
||||||
|
auto
|
||||||
|
debug_auto
|
||||||
|
default
|
||||||
|
```
|
||||||
|
|
||||||
|
и другие существующие значения, если они используются текущим runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Сохранение identity канонического Quote
|
||||||
|
|
||||||
|
Каноническая модель:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Quote
|
||||||
|
```
|
||||||
|
|
||||||
|
является immutable-моделью:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Build 040 не создаёт копию полученного объекта.
|
||||||
|
|
||||||
|
Последовательность:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fresh Quote
|
||||||
|
↓
|
||||||
|
MarketPriceCache.set_quote(quote)
|
||||||
|
↓
|
||||||
|
return quote
|
||||||
|
```
|
||||||
|
|
||||||
|
В cache сохраняется тот же экземпляр, который возвращается из метода.
|
||||||
|
|
||||||
|
Это зафиксировано тестами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Изменённые production-файлы
|
||||||
|
|
||||||
|
В рамках Build 040 изменены только два production-файла:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/integrations/exchange/service.py
|
||||||
|
app/src/integrations/exchange/market_data_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.1. service.py
|
||||||
|
|
||||||
|
Добавлен новый публичный метод:
|
||||||
|
|
||||||
|
```python
|
||||||
|
refresh_quote_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
Существующие методы не подвергались сопутствующему рефакторингу.
|
||||||
|
|
||||||
|
В частности, не изменялись:
|
||||||
|
|
||||||
|
```text
|
||||||
|
get_quote()
|
||||||
|
get_execution_snapshot()
|
||||||
|
_get_fresh_quote()
|
||||||
|
_load_quote_via_acquisition()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2. market_data_runner.py
|
||||||
|
|
||||||
|
Изменена только ссылка на удалённый legacy refresh-метод.
|
||||||
|
|
||||||
|
Остальная логика runner сохранена.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Изменённые тестовые файлы
|
||||||
|
|
||||||
|
В рамках Build 040 изменены:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/tests/unit/integrations/exchange/test_service_quote.py
|
||||||
|
app/tests/unit/integrations/exchange/test_market_data_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавлены тесты нового refresh-контракта и REST fallback runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Тесты refresh_quote_cache()
|
||||||
|
|
||||||
|
Тестами зафиксировано следующее поведение:
|
||||||
|
|
||||||
|
- принудительное получение свежего `Quote`;
|
||||||
|
- отсутствие cache-first поведения;
|
||||||
|
- замена существующей cache-записи новым объектом;
|
||||||
|
- сохранение того же экземпляра `Quote`;
|
||||||
|
- использование правильного `runtime_key`;
|
||||||
|
- нормализация runtime key;
|
||||||
|
- использование default runtime key;
|
||||||
|
- сохранение `ExchangeError` для невалидного символа;
|
||||||
|
- отсутствие REST acquisition при невалидном символе;
|
||||||
|
- сохранение exception semantics acquisition pipeline;
|
||||||
|
- отсутствие обновления cache при ошибке;
|
||||||
|
- сохранение mock-поведения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Тесты REST fallback MarketDataRunner
|
||||||
|
|
||||||
|
Тестами зафиксировано поведение:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MarketDataRunner._rest_fallback_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
При успешном refresh:
|
||||||
|
|
||||||
|
```text
|
||||||
|
refresh_quote_cache() вызывается
|
||||||
|
↓
|
||||||
|
передаётся текущий symbol
|
||||||
|
↓
|
||||||
|
передаётся context.runtime_key
|
||||||
|
↓
|
||||||
|
last_rest_state = AVAILABLE
|
||||||
|
↓
|
||||||
|
last_rest_error_key = None
|
||||||
|
```
|
||||||
|
|
||||||
|
При ошибке сохраняется существующее поведение runner:
|
||||||
|
|
||||||
|
```text
|
||||||
|
last_rest_state = UNAVAILABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
и существующая обработка ошибки не изменяется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Целевая проверка Build 040
|
||||||
|
|
||||||
|
Выполнена команда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q \
|
||||||
|
tests/unit/integrations/exchange/test_service_quote.py \
|
||||||
|
tests/unit/integrations/exchange/test_market_data_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
18 passed in 0.11s
|
||||||
|
```
|
||||||
|
|
||||||
|
Все целевые тесты Build 040 успешно пройдены.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Связанные регрессионные тесты
|
||||||
|
|
||||||
|
Выполнена команда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q \
|
||||||
|
tests/unit/integrations/exchange/test_service_execution_quote.py \
|
||||||
|
tests/unit/integrations/exchange/test_service_symbol_runtime_status.py \
|
||||||
|
tests/unit/integrations/exchange/test_market_cache.py \
|
||||||
|
tests/unit/integrations/exchange/test_market_stream.py \
|
||||||
|
tests/unit/storage/test_quote_store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
107 passed in 1.81s
|
||||||
|
```
|
||||||
|
|
||||||
|
Связанные компоненты работают без регрессий.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Полный regression suite
|
||||||
|
|
||||||
|
После завершения Build 040 выполнен полный набор тестов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
614 passed in 2.03s
|
||||||
|
```
|
||||||
|
|
||||||
|
Все тесты успешно пройдены.
|
||||||
|
|
||||||
|
По сравнению с Build 039 количество тестов увеличилось:
|
||||||
|
|
||||||
|
```text
|
||||||
|
606 → 614
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавлено:
|
||||||
|
|
||||||
|
```text
|
||||||
|
8 тестов
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Финальный архитектурный grep
|
||||||
|
|
||||||
|
Выполнена команда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -RIn \
|
||||||
|
--exclude-dir="__pycache__" \
|
||||||
|
--exclude="*.pyc" \
|
||||||
|
-E "refresh_market_snapshot_cache|refresh_price_cache|MarketPriceSnapshot|TickerPrice" \
|
||||||
|
src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<пусто>
|
||||||
|
```
|
||||||
|
|
||||||
|
Это подтверждает отсутствие следующих legacy-контрактов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
refresh_market_snapshot_cache
|
||||||
|
refresh_price_cache
|
||||||
|
MarketPriceSnapshot
|
||||||
|
TickerPrice
|
||||||
|
```
|
||||||
|
|
||||||
|
в:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src
|
||||||
|
tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. Что намеренно не изменялось
|
||||||
|
|
||||||
|
В Build 040 намеренно не изменялись:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Market Data Acquisition architecture
|
||||||
|
Quote model
|
||||||
|
QuoteAcquisitionService
|
||||||
|
QuotesFeed
|
||||||
|
Quote Store
|
||||||
|
MarketPriceCache contract
|
||||||
|
get_quote()
|
||||||
|
get_execution_snapshot()
|
||||||
|
WebSocket adapter
|
||||||
|
market_stream.py
|
||||||
|
MarketDataRunner lifecycle
|
||||||
|
MarketDataRunner retry logic
|
||||||
|
runtime state transitions
|
||||||
|
journal events
|
||||||
|
event titles
|
||||||
|
Telegram UI
|
||||||
|
trading strategies
|
||||||
|
execution logic
|
||||||
|
diagnostics logic
|
||||||
|
```
|
||||||
|
|
||||||
|
Также не выполнялись:
|
||||||
|
|
||||||
|
- переносы файлов;
|
||||||
|
- переименования каталогов;
|
||||||
|
- изменение утверждённой структуры проекта;
|
||||||
|
- добавление новых abstraction layers;
|
||||||
|
- добавление override-параметров «на будущее»;
|
||||||
|
- удаление дополнительных legacy-компонентов вне границ Build 040.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. Критерии завершения Build 040
|
||||||
|
|
||||||
|
Build 040 считается завершённым, поскольку выполнены все критерии:
|
||||||
|
|
||||||
|
- [x] найден оставшийся активный вызов удалённого `refresh_market_snapshot_cache`;
|
||||||
|
- [x] фактический старый контракт восстановлен из Git;
|
||||||
|
- [x] новый контракт реализован на канонической модели `Quote`;
|
||||||
|
- [x] добавлен `refresh_quote_cache()`;
|
||||||
|
- [x] новый метод не использует cache-first поведение;
|
||||||
|
- [x] fresh Quote принудительно получается через существующий acquisition pipeline;
|
||||||
|
- [x] runtime isolation сохранена;
|
||||||
|
- [x] mock-поведение сохранено;
|
||||||
|
- [x] exception semantics сохранена;
|
||||||
|
- [x] тот же экземпляр `Quote` сохраняется и возвращается;
|
||||||
|
- [x] `MarketDataRunner` переключён на новый метод;
|
||||||
|
- [x] WebSocket-first логика не изменена;
|
||||||
|
- [x] lifecycle runner не изменён;
|
||||||
|
- [x] целевые тесты проходят;
|
||||||
|
- [x] связанные регрессионные тесты проходят;
|
||||||
|
- [x] полный regression suite проходит;
|
||||||
|
- [x] финальный legacy grep пуст.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 24. Итоговое состояние после Build 040
|
||||||
|
|
||||||
|
После завершения Build 040 runtime-поток получения котировки имеет следующий вид:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocket available
|
||||||
|
↓
|
||||||
|
DzengiWebSocketQuoteAdapter
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
MarketPriceCache
|
||||||
|
```
|
||||||
|
|
||||||
|
При отказе WebSocket:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocket unavailable
|
||||||
|
↓
|
||||||
|
MarketDataRunner._rest_fallback_once()
|
||||||
|
↓
|
||||||
|
ExchangeService.refresh_quote_cache()
|
||||||
|
↓
|
||||||
|
Quote Acquisition Pipeline
|
||||||
|
↓
|
||||||
|
Fresh REST Quote
|
||||||
|
↓
|
||||||
|
MarketPriceCache
|
||||||
|
↓
|
||||||
|
REST state = AVAILABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
При ошибке REST refresh:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REST acquisition error
|
||||||
|
↓
|
||||||
|
ExchangeError
|
||||||
|
↓
|
||||||
|
MarketDataRunner existing error handling
|
||||||
|
↓
|
||||||
|
REST state = UNAVAILABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
Таким образом, Build 040 восстанавливает разорванный fallback-контракт, не изменяя существующую runtime-логику.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 25. Зафиксированный принцип для следующих Builds
|
||||||
|
|
||||||
|
Build 040 подтверждает принцип, зафиксированный после Build 039:
|
||||||
|
|
||||||
|
> При миграции нельзя считать новую функцию эквивалентной старой только потому, что они решают похожую задачу.
|
||||||
|
|
||||||
|
Перед заменой legacy-компонента необходимо восстановить и сравнить фактическое поведение:
|
||||||
|
|
||||||
|
```text
|
||||||
|
порядок операций
|
||||||
|
чтение cache
|
||||||
|
запись cache
|
||||||
|
принудительность refresh
|
||||||
|
тип возвращаемого значения
|
||||||
|
identity результата
|
||||||
|
runtime isolation
|
||||||
|
mock-поведение
|
||||||
|
fallback-поведение
|
||||||
|
exception semantics
|
||||||
|
side effects
|
||||||
|
граничные условия
|
||||||
|
```
|
||||||
|
|
||||||
|
Если старый контракт доступен в истории Git, его необходимо использовать как источник фактического поведения вместо предположений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 26. Статус Build
|
||||||
|
|
||||||
|
```text
|
||||||
|
Build 040: COMPLETED
|
||||||
|
Target tests: 18 passed
|
||||||
|
Related regression tests: 107 passed
|
||||||
|
Full regression suite: 614 passed
|
||||||
|
Legacy grep: empty
|
||||||
|
Canonical quote model: Quote
|
||||||
|
REST fallback contract: refresh_quote_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
Build 040 полностью завершён.
|
||||||
514
docs/migrations/build_041.md
Normal file
514
docs/migrations/build_041.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
# Build 041 — Аудит и удаление остатков legacy quote parsing
|
||||||
|
|
||||||
|
**Статус:** завершён
|
||||||
|
**Подсистема:** Market Data Acquisition / Quotes Feed
|
||||||
|
**Проект:** Dzentra
|
||||||
|
**Тип изменения:** архитектурная миграция без изменения рабочего поведения
|
||||||
|
**Основное требование:** не изменять логику работы текущих файлов и наблюдаемое поведение бота
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цель Build
|
||||||
|
|
||||||
|
Цель Build 041 — выполнить аудит оставшихся production-зависимостей от legacy-разбора котировок и удалить подтверждённо неиспользуемую дублирующую логику WebSocket quote parsing из legacy integration layer.
|
||||||
|
|
||||||
|
Build продолжает миграцию Quotes Feed после:
|
||||||
|
|
||||||
|
- Build 039 — удаления legacy `TickerPrice` и market snapshot dict layer;
|
||||||
|
- Build 040 — восстановления и фиксации REST fallback через канонический Quotes acquisition pipeline.
|
||||||
|
|
||||||
|
Основной архитектурный принцип Build 041:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
Raw Dzengi WebSocket payload
|
||||||
|
↓
|
||||||
|
DzengiWebSocketQuoteAdapter
|
||||||
|
↓
|
||||||
|
schema validation
|
||||||
|
↓
|
||||||
|
parser
|
||||||
|
↓
|
||||||
|
value validation
|
||||||
|
↓
|
||||||
|
mapper
|
||||||
|
↓
|
||||||
|
canonical Quote
|
||||||
|
↓
|
||||||
|
runtime consumers
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Production-код вне `market_data/acquisition` не должен самостоятельно интерпретировать структуру WebSocket payload для получения `bid_price` и `ask_price`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Основное ограничение
|
||||||
|
|
||||||
|
Главное требование Build 041:
|
||||||
|
|
||||||
|
> **Не менять логику работы текущих файлов.**
|
||||||
|
|
||||||
|
В рамках Build запрещалось:
|
||||||
|
|
||||||
|
- изменять торговую логику;
|
||||||
|
- изменять алгоритмы принятия решений;
|
||||||
|
- изменять execution pricing semantics;
|
||||||
|
- изменять формат канонической модели `Quote`;
|
||||||
|
- изменять REST Quotes Feed;
|
||||||
|
- изменять WebSocket transport;
|
||||||
|
- изменять алгоритм переподключения WebSocket;
|
||||||
|
- изменять runtime lifecycle;
|
||||||
|
- изменять журналирование;
|
||||||
|
- изменять UI;
|
||||||
|
- изменять обработку торговых сигналов;
|
||||||
|
- изменять формат runtime-состояния;
|
||||||
|
- удалять код без доказанного отсутствия production-зависимостей.
|
||||||
|
|
||||||
|
Допускалось только удаление legacy quote parsing, если одновременно подтверждены следующие условия:
|
||||||
|
|
||||||
|
1. production-код уже использует канонический `DzengiWebSocketQuoteAdapter`;
|
||||||
|
2. удаляемая функция больше не вызывается;
|
||||||
|
3. соответствующая логика уже реализована в `market_data/acquisition`;
|
||||||
|
4. полный набор тестов подтверждает отсутствие регрессий.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Предварительный аудит
|
||||||
|
|
||||||
|
Перед изменениями был выполнен аудит всех известных признаков legacy quote parsing.
|
||||||
|
|
||||||
|
Проверялись:
|
||||||
|
|
||||||
|
- прямое чтение `lastPrice`;
|
||||||
|
- прямое чтение `bidPrice`;
|
||||||
|
- прямое чтение `askPrice`;
|
||||||
|
- прямое чтение `closeTime`;
|
||||||
|
- прямое чтение `eventTime`;
|
||||||
|
- прямое чтение `bids`;
|
||||||
|
- прямое чтение `asks`;
|
||||||
|
- функции извлечения best bid / best ask;
|
||||||
|
- старые market snapshot helpers;
|
||||||
|
- старые ticker price helpers;
|
||||||
|
- прямое создание `Quote`;
|
||||||
|
- REST endpoint `/api/v1/ticker/24hr`;
|
||||||
|
- все production-потребители `DzengiWebSocketQuoteAdapter`;
|
||||||
|
- все production-потребители `QuotesFeed`;
|
||||||
|
- все production-потребители `QuoteAcquisitionService`.
|
||||||
|
|
||||||
|
Аудит показал, что корректный разбор REST-котировок уже сосредоточен в:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
src/market_data/acquisition/
|
||||||
|
├── adapters/dzengi/models.py
|
||||||
|
├── adapters/dzengi/parser.py
|
||||||
|
├── adapters/dzengi/mapper.py
|
||||||
|
├── adapters/dzengi/rest.py
|
||||||
|
├── adapters/dzengi/websocket.py
|
||||||
|
├── validation/schema.py
|
||||||
|
├── validation/values.py
|
||||||
|
├── handlers/quotes_handler.py
|
||||||
|
├── feeds/quotes_feed.py
|
||||||
|
└── service.py
|
||||||
|
~~~
|
||||||
|
|
||||||
|
При этом в legacy integration layer оставались отдельные helpers, самостоятельно интерпретировавшие структуру WebSocket payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Обнаруженные legacy helpers
|
||||||
|
|
||||||
|
Аудит выявил следующие legacy-функции.
|
||||||
|
|
||||||
|
### `src/integrations/exchange/market_stream.py`
|
||||||
|
|
||||||
|
~~~python
|
||||||
|
def _extract_depth_prices(
|
||||||
|
event: JsonDict,
|
||||||
|
) -> tuple[float | None, float | None]:
|
||||||
|
...
|
||||||
|
~~~
|
||||||
|
|
||||||
|
~~~python
|
||||||
|
def _extract_first_price(
|
||||||
|
value: object,
|
||||||
|
) -> float | None:
|
||||||
|
...
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Эти функции самостоятельно разбирали:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
bids
|
||||||
|
asks
|
||||||
|
bidPrice
|
||||||
|
askPrice
|
||||||
|
~~~
|
||||||
|
|
||||||
|
и тем самым дублировали ответственность канонического Dzengi WebSocket quote adapter.
|
||||||
|
|
||||||
|
### `src/integrations/exchange/market_data_runner.py`
|
||||||
|
|
||||||
|
~~~python
|
||||||
|
def _extract_best_price(
|
||||||
|
...
|
||||||
|
) -> float | None:
|
||||||
|
...
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Функция также содержала legacy-логику извлечения цены непосредственно из сырой структуры WebSocket payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Подтверждение канонического пути
|
||||||
|
|
||||||
|
До удаления legacy helpers было подтверждено, что рабочий runtime уже использует:
|
||||||
|
|
||||||
|
~~~python
|
||||||
|
DzengiWebSocketQuoteAdapter
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Канонический путь обработки WebSocket-котировки:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
ExchangeWebSocketClient
|
||||||
|
↓
|
||||||
|
raw WebSocket message
|
||||||
|
↓
|
||||||
|
DzengiWebSocketQuoteAdapter.map_message()
|
||||||
|
↓
|
||||||
|
validate_dzengi_websocket_quote_schema()
|
||||||
|
↓
|
||||||
|
parse_dzengi_websocket_quote()
|
||||||
|
↓
|
||||||
|
validate_dzengi_websocket_quote_values()
|
||||||
|
↓
|
||||||
|
map_dzengi_websocket_quote_to_quote()
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Таким образом, production runtime получает уже каноническую модель:
|
||||||
|
|
||||||
|
~~~python
|
||||||
|
Quote
|
||||||
|
~~~
|
||||||
|
|
||||||
|
и не должен повторно разбирать сырой Dzengi payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Выполненные изменения
|
||||||
|
|
||||||
|
В Build 041 удалена подтверждённо избыточная legacy quote parsing logic.
|
||||||
|
|
||||||
|
Изменения затронули:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
app/src/integrations/exchange/market_stream.py
|
||||||
|
app/src/integrations/exchange/market_data_runner.py
|
||||||
|
app/tests/unit/integrations/exchange/test_market_stream.py
|
||||||
|
app/tests/unit/integrations/exchange/test_market_data_runner.py
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Удалены legacy helpers:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
_extract_depth_prices
|
||||||
|
_extract_first_price
|
||||||
|
_extract_best_price
|
||||||
|
_extract_market_event
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Также удалены остаточные прямые обращения вида:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
first.get("bidPrice")
|
||||||
|
first.get("askPrice")
|
||||||
|
~~~
|
||||||
|
|
||||||
|
в проверяемом legacy integration-контуре.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Что не изменялось
|
||||||
|
|
||||||
|
Build 041 не изменял:
|
||||||
|
|
||||||
|
### REST Quotes Feed
|
||||||
|
|
||||||
|
Не изменялись:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
DzengiQuoteDocumentSource
|
||||||
|
DzengiQuoteDocumentHandler
|
||||||
|
QuotesFeed
|
||||||
|
QuoteAcquisitionService
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Не изменялся endpoint:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
/api/v1/ticker/24hr
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Не изменялся REST fallback, восстановленный и зафиксированный в Build 040.
|
||||||
|
|
||||||
|
### Каноническая модель Quote
|
||||||
|
|
||||||
|
Не изменялись:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
symbol
|
||||||
|
last_price
|
||||||
|
bid_price
|
||||||
|
ask_price
|
||||||
|
source
|
||||||
|
exchange_timestamp
|
||||||
|
received_at
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### WebSocket acquisition pipeline
|
||||||
|
|
||||||
|
Не изменялись:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
DzengiWebSocketQuoteAdapter
|
||||||
|
validate_dzengi_websocket_quote_schema
|
||||||
|
parse_dzengi_websocket_quote
|
||||||
|
validate_dzengi_websocket_quote_values
|
||||||
|
map_dzengi_websocket_quote_to_quote
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### Runtime semantics
|
||||||
|
|
||||||
|
Не изменялись:
|
||||||
|
|
||||||
|
- выбор торгового инструмента;
|
||||||
|
- запуск и остановка market runtime;
|
||||||
|
- reconnect logic;
|
||||||
|
- обработка WebSocket-соединения;
|
||||||
|
- вычисление spread;
|
||||||
|
- формирование runtime-состояния;
|
||||||
|
- execution pricing;
|
||||||
|
- стратегия;
|
||||||
|
- автоторговля;
|
||||||
|
- Telegram UI;
|
||||||
|
- журнал.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Архитектурный результат
|
||||||
|
|
||||||
|
До Build 041 часть legacy integration layer всё ещё содержала собственную интерпретацию WebSocket payload:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
Raw WebSocket payload
|
||||||
|
├──→ legacy parser in market_stream
|
||||||
|
├──→ legacy parser in market_data_runner
|
||||||
|
└──→ canonical DzengiWebSocketQuoteAdapter
|
||||||
|
~~~
|
||||||
|
|
||||||
|
После Build 041:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
Raw WebSocket payload
|
||||||
|
↓
|
||||||
|
DzengiWebSocketQuoteAdapter
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
market_stream / market_data_runner
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Таким образом:
|
||||||
|
|
||||||
|
- разбор Dzengi WebSocket payload имеет единственный канонический путь;
|
||||||
|
- transport-specific parsing находится в `market_data/acquisition`;
|
||||||
|
- integration layer работает с канонической моделью `Quote`;
|
||||||
|
- дублирующая legacy parsing logic удалена;
|
||||||
|
- runtime-поведение сохранено.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Проверки
|
||||||
|
|
||||||
|
После изменений выполнены целевые тесты integration layer:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
python -m pytest -q \
|
||||||
|
tests/unit/integrations/exchange/test_market_data_runner.py \
|
||||||
|
tests/unit/integrations/exchange/test_market_stream.py
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
15 passed in 0.10s
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Выполнены целевые тесты канонического WebSocket quote acquisition pipeline:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
python -m pytest -q \
|
||||||
|
tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_quote_parser.py \
|
||||||
|
tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_quote_mapper.py \
|
||||||
|
tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_quote_adapter.py \
|
||||||
|
tests/unit/market_data/acquisition/validation/test_websocket_quote_schema.py \
|
||||||
|
tests/unit/market_data/acquisition/validation/test_websocket_quote_values.py
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
24 passed in 0.02s
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Выполнен полный набор тестов проекта:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
python -m pytest -q
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
614 passed in 1.77s
|
||||||
|
~~~
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Финальный grep-контроль
|
||||||
|
|
||||||
|
После удаления legacy quote parsing выполнена проверка:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
grep -RIn \
|
||||||
|
--exclude-dir="__pycache__" \
|
||||||
|
--exclude="*.pyc" \
|
||||||
|
-E "_extract_depth_prices|_extract_first_price|_extract_best_price|_extract_market_event|first\.get\(\"bidPrice\"\)|first\.get\(\"askPrice\"\)" \
|
||||||
|
src tests
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
совпадений нет
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Это подтверждает отсутствие проверяемых legacy helpers и прямого parsing `bidPrice` / `askPrice` через старый integration-код.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Сохранённые допустимые transport-specific поля
|
||||||
|
|
||||||
|
После Build 041 в проекте по-прежнему существуют обращения к:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
lastPrice
|
||||||
|
bidPrice
|
||||||
|
askPrice
|
||||||
|
closeTime
|
||||||
|
eventTime
|
||||||
|
bids
|
||||||
|
asks
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Их наличие само по себе не является legacy-зависимостью.
|
||||||
|
|
||||||
|
Они допустимы внутри канонического Dzengi acquisition adapter:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
market_data/acquisition/adapters/dzengi/
|
||||||
|
market_data/acquisition/validation/
|
||||||
|
~~~
|
||||||
|
|
||||||
|
поскольку именно этот слой отвечает за:
|
||||||
|
|
||||||
|
- знание transport-specific формата Dzengi;
|
||||||
|
- structural validation;
|
||||||
|
- parsing;
|
||||||
|
- value validation;
|
||||||
|
- mapping в каноническую модель `Quote`.
|
||||||
|
|
||||||
|
Также такие поля допустимы в unit-тестах соответствующего transport adapter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Критерии завершения Build
|
||||||
|
|
||||||
|
Build 041 считается завершённым, поскольку выполнены все критерии:
|
||||||
|
|
||||||
|
- [x] проведён аудит legacy quote parsing;
|
||||||
|
- [x] подтверждён единственный канонический WebSocket parsing pipeline;
|
||||||
|
- [x] удалён `_extract_depth_prices`;
|
||||||
|
- [x] удалён `_extract_first_price`;
|
||||||
|
- [x] удалён `_extract_best_price`;
|
||||||
|
- [x] удалён `_extract_market_event`;
|
||||||
|
- [x] удалены проверяемые прямые обращения к `bidPrice` и `askPrice` в legacy integration layer;
|
||||||
|
- [x] REST Quotes Feed не изменён;
|
||||||
|
- [x] REST fallback Build 040 сохранён;
|
||||||
|
- [x] каноническая модель `Quote` не изменена;
|
||||||
|
- [x] торговая логика не изменена;
|
||||||
|
- [x] runtime semantics не изменены;
|
||||||
|
- [x] целевые integration-тесты проходят;
|
||||||
|
- [x] целевые acquisition-тесты проходят;
|
||||||
|
- [x] полный набор из 614 тестов проходит;
|
||||||
|
- [x] финальный grep не обнаруживает удалённые legacy helpers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Commit
|
||||||
|
|
||||||
|
Build зафиксирован коммитом:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
24a4da9 build 041: remove legacy websocket quote parsing
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Предыдущий Build:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
e68ed7f build 040: restore quote REST fallback after build 039
|
||||||
|
~~~
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Итог
|
||||||
|
|
||||||
|
Build 041 завершает удаление подтверждённых остатков legacy WebSocket quote parsing из integration layer.
|
||||||
|
|
||||||
|
Итоговая архитектура:
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
Dzengi REST ticker/24hr
|
||||||
|
↓
|
||||||
|
DzengiQuoteDocumentSource
|
||||||
|
↓
|
||||||
|
DzengiQuoteDocumentHandler
|
||||||
|
↓
|
||||||
|
QuotesFeed
|
||||||
|
↓
|
||||||
|
QuoteAcquisitionService
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
|
||||||
|
Dzengi WebSocket message
|
||||||
|
↓
|
||||||
|
DzengiWebSocketQuoteAdapter
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
runtime / execution / strategy / UI consumers
|
||||||
|
~~~
|
||||||
|
|
||||||
|
После Build 041:
|
||||||
|
|
||||||
|
- REST и WebSocket transport-specific parsing сосредоточен в `market_data/acquisition`;
|
||||||
|
- runtime получает каноническую модель `Quote`;
|
||||||
|
- legacy WebSocket parsing helpers удалены;
|
||||||
|
- дублирующая интерпретация raw payload устранена;
|
||||||
|
- рабочая логика текущих файлов сохранена;
|
||||||
|
- полный test suite подтверждает отсутствие обнаруженных регрессий.
|
||||||
|
|
||||||
|
Build 041 завершён.
|
||||||
287
docs/migrations/build_042.md
Normal file
287
docs/migrations/build_042.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Build 042 — Удаление MarketPriceCache
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
**Завершён**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цель Build 042
|
||||||
|
|
||||||
|
Целью Build 042 было провести аудит зависимостей legacy-компонента `MarketPriceCache`, определить безопасный способ его удаления и устранить compatibility facade без нарушения работы существующего Quotes Feed, REST fallback, WebSocket runtime и потребителей котировок.
|
||||||
|
|
||||||
|
Основное ограничение миграции:
|
||||||
|
|
||||||
|
> Legacy-компонент может быть удалён только после подтверждения, что его поведение полностью покрывается целевым `QuoteStore` и что все runtime-потребители переведены без изменения существующих контрактов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Исходное состояние
|
||||||
|
|
||||||
|
До Build 042 в проекте существовал legacy-компонент:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/integrations/exchange/market_cache.py
|
||||||
|
```
|
||||||
|
|
||||||
|
с классом:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MarketPriceCache
|
||||||
|
```
|
||||||
|
|
||||||
|
`MarketPriceCache` использовался как compatibility facade над:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/storage/quote_store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
и внутренне содержал общий экземпляр `InMemoryQuoteStore`.
|
||||||
|
|
||||||
|
Основными потребителями были:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/integrations/exchange/service.py
|
||||||
|
src/integrations/exchange/market_stream.py
|
||||||
|
src/integrations/exchange/market_data_runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Результаты аудита
|
||||||
|
|
||||||
|
Аудит подтвердил, что `MarketPriceCache` не содержал самостоятельной бизнес-логики хранения котировок. Его обязанности ограничивались делегированием операций `set`, `get` и `clear` в `QuoteStore`, нормализацией `runtime_key`, использованием фиксированного `source_name` и предоставлением глобального shared store через class-level `_store`.
|
||||||
|
|
||||||
|
Фактическое хранение данных уже выполнялось через:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/storage/quote_store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Основные контракты:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QuoteStoreProtocol
|
||||||
|
InMemoryQuoteStore
|
||||||
|
```
|
||||||
|
|
||||||
|
Таким образом, `MarketPriceCache` являлся промежуточным compatibility facade и не был необходим как самостоятельный архитектурный слой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Критические семантики, сохранённые при удалении
|
||||||
|
|
||||||
|
### 4.1. Общий shared Quote Store
|
||||||
|
|
||||||
|
Все основные производители и потребители котировок продолжают работать с одним общим экземпляром `QuoteStore`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocket / REST
|
||||||
|
↓
|
||||||
|
Quote model
|
||||||
|
↓
|
||||||
|
shared QuoteStore
|
||||||
|
↓
|
||||||
|
ExchangeService / runtime consumers
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2. Изоляция по runtime_key
|
||||||
|
|
||||||
|
Сохранена изоляция котировок между runtime-контекстами, включая:
|
||||||
|
|
||||||
|
```text
|
||||||
|
auto
|
||||||
|
debug_auto
|
||||||
|
default
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключ хранения остаётся логически эквивалентным:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(source_name, runtime_key, symbol)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3. Нормализация runtime_key
|
||||||
|
|
||||||
|
Сохранена нормализация:
|
||||||
|
|
||||||
|
```text
|
||||||
|
" AUTO " → "auto"
|
||||||
|
" Debug_Auto " → "debug_auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4. Нормализация symbol
|
||||||
|
|
||||||
|
Сохранена нормализация символов при операциях чтения, записи и очистки.
|
||||||
|
|
||||||
|
```text
|
||||||
|
" btc/usd_leverage " → "BTC/USD_LEVERAGE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5. Семантика clear()
|
||||||
|
|
||||||
|
Сохранена возможность точечной и общей очистки `QuoteStore` по `source_name`, `runtime_key`, `symbol`, комбинации фильтров и полной очистке store.
|
||||||
|
|
||||||
|
### 4.6. REST fallback
|
||||||
|
|
||||||
|
Удаление `MarketPriceCache` не нарушило восстановленный в Build 040 REST fallback. Если свежая котировка отсутствует в shared `QuoteStore`, `ExchangeService` сохраняет возможность загрузить котировку через новый Quotes Feed acquisition pipeline и сохранить полученный `Quote` в общий store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Выполненные изменения
|
||||||
|
|
||||||
|
В рамках Build 042:
|
||||||
|
|
||||||
|
- удалён compatibility facade `MarketPriceCache`;
|
||||||
|
- удалён файл `src/integrations/exchange/market_cache.py`;
|
||||||
|
- удалён legacy-набор тестов `tests/unit/integrations/exchange/test_market_cache.py`;
|
||||||
|
- `ExchangeService` переведён на прямую работу с shared `QuoteStore`;
|
||||||
|
- `MarketDataRunner` переведён на прямую работу с shared `QuoteStore`;
|
||||||
|
- `market_stream` переведён на прямую работу с shared `QuoteStore`;
|
||||||
|
- сохранена runtime isolation;
|
||||||
|
- сохранена нормализация символов и runtime keys;
|
||||||
|
- сохранена семантика очистки;
|
||||||
|
- сохранён REST fallback;
|
||||||
|
- сохранены существующие публичные контракты получения котировок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Архитектурный результат
|
||||||
|
|
||||||
|
До Build 042:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocket / REST
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
MarketPriceCache
|
||||||
|
↓
|
||||||
|
InMemoryQuoteStore
|
||||||
|
```
|
||||||
|
|
||||||
|
После Build 042:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocket / REST
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
shared QuoteStore
|
||||||
|
↓
|
||||||
|
ExchangeService / runtime consumers
|
||||||
|
```
|
||||||
|
|
||||||
|
Удалён лишний промежуточный слой `MarketPriceCache`. `QuoteStore` теперь является непосредственным storage-контрактом для канонических моделей `Quote`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Проверка тестами
|
||||||
|
|
||||||
|
### Quote Store
|
||||||
|
|
||||||
|
```text
|
||||||
|
python -m pytest -q tests/unit/storage/test_quote_store.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
64 passed in 0.04s
|
||||||
|
```
|
||||||
|
|
||||||
|
### ExchangeService quote contracts
|
||||||
|
|
||||||
|
```text
|
||||||
|
python -m pytest -q tests/unit/integrations/exchange/test_service_quote.py tests/unit/integrations/exchange/test_service_execution_quote.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
11 passed in 0.11s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Market runtime
|
||||||
|
|
||||||
|
```text
|
||||||
|
python -m pytest -q tests/unit/integrations/exchange/test_market_data_runner.py tests/unit/integrations/exchange/test_market_stream.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
15 passed in 0.09s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Полный regression suite
|
||||||
|
|
||||||
|
```text
|
||||||
|
python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
613 passed in 2.37s
|
||||||
|
```
|
||||||
|
|
||||||
|
Уменьшение общего количества тестов с `614` до `613` является ожидаемым результатом удаления legacy-файла:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/unit/integrations/exchange/test_market_cache.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Контроль отсутствия legacy-зависимостей
|
||||||
|
|
||||||
|
Выполнена финальная проверка:
|
||||||
|
|
||||||
|
```text
|
||||||
|
grep -RIn --exclude-dir="__pycache__" --exclude="*.pyc" -E "\bMarketPriceCache\b|integrations\.exchange\.market_cache|market_cache" src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
пусто
|
||||||
|
```
|
||||||
|
|
||||||
|
Это подтверждает отсутствие оставшихся ссылок на `MarketPriceCache`, `src.integrations.exchange.market_cache` и `market_cache`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Итог Build 042
|
||||||
|
|
||||||
|
Build 042 завершён успешно.
|
||||||
|
|
||||||
|
Подтверждено:
|
||||||
|
|
||||||
|
- legacy `MarketPriceCache` полностью удалён;
|
||||||
|
- прямые зависимости от `market_cache.py` отсутствуют;
|
||||||
|
- shared `QuoteStore` стал непосредственным storage-механизмом котировок;
|
||||||
|
- WebSocket и REST пути используют каноническую модель `Quote`;
|
||||||
|
- runtime isolation сохранена;
|
||||||
|
- REST fallback сохранён;
|
||||||
|
- полный regression suite проходит без ошибок;
|
||||||
|
- архитектура готова к финальной проверке Quotes Feed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Следующий этап
|
||||||
|
|
||||||
|
```text
|
||||||
|
Build 043 — Финальная архитектурная проверка Quotes Feed
|
||||||
|
```
|
||||||
|
|
||||||
|
Цель Build 043:
|
||||||
|
|
||||||
|
- проверить целостность всей архитектурной цепочки Quotes Feed;
|
||||||
|
- подтвердить отсутствие legacy quote parsing;
|
||||||
|
- подтвердить отсутствие legacy quote cache facade;
|
||||||
|
- проверить REST и WebSocket пути получения котировок;
|
||||||
|
- проверить единый canonical `Quote`;
|
||||||
|
- проверить Feed → Handler → Adapter → Validation → Mapper → Storage → Consumer flow;
|
||||||
|
- проверить границы ответственности модулей;
|
||||||
|
- подтвердить готовность Quotes Feed к завершению миграции.
|
||||||
76
docs/migrations/build_043.md
Normal file
76
docs/migrations/build_043.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Build 043 --- Финальная архитектурная проверка Quotes Feed
|
||||||
|
|
||||||
|
## Контроль документа
|
||||||
|
|
||||||
|
Поле Значение
|
||||||
|
---------- ---------------------------------------------
|
||||||
|
Build 043
|
||||||
|
Название Final Quotes Feed Architecture Verification
|
||||||
|
Статус Completed
|
||||||
|
Дата 2026-07-14
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Подтвердить завершение миграции подсистемы Quotes Feed на новую
|
||||||
|
архитектуру Market Data Acquisition и убедиться в отсутствии
|
||||||
|
legacy-компонентов.
|
||||||
|
|
||||||
|
## Выполненные проверки
|
||||||
|
|
||||||
|
- Полностью удалены `MarketPriceCache`, `TickerPrice`,
|
||||||
|
`MarketPriceSnapshot` и legacy WebSocket parsing.
|
||||||
|
- Все REST- и WebSocket-сообщения проходят цепочку:
|
||||||
|
`Schema → Parser → Value Validation → Mapper → Quote`.
|
||||||
|
- Единственная production-модель котировки --- `Quote`.
|
||||||
|
- Единственная точка получения котировок потребителями ---
|
||||||
|
`ExchangeService.get_quote()`.
|
||||||
|
- Единственная точка хранения котировок --- общий `QuoteStore`.
|
||||||
|
- REST endpoint `/api/v1/ticker/24hr` используется только внутри
|
||||||
|
Dzengi REST adapter.
|
||||||
|
- Raw-поля (`lastPrice`, `bidPrice`, `askPrice`, `closeTime`, `bids`,
|
||||||
|
`asks`) изолированы внутри Acquisition Layer.
|
||||||
|
|
||||||
|
## Архитектурная схема
|
||||||
|
|
||||||
|
``` text
|
||||||
|
Dzengi REST / WebSocket
|
||||||
|
↓
|
||||||
|
Schema Validation
|
||||||
|
↓
|
||||||
|
Parser
|
||||||
|
↓
|
||||||
|
Value Validation
|
||||||
|
↓
|
||||||
|
Mapper
|
||||||
|
↓
|
||||||
|
Quote
|
||||||
|
↓
|
||||||
|
QuoteStore
|
||||||
|
↓
|
||||||
|
ExchangeService.get_quote()
|
||||||
|
↓
|
||||||
|
Trading / Telegram / Diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Результаты
|
||||||
|
|
||||||
|
- Legacy зависимости отсутствуют.
|
||||||
|
- Нарушений архитектурных границ не обнаружено.
|
||||||
|
- Consumers работают только через публичный API.
|
||||||
|
- Полный регрессионный набор успешно пройден.
|
||||||
|
|
||||||
|
## Проверка качества
|
||||||
|
|
||||||
|
- Полный запуск тестов:
|
||||||
|
- **613 passed**
|
||||||
|
- Дополнительные grep-проверки подтвердили отсутствие legacy-кода.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Build 043 завершён успешно.
|
||||||
|
|
||||||
|
Миграция подсистемы **Quotes Feed** считается завершённой. Подсистема
|
||||||
|
соответствует утверждённой архитектуре Dzentra Market Data Acquisition и
|
||||||
|
готова к переходу к следующему этапу миграции.
|
||||||
Reference in New Issue
Block a user