# Build 010 — Instrument Feed

**Статус:** Завершён  
**Подсистема:** `market_data/acquisition`  
**Область:** Instrument Reference Data  
**Тип изменения:** Изолированное добавление orchestration-компонента Feed без подключения к production runtime  
**Результат полного набора тестов:** `142 passed`

---

## 1. Цель Build 010

Цель Build 010 — реализовать общий Instrument Feed, соединяющий уже существующие контракты:

```text
InstrumentDocumentSource
    ↓
InstrumentDocumentHandler
```

и предоставляющий единый интерфейс:

```text
InstrumentFeedProtocol
```

Реализован класс:

```text
InstrumentFeed
```

Архитектурная граница Build 010:

```text
InstrumentFeed.load_instruments()
    ↓
InstrumentDocumentSource.fetch_instrument_document()
    ↓
object
    ↓
InstrumentDocumentHandler.handle_instrument_document()
    ↓
tuple[Instrument, ...]
```

Build 010 не создаёт Registry, Acquisition Service, compatibility mapper и не подключается к существующему `ExchangeService`.

---

## 2. Почему Build 010 выполняется именно сейчас

До начала Build 010 были завершены:

```text
Build 001 — внутренняя модель Instrument Reference Data
Build 002 — raw-модели ответа Dzengi
Build 003 — структурная валидация exchangeInfo
Build 004 — parser exchangeInfo
Build 005 — value validation
Build 006 — mapper Dzengi → Instrument
Build 007 — Protocol и Exceptions
Build 008 — Dzengi REST Adapter
Build 009 — Instrument Handler
```

После Build 009 существовали две независимые части acquisition pipeline.

### Получение документа

```text
Dzengi REST API
    ↓
ExchangeRestClient.get_payload()
    ↓
DzengiInstrumentDocumentSource
    ↓
object
```

### Обработка документа

```text
object
    ↓
DzengiInstrumentDocumentHandler
    ↓
validate_exchange_info_schema()
    ↓
parse_exchange_info()
    ↓
validate_exchange_info_values()
    ↓
map_dzengi_exchange_info_to_instruments()
    ↓
tuple[Instrument, ...]
```

До Build 010 эти две части не были соединены общим orchestration-компонентом.

Build 010 реализовал эту связь через абстрактные контракты:

```text
InstrumentDocumentSource
InstrumentDocumentHandler
```

без зависимости самого Feed от конкретной биржи или формата документа.

---

## 3. Изменённые файлы

В рамках Build 010 реализован production-файл:

```text
app/src/market_data/acquisition/feeds/instrument_feed.py
```

Создан тестовый файл:

```text
app/tests/unit/market_data/acquisition/feeds/test_instrument_feed.py
```

Другие production-файлы не изменялись.

---

## 4. Реализованный Instrument Feed

В файле:

```text
app/src/market_data/acquisition/feeds/instrument_feed.py
```

реализован класс:

```python
class InstrumentFeed:
    ...
```

Его публичный метод:

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

соответствует контракту:

```text
InstrumentFeedProtocol
```

---

## 5. Зависимости Instrument Feed

`InstrumentFeed` получает две обязательные зависимости:

```python
def __init__(
    self,
    *,
    source: InstrumentDocumentSource,
    handler: InstrumentDocumentHandler,
) -> None:
    ...
```

Первая зависимость:

```text
InstrumentDocumentSource
```

отвечает за получение исходного документа.

Вторая зависимость:

```text
InstrumentDocumentHandler
```

отвечает за преобразование исходного документа во внутренние модели:

```text
tuple[Instrument, ...]
```

Обе зависимости передаются явно через конструктор.

---

## 6. Реализованный orchestration pipeline

Метод:

```python
load_instruments()
```

выполняет только две операции:

```python
document = self._source.fetch_instrument_document()

return self._handler.handle_instrument_document(document)
```

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

```text
InstrumentFeed.load_instruments()
    ↓
source.fetch_instrument_document()
    ↓
object
    ↓
handler.handle_instrument_document(document)
    ↓
tuple[Instrument, ...]
```

