# Build 021 — Перевод первой группы потребителей на канонический Instrument API

## Статус

**Завершён**

---

## Цель Build

Перевести первую группу production-потребителей справочника инструментов с legacy-модели:

```python
ExchangeSymbol
```

и legacy API:

```python
ExchangeService.get_exchange_symbols()
```

на каноническую модель:

```python
Instrument
```

и новый публичный API:

```python
ExchangeService.get_instruments()
```

При этом необходимо:

- сохранить работоспособность существующего бота;
- не удалять legacy API преждевременно;
- не нарушить существующие runtime-контуры;
- сохранить прежнее поведение потребителей;
- продолжить постепенный переход к каноническому Instrument Reference Data pipeline;
- не создавать параллельный источник истины для справочника инструментов.

---

## Исходное состояние

После завершения Build 020 канонический справочник инструментов уже сохранялся в:

```python
InstrumentStoreProtocol
```

с текущей in-memory реализацией:

```python
InMemoryInstrumentStore
```

В `ExchangeService` существовали два уровня представления данных:

```text
Instrument
    ↓
Instrument Store
    ↓
get_exchange_symbols()
    ↓
ExchangeSymbol
    ↓
legacy consumers
```

Каноническая модель:

```python
Instrument
```

уже являлась источником полных reference data инструмента, однако production-потребители продолжали использовать legacy-модель:

```python
ExchangeSymbol
```

Первым выбранным потребителем стал:

```text
app/src/telegram/ui/currency_ui.py
```

До миграции он получал список инструментов через:

```python
exchange_service.get_exchange_symbols()
```

и работал с:

```python
ExchangeSymbol
```

---

## Принятое архитектурное решение

В `ExchangeService` добавлен публичный канонический API:

```python
def get_instruments(self) -> tuple[Instrument, ...]:
    ...
```

Теперь новые потребители должны получать reference data через:

```python
ExchangeService.get_instruments()
```

Legacy API:

```python
ExchangeService.get_exchange_symbols()
```

сохраняется как compatibility API для ещё не переведённых потребителей.

Целевая архитектура на текущем этапе:

```text
Dzengi exchangeInfo
        ↓
DzengiInstrumentDocumentSource
        ↓
InstrumentFeed
        ↓
DzengiInstrumentDocumentHandler
        ↓
InstrumentAcquisitionService
        ↓
tuple[Instrument, ...]
        ↓
Instrument Store
        ↓
ExchangeService.get_instruments()
        ├──→ new consumers
        │
        └──→ get_exchange_symbols()
                    ↓
        map_instruments_to_exchange_symbols()
                    ↓
            list[ExchangeSymbol]
                    ↓
             legacy consumers
```

Таким образом:

- `Instrument` является канонической моделью;
- `Instrument Store` является runtime-хранилищем канонического справочника;
- `get_instruments()` является публичным API для новых и мигрированных потребителей;
- `get_exchange_symbols()` является временным compatibility API;
- `ExchangeSymbol` не является источником истины.

---

## Реализованные изменения

### 1. Добавлен публичный `get_instruments()`

В файле:

```text
app/src/integrations/exchange/service.py
```

добавлен публичный метод:

```python
def get_instruments(self) -> tuple[Instrument, ...]:
    ...
```

Метод обеспечивает единый доступ к каноническому справочнику инструментов.

Его поведение:

```text
exchange disabled
        ↓
return ()

exchange enabled
        ↓
Instrument Store lookup
        ↓
    ┌── cache hit ──→ return tuple[Instrument, ...]
    │
    └── cache miss
            ↓
    acquisition pipeline
            ↓
    tuple[Instrument, ...]
            ↓
    Instrument Store
            ↓
    return instruments
```

---

### 2. Сохранено поведение при выключенной бирже

Если:

```python
exchange_enabled is False
```

метод:

```python
get_instruments()
```

возвращает:

```python
()
```

При этом:

- `Instrument Store` не читается;
- acquisition pipeline не запускается;
- внешние запросы к бирже не выполняются.

---

### 3. Реализовано чтение из Instrument Store

При вызове:

```python
get_instruments()
```

сначала проверяется каноническое хранилище:

```python
ExchangeService._instrument_store
```

Если данные уже присутствуют, метод возвращает сохранённый:

```python
tuple[Instrument, ...]
```

без повторного запуска acquisition pipeline.

Это устраняет повторную загрузку reference data и сохраняет единый runtime-источник канонических инструментов.

---

### 4. Реализована загрузка при отсутствии данных в Store

Если `Instrument Store` не содержит данных для текущего источника, `get_instruments()` запускает существующий acquisition pipeline:

