build 039: complete Quotes Feed migration foundation

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

View File

@@ -0,0 +1,863 @@
# Build 020 — Перенос кэша инструментов в Storage Layer
**Engineering Migration Record**
---
## Контроль документа
| Свойство | Значение |
|---|---|
| Документ | Build 020 — Перенос кэша инструментов в Storage Layer |
| Тип документа | Engineering Migration Record |
| Статус | **Complete** |
| Проект | Dzentra |
| Подсистема | Instrument Reference Data |
| Build | 020 |
| Язык | Русский |
| Целевой файл | `docs/migrations/instrument_reference_data/build_020.md` |
---
## 1. Цель Build 020
Цель Build 020 — удалить каноническое хранение справочных данных инструментов из legacy-кэша `ExchangeService._exchange_symbols_cache` и перенести его в специализированный Storage Layer.
До выполнения Build 020 `ExchangeService` одновременно отвечал за:
- получение справочных данных инструментов;
- запуск acquisition pipeline;
- преобразование канонических моделей `Instrument` в legacy-модели `ExchangeSymbol`;
- хранение результата в собственном class-level cache.
Это создавало архитектурную зависимость канонических справочных данных от legacy exchange layer.
После Build 020 канонические данные инструментов хранятся в специализированном:
```text
InMemoryInstrumentStore
```
в виде:
```text
tuple[Instrument, ...]
```
Legacy-модели `ExchangeSymbol` больше не являются каноническим представлением справочных данных.
---
## 2. Архитектурное состояние до Build 020
До миграции `ExchangeService` содержал:
```python
_exchange_symbols_cache: list[ExchangeSymbol] | None = None
```
Метод:
```python
get_exchange_symbols()
```
работал по следующей схеме:
```text
get_exchange_symbols()
_exchange_symbols_cache
↓ cache miss
_load_exchange_symbols_via_acquisition()
Instrument Acquisition Pipeline
tuple[Instrument, ...]
map_instruments_to_exchange_symbols()
list[ExchangeSymbol]
_exchange_symbols_cache
```
Таким образом, результат новой Instrument Reference Data pipeline сразу преобразовывался в legacy-модель, и именно legacy-представление сохранялось как основной кэш.
---
## 3. Архитектурная проблема
Старый подход имел несколько принципиальных недостатков.
### 3.1. Legacy-модель использовалась как каноническое хранилище
Каноническая модель:
```python
Instrument
```
содержит полные справочные данные инструмента.
Legacy-модель:
```python
ExchangeSymbol
```
является сокращённой compatibility-проекцией и содержит только часть этих данных.
Хранение только `ExchangeSymbol` означало потерю полноценного канонического представления после завершения acquisition pipeline.
### 3.2. Exchange Layer владел состоянием справочных данных
Поле:
```python
ExchangeService._exchange_symbols_cache
```
делало `ExchangeService` владельцем справочных данных инструментов.
Это противоречило целевой архитектуре:
```text
Acquisition Layer
Canonical Instrument Models
Storage Layer
Consumers / Compatibility Projections
```
### 3.3. Канонические данные и legacy-проекция были объединены
Результат acquisition pipeline немедленно преобразовывался:
```text
Instrument
ExchangeSymbol
```
и сохранялся только после преобразования.
Это не позволяло независимо использовать полный `Instrument` в будущих подсистемах Dzentra.
---
## 4. Реализованное архитектурное решение
В Build 020 введено разделение между:
1. каноническим хранилищем инструментов;
2. legacy compatibility projection cache.
Теперь `ExchangeService` использует:
```python
_instrument_store: InstrumentStoreProtocol = InMemoryInstrumentStore()
```
для хранения канонических моделей:
```python
tuple[Instrument, ...]
```
и отдельный:
```python
_exchange_symbols_projection_cache: list[ExchangeSymbol] | None = None
```
для временного кэширования legacy-проекции.
Итоговая схема:
```text
DzengiInstrumentDocumentSource
InstrumentFeed
DzengiInstrumentDocumentHandler
InstrumentAcquisitionService
tuple[Instrument, ...]
InMemoryInstrumentStore
Canonical Instrument Reference Data
map_instruments_to_exchange_symbols()
list[ExchangeSymbol]
Legacy Compatibility Projection Cache
```
---
## 5. Каноническое хранилище
Канонические справочные данные теперь находятся в:
```python
ExchangeService._instrument_store
```
Тип зависимости:
```python
InstrumentStoreProtocol
```
Текущая реализация:
```python
InMemoryInstrumentStore
```
Store хранит:
```python
tuple[Instrument, ...]
```
Ключ источника:
```text
dzengi
```
Таким образом, канонические справочные данные больше не зависят от legacy-модели `ExchangeSymbol`.
---
## 6. Новый production flow
Метод:
```python
get_exchange_symbols()
```
сохраняет прежний внешний контракт:
```python
list[ExchangeSymbol]
```
Это необходимо для сохранения работоспособности существующего бота во время поэтапной миграции.
Внутренний flow теперь выглядит следующим образом:
```text
get_exchange_symbols()
Проверка exchange_enabled
Проверка _exchange_symbols_projection_cache
↓ cache miss
Чтение InstrumentStore
↓ store miss
_load_instruments_via_acquisition()
tuple[Instrument, ...]
Сохранение в InstrumentStore
map_instruments_to_exchange_symbols()
list[ExchangeSymbol]
Сохранение в _exchange_symbols_projection_cache
Возврат legacy-результата
```
---
## 7. Разделение канонического кэша и compatibility projection cache
После Build 020 существуют два разных уровня состояния.
### 7.1. Каноническое состояние
```python
_instrument_store
```
Хранит:
```python
tuple[Instrument, ...]
```
Назначение:
- хранение полной справочной модели инструмента;
- повторное использование канонических данных;
- основа для будущих market intelligence consumers;
- независимость от legacy exchange models.
### 7.2. Compatibility projection cache
```python
_exchange_symbols_projection_cache
```
Хранит:
```python
list[ExchangeSymbol] | None
```
Назначение:
- сохранить старый контракт `get_exchange_symbols()`;
- не выполнять повторный compatibility mapping при каждом вызове;
- обеспечить безопасную постепенную миграцию legacy-кода.
`_exchange_symbols_projection_cache` не является источником истины.
Источником истины является:
```text
InstrumentStore
```
---
## 8. Изменение acquisition loader
Старый метод:
```python
_load_exchange_symbols_via_acquisition()
```
удалён.
Он одновременно:
- запускал acquisition pipeline;
- получал `Instrument`;
- выполнял compatibility mapping;
- возвращал `ExchangeSymbol`.
Вместо него используется:
```python
_load_instruments_via_acquisition()
```
Новый метод возвращает:
```python
tuple[Instrument, ...]
```
и не выполняет:
```python
map_instruments_to_exchange_symbols()
```
Это обеспечивает чистую архитектурную границу:
```text
Acquisition Pipeline
Canonical Instrument Models
```
Compatibility mapping выполняется отдельно только там, где действительно требуется legacy-контракт.
---
## 9. Сохранение обратной совместимости
Build 020 не меняет публичный контракт:
```python
ExchangeService.get_exchange_symbols()
```
Он по-прежнему возвращает:
```python
list[ExchangeSymbol]
```
Благодаря этому продолжают работать существующие consumers, включая:
```text
validate_symbol()
currency_ui.py
legacy exchange UI
существующие unit tests
```
Миграция выполнена без обязательного одновременного переписывания всех legacy consumers.
---
## 10. Поведение при отключённой бирже
При:
```python
exchange_enabled = False
```
метод:
```python
get_exchange_symbols()
```
возвращает:
```python
[]
```
При этом он не должен:
- читать `InstrumentStore`;
- использовать существующий projection cache;
- запускать acquisition pipeline;
- обращаться к реальной бирже.
Это сохраняет прежнее поведение mock/disabled режима.
---
## 11. Поведение при cache hit
Если существует:
```python
_exchange_symbols_projection_cache
```
метод возвращает существующий объект legacy-проекции без:
- чтения acquisition source;
- повторной обработки документа;
- повторного compatibility mapping.
Если projection cache отсутствует, но канонические данные уже существуют в:
```python
InstrumentStore
```
то acquisition pipeline не запускается повторно.
Вместо этого выполняется:
```text
InstrumentStore
tuple[Instrument, ...]
Compatibility Mapper
list[ExchangeSymbol]
```
Пустой канонический набор:
```python
()
```
также считается валидным cache hit и не должен ошибочно интерпретироваться как отсутствие данных.
---
## 12. Поведение при cache miss
Если одновременно отсутствуют:
```text
_exchange_symbols_projection_cache
InstrumentStore entry for "dzengi"
```
выполняется полный production flow:
```text
Instrument Source
Instrument Handler
Instrument Acquisition Service
tuple[Instrument, ...]
InstrumentStore.set(...)
Compatibility Mapper
list[ExchangeSymbol]
```
После успешной загрузки:
- канонический `tuple[Instrument, ...]` сохраняется в `InstrumentStore`;
- legacy-проекция создаётся отдельно;
- legacy-проекция сохраняется в `_exchange_symbols_projection_cache`.
---
## 13. Поведение при ошибках
Если acquisition pipeline завершается ошибкой:
- ошибка логируется как exchange request error;
- endpoint сохраняется как:
```text
exchangeInfo
```
- исходная ошибка становится причиной `ExchangeError`;
- канонический store не должен получать частичные или некорректные данные;
- projection cache не должен заполняться.
Таким образом, ошибка acquisition не создаёт ложное успешное состояние.
Если ошибка возникает на этапе compatibility mapping:
- канонические данные уже могут находиться в `InstrumentStore`;
- projection cache не должен заполняться некорректным результатом;
- следующий вызов может повторно построить legacy-проекцию из сохранённых канонических данных без повторного запуска acquisition pipeline.
---
## 14. Инварианты Build 020
После завершения Build 020 действуют следующие обязательные инварианты.
1. `ExchangeService._exchange_symbols_cache` отсутствует в production-коде.
2. Канонические справочные данные хранятся как:
```python
tuple[Instrument, ...]
```
3. Владельцем канонического состояния является:
```text
InstrumentStore
```
4. Текущей реализацией store является:
```python
InMemoryInstrumentStore
```
5. `ExchangeService` зависит от абстракции:
```python
InstrumentStoreProtocol
```
6. Старый loader:
```python
_load_exchange_symbols_via_acquisition()
```
отсутствует.
7. Новый loader:
```python
_load_instruments_via_acquisition()
```
возвращает только:
```python
tuple[Instrument, ...]
```
8. Новый loader не вызывает:
```python
map_instruments_to_exchange_symbols()
```
9. Compatibility mapping выполняется после получения канонических моделей из store или acquisition pipeline.
10. `_exchange_symbols_projection_cache` не является каноническим источником данных.
11. Публичный контракт:
```python
get_exchange_symbols() -> list[ExchangeSymbol]
```
сохранён для обратной совместимости.
12. `validate_symbol()` продолжает работать через `get_exchange_symbols()`.
13. При отключённой бирже store, projection cache и acquisition pipeline не используются.
14. Ошибка acquisition не заполняет канонический store.
15. Ошибка acquisition не заполняет projection cache.
16. Пустой `tuple[Instrument, ...]` является валидным сохранённым значением и должен отличаться от отсутствия записи в store.
---
## 15. Изменённые production-файлы
Основные изменения Build 020 выполнены в:
```text
app/src/integrations/exchange/service.py
```
Используются ранее подготовленные компоненты Storage Layer:
```text
app/src/storage/instrument_store.py
app/src/storage/exceptions.py
```
Используется compatibility mapper:
```text
app/src/market_data/acquisition/compatibility.py
```
---
## 16. Тестовое покрытие
Основные проверки миграции находятся в:
```text
app/tests/unit/integrations/exchange/test_service_exchange_symbols.py
```
Дополнительно проверена совместимость:
```text
app/tests/unit/integrations/exchange/test_service_validate_symbol.py
```
И отдельно сохраняется тестовое покрытие самого store:
```text
app/tests/unit/storage/test_instrument_store.py
```
Проверены следующие сценарии:
- отключённая биржа возвращает пустой список;
- отключённая биржа не читает существующий `InstrumentStore`;
- cache hit legacy-проекции не запускает acquisition pipeline;
- store hit не запускает acquisition pipeline;
- store miss запускает acquisition pipeline;
- загруженные `Instrument` сохраняются в store;
- пустой tuple корректно обрабатывается как cache hit;
- compatibility mapping получает именно канонические `Instrument`;
- legacy-проекция сохраняет прежний тип `list[ExchangeSymbol]`;
- сохраняется порядок инструментов;
- acquisition error оборачивается в `ExchangeError`;
- acquisition error логируется с endpoint `exchangeInfo`;
- acquisition error не заполняет store;
- acquisition error не заполняет projection cache;
- acquisition loader использует реальную processing pipeline;
- acquisition loader использует registry key `dzengi`;
- acquisition loader возвращает `tuple[Instrument, ...]`;
- acquisition loader не вызывает compatibility mapper;
- `validate_symbol()` сохраняет прежнее поведение.
---
## 17. Результаты проверок
Целевая проверка `get_exchange_symbols()`:
```text
23 passed in 0.12s
```
Целевая проверка `validate_symbol()`:
```text
16 passed in 0.07s
```
Проверка синтаксической компиляции:
```text
py_compile — успешно
```
Полный regression suite:
```text
426 passed in 0.23s
```
Финальная архитектурная проверка подтвердила:
```text
старый _exchange_symbols_cache отсутствует в production-коде;
старый _load_exchange_symbols_via_acquisition отсутствует;
канонический store подключён через InstrumentStoreProtocol;
текущая реализация store — InMemoryInstrumentStore;
legacy projection cache отделён от канонического store;
compatibility mapper не вызывается внутри acquisition loader;
новый acquisition loader возвращает канонические Instrument.
```
---
## 18. Архитектурный результат
До Build 020:
```text
Exchange API
Acquisition Pipeline
Instrument
Compatibility Mapper
ExchangeSymbol
ExchangeService class-level cache
```
После Build 020:
```text
Exchange API
Acquisition Pipeline
Instrument
InstrumentStore
Canonical Instrument Reference Data
Compatibility Mapper
ExchangeSymbol
Temporary Legacy Projection Cache
```
Ключевое изменение:
```text
ExchangeSymbol больше не является канонически сохраняемой моделью Instrument Reference Data.
```
Каноническим представлением теперь является:
```python
Instrument
```
а владельцем его состояния является:
```text
Storage Layer
```
---
## 19. Ограничения текущего этапа
Build 020 намеренно не выполняет полную миграцию всех consumers на каноническую модель `Instrument`.
На текущем этапе сохраняются:
```python
get_exchange_symbols() -> list[ExchangeSymbol]
```
и:
```python
_exchange_symbols_projection_cache
```
Они являются compatibility-механизмами переходного периода.
Также Build 020 не переносит в новый `InstrumentStore`:
- market price cache;
- execution price cache;
- balance snapshots;
- journal storage;
- другие runtime caches.
Build 020 касается исключительно хранения канонических Instrument Reference Data.
---
## 20. Критерии завершения
Build 020 считается завершённым, если одновременно выполняются следующие условия:
- [x] созданный ранее `InstrumentStore` используется production-кодом;
- [x] канонические данные хранятся как `tuple[Instrument, ...]`;
- [x] старый `_exchange_symbols_cache` удалён;
- [x] старый `_load_exchange_symbols_via_acquisition()` удалён;
- [x] новый `_load_instruments_via_acquisition()` возвращает канонические модели;
- [x] acquisition loader не выполняет compatibility mapping;
- [x] legacy API `get_exchange_symbols()` сохранён;
- [x] `validate_symbol()` сохраняет прежнее поведение;
- [x] ошибки acquisition не создают ложное состояние кэша;
- [x] целевые тесты проходят;
- [x] `py_compile` проходит;
- [x] полный regression suite проходит;
- [x] финальная архитектурная grep-проверка выполнена.
---
## 21. Итоговый статус
```text
BUILD 020 — COMPLETE
```
Build 020 завершает фактический перенос канонического кэша Instrument Reference Data из legacy `ExchangeService._exchange_symbols_cache` в специализированный Storage Layer.
Существующий бот продолжает работать через сохранённый compatibility-контракт:
```python
get_exchange_symbols() -> list[ExchangeSymbol]
```
При этом новая архитектура уже располагает полноценным каноническим хранилищем:
```text
Instrument Acquisition Pipeline
tuple[Instrument, ...]
InstrumentStore
```
Это создаёт основу для дальнейшего поэтапного перевода consumers с legacy `ExchangeSymbol` на каноническую модель `Instrument` без нарушения работоспособности существующего бота.