# 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` без нарушения работоспособности существующего бота.