build 059.5: add internal candle close event model

This commit is contained in:
2026-07-17 07:51:07 +03:00
parent 925b447d45
commit a1efa39517
3 changed files with 726 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
```