# Build 032 — Канонический Quote Store

**Статус:** Завершён  
**Результат:** Успешно  
**Полная регрессия:** `562 passed`

---

## 1. Назначение Build

Цель Build 032 — создать каноническое оперативное хранилище текущих котировок `Quote` в Storage layer и подготовить архитектурную основу для последующего переноса legacy-механизма `MarketPriceCache`.

До выполнения Build 032 в проекте уже существовал канонический контур получения текущей котировки:

```text
Dzengi REST API
    ↓
DzengiQuoteDocumentSource
    ↓
Dzengi quote parser
    ↓
Dzengi quote value validation
    ↓
Dzengi quote mapper
    ↓
QuotesHandler
    ↓
QuotesFeed
    ↓
QuoteAcquisitionService
    ↓
Quote
```

Однако канонического хранилища объектов `Quote` ещё не существовало.

Текущие runtime-котировки продолжал хранить legacy-компонент:

```text
MarketPriceCache
```

Build 032 вводит новый независимый Storage-компонент:

```text
canonical Quote
        ↓
InMemoryQuoteStore
```

При этом существующий production pipeline не переключается на новое хранилище в рамках данного Build.

---

## 2. Архитектурная граница Build

Build 032 ограничен созданием канонического Quote Store.

В рамках Build:

- добавлен контракт `QuoteStoreProtocol`;
- добавлена in-memory реализация `InMemoryQuoteStore`;
- добавлена специализированная ошибка `QuoteStoreError`;
- реализовано хранение канонических объектов `Quote`;
- реализована изоляция по источнику данных;
- реализована изоляция по runtime-контексту;
- реализована изоляция по торговому инструменту;
- реализована полная, выборочная и комбинированная очистка;
- добавлен полный набор unit-тестов.

В рамках Build не выполнялись:

- изменение `ExchangeService`;
- изменение `MarketPriceCache`;
- подключение `QuoteStore` к `ExchangeService`;
- перенос данных из `MarketPriceCache`;
- изменение `QuotesFeed`;
- изменение `QuoteAcquisitionService`;
- изменение WebSocket-контура;
- изменение market runtime;
- изменение UI-потребителей;
- изменение execution-потребителей;
- удаление legacy-компонентов.

Эти изменения относятся к последующим Build утверждённого плана миграции Quotes Feed.

---

## 3. Изменённые файлы

### Изменён production-файл

```text
src/storage/exceptions.py
```

Добавлена специализированная ошибка:

```python
QuoteStoreError
```

### Добавлен production-файл

```text
src/storage/quote_store.py
```

Содержит:

```python
QuoteStoreProtocol
InMemoryQuoteStore
```

### Добавлен тестовый файл

```text
tests/unit/storage/test_quote_store.py
```

---

## 4. Целевая архитектура

После Build 032 Storage layer содержит два специализированных канонических хранилища:

```text
src/storage/
├── exceptions.py
├── instrument_store.py
└── quote_store.py
```

Архитектурно:

```text
Instrument
    ↓
InMemoryInstrumentStore
```

и:

```text
Quote
    ↓
InMemoryQuoteStore
```

`InstrumentStore` хранит канонический справочник инструментов.

`QuoteStore` хранит канонические текущие котировки.

---

## 5. Контракт Quote Store

Канонический контракт представлен протоколом:

```python
QuoteStoreProtocol
```

Он определяет три основные операции:

```text
get()
set()
clear()
```

Концептуальный контракт:

```python
@runtime_checkable
class QuoteStoreProtocol(Protocol):
    def get(
        self,
        source_name: str,
        symbol: str,
        *,
        runtime_key: str = "default",
    ) -> Quote | None:
        ...

    def set(
        self,
        source_name: str,
        quote: Quote,
        *,
        runtime_key: str = "default",
    ) -> None:
        ...

    def clear(
        self,
        source_name: str | None = None,
        symbol: str | None = None,
        *,
        runtime_key: str | None = None,
    ) -> None:
        ...
```

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

- Dzengi;
- REST;
- WebSocket;
- `ExchangeService`;
- `MarketPriceCache`;
- UI;
- Execution layer.

