618 lines
14 KiB
Markdown
618 lines
14 KiB
Markdown
# 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-совместимости**. |