# Build 035 — Перевод market runtime на Quotes Feed

**Статус:** Завершён  
**Подсистема:** Market Data  
**Контур:** Market Data Acquisition / Quotes Feed / Market Runtime  
**Проект:** Dzentra  
**Язык документации:** Русский  

---

## 1. Цель Build

Цель Build 035 — перевести существующий WebSocket market runtime с самостоятельного legacy parsing рыночных сообщений на канонический контур обработки котировок, созданный в предыдущих Build.

До Build 035 runtime самостоятельно извлекал цены из WebSocket-сообщений Dzengi и передавал примитивные значения в legacy facade:

```text
Dzengi WebSocket message
        ↓
legacy runtime parsing
        ↓
float price / bid / ask
        ↓
MarketPriceCache.set_price()
        ↓
Quote Store
```

После Build 035 рабочий runtime-путь использует специализированный WebSocket-адаптер и каноническую модель `Quote`:

```text
Dzengi WebSocket message
        ↓
ExchangeWebSocketClient
        ↓
DzengiWebSocketQuoteAdapter
        ↓
canonical Quote
        ↓
MarketPriceCache.set_quote()
        ↓
Quote Store
```

Таким образом, WebSocket runtime больше не выполняет собственное преобразование транспортного формата Dzengi в набор примитивных ценовых значений.

---

## 2. Предпосылки

Build 035 опирается на результаты предыдущих этапов миграции.

### Build 027

Создана каноническая модель:

```text
Quote
```

### Build 028

Созданы:

```text
REST quote schema validation
REST quote parser
REST quote value validation
```

### Build 029

Созданы:

```text
REST quote mapper
DzengiQuoteDocumentHandler
```

### Build 030

Создан полный REST Quotes Feed:

```text
Dzengi REST ticker/24hr
        ↓
DzengiQuoteDocumentSource
        ↓
QuotesFeed
        ↓
QuoteAcquisitionService
        ↓
canonical Quote
```

### Build 031

Новый REST Quotes Feed подключён под legacy `ExchangeService` facade.

### Build 032

Создан канонический:

```text
Quote Store
```

### Build 033

`MarketPriceCache` переведён на использование `Quote Store` как внутреннего хранилища.

### Build 034

Создан специализированный контур обработки WebSocket-котировок:

```text
Dzengi WebSocket message
        ↓
schema validation
        ↓
parser
        ↓
value validation
        ↓
mapper
        ↓
DzengiWebSocketQuoteAdapter
        ↓
canonical Quote
```

Build 035 подключает этот контур к существующему market runtime.

---

## 3. Архитектурная проблема до Build 035

До Build 035 существовало несколько независимых путей обработки котировок.

REST-контур уже использовал каноническую модель:

```text
REST ticker/24hr
        ↓
Quotes Feed
        ↓
Quote
        ↓
Quote Store
```

Но WebSocket runtime продолжал самостоятельно разбирать транспортные сообщения.

### `market_stream.py`

Рабочий путь имел вид:

```text
WebSocket message
        ↓
_payload_from_message()
        ↓
_extract_market_event()
        ↓
float price / bid / ask
        ↓
MarketPriceCache.set_price()
```

### `market_data_runner.py`

Рабочий путь имел вид:

```text
WebSocket message
        ↓
_extract_depth_payload()
        ↓
_extract_best_price()
        ↓
float midpoint
        ↓
MarketPriceCache.set_price()
```

Таким образом, логика понимания формата Dzengi WebSocket существовала одновременно:

```text
в новом DzengiWebSocketQuoteAdapter
в market_stream.py
в market_data_runner.py
```

Это нарушало архитектурную границу:

```text
transport-specific parsing
        ↓
только adapter layer
```

---

## 4. Архитектурный результат

После Build 035 оба WebSocket runtime-пути используют единый канонический адаптер:

```text
ExchangeWebSocketClient
        ↓
decoded WebSocket message
        ↓
DzengiWebSocketQuoteAdapter
        ↓
canonical Quote
        ↓
MarketPriceCache compatibility facade
        ↓
Quote Store
```

Runtime больше не должен самостоятельно знать:

