feat: add market data architecture and complete migration through build 039
This commit is contained in:
552
docs/migrations/build_015.md
Normal file
552
docs/migrations/build_015.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user