903 lines
21 KiB
Markdown
903 lines
21 KiB
Markdown
# Build 060.1 — Canonical Trade Model
|
||
|
||
**Engineering Migration Report**
|
||
|
||
---
|
||
|
||
# Контроль документа
|
||
|
||
| Свойство | Значение |
|
||
|---|---|
|
||
| Build | 060.1 |
|
||
| Название | Canonical Trade Model |
|
||
| Статус | Завершён |
|
||
| Проект | Dzentra |
|
||
| Подсистема | Market Data Acquisition |
|
||
| Компонент | Trades Feed / Time & Sales |
|
||
| Версия документа | 1.0 |
|
||
| Дата завершения | 2026-07-18 |
|
||
|
||
---
|
||
|
||
# Цель Build
|
||
|
||
Ввести первый стабильный канонический контракт одной исполненной биржевой сделки:
|
||
|
||
```text
|
||
Trade
|
||
```
|
||
|
||
Новая модель должна стать единым внутренним представлением сделки в подсистеме `Market Data Acquisition` независимо от способа доставки исходных данных.
|
||
|
||
Она предназначена для последующего формирования из двух подтверждённых источников:
|
||
|
||
```text
|
||
REST GET /api/v2/aggTrades
|
||
```
|
||
|
||
и
|
||
|
||
```text
|
||
WebSocket trades.subscribe → internal.trade
|
||
```
|
||
|
||
Build 060.1 ограничен только модельным уровнем.
|
||
|
||
В рамках Build не реализуются:
|
||
|
||
- REST Parser;
|
||
- WebSocket Parser;
|
||
- schema validation;
|
||
- value validation;
|
||
- REST Mapper;
|
||
- WebSocket Mapper;
|
||
- document source;
|
||
- handler;
|
||
- feed;
|
||
- protocol;
|
||
- registry;
|
||
- acquisition service;
|
||
- WebSocket Runtime;
|
||
- production-интеграция.
|
||
|
||
---
|
||
|
||
# Архитектурный контекст
|
||
|
||
До начала Build 060.1 в Dzentra уже существовали канонические модели рыночных данных:
|
||
|
||
```text
|
||
Candle
|
||
Quote
|
||
```
|
||
|
||
Они используют единые conventions:
|
||
|
||
- `@dataclass(frozen=True, slots=True)`;
|
||
- `Decimal` для числовых рыночных значений;
|
||
- `datetime` для времени;
|
||
- source-independent названия полей;
|
||
- отсутствие parsing и validation внутри модели;
|
||
- отсутствие transport-specific полей;
|
||
- отсутствие mutable-состояния.
|
||
|
||
Build 057 предварительно подтвердил, что REST и WebSocket Trade API описывают одни и те же биржевые сделки.
|
||
|
||
Сопоставление transport-полей:
|
||
|
||
| REST | WebSocket | Каноническая семантика |
|
||
|---|---|---|
|
||
| `a` | `id` | `trade_id` |
|
||
| `p` | `price` | `price` |
|
||
| `q` | `size` | `quantity` |
|
||
| `T` | `ts` | `executed_at` |
|
||
| `m` | `buyer` | `aggressor_side` |
|
||
|
||
Для стороны агрессора подтверждено правило:
|
||
|
||
```text
|
||
buyer == not m
|
||
```
|
||
|
||
Следовательно, REST и WebSocket должны преобразовываться в одну модель `Trade`, а не в отдельные типы.
|
||
|
||
---
|
||
|
||
# Исходное состояние
|
||
|
||
До Build 060.1 файл:
|
||
|
||
```text
|
||
app/src/market_data/acquisition/models/trade.py
|
||
```
|
||
|
||
существовал, но не содержал модели `Trade`.
|
||
|
||
Файл:
|
||
|
||
```text
|
||
app/src/market_data/acquisition/models/__init__.py
|
||
```
|
||
|
||
не использовался для экспорта существующих Canonical Models.
|
||
|
||
Отдельный unit-тест для Trade отсутствовал.
|
||
|
||
Таким образом Build мог быть реализован как строго локальное additive change без изменения существующего production-поведения.
|
||
|
||
---
|
||
|
||
# Предварительный архитектурный аудит
|
||
|
||
Перед реализацией были проверены:
|
||
|
||
```text
|
||
app/src/market_data/acquisition/models/candle.py
|
||
app/src/market_data/acquisition/models/quote.py
|
||
app/src/market_data/acquisition/models/trade.py
|
||
app/src/market_data/acquisition/models/__init__.py
|
||
docs/migrations/build_057.md
|
||
app/tests/unit/market_data/acquisition/models/
|
||
```
|
||
|
||
Аудит подтвердил:
|
||
|
||
- `Trade` должен следовать conventions `Candle` и `Quote`;
|
||
- модель должна быть immutable;
|
||
- модель должна использовать `slots=True`;
|
||
- `price` и `quantity` должны иметь тип `Decimal`;
|
||
- время исполнения должно иметь тип `datetime`;
|
||
- модель не должна выполнять validation;
|
||
- модель не должна выполнять normalization;
|
||
- `models/__init__.py` не должен изменяться;
|
||
- REST и WebSocket должны использовать один Canonical Trade;
|
||
- transport boolean не должен попадать во внутренний контракт.
|
||
|
||
---
|
||
|
||
# Рассмотренные архитектурные решения
|
||
|
||
## Название идентификатора
|
||
|
||
Рассматривались:
|
||
|
||
```text
|
||
trade_id
|
||
exchange_trade_id
|
||
```
|
||
|
||
Выбрано:
|
||
|
||
```text
|
||
trade_id
|
||
```
|
||
|
||
Причины:
|
||
|
||
- поле остаётся предметным и кратким;
|
||
- источник уже фиксируется отдельно через `source`;
|
||
- модель не должна быть жёстко привязана к конкретной бирже;
|
||
- имя одинаково применимо к REST и WebSocket.
|
||
|
||
---
|
||
|
||
## Название количества
|
||
|
||
Рассматривались:
|
||
|
||
```text
|
||
quantity
|
||
size
|
||
```
|
||
|
||
Выбрано:
|
||
|
||
```text
|
||
quantity
|
||
```
|
||
|
||
Причины:
|
||
|
||
- `size` является именем WebSocket transport-поля;
|
||
- REST использует `q`;
|
||
- `quantity` является source-independent предметным названием;
|
||
- имя согласуется с общей терминологией торговых систем.
|
||
|
||
---
|
||
|
||
## Представление стороны сделки
|
||
|
||
Рассматривались:
|
||
|
||
```text
|
||
bool
|
||
str
|
||
Enum
|
||
```
|
||
|
||
Выбран отдельный Enum:
|
||
|
||
```python
|
||
class TradeAggressorSide(Enum):
|
||
BUY = "buy"
|
||
SELL = "sell"
|
||
```
|
||
|
||
Причины:
|
||
|
||
- boolean неоднозначен без знания transport-семантики;
|
||
- REST `m` и WebSocket `buyer` имеют противоположную логику;
|
||
- произвольная строка допускает неконтролируемые значения;
|
||
- Enum явно фиксирует допустимый предметный контракт.
|
||
|
||
---
|
||
|
||
## Название поля стороны
|
||
|
||
Рассматривались:
|
||
|
||
```text
|
||
side
|
||
buyer
|
||
aggressor
|
||
aggressor_side
|
||
```
|
||
|
||
Выбрано:
|
||
|
||
```text
|
||
aggressor_side
|
||
```
|
||
|
||
Причины:
|
||
|
||
- `buyer` является transport-полем WebSocket;
|
||
- `side` может ошибочно трактоваться как сторона ордера или позиции;
|
||
- `aggressor_side` точно отражает участника, инициировавшего исполнение.
|
||
|
||
---
|
||
|
||
## Название времени
|
||
|
||
Рассматривались:
|
||
|
||
```text
|
||
timestamp
|
||
trade_time
|
||
executed_at
|
||
```
|
||
|
||
Выбрано:
|
||
|
||
```text
|
||
executed_at
|
||
```
|
||
|
||
Причины:
|
||
|
||
- поле описывает предметное время исполнения;
|
||
- имя не связано с transport-форматом;
|
||
- тип `datetime` уже делает слово `timestamp` избыточным;
|
||
- название согласуется с событийной семантикой сделки.
|
||
|
||
---
|
||
|
||
# Окончательно утверждённый контракт
|
||
|
||
Добавлен предметный Enum:
|
||
|
||
```python
|
||
class TradeAggressorSide(Enum):
|
||
BUY = "buy"
|
||
SELL = "sell"
|
||
```
|
||
|
||
Добавлена каноническая immutable-модель:
|
||
|
||
```python
|
||
@dataclass(frozen=True, slots=True)
|
||
class Trade:
|
||
symbol: str
|
||
|
||
trade_id: int
|
||
|
||
price: Decimal
|
||
quantity: Decimal
|
||
|
||
executed_at: datetime
|
||
|
||
aggressor_side: TradeAggressorSide
|
||
|
||
source: str
|
||
```
|
||
|
||
---
|
||
|
||
# Семантика Canonical Trade
|
||
|
||
## `symbol`
|
||
|
||
```python
|
||
symbol: str
|
||
```
|
||
|
||
Канонический идентификатор торгового инструмента.
|
||
|
||
Модель не выполняет нормализацию символа и не проверяет его существование.
|
||
|
||
---
|
||
|
||
## `trade_id`
|
||
|
||
```python
|
||
trade_id: int
|
||
```
|
||
|
||
Идентификатор исполненной сделки, предоставленный источником рыночных данных.
|
||
|
||
В исследованных REST и WebSocket контрактах значение совпадает.
|
||
|
||
---
|
||
|
||
## `price`
|
||
|
||
```python
|
||
price: Decimal
|
||
```
|
||
|
||
Цена фактического исполнения сделки.
|
||
|
||
Используется `Decimal`, чтобы исключить потерю точности, характерную для `float`.
|
||
|
||
---
|
||
|
||
## `quantity`
|
||
|
||
```python
|
||
quantity: Decimal
|
||
```
|
||
|
||
Исполненное количество инструмента.
|
||
|
||
Каноническое имя не зависит от REST-поля `q` или WebSocket-поля `size`.
|
||
|
||
---
|
||
|
||
## `executed_at`
|
||
|
||
```python
|
||
executed_at: datetime
|
||
```
|
||
|
||
Время фактического исполнения сделки.
|
||
|
||
Преобразование миллисекундного transport timestamp в timezone-aware `datetime` должно выполняться будущим mapper-слоем, а не моделью.
|
||
|
||
---
|
||
|
||
## `aggressor_side`
|
||
|
||
```python
|
||
aggressor_side: TradeAggressorSide
|
||
```
|
||
|
||
Сторона участника, инициировавшего исполнение:
|
||
|
||
```text
|
||
TradeAggressorSide.BUY
|
||
TradeAggressorSide.SELL
|
||
```
|
||
|
||
Transport-поля `m` и `buyer` не сохраняются внутри Canonical Trade.
|
||
|
||
---
|
||
|
||
## `source`
|
||
|
||
```python
|
||
source: str
|
||
```
|
||
|
||
Логический источник рыночных данных.
|
||
|
||
Поле не означает способ доставки и не должно содержать transport-различие вида:
|
||
|
||
```text
|
||
rest
|
||
websocket
|
||
```
|
||
|
||
если оба пути относятся к одному и тому же рыночному источнику.
|
||
|
||
---
|
||
|
||
# Поля, намеренно не включённые в модель
|
||
|
||
## `received_at`
|
||
|
||
Поле не добавлено.
|
||
|
||
Причины:
|
||
|
||
- оно описывает acquisition/runtime, а не саму сделку;
|
||
- для REST history и WebSocket realtime оно имеет различную практическую семантику;
|
||
- его наличие сделало бы модель зависимой от способа получения данных.
|
||
|
||
---
|
||
|
||
## `order_id`
|
||
|
||
Поле не добавлено.
|
||
|
||
Причины:
|
||
|
||
- присутствует в исследованном WebSocket событии;
|
||
- отсутствует в REST `aggTrades`;
|
||
- не имеет общего контракта для обоих transport-путей;
|
||
- является биржевым implementation detail.
|
||
|
||
---
|
||
|
||
## `buyer`
|
||
|
||
Поле не добавлено.
|
||
|
||
Причины:
|
||
|
||
- является WebSocket transport boolean;
|
||
- требует знания внешней семантики;
|
||
- заменено предметным `TradeAggressorSide`.
|
||
|
||
---
|
||
|
||
## `m`
|
||
|
||
Поле не добавлено.
|
||
|
||
Причины:
|
||
|
||
- является REST transport-полем;
|
||
- означает `isBuyerMaker`;
|
||
- имеет обратную семантику относительно WebSocket `buyer`;
|
||
- должно устраняться mapper-слоем.
|
||
|
||
---
|
||
|
||
## `size`
|
||
|
||
Поле не добавлено.
|
||
|
||
Причина:
|
||
|
||
- это имя WebSocket transport-поля;
|
||
- в Canonical Trade используется source-independent `quantity`.
|
||
|
||
---
|
||
|
||
## Исходные timestamp-поля
|
||
|
||
Не добавлены:
|
||
|
||
```text
|
||
T
|
||
ts
|
||
timestamp_ms
|
||
```
|
||
|
||
Transport timestamp должен быть преобразован в:
|
||
|
||
```text
|
||
executed_at: datetime
|
||
```
|
||
|
||
до создания Canonical Trade.
|
||
|
||
---
|
||
|
||
# Изменённые файлы
|
||
|
||
В рамках Build изменены или созданы только:
|
||
|
||
```text
|
||
app/src/market_data/acquisition/models/trade.py
|
||
app/tests/unit/market_data/acquisition/models/test_trade.py
|
||
docs/migrations/build_060_1.md
|
||
```
|
||
|
||
---
|
||
|
||
# Изменения в `trade.py`
|
||
|
||
Файл:
|
||
|
||
```text
|
||
app/src/market_data/acquisition/models/trade.py
|
||
```
|
||
|
||
получил:
|
||
|
||
- imports `dataclass`, `datetime`, `Decimal`, `Enum`;
|
||
- предметный Enum `TradeAggressorSide`;
|
||
- immutable-модель `Trade`;
|
||
- комментарии на русском языке;
|
||
- группировку полей в стиле существующих Canonical Models.
|
||
|
||
Модель не содержит:
|
||
|
||
- методов;
|
||
- `__post_init__`;
|
||
- validation;
|
||
- parsing;
|
||
- normalization;
|
||
- transport mapping;
|
||
- runtime metadata.
|
||
|
||
---
|
||
|
||
# Изменения в unit-тестах
|
||
|
||
Создан файл:
|
||
|
||
```text
|
||
app/tests/unit/market_data/acquisition/models/test_trade.py
|
||
```
|
||
|
||
Тесты проверяют:
|
||
|
||
- сохранение `symbol`;
|
||
- сохранение `trade_id`;
|
||
- сохранение `price`;
|
||
- сохранение `quantity`;
|
||
- сохранение `executed_at`;
|
||
- сохранение `aggressor_side`;
|
||
- сохранение `source`;
|
||
- поддержку `TradeAggressorSide.BUY`;
|
||
- поддержку `TradeAggressorSide.SELL`;
|
||
- стабильность значений Enum;
|
||
- immutable-поведение;
|
||
- наличие `slots=True`;
|
||
- отсутствие скрытого преобразования значений моделью.
|
||
|
||
---
|
||
|
||
# Что намеренно не изменялось
|
||
|
||
Build 060.1 намеренно не изменяет:
|
||
|
||
```text
|
||
app/src/market_data/acquisition/models/__init__.py
|
||
app/src/market_data/acquisition/protocol.py
|
||
app/src/market_data/acquisition/registry.py
|
||
app/src/market_data/acquisition/service.py
|
||
app/src/market_data/acquisition/feeds/trades_feed.py
|
||
app/src/market_data/acquisition/handlers/trades_handler.py
|
||
app/src/market_data/acquisition/runtime/
|
||
app/src/market_data/acquisition/adapters/
|
||
app/src/market_data/acquisition/validation/
|
||
```
|
||
|
||
Также не выполнялись:
|
||
|
||
- REST-интеграция;
|
||
- WebSocket-интеграция;
|
||
- создание transport-моделей;
|
||
- создание parser;
|
||
- создание mapper;
|
||
- создание handler;
|
||
- создание feed;
|
||
- регистрация нового feed;
|
||
- изменение публичных protocol;
|
||
- подключение к runtime;
|
||
- изменение Candle Feed;
|
||
- изменение Quote;
|
||
- реорганизация каталогов;
|
||
- cleanup unrelated-кода.
|
||
|
||
---
|
||
|
||
# Проверка компиляции
|
||
|
||
Команды выполнялись из каталога:
|
||
|
||
```text
|
||
~/vsprojects/dzentra_bot/app
|
||
```
|
||
|
||
Активировано окружение:
|
||
|
||
```bash
|
||
source .venv/bin/activate
|
||
```
|
||
|
||
Выполнена команда:
|
||
|
||
```bash
|
||
python -m compileall src
|
||
```
|
||
|
||
Результат:
|
||
|
||
```text
|
||
успешно
|
||
```
|
||
|
||
Все каталоги `src` были обработаны без ошибок компиляции.
|
||
|
||
---
|
||
|
||
# Полный regression suite
|
||
|
||
Выполнена команда:
|
||
|
||
```bash
|
||
python -m pytest -q
|
||
```
|
||
|
||
Результат:
|
||
|
||
```text
|
||
990 passed in 4.74s
|
||
```
|
||
|
||
Регрессий не обнаружено.
|
||
|
||
---
|
||
|
||
# Проверка форматирования
|
||
|
||
Выполнена команда:
|
||
|
||
```bash
|
||
git diff --check
|
||
```
|
||
|
||
Вывод отсутствует.
|
||
|
||
Это подтверждает отсутствие:
|
||
|
||
- trailing whitespace;
|
||
- whitespace errors;
|
||
- некорректных пустых строк в diff.
|
||
|
||
---
|
||
|
||
# Контроль размещения новой модели
|
||
|
||
Выполнена команда:
|
||
|
||
```bash
|
||
grep -RIn \
|
||
--exclude-dir="__pycache__" \
|
||
--exclude="*.pyc" \
|
||
"TradeAggressorSide\|class Trade" \
|
||
src tests
|
||
```
|
||
|
||
Результат подтвердил наличие нового контракта только в утверждённых местах:
|
||
|
||
```text
|
||
src/market_data/acquisition/models/trade.py
|
||
tests/unit/market_data/acquisition/models/test_trade.py
|
||
```
|
||
|
||
Обнаружены:
|
||
|
||
```text
|
||
class TradeAggressorSide(Enum)
|
||
class Trade
|
||
```
|
||
|
||
и их использования в целевом unit-тесте.
|
||
|
||
Production-потребители `Trade` в рамках Build 060.1 не добавлялись.
|
||
|
||
---
|
||
|
||
# Состояние Git
|
||
|
||
Выполнена команда:
|
||
|
||
```bash
|
||
git status
|
||
```
|
||
|
||
Для Build 060.1 зафиксированы:
|
||
|
||
```text
|
||
modified:
|
||
app/src/market_data/acquisition/models/trade.py
|
||
|
||
untracked:
|
||
app/tests/unit/market_data/acquisition/models/test_trade.py
|
||
docs/migrations/build_060_1.md
|
||
```
|
||
|
||
Дополнительно в рабочем дереве присутствует архитектурная контрольная точка:
|
||
|
||
```text
|
||
docs/migrations/build_060_transition_&_architecture_checkpoint.md
|
||
```
|
||
|
||
Она не является реализационным файлом Build 060.1 и должна учитываться отдельно при формировании commit согласно принятой стратегии репозитория.
|
||
|
||
Ветка:
|
||
|
||
```text
|
||
main
|
||
```
|
||
|
||
на момент проверки опережала `origin/main` на 27 локальных commits.
|
||
|
||
Это состояние не связано с реализацией Canonical Trade и не изменялось в рамках Build.
|
||
|
||
---
|
||
|
||
# Фактический diff модели
|
||
|
||
В `trade.py` добавлено 31 строка.
|
||
|
||
Основной diff:
|
||
|
||
```python
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from enum import Enum
|
||
|
||
|
||
class TradeAggressorSide(Enum):
|
||
BUY = "buy"
|
||
SELL = "sell"
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class Trade:
|
||
symbol: str
|
||
|
||
trade_id: int
|
||
|
||
price: Decimal
|
||
quantity: Decimal
|
||
|
||
executed_at: datetime
|
||
|
||
aggressor_side: TradeAggressorSide
|
||
|
||
source: str
|
||
```
|
||
|
||
Рабочий код других компонентов не изменялся.
|
||
|
||
---
|
||
|
||
# Архитектурный результат
|
||
|
||
После Build 060.1 в Dzentra существует третий базовый канонический тип рыночных данных:
|
||
|
||
```text
|
||
Candle
|
||
Quote
|
||
Trade
|
||
```
|
||
|
||
Новый контракт:
|
||
|
||
- не зависит от Dzengi;
|
||
- не зависит от REST;
|
||
- не зависит от WebSocket;
|
||
- не содержит transport-семантику;
|
||
- immutable;
|
||
- memory-efficient за счёт `slots=True`;
|
||
- использует точные числовые типы;
|
||
- выражает сторону агрессора отдельным предметным Enum;
|
||
- готов для последующего использования обоими transport-путями.
|
||
|
||
Целевая будущая схема:
|
||
|
||
```text
|
||
Dzengi REST aggTrades
|
||
↓
|
||
REST document source
|
||
↓
|
||
REST schema validation
|
||
↓
|
||
REST parser
|
||
↓
|
||
value validation
|
||
↓
|
||
REST mapper
|
||
↓
|
||
Trade
|
||
```
|
||
|
||
и:
|
||
|
||
```text
|
||
Dzengi WebSocket internal.trade
|
||
↓
|
||
WebSocket parser
|
||
↓
|
||
WebSocket schema validation
|
||
↓
|
||
value validation
|
||
↓
|
||
WebSocket mapper
|
||
↓
|
||
Trade
|
||
```
|
||
|
||
Оба пути должны завершаться одной и той же канонической моделью.
|
||
|
||
---
|
||
|
||
# Критерии завершения
|
||
|
||
Build 060.1 считается завершённым, поскольку:
|
||
|
||
- проведён архитектурный аудит;
|
||
- утверждён минимальный Canonical Trade contract;
|
||
- добавлен `TradeAggressorSide`;
|
||
- добавлен immutable `Trade`;
|
||
- модель использует `Decimal`;
|
||
- модель использует `datetime`;
|
||
- transport-поля исключены;
|
||
- `received_at` не добавлен;
|
||
- `order_id` не добавлен;
|
||
- `models/__init__.py` не изменён;
|
||
- добавлены целевые unit-тесты;
|
||
- `python -m compileall src` проходит;
|
||
- полный test suite проходит;
|
||
- результат полного suite: `990 passed in 4.74s`;
|
||
- `git diff --check` чистый;
|
||
- grep подтверждает локальность новой модели;
|
||
- scope Build не расширен.
|
||
|
||
---
|
||
|
||
# Итог
|
||
|
||
**Build 060.1 завершён успешно.**
|
||
|
||
Текущее состояние:
|
||
|
||
```text
|
||
TradeAggressorSide — реализован
|
||
Trade — реализован
|
||
Immutable contract — подтверждён
|
||
slots=True — подтверждён
|
||
Decimal fields — подтверждены
|
||
executed_at datetime — подтверждён
|
||
Transport independence — сохранена
|
||
Compile check — успешно
|
||
Full regression suite — 990 passed
|
||
Whitespace check — чисто
|
||
Production integration — намеренно не выполнялась
|
||
```
|
||
|
||
Build создал стабильную модельную основу для дальнейшей реализации Trades Feed без изменения существующего runtime и production-поведения.
|
||
|
||
---
|
||
|
||
# Следующий этап
|
||
|
||
Следующий Build должен быть отдельным, минимальным и additive.
|
||
|
||
Его точный scope должен быть утверждён до написания кода.
|
||
|
||
Наиболее логичное продолжение ветки Trades Feed:
|
||
|
||
```text
|
||
Build 060.2 — REST Trade Transport Foundation
|
||
```
|
||
|
||
Предполагаемые направления будущего этапа:
|
||
|
||
- REST Trade document contract;
|
||
- REST schema validation;
|
||
- REST parser;
|
||
- REST value validation;
|
||
- REST mapper в Canonical Trade;
|
||
- целевые unit-тесты.
|
||
|
||
WebSocket Trade integration, feed, handler, protocol, registry, service и runtime должны оставаться отдельными последующими Build.
|