Feed не реализует самостоятельно transport, parsing, validation или mapping.

---

## 7. Разделение ответственности

После Build 010 обязанности компонентов разделены следующим образом.

### InstrumentDocumentSource

Отвечает за:

```text
получение документа от внешнего источника.
```

Конкретная реализация:

```text
DzengiInstrumentDocumentSource
```

---

### InstrumentDocumentHandler

Отвечает за:

```text
преобразование исходного документа
во внутренние модели Instrument.
```

Конкретная реализация:

```text
DzengiInstrumentDocumentHandler
```

---

### InstrumentFeed

Отвечает только за:

```text
получить документ через Source;
передать документ Handler;
вернуть результат Handler.
```

Feed не знает внутренней реализации Source и Handler.

---

## 8. Независимость Instrument Feed от Dzengi

В `instrument_feed.py` отсутствуют прямые зависимости от:

```text
DzengiInstrumentDocumentSource
DzengiInstrumentDocumentHandler
ExchangeRestClient
DzengiExchangeInfoResponse
DzengiExchangeInfoSymbol
Dzengi parser
Dzengi mapper
Dzengi validation pipeline
```

Feed зависит только от общих контрактов:

```text
InstrumentDocumentSource
InstrumentDocumentHandler
```

и внутренней модели:

```text
Instrument
```

Архитектурная зависимость:

```text
InstrumentFeed
    ↓
InstrumentDocumentSource Protocol
    ↓
конкретная реализация определяется снаружи
```

```text
InstrumentFeed
    ↓
InstrumentDocumentHandler Protocol
    ↓
конкретная реализация определяется снаружи
```

Таким образом, Feed остаётся source-independent orchestration-компонентом.

---

## 9. Почему Feed не создаёт зависимости самостоятельно

В Build 010 намеренно не используется:

```python
self._source = DzengiInstrumentDocumentSource()
self._handler = DzengiInstrumentDocumentHandler()
```

Такое решение напрямую связало бы общий Feed с конкретным источником Dzengi.

Вместо этого используется явная dependency injection:

```python
InstrumentFeed(
    source=source,
    handler=handler,
)
```

Это обеспечивает:

```text
независимость от конкретной биржи;
явные архитектурные зависимости;
тестируемость без сети;
возможность другой реализации Source;
возможность другой реализации Handler;
отсутствие скрытой composition logic.
```

Создание конкретной production-композиции не относится к ответственности Feed.

---

## 10. Почему аргументы конструктора keyword-only

Конструктор объявлен как:

```python
def __init__(
    self,
    *,
    source: InstrumentDocumentSource,
    handler: InstrumentDocumentHandler,
) -> None:
    ...
```

Символ:

```text
*
```

делает аргументы keyword-only.

Корректное создание:

```python
InstrumentFeed(
    source=source,
    handler=handler,
)
```

Это явно показывает роли зависимостей и исключает позиционную неоднозначность.

---

## 11. Отсутствие нового Feed exception

В Build 010 намеренно не создавалась ошибка:

```text
InstrumentReferenceFeedError
```

Feed сохраняет без дополнительного wrapping специализированные ошибки нижележащих компонентов:

```text
InstrumentReferenceTransportError
InstrumentReferenceSchemaError
InstrumentReferenceParseError
InstrumentReferenceValueError
InstrumentReferenceMappingError
```

Это сохраняет точную диагностическую классификацию отказов.

---

## 12. Отсутствие try/except в Instrument Feed

В `load_instruments()` намеренно отсутствует:

```python
try:
    ...
except Exception:
    ...
```

Если Source выбрасывает:

```text
InstrumentReferenceTransportError
```

Feed передаёт тот же объект исключения вызывающему коду.

Если Handler выбрасывает:

```text
InstrumentReferenceSchemaError
InstrumentReferenceParseError
InstrumentReferenceValueError
InstrumentReferenceMappingError
```