---

## 6. Модель хранения

Quote Store использует составной ключ:

```text
source_name + runtime_key + symbol
```

Внутреннее представление:

```python
tuple[str, str, str]
```

Примеры независимых записей:

```text
("dzengi", "auto", "BTC/USD_LEVERAGE")

("dzengi", "debug_auto", "BTC/USD_LEVERAGE")

("secondary", "auto", "BTC/USD_LEVERAGE")
```

Все эти записи независимы друг от друга.

Такая модель позволяет одновременно хранить:

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

---

## 7. Семантика `source_name`

`source_name` представляет namespace источника данных в Storage layer.

Применяются следующие правила:

```text
внешние пробелы удаляются;
регистр сохраняется;
пустое значение запрещено.
```

Пример:

```text
"  dzengi  " → "dzengi"
```

При этом:

```text
"DZENGI" != "dzengi"
```

Такое поведение соответствует существующей семантике:

```text
InstrumentRegistry
QuoteRegistry
InstrumentStore
```

Quote Store не требует равенства:

```text
source_name == quote.source
```

Это принципиально позволяет использовать алиасы источников:

```text
dzengi
dzengi-demo
dzengi-prod
dzengi-primary
```

при сохранении канонического происхождения самой модели в:

```python
quote.source
```

---

## 8. Семантика `runtime_key`

`runtime_key` разделяет независимые runtime-контексты.

Примеры:

```text
default
auto
debug_auto
```

Правила нормализации:

```text
внешние пробелы удаляются;
значение приводится к lowercase;
пустое значение запрещено.
```

Пример:

```text
"  AUTO  " → "auto"
```

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

```text
AUTO
Auto
auto
```

адресуют один runtime namespace:

```text
auto
```

Эта семантика соответствует существующему поведению legacy `MarketPriceCache`.

---

## 9. Семантика `symbol`

Символ используется как третья часть ключа Quote Store.

Правила нормализации:

```text
внешние пробелы удаляются;
значение приводится к uppercase;
пустое значение запрещено.
```

Пример:

```text
" btc/usd_leverage "
```

преобразуется в ключ:

```text
BTC/USD_LEVERAGE
```

Нормализация применяется только к ключу хранения.

Сам объект `Quote` не изменяется.

---

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

Метод:

```python
set()
```

сохраняет канонический объект `Quote`.

Основные гарантии:

- принимается объект `Quote`;
- используется `quote.symbol`;
- объект сохраняется без копирования;
- `Decimal` не преобразуется в `float`;
- `datetime` не преобразуется в строку;
- timestamps не изменяются;
- существующая запись с тем же ключом заменяется;
- содержимое `Quote` не нормализуется повторно.

Пример:

```python
store.set(
    "dzengi",
    quote,
    runtime_key="auto",
)
```

Если для ключа:

```text
("dzengi", "auto", "BTC/USD_LEVERAGE")
```

уже существует запись, она заменяется новой.

Quote Store сохраняет identity объекта:

```python
store.get(
    "dzengi",
    "BTC/USD_LEVERAGE",
    runtime_key="auto",
) is quote
```

---

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

Метод:

```python
get()
```

возвращает:

```python
Quote | None
```

Если запись существует:

```text
возвращается исходный сохранённый объект Quote
```

Если запись отсутствует:

```python
None
```

Store не:

- создаёт копию;
- выполняет сетевой запрос;
- обращается к Acquisition layer;
- вычисляет freshness;
- выполняет fallback.

---

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

Метод:

```python
clear()
```

поддерживает полную, выборочную и комбинированную очистку.

### 12.1. Полная очистка

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

Удаляет все сохранённые котировки.

---

### 12.2. Очистка по источнику

```python
store.clear(
    source_name="dzengi",
)
```

Удаляет все котировки указанного источника независимо от:

- символа;
- runtime-контекста.

---

### 12.3. Очистка по символу

```python
store.clear(
    symbol="BTC/USD_LEVERAGE",
)
```

Удаляет указанный символ у всех:

- источников;
- runtime-контекстов.

---

