# Build 019 — Подготовка переноса кэша в Storage

## Статус

**COMPLETE**

---

## Цель

Подготовить независимый storage-контракт для хранения канонического справочника инструментов перед последующим переносом legacy-кэша:

```python
ExchangeService._exchange_symbols_cache
```

из слоя:

```text
src/integrations/exchange
```

в слой:

```text
src/storage
```

без изменения текущего production-пути и без нарушения работы существующего бота.

---

## Архитектурный принцип

До Build 019 кэш справочника торговых инструментов находился непосредственно внутри legacy-интеграционного сервиса:

```python
ExchangeService._exchange_symbols_cache
```

Это создаёт архитектурную связь между:

- получением Instrument Reference Data;
- legacy-моделью `ExchangeSymbol`;
- интеграционным слоем биржи;
- runtime-хранением загруженного справочника.

В новой архитектуре ответственность разделяется:

```text
Market Data Acquisition
        │
        ▼
tuple[Instrument, ...]
        │
        ▼
Storage
        │
        ▼
Compatibility Layer
        │
        ▼
Legacy ExchangeSymbol
```

Build 019 создаёт только новый storage-контракт и его in-memory реализацию.

Переключение production-кода на новый store в рамках Build 019 не выполняется.

---

## Созданные файлы

```text
app/src/storage/exceptions.py
app/src/storage/instrument_store.py
app/tests/unit/storage/test_instrument_store.py
```

---

## `src/storage/exceptions.py`

Создана базовая иерархия ошибок storage-слоя:

```text
Exception
    │
    ▼
StorageError
    │
    ▼
InstrumentStoreError
```

### `StorageError`

Базовая ошибка storage-слоя.

### `InstrumentStoreError`

Специализированная ошибка операций и нарушений контракта хранилища справочника инструментов.

---

## `src/storage/instrument_store.py`

Созданы:

```text
InstrumentStoreProtocol
InMemoryInstrumentStore
```

### `InstrumentStoreProtocol`

Определяет независимый контракт runtime-хранилища канонического справочника инструментов.

Поддерживаемые операции:

```python
get(
    source_name: str,
) -> tuple[Instrument, ...] | None
```

```python
set(
    source_name: str,
    instruments: tuple[Instrument, ...],
) -> None
```

```python
clear(
    source_name: str | None = None,
) -> None
```

Контракт не зависит от:

- `ExchangeService`;
- `ExchangeSymbol`;
- Dzengi REST API;
- PostgreSQL;
- Redis;
- Telegram UI;
- legacy compatibility mapper.

---

## Семантика `get()`

Метод:

```python
get(source_name)
```

возвращает:

```text
None
```

если данные для источника никогда не сохранялись.

Это означает:

```text
cache miss
```

Если в store был успешно сохранён пустой справочник:

```python
()
```

метод возвращает именно:

```python
()
```

Таким образом:

```text
None != ()
```

и состояния:

```text
данные отсутствуют
```

и:

```text
успешно загружен пустой справочник
```

не смешиваются.

---

## Семантика `set()`

Метод принимает исключительно:

```python
tuple[Instrument, ...]
```

Это сохраняет immutable-контракт новой модели Instrument Reference Data.

Store не выполняет:

- копирование tuple;
- сортировку;
- преобразование элементов;
- mapping в `ExchangeSymbol`;
- нормализацию `Instrument`;
- изменение порядка элементов.

Сохраняется исходный объект tuple.

Следовательно:

```python
store.set("dzengi", instruments)

assert store.get("dzengi") is instruments
```

---

## Семантика `clear()`

Поддерживаются два режима.

Очистка конкретного источника:

```python
store.clear("dzengi")
```

Полная очистка store:

```python
store.clear()
```

Очистка неизвестного источника является идемпотентной и не вызывает ошибку.

---

## Изоляция источников

Store поддерживает независимое хранение нескольких источников:

```text
dzengi
secondary
other-source
```

Например:

```text
InMemoryInstrumentStore
├── dzengi
│   └── tuple[Instrument, ...]
│
└── secondary
    └── tuple[Instrument, ...]
```

Изменение или очистка одного источника не влияет на остальные.

---

## Нормализация имени источника

Внешние пробелы удаляются:

```text
"  dzengi  "
```

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

```text
"dzengi"
```

Регистр сохраняется.

Следовательно:

```text
dzengi
```

и:

```text
DZENGI
```

являются разными ключами.

Пустые имена источников запрещены:

```text
""
" "
"   "
"\t"
"\n"
```

и приводят к:

```python
InstrumentStoreError
```

---

## Runtime-валидация

`InMemoryInstrumentStore.set()` проверяет:

1. что набор передан как `tuple`;
2. что каждый элемент является экземпляром `Instrument`.

Нарушение контракта приводит к:

```python
InstrumentStoreError
```

---

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

Build 019 не изменяет:

```text
app/src/integrations/exchange/service.py
app/src/market_data/acquisition/service.py
app/src/market_data/acquisition/compatibility.py
app/src/storage/session.py
app/src/storage/schema.py
app/src/storage/models.py
app/src/storage/repositories/*
app/src/storage/__init__.py
```

Не добавлялись:

- PostgreSQL-таблицы;
- Redis;
- новый database repository;
- dependency injection в `ExchangeService`;
- production singleton store;
- глобальный storage registry.

---

## Legacy-кэш

