build 039: complete Quotes Feed migration foundation
This commit is contained in:
618
docs/migrations/build_019.md
Normal file
618
docs/migrations/build_019.md
Normal 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-совместимости**.
|
||||
Reference in New Issue
Block a user