diff --git a/app/src/market_data/acquisition/runtime/runtime_commands.py b/app/src/market_data/acquisition/runtime/runtime_commands.py new file mode 100644 index 0000000..ad3006d --- /dev/null +++ b/app/src/market_data/acquisition/runtime/runtime_commands.py @@ -0,0 +1,82 @@ +# app/src/market_data/acquisition/runtime/runtime_commands.py + +from __future__ import annotations + +from dataclasses import dataclass + +from src.market_data.acquisition.runtime.transport_messages import ( + TransportBinaryMessage, + TransportTextMessage, +) + + +TransportMessage = TransportTextMessage | TransportBinaryMessage + + +@dataclass(frozen=True, slots=True) +class ConnectCommand: + """ + Команда на открытие транспортного соединения. + + Команда выражает намерение Runtime и не выполняет подключение + самостоятельно. + """ + + +@dataclass(frozen=True, slots=True) +class DisconnectCommand: + """ + Команда на закрытие транспортного соединения. + + Команда не содержит сетевой логики и не управляет транспортом + самостоятельно. + """ + + +@dataclass(frozen=True, slots=True) +class SubscribeCommand: + """ + Команда на регистрацию и отправку транспортной подписки. + + subscription_key является стабильным идентификатором подписки, + используемым будущим Subscription Manager. + + message содержит уже подготовленный транспортный payload. + Runtime не интерпретирует его содержимое. + """ + + subscription_key: str + message: TransportMessage + + +@dataclass(frozen=True, slots=True) +class UnsubscribeCommand: + """ + Команда на удаление и отправку отмены транспортной подписки. + + subscription_key связывает отмену с ранее зарегистрированной + подпиской. + + message содержит уже подготовленный транспортный payload. + """ + + subscription_key: str + message: TransportMessage + + +@dataclass(frozen=True, slots=True) +class SendTextCommand: + """ + Команда на отправку текстового транспортного сообщения. + """ + + message: TransportTextMessage + + +@dataclass(frozen=True, slots=True) +class SendBinaryCommand: + """ + Команда на отправку бинарного транспортного сообщения. + """ + + message: TransportBinaryMessage \ No newline at end of file diff --git a/app/tests/unit/market_data/acquisition/runtime/test_runtime_commands.py b/app/tests/unit/market_data/acquisition/runtime/test_runtime_commands.py new file mode 100644 index 0000000..acda135 --- /dev/null +++ b/app/tests/unit/market_data/acquisition/runtime/test_runtime_commands.py @@ -0,0 +1,97 @@ +# app/tests/unit/market_data/acquisition/runtime/test_runtime_commands.py + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from src.market_data.acquisition.runtime.runtime_commands import ( + ConnectCommand, + DisconnectCommand, + SendBinaryCommand, + SendTextCommand, + SubscribeCommand, + UnsubscribeCommand, +) +from src.market_data.acquisition.runtime.transport_messages import ( + TransportBinaryMessage, + TransportTextMessage, +) + + +def test_connect_command_is_marker_command() -> None: + assert ConnectCommand() == ConnectCommand() + + +def test_disconnect_command_is_marker_command() -> None: + assert DisconnectCommand() == DisconnectCommand() + + +def test_subscribe_command_contains_subscription_key_and_text_message() -> None: + message = TransportTextMessage(payload='{"action":"subscribe"}') + command = SubscribeCommand( + subscription_key="quotes:BTCUSDT", + message=message, + ) + + assert command.subscription_key == "quotes:BTCUSDT" + assert command.message is message + + +def test_subscribe_command_supports_binary_message() -> None: + message = TransportBinaryMessage(payload=b"\x01\x02") + command = SubscribeCommand( + subscription_key="binary:market-data", + message=message, + ) + + assert command.message is message + + +def test_unsubscribe_command_contains_subscription_key_and_message() -> None: + message = TransportTextMessage(payload='{"action":"unsubscribe"}') + command = UnsubscribeCommand( + subscription_key="quotes:BTCUSDT", + message=message, + ) + + assert command.subscription_key == "quotes:BTCUSDT" + assert command.message is message + + +def test_send_text_command_contains_transport_text_message() -> None: + message = TransportTextMessage(payload="ping") + command = SendTextCommand(message=message) + + assert command.message is message + + +def test_send_binary_command_contains_transport_binary_message() -> None: + message = TransportBinaryMessage(payload=b"\x00") + command = SendBinaryCommand(message=message) + + assert command.message is message + + +def test_subscribe_command_is_immutable() -> None: + command = SubscribeCommand( + subscription_key="quotes:BTCUSDT", + message=TransportTextMessage(payload="subscribe"), + ) + + with pytest.raises(FrozenInstanceError): + setattr(command, "subscription_key", "quotes:ETHUSDT") + + +def test_send_text_command_is_immutable() -> None: + command = SendTextCommand( + message=TransportTextMessage(payload="ping"), + ) + + with pytest.raises(FrozenInstanceError): + setattr( + command, + "message", + TransportTextMessage(payload="pong"), + ) \ No newline at end of file diff --git a/docs/migrations/build_059_11.md b/docs/migrations/build_059_11.md new file mode 100644 index 0000000..0ac6ed5 --- /dev/null +++ b/docs/migrations/build_059_11.md @@ -0,0 +1,580 @@ +# Build 059.11 — Runtime Commands + +**Migration Build** + +--- + +# Цель Build + +После завершения Build 059.10 Runtime уже содержит два фундаментальных уровня: + +- Runtime Protocols +- Transport Message Models + +Следующим шагом является появление объектов, описывающих **намерения Runtime**. + +Данный Build вводит отдельный слой **Runtime Commands**. + +Команды описывают **что необходимо сделать**, но не содержат логики выполнения. + +--- + +# Почему понадобился отдельный слой Commands + +До настоящего момента Runtime умел описывать: + +- транспортные интерфейсы; +- транспортные сообщения. + +Однако отсутствовало понятие действия. + +Например: + +- открыть соединение; +- закрыть соединение; +- зарегистрировать подписку; +- отправить сообщение. + +Все эти действия являются командами Runtime. + +Они не являются: + +- транспортными сообщениями; +- событиями Runtime; +- сетевыми операциями. + +Поэтому они выделяются в самостоятельный уровень архитектуры. + +--- + +# Архитектурная идея + +Runtime начинает строиться по принципу Command-driven Architecture. + +Получается следующая цепочка. + +```text +Runtime Service + │ + ▼ +Runtime Command + │ + ▼ +Transport + │ + ▼ +Transport Message + │ + ▼ +Network +``` + +Таким образом команды описывают исключительно намерение. + +Исполнение намерения остаётся обязанностью Session и Transport. + +--- + +# Новый модуль + +Создан файл + +``` +src/market_data/acquisition/runtime/runtime_commands.py +``` + +В нём определены immutable-модели Runtime-команд. + +--- + +# Использование Transport Messages + +Все команды, связанные с передачей данных, используют модели Build 059.10. + +То есть Runtime продолжает работать только с двумя видами транспортных сообщений: + +``` +TransportTextMessage +TransportBinaryMessage +``` + +Никакие команды не работают непосредственно со строками или массивами байтов. + +Это обеспечивает единый транспортный контракт Runtime. + +--- + +# TransportMessage + +В модуле введён локальный type alias. + +```python +TransportMessage = + TransportTextMessage + | TransportBinaryMessage +``` + +Он используется исключительно для типизации. + +Никакой новой Runtime-модели не создаётся. + +Данный alias позволяет избежать дублирования кода в командах подписки. + +--- + +# Новые команды + +В Build добавлены шесть Runtime-команд. + +--- + +## ConnectCommand + +Представляет намерение открыть транспортное соединение. + +Команда: + +- не содержит URL; +- не содержит параметры подключения; +- не выполняет подключение самостоятельно. + +Она лишь сообщает Runtime: + +> необходимо открыть соединение. + +--- + +## DisconnectCommand + +Представляет намерение завершить соединение. + +Команда не управляет транспортом. + +Она только описывает действие Runtime. + +--- + +## Почему ConnectCommand не содержит URL + +Во время проектирования рассматривались варианты добавить: + +``` +url +headers +timeout +ssl +authentication +``` + +От данной идеи было принято решение отказаться. + +Причины следующие. + +--- + +### Причина №1 + +URL является частью конфигурации транспорта. + +Он не должен передаваться вместе с каждой командой. + +--- + +### Причина №2 + +Команда должна описывать действие. + +Не настройки транспорта. + +--- + +### Причина №3 + +Подобная архитектура позволяет легко заменить транспорт без изменения Runtime-команд. + +--- + +# SubscribeCommand + +Представляет команду регистрации новой подписки Runtime. + +Содержит два поля. + +``` +subscription_key +message +``` + +--- + +## subscription_key + +Строковый идентификатор подписки. + +На текущем этапе Runtime он нигде не интерпретируется. + +Однако именно он станет основным ключом будущего Subscription Manager. + +Например: + +``` +quotes:BTCUSDT +``` + +или + +``` +candles:ETHUSDT:1m +``` + +Формат идентификатора пока намеренно не стандартизируется. + +Это будет сделано позднее после проектирования Subscription Manager. + +--- + +## message + +Содержит транспортное сообщение. + +Тип поля: + +``` +TransportMessage +``` + +То есть подписка может использовать: + +- текстовое сообщение; +- бинарное сообщение. + +Runtime не анализирует содержимое payload. + +--- + +# Почему SubscribeCommand хранит готовое сообщение + +Рассматривались два варианта. + +--- + +## Вариант 1 + +Хранить параметры подписки. + +Например + +``` +symbol +interval +channel +``` + +--- + +## Вариант 2 + +Хранить уже подготовленное транспортное сообщение. + +Был выбран второй вариант. + +Причины. + +--- + +### Runtime не знает протокол биржи + +Построением JSON занимается слой выше. + +Runtime не должен знать: + +- Binance; +- Bybit; +- Dzengi; +- Kraken; +- OKX. + +--- + +### Runtime отвечает только за доставку + +Transport получает уже готовое сообщение. + +Как оно было сформировано — его не касается. + +--- + +# UnsubscribeCommand + +Представляет команду отмены существующей подписки. + +Содержит: + +``` +subscription_key +message +``` + +По архитектуре полностью симметрична SubscribeCommand. + +Это позволит Subscription Manager использовать единый механизм регистрации и удаления подписок. + +--- + +# SendTextCommand + +Представляет команду отправки текстового транспортного сообщения. + +Содержит + +``` +TransportTextMessage +``` + +Никаких дополнительных параметров команда не имеет. + +--- + +# SendBinaryCommand + +Представляет команду отправки бинарного транспортного сообщения. + +Содержит + +``` +TransportBinaryMessage +``` + +Runtime рассматривает его исключительно как транспортный payload. + +--- + +# Почему отсутствует базовый класс Command + +Во время проектирования обсуждались варианты: + +``` +BaseCommand +``` + +или + +``` +RuntimeCommand +``` + +Было принято решение отказаться. + +Причины. + +--- + +## Причина №1 + +Все команды являются immutable dataclass. + +Общего поведения между ними нет. + +--- + +## Причина №2 + +Преждевременная иерархия усложняет архитектуру. + +Добавлять её без появления реальной общей логики нецелесообразно. + +--- + +## Причина №3 + +При необходимости общий базовый тип может быть введён отдельным Build без нарушения обратной совместимости. + +--- + +# Почему отсутствует CommandType + +Также обсуждалось использование enum. + +Например. + +``` +CONNECT +DISCONNECT +SUBSCRIBE +``` + +От идеи отказались. + +Тип команды уже определяется её классом. + +Дополнительный enum создавал бы дублирование информации. + +--- + +# Почему команды ничего не делают + +Очень важный архитектурный принцип Runtime. + +Команды являются моделями данных. + +Они: + +- ничего не выполняют; +- не взаимодействуют с сетью; +- не содержат asyncio; +- не вызывают Transport; +- не знают Session. + +Это соответствует принципу Command Pattern. + +--- + +# Что появится позже + +Исполнением команд будут заниматься Runtime Services. + +Например. + +```text +Session + +получает + +ConnectCommand + +↓ + +вызывает + +Transport.connect() +``` + +Таким образом команда остаётся полностью независимой от реализации транспорта. + +--- + +# Обновлённая архитектура Runtime + +После завершения Build Runtime выглядит следующим образом. + +```text +Runtime + +Protocols + │ +Transport Messages + │ +Runtime Commands + │ +Transport + │ +Network +``` + +Следующим Build между Commands и Transport появятся Runtime Events. + +--- + +# Что НЕ входит в Build + +Сознательно не реализованы: + +- Session; +- Command Dispatcher; +- очередь команд; +- asyncio; +- обработчики команд; +- сериализация; +- JSON; +- Subscription Manager; +- Reconnect; +- Heartbeat; +- Ping/Pong. + +Все эти элементы будут появляться постепенно отдельными Build. + +--- + +# Проверка Build + +Выполнена полная проверка. + +--- + +## Компиляция + +``` +python -m compileall +``` + +Успешно. + +--- + +## Unit Tests + +Созданы тесты: + +``` +test_runtime_commands.py +``` + +Проверяется: + +- создание каждой команды; +- корректность хранения TransportMessage; +- поддержка текстовых сообщений; +- поддержка бинарных сообщений; +- immutable-поведение dataclass. + +Все тесты успешно пройдены. + +--- + +## Runtime Regression + +Проверены совместно: + +- Runtime Protocols; +- Transport Messages; +- Runtime Commands. + +Все Runtime-тесты успешно завершены. + +--- + +## Проверка репозитория + +Выполнен + +``` +git diff --check +``` + +Ошибок форматирования не обнаружено. + +--- + +# Итог + +Build 059.11 завершает формирование слоя Runtime Commands. + +Теперь Runtime имеет три независимых уровня: + +```text +Protocols + │ +Transport Messages + │ +Runtime Commands +``` + +Каждый уровень отвечает только за собственную область ответственности. + +Это создаёт устойчивый фундамент для следующих Build, в которых будут реализованы Runtime Events, Subscription Manager, Session и механизмы обеспечения надёжности соединения. + +Главный принцип миграции Dzentra остаётся неизменным: + +- архитектура развивается небольшими независимыми шагами; +- каждый Build вводит только один новый уровень ответственности; +- существующий торговый бот продолжает работать без необходимости полной одномоментной миграции на новую Runtime-подсистему. \ No newline at end of file