После Build 019 legacy-кэш остаётся на прежнем месте:

```python
class ExchangeService:
    _exchange_symbols_cache: list[ExchangeSymbol] | None = None
```

Текущий production-путь остаётся неизменным:

```text
ExchangeService.get_exchange_symbols()
        │
        ├── cache hit
        │       │
        │       ▼
        │   _exchange_symbols_cache
        │
        └── cache miss
                │
                ▼
        _load_exchange_symbols_via_acquisition()
                │
                ▼
        InstrumentAcquisitionService
                │
                ▼
        tuple[Instrument, ...]
                │
                ▼
        map_instruments_to_exchange_symbols()
                │
                ▼
        list[ExchangeSymbol]
                │
                ▼
        _exchange_symbols_cache
```

Новый `InMemoryInstrumentStore` в этот production-путь пока не подключён.

---

## Тестовое покрытие

Создан файл:

```text
app/tests/unit/storage/test_instrument_store.py
```

Проверены:

- соответствие `InstrumentStoreProtocol`;
- cache miss;
- сохранение и получение данных;
- сохранение identity исходного tuple;
- различие между `None` и пустым tuple;
- изоляция разных источников;
- очистка одного источника;
- полная очистка store;
- замена ранее сохранённого значения;
- нормализация внешних пробелов имени источника;
- сохранение регистра имени источника;
- отклонение пустых имён источников;
- отклонение списка вместо tuple;
- отклонение объектов, не являющихся `Instrument`;
- сохранение порядка инструментов;
- отсутствие изменения входного tuple;
- изоляция разных экземпляров store;
- идемпотентная очистка неизвестного источника;
- наследование `InstrumentStoreError` от `StorageError`.

---

## Результаты проверок

### Unit-тесты нового store

Команда:

```bash
python -m pytest \
  tests/unit/storage/test_instrument_store.py \
  -q
```

Результат:

```text
32 passed in 0.02s
```

---

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

Команда:

```bash
python -m py_compile \
  src/storage/exceptions.py \
  src/storage/instrument_store.py \
  tests/unit/storage/test_instrument_store.py
```

Результат:

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

---

### Полный regression suite

Команда:

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

Результат:

```text
419 passed in 0.21s
```

---

## Архитектурная проверка

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

```bash
grep -RIn \
  --exclude-dir="__pycache__" \
  --exclude="*.pyc" \
  -E "InstrumentStoreProtocol|InMemoryInstrumentStore|InstrumentStoreError|StorageError|_exchange_symbols_cache|instrument_store" \
  src tests
```

Проверка подтвердила:

- `InstrumentStoreProtocol` определён в `src/storage/instrument_store.py`;
- `InMemoryInstrumentStore` определён в `src/storage/instrument_store.py`;
- `StorageError` и `InstrumentStoreError` находятся в `src/storage/exceptions.py`;
- новый store используется только собственными unit-тестами;
- production-код на новый store не переключён;
- `_exchange_symbols_cache` остаётся в `ExchangeService`;
- существующие legacy-тесты кэша продолжают работать.

---

## Итоговая архитектура после Build 019

```text
External Exchange API
        │
        ▼
Market Data Acquisition
        │
        ▼
tuple[Instrument, ...]
        │
        ├──────────────────────────────┐
        │                              │
        ▼                              ▼
InMemoryInstrumentStore       Compatibility Layer
        │                              │
        │                              ▼
        │                     list[ExchangeSymbol]
        │                              │
        │                              ▼
        │                 ExchangeService._exchange_symbols_cache
        │
        ▼
готов к будущему подключению
в production-путь
```

На текущем этапе новый store существует независимо от legacy-кэша.

---

## Граница Build 019

Build 019 считается завершённым, потому что:

1. создан независимый storage-контракт для `Instrument`;
2. создана in-memory реализация store;
3. сохранена семантика immutable `tuple[Instrument, ...]`;
4. определено различие между cache miss и пустым справочником;
5. обеспечена изоляция источников;
6. добавлена специализированная иерархия storage-ошибок;
7. production-код не изменён;
8. legacy-кэш не удалён;
9. полный regression suite проходит успешно.

---

## Следующий шаг

Следующий логический этап:

```text
Build 020 — Подключение Instrument Store к production-пути
```

Цель следующего этапа:

```text
переключить хранение канонического tuple[Instrument, ...]
с legacy-кэша ExchangeService
на InMemoryInstrumentStore
```

при сохранении внешнего legacy-контракта:

```python
ExchangeService.get_exchange_symbols() -> list[ExchangeSymbol]
```

и без нарушения работы существующего бота.

На Build 020 необходимо отдельно определить:

- где создаётся production-экземпляр `InMemoryInstrumentStore`;
- как `ExchangeService` получает доступ к нему;
- сохраняется ли временно `_exchange_symbols_cache` как compatibility-кэш;
- в какой точке выполняется mapping `Instrument -> ExchangeSymbol`;
- как сохранить существующую identity-семантику `get_exchange_symbols()`;
- как обеспечить безопасный rollback без изменения внешнего API.

---

## Итог

**Build 019 завершён успешно.**

Новый storage-контракт создан и полностью покрыт unit-тестами.

Существующий бот продолжает использовать прежний production-путь без изменений.

Следующий этап — **Build 020: безопасное подключение `InstrumentStore` к production-пути с сохранением legacy-совместимости**.