# Build 015 — Переключение `get_exchange_symbols()` на новый Acquisition Pipeline

## Статус

**COMPLETE**

---

## Цель

Переключить существующий публичный метод:

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

с прямого legacy-получения и обработки `exchangeInfo` на новый стандартизированный Instrument Reference Data acquisition pipeline, сохранив при этом существующий внешний контракт и работоспособность старого бота.

---

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

До Build 015 метод:

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

самостоятельно выполнял весь цикл обработки `exchangeInfo`:

1. создавал `ExchangeRestClient`;
2. выполнял прямой REST-запрос:

   ```text
   /api/v1/exchangeInfo
   ```

3. извлекал массив `symbols`;
4. преобразовывал каждый элемент в legacy-модель `ExchangeSymbol`;
5. сохранял результат в class-level cache:

   ```python
   _exchange_symbols_cache
   ```

Таким образом, transport, validation, parsing, mapping и compatibility logic были сосредоточены внутри legacy `ExchangeService`.

---

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

Метод:

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

переключён на новый Instrument Reference Data acquisition pipeline.

Теперь production-путь использует следующую цепочку:

```text
ExchangeService.get_exchange_symbols()
        │
        ▼
_load_exchange_symbols_via_acquisition()
        │
        ▼
DzengiInstrumentDocumentSource
        │
        ▼
DzengiInstrumentDocumentHandler
        │
        ▼
InstrumentFeed
        │
        ▼
InstrumentFeedRegistry
        │
        ▼
InstrumentAcquisitionService
        │
        ▼
Instrument
        │
        ▼
map_instruments_to_exchange_symbols()
        │
        ▼
ExchangeSymbol
```

---

## Новый production-путь

В `ExchangeService` используется отдельный compatibility bridge:

```python
def _load_exchange_symbols_via_acquisition(
    self,
) -> list[ExchangeSymbol]:
```

Его задача:

1. создать источник Instrument Reference Data для Dzengi;
2. создать обработчик документа;
3. собрать `InstrumentFeed`;
4. зарегистрировать feed;
5. выполнить acquisition через `InstrumentAcquisitionService`;
6. получить канонические модели `Instrument`;
7. преобразовать их в legacy-модели `ExchangeSymbol`.

Это позволяет старому боту продолжать использовать существующий контракт:

```python
list[ExchangeSymbol]
```

при том, что фактическим источником данных уже является новая архитектура `market_data/acquisition`.

---

## Сохранённый публичный контракт

Сигнатура метода не изменилась:

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

Это принципиально важно для безопасной поэтапной миграции.

Существующие потребители не требуют немедленного изменения и продолжают работать через прежний API.

В частности, существующий UI продолжает использовать:

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

без знания о внутреннем переходе на новый acquisition pipeline.

---

## Сохранение cache semantics

Сохранён существующий class-level cache:

```python
_exchange_symbols_cache: list[ExchangeSymbol] | None = None
```

Поведение осталось прежним:

```text
Первый вызов
    │
    ▼
Новый acquisition pipeline
    │
    ▼
Compatibility mapping
    │
    ▼
_exchange_symbols_cache
    │
    ▼
list[ExchangeSymbol]
```

Последующие вызовы:

```text
_exchange_symbols_cache
    │
    ▼
list[ExchangeSymbol]
```

без повторного обращения к acquisition pipeline.

Cache заполняется только после успешной загрузки данных.

При ошибке acquisition cache остаётся незаполненным.

---

## Поведение при отключённой бирже

Сохранено прежнее поведение:

```python
if not self.settings.exchange_enabled:
    return []
```

Новый acquisition pipeline в этом случае не вызывается.

---

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

Ошибки нового acquisition pipeline проходят через существующую систему `ExchangeService`.

При ошибке:

1. ошибка логируется через:

   ```python
   self._log_exchange_error(...)
   ```

2. используется legacy endpoint identifier:

   ```text
   exchangeInfo
   ```

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

Это сохраняет существующее поведение старого бота и его журналирования.

---

## Удаление прямого legacy REST-пути

После Build 015 метод:

```python
get_exchange_symbols()
```

больше не выполняет прямой вызов:

```python
ExchangeRestClient().get_json("/api/v1/exchangeInfo")
```

Фактический REST transport теперь инкапсулирован в:

```text
src/market_data/acquisition/adapters/dzengi/rest.py
```

через:

```python
DzengiInstrumentDocumentSource
```

и константу:

```python
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
```

Таким образом, ownership получения Instrument Reference Data перенесён из:

```text
integrations/exchange
```

в:

```text
market_data/acquisition
```

---

## Legacy helpers

В `ExchangeService` временно остаются legacy helpers:

```python
_extract_exchange_symbols_raw()
_parse_exchange_symbol()
_parse_exchange_symbol_status()
_parse_market_modes()
_extract_filter_value()
```

Они больше не являются частью нового production-пути `get_exchange_symbols()`.

Их немедленное удаление не выполнялось в Build 015, поскольку миграция проводится поэтапно и без ненужного расширения scope текущего Build.

Удаление legacy helpers должно выполняться отдельным контролируемым этапом после подтверждения отсутствия production-зависимостей и завершения необходимых migration/equivalence проверок.

---

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

Создан файл:

```text
tests/unit/integrations/exchange/test_service_exchange_symbols.py
```

Тестами проверяются:

- возврат пустого списка при отключённой бирже;
- отсутствие вызова acquisition pipeline при отключённой бирже;
- возврат существующего cache;
- отсутствие повторного acquisition при наличии cache;
- загрузка через новый acquisition pipeline;
- заполнение `_exchange_symbols_cache`;
- повторное использование cache;
- сохранение legacy-типа `ExchangeSymbol`;
- сохранение порядка инструментов;
- корректное распространение ошибок;
- отсутствие заполнения cache при ошибке;
- сохранение существующего error logging;
- отсутствие прямого legacy REST-вызова из `get_exchange_symbols()`;
- корректная сборка нового acquisition pipeline;
- использование compatibility mapper;
- корректное поведение пустого результата.

---

## Исправление статической типизации теста

После первоначального завершения Build 015 в файле:

```text
tests/unit/integrations/exchange/test_service_exchange_symbols.py
```

были обнаружены две ошибки статической типизации Pylance.

### Типизация yield-fixture

Исходная аннотация:

```python
@pytest.fixture(autouse=True)
def reset_exchange_symbols_cache() -> None:
```

была некорректна, поскольку функция содержит `yield` и является генератором.

Исправлено на:

```python
@pytest.fixture(autouse=True)
def reset_exchange_symbols_cache() -> Iterator[None]:
    ExchangeService._exchange_symbols_cache = None

    yield

    ExchangeService._exchange_symbols_cache = None
```

Добавлен импорт:

```python
from collections.abc import Iterator
```

### Типизация тестовых settings

Тестовый helper создаёт `ExchangeService` без вызова его конструктора:

```python
service = object.__new__(ExchangeService)
```

Для изоляции теста используется `SimpleNamespace`, тогда как production-атрибут:

```python
service.settings
```

типизирован как `Settings`.

Для явного обозначения тестовой границы применён `cast`:

```python
service.settings = cast(
    Settings,
    _settings(
        exchange_enabled=exchange_enabled,
    ),
)
```

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

- production-код не изменялся;
- тестовая изоляция сохранена;
- `# type: ignore` не использовался;
- ошибки Pylance устранены.

---

## Результаты окончательной проверки

### Проверка компиляции

Команда:

```bash
python -m py_compile \
  src/integrations/exchange/service.py \
  tests/unit/integrations/exchange/test_service_exchange_symbols.py
```

Результат:

```text
Ошибок нет.
```

---

### Unit-тесты Build 015

Команда:

```bash
python -m pytest \
  tests/unit/integrations/exchange/test_service_exchange_symbols.py \
  -q
```

Результат:

```text
16 passed in 0.07s
```

---

### Полный regression suite

Команда:

```bash
python -m pytest -q
```

Результат:

```text
220 passed in 0.15s
```

---

## Проверка production-пути

Выполнен поиск:

```bash
grep -RIn \
  --exclude-dir="__pycache__" \
  --exclude="*.pyc" \
  -E "get_exchange_symbols|_exchange_symbols_cache|_load_exchange_symbols_via_acquisition|DzengiInstrumentDocumentSource|InstrumentAcquisitionService|map_instruments_to_exchange_symbols|ExchangeRestClient.*exchangeInfo|exchangeInfo" \
  src tests
```

Проверка подтвердила:

- `get_exchange_symbols()` использует `_load_exchange_symbols_via_acquisition()`;
- новый production-путь использует `DzengiInstrumentDocumentSource`;
- используется `InstrumentAcquisitionService`;
- используется compatibility mapper `map_instruments_to_exchange_symbols()`;
- class-level cache `_exchange_symbols_cache` сохранён;
- прямой legacy REST-вызов `exchangeInfo` удалён из `get_exchange_symbols()`;
- существующие внешние потребители продолжают работать через прежний публичный контракт.

---

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

До Build 015:

```text
Legacy consumer
    │
    ▼
ExchangeService.get_exchange_symbols()
    │
    ▼
ExchangeRestClient
    │
    ▼
exchangeInfo
    │
    ▼
Legacy parsing
    │
    ▼
ExchangeSymbol
```

После Build 015:

```text
Legacy consumer
    │
    ▼
ExchangeService.get_exchange_symbols()
    │
    ▼
Instrument Acquisition Pipeline
    │
    ▼
Canonical Instrument
    │
    ▼
Compatibility Mapper
    │
    ▼
ExchangeSymbol
```

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

- новый `market_data/acquisition` стал фактическим production-владельцем получения Instrument Reference Data;
- legacy `ExchangeService` сохраняет прежний публичный API;
- существующий бот продолжает работать без массового изменения потребителей;
- создан безопасный compatibility boundary между новой и старой архитектурой;
- переход выполнен без регрессий.

---

## Итог

```text
BUILD 015 — COMPLETE
```

Build 015 завершён.

`ExchangeService.get_exchange_symbols()` успешно переключён на новый Instrument Reference Data acquisition pipeline с сохранением:

- существующего публичного контракта;
- legacy-модели `ExchangeSymbol`;
- cache semantics;
- обработки ошибок;
- журналирования;
- существующих потребителей старого бота.

Окончательные результаты проверки:

```text
py_compile — успешно
16 passed in 0.07s
220 passed in 0.15s
```