# Build 006 — Dzengi → Instrument Mapper

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

---

## 1. Цель Build 006

Цель Build 006 — реализовать преобразование source-specific raw-моделей Dzengi во внутреннюю source-independent модель Dzentra:

```text
DzengiExchangeInfoSymbol
    ↓
Instrument
```

Также реализовано преобразование полного ответа `exchangeInfo`:

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

После завершения Build 006 pipeline Instrument Reference Data выглядит следующим образом:

```text
Dzengi raw JSON
    ↓
Schema Validation
    ↓
Parser
    ↓
Dzengi Raw Models
    ↓
Value Validation
    ↓
Mapper
    ↓
Instrument
```

Build 006 не подключает новый mapper к существующему `ExchangeService` и не меняет поведение работающего бота.

---

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

До начала Build 006 были завершены необходимые предыдущие этапы:

```text
Build 001 — внутренняя модель Instrument
Build 002 — транспортные модели Dzengi
Build 003 — структурная валидация exchangeInfo
Build 004 — parser exchangeInfo
Build 005 — value validation
```

К началу Build 006 входные данные mapper уже:

- структурно проверены;
- преобразованы в типизированные raw-модели;
- проверены на допустимость значений;
- изолированы от legacy-кода.

Поэтому mapper может отвечать только за преобразование модели источника во внутреннюю модель Dzentra.

---

## 3. Архитектурная граница Build 006

Build 006 отвечает только за преобразование:

```text
DzengiExchangeInfoSymbol
    ↓
Instrument
```

и:

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

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

- REST-запросы;
- schema validation;
- parsing JSON;
- полную value validation;
- нормализацию пользовательского ввода символа;
- определение runtime-статуса рынка;
- управление кэшем;
- создание legacy-модели `ExchangeSymbol`;
- интеграцию с `ExchangeService`;
- интеграцию с Telegram UI;
- интеграцию с автоторговлей.

Таким образом, mapper остаётся изолированным слоем между source-specific моделями адаптера Dzengi и source-independent моделью Dzentra.

---

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

В рамках Build 006 изменены:

```text
app/src/market_data/acquisition/exceptions.py
app/src/market_data/acquisition/adapters/dzengi/mapper.py
```

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

```text
app/tests/unit/market_data/acquisition/adapters/dzengi/test_mapper.py
```

Не изменялись:

```text
app/src/market_data/acquisition/models/instrument.py
app/src/market_data/acquisition/adapters/dzengi/models.py
app/src/market_data/acquisition/adapters/dzengi/parser.py
app/src/market_data/acquisition/validation/schema.py
app/src/market_data/acquisition/validation/values.py
app/src/integrations/exchange/*
app/src/telegram/*
app/src/trading/*
```

Работающий legacy-код бота не изменён.

---

## 5. Новая ошибка mapper

В файл:

```text
app/src/market_data/acquisition/exceptions.py
```

добавлена ошибка:

```python
class InstrumentReferenceMappingError(MarketDataAcquisitionError):
    pass
```

Итоговая иерархия ошибок Instrument Reference Data:

```text
MarketDataAcquisitionError
├── InstrumentReferenceSchemaError
├── InstrumentReferenceParseError
├── InstrumentReferenceValueError
└── InstrumentReferenceMappingError
```

`InstrumentReferenceMappingError` обозначает ошибки, возникшие непосредственно при преобразовании source-specific raw-модели во внутреннюю модель `Instrument`.

Примеры:

```text
несколько LOT_SIZE filters
несколько MIN_NOTIONAL filters
невозможность преобразования значения в Decimal
неконечное числовое значение
```

---

## 6. Публичный контракт mapper

В файле:

```text
app/src/market_data/acquisition/adapters/dzengi/mapper.py
```

реализованы две публичные функции.

### 6.1. Преобразование одного инструмента

```python
def map_dzengi_symbol_to_instrument(
    symbol: DzengiExchangeInfoSymbol,
) -> Instrument:
    ...
```

Функция преобразует один объект:

```text
DzengiExchangeInfoSymbol
```

в один объект:

```text
Instrument
```

### 6.2. Преобразование полного ответа exchangeInfo

```python
def map_dzengi_exchange_info_to_instruments(
    response: DzengiExchangeInfoResponse,
) -> tuple[Instrument, ...]:
    ...
```

Функция преобразует все элементы:

```text
DzengiExchangeInfoResponse.payload.symbols
```

в immutable-последовательность:

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

Порядок инструментов сохраняется.

---

## 7. Правила преобразования полей

Реализовано следующее соответствие:

```text
DzengiExchangeInfoSymbol.symbol
    → Instrument.symbol

name
    → name

status
    → status

base_asset
    → base_asset

quote_asset
    → quote_asset

asset_type
    → asset_type

market_type
    → market_type

market_modes
    → market_modes

order_types
    → order_types

base_asset_precision
    → base_asset_precision

quote_precision
    → quote_asset_precision

tick_size
    → tick_size

tick_value
    → tick_value

country
    → country

sector
    → sector

industry
    → industry

trading_hours
    → trading_hours
```

---

## 8. Преобразование числовых значений в Decimal

Следующие значения преобразуются во внутренний тип `Decimal`:

```text
tick_size
tick_value
step_size
min_qty
max_qty
min_notional
```

Используется преобразование:

```python
Decimal(str(value))
```

Это позволяет избежать дополнительной двоичной погрешности при непосредственном преобразовании `float` в `Decimal`.

Примеры:

```text
0.01
    → Decimal("0.01")

"0.001"
    → Decimal("0.001")

0
    → Decimal("0")

0.00000001
    → Decimal("1E-8")
```

Если исходное значение отсутствует:

```text
None → None
```

---

## 9. Защита Decimal-преобразования

Mapper предполагает, что перед его вызовом уже выполнена:

```text
Value Validation
```

Однако публичная функция mapper может быть вызвана напрямую.

Поэтому непосредственно в mapper оставлена локальная защита преобразования в `Decimal`.

Если значение невозможно преобразовать:

```text
"not-a-number"
```

возникает:

```text
InstrumentReferenceMappingError
```

Также отклоняются неконечные значения:

```text
NaN
Infinity
-Infinity
```

Это не повторение полной Value Validation. Mapper защищает только собственную непосредственную обязанность — корректное создание `Decimal`.

---

## 10. Извлечение LOT_SIZE

Из:

```text
DzengiLotSizeFilter
```

извлекаются:

```text
min_qty
max_qty
step_size
```

Пример исходного фильтра:

```python
DzengiLotSizeFilter(
    filter_type="LOT_SIZE",
    min_qty="0.001",
    max_qty="1000",
    step_size="0.001",
)
```

Результат:

```text
Instrument.min_qty
    = Decimal("0.001")

Instrument.max_qty
    = Decimal("1000")

Instrument.step_size
    = Decimal("0.001")
```

Если `LOT_SIZE` отсутствует:

```text
min_qty   → None
max_qty   → None
step_size → None
```

---

## 11. Извлечение MIN_NOTIONAL

Из:

```text
DzengiMinNotionalFilter
```

извлекается:

```text
min_notional
```

Пример исходного фильтра:

```python
DzengiMinNotionalFilter(
    filter_type="MIN_NOTIONAL",
    min_notional="2",
)
```

Результат:

```text
Instrument.min_notional
    = Decimal("2")
```

Если `MIN_NOTIONAL` отсутствует:

```text
min_notional → None
```

---

## 12. Неизвестные filters

Неизвестные фильтры представлены raw-моделью:

```text
DzengiUnknownFilter
```

Mapper не переносит их во внутреннюю модель `Instrument` и не выбрасывает из-за них ошибку.

Например:

```python
DzengiUnknownFilter(
    filter_type="FUTURE_FILTER",
    fields=(
        ("enabled", True),
        ("limit", 10),
    ),
)
```

не препятствует созданию `Instrument`.

Это сознательное архитектурное решение:

```text
Raw Models
    сохраняют неизвестные source-specific данные

Instrument
    содержит только известные канонические поля Dzentra
```

Неизвестные данные не теряются на транспортном уровне, но не загрязняют source-independent модель.

---

## 13. Защита от дублирующихся известных filters

Mapper не допускает неоднозначного выбора значения.

Если один инструмент содержит несколько фильтров:

```text
LOT_SIZE
```

возникает:

```text
InstrumentReferenceMappingError
```

Аналогичное правило действует для нескольких фильтров:

```text
MIN_NOTIONAL
```

Mapper не выбирает молча первый или последний фильтр, поскольку это могло бы привести к использованию неверных торговых ограничений.

---

## 14. Нормализация необязательных текстовых полей

Для следующих optional-полей применяется предметная нормализация:

```text
asset_type
country
sector
industry
trading_hours
```

Правила:

```text
None
    → None

""
    → None

"   "
    → None

" DE "
    → "DE"
```

Это соответствует фактическому поведению Dzengi API, где некоторые справочные поля могут присутствовать как пустые строки.

Например:

```text
country=""
sector=""
industry=""
```

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

```text
country=None
sector=None
industry=None
```

---