Feed также передаёт исходное исключение без изменения.

Цепочка:

```text
нижний компонент
    ↓
специализированное исключение
    ↓
InstrumentFeed
    ↓
то же исключение
    ↓
внешний потребитель
```

---

## 13. Поведение при transport error

Если:

```text
source.fetch_instrument_document()
```

выбрасывает:

```text
InstrumentReferenceTransportError
```

то:

```text
ошибка передаётся без wrapping;
handler не вызывается;
повторный запрос не выполняется.
```

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

```text
InstrumentFeed.load_instruments()
    ↓
source.fetch_instrument_document()
    ↓
InstrumentReferenceTransportError
    ↓
выполнение прекращается
```

Handler в этом случае не получает документ.

---

## 14. Поведение при processing error

Если Source успешно возвращает документ:

```text
object
```

но Handler выбрасывает специализированную processing-ошибку, например:

```text
InstrumentReferenceValueError
```

то Feed:

```text
не перехватывает ошибку;
не заменяет её другим типом;
не повторяет Source;
не повторяет Handler.
```

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

```text
source
    ↓
document
    ↓
handler
    ↓
InstrumentReferenceValueError
    ↓
внешний потребитель
```

---

## 15. Отсутствие retry

Build 010 не реализует retry.

Если Source завершился ошибкой:

```text
InstrumentReferenceTransportError
```

Feed не выполняет:

```text
повторный REST-запрос;
задержку;
backoff;
повторный вызов Source.
```

В рамках одного вызова:

```python
feed.load_instruments()
```

Source вызывается ровно один раз.

Retry policy, если она потребуется, должна находиться на другом архитектурном уровне и не должна скрыто появляться внутри базового Feed.

---

## 16. Передача документа без изменения

Документ, возвращённый Source:

```python
document = self._source.fetch_instrument_document()
```

передаётся непосредственно Handler:

```python
self._handler.handle_instrument_document(document)
```

Feed не выполняет:

```text
копирование;
преобразование;
нормализацию;
фильтрацию;
валидацию;
оборачивание;
изменение структуры.
```

Тестом подтверждено сохранение identity:

```python
handler.documents[0] is document
```

---

## 17. Возврат результата без изменения

Если Handler возвращает:

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

Feed возвращает непосредственно тот же объект:

```python
return self._handler.handle_instrument_document(document)
```

Feed не выполняет:

```python
tuple(instruments)
```

или другое копирование коллекции.

Тестом подтверждено:

```python
result is instruments
```

Это гарантирует отсутствие скрытого преобразования результата.

---

## 18. Сохранение порядка инструментов

Feed не выполняет:

```text
сортировку;
фильтрацию;
дедупликацию;
перегруппировку.
```

Если Handler возвращает:

```text
BTC/USD_LEVERAGE
ETH/USD_LEVERAGE
XRP/USD_LEVERAGE
```

Feed возвращает инструменты в том же порядке:

```text
BTC/USD_LEVERAGE
ETH/USD_LEVERAGE
XRP/USD_LEVERAGE
```

Ответственность за порядок данных не переносится в Feed.

---

## 19. Поведение при пустом результате

Если Handler возвращает:

```python
()
```

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

```python
()
```

без ошибки.

Feed не определяет, является ли пустой справочник:

```text
допустимым состоянием;
временной ошибкой;
критическим отказом;
условием для сохранения предыдущего snapshot.
```

Такая policy logic не относится к ответственности Feed.

---

## 20. Что Instrument Feed не делает

Build 010 сознательно не выполняет:

```text
REST-запросы самостоятельно;
парсинг JSON;
schema validation;
value validation;
mapping;
retry;
backoff;
кэширование;
фильтрацию инструментов;
сортировку инструментов;
удаление дубликатов;
нормализацию символов;
проверку exchange_enabled;
создание Registry;
создание Acquisition Service;
создание legacy ExchangeSymbol;
создание compatibility mapper;
логирование;
формирование Telegram-сообщений;
изменение ExchangeService;
изменение AutoTrade;
изменение trading runtime.
```