### 12.4. Очистка по runtime

```python
store.clear(
    runtime_key="auto",
)
```

Удаляет все котировки указанного runtime-контекста.

---

### 12.5. Точная очистка

```python
store.clear(
    source_name="dzengi",
    symbol="BTC/USD_LEVERAGE",
    runtime_key="auto",
)
```

Удаляет только одну конкретную запись.

---

### 12.6. Комбинированная очистка

Поддерживаются комбинации фильтров.

Например:

```python
store.clear(
    source_name="dzengi",
    runtime_key="auto",
)
```

Удаляет все котировки источника `dzengi` только из runtime:

```text
auto
```

Остальные записи сохраняются.

---

### 12.7. Идемпотентность

Очистка отсутствующей записи не является ошибкой.

Например:

```python
store.clear(
    source_name="unknown",
    symbol="UNKNOWN",
    runtime_key="unknown",
)
```

завершается без исключения.

---

## 13. Специализированная ошибка

В Storage layer добавлена ошибка:

```python
QuoteStoreError
```

Иерархия:

```text
Exception
    ↓
StorageError
    ↓
QuoteStoreError
```

Она используется для нарушений контракта Quote Store.

Примеры:

- пустой `source_name`;
- пустой `runtime_key`;
- пустой `symbol`;
- передача объекта неправильного типа.

Это позволяет отличать ошибки хранения котировок от:

- ошибок Acquisition;
- ошибок адаптера биржи;
- транспортных ошибок;
- ошибок Exchange facade;
- ошибок Execution layer.

---

## 14. Сохранение канонической модели

Quote Store хранит непосредственно:

```python
Quote
```

Хранилище не создаёт промежуточные представления типа:

```text
MarketPriceSnapshot
dict[str, object]
TickerPrice
```

Архитектурно:

```text
Quote
    ↓
Quote Store
```

а не:

```text
Quote
    ↓
legacy dict
    ↓
MarketPriceSnapshot
    ↓
Store
```

Это принципиально для дальнейшего устранения legacy quote representations.

---

## 15. Сохранение точности чисел

Числовые поля канонического `Quote` используют:

```python
Decimal
```

Quote Store сохраняет их без преобразования.

Не выполняется:

```text
Decimal → float
```

Таким образом сохраняются:

- точность котировок;
- исходная числовая семантика;
- единый канонический тип данных.

---

## 16. Сохранение временной семантики

Quote Store сохраняет временные поля модели без преобразования.

Не выполняется:

```text
datetime → str
```

Store не:

- форматирует timestamps;
- переводит время в локальную строку;
- вычисляет возраст записи;
- определяет freshness.

Временная семантика остаётся частью канонической модели `Quote`.

---

## 17. Изоляция экземпляров

Разные экземпляры:

```python
InMemoryQuoteStore()
```

имеют независимое состояние.

Пример:

```text
first_store
    ↓
собственные записи

second_store
    ↓
собственные записи
```

Запись в одном экземпляре не появляется в другом.

Это отличает Quote Store от legacy `MarketPriceCache`, использующего class-level storage.

---

## 18. Ответственность Quote Store

Quote Store отвечает только за:

```text
хранение уже созданных канонических Quote
```

Quote Store не отвечает за:

- получение данных;
- REST-запросы;
- WebSocket-соединения;
- parsing;
- schema validation;
- value validation;
- sequence validation;
- mapping;
- retry;
- reconnect;
- freshness;
- вычисление возраста;
- выбор REST или WebSocket;
- market status;
- execution pricing.

Эти обязанности принадлежат другим компонентам архитектуры.

---

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

Добавлен файл:

```text
tests/unit/storage/test_quote_store.py
```

Проверены:

