diff --git a/app/src/market_data/acquisition/models/candle_close.py b/app/src/market_data/acquisition/models/candle_close.py new file mode 100644 index 0000000..4d4ad77 --- /dev/null +++ b/app/src/market_data/acquisition/models/candle_close.py @@ -0,0 +1,29 @@ +# app/src/market_data/acquisition/models/candle_close.py + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + + +# Внутреннее неизменяемое уведомление о закрытии OHLC-свечи. +# +# Модель не является канонической Candle, поскольку WebSocket-событие +# не содержит volume. Она используется как сигнал для последующего +# REST reconciliation и получения полной OHLCV-свечи. +@dataclass(frozen=True, slots=True) +class CandleCloseEvent: + symbol: str + interval: str + candle_type: str + + open_time: datetime + received_at: datetime + + open_price: Decimal + high_price: Decimal + low_price: Decimal + close_price: Decimal + + source: str \ No newline at end of file diff --git a/app/tests/unit/market_data/acquisition/models/test_candle_close.py b/app/tests/unit/market_data/acquisition/models/test_candle_close.py new file mode 100644 index 0000000..b3e1b42 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/models/test_candle_close.py @@ -0,0 +1,142 @@ +# app/tests/unit/market_data/acquisition/models/test_candle_close.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.candle import Candle +from src.market_data.acquisition.models.candle_close import ( + CandleCloseEvent, +) + + +def _event() -> CandleCloseEvent: + return CandleCloseEvent( + symbol="BTC/USD_LEVERAGE", + interval="1m", + candle_type="classic", + open_time=datetime( + 2026, + 7, + 16, + 18, + 39, + tzinfo=timezone.utc, + ), + received_at=datetime( + 2026, + 7, + 16, + 18, + 40, + 0, + 125000, + tzinfo=timezone.utc, + ), + open_price=Decimal("63992.00"), + high_price=Decimal("64032.55"), + low_price=Decimal("63984.00"), + close_price=Decimal("64032.55"), + source="dzengi_websocket_ohlc", + ) + + +def test_candle_close_event_preserves_all_fields() -> None: + event = _event() + + assert event.symbol == "BTC/USD_LEVERAGE" + assert event.interval == "1m" + assert event.candle_type == "classic" + + assert event.open_time == datetime( + 2026, + 7, + 16, + 18, + 39, + tzinfo=timezone.utc, + ) + assert event.received_at == datetime( + 2026, + 7, + 16, + 18, + 40, + 0, + 125000, + tzinfo=timezone.utc, + ) + + assert event.open_price == Decimal("63992.00") + assert event.high_price == Decimal("64032.55") + assert event.low_price == Decimal("63984.00") + assert event.close_price == Decimal("64032.55") + + assert event.source == "dzengi_websocket_ohlc" + + +def test_candle_close_event_is_immutable() -> None: + event = _event() + + with pytest.raises(FrozenInstanceError): + event.close_price = Decimal("1") # type: ignore[misc] + + +def test_candle_close_event_uses_slots() -> None: + event = _event() + + assert not hasattr(event, "__dict__") + + +def test_candle_close_event_is_not_canonical_candle() -> None: + event = _event() + + assert not isinstance(event, Candle) + + +def test_candle_close_event_has_no_volume_field() -> None: + event = _event() + + assert not hasattr(event, "volume") + + +def test_candle_close_event_supports_heikin_ashi_type() -> None: + event = CandleCloseEvent( + symbol="BTC/USD_LEVERAGE", + interval="1m", + candle_type="heikin-ashi", + open_time=datetime( + 2026, + 7, + 16, + 18, + 39, + tzinfo=timezone.utc, + ), + received_at=datetime( + 2026, + 7, + 16, + 18, + 40, + tzinfo=timezone.utc, + ), + open_price=Decimal("64068.18"), + high_price=Decimal("64128.80"), + low_price=Decimal("64068.18"), + close_price=Decimal("64100.85"), + source="dzengi_websocket_ohlc", + ) + + assert event.candle_type == "heikin-ashi" + + +def test_candle_close_event_keeps_open_and_receive_times_separate() -> None: + event = _event() + + assert event.received_at > event.open_time + assert event.received_at != event.open_time \ No newline at end of file diff --git a/docs/migrations/build_059_5.md b/docs/migrations/build_059_5.md new file mode 100644 index 0000000..55df46c --- /dev/null +++ b/docs/migrations/build_059_5.md @@ -0,0 +1,555 @@ +# Build 059.5 — Internal Candle Close Event Model + +**Проект:** Dzentra +**Подсистема:** Market Data Acquisition +**Этап:** 059.5 +**Статус:** Completed + +--- + +# Цель + +Добавить внутреннюю immutable-модель уведомления о закрытии OHLC-свечи. + +Модель должна представлять уже проверенное WebSocket-событие внутри Dzentra, но не должна заменять каноническую модель: + +```text +Candle +``` + +Причина разделения заключается в том, что фактическое событие Dzengi WebSocket: + +```text +ohlc.event +``` + +содержит: + +```text +symbol +interval +type +t +o +h +l +c +``` + +но не содержит: + +```text +volume +``` + +Поэтому WebSocket OHLC-событие не является полноценной OHLCV-свечой. + +--- + +# Причина изменения + +После Build 059.4 проект умеет: + +1. структурно проверять входящее WebSocket-сообщение; +2. преобразовывать его в transport-модель; +3. проверять предметную допустимость значений. + +Подготовленная цепочка выглядит так: + +```text +Raw WebSocket message + ↓ +ValidatedWebSocketOhlcDocument + ↓ +DzengiWebSocketOhlcEvent + ↓ +Value Validation +``` + +Однако transport-модель: + +```text +DzengiWebSocketOhlcEvent +``` + +принадлежит адаптеру конкретного внешнего источника Dzengi. + +Перед дальнейшим использованием в runtime требуется источник-независимая внутренняя модель Dzentra. + +--- + +# Реализовано + +Создан файл: + +```text +src/market_data/acquisition/models/candle_close.py +``` + +Добавлена модель: + +```text +CandleCloseEvent +``` + +--- + +# Структура модели + +Модель содержит следующие поля: + +```text +symbol +interval +candle_type + +open_time +received_at + +open_price +high_price +low_price +close_price + +source +``` + +Полное назначение модели: + +```text +внутреннее уведомление Dzentra +о завершении OHLC-свечи, +требующее последующего REST reconciliation +для получения полной OHLCV-свечи +``` + +--- + +# Контракт модели + +Модель объявлена как: + +```text +frozen=True +slots=True +``` + +Это обеспечивает: + +- неизменяемость; +- отсутствие динамического `__dict__`; +- уменьшение вероятности случайного изменения события; +- компактное представление в памяти; +- предсказуемый runtime-контракт. + +--- + +# Отличие от transport-модели + +Transport-модель: + +```text +DzengiWebSocketOhlcEvent +``` + +отражает внешний контракт Dzengi. + +Она содержит: + +```text +open_time: int +OHLC: str | int | float +``` + +Внутренняя модель: + +```text +CandleCloseEvent +``` + +содержит: + +```text +open_time: datetime +received_at: datetime +OHLC: Decimal +source: str +``` + +Таким образом transport-особенности внешнего API не распространяются на внутренние слои Dzentra. + +--- + +# Отличие от канонической Candle + +Каноническая модель: + +```text +Candle +``` + +представляет полноценную OHLCV-свечу. + +Она содержит: + +```text +volume: Decimal +``` + +Модель: + +```text +CandleCloseEvent +``` + +не содержит `volume`. + +Это преднамеренное архитектурное решение. + +WebSocket-событие не должно: + +- подставлять фиктивный нулевой объём; +- использовать nullable volume; +- притворяться полной OHLCV-свечой; +- наследоваться от `Candle`; +- передаваться потребителям, ожидающим каноническую свечу. + +Полная `Candle` будет получена только после REST reconciliation. + +--- + +# Поля времени + +## open_time + +Поле: + +```text +open_time +``` + +представляет время открытия завершённой свечи. + +Для минутной свечи это начало минутного интервала, к которому относится событие. + +Тип: + +```text +datetime +``` + +Ожидаемая timezone: + +```text +UTC +``` + +--- + +## received_at + +Поле: + +```text +received_at +``` + +представляет момент получения события системой Dzentra. + +Оно не равно `open_time`. + +Разделение двух времён необходимо для: + +- измерения задержки WebSocket; +- диагностики запоздавших сообщений; +- определения порядка фактической обработки; +- runtime-метрик; +- журналирования; +- анализа качества источника данных. + +--- + +# Поля OHLC + +Внутренняя модель использует: + +```text +Decimal +``` + +для: + +```text +open_price +high_price +low_price +close_price +``` + +Это исключает дальнейшее распространение внешних типов: + +```text +str +int +float +``` + +Преобразование transport-значений в `Decimal` будет реализовано в следующем этапе: + +```text +Build 059.6 — Mapper +``` + +--- + +# Поле candle_type + +Поле: + +```text +candle_type +``` + +сохраняет тип серии свечей. + +Подтверждённые runtime-значения: + +```text +classic +heikin-ashi +``` + +Для production-интеграции Candles Feed основной интерес представляет: + +```text +classic +``` + +Поскольку именно классические свечи будут использоваться для REST reconciliation с каноническими OHLCV-свечами. + +`heikin-ashi` остаётся поддерживаемым внутренним типом события, но не должен автоматически преобразовываться в обычную каноническую `Candle`. + +--- + +# Поле source + +Поле: + +```text +source +``` + +фиксирует происхождение внутреннего события. + +Пример: + +```text +dzengi_websocket_ohlc +``` + +Это позволит в дальнейшем: + +- различать источники; +- поддерживать несколько адаптеров; +- вести диагностику; +- применять источник-зависимую политику reconciliation; +- не встраивать имя Dzengi в общую доменную модель. + +--- + +# Новый внутренний конвейер + +После Build 059.5 архитектура выглядит так: + +```text +Raw WebSocket message + ↓ +Schema Validation + ↓ +ValidatedWebSocketOhlcDocument + ↓ +Parser + ↓ +DzengiWebSocketOhlcEvent + ↓ +Value Validation + ↓ +Mapper + ↓ +CandleCloseEvent + ↓ +REST reconciliation + ↓ +Canonical Candle +``` + +На текущем этапе модель `CandleCloseEvent` уже существует, но mapper ещё не реализован. + +--- + +# Unit Tests + +Создан файл: + +```text +tests/unit/market_data/acquisition/models/test_candle_close.py +``` + +Проверяются: + +- сохранение всех полей; +- неизменяемость модели; +- использование `slots`; +- отсутствие `__dict__`; +- отсутствие наследования от `Candle`; +- отсутствие поля `volume`; +- поддержка типа `heikin-ashi`; +- раздельное хранение `open_time` и `received_at`. + +Всего выполнено: + +```text +7 tests +``` + +--- + +# Проверка синтаксиса + +Выполнена команда: + +```bash +python -m compileall \ + src/market_data/acquisition/models/candle_close.py \ + tests/unit/market_data/acquisition/models/test_candle_close.py +``` + +Результат: + +```text +успешно +``` + +--- + +# Целевые тесты + +Выполнена команда: + +```bash +python -m pytest -q \ + tests/unit/market_data/acquisition/models/test_candle_close.py +``` + +Результат: + +```text +7 passed in 0.01s +``` + +--- + +# Регрессия слоя моделей + +Выполнена команда: + +```bash +python -m pytest -q \ + tests/unit/market_data/acquisition/models +``` + +Результат: + +```text +42 passed in 0.02s +``` + +--- + +# Проверка форматирования + +Выполнена команда: + +```bash +git diff --check +``` + +Ошибок форматирования не обнаружено. + +--- + +# Созданные файлы + +```text +src/market_data/acquisition/models/candle_close.py +tests/unit/market_data/acquisition/models/test_candle_close.py +docs/migrations/build_059_5.md +``` + +--- + +# Совместимость + +Изменение полностью обратно совместимо. + +Не изменены: + +- каноническая модель `Candle`; +- transport-модель Dzengi; +- REST Candles Feed; +- parser; +- value validation; +- WebSocket Adapter; +- ExchangeService; +- runtime; +- Market Analysis; +- Trading. + +Новая модель пока не подключена к runtime и не влияет на работающий бот. + +--- + +# Ограничения этапа + +Build 059.5 не выполняет: + +- преобразование transport-модели; +- преобразование timestamp; +- преобразование OHLC в `Decimal`; +- установку реального `received_at`; +- mapping; +- WebSocket Adapter; +- REST reconciliation; +- проверку соответствия REST и WebSocket OHLC; +- создание канонической `Candle`; +- публикацию события в runtime. + +--- + +# Итог + +Build 059.5 добавляет источник-независимую внутреннюю модель: + +```text +CandleCloseEvent +``` + +Она представляет подтверждённое уведомление о завершении OHLC-свечи без фиктивного объёма. + +Модель создаёт архитектурную границу между: + +```text +внешним Dzengi WebSocket контрактом +``` + +и: + +```text +внутренним runtime Dzentra +``` + +Следующий этап: + +```text +Build 059.6 — Mapper +``` \ No newline at end of file