diff --git a/app/src/market_data/acquisition/models/trade.py b/app/src/market_data/acquisition/models/trade.py index e69de29..193ca73 100644 --- a/app/src/market_data/acquisition/models/trade.py +++ b/app/src/market_data/acquisition/models/trade.py @@ -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 diff --git a/app/tests/unit/market_data/acquisition/models/test_trade.py b/app/tests/unit/market_data/acquisition/models/test_trade.py new file mode 100644 index 0000000..7ba737d --- /dev/null +++ b/app/tests/unit/market_data/acquisition/models/test_trade.py @@ -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__") diff --git a/docs/migrations/build_060_1.md b/docs/migrations/build_060_1.md new file mode 100644 index 0000000..426a3fd --- /dev/null +++ b/docs/migrations/build_060_1.md @@ -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.