## 15. Обязательные строки не нормализуются mapper

Следующие поля переносятся без изменения:

```text
symbol
name
status
base_asset
quote_asset
market_type
```

Причины:

- parser сохраняет транспортное значение;
- Value Validation уже проверяет обязательность и непустоту;
- mapper не должен незаметно менять идентификаторы или статусы источника.

---

## 16. Сохранение порядка последовательностей

Mapper сохраняет исходный порядок:

```text
market_modes
order_types
```

Например:

```text
("REGULAR", "CLOSE_ONLY", "EXTENDED")
```

остаётся:

```text
("REGULAR", "CLOSE_ONLY", "EXTENDED")
```

А:

```text
("MARKET", "LIMIT", "STOP")
```

остаётся:

```text
("MARKET", "LIMIT", "STOP")
```

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

```text
sorting
deduplication
set conversion
```

---

## 17. Поля Dzengi, которые сознательно не входят в Instrument

Следующие source-specific данные сохранены в `DzengiExchangeInfoSymbol`, но не переносятся в текущую модель `Instrument`:

```text
quote_asset_id

trading_fee
exchange_fee

long_rate
short_rate
swap_charge_interval

min_sl_gap
max_sl_gap
min_tp_gap
max_tp_gap
```

Они не потеряны на транспортном уровне.

В дальнейшем эти данные могут использоваться отдельными предметными контрактами:

```text
fees
financing
execution constraints
provider-specific identifiers
```

Build 006 не смешивает эти области с базовой моделью `Instrument`.

---

## 18. Immutable-результат

Модель:

```text
Instrument
```

объявлена как:

```python
@dataclass(frozen=True, slots=True)
```

Тестами подтверждено, что попытка изменения уже созданного объекта приводит к:

```text
FrozenInstanceError
```

Это обеспечивает стабильность справочного value object после mapping.

---

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

Создан файл:

```text
app/tests/unit/market_data/acquisition/adapters/dzengi/test_mapper.py
```

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

Проверены:

1. полное преобразование `DzengiExchangeInfoSymbol → Instrument`;
2. преобразование полного `DzengiExchangeInfoResponse`;
3. возврат `tuple[Instrument, ...]`;
4. сохранение порядка инструментов;
5. точное преобразование чисел в `Decimal`;
6. извлечение `LOT_SIZE`;
7. извлечение `MIN_NOTIONAL`;
8. работа при отсутствии filters;
9. работа при отсутствии optional numeric values;
10. игнорирование неизвестных filters;
11. преобразование пустого `asset_type` в `None`;
12. преобразование пустого `country` в `None`;
13. преобразование пустого `sector` в `None`;
14. преобразование пустого `industry` в `None`;
15. преобразование пустого `trading_hours` в `None`;
16. очистка внешних пробелов optional text;
17. сохранение порядка `market_modes`;
18. сохранение порядка `order_types`;
19. отклонение нескольких `LOT_SIZE`;
20. отклонение нескольких `MIN_NOTIONAL`;
21. отклонение неконечных числовых значений;
22. immutable-поведение итогового `Instrument`.

---

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

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

Команда:

```bash
python -m pytest \
  tests/unit/market_data/acquisition/adapters/dzengi/test_mapper.py \
  -q
```

Результат:

```text
22 passed in 0.03s
```

Статус:

```text
PASSED
```

---

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

Команда:

```bash
python -m py_compile \
  src/market_data/acquisition/exceptions.py \
  src/market_data/acquisition/adapters/dzengi/mapper.py \
  tests/unit/market_data/acquisition/adapters/dzengi/test_mapper.py
```

Результат:

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

Статус:

```text
PASSED
```

---

### Проверка 3 — полный pipeline на реальном Dzengi sample

Проверена цепочка:

```text
JSON
    ↓
Schema Validation
    ↓
Parser
    ↓
Value Validation
    ↓
Mapper
    ↓
Instrument
```

Результат:

```text
Instruments: 51
```

Первый инструмент:

```text
Instrument(
    symbol='ETH/EUR_LEVERAGE',
    name='ETH/EUR',
    status='TRADING',
    base_asset='ETH',
    quote_asset='EUR',
    asset_type='CRYPTOCURRENCY',
    market_type='LEVERAGE',
    market_modes=('REGULAR',),
    order_types=('LIMIT', 'MARKET', 'STOP'),
    base_asset_precision=3,
    quote_asset_precision=3,
    tick_size=Decimal('0.01'),
    tick_value=Decimal('18.3415'),
    step_size=Decimal('0.001'),
    min_qty=Decimal('0.001'),
    max_qty=Decimal('1000'),
    min_notional=Decimal('2'),
    country=None,
    sector=None,
    industry=None,
    trading_hours='UTC; Mon - 21:00, 21:05 -; Tue - 21:00, 21:05 -; Wed - 21:00, 21:05 -; Thu - 21:00, 21:05 -; Fri - 21:00, 22:01 -;Sat - 05:00, 07:00 - 21:00, 21:05 -; Sun - 21:00, 21:05 -',
)
```

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