- соответствие `InMemoryQuoteStore` протоколу `QuoteStoreProtocol`;
- получение отсутствующей записи;
- сохранение `Quote`;
- получение сохранённого `Quote`;
- сохранение identity объекта;
- замена существующей записи;
- изоляция разных источников;
- изоляция разных runtime-контекстов;
- изоляция разных символов;
- нормализация внешних пробелов `source_name`;
- сохранение регистра `source_name`;
- lowercase-нормализация `runtime_key`;
- uppercase-нормализация символа;
- запрет пустого `source_name`;
- запрет пустого `runtime_key`;
- запрет пустого символа;
- запрет объекта неправильного типа;
- полная очистка;
- очистка по источнику;
- очистка по runtime;
- очистка по символу;
- точечная очистка;
- комбинированная очистка;
- идемпотентность очистки;
- независимость экземпляров Store;
- сохранение `Decimal`;
- сохранение `datetime`;
- наследование `QuoteStoreError` от `StorageError`.

---

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

Проверка синтаксиса:

```bash
python -m py_compile \
  src/storage/exceptions.py \
  src/storage/quote_store.py \
  tests/unit/storage/test_quote_store.py
```

Результат:

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

Специализированные тесты:

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

Результат:

```text
92 passed in 0.05s
```

Полная регрессия:

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

Результат:

```text
562 passed in 0.28s
```

---

## 21. Изменение количества тестов

До Build 032:

```text
502 passed
```

После Build 032:

```text
562 passed
```

Добавлено:

```text
60 тестов
```

Полная регрессия осталась зелёной.

---

## 22. Состояние архитектуры после Build 032

После завершения Build 032 существуют два параллельных контура.

Канонический REST Quotes Feed:

```text
Dzengi REST API
    ↓
DzengiQuoteDocumentSource
    ↓
Dzengi quote parser
    ↓
Dzengi quote value validation
    ↓
Dzengi quote mapper
    ↓
QuotesHandler
    ↓
QuotesFeed
    ↓
QuoteAcquisitionService
    ↓
Quote
```

Каноническое хранилище:

```text
Quote
    ↓
InMemoryQuoteStore
```

При этом legacy runtime-контур пока продолжает использовать:

```text
MarketPriceCache
```

То есть на момент завершения Build 032:

```text
QuoteAcquisitionService
        ↓
      Quote

        и

InMemoryQuoteStore
```

существуют как канонические компоненты, но production pipeline ещё не переключён на новый Store.

---

## 23. Что не изменилось

Build 032 не изменил поведение работающего бота.

Не изменялись:

```text
src/integrations/exchange/service.py
src/integrations/exchange/market_cache.py
src/integrations/exchange/market_stream.py
src/integrations/exchange/market_data_runner.py
src/market_data/acquisition/service.py
```

Также не изменялись:

- Telegram UI;
- AutoTrade runtime;
- стратегии;
- diagnostics;
- debug runtime;
- execution pricing.

Это соответствует принятому принципу миграции:

```text
сначала создать новый канонический компонент
        ↓
проверить его изолированно
        ↓
подключить под существующие facade-контракты
        ↓
перевести потребителей
        ↓
удалить legacy только после полного переключения
```

---

## 24. Итог Build 032

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

Создан канонический Storage-компонент для текущих котировок:

```text
QuoteStoreProtocol
        ↓
InMemoryQuoteStore
        ↓
Quote
```

Достигнуты следующие архитектурные свойства:

```text
каноническая модель хранения
изоляция источников
изоляция runtime-контекстов
изоляция символов
сохранение Decimal
сохранение datetime
отсутствие зависимости от биржи
отсутствие зависимости от Acquisition
отсутствие зависимости от ExchangeService
отсутствие зависимости от legacy MarketPriceCache
полная тестовая изоляция
```

Build завершён с полной зелёной регрессией:

```text
562 passed
```

---

## 25. Следующий этап

Следующий этап утверждённого плана:

```text
Build 033 — Перенос MarketPriceCache на Quote Store
```

Его задача — начать интеграцию канонического `QuoteStore` в существующий runtime-контур котировок без нарушения обратной совместимости работающего бота.

Целевая переходная схема:

```text
legacy consumers
        ↓
ExchangeService facade
        ↓
MarketPriceCache compatibility layer
        ↓
Quote Store
        ↓
canonical Quote
```

После Build 033 `MarketPriceCache` должен перестать быть самостоятельным владельцем quote state и стать временным compatibility layer над каноническим `QuoteStore`.