build 039: complete Quotes Feed migration foundation

This commit is contained in:
2026-07-14 09:58:16 +03:00
parent 26deb861bc
commit 7b62873832
443 changed files with 80452 additions and 1335 deletions

View File

@@ -0,0 +1,618 @@
# 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-совместимости**.