# 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-совместимости**.