Build 060.1: add canonical Trade model

This commit is contained in:
2026-07-18 10:02:25 +03:00
parent f2bacb80c9
commit c52c099d54
3 changed files with 995 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
# app/src/market_data/acquisition/models/trade.py
from __future__ import annotations
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

View File

@@ -0,0 +1,62 @@
# app/tests/unit/market_data/acquisition/models/test_trade.py
from __future__ import annotations
from dataclasses import FrozenInstanceError
from datetime import datetime, timezone
from decimal import Decimal
import pytest
from src.market_data.acquisition.models.trade import Trade, TradeAggressorSide
def _trade(
*,
aggressor_side: TradeAggressorSide = TradeAggressorSide.BUY,
) -> Trade:
return Trade(
symbol="BTC/USD_LEVERAGE",
trade_id=2134857062,
price=Decimal("64497.25"),
quantity=Decimal("0.005"),
executed_at=datetime(2026, 7, 16, 11, 27, 46, 823000, tzinfo=timezone.utc),
aggressor_side=aggressor_side,
source="dzengi",
)
def test_trade_stores_canonical_values() -> None:
trade = _trade()
assert trade.symbol == "BTC/USD_LEVERAGE"
assert trade.trade_id == 2134857062
assert trade.price == Decimal("64497.25")
assert trade.quantity == Decimal("0.005")
assert trade.executed_at.tzinfo is timezone.utc
assert trade.aggressor_side is TradeAggressorSide.BUY
assert trade.source == "dzengi"
def test_trade_supports_sell_aggressor_side() -> None:
trade = _trade(aggressor_side=TradeAggressorSide.SELL)
assert trade.aggressor_side is TradeAggressorSide.SELL
def test_trade_aggressor_side_has_stable_values() -> None:
assert TradeAggressorSide.BUY.value == "buy"
assert TradeAggressorSide.SELL.value == "sell"
def test_trade_is_frozen() -> None:
trade = _trade()
with pytest.raises(FrozenInstanceError):
setattr(trade, "price", Decimal("1"))
def test_trade_uses_slots() -> None:
trade = _trade()
assert not hasattr(trade, "__dict__")

View File

@@ -0,0 +1,902 @@
# 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.