```text
51 инструмент успешно прошёл полный pipeline.
```

Статус:

```text
PASSED
```

---

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

Команда:

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

Результат:

```text
106 passed in 0.06s
```

Статус:

```text
PASSED
```

---

### Проверка 5 — отсутствие production-интеграции

Команда:

```bash
grep -RIn \
  --exclude-dir="__pycache__" \
  --exclude="*.pyc" \
  -E "map_dzengi_symbol_to_instrument|map_dzengi_exchange_info_to_instruments|InstrumentReferenceMappingError" \
  src tests
```

Подтверждено, что mapper используется только в:

```text
src/market_data/acquisition/adapters/dzengi/mapper.py
tests/unit/market_data/acquisition/adapters/dzengi/test_mapper.py
```

Новая ошибка объявлена в:

```text
src/market_data/acquisition/exceptions.py
```

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

```text
src/integrations/exchange/*
src/telegram/*
src/trading/*
```

Статус:

```text
PASSED
```

---

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

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

```text
market_data/
└── acquisition/
    ├── exceptions.py
    │
    ├── models/
    │   └── instrument.py
    │       └── Instrument
    │
    ├── validation/
    │   ├── schema.py
    │   │   └── validate_exchange_info_schema()
    │   │
    │   └── values.py
    │       └── validate_exchange_info_values()
    │
    └── adapters/
        └── dzengi/
            ├── models.py
            │   ├── DzengiExchangeInfoResponse
            │   ├── DzengiExchangeInfoPayload
            │   ├── DzengiExchangeInfoSymbol
            │   ├── DzengiLotSizeFilter
            │   ├── DzengiMinNotionalFilter
            │   └── DzengiUnknownFilter
            │
            ├── parser.py
            │   └── parse_exchange_info()
            │
            └── mapper.py
                ├── map_dzengi_symbol_to_instrument()
                └── map_dzengi_exchange_info_to_instruments()
```

Рабочая цепочка:

```text
Raw JSON document
    ↓
validate_exchange_info_schema()
    ↓
ValidatedExchangeInfoDocument
    ↓
parse_exchange_info()
    ↓
DzengiExchangeInfoResponse
    ↓
validate_exchange_info_values()
    ↓
map_dzengi_exchange_info_to_instruments()
    ↓
tuple[Instrument, ...]
```

---

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

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

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

Поэтому старый бот продолжает работать по прежнему пути.

Новая подсистема строится параллельно и пока не заменяет legacy-реализацию.

Это соответствует стратегии миграции:

```text
Сначала построить и протестировать новый путь.
Затем подключать его постепенно.
Старый рабочий путь не удалять до подтверждения новой реализации.
```

---

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

| Изменение | Классификация |
|---|---|
| Создание Dzengi mapper | Обязательное архитектурное изменение |
| Преобразование raw-модели в `Instrument` | Обязательное архитектурное изменение |
| Преобразование чисел в `Decimal` | Обязательное архитектурное изменение |
| Извлечение `LOT_SIZE` | Обязательное архитектурное изменение |
| Извлечение `MIN_NOTIONAL` | Обязательное архитектурное изменение |
| Защита от дублирующихся известных filters | Улучшение надёжности |
| Пустые optional text → `None` | Предметная нормализация |
| Игнорирование unknown filters в `Instrument` | Разграничение source/domain layers |
| Новая `InstrumentReferenceMappingError` | Улучшение диагностируемости |
| Изменение production-поведения | Отсутствует |

---

## 24. Итог Build 006

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

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

```text
DzengiExchangeInfoSymbol
    ↓
Instrument
```

и:

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

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

```text
22 mapper tests passed
106 total project tests passed
51 real Dzengi instruments successfully mapped
Python compilation passed
No production integration detected
Legacy bot behavior unchanged
```

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

```text
BUILD 006 — COMPLETE
```

---

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

Следующий этап необходимо определить по утверждённому плану миграции.

Новый pipeline уже умеет преобразовывать сохранённый JSON-документ в:

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

Следующий Build должен добавить следующий минимальный слой, не подключая сразу новый путь ко всем legacy-потребителям и не изменяя работающий `ExchangeService` без совместимого переходного контракта.