Единственная ответственность Feed:

```text
Source
    ↓
Handler
    ↓
tuple[Instrument, ...]
```

---

## 21. Реализованные тестовые сценарии

Создан файл:

```text
app/tests/unit/market_data/acquisition/feeds/test_instrument_feed.py
```

Реализовано 10 тестов.

Проверены следующие сценарии:

1. `InstrumentFeed` соответствует `InstrumentFeedProtocol`;
2. Source вызывается один раз;
3. Handler вызывается один раз;
4. документ передаётся Handler без изменения;
5. результат Handler возвращается без изменения;
6. порядок инструментов сохраняется;
7. пустой `tuple` возвращается без ошибки;
8. `InstrumentReferenceTransportError` сохраняется без wrapping;
9. processing error сохраняется без wrapping;
10. после transport error Source не вызывается повторно.

---

## 22. Проверка соответствия InstrumentFeedProtocol

Тестом подтверждено:

```python
assert isinstance(feed, InstrumentFeedProtocol)
```

Это возможно благодаря:

```text
@runtime_checkable
```

в определении Protocol и структурному соответствию метода:

```python
load_instruments() -> tuple[Instrument, ...]
```

Feed не наследуется явно от Protocol.

Используется structural typing:

```text
если объект реализует требуемый контракт,
он соответствует Protocol.
```

---

## 23. Проверка вызова Source

Тестовый Source ведёт счётчик:

```python
self.call_count = 0
```

При каждом вызове:

```python
fetch_instrument_document()
```

значение увеличивается.

После:

```python
feed.load_instruments()
```

подтверждено:

```python
assert source.call_count == 1
```

Таким образом, Feed не выполняет скрытых повторных запросов.

---

## 24. Проверка вызова Handler

Тестовый Handler сохраняет полученные документы:

```python
self.documents: list[object] = []
```

При вызове:

```python
handle_instrument_document(document)
```

документ добавляется в список.

После:

```python
feed.load_instruments()
```

подтверждено:

```python
assert len(handler.documents) == 1
```

Handler вызывается ровно один раз.

---

## 25. Проверка transport error

Создан исходный объект ошибки:

```python
original_error = InstrumentReferenceTransportError(
    "Не удалось получить exchangeInfo."
)
```

Source выбрасывает именно этот объект.

Тест подтверждает:

```python
assert exc_info.value is original_error
```

Это доказывает отсутствие:

```text
wrapping;
замены исключения;
создания нового объекта ошибки.
```

Дополнительно подтверждено:

```python
assert source.call_count == 1
assert handler.documents == []
```

То есть:

```text
Source вызван один раз;
Handler не вызван.
```

---

## 26. Проверка processing error

Создан исходный объект:

```python
original_error = InstrumentReferenceValueError(
    "Некорректное значение."
)
```

Handler выбрасывает именно этот объект.

Тест подтверждает:

```python
assert exc_info.value is original_error
```

Дополнительно:

```python
assert source.call_count == 1
assert handler.documents == [document]
```

Это подтверждает корректную последовательность:

```text
Source успешно вызван один раз
    ↓
документ передан Handler
    ↓
Handler выбросил processing error
    ↓
ошибка вышла из Feed без изменения
```

---

## 27. Выполненные проверки

### Проверка 1 — unit-тесты Instrument Feed

Команда:

```bash
python -m pytest \
  tests/unit/market_data/acquisition/feeds/test_instrument_feed.py \
  -q
```

Результат:

```text
.......... [100%]
10 passed in 0.01s
```

Статус:

```text
PASSED
```

---

### Проверка 2 — Python compilation

Команда:

```bash
python -m py_compile \
  src/market_data/acquisition/feeds/instrument_feed.py \
  tests/unit/market_data/acquisition/feeds/test_instrument_feed.py
```

Результат:

```text
Команда завершилась без ошибок и без вывода.
```

