# 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`.