```text
DzengiInstrumentDocumentSource
        ↓
InstrumentFeed
        ↓
DzengiInstrumentDocumentHandler
        ↓
InstrumentAcquisitionService
        ↓
tuple[Instrument, ...]
```

Полученные канонические модели:

```python
Instrument
```

сохраняются в:

```python
Instrument Store
```

и затем возвращаются вызывающему коду.

---

### 5. `get_exchange_symbols()` переведён на канонический `get_instruments()`

Legacy API:

```python
get_exchange_symbols()
```

больше не должен самостоятельно загружать справочник инструментов.

Теперь его роль ограничена compatibility projection:

```text
get_instruments()
        ↓
tuple[Instrument, ...]
        ↓
map_instruments_to_exchange_symbols()
        ↓
list[ExchangeSymbol]
```

Таким образом, оба API используют один канонический источник данных:

```text
Instrument Store
```

а параллельная загрузка reference data отсутствует.

---

### 6. Переведён первый production-потребитель

Файл:

```text
app/src/telegram/ui/currency_ui.py
```

переведён с:

```python
ExchangeSymbol
```

на:

```python
Instrument
```

До миграции использовался resolver:

```python
_resolve_asset_quote_symbol()
```

После миграции используется:

```python
_resolve_asset_quote_instrument()
```

До миграции:

```python
symbols = exchange_service.get_exchange_symbols()
```

После миграции:

```python
instruments = exchange_service.get_instruments()
```

Теперь `currency_ui.py` больше не зависит от:

```python
ExchangeSymbol
```

и:

```python
get_exchange_symbols()
```

---

## Поведение `currency_ui.py` после миграции

Логика выбора торгового инструмента сохранена.

Для заданного актива:

```python
BTC
```

resolver получает:

```python
tuple[Instrument, ...]
```

и выбирает кандидатов, у которых:

```text
base_asset == BTC
```

и:

```text
quote_asset ∈ {USD, USDT}
```

Затем кандидаты сортируются по прежним приоритетам:

```text
1. quote asset
2. trading status
3. market type
4. symbol
```

Приоритет котируемой валюты:

```text
USD  → 3
USDT → 2
other → 0
```

Приоритет статуса:

```text
TRADING      → 2
other status → 1
HALT/BREAK   → 0
```

Приоритет типа рынка:

```text
SPOT     → 3
LEVERAGE → 2
other    → 1
```

Таким образом, поведение выбора инструмента осталось совместимым с предыдущей реализацией.

---

## Получение USD-оценки актива

Функция:

```python
get_asset_usd_rate()
```

теперь использует канонический `Instrument`.

Последовательность:

```text
currency
    ↓
USD / USDT?
    ├── yes → 1.0
    │
    └── no
          ↓
    price cache hit?
          ├── yes → cached rate
          │
          └── no
                ↓
    _resolve_asset_quote_instrument()
                ↓
          Instrument | None
                ↓
    exchange_service.get_price(instrument.symbol)
                ↓
              rate
```

При этом существующая логика:

```python
USDT ~= USD
```

сохранена без изменения.

---

## Обработка ошибок

Если:

```python
exchange_service.get_instruments()
```

вызывает:

```python
ExchangeError
```

resolver возвращает:

```python
None
```

Если подходящий инструмент отсутствует:

```python
None
```

Если получение цены вызывает:

```python
ExchangeError
```

в price cache сохраняется:

```python
None
```

и функция возвращает:

```python
None
```

Таким образом, прежняя отказоустойчивая семантика `currency_ui.py` сохранена.

---

## Добавленные тесты

Добавлен отдельный тестовый файл:

```text
app/tests/unit/integrations/exchange/test_service_instruments.py
```

Он проверяет канонический API:

```python
ExchangeService.get_instruments()
```

В том числе:

- возврат пустого tuple при выключенной бирже;
- отсутствие чтения Store при выключенной бирже;
- отсутствие запуска acquisition pipeline при выключенной бирже;
- чтение существующих инструментов из Store;
- загрузку через acquisition pipeline при cache miss;
- сохранение загруженных инструментов в Store;
- повторное использование Store;
- общее состояние Store между экземплярами `ExchangeService`;
- сохранение ошибок acquisition pipeline;
- отсутствие fallback на legacy REST path;
- использование `get_instruments()` внутри `get_exchange_symbols()`;
- преобразование канонических `Instrument` в legacy `ExchangeSymbol`;
- сохранение identity compatibility projection cache.

---

## Добавлены тесты для первого production-потребителя

Добавлен файл:

```text
app/tests/unit/telegram/ui/test_currency_ui.py
```

Тестами зафиксировано, что `currency_ui.py`:

- использует `get_instruments()`;
- не использует `get_exchange_symbols()`;
- работает с `Instrument`;
- выбирает инструмент с `USD` раньше `USDT`;
- учитывает статус инструмента;
- учитывает тип рынка;
- сохраняет детерминированную сортировку;
- возвращает `None`, если подходящего инструмента нет;
- возвращает `None` при ошибке получения справочника;
- не выполняет instrument lookup при наличии цены в cache;
- сохраняет прежнее поведение USD/USDT;
- корректно получает цену через `instrument.symbol`;
- сохраняет `None` в cache при ошибке получения цены;
- корректно рассчитывает USD-оценку баланса.

---

## Результаты тестирования

Проверка канонического API:

```text
14 passed in 0.11s
```

Проверка первого production-потребителя:

```text
20 passed in 0.08s
```

Совместная проверка затронутого migration-контура:

```text
73 passed in 0.11s
```

Полный regression suite:

```text
460 passed in 0.26s
```

Все тесты проходят успешно.

---

## Проверка фактического состояния кода

После завершения Build 021 файл:

```text
app/src/telegram/ui/currency_ui.py
```

содержит:

```python
from src.market_data.acquisition.models.instrument import Instrument
```

и использует:

```python
exchange_service.get_instruments()
```

Legacy-зависимости в этом production-потребителе отсутствуют:

```text
ExchangeSymbol
get_exchange_symbols()
_resolve_asset_quote_symbol()
```

В `ExchangeService` одновременно существуют:

```python
def get_instruments(self) -> tuple[Instrument, ...]:
    ...
```

и:

```python
def get_exchange_symbols(self) -> list[ExchangeSymbol]:
    ...
```

Это ожидаемое промежуточное состояние миграции.

---

## Что намеренно не сделано в Build 021

В рамках этого Build не удалялись:

```python
ExchangeSymbol
```

```python
SymbolValidationResult
```

```python
get_exchange_symbols()
```

```python
validate_symbol()
```

```python
map_instruments_to_exchange_symbols()
```

```python
_exchange_symbols_projection_cache
```

Они остаются необходимыми для ещё не переведённых legacy-потребителей.

Также не переводились следующие runtime-контуры:

```text
ExchangeService internal runtime methods
market_stream.py
market_data_runner.py
telegram/handlers/market.py
```

Их миграция должна выполняться отдельными Build с собственными regression tests.

---

## Архитектурный результат

До Build 021:

```text
Instrument
    ↓
Instrument Store
    ↓
ExchangeSymbol projection
    ↓
all production consumers
```

После Build 021:

```text
                    Instrument
                        ↓
                 Instrument Store
                        ↓
            ExchangeService.get_instruments()
                  ┌─────┴─────┐
                  ↓           ↓
          new consumers   compatibility
                  ↓           ↓
           currency_ui   get_exchange_symbols()
                              ↓
                         ExchangeSymbol
                              ↓
                      legacy consumers
```

Первый production-потребитель полностью переведён на канонический `Instrument API`.

---

## Критерии завершения Build 021

Build считается завершённым, поскольку выполнены все критерии:

- [x] добавлен публичный `ExchangeService.get_instruments()`;
- [x] `get_instruments()` использует `Instrument Store`;
- [x] при cache miss используется acquisition pipeline;
- [x] при выключенной бирже не читается Store и не запускается acquisition;
- [x] `get_exchange_symbols()` получает данные через `get_instruments()`;
- [x] первый production-потребитель переведён на `Instrument`;
- [x] `currency_ui.py` больше не использует `ExchangeSymbol`;
- [x] `currency_ui.py` больше не вызывает `get_exchange_symbols()`;
- [x] legacy API сохранён для остальных потребителей;
- [x] добавлены unit tests для `get_instruments()`;
- [x] добавлены unit tests для `currency_ui.py`;
- [x] migration-контур проходит `73` теста;
- [x] полный regression suite проходит `460` тестов;
- [x] существующий бот остаётся работоспособным.

---

## Итог

**Build 021 завершён успешно.**

В проекте появился публичный канонический API:

```python
ExchangeService.get_instruments()
```

Первый production-потребитель:

```text
app/src/telegram/ui/currency_ui.py
```

переведён с legacy-модели:

```python
ExchangeSymbol
```

на каноническую:

```python
Instrument
```

При этом legacy compatibility layer сохранён для остальных потребителей, а полный regression suite подтверждает отсутствие регрессий:

```text
460 passed in 0.26s
```