Статус:

```text
PASSED
```

---

### Проверка 3 — полный набор тестов проекта

Команда:

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

Результат:

```text
.............................................................................................................................................. [100%]
142 passed in 0.08s
```

Статус:

```text
PASSED
```

---

### Проверка 4 — отсутствие преждевременной production-интеграции

Команда:

```bash
grep -RIn \
  --exclude-dir="__pycache__" \
  --exclude="*.pyc" \
  -E "InstrumentFeed|InstrumentFeedProtocol" \
  src tests
```

Полученные production-использования:

```text
src/market_data/acquisition/protocol.py:
    InstrumentFeedProtocol

src/market_data/acquisition/feeds/instrument_feed.py:
    InstrumentFeed
```

Остальные использования находятся исключительно в unit-тестах:

```text
tests/unit/market_data/acquisition/test_protocol.py
tests/unit/market_data/acquisition/feeds/test_instrument_feed.py
```

Не обнаружено подключения к:

```text
ExchangeService
Registry
Acquisition Service
Telegram UI
AutoTrade
Trading runtime
другим production-потребителям
```

Статус:

```text
PASSED
```

---

## 28. Архитектура после Build 010

После завершения Build 010 новая часть подсистемы имеет следующую структуру:

```text
market_data/
└── acquisition/
    ├── exceptions.py
    │   ├── MarketDataAcquisitionError
    │   ├── InstrumentReferenceTransportError
    │   ├── InstrumentReferenceSchemaError
    │   ├── InstrumentReferenceParseError
    │   ├── InstrumentReferenceValueError
    │   └── InstrumentReferenceMappingError
    │
    ├── protocol.py
    │   ├── InstrumentDocumentSource
    │   ├── InstrumentDocumentHandler
    │   └── InstrumentFeedProtocol
    │
    ├── models/
    │   └── instrument.py
    │       └── Instrument
    │
    ├── validation/
    │   ├── schema.py
    │   └── values.py
    │
    ├── handlers/
    │   └── instrument_handler.py
    │       └── DzengiInstrumentDocumentHandler
    │
    ├── feeds/
    │   └── instrument_feed.py
    │       └── InstrumentFeed
    │
    └── adapters/
        └── dzengi/
            ├── models.py
            ├── parser.py
            ├── mapper.py
            └── rest.py
                ├── _PayloadRestClient
                └── DzengiInstrumentDocumentSource
```

На текущем этапе ещё не реализованы:

```text
registry.py
service.py
```

---

## 29. Полная архитектурная цепочка после Build 010

После Build 010 реализована единая техническая цепочка:

```text
Dzengi REST API
    ↓
ExchangeRestClient.get_payload()
    ↓
DzengiInstrumentDocumentSource
    ↓
object
    ↓
InstrumentFeed
    ↓
DzengiInstrumentDocumentHandler
    ↓
validate_exchange_info_schema()
    ↓
ValidatedExchangeInfoDocument
    ↓
parse_exchange_info()
    ↓
DzengiExchangeInfoResponse
    ↓
validate_exchange_info_values()
    ↓
map_dzengi_exchange_info_to_instruments()
    ↓
tuple[Instrument, ...]
```

При этом сам `InstrumentFeed` не знает, что используются:

```text
DzengiInstrumentDocumentSource
DzengiInstrumentDocumentHandler
```

Для него зависимости представлены только общими контрактами:

```text
InstrumentDocumentSource
InstrumentDocumentHandler
```

---

## 30. Что ещё не реализовано

После Build 010 новая acquisition pipeline уже способна технически выполнить:

```text
REST API
    ↓
document
    ↓
validation
    ↓
parsing
    ↓
value validation
    ↓
mapping
    ↓
tuple[Instrument, ...]
```

Однако пока отсутствуют:

```text
Registry;
Acquisition Service;
официальная production composition;
equivalence verification с legacy implementation;
compatibility mapper Instrument → ExchangeSymbol;
переключение get_exchange_symbols();
перевод normalize_symbol()/symbol_candidates();
переключение validate_symbol();
переключение get_symbol_runtime_status();
перенос кэша.
```