```text
как устроены payload / Payload
как извлекается symbolName
как извлекаются bids / asks
какие варианты depth item поддерживает Dzengi
как вычисляется canonical last_price
как преобразуется timestamp
```

Эта ответственность принадлежит:

```text
src/market_data/acquisition/adapters/dzengi/websocket.py
```

и связанному с ним контуру:

```text
schema validation
parser
value validation
mapper
```

---

## 5. Изменённые production-файлы

В рамках Build 035 изменены:

```text
src/integrations/exchange/market_cache.py
src/integrations/exchange/market_stream.py
src/integrations/exchange/market_data_runner.py
```

---

## 6. Изменённые тестовые файлы

Расширены существующие тесты:

```text
tests/unit/integrations/exchange/test_market_cache.py
tests/unit/integrations/exchange/test_market_stream.py
tests/unit/integrations/exchange/test_market_data_runner.py
```

Новые тестовые файлы не создавались.

---

## 7. Расширение MarketPriceCache

В `MarketPriceCache` добавлен новый метод:

```python
@classmethod
def set_quote(
    cls,
    quote: Quote,
    *,
    runtime_key: str = "default",
) -> None:
    ...
```

Его задача — принять уже готовый канонический объект:

```text
Quote
```

и записать его в существующее внутреннее хранилище через compatibility facade.

Цепочка:

```text
canonical Quote
        ↓
MarketPriceCache.set_quote()
        ↓
Quote Store
```

---

## 8. Сохранение канонического Quote без повторного mapping

До Build 035 при наличии уже готового `Quote` потенциально мог возникнуть лишний цикл:

```text
Quote
        ↓
float values
        ↓
MarketPriceCache.set_price()
        ↓
создание нового Quote
        ↓
Quote Store
```

После Build 035 используется прямой путь:

```text
Quote
        ↓
MarketPriceCache.set_quote()
        ↓
Quote Store
```

При этом не требуется:

```text
преобразовывать Decimal в float
повторно создавать Quote
повторно вычислять received_at
повторно преобразовывать exchange_timestamp
изменять source
```

Таким образом, сохраняется исходный канонический объект.

---

## 9. Сохранение legacy set_price()

Существующий публичный метод:

```text
MarketPriceCache.set_price()
```

не удалён.

Это необходимо для сохранения обратной совместимости существующего бота и legacy-потребителей.

После Build 035 его архитектурная роль:

```text
legacy primitive values
        ↓
MarketPriceCache.set_price()
        ↓
canonical Quote
        ↓
MarketPriceCache.set_quote()
        ↓
Quote Store
```

Таким образом, `set_price()` остаётся compatibility entry point, а непосредственная запись готового канонического объекта выполняется через:

```text
set_quote()
```

---

## 10. Перевод market_stream.py

Рабочий WebSocket-путь `market_stream.py` переведён на:

```text
ExchangeWebSocketClient.stream_depth()
        ↓
DzengiWebSocketQuoteAdapter.map_message()
        ↓
Quote
        ↓
MarketPriceCache.set_quote()
```

Новый runtime-путь больше не использует legacy-функцию:

```text
_extract_market_event()
```

для обработки рабочих WebSocket-сообщений.

---

## 11. Проверка символа в market_stream.py

После получения канонического `Quote` выполняется проверка соответствия символа ожидаемому инструменту.

Концептуально:

```text
requested symbol
        ↕
Quote.symbol
```

Если сообщение относится к другому инструменту, оно не должно записываться в runtime namespace.

Это предотвращает сохранение чужой котировки в контексте текущего WebSocket-потока.

---

## 12. Обработка невалидных сообщений в market_stream.py

Ошибка обработки отдельного WebSocket-сообщения не должна немедленно завершать весь market stream.

Ошибки канонического Acquisition-контура отдельного сообщения обрабатываются внутри цикла:

```text
invalid WebSocket message
        ↓
DzengiWebSocketQuoteAdapter
        ↓
MarketDataAcquisitionError
        ↓
сообщение пропускается
        ↓
stream продолжает работу
```

При этом сетевые, transport и connection errors не маскируются этим механизмом и продолжают обрабатываться существующим reconnect-контуром.

