feat: add market data architecture and complete migration through build 039

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

View File

@@ -0,0 +1,552 @@
# Build 015 — Переключение `get_exchange_symbols()` на новый Acquisition Pipeline
## Статус
**COMPLETE**
---
## Цель
Переключить существующий публичный метод:
```python
ExchangeService.get_exchange_symbols()
```
с прямого legacy-получения и обработки `exchangeInfo` на новый стандартизированный Instrument Reference Data acquisition pipeline, сохранив при этом существующий внешний контракт и работоспособность старого бота.
---
## Исходное состояние
До Build 015 метод:
```python
ExchangeService.get_exchange_symbols()
```
самостоятельно выполнял весь цикл обработки `exchangeInfo`:
1. создавал `ExchangeRestClient`;
2. выполнял прямой REST-запрос:
```text
/api/v1/exchangeInfo
```
3. извлекал массив `symbols`;
4. преобразовывал каждый элемент в legacy-модель `ExchangeSymbol`;
5. сохранял результат в class-level cache:
```python
_exchange_symbols_cache
```
Таким образом, transport, validation, parsing, mapping и compatibility logic были сосредоточены внутри legacy `ExchangeService`.
---
## Реализованное изменение
Метод:
```python
ExchangeService.get_exchange_symbols()
```
переключён на новый Instrument Reference Data acquisition pipeline.
Теперь production-путь использует следующую цепочку:
```text
ExchangeService.get_exchange_symbols()
_load_exchange_symbols_via_acquisition()
DzengiInstrumentDocumentSource
DzengiInstrumentDocumentHandler
InstrumentFeed
InstrumentFeedRegistry
InstrumentAcquisitionService
Instrument
map_instruments_to_exchange_symbols()
ExchangeSymbol
```
---
## Новый production-путь
В `ExchangeService` используется отдельный compatibility bridge:
```python
def _load_exchange_symbols_via_acquisition(
self,
) -> list[ExchangeSymbol]:
```
Его задача:
1. создать источник Instrument Reference Data для Dzengi;
2. создать обработчик документа;
3. собрать `InstrumentFeed`;
4. зарегистрировать feed;
5. выполнить acquisition через `InstrumentAcquisitionService`;
6. получить канонические модели `Instrument`;
7. преобразовать их в legacy-модели `ExchangeSymbol`.
Это позволяет старому боту продолжать использовать существующий контракт:
```python
list[ExchangeSymbol]
```
при том, что фактическим источником данных уже является новая архитектура `market_data/acquisition`.
---
## Сохранённый публичный контракт
Сигнатура метода не изменилась:
```python
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
```
Это принципиально важно для безопасной поэтапной миграции.
Существующие потребители не требуют немедленного изменения и продолжают работать через прежний API.
В частности, существующий UI продолжает использовать:
```python
exchange_service.get_exchange_symbols()
```
без знания о внутреннем переходе на новый acquisition pipeline.
---
## Сохранение cache semantics
Сохранён существующий class-level cache:
```python
_exchange_symbols_cache: list[ExchangeSymbol] | None = None
```
Поведение осталось прежним:
```text
Первый вызов
Новый acquisition pipeline
Compatibility mapping
_exchange_symbols_cache
list[ExchangeSymbol]
```
Последующие вызовы:
```text
_exchange_symbols_cache
list[ExchangeSymbol]
```
без повторного обращения к acquisition pipeline.
Cache заполняется только после успешной загрузки данных.
При ошибке acquisition cache остаётся незаполненным.
---
## Поведение при отключённой бирже
Сохранено прежнее поведение:
```python
if not self.settings.exchange_enabled:
return []
```
Новый acquisition pipeline в этом случае не вызывается.
---
## Обработка ошибок
Ошибки нового acquisition pipeline проходят через существующую систему `ExchangeService`.
При ошибке:
1. ошибка логируется через:
```python
self._log_exchange_error(...)
```
2. используется legacy endpoint identifier:
```text
exchangeInfo
```
3. вызывающему коду возвращается совместимая `ExchangeError`.
Это сохраняет существующее поведение старого бота и его журналирования.
---
## Удаление прямого legacy REST-пути
После Build 015 метод:
```python
get_exchange_symbols()
```
больше не выполняет прямой вызов:
```python
ExchangeRestClient().get_json("/api/v1/exchangeInfo")
```
Фактический REST transport теперь инкапсулирован в:
```text
src/market_data/acquisition/adapters/dzengi/rest.py
```
через:
```python
DzengiInstrumentDocumentSource
```
и константу:
```python
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
```
Таким образом, ownership получения Instrument Reference Data перенесён из:
```text
integrations/exchange
```
в:
```text
market_data/acquisition
```
---
## Legacy helpers
В `ExchangeService` временно остаются legacy helpers:
```python
_extract_exchange_symbols_raw()
_parse_exchange_symbol()
_parse_exchange_symbol_status()
_parse_market_modes()
_extract_filter_value()
```
Они больше не являются частью нового production-пути `get_exchange_symbols()`.
Их немедленное удаление не выполнялось в Build 015, поскольку миграция проводится поэтапно и без ненужного расширения scope текущего Build.
Удаление legacy helpers должно выполняться отдельным контролируемым этапом после подтверждения отсутствия production-зависимостей и завершения необходимых migration/equivalence проверок.
---
## Добавленные тесты
Создан файл:
```text
tests/unit/integrations/exchange/test_service_exchange_symbols.py
```
Тестами проверяются:
- возврат пустого списка при отключённой бирже;
- отсутствие вызова acquisition pipeline при отключённой бирже;
- возврат существующего cache;
- отсутствие повторного acquisition при наличии cache;
- загрузка через новый acquisition pipeline;
- заполнение `_exchange_symbols_cache`;
- повторное использование cache;
- сохранение legacy-типа `ExchangeSymbol`;
- сохранение порядка инструментов;
- корректное распространение ошибок;
- отсутствие заполнения cache при ошибке;
- сохранение существующего error logging;
- отсутствие прямого legacy REST-вызова из `get_exchange_symbols()`;
- корректная сборка нового acquisition pipeline;
- использование compatibility mapper;
- корректное поведение пустого результата.
---
## Исправление статической типизации теста
После первоначального завершения Build 015 в файле:
```text
tests/unit/integrations/exchange/test_service_exchange_symbols.py
```
были обнаружены две ошибки статической типизации Pylance.
### Типизация yield-fixture
Исходная аннотация:
```python
@pytest.fixture(autouse=True)
def reset_exchange_symbols_cache() -> None:
```
была некорректна, поскольку функция содержит `yield` и является генератором.
Исправлено на:
```python
@pytest.fixture(autouse=True)
def reset_exchange_symbols_cache() -> Iterator[None]:
ExchangeService._exchange_symbols_cache = None
yield
ExchangeService._exchange_symbols_cache = None
```
Добавлен импорт:
```python
from collections.abc import Iterator
```
### Типизация тестовых settings
Тестовый helper создаёт `ExchangeService` без вызова его конструктора:
```python
service = object.__new__(ExchangeService)
```
Для изоляции теста используется `SimpleNamespace`, тогда как production-атрибут:
```python
service.settings
```
типизирован как `Settings`.
Для явного обозначения тестовой границы применён `cast`:
```python
service.settings = cast(
Settings,
_settings(
exchange_enabled=exchange_enabled,
),
)
```
Таким образом:
- production-код не изменялся;
- тестовая изоляция сохранена;
- `# type: ignore` не использовался;
- ошибки Pylance устранены.
---
## Результаты окончательной проверки
### Проверка компиляции
Команда:
```bash
python -m py_compile \
src/integrations/exchange/service.py \
tests/unit/integrations/exchange/test_service_exchange_symbols.py
```
Результат:
```text
Ошибок нет.
```
---
### Unit-тесты Build 015
Команда:
```bash
python -m pytest \
tests/unit/integrations/exchange/test_service_exchange_symbols.py \
-q
```
Результат:
```text
16 passed in 0.07s
```
---
### Полный regression suite
Команда:
```bash
python -m pytest -q
```
Результат:
```text
220 passed in 0.15s
```
---
## Проверка production-пути
Выполнен поиск:
```bash
grep -RIn \
--exclude-dir="__pycache__" \
--exclude="*.pyc" \
-E "get_exchange_symbols|_exchange_symbols_cache|_load_exchange_symbols_via_acquisition|DzengiInstrumentDocumentSource|InstrumentAcquisitionService|map_instruments_to_exchange_symbols|ExchangeRestClient.*exchangeInfo|exchangeInfo" \
src tests
```
Проверка подтвердила:
- `get_exchange_symbols()` использует `_load_exchange_symbols_via_acquisition()`;
- новый production-путь использует `DzengiInstrumentDocumentSource`;
- используется `InstrumentAcquisitionService`;
- используется compatibility mapper `map_instruments_to_exchange_symbols()`;
- class-level cache `_exchange_symbols_cache` сохранён;
- прямой legacy REST-вызов `exchangeInfo` удалён из `get_exchange_symbols()`;
- существующие внешние потребители продолжают работать через прежний публичный контракт.
---
## Архитектурный результат
До Build 015:
```text
Legacy consumer
ExchangeService.get_exchange_symbols()
ExchangeRestClient
exchangeInfo
Legacy parsing
ExchangeSymbol
```
После Build 015:
```text
Legacy consumer
ExchangeService.get_exchange_symbols()
Instrument Acquisition Pipeline
Canonical Instrument
Compatibility Mapper
ExchangeSymbol
```
Таким образом:
- новый `market_data/acquisition` стал фактическим production-владельцем получения Instrument Reference Data;
- legacy `ExchangeService` сохраняет прежний публичный API;
- существующий бот продолжает работать без массового изменения потребителей;
- создан безопасный compatibility boundary между новой и старой архитектурой;
- переход выполнен без регрессий.
---
## Итог
```text
BUILD 015 — COMPLETE
```
Build 015 завершён.
`ExchangeService.get_exchange_symbols()` успешно переключён на новый Instrument Reference Data acquisition pipeline с сохранением:
- существующего публичного контракта;
- legacy-модели `ExchangeSymbol`;
- cache semantics;
- обработки ошибок;
- журналирования;
- существующих потребителей старого бота.
Окончательные результаты проверки:
```text
py_compile — успешно
16 passed in 0.07s
220 passed in 0.15s
```