Поэтому новая цепочка остаётся изолированной от существующего production runtime.

---

## 31. Влияние на legacy-систему

Build 010 не подключён к существующим компонентам:

```text
ExchangeService
ExchangeSymbol
SymbolValidationResult
Telegram UI
AutoTrade
Market Stream
Market Data Runner
Execution Quality
Trading runtime
```

Не изменены:

```text
ExchangeService.get_exchange_symbols()
ExchangeService.validate_symbol()
ExchangeService.get_symbol_runtime_status()
normalize_symbol()
symbol_candidates()
```

Старый production-путь продолжает работать без изменений.

---

## 32. Обратная совместимость

Подтверждено сохранение:

```text
сигнатур существующих методов;
старых импортов;
существующего формата legacy-ошибок;
Telegram UI;
автоторговли;
runtime-поведения;
legacy ExchangeSymbol;
legacy-кэша.
```

Build 010 имеет полную обратную совместимость.

---

## 33. Классификация изменений

| Изменение | Классификация |
|---|---|
| `InstrumentFeed` | Обязательное архитектурное изменение |
| Соединение Source и Handler | Обязательное архитектурное изменение |
| Dependency injection Source и Handler | Обязательное разделение ответственности |
| Keyword-only зависимости | Улучшение надёжности API |
| Сохранение специализированных ошибок | Улучшение диагностируемости |
| Новый Feed exception | Не создаётся |
| Retry | Не добавляется |
| Кэширование | Не добавляется |
| Фильтрация и сортировка | Не выполняются |
| Изменение legacy-кода | Отсутствует |
| Изменение production-поведения | Отсутствует |

---

## 34. Условие завершения Build 010

Все условия выполнены:

```text
InstrumentFeed
    реализован;

InstrumentFeedProtocol
    соблюдён;

InstrumentDocumentSource
    передаётся как явная зависимость;

InstrumentDocumentHandler
    передаётся как явная зависимость;

Source
    вызывается ровно один раз;

Handler
    вызывается ровно один раз после успешного Source;

документ
    передаётся Handler без изменения;

результат
    возвращается без копирования и преобразования;

порядок инструментов
    сохраняется;

пустой tuple
    поддерживается;

специализированные ошибки
    сохраняются без wrapping;

retry
    отсутствует;

Dzengi-зависимости в Feed
    отсутствуют;

unit-тесты
    проходят;

полный pytest
    проходит;

Feed
    не подключён к production runtime.
```

---

## 35. Итог Build 010

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

Реализовано:

```text
InstrumentFeed

InstrumentDocumentSource
    ↓
fetch_instrument_document()
    ↓
object
    ↓
InstrumentDocumentHandler
    ↓
handle_instrument_document()
    ↓
tuple[Instrument, ...]
```

Подтверждено:

```text
10 Instrument Feed tests passed
142 total project tests passed
Python compilation passed
No premature production integration detected
Legacy bot behavior unchanged
```

Итоговый статус:

```text
BUILD 010 — COMPLETE
```

---

## 36. Следующий этап

Следующий этап утверждённого плана:

```text
Build 011 — Registry
```
Registry хранит доступные Instrument Feed и предоставляет нужный Feed по идентификатору источника.
Хранение актуального справочника относится к будущему переносу кэша:

```text
Build 019 — подготовка переноса кэша
Build 020 — перенос кэша в Storage
```

Предполагаемая архитектурная граница:

```text
tuple[Instrument, ...]
    ↓
Instrument Registry
    ↓
доступ к актуальному справочнику инструментов
```

Build 011 не должен:

```text
создавать Acquisition Service;
изменять ExchangeService;
переключать legacy get_exchange_symbols();
создавать compatibility mapper;
переносить legacy-кэш;
подключаться к Telegram UI;
изменять AutoTrade;
изменять trading runtime.
```