---

## 13. Перевод MarketDataRunner

Основной WebSocket runtime в:

```text
MarketDataRunner._run_websocket()
```

переведён с самостоятельного извлечения:

```text
best_bid
best_ask
```

на канонический путь:

```text
raw WebSocket payload
        ↓
DzengiWebSocketQuoteAdapter.map_message()
        ↓
Quote
```

После успешного mapping готовый объект записывается:

```text
Quote
        ↓
MarketPriceCache.set_quote(
    runtime_key=context.runtime_key
)
        ↓
Quote Store
```

---

## 14. Runtime isolation

Сохраняется существующая изоляция runtime-контекстов через:

```text
runtime_key
```

Примеры существующих runtime:

```text
auto
debug_auto
default
```

При записи через:

```text
MarketPriceCache.set_quote()
```

используется соответствующий:

```text
context.runtime_key
```

Таким образом, котировки разных runtime не смешиваются.

---

## 15. Bid и ask после перехода на Quote

До Build 035 `MarketDataRunner` самостоятельно извлекал:

```text
best_bid
best_ask
```

из сырого WebSocket payload.

После Build 035 эти значения берутся из уже проверенного канонического объекта:

```text
Quote.bid_price
Quote.ask_price
```

Для legacy logging boundary при необходимости допускается преобразование:

```text
Decimal → float
```

Но внутренний канонический объект остаётся основанным на:

```text
Decimal
```

---

## 16. Last price для depth-сообщений

Согласно контракту Build 034 для WebSocket depth-сообщений:

```text
last_price = midpoint(best_bid, best_ask)
```

После Build 035 runtime больше не должен самостоятельно повторять этот расчёт.

Он получает готовое значение:

```text
Quote.last_price
```

из `DzengiWebSocketQuoteAdapter`.

Таким образом, правило определения `last_price` имеет одну каноническую реализацию.

---

## 17. Сохранение invalid payload semantics

До Build 035 `MarketDataRunner` поддерживал счётчик последовательных невалидных WebSocket-сообщений.

Существующая семантика сохранена:

```text
invalid message
        ↓
invalid_payload_count += 1
```

Успешный `Quote`:

```text
valid Quote
        ↓
invalid_payload_count = 0
```

После пяти последовательных невалидных сообщений:

```text
5 consecutive invalid messages
        ↓
RuntimeError
        ↓
существующий fallback-контур
```

Таким образом, Build 035 не изменяет существующую политику деградации runtime.

---

## 18. REST fallback

REST fallback не потребовал изменения.

К моменту Build 035 REST-путь уже использует новый Quotes Feed:

```text
Dzengi REST ticker/24hr
        ↓
DzengiQuoteDocumentSource
        ↓
QuotesFeed
        ↓
QuoteAcquisitionService
        ↓
canonical Quote
        ↓
MarketPriceCache facade
        ↓
Quote Store
```

После Build 035 WebSocket и REST-пути сходятся на одной канонической модели:

```text
                    WebSocket
                        ↓
            DzengiWebSocketQuoteAdapter
                        ↓
                      Quote
                        ↓
                    Quote Store
                        ↑
                      Quote
                        ↑
               REST Quotes Feed
                        ↑
                       REST
```

---

## 19. Что намеренно не изменялось

В Build 035 не изменялись:

```text
src/integrations/exchange/ws_client.py
src/market_data/acquisition/adapters/dzengi/websocket.py
src/market_data/acquisition/models/quote.py
src/storage/quote_store.py
```

Причина:

- `ws_client.py` уже имеет достаточный transport-only контракт;
- WebSocket-адаптер завершён в Build 034;
- каноническая модель `Quote` не требует расширения;
- `Quote Store` уже предоставляет необходимый контракт хранения.

---

## 20. Legacy parsing helpers

После перевода рабочего runtime-пути некоторые legacy helper-функции больше не являются частью основного пути обработки котировок.

В частности:

```text
market_stream.py:
    _extract_market_event()

market_data_runner.py:
    _extract_best_price()
```

Они не удалены в Build 035.

