Files
dzentra_bot/docs/migrations/build_006.md

24 KiB
Raw Permalink Blame History

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:

DzengiExchangeInfoSymbol
    ↓
Instrument

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

DzengiExchangeInfoResponse
    ↓
tuple[Instrument, ...]

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

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

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


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

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

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 отвечает только за преобразование:

DzengiExchangeInfoSymbol
    ↓
Instrument

и:

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 изменены:

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

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

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

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

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

В файл:

app/src/market_data/acquisition/exceptions.py

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

class InstrumentReferenceMappingError(MarketDataAcquisitionError):
    pass

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

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

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

Примеры:

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

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

В файле:

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

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

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

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

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

DzengiExchangeInfoSymbol

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

Instrument

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

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

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

DzengiExchangeInfoResponse.payload.symbols

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

tuple[Instrument, ...]

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


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

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

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:

tick_size
tick_value
step_size
min_qty
max_qty
min_notional

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

Decimal(str(value))

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

Примеры:

0.01
    → Decimal("0.01")

"0.001"
    → Decimal("0.001")

0
    → Decimal("0")

0.00000001
    → Decimal("1E-8")

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

None → None

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

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

Value Validation

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

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

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

"not-a-number"

возникает:

InstrumentReferenceMappingError

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

NaN
Infinity
-Infinity

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


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

Из:

DzengiLotSizeFilter

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

min_qty
max_qty
step_size

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

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

Результат:

Instrument.min_qty
    = Decimal("0.001")

Instrument.max_qty
    = Decimal("1000")

Instrument.step_size
    = Decimal("0.001")

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

min_qty   → None
max_qty   → None
step_size → None

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

Из:

DzengiMinNotionalFilter

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

min_notional

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

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

Результат:

Instrument.min_notional
    = Decimal("2")

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

min_notional → None

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

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

DzengiUnknownFilter

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

Например:

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

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

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

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

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

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


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

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

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

LOT_SIZE

возникает:

InstrumentReferenceMappingError

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

MIN_NOTIONAL

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


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

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

asset_type
country
sector
industry
trading_hours

Правила:

None
    → None

""
    → None

"   "
    → None

" DE "
    → "DE"

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

Например:

country=""
sector=""
industry=""

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

country=None
sector=None
industry=None

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

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

symbol
name
status
base_asset
quote_asset
market_type

Причины:

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

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

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

market_modes
order_types

Например:

("REGULAR", "CLOSE_ONLY", "EXTENDED")

остаётся:

("REGULAR", "CLOSE_ONLY", "EXTENDED")

А:

("MARKET", "LIMIT", "STOP")

остаётся:

("MARKET", "LIMIT", "STOP")

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

sorting
deduplication
set conversion

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

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

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

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

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

fees
financing
execution constraints
provider-specific identifiers

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


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

Модель:

Instrument

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

@dataclass(frozen=True, slots=True)

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

FrozenInstanceError

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


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

Создан файл:

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

Команда:

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

Результат:

22 passed in 0.03s

Статус:

PASSED

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

Команда:

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

Результат:

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

Статус:

PASSED

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

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

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

Результат:

Instruments: 51

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

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 -',
)

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

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

Статус:

PASSED

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

Команда:

python -m pytest -q

Результат:

106 passed in 0.06s

Статус:

PASSED

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

Команда:

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

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

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

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

src/market_data/acquisition/exceptions.py

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

src/integrations/exchange/*
src/telegram/*
src/trading/*

Статус:

PASSED

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

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

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()

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

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 не подключён к существующим компонентам:

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

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

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

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

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

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 завершён успешно.

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

DzengiExchangeInfoSymbol
    ↓
Instrument

и:

DzengiExchangeInfoResponse
    ↓
tuple[Instrument, ...]

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

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

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

BUILD 006 — COMPLETE

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

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

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

tuple[Instrument, ...]

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