Это намеренное решение.

Окончательная очистка legacy parsing относится к последующему этапу:

```text
Build 039 — Удаление legacy quote parsing и MarketPriceCache
```

Build 035 меняет рабочий runtime-путь, но не выполняет преждевременную очистку compatibility layer.

---

## 21. Обратная совместимость

Build 035 сохраняет работоспособность существующего бота.

Не удалены:

```text
MarketPriceCache
MarketPriceCache.set_price()
legacy read APIs
runtime_key isolation
REST fallback
существующие runtime lifecycle contracts
```

Это соответствует принятому принципу миграции Dzentra:

```text
новый контур создаётся
        ↓
существующие потребители постепенно переключаются
        ↓
legacy удаляется только после завершения миграции
```

---

## 22. Проверка компиляции

Выполнена команда:

```bash
python -m py_compile \
  src/integrations/exchange/market_cache.py \
  src/integrations/exchange/market_stream.py \
  src/integrations/exchange/market_data_runner.py \
  tests/unit/integrations/exchange/test_market_cache.py \
  tests/unit/integrations/exchange/test_market_stream.py \
  tests/unit/integrations/exchange/test_market_data_runner.py
```

Результат:

```text
успешно
```

---

## 23. Специализированные тесты

Выполнена команда:

```bash
python -m pytest \
  tests/unit/integrations/exchange/test_market_cache.py \
  tests/unit/integrations/exchange/test_market_stream.py \
  tests/unit/integrations/exchange/test_market_data_runner.py \
  tests/unit/market_data/acquisition/adapters/dzengi/test_websocket_quote_adapter.py \
  -q
```

Результат:

```text
33 passed in 0.13s
```

---

## 24. Полная регрессия

Выполнена команда:

```bash
python -m pytest -q
```

Результат:

```text
608 passed in 0.29s
```

Количество тестов до Build 035:

```text
602 passed
```

Количество тестов после Build 035:

```text
608 passed
```

Добавлено:

```text
6 тестов
```

Полная регрессия подтверждает отсутствие обнаруженных регрессий в существующем коде проекта.

---

## 25. Итог Build

Build 035 завершён полностью.

Реализованы:

```text
подключение DzengiWebSocketQuoteAdapter к market_stream
подключение DzengiWebSocketQuoteAdapter к MarketDataRunner
передача canonical Quote непосредственно в compatibility facade
добавление MarketPriceCache.set_quote()
сохранение legacy MarketPriceCache.set_price()
сохранение runtime_key isolation
сохранение invalid payload semantics
сохранение REST fallback
сохранение обратной совместимости
```

Подтверждён новый рабочий WebSocket-путь:

```text
ExchangeWebSocketClient
        ↓
DzengiWebSocketQuoteAdapter
        ↓
canonical Quote
        ↓
MarketPriceCache.set_quote()
        ↓
Quote Store
```

---

## 26. Архитектурное состояние после Build 035

После завершения Build 035 Dzentra имеет два канонических пути получения текущей котировки.

### WebSocket

```text
Dzengi WebSocket
        ↓
ExchangeWebSocketClient
        ↓
DzengiWebSocketQuoteAdapter
        ↓
Quote
        ↓
MarketPriceCache compatibility facade
        ↓
Quote Store
```

### REST

```text
Dzengi REST ticker/24hr
        ↓
DzengiQuoteDocumentSource
        ↓
QuotesFeed
        ↓
QuoteAcquisitionService
        ↓
Quote
        ↓
MarketPriceCache compatibility facade
        ↓
Quote Store
```

Таким образом, оба transport-пути приводят данные к единой канонической модели:

```text
Quote
```

до передачи их потребителям.

---

## 27. Следующий Build

Следующий этап:

```text
Build 036 — Перевод read-only и UI-потребителей
```

Его задача — определить и перевести read-only и UI-потребителей текущей рыночной котировки с legacy-представлений на канонический `Quote` и новый контур хранения там, где это архитектурно обосновано.

Build 036 должен выполняться без изменения execution semantics и без преждевременного удаления legacy compatibility layer.

Execution-потребители остаются отдельным последующим этапом миграции.