Compare commits
59 Commits
a6325cf4cf
...
64a5bdd04c
| Author | SHA1 | Date | |
|---|---|---|---|
| 64a5bdd04c | |||
| 8c98de9acc | |||
| 8c485e32b1 | |||
| 58e5a12a4d | |||
| cb8acfe5fe | |||
| 60bec1eaf9 | |||
| c142145361 | |||
| ee8765b716 | |||
| 9953bab993 | |||
| 433ac5d375 | |||
| ad6631f773 | |||
| 14f698d48a | |||
| 317b9683ef | |||
| cf58fc223c | |||
| a891302eaf | |||
| 618b716e71 | |||
| fe308b9e60 | |||
| be6beac560 | |||
| 02868561d6 | |||
| bf7645237e | |||
| b7bef85d20 | |||
| 6d121abe96 | |||
| d3f8680921 | |||
| 034f5984ca | |||
| 0b4edca287 | |||
| 2523d715e7 | |||
| 7eee8ce36c | |||
| 6b0d5badce | |||
| a0ae22cb2e | |||
| c0f69ee94a | |||
| 013efd8a97 | |||
| c52c099d54 | |||
| f2bacb80c9 | |||
| d2d8f8fad4 | |||
| 4977c7678a | |||
| 8109ed6210 | |||
| e5a4efa5d1 | |||
| 20a70df1f0 | |||
| 85e9662f5b | |||
| 5c7ecf9340 | |||
| a1efa39517 | |||
| 925b447d45 | |||
| c8c0bb6951 | |||
| e0aa04b32a | |||
| ebb578db35 | |||
| cd301586d3 | |||
| eaa0eaf710 | |||
| ed69420196 | |||
| 09f3f66a4e | |||
| f027436e2f | |||
| 642d607162 | |||
| 0b2187dac4 | |||
| c8d33f8baa | |||
| 16ed64f7c6 | |||
| 3a253d89a9 | |||
| 77e87c0504 | |||
| a10a760166 | |||
| 0e86cca19d | |||
| 47c75d68b1 |
30
.dockerignore
Normal file
30
.dockerignore
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# В build context разрешены только production source и requirements.
|
||||||
|
**
|
||||||
|
!.dockerignore
|
||||||
|
!infra/
|
||||||
|
!infra/docker/
|
||||||
|
!infra/docker/Dockerfile
|
||||||
|
!infra/docker/postgres/
|
||||||
|
!infra/docker/postgres/Dockerfile
|
||||||
|
!infra/docker/postgres/healthcheck-application-role.sh
|
||||||
|
!infra/docker/postgres/init-application-role.sh
|
||||||
|
!app/
|
||||||
|
!app/requirements.txt
|
||||||
|
!app/requirements.lock
|
||||||
|
!app/src/
|
||||||
|
!app/src/**
|
||||||
|
|
||||||
|
# Локальные артефакты запрещены даже внутри разрешённого source tree.
|
||||||
|
app/src/**/__pycache__/
|
||||||
|
app/src/**/*.py[cod]
|
||||||
|
app/src/**/.DS_Store
|
||||||
|
app/src/**/.env
|
||||||
|
app/src/**/.env.*
|
||||||
|
app/src/**/*.pem
|
||||||
|
app/src/**/*.key
|
||||||
|
app/src/**/*.p12
|
||||||
|
app/src/**/*.pfx
|
||||||
|
app/src/**/*.log
|
||||||
|
app/src/**/*.dump
|
||||||
|
app/src/**/*.sqlite
|
||||||
|
app/src/**/*.sqlite3
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"python.defaultInterpreterPath": "app/.venv/bin/python",
|
"python.defaultInterpreterPath": "app/.venv/bin/python",
|
||||||
"python-envs.defaultEnvManager": "ms-python.python:system"
|
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||||
|
"python.analysis.typeCheckingMode": "standard",
|
||||||
|
"python.analysis.diagnosticMode": "workspace"
|
||||||
}
|
}
|
||||||
|
|||||||
96
README.md
96
README.md
@@ -1,33 +1,83 @@
|
|||||||
# dzentra_bot
|
# Dzentra
|
||||||
|
|
||||||
Telegram-бот для автоторговли криптовалютой.
|
Dzentra — модульный монолит автоматической торговой системы с
|
||||||
|
Telegram-интерфейсом. В текущем принятом Production scope полностью
|
||||||
|
собрана вертикаль Trades Feed: получение сделок, проверка
|
||||||
|
последовательности, reconnect/recovery, опциональное сохранение в
|
||||||
|
PostgreSQL и восстановление после перезапуска.
|
||||||
|
|
||||||
## Bootstrap v2
|
## Текущий scope
|
||||||
Это стабильный стартовый каркас проекта с:
|
|
||||||
- чистой структурой каталогов
|
|
||||||
- компактным верхним меню
|
|
||||||
- базовыми handlers
|
|
||||||
- документацией
|
|
||||||
- Docker-файлами
|
|
||||||
- опциональным созданием `.venv`, установкой зависимостей и первым git commit
|
|
||||||
|
|
||||||
## Верхнее меню
|
- Production ingestion, Consistency, Recovery, persistent checkpoint и
|
||||||
- 🏠 Главная
|
Startup Recovery реализованы для Trades.
|
||||||
- 📈 Рынок
|
- Historical Access и deterministic Replay доступны как отдельные API и
|
||||||
- 💼 Портфель
|
не запускаются автоматически.
|
||||||
- ⚡ Торговля
|
- REST-потоки Quotes и Candles продолжают использоваться Trading/UI.
|
||||||
- 🤖 Авто
|
Новые WebSocket Runtime, Consistency/Recovery и persistent writer
|
||||||
- 📒 Журнал
|
wiring для них пока не подключены.
|
||||||
- ⚙️ Система
|
- Retention и создание месячных партиций выполняются только явным вызовом.
|
||||||
|
- Обычный запуск приложения требует PostgreSQL для журнала и balance
|
||||||
|
snapshots, даже если Trade Stream и Market Data Storage выключены.
|
||||||
|
|
||||||
|
Подробное фактическое состояние описано в
|
||||||
|
[архитектуре Trades Feed](docs/architecture/trades_feed.md).
|
||||||
|
|
||||||
|
## Быстрый старт существующего checkout
|
||||||
|
|
||||||
|
Требуются Python 3.12, доступный PostgreSQL и Telegram Bot Token. Команды
|
||||||
|
выполняются из корня репозитория:
|
||||||
|
|
||||||
## Рекомендуемый запуск
|
|
||||||
```bash
|
```bash
|
||||||
/opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install
|
python3.12 -m venv app/.venv
|
||||||
|
app/.venv/bin/python -m pip install --upgrade pip
|
||||||
|
app/.venv/bin/python -m pip install -r app/requirements-dev.txt
|
||||||
|
test -e app/.env || cp app/.env.example app/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
## После bootstrap
|
Заполните созданный `app/.env`, не добавляйте его в Git и не
|
||||||
|
перезаписывайте уже существующий файл. Затем запустите приложение:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app
|
cd app
|
||||||
source .venv/bin/activate
|
.venv/bin/python -m src.main
|
||||||
python -m src.main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Настройки режимов, PostgreSQL и безопасный Docker-запуск приведены в
|
||||||
|
[эксплуатационном руководстве](docs/operations/trades_feed_runtime.md).
|
||||||
|
Более подробная инструкция для разработчика находится в
|
||||||
|
[app/README.md](app/README.md).
|
||||||
|
|
||||||
|
## Обязательные проверки
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check_python_types.sh
|
||||||
|
app/.venv/bin/python scripts/check_documentation_integrity.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый результат Pyright — `0 errors, 0 warnings`, а documentation
|
||||||
|
gate — `issues=0`. Обычная offline-регрессия запускается из `app`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
.venv/bin/python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Default pytest-набор исключает opt-in группы `integration`, `stress` и
|
||||||
|
`live`, но включает статическую проверку Pyright. Целевой тест не
|
||||||
|
заменяет полный type gate.
|
||||||
|
|
||||||
|
## Навигация
|
||||||
|
|
||||||
|
- [Обзор архитектуры](docs/architecture/overview.md)
|
||||||
|
- [Структура проекта](docs/architecture/project_structure.md)
|
||||||
|
- [Текущая архитектура Trades Feed](docs/architecture/trades_feed.md)
|
||||||
|
- [Эксплуатация Trade Stream Runtime](docs/operations/trades_feed_runtime.md)
|
||||||
|
- [Архитектура Build 060.30](docs/migrations/build_060_30_architecture.md)
|
||||||
|
|
||||||
|
## Исторический bootstrap
|
||||||
|
|
||||||
|
`bootstrap_project.py` — исторический генератор первоначального каркаса.
|
||||||
|
Для установки или запуска существующего checkout его использовать не
|
||||||
|
нужно.
|
||||||
|
|||||||
@@ -1,20 +1,73 @@
|
|||||||
|
# Настройки приложения. Файл является примером: не храните здесь
|
||||||
|
# реальные секреты. Для каждого секрета задайте либо прямое значение,
|
||||||
|
# либо *_FILE, но не оба. *_FILE должен указывать на доступный для чтения
|
||||||
|
# UTF-8 файл с одним секретом.
|
||||||
|
|
||||||
|
# Telegram и приложение
|
||||||
BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE
|
BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE
|
||||||
|
# BOT_TOKEN_FILE=/run/secrets/bot_token
|
||||||
BOT_PARSE_MODE=HTML
|
BOT_PARSE_MODE=HTML
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
TZ=Europe/Minsk
|
TZ=Europe/Minsk
|
||||||
|
DEBUG_ENABLED=false
|
||||||
|
JOURNAL_DEBUG_ENABLED=false
|
||||||
|
# Только для контейнера с отдельным runtime-файлом, доступным для записи:
|
||||||
|
# DZENTRA_RUNTIME_ENV_FILE=/var/lib/dzentra/runtime.env
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=dzentra_bot
|
DB_NAME=dzentra_bot
|
||||||
DB_USER=dzentra_bot
|
DB_USER=dzentra_bot
|
||||||
DB_PASSWORD=change_me
|
DB_PASSWORD=PUT_YOUR_DATABASE_PASSWORD_HERE
|
||||||
|
# DB_PASSWORD_FILE=/run/secrets/db_password
|
||||||
|
|
||||||
EXCHANGE_ENABLED=true
|
# Постоянное хранилище Market Data
|
||||||
|
MARKET_DATA_STORAGE_ENABLED=false
|
||||||
|
MARKET_DATA_STORAGE_POOL_MIN_SIZE=1
|
||||||
|
MARKET_DATA_STORAGE_POOL_MAX_SIZE=4
|
||||||
|
MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS=10
|
||||||
|
|
||||||
|
# Биржа. Безопасный пример совпадает с выключенными значениями по умолчанию.
|
||||||
|
EXCHANGE_ENABLED=false
|
||||||
EXCHANGE_NAME=dzengi
|
EXCHANGE_NAME=dzengi
|
||||||
EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com
|
EXCHANGE_BASE_URL=
|
||||||
|
EXCHANGE_WS_URL=
|
||||||
EXCHANGE_API_KEY=
|
EXCHANGE_API_KEY=
|
||||||
|
# EXCHANGE_API_KEY_FILE=/run/secrets/exchange_api_key
|
||||||
EXCHANGE_API_SECRET=
|
EXCHANGE_API_SECRET=
|
||||||
|
# EXCHANGE_API_SECRET_FILE=/run/secrets/exchange_api_secret
|
||||||
EXCHANGE_TIMEOUT_SEC=10
|
EXCHANGE_TIMEOUT_SEC=10
|
||||||
EXCHANGE_TESTNET=true
|
EXCHANGE_TESTNET=false
|
||||||
DEFAULT_SYMBOL=BTC/USD_LEVERAGE
|
DEFAULT_SYMBOL=ETH/USD_LEVERAGE
|
||||||
|
|
||||||
|
# Поток сделок Production Trade Stream
|
||||||
|
TRADE_STREAM_ENABLED=false
|
||||||
|
TRADE_STREAM_WS_URL=
|
||||||
|
TRADE_STREAM_SYMBOLS=
|
||||||
|
TRADE_STREAM_OPEN_TIMEOUT_SECONDS=10
|
||||||
|
TRADE_STREAM_PROBE_TIMEOUT_SECONDS=20
|
||||||
|
TRADE_STREAM_CLOSE_TIMEOUT_SECONDS=10
|
||||||
|
TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS=30
|
||||||
|
TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS=5
|
||||||
|
TRADE_STREAM_RECOVERY_WINDOW_MS=3599999
|
||||||
|
TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS=10
|
||||||
|
TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY=10000
|
||||||
|
|
||||||
|
# Управляющие переменные Docker Compose передаются самому Compose,
|
||||||
|
# а не сохраняются в app/.env. Пути должны вести к защищённым файлам
|
||||||
|
# вне repository; BOT_STOP_GRACE_PERIOD задаётся после расчёта бюджета.
|
||||||
|
# Каталог файлов с секретами: mode 0700; сами файлы: mode 0444.
|
||||||
|
# DZENTRA_COMPOSE_PROJECT_NAME=dzentra-production
|
||||||
|
# DZENTRA_BOT_IMAGE=dzentra-bot:local
|
||||||
|
# DZENTRA_POSTGRES_IMAGE=dzentra-postgres:16.14-hardened
|
||||||
|
# BOT_TOKEN_SECRET_FILE=/absolute/protected/dzentra/bot_token
|
||||||
|
# POSTGRES_ADMIN_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/postgres_admin_password
|
||||||
|
# DB_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/db_password
|
||||||
|
# POSTGRES_ADMIN_USER=dzentra_admin
|
||||||
|
# BOT_STOP_GRACE_PERIOD=30m
|
||||||
|
# POSTGRES_STOP_GRACE_PERIOD=60s
|
||||||
|
# Только с infra/compose/docker-compose.exchange-auth.yml:
|
||||||
|
# EXCHANGE_API_KEY_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_key
|
||||||
|
# EXCHANGE_API_SECRET_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_secret
|
||||||
|
|||||||
122
app/README.md
122
app/README.md
@@ -1,3 +1,121 @@
|
|||||||
# app
|
# Приложение Dzentra
|
||||||
|
|
||||||
Здесь находятся исходный код приложения, env-файлы и зависимости.
|
Каталог `app` содержит исполняемое Python-приложение, зависимости,
|
||||||
|
настройки и тесты Dzentra. Приложение объединяет Telegram-бота и
|
||||||
|
принятую Production-вертикаль Trades Feed.
|
||||||
|
|
||||||
|
## Реализованные границы
|
||||||
|
|
||||||
|
| Возможность | Текущее состояние |
|
||||||
|
|---|---|
|
||||||
|
| Live Trades, reconnect и REST Recovery | Подключены к Production Runtime |
|
||||||
|
| Persistent Trades и checkpoint | Опционально подключены через `MARKET_DATA_STORAGE_ENABLED` |
|
||||||
|
| Startup Hydration и Recovery | Выполняются при включённом Storage |
|
||||||
|
| Historical Access и Replay | Готовы для явного вызова; автоматически не запускаются |
|
||||||
|
| Quote/Candle REST feeds | Используются существующими Trading/UI сценариями |
|
||||||
|
| Quote/Candle WebSocket и persistence | Runtime/Consistency/Recovery и persistent writers не подключены |
|
||||||
|
| Retention и месячные партиции | Только явный вызов; Scheduler их не запускает |
|
||||||
|
|
||||||
|
Подробности приведены в
|
||||||
|
[текущей архитектуре Trades Feed](../docs/architecture/trades_feed.md).
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Python 3.12;
|
||||||
|
- PostgreSQL, доступный по параметрам `DB_*`;
|
||||||
|
- Telegram Bot Token;
|
||||||
|
- отдельное виртуальное окружение `app/.venv`.
|
||||||
|
|
||||||
|
PostgreSQL нужен даже при выключенных Trade Stream и Market Data Storage:
|
||||||
|
обычный Bootstrap всегда инициализирует таблицы журнала и balance
|
||||||
|
snapshots.
|
||||||
|
|
||||||
|
## Подготовка окружения
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 -m venv app/.venv
|
||||||
|
app/.venv/bin/python -m pip install --upgrade pip
|
||||||
|
app/.venv/bin/python -m pip install -r app/requirements-dev.txt
|
||||||
|
test -e app/.env || cp app/.env.example app/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Замените значения-заглушки в `app/.env`. Не добавляйте этот файл в Git и
|
||||||
|
не перезаписывайте существующий `.env`. Для каждого секрета разрешён либо
|
||||||
|
прямой параметр, либо соответствующий `*_FILE`, но не оба одновременно.
|
||||||
|
|
||||||
|
`requirements-dev.txt` устанавливает production dependencies и
|
||||||
|
инструменты разработки. Production container использует проверяемый
|
||||||
|
hash-lock `requirements.lock`; вручную редактировать его dependency
|
||||||
|
records не следует.
|
||||||
|
|
||||||
|
## Режимы Trades Feed
|
||||||
|
|
||||||
|
| `TRADE_STREAM_ENABLED` | `MARKET_DATA_STORAGE_ENABLED` | Поведение |
|
||||||
|
|---:|---:|---|
|
||||||
|
| `false` | `false` | Trade Runtime и отдельный Market Data pool не создаются |
|
||||||
|
| `true` | `false` | Live/Recovery работают только с состоянием в памяти |
|
||||||
|
| `true` | `true` | Добавляются persistent Trades, checkpoint, Hydration и Startup Recovery |
|
||||||
|
| `false` | `true` | Недопустимая конфигурация; startup завершается ошибкой |
|
||||||
|
|
||||||
|
При включённом Trade Stream обязательны явные `TRADE_STREAM_WS_URL`,
|
||||||
|
`TRADE_STREAM_SYMBOLS` и `EXCHANGE_BASE_URL`. Полный контракт настроек и
|
||||||
|
Docker secrets находится в
|
||||||
|
[эксплуатационном руководстве](../docs/operations/trades_feed_runtime.md).
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Из каталога `app`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m src.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибка включённого Trade Runtime, Storage или Startup Recovery является
|
||||||
|
фатальной и завершает всё приложение, а не оставляет Telegram polling в
|
||||||
|
частично работающем состоянии.
|
||||||
|
|
||||||
|
## Проверки разработчика
|
||||||
|
|
||||||
|
Обязательная отдельная проверка типов запускается из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check_python_types.sh
|
||||||
|
app/.venv/bin/python scripts/check_documentation_integrity.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый результат — `0 errors, 0 warnings` для Pyright и `issues=0`
|
||||||
|
для documentation gate. Default offline regression запускается из `app`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Default-набор исключает маркеры `integration`, `stress` и `live`. При
|
||||||
|
этом он включает `tests/static/test_python_type_gate.py`, поэтому полный
|
||||||
|
запуск дополнительно контролирует Pyright. Один целевой тест этот gate не
|
||||||
|
заменяет.
|
||||||
|
|
||||||
|
Назначение и безопасные команды opt-in наборов:
|
||||||
|
|
||||||
|
- `integration` — локальные сетевые и PostgreSQL-сценарии;
|
||||||
|
- `stress` — fixed stress и opt-in soak;
|
||||||
|
- `live` — только явно разрешённая проверка внешних Dzengi endpoints.
|
||||||
|
|
||||||
|
Параметры этих запусков не дублируются здесь и поддерживаются в
|
||||||
|
[runbook](../docs/operations/trades_feed_runtime.md).
|
||||||
|
|
||||||
|
## Структура и навигация
|
||||||
|
|
||||||
|
- `src/` — production-код;
|
||||||
|
- `tests/unit/` и `tests/static/` — default offline regression;
|
||||||
|
- `tests/integration/`, `tests/stress/`, `tests/live/` — opt-in проверки;
|
||||||
|
- `scripts/` и `tools/` — диагностические и исследовательские утилиты,
|
||||||
|
не automatic Production Runtime;
|
||||||
|
- [обзор структуры проекта](../docs/architecture/project_structure.md);
|
||||||
|
- [границы пакета Storage](src/storage/README.md);
|
||||||
|
- [архитектура Build 060.30](../docs/migrations/build_060_30_architecture.md).
|
||||||
|
|
||||||
|
Корневой `bootstrap_project.py` является историческим генератором
|
||||||
|
каркаса. Для текущего checkout его запускать не нужно.
|
||||||
|
|||||||
25
app/pytest.ini
Normal file
25
app/pytest.ini
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[pytest]
|
||||||
|
minversion = 8.0
|
||||||
|
|
||||||
|
testpaths =
|
||||||
|
tests
|
||||||
|
|
||||||
|
python_files =
|
||||||
|
test_*.py
|
||||||
|
|
||||||
|
python_classes =
|
||||||
|
Test*
|
||||||
|
|
||||||
|
python_functions =
|
||||||
|
test_*
|
||||||
|
|
||||||
|
addopts =
|
||||||
|
-ra
|
||||||
|
--strict-markers
|
||||||
|
-m
|
||||||
|
"not integration and not stress and not live"
|
||||||
|
|
||||||
|
markers =
|
||||||
|
integration: deterministic integration tests using local network endpoints
|
||||||
|
stress: explicit local stress and soak verification
|
||||||
|
live: opt-in verification against explicitly configured live endpoints
|
||||||
4
app/requirements-dev.txt
Normal file
4
app/requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
pytest==9.1.1
|
||||||
|
pyright[nodejs]==1.1.411
|
||||||
895
app/requirements.lock
Normal file
895
app/requirements.lock
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
# Сгенерированный hash-lock production-зависимостей; вручную не редактировать.
|
||||||
|
# Источник: app/requirements.txt; Python 3.12; universal resolution.
|
||||||
|
# Команда приведена в docs/operations/trades_feed_runtime.md.
|
||||||
|
|
||||||
|
aiofiles==24.1.0 \
|
||||||
|
--hash=sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c \
|
||||||
|
--hash=sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5
|
||||||
|
aiogram==3.13.1 \
|
||||||
|
--hash=sha256:148cbba15c2b6e0bc482aff6ae86848ed43e023b7102336bad17698242693000 \
|
||||||
|
--hash=sha256:2674640ae74501ae30e68a5354e03f12e10db3adbcbd475bb56904536288c9c9
|
||||||
|
aiohappyeyeballs==2.7.1 \
|
||||||
|
--hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
|
||||||
|
--hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
|
||||||
|
aiohttp==3.10.11 \
|
||||||
|
--hash=sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d \
|
||||||
|
--hash=sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc \
|
||||||
|
--hash=sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261 \
|
||||||
|
--hash=sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b \
|
||||||
|
--hash=sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625 \
|
||||||
|
--hash=sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39 \
|
||||||
|
--hash=sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f \
|
||||||
|
--hash=sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636 \
|
||||||
|
--hash=sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac \
|
||||||
|
--hash=sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217 \
|
||||||
|
--hash=sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9 \
|
||||||
|
--hash=sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f \
|
||||||
|
--hash=sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006 \
|
||||||
|
--hash=sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e \
|
||||||
|
--hash=sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9 \
|
||||||
|
--hash=sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a \
|
||||||
|
--hash=sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695 \
|
||||||
|
--hash=sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730 \
|
||||||
|
--hash=sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e \
|
||||||
|
--hash=sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382 \
|
||||||
|
--hash=sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc \
|
||||||
|
--hash=sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00 \
|
||||||
|
--hash=sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8 \
|
||||||
|
--hash=sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658 \
|
||||||
|
--hash=sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339 \
|
||||||
|
--hash=sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a \
|
||||||
|
--hash=sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a \
|
||||||
|
--hash=sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e \
|
||||||
|
--hash=sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d \
|
||||||
|
--hash=sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e \
|
||||||
|
--hash=sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca \
|
||||||
|
--hash=sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038 \
|
||||||
|
--hash=sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461 \
|
||||||
|
--hash=sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829 \
|
||||||
|
--hash=sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31 \
|
||||||
|
--hash=sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4 \
|
||||||
|
--hash=sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127 \
|
||||||
|
--hash=sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3 \
|
||||||
|
--hash=sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97 \
|
||||||
|
--hash=sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710 \
|
||||||
|
--hash=sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2 \
|
||||||
|
--hash=sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674 \
|
||||||
|
--hash=sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb \
|
||||||
|
--hash=sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03 \
|
||||||
|
--hash=sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07 \
|
||||||
|
--hash=sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9 \
|
||||||
|
--hash=sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6 \
|
||||||
|
--hash=sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f \
|
||||||
|
--hash=sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298 \
|
||||||
|
--hash=sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b \
|
||||||
|
--hash=sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01 \
|
||||||
|
--hash=sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9 \
|
||||||
|
--hash=sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7 \
|
||||||
|
--hash=sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413 \
|
||||||
|
--hash=sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec \
|
||||||
|
--hash=sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7 \
|
||||||
|
--hash=sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4 \
|
||||||
|
--hash=sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb \
|
||||||
|
--hash=sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519 \
|
||||||
|
--hash=sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d \
|
||||||
|
--hash=sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6 \
|
||||||
|
--hash=sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27 \
|
||||||
|
--hash=sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1 \
|
||||||
|
--hash=sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087 \
|
||||||
|
--hash=sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067 \
|
||||||
|
--hash=sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f \
|
||||||
|
--hash=sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95 \
|
||||||
|
--hash=sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa \
|
||||||
|
--hash=sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa \
|
||||||
|
--hash=sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16 \
|
||||||
|
--hash=sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106 \
|
||||||
|
--hash=sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92 \
|
||||||
|
--hash=sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777 \
|
||||||
|
--hash=sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa \
|
||||||
|
--hash=sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e \
|
||||||
|
--hash=sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7 \
|
||||||
|
--hash=sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115 \
|
||||||
|
--hash=sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120 \
|
||||||
|
--hash=sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d \
|
||||||
|
--hash=sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b \
|
||||||
|
--hash=sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a \
|
||||||
|
--hash=sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725 \
|
||||||
|
--hash=sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a \
|
||||||
|
--hash=sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d \
|
||||||
|
--hash=sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288 \
|
||||||
|
--hash=sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8 \
|
||||||
|
--hash=sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385 \
|
||||||
|
--hash=sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24 \
|
||||||
|
--hash=sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71 \
|
||||||
|
--hash=sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138 \
|
||||||
|
--hash=sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177
|
||||||
|
aiosignal==1.4.0 \
|
||||||
|
--hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \
|
||||||
|
--hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7
|
||||||
|
annotated-types==0.8.0 \
|
||||||
|
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
|
||||||
|
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
||||||
|
attrs==26.1.0 \
|
||||||
|
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
|
||||||
|
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
|
||||||
|
certifi==2026.7.22 \
|
||||||
|
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
|
||||||
|
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
|
||||||
|
et-xmlfile==2.0.0 \
|
||||||
|
--hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \
|
||||||
|
--hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54
|
||||||
|
frozenlist==1.8.0 \
|
||||||
|
--hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \
|
||||||
|
--hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \
|
||||||
|
--hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \
|
||||||
|
--hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \
|
||||||
|
--hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \
|
||||||
|
--hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \
|
||||||
|
--hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \
|
||||||
|
--hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \
|
||||||
|
--hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \
|
||||||
|
--hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \
|
||||||
|
--hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \
|
||||||
|
--hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \
|
||||||
|
--hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \
|
||||||
|
--hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \
|
||||||
|
--hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \
|
||||||
|
--hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \
|
||||||
|
--hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \
|
||||||
|
--hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \
|
||||||
|
--hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \
|
||||||
|
--hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \
|
||||||
|
--hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \
|
||||||
|
--hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \
|
||||||
|
--hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \
|
||||||
|
--hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \
|
||||||
|
--hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \
|
||||||
|
--hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \
|
||||||
|
--hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \
|
||||||
|
--hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \
|
||||||
|
--hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \
|
||||||
|
--hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \
|
||||||
|
--hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \
|
||||||
|
--hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \
|
||||||
|
--hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \
|
||||||
|
--hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \
|
||||||
|
--hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \
|
||||||
|
--hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \
|
||||||
|
--hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \
|
||||||
|
--hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \
|
||||||
|
--hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \
|
||||||
|
--hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \
|
||||||
|
--hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \
|
||||||
|
--hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \
|
||||||
|
--hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \
|
||||||
|
--hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \
|
||||||
|
--hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \
|
||||||
|
--hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \
|
||||||
|
--hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \
|
||||||
|
--hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \
|
||||||
|
--hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \
|
||||||
|
--hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \
|
||||||
|
--hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \
|
||||||
|
--hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \
|
||||||
|
--hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \
|
||||||
|
--hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \
|
||||||
|
--hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \
|
||||||
|
--hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \
|
||||||
|
--hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \
|
||||||
|
--hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \
|
||||||
|
--hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \
|
||||||
|
--hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \
|
||||||
|
--hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \
|
||||||
|
--hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \
|
||||||
|
--hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \
|
||||||
|
--hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \
|
||||||
|
--hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \
|
||||||
|
--hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \
|
||||||
|
--hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \
|
||||||
|
--hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \
|
||||||
|
--hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \
|
||||||
|
--hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \
|
||||||
|
--hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \
|
||||||
|
--hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \
|
||||||
|
--hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \
|
||||||
|
--hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \
|
||||||
|
--hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \
|
||||||
|
--hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \
|
||||||
|
--hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \
|
||||||
|
--hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \
|
||||||
|
--hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \
|
||||||
|
--hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \
|
||||||
|
--hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \
|
||||||
|
--hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \
|
||||||
|
--hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \
|
||||||
|
--hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \
|
||||||
|
--hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \
|
||||||
|
--hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \
|
||||||
|
--hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \
|
||||||
|
--hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \
|
||||||
|
--hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \
|
||||||
|
--hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \
|
||||||
|
--hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \
|
||||||
|
--hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \
|
||||||
|
--hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \
|
||||||
|
--hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \
|
||||||
|
--hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \
|
||||||
|
--hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \
|
||||||
|
--hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \
|
||||||
|
--hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \
|
||||||
|
--hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \
|
||||||
|
--hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \
|
||||||
|
--hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \
|
||||||
|
--hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \
|
||||||
|
--hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \
|
||||||
|
--hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \
|
||||||
|
--hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \
|
||||||
|
--hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \
|
||||||
|
--hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \
|
||||||
|
--hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \
|
||||||
|
--hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \
|
||||||
|
--hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \
|
||||||
|
--hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \
|
||||||
|
--hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \
|
||||||
|
--hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \
|
||||||
|
--hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \
|
||||||
|
--hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \
|
||||||
|
--hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \
|
||||||
|
--hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \
|
||||||
|
--hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \
|
||||||
|
--hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \
|
||||||
|
--hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \
|
||||||
|
--hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \
|
||||||
|
--hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \
|
||||||
|
--hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \
|
||||||
|
--hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \
|
||||||
|
--hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \
|
||||||
|
--hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \
|
||||||
|
--hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \
|
||||||
|
--hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \
|
||||||
|
--hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \
|
||||||
|
--hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd
|
||||||
|
idna==3.18 \
|
||||||
|
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
||||||
|
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
||||||
|
magic-filter==1.0.12 \
|
||||||
|
--hash=sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9 \
|
||||||
|
--hash=sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6
|
||||||
|
multidict==6.7.1 \
|
||||||
|
--hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \
|
||||||
|
--hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \
|
||||||
|
--hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \
|
||||||
|
--hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \
|
||||||
|
--hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \
|
||||||
|
--hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \
|
||||||
|
--hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \
|
||||||
|
--hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \
|
||||||
|
--hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \
|
||||||
|
--hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \
|
||||||
|
--hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \
|
||||||
|
--hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \
|
||||||
|
--hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \
|
||||||
|
--hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \
|
||||||
|
--hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \
|
||||||
|
--hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \
|
||||||
|
--hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \
|
||||||
|
--hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \
|
||||||
|
--hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \
|
||||||
|
--hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \
|
||||||
|
--hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \
|
||||||
|
--hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \
|
||||||
|
--hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \
|
||||||
|
--hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \
|
||||||
|
--hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \
|
||||||
|
--hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \
|
||||||
|
--hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \
|
||||||
|
--hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \
|
||||||
|
--hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \
|
||||||
|
--hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \
|
||||||
|
--hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \
|
||||||
|
--hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \
|
||||||
|
--hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \
|
||||||
|
--hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \
|
||||||
|
--hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \
|
||||||
|
--hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \
|
||||||
|
--hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \
|
||||||
|
--hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \
|
||||||
|
--hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \
|
||||||
|
--hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \
|
||||||
|
--hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \
|
||||||
|
--hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \
|
||||||
|
--hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \
|
||||||
|
--hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \
|
||||||
|
--hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \
|
||||||
|
--hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \
|
||||||
|
--hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \
|
||||||
|
--hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \
|
||||||
|
--hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \
|
||||||
|
--hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \
|
||||||
|
--hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \
|
||||||
|
--hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \
|
||||||
|
--hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \
|
||||||
|
--hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \
|
||||||
|
--hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \
|
||||||
|
--hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \
|
||||||
|
--hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \
|
||||||
|
--hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \
|
||||||
|
--hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \
|
||||||
|
--hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \
|
||||||
|
--hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \
|
||||||
|
--hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \
|
||||||
|
--hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \
|
||||||
|
--hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \
|
||||||
|
--hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \
|
||||||
|
--hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \
|
||||||
|
--hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \
|
||||||
|
--hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \
|
||||||
|
--hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \
|
||||||
|
--hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \
|
||||||
|
--hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \
|
||||||
|
--hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \
|
||||||
|
--hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \
|
||||||
|
--hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \
|
||||||
|
--hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \
|
||||||
|
--hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \
|
||||||
|
--hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \
|
||||||
|
--hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \
|
||||||
|
--hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \
|
||||||
|
--hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \
|
||||||
|
--hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \
|
||||||
|
--hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \
|
||||||
|
--hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \
|
||||||
|
--hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \
|
||||||
|
--hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \
|
||||||
|
--hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \
|
||||||
|
--hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \
|
||||||
|
--hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \
|
||||||
|
--hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \
|
||||||
|
--hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \
|
||||||
|
--hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \
|
||||||
|
--hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \
|
||||||
|
--hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \
|
||||||
|
--hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \
|
||||||
|
--hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \
|
||||||
|
--hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \
|
||||||
|
--hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \
|
||||||
|
--hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \
|
||||||
|
--hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \
|
||||||
|
--hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \
|
||||||
|
--hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \
|
||||||
|
--hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \
|
||||||
|
--hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \
|
||||||
|
--hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \
|
||||||
|
--hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \
|
||||||
|
--hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \
|
||||||
|
--hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \
|
||||||
|
--hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \
|
||||||
|
--hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \
|
||||||
|
--hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \
|
||||||
|
--hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \
|
||||||
|
--hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \
|
||||||
|
--hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \
|
||||||
|
--hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \
|
||||||
|
--hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \
|
||||||
|
--hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \
|
||||||
|
--hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \
|
||||||
|
--hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \
|
||||||
|
--hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \
|
||||||
|
--hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \
|
||||||
|
--hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \
|
||||||
|
--hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \
|
||||||
|
--hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \
|
||||||
|
--hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \
|
||||||
|
--hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \
|
||||||
|
--hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \
|
||||||
|
--hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \
|
||||||
|
--hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \
|
||||||
|
--hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \
|
||||||
|
--hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \
|
||||||
|
--hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \
|
||||||
|
--hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \
|
||||||
|
--hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \
|
||||||
|
--hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \
|
||||||
|
--hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \
|
||||||
|
--hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \
|
||||||
|
--hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \
|
||||||
|
--hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \
|
||||||
|
--hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \
|
||||||
|
--hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \
|
||||||
|
--hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \
|
||||||
|
--hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \
|
||||||
|
--hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \
|
||||||
|
--hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \
|
||||||
|
--hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \
|
||||||
|
--hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19
|
||||||
|
openpyxl==3.1.5 \
|
||||||
|
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
|
||||||
|
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
|
||||||
|
propcache==0.5.2 \
|
||||||
|
--hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \
|
||||||
|
--hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \
|
||||||
|
--hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \
|
||||||
|
--hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \
|
||||||
|
--hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \
|
||||||
|
--hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \
|
||||||
|
--hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \
|
||||||
|
--hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \
|
||||||
|
--hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \
|
||||||
|
--hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \
|
||||||
|
--hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \
|
||||||
|
--hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \
|
||||||
|
--hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \
|
||||||
|
--hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \
|
||||||
|
--hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \
|
||||||
|
--hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \
|
||||||
|
--hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \
|
||||||
|
--hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \
|
||||||
|
--hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \
|
||||||
|
--hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \
|
||||||
|
--hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \
|
||||||
|
--hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \
|
||||||
|
--hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \
|
||||||
|
--hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \
|
||||||
|
--hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \
|
||||||
|
--hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \
|
||||||
|
--hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \
|
||||||
|
--hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \
|
||||||
|
--hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \
|
||||||
|
--hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \
|
||||||
|
--hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \
|
||||||
|
--hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \
|
||||||
|
--hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \
|
||||||
|
--hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \
|
||||||
|
--hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \
|
||||||
|
--hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \
|
||||||
|
--hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \
|
||||||
|
--hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \
|
||||||
|
--hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \
|
||||||
|
--hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \
|
||||||
|
--hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \
|
||||||
|
--hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \
|
||||||
|
--hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \
|
||||||
|
--hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \
|
||||||
|
--hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \
|
||||||
|
--hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \
|
||||||
|
--hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \
|
||||||
|
--hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \
|
||||||
|
--hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \
|
||||||
|
--hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \
|
||||||
|
--hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \
|
||||||
|
--hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \
|
||||||
|
--hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \
|
||||||
|
--hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \
|
||||||
|
--hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \
|
||||||
|
--hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \
|
||||||
|
--hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \
|
||||||
|
--hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \
|
||||||
|
--hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \
|
||||||
|
--hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \
|
||||||
|
--hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \
|
||||||
|
--hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \
|
||||||
|
--hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \
|
||||||
|
--hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \
|
||||||
|
--hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \
|
||||||
|
--hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \
|
||||||
|
--hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \
|
||||||
|
--hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \
|
||||||
|
--hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \
|
||||||
|
--hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \
|
||||||
|
--hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \
|
||||||
|
--hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \
|
||||||
|
--hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \
|
||||||
|
--hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \
|
||||||
|
--hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \
|
||||||
|
--hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \
|
||||||
|
--hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \
|
||||||
|
--hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \
|
||||||
|
--hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \
|
||||||
|
--hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \
|
||||||
|
--hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \
|
||||||
|
--hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \
|
||||||
|
--hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \
|
||||||
|
--hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \
|
||||||
|
--hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \
|
||||||
|
--hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \
|
||||||
|
--hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \
|
||||||
|
--hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \
|
||||||
|
--hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \
|
||||||
|
--hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \
|
||||||
|
--hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \
|
||||||
|
--hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \
|
||||||
|
--hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \
|
||||||
|
--hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \
|
||||||
|
--hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \
|
||||||
|
--hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \
|
||||||
|
--hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \
|
||||||
|
--hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \
|
||||||
|
--hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \
|
||||||
|
--hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \
|
||||||
|
--hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \
|
||||||
|
--hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \
|
||||||
|
--hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \
|
||||||
|
--hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \
|
||||||
|
--hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \
|
||||||
|
--hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \
|
||||||
|
--hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \
|
||||||
|
--hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \
|
||||||
|
--hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \
|
||||||
|
--hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \
|
||||||
|
--hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \
|
||||||
|
--hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \
|
||||||
|
--hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \
|
||||||
|
--hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \
|
||||||
|
--hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \
|
||||||
|
--hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \
|
||||||
|
--hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \
|
||||||
|
--hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \
|
||||||
|
--hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \
|
||||||
|
--hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \
|
||||||
|
--hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6
|
||||||
|
psycopg==3.2.9 \
|
||||||
|
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
|
||||||
|
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
|
||||||
|
psycopg-binary==3.2.9 ; implementation_name != 'pypy' \
|
||||||
|
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
|
||||||
|
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
|
||||||
|
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
|
||||||
|
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
|
||||||
|
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
|
||||||
|
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
|
||||||
|
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
|
||||||
|
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
|
||||||
|
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
|
||||||
|
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
|
||||||
|
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
|
||||||
|
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
|
||||||
|
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
|
||||||
|
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
|
||||||
|
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
|
||||||
|
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
|
||||||
|
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
|
||||||
|
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
|
||||||
|
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
|
||||||
|
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
|
||||||
|
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
|
||||||
|
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
|
||||||
|
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
|
||||||
|
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
|
||||||
|
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
|
||||||
|
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
|
||||||
|
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
|
||||||
|
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
|
||||||
|
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
|
||||||
|
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
|
||||||
|
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
|
||||||
|
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
|
||||||
|
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
|
||||||
|
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
|
||||||
|
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
|
||||||
|
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
|
||||||
|
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
|
||||||
|
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
|
||||||
|
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
|
||||||
|
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
|
||||||
|
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
|
||||||
|
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
|
||||||
|
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
|
||||||
|
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
|
||||||
|
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
|
||||||
|
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
|
||||||
|
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
|
||||||
|
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
|
||||||
|
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
|
||||||
|
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
|
||||||
|
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
|
||||||
|
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
|
||||||
|
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
|
||||||
|
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
|
||||||
|
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
|
||||||
|
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
|
||||||
|
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
|
||||||
|
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
|
||||||
|
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
|
||||||
|
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
|
||||||
|
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
|
||||||
|
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
|
||||||
|
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
|
||||||
|
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
|
||||||
|
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
|
||||||
|
psycopg-pool==3.3.1 \
|
||||||
|
--hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \
|
||||||
|
--hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c
|
||||||
|
pydantic==2.9.2 \
|
||||||
|
--hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \
|
||||||
|
--hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12
|
||||||
|
pydantic-core==2.23.4 \
|
||||||
|
--hash=sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36 \
|
||||||
|
--hash=sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05 \
|
||||||
|
--hash=sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071 \
|
||||||
|
--hash=sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327 \
|
||||||
|
--hash=sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c \
|
||||||
|
--hash=sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36 \
|
||||||
|
--hash=sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29 \
|
||||||
|
--hash=sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744 \
|
||||||
|
--hash=sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d \
|
||||||
|
--hash=sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec \
|
||||||
|
--hash=sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e \
|
||||||
|
--hash=sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e \
|
||||||
|
--hash=sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577 \
|
||||||
|
--hash=sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232 \
|
||||||
|
--hash=sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863 \
|
||||||
|
--hash=sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6 \
|
||||||
|
--hash=sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368 \
|
||||||
|
--hash=sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480 \
|
||||||
|
--hash=sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2 \
|
||||||
|
--hash=sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2 \
|
||||||
|
--hash=sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6 \
|
||||||
|
--hash=sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769 \
|
||||||
|
--hash=sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d \
|
||||||
|
--hash=sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 \
|
||||||
|
--hash=sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84 \
|
||||||
|
--hash=sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 \
|
||||||
|
--hash=sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271 \
|
||||||
|
--hash=sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5 \
|
||||||
|
--hash=sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb \
|
||||||
|
--hash=sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13 \
|
||||||
|
--hash=sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323 \
|
||||||
|
--hash=sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556 \
|
||||||
|
--hash=sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665 \
|
||||||
|
--hash=sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef \
|
||||||
|
--hash=sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb \
|
||||||
|
--hash=sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119 \
|
||||||
|
--hash=sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126 \
|
||||||
|
--hash=sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510 \
|
||||||
|
--hash=sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b \
|
||||||
|
--hash=sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87 \
|
||||||
|
--hash=sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f \
|
||||||
|
--hash=sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc \
|
||||||
|
--hash=sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8 \
|
||||||
|
--hash=sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21 \
|
||||||
|
--hash=sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f \
|
||||||
|
--hash=sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6 \
|
||||||
|
--hash=sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658 \
|
||||||
|
--hash=sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b \
|
||||||
|
--hash=sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3 \
|
||||||
|
--hash=sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb \
|
||||||
|
--hash=sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59 \
|
||||||
|
--hash=sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24 \
|
||||||
|
--hash=sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9 \
|
||||||
|
--hash=sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3 \
|
||||||
|
--hash=sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd \
|
||||||
|
--hash=sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753 \
|
||||||
|
--hash=sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55 \
|
||||||
|
--hash=sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad \
|
||||||
|
--hash=sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a \
|
||||||
|
--hash=sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605 \
|
||||||
|
--hash=sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e \
|
||||||
|
--hash=sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b \
|
||||||
|
--hash=sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433 \
|
||||||
|
--hash=sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8 \
|
||||||
|
--hash=sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07 \
|
||||||
|
--hash=sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728 \
|
||||||
|
--hash=sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0 \
|
||||||
|
--hash=sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327 \
|
||||||
|
--hash=sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555 \
|
||||||
|
--hash=sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64 \
|
||||||
|
--hash=sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6 \
|
||||||
|
--hash=sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea \
|
||||||
|
--hash=sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b \
|
||||||
|
--hash=sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df \
|
||||||
|
--hash=sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e \
|
||||||
|
--hash=sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd \
|
||||||
|
--hash=sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068 \
|
||||||
|
--hash=sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3 \
|
||||||
|
--hash=sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040 \
|
||||||
|
--hash=sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12 \
|
||||||
|
--hash=sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916 \
|
||||||
|
--hash=sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f \
|
||||||
|
--hash=sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f \
|
||||||
|
--hash=sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801 \
|
||||||
|
--hash=sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231 \
|
||||||
|
--hash=sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5 \
|
||||||
|
--hash=sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8 \
|
||||||
|
--hash=sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee \
|
||||||
|
--hash=sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607
|
||||||
|
python-dotenv==1.0.1 \
|
||||||
|
--hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \
|
||||||
|
--hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a
|
||||||
|
typing-extensions==4.16.0 \
|
||||||
|
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
||||||
|
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
||||||
|
tzdata==2026.3 ; sys_platform == 'win32' \
|
||||||
|
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
|
||||||
|
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
|
||||||
|
websockets==13.1 \
|
||||||
|
--hash=sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a \
|
||||||
|
--hash=sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54 \
|
||||||
|
--hash=sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23 \
|
||||||
|
--hash=sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7 \
|
||||||
|
--hash=sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135 \
|
||||||
|
--hash=sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700 \
|
||||||
|
--hash=sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf \
|
||||||
|
--hash=sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5 \
|
||||||
|
--hash=sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e \
|
||||||
|
--hash=sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c \
|
||||||
|
--hash=sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02 \
|
||||||
|
--hash=sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a \
|
||||||
|
--hash=sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418 \
|
||||||
|
--hash=sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f \
|
||||||
|
--hash=sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3 \
|
||||||
|
--hash=sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68 \
|
||||||
|
--hash=sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978 \
|
||||||
|
--hash=sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20 \
|
||||||
|
--hash=sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295 \
|
||||||
|
--hash=sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b \
|
||||||
|
--hash=sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6 \
|
||||||
|
--hash=sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb \
|
||||||
|
--hash=sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a \
|
||||||
|
--hash=sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa \
|
||||||
|
--hash=sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0 \
|
||||||
|
--hash=sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a \
|
||||||
|
--hash=sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238 \
|
||||||
|
--hash=sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c \
|
||||||
|
--hash=sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084 \
|
||||||
|
--hash=sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19 \
|
||||||
|
--hash=sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d \
|
||||||
|
--hash=sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7 \
|
||||||
|
--hash=sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9 \
|
||||||
|
--hash=sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79 \
|
||||||
|
--hash=sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96 \
|
||||||
|
--hash=sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6 \
|
||||||
|
--hash=sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe \
|
||||||
|
--hash=sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842 \
|
||||||
|
--hash=sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa \
|
||||||
|
--hash=sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3 \
|
||||||
|
--hash=sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d \
|
||||||
|
--hash=sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51 \
|
||||||
|
--hash=sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7 \
|
||||||
|
--hash=sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09 \
|
||||||
|
--hash=sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096 \
|
||||||
|
--hash=sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9 \
|
||||||
|
--hash=sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b \
|
||||||
|
--hash=sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5 \
|
||||||
|
--hash=sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678 \
|
||||||
|
--hash=sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea \
|
||||||
|
--hash=sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d \
|
||||||
|
--hash=sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49 \
|
||||||
|
--hash=sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc \
|
||||||
|
--hash=sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5 \
|
||||||
|
--hash=sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027 \
|
||||||
|
--hash=sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0 \
|
||||||
|
--hash=sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878 \
|
||||||
|
--hash=sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c \
|
||||||
|
--hash=sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa \
|
||||||
|
--hash=sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f \
|
||||||
|
--hash=sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6 \
|
||||||
|
--hash=sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2 \
|
||||||
|
--hash=sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf \
|
||||||
|
--hash=sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708 \
|
||||||
|
--hash=sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6 \
|
||||||
|
--hash=sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f \
|
||||||
|
--hash=sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd \
|
||||||
|
--hash=sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2 \
|
||||||
|
--hash=sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d \
|
||||||
|
--hash=sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7 \
|
||||||
|
--hash=sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f \
|
||||||
|
--hash=sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5 \
|
||||||
|
--hash=sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6 \
|
||||||
|
--hash=sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557 \
|
||||||
|
--hash=sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14 \
|
||||||
|
--hash=sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7 \
|
||||||
|
--hash=sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd \
|
||||||
|
--hash=sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c \
|
||||||
|
--hash=sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17 \
|
||||||
|
--hash=sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23 \
|
||||||
|
--hash=sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db \
|
||||||
|
--hash=sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6 \
|
||||||
|
--hash=sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d \
|
||||||
|
--hash=sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9 \
|
||||||
|
--hash=sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee \
|
||||||
|
--hash=sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6
|
||||||
|
yarl==1.24.5 \
|
||||||
|
--hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \
|
||||||
|
--hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \
|
||||||
|
--hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \
|
||||||
|
--hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \
|
||||||
|
--hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \
|
||||||
|
--hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \
|
||||||
|
--hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \
|
||||||
|
--hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \
|
||||||
|
--hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \
|
||||||
|
--hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \
|
||||||
|
--hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \
|
||||||
|
--hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \
|
||||||
|
--hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \
|
||||||
|
--hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \
|
||||||
|
--hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \
|
||||||
|
--hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \
|
||||||
|
--hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \
|
||||||
|
--hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \
|
||||||
|
--hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \
|
||||||
|
--hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \
|
||||||
|
--hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \
|
||||||
|
--hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \
|
||||||
|
--hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \
|
||||||
|
--hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \
|
||||||
|
--hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \
|
||||||
|
--hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \
|
||||||
|
--hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \
|
||||||
|
--hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \
|
||||||
|
--hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \
|
||||||
|
--hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \
|
||||||
|
--hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \
|
||||||
|
--hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \
|
||||||
|
--hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \
|
||||||
|
--hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \
|
||||||
|
--hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \
|
||||||
|
--hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \
|
||||||
|
--hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \
|
||||||
|
--hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \
|
||||||
|
--hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \
|
||||||
|
--hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \
|
||||||
|
--hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \
|
||||||
|
--hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \
|
||||||
|
--hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \
|
||||||
|
--hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \
|
||||||
|
--hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \
|
||||||
|
--hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \
|
||||||
|
--hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \
|
||||||
|
--hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \
|
||||||
|
--hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \
|
||||||
|
--hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \
|
||||||
|
--hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \
|
||||||
|
--hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \
|
||||||
|
--hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \
|
||||||
|
--hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \
|
||||||
|
--hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \
|
||||||
|
--hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \
|
||||||
|
--hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \
|
||||||
|
--hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \
|
||||||
|
--hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \
|
||||||
|
--hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \
|
||||||
|
--hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \
|
||||||
|
--hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \
|
||||||
|
--hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \
|
||||||
|
--hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \
|
||||||
|
--hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \
|
||||||
|
--hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \
|
||||||
|
--hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \
|
||||||
|
--hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \
|
||||||
|
--hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \
|
||||||
|
--hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \
|
||||||
|
--hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \
|
||||||
|
--hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \
|
||||||
|
--hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \
|
||||||
|
--hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \
|
||||||
|
--hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \
|
||||||
|
--hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \
|
||||||
|
--hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \
|
||||||
|
--hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \
|
||||||
|
--hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \
|
||||||
|
--hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \
|
||||||
|
--hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \
|
||||||
|
--hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \
|
||||||
|
--hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \
|
||||||
|
--hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \
|
||||||
|
--hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \
|
||||||
|
--hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \
|
||||||
|
--hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \
|
||||||
|
--hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \
|
||||||
|
--hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \
|
||||||
|
--hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \
|
||||||
|
--hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \
|
||||||
|
--hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \
|
||||||
|
--hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \
|
||||||
|
--hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \
|
||||||
|
--hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \
|
||||||
|
--hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \
|
||||||
|
--hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \
|
||||||
|
--hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \
|
||||||
|
--hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \
|
||||||
|
--hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \
|
||||||
|
--hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \
|
||||||
|
--hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \
|
||||||
|
--hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \
|
||||||
|
--hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
aiogram==3.13.1
|
aiogram==3.13.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
psycopg[binary]==3.2.9
|
psycopg[binary]==3.2.9
|
||||||
|
psycopg-pool==3.3.1
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
328
app/scripts/check_ohlc_websocket.py
Normal file
328
app/scripts/check_ohlc_websocket.py
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# app/scripts/check_ohlc_websocket.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
from websockets.typing import Subprotocol
|
||||||
|
|
||||||
|
from src.core.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
SYMBOLS = (
|
||||||
|
"BTC/USD_LEVERAGE",
|
||||||
|
)
|
||||||
|
|
||||||
|
INTERVALS = (
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
)
|
||||||
|
|
||||||
|
CANDLE_TYPE = "classic"
|
||||||
|
|
||||||
|
MAX_MESSAGES = 30
|
||||||
|
TOTAL_TIMEOUT_SECONDS = 300.0
|
||||||
|
RECEIVE_TIMEOUT_SECONDS = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def build_ws_url() -> str:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
raw_url = settings.exchange_ws_url or settings.exchange_base_url
|
||||||
|
|
||||||
|
if raw_url.startswith("https://"):
|
||||||
|
raw_url = raw_url.replace("https://", "wss://", 1)
|
||||||
|
elif raw_url.startswith("http://"):
|
||||||
|
raw_url = raw_url.replace("http://", "ws://", 1)
|
||||||
|
|
||||||
|
raw_url = raw_url.rstrip("/")
|
||||||
|
|
||||||
|
if raw_url.endswith("/connect"):
|
||||||
|
return raw_url
|
||||||
|
|
||||||
|
return f"{raw_url}/connect"
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers() -> dict[str, str]:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Origin": settings.exchange_base_url.rstrip("/"),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.exchange_api_key:
|
||||||
|
headers["X-MBX-APIKEY"] = settings.exchange_api_key
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def build_subscribe_request(
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
intervals: tuple[str, ...],
|
||||||
|
candle_type: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"correlationId": str(uuid4()),
|
||||||
|
"destination": "OHLCMarketData.subscribe",
|
||||||
|
"payload": {
|
||||||
|
"intervals": list(intervals),
|
||||||
|
"symbols": list(symbols),
|
||||||
|
"type": candle_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def message_to_text(
|
||||||
|
raw_message: str | bytes | bytearray | memoryview,
|
||||||
|
) -> str:
|
||||||
|
if isinstance(raw_message, str):
|
||||||
|
return raw_message
|
||||||
|
|
||||||
|
if isinstance(raw_message, memoryview):
|
||||||
|
return raw_message.tobytes().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
return bytes(raw_message).decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_document(
|
||||||
|
raw_message: str | bytes | bytearray | memoryview,
|
||||||
|
) -> object | None:
|
||||||
|
text = message_to_text(raw_message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_json(
|
||||||
|
raw_message: str | bytes | bytearray | memoryview,
|
||||||
|
) -> str:
|
||||||
|
text = message_to_text(raw_message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
document,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def destination_from_document(document: object) -> str | None:
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
direct_destination = document.get("destination")
|
||||||
|
|
||||||
|
if isinstance(direct_destination, str):
|
||||||
|
return direct_destination
|
||||||
|
|
||||||
|
upper_destination = document.get("Destination")
|
||||||
|
|
||||||
|
if isinstance(upper_destination, str):
|
||||||
|
return upper_destination
|
||||||
|
|
||||||
|
payload = document.get("payload")
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
nested_destination = payload.get("destination")
|
||||||
|
|
||||||
|
if isinstance(nested_destination, str):
|
||||||
|
return nested_destination
|
||||||
|
|
||||||
|
nested_upper_destination = payload.get("Destination")
|
||||||
|
|
||||||
|
if isinstance(nested_upper_destination, str):
|
||||||
|
return nested_upper_destination
|
||||||
|
|
||||||
|
upper_payload = document.get("Payload")
|
||||||
|
|
||||||
|
if isinstance(upper_payload, dict):
|
||||||
|
nested_destination = upper_payload.get("destination")
|
||||||
|
|
||||||
|
if isinstance(nested_destination, str):
|
||||||
|
return nested_destination
|
||||||
|
|
||||||
|
nested_upper_destination = upper_payload.get("Destination")
|
||||||
|
|
||||||
|
if isinstance(nested_upper_destination, str):
|
||||||
|
return nested_upper_destination
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_ohlc_event(document: object) -> bool:
|
||||||
|
destination = destination_from_document(document)
|
||||||
|
|
||||||
|
if destination is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return destination.lower() == "ohlc.event"
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_seconds(
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
started_at: float,
|
||||||
|
) -> float:
|
||||||
|
return max(0.0, loop.time() - started_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_ohlc() -> None:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
ws_url = build_ws_url()
|
||||||
|
headers = build_headers()
|
||||||
|
|
||||||
|
request = build_subscribe_request(
|
||||||
|
SYMBOLS,
|
||||||
|
INTERVALS,
|
||||||
|
CANDLE_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"WebSocket URL: {ws_url}")
|
||||||
|
print(f"Symbols: {', '.join(SYMBOLS)}")
|
||||||
|
print(f"Intervals: {', '.join(INTERVALS)}")
|
||||||
|
print(f"Candle type: {CANDLE_TYPE}")
|
||||||
|
print(f"Maximum messages: {MAX_MESSAGES}")
|
||||||
|
print(f"Total timeout: {TOTAL_TIMEOUT_SECONDS:.0f} seconds")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("Subscription request:")
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
request,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
async with websockets.connect(
|
||||||
|
ws_url,
|
||||||
|
extra_headers=headers,
|
||||||
|
subprotocols=[Subprotocol("json")],
|
||||||
|
ping_interval=20,
|
||||||
|
ping_timeout=float(settings.exchange_timeout_sec),
|
||||||
|
open_timeout=float(settings.exchange_timeout_sec),
|
||||||
|
close_timeout=float(settings.exchange_timeout_sec),
|
||||||
|
) as websocket:
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
request,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Subscription request sent.")
|
||||||
|
print("Waiting for acknowledgement and OHLC events...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
started_at = loop.time()
|
||||||
|
deadline = started_at + TOTAL_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
received_count = 0
|
||||||
|
ohlc_event_count = 0
|
||||||
|
timeout_count = 0
|
||||||
|
|
||||||
|
while received_count < MAX_MESSAGES:
|
||||||
|
remaining_seconds = deadline - loop.time()
|
||||||
|
|
||||||
|
if remaining_seconds <= 0:
|
||||||
|
print(
|
||||||
|
"Total timeout reached after "
|
||||||
|
f"{elapsed_seconds(loop, started_at):.1f} seconds."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
receive_timeout = min(
|
||||||
|
RECEIVE_TIMEOUT_SECONDS,
|
||||||
|
remaining_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_message = await asyncio.wait_for(
|
||||||
|
websocket.recv(),
|
||||||
|
timeout=receive_timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
timeout_count += 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
"No OHLC event yet; connection is alive. "
|
||||||
|
f"Elapsed: {elapsed_seconds(loop, started_at):.1f}s, "
|
||||||
|
f"remaining: {max(0.0, remaining_seconds):.1f}s."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
raw_message,
|
||||||
|
(str, bytes, bytearray, memoryview),
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
"Skipped unsupported WebSocket message type: "
|
||||||
|
f"{type(raw_message).__name__}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
received_count += 1
|
||||||
|
document = decode_document(raw_message)
|
||||||
|
|
||||||
|
if is_ohlc_event(document):
|
||||||
|
ohlc_event_count += 1
|
||||||
|
message_kind = "OHLC EVENT"
|
||||||
|
else:
|
||||||
|
message_kind = "CONTROL / ACKNOWLEDGEMENT"
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print(
|
||||||
|
f"Message #{received_count} — {message_kind} — "
|
||||||
|
f"elapsed {elapsed_seconds(loop, started_at):.1f}s"
|
||||||
|
)
|
||||||
|
print(format_json(raw_message))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("Diagnostic summary")
|
||||||
|
print(f"Received messages: {received_count}")
|
||||||
|
print(f"OHLC events: {ohlc_event_count}")
|
||||||
|
print(f"Receive timeouts: {timeout_count}")
|
||||||
|
print(
|
||||||
|
"Elapsed time: "
|
||||||
|
f"{elapsed_seconds(loop, started_at):.1f} seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
asyncio.run(receive_ohlc())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
print("Stopped by user.")
|
||||||
|
except Exception as exc:
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"WebSocket diagnostic failed: "
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1664
app/scripts/check_trade_backfill_api.py
Normal file
1664
app/scripts/check_trade_backfill_api.py
Normal file
File diff suppressed because it is too large
Load Diff
214
app/scripts/check_trades_unsubscribe.py
Normal file
214
app/scripts/check_trades_unsubscribe.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
from websockets.typing import Subprotocol
|
||||||
|
|
||||||
|
|
||||||
|
SYMBOL = os.getenv(
|
||||||
|
"TRADE_TEST_SYMBOL",
|
||||||
|
"BTC/USD_LEVERAGE",
|
||||||
|
)
|
||||||
|
|
||||||
|
OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS = 30
|
||||||
|
OBSERVE_AFTER_UNSUBSCRIBE_SECONDS = 60
|
||||||
|
RECEIVE_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def build_ws_url() -> str:
|
||||||
|
raw_url = os.getenv(
|
||||||
|
"EXCHANGE_WS_URL",
|
||||||
|
"wss://api-adapter.dzengi.com",
|
||||||
|
).rstrip("/")
|
||||||
|
|
||||||
|
if raw_url.startswith("https://"):
|
||||||
|
raw_url = raw_url.replace("https://", "wss://", 1)
|
||||||
|
elif raw_url.startswith("http://"):
|
||||||
|
raw_url = raw_url.replace("http://", "ws://", 1)
|
||||||
|
|
||||||
|
if raw_url.endswith("/connect"):
|
||||||
|
return raw_url
|
||||||
|
|
||||||
|
return f"{raw_url}/connect"
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers() -> dict[str, str]:
|
||||||
|
base_url = os.getenv(
|
||||||
|
"EXCHANGE_BASE_URL",
|
||||||
|
"https://api-adapter.dzengi.com",
|
||||||
|
).rstrip("/")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Origin": base_url,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
api_key = os.getenv("EXCHANGE_API_KEY", "").strip()
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
headers["X-MBX-APIKEY"] = api_key
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def build_request(destination: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"correlationId": str(uuid4()),
|
||||||
|
"destination": destination,
|
||||||
|
"payload": {
|
||||||
|
"symbols": [SYMBOL],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_message(raw_message: str | bytes) -> Any:
|
||||||
|
if isinstance(raw_message, bytes):
|
||||||
|
raw_message = raw_message.decode("utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(raw_message)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return raw_message
|
||||||
|
|
||||||
|
|
||||||
|
def print_message(label: str, message: Any) -> None:
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print(label)
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
if isinstance(message, (dict, list)):
|
||||||
|
print(json.dumps(message, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(message)
|
||||||
|
|
||||||
|
|
||||||
|
def is_trade_event(message: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(message, dict)
|
||||||
|
and message.get("destination") == "internal.trade"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_until(
|
||||||
|
websocket: Any,
|
||||||
|
*,
|
||||||
|
duration_seconds: int,
|
||||||
|
phase: str,
|
||||||
|
) -> int:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + duration_seconds
|
||||||
|
trade_count = 0
|
||||||
|
|
||||||
|
while loop.time() < deadline:
|
||||||
|
remaining = deadline - loop.time()
|
||||||
|
timeout = min(RECEIVE_TIMEOUT_SECONDS, remaining)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_message = await asyncio.wait_for(
|
||||||
|
websocket.recv(),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(
|
||||||
|
f"[{phase}] За последние {timeout:.0f} секунд "
|
||||||
|
"сообщений не получено; соединение остаётся открытым."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = decode_message(raw_message)
|
||||||
|
print_message(f"[{phase}] Получено сообщение", message)
|
||||||
|
|
||||||
|
if is_trade_event(message):
|
||||||
|
trade_count += 1
|
||||||
|
|
||||||
|
return trade_count
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
ws_url = build_ws_url()
|
||||||
|
headers = build_headers()
|
||||||
|
|
||||||
|
subscribe_request = build_request("trades.subscribe")
|
||||||
|
unsubscribe_request = build_request("trades.unsubscribe")
|
||||||
|
|
||||||
|
print(f"WebSocket URL: {ws_url}")
|
||||||
|
print(f"Symbol: {SYMBOL}")
|
||||||
|
|
||||||
|
async with websockets.connect(
|
||||||
|
ws_url,
|
||||||
|
extra_headers=headers,
|
||||||
|
subprotocols=[Subprotocol("json")],
|
||||||
|
ping_interval=20,
|
||||||
|
ping_timeout=20,
|
||||||
|
open_timeout=20,
|
||||||
|
) as websocket:
|
||||||
|
print_message(
|
||||||
|
"Отправляется trades.subscribe",
|
||||||
|
subscribe_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(subscribe_request),
|
||||||
|
)
|
||||||
|
|
||||||
|
before_count = await receive_until(
|
||||||
|
websocket,
|
||||||
|
duration_seconds=OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS,
|
||||||
|
phase="BEFORE UNSUBSCRIBE",
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Количество Trade Event до unsubscribe: "
|
||||||
|
f"{before_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print_message(
|
||||||
|
"Отправляется trades.unsubscribe",
|
||||||
|
unsubscribe_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(unsubscribe_request),
|
||||||
|
)
|
||||||
|
|
||||||
|
after_count = await receive_until(
|
||||||
|
websocket,
|
||||||
|
duration_seconds=OBSERVE_AFTER_UNSUBSCRIBE_SECONDS,
|
||||||
|
phase="AFTER UNSUBSCRIBE",
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("РЕЗУЛЬТАТ НАБЛЮДЕНИЯ")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"Trade Event до unsubscribe: {before_count}")
|
||||||
|
print(f"Trade Event после unsubscribe: {after_count}")
|
||||||
|
|
||||||
|
if before_count == 0:
|
||||||
|
print(
|
||||||
|
"До unsubscribe не было получено ни одной сделки. "
|
||||||
|
"Эксперимент нельзя считать доказательным."
|
||||||
|
)
|
||||||
|
elif after_count == 0:
|
||||||
|
print(
|
||||||
|
"После trades.unsubscribe новые сделки не поступили. "
|
||||||
|
"Команда, вероятно, поддерживается, но необходимо "
|
||||||
|
"проверить ACK или повторить тест при высокой активности."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"После trades.unsubscribe сделки продолжили поступать. "
|
||||||
|
"Команда либо не поддерживается, либо была отклонена, "
|
||||||
|
"либо имеет другой формат."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
269
app/scripts/check_trades_websocket.py
Normal file
269
app/scripts/check_trades_websocket.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# app/scripts/check_trades_websocket.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
from websockets.typing import Subprotocol
|
||||||
|
|
||||||
|
from src.core.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
SYMBOLS = (
|
||||||
|
"BTC/USD_LEVERAGE",
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_MESSAGES = 100
|
||||||
|
TOTAL_TIMEOUT_SECONDS = 3000.0
|
||||||
|
RECEIVE_TIMEOUT_SECONDS = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def build_ws_url() -> str:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
raw_url = settings.exchange_ws_url or settings.exchange_base_url
|
||||||
|
|
||||||
|
if raw_url.startswith("https://"):
|
||||||
|
raw_url = raw_url.replace("https://", "wss://", 1)
|
||||||
|
elif raw_url.startswith("http://"):
|
||||||
|
raw_url = raw_url.replace("http://", "ws://", 1)
|
||||||
|
|
||||||
|
raw_url = raw_url.rstrip("/")
|
||||||
|
|
||||||
|
if raw_url.endswith("/connect"):
|
||||||
|
return raw_url
|
||||||
|
|
||||||
|
return f"{raw_url}/connect"
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers() -> dict[str, str]:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Origin": settings.exchange_base_url.rstrip("/"),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.exchange_api_key:
|
||||||
|
headers["X-MBX-APIKEY"] = settings.exchange_api_key
|
||||||
|
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def build_subscribe_request(
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"correlationId": str(uuid4()),
|
||||||
|
"destination": "trades.subscribe",
|
||||||
|
"payload": {
|
||||||
|
"symbols": list(symbols),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_json(
|
||||||
|
raw_message: str | bytes | bytearray | memoryview,
|
||||||
|
) -> str:
|
||||||
|
if isinstance(raw_message, str):
|
||||||
|
text = raw_message
|
||||||
|
elif isinstance(raw_message, memoryview):
|
||||||
|
text = raw_message.tobytes().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = bytes(raw_message).decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
document,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_document(
|
||||||
|
raw_message: str | bytes | bytearray | memoryview,
|
||||||
|
) -> object | None:
|
||||||
|
if isinstance(raw_message, str):
|
||||||
|
text = raw_message
|
||||||
|
elif isinstance(raw_message, memoryview):
|
||||||
|
text = raw_message.tobytes().decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = bytes(raw_message).decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_trade_event(document: object) -> bool:
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return document.get("destination") == "internal.trade"
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_seconds(
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
started_at: float,
|
||||||
|
) -> float:
|
||||||
|
return max(0.0, loop.time() - started_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_trades() -> None:
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
ws_url = build_ws_url()
|
||||||
|
headers = build_headers()
|
||||||
|
request = build_subscribe_request(SYMBOLS)
|
||||||
|
|
||||||
|
print(f"WebSocket URL: {ws_url}")
|
||||||
|
print(f"Symbols: {', '.join(SYMBOLS)}")
|
||||||
|
print(f"Maximum messages: {MAX_MESSAGES}")
|
||||||
|
print(f"Total timeout: {TOTAL_TIMEOUT_SECONDS:.0f} seconds")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("Subscription request:")
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
request,
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
async with websockets.connect(
|
||||||
|
ws_url,
|
||||||
|
extra_headers=headers,
|
||||||
|
subprotocols=[Subprotocol("json")],
|
||||||
|
ping_interval=20,
|
||||||
|
ping_timeout=float(settings.exchange_timeout_sec),
|
||||||
|
open_timeout=float(settings.exchange_timeout_sec),
|
||||||
|
close_timeout=float(settings.exchange_timeout_sec),
|
||||||
|
) as websocket:
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
request,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Subscription request sent.")
|
||||||
|
print("Waiting for acknowledgement and trade events...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
started_at = loop.time()
|
||||||
|
deadline = started_at + TOTAL_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
received_count = 0
|
||||||
|
trade_event_count = 0
|
||||||
|
timeout_count = 0
|
||||||
|
|
||||||
|
while received_count < MAX_MESSAGES:
|
||||||
|
remaining_seconds = deadline - loop.time()
|
||||||
|
|
||||||
|
if remaining_seconds <= 0:
|
||||||
|
print(
|
||||||
|
"Total timeout reached after "
|
||||||
|
f"{elapsed_seconds(loop, started_at):.1f} seconds."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
receive_timeout = min(
|
||||||
|
RECEIVE_TIMEOUT_SECONDS,
|
||||||
|
remaining_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_message = await asyncio.wait_for(
|
||||||
|
websocket.recv(),
|
||||||
|
timeout=receive_timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
timeout_count += 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
"No trade event yet; connection is alive. "
|
||||||
|
f"Elapsed: {elapsed_seconds(loop, started_at):.1f}s, "
|
||||||
|
f"remaining: {max(0.0, remaining_seconds):.1f}s."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
raw_message,
|
||||||
|
(str, bytes, bytearray, memoryview),
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
"Skipped unsupported WebSocket message type: "
|
||||||
|
f"{type(raw_message).__name__}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
received_count += 1
|
||||||
|
document = decode_document(raw_message)
|
||||||
|
|
||||||
|
if is_trade_event(document):
|
||||||
|
trade_event_count += 1
|
||||||
|
message_kind = "TRADE EVENT"
|
||||||
|
else:
|
||||||
|
message_kind = "CONTROL / ACKNOWLEDGEMENT"
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print(
|
||||||
|
f"Message #{received_count} — {message_kind} — "
|
||||||
|
f"elapsed {elapsed_seconds(loop, started_at):.1f}s"
|
||||||
|
)
|
||||||
|
print(format_json(raw_message))
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("Diagnostic summary")
|
||||||
|
print(f"Received messages: {received_count}")
|
||||||
|
print(f"Trade events: {trade_event_count}")
|
||||||
|
print(f"Receive timeouts: {timeout_count}")
|
||||||
|
print(
|
||||||
|
"Elapsed time: "
|
||||||
|
f"{elapsed_seconds(loop, started_at):.1f} seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
asyncio.run(receive_trades())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
print("Stopped by user.")
|
||||||
|
except Exception as exc:
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"WebSocket diagnostic failed: "
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -5,7 +5,14 @@ from __future__ import annotations
|
|||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
|
||||||
|
from src.bootstrap.application import ApplicationComposition
|
||||||
from src.bootstrap.logging import setup_logging
|
from src.bootstrap.logging import setup_logging
|
||||||
|
from src.bootstrap.market_data_storage import (
|
||||||
|
build_market_data_storage,
|
||||||
|
)
|
||||||
|
from src.bootstrap.trade_stream_runtime import (
|
||||||
|
build_trade_stream_production_runtime,
|
||||||
|
)
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.notifications.targets import NotificationTargetRegistry
|
from src.notifications.targets import NotificationTargetRegistry
|
||||||
from src.storage.schema import init_schema
|
from src.storage.schema import init_schema
|
||||||
@@ -13,7 +20,7 @@ from src.telegram.routers import setup_routers
|
|||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> tuple[Bot, Dispatcher]:
|
def create_app() -> ApplicationComposition:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
|
||||||
setup_logging(settings.log_level)
|
setup_logging(settings.log_level)
|
||||||
@@ -37,18 +44,20 @@ def create_app() -> tuple[Bot, Dispatcher]:
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
market_data_storage = build_market_data_storage(settings)
|
||||||
journal.log_info(
|
trade_stream_runtime = build_trade_stream_production_runtime(
|
||||||
"app_started",
|
settings,
|
||||||
"Приложение запущено",
|
trade_observation_sink=(
|
||||||
{
|
market_data_storage.trade_observation_sink
|
||||||
"env": settings.app_env,
|
if market_data_storage is not None
|
||||||
"exchange_name": settings.exchange_name,
|
else None
|
||||||
"default_symbol": settings.default_symbol,
|
),
|
||||||
},
|
checkpoint_storage=(
|
||||||
|
market_data_storage.trade_repository
|
||||||
|
if market_data_storage is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
bot = Bot(
|
bot = Bot(
|
||||||
token=settings.bot_token,
|
token=settings.bot_token,
|
||||||
@@ -61,4 +70,32 @@ def create_app() -> tuple[Bot, Dispatcher]:
|
|||||||
|
|
||||||
setup_routers(dispatcher)
|
setup_routers(dispatcher)
|
||||||
|
|
||||||
return bot, dispatcher
|
try:
|
||||||
|
journal.log_info(
|
||||||
|
"app_started",
|
||||||
|
"Приложение запущено",
|
||||||
|
{
|
||||||
|
"env": settings.app_env,
|
||||||
|
"exchange_name": settings.exchange_name,
|
||||||
|
"default_symbol": settings.default_symbol,
|
||||||
|
"trade_stream_enabled": (
|
||||||
|
settings.trade_stream.enabled
|
||||||
|
),
|
||||||
|
"market_data_storage_enabled": (
|
||||||
|
settings.market_data_storage.enabled
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ApplicationComposition(
|
||||||
|
bot=bot,
|
||||||
|
dispatcher=dispatcher,
|
||||||
|
trade_stream_runtime=trade_stream_runtime,
|
||||||
|
market_data_storage_lifecycle=(
|
||||||
|
market_data_storage.lifecycle
|
||||||
|
if market_data_storage is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
326
app/src/bootstrap/application.py
Normal file
326
app/src/bootstrap/application.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
|
||||||
|
from src.bootstrap.market_data_storage import (
|
||||||
|
MarketDataStorageLifecycleProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
||||||
|
TradeStreamProductionRuntimeProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApplicationComposition:
|
||||||
|
"""Корневые компоненты одного запущенного экземпляра приложения."""
|
||||||
|
|
||||||
|
bot: Bot
|
||||||
|
dispatcher: Dispatcher
|
||||||
|
trade_stream_runtime: (
|
||||||
|
TradeStreamProductionRuntimeProtocol | None
|
||||||
|
)
|
||||||
|
market_data_storage_lifecycle: (
|
||||||
|
MarketDataStorageLifecycleProtocol | None
|
||||||
|
) = None
|
||||||
|
|
||||||
|
|
||||||
|
async def run_application(
|
||||||
|
application: ApplicationComposition,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Выполнять Telegram polling и опциональный Trade Stream как одно целое.
|
||||||
|
|
||||||
|
Ошибка включённого Trade Stream считается фатальной для всего
|
||||||
|
приложения. Остановка Telegram, Trade Stream и bot session
|
||||||
|
выполняется одним владельцем и не оставляет фоновых root tasks.
|
||||||
|
"""
|
||||||
|
polling_task: asyncio.Task[None] | None = None
|
||||||
|
runtime_task: asyncio.Task[None] | None = None
|
||||||
|
primary_error: BaseException | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_lifecycle = (
|
||||||
|
application.market_data_storage_lifecycle
|
||||||
|
)
|
||||||
|
|
||||||
|
if storage_lifecycle is not None:
|
||||||
|
await _run_blocking_lifecycle_operation(
|
||||||
|
storage_lifecycle.start,
|
||||||
|
task_name="market-data-storage-startup",
|
||||||
|
)
|
||||||
|
|
||||||
|
polling_task = asyncio.create_task(
|
||||||
|
application.dispatcher.start_polling(
|
||||||
|
application.bot,
|
||||||
|
close_bot_session=False,
|
||||||
|
),
|
||||||
|
name="telegram-polling",
|
||||||
|
)
|
||||||
|
runtime_task = (
|
||||||
|
asyncio.create_task(
|
||||||
|
application.trade_stream_runtime.run(),
|
||||||
|
name="trade-stream-runtime",
|
||||||
|
)
|
||||||
|
if application.trade_stream_runtime is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
await _wait_for_root_tasks(
|
||||||
|
polling_task=polling_task,
|
||||||
|
runtime_task=runtime_task,
|
||||||
|
)
|
||||||
|
except BaseException as error:
|
||||||
|
primary_error = error
|
||||||
|
|
||||||
|
cleanup_task = asyncio.create_task(
|
||||||
|
_shutdown_application(
|
||||||
|
application=application,
|
||||||
|
polling_task=polling_task,
|
||||||
|
runtime_task=runtime_task,
|
||||||
|
primary_error=primary_error,
|
||||||
|
),
|
||||||
|
name="application-shutdown",
|
||||||
|
)
|
||||||
|
cleanup_error = await _await_cleanup(
|
||||||
|
cleanup_task,
|
||||||
|
primary_error=primary_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if primary_error is not None:
|
||||||
|
if cleanup_error is not None:
|
||||||
|
primary_error.add_note(
|
||||||
|
"Application cleanup also failed: "
|
||||||
|
f"{type(cleanup_error).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise primary_error.with_traceback(
|
||||||
|
primary_error.__traceback__,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cleanup_error is not None:
|
||||||
|
raise cleanup_error.with_traceback(
|
||||||
|
cleanup_error.__traceback__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_root_tasks(
|
||||||
|
*,
|
||||||
|
polling_task: asyncio.Task[None],
|
||||||
|
runtime_task: asyncio.Task[None] | None,
|
||||||
|
) -> None:
|
||||||
|
if runtime_task is None:
|
||||||
|
await polling_task
|
||||||
|
return
|
||||||
|
|
||||||
|
done_tasks, _ = await asyncio.wait(
|
||||||
|
(
|
||||||
|
polling_task,
|
||||||
|
runtime_task,
|
||||||
|
),
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if runtime_task in done_tasks:
|
||||||
|
if runtime_task.cancelled():
|
||||||
|
raise RuntimeError(
|
||||||
|
"Trade Stream Runtime was cancelled unexpectedly."
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_error = runtime_task.exception()
|
||||||
|
|
||||||
|
if runtime_error is not None:
|
||||||
|
raise runtime_error.with_traceback(
|
||||||
|
runtime_error.__traceback__,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"Trade Stream Runtime terminated unexpectedly."
|
||||||
|
)
|
||||||
|
|
||||||
|
await polling_task
|
||||||
|
|
||||||
|
|
||||||
|
async def _shutdown_application(
|
||||||
|
*,
|
||||||
|
application: ApplicationComposition,
|
||||||
|
polling_task: asyncio.Task[None] | None,
|
||||||
|
runtime_task: asyncio.Task[None] | None,
|
||||||
|
primary_error: BaseException | None,
|
||||||
|
) -> BaseException | None:
|
||||||
|
cleanup_error: BaseException | None = None
|
||||||
|
polling_cancelled = False
|
||||||
|
runtime_cancelled = False
|
||||||
|
|
||||||
|
if polling_task is not None and not polling_task.done():
|
||||||
|
polling_cancelled = True
|
||||||
|
polling_task.cancel()
|
||||||
|
|
||||||
|
runtime = application.trade_stream_runtime
|
||||||
|
|
||||||
|
if runtime is not None:
|
||||||
|
try:
|
||||||
|
await runtime.stop()
|
||||||
|
except BaseException as error:
|
||||||
|
cleanup_error = _merge_cleanup_error(
|
||||||
|
cleanup_error,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if runtime_task is not None and not runtime_task.done():
|
||||||
|
runtime_cancelled = True
|
||||||
|
runtime_task.cancel()
|
||||||
|
|
||||||
|
if polling_task is not None:
|
||||||
|
cleanup_error = await _observe_root_task(
|
||||||
|
polling_task,
|
||||||
|
expected_cancellation=polling_cancelled,
|
||||||
|
primary_error=primary_error,
|
||||||
|
cleanup_error=cleanup_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if runtime_task is not None:
|
||||||
|
cleanup_error = await _observe_root_task(
|
||||||
|
runtime_task,
|
||||||
|
expected_cancellation=runtime_cancelled,
|
||||||
|
primary_error=primary_error,
|
||||||
|
cleanup_error=cleanup_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
storage_lifecycle = application.market_data_storage_lifecycle
|
||||||
|
|
||||||
|
if storage_lifecycle is not None:
|
||||||
|
try:
|
||||||
|
await _run_blocking_lifecycle_operation(
|
||||||
|
storage_lifecycle.stop,
|
||||||
|
task_name="market-data-storage-shutdown",
|
||||||
|
)
|
||||||
|
except BaseException as error:
|
||||||
|
cleanup_error = _merge_cleanup_error(
|
||||||
|
cleanup_error,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await application.bot.session.close()
|
||||||
|
except BaseException as error:
|
||||||
|
cleanup_error = _merge_cleanup_error(
|
||||||
|
cleanup_error,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleanup_error
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_blocking_lifecycle_operation(
|
||||||
|
operation: Callable[[], None],
|
||||||
|
*,
|
||||||
|
task_name: str,
|
||||||
|
) -> None:
|
||||||
|
operation_task = asyncio.create_task(
|
||||||
|
asyncio.to_thread(operation),
|
||||||
|
name=task_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.shield(operation_task)
|
||||||
|
except asyncio.CancelledError as cancellation:
|
||||||
|
try:
|
||||||
|
await _wait_for_blocking_operation(operation_task)
|
||||||
|
except BaseException as operation_error:
|
||||||
|
cancellation.add_note(
|
||||||
|
"Application blocking lifecycle operation also failed: "
|
||||||
|
f"{type(operation_error).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_blocking_operation(
|
||||||
|
operation_task: asyncio.Task[None],
|
||||||
|
) -> None:
|
||||||
|
while not operation_task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.shield(operation_task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
continue
|
||||||
|
except BaseException:
|
||||||
|
break
|
||||||
|
|
||||||
|
operation_task.result()
|
||||||
|
|
||||||
|
|
||||||
|
async def _observe_root_task(
|
||||||
|
task: asyncio.Task[None],
|
||||||
|
*,
|
||||||
|
expected_cancellation: bool,
|
||||||
|
primary_error: BaseException | None,
|
||||||
|
cleanup_error: BaseException | None,
|
||||||
|
) -> BaseException | None:
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError as error:
|
||||||
|
if (
|
||||||
|
not expected_cancellation
|
||||||
|
and error is not primary_error
|
||||||
|
and not isinstance(primary_error, asyncio.CancelledError)
|
||||||
|
):
|
||||||
|
return _merge_cleanup_error(
|
||||||
|
cleanup_error,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
except BaseException as error:
|
||||||
|
if error is not primary_error:
|
||||||
|
return _merge_cleanup_error(
|
||||||
|
cleanup_error,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleanup_error
|
||||||
|
|
||||||
|
|
||||||
|
async def _await_cleanup(
|
||||||
|
cleanup_task: asyncio.Task[BaseException | None],
|
||||||
|
*,
|
||||||
|
primary_error: BaseException | None,
|
||||||
|
) -> BaseException | None:
|
||||||
|
interrupted_error: asyncio.CancelledError | None = None
|
||||||
|
|
||||||
|
while not cleanup_task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.shield(cleanup_task)
|
||||||
|
except asyncio.CancelledError as error:
|
||||||
|
if primary_error is None:
|
||||||
|
primary_error = error
|
||||||
|
interrupted_error = error
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleanup_error = cleanup_task.result()
|
||||||
|
|
||||||
|
if interrupted_error is not None:
|
||||||
|
if cleanup_error is not None:
|
||||||
|
interrupted_error.add_note(
|
||||||
|
"Application cleanup also failed: "
|
||||||
|
f"{type(cleanup_error).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise interrupted_error
|
||||||
|
|
||||||
|
return cleanup_error
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_cleanup_error(
|
||||||
|
current_error: BaseException | None,
|
||||||
|
new_error: BaseException,
|
||||||
|
) -> BaseException:
|
||||||
|
if current_error is None:
|
||||||
|
return new_error
|
||||||
|
|
||||||
|
current_error.add_note(
|
||||||
|
"Additional application cleanup failure: "
|
||||||
|
f"{type(new_error).__name__}."
|
||||||
|
)
|
||||||
|
return current_error
|
||||||
156
app/src/bootstrap/market_data_storage.py
Normal file
156
app/src/bootstrap/market_data_storage.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from psycopg.conninfo import make_conninfo
|
||||||
|
|
||||||
|
from src.core.config import Settings
|
||||||
|
from src.market_data.storage.postgres_trade_repository import (
|
||||||
|
PostgresTradeRepository,
|
||||||
|
)
|
||||||
|
from src.market_data.storage.trade_storage_observation_sink import (
|
||||||
|
TradeStorageObservationSink,
|
||||||
|
)
|
||||||
|
from src.storage.migrations import StorageMigrationRunner
|
||||||
|
from src.storage.postgres_pool import PostgresConnectionPool
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MarketDataStorageLifecycleProtocol(Protocol):
|
||||||
|
"""Синхронный жизненный цикл под управлением сборки приложения."""
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Открыть пул и применить миграции до запуска Runtime."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Закрыть пул после завершения всех записей Runtime."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class _ConnectionPoolLifecycleProtocol(Protocol):
|
||||||
|
def open(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class _MigrationRunnerProtocol(Protocol):
|
||||||
|
def run(self) -> tuple[int, ...]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataStorageLifecycle:
|
||||||
|
"""Управляет запуском пула, миграциями и очисткой частичного запуска."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_connection_pool",
|
||||||
|
"_migration_runner",
|
||||||
|
"_started",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connection_pool: _ConnectionPoolLifecycleProtocol,
|
||||||
|
migration_runner: _MigrationRunnerProtocol,
|
||||||
|
) -> None:
|
||||||
|
self._connection_pool = connection_pool
|
||||||
|
self._migration_runner = migration_runner
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def started(self) -> bool:
|
||||||
|
"""Показать, завершились ли запуск пула и миграции."""
|
||||||
|
return self._started
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._connection_pool.open()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._migration_runner.run()
|
||||||
|
except BaseException as error:
|
||||||
|
try:
|
||||||
|
self._connection_pool.close()
|
||||||
|
except BaseException as cleanup_error:
|
||||||
|
error.add_note(
|
||||||
|
"Market Data Storage startup cleanup also failed: "
|
||||||
|
f"{type(cleanup_error).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._started = False
|
||||||
|
self._connection_pool.close()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MarketDataStorageBootstrapComposition:
|
||||||
|
"""Неактивный граф зависимостей постоянного хранилища сделок."""
|
||||||
|
|
||||||
|
connection_pool: PostgresConnectionPool
|
||||||
|
migration_runner: StorageMigrationRunner
|
||||||
|
trade_repository: PostgresTradeRepository
|
||||||
|
trade_observation_sink: TradeStorageObservationSink
|
||||||
|
lifecycle: MarketDataStorageLifecycle
|
||||||
|
|
||||||
|
|
||||||
|
def build_market_data_storage(
|
||||||
|
settings: Settings,
|
||||||
|
) -> MarketDataStorageBootstrapComposition | None:
|
||||||
|
"""Собрать зависимости хранилища без открытия PostgreSQL."""
|
||||||
|
storage_settings = settings.market_data_storage
|
||||||
|
|
||||||
|
if not storage_settings.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not settings.trade_stream.enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Trade Stream must be enabled when Market Data Storage "
|
||||||
|
"is enabled."
|
||||||
|
)
|
||||||
|
|
||||||
|
conninfo = make_conninfo(
|
||||||
|
host=settings.db_host,
|
||||||
|
port=settings.db_port,
|
||||||
|
dbname=settings.db_name,
|
||||||
|
user=settings.db_user,
|
||||||
|
password=settings.db_password,
|
||||||
|
)
|
||||||
|
connection_pool = PostgresConnectionPool(
|
||||||
|
conninfo=conninfo,
|
||||||
|
min_size=storage_settings.pool_min_size,
|
||||||
|
max_size=storage_settings.pool_max_size,
|
||||||
|
timeout_seconds=storage_settings.pool_timeout_seconds,
|
||||||
|
name="market-data-storage",
|
||||||
|
)
|
||||||
|
migration_runner = StorageMigrationRunner(
|
||||||
|
connection_provider=connection_pool.connection,
|
||||||
|
)
|
||||||
|
trade_repository = PostgresTradeRepository(
|
||||||
|
connection_provider=connection_pool.connection,
|
||||||
|
)
|
||||||
|
trade_observation_sink = TradeStorageObservationSink(
|
||||||
|
trade_storage=trade_repository,
|
||||||
|
venue=settings.exchange_name,
|
||||||
|
)
|
||||||
|
lifecycle = MarketDataStorageLifecycle(
|
||||||
|
connection_pool=connection_pool,
|
||||||
|
migration_runner=migration_runner,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MarketDataStorageBootstrapComposition(
|
||||||
|
connection_pool=connection_pool,
|
||||||
|
migration_runner=migration_runner,
|
||||||
|
trade_repository=trade_repository,
|
||||||
|
trade_observation_sink=trade_observation_sink,
|
||||||
|
lifecycle=lifecycle,
|
||||||
|
)
|
||||||
171
app/src/bootstrap/trade_stream_runtime.py
Normal file
171
app/src/bootstrap/trade_stream_runtime.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.core.config import Settings
|
||||||
|
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||||
|
DzengiTradesDocumentSource,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
||||||
|
DzengiUnifiedWebSocketAdapter,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket_control_message_handler import (
|
||||||
|
DzengiWebSocketControlMessageHandler,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket_inbound_message_classifier import (
|
||||||
|
DzengiWebSocketInboundMessageClassifier,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket_transport import (
|
||||||
|
DzengiWebSocketTransport,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_observation_sink_protocol import (
|
||||||
|
TradeObservationSinkProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.acquisition_runtime_event_logging_consumer import (
|
||||||
|
AcquisitionRuntimeEventLoggingConsumer,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.acquisition_runtime_event_publisher import (
|
||||||
|
AcquisitionRuntimeEventPublisher,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
||||||
|
TradeStreamProductionRuntime,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_session import (
|
||||||
|
WebSocketSession,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_subscription_manager import (
|
||||||
|
WebSocketSubscriptionManager,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.trade_stream_runtime_composition import (
|
||||||
|
build_trade_stream_runtime_composition,
|
||||||
|
)
|
||||||
|
from src.market_data.storage.contracts import (
|
||||||
|
TradeCheckpointStorageProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_trade_stream_production_runtime(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
trade_observation_sink: TradeObservationSinkProtocol | None = None,
|
||||||
|
checkpoint_storage: TradeCheckpointStorageProtocol | None = None,
|
||||||
|
) -> TradeStreamProductionRuntime | None:
|
||||||
|
"""
|
||||||
|
Собрать Production Trade Stream Runtime без запуска lifecycle.
|
||||||
|
|
||||||
|
Выключенный feature flag возвращает ``None`` до создания
|
||||||
|
WebSocket/REST-зависимостей. Все stateful-компоненты включённого
|
||||||
|
Runtime создаются один раз и затем передаются по identity.
|
||||||
|
"""
|
||||||
|
trade_stream = settings.trade_stream
|
||||||
|
storage_enabled = settings.market_data_storage.enabled
|
||||||
|
|
||||||
|
if not trade_stream.enabled:
|
||||||
|
if storage_enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Trade Stream must be enabled when Market Data "
|
||||||
|
"Storage is enabled."
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
if storage_enabled and (
|
||||||
|
trade_observation_sink is None
|
||||||
|
or checkpoint_storage is None
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Enabled Market Data Storage requires both Trade "
|
||||||
|
"observation sink and checkpoint storage."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not storage_enabled and checkpoint_storage is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Checkpoint storage requires enabled Market Data Storage."
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Origin": settings.exchange_base_url.rstrip("/"),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.exchange_api_key:
|
||||||
|
headers["X-MBX-APIKEY"] = settings.exchange_api_key
|
||||||
|
|
||||||
|
transport = DzengiWebSocketTransport(
|
||||||
|
url=trade_stream.websocket_url,
|
||||||
|
headers=headers,
|
||||||
|
open_timeout=trade_stream.open_timeout_seconds,
|
||||||
|
ping_interval=None,
|
||||||
|
ping_timeout=None,
|
||||||
|
probe_timeout=trade_stream.probe_timeout_seconds,
|
||||||
|
close_timeout=trade_stream.close_timeout_seconds,
|
||||||
|
)
|
||||||
|
session = WebSocketSession(transport)
|
||||||
|
subscription_manager = WebSocketSubscriptionManager(
|
||||||
|
transport,
|
||||||
|
supports_unsubscribe=False,
|
||||||
|
)
|
||||||
|
event_publisher = AcquisitionRuntimeEventPublisher(
|
||||||
|
(
|
||||||
|
AcquisitionRuntimeEventLoggingConsumer(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recovery_document_source = DzengiTradesDocumentSource(
|
||||||
|
ExchangeRestClient(settings=settings),
|
||||||
|
)
|
||||||
|
message_adapter = DzengiUnifiedWebSocketAdapter()
|
||||||
|
|
||||||
|
composition = build_trade_stream_runtime_composition(
|
||||||
|
session=session,
|
||||||
|
transport=transport,
|
||||||
|
subscription_manager=subscription_manager,
|
||||||
|
event_publisher=event_publisher,
|
||||||
|
message_adapter=message_adapter,
|
||||||
|
recovery_document_source=recovery_document_source,
|
||||||
|
symbols=trade_stream.symbols,
|
||||||
|
heartbeat_timeout_seconds=(
|
||||||
|
trade_stream.heartbeat_timeout_seconds
|
||||||
|
),
|
||||||
|
scheduler_interval_seconds=(
|
||||||
|
trade_stream.scheduler_interval_seconds
|
||||||
|
),
|
||||||
|
trade_observation_sink=trade_observation_sink,
|
||||||
|
max_recovery_window_ms=trade_stream.recovery_window_ms,
|
||||||
|
checkpoint_storage=checkpoint_storage,
|
||||||
|
checkpoint_venue=(
|
||||||
|
settings.exchange_name
|
||||||
|
if checkpoint_storage is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return TradeStreamProductionRuntime(
|
||||||
|
session=session,
|
||||||
|
transport=transport,
|
||||||
|
subscription_manager=subscription_manager,
|
||||||
|
event_publisher=event_publisher,
|
||||||
|
trade_stream_service=(
|
||||||
|
composition.trade_stream_acquisition_service
|
||||||
|
),
|
||||||
|
message_classifier=(
|
||||||
|
DzengiWebSocketInboundMessageClassifier()
|
||||||
|
),
|
||||||
|
control_message_handler=(
|
||||||
|
DzengiWebSocketControlMessageHandler()
|
||||||
|
),
|
||||||
|
live_processing_gate=composition.live_processing_gate,
|
||||||
|
reconnect_recovery_coordinator=(
|
||||||
|
composition.runtime_reconnect_recovery_coordinator
|
||||||
|
),
|
||||||
|
runtime_supervisor=composition.runtime_supervisor,
|
||||||
|
runtime_scheduler=composition.runtime_scheduler,
|
||||||
|
symbols=trade_stream.symbols,
|
||||||
|
startup_recovery_coordinator=(
|
||||||
|
composition.runtime_startup_recovery_coordinator
|
||||||
|
),
|
||||||
|
subscription_ack_timeout_seconds=(
|
||||||
|
trade_stream.subscription_ack_timeout_seconds
|
||||||
|
),
|
||||||
|
startup_market_buffer_capacity=(
|
||||||
|
trade_stream.startup_market_buffer_capacity
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -2,35 +2,84 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from src.market_data.acquisition.symbols import normalize_symbol
|
||||||
|
|
||||||
|
|
||||||
# корень проекта
|
# корень проекта
|
||||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
# .env файл
|
|
||||||
ENV_FILE = BASE_DIR / ".env"
|
def _resolve_env_file() -> Path:
|
||||||
|
"""Выбирает отдельный env-файл для записи либо обычный app/.env."""
|
||||||
|
|
||||||
|
raw_runtime_env_file = os.getenv("DZENTRA_RUNTIME_ENV_FILE")
|
||||||
|
|
||||||
|
if raw_runtime_env_file is None:
|
||||||
|
return BASE_DIR / ".env"
|
||||||
|
|
||||||
|
runtime_env_file = raw_runtime_env_file.strip()
|
||||||
|
|
||||||
|
if not runtime_env_file:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Path(runtime_env_file)
|
||||||
|
|
||||||
|
|
||||||
|
# Файл должен быть выбран окружением процесса до импорта модуля настроек.
|
||||||
|
ENV_FILE = _resolve_env_file()
|
||||||
|
|
||||||
# загружаем переменные окружения
|
# загружаем переменные окружения
|
||||||
load_dotenv(ENV_FILE)
|
load_dotenv(ENV_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeStreamSettings:
|
||||||
|
"""Настройки Production Trade Stream Runtime."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
websocket_url: str
|
||||||
|
symbols: tuple[str, ...]
|
||||||
|
open_timeout_seconds: float
|
||||||
|
probe_timeout_seconds: float
|
||||||
|
close_timeout_seconds: float
|
||||||
|
heartbeat_timeout_seconds: float
|
||||||
|
scheduler_interval_seconds: float
|
||||||
|
recovery_window_ms: int
|
||||||
|
subscription_ack_timeout_seconds: float = 10.0
|
||||||
|
startup_market_buffer_capacity: int = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MarketDataStorageSettings:
|
||||||
|
"""Настройки persistent Canonical Market Data Storage."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
pool_min_size: int
|
||||||
|
pool_max_size: int
|
||||||
|
pool_timeout_seconds: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
# Telegram
|
# Telegram
|
||||||
bot_token: str
|
bot_token: str
|
||||||
bot_parse_mode: str
|
bot_parse_mode: str
|
||||||
|
|
||||||
# App
|
# Приложение
|
||||||
app_env: str
|
app_env: str
|
||||||
log_level: str
|
log_level: str
|
||||||
tz: str
|
tz: str
|
||||||
|
|
||||||
# Exchange
|
# Биржа
|
||||||
exchange_enabled: bool
|
exchange_enabled: bool
|
||||||
exchange_name: str
|
exchange_name: str
|
||||||
exchange_base_url: str
|
exchange_base_url: str
|
||||||
@@ -40,24 +89,26 @@ class Settings:
|
|||||||
exchange_timeout_sec: int
|
exchange_timeout_sec: int
|
||||||
exchange_testnet: bool
|
exchange_testnet: bool
|
||||||
default_symbol: str
|
default_symbol: str
|
||||||
|
trade_stream: TradeStreamSettings
|
||||||
|
|
||||||
# Database
|
# База данных
|
||||||
db_host: str
|
db_host: str
|
||||||
db_port: int
|
db_port: int
|
||||||
db_name: str
|
db_name: str
|
||||||
db_user: str
|
db_user: str
|
||||||
db_password: str
|
db_password: str
|
||||||
|
market_data_storage: MarketDataStorageSettings
|
||||||
|
|
||||||
# Debug helpers
|
# Отладочные параметры
|
||||||
debug_enabled: bool
|
debug_enabled: bool
|
||||||
journal_debug_enabled: bool
|
journal_debug_enabled: bool
|
||||||
|
|
||||||
# helper: demo/live mode
|
# Вспомогательное свойство режима demo/live
|
||||||
def is_demo_mode(self) -> bool:
|
def is_demo_mode(self) -> bool:
|
||||||
return "demo" in self.exchange_base_url.lower()
|
return "demo" in self.exchange_base_url.lower()
|
||||||
|
|
||||||
|
|
||||||
# parse bool
|
# Разбор булева значения
|
||||||
def _parse_bool(raw_value: str, default: bool = False) -> bool:
|
def _parse_bool(raw_value: str, default: bool = False) -> bool:
|
||||||
value = (raw_value or "").strip().lower()
|
value = (raw_value or "").strip().lower()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -66,7 +117,7 @@ def _parse_bool(raw_value: str, default: bool = False) -> bool:
|
|||||||
return value in {"1", "true", "yes", "on"}
|
return value in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
# parse int
|
# Разбор целого числа
|
||||||
def _parse_int(raw_value: str, default: int) -> int:
|
def _parse_int(raw_value: str, default: int) -> int:
|
||||||
value = (raw_value or "").strip()
|
value = (raw_value or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -75,19 +126,377 @@ def _parse_int(raw_value: str, default: int) -> int:
|
|||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
# load all settings
|
def _parse_strict_bool(
|
||||||
def load_settings() -> Settings:
|
raw_value: str,
|
||||||
bot_token = os.getenv("BOT_TOKEN", "").strip()
|
*,
|
||||||
|
name: str,
|
||||||
|
default: bool,
|
||||||
|
) -> bool:
|
||||||
|
value = (raw_value or "").strip().lower()
|
||||||
|
|
||||||
if not bot_token:
|
if not value:
|
||||||
raise RuntimeError("BOT_TOKEN is not set in app/.env")
|
return default
|
||||||
|
|
||||||
|
if value in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if value in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be a boolean value"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_positive_float(
|
||||||
|
raw_value: str,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
default: float,
|
||||||
|
) -> float:
|
||||||
|
value = (raw_value or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = (
|
||||||
|
float(value)
|
||||||
|
if value
|
||||||
|
else float(default)
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be a number"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
if not math.isfinite(result) or result <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be positive and finite"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_positive_int(
|
||||||
|
raw_value: str,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
default: int,
|
||||||
|
) -> int:
|
||||||
|
value = (raw_value or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = (
|
||||||
|
int(value)
|
||||||
|
if value
|
||||||
|
else int(default)
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be an integer"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
if result <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_trailing_newline(value: str) -> str:
|
||||||
|
"""Удаляет ровно один завершающий перевод строки из файла секрета."""
|
||||||
|
|
||||||
|
if value.endswith("\r\n"):
|
||||||
|
return value[:-2]
|
||||||
|
|
||||||
|
if value.endswith(("\n", "\r")):
|
||||||
|
return value[:-1]
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_secret(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
required: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Загружает секрет напрямую или из указанного переменной файла."""
|
||||||
|
|
||||||
|
file_name = f"{name}_FILE"
|
||||||
|
direct_value = os.getenv(name, "").strip()
|
||||||
|
raw_file_path = os.getenv(file_name)
|
||||||
|
|
||||||
|
if direct_value and raw_file_path is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{name} and {file_name} must not be set together"
|
||||||
|
)
|
||||||
|
|
||||||
|
if raw_file_path is not None:
|
||||||
|
file_path = raw_file_path.strip()
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{file_name} must contain a non-empty file path"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
secret = Path(file_path).read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to read secret configured by {file_name}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
secret = _remove_trailing_newline(secret)
|
||||||
|
|
||||||
|
if not secret or secret.isspace():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{file_name} contains an empty secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
return secret
|
||||||
|
|
||||||
|
if direct_value:
|
||||||
|
return direct_value
|
||||||
|
|
||||||
|
if required:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{name} or {file_name} is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_trade_stream_symbols(
|
||||||
|
raw_value: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
value = (raw_value or "").strip()
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TRADE_STREAM_SYMBOLS is required when "
|
||||||
|
"TRADE_STREAM_ENABLED is true"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_symbols = value.split(",")
|
||||||
|
|
||||||
|
if any(not symbol.strip() for symbol in raw_symbols):
|
||||||
|
raise ValueError(
|
||||||
|
"TRADE_STREAM_SYMBOLS must not contain empty symbols"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
normalize_symbol(symbol)
|
||||||
|
for symbol in raw_symbols
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_trade_stream_settings(
|
||||||
|
*,
|
||||||
|
exchange_base_url: str,
|
||||||
|
) -> TradeStreamSettings:
|
||||||
|
enabled = _parse_strict_bool(
|
||||||
|
os.getenv("TRADE_STREAM_ENABLED", "false"),
|
||||||
|
name="TRADE_STREAM_ENABLED",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
return TradeStreamSettings(
|
||||||
|
enabled=False,
|
||||||
|
websocket_url="",
|
||||||
|
symbols=(),
|
||||||
|
open_timeout_seconds=10.0,
|
||||||
|
probe_timeout_seconds=20.0,
|
||||||
|
close_timeout_seconds=10.0,
|
||||||
|
heartbeat_timeout_seconds=30.0,
|
||||||
|
scheduler_interval_seconds=5.0,
|
||||||
|
recovery_window_ms=3_599_999,
|
||||||
|
subscription_ack_timeout_seconds=10.0,
|
||||||
|
startup_market_buffer_capacity=10_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
websocket_url = os.getenv(
|
||||||
|
"TRADE_STREAM_WS_URL",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if not websocket_url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TRADE_STREAM_WS_URL is required when "
|
||||||
|
"TRADE_STREAM_ENABLED is true"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exchange_base_url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"EXCHANGE_BASE_URL is required for Trade Stream Recovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
return TradeStreamSettings(
|
||||||
|
enabled=True,
|
||||||
|
websocket_url=websocket_url,
|
||||||
|
symbols=_parse_trade_stream_symbols(
|
||||||
|
os.getenv("TRADE_STREAM_SYMBOLS", ""),
|
||||||
|
),
|
||||||
|
open_timeout_seconds=_parse_positive_float(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
|
||||||
|
"10",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
|
||||||
|
default=10.0,
|
||||||
|
),
|
||||||
|
probe_timeout_seconds=_parse_positive_float(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
|
||||||
|
"20",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
|
||||||
|
default=20.0,
|
||||||
|
),
|
||||||
|
close_timeout_seconds=_parse_positive_float(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
|
||||||
|
"10",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
|
||||||
|
default=10.0,
|
||||||
|
),
|
||||||
|
heartbeat_timeout_seconds=_parse_positive_float(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
|
||||||
|
"30",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
|
||||||
|
default=30.0,
|
||||||
|
),
|
||||||
|
scheduler_interval_seconds=_parse_positive_float(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
|
||||||
|
"5",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
|
||||||
|
default=5.0,
|
||||||
|
),
|
||||||
|
recovery_window_ms=_parse_positive_int(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_RECOVERY_WINDOW_MS",
|
||||||
|
"3599999",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_RECOVERY_WINDOW_MS",
|
||||||
|
default=3_599_999,
|
||||||
|
),
|
||||||
|
subscription_ack_timeout_seconds=_parse_positive_float(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS",
|
||||||
|
"10",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS",
|
||||||
|
default=10.0,
|
||||||
|
),
|
||||||
|
startup_market_buffer_capacity=_parse_positive_int(
|
||||||
|
os.getenv(
|
||||||
|
"TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY",
|
||||||
|
"10000",
|
||||||
|
),
|
||||||
|
name="TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY",
|
||||||
|
default=10_000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_market_data_storage_settings(
|
||||||
|
*,
|
||||||
|
trade_stream_enabled: bool,
|
||||||
|
) -> MarketDataStorageSettings:
|
||||||
|
enabled = _parse_strict_bool(
|
||||||
|
os.getenv("MARKET_DATA_STORAGE_ENABLED", "false"),
|
||||||
|
name="MARKET_DATA_STORAGE_ENABLED",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
return MarketDataStorageSettings(
|
||||||
|
enabled=False,
|
||||||
|
pool_min_size=1,
|
||||||
|
pool_max_size=4,
|
||||||
|
pool_timeout_seconds=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not trade_stream_enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TRADE_STREAM_ENABLED must be true when "
|
||||||
|
"MARKET_DATA_STORAGE_ENABLED is true"
|
||||||
|
)
|
||||||
|
|
||||||
|
pool_min_size = _parse_positive_int(
|
||||||
|
os.getenv(
|
||||||
|
"MARKET_DATA_STORAGE_POOL_MIN_SIZE",
|
||||||
|
"1",
|
||||||
|
),
|
||||||
|
name="MARKET_DATA_STORAGE_POOL_MIN_SIZE",
|
||||||
|
default=1,
|
||||||
|
)
|
||||||
|
pool_max_size = _parse_positive_int(
|
||||||
|
os.getenv(
|
||||||
|
"MARKET_DATA_STORAGE_POOL_MAX_SIZE",
|
||||||
|
"4",
|
||||||
|
),
|
||||||
|
name="MARKET_DATA_STORAGE_POOL_MAX_SIZE",
|
||||||
|
default=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
if pool_max_size < pool_min_size:
|
||||||
|
raise ValueError(
|
||||||
|
"MARKET_DATA_STORAGE_POOL_MAX_SIZE must not be smaller "
|
||||||
|
"than MARKET_DATA_STORAGE_POOL_MIN_SIZE"
|
||||||
|
)
|
||||||
|
|
||||||
|
return MarketDataStorageSettings(
|
||||||
|
enabled=True,
|
||||||
|
pool_min_size=pool_min_size,
|
||||||
|
pool_max_size=pool_max_size,
|
||||||
|
pool_timeout_seconds=_parse_positive_float(
|
||||||
|
os.getenv(
|
||||||
|
"MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS",
|
||||||
|
"10",
|
||||||
|
),
|
||||||
|
name="MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS",
|
||||||
|
default=10.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Загрузка всех настроек
|
||||||
|
def load_settings() -> Settings:
|
||||||
|
bot_token = _load_secret(
|
||||||
|
"BOT_TOKEN",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
exchange_api_key = _load_secret("EXCHANGE_API_KEY")
|
||||||
|
exchange_api_secret = _load_secret("EXCHANGE_API_SECRET")
|
||||||
|
db_password = _load_secret("DB_PASSWORD")
|
||||||
|
|
||||||
|
exchange_base_url = os.getenv(
|
||||||
|
"EXCHANGE_BASE_URL",
|
||||||
|
"",
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
trade_stream = _load_trade_stream_settings(
|
||||||
|
exchange_base_url=exchange_base_url,
|
||||||
|
)
|
||||||
|
market_data_storage = _load_market_data_storage_settings(
|
||||||
|
trade_stream_enabled=trade_stream.enabled,
|
||||||
|
)
|
||||||
|
|
||||||
return Settings(
|
return Settings(
|
||||||
# Telegram
|
# Telegram
|
||||||
bot_token=bot_token,
|
bot_token=bot_token,
|
||||||
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
|
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
|
||||||
|
|
||||||
# App
|
# Приложение
|
||||||
app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
|
app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
|
||||||
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
|
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
|
||||||
tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk",
|
tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk",
|
||||||
@@ -96,22 +505,24 @@ def load_settings() -> Settings:
|
|||||||
os.getenv("JOURNAL_DEBUG_ENABLED", "false")
|
os.getenv("JOURNAL_DEBUG_ENABLED", "false")
|
||||||
),
|
),
|
||||||
|
|
||||||
# Exchange
|
# Биржа
|
||||||
exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")),
|
exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")),
|
||||||
exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
|
exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
|
||||||
exchange_base_url=os.getenv("EXCHANGE_BASE_URL", "").strip(),
|
exchange_base_url=exchange_base_url,
|
||||||
exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(),
|
exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(),
|
||||||
exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(),
|
exchange_api_key=exchange_api_key,
|
||||||
exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(),
|
exchange_api_secret=exchange_api_secret,
|
||||||
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
|
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
|
||||||
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
||||||
default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip()
|
default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip()
|
||||||
or "ETH/USD_LEVERAGE",
|
or "ETH/USD_LEVERAGE",
|
||||||
|
trade_stream=trade_stream,
|
||||||
|
|
||||||
# Database
|
# База данных
|
||||||
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
|
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
|
||||||
db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432),
|
db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432),
|
||||||
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",
|
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",
|
||||||
db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot",
|
db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot",
|
||||||
db_password=os.getenv("DB_PASSWORD", "").strip(),
|
db_password=db_password,
|
||||||
|
market_data_storage=market_data_storage,
|
||||||
)
|
)
|
||||||
@@ -88,32 +88,6 @@ class ExchangeMarketStatus:
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# Одна свеча OHLCV.
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class Kline:
|
|
||||||
symbol: str
|
|
||||||
interval: str
|
|
||||||
|
|
||||||
open_time: int
|
|
||||||
|
|
||||||
open_price: float
|
|
||||||
high_price: float
|
|
||||||
low_price: float
|
|
||||||
close_price: float
|
|
||||||
volume: float
|
|
||||||
|
|
||||||
source: str
|
|
||||||
|
|
||||||
|
|
||||||
# Пакет свечей.
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class KlineBatch:
|
|
||||||
symbol: str
|
|
||||||
interval: str
|
|
||||||
candles: list[Kline]
|
|
||||||
source: str
|
|
||||||
|
|
||||||
|
|
||||||
# Информация о торговой комиссии для инструмента.
|
# Информация о торговой комиссии для инструмента.
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class TradingFee:
|
class TradingFee:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from urllib.error import HTTPError, URLError
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import Settings, load_settings
|
||||||
from src.integrations.exchange.exceptions import (
|
from src.integrations.exchange.exceptions import (
|
||||||
ExchangeConnectionError,
|
ExchangeConnectionError,
|
||||||
ExchangeResponseError,
|
ExchangeResponseError,
|
||||||
@@ -15,8 +15,15 @@ from src.integrations.exchange.exceptions import (
|
|||||||
|
|
||||||
|
|
||||||
class ExchangeRestClient:
|
class ExchangeRestClient:
|
||||||
def __init__(self) -> None:
|
def __init__(
|
||||||
self.settings = load_settings()
|
self,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.settings = (
|
||||||
|
settings
|
||||||
|
if settings is not None
|
||||||
|
else load_settings()
|
||||||
|
)
|
||||||
if not self.settings.exchange_base_url:
|
if not self.settings.exchange_base_url:
|
||||||
raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.")
|
raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.")
|
||||||
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ from src.integrations.exchange.models import (
|
|||||||
BalanceSummary,
|
BalanceSummary,
|
||||||
ExchangeHealth,
|
ExchangeHealth,
|
||||||
ExecutionPriceSnapshot,
|
ExecutionPriceSnapshot,
|
||||||
Kline,
|
|
||||||
KlineBatch,
|
|
||||||
PrivateAuthHealth,
|
PrivateAuthHealth,
|
||||||
SymbolValidationResult,
|
SymbolValidationResult,
|
||||||
TimeSyncStatus,
|
TimeSyncStatus,
|
||||||
@@ -41,24 +39,32 @@ from src.integrations.exchange.status import (
|
|||||||
classify_exchange_error,
|
classify_exchange_error,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||||
|
DzengiCandlesDocumentSource,
|
||||||
DzengiInstrumentDocumentSource,
|
DzengiInstrumentDocumentSource,
|
||||||
DzengiQuoteDocumentSource,
|
DzengiQuoteDocumentSource,
|
||||||
)
|
)
|
||||||
|
from src.market_data.acquisition.feeds.candles_feed import CandlesFeed
|
||||||
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
|
from src.market_data.acquisition.feeds.instrument_feed import InstrumentFeed
|
||||||
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
|
from src.market_data.acquisition.feeds.quotes_feed import QuotesFeed
|
||||||
|
from src.market_data.acquisition.handlers.candles_handler import (
|
||||||
|
DzengiCandlesDocumentHandler,
|
||||||
|
)
|
||||||
from src.market_data.acquisition.handlers.instrument_handler import (
|
from src.market_data.acquisition.handlers.instrument_handler import (
|
||||||
DzengiInstrumentDocumentHandler,
|
DzengiInstrumentDocumentHandler,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.handlers.quotes_handler import (
|
from src.market_data.acquisition.handlers.quotes_handler import (
|
||||||
DzengiQuoteDocumentHandler,
|
DzengiQuoteDocumentHandler,
|
||||||
)
|
)
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
from src.market_data.acquisition.registry import (
|
from src.market_data.acquisition.registry import (
|
||||||
|
CandlesFeedRegistry,
|
||||||
InstrumentFeedRegistry,
|
InstrumentFeedRegistry,
|
||||||
QuoteFeedRegistry,
|
QuoteFeedRegistry,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.service import (
|
from src.market_data.acquisition.service import (
|
||||||
|
CandlesAcquisitionService,
|
||||||
InstrumentAcquisitionService,
|
InstrumentAcquisitionService,
|
||||||
QuoteAcquisitionService,
|
QuoteAcquisitionService,
|
||||||
)
|
)
|
||||||
@@ -79,6 +85,7 @@ from src.trading.journal.service import JournalService
|
|||||||
|
|
||||||
_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
|
_INSTRUMENT_REFERENCE_SOURCE_NAME = "dzengi"
|
||||||
_QUOTE_SOURCE_NAME = "dzengi"
|
_QUOTE_SOURCE_NAME = "dzengi"
|
||||||
|
_CANDLES_SOURCE_NAME = "dzengi"
|
||||||
|
|
||||||
|
|
||||||
class ExchangeService:
|
class ExchangeService:
|
||||||
@@ -491,15 +498,15 @@ class ExchangeService:
|
|||||||
def _runtime_key(self, runtime_key: str | None) -> str:
|
def _runtime_key(self, runtime_key: str | None) -> str:
|
||||||
return (runtime_key or self._default_runtime_key).strip().lower()
|
return (runtime_key or self._default_runtime_key).strip().lower()
|
||||||
|
|
||||||
# Получить свечи инструмента через REST API.
|
# Получить канонические свечи инструмента через Candles Feed.
|
||||||
def get_klines(
|
def get_candles(
|
||||||
self,
|
self,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
*,
|
*,
|
||||||
interval: str = "1m",
|
interval: str = "1m",
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
price_type: str = "bid",
|
price_type: str = "bid",
|
||||||
) -> KlineBatch:
|
) -> tuple[Candle, ...]:
|
||||||
symbol_to_use = symbol or self.settings.default_symbol
|
symbol_to_use = symbol or self.settings.default_symbol
|
||||||
|
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
@@ -517,23 +524,20 @@ class ExchangeService:
|
|||||||
normalized_price_type = "bid"
|
normalized_price_type = "bid"
|
||||||
|
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
raise ExchangeError("Klines are not available in mock exchange mode.")
|
raise ExchangeError(
|
||||||
|
"Candles are not available in mock exchange mode."
|
||||||
|
)
|
||||||
|
|
||||||
validation = self.validate_symbol(symbol_to_use)
|
validation = self.validate_symbol(symbol_to_use)
|
||||||
if not validation.is_valid:
|
if not validation.is_valid:
|
||||||
raise ExchangeError(validation.message)
|
raise ExchangeError(validation.message)
|
||||||
|
|
||||||
client = ExchangeRestClient()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = client.get_payload(
|
return self._load_candles_via_acquisition(
|
||||||
"/api/v1/klines",
|
symbol=validation.normalized_symbol,
|
||||||
params={
|
interval=interval,
|
||||||
"symbol": validation.normalized_symbol,
|
limit=limit,
|
||||||
"interval": interval,
|
price_type=normalized_price_type,
|
||||||
"limit": str(limit),
|
|
||||||
"priceType": normalized_price_type,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log_exchange_error(
|
self._log_exchange_error(
|
||||||
@@ -548,150 +552,41 @@ class ExchangeService:
|
|||||||
)
|
)
|
||||||
raise ExchangeError(str(exc)) from exc
|
raise ExchangeError(str(exc)) from exc
|
||||||
|
|
||||||
candles = self._parse_klines_payload(
|
# Собрать Candles acquisition pipeline и вернуть канонические модели Candle.
|
||||||
payload=payload,
|
def _load_candles_via_acquisition(
|
||||||
symbol=validation.normalized_symbol,
|
|
||||||
interval=interval,
|
|
||||||
source=f"rest_klines:{normalized_price_type}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return KlineBatch(
|
|
||||||
symbol=validation.normalized_symbol,
|
|
||||||
interval=interval,
|
|
||||||
candles=candles[-limit:],
|
|
||||||
source=f"rest_klines:{normalized_price_type}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Преобразовать сырой payload свечей в список Kline.
|
|
||||||
def _parse_klines_payload(
|
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
payload: object,
|
|
||||||
symbol: str,
|
symbol: str,
|
||||||
interval: str,
|
interval: str,
|
||||||
source: str,
|
limit: int,
|
||||||
) -> list[Kline]:
|
price_type: str,
|
||||||
raw_items = self._extract_klines_items(payload)
|
) -> tuple[Candle, ...]:
|
||||||
|
source = DzengiCandlesDocumentSource()
|
||||||
|
handler = DzengiCandlesDocumentHandler()
|
||||||
|
|
||||||
candles: list[Kline] = []
|
feed = CandlesFeed(
|
||||||
|
|
||||||
for item in raw_items:
|
|
||||||
candle = self._parse_kline_item(
|
|
||||||
item=item,
|
|
||||||
symbol=symbol,
|
|
||||||
interval=interval,
|
|
||||||
source=source,
|
source=source,
|
||||||
|
handler=handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
if candle is not None:
|
registry = CandlesFeedRegistry()
|
||||||
candles.append(candle)
|
registry.register(
|
||||||
|
_CANDLES_SOURCE_NAME,
|
||||||
candles.sort(key=lambda item: item.open_time)
|
feed,
|
||||||
|
|
||||||
return candles
|
|
||||||
|
|
||||||
# Извлечь массив свечей из разных возможных форматов ответа API.
|
|
||||||
def _extract_klines_items(self, payload: object) -> list[object]:
|
|
||||||
if isinstance(payload, list):
|
|
||||||
return payload
|
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return []
|
|
||||||
|
|
||||||
for key in ("klines", "candles", "data", "result"):
|
|
||||||
value = payload.get(key)
|
|
||||||
if isinstance(value, list):
|
|
||||||
return value
|
|
||||||
|
|
||||||
inner = payload.get("payload")
|
|
||||||
|
|
||||||
if isinstance(inner, list):
|
|
||||||
return inner
|
|
||||||
|
|
||||||
if isinstance(inner, dict):
|
|
||||||
for key in ("klines", "candles", "data"):
|
|
||||||
value = inner.get(key)
|
|
||||||
if isinstance(value, list):
|
|
||||||
return value
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Преобразовать одну свечу из dict/list формата в Kline.
|
|
||||||
def _parse_kline_item(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
item: object,
|
|
||||||
symbol: str,
|
|
||||||
interval: str,
|
|
||||||
source: str,
|
|
||||||
) -> Kline | None:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
open_time = (
|
|
||||||
item.get("openTime")
|
|
||||||
or item.get("open_time")
|
|
||||||
or item.get("time")
|
|
||||||
or item.get("timestamp")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
open_time_value = safe_float(open_time)
|
acquisition_service = CandlesAcquisitionService(
|
||||||
open_price = safe_float(item.get("open"))
|
registry=registry,
|
||||||
high_price = safe_float(item.get("high"))
|
)
|
||||||
low_price = safe_float(item.get("low"))
|
|
||||||
close_price = safe_float(item.get("close"))
|
|
||||||
volume = safe_float(item.get("volume")) or 0.0
|
|
||||||
|
|
||||||
if (
|
return acquisition_service.load_candles(
|
||||||
open_time_value is None
|
_CANDLES_SOURCE_NAME,
|
||||||
or open_price is None
|
symbol,
|
||||||
or high_price is None
|
|
||||||
or low_price is None
|
|
||||||
or close_price is None
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return Kline(
|
|
||||||
symbol=symbol,
|
|
||||||
interval=interval,
|
interval=interval,
|
||||||
open_time=int(open_time_value),
|
limit=limit,
|
||||||
open_price=open_price,
|
price_type=price_type,
|
||||||
high_price=high_price,
|
|
||||||
low_price=low_price,
|
|
||||||
close_price=close_price,
|
|
||||||
volume=volume,
|
|
||||||
source=source,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(item, list) and len(item) >= 6:
|
|
||||||
open_time_value = safe_float(item[0])
|
|
||||||
open_price = safe_float(item[1])
|
|
||||||
high_price = safe_float(item[2])
|
|
||||||
low_price = safe_float(item[3])
|
|
||||||
close_price = safe_float(item[4])
|
|
||||||
volume = safe_float(item[5]) or 0.0
|
|
||||||
|
|
||||||
if (
|
|
||||||
open_time_value is None
|
|
||||||
or open_price is None
|
|
||||||
or high_price is None
|
|
||||||
or low_price is None
|
|
||||||
or close_price is None
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return Kline(
|
|
||||||
symbol=symbol,
|
|
||||||
interval=interval,
|
|
||||||
open_time=int(open_time_value),
|
|
||||||
open_price=open_price,
|
|
||||||
high_price=high_price,
|
|
||||||
low_price=low_price,
|
|
||||||
close_price=close_price,
|
|
||||||
volume=volume,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Проверить публичную доступность биржи.
|
# Проверить публичную доступность биржи.
|
||||||
def get_health(self) -> ExchangeHealth:
|
def get_health(self) -> ExchangeHealth:
|
||||||
if not self.settings.exchange_enabled:
|
if not self.settings.exchange_enabled:
|
||||||
|
|||||||
@@ -2,25 +2,13 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from src.bootstrap.application import run_application
|
||||||
from src.bootstrap.app_factory import create_app
|
from src.bootstrap.app_factory import create_app
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
# создаём bot + dispatcher
|
application = create_app()
|
||||||
bot, dispatcher = create_app()
|
await run_application(application)
|
||||||
|
|
||||||
# WebSocket stream временно отключён.
|
|
||||||
# Причина: Dzengi Swagger содержит wss:/api/v1/* endpoints,
|
|
||||||
# но runtime probe не нашёл endpoint с WebSocket Upgrade 101.
|
|
||||||
#
|
|
||||||
# Когда Dzengi подтвердит рабочий WS endpoint,
|
|
||||||
# можно будет вернуть запуск:
|
|
||||||
#
|
|
||||||
# from src.integrations.exchange.market_stream import start_market_stream
|
|
||||||
# market_stream_task = asyncio.create_task(start_market_stream())
|
|
||||||
|
|
||||||
# запускаем Telegram polling
|
|
||||||
await dispatcher.start_polling(bot)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# app/src/market_data/__init__.py
|
||||||
86
app/src/market_data/access/__init__.py
Normal file
86
app/src/market_data/access/__init__.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from src.market_data.access.contracts import (
|
||||||
|
CandleRevisionHistoryReaderProtocol,
|
||||||
|
MarketDataHistoricalAccessProtocol,
|
||||||
|
QuoteHistoryReaderProtocol,
|
||||||
|
TradeHistoryReaderProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.access.exceptions import (
|
||||||
|
MarketDataAccessError,
|
||||||
|
MarketDataAccessIntegrityError,
|
||||||
|
MarketDataAccessOperationError,
|
||||||
|
MarketDataAccessValidationError,
|
||||||
|
MarketDataCursorError,
|
||||||
|
)
|
||||||
|
from src.market_data.access.market_data_historical_access import (
|
||||||
|
MarketDataHistoricalAccess,
|
||||||
|
)
|
||||||
|
from src.market_data.access.models import (
|
||||||
|
HISTORY_CURSOR_VERSION,
|
||||||
|
HISTORY_PAGE_LIMIT_DEFAULT,
|
||||||
|
HISTORY_PAGE_LIMIT_MAX,
|
||||||
|
CandleRevisionHistoryCursor,
|
||||||
|
CandleRevisionHistoryPage,
|
||||||
|
CandleRevisionHistoryQuery,
|
||||||
|
CandleRevisionHistoryRecord,
|
||||||
|
HistoricalTimeRange,
|
||||||
|
HistoryPage,
|
||||||
|
HistoryQuery,
|
||||||
|
HistoryRecord,
|
||||||
|
QuoteHistoryCursor,
|
||||||
|
QuoteHistoryPage,
|
||||||
|
QuoteHistoryQuery,
|
||||||
|
QuoteHistoryRecord,
|
||||||
|
TradeHistoryCursor,
|
||||||
|
TradeHistoryPage,
|
||||||
|
TradeHistoryQuery,
|
||||||
|
TradeHistoryRecord,
|
||||||
|
)
|
||||||
|
from src.market_data.access.postgres_candle_revision_history_repository import (
|
||||||
|
PostgresCandleRevisionHistoryRepository,
|
||||||
|
)
|
||||||
|
from src.market_data.access.postgres_history_support import (
|
||||||
|
PostgresHistoryConnectionProvider,
|
||||||
|
)
|
||||||
|
from src.market_data.access.postgres_quote_history_repository import (
|
||||||
|
PostgresQuoteHistoryRepository,
|
||||||
|
)
|
||||||
|
from src.market_data.access.postgres_trade_history_repository import (
|
||||||
|
PostgresTradeHistoryRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"HISTORY_CURSOR_VERSION",
|
||||||
|
"HISTORY_PAGE_LIMIT_DEFAULT",
|
||||||
|
"HISTORY_PAGE_LIMIT_MAX",
|
||||||
|
"CandleRevisionHistoryCursor",
|
||||||
|
"CandleRevisionHistoryPage",
|
||||||
|
"CandleRevisionHistoryQuery",
|
||||||
|
"CandleRevisionHistoryReaderProtocol",
|
||||||
|
"CandleRevisionHistoryRecord",
|
||||||
|
"HistoricalTimeRange",
|
||||||
|
"HistoryPage",
|
||||||
|
"HistoryQuery",
|
||||||
|
"HistoryRecord",
|
||||||
|
"MarketDataAccessError",
|
||||||
|
"MarketDataAccessIntegrityError",
|
||||||
|
"MarketDataAccessOperationError",
|
||||||
|
"MarketDataAccessValidationError",
|
||||||
|
"MarketDataCursorError",
|
||||||
|
"MarketDataHistoricalAccess",
|
||||||
|
"MarketDataHistoricalAccessProtocol",
|
||||||
|
"PostgresCandleRevisionHistoryRepository",
|
||||||
|
"PostgresHistoryConnectionProvider",
|
||||||
|
"PostgresQuoteHistoryRepository",
|
||||||
|
"PostgresTradeHistoryRepository",
|
||||||
|
"QuoteHistoryCursor",
|
||||||
|
"QuoteHistoryPage",
|
||||||
|
"QuoteHistoryQuery",
|
||||||
|
"QuoteHistoryReaderProtocol",
|
||||||
|
"QuoteHistoryRecord",
|
||||||
|
"TradeHistoryCursor",
|
||||||
|
"TradeHistoryPage",
|
||||||
|
"TradeHistoryQuery",
|
||||||
|
"TradeHistoryReaderProtocol",
|
||||||
|
"TradeHistoryRecord",
|
||||||
|
)
|
||||||
55
app/src/market_data/access/contracts.py
Normal file
55
app/src/market_data/access/contracts.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.access.models import (
|
||||||
|
CandleRevisionHistoryPage,
|
||||||
|
CandleRevisionHistoryQuery,
|
||||||
|
QuoteHistoryPage,
|
||||||
|
QuoteHistoryQuery,
|
||||||
|
TradeHistoryPage,
|
||||||
|
TradeHistoryQuery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TradeHistoryReaderProtocol(Protocol):
|
||||||
|
"""Граница чтения одной страницы Canonical Trade History."""
|
||||||
|
|
||||||
|
def query_trades(
|
||||||
|
self,
|
||||||
|
query: TradeHistoryQuery,
|
||||||
|
) -> TradeHistoryPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class QuoteHistoryReaderProtocol(Protocol):
|
||||||
|
"""Граница чтения одной страницы Canonical Quote History."""
|
||||||
|
|
||||||
|
def query_quotes(
|
||||||
|
self,
|
||||||
|
query: QuoteHistoryQuery,
|
||||||
|
) -> QuoteHistoryPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CandleRevisionHistoryReaderProtocol(Protocol):
|
||||||
|
"""Граница чтения одной страницы Canonical Candle revisions."""
|
||||||
|
|
||||||
|
def query_candle_revisions(
|
||||||
|
self,
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
) -> CandleRevisionHistoryPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MarketDataHistoricalAccessProtocol(
|
||||||
|
TradeHistoryReaderProtocol,
|
||||||
|
QuoteHistoryReaderProtocol,
|
||||||
|
CandleRevisionHistoryReaderProtocol,
|
||||||
|
Protocol,
|
||||||
|
):
|
||||||
|
"""Объединённая DB-neutral граница Historical Access."""
|
||||||
21
app/src/market_data/access/exceptions.py
Normal file
21
app/src/market_data/access/exceptions.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataAccessError(Exception):
|
||||||
|
"""Базовая ошибка доступа к историческим рыночным данным."""
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataAccessValidationError(MarketDataAccessError):
|
||||||
|
"""Недопустимый запрос или значение на публичной границе чтения."""
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataCursorError(MarketDataAccessValidationError):
|
||||||
|
"""Курсор повреждён либо принадлежит другому запросу."""
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataAccessIntegrityError(MarketDataAccessError):
|
||||||
|
"""Сохранённая строка не соответствует Canonical-контракту."""
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataAccessOperationError(MarketDataAccessError):
|
||||||
|
"""Ошибка backend-операции Historical Access."""
|
||||||
76
app/src/market_data/access/market_data_historical_access.py
Normal file
76
app/src/market_data/access/market_data_historical_access.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.access.contracts import (
|
||||||
|
CandleRevisionHistoryReaderProtocol,
|
||||||
|
QuoteHistoryReaderProtocol,
|
||||||
|
TradeHistoryReaderProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.access.models import (
|
||||||
|
CandleRevisionHistoryPage,
|
||||||
|
CandleRevisionHistoryQuery,
|
||||||
|
QuoteHistoryPage,
|
||||||
|
QuoteHistoryQuery,
|
||||||
|
TradeHistoryPage,
|
||||||
|
TradeHistoryQuery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataHistoricalAccess:
|
||||||
|
"""Независимый от базы данных фасад трёх модулей чтения истории."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_trade_reader",
|
||||||
|
"_quote_reader",
|
||||||
|
"_candle_revision_reader",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
trade_reader: TradeHistoryReaderProtocol,
|
||||||
|
quote_reader: QuoteHistoryReaderProtocol,
|
||||||
|
candle_revision_reader: CandleRevisionHistoryReaderProtocol,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(trade_reader, TradeHistoryReaderProtocol):
|
||||||
|
raise TypeError(
|
||||||
|
"trade_reader must implement TradeHistoryReaderProtocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(quote_reader, QuoteHistoryReaderProtocol):
|
||||||
|
raise TypeError(
|
||||||
|
"quote_reader must implement QuoteHistoryReaderProtocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
candle_revision_reader,
|
||||||
|
CandleRevisionHistoryReaderProtocol,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"candle_revision_reader must implement "
|
||||||
|
"CandleRevisionHistoryReaderProtocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._trade_reader = trade_reader
|
||||||
|
self._quote_reader = quote_reader
|
||||||
|
self._candle_revision_reader = candle_revision_reader
|
||||||
|
|
||||||
|
def query_trades(
|
||||||
|
self,
|
||||||
|
query: TradeHistoryQuery,
|
||||||
|
) -> TradeHistoryPage:
|
||||||
|
"""Передать запрос истории сделок соответствующему модулю."""
|
||||||
|
return self._trade_reader.query_trades(query)
|
||||||
|
|
||||||
|
def query_quotes(
|
||||||
|
self,
|
||||||
|
query: QuoteHistoryQuery,
|
||||||
|
) -> QuoteHistoryPage:
|
||||||
|
"""Передать запрос истории котировок соответствующему модулю."""
|
||||||
|
return self._quote_reader.query_quotes(query)
|
||||||
|
|
||||||
|
def query_candle_revisions(
|
||||||
|
self,
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
) -> CandleRevisionHistoryPage:
|
||||||
|
"""Передать запрос истории свечей соответствующему модулю."""
|
||||||
|
return self._candle_revision_reader.query_candle_revisions(query)
|
||||||
901
app/src/market_data/access/models.py
Normal file
901
app/src/market_data/access/models.py
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.symbols import normalize_symbol
|
||||||
|
|
||||||
|
|
||||||
|
HISTORY_CURSOR_VERSION = 1
|
||||||
|
HISTORY_PAGE_LIMIT_DEFAULT = 500
|
||||||
|
HISTORY_PAGE_LIMIT_MAX = 1_000
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_non_empty_text(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
uppercase: bool = False,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError(f"{field_name} must be a string")
|
||||||
|
|
||||||
|
normalized = value.strip()
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{field_name} must not be empty")
|
||||||
|
|
||||||
|
return normalized.upper() if uppercase else normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_symbol(value: object, *, field_name: str) -> str:
|
||||||
|
raw_symbol = _normalize_non_empty_text(
|
||||||
|
value,
|
||||||
|
field_name=field_name,
|
||||||
|
)
|
||||||
|
normalized = normalize_symbol(raw_symbol)
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{field_name} must not be empty")
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_utc_datetime(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> datetime:
|
||||||
|
if not isinstance(value, datetime):
|
||||||
|
raise TypeError(f"{field_name} must be a datetime")
|
||||||
|
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise ValueError(f"{field_name} must contain timezone")
|
||||||
|
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_positive_integer(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
maximum: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise TypeError(f"{field_name} must be an integer")
|
||||||
|
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError(f"{field_name} must be positive")
|
||||||
|
|
||||||
|
if maximum is not None and value > maximum:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must not exceed {maximum}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_observation_sources(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
primary_source: object,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, tuple):
|
||||||
|
raise TypeError("observation_sources must be a tuple")
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise ValueError("observation_sources must not be empty")
|
||||||
|
|
||||||
|
normalized = tuple(
|
||||||
|
_normalize_non_empty_text(
|
||||||
|
source,
|
||||||
|
field_name="observation_sources item",
|
||||||
|
)
|
||||||
|
for source in value
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise ValueError("observation_sources must be unique")
|
||||||
|
|
||||||
|
normalized_primary = _normalize_non_empty_text(
|
||||||
|
primary_source,
|
||||||
|
field_name="payload.source",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized[0] != normalized_primary:
|
||||||
|
raise ValueError(
|
||||||
|
"first observation source must match payload.source"
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_canonical_symbol_and_source(
|
||||||
|
*,
|
||||||
|
symbol: object,
|
||||||
|
source: object,
|
||||||
|
) -> None:
|
||||||
|
normalized_symbol = _normalize_symbol(
|
||||||
|
symbol,
|
||||||
|
field_name="payload.symbol",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_symbol != symbol:
|
||||||
|
raise ValueError("payload.symbol must be canonical")
|
||||||
|
|
||||||
|
normalized_source = _normalize_non_empty_text(
|
||||||
|
source,
|
||||||
|
field_name="payload.source",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_source != source:
|
||||||
|
raise ValueError("payload.source must be canonical")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HistoricalTimeRange:
|
||||||
|
"""Полуоткрытый UTC-диапазон исторического запроса ``[start, end)``."""
|
||||||
|
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_start = _normalize_utc_datetime(
|
||||||
|
self.start_time,
|
||||||
|
field_name="start_time",
|
||||||
|
)
|
||||||
|
normalized_end = _normalize_utc_datetime(
|
||||||
|
self.end_time,
|
||||||
|
field_name="end_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_start >= normalized_end:
|
||||||
|
raise ValueError("start_time must be earlier than end_time")
|
||||||
|
|
||||||
|
object.__setattr__(self, "start_time", normalized_start)
|
||||||
|
object.__setattr__(self, "end_time", normalized_end)
|
||||||
|
|
||||||
|
def contains(self, instant: datetime) -> bool:
|
||||||
|
"""Проверить принадлежность времени полуоткрытому диапазону."""
|
||||||
|
normalized = _normalize_utc_datetime(
|
||||||
|
instant,
|
||||||
|
field_name="instant",
|
||||||
|
)
|
||||||
|
return self.start_time <= normalized < self.end_time
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeHistoryRecord:
|
||||||
|
"""Canonical Trade и неизменяемые технические данные его хранения."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
trade: Trade
|
||||||
|
first_observed_at: datetime
|
||||||
|
last_observed_at: datetime
|
||||||
|
observation_sources: tuple[str, ...]
|
||||||
|
replay_sequence: int
|
||||||
|
canonical_schema_version: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_venue = _normalize_non_empty_text(
|
||||||
|
self.venue,
|
||||||
|
field_name="venue",
|
||||||
|
)
|
||||||
|
|
||||||
|
if type(self.trade) is not Trade:
|
||||||
|
raise TypeError("trade must be a Canonical Trade")
|
||||||
|
|
||||||
|
_validate_canonical_symbol_and_source(
|
||||||
|
symbol=self.trade.symbol,
|
||||||
|
source=self.trade.source,
|
||||||
|
)
|
||||||
|
_normalize_utc_datetime(
|
||||||
|
self.trade.executed_at,
|
||||||
|
field_name="trade.executed_at",
|
||||||
|
)
|
||||||
|
normalized_first = _normalize_utc_datetime(
|
||||||
|
self.first_observed_at,
|
||||||
|
field_name="first_observed_at",
|
||||||
|
)
|
||||||
|
normalized_last = _normalize_utc_datetime(
|
||||||
|
self.last_observed_at,
|
||||||
|
field_name="last_observed_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_last < normalized_first:
|
||||||
|
raise ValueError(
|
||||||
|
"last_observed_at must not precede first_observed_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_sources = _normalize_observation_sources(
|
||||||
|
self.observation_sources,
|
||||||
|
primary_source=self.trade.source,
|
||||||
|
)
|
||||||
|
normalized_sequence = _normalize_positive_integer(
|
||||||
|
self.replay_sequence,
|
||||||
|
field_name="replay_sequence",
|
||||||
|
)
|
||||||
|
normalized_version = _normalize_positive_integer(
|
||||||
|
self.canonical_schema_version,
|
||||||
|
field_name="canonical_schema_version",
|
||||||
|
)
|
||||||
|
|
||||||
|
object.__setattr__(self, "venue", normalized_venue)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"first_observed_at",
|
||||||
|
normalized_first,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"last_observed_at",
|
||||||
|
normalized_last,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"observation_sources",
|
||||||
|
normalized_sources,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"replay_sequence",
|
||||||
|
normalized_sequence,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"canonical_schema_version",
|
||||||
|
normalized_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbol(self) -> str:
|
||||||
|
return self.trade.symbol
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event_time(self) -> datetime:
|
||||||
|
return self.trade.executed_at.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_key(self) -> tuple[datetime, int]:
|
||||||
|
return (self.event_time, self.replay_sequence)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QuoteHistoryRecord:
|
||||||
|
"""Canonical Quote и неизменяемые технические данные его хранения."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
quote: Quote
|
||||||
|
observation_sources: tuple[str, ...]
|
||||||
|
replay_sequence: int
|
||||||
|
canonical_schema_version: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_venue = _normalize_non_empty_text(
|
||||||
|
self.venue,
|
||||||
|
field_name="venue",
|
||||||
|
)
|
||||||
|
|
||||||
|
if type(self.quote) is not Quote:
|
||||||
|
raise TypeError("quote must be a Canonical Quote")
|
||||||
|
|
||||||
|
_validate_canonical_symbol_and_source(
|
||||||
|
symbol=self.quote.symbol,
|
||||||
|
source=self.quote.source,
|
||||||
|
)
|
||||||
|
_normalize_utc_datetime(
|
||||||
|
self.quote.received_at,
|
||||||
|
field_name="quote.received_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.quote.exchange_timestamp is not None:
|
||||||
|
_normalize_utc_datetime(
|
||||||
|
self.quote.exchange_timestamp,
|
||||||
|
field_name="quote.exchange_timestamp",
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_sources = _normalize_observation_sources(
|
||||||
|
self.observation_sources,
|
||||||
|
primary_source=self.quote.source,
|
||||||
|
)
|
||||||
|
normalized_sequence = _normalize_positive_integer(
|
||||||
|
self.replay_sequence,
|
||||||
|
field_name="replay_sequence",
|
||||||
|
)
|
||||||
|
normalized_version = _normalize_positive_integer(
|
||||||
|
self.canonical_schema_version,
|
||||||
|
field_name="canonical_schema_version",
|
||||||
|
)
|
||||||
|
|
||||||
|
object.__setattr__(self, "venue", normalized_venue)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"observation_sources",
|
||||||
|
normalized_sources,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"replay_sequence",
|
||||||
|
normalized_sequence,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"canonical_schema_version",
|
||||||
|
normalized_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbol(self) -> str:
|
||||||
|
return self.quote.symbol
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event_time(self) -> datetime:
|
||||||
|
return self.quote.received_at.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_key(self) -> tuple[datetime, int]:
|
||||||
|
return (self.event_time, self.replay_sequence)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CandleRevisionHistoryRecord:
|
||||||
|
"""Canonical Candle и метаданные одной наблюдавшейся ревизии."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
candle: Candle
|
||||||
|
observed_at: datetime
|
||||||
|
is_final: bool
|
||||||
|
observation_sources: tuple[str, ...]
|
||||||
|
replay_sequence: int
|
||||||
|
canonical_schema_version: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_venue = _normalize_non_empty_text(
|
||||||
|
self.venue,
|
||||||
|
field_name="venue",
|
||||||
|
)
|
||||||
|
|
||||||
|
if type(self.candle) is not Candle:
|
||||||
|
raise TypeError("candle must be a Canonical Candle")
|
||||||
|
|
||||||
|
_validate_canonical_symbol_and_source(
|
||||||
|
symbol=self.candle.symbol,
|
||||||
|
source=self.candle.source,
|
||||||
|
)
|
||||||
|
normalized_interval = _normalize_non_empty_text(
|
||||||
|
self.candle.interval,
|
||||||
|
field_name="candle.interval",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_interval != self.candle.interval:
|
||||||
|
raise ValueError("candle.interval must be canonical")
|
||||||
|
normalized_open_time = _normalize_utc_datetime(
|
||||||
|
self.candle.open_time,
|
||||||
|
field_name="candle.open_time",
|
||||||
|
)
|
||||||
|
normalized_observed_at = _normalize_utc_datetime(
|
||||||
|
self.observed_at,
|
||||||
|
field_name="observed_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_observed_at < normalized_open_time:
|
||||||
|
raise ValueError("observed_at must not precede candle.open_time")
|
||||||
|
|
||||||
|
if not isinstance(self.is_final, bool):
|
||||||
|
raise TypeError("is_final must be a boolean")
|
||||||
|
|
||||||
|
normalized_sources = _normalize_observation_sources(
|
||||||
|
self.observation_sources,
|
||||||
|
primary_source=self.candle.source,
|
||||||
|
)
|
||||||
|
normalized_sequence = _normalize_positive_integer(
|
||||||
|
self.replay_sequence,
|
||||||
|
field_name="replay_sequence",
|
||||||
|
)
|
||||||
|
normalized_version = _normalize_positive_integer(
|
||||||
|
self.canonical_schema_version,
|
||||||
|
field_name="canonical_schema_version",
|
||||||
|
)
|
||||||
|
|
||||||
|
object.__setattr__(self, "venue", normalized_venue)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"observed_at",
|
||||||
|
normalized_observed_at,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"observation_sources",
|
||||||
|
normalized_sources,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"replay_sequence",
|
||||||
|
normalized_sequence,
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"canonical_schema_version",
|
||||||
|
normalized_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbol(self) -> str:
|
||||||
|
return self.candle.symbol
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interval(self) -> str:
|
||||||
|
return self.candle.interval
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event_time(self) -> datetime:
|
||||||
|
"""Вернуть ось Historical Query — время открытия свечи."""
|
||||||
|
return self.candle.open_time.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def replay_at(self) -> datetime:
|
||||||
|
"""Вернуть ось Replay — время наблюдения конкретной ревизии."""
|
||||||
|
return self.observed_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_key(self) -> tuple[datetime, int]:
|
||||||
|
return (self.event_time, self.replay_sequence)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeHistoryCursor:
|
||||||
|
"""Структурированный keyset-курсор Trade History."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
symbol: str
|
||||||
|
time_range: HistoricalTimeRange
|
||||||
|
executed_at: datetime
|
||||||
|
replay_sequence: int
|
||||||
|
version: int = HISTORY_CURSOR_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_venue, normalized_symbol = _normalize_cursor_scope(
|
||||||
|
venue=self.venue,
|
||||||
|
symbol=self.symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
normalized_time = _normalize_cursor_position(
|
||||||
|
cursor_time=self.executed_at,
|
||||||
|
replay_sequence=self.replay_sequence,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
_validate_cursor_version(self.version)
|
||||||
|
|
||||||
|
object.__setattr__(self, "venue", normalized_venue)
|
||||||
|
object.__setattr__(self, "symbol", normalized_symbol)
|
||||||
|
object.__setattr__(self, "executed_at", normalized_time)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QuoteHistoryCursor:
|
||||||
|
"""Структурированный keyset-курсор Quote History."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
symbol: str
|
||||||
|
time_range: HistoricalTimeRange
|
||||||
|
received_at: datetime
|
||||||
|
replay_sequence: int
|
||||||
|
version: int = HISTORY_CURSOR_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_venue, normalized_symbol = _normalize_cursor_scope(
|
||||||
|
venue=self.venue,
|
||||||
|
symbol=self.symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
normalized_time = _normalize_cursor_position(
|
||||||
|
cursor_time=self.received_at,
|
||||||
|
replay_sequence=self.replay_sequence,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
_validate_cursor_version(self.version)
|
||||||
|
|
||||||
|
object.__setattr__(self, "venue", normalized_venue)
|
||||||
|
object.__setattr__(self, "symbol", normalized_symbol)
|
||||||
|
object.__setattr__(self, "received_at", normalized_time)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CandleRevisionHistoryCursor:
|
||||||
|
"""Структурированный keyset-курсор Candle Revision History."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
symbol: str
|
||||||
|
interval: str
|
||||||
|
time_range: HistoricalTimeRange
|
||||||
|
open_time: datetime
|
||||||
|
replay_sequence: int
|
||||||
|
version: int = HISTORY_CURSOR_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_venue, normalized_symbol = _normalize_cursor_scope(
|
||||||
|
venue=self.venue,
|
||||||
|
symbol=self.symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
normalized_interval = _normalize_non_empty_text(
|
||||||
|
self.interval,
|
||||||
|
field_name="interval",
|
||||||
|
)
|
||||||
|
normalized_time = _normalize_cursor_position(
|
||||||
|
cursor_time=self.open_time,
|
||||||
|
replay_sequence=self.replay_sequence,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
_validate_cursor_version(self.version)
|
||||||
|
|
||||||
|
object.__setattr__(self, "venue", normalized_venue)
|
||||||
|
object.__setattr__(self, "symbol", normalized_symbol)
|
||||||
|
object.__setattr__(self, "interval", normalized_interval)
|
||||||
|
object.__setattr__(self, "open_time", normalized_time)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cursor_scope(
|
||||||
|
*,
|
||||||
|
venue: object,
|
||||||
|
symbol: object,
|
||||||
|
time_range: object,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
if type(time_range) is not HistoricalTimeRange:
|
||||||
|
raise TypeError("time_range must be HistoricalTimeRange")
|
||||||
|
|
||||||
|
return (
|
||||||
|
_normalize_non_empty_text(venue, field_name="venue"),
|
||||||
|
_normalize_symbol(symbol, field_name="symbol"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cursor_position(
|
||||||
|
*,
|
||||||
|
cursor_time: object,
|
||||||
|
replay_sequence: object,
|
||||||
|
time_range: HistoricalTimeRange,
|
||||||
|
) -> datetime:
|
||||||
|
normalized_time = _normalize_utc_datetime(
|
||||||
|
cursor_time,
|
||||||
|
field_name="cursor time",
|
||||||
|
)
|
||||||
|
_normalize_positive_integer(
|
||||||
|
replay_sequence,
|
||||||
|
field_name="replay_sequence",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not time_range.contains(normalized_time):
|
||||||
|
raise ValueError("cursor time must belong to query range")
|
||||||
|
|
||||||
|
return normalized_time
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_cursor_version(version: object) -> None:
|
||||||
|
if isinstance(version, bool) or not isinstance(version, int):
|
||||||
|
raise TypeError("version must be an integer")
|
||||||
|
|
||||||
|
if version != HISTORY_CURSOR_VERSION:
|
||||||
|
raise ValueError("unsupported history cursor version")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeHistoryQuery:
|
||||||
|
"""Один forward-only запрос истории Trades."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
symbol: str
|
||||||
|
time_range: HistoricalTimeRange
|
||||||
|
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
|
||||||
|
cursor: TradeHistoryCursor | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
venue, symbol = _normalize_query(
|
||||||
|
venue=self.venue,
|
||||||
|
symbol=self.symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
limit=self.limit,
|
||||||
|
)
|
||||||
|
_validate_query_cursor(
|
||||||
|
cursor=self.cursor,
|
||||||
|
cursor_type=TradeHistoryCursor,
|
||||||
|
venue=venue,
|
||||||
|
symbol=symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "venue", venue)
|
||||||
|
object.__setattr__(self, "symbol", symbol)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QuoteHistoryQuery:
|
||||||
|
"""Один forward-only запрос истории Quotes."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
symbol: str
|
||||||
|
time_range: HistoricalTimeRange
|
||||||
|
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
|
||||||
|
cursor: QuoteHistoryCursor | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
venue, symbol = _normalize_query(
|
||||||
|
venue=self.venue,
|
||||||
|
symbol=self.symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
limit=self.limit,
|
||||||
|
)
|
||||||
|
_validate_query_cursor(
|
||||||
|
cursor=self.cursor,
|
||||||
|
cursor_type=QuoteHistoryCursor,
|
||||||
|
venue=venue,
|
||||||
|
symbol=symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "venue", venue)
|
||||||
|
object.__setattr__(self, "symbol", symbol)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CandleRevisionHistoryQuery:
|
||||||
|
"""Один forward-only запрос истории ревизий Candles."""
|
||||||
|
|
||||||
|
venue: str
|
||||||
|
symbol: str
|
||||||
|
interval: str
|
||||||
|
time_range: HistoricalTimeRange
|
||||||
|
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
|
||||||
|
cursor: CandleRevisionHistoryCursor | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
venue, symbol = _normalize_query(
|
||||||
|
venue=self.venue,
|
||||||
|
symbol=self.symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
limit=self.limit,
|
||||||
|
)
|
||||||
|
interval = _normalize_non_empty_text(
|
||||||
|
self.interval,
|
||||||
|
field_name="interval",
|
||||||
|
)
|
||||||
|
_validate_query_cursor(
|
||||||
|
cursor=self.cursor,
|
||||||
|
cursor_type=CandleRevisionHistoryCursor,
|
||||||
|
venue=venue,
|
||||||
|
symbol=symbol,
|
||||||
|
time_range=self.time_range,
|
||||||
|
interval=interval,
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "venue", venue)
|
||||||
|
object.__setattr__(self, "symbol", symbol)
|
||||||
|
object.__setattr__(self, "interval", interval)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_query(
|
||||||
|
*,
|
||||||
|
venue: object,
|
||||||
|
symbol: object,
|
||||||
|
time_range: object,
|
||||||
|
limit: object,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
if type(time_range) is not HistoricalTimeRange:
|
||||||
|
raise TypeError("time_range must be HistoricalTimeRange")
|
||||||
|
|
||||||
|
_normalize_positive_integer(
|
||||||
|
limit,
|
||||||
|
field_name="limit",
|
||||||
|
maximum=HISTORY_PAGE_LIMIT_MAX,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_normalize_non_empty_text(venue, field_name="venue"),
|
||||||
|
_normalize_symbol(symbol, field_name="symbol"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_query_cursor(
|
||||||
|
*,
|
||||||
|
cursor: object,
|
||||||
|
cursor_type: type[object],
|
||||||
|
venue: str,
|
||||||
|
symbol: str,
|
||||||
|
time_range: HistoricalTimeRange,
|
||||||
|
interval: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if cursor is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if type(cursor) is not cursor_type:
|
||||||
|
raise TypeError(f"cursor must be {cursor_type.__name__} or None")
|
||||||
|
|
||||||
|
if (
|
||||||
|
getattr(cursor, "venue") != venue
|
||||||
|
or getattr(cursor, "symbol") != symbol
|
||||||
|
or getattr(cursor, "time_range") != time_range
|
||||||
|
):
|
||||||
|
raise ValueError("cursor does not belong to query scope")
|
||||||
|
|
||||||
|
if interval is not None and getattr(cursor, "interval") != interval:
|
||||||
|
raise ValueError("cursor does not belong to query interval")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeHistoryPage:
|
||||||
|
"""Одна типизированная страница Trade History."""
|
||||||
|
|
||||||
|
query: TradeHistoryQuery
|
||||||
|
items: tuple[TradeHistoryRecord, ...]
|
||||||
|
next_cursor: TradeHistoryCursor | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_page(
|
||||||
|
query=self.query,
|
||||||
|
query_type=TradeHistoryQuery,
|
||||||
|
items=self.items,
|
||||||
|
item_type=TradeHistoryRecord,
|
||||||
|
next_cursor=self.next_cursor,
|
||||||
|
cursor_type=TradeHistoryCursor,
|
||||||
|
cursor_time_name="executed_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_more(self) -> bool:
|
||||||
|
return self.next_cursor is not None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QuoteHistoryPage:
|
||||||
|
"""Одна типизированная страница Quote History."""
|
||||||
|
|
||||||
|
query: QuoteHistoryQuery
|
||||||
|
items: tuple[QuoteHistoryRecord, ...]
|
||||||
|
next_cursor: QuoteHistoryCursor | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_page(
|
||||||
|
query=self.query,
|
||||||
|
query_type=QuoteHistoryQuery,
|
||||||
|
items=self.items,
|
||||||
|
item_type=QuoteHistoryRecord,
|
||||||
|
next_cursor=self.next_cursor,
|
||||||
|
cursor_type=QuoteHistoryCursor,
|
||||||
|
cursor_time_name="received_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_more(self) -> bool:
|
||||||
|
return self.next_cursor is not None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CandleRevisionHistoryPage:
|
||||||
|
"""Одна типизированная страница Candle Revision History."""
|
||||||
|
|
||||||
|
query: CandleRevisionHistoryQuery
|
||||||
|
items: tuple[CandleRevisionHistoryRecord, ...]
|
||||||
|
next_cursor: CandleRevisionHistoryCursor | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_page(
|
||||||
|
query=self.query,
|
||||||
|
query_type=CandleRevisionHistoryQuery,
|
||||||
|
items=self.items,
|
||||||
|
item_type=CandleRevisionHistoryRecord,
|
||||||
|
next_cursor=self.next_cursor,
|
||||||
|
cursor_type=CandleRevisionHistoryCursor,
|
||||||
|
cursor_time_name="open_time",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_more(self) -> bool:
|
||||||
|
return self.next_cursor is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_page(
|
||||||
|
*,
|
||||||
|
query: object,
|
||||||
|
query_type: type[object],
|
||||||
|
items: object,
|
||||||
|
item_type: type[object],
|
||||||
|
next_cursor: object,
|
||||||
|
cursor_type: type[object],
|
||||||
|
cursor_time_name: str,
|
||||||
|
) -> None:
|
||||||
|
if type(query) is not query_type:
|
||||||
|
raise TypeError(f"query must be {query_type.__name__}")
|
||||||
|
|
||||||
|
if type(items) is not tuple:
|
||||||
|
raise TypeError("items must be a tuple")
|
||||||
|
|
||||||
|
if len(items) > getattr(query, "limit"):
|
||||||
|
raise ValueError("items must not exceed query limit")
|
||||||
|
|
||||||
|
if any(type(item) is not item_type for item in items):
|
||||||
|
raise TypeError(f"items must contain only {item_type.__name__}")
|
||||||
|
|
||||||
|
if next_cursor is not None and type(next_cursor) is not cursor_type:
|
||||||
|
raise TypeError(
|
||||||
|
f"next_cursor must be {cursor_type.__name__} or None"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
if next_cursor is not None:
|
||||||
|
raise ValueError("empty page must not have next_cursor")
|
||||||
|
return
|
||||||
|
|
||||||
|
for previous, current in zip(items, items[1:], strict=False):
|
||||||
|
if getattr(previous, "order_key") >= getattr(current, "order_key"):
|
||||||
|
raise ValueError("page items must be strictly ordered")
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if (
|
||||||
|
getattr(item, "venue") != getattr(query, "venue")
|
||||||
|
or getattr(item, "symbol") != getattr(query, "symbol")
|
||||||
|
):
|
||||||
|
raise ValueError("page items must belong to one query scope")
|
||||||
|
|
||||||
|
if getattr(item, "interval", None) != getattr(
|
||||||
|
query,
|
||||||
|
"interval",
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
raise ValueError("page items must belong to one interval")
|
||||||
|
|
||||||
|
if not getattr(query, "time_range").contains(
|
||||||
|
getattr(item, "event_time")
|
||||||
|
):
|
||||||
|
raise ValueError("page item time must belong to query range")
|
||||||
|
|
||||||
|
query_cursor = getattr(query, "cursor")
|
||||||
|
|
||||||
|
if query_cursor is not None:
|
||||||
|
incoming_key = (
|
||||||
|
getattr(query_cursor, cursor_time_name),
|
||||||
|
getattr(query_cursor, "replay_sequence"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if getattr(items[0], "order_key") <= incoming_key:
|
||||||
|
raise ValueError("page items must follow query cursor")
|
||||||
|
|
||||||
|
if next_cursor is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
last = items[-1]
|
||||||
|
|
||||||
|
if (
|
||||||
|
getattr(next_cursor, "venue") != getattr(query, "venue")
|
||||||
|
or getattr(next_cursor, "symbol") != getattr(query, "symbol")
|
||||||
|
or getattr(next_cursor, "time_range")
|
||||||
|
!= getattr(query, "time_range")
|
||||||
|
or getattr(next_cursor, "replay_sequence")
|
||||||
|
!= getattr(last, "replay_sequence")
|
||||||
|
or getattr(next_cursor, cursor_time_name)
|
||||||
|
!= getattr(last, "event_time")
|
||||||
|
):
|
||||||
|
raise ValueError("next_cursor must point to the last page item")
|
||||||
|
|
||||||
|
cursor_interval = getattr(next_cursor, "interval", None)
|
||||||
|
|
||||||
|
if cursor_interval != getattr(query, "interval", None):
|
||||||
|
raise ValueError("next_cursor must belong to page interval")
|
||||||
|
|
||||||
|
|
||||||
|
HistoryRecord = (
|
||||||
|
TradeHistoryRecord
|
||||||
|
| QuoteHistoryRecord
|
||||||
|
| CandleRevisionHistoryRecord
|
||||||
|
)
|
||||||
|
HistoryQuery = (
|
||||||
|
TradeHistoryQuery
|
||||||
|
| QuoteHistoryQuery
|
||||||
|
| CandleRevisionHistoryQuery
|
||||||
|
)
|
||||||
|
HistoryPage = (
|
||||||
|
TradeHistoryPage
|
||||||
|
| QuoteHistoryPage
|
||||||
|
| CandleRevisionHistoryPage
|
||||||
|
)
|
||||||
|
HistoryOrderKey = tuple[datetime, int]
|
||||||
|
HistoryOrderKeyFactory = Callable[[Any], HistoryOrderKey]
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.market_data.access.exceptions import (
|
||||||
|
MarketDataAccessError,
|
||||||
|
MarketDataAccessIntegrityError,
|
||||||
|
MarketDataAccessOperationError,
|
||||||
|
MarketDataAccessValidationError,
|
||||||
|
)
|
||||||
|
from src.market_data.access.models import (
|
||||||
|
CandleRevisionHistoryCursor,
|
||||||
|
CandleRevisionHistoryPage,
|
||||||
|
CandleRevisionHistoryQuery,
|
||||||
|
CandleRevisionHistoryRecord,
|
||||||
|
)
|
||||||
|
from src.market_data.access.postgres_history_support import (
|
||||||
|
PostgresHistoryConnectionProvider,
|
||||||
|
candle_history_record_from_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CANDLE_REVISION_HISTORY_COLUMNS_SQL = """
|
||||||
|
venue,
|
||||||
|
symbol,
|
||||||
|
interval,
|
||||||
|
open_time,
|
||||||
|
observed_at,
|
||||||
|
open_price,
|
||||||
|
high_price,
|
||||||
|
low_price,
|
||||||
|
close_price,
|
||||||
|
volume,
|
||||||
|
is_final,
|
||||||
|
source,
|
||||||
|
observation_sources,
|
||||||
|
replay_sequence,
|
||||||
|
canonical_schema_version
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_CANDLE_REVISION_HISTORY_SQL = f"""
|
||||||
|
SELECT
|
||||||
|
{_CANDLE_REVISION_HISTORY_COLUMNS_SQL}
|
||||||
|
FROM market_data.candle_revisions
|
||||||
|
WHERE venue = %s
|
||||||
|
AND symbol = %s
|
||||||
|
AND interval = %s
|
||||||
|
AND open_time >= %s
|
||||||
|
AND open_time < %s
|
||||||
|
ORDER BY open_time ASC,
|
||||||
|
replay_sequence ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_CANDLE_REVISION_HISTORY_AFTER_CURSOR_SQL = f"""
|
||||||
|
SELECT
|
||||||
|
{_CANDLE_REVISION_HISTORY_COLUMNS_SQL}
|
||||||
|
FROM market_data.candle_revisions
|
||||||
|
WHERE venue = %s
|
||||||
|
AND symbol = %s
|
||||||
|
AND interval = %s
|
||||||
|
AND open_time >= %s
|
||||||
|
AND open_time < %s
|
||||||
|
AND (open_time, replay_sequence) > (%s, %s)
|
||||||
|
ORDER BY open_time ASC,
|
||||||
|
replay_sequence ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresCandleRevisionHistoryRepository:
|
||||||
|
"""PostgreSQL-адаптер одной страницы истории ревизий свечей."""
|
||||||
|
|
||||||
|
__slots__ = ("_connection_provider",)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connection_provider: PostgresHistoryConnectionProvider,
|
||||||
|
) -> None:
|
||||||
|
if not callable(connection_provider):
|
||||||
|
raise TypeError("connection_provider must be callable")
|
||||||
|
|
||||||
|
self._connection_provider = connection_provider
|
||||||
|
|
||||||
|
def query_candle_revisions(
|
||||||
|
self,
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
) -> CandleRevisionHistoryPage:
|
||||||
|
"""Прочитать страницу в устойчивом порядке времени открытия."""
|
||||||
|
if type(query) is not CandleRevisionHistoryQuery:
|
||||||
|
raise MarketDataAccessValidationError(
|
||||||
|
"query must be CandleRevisionHistoryQuery"
|
||||||
|
)
|
||||||
|
|
||||||
|
sql, parameters = self._build_query(query)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._connection_provider() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, parameters)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return self._page_from_rows(query=query, rows=rows)
|
||||||
|
except MarketDataAccessError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise MarketDataAccessOperationError(
|
||||||
|
"Failed to query PostgreSQL Candle Revision History."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_query(
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
) -> tuple[str, tuple[object, ...]]:
|
||||||
|
fetch_limit = query.limit + 1
|
||||||
|
cursor = query.cursor
|
||||||
|
|
||||||
|
if cursor is None:
|
||||||
|
return (
|
||||||
|
_SELECT_CANDLE_REVISION_HISTORY_SQL,
|
||||||
|
(
|
||||||
|
query.venue,
|
||||||
|
query.symbol,
|
||||||
|
query.interval,
|
||||||
|
query.time_range.start_time,
|
||||||
|
query.time_range.end_time,
|
||||||
|
fetch_limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
_SELECT_CANDLE_REVISION_HISTORY_AFTER_CURSOR_SQL,
|
||||||
|
(
|
||||||
|
query.venue,
|
||||||
|
query.symbol,
|
||||||
|
query.interval,
|
||||||
|
query.time_range.start_time,
|
||||||
|
query.time_range.end_time,
|
||||||
|
cursor.open_time,
|
||||||
|
cursor.replay_sequence,
|
||||||
|
fetch_limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _page_from_rows(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
rows: object,
|
||||||
|
) -> CandleRevisionHistoryPage:
|
||||||
|
if not isinstance(rows, Sequence) or isinstance(
|
||||||
|
rows,
|
||||||
|
(str, bytes, bytearray),
|
||||||
|
):
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Candle Revision History rows."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(rows) > query.limit + 1:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL exceeded the requested Candle History limit."
|
||||||
|
)
|
||||||
|
|
||||||
|
records = tuple(
|
||||||
|
cls._record_from_row(row=row, query=query)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
cls._validate_records(records=records, query=query)
|
||||||
|
|
||||||
|
has_more = len(records) > query.limit
|
||||||
|
items = records[: query.limit]
|
||||||
|
next_cursor = (
|
||||||
|
cls._cursor_from_record(query=query, record=items[-1])
|
||||||
|
if has_more
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return CandleRevisionHistoryPage(
|
||||||
|
query=query,
|
||||||
|
items=items,
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned inconsistent Candle History page."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record_from_row(
|
||||||
|
*,
|
||||||
|
row: object,
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
) -> CandleRevisionHistoryRecord:
|
||||||
|
record = candle_history_record_from_row(row)
|
||||||
|
|
||||||
|
if (
|
||||||
|
record.venue != query.venue
|
||||||
|
or record.symbol != query.symbol
|
||||||
|
or record.interval != query.interval
|
||||||
|
):
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned Candle revision outside query scope."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not query.time_range.contains(record.event_time):
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned Candle revision outside query range."
|
||||||
|
)
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_records(
|
||||||
|
*,
|
||||||
|
records: tuple[CandleRevisionHistoryRecord, ...],
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
) -> None:
|
||||||
|
seen_sequences: set[int] = set()
|
||||||
|
previous_key: tuple[datetime, int] | None = None
|
||||||
|
incoming_key = (
|
||||||
|
(
|
||||||
|
query.cursor.open_time,
|
||||||
|
query.cursor.replay_sequence,
|
||||||
|
)
|
||||||
|
if query.cursor is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
if record.replay_sequence in seen_sequences:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Candle History contains duplicate replay_sequence."
|
||||||
|
)
|
||||||
|
|
||||||
|
if incoming_key is not None and record.order_key <= incoming_key:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Candle History row does not follow query cursor."
|
||||||
|
)
|
||||||
|
|
||||||
|
if previous_key is not None and record.order_key <= previous_key:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Candle History rows are not strictly ordered."
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_sequences.add(record.replay_sequence)
|
||||||
|
previous_key = record.order_key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cursor_from_record(
|
||||||
|
*,
|
||||||
|
query: CandleRevisionHistoryQuery,
|
||||||
|
record: CandleRevisionHistoryRecord,
|
||||||
|
) -> CandleRevisionHistoryCursor:
|
||||||
|
try:
|
||||||
|
return CandleRevisionHistoryCursor(
|
||||||
|
venue=query.venue,
|
||||||
|
symbol=query.symbol,
|
||||||
|
interval=query.interval,
|
||||||
|
time_range=query.time_range,
|
||||||
|
open_time=record.event_time,
|
||||||
|
replay_sequence=record.replay_sequence,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Cannot create Candle History cursor from stored row."
|
||||||
|
) from error
|
||||||
486
app/src/market_data/access/postgres_history_support.py
Normal file
486
app/src/market_data/access/postgres_history_support.py
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.market_data.access.exceptions import MarketDataAccessIntegrityError
|
||||||
|
from src.market_data.access.models import (
|
||||||
|
CandleRevisionHistoryRecord,
|
||||||
|
QuoteHistoryRecord,
|
||||||
|
TradeHistoryRecord,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.models.trade import (
|
||||||
|
Trade,
|
||||||
|
TradeAggressorSide,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.symbols import normalize_symbol
|
||||||
|
from src.market_data.acquisition.trade_id_sequence import (
|
||||||
|
validate_signed_trade_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CANONICAL_TRADE_SCHEMA_VERSION = 1
|
||||||
|
CANONICAL_QUOTE_SCHEMA_VERSION = 1
|
||||||
|
CANONICAL_CANDLE_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
PostgresHistoryConnectionProvider = Callable[
|
||||||
|
[],
|
||||||
|
AbstractContextManager[Any],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_text(value: object, *, field_name: str) -> str:
|
||||||
|
"""Проверить каноническую непустую строку без скрытой нормализации."""
|
||||||
|
if type(value) is not str:
|
||||||
|
raise TypeError(f"{field_name} must be a string")
|
||||||
|
|
||||||
|
normalized = value.strip()
|
||||||
|
|
||||||
|
if not normalized or normalized != value:
|
||||||
|
raise ValueError(f"{field_name} must be canonical")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_symbol(value: object, *, field_name: str) -> str:
|
||||||
|
"""Проверить уже канонизированный символ сохранённой строки."""
|
||||||
|
raw_symbol = canonical_text(value, field_name=field_name)
|
||||||
|
normalized = normalize_symbol(raw_symbol)
|
||||||
|
|
||||||
|
if normalized != raw_symbol:
|
||||||
|
raise ValueError(f"{field_name} must be canonical")
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def aware_utc_datetime(value: object, *, field_name: str) -> datetime:
|
||||||
|
"""Проверить datetime с часовым поясом и привести его к UTC."""
|
||||||
|
if (
|
||||||
|
type(value) is not datetime
|
||||||
|
or value.tzinfo is None
|
||||||
|
or value.utcoffset() is None
|
||||||
|
):
|
||||||
|
raise TypeError(f"{field_name} must be timezone-aware datetime")
|
||||||
|
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def positive_integer(value: object, *, field_name: str) -> int:
|
||||||
|
"""Проверить точный положительный целочисленный тип."""
|
||||||
|
if type(value) is not int:
|
||||||
|
raise TypeError(f"{field_name} must be an integer")
|
||||||
|
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError(f"{field_name} must be positive")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def signed_trade_id(value: object) -> int:
|
||||||
|
"""Проверить знаковый 32-битный идентификатор без изменения."""
|
||||||
|
if type(value) is not int:
|
||||||
|
raise TypeError("stored trade.trade_id must be an integer")
|
||||||
|
|
||||||
|
validate_signed_trade_id(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def decimal_value(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
allow_zero: bool = False,
|
||||||
|
) -> Decimal:
|
||||||
|
"""Проверить точный конечный Decimal и допустимый знак."""
|
||||||
|
if type(value) is not Decimal or not value.is_finite():
|
||||||
|
raise TypeError(f"{field_name} must be a finite Decimal")
|
||||||
|
|
||||||
|
if allow_zero:
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError(f"{field_name} must not be negative")
|
||||||
|
elif value <= 0:
|
||||||
|
raise ValueError(f"{field_name} must be positive")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def observation_sources(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
primary_source: str,
|
||||||
|
field_name: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Проверить порядок и уникальность источников наблюдения."""
|
||||||
|
if type(value) is not list:
|
||||||
|
raise TypeError(f"{field_name} must be a list")
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"{field_name} must not be empty")
|
||||||
|
|
||||||
|
normalized = tuple(
|
||||||
|
canonical_text(
|
||||||
|
source,
|
||||||
|
field_name=f"{field_name} item",
|
||||||
|
)
|
||||||
|
for source in value
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise ValueError(f"{field_name} must be unique")
|
||||||
|
|
||||||
|
if normalized[0] != primary_source:
|
||||||
|
raise ValueError(
|
||||||
|
"first observation source must match stored payload.source"
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def trade_history_record_from_row(row: object) -> TradeHistoryRecord:
|
||||||
|
"""Материализовать строго проверенную строку канонической сделки."""
|
||||||
|
if type(row) is not tuple or len(row) != 13:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Trade History row."
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
venue,
|
||||||
|
symbol,
|
||||||
|
trade_id,
|
||||||
|
executed_at,
|
||||||
|
price,
|
||||||
|
quantity,
|
||||||
|
aggressor_side,
|
||||||
|
source,
|
||||||
|
first_observed_at,
|
||||||
|
last_observed_at,
|
||||||
|
stored_sources,
|
||||||
|
replay_sequence,
|
||||||
|
canonical_schema_version,
|
||||||
|
) = row
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_venue = canonical_text(
|
||||||
|
venue,
|
||||||
|
field_name="stored trade.venue",
|
||||||
|
)
|
||||||
|
normalized_symbol = canonical_symbol(
|
||||||
|
symbol,
|
||||||
|
field_name="stored trade.symbol",
|
||||||
|
)
|
||||||
|
normalized_trade_id = signed_trade_id(trade_id)
|
||||||
|
normalized_executed_at = aware_utc_datetime(
|
||||||
|
executed_at,
|
||||||
|
field_name="stored trade.executed_at",
|
||||||
|
)
|
||||||
|
normalized_price = decimal_value(
|
||||||
|
price,
|
||||||
|
field_name="stored trade.price",
|
||||||
|
)
|
||||||
|
normalized_quantity = decimal_value(
|
||||||
|
quantity,
|
||||||
|
field_name="stored trade.quantity",
|
||||||
|
)
|
||||||
|
|
||||||
|
if type(aggressor_side) is not str:
|
||||||
|
raise TypeError("stored trade.aggressor_side must be a string")
|
||||||
|
|
||||||
|
normalized_side = TradeAggressorSide(aggressor_side)
|
||||||
|
normalized_source = canonical_text(
|
||||||
|
source,
|
||||||
|
field_name="stored trade.source",
|
||||||
|
)
|
||||||
|
normalized_first_observed_at = aware_utc_datetime(
|
||||||
|
first_observed_at,
|
||||||
|
field_name="stored trade.first_observed_at",
|
||||||
|
)
|
||||||
|
normalized_last_observed_at = aware_utc_datetime(
|
||||||
|
last_observed_at,
|
||||||
|
field_name="stored trade.last_observed_at",
|
||||||
|
)
|
||||||
|
normalized_sources = observation_sources(
|
||||||
|
stored_sources,
|
||||||
|
primary_source=normalized_source,
|
||||||
|
field_name="stored trade.observation_sources",
|
||||||
|
)
|
||||||
|
normalized_sequence = positive_integer(
|
||||||
|
replay_sequence,
|
||||||
|
field_name="stored trade.replay_sequence",
|
||||||
|
)
|
||||||
|
normalized_version = positive_integer(
|
||||||
|
canonical_schema_version,
|
||||||
|
field_name="stored trade.canonical_schema_version",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_version != CANONICAL_TRADE_SCHEMA_VERSION:
|
||||||
|
raise ValueError("unsupported Canonical Trade schema version")
|
||||||
|
|
||||||
|
trade = Trade(
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
trade_id=normalized_trade_id,
|
||||||
|
price=normalized_price,
|
||||||
|
quantity=normalized_quantity,
|
||||||
|
executed_at=normalized_executed_at,
|
||||||
|
aggressor_side=normalized_side,
|
||||||
|
source=normalized_source,
|
||||||
|
)
|
||||||
|
return TradeHistoryRecord(
|
||||||
|
venue=normalized_venue,
|
||||||
|
trade=trade,
|
||||||
|
first_observed_at=normalized_first_observed_at,
|
||||||
|
last_observed_at=normalized_last_observed_at,
|
||||||
|
observation_sources=normalized_sources,
|
||||||
|
replay_sequence=normalized_sequence,
|
||||||
|
canonical_schema_version=normalized_version,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, ArithmeticError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Canonical Trade values."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def quote_history_record_from_row(row: object) -> QuoteHistoryRecord:
|
||||||
|
"""Материализовать строго проверенную строку канонической котировки."""
|
||||||
|
if type(row) is not tuple or len(row) != 11:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Quote History row."
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
venue,
|
||||||
|
symbol,
|
||||||
|
received_at,
|
||||||
|
exchange_timestamp,
|
||||||
|
last_price,
|
||||||
|
bid_price,
|
||||||
|
ask_price,
|
||||||
|
source,
|
||||||
|
stored_sources,
|
||||||
|
replay_sequence,
|
||||||
|
canonical_schema_version,
|
||||||
|
) = row
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_venue = canonical_text(
|
||||||
|
venue,
|
||||||
|
field_name="stored quote.venue",
|
||||||
|
)
|
||||||
|
normalized_symbol = canonical_symbol(
|
||||||
|
symbol,
|
||||||
|
field_name="stored quote.symbol",
|
||||||
|
)
|
||||||
|
normalized_received_at = aware_utc_datetime(
|
||||||
|
received_at,
|
||||||
|
field_name="stored quote.received_at",
|
||||||
|
)
|
||||||
|
normalized_exchange_timestamp = (
|
||||||
|
aware_utc_datetime(
|
||||||
|
exchange_timestamp,
|
||||||
|
field_name="stored quote.exchange_timestamp",
|
||||||
|
)
|
||||||
|
if exchange_timestamp is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
normalized_last_price = decimal_value(
|
||||||
|
last_price,
|
||||||
|
field_name="stored quote.last_price",
|
||||||
|
)
|
||||||
|
normalized_bid_price = decimal_value(
|
||||||
|
bid_price,
|
||||||
|
field_name="stored quote.bid_price",
|
||||||
|
)
|
||||||
|
normalized_ask_price = decimal_value(
|
||||||
|
ask_price,
|
||||||
|
field_name="stored quote.ask_price",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_bid_price > normalized_ask_price:
|
||||||
|
raise ValueError("stored quote.bid_price exceeds ask_price")
|
||||||
|
|
||||||
|
normalized_source = canonical_text(
|
||||||
|
source,
|
||||||
|
field_name="stored quote.source",
|
||||||
|
)
|
||||||
|
normalized_sources = observation_sources(
|
||||||
|
stored_sources,
|
||||||
|
primary_source=normalized_source,
|
||||||
|
field_name="stored quote.observation_sources",
|
||||||
|
)
|
||||||
|
normalized_sequence = positive_integer(
|
||||||
|
replay_sequence,
|
||||||
|
field_name="stored quote.replay_sequence",
|
||||||
|
)
|
||||||
|
normalized_version = positive_integer(
|
||||||
|
canonical_schema_version,
|
||||||
|
field_name="stored quote.canonical_schema_version",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_version != CANONICAL_QUOTE_SCHEMA_VERSION:
|
||||||
|
raise ValueError("unsupported Canonical Quote schema version")
|
||||||
|
|
||||||
|
quote = Quote(
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
last_price=normalized_last_price,
|
||||||
|
bid_price=normalized_bid_price,
|
||||||
|
ask_price=normalized_ask_price,
|
||||||
|
exchange_timestamp=normalized_exchange_timestamp,
|
||||||
|
received_at=normalized_received_at,
|
||||||
|
source=normalized_source,
|
||||||
|
)
|
||||||
|
return QuoteHistoryRecord(
|
||||||
|
venue=normalized_venue,
|
||||||
|
quote=quote,
|
||||||
|
observation_sources=normalized_sources,
|
||||||
|
replay_sequence=normalized_sequence,
|
||||||
|
canonical_schema_version=normalized_version,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, ArithmeticError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Canonical Quote values."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def candle_history_record_from_row(
|
||||||
|
row: object,
|
||||||
|
) -> CandleRevisionHistoryRecord:
|
||||||
|
"""Материализовать строго проверенную строку ревизии свечи."""
|
||||||
|
if type(row) is not tuple or len(row) != 15:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Candle History row."
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
venue,
|
||||||
|
symbol,
|
||||||
|
interval,
|
||||||
|
open_time,
|
||||||
|
observed_at,
|
||||||
|
open_price,
|
||||||
|
high_price,
|
||||||
|
low_price,
|
||||||
|
close_price,
|
||||||
|
volume,
|
||||||
|
is_final,
|
||||||
|
source,
|
||||||
|
stored_sources,
|
||||||
|
replay_sequence,
|
||||||
|
canonical_schema_version,
|
||||||
|
) = row
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_venue = canonical_text(
|
||||||
|
venue,
|
||||||
|
field_name="stored candle.venue",
|
||||||
|
)
|
||||||
|
normalized_symbol = canonical_symbol(
|
||||||
|
symbol,
|
||||||
|
field_name="stored candle.symbol",
|
||||||
|
)
|
||||||
|
normalized_interval = canonical_text(
|
||||||
|
interval,
|
||||||
|
field_name="stored candle.interval",
|
||||||
|
)
|
||||||
|
normalized_open_time = aware_utc_datetime(
|
||||||
|
open_time,
|
||||||
|
field_name="stored candle.open_time",
|
||||||
|
)
|
||||||
|
normalized_observed_at = aware_utc_datetime(
|
||||||
|
observed_at,
|
||||||
|
field_name="stored candle.observed_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_observed_at < normalized_open_time:
|
||||||
|
raise ValueError("stored candle.observed_at precedes open_time")
|
||||||
|
|
||||||
|
normalized_open_price = decimal_value(
|
||||||
|
open_price,
|
||||||
|
field_name="stored candle.open_price",
|
||||||
|
)
|
||||||
|
normalized_high_price = decimal_value(
|
||||||
|
high_price,
|
||||||
|
field_name="stored candle.high_price",
|
||||||
|
)
|
||||||
|
normalized_low_price = decimal_value(
|
||||||
|
low_price,
|
||||||
|
field_name="stored candle.low_price",
|
||||||
|
)
|
||||||
|
normalized_close_price = decimal_value(
|
||||||
|
close_price,
|
||||||
|
field_name="stored candle.close_price",
|
||||||
|
)
|
||||||
|
normalized_volume = decimal_value(
|
||||||
|
volume,
|
||||||
|
field_name="stored candle.volume",
|
||||||
|
allow_zero=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_low_price > normalized_high_price:
|
||||||
|
raise ValueError("stored candle.low_price exceeds high_price")
|
||||||
|
|
||||||
|
if not (
|
||||||
|
normalized_low_price
|
||||||
|
<= normalized_open_price
|
||||||
|
<= normalized_high_price
|
||||||
|
):
|
||||||
|
raise ValueError("stored candle.open_price is outside range")
|
||||||
|
|
||||||
|
if not (
|
||||||
|
normalized_low_price
|
||||||
|
<= normalized_close_price
|
||||||
|
<= normalized_high_price
|
||||||
|
):
|
||||||
|
raise ValueError("stored candle.close_price is outside range")
|
||||||
|
|
||||||
|
if type(is_final) is not bool:
|
||||||
|
raise TypeError("stored candle.is_final must be a boolean")
|
||||||
|
|
||||||
|
normalized_source = canonical_text(
|
||||||
|
source,
|
||||||
|
field_name="stored candle.source",
|
||||||
|
)
|
||||||
|
normalized_sources = observation_sources(
|
||||||
|
stored_sources,
|
||||||
|
primary_source=normalized_source,
|
||||||
|
field_name="stored candle.observation_sources",
|
||||||
|
)
|
||||||
|
normalized_sequence = positive_integer(
|
||||||
|
replay_sequence,
|
||||||
|
field_name="stored candle.replay_sequence",
|
||||||
|
)
|
||||||
|
normalized_version = positive_integer(
|
||||||
|
canonical_schema_version,
|
||||||
|
field_name="stored candle.canonical_schema_version",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_version != CANONICAL_CANDLE_SCHEMA_VERSION:
|
||||||
|
raise ValueError("unsupported Canonical Candle schema version")
|
||||||
|
|
||||||
|
candle = Candle(
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
interval=normalized_interval,
|
||||||
|
open_time=normalized_open_time,
|
||||||
|
open_price=normalized_open_price,
|
||||||
|
high_price=normalized_high_price,
|
||||||
|
low_price=normalized_low_price,
|
||||||
|
close_price=normalized_close_price,
|
||||||
|
volume=normalized_volume,
|
||||||
|
source=normalized_source,
|
||||||
|
)
|
||||||
|
return CandleRevisionHistoryRecord(
|
||||||
|
venue=normalized_venue,
|
||||||
|
candle=candle,
|
||||||
|
observed_at=normalized_observed_at,
|
||||||
|
is_final=is_final,
|
||||||
|
observation_sources=normalized_sources,
|
||||||
|
replay_sequence=normalized_sequence,
|
||||||
|
canonical_schema_version=normalized_version,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, ArithmeticError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Canonical Candle values."
|
||||||
|
) from error
|
||||||
253
app/src/market_data/access/postgres_quote_history_repository.py
Normal file
253
app/src/market_data/access/postgres_quote_history_repository.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from src.market_data.access.exceptions import (
|
||||||
|
MarketDataAccessError,
|
||||||
|
MarketDataAccessIntegrityError,
|
||||||
|
MarketDataAccessOperationError,
|
||||||
|
MarketDataAccessValidationError,
|
||||||
|
)
|
||||||
|
from src.market_data.access.models import (
|
||||||
|
QuoteHistoryCursor,
|
||||||
|
QuoteHistoryPage,
|
||||||
|
QuoteHistoryQuery,
|
||||||
|
QuoteHistoryRecord,
|
||||||
|
)
|
||||||
|
from src.market_data.access.postgres_history_support import (
|
||||||
|
PostgresHistoryConnectionProvider,
|
||||||
|
quote_history_record_from_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_QUOTE_HISTORY_COLUMNS_SQL = """
|
||||||
|
venue,
|
||||||
|
symbol,
|
||||||
|
received_at,
|
||||||
|
exchange_timestamp,
|
||||||
|
last_price,
|
||||||
|
bid_price,
|
||||||
|
ask_price,
|
||||||
|
source,
|
||||||
|
observation_sources,
|
||||||
|
replay_sequence,
|
||||||
|
canonical_schema_version
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_QUOTE_HISTORY_SQL = f"""
|
||||||
|
SELECT
|
||||||
|
{_QUOTE_HISTORY_COLUMNS_SQL}
|
||||||
|
FROM market_data.quotes
|
||||||
|
WHERE venue = %s
|
||||||
|
AND symbol = %s
|
||||||
|
AND received_at >= %s
|
||||||
|
AND received_at < %s
|
||||||
|
ORDER BY received_at ASC,
|
||||||
|
replay_sequence ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_QUOTE_HISTORY_AFTER_CURSOR_SQL = f"""
|
||||||
|
SELECT
|
||||||
|
{_QUOTE_HISTORY_COLUMNS_SQL}
|
||||||
|
FROM market_data.quotes
|
||||||
|
WHERE venue = %s
|
||||||
|
AND symbol = %s
|
||||||
|
AND received_at >= %s
|
||||||
|
AND received_at < %s
|
||||||
|
AND (received_at, replay_sequence) > (%s, %s)
|
||||||
|
ORDER BY received_at ASC,
|
||||||
|
replay_sequence ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresQuoteHistoryRepository:
|
||||||
|
"""PostgreSQL-адаптер одной страницы канонической истории котировок."""
|
||||||
|
|
||||||
|
__slots__ = ("_connection_provider",)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connection_provider: PostgresHistoryConnectionProvider,
|
||||||
|
) -> None:
|
||||||
|
if not callable(connection_provider):
|
||||||
|
raise TypeError("connection_provider must be callable")
|
||||||
|
|
||||||
|
self._connection_provider = connection_provider
|
||||||
|
|
||||||
|
def query_quotes(self, query: QuoteHistoryQuery) -> QuoteHistoryPage:
|
||||||
|
"""Прочитать ограниченную страницу с устойчивой сортировкой."""
|
||||||
|
if type(query) is not QuoteHistoryQuery:
|
||||||
|
raise MarketDataAccessValidationError(
|
||||||
|
"query must be QuoteHistoryQuery"
|
||||||
|
)
|
||||||
|
|
||||||
|
sql, parameters = self._build_query(query)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._connection_provider() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, parameters)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return self._page_from_rows(query=query, rows=rows)
|
||||||
|
except MarketDataAccessError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise MarketDataAccessOperationError(
|
||||||
|
"Failed to query PostgreSQL Quote History."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_query(
|
||||||
|
query: QuoteHistoryQuery,
|
||||||
|
) -> tuple[str, tuple[object, ...]]:
|
||||||
|
fetch_limit = query.limit + 1
|
||||||
|
cursor = query.cursor
|
||||||
|
|
||||||
|
if cursor is None:
|
||||||
|
return (
|
||||||
|
_SELECT_QUOTE_HISTORY_SQL,
|
||||||
|
(
|
||||||
|
query.venue,
|
||||||
|
query.symbol,
|
||||||
|
query.time_range.start_time,
|
||||||
|
query.time_range.end_time,
|
||||||
|
fetch_limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
_SELECT_QUOTE_HISTORY_AFTER_CURSOR_SQL,
|
||||||
|
(
|
||||||
|
query.venue,
|
||||||
|
query.symbol,
|
||||||
|
query.time_range.start_time,
|
||||||
|
query.time_range.end_time,
|
||||||
|
cursor.received_at,
|
||||||
|
cursor.replay_sequence,
|
||||||
|
fetch_limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _page_from_rows(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
query: QuoteHistoryQuery,
|
||||||
|
rows: object,
|
||||||
|
) -> QuoteHistoryPage:
|
||||||
|
if not isinstance(rows, Sequence) or isinstance(
|
||||||
|
rows,
|
||||||
|
(str, bytes, bytearray),
|
||||||
|
):
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Quote History rows."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(rows) > query.limit + 1:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL exceeded the requested Quote History limit."
|
||||||
|
)
|
||||||
|
|
||||||
|
records = tuple(
|
||||||
|
cls._record_from_row(row=row, query=query)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
cls._validate_records(records=records, query=query)
|
||||||
|
|
||||||
|
has_more = len(records) > query.limit
|
||||||
|
items = records[: query.limit]
|
||||||
|
next_cursor = (
|
||||||
|
cls._cursor_from_record(query=query, record=items[-1])
|
||||||
|
if has_more
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return QuoteHistoryPage(
|
||||||
|
query=query,
|
||||||
|
items=items,
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned inconsistent Quote History page."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record_from_row(
|
||||||
|
*,
|
||||||
|
row: object,
|
||||||
|
query: QuoteHistoryQuery,
|
||||||
|
) -> QuoteHistoryRecord:
|
||||||
|
record = quote_history_record_from_row(row)
|
||||||
|
|
||||||
|
if record.venue != query.venue or record.symbol != query.symbol:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned Quote outside query scope."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not query.time_range.contains(record.event_time):
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned Quote outside query range."
|
||||||
|
)
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_records(
|
||||||
|
*,
|
||||||
|
records: tuple[QuoteHistoryRecord, ...],
|
||||||
|
query: QuoteHistoryQuery,
|
||||||
|
) -> None:
|
||||||
|
seen_sequences: set[int] = set()
|
||||||
|
previous_key: tuple[datetime, int] | None = None
|
||||||
|
incoming_key = (
|
||||||
|
(
|
||||||
|
query.cursor.received_at,
|
||||||
|
query.cursor.replay_sequence,
|
||||||
|
)
|
||||||
|
if query.cursor is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
if record.replay_sequence in seen_sequences:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Quote History contains duplicate replay_sequence."
|
||||||
|
)
|
||||||
|
|
||||||
|
if incoming_key is not None and record.order_key <= incoming_key:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Quote History row does not follow query cursor."
|
||||||
|
)
|
||||||
|
|
||||||
|
if previous_key is not None and record.order_key <= previous_key:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Quote History rows are not strictly ordered."
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_sequences.add(record.replay_sequence)
|
||||||
|
previous_key = record.order_key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cursor_from_record(
|
||||||
|
*,
|
||||||
|
query: QuoteHistoryQuery,
|
||||||
|
record: QuoteHistoryRecord,
|
||||||
|
) -> QuoteHistoryCursor:
|
||||||
|
try:
|
||||||
|
return QuoteHistoryCursor(
|
||||||
|
venue=query.venue,
|
||||||
|
symbol=query.symbol,
|
||||||
|
time_range=query.time_range,
|
||||||
|
received_at=record.event_time,
|
||||||
|
replay_sequence=record.replay_sequence,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Cannot create Quote History cursor from stored row."
|
||||||
|
) from error
|
||||||
257
app/src/market_data/access/postgres_trade_history_repository.py
Normal file
257
app/src/market_data/access/postgres_trade_history_repository.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.market_data.access.exceptions import (
|
||||||
|
MarketDataAccessError,
|
||||||
|
MarketDataAccessIntegrityError,
|
||||||
|
MarketDataAccessOperationError,
|
||||||
|
MarketDataAccessValidationError,
|
||||||
|
)
|
||||||
|
from src.market_data.access.models import (
|
||||||
|
TradeHistoryCursor,
|
||||||
|
TradeHistoryPage,
|
||||||
|
TradeHistoryQuery,
|
||||||
|
TradeHistoryRecord,
|
||||||
|
)
|
||||||
|
from src.market_data.access.postgres_history_support import (
|
||||||
|
PostgresHistoryConnectionProvider,
|
||||||
|
trade_history_record_from_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TRADE_HISTORY_COLUMNS_SQL = """
|
||||||
|
venue,
|
||||||
|
symbol,
|
||||||
|
trade_id,
|
||||||
|
executed_at,
|
||||||
|
price,
|
||||||
|
quantity,
|
||||||
|
aggressor_side,
|
||||||
|
source,
|
||||||
|
first_observed_at,
|
||||||
|
last_observed_at,
|
||||||
|
observation_sources,
|
||||||
|
replay_sequence,
|
||||||
|
canonical_schema_version
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_TRADE_HISTORY_SQL = f"""
|
||||||
|
SELECT
|
||||||
|
{_TRADE_HISTORY_COLUMNS_SQL}
|
||||||
|
FROM market_data.trades
|
||||||
|
WHERE venue = %s
|
||||||
|
AND symbol = %s
|
||||||
|
AND executed_at >= %s
|
||||||
|
AND executed_at < %s
|
||||||
|
ORDER BY executed_at ASC,
|
||||||
|
replay_sequence ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_TRADE_HISTORY_AFTER_CURSOR_SQL = f"""
|
||||||
|
SELECT
|
||||||
|
{_TRADE_HISTORY_COLUMNS_SQL}
|
||||||
|
FROM market_data.trades
|
||||||
|
WHERE venue = %s
|
||||||
|
AND symbol = %s
|
||||||
|
AND executed_at >= %s
|
||||||
|
AND executed_at < %s
|
||||||
|
AND (executed_at, replay_sequence) > (%s, %s)
|
||||||
|
ORDER BY executed_at ASC,
|
||||||
|
replay_sequence ASC
|
||||||
|
LIMIT %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresTradeHistoryRepository:
|
||||||
|
"""PostgreSQL-адаптер одной страницы канонической истории сделок."""
|
||||||
|
|
||||||
|
__slots__ = ("_connection_provider",)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connection_provider: PostgresHistoryConnectionProvider,
|
||||||
|
) -> None:
|
||||||
|
if not callable(connection_provider):
|
||||||
|
raise TypeError("connection_provider must be callable")
|
||||||
|
|
||||||
|
self._connection_provider = connection_provider
|
||||||
|
|
||||||
|
def query_trades(self, query: TradeHistoryQuery) -> TradeHistoryPage:
|
||||||
|
"""Прочитать одну bounded страницу в устойчивом keyset-порядке."""
|
||||||
|
if type(query) is not TradeHistoryQuery:
|
||||||
|
raise MarketDataAccessValidationError(
|
||||||
|
"query must be TradeHistoryQuery"
|
||||||
|
)
|
||||||
|
|
||||||
|
sql, parameters = self._build_query(query)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._connection_provider() as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, parameters)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return self._page_from_rows(query=query, rows=rows)
|
||||||
|
except MarketDataAccessError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise MarketDataAccessOperationError(
|
||||||
|
"Failed to query PostgreSQL Trade History."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_query(
|
||||||
|
query: TradeHistoryQuery,
|
||||||
|
) -> tuple[str, tuple[object, ...]]:
|
||||||
|
fetch_limit = query.limit + 1
|
||||||
|
cursor = query.cursor
|
||||||
|
|
||||||
|
if cursor is None:
|
||||||
|
return (
|
||||||
|
_SELECT_TRADE_HISTORY_SQL,
|
||||||
|
(
|
||||||
|
query.venue,
|
||||||
|
query.symbol,
|
||||||
|
query.time_range.start_time,
|
||||||
|
query.time_range.end_time,
|
||||||
|
fetch_limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
_SELECT_TRADE_HISTORY_AFTER_CURSOR_SQL,
|
||||||
|
(
|
||||||
|
query.venue,
|
||||||
|
query.symbol,
|
||||||
|
query.time_range.start_time,
|
||||||
|
query.time_range.end_time,
|
||||||
|
cursor.executed_at,
|
||||||
|
cursor.replay_sequence,
|
||||||
|
fetch_limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _page_from_rows(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
query: TradeHistoryQuery,
|
||||||
|
rows: object,
|
||||||
|
) -> TradeHistoryPage:
|
||||||
|
if not isinstance(rows, Sequence) or isinstance(
|
||||||
|
rows,
|
||||||
|
(str, bytes, bytearray),
|
||||||
|
):
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Trade History rows."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(rows) > query.limit + 1:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL exceeded the requested Trade History limit."
|
||||||
|
)
|
||||||
|
|
||||||
|
records = tuple(
|
||||||
|
cls._record_from_row(row=row, query=query)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
cls._validate_records(records=records, query=query)
|
||||||
|
|
||||||
|
has_more = len(records) > query.limit
|
||||||
|
items = records[: query.limit]
|
||||||
|
next_cursor = (
|
||||||
|
cls._cursor_from_record(query=query, record=items[-1])
|
||||||
|
if has_more
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return TradeHistoryPage(
|
||||||
|
query=query,
|
||||||
|
items=items,
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned inconsistent Trade History page."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _record_from_row(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
row: object,
|
||||||
|
query: TradeHistoryQuery,
|
||||||
|
) -> TradeHistoryRecord:
|
||||||
|
record = trade_history_record_from_row(row)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if record.venue != query.venue or record.symbol != query.symbol:
|
||||||
|
raise ValueError("stored Trade is outside query scope")
|
||||||
|
|
||||||
|
if not query.time_range.contains(record.event_time):
|
||||||
|
raise ValueError("stored Trade is outside query range")
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"PostgreSQL returned invalid Canonical Trade values."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
return record
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_records(
|
||||||
|
*,
|
||||||
|
records: tuple[TradeHistoryRecord, ...],
|
||||||
|
query: TradeHistoryQuery,
|
||||||
|
) -> None:
|
||||||
|
seen_sequences: set[int] = set()
|
||||||
|
previous_key: tuple[datetime, int] | None = None
|
||||||
|
incoming_key = (
|
||||||
|
(
|
||||||
|
query.cursor.executed_at,
|
||||||
|
query.cursor.replay_sequence,
|
||||||
|
)
|
||||||
|
if query.cursor is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
if record.replay_sequence in seen_sequences:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Trade History contains duplicate replay_sequence."
|
||||||
|
)
|
||||||
|
|
||||||
|
if incoming_key is not None and record.order_key <= incoming_key:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Trade History row does not follow query cursor."
|
||||||
|
)
|
||||||
|
|
||||||
|
if previous_key is not None and record.order_key <= previous_key:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Trade History rows are not strictly ordered."
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_sequences.add(record.replay_sequence)
|
||||||
|
previous_key = record.order_key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cursor_from_record(
|
||||||
|
*,
|
||||||
|
query: TradeHistoryQuery,
|
||||||
|
record: TradeHistoryRecord,
|
||||||
|
) -> TradeHistoryCursor:
|
||||||
|
try:
|
||||||
|
return TradeHistoryCursor(
|
||||||
|
venue=query.venue,
|
||||||
|
symbol=query.symbol,
|
||||||
|
time_range=query.time_range,
|
||||||
|
executed_at=record.event_time,
|
||||||
|
replay_sequence=record.replay_sequence,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise MarketDataAccessIntegrityError(
|
||||||
|
"Cannot create Trade History cursor from stored row."
|
||||||
|
) from error
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# app/src/market_data/acquisition/adapters/dzengi/__init__.py
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket_trade_adapter import (
|
||||||
|
adapt_websocket_trade_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"adapt_websocket_trade_document",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# app/src/market_data/acquisition/adapters/dzengi/auth.py
|
||||||
@@ -9,21 +9,36 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
|||||||
DzengiExchangeInfoResponse,
|
DzengiExchangeInfoResponse,
|
||||||
DzengiExchangeInfoSymbol,
|
DzengiExchangeInfoSymbol,
|
||||||
DzengiInstrumentFilter,
|
DzengiInstrumentFilter,
|
||||||
|
DzengiKlinesResponse,
|
||||||
DzengiLotSizeFilter,
|
DzengiLotSizeFilter,
|
||||||
DzengiMinNotionalFilter,
|
DzengiMinNotionalFilter,
|
||||||
DzengiRawNumeric,
|
DzengiRawNumeric,
|
||||||
|
DzengiRestAggTrade,
|
||||||
DzengiTicker24hrResponse,
|
DzengiTicker24hrResponse,
|
||||||
|
DzengiWebSocketOhlcEvent,
|
||||||
DzengiWebSocketQuoteResponse,
|
DzengiWebSocketQuoteResponse,
|
||||||
|
DzengiWebSocketTradeEvent,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleMappingError,
|
||||||
|
CandleWebSocketMappingError,
|
||||||
InstrumentReferenceMappingError,
|
InstrumentReferenceMappingError,
|
||||||
QuoteMappingError,
|
QuoteMappingError,
|
||||||
|
TradeMappingError,
|
||||||
)
|
)
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
|
from src.market_data.acquisition.models.candle_close import CandleCloseEvent
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.models.trade import (
|
||||||
|
Trade,
|
||||||
|
TradeAggressorSide,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_DZENGI_SOURCE_NAME = "dzengi"
|
_DZENGI_SOURCE_NAME = "dzengi"
|
||||||
|
_DZENGI_WEBSOCKET_OHLC_SOURCE_NAME = "dzengi_websocket_ohlc"
|
||||||
|
_DZENGI_WEBSOCKET_TRADE_SOURCE_NAME = "dzengi_websocket_trade"
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_symbol_to_instrument(
|
def map_dzengi_symbol_to_instrument(
|
||||||
@@ -314,3 +329,336 @@ def map_dzengi_websocket_quote_to_quote(
|
|||||||
received_at=normalized_received_at,
|
received_at=normalized_received_at,
|
||||||
source=_DZENGI_SOURCE_NAME,
|
source=_DZENGI_SOURCE_NAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_websocket_ohlc_to_candle_close_event(
|
||||||
|
event: DzengiWebSocketOhlcEvent,
|
||||||
|
*,
|
||||||
|
received_at: datetime,
|
||||||
|
) -> CandleCloseEvent:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенное Dzengi WebSocket OHLC-событие
|
||||||
|
во внутреннюю модель CandleCloseEvent.
|
||||||
|
|
||||||
|
Функция предполагает, что до mapper уже были выполнены:
|
||||||
|
schema validation, parsing и value validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_received_at = _require_websocket_ohlc_aware_datetime(
|
||||||
|
received_at,
|
||||||
|
field_name="received_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
return CandleCloseEvent(
|
||||||
|
symbol=event.symbol.strip(),
|
||||||
|
interval=event.interval,
|
||||||
|
candle_type=event.candle_type,
|
||||||
|
open_time=_websocket_ohlc_timestamp_ms_to_utc_datetime(
|
||||||
|
event.open_time,
|
||||||
|
),
|
||||||
|
received_at=normalized_received_at,
|
||||||
|
open_price=_required_websocket_ohlc_decimal(
|
||||||
|
event.open_price,
|
||||||
|
field_name="open",
|
||||||
|
),
|
||||||
|
high_price=_required_websocket_ohlc_decimal(
|
||||||
|
event.high_price,
|
||||||
|
field_name="high",
|
||||||
|
),
|
||||||
|
low_price=_required_websocket_ohlc_decimal(
|
||||||
|
event.low_price,
|
||||||
|
field_name="low",
|
||||||
|
),
|
||||||
|
close_price=_required_websocket_ohlc_decimal(
|
||||||
|
event.close_price,
|
||||||
|
field_name="close",
|
||||||
|
),
|
||||||
|
source=_DZENGI_WEBSOCKET_OHLC_SOURCE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_ohlc_timestamp_ms_to_utc_datetime(
|
||||||
|
value: int,
|
||||||
|
) -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(
|
||||||
|
value / 1000,
|
||||||
|
tz=timezone.utc,
|
||||||
|
)
|
||||||
|
except (OverflowError, OSError, ValueError) as exc:
|
||||||
|
raise CandleWebSocketMappingError(
|
||||||
|
"Поле t WebSocket OHLC-события невозможно "
|
||||||
|
"преобразовать в UTC datetime."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _required_websocket_ohlc_decimal(
|
||||||
|
value: DzengiRawNumeric,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> Decimal:
|
||||||
|
try:
|
||||||
|
result = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise CandleWebSocketMappingError(
|
||||||
|
f"Поле {field_name} WebSocket OHLC-события невозможно "
|
||||||
|
"преобразовать в Decimal."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not result.is_finite():
|
||||||
|
raise CandleWebSocketMappingError(
|
||||||
|
f"Поле {field_name} WebSocket OHLC-события должно быть "
|
||||||
|
"конечным числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _require_websocket_ohlc_aware_datetime(
|
||||||
|
value: datetime,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> datetime:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise CandleWebSocketMappingError(
|
||||||
|
f"Поле {field_name} должно содержать "
|
||||||
|
"timezone-aware datetime."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_klines_to_candles(
|
||||||
|
response: DzengiKlinesResponse,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
interval: str,
|
||||||
|
source: str,
|
||||||
|
) -> tuple[Candle, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенные raw-модели Dzengi klines
|
||||||
|
в канонический immutable-набор Candle.
|
||||||
|
|
||||||
|
Функция предполагает, что до mapper уже были выполнены:
|
||||||
|
schema validation, parsing и value validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
candles = tuple(
|
||||||
|
Candle(
|
||||||
|
symbol=symbol.strip(),
|
||||||
|
interval=interval.strip(),
|
||||||
|
open_time=_candle_timestamp_ms_to_utc_datetime(
|
||||||
|
item.open_time,
|
||||||
|
),
|
||||||
|
open_price=_required_candle_decimal(
|
||||||
|
item.open_price,
|
||||||
|
field_name="open",
|
||||||
|
),
|
||||||
|
high_price=_required_candle_decimal(
|
||||||
|
item.high_price,
|
||||||
|
field_name="high",
|
||||||
|
),
|
||||||
|
low_price=_required_candle_decimal(
|
||||||
|
item.low_price,
|
||||||
|
field_name="low",
|
||||||
|
),
|
||||||
|
close_price=_required_candle_decimal(
|
||||||
|
item.close_price,
|
||||||
|
field_name="close",
|
||||||
|
),
|
||||||
|
volume=_required_candle_decimal(
|
||||||
|
item.volume,
|
||||||
|
field_name="volume",
|
||||||
|
),
|
||||||
|
source=source.strip(),
|
||||||
|
)
|
||||||
|
for item in response.items
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
candles,
|
||||||
|
key=lambda candle: candle.open_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candle_timestamp_ms_to_utc_datetime(value: int) -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(
|
||||||
|
value / 1000,
|
||||||
|
tz=timezone.utc,
|
||||||
|
)
|
||||||
|
except (OverflowError, OSError, ValueError) as exc:
|
||||||
|
raise CandleMappingError(
|
||||||
|
"Поле openTime свечи невозможно преобразовать "
|
||||||
|
"в UTC datetime."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _required_candle_decimal(
|
||||||
|
value: DzengiRawNumeric,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> Decimal:
|
||||||
|
try:
|
||||||
|
result = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise CandleMappingError(
|
||||||
|
f"Поле {field_name} свечи невозможно "
|
||||||
|
"преобразовать в Decimal."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not result.is_finite():
|
||||||
|
raise CandleMappingError(
|
||||||
|
f"Поле {field_name} свечи должно быть конечным числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_rest_agg_trades_to_trades(
|
||||||
|
trades: tuple[DzengiRestAggTrade, ...],
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
) -> tuple[Trade, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенные transport-модели Dzengi aggTrades
|
||||||
|
в канонический immutable-набор Trade.
|
||||||
|
|
||||||
|
Функция предполагает, что до mapper уже были выполнены:
|
||||||
|
schema validation, parsing и value validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
_map_dzengi_rest_agg_trade(
|
||||||
|
trade,
|
||||||
|
symbol=symbol,
|
||||||
|
)
|
||||||
|
for trade in trades
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_dzengi_websocket_trade_to_trade(
|
||||||
|
event: DzengiWebSocketTradeEvent,
|
||||||
|
) -> Trade:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенную transport-модель
|
||||||
|
WebSocket Trade в каноническую модель Trade.
|
||||||
|
|
||||||
|
Предполагается, что ранее успешно выполнены:
|
||||||
|
|
||||||
|
- Schema Validation
|
||||||
|
- Parser
|
||||||
|
- Value Validation
|
||||||
|
"""
|
||||||
|
|
||||||
|
return Trade(
|
||||||
|
symbol=event.symbol.strip(),
|
||||||
|
trade_id=event.trade_id,
|
||||||
|
price=_required_trade_decimal(
|
||||||
|
event.price,
|
||||||
|
field_name="price",
|
||||||
|
),
|
||||||
|
quantity=_required_trade_decimal(
|
||||||
|
event.size,
|
||||||
|
field_name="size",
|
||||||
|
),
|
||||||
|
executed_at=_trade_timestamp_ms_to_utc_datetime(
|
||||||
|
event.timestamp,
|
||||||
|
),
|
||||||
|
aggressor_side=_websocket_trade_aggressor_side(
|
||||||
|
event.buyer,
|
||||||
|
),
|
||||||
|
source=_DZENGI_WEBSOCKET_TRADE_SOURCE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_dzengi_rest_agg_trade(
|
||||||
|
trade: DzengiRestAggTrade,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
) -> Trade:
|
||||||
|
return Trade(
|
||||||
|
symbol=symbol.strip(),
|
||||||
|
trade_id=trade.aggregate_trade_id,
|
||||||
|
price=_required_trade_decimal(
|
||||||
|
trade.price,
|
||||||
|
field_name="price",
|
||||||
|
),
|
||||||
|
quantity=_required_trade_decimal(
|
||||||
|
trade.quantity,
|
||||||
|
field_name="quantity",
|
||||||
|
),
|
||||||
|
executed_at=_trade_timestamp_ms_to_utc_datetime(
|
||||||
|
trade.timestamp,
|
||||||
|
),
|
||||||
|
aggressor_side=_trade_aggressor_side(
|
||||||
|
trade.buyer_is_maker,
|
||||||
|
),
|
||||||
|
source=_DZENGI_SOURCE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_aggressor_side(
|
||||||
|
buyer_is_maker: bool,
|
||||||
|
) -> TradeAggressorSide:
|
||||||
|
if buyer_is_maker:
|
||||||
|
return TradeAggressorSide.SELL
|
||||||
|
|
||||||
|
return TradeAggressorSide.BUY
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_trade_aggressor_side(
|
||||||
|
buyer: bool,
|
||||||
|
) -> TradeAggressorSide:
|
||||||
|
"""
|
||||||
|
Преобразовать направление WebSocket Trade
|
||||||
|
в каноническую сторону агрессора.
|
||||||
|
|
||||||
|
WebSocket:
|
||||||
|
buyer=True -> BUY
|
||||||
|
buyer=False -> SELL
|
||||||
|
"""
|
||||||
|
|
||||||
|
if buyer:
|
||||||
|
return TradeAggressorSide.BUY
|
||||||
|
|
||||||
|
return TradeAggressorSide.SELL
|
||||||
|
|
||||||
|
|
||||||
|
def _required_trade_decimal(
|
||||||
|
value: DzengiRawNumeric,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> Decimal:
|
||||||
|
try:
|
||||||
|
result = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise TradeMappingError(
|
||||||
|
f"Поле {field_name} сделки невозможно "
|
||||||
|
"преобразовать в Decimal."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not result.is_finite():
|
||||||
|
raise TradeMappingError(
|
||||||
|
f"Поле {field_name} сделки должно быть "
|
||||||
|
"конечным числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_timestamp_ms_to_utc_datetime(
|
||||||
|
value: int,
|
||||||
|
) -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(
|
||||||
|
value / 1000,
|
||||||
|
tz=timezone.utc,
|
||||||
|
)
|
||||||
|
except (OverflowError, OSError, ValueError) as exc:
|
||||||
|
raise TradeMappingError(
|
||||||
|
"Поле timestamp сделки невозможно "
|
||||||
|
"преобразовать в UTC datetime."
|
||||||
|
) from exc
|
||||||
@@ -131,3 +131,63 @@ class DzengiWebSocketQuoteResponse:
|
|||||||
bid_price: DzengiRawNumeric
|
bid_price: DzengiRawNumeric
|
||||||
ask_price: DzengiRawNumeric
|
ask_price: DzengiRawNumeric
|
||||||
timestamp: int | None
|
timestamp: int | None
|
||||||
|
|
||||||
|
|
||||||
|
# Транспортное представление одной свечи Dzengi GET /api/v1/klines.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiKline:
|
||||||
|
open_time: int
|
||||||
|
open_price: str | int | float
|
||||||
|
high_price: str | int | float
|
||||||
|
low_price: str | int | float
|
||||||
|
close_price: str | int | float
|
||||||
|
volume: str | int | float
|
||||||
|
|
||||||
|
|
||||||
|
# Транспортное представление ответа Dzengi GET /api/v1/klines.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiKlinesResponse:
|
||||||
|
items: tuple[DzengiKline, ...]
|
||||||
|
|
||||||
|
|
||||||
|
# Транспортное представление одной агрегированной сделки
|
||||||
|
# Dzengi GET /api/v2/aggTrades.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiRestAggTrade:
|
||||||
|
aggregate_trade_id: int
|
||||||
|
price: DzengiRawNumeric
|
||||||
|
quantity: DzengiRawNumeric
|
||||||
|
timestamp: int
|
||||||
|
buyer_is_maker: bool
|
||||||
|
|
||||||
|
|
||||||
|
# Транспортное представление одного события
|
||||||
|
# Dzengi WebSocket internal.trade.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiWebSocketTradeEvent:
|
||||||
|
trade_id: int
|
||||||
|
price: DzengiRawNumeric
|
||||||
|
size: DzengiRawNumeric
|
||||||
|
timestamp: int
|
||||||
|
symbol: str
|
||||||
|
buyer: bool
|
||||||
|
order_id: str
|
||||||
|
|
||||||
|
|
||||||
|
# Транспортное представление одного события Dzengi WebSocket ohlc.event.
|
||||||
|
#
|
||||||
|
# Событие содержит завершённую OHLC-свечу без объёма и поэтому не является
|
||||||
|
# канонической моделью Candle. Поле candle_type принимает значения,
|
||||||
|
# подтверждённые runtime-контрактом Dzengi: classic или heikin-ashi.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DzengiWebSocketOhlcEvent:
|
||||||
|
symbol: str
|
||||||
|
interval: str
|
||||||
|
candle_type: str
|
||||||
|
|
||||||
|
open_time: int
|
||||||
|
|
||||||
|
open_price: DzengiRawNumeric
|
||||||
|
high_price: DzengiRawNumeric
|
||||||
|
low_price: DzengiRawNumeric
|
||||||
|
close_price: DzengiRawNumeric
|
||||||
|
|||||||
@@ -11,22 +11,34 @@ from src.market_data.acquisition.adapters.dzengi.models import (
|
|||||||
DzengiInstrumentFilter,
|
DzengiInstrumentFilter,
|
||||||
DzengiJsonNumber,
|
DzengiJsonNumber,
|
||||||
DzengiJsonScalar,
|
DzengiJsonScalar,
|
||||||
|
DzengiKline,
|
||||||
|
DzengiKlinesResponse,
|
||||||
DzengiLotSizeFilter,
|
DzengiLotSizeFilter,
|
||||||
DzengiMinNotionalFilter,
|
DzengiMinNotionalFilter,
|
||||||
DzengiRateLimit,
|
DzengiRateLimit,
|
||||||
DzengiRawNumeric,
|
DzengiRawNumeric,
|
||||||
|
DzengiRestAggTrade,
|
||||||
DzengiUnknownFilter,
|
DzengiUnknownFilter,
|
||||||
DzengiTicker24hrResponse,
|
DzengiTicker24hrResponse,
|
||||||
|
DzengiWebSocketOhlcEvent,
|
||||||
DzengiWebSocketQuoteResponse,
|
DzengiWebSocketQuoteResponse,
|
||||||
|
DzengiWebSocketTradeEvent,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleParseError,
|
||||||
|
CandleWebSocketParseError,
|
||||||
InstrumentReferenceParseError,
|
InstrumentReferenceParseError,
|
||||||
QuoteParseError,
|
QuoteParseError,
|
||||||
|
TradeParseError,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.validation.schema import (
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
ValidatedCandlesDocument,
|
||||||
ValidatedExchangeInfoDocument,
|
ValidatedExchangeInfoDocument,
|
||||||
ValidatedQuoteDocument,
|
ValidatedQuoteDocument,
|
||||||
|
ValidatedRestAggTradesDocument,
|
||||||
|
ValidatedWebSocketOhlcDocument,
|
||||||
ValidatedWebSocketQuoteDocument,
|
ValidatedWebSocketQuoteDocument,
|
||||||
|
ValidatedWebSocketTradeDocument,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -714,3 +726,446 @@ def _websocket_optional_timestamp(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return _quote_required_int(value, path=path)
|
return _quote_required_int(value, path=path)
|
||||||
|
|
||||||
|
|
||||||
|
# Преобразовать структурно проверенное событие WebSocket OHLC
|
||||||
|
# в транспортную модель адаптера Dzengi.
|
||||||
|
def parse_dzengi_websocket_ohlc(
|
||||||
|
document: ValidatedWebSocketOhlcDocument,
|
||||||
|
) -> DzengiWebSocketOhlcEvent:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенный payload события ``ohlc.event``
|
||||||
|
в transport-модель Dzengi.
|
||||||
|
|
||||||
|
Функция не выполняет предметную проверку значений, не проверяет
|
||||||
|
допустимость интервала или типа свечи, не преобразует timestamp
|
||||||
|
в datetime и не выполняет mapping во внутреннюю модель Dzentra.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = document.payload
|
||||||
|
|
||||||
|
return DzengiWebSocketOhlcEvent(
|
||||||
|
symbol=_websocket_ohlc_required_string(
|
||||||
|
payload.get("symbol"),
|
||||||
|
path="$.payload.symbol",
|
||||||
|
),
|
||||||
|
interval=_websocket_ohlc_required_string(
|
||||||
|
payload.get("interval"),
|
||||||
|
path="$.payload.interval",
|
||||||
|
),
|
||||||
|
candle_type=_websocket_ohlc_required_string(
|
||||||
|
payload.get("type"),
|
||||||
|
path="$.payload.type",
|
||||||
|
),
|
||||||
|
open_time=_websocket_ohlc_required_int(
|
||||||
|
payload.get("t"),
|
||||||
|
path="$.payload.t",
|
||||||
|
),
|
||||||
|
open_price=_websocket_ohlc_required_raw_numeric(
|
||||||
|
payload.get("o"),
|
||||||
|
path="$.payload.o",
|
||||||
|
),
|
||||||
|
high_price=_websocket_ohlc_required_raw_numeric(
|
||||||
|
payload.get("h"),
|
||||||
|
path="$.payload.h",
|
||||||
|
),
|
||||||
|
low_price=_websocket_ohlc_required_raw_numeric(
|
||||||
|
payload.get("l"),
|
||||||
|
path="$.payload.l",
|
||||||
|
),
|
||||||
|
close_price=_websocket_ohlc_required_raw_numeric(
|
||||||
|
payload.get("c"),
|
||||||
|
path="$.payload.c",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_ohlc_required_string(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise CandleWebSocketParseError(
|
||||||
|
f"{path} должен быть строкой, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_ohlc_required_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise CandleWebSocketParseError(
|
||||||
|
f"{path} должен быть целым числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_ohlc_required_raw_numeric(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiRawNumeric:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||||
|
raise CandleWebSocketParseError(
|
||||||
|
f"{path} должен быть строкой или числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Преобразовать структурно проверенное событие WebSocket Trade
|
||||||
|
# в транспортную модель адаптера Dzengi.
|
||||||
|
def parse_dzengi_websocket_trade(
|
||||||
|
document: ValidatedWebSocketTradeDocument,
|
||||||
|
) -> DzengiWebSocketTradeEvent:
|
||||||
|
"""
|
||||||
|
Преобразовать проверенный payload события ``internal.trade``
|
||||||
|
в transport-модель Dzengi.
|
||||||
|
|
||||||
|
Функция не выполняет предметную проверку значений, не проверяет
|
||||||
|
допустимость цены, размера сделки или timestamp, не интерпретирует
|
||||||
|
сторону сделки и не выполняет mapping во внутреннюю модель Dzentra.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = document.payload
|
||||||
|
|
||||||
|
return DzengiWebSocketTradeEvent(
|
||||||
|
trade_id=_websocket_trade_required_int(
|
||||||
|
payload.get("id"),
|
||||||
|
path="$.payload.id",
|
||||||
|
),
|
||||||
|
price=_websocket_trade_required_raw_numeric(
|
||||||
|
payload.get("price"),
|
||||||
|
path="$.payload.price",
|
||||||
|
),
|
||||||
|
size=_websocket_trade_required_raw_numeric(
|
||||||
|
payload.get("size"),
|
||||||
|
path="$.payload.size",
|
||||||
|
),
|
||||||
|
timestamp=_websocket_trade_required_int(
|
||||||
|
payload.get("ts"),
|
||||||
|
path="$.payload.ts",
|
||||||
|
),
|
||||||
|
symbol=_websocket_trade_required_string(
|
||||||
|
payload.get("symbol"),
|
||||||
|
path="$.payload.symbol",
|
||||||
|
),
|
||||||
|
buyer=_websocket_trade_required_bool(
|
||||||
|
payload.get("buyer"),
|
||||||
|
path="$.payload.buyer",
|
||||||
|
),
|
||||||
|
order_id=_websocket_trade_required_string(
|
||||||
|
payload.get("orderId"),
|
||||||
|
path="$.payload.orderId",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_trade_required_string(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TradeParseError(
|
||||||
|
f"{path} должен быть строкой, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_trade_required_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise TradeParseError(
|
||||||
|
f"{path} должен быть целым числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_trade_required_bool(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> bool:
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise TradeParseError(
|
||||||
|
f"{path} должен быть булевым значением, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _websocket_trade_required_raw_numeric(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiRawNumeric:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||||
|
raise TradeParseError(
|
||||||
|
f"{path} должен быть строкой или числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Преобразовать структурно проверенный ответ klines в raw-модели Dzengi.
|
||||||
|
def parse_candles(
|
||||||
|
document: ValidatedCandlesDocument,
|
||||||
|
) -> DzengiKlinesResponse:
|
||||||
|
"""
|
||||||
|
Преобразовать элементы проверенного документа klines в transport-модели.
|
||||||
|
|
||||||
|
Функция не выполняет предметную проверку числовых значений,
|
||||||
|
OHLC-инвариантов или mapping во внутреннюю модель Candle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = tuple(
|
||||||
|
_parse_candle_item(
|
||||||
|
item,
|
||||||
|
path=f"$.candles[{index}]",
|
||||||
|
)
|
||||||
|
for index, item in enumerate(document.items)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DzengiKlinesResponse(items=items)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_candle_item(
|
||||||
|
item: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiKline:
|
||||||
|
if isinstance(item, Mapping):
|
||||||
|
return _parse_candle_mapping(item, path=path)
|
||||||
|
|
||||||
|
if isinstance(item, tuple):
|
||||||
|
return _parse_candle_sequence(item, path=path)
|
||||||
|
|
||||||
|
raise CandleParseError(
|
||||||
|
f"{path} должен быть проверенным JSON-объектом или массивом."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_candle_mapping(
|
||||||
|
item: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiKline:
|
||||||
|
open_time_key = _first_present_key(
|
||||||
|
item,
|
||||||
|
keys=("openTime", "open_time", "time", "timestamp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if open_time_key is None:
|
||||||
|
raise CandleParseError(
|
||||||
|
f"{path} не содержит openTime, open_time, time или timestamp."
|
||||||
|
)
|
||||||
|
|
||||||
|
return DzengiKline(
|
||||||
|
open_time=_candle_required_int(
|
||||||
|
item.get(open_time_key),
|
||||||
|
path=f"{path}.{open_time_key}",
|
||||||
|
),
|
||||||
|
open_price=_candle_required_raw_numeric(
|
||||||
|
_required_candle_field(item, key="open", path=path),
|
||||||
|
path=f"{path}.open",
|
||||||
|
),
|
||||||
|
high_price=_candle_required_raw_numeric(
|
||||||
|
_required_candle_field(item, key="high", path=path),
|
||||||
|
path=f"{path}.high",
|
||||||
|
),
|
||||||
|
low_price=_candle_required_raw_numeric(
|
||||||
|
_required_candle_field(item, key="low", path=path),
|
||||||
|
path=f"{path}.low",
|
||||||
|
),
|
||||||
|
close_price=_candle_required_raw_numeric(
|
||||||
|
_required_candle_field(item, key="close", path=path),
|
||||||
|
path=f"{path}.close",
|
||||||
|
),
|
||||||
|
volume=_candle_required_raw_numeric(
|
||||||
|
_required_candle_field(item, key="volume", path=path),
|
||||||
|
path=f"{path}.volume",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_candle_sequence(
|
||||||
|
item: tuple[object, ...],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiKline:
|
||||||
|
if len(item) < 6:
|
||||||
|
raise CandleParseError(
|
||||||
|
f"{path} должен содержать минимум 6 элементов."
|
||||||
|
)
|
||||||
|
|
||||||
|
return DzengiKline(
|
||||||
|
open_time=_candle_required_int(item[0], path=f"{path}[0]"),
|
||||||
|
open_price=_candle_required_raw_numeric(item[1], path=f"{path}[1]"),
|
||||||
|
high_price=_candle_required_raw_numeric(item[2], path=f"{path}[2]"),
|
||||||
|
low_price=_candle_required_raw_numeric(item[3], path=f"{path}[3]"),
|
||||||
|
close_price=_candle_required_raw_numeric(item[4], path=f"{path}[4]"),
|
||||||
|
volume=_candle_required_raw_numeric(item[5], path=f"{path}[5]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_present_key(
|
||||||
|
mapping: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
keys: tuple[str, ...],
|
||||||
|
) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _required_candle_field(
|
||||||
|
mapping: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
path: str,
|
||||||
|
) -> object:
|
||||||
|
if key not in mapping:
|
||||||
|
raise CandleParseError(
|
||||||
|
f"{path}.{key} отсутствует в элементе klines."
|
||||||
|
)
|
||||||
|
return mapping.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _candle_required_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise CandleParseError(
|
||||||
|
f"{path} должен быть целым числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _candle_required_raw_numeric(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiRawNumeric:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||||
|
raise CandleParseError(
|
||||||
|
f"{path} должен быть строкой или числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Преобразовать структурно проверенный ответ aggTrades
|
||||||
|
# в транспортные модели адаптера Dzengi.
|
||||||
|
def parse_rest_agg_trades(
|
||||||
|
document: ValidatedRestAggTradesDocument,
|
||||||
|
) -> tuple[DzengiRestAggTrade, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать элементы проверенного документа aggTrades
|
||||||
|
в transport-модели Dzengi.
|
||||||
|
|
||||||
|
Функция не выполняет schema validation, предметную проверку значений,
|
||||||
|
преобразование числовых значений в Decimal или mapping
|
||||||
|
во внутреннюю модель Trade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
_parse_rest_agg_trade_item(
|
||||||
|
item,
|
||||||
|
path=f"$[{index}]",
|
||||||
|
)
|
||||||
|
for index, item in enumerate(document.items)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rest_agg_trade_item(
|
||||||
|
item: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiRestAggTrade:
|
||||||
|
return DzengiRestAggTrade(
|
||||||
|
aggregate_trade_id=_trade_required_int(
|
||||||
|
item.get("a"),
|
||||||
|
path=f"{path}.a",
|
||||||
|
),
|
||||||
|
price=_trade_required_raw_numeric(
|
||||||
|
item.get("p"),
|
||||||
|
path=f"{path}.p",
|
||||||
|
),
|
||||||
|
quantity=_trade_required_raw_numeric(
|
||||||
|
item.get("q"),
|
||||||
|
path=f"{path}.q",
|
||||||
|
),
|
||||||
|
timestamp=_trade_required_int(
|
||||||
|
item.get("T"),
|
||||||
|
path=f"{path}.T",
|
||||||
|
),
|
||||||
|
buyer_is_maker=_trade_required_bool(
|
||||||
|
item.get("m"),
|
||||||
|
path=f"{path}.m",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_required_int(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise TradeParseError(
|
||||||
|
f"{path} должен быть целым числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_required_raw_numeric(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> DzengiRawNumeric:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||||
|
raise TradeParseError(
|
||||||
|
f"{path} должен быть строкой или числом, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_required_bool(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
) -> bool:
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise TradeParseError(
|
||||||
|
f"{path} должен быть логическим значением, "
|
||||||
|
f"получен {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ from typing import Protocol
|
|||||||
|
|
||||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
from src.integrations.exchange.rest_client import ExchangeRestClient
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleTransportError,
|
||||||
InstrumentReferenceTransportError,
|
InstrumentReferenceTransportError,
|
||||||
QuoteTransportError,
|
QuoteTransportError,
|
||||||
|
TradeTransportError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
|
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
|
||||||
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
|
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
|
||||||
|
_KLINES_PATH = "/api/v1/klines"
|
||||||
|
_AGG_TRADES_PATH = "/api/v1/aggTrades"
|
||||||
|
|
||||||
|
|
||||||
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
|
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
|
||||||
@@ -104,3 +108,108 @@ class DzengiQuoteDocumentSource:
|
|||||||
"Не удалось получить текущую котировку "
|
"Не удалось получить текущую котировку "
|
||||||
f"от Dzengi для символа '{symbol}': {exc}"
|
f"от Dzengi для символа '{symbol}': {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class DzengiCandlesDocumentSource:
|
||||||
|
"""Источник сырого документа свечей через Dzengi REST API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: _PayloadRestClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def fetch_candles_document(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
interval: str,
|
||||||
|
limit: int,
|
||||||
|
price_type: str,
|
||||||
|
) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный ответ Dzengi klines без его обработки.
|
||||||
|
|
||||||
|
Метод не выполняет нормализацию параметров запроса, schema validation,
|
||||||
|
parsing, value validation, mapping, retry, сортировку или кэширование.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
client: _PayloadRestClient = (
|
||||||
|
self._client
|
||||||
|
if self._client is not None
|
||||||
|
else ExchangeRestClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
return client.get_payload(
|
||||||
|
_KLINES_PATH,
|
||||||
|
params={
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"limit": str(limit),
|
||||||
|
"priceType": price_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
raise CandleTransportError(
|
||||||
|
"Не удалось получить свечи "
|
||||||
|
f"от Dzengi для символа '{symbol}', "
|
||||||
|
f"интервала '{interval}' и типа цены '{price_type}': {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class DzengiTradesDocumentSource:
|
||||||
|
"""Источник сырого документа агрегированных сделок через Dzengi REST API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: _PayloadRestClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def fetch_trades_document(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
start_time: int | None = None,
|
||||||
|
end_time: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный ответ Dzengi aggTrades без его обработки.
|
||||||
|
|
||||||
|
Метод не выполняет schema validation, parsing, value validation,
|
||||||
|
mapping, сортировку, дедупликацию или кэширование.
|
||||||
|
"""
|
||||||
|
|
||||||
|
params: dict[str, str] = {
|
||||||
|
"symbol": symbol,
|
||||||
|
}
|
||||||
|
|
||||||
|
if start_time is not None:
|
||||||
|
params["startTime"] = str(start_time)
|
||||||
|
|
||||||
|
if end_time is not None:
|
||||||
|
params["endTime"] = str(end_time)
|
||||||
|
|
||||||
|
if limit is not None:
|
||||||
|
params["limit"] = str(limit)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client: _PayloadRestClient = (
|
||||||
|
self._client
|
||||||
|
if self._client is not None
|
||||||
|
else ExchangeRestClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
return client.get_payload(
|
||||||
|
_AGG_TRADES_PATH,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
raise TradeTransportError(
|
||||||
|
"Не удалось получить агрегированные сделки "
|
||||||
|
f"от Dzengi для символа '{symbol}': {exc}"
|
||||||
|
) from exc
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# src/market_data/acquisition/adapters/dzengi/rest_trade_adapter.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_rest_agg_trades_to_trades,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
|
parse_rest_agg_trades,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
ValidatedRestAggTradesDocument,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_rest_agg_trade_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_rest_agg_trades_document(
|
||||||
|
document: ValidatedRestAggTradesDocument,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
) -> tuple[Trade, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать структурно проверенный документ Dzengi aggTrades
|
||||||
|
в канонический immutable-набор Trade.
|
||||||
|
|
||||||
|
Функция последовательно выполняет parsing, value validation
|
||||||
|
и mapping. Schema validation должна быть выполнена вызывающим слоем.
|
||||||
|
Исключения отдельных этапов не перехватываются и не оборачиваются.
|
||||||
|
"""
|
||||||
|
|
||||||
|
trades = parse_rest_agg_trades(document)
|
||||||
|
|
||||||
|
validate_rest_agg_trade_values(trades)
|
||||||
|
|
||||||
|
return map_dzengi_rest_agg_trades_to_trades(
|
||||||
|
trades,
|
||||||
|
symbol=symbol,
|
||||||
|
)
|
||||||
@@ -5,16 +5,29 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_websocket_ohlc_to_candle_close_event,
|
||||||
map_dzengi_websocket_quote_to_quote,
|
map_dzengi_websocket_quote_to_quote,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
|
parse_dzengi_websocket_ohlc,
|
||||||
parse_dzengi_websocket_quote,
|
parse_dzengi_websocket_quote,
|
||||||
)
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.websocket_trade_adapter import (
|
||||||
|
adapt_websocket_trade_document,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
WebSocketMessageRoutingError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.candle_close import CandleCloseEvent
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
from src.market_data.acquisition.validation.schema import (
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
validate_dzengi_websocket_ohlc_schema,
|
||||||
validate_dzengi_websocket_quote_schema,
|
validate_dzengi_websocket_quote_schema,
|
||||||
|
validate_dzengi_websocket_trade_schema,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.validation.values import (
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_dzengi_websocket_ohlc_values,
|
||||||
validate_dzengi_websocket_quote_values,
|
validate_dzengi_websocket_quote_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,3 +48,98 @@ class DzengiWebSocketQuoteAdapter:
|
|||||||
response,
|
response,
|
||||||
received_at=received_at or datetime.now(timezone.utc),
|
received_at=received_at or datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Преобразует одно декодированное сообщение Dzengi WebSocket
|
||||||
|
# в CandleCloseEvent.
|
||||||
|
class DzengiWebSocketOhlcAdapter:
|
||||||
|
def map_message(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
*,
|
||||||
|
received_at: datetime | None = None,
|
||||||
|
) -> CandleCloseEvent:
|
||||||
|
validated = validate_dzengi_websocket_ohlc_schema(document)
|
||||||
|
event = parse_dzengi_websocket_ohlc(validated)
|
||||||
|
validate_dzengi_websocket_ohlc_values(event)
|
||||||
|
|
||||||
|
return map_dzengi_websocket_ohlc_to_candle_close_event(
|
||||||
|
event,
|
||||||
|
received_at=received_at or datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Преобразует одно декодированное сообщение Dzengi WebSocket
|
||||||
|
# internal.trade в каноническую модель Trade.
|
||||||
|
class DzengiWebSocketTradeAdapter:
|
||||||
|
def map_message(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> Trade:
|
||||||
|
validated = validate_dzengi_websocket_trade_schema(
|
||||||
|
document,
|
||||||
|
)
|
||||||
|
|
||||||
|
return adapt_websocket_trade_document(
|
||||||
|
validated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Определяет тип декодированного сообщения Dzengi WebSocket
|
||||||
|
# и передаёт его соответствующему специализированному адаптеру.
|
||||||
|
class DzengiUnifiedWebSocketAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
quote_adapter: DzengiWebSocketQuoteAdapter | None = None,
|
||||||
|
ohlc_adapter: DzengiWebSocketOhlcAdapter | None = None,
|
||||||
|
trade_adapter: DzengiWebSocketTradeAdapter | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._quote_adapter = (
|
||||||
|
quote_adapter
|
||||||
|
or DzengiWebSocketQuoteAdapter()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._ohlc_adapter = (
|
||||||
|
ohlc_adapter
|
||||||
|
or DzengiWebSocketOhlcAdapter()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._trade_adapter = (
|
||||||
|
trade_adapter
|
||||||
|
or DzengiWebSocketTradeAdapter()
|
||||||
|
)
|
||||||
|
|
||||||
|
def map_message(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
*,
|
||||||
|
received_at: datetime | None = None,
|
||||||
|
) -> Quote | CandleCloseEvent | Trade:
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Сообщение Dzengi WebSocket должно быть объектом."
|
||||||
|
)
|
||||||
|
|
||||||
|
destination = document.get("destination")
|
||||||
|
|
||||||
|
if destination == "ohlc.event":
|
||||||
|
return self._ohlc_adapter.map_message(
|
||||||
|
document,
|
||||||
|
received_at=received_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
if destination == "internal.trade":
|
||||||
|
return self._trade_adapter.map_message(
|
||||||
|
document,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "Payload" in document:
|
||||||
|
return self._quote_adapter.map_message(
|
||||||
|
document,
|
||||||
|
received_at=received_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Не удалось определить тип сообщения Dzengi WebSocket."
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
WebSocketControlMessageError,
|
||||||
|
WebSocketMessageRoutingError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TRADE_SUBSCRIPTION_DESTINATION = "trades.subscribe"
|
||||||
|
_SUCCESS_STATUS = "OK"
|
||||||
|
|
||||||
|
|
||||||
|
class DzengiWebSocketControlMessageHandler:
|
||||||
|
"""
|
||||||
|
Provider-specific проверка Dzengi control/ACK сообщений.
|
||||||
|
|
||||||
|
Handler не управляет lifecycle и не хранит pending requests.
|
||||||
|
Ожидаемый correlation ID передаётся владельцем Runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
*,
|
||||||
|
expected_correlation_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Проверить ACK текущей Trade Stream подписки."""
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Control message Dzengi WebSocket должен быть объектом."
|
||||||
|
)
|
||||||
|
|
||||||
|
correlation_id = document.get("correlationId")
|
||||||
|
|
||||||
|
if correlation_id != expected_correlation_id:
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Получен control message с неизвестным correlationId."
|
||||||
|
)
|
||||||
|
|
||||||
|
status = document.get("status")
|
||||||
|
|
||||||
|
if not isinstance(status, str) or not status.strip():
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Control message Dzengi WebSocket должен содержать "
|
||||||
|
"непустой строковый status."
|
||||||
|
)
|
||||||
|
|
||||||
|
if status != _SUCCESS_STATUS:
|
||||||
|
raise WebSocketControlMessageError(
|
||||||
|
"Dzengi отклонил Trade Stream subscription ACK."
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
document.get("destination")
|
||||||
|
!= _TRADE_SUBSCRIPTION_DESTINATION
|
||||||
|
):
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Получен успешный control message с неизвестным "
|
||||||
|
"destination."
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
WebSocketMessageRoutingError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_inbound_message import (
|
||||||
|
WebSocketInboundMessageKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_DZENGI_MARKET_DESTINATIONS = frozenset(
|
||||||
|
{
|
||||||
|
"internal.trade",
|
||||||
|
"ohlc.event",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DzengiWebSocketInboundMessageClassifier:
|
||||||
|
"""
|
||||||
|
Классификатор декодированных входящих сообщений Dzengi.
|
||||||
|
|
||||||
|
Market markers имеют приоритет над correlationId, чтобы market
|
||||||
|
document с дополнительными transport metadata не был ошибочно
|
||||||
|
принят за control response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> WebSocketInboundMessageKind:
|
||||||
|
"""Разделить market documents и ответы на Runtime-команды."""
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Сообщение Dzengi WebSocket должно быть объектом."
|
||||||
|
)
|
||||||
|
|
||||||
|
destination = document.get("destination")
|
||||||
|
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
isinstance(destination, str)
|
||||||
|
and destination in _DZENGI_MARKET_DESTINATIONS
|
||||||
|
)
|
||||||
|
or "Payload" in document
|
||||||
|
):
|
||||||
|
return WebSocketInboundMessageKind.MARKET
|
||||||
|
|
||||||
|
if "correlationId" in document:
|
||||||
|
self._validate_correlation_id(
|
||||||
|
document["correlationId"],
|
||||||
|
)
|
||||||
|
return WebSocketInboundMessageKind.CONTROL
|
||||||
|
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"Не удалось определить категорию сообщения "
|
||||||
|
"Dzengi WebSocket."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_correlation_id(
|
||||||
|
correlation_id: object,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
isinstance(correlation_id, bool)
|
||||||
|
or not isinstance(correlation_id, (str, int))
|
||||||
|
):
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"correlationId сообщения Dzengi WebSocket должен "
|
||||||
|
"быть строкой или целым числом."
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
isinstance(correlation_id, str)
|
||||||
|
and not correlation_id.strip()
|
||||||
|
):
|
||||||
|
raise WebSocketMessageRoutingError(
|
||||||
|
"correlationId сообщения Dzengi WebSocket "
|
||||||
|
"не должен быть пустым."
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# src/market_data/acquisition/adapters/dzengi/websocket_trade_adapter.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_websocket_trade_to_trade,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
|
parse_dzengi_websocket_trade,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
ValidatedWebSocketTradeDocument,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_dzengi_websocket_trade_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_websocket_trade_document(
|
||||||
|
document: ValidatedWebSocketTradeDocument,
|
||||||
|
) -> Trade:
|
||||||
|
"""
|
||||||
|
Преобразовать структурно проверенный документ Dzengi WebSocket Trade
|
||||||
|
в каноническую immutable-модель Trade.
|
||||||
|
|
||||||
|
Функция последовательно выполняет parsing, value validation
|
||||||
|
и mapping. Schema validation должна быть выполнена вызывающим слоем.
|
||||||
|
Исключения отдельных этапов не перехватываются и не оборачиваются.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event = parse_dzengi_websocket_trade(document)
|
||||||
|
|
||||||
|
validate_dzengi_websocket_trade_values(event)
|
||||||
|
|
||||||
|
return map_dzengi_websocket_trade_to_trade(event)
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
|
from typing import Protocol, cast
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from websockets.asyncio.client import connect as websocket_connect
|
||||||
|
from websockets.protocol import State
|
||||||
|
from websockets.typing import Subprotocol
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
WebSocketTransportError,
|
||||||
|
WebSocketTransportNotConnectedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _WebSocketConnectionProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Минимальный контракт соединения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> State:
|
||||||
|
"""Вернуть состояние WebSocket-соединения."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def close(
|
||||||
|
self,
|
||||||
|
code: int = 1000,
|
||||||
|
reason: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Закрыть WebSocket-соединение."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
message: str | bytes,
|
||||||
|
) -> None:
|
||||||
|
"""Отправить одно сообщение."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def recv(self) -> str | bytes:
|
||||||
|
"""Получить одно сообщение."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def ping(self) -> Awaitable[float]:
|
||||||
|
"""Отправить Ping и вернуть ожидание соответствующего Pong."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
WebSocketConnector = Callable[
|
||||||
|
...,
|
||||||
|
Awaitable[_WebSocketConnectionProtocol],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_dzengi_websocket_url(
|
||||||
|
raw_url: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Нормализовать Dzengi HTTP/WebSocket URL до endpoint ``/connect``.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw_url, str):
|
||||||
|
raise TypeError("raw_url must be a string")
|
||||||
|
|
||||||
|
normalized_url = raw_url.strip()
|
||||||
|
|
||||||
|
if not normalized_url:
|
||||||
|
raise ValueError("raw_url must not be empty")
|
||||||
|
|
||||||
|
parsed = urlsplit(normalized_url)
|
||||||
|
|
||||||
|
scheme = {
|
||||||
|
"http": "ws",
|
||||||
|
"https": "wss",
|
||||||
|
"ws": "ws",
|
||||||
|
"wss": "wss",
|
||||||
|
}.get(parsed.scheme.lower())
|
||||||
|
|
||||||
|
if scheme is None or not parsed.netloc:
|
||||||
|
raise ValueError(
|
||||||
|
"raw_url must be an absolute HTTP or WebSocket URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed.fragment:
|
||||||
|
raise ValueError("raw_url must not contain a fragment")
|
||||||
|
|
||||||
|
path = parsed.path.rstrip("/")
|
||||||
|
|
||||||
|
if not path.endswith("/connect"):
|
||||||
|
path = f"{path}/connect"
|
||||||
|
|
||||||
|
return urlunsplit(
|
||||||
|
(
|
||||||
|
scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
path,
|
||||||
|
parsed.query,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DzengiWebSocketTransport:
|
||||||
|
"""
|
||||||
|
Низкоуровневый WebSocket-транспорт Dzengi.
|
||||||
|
|
||||||
|
Transport управляет только соединением и непрозрачными
|
||||||
|
``str``/``bytes`` сообщениями. JSON parsing, subscription routing,
|
||||||
|
recovery и lifecycle находятся за пределами этого компонента.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_url",
|
||||||
|
"_headers",
|
||||||
|
"_open_timeout",
|
||||||
|
"_ping_interval",
|
||||||
|
"_ping_timeout",
|
||||||
|
"_probe_timeout",
|
||||||
|
"_close_timeout",
|
||||||
|
"_connector",
|
||||||
|
"_connection",
|
||||||
|
"_lifecycle_lock",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
open_timeout: float | None = 10.0,
|
||||||
|
ping_interval: float | None = 20.0,
|
||||||
|
ping_timeout: float | None = 20.0,
|
||||||
|
probe_timeout: float | None = None,
|
||||||
|
close_timeout: float | None = 10.0,
|
||||||
|
connector: WebSocketConnector | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._url = build_dzengi_websocket_url(url)
|
||||||
|
self._headers = self._validate_headers(headers)
|
||||||
|
self._open_timeout = self._validate_timeout(
|
||||||
|
"open_timeout",
|
||||||
|
open_timeout,
|
||||||
|
)
|
||||||
|
self._ping_interval = self._validate_timeout(
|
||||||
|
"ping_interval",
|
||||||
|
ping_interval,
|
||||||
|
)
|
||||||
|
self._ping_timeout = self._validate_timeout(
|
||||||
|
"ping_timeout",
|
||||||
|
ping_timeout,
|
||||||
|
)
|
||||||
|
self._probe_timeout = self._validate_probe_timeout(
|
||||||
|
(
|
||||||
|
probe_timeout
|
||||||
|
if probe_timeout is not None
|
||||||
|
else (
|
||||||
|
ping_timeout
|
||||||
|
if ping_timeout is not None
|
||||||
|
else 20.0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._close_timeout = self._validate_timeout(
|
||||||
|
"close_timeout",
|
||||||
|
close_timeout,
|
||||||
|
)
|
||||||
|
self._connector = (
|
||||||
|
connector
|
||||||
|
if connector is not None
|
||||||
|
else cast(
|
||||||
|
WebSocketConnector,
|
||||||
|
websocket_connect,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._connection: _WebSocketConnectionProtocol | None = None
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
"""Вернуть нормализованный WebSocket URL."""
|
||||||
|
return self._url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Показывает, открыто ли соединение."""
|
||||||
|
connection = self._connection
|
||||||
|
|
||||||
|
return (
|
||||||
|
connection is not None
|
||||||
|
and connection.state is State.OPEN
|
||||||
|
)
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""
|
||||||
|
Идемпотентно открыть WebSocket-соединение Dzengi.
|
||||||
|
"""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
if self.is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = await self._connector(
|
||||||
|
self._url,
|
||||||
|
additional_headers=(
|
||||||
|
self._headers
|
||||||
|
if self._headers
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
subprotocols=(
|
||||||
|
Subprotocol("json"),
|
||||||
|
),
|
||||||
|
open_timeout=self._open_timeout,
|
||||||
|
ping_interval=self._ping_interval,
|
||||||
|
ping_timeout=self._ping_timeout,
|
||||||
|
close_timeout=self._close_timeout,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
raise WebSocketTransportError(
|
||||||
|
"Не удалось открыть Dzengi WebSocket connection: "
|
||||||
|
f"{error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
self._connection = connection
|
||||||
|
|
||||||
|
if self.is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
await connection.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise WebSocketTransportError(
|
||||||
|
"Dzengi WebSocket connection не перешло "
|
||||||
|
"в состояние OPEN."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""
|
||||||
|
Идемпотентно закрыть WebSocket-соединение.
|
||||||
|
"""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
connection = self._connection
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
|
if connection is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await connection.close()
|
||||||
|
except Exception as error:
|
||||||
|
raise WebSocketTransportError(
|
||||||
|
"Не удалось закрыть Dzengi WebSocket connection: "
|
||||||
|
f"{error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
message: str | bytes,
|
||||||
|
) -> None:
|
||||||
|
"""Отправить непрозрачное транспортное сообщение."""
|
||||||
|
if not isinstance(message, (str, bytes)):
|
||||||
|
raise TypeError("message must be str or bytes")
|
||||||
|
|
||||||
|
connection = self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await connection.send(message)
|
||||||
|
except Exception as error:
|
||||||
|
self._discard_closed_connection(connection)
|
||||||
|
|
||||||
|
raise WebSocketTransportError(
|
||||||
|
"Не удалось отправить Dzengi WebSocket message: "
|
||||||
|
f"{error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
async def receive(self) -> str | bytes:
|
||||||
|
"""Получить непрозрачное транспортное сообщение."""
|
||||||
|
connection = self._require_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await connection.recv()
|
||||||
|
except Exception as error:
|
||||||
|
self._discard_closed_connection(connection)
|
||||||
|
|
||||||
|
raise WebSocketTransportError(
|
||||||
|
"Не удалось получить Dzengi WebSocket message: "
|
||||||
|
f"{error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
"""
|
||||||
|
Подтвердить liveness текущего connection через Ping/Pong.
|
||||||
|
|
||||||
|
Закрытое соединение и отсутствие Pong являются штатным
|
||||||
|
отрицательным результатом. Cancellation не перехватывается.
|
||||||
|
"""
|
||||||
|
connection = self._connection
|
||||||
|
|
||||||
|
if (
|
||||||
|
connection is None
|
||||||
|
or connection.state is not State.OPEN
|
||||||
|
):
|
||||||
|
self._connection = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pong_waiter = await connection.ping()
|
||||||
|
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.shield(
|
||||||
|
pong_waiter,
|
||||||
|
),
|
||||||
|
timeout=self._probe_timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
self._discard_closed_connection(connection)
|
||||||
|
|
||||||
|
if connection.state is not State.OPEN:
|
||||||
|
return False
|
||||||
|
|
||||||
|
raise WebSocketTransportError(
|
||||||
|
"Не удалось проверить Dzengi WebSocket liveness: "
|
||||||
|
f"{error}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._connection is connection
|
||||||
|
and connection.state is State.OPEN
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_connection(
|
||||||
|
self,
|
||||||
|
) -> _WebSocketConnectionProtocol:
|
||||||
|
"""
|
||||||
|
Вернуть открытое соединение.
|
||||||
|
"""
|
||||||
|
connection = self._connection
|
||||||
|
|
||||||
|
if (
|
||||||
|
connection is None
|
||||||
|
or connection.state is not State.OPEN
|
||||||
|
):
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
|
raise WebSocketTransportNotConnectedError(
|
||||||
|
"Dzengi WebSocket transport is not connected."
|
||||||
|
)
|
||||||
|
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _discard_closed_connection(
|
||||||
|
self,
|
||||||
|
connection: _WebSocketConnectionProtocol,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Удалить ссылку на закрытое соединение.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
self._connection is connection
|
||||||
|
and connection.state is not State.OPEN
|
||||||
|
):
|
||||||
|
self._connection = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_headers(
|
||||||
|
headers: Mapping[str, str] | None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if headers is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not isinstance(headers, Mapping):
|
||||||
|
raise TypeError("headers must be a mapping")
|
||||||
|
|
||||||
|
normalized_headers: dict[str, str] = {}
|
||||||
|
|
||||||
|
for name, value in headers.items():
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"header names must be non-empty strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError("header values must be strings")
|
||||||
|
|
||||||
|
normalized_headers[name] = value
|
||||||
|
|
||||||
|
return normalized_headers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_timeout(
|
||||||
|
name: str,
|
||||||
|
value: float | None,
|
||||||
|
) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if (
|
||||||
|
isinstance(value, bool)
|
||||||
|
or not isinstance(value, (int, float))
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
f"{name} must be an integer, float, or None"
|
||||||
|
)
|
||||||
|
|
||||||
|
if value <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_probe_timeout(
|
||||||
|
value: float,
|
||||||
|
) -> float:
|
||||||
|
if (
|
||||||
|
isinstance(value, bool)
|
||||||
|
or not isinstance(value, (int, float))
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"probe_timeout must be an integer or float"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_value = float(value)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not math.isfinite(normalized_value)
|
||||||
|
or normalized_value <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"probe_timeout must be positive and finite"
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_value
|
||||||
11
app/src/market_data/acquisition/checkpoint/__init__.py
Normal file
11
app/src/market_data/acquisition/checkpoint/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Восстановление persistent Trade Stream checkpoint."""
|
||||||
|
|
||||||
|
from src.market_data.acquisition.checkpoint.trade_stream_state_hydrator import (
|
||||||
|
TradeStreamStateHydrator,
|
||||||
|
TradeStreamStateHydratorProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"TradeStreamStateHydrator",
|
||||||
|
"TradeStreamStateHydratorProtocol",
|
||||||
|
)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
||||||
|
TradeConsistencyError,
|
||||||
|
TradeOrderingError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||||
|
DEFAULT_DEDUPLICATION_WINDOW_SIZE,
|
||||||
|
TradeStreamState,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
||||||
|
TradeStreamStateStoreInitializationError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
||||||
|
TradeStreamStateStoreProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.symbols import normalize_symbol
|
||||||
|
from src.market_data.storage.contracts import (
|
||||||
|
PersistentTradeCheckpoint,
|
||||||
|
TradeCheckpointStorageProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.storage.exceptions import (
|
||||||
|
MarketDataCheckpointIntegrityError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TradeStreamStateHydratorProtocol(Protocol):
|
||||||
|
"""Контракт восстановления Consistency state до запуска Runtime."""
|
||||||
|
|
||||||
|
def hydrate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
) -> tuple[TradeStreamState, ...]:
|
||||||
|
"""Восстановить и атомарно опубликовать состояния symbols."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStreamStateHydrator:
|
||||||
|
"""Восстанавливает TradeStreamState из persistent checkpoint."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_checkpoint_storage",
|
||||||
|
"_state_store",
|
||||||
|
"_venue",
|
||||||
|
"_deduplication_window_size",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
checkpoint_storage: TradeCheckpointStorageProtocol,
|
||||||
|
state_store: TradeStreamStateStoreProtocol,
|
||||||
|
venue: str,
|
||||||
|
deduplication_window_size: int = (
|
||||||
|
DEFAULT_DEDUPLICATION_WINDOW_SIZE
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(
|
||||||
|
checkpoint_storage,
|
||||||
|
TradeCheckpointStorageProtocol,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"checkpoint_storage must implement "
|
||||||
|
"TradeCheckpointStorageProtocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(state_store, TradeStreamStateStoreProtocol):
|
||||||
|
raise TypeError(
|
||||||
|
"state_store must implement TradeStreamStateStoreProtocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(venue, str):
|
||||||
|
raise TypeError("venue must be a string")
|
||||||
|
|
||||||
|
normalized_venue = venue.strip().lower()
|
||||||
|
|
||||||
|
if not normalized_venue:
|
||||||
|
raise ValueError("venue must not be empty")
|
||||||
|
|
||||||
|
if (
|
||||||
|
isinstance(deduplication_window_size, bool)
|
||||||
|
or not isinstance(deduplication_window_size, int)
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"deduplication_window_size must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if deduplication_window_size <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"deduplication_window_size must be positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._checkpoint_storage = checkpoint_storage
|
||||||
|
self._state_store = state_store
|
||||||
|
self._venue = normalized_venue
|
||||||
|
self._deduplication_window_size = deduplication_window_size
|
||||||
|
|
||||||
|
def hydrate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
) -> tuple[TradeStreamState, ...]:
|
||||||
|
normalized_symbols = self._normalize_symbols(symbols)
|
||||||
|
|
||||||
|
if not self._state_store.is_empty():
|
||||||
|
raise TradeStreamStateStoreInitializationError(
|
||||||
|
"Trade Stream State Store уже содержит состояния."
|
||||||
|
)
|
||||||
|
|
||||||
|
states = tuple(
|
||||||
|
self._prepare_state(symbol)
|
||||||
|
for symbol in normalized_symbols
|
||||||
|
)
|
||||||
|
self._state_store.initialize(states)
|
||||||
|
return states
|
||||||
|
|
||||||
|
def _prepare_state(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
checkpoint = self._checkpoint_storage.load_checkpoint(
|
||||||
|
venue=self._venue,
|
||||||
|
symbol=symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
if checkpoint is not None:
|
||||||
|
return self._state_from_checkpoint(
|
||||||
|
symbol=symbol,
|
||||||
|
checkpoint=checkpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
latest_tail = self._checkpoint_storage.load_latest_trade_tail(
|
||||||
|
venue=self._venue,
|
||||||
|
symbol=symbol,
|
||||||
|
limit=self._deduplication_window_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(latest_tail, tuple):
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Checkpoint storage returned non-tuple latest Trade tail."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not latest_tail:
|
||||||
|
concurrent_checkpoint = (
|
||||||
|
self._checkpoint_storage.load_checkpoint(
|
||||||
|
venue=self._venue,
|
||||||
|
symbol=symbol,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if concurrent_checkpoint is not None:
|
||||||
|
return self._state_from_checkpoint(
|
||||||
|
symbol=symbol,
|
||||||
|
checkpoint=concurrent_checkpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TradeStreamState.from_history(
|
||||||
|
symbol=symbol,
|
||||||
|
trades=(),
|
||||||
|
deduplication_window_size=(
|
||||||
|
self._deduplication_window_size
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_state = self._state_from_tail(
|
||||||
|
symbol=symbol,
|
||||||
|
trades=latest_tail,
|
||||||
|
)
|
||||||
|
candidate = candidate_state.last_trade
|
||||||
|
|
||||||
|
if candidate is None:
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Latest Trade tail unexpectedly produced empty state."
|
||||||
|
)
|
||||||
|
|
||||||
|
adopted = (
|
||||||
|
self._checkpoint_storage.adopt_existing_trade_as_checkpoint(
|
||||||
|
venue=self._venue,
|
||||||
|
trade=candidate,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._validate_checkpoint(
|
||||||
|
checkpoint=adopted,
|
||||||
|
symbol=symbol,
|
||||||
|
expected_trade=candidate,
|
||||||
|
)
|
||||||
|
return self._state_from_checkpoint(
|
||||||
|
symbol=symbol,
|
||||||
|
checkpoint=adopted,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _state_from_checkpoint(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
checkpoint: PersistentTradeCheckpoint,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
self._validate_checkpoint(
|
||||||
|
checkpoint=checkpoint,
|
||||||
|
symbol=symbol,
|
||||||
|
expected_trade=None,
|
||||||
|
)
|
||||||
|
tail = self._checkpoint_storage.load_checkpoint_tail(
|
||||||
|
venue=self._venue,
|
||||||
|
checkpoint=checkpoint,
|
||||||
|
limit=self._deduplication_window_size,
|
||||||
|
)
|
||||||
|
state = self._state_from_tail(
|
||||||
|
symbol=symbol,
|
||||||
|
trades=tail,
|
||||||
|
)
|
||||||
|
|
||||||
|
if state.last_trade is None or not self._same_market_trade(
|
||||||
|
state.last_trade,
|
||||||
|
checkpoint.trade,
|
||||||
|
):
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Hydrated tail does not end at persistent checkpoint."
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _state_from_tail(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
trades: object,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
if not isinstance(trades, tuple):
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Checkpoint storage returned non-tuple Trade tail."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return TradeStreamState.from_history(
|
||||||
|
symbol=symbol,
|
||||||
|
trades=trades,
|
||||||
|
deduplication_window_size=(
|
||||||
|
self._deduplication_window_size
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
ValueError,
|
||||||
|
TradeConsistencyError,
|
||||||
|
TradeOrderingError,
|
||||||
|
) as error:
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Persistent Trade tail cannot hydrate Consistency state."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def _validate_checkpoint(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
checkpoint: object,
|
||||||
|
symbol: str,
|
||||||
|
expected_trade: Trade | None,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(checkpoint, PersistentTradeCheckpoint):
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Checkpoint storage returned invalid checkpoint."
|
||||||
|
)
|
||||||
|
|
||||||
|
if checkpoint.venue.strip().lower() != self._venue:
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Persistent checkpoint venue does not match hydrator."
|
||||||
|
)
|
||||||
|
|
||||||
|
if checkpoint.trade.symbol.strip().upper() != symbol:
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Persistent checkpoint symbol does not match request."
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_trade is not None and not self._same_market_trade(
|
||||||
|
checkpoint.trade,
|
||||||
|
expected_trade,
|
||||||
|
):
|
||||||
|
raise MarketDataCheckpointIntegrityError(
|
||||||
|
"Adopted checkpoint does not match candidate Trade."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _same_market_trade(
|
||||||
|
first: Trade,
|
||||||
|
second: Trade,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
first.symbol == second.symbol
|
||||||
|
and first.trade_id == second.trade_id
|
||||||
|
and first.executed_at == second.executed_at
|
||||||
|
and first.price == second.price
|
||||||
|
and first.quantity == second.quantity
|
||||||
|
and first.aggressor_side is second.aggressor_side
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_symbols(
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if not isinstance(symbols, tuple):
|
||||||
|
raise TypeError("symbols must be a tuple")
|
||||||
|
|
||||||
|
if not symbols:
|
||||||
|
raise ValueError("symbols must not be empty")
|
||||||
|
|
||||||
|
normalized: list[str] = []
|
||||||
|
|
||||||
|
for symbol in symbols:
|
||||||
|
if not isinstance(symbol, str):
|
||||||
|
raise TypeError("symbols must contain only strings")
|
||||||
|
|
||||||
|
value = normalize_symbol(symbol)
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise ValueError("symbols must not contain empty values")
|
||||||
|
|
||||||
|
if value in normalized:
|
||||||
|
raise ValueError("symbols must not contain duplicates")
|
||||||
|
|
||||||
|
normalized.append(value)
|
||||||
|
|
||||||
|
return tuple(normalized)
|
||||||
1
app/src/market_data/acquisition/consistency/__init__.py
Normal file
1
app/src/market_data/acquisition/consistency/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/__init__.py
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TradeObservationSinkProtocol(Protocol):
|
||||||
|
"""Надёжный приёмник одного наблюдения канонической сделки."""
|
||||||
|
|
||||||
|
def persist_accepted(
|
||||||
|
self,
|
||||||
|
trade: Trade,
|
||||||
|
*,
|
||||||
|
expected_trade: Trade | None,
|
||||||
|
) -> None:
|
||||||
|
"""Сохранить новую Trade и атомарно продвинуть checkpoint."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def persist_duplicate(
|
||||||
|
self,
|
||||||
|
trade: Trade,
|
||||||
|
) -> None:
|
||||||
|
"""Сохранить provenance дубликата без продвижения checkpoint."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/trade_stream_consistency_controller.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.consistency.trade_observation_sink_protocol import (
|
||||||
|
TradeObservationSinkProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_protocol import (
|
||||||
|
TradeStreamConsistencyProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||||
|
TradeStreamState,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
||||||
|
TradeStreamStateStoreProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStreamConsistencyController(
|
||||||
|
TradeStreamConsistencyProtocol,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Контроллер проверки согласованности Canonical Trade Stream.
|
||||||
|
|
||||||
|
Для каждого торгового инструмента используется
|
||||||
|
специализированное хранилище состояний
|
||||||
|
Trade Stream Consistency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
state_store: TradeStreamStateStoreProtocol,
|
||||||
|
*,
|
||||||
|
trade_observation_sink: TradeObservationSinkProtocol | None = None,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
trade_observation_sink is not None
|
||||||
|
and not isinstance(
|
||||||
|
trade_observation_sink,
|
||||||
|
TradeObservationSinkProtocol,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"trade_observation_sink must implement "
|
||||||
|
"TradeObservationSinkProtocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._state_store = state_store
|
||||||
|
self._trade_observation_sink = trade_observation_sink
|
||||||
|
|
||||||
|
def accept(
|
||||||
|
self,
|
||||||
|
trade: Trade,
|
||||||
|
) -> Trade | None:
|
||||||
|
state = self._get_state(trade.symbol)
|
||||||
|
|
||||||
|
sink = self._trade_observation_sink
|
||||||
|
|
||||||
|
return state.accept(
|
||||||
|
trade,
|
||||||
|
before_checkpoint=(
|
||||||
|
sink.persist_accepted
|
||||||
|
if sink is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
on_duplicate=(
|
||||||
|
sink.persist_duplicate
|
||||||
|
if sink is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_state(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
"""
|
||||||
|
Возвращает состояние проверки Trade Stream
|
||||||
|
для указанного торгового инструмента.
|
||||||
|
|
||||||
|
При первом обращении состояние автоматически
|
||||||
|
создаётся специализированным хранилищем.
|
||||||
|
"""
|
||||||
|
return self._state_store.get_or_create(symbol)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/trade_stream_exceptions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
MarketDataAcquisitionError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Нарушение монотонности Canonical Trade Stream.
|
||||||
|
class TradeOrderingError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Обнаружен конфликтующий дубликат сделки.
|
||||||
|
class TradeConsistencyError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/trade_stream_protocol.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
|
||||||
|
|
||||||
|
# Контракт проверки согласованности Canonical Trade Stream.
|
||||||
|
@runtime_checkable
|
||||||
|
class TradeStreamConsistencyProtocol(Protocol):
|
||||||
|
def accept(
|
||||||
|
self,
|
||||||
|
trade: Trade,
|
||||||
|
) -> Trade | None:
|
||||||
|
"""
|
||||||
|
Проверить согласованность поступившей сделки.
|
||||||
|
|
||||||
|
Возвращает исходную модель Trade, если сделка принята.
|
||||||
|
|
||||||
|
Возвращает None, если обнаружен полный дубликат.
|
||||||
|
|
||||||
|
Генерирует TradeOrderingError при нарушении порядка.
|
||||||
|
|
||||||
|
Генерирует TradeConsistencyError при обнаружении
|
||||||
|
конфликтующего дубликата.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/trade_stream_state.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, Self
|
||||||
|
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
||||||
|
TradeConsistencyError,
|
||||||
|
TradeOrderingError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.trade_id_sequence import (
|
||||||
|
is_trade_id_newer,
|
||||||
|
validate_signed_trade_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_DEDUPLICATION_WINDOW_SIZE = 10_000
|
||||||
|
TradeDuplicateCallback = Callable[[Trade], None]
|
||||||
|
|
||||||
|
|
||||||
|
class TradeCheckpointCallback(Protocol):
|
||||||
|
"""Callback атомарной записи перед in-memory checkpoint."""
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
trade: Trade,
|
||||||
|
*,
|
||||||
|
expected_trade: Trade | None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TradeStreamState:
|
||||||
|
"""
|
||||||
|
Состояние проверки согласованности Canonical Trade Stream
|
||||||
|
для одного торгового инструмента.
|
||||||
|
"""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
deduplication_window_size: int = DEFAULT_DEDUPLICATION_WINDOW_SIZE
|
||||||
|
|
||||||
|
last_trade_id: int | None = None
|
||||||
|
last_trade: Trade | None = None
|
||||||
|
|
||||||
|
_trade_window: deque[int] = field(init=False, repr=False)
|
||||||
|
_trades: dict[int, Trade] = field(init=False, repr=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.symbol:
|
||||||
|
raise ValueError("symbol must not be empty")
|
||||||
|
|
||||||
|
if self.deduplication_window_size <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"deduplication_window_size must be positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._trade_window = deque(
|
||||||
|
maxlen=self.deduplication_window_size,
|
||||||
|
)
|
||||||
|
self._trades = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_history(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
trades: tuple[Trade, ...],
|
||||||
|
deduplication_window_size: int = (
|
||||||
|
DEFAULT_DEDUPLICATION_WINDOW_SIZE
|
||||||
|
),
|
||||||
|
) -> Self:
|
||||||
|
"""Строго восстановить временное состояние из durable tail."""
|
||||||
|
if not isinstance(trades, tuple):
|
||||||
|
raise TypeError("trades must be a tuple")
|
||||||
|
|
||||||
|
if len(trades) > deduplication_window_size:
|
||||||
|
raise ValueError(
|
||||||
|
"trades must fit deduplication_window_size"
|
||||||
|
)
|
||||||
|
|
||||||
|
state = cls(
|
||||||
|
symbol=symbol,
|
||||||
|
deduplication_window_size=deduplication_window_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
for trade in trades:
|
||||||
|
if not isinstance(trade, Trade):
|
||||||
|
raise TypeError("trades must contain only Trade")
|
||||||
|
|
||||||
|
if state.accept(trade) is None:
|
||||||
|
raise TradeConsistencyError(
|
||||||
|
"Hydration history contains a duplicate Trade."
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def accept(
|
||||||
|
self,
|
||||||
|
trade: Trade,
|
||||||
|
*,
|
||||||
|
before_checkpoint: TradeCheckpointCallback | None = None,
|
||||||
|
on_duplicate: TradeDuplicateCallback | None = None,
|
||||||
|
) -> Trade | None:
|
||||||
|
"""
|
||||||
|
Проверить сделку на согласованность.
|
||||||
|
|
||||||
|
Возвращает принятую сделку.
|
||||||
|
|
||||||
|
Возвращает None при полном дубликате.
|
||||||
|
|
||||||
|
Генерирует исключение при нарушении порядка
|
||||||
|
либо конфликтующем дубликате.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if before_checkpoint is not None and not callable(
|
||||||
|
before_checkpoint
|
||||||
|
):
|
||||||
|
raise TypeError("before_checkpoint must be callable")
|
||||||
|
|
||||||
|
if on_duplicate is not None and not callable(on_duplicate):
|
||||||
|
raise TypeError("on_duplicate must be callable")
|
||||||
|
|
||||||
|
if trade.symbol != self.symbol:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected symbol: {trade.symbol!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
trade_id = trade.trade_id
|
||||||
|
validate_signed_trade_id(trade_id)
|
||||||
|
|
||||||
|
if self.last_trade_id is not None:
|
||||||
|
if not is_trade_id_newer(
|
||||||
|
trade_id,
|
||||||
|
self.last_trade_id,
|
||||||
|
) and trade_id != self.last_trade_id:
|
||||||
|
previous = self._trades.get(trade_id)
|
||||||
|
|
||||||
|
if previous is None:
|
||||||
|
raise TradeOrderingError()
|
||||||
|
|
||||||
|
if self._is_same_market_trade(
|
||||||
|
previous,
|
||||||
|
trade,
|
||||||
|
):
|
||||||
|
self._run_duplicate_observation(
|
||||||
|
trade,
|
||||||
|
callback=on_duplicate,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
raise TradeConsistencyError()
|
||||||
|
|
||||||
|
previous = self._trades.get(trade_id)
|
||||||
|
|
||||||
|
if previous is not None:
|
||||||
|
if self._is_same_market_trade(
|
||||||
|
previous,
|
||||||
|
trade,
|
||||||
|
):
|
||||||
|
self._run_duplicate_observation(
|
||||||
|
trade,
|
||||||
|
callback=on_duplicate,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
raise TradeConsistencyError()
|
||||||
|
|
||||||
|
self._run_before_checkpoint(
|
||||||
|
trade,
|
||||||
|
expected_trade=self.last_trade,
|
||||||
|
callback=before_checkpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._append(trade)
|
||||||
|
|
||||||
|
self.last_trade_id = trade_id
|
||||||
|
self.last_trade = trade
|
||||||
|
|
||||||
|
return trade
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_before_checkpoint(
|
||||||
|
trade: Trade,
|
||||||
|
*,
|
||||||
|
expected_trade: Trade | None,
|
||||||
|
callback: TradeCheckpointCallback | None,
|
||||||
|
) -> None:
|
||||||
|
if callback is not None:
|
||||||
|
callback(
|
||||||
|
trade,
|
||||||
|
expected_trade=expected_trade,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_duplicate_observation(
|
||||||
|
trade: Trade,
|
||||||
|
*,
|
||||||
|
callback: TradeDuplicateCallback | None,
|
||||||
|
) -> None:
|
||||||
|
if callback is not None:
|
||||||
|
callback(trade)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_same_market_trade(
|
||||||
|
first: Trade,
|
||||||
|
second: Trade,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Сравнить биржевой факт независимо от transport source.
|
||||||
|
|
||||||
|
Одна сделка закономерно приходит через WebSocket и повторяется
|
||||||
|
через REST recovery. Поле ``source`` описывает путь доставки и
|
||||||
|
поэтому не является частью identity биржевой сделки.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
first.symbol == second.symbol
|
||||||
|
and first.trade_id == second.trade_id
|
||||||
|
and first.price == second.price
|
||||||
|
and first.quantity == second.quantity
|
||||||
|
and first.executed_at == second.executed_at
|
||||||
|
and first.aggressor_side is second.aggressor_side
|
||||||
|
)
|
||||||
|
|
||||||
|
def _append(
|
||||||
|
self,
|
||||||
|
trade: Trade,
|
||||||
|
) -> None:
|
||||||
|
if len(self._trade_window) == self.deduplication_window_size:
|
||||||
|
oldest_trade_id = self._trade_window[0]
|
||||||
|
self._trades.pop(oldest_trade_id, None)
|
||||||
|
|
||||||
|
self._trade_window.append(trade.trade_id)
|
||||||
|
self._trades[trade.trade_id] = trade
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/trade_stream_state_store.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Хранилище состояний Trade Stream Consistency.
|
||||||
|
|
||||||
|
Build 060.20.1 переносит владение инфраструктурным состоянием
|
||||||
|
из общего Runtime Registry в специализированное хранилище
|
||||||
|
подсистемы проверки согласованности потока сделок.
|
||||||
|
|
||||||
|
Хранилище отвечает исключительно за жизненный цикл объектов
|
||||||
|
TradeStreamState и не содержит логики проверки последовательности
|
||||||
|
или восстановления потока сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||||
|
TradeStreamState,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
||||||
|
TradeStreamStateNotFoundError,
|
||||||
|
TradeStreamStateStoreInitializationError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
||||||
|
TradeStreamStateStoreProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStreamStateStore(TradeStreamStateStoreProtocol):
|
||||||
|
"""
|
||||||
|
Хранилище состояний Trade Stream.
|
||||||
|
|
||||||
|
Для каждого торгового инструмента существует
|
||||||
|
единственный экземпляр TradeStreamState.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""
|
||||||
|
Создаёт пустое хранилище состояний.
|
||||||
|
"""
|
||||||
|
self._states: dict[str, TradeStreamState] = {}
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
self,
|
||||||
|
states: tuple[TradeStreamState, ...],
|
||||||
|
) -> None:
|
||||||
|
"""Однократно опубликовать полностью подготовленный набор."""
|
||||||
|
if not isinstance(states, tuple):
|
||||||
|
raise TypeError("states must be a tuple")
|
||||||
|
|
||||||
|
if self._initialized or self._states:
|
||||||
|
raise TradeStreamStateStoreInitializationError(
|
||||||
|
"Trade Stream State Store уже содержит состояния."
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared: dict[str, TradeStreamState] = {}
|
||||||
|
|
||||||
|
for state in states:
|
||||||
|
if not isinstance(state, TradeStreamState):
|
||||||
|
raise TypeError(
|
||||||
|
"states must contain only TradeStreamState"
|
||||||
|
)
|
||||||
|
|
||||||
|
if state.symbol in prepared:
|
||||||
|
raise TradeStreamStateStoreInitializationError(
|
||||||
|
f"Состояние для символа {state.symbol!r} "
|
||||||
|
"передано повторно."
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared[state.symbol] = state
|
||||||
|
|
||||||
|
self._states.update(prepared)
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""Проверить, что хранилище не содержит состояний."""
|
||||||
|
return not self._states and not self._initialized
|
||||||
|
|
||||||
|
def get_or_create(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
"""
|
||||||
|
Возвращает существующее состояние либо создаёт новое.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Экземпляр TradeStreamState.
|
||||||
|
"""
|
||||||
|
state = self._states.get(symbol)
|
||||||
|
|
||||||
|
if state is None:
|
||||||
|
state = TradeStreamState(symbol=symbol)
|
||||||
|
self._states[symbol] = state
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
"""
|
||||||
|
Возвращает существующее состояние.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TradeStreamStateNotFoundError:
|
||||||
|
Если состояние отсутствует.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._states[symbol]
|
||||||
|
except KeyError as error:
|
||||||
|
raise TradeStreamStateNotFoundError(
|
||||||
|
f"Состояние Trade Stream для символа {symbol!r} не найдено."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def contains(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет наличие состояния.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True, если состояние существует.
|
||||||
|
"""
|
||||||
|
return symbol in self._states
|
||||||
|
|
||||||
|
def remove(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Удаляет состояние.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TradeStreamStateNotFoundError:
|
||||||
|
Если состояние отсутствует.
|
||||||
|
"""
|
||||||
|
if symbol not in self._states:
|
||||||
|
raise TradeStreamStateNotFoundError(
|
||||||
|
f"Состояние Trade Stream для символа "
|
||||||
|
f"{symbol!r} не найдено."
|
||||||
|
)
|
||||||
|
|
||||||
|
del self._states[symbol]
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""
|
||||||
|
Полностью очищает хранилище состояний.
|
||||||
|
"""
|
||||||
|
self._states.clear()
|
||||||
|
self._initialized = False
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/trade_stream_state_store_exceptions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Исключения хранилища состояний Trade Stream Consistency.
|
||||||
|
|
||||||
|
Build 060.20.1 вводит специализированное хранилище
|
||||||
|
состояния проверки согласованности потока сделок.
|
||||||
|
|
||||||
|
Исключения относятся исключительно к управлению
|
||||||
|
TradeStreamState и не описывают ошибки проверки
|
||||||
|
последовательности сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStreamStateStoreError(Exception):
|
||||||
|
"""
|
||||||
|
Базовое исключение хранилища состояний Trade Stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStreamStateNotFoundError(
|
||||||
|
TradeStreamStateStoreError,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Состояние торгового инструмента отсутствует.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStreamStateStoreInitializationError(
|
||||||
|
TradeStreamStateStoreError,
|
||||||
|
):
|
||||||
|
"""Хранилище состояний нельзя безопасно инициализировать."""
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# app/src/market_data/acquisition/consistency/trade_stream_state_store_protocol.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Протокол хранилища состояния Trade Stream Consistency.
|
||||||
|
|
||||||
|
Build 060.20.1 заменяет универсальный Trade Runtime Registry
|
||||||
|
специализированным типизированным хранилищем состояния
|
||||||
|
Canonical Trade Stream.
|
||||||
|
|
||||||
|
TradeStreamStateStoreProtocol определяет минимальный контракт
|
||||||
|
доступа к состоянию проверки согласованности потока сделок.
|
||||||
|
|
||||||
|
Протокол не описывает внутреннюю реализацию хранения
|
||||||
|
и не содержит бизнес-логики Trade Stream Consistency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||||
|
TradeStreamState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TradeStreamStateStoreProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Протокол хранилища состояний Trade Stream Consistency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
self,
|
||||||
|
states: tuple[TradeStreamState, ...],
|
||||||
|
) -> None:
|
||||||
|
"""Однократно и атомарно опубликовать готовые состояния."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""Проверить, что ни одно состояние ещё не опубликовано."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_or_create(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
"""
|
||||||
|
Возвращает существующее состояние торгового инструмента
|
||||||
|
либо создаёт новое состояние при первом обращении.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Существующий либо созданный TradeStreamState.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> TradeStreamState:
|
||||||
|
"""
|
||||||
|
Возвращает существующее состояние торгового инструмента.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Зарегистрированный TradeStreamState.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TradeStreamStateNotFoundError:
|
||||||
|
Если состояние торгового инструмента отсутствует.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def contains(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет наличие состояния торгового инструмента.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True, если состояние существует,
|
||||||
|
иначе False.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def remove(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Удаляет состояние торгового инструмента.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Идентификатор торгового инструмента.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TradeStreamStateNotFoundError:
|
||||||
|
Если состояние торгового инструмента отсутствует.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""
|
||||||
|
Полностью очищает хранилище состояний.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -37,6 +37,7 @@ class InstrumentReferenceMappingError(MarketDataAcquisitionError):
|
|||||||
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
|
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Ошибка получения Quotes Feed от внешнего источника.
|
# Ошибка получения Quotes Feed от внешнего источника.
|
||||||
class QuoteTransportError(MarketDataAcquisitionError):
|
class QuoteTransportError(MarketDataAcquisitionError):
|
||||||
pass
|
pass
|
||||||
@@ -65,3 +66,132 @@ class QuoteMappingError(MarketDataAcquisitionError):
|
|||||||
# Ошибка регистрации или получения Quotes Feed.
|
# Ошибка регистрации или получения Quotes Feed.
|
||||||
class QuoteFeedRegistryError(MarketDataAcquisitionError):
|
class QuoteFeedRegistryError(MarketDataAcquisitionError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка получения Candles Feed от внешнего источника.
|
||||||
|
class CandleTransportError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка структуры документа Candles Feed.
|
||||||
|
class CandleSchemaError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка структуры события Candles Feed из WebSocket.
|
||||||
|
class CandleWebSocketSchemaError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования проверенного WebSocket OHLC в raw-модель адаптера.
|
||||||
|
class CandleWebSocketParseError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка допустимости значений WebSocket OHLC-события.
|
||||||
|
class CandleWebSocketValueError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования WebSocket OHLC-события
|
||||||
|
# во внутреннюю модель уведомления о закрытии свечи.
|
||||||
|
class CandleWebSocketMappingError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования проверенного документа в raw-модели свечей.
|
||||||
|
class CandleParseError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка допустимости значений Candles Feed.
|
||||||
|
class CandleValueError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования raw-модели источника во внутреннюю модель Candle.
|
||||||
|
class CandleMappingError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка регистрации или получения Candles Feed.
|
||||||
|
class CandleFeedRegistryError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка получения Trades Feed от внешнего источника.
|
||||||
|
class TradeTransportError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка структуры документа Trades Feed.
|
||||||
|
class TradeSchemaError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования проверенного документа в raw-модели сделок.
|
||||||
|
class TradeParseError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка проверки допустимости значений raw-моделей сделок.
|
||||||
|
class TradeValueError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка преобразования raw-модели источника
|
||||||
|
# во внутреннюю модель Trade.
|
||||||
|
class TradeMappingError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка регистрации или получения Trades Feed.
|
||||||
|
class TradeFeedRegistryError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка определения типа входящего WebSocket-сообщения
|
||||||
|
# и выбора специализированного адаптера.
|
||||||
|
class WebSocketMessageRoutingError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка декодирования сырого WebSocket-сообщения.
|
||||||
|
class WebSocketMessageDecodeError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка обработки control/ACK сообщения WebSocket-провайдера.
|
||||||
|
class WebSocketControlMessageError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Истекло время ожидания подтверждения стартовой WebSocket-подписки.
|
||||||
|
class WebSocketSubscriptionAckTimeoutError(
|
||||||
|
MarketDataAcquisitionError,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Переполнен буфер market-сообщений до подтверждения подписки.
|
||||||
|
class WebSocketStartupMarketBufferOverflowError(
|
||||||
|
MarketDataAcquisitionError,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Ошибка низкоуровневой WebSocket-инфраструктуры Acquisition Runtime.
|
||||||
|
class WebSocketTransportError(MarketDataAcquisitionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Попытка использовать WebSocket-транспорт без открытого соединения.
|
||||||
|
class WebSocketTransportNotConnectedError(WebSocketTransportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# WebSocket-провайдер не поддерживает транспортную операцию unsubscribe.
|
||||||
|
class WebSocketUnsubscribeNotSupportedError(
|
||||||
|
MarketDataAcquisitionError,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# app/src/market_data/acquisition/feeds/candles_feed.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
|
from src.market_data.acquisition.protocol import (
|
||||||
|
CandlesDocumentHandler,
|
||||||
|
CandlesDocumentSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CandlesFeed:
|
||||||
|
"""
|
||||||
|
Read-only Feed канонических рыночных свечей.
|
||||||
|
|
||||||
|
Feed координирует получение и обработку документа, но не выполняет
|
||||||
|
transport parsing, value validation, mapping или кэширование.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: CandlesDocumentSource,
|
||||||
|
handler: CandlesDocumentHandler,
|
||||||
|
) -> None:
|
||||||
|
self._source = source
|
||||||
|
self._handler = handler
|
||||||
|
|
||||||
|
def load_candles(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
interval: str,
|
||||||
|
limit: int,
|
||||||
|
price_type: str,
|
||||||
|
) -> tuple[Candle, ...]:
|
||||||
|
"""
|
||||||
|
Получить канонический immutable-набор свечей инструмента.
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = self._source.fetch_candles_document(
|
||||||
|
symbol,
|
||||||
|
interval=interval,
|
||||||
|
limit=limit,
|
||||||
|
price_type=price_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._handler.handle_candles_document(
|
||||||
|
document,
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
source=f"rest_klines:{price_type}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# app/src/market_data/acquisition/feeds/trades_feed.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.protocol import (
|
||||||
|
TradeDocumentHandler,
|
||||||
|
TradeDocumentSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Источник готовой внутренней модели Trade.
|
||||||
|
class TradesFeed:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: TradeDocumentSource,
|
||||||
|
handler: TradeDocumentHandler,
|
||||||
|
) -> None:
|
||||||
|
self._source = source
|
||||||
|
self._handler = handler
|
||||||
|
|
||||||
|
def load_trade(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> Trade:
|
||||||
|
"""
|
||||||
|
Получить последнюю сделку инструмента.
|
||||||
|
|
||||||
|
Feed получает сырой документ у источника и делегирует
|
||||||
|
его преобразование специализированному обработчику.
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = self._source.fetch_trade_document(symbol)
|
||||||
|
|
||||||
|
return self._handler.handle_trade_document(document)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# app/src/market_data/acquisition/handlers/candles_handler.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_klines_to_candles,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
|
parse_candles,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
validate_candles_schema,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_candles_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик документа Candles Feed формата Dzengi /api/v1/klines.
|
||||||
|
class DzengiCandlesDocumentHandler:
|
||||||
|
def handle_candles_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
interval: str,
|
||||||
|
source: str,
|
||||||
|
) -> tuple[Candle, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать сырой документ Dzengi в канонические модели Candle.
|
||||||
|
|
||||||
|
Порядок обработки:
|
||||||
|
|
||||||
|
schema validation
|
||||||
|
→
|
||||||
|
parsing
|
||||||
|
→
|
||||||
|
value validation
|
||||||
|
→
|
||||||
|
mapping
|
||||||
|
"""
|
||||||
|
|
||||||
|
validated_document = validate_candles_schema(document)
|
||||||
|
response = parse_candles(validated_document)
|
||||||
|
|
||||||
|
validate_candles_values(response)
|
||||||
|
|
||||||
|
return map_dzengi_klines_to_candles(
|
||||||
|
response,
|
||||||
|
symbol=symbol,
|
||||||
|
interval=interval,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# app/src/market_data/acquisition/handlers/trades_handler.py
|
||||||
|
|
||||||
|
# app/src/market_data/acquisition/handlers/trades_handler.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi import (
|
||||||
|
adapt_websocket_trade_document,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
validate_dzengi_websocket_trade_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик документа Trades Feed формата Dzengi WebSocket internal.trade.
|
||||||
|
class DzengiTradeDocumentHandler:
|
||||||
|
def handle_trade_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> Trade:
|
||||||
|
validated_document = validate_dzengi_websocket_trade_schema(document)
|
||||||
|
|
||||||
|
return adapt_websocket_trade_document(validated_document)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# app/src/market_data/acquisition/models/candle.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
# Каноническая неизменяемая модель одной рыночной свечи OHLCV.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Candle:
|
||||||
|
symbol: str
|
||||||
|
interval: str
|
||||||
|
|
||||||
|
open_time: datetime
|
||||||
|
|
||||||
|
open_price: Decimal
|
||||||
|
high_price: Decimal
|
||||||
|
low_price: Decimal
|
||||||
|
close_price: Decimal
|
||||||
|
volume: Decimal
|
||||||
|
|
||||||
|
source: str
|
||||||
29
app/src/market_data/acquisition/models/candle_close.py
Normal file
29
app/src/market_data/acquisition/models/candle_close.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
|
||||||
|
|
||||||
# Источник сырого документа Instrument Reference Data.
|
# Источник сырого документа Instrument Reference Data.
|
||||||
@@ -84,3 +86,100 @@ class QuoteFeedProtocol(Protocol):
|
|||||||
Получить внутреннюю модель текущей котировки инструмента.
|
Получить внутреннюю модель текущей котировки инструмента.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Источник сырого документа Trades Feed.
|
||||||
|
@runtime_checkable
|
||||||
|
class TradeDocumentSource(Protocol):
|
||||||
|
def fetch_trade_document(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный транспортный документ сделок.
|
||||||
|
|
||||||
|
Источник не выполняет schema validation, parsing,
|
||||||
|
value validation или mapping во внутреннюю модель Trade.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик сырого документа Trades Feed.
|
||||||
|
@runtime_checkable
|
||||||
|
class TradeDocumentHandler(Protocol):
|
||||||
|
def handle_trade_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> Trade:
|
||||||
|
"""
|
||||||
|
Преобразовать сырой документ
|
||||||
|
в проверенную внутреннюю модель Trade.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Источник готовой модели Trade.
|
||||||
|
@runtime_checkable
|
||||||
|
class TradesFeedProtocol(Protocol):
|
||||||
|
def load_trade(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
) -> Trade:
|
||||||
|
"""
|
||||||
|
Получить внутреннюю модель последней сделки инструмента.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Источник сырого документа Candles Feed.
|
||||||
|
@runtime_checkable
|
||||||
|
class CandlesDocumentSource(Protocol):
|
||||||
|
def fetch_candles_document(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
interval: str,
|
||||||
|
limit: int,
|
||||||
|
price_type: str,
|
||||||
|
) -> object:
|
||||||
|
"""
|
||||||
|
Получить декодированный транспортный документ рыночных свечей.
|
||||||
|
|
||||||
|
Источник не выполняет schema validation, parsing, value validation
|
||||||
|
или mapping во внутреннюю модель Candle.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Обработчик сырого документа Candles Feed.
|
||||||
|
@runtime_checkable
|
||||||
|
class CandlesDocumentHandler(Protocol):
|
||||||
|
def handle_candles_document(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
interval: str,
|
||||||
|
source: str,
|
||||||
|
) -> tuple[Candle, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать сырой документ в проверенный immutable-набор Candle.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# Источник готового набора свечей.
|
||||||
|
@runtime_checkable
|
||||||
|
class CandlesFeedProtocol(Protocol):
|
||||||
|
def load_candles(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
interval: str,
|
||||||
|
limit: int,
|
||||||
|
price_type: str,
|
||||||
|
) -> tuple[Candle, ...]:
|
||||||
|
"""
|
||||||
|
Получить immutable-набор внутренних моделей Candle.
|
||||||
|
"""
|
||||||
|
...
|
||||||
1
app/src/market_data/acquisition/recovery/__init__.py
Normal file
1
app/src/market_data/acquisition/recovery/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/__init__.py
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_controller.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.rest import (
|
||||||
|
DzengiTradesDocumentSource,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.rest_trade_adapter import (
|
||||||
|
adapt_rest_agg_trades_document,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_protocol import (
|
||||||
|
TradeStreamConsistencyProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_normalizer import (
|
||||||
|
normalize_recovered_trades,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_protocol import (
|
||||||
|
TradeRecoveryProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_request import (
|
||||||
|
TradeRecoveryRequest,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||||
|
TradeRecoveryResult,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
validate_rest_agg_trades_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryController(TradeRecoveryProtocol):
|
||||||
|
"""
|
||||||
|
Контроллер восстановления пропущенных сделок через Dzengi REST API.
|
||||||
|
|
||||||
|
Контроллер выполняет одну stateless-операцию восстановления:
|
||||||
|
|
||||||
|
1. получает сырой документ aggTrades;
|
||||||
|
2. выполняет schema validation;
|
||||||
|
3. преобразует документ в канонические Trade;
|
||||||
|
4. нормализует порядок сделок;
|
||||||
|
5. пропускает сделки через общий consistency-контроллер;
|
||||||
|
6. возвращает только принятые сделки.
|
||||||
|
|
||||||
|
Контроллер не владеет состоянием согласованности потока. Для Recovery
|
||||||
|
должен передаваться тот же экземпляр TradeStreamConsistencyProtocol,
|
||||||
|
который используется основным потоком сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_source: DzengiTradesDocumentSource,
|
||||||
|
consistency_controller: TradeStreamConsistencyProtocol,
|
||||||
|
) -> None:
|
||||||
|
self._document_source = document_source
|
||||||
|
self._consistency_controller = consistency_controller
|
||||||
|
|
||||||
|
def recover(
|
||||||
|
self,
|
||||||
|
request: TradeRecoveryRequest,
|
||||||
|
) -> TradeRecoveryResult:
|
||||||
|
"""
|
||||||
|
Выполнить одну операцию восстановления сделок.
|
||||||
|
|
||||||
|
Идентичные дубликаты, возвращённые consistency-контроллером
|
||||||
|
как None, не включаются в результат.
|
||||||
|
|
||||||
|
Исключения transport, schema, parsing, value validation, mapping
|
||||||
|
и consistency не перехватываются и не оборачиваются.
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = self._document_source.fetch_trades_document(
|
||||||
|
request.symbol,
|
||||||
|
start_time=request.start_time,
|
||||||
|
end_time=request.end_time,
|
||||||
|
limit=request.limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
validated_document = validate_rest_agg_trades_schema(
|
||||||
|
document,
|
||||||
|
)
|
||||||
|
|
||||||
|
trades = adapt_rest_agg_trades_document(
|
||||||
|
validated_document,
|
||||||
|
symbol=request.symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_trades = normalize_recovered_trades(
|
||||||
|
trades,
|
||||||
|
)
|
||||||
|
|
||||||
|
recovered_trades = self._accept_trades(
|
||||||
|
normalized_trades,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TradeRecoveryResult(
|
||||||
|
symbol=request.symbol,
|
||||||
|
requested_start_time=request.start_time,
|
||||||
|
requested_end_time=request.end_time,
|
||||||
|
recovered_trades=recovered_trades,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _accept_trades(
|
||||||
|
self,
|
||||||
|
trades: tuple[Trade, ...],
|
||||||
|
) -> tuple[Trade, ...]:
|
||||||
|
accepted_trades: list[Trade] = []
|
||||||
|
|
||||||
|
for trade in trades:
|
||||||
|
accepted_trade = self._consistency_controller.accept(
|
||||||
|
trade,
|
||||||
|
)
|
||||||
|
|
||||||
|
if accepted_trade is not None:
|
||||||
|
accepted_trades.append(accepted_trade)
|
||||||
|
|
||||||
|
return tuple(accepted_trades)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_exceptions.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
MarketDataAcquisitionError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryError(MarketDataAcquisitionError):
|
||||||
|
"""
|
||||||
|
Базовое исключение подсистемы восстановления сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryWindowError(TradeRecoveryError):
|
||||||
|
"""
|
||||||
|
Некорректный диапазон восстановления сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryLimitError(TradeRecoveryError):
|
||||||
|
"""
|
||||||
|
Некорректное значение параметра limit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryNormalizationError(TradeRecoveryError):
|
||||||
|
"""
|
||||||
|
Ошибка нормализации восстановленных сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryControllerError(TradeRecoveryError):
|
||||||
|
"""
|
||||||
|
Ошибка контроллера восстановления сделок.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_normalizer.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.trade_id_sequence import (
|
||||||
|
trade_id_relative_offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_recovered_trades(
|
||||||
|
trades: Iterable[Trade],
|
||||||
|
) -> tuple[Trade, ...]:
|
||||||
|
"""
|
||||||
|
Нормализовать порядок восстановленных сделок.
|
||||||
|
|
||||||
|
Сделки возвращаются в хронологическом порядке signed 32-bit
|
||||||
|
``trade_id`` с учётом rollover.
|
||||||
|
Исходная последовательность не изменяется.
|
||||||
|
|
||||||
|
Нормализатор намеренно не выполняет дедупликацию и не проверяет
|
||||||
|
согласованность сделок. Идентичные и конфликтующие дубликаты должны
|
||||||
|
обрабатываться экземпляром ``TradeStreamConsistencyController``.
|
||||||
|
|
||||||
|
Входной набор должен покрывать меньше половины 32-битного цикла.
|
||||||
|
Это ограничение делает modular-порядок однозначным и существенно
|
||||||
|
шире максимального часового Recovery window Runtime.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
trades
|
||||||
|
Последовательность канонических сделок.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple[Trade, ...]
|
||||||
|
Неизменяемая последовательность сделок, отсортированная
|
||||||
|
по rollover-aware порядку ``trade_id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source = tuple(trades)
|
||||||
|
|
||||||
|
if not source:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
reference_trade_id = source[0].trade_id
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
source,
|
||||||
|
key=lambda trade: trade_id_relative_offset(
|
||||||
|
trade.trade_id,
|
||||||
|
reference_trade_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_protocol.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_request import (
|
||||||
|
TradeRecoveryRequest,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||||
|
TradeRecoveryResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт подсистемы восстановления сделок.
|
||||||
|
|
||||||
|
Реализация должна:
|
||||||
|
|
||||||
|
- получать сделки из внешнего источника;
|
||||||
|
- нормализовать их порядок;
|
||||||
|
- выполнять согласование через
|
||||||
|
TradeStreamConsistencyController;
|
||||||
|
- возвращать канонический результат восстановления.
|
||||||
|
|
||||||
|
Реализация не должна:
|
||||||
|
|
||||||
|
- выполнять повторные попытки;
|
||||||
|
- управлять WebSocket;
|
||||||
|
- управлять Runtime;
|
||||||
|
- выполнять кэширование;
|
||||||
|
- принимать решения о реконнекте.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def recover(
|
||||||
|
self,
|
||||||
|
request: TradeRecoveryRequest,
|
||||||
|
) -> TradeRecoveryResult:
|
||||||
|
"""
|
||||||
|
Выполнить одну операцию восстановления сделок.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
request
|
||||||
|
Параметры операции восстановления.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
TradeRecoveryResult
|
||||||
|
Канонический результат восстановления.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_request.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_RECOVERY_WINDOW_MS = 60 * 60 * 1000
|
||||||
|
_MIN_RECOVERY_LIMIT = 1
|
||||||
|
_MAX_RECOVERY_LIMIT = 1000
|
||||||
|
|
||||||
|
|
||||||
|
# Неизменяемое описание одного REST-запроса восстановления сделок.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeRecoveryRequest:
|
||||||
|
symbol: str
|
||||||
|
|
||||||
|
start_time: int
|
||||||
|
end_time: int
|
||||||
|
|
||||||
|
limit: int | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""
|
||||||
|
Проверить локальные инварианты запроса восстановления.
|
||||||
|
|
||||||
|
Временные границы задаются в миллисекундах Unix time и передаются
|
||||||
|
в Dzengi REST API как параметры startTime и endTime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(self.symbol, str):
|
||||||
|
raise TypeError("symbol должен иметь тип str.")
|
||||||
|
|
||||||
|
if not self.symbol.strip():
|
||||||
|
raise ValueError("symbol не должен быть пустым.")
|
||||||
|
|
||||||
|
if isinstance(self.start_time, bool) or not isinstance(
|
||||||
|
self.start_time,
|
||||||
|
int,
|
||||||
|
):
|
||||||
|
raise TypeError("start_time должен иметь тип int.")
|
||||||
|
|
||||||
|
if isinstance(self.end_time, bool) or not isinstance(
|
||||||
|
self.end_time,
|
||||||
|
int,
|
||||||
|
):
|
||||||
|
raise TypeError("end_time должен иметь тип int.")
|
||||||
|
|
||||||
|
if self.start_time < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"start_time не должен быть отрицательным."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.end_time < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"end_time не должен быть отрицательным."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.start_time > self.end_time:
|
||||||
|
raise ValueError(
|
||||||
|
"start_time не должен быть больше end_time."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.end_time - self.start_time >= _MAX_RECOVERY_WINDOW_MS:
|
||||||
|
raise ValueError(
|
||||||
|
"Диапазон восстановления должен быть меньше одного часа."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.limit is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(self.limit, bool) or not isinstance(self.limit, int):
|
||||||
|
raise TypeError("limit должен иметь тип int или None.")
|
||||||
|
|
||||||
|
if not _MIN_RECOVERY_LIMIT <= self.limit <= _MAX_RECOVERY_LIMIT:
|
||||||
|
raise ValueError(
|
||||||
|
"limit должен находиться в диапазоне от 1 до 1000."
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_result.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
|
||||||
|
|
||||||
|
# Неизменяемый результат одной операции восстановления сделок.
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeRecoveryResult:
|
||||||
|
symbol: str
|
||||||
|
|
||||||
|
requested_start_time: int
|
||||||
|
requested_end_time: int
|
||||||
|
|
||||||
|
recovered_trades: tuple[Trade, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recovered_count(self) -> int:
|
||||||
|
"""
|
||||||
|
Количество успешно восстановленных сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return len(self.recovered_trades)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""
|
||||||
|
Признак отсутствия восстановленных сделок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return not self.recovered_trades
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first_trade(self) -> Trade | None:
|
||||||
|
"""
|
||||||
|
Первая сделка после нормализации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.recovered_trades:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.recovered_trades[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_trade(self) -> Trade | None:
|
||||||
|
"""
|
||||||
|
Последняя сделка после нормализации.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.recovered_trades:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.recovered_trades[-1]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_window.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TradeRecoveryWindow:
|
||||||
|
"""
|
||||||
|
Неизменяемое описание одного временного диапазона
|
||||||
|
восстановления Trade Stream.
|
||||||
|
|
||||||
|
Модель не выполняет Recovery и не содержит ограничений
|
||||||
|
конкретного REST API. Допустимый максимальный размер окна
|
||||||
|
контролируется TradeRecoveryWindowPlanner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
start_time: int
|
||||||
|
end_time: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not isinstance(self.symbol, str):
|
||||||
|
raise TypeError("symbol must be a string")
|
||||||
|
|
||||||
|
if not self.symbol.strip():
|
||||||
|
raise ValueError("symbol must not be empty")
|
||||||
|
|
||||||
|
if not isinstance(self.start_time, int) or isinstance(
|
||||||
|
self.start_time,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError("start_time must be an integer")
|
||||||
|
|
||||||
|
if not isinstance(self.end_time, int) or isinstance(
|
||||||
|
self.end_time,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError("end_time must be an integer")
|
||||||
|
|
||||||
|
if self.start_time < 0:
|
||||||
|
raise ValueError("start_time must not be negative")
|
||||||
|
|
||||||
|
if self.end_time < 0:
|
||||||
|
raise ValueError("end_time must not be negative")
|
||||||
|
|
||||||
|
if self.start_time > self.end_time:
|
||||||
|
raise ValueError(
|
||||||
|
"start_time must not be greater than end_time"
|
||||||
|
)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# app/src/market_data/acquisition/recovery/trade_recovery_window_planner.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_window import (
|
||||||
|
TradeRecoveryWindow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS = 3_599_999
|
||||||
|
DEFAULT_TRADE_RECOVERY_WINDOW_MS = (
|
||||||
|
MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TradeRecoveryWindowPlanner:
|
||||||
|
"""
|
||||||
|
Stateless-планировщик временных окон восстановления Trade Stream.
|
||||||
|
|
||||||
|
Planner разбивает один непрерывный временной диапазон на
|
||||||
|
последовательность окон, каждое из которых совместимо с ограничением
|
||||||
|
TradeRecoveryRequest:
|
||||||
|
|
||||||
|
end_time - start_time < 3_600_000 ms
|
||||||
|
|
||||||
|
Planner не выполняет REST-запросы и не создаёт TradeRecoveryRequest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_max_window_ms",)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
max_window_ms: int = DEFAULT_TRADE_RECOVERY_WINDOW_MS,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(max_window_ms, int) or isinstance(
|
||||||
|
max_window_ms,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError("max_window_ms must be an integer")
|
||||||
|
|
||||||
|
if max_window_ms <= 0:
|
||||||
|
raise ValueError("max_window_ms must be positive")
|
||||||
|
|
||||||
|
if max_window_ms > MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS:
|
||||||
|
raise ValueError(
|
||||||
|
"max_window_ms must not exceed "
|
||||||
|
f"{MAX_TRADE_RECOVERY_REQUEST_WINDOW_MS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._max_window_ms = max_window_ms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_window_ms(self) -> int:
|
||||||
|
"""
|
||||||
|
Максимальная длительность одного создаваемого окна.
|
||||||
|
"""
|
||||||
|
return self._max_window_ms
|
||||||
|
|
||||||
|
def build_windows(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
start_time: int,
|
||||||
|
end_time: int,
|
||||||
|
) -> tuple[TradeRecoveryWindow, ...]:
|
||||||
|
"""
|
||||||
|
Построить последовательность Recovery Window.
|
||||||
|
|
||||||
|
Если start_time равен end_time, восстановление не требуется
|
||||||
|
и возвращается пустой tuple.
|
||||||
|
|
||||||
|
Соседние окна имеют общую границу:
|
||||||
|
|
||||||
|
previous.end_time == next.start_time
|
||||||
|
|
||||||
|
Возможный повтор Trade на границе должен быть устранён
|
||||||
|
существующим Consistency Layer.
|
||||||
|
"""
|
||||||
|
self._validate_range(
|
||||||
|
symbol=symbol,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_time == end_time:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
windows: list[TradeRecoveryWindow] = []
|
||||||
|
cursor = start_time
|
||||||
|
|
||||||
|
while cursor < end_time:
|
||||||
|
window_end = min(
|
||||||
|
cursor + self._max_window_ms,
|
||||||
|
end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
windows.append(
|
||||||
|
TradeRecoveryWindow(
|
||||||
|
symbol=symbol,
|
||||||
|
start_time=cursor,
|
||||||
|
end_time=window_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = window_end
|
||||||
|
|
||||||
|
return tuple(windows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_range(
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
start_time: int,
|
||||||
|
end_time: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Проверить исходный диапазон до начала разбиения.
|
||||||
|
"""
|
||||||
|
if not isinstance(symbol, str):
|
||||||
|
raise TypeError("symbol must be a string")
|
||||||
|
|
||||||
|
if not symbol.strip():
|
||||||
|
raise ValueError("symbol must not be empty")
|
||||||
|
|
||||||
|
if not isinstance(start_time, int) or isinstance(
|
||||||
|
start_time,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError("start_time must be an integer")
|
||||||
|
|
||||||
|
if not isinstance(end_time, int) or isinstance(
|
||||||
|
end_time,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError("end_time must be an integer")
|
||||||
|
|
||||||
|
if start_time < 0:
|
||||||
|
raise ValueError("start_time must not be negative")
|
||||||
|
|
||||||
|
if end_time < 0:
|
||||||
|
raise ValueError("end_time must not be negative")
|
||||||
|
|
||||||
|
if start_time > end_time:
|
||||||
|
raise ValueError(
|
||||||
|
"start_time must not be greater than end_time"
|
||||||
|
)
|
||||||
@@ -3,12 +3,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
CandleFeedRegistryError,
|
||||||
InstrumentFeedRegistryError,
|
InstrumentFeedRegistryError,
|
||||||
QuoteFeedRegistryError,
|
QuoteFeedRegistryError,
|
||||||
|
TradeFeedRegistryError,
|
||||||
)
|
)
|
||||||
from src.market_data.acquisition.protocol import (
|
from src.market_data.acquisition.protocol import (
|
||||||
|
CandlesFeedProtocol,
|
||||||
InstrumentFeedProtocol,
|
InstrumentFeedProtocol,
|
||||||
QuoteFeedProtocol,
|
QuoteFeedProtocol,
|
||||||
|
TradesFeedProtocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,3 +144,133 @@ class QuoteFeedRegistry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return normalized_source_name
|
return normalized_source_name
|
||||||
|
|
||||||
|
|
||||||
|
# Реестр доступных потоков рыночных сделок.
|
||||||
|
class TradesFeedRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._feeds: dict[str, TradesFeedProtocol] = {}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
feed: TradesFeedProtocol,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Зарегистрировать Trades Feed для указанного источника.
|
||||||
|
|
||||||
|
Повторная регистрация того же имени запрещена, чтобы исключить
|
||||||
|
неявную замену production-зависимости.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
if not isinstance(feed, TradesFeedProtocol):
|
||||||
|
raise TradeFeedRegistryError(
|
||||||
|
f"Объект для источника '{normalized_source_name}' "
|
||||||
|
"не соответствует TradesFeedProtocol."
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_source_name in self._feeds:
|
||||||
|
raise TradeFeedRegistryError(
|
||||||
|
f"Trades Feed для источника "
|
||||||
|
f"'{normalized_source_name}' уже зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._feeds[normalized_source_name] = feed
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> TradesFeedProtocol:
|
||||||
|
"""Вернуть зарегистрированный Trades Feed по имени источника."""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
feed = self._feeds.get(normalized_source_name)
|
||||||
|
|
||||||
|
if feed is None:
|
||||||
|
raise TradeFeedRegistryError(
|
||||||
|
f"Trades Feed для источника "
|
||||||
|
f"'{normalized_source_name}' не зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
return feed
|
||||||
|
|
||||||
|
def _normalize_source_name(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_source_name = source_name.strip()
|
||||||
|
|
||||||
|
if not normalized_source_name:
|
||||||
|
raise TradeFeedRegistryError(
|
||||||
|
"Имя источника Trades Feed не должно быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_source_name
|
||||||
|
|
||||||
|
|
||||||
|
# Реестр доступных потоков рыночных свечей.
|
||||||
|
class CandlesFeedRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._feeds: dict[str, CandlesFeedProtocol] = {}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
feed: CandlesFeedProtocol,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Зарегистрировать Candles Feed для указанного источника.
|
||||||
|
|
||||||
|
Повторная регистрация того же имени запрещена, чтобы исключить
|
||||||
|
неявную замену production-зависимости.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
if not isinstance(feed, CandlesFeedProtocol):
|
||||||
|
raise CandleFeedRegistryError(
|
||||||
|
f"Объект для источника '{normalized_source_name}' "
|
||||||
|
"не соответствует CandlesFeedProtocol."
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized_source_name in self._feeds:
|
||||||
|
raise CandleFeedRegistryError(
|
||||||
|
f"Candles Feed для источника "
|
||||||
|
f"'{normalized_source_name}' уже зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._feeds[normalized_source_name] = feed
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> CandlesFeedProtocol:
|
||||||
|
"""Вернуть зарегистрированный Candles Feed по имени источника."""
|
||||||
|
|
||||||
|
normalized_source_name = self._normalize_source_name(source_name)
|
||||||
|
|
||||||
|
feed = self._feeds.get(normalized_source_name)
|
||||||
|
|
||||||
|
if feed is None:
|
||||||
|
raise CandleFeedRegistryError(
|
||||||
|
f"Candles Feed для источника "
|
||||||
|
f"'{normalized_source_name}' не зарегистрирован."
|
||||||
|
)
|
||||||
|
|
||||||
|
return feed
|
||||||
|
|
||||||
|
def _normalize_source_name(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
) -> str:
|
||||||
|
normalized_source_name = source_name.strip()
|
||||||
|
|
||||||
|
if not normalized_source_name:
|
||||||
|
raise CandleFeedRegistryError(
|
||||||
|
"Имя источника Candles Feed не должно быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized_source_name
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/__init__.py
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.runtime_events import (
|
||||||
|
ConnectedEvent,
|
||||||
|
ConnectFailedEvent,
|
||||||
|
DisconnectedEvent,
|
||||||
|
HeartbeatTimeoutEvent,
|
||||||
|
MessageReceivedEvent,
|
||||||
|
MessageSentEvent,
|
||||||
|
ReconnectCompletedEvent,
|
||||||
|
ReconnectFailedEvent,
|
||||||
|
ReconnectStartedEvent,
|
||||||
|
TransportMessage,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
AcquisitionRuntimeEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AcquisitionRuntimeEventLoggingConsumer:
|
||||||
|
"""
|
||||||
|
Безопасно записывает Acquisition Runtime Events в standard logging.
|
||||||
|
|
||||||
|
Содержимое transport payload намеренно не журналируется. Для
|
||||||
|
MessageReceivedEvent и MessageSentEvent фиксируются только тип
|
||||||
|
сообщения и размер payload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_logger",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
event_logger: logging.Logger = logger,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(event_logger, logging.Logger):
|
||||||
|
raise TypeError(
|
||||||
|
"event_logger must be a logging.Logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._logger = event_logger
|
||||||
|
|
||||||
|
async def consume(
|
||||||
|
self,
|
||||||
|
event: AcquisitionRuntimeEvent,
|
||||||
|
) -> None:
|
||||||
|
"""Записать одно Acquisition Runtime Event."""
|
||||||
|
if isinstance(event, ConnectedEvent):
|
||||||
|
self._logger.info(
|
||||||
|
"Acquisition WebSocket connected."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, DisconnectedEvent):
|
||||||
|
self._logger.info(
|
||||||
|
"Acquisition WebSocket disconnected."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, ConnectFailedEvent):
|
||||||
|
self._logger.error(
|
||||||
|
"Acquisition WebSocket connection failed: %s",
|
||||||
|
event.reason,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, MessageReceivedEvent):
|
||||||
|
self._log_transport_message(
|
||||||
|
direction="received",
|
||||||
|
message=event.message,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, MessageSentEvent):
|
||||||
|
self._log_transport_message(
|
||||||
|
direction="sent",
|
||||||
|
message=event.message,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, ReconnectStartedEvent):
|
||||||
|
self._logger.info(
|
||||||
|
"Acquisition WebSocket reconnect started: attempt=%d",
|
||||||
|
event.attempt,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, ReconnectCompletedEvent):
|
||||||
|
self._logger.info(
|
||||||
|
"Acquisition WebSocket reconnect completed: attempt=%d",
|
||||||
|
event.attempt,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, ReconnectFailedEvent):
|
||||||
|
self._logger.error(
|
||||||
|
"Acquisition WebSocket reconnect failed: "
|
||||||
|
"attempt=%d reason=%s",
|
||||||
|
event.attempt,
|
||||||
|
event.reason,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, HeartbeatTimeoutEvent):
|
||||||
|
self._logger.warning(
|
||||||
|
"Acquisition WebSocket heartbeat timeout: "
|
||||||
|
"timeout_seconds=%s",
|
||||||
|
event.timeout_seconds,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise TypeError(
|
||||||
|
"event must be an AcquisitionRuntimeEvent"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_transport_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
direction: str,
|
||||||
|
message: TransportMessage,
|
||||||
|
) -> None:
|
||||||
|
self._logger.debug(
|
||||||
|
"Acquisition WebSocket message %s: "
|
||||||
|
"message_type=%s payload_size=%d",
|
||||||
|
direction,
|
||||||
|
type(message).__name__,
|
||||||
|
len(message.payload),
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
AcquisitionRuntimeEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_active_publisher_ids: ContextVar[tuple[int, ...]] = ContextVar(
|
||||||
|
"active_acquisition_runtime_event_publisher_ids",
|
||||||
|
default=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AcquisitionRuntimeEventConsumerProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт одного потребителя Acquisition Runtime Events.
|
||||||
|
|
||||||
|
Consumer обрабатывает уже произошедший инфраструктурный факт
|
||||||
|
и не управляет lifecycle Publisher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def consume(
|
||||||
|
self,
|
||||||
|
event: AcquisitionRuntimeEvent,
|
||||||
|
) -> None:
|
||||||
|
"""Обработать одно Acquisition Runtime Event."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class AcquisitionRuntimeEventPublisher:
|
||||||
|
"""
|
||||||
|
Последовательный in-process Publisher Acquisition Runtime Events.
|
||||||
|
|
||||||
|
Один вызов publish() полностью доставляет событие всем Consumer
|
||||||
|
в порядке их регистрации. Параллельные вызовы сериализуются одним
|
||||||
|
asyncio.Lock.
|
||||||
|
|
||||||
|
Publisher не создаёт фоновых задач, не хранит очередь и не управляет
|
||||||
|
lifecycle Consumer.
|
||||||
|
|
||||||
|
Обычная ошибка одного Consumer журналируется и не препятствует
|
||||||
|
доставке остальным Consumer. Сигналы отмены и другие BaseException
|
||||||
|
не перехватываются.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_consumers",
|
||||||
|
"_publish_lock",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
consumers: Iterable[
|
||||||
|
AcquisitionRuntimeEventConsumerProtocol
|
||||||
|
] = (),
|
||||||
|
) -> None:
|
||||||
|
registered_consumers = tuple(consumers)
|
||||||
|
|
||||||
|
for consumer in registered_consumers:
|
||||||
|
if not isinstance(
|
||||||
|
consumer,
|
||||||
|
AcquisitionRuntimeEventConsumerProtocol,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"consumer must satisfy "
|
||||||
|
"AcquisitionRuntimeEventConsumerProtocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._consumers = registered_consumers
|
||||||
|
self._publish_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
event: AcquisitionRuntimeEvent,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Последовательно доставить событие всем Consumer.
|
||||||
|
|
||||||
|
Рекурсивная публикация через тот же Publisher запрещена.
|
||||||
|
Такое ограничение предотвращает deadlock на publish lock.
|
||||||
|
"""
|
||||||
|
if not isinstance(event, AcquisitionRuntimeEvent):
|
||||||
|
raise TypeError(
|
||||||
|
"event must be an AcquisitionRuntimeEvent"
|
||||||
|
)
|
||||||
|
|
||||||
|
publisher_id = id(self)
|
||||||
|
active_publisher_ids = _active_publisher_ids.get()
|
||||||
|
|
||||||
|
if publisher_id in active_publisher_ids:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Recursive publication through the same "
|
||||||
|
"AcquisitionRuntimeEventPublisher is not supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
active_token = _active_publisher_ids.set(
|
||||||
|
(*active_publisher_ids, publisher_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._publish_lock:
|
||||||
|
for consumer in self._consumers:
|
||||||
|
try:
|
||||||
|
await consumer.consume(event)
|
||||||
|
except Exception as error:
|
||||||
|
self._report_consumer_error(
|
||||||
|
event=event,
|
||||||
|
consumer=consumer,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_active_publisher_ids.reset(active_token)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _report_consumer_error(
|
||||||
|
*,
|
||||||
|
event: AcquisitionRuntimeEvent,
|
||||||
|
consumer: AcquisitionRuntimeEventConsumerProtocol,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Безопасно зафиксировать ошибку Consumer.
|
||||||
|
|
||||||
|
Текст исключения и traceback намеренно не журналируются:
|
||||||
|
они могут содержать repr(event) с transport payload.
|
||||||
|
|
||||||
|
Ошибка logging handler не должна прерывать Runtime lifecycle
|
||||||
|
или доставку события остальным Consumer.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.error(
|
||||||
|
"Acquisition Runtime Event consumer failed: "
|
||||||
|
"event=%s consumer=%s error_type=%s",
|
||||||
|
type(event).__name__,
|
||||||
|
type(consumer).__name__,
|
||||||
|
type(error).__name__,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/acquisition_runtime_service.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Сервис исполнения команд Acquisition Runtime.
|
||||||
|
|
||||||
|
Build 060.22 вводит первую производственную реализацию
|
||||||
|
сервисного слоя Runtime подсистемы Market Data Acquisition.
|
||||||
|
|
||||||
|
Сервис принимает типизированные Runtime-команды и делегирует
|
||||||
|
их выполнение соответствующим инфраструктурным зависимостям.
|
||||||
|
|
||||||
|
Сервис не содержит знаний о Trades Feed, Consistency или Recovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.acquisition_runtime_service_protocol import (
|
||||||
|
AcquisitionRuntimeServiceProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.runtime_commands import (
|
||||||
|
ConnectCommand,
|
||||||
|
DisconnectCommand,
|
||||||
|
SendBinaryCommand,
|
||||||
|
SendTextCommand,
|
||||||
|
SubscribeCommand,
|
||||||
|
UnsubscribeCommand,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
AcquisitionRuntimeCommand,
|
||||||
|
AcquisitionRuntimeCommandDispatcherProtocol,
|
||||||
|
AcquisitionRuntimeEventPublisherProtocol,
|
||||||
|
WebSocketSessionProtocol,
|
||||||
|
WebSocketSubscriptionManagerProtocol,
|
||||||
|
WebSocketTransportProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AcquisitionRuntimeService(
|
||||||
|
AcquisitionRuntimeServiceProtocol,
|
||||||
|
AcquisitionRuntimeCommandDispatcherProtocol,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Сервис маршрутизации Acquisition Runtime Commands.
|
||||||
|
|
||||||
|
Каждая команда делегируется ровно одной инфраструктурной
|
||||||
|
зависимости.
|
||||||
|
|
||||||
|
Сервис не управляет reconnect, heartbeat, scheduler,
|
||||||
|
Recovery или обработкой рыночных данных.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: WebSocketSessionProtocol,
|
||||||
|
transport: WebSocketTransportProtocol,
|
||||||
|
subscription_manager: WebSocketSubscriptionManagerProtocol,
|
||||||
|
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Создать Acquisition Runtime Service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session:
|
||||||
|
Компонент жизненного цикла WebSocket-сессии.
|
||||||
|
|
||||||
|
transport:
|
||||||
|
Низкоуровневый WebSocket-транспорт.
|
||||||
|
|
||||||
|
subscription_manager:
|
||||||
|
Компонент управления активными подписками.
|
||||||
|
|
||||||
|
event_publisher:
|
||||||
|
Компонент публикации инфраструктурных Runtime-событий.
|
||||||
|
|
||||||
|
В Build 060.22 зависимость сохраняется как часть
|
||||||
|
утверждённой архитектуры, но публикация событий
|
||||||
|
будет подключена в последующих Build.
|
||||||
|
"""
|
||||||
|
self._session = session
|
||||||
|
self._transport = transport
|
||||||
|
self._subscription_manager = subscription_manager
|
||||||
|
self._event_publisher = event_publisher
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
command: AcquisitionRuntimeCommand,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Выполнить одну инфраструктурную команду Acquisition Runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command:
|
||||||
|
Типизированная команда Runtime Layer.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError:
|
||||||
|
Если передан неподдерживаемый тип команды.
|
||||||
|
|
||||||
|
Exception:
|
||||||
|
Любое исключение инфраструктурной зависимости
|
||||||
|
распространяется вызывающему компоненту без изменения.
|
||||||
|
"""
|
||||||
|
if isinstance(command, ConnectCommand):
|
||||||
|
await self._session.start()
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(command, DisconnectCommand):
|
||||||
|
await self._session.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(command, SubscribeCommand):
|
||||||
|
await self._subscription_manager.subscribe(
|
||||||
|
command.subscription_key,
|
||||||
|
command.message,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(command, UnsubscribeCommand):
|
||||||
|
await self._subscription_manager.unsubscribe(
|
||||||
|
command.subscription_key,
|
||||||
|
command.message,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(command, SendTextCommand):
|
||||||
|
await self._transport.send(command.message.payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(command, SendBinaryCommand):
|
||||||
|
await self._transport.send(command.message.payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise TypeError(
|
||||||
|
"Неподдерживаемый тип Acquisition Runtime Command: "
|
||||||
|
f"{type(command).__name__}."
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/acquisition_runtime_service_protocol.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Публичный контракт Acquisition Runtime Service.
|
||||||
|
|
||||||
|
Build 060.22 вводит сервисный уровень исполнения типизированных
|
||||||
|
команд Runtime подсистемы Market Data Acquisition.
|
||||||
|
|
||||||
|
Контракт не определяет устройство WebSocket Session, Transport,
|
||||||
|
Subscription Manager или механизма публикации событий.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
AcquisitionRuntimeCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AcquisitionRuntimeServiceProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт сервиса исполнения Acquisition Runtime Commands.
|
||||||
|
|
||||||
|
Реализация принимает одну типизированную команду и передаёт её
|
||||||
|
соответствующей инфраструктурной зависимости.
|
||||||
|
|
||||||
|
Сервис не содержит знаний о Trades Feed, Consistency и Recovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
command: AcquisitionRuntimeCommand,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Выполнить одну инфраструктурную команду Acquisition Runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command:
|
||||||
|
Типизированная команда Runtime Layer.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/heartbeat.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Callable, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.runtime_events import (
|
||||||
|
HeartbeatTimeoutEvent,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
AcquisitionRuntimeEventPublisherProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatState(str, Enum):
|
||||||
|
"""
|
||||||
|
Текущее состояние Heartbeat Monitor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
IDLE = "idle"
|
||||||
|
MONITORING = "monitoring"
|
||||||
|
TIMED_OUT = "timed_out"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class HeartbeatMonitorProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт пассивного контроля активности Acquisition Runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> HeartbeatState:
|
||||||
|
"""Вернуть текущее состояние Heartbeat Monitor."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timeout_seconds(self) -> float:
|
||||||
|
"""Вернуть настроенный интервал timeout."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_activity_at(self) -> float | None:
|
||||||
|
"""Вернуть monotonic-время последней активности."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Начать мониторинг активности."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Остановить мониторинг и очистить текущее состояние."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_activity(self) -> None:
|
||||||
|
"""Зафиксировать текущий момент как последнюю активность."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def check_timeout(self) -> bool:
|
||||||
|
"""
|
||||||
|
Проверить наступление timeout.
|
||||||
|
|
||||||
|
Вернуть True только при первом обнаружении timeout.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatMonitor:
|
||||||
|
"""
|
||||||
|
Пассивный монитор активности Acquisition Runtime.
|
||||||
|
|
||||||
|
Monitor:
|
||||||
|
|
||||||
|
- хранит monotonic-время последней активности;
|
||||||
|
- детерминированно проверяет превышение timeout;
|
||||||
|
- однократно публикует HeartbeatTimeoutEvent;
|
||||||
|
- не запускает собственные фоновые задачи;
|
||||||
|
- не выполняет reconnect;
|
||||||
|
- не управляет WebSocket lifecycle.
|
||||||
|
|
||||||
|
Периодический вызов check_timeout() выполняет Runtime Scheduler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_event_publisher",
|
||||||
|
"_timeout_seconds",
|
||||||
|
"_clock",
|
||||||
|
"_state",
|
||||||
|
"_last_activity_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(timeout_seconds, (int, float)) or isinstance(
|
||||||
|
timeout_seconds,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"timeout_seconds must be an integer or float"
|
||||||
|
)
|
||||||
|
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"timeout_seconds must be positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not callable(clock):
|
||||||
|
raise TypeError("clock must be callable")
|
||||||
|
|
||||||
|
self._event_publisher = event_publisher
|
||||||
|
self._timeout_seconds = float(timeout_seconds)
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
self._state = HeartbeatState.IDLE
|
||||||
|
self._last_activity_at: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> HeartbeatState:
|
||||||
|
"""Вернуть текущее состояние Heartbeat Monitor."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timeout_seconds(self) -> float:
|
||||||
|
"""Вернуть настроенный интервал timeout."""
|
||||||
|
return self._timeout_seconds
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_activity_at(self) -> float | None:
|
||||||
|
"""Вернуть monotonic-время последней активности."""
|
||||||
|
return self._last_activity_at
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""
|
||||||
|
Начать мониторинг с текущего monotonic-времени.
|
||||||
|
"""
|
||||||
|
self._last_activity_at = self._clock()
|
||||||
|
self._state = HeartbeatState.MONITORING
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""
|
||||||
|
Остановить мониторинг и удалить runtime-состояние Heartbeat.
|
||||||
|
"""
|
||||||
|
self._last_activity_at = None
|
||||||
|
self._state = HeartbeatState.IDLE
|
||||||
|
|
||||||
|
def record_activity(self) -> None:
|
||||||
|
"""
|
||||||
|
Зафиксировать текущий момент как последнюю активность.
|
||||||
|
|
||||||
|
Получение новой активности также переводит Monitor из состояния
|
||||||
|
TIMED_OUT обратно в MONITORING.
|
||||||
|
"""
|
||||||
|
self._last_activity_at = self._clock()
|
||||||
|
self._state = HeartbeatState.MONITORING
|
||||||
|
|
||||||
|
async def check_timeout(self) -> bool:
|
||||||
|
"""
|
||||||
|
Проверить, истёк ли настроенный интервал активности.
|
||||||
|
|
||||||
|
HeartbeatTimeoutEvent публикуется только один раз для каждого
|
||||||
|
периода отсутствия активности.
|
||||||
|
|
||||||
|
После новой активности Monitor снова может зафиксировать timeout.
|
||||||
|
"""
|
||||||
|
if self._state is not HeartbeatState.MONITORING:
|
||||||
|
return False
|
||||||
|
|
||||||
|
last_activity_at = self._last_activity_at
|
||||||
|
|
||||||
|
if last_activity_at is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
elapsed_seconds = self._clock() - last_activity_at
|
||||||
|
|
||||||
|
if elapsed_seconds < self._timeout_seconds:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._state = HeartbeatState.TIMED_OUT
|
||||||
|
|
||||||
|
await self._event_publisher.publish(
|
||||||
|
HeartbeatTimeoutEvent(
|
||||||
|
timeout_seconds=self._timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
118
app/src/market_data/acquisition/runtime/live_processing_gate.py
Normal file
118
app/src/market_data/acquisition/runtime/live_processing_gate.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeLiveProcessingGateProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт общей границы Live processing и Runtime Recovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def locked(self) -> bool:
|
||||||
|
"""Показывает, закрыт ли gate текущей операцией."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed(self) -> bool:
|
||||||
|
"""Показывает, запрещено ли продолжать Live processing."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def fail(
|
||||||
|
self,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
"""Запретить Live processing после terminal Runtime error."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Разрешить Live processing для нового Runtime lifecycle."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def __aenter__(
|
||||||
|
self,
|
||||||
|
) -> RuntimeLiveProcessingGateProtocol:
|
||||||
|
"""Закрыть gate и дождаться эксклюзивного доступа."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_value: BaseException | None,
|
||||||
|
traceback: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
"""Открыть gate после завершения защищённой операции."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeLiveProcessingGate:
|
||||||
|
"""
|
||||||
|
Единый asyncio gate для Live processing и Recovery.
|
||||||
|
|
||||||
|
Gate не содержит market state и не создаёт фоновые задачи.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_lock",
|
||||||
|
"_failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._failure: Exception | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def locked(self) -> bool:
|
||||||
|
"""Показывает, закрыт ли gate текущей операцией."""
|
||||||
|
return self._lock.locked()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed(self) -> bool:
|
||||||
|
"""Показывает, запрещено ли продолжать Live processing."""
|
||||||
|
return self._failure is not None
|
||||||
|
|
||||||
|
def fail(
|
||||||
|
self,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
"""Запретить Live processing после terminal Runtime error."""
|
||||||
|
if not isinstance(error, Exception):
|
||||||
|
raise TypeError("error must be an Exception")
|
||||||
|
|
||||||
|
self._failure = error
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Разрешить Live processing для нового Runtime lifecycle."""
|
||||||
|
if self._lock.locked():
|
||||||
|
raise RuntimeError(
|
||||||
|
"Live processing gate cannot be reset while locked."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._failure = None
|
||||||
|
|
||||||
|
async def __aenter__(
|
||||||
|
self,
|
||||||
|
) -> RuntimeLiveProcessingGate:
|
||||||
|
"""Закрыть gate и дождаться эксклюзивного доступа."""
|
||||||
|
await self._lock.acquire()
|
||||||
|
|
||||||
|
failure = self._failure
|
||||||
|
|
||||||
|
if failure is not None:
|
||||||
|
self._lock.release()
|
||||||
|
raise failure
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_value: BaseException | None,
|
||||||
|
traceback: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
"""Открыть gate после завершения защищённой операции."""
|
||||||
|
self._lock.release()
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/reconnect.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.runtime_commands import (
|
||||||
|
ConnectCommand,
|
||||||
|
DisconnectCommand,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.runtime_events import (
|
||||||
|
ReconnectCompletedEvent,
|
||||||
|
ReconnectFailedEvent,
|
||||||
|
ReconnectStartedEvent,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
AcquisitionRuntimeCommandDispatcherProtocol,
|
||||||
|
AcquisitionRuntimeEventPublisherProtocol,
|
||||||
|
WebSocketSubscriptionManagerProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReconnectState(str, Enum):
|
||||||
|
"""
|
||||||
|
Текущее состояние ReconnectCoordinator.
|
||||||
|
|
||||||
|
Состояния описывают только lifecycle одной инфраструктурной
|
||||||
|
операции reconnect.
|
||||||
|
|
||||||
|
Состояния Heartbeat, Supervisor и Trade Recovery принадлежат
|
||||||
|
соответствующим специализированным компонентам.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DISCONNECTED = "disconnected"
|
||||||
|
CONNECTING = "connecting"
|
||||||
|
RESTORING_SUBSCRIPTIONS = "restoring_subscriptions"
|
||||||
|
CONNECTED = "connected"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ReconnectCoordinatorProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт координатора повторного подключения Acquisition Runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> ReconnectState:
|
||||||
|
"""
|
||||||
|
Вернуть текущее состояние reconnect lifecycle.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempt(self) -> int:
|
||||||
|
"""
|
||||||
|
Вернуть номер последней начатой попытки reconnect.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def reconnect(self) -> None:
|
||||||
|
"""
|
||||||
|
Выполнить одну попытку повторного подключения.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ReconnectCoordinator:
|
||||||
|
"""
|
||||||
|
Координатор одной попытки повторного подключения.
|
||||||
|
|
||||||
|
Coordinator:
|
||||||
|
|
||||||
|
- увеличивает номер попытки;
|
||||||
|
- публикует ReconnectStartedEvent;
|
||||||
|
- передаёт DisconnectCommand и ConnectCommand в Runtime Dispatcher;
|
||||||
|
- восстанавливает зарегистрированные подписки;
|
||||||
|
- публикует ReconnectCompletedEvent;
|
||||||
|
- при ошибке публикует ReconnectFailedEvent;
|
||||||
|
- сохраняет текущее состояние lifecycle.
|
||||||
|
|
||||||
|
Coordinator не выполняет:
|
||||||
|
|
||||||
|
- retry loop;
|
||||||
|
- backoff;
|
||||||
|
- scheduling;
|
||||||
|
- heartbeat monitoring;
|
||||||
|
- Trade Recovery;
|
||||||
|
- обработку рыночных сообщений.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_command_dispatcher",
|
||||||
|
"_subscription_manager",
|
||||||
|
"_event_publisher",
|
||||||
|
"_state",
|
||||||
|
"_attempt",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
command_dispatcher: AcquisitionRuntimeCommandDispatcherProtocol,
|
||||||
|
subscription_manager: WebSocketSubscriptionManagerProtocol,
|
||||||
|
event_publisher: AcquisitionRuntimeEventPublisherProtocol,
|
||||||
|
) -> None:
|
||||||
|
self._command_dispatcher = command_dispatcher
|
||||||
|
self._subscription_manager = subscription_manager
|
||||||
|
self._event_publisher = event_publisher
|
||||||
|
|
||||||
|
self._state = ReconnectState.DISCONNECTED
|
||||||
|
self._attempt = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> ReconnectState:
|
||||||
|
"""
|
||||||
|
Вернуть текущее состояние reconnect lifecycle.
|
||||||
|
"""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempt(self) -> int:
|
||||||
|
"""
|
||||||
|
Вернуть номер последней начатой попытки reconnect.
|
||||||
|
"""
|
||||||
|
return self._attempt
|
||||||
|
|
||||||
|
async def reconnect(self) -> None:
|
||||||
|
"""
|
||||||
|
Выполнить одну попытку повторного подключения.
|
||||||
|
|
||||||
|
Последовательность:
|
||||||
|
|
||||||
|
ReconnectStartedEvent
|
||||||
|
↓
|
||||||
|
DisconnectCommand
|
||||||
|
↓
|
||||||
|
ConnectCommand
|
||||||
|
↓
|
||||||
|
restore_subscriptions()
|
||||||
|
↓
|
||||||
|
ReconnectCompletedEvent
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception:
|
||||||
|
Любая ошибка Runtime Dispatcher или Subscription Manager
|
||||||
|
распространяется вызывающему компоненту без обёртки.
|
||||||
|
"""
|
||||||
|
self._attempt += 1
|
||||||
|
current_attempt = self._attempt
|
||||||
|
|
||||||
|
self._state = ReconnectState.CONNECTING
|
||||||
|
|
||||||
|
await self._event_publisher.publish(
|
||||||
|
ReconnectStartedEvent(
|
||||||
|
attempt=current_attempt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._command_dispatcher.dispatch(
|
||||||
|
DisconnectCommand()
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._command_dispatcher.dispatch(
|
||||||
|
ConnectCommand()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._state = (
|
||||||
|
ReconnectState.RESTORING_SUBSCRIPTIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._subscription_manager.restore_subscriptions()
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
self._state = ReconnectState.FAILED
|
||||||
|
|
||||||
|
await self._event_publisher.publish(
|
||||||
|
ReconnectFailedEvent(
|
||||||
|
attempt=current_attempt,
|
||||||
|
reason=str(error),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._state = ReconnectState.CONNECTED
|
||||||
|
|
||||||
|
await self._event_publisher.publish(
|
||||||
|
ReconnectCompletedEvent(
|
||||||
|
attempt=current_attempt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
82
app/src/market_data/acquisition/runtime/runtime_commands.py
Normal file
82
app/src/market_data/acquisition/runtime/runtime_commands.py
Normal file
@@ -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
|
||||||
114
app/src/market_data/acquisition/runtime/runtime_events.py
Normal file
114
app/src/market_data/acquisition/runtime/runtime_events.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/runtime_events.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 ConnectedEvent:
|
||||||
|
"""
|
||||||
|
Событие успешного открытия транспортного соединения.
|
||||||
|
|
||||||
|
Событие фиксирует уже произошедший факт подключения и не содержит
|
||||||
|
логики управления транспортом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DisconnectedEvent:
|
||||||
|
"""
|
||||||
|
Событие завершения транспортного соединения.
|
||||||
|
|
||||||
|
Событие не определяет причину отключения и не запускает reconnect
|
||||||
|
самостоятельно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectFailedEvent:
|
||||||
|
"""
|
||||||
|
Событие неуспешной попытки открытия транспортного соединения.
|
||||||
|
|
||||||
|
reason содержит безопасное текстовое описание причины ошибки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MessageReceivedEvent:
|
||||||
|
"""
|
||||||
|
Событие получения транспортного сообщения.
|
||||||
|
|
||||||
|
message содержит непрозрачный текстовый или бинарный payload.
|
||||||
|
Runtime Event не интерпретирует содержимое сообщения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
message: TransportMessage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MessageSentEvent:
|
||||||
|
"""
|
||||||
|
Событие успешной отправки транспортного сообщения.
|
||||||
|
|
||||||
|
message содержит сообщение, переданное транспортному уровню.
|
||||||
|
"""
|
||||||
|
|
||||||
|
message: TransportMessage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReconnectStartedEvent:
|
||||||
|
"""
|
||||||
|
Событие начала попытки восстановления соединения.
|
||||||
|
|
||||||
|
attempt содержит номер текущей попытки reconnect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
attempt: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReconnectCompletedEvent:
|
||||||
|
"""
|
||||||
|
Событие успешного завершения восстановления соединения.
|
||||||
|
|
||||||
|
attempt содержит номер попытки, на которой соединение было
|
||||||
|
восстановлено.
|
||||||
|
"""
|
||||||
|
|
||||||
|
attempt: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReconnectFailedEvent:
|
||||||
|
"""
|
||||||
|
Событие неуспешной попытки восстановления соединения.
|
||||||
|
|
||||||
|
attempt содержит номер попытки.
|
||||||
|
reason содержит безопасное текстовое описание причины ошибки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
attempt: int
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HeartbeatTimeoutEvent:
|
||||||
|
"""
|
||||||
|
Событие превышения допустимого интервала heartbeat.
|
||||||
|
|
||||||
|
timeout_seconds содержит настроенный порог ожидания ответа.
|
||||||
|
"""
|
||||||
|
|
||||||
|
timeout_seconds: float
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeLivenessProbeProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт активной проверки transport liveness.
|
||||||
|
|
||||||
|
Успешная проверка подтверждается transport-level ответом, а не
|
||||||
|
наличием market events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def probe(self) -> bool:
|
||||||
|
"""
|
||||||
|
Проверить активность текущего transport connection.
|
||||||
|
|
||||||
|
Вернуть True только после подтверждённого transport response.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||||
|
TradeRecoveryResult,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.live_processing_gate import (
|
||||||
|
RuntimeLiveProcessingGateProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.reconnect import (
|
||||||
|
ReconnectCoordinatorProtocol,
|
||||||
|
ReconnectState,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.runtime_recovery_protocol import (
|
||||||
|
RuntimeRecoveryProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.symbols import normalize_symbol
|
||||||
|
|
||||||
|
|
||||||
|
RuntimeUnixTimeMillisecondsClock = Callable[[], int]
|
||||||
|
|
||||||
|
|
||||||
|
def system_unix_time_ms() -> int:
|
||||||
|
"""Вернуть текущее Unix time без промежуточного float."""
|
||||||
|
return time.time_ns() // 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeReconnectRecoveryProtocol(
|
||||||
|
ReconnectCoordinatorProtocol,
|
||||||
|
Protocol,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Контракт единой reconnect → restore → recovery операции.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generation(self) -> int:
|
||||||
|
"""Вернуть поколение последней начатой операции."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live_processing_gate(
|
||||||
|
self,
|
||||||
|
) -> RuntimeLiveProcessingGateProtocol:
|
||||||
|
"""Вернуть общий gate Live processing и Recovery."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbols(self) -> tuple[str, ...]:
|
||||||
|
"""Вернуть нормализованные Recovery symbols."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def reconnect_after_transport_failure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observed_generation: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Восстановить connection, если ошибка относится к текущему
|
||||||
|
поколению.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class _ReconnectRecoveryOperation:
|
||||||
|
__slots__ = (
|
||||||
|
"source_generation",
|
||||||
|
"completion",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_generation: int,
|
||||||
|
completion: asyncio.Future[None],
|
||||||
|
) -> None:
|
||||||
|
self.source_generation = source_generation
|
||||||
|
self.completion = completion
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeReconnectRecoveryCoordinator:
|
||||||
|
"""
|
||||||
|
Single-flight координатор reconnect → restore → recovery.
|
||||||
|
|
||||||
|
Существующий ReconnectCoordinator сохраняет ответственность только
|
||||||
|
за Disconnect → Connect → restore subscriptions. Этот компонент
|
||||||
|
закрывает общий Live processing gate и после reconnect выполняет
|
||||||
|
синхронный Runtime Recovery вне event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_reconnect_coordinator",
|
||||||
|
"_recovery_coordinator",
|
||||||
|
"_live_processing_gate",
|
||||||
|
"_symbols",
|
||||||
|
"_clock",
|
||||||
|
"_coordination_lock",
|
||||||
|
"_generation",
|
||||||
|
"_active_operation",
|
||||||
|
"_last_operation",
|
||||||
|
"_recovery_task",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
reconnect_coordinator: ReconnectCoordinatorProtocol,
|
||||||
|
recovery_coordinator: RuntimeRecoveryProtocol,
|
||||||
|
live_processing_gate: RuntimeLiveProcessingGateProtocol,
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
clock: RuntimeUnixTimeMillisecondsClock = system_unix_time_ms,
|
||||||
|
) -> None:
|
||||||
|
if not callable(clock):
|
||||||
|
raise TypeError("clock must be callable")
|
||||||
|
|
||||||
|
self._reconnect_coordinator = reconnect_coordinator
|
||||||
|
self._recovery_coordinator = recovery_coordinator
|
||||||
|
self._live_processing_gate = live_processing_gate
|
||||||
|
self._symbols = self._normalize_symbols(symbols)
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
self._coordination_lock = asyncio.Lock()
|
||||||
|
self._generation = 0
|
||||||
|
self._active_operation: _ReconnectRecoveryOperation | None = None
|
||||||
|
self._last_operation: _ReconnectRecoveryOperation | None = None
|
||||||
|
self._recovery_task: (
|
||||||
|
asyncio.Task[tuple[TradeRecoveryResult, ...]] | None
|
||||||
|
) = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> ReconnectState:
|
||||||
|
"""Вернуть состояние базовой reconnect-операции."""
|
||||||
|
return self._reconnect_coordinator.state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempt(self) -> int:
|
||||||
|
"""Вернуть номер последней базовой reconnect-попытки."""
|
||||||
|
return self._reconnect_coordinator.attempt
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generation(self) -> int:
|
||||||
|
"""Вернуть поколение последней начатой операции."""
|
||||||
|
return self._generation
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live_processing_gate(
|
||||||
|
self,
|
||||||
|
) -> RuntimeLiveProcessingGateProtocol:
|
||||||
|
"""Вернуть общий gate Live processing и Recovery."""
|
||||||
|
return self._live_processing_gate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbols(self) -> tuple[str, ...]:
|
||||||
|
"""Вернуть нормализованные Recovery symbols."""
|
||||||
|
return self._symbols
|
||||||
|
|
||||||
|
async def reconnect(self) -> None:
|
||||||
|
"""
|
||||||
|
Выполнить либо присоединиться к текущей single-flight операции.
|
||||||
|
"""
|
||||||
|
await self._run_or_join(
|
||||||
|
observed_generation=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reconnect_after_transport_failure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observed_generation: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Обработать transport error конкретного connection generation.
|
||||||
|
|
||||||
|
Ошибка старого connection присоединяется к уже выполняемой
|
||||||
|
операции либо использует её завершённый результат и не запускает
|
||||||
|
второй reconnect.
|
||||||
|
"""
|
||||||
|
self._validate_generation(observed_generation)
|
||||||
|
|
||||||
|
await self._run_or_join(
|
||||||
|
observed_generation=observed_generation,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_or_join(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observed_generation: int | None,
|
||||||
|
) -> None:
|
||||||
|
operation, is_leader = await self._select_operation(
|
||||||
|
observed_generation=observed_generation,
|
||||||
|
)
|
||||||
|
|
||||||
|
if operation is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not is_leader:
|
||||||
|
await asyncio.shield(
|
||||||
|
operation.completion,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._execute_operation()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
operation.completion.cancel()
|
||||||
|
raise
|
||||||
|
except BaseException as error:
|
||||||
|
operation.completion.set_exception(error)
|
||||||
|
operation.completion.exception()
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
operation.completion.set_result(None)
|
||||||
|
finally:
|
||||||
|
if self._active_operation is operation:
|
||||||
|
self._last_operation = operation
|
||||||
|
self._active_operation = None
|
||||||
|
|
||||||
|
async def _select_operation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observed_generation: int | None,
|
||||||
|
) -> tuple[_ReconnectRecoveryOperation | None, bool]:
|
||||||
|
async with self._coordination_lock:
|
||||||
|
active_operation = self._active_operation
|
||||||
|
|
||||||
|
if active_operation is not None:
|
||||||
|
return (
|
||||||
|
active_operation,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
observed_generation is not None
|
||||||
|
and observed_generation != self._generation
|
||||||
|
):
|
||||||
|
last_operation = self._last_operation
|
||||||
|
|
||||||
|
if (
|
||||||
|
last_operation is not None
|
||||||
|
and last_operation.source_generation
|
||||||
|
== observed_generation
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
last_operation,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
source_generation = self._generation
|
||||||
|
self._generation += 1
|
||||||
|
|
||||||
|
operation = _ReconnectRecoveryOperation(
|
||||||
|
source_generation=source_generation,
|
||||||
|
completion=asyncio.get_running_loop().create_future(),
|
||||||
|
)
|
||||||
|
self._active_operation = operation
|
||||||
|
|
||||||
|
return (
|
||||||
|
operation,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_operation(self) -> None:
|
||||||
|
async with self._live_processing_gate:
|
||||||
|
try:
|
||||||
|
await self._reconnect_coordinator.reconnect()
|
||||||
|
|
||||||
|
recovery_end_time = self._clock()
|
||||||
|
self._validate_recovery_end_time(
|
||||||
|
recovery_end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
recovery_task = asyncio.create_task(
|
||||||
|
asyncio.to_thread(
|
||||||
|
self._recover_symbols,
|
||||||
|
recovery_end_time,
|
||||||
|
),
|
||||||
|
name="trade-stream-runtime-recovery",
|
||||||
|
)
|
||||||
|
self._recovery_task = recovery_task
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.shield(
|
||||||
|
recovery_task,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError as cancellation:
|
||||||
|
try:
|
||||||
|
await self._wait_for_recovery_completion(
|
||||||
|
recovery_task,
|
||||||
|
)
|
||||||
|
except BaseException as recovery_error:
|
||||||
|
cancellation.add_note(
|
||||||
|
"Trade Stream Runtime Recovery also failed: "
|
||||||
|
f"{type(recovery_error).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if self._recovery_task is recovery_task:
|
||||||
|
self._recovery_task = None
|
||||||
|
except Exception as error:
|
||||||
|
self._live_processing_gate.fail(error)
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _wait_for_recovery_completion(
|
||||||
|
recovery_task: asyncio.Task[
|
||||||
|
tuple[TradeRecoveryResult, ...]
|
||||||
|
],
|
||||||
|
) -> None:
|
||||||
|
while not recovery_task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.shield(
|
||||||
|
recovery_task,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
continue
|
||||||
|
except BaseException:
|
||||||
|
break
|
||||||
|
|
||||||
|
recovery_task.result()
|
||||||
|
|
||||||
|
def _recover_symbols(
|
||||||
|
self,
|
||||||
|
recovery_end_time: int,
|
||||||
|
) -> tuple[TradeRecoveryResult, ...]:
|
||||||
|
return tuple(
|
||||||
|
self._recovery_coordinator.recover(
|
||||||
|
symbol=symbol,
|
||||||
|
recovery_end_time=recovery_end_time,
|
||||||
|
)
|
||||||
|
for symbol in self._symbols
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_symbols(
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if not isinstance(symbols, tuple):
|
||||||
|
raise TypeError("symbols must be a tuple")
|
||||||
|
|
||||||
|
normalized_symbols: set[str] = set()
|
||||||
|
|
||||||
|
for symbol in symbols:
|
||||||
|
if not isinstance(symbol, str):
|
||||||
|
raise TypeError(
|
||||||
|
"symbols must contain only strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_symbol = normalize_symbol(symbol)
|
||||||
|
|
||||||
|
if normalized_symbol:
|
||||||
|
normalized_symbols.add(normalized_symbol)
|
||||||
|
|
||||||
|
if not normalized_symbols:
|
||||||
|
raise ValueError(
|
||||||
|
"symbols must contain at least one non-empty symbol"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(sorted(normalized_symbols))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_generation(
|
||||||
|
generation: int,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(generation, bool) or not isinstance(
|
||||||
|
generation,
|
||||||
|
int,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"observed_generation must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if generation < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"observed_generation must not be negative"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_recovery_end_time(
|
||||||
|
recovery_end_time: int,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(recovery_end_time, bool) or not isinstance(
|
||||||
|
recovery_end_time,
|
||||||
|
int,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"clock must return an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if recovery_end_time < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"clock must not return a negative value"
|
||||||
|
)
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/
|
||||||
|
# runtime_recovery_coordinator.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
||||||
|
TradeStreamStateNotFoundError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
||||||
|
TradeStreamStateStoreProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_protocol import (
|
||||||
|
TradeRecoveryProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_request import (
|
||||||
|
TradeRecoveryRequest,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||||
|
TradeRecoveryResult,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_window_planner import (
|
||||||
|
TradeRecoveryWindowPlanner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeRecoveryCoordinator:
|
||||||
|
"""
|
||||||
|
Runtime-координатор восстановления Trade Stream.
|
||||||
|
|
||||||
|
Coordinator связывает существующие компоненты:
|
||||||
|
|
||||||
|
- TradeStreamStateStoreProtocol;
|
||||||
|
- checkpoint в TradeStreamState.last_trade;
|
||||||
|
- TradeRecoveryWindowPlanner;
|
||||||
|
- TradeRecoveryProtocol.
|
||||||
|
|
||||||
|
Последовательность восстановления:
|
||||||
|
|
||||||
|
state_store.get(symbol)
|
||||||
|
↓
|
||||||
|
TradeStreamState.last_trade
|
||||||
|
↓
|
||||||
|
executed_at → Unix milliseconds
|
||||||
|
↓
|
||||||
|
TradeRecoveryWindowPlanner.build_windows()
|
||||||
|
↓
|
||||||
|
TradeRecoveryRequest для каждого окна
|
||||||
|
↓
|
||||||
|
TradeRecoveryProtocol.recover()
|
||||||
|
↓
|
||||||
|
объединённый TradeRecoveryResult
|
||||||
|
|
||||||
|
Coordinator не выполняет:
|
||||||
|
|
||||||
|
- WebSocket connect или disconnect;
|
||||||
|
- reconnect;
|
||||||
|
- восстановление подписок;
|
||||||
|
- heartbeat monitoring;
|
||||||
|
- scheduling;
|
||||||
|
- retry или backoff;
|
||||||
|
- публикацию Runtime Events;
|
||||||
|
- создание нового TradeStreamState;
|
||||||
|
- обработку и проверку согласованности Trade.
|
||||||
|
|
||||||
|
Отсутствие состояния или последней принятой сделки является
|
||||||
|
штатной ситуацией: восстановление пропускается и возвращается
|
||||||
|
пустой TradeRecoveryResult.
|
||||||
|
|
||||||
|
Все остальные ошибки State Store, Window Planner и Trade Recovery
|
||||||
|
распространяются вызывающему коду без обёртки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_state_store",
|
||||||
|
"_window_planner",
|
||||||
|
"_recovery_controller",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state_store: TradeStreamStateStoreProtocol,
|
||||||
|
window_planner: TradeRecoveryWindowPlanner,
|
||||||
|
recovery_controller: TradeRecoveryProtocol,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Создать Runtime Recovery Coordinator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state_store:
|
||||||
|
Общее хранилище состояния Trade Stream Consistency.
|
||||||
|
|
||||||
|
Должен передаваться тот же экземпляр хранилища,
|
||||||
|
который используется основным WebSocket-потоком.
|
||||||
|
|
||||||
|
window_planner:
|
||||||
|
Планировщик допустимых временных окон восстановления.
|
||||||
|
|
||||||
|
recovery_controller:
|
||||||
|
Существующий stateless-контроллер Trade Recovery.
|
||||||
|
"""
|
||||||
|
self._state_store = state_store
|
||||||
|
self._window_planner = window_planner
|
||||||
|
self._recovery_controller = recovery_controller
|
||||||
|
|
||||||
|
def recover(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
recovery_end_time: int,
|
||||||
|
) -> TradeRecoveryResult:
|
||||||
|
"""
|
||||||
|
Восстановить пропущенные сделки одного инструмента.
|
||||||
|
|
||||||
|
Начальная граница определяется по времени последней сделки,
|
||||||
|
принятой общим Trade Stream Consistency Layer.
|
||||||
|
|
||||||
|
Конечная граница передаётся вызывающим Runtime-компонентом.
|
||||||
|
Coordinator не получает системное время самостоятельно.
|
||||||
|
|
||||||
|
Возможный повтор последней принятой сделки на левой границе
|
||||||
|
безопасно устраняется существующим Consistency Layer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Символ торгового инструмента.
|
||||||
|
|
||||||
|
recovery_end_time:
|
||||||
|
Конечная граница восстановления в миллисекундах
|
||||||
|
Unix time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Объединённый TradeRecoveryResult для полного диапазона.
|
||||||
|
|
||||||
|
Если состояние или checkpoint отсутствуют, возвращается
|
||||||
|
пустой результат с границами, равными recovery_end_time.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError:
|
||||||
|
Если symbol или recovery_end_time имеют неверный тип.
|
||||||
|
|
||||||
|
ValueError:
|
||||||
|
Если symbol пустой, recovery_end_time отрицательный,
|
||||||
|
checkpoint не содержит timezone-aware datetime либо
|
||||||
|
конечная граница расположена раньше checkpoint.
|
||||||
|
|
||||||
|
Exception:
|
||||||
|
Ошибки Window Planner и Trade Recovery Controller
|
||||||
|
распространяются без изменения.
|
||||||
|
"""
|
||||||
|
self._validate_input(
|
||||||
|
symbol=symbol,
|
||||||
|
recovery_end_time=recovery_end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = self._state_store.get(symbol)
|
||||||
|
except TradeStreamStateNotFoundError:
|
||||||
|
return self._build_empty_result(
|
||||||
|
symbol=symbol,
|
||||||
|
boundary_time=recovery_end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoint_trade = state.last_trade
|
||||||
|
|
||||||
|
if checkpoint_trade is None:
|
||||||
|
return self._build_empty_result(
|
||||||
|
symbol=symbol,
|
||||||
|
boundary_time=recovery_end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
recovery_start_time = self._datetime_to_unix_ms(
|
||||||
|
checkpoint_trade.executed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
windows = self._window_planner.build_windows(
|
||||||
|
symbol=symbol,
|
||||||
|
start_time=recovery_start_time,
|
||||||
|
end_time=recovery_end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
recovered_trades: list[Trade] = []
|
||||||
|
|
||||||
|
for window in windows:
|
||||||
|
window_result = self._recovery_controller.recover(
|
||||||
|
TradeRecoveryRequest(
|
||||||
|
symbol=window.symbol,
|
||||||
|
start_time=window.start_time,
|
||||||
|
end_time=window.end_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recovered_trades.extend(
|
||||||
|
window_result.recovered_trades,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TradeRecoveryResult(
|
||||||
|
symbol=symbol,
|
||||||
|
requested_start_time=recovery_start_time,
|
||||||
|
requested_end_time=recovery_end_time,
|
||||||
|
recovered_trades=tuple(recovered_trades),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_input(
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
recovery_end_time: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Проверить входные параметры до обращения к State Store.
|
||||||
|
"""
|
||||||
|
if not isinstance(symbol, str):
|
||||||
|
raise TypeError(
|
||||||
|
"symbol должен иметь тип str."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not symbol.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"symbol не должен быть пустым."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(recovery_end_time, bool) or not isinstance(
|
||||||
|
recovery_end_time,
|
||||||
|
int,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"recovery_end_time должен иметь тип int."
|
||||||
|
)
|
||||||
|
|
||||||
|
if recovery_end_time < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"recovery_end_time не должен быть отрицательным."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _datetime_to_unix_ms(
|
||||||
|
value: datetime,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Преобразовать timezone-aware datetime в Unix milliseconds.
|
||||||
|
|
||||||
|
Расчёт выполняется относительно UTC epoch без использования
|
||||||
|
float, чтобы не вносить погрешность преобразования timestamp.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, datetime):
|
||||||
|
raise TypeError(
|
||||||
|
"checkpoint executed_at должен иметь тип datetime."
|
||||||
|
)
|
||||||
|
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise ValueError(
|
||||||
|
"checkpoint executed_at должен содержать timezone."
|
||||||
|
)
|
||||||
|
|
||||||
|
utc_value = value.astimezone(
|
||||||
|
timezone.utc,
|
||||||
|
)
|
||||||
|
|
||||||
|
epoch = datetime(
|
||||||
|
1970,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
|
||||||
|
delta = utc_value - epoch
|
||||||
|
|
||||||
|
return (
|
||||||
|
delta.days * 86_400_000
|
||||||
|
+ delta.seconds * 1_000
|
||||||
|
+ delta.microseconds // 1_000
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_empty_result(
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
boundary_time: int,
|
||||||
|
) -> TradeRecoveryResult:
|
||||||
|
"""
|
||||||
|
Создать пустой результат при отсутствии Recovery Checkpoint.
|
||||||
|
|
||||||
|
Равные границы явно обозначают отсутствие диапазона,
|
||||||
|
который можно восстановить на основе существующего состояния.
|
||||||
|
"""
|
||||||
|
return TradeRecoveryResult(
|
||||||
|
symbol=symbol,
|
||||||
|
requested_start_time=boundary_time,
|
||||||
|
requested_end_time=boundary_time,
|
||||||
|
recovered_trades=(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/runtime_recovery_protocol.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Публичный контракт Runtime Recovery Coordinator.
|
||||||
|
|
||||||
|
Runtime Recovery Coordinator связывает Runtime Layer с существующим
|
||||||
|
Trade Recovery Layer, но не выполняет reconnect, WebSocket-операции,
|
||||||
|
heartbeat, scheduling или восстановление подписок.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||||
|
TradeRecoveryResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeRecoveryProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт Runtime-координатора восстановления Trade Stream.
|
||||||
|
|
||||||
|
Реализация должна:
|
||||||
|
|
||||||
|
- получить существующий checkpoint торгового инструмента;
|
||||||
|
- определить полный временной диапазон восстановления;
|
||||||
|
- разбить диапазон на допустимые Recovery Window;
|
||||||
|
- последовательно выполнить Trade Recovery для каждого окна;
|
||||||
|
- вернуть объединённый TradeRecoveryResult.
|
||||||
|
|
||||||
|
Реализация не должна:
|
||||||
|
|
||||||
|
- создавать новое состояние Trade Stream;
|
||||||
|
- выполнять WebSocket connect или disconnect;
|
||||||
|
- восстанавливать WebSocket-подписки;
|
||||||
|
- управлять Heartbeat Monitor;
|
||||||
|
- управлять Runtime Supervisor;
|
||||||
|
- запускать Scheduler;
|
||||||
|
- выполнять retry или backoff;
|
||||||
|
- скрывать ошибки нижележащих компонентов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def recover(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
recovery_end_time: int,
|
||||||
|
) -> TradeRecoveryResult:
|
||||||
|
"""
|
||||||
|
Восстановить пропущенные сделки одного торгового инструмента.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol:
|
||||||
|
Символ торгового инструмента.
|
||||||
|
|
||||||
|
recovery_end_time:
|
||||||
|
Правая граница восстанавливаемого диапазона
|
||||||
|
в миллисекундах Unix time.
|
||||||
|
|
||||||
|
Значение определяется вызывающим Runtime-компонентом.
|
||||||
|
Coordinator самостоятельно текущее время не вычисляет.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Объединённый результат восстановления всего диапазона.
|
||||||
|
|
||||||
|
Если существующее состояние или checkpoint отсутствуют,
|
||||||
|
реализация возвращает пустой TradeRecoveryResult и не создаёт
|
||||||
|
новое состояние Trade Stream.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Protocol, TypeVar, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.checkpoint.trade_stream_state_hydrator import (
|
||||||
|
TradeStreamStateHydratorProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.consistency.trade_stream_state import (
|
||||||
|
TradeStreamState,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.recovery.trade_recovery_result import (
|
||||||
|
TradeRecoveryResult,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.live_processing_gate import (
|
||||||
|
RuntimeLiveProcessingGateProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.runtime_reconnect_recovery_coordinator import (
|
||||||
|
RuntimeUnixTimeMillisecondsClock,
|
||||||
|
system_unix_time_ms,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.runtime_recovery_protocol import (
|
||||||
|
RuntimeRecoveryProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.symbols import normalize_symbol
|
||||||
|
|
||||||
|
|
||||||
|
_WorkerResult = TypeVar("_WorkerResult")
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeStartupRecoveryProtocol(Protocol):
|
||||||
|
"""Контракт восстановления состояния при запуске Trade Stream."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live_processing_gate(
|
||||||
|
self,
|
||||||
|
) -> RuntimeLiveProcessingGateProtocol:
|
||||||
|
"""Вернуть общий gate Startup Recovery и Live processing."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbols(self) -> tuple[str, ...]:
|
||||||
|
"""Вернуть нормализованные символы Startup Recovery."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def hydrate_once(
|
||||||
|
self,
|
||||||
|
) -> tuple[TradeStreamState, ...]:
|
||||||
|
"""Один раз восстановить Consistency state из хранилища."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def recover_after_ack(
|
||||||
|
self,
|
||||||
|
) -> tuple[TradeRecoveryResult, ...]:
|
||||||
|
"""Выполнить REST Recovery после подтверждения подписки."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeStartupRecoveryCoordinator:
|
||||||
|
"""
|
||||||
|
Координирует blocking-операции Startup Hydration и Recovery.
|
||||||
|
|
||||||
|
Владельцем общей границы subscribe → ACK → Recovery → buffered Live
|
||||||
|
остаётся Production Runtime. Координатор хранит тот же gate для
|
||||||
|
проверки единства dependency graph, но самостоятельно его не
|
||||||
|
захватывает.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_state_hydrator",
|
||||||
|
"_recovery_coordinator",
|
||||||
|
"_live_processing_gate",
|
||||||
|
"_symbols",
|
||||||
|
"_clock",
|
||||||
|
"_hydration_lock",
|
||||||
|
"_hydration_completed",
|
||||||
|
"_hydrated_states",
|
||||||
|
"_hydration_task",
|
||||||
|
"_recovery_task",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state_hydrator: TradeStreamStateHydratorProtocol,
|
||||||
|
recovery_coordinator: RuntimeRecoveryProtocol,
|
||||||
|
live_processing_gate: RuntimeLiveProcessingGateProtocol,
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
clock: RuntimeUnixTimeMillisecondsClock = system_unix_time_ms,
|
||||||
|
) -> None:
|
||||||
|
if not callable(clock):
|
||||||
|
raise TypeError("clock must be callable")
|
||||||
|
|
||||||
|
self._state_hydrator = state_hydrator
|
||||||
|
self._recovery_coordinator = recovery_coordinator
|
||||||
|
self._live_processing_gate = live_processing_gate
|
||||||
|
self._symbols = self._normalize_symbols(symbols)
|
||||||
|
self._clock = clock
|
||||||
|
self._hydration_lock = asyncio.Lock()
|
||||||
|
self._hydration_completed = False
|
||||||
|
self._hydrated_states: tuple[TradeStreamState, ...] = ()
|
||||||
|
self._hydration_task: (
|
||||||
|
asyncio.Task[tuple[TradeStreamState, ...]] | None
|
||||||
|
) = None
|
||||||
|
self._recovery_task: (
|
||||||
|
asyncio.Task[tuple[TradeRecoveryResult, ...]] | None
|
||||||
|
) = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live_processing_gate(
|
||||||
|
self,
|
||||||
|
) -> RuntimeLiveProcessingGateProtocol:
|
||||||
|
"""Вернуть общий gate Startup Recovery и Live processing."""
|
||||||
|
return self._live_processing_gate
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbols(self) -> tuple[str, ...]:
|
||||||
|
"""Вернуть нормализованные символы Startup Recovery."""
|
||||||
|
return self._symbols
|
||||||
|
|
||||||
|
async def hydrate_once(
|
||||||
|
self,
|
||||||
|
) -> tuple[TradeStreamState, ...]:
|
||||||
|
"""
|
||||||
|
Восстановить Consistency state не более одного успешного раза.
|
||||||
|
|
||||||
|
При отмене вызывающего coroutine уже запущенный blocking worker
|
||||||
|
завершается до возврата управления. Если worker успел успешно
|
||||||
|
восстановить state, результат сохраняется для следующего вызова.
|
||||||
|
"""
|
||||||
|
if self._hydration_completed:
|
||||||
|
return self._hydrated_states
|
||||||
|
|
||||||
|
async with self._hydration_lock:
|
||||||
|
if self._hydration_completed:
|
||||||
|
return self._hydrated_states
|
||||||
|
|
||||||
|
hydration_task = asyncio.create_task(
|
||||||
|
asyncio.to_thread(
|
||||||
|
self._hydrate_symbols,
|
||||||
|
),
|
||||||
|
name="trade-stream-state-hydration",
|
||||||
|
)
|
||||||
|
self._hydration_task = hydration_task
|
||||||
|
|
||||||
|
try:
|
||||||
|
states = await asyncio.shield(
|
||||||
|
hydration_task,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError as cancellation:
|
||||||
|
try:
|
||||||
|
states = await self._wait_for_worker_completion(
|
||||||
|
hydration_task,
|
||||||
|
)
|
||||||
|
except BaseException as hydration_error:
|
||||||
|
cancellation.add_note(
|
||||||
|
"Trade Stream state hydration also failed: "
|
||||||
|
f"{type(hydration_error).__name__}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._store_hydration_result(states)
|
||||||
|
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self._store_hydration_result(states)
|
||||||
|
return states
|
||||||
|
finally:
|
||||||
|
if self._hydration_task is hydration_task:
|
||||||
|
self._hydration_task = None
|
||||||
|
|
||||||
|
async def recover_after_ack(
|
||||||
|
self,
|
||||||
|
) -> tuple[TradeRecoveryResult, ...]:
|
||||||
|
"""
|
||||||
|
Последовательно восстановить symbols по одной временной границе.
|
||||||
|
|
||||||
|
Вызывающий Production Runtime обязан удерживать общий live gate
|
||||||
|
от отправки подписки до завершения buffered Live processing.
|
||||||
|
"""
|
||||||
|
recovery_end_time = self._clock()
|
||||||
|
self._validate_recovery_end_time(recovery_end_time)
|
||||||
|
|
||||||
|
recovery_task = asyncio.create_task(
|
||||||
|
asyncio.to_thread(
|
||||||
|
self._recover_symbols,
|
||||||
|
recovery_end_time,
|
||||||
|
),
|
||||||
|
name="trade-stream-startup-recovery",
|
||||||
|
)
|
||||||
|
self._recovery_task = recovery_task
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.shield(
|
||||||
|
recovery_task,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError as cancellation:
|
||||||
|
try:
|
||||||
|
await self._wait_for_worker_completion(
|
||||||
|
recovery_task,
|
||||||
|
)
|
||||||
|
except BaseException as recovery_error:
|
||||||
|
cancellation.add_note(
|
||||||
|
"Trade Stream Startup Recovery also failed: "
|
||||||
|
f"{type(recovery_error).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if self._recovery_task is recovery_task:
|
||||||
|
self._recovery_task = None
|
||||||
|
|
||||||
|
def _hydrate_symbols(
|
||||||
|
self,
|
||||||
|
) -> tuple[TradeStreamState, ...]:
|
||||||
|
return self._state_hydrator.hydrate(
|
||||||
|
symbols=self._symbols,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _recover_symbols(
|
||||||
|
self,
|
||||||
|
recovery_end_time: int,
|
||||||
|
) -> tuple[TradeRecoveryResult, ...]:
|
||||||
|
return tuple(
|
||||||
|
self._recovery_coordinator.recover(
|
||||||
|
symbol=symbol,
|
||||||
|
recovery_end_time=recovery_end_time,
|
||||||
|
)
|
||||||
|
for symbol in self._symbols
|
||||||
|
)
|
||||||
|
|
||||||
|
def _store_hydration_result(
|
||||||
|
self,
|
||||||
|
states: tuple[TradeStreamState, ...],
|
||||||
|
) -> None:
|
||||||
|
self._hydrated_states = states
|
||||||
|
self._hydration_completed = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _wait_for_worker_completion(
|
||||||
|
worker_task: asyncio.Task[_WorkerResult],
|
||||||
|
) -> _WorkerResult:
|
||||||
|
while not worker_task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.shield(
|
||||||
|
worker_task,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
continue
|
||||||
|
except BaseException:
|
||||||
|
break
|
||||||
|
|
||||||
|
return worker_task.result()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_symbols(
|
||||||
|
symbols: tuple[str, ...],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if not isinstance(symbols, tuple):
|
||||||
|
raise TypeError("symbols must be a tuple")
|
||||||
|
|
||||||
|
normalized_symbols: set[str] = set()
|
||||||
|
|
||||||
|
for symbol in symbols:
|
||||||
|
if not isinstance(symbol, str):
|
||||||
|
raise TypeError(
|
||||||
|
"symbols must contain only strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_symbol = normalize_symbol(symbol)
|
||||||
|
|
||||||
|
if normalized_symbol:
|
||||||
|
normalized_symbols.add(normalized_symbol)
|
||||||
|
|
||||||
|
if not normalized_symbols:
|
||||||
|
raise ValueError(
|
||||||
|
"symbols must contain at least one non-empty symbol"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(sorted(normalized_symbols))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_recovery_end_time(
|
||||||
|
recovery_end_time: int,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(recovery_end_time, bool) or not isinstance(
|
||||||
|
recovery_end_time,
|
||||||
|
int,
|
||||||
|
):
|
||||||
|
raise TypeError("clock must return an integer")
|
||||||
|
|
||||||
|
if recovery_end_time < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"clock must not return a negative value"
|
||||||
|
)
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/scheduler.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.heartbeat import (
|
||||||
|
HeartbeatMonitorProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.runtime_liveness_probe import (
|
||||||
|
RuntimeLivenessProbeProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.supervisor import (
|
||||||
|
RuntimeSupervisorProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RuntimeSleep = Callable[[float], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeSchedulerProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт периодического планировщика Acquisition Runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
"""
|
||||||
|
Вернуть признак активного scheduler loop.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interval_seconds(self) -> float:
|
||||||
|
"""
|
||||||
|
Вернуть интервал между периодическими проверками.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def liveness_probe(self) -> RuntimeLivenessProbeProtocol:
|
||||||
|
"""Вернуть используемый transport liveness probe."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime_supervisor(self) -> RuntimeSupervisorProtocol:
|
||||||
|
"""Вернуть Supervisor, получающий timeout."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def claim(self, owner: object) -> None:
|
||||||
|
"""Закрепить Scheduler за единственным lifecycle owner."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def release(self, owner: object) -> None:
|
||||||
|
"""Освободить Scheduler после завершения owned task."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: object | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Запустить scheduler loop.
|
||||||
|
|
||||||
|
Метод выполняется до вызова stop() либо возникновения ошибки.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""
|
||||||
|
Запросить остановку scheduler loop.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def run_once(self) -> bool:
|
||||||
|
"""
|
||||||
|
Выполнить одну проверку Heartbeat.
|
||||||
|
|
||||||
|
Вернуть True, если был обнаружен timeout.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeScheduler:
|
||||||
|
"""
|
||||||
|
Периодический планировщик Acquisition Runtime.
|
||||||
|
|
||||||
|
Scheduler отвечает только за время выполнения проверок:
|
||||||
|
|
||||||
|
RuntimeLivenessProbe.probe()
|
||||||
|
│
|
||||||
|
├── True
|
||||||
|
│ └── RuntimeSupervisor.notify_activity()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
HeartbeatMonitor.check_timeout()
|
||||||
|
│
|
||||||
|
├── False
|
||||||
|
│ └── ожидание следующего интервала
|
||||||
|
│
|
||||||
|
└── True
|
||||||
|
└── RuntimeSupervisor
|
||||||
|
.handle_heartbeat_timeout()
|
||||||
|
|
||||||
|
Scheduler не выполняет:
|
||||||
|
|
||||||
|
- reconnect;
|
||||||
|
- Heartbeat calculations;
|
||||||
|
- управление WebSocket;
|
||||||
|
- Trade Recovery;
|
||||||
|
- backoff;
|
||||||
|
- retry policy;
|
||||||
|
- публикацию Runtime Events.
|
||||||
|
|
||||||
|
Ошибки liveness probe, Heartbeat Monitor, Runtime Supervisor и
|
||||||
|
sleep-функции распространяются вызывающему коду без обёртки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_liveness_probe",
|
||||||
|
"_heartbeat_monitor",
|
||||||
|
"_runtime_supervisor",
|
||||||
|
"_interval_seconds",
|
||||||
|
"_sleep",
|
||||||
|
"_running",
|
||||||
|
"_owner",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
liveness_probe: RuntimeLivenessProbeProtocol,
|
||||||
|
heartbeat_monitor: HeartbeatMonitorProtocol,
|
||||||
|
runtime_supervisor: RuntimeSupervisorProtocol,
|
||||||
|
*,
|
||||||
|
interval_seconds: float,
|
||||||
|
sleep: RuntimeSleep = asyncio.sleep,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(interval_seconds, (int, float)) or isinstance(
|
||||||
|
interval_seconds,
|
||||||
|
bool,
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"interval_seconds must be an integer or float"
|
||||||
|
)
|
||||||
|
|
||||||
|
if interval_seconds <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"interval_seconds must be positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not callable(sleep):
|
||||||
|
raise TypeError("sleep must be callable")
|
||||||
|
|
||||||
|
self._liveness_probe = liveness_probe
|
||||||
|
self._heartbeat_monitor = heartbeat_monitor
|
||||||
|
self._runtime_supervisor = runtime_supervisor
|
||||||
|
self._interval_seconds = float(interval_seconds)
|
||||||
|
self._sleep = sleep
|
||||||
|
self._running = False
|
||||||
|
self._owner: object | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
"""
|
||||||
|
Вернуть признак активного scheduler loop.
|
||||||
|
"""
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interval_seconds(self) -> float:
|
||||||
|
"""
|
||||||
|
Вернуть интервал между проверками.
|
||||||
|
"""
|
||||||
|
return self._interval_seconds
|
||||||
|
|
||||||
|
@property
|
||||||
|
def liveness_probe(self) -> RuntimeLivenessProbeProtocol:
|
||||||
|
"""Вернуть используемый transport liveness probe."""
|
||||||
|
return self._liveness_probe
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime_supervisor(self) -> RuntimeSupervisorProtocol:
|
||||||
|
"""Вернуть Supervisor, получающий timeout."""
|
||||||
|
return self._runtime_supervisor
|
||||||
|
|
||||||
|
def claim(self, owner: object) -> None:
|
||||||
|
"""
|
||||||
|
Атомарно закрепить Scheduler за lifecycle owner.
|
||||||
|
|
||||||
|
После claim только этот owner может запустить loop. Claim не
|
||||||
|
создаёт asyncio-задачу и безопасен до длительного startup.
|
||||||
|
"""
|
||||||
|
if owner is None:
|
||||||
|
raise TypeError("owner must not be None")
|
||||||
|
|
||||||
|
if self._owner is owner:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._owner is not None or self._running:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Runtime Scheduler is already owned or active."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._owner = owner
|
||||||
|
|
||||||
|
def release(self, owner: object) -> None:
|
||||||
|
"""
|
||||||
|
Освободить Scheduler после остановки owned loop.
|
||||||
|
"""
|
||||||
|
if self._owner is not owner:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Runtime Scheduler ownership does not match."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._running:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot release a running Runtime Scheduler."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._owner = None
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: object | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Запустить периодический scheduler loop.
|
||||||
|
|
||||||
|
Если Scheduler уже запущен, повторный вызов немедленно
|
||||||
|
завершается и не создаёт второй параллельный цикл.
|
||||||
|
|
||||||
|
Scheduler всегда сбрасывает running в False:
|
||||||
|
|
||||||
|
- после stop();
|
||||||
|
- после ошибки;
|
||||||
|
- после отмены внешней asyncio-задачи.
|
||||||
|
"""
|
||||||
|
if self._owner is not None and self._owner is not owner:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Runtime Scheduler is owned by another lifecycle."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._owner is None and owner is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Runtime Scheduler owner must claim it before start."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
await self.run_once()
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
await self._sleep(
|
||||||
|
self._interval_seconds,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""
|
||||||
|
Запросить завершение scheduler loop.
|
||||||
|
|
||||||
|
Метод идемпотентен. Он не отменяет внешнюю asyncio-задачу,
|
||||||
|
а останавливает цикл на ближайшей управляемой границе.
|
||||||
|
"""
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def run_once(self) -> bool:
|
||||||
|
"""
|
||||||
|
Выполнить одну liveness- и Heartbeat-проверку.
|
||||||
|
|
||||||
|
Успешный transport probe обновляет время активности через
|
||||||
|
Supervisor. Если Heartbeat Monitor подтверждает timeout,
|
||||||
|
Scheduler передаёт его Runtime Supervisor.
|
||||||
|
|
||||||
|
Возвращаемое значение отражает только результат проверки
|
||||||
|
Heartbeat Monitor.
|
||||||
|
"""
|
||||||
|
connection_is_live = await self._liveness_probe.probe()
|
||||||
|
|
||||||
|
if not isinstance(connection_is_live, bool):
|
||||||
|
raise TypeError(
|
||||||
|
"liveness probe must return a boolean"
|
||||||
|
)
|
||||||
|
|
||||||
|
if connection_is_live:
|
||||||
|
self._runtime_supervisor.notify_activity()
|
||||||
|
|
||||||
|
timed_out = await self._heartbeat_monitor.check_timeout()
|
||||||
|
|
||||||
|
if timed_out:
|
||||||
|
await self._runtime_supervisor.handle_heartbeat_timeout()
|
||||||
|
|
||||||
|
return timed_out
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/supervisor.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.heartbeat import (
|
||||||
|
HeartbeatMonitorProtocol,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.reconnect import (
|
||||||
|
ReconnectCoordinatorProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSupervisorState(str, Enum):
|
||||||
|
"""
|
||||||
|
Текущее состояние Runtime Supervisor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STOPPED = "stopped"
|
||||||
|
RUNNING = "running"
|
||||||
|
RECONNECTING = "reconnecting"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSupervisorReconnectProtocol(
|
||||||
|
ReconnectCoordinatorProtocol,
|
||||||
|
Protocol,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generation-aware reconnect-контракт Runtime Supervisor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generation(self) -> int:
|
||||||
|
"""Вернуть текущее connection generation."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def reconnect_after_transport_failure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
observed_generation: int,
|
||||||
|
) -> None:
|
||||||
|
"""Восстановить только наблюдавшееся поколение connection."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeSupervisorProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт координатора lifecycle Acquisition Runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> RuntimeSupervisorState:
|
||||||
|
"""
|
||||||
|
Вернуть текущее состояние Runtime Supervisor.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""
|
||||||
|
Запустить supervision и Heartbeat Monitor.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""
|
||||||
|
Остановить supervision и Heartbeat Monitor.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def notify_activity(self) -> None:
|
||||||
|
"""
|
||||||
|
Передать Heartbeat Monitor информацию о Runtime activity.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def handle_heartbeat_timeout(self) -> bool:
|
||||||
|
"""
|
||||||
|
Обработать подтверждённый Heartbeat timeout.
|
||||||
|
|
||||||
|
Вернуть True, если была выполнена попытка reconnect.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeSupervisor:
|
||||||
|
"""
|
||||||
|
Координатор lifecycle Acquisition Runtime.
|
||||||
|
|
||||||
|
Supervisor объединяет:
|
||||||
|
|
||||||
|
- HeartbeatMonitor;
|
||||||
|
- ReconnectCoordinator;
|
||||||
|
- общее состояние Runtime Session.
|
||||||
|
|
||||||
|
Supervisor:
|
||||||
|
|
||||||
|
- запускает и останавливает Heartbeat Monitor;
|
||||||
|
- принимает уведомления о Runtime activity;
|
||||||
|
- запускает одну попытку reconnect после Heartbeat timeout;
|
||||||
|
- после успешного reconnect начинает новый период Heartbeat;
|
||||||
|
- фиксирует состояния STOPPED, RUNNING, RECONNECTING и FAILED;
|
||||||
|
- предотвращает параллельный reconnect.
|
||||||
|
|
||||||
|
Supervisor не выполняет:
|
||||||
|
|
||||||
|
- периодические проверки Heartbeat;
|
||||||
|
- retry loop;
|
||||||
|
- backoff;
|
||||||
|
- scheduling;
|
||||||
|
- Trade Recovery;
|
||||||
|
- управление WebSocket Transport;
|
||||||
|
- публикацию Runtime Events.
|
||||||
|
|
||||||
|
Периодический вызов Heartbeat и передача timeout в Supervisor
|
||||||
|
будут ответственностью Runtime Scheduler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_heartbeat_monitor",
|
||||||
|
"_reconnect_coordinator",
|
||||||
|
"_observed_generation",
|
||||||
|
"_state",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
heartbeat_monitor: HeartbeatMonitorProtocol,
|
||||||
|
reconnect_coordinator: RuntimeSupervisorReconnectProtocol,
|
||||||
|
) -> None:
|
||||||
|
self._heartbeat_monitor = heartbeat_monitor
|
||||||
|
self._reconnect_coordinator = reconnect_coordinator
|
||||||
|
self._observed_generation = reconnect_coordinator.generation
|
||||||
|
self._state = RuntimeSupervisorState.STOPPED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> RuntimeSupervisorState:
|
||||||
|
"""
|
||||||
|
Вернуть текущее состояние Runtime Supervisor.
|
||||||
|
"""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""
|
||||||
|
Запустить supervision.
|
||||||
|
|
||||||
|
Повторный вызов start() начинает новый Heartbeat monitoring
|
||||||
|
period и сохраняет Supervisor в состоянии RUNNING.
|
||||||
|
"""
|
||||||
|
self._heartbeat_monitor.start()
|
||||||
|
self._observed_generation = (
|
||||||
|
self._reconnect_coordinator.generation
|
||||||
|
)
|
||||||
|
self._state = RuntimeSupervisorState.RUNNING
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""
|
||||||
|
Остановить supervision.
|
||||||
|
|
||||||
|
Повторный вызов stop() безопасен и сохраняет состояние STOPPED.
|
||||||
|
"""
|
||||||
|
self._heartbeat_monitor.stop()
|
||||||
|
self._state = RuntimeSupervisorState.STOPPED
|
||||||
|
|
||||||
|
def notify_activity(self) -> None:
|
||||||
|
"""
|
||||||
|
Передать информацию о Runtime activity в Heartbeat Monitor.
|
||||||
|
|
||||||
|
Активность учитывается только во время RUNNING lifecycle.
|
||||||
|
События, полученные после stop() либо во время reconnect,
|
||||||
|
не должны неявно запускать Heartbeat Monitor.
|
||||||
|
"""
|
||||||
|
if self._state is not RuntimeSupervisorState.RUNNING:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._heartbeat_monitor.record_activity()
|
||||||
|
self._observed_generation = (
|
||||||
|
self._reconnect_coordinator.generation
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_heartbeat_timeout(self) -> bool:
|
||||||
|
"""
|
||||||
|
Обработать подтверждённый Heartbeat timeout.
|
||||||
|
|
||||||
|
Последовательность:
|
||||||
|
|
||||||
|
RUNNING
|
||||||
|
↓
|
||||||
|
stop Heartbeat
|
||||||
|
↓
|
||||||
|
RECONNECTING
|
||||||
|
↓
|
||||||
|
reconnect текущего connection generation
|
||||||
|
├── success:
|
||||||
|
│ start Heartbeat
|
||||||
|
│ RUNNING
|
||||||
|
│
|
||||||
|
└── failure:
|
||||||
|
FAILED
|
||||||
|
exception propagates
|
||||||
|
|
||||||
|
Если Supervisor не находится в состоянии RUNNING,
|
||||||
|
новая попытка reconnect не запускается.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool:
|
||||||
|
True, если reconnect был выполнен успешно.
|
||||||
|
False, если Supervisor не находился в RUNNING.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception:
|
||||||
|
Исходная ошибка ReconnectCoordinator распространяется
|
||||||
|
вызывающему компоненту без обёртки.
|
||||||
|
"""
|
||||||
|
if self._state is not RuntimeSupervisorState.RUNNING:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._heartbeat_monitor.stop()
|
||||||
|
self._state = RuntimeSupervisorState.RECONNECTING
|
||||||
|
observed_generation = self._observed_generation
|
||||||
|
|
||||||
|
try:
|
||||||
|
await (
|
||||||
|
self._reconnect_coordinator
|
||||||
|
.reconnect_after_transport_failure(
|
||||||
|
observed_generation=observed_generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._state = RuntimeSupervisorState.FAILED
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._heartbeat_monitor.start()
|
||||||
|
self._observed_generation = (
|
||||||
|
self._reconnect_coordinator.generation
|
||||||
|
)
|
||||||
|
self._state = RuntimeSupervisorState.RUNNING
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/transport_messages.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TransportTextMessage:
|
||||||
|
"""
|
||||||
|
Транспортное текстовое сообщение.
|
||||||
|
|
||||||
|
Представляет уже полученный или подготовленный для отправки
|
||||||
|
текстовый payload без какой-либо интерпретации содержимого.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TransportBinaryMessage:
|
||||||
|
"""
|
||||||
|
Транспортное бинарное сообщение.
|
||||||
|
|
||||||
|
Представляет уже полученный или подготовленный для отправки
|
||||||
|
бинарный payload без какой-либо интерпретации содержимого.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload: bytes
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketInboundMessageKind(str, Enum):
|
||||||
|
"""
|
||||||
|
Категория декодированного входящего WebSocket-сообщения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MARKET = "market"
|
||||||
|
CONTROL = "control"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WebSocketInboundMessageClassifierProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт классификации декодированного WebSocket-документа.
|
||||||
|
|
||||||
|
Конкретная реализация содержит provider-specific знания о форме
|
||||||
|
market и control messages. Production Runtime использует только
|
||||||
|
итоговую категорию и не интерпретирует exchange-specific поля.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def classify(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
) -> WebSocketInboundMessageKind:
|
||||||
|
"""Классифицировать один декодированный документ."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WebSocketControlMessageHandlerProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт provider-specific проверки входящего control/ACK.
|
||||||
|
|
||||||
|
Production Runtime передаёт ожидаемый correlation ID, но не
|
||||||
|
интерпретирует provider-specific поля ответа самостоятельно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
self,
|
||||||
|
document: object,
|
||||||
|
*,
|
||||||
|
expected_correlation_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Проверить принадлежность и успешность control/ACK сообщения.
|
||||||
|
"""
|
||||||
|
...
|
||||||
214
app/src/market_data/acquisition/runtime/websocket_protocol.py
Normal file
214
app/src/market_data/acquisition/runtime/websocket_protocol.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# app/src/market_data/acquisition/runtime/websocket_protocol.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.runtime_commands import (
|
||||||
|
ConnectCommand,
|
||||||
|
DisconnectCommand,
|
||||||
|
SendBinaryCommand,
|
||||||
|
SendTextCommand,
|
||||||
|
SubscribeCommand,
|
||||||
|
UnsubscribeCommand,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.runtime_events import (
|
||||||
|
ConnectedEvent,
|
||||||
|
ConnectFailedEvent,
|
||||||
|
DisconnectedEvent,
|
||||||
|
HeartbeatTimeoutEvent,
|
||||||
|
MessageReceivedEvent,
|
||||||
|
MessageSentEvent,
|
||||||
|
ReconnectCompletedEvent,
|
||||||
|
ReconnectFailedEvent,
|
||||||
|
ReconnectStartedEvent,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.transport_messages import (
|
||||||
|
TransportBinaryMessage,
|
||||||
|
TransportTextMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AcquisitionRuntimeCommand = (
|
||||||
|
ConnectCommand
|
||||||
|
| DisconnectCommand
|
||||||
|
| SubscribeCommand
|
||||||
|
| UnsubscribeCommand
|
||||||
|
| SendTextCommand
|
||||||
|
| SendBinaryCommand
|
||||||
|
)
|
||||||
|
|
||||||
|
AcquisitionRuntimeEvent = (
|
||||||
|
ConnectedEvent
|
||||||
|
| DisconnectedEvent
|
||||||
|
| ConnectFailedEvent
|
||||||
|
| MessageReceivedEvent
|
||||||
|
| MessageSentEvent
|
||||||
|
| ReconnectStartedEvent
|
||||||
|
| ReconnectCompletedEvent
|
||||||
|
| ReconnectFailedEvent
|
||||||
|
| HeartbeatTimeoutEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
AcquisitionSubscriptionMessage = (
|
||||||
|
TransportTextMessage
|
||||||
|
| TransportBinaryMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WebSocketTransportProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Низкоуровневый транспорт WebSocket.
|
||||||
|
|
||||||
|
Контракт отвечает только за открытие и закрытие соединения,
|
||||||
|
отправку исходящих сообщений и получение входящих сообщений.
|
||||||
|
|
||||||
|
Реализация не должна выполнять:
|
||||||
|
- parsing;
|
||||||
|
- schema validation;
|
||||||
|
- value validation;
|
||||||
|
- mapping;
|
||||||
|
- routing бизнес-событий.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Открыть WebSocket-соединение."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Закрыть WebSocket-соединение."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def send(self, message: str | bytes) -> None:
|
||||||
|
"""Отправить сериализованное транспортное сообщение."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def receive(self) -> str | bytes:
|
||||||
|
"""Получить одно сырое транспортное сообщение."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WebSocketSessionProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт жизненного цикла WebSocket-сессии.
|
||||||
|
|
||||||
|
Session координирует транспорт и последующие runtime-компоненты,
|
||||||
|
но не содержит exchange-specific parsing или mapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Показывает, открыта ли активная WebSocket-сессия."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Запустить WebSocket-сессию."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Остановить WebSocket-сессию."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WebSocketSubscriptionManagerProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт управления активными WebSocket-подписками.
|
||||||
|
|
||||||
|
Subscription Manager отвечает за регистрацию и удаление
|
||||||
|
активных подписок, а также за восстановление зарегистрированных
|
||||||
|
подписок после повторного подключения.
|
||||||
|
|
||||||
|
Менеджер работает только с универсальными транспортными
|
||||||
|
сообщениями и не содержит exchange-specific логики.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def subscribe(
|
||||||
|
self,
|
||||||
|
subscription_key: str,
|
||||||
|
message: AcquisitionSubscriptionMessage,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Зарегистрировать и выполнить WebSocket-подписку.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subscription_key:
|
||||||
|
Уникальный инфраструктурный ключ подписки.
|
||||||
|
|
||||||
|
message:
|
||||||
|
Полностью сформированное транспортное сообщение
|
||||||
|
подписки.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def unsubscribe(
|
||||||
|
self,
|
||||||
|
subscription_key: str,
|
||||||
|
message: AcquisitionSubscriptionMessage,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Отменить и удалить существующую WebSocket-подписку.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subscription_key:
|
||||||
|
Уникальный инфраструктурный ключ подписки.
|
||||||
|
|
||||||
|
message:
|
||||||
|
Полностью сформированное транспортное сообщение
|
||||||
|
отмены подписки.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def restore_subscriptions(self) -> None:
|
||||||
|
"""Восстановить активные подписки после подключения."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def clear_subscriptions(self) -> None:
|
||||||
|
"""Очистить runtime-состояние активных подписок."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AcquisitionRuntimeCommandDispatcherProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт передачи инфраструктурных команд в Runtime.
|
||||||
|
|
||||||
|
Dispatcher принимает только типизированные Runtime-команды
|
||||||
|
и не содержит знаний о Trade, Feed, Consistency или Recovery.
|
||||||
|
|
||||||
|
Конкретная маршрутизация и выполнение команд будут реализованы
|
||||||
|
в последующих Build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
command: AcquisitionRuntimeCommand,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Передать одну инфраструктурную команду в Runtime.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AcquisitionRuntimeEventPublisherProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Контракт публикации инфраструктурных событий Runtime.
|
||||||
|
|
||||||
|
Publisher передаёт уже произошедшие инфраструктурные факты
|
||||||
|
заинтересованным потребителям и не определяет их реакцию.
|
||||||
|
|
||||||
|
Конкретный механизм доставки событий будет реализован
|
||||||
|
в последующих Build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
event: AcquisitionRuntimeEvent,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Опубликовать одно инфраструктурное событие Runtime.
|
||||||
|
"""
|
||||||
|
...
|
||||||
81
app/src/market_data/acquisition/runtime/websocket_session.py
Normal file
81
app/src/market_data/acquisition/runtime/websocket_session.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
WebSocketTransportProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionTransportProtocol(
|
||||||
|
WebSocketTransportProtocol,
|
||||||
|
Protocol,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Transport-контракт для Session lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Показывает состояние Transport."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketSession:
|
||||||
|
"""
|
||||||
|
Идемпотентный lifecycle WebSocket-сессии.
|
||||||
|
|
||||||
|
Session сериализует start/stop, делегирует сетевые операции
|
||||||
|
Transport и не содержит parsing, subscriptions или recovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_transport",
|
||||||
|
"_started",
|
||||||
|
"_lifecycle_lock",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: _SessionTransportProtocol,
|
||||||
|
) -> None:
|
||||||
|
self._transport = transport
|
||||||
|
self._started = False
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Показывает состояние Session."""
|
||||||
|
return (
|
||||||
|
self._started
|
||||||
|
and self._transport.is_connected
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""
|
||||||
|
Открыть Session без повторного соединения.
|
||||||
|
"""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
if self.is_connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._started = False
|
||||||
|
await self._transport.connect()
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""
|
||||||
|
Закрыть Session и сбросить локальное состояние.
|
||||||
|
"""
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
if (
|
||||||
|
not self._started
|
||||||
|
and not self._transport.is_connected
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._transport.disconnect()
|
||||||
|
finally:
|
||||||
|
self._started = False
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
WebSocketUnsubscribeNotSupportedError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.transport_messages import (
|
||||||
|
TransportBinaryMessage,
|
||||||
|
TransportTextMessage,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.websocket_protocol import (
|
||||||
|
AcquisitionSubscriptionMessage,
|
||||||
|
WebSocketTransportProtocol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketSubscriptionManager:
|
||||||
|
"""
|
||||||
|
Реестр WebSocket-подписок и механизм их восстановления.
|
||||||
|
|
||||||
|
Manager хранит только универсальные transport messages.
|
||||||
|
Он не разбирает payload и не содержит exchange-specific знаний.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_transport",
|
||||||
|
"_supports_unsubscribe",
|
||||||
|
"_subscriptions",
|
||||||
|
"_active_subscription_keys",
|
||||||
|
"_lock",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
transport: WebSocketTransportProtocol,
|
||||||
|
*,
|
||||||
|
supports_unsubscribe: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(supports_unsubscribe, bool):
|
||||||
|
raise TypeError(
|
||||||
|
"supports_unsubscribe must be a boolean"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._transport = transport
|
||||||
|
self._supports_unsubscribe = supports_unsubscribe
|
||||||
|
self._subscriptions: dict[
|
||||||
|
str,
|
||||||
|
AcquisitionSubscriptionMessage,
|
||||||
|
] = {}
|
||||||
|
self._active_subscription_keys: set[str] = set()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscription_keys(self) -> tuple[str, ...]:
|
||||||
|
"""Вернуть ключи в порядке регистрации."""
|
||||||
|
return tuple(self._subscriptions)
|
||||||
|
|
||||||
|
async def subscribe(
|
||||||
|
self,
|
||||||
|
subscription_key: str,
|
||||||
|
message: AcquisitionSubscriptionMessage,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Идемпотентно отправить и зарегистрировать подписку.
|
||||||
|
|
||||||
|
Desired subscription сохраняется до сетевой отправки.
|
||||||
|
При ошибке она остаётся pending для последующего retry/reconnect.
|
||||||
|
"""
|
||||||
|
self._validate_subscription_key(subscription_key)
|
||||||
|
self._validate_message(message)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if subscription_key in self._active_subscription_keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._subscriptions[subscription_key] = message
|
||||||
|
|
||||||
|
await self._send(message)
|
||||||
|
self._active_subscription_keys.add(
|
||||||
|
subscription_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def unsubscribe(
|
||||||
|
self,
|
||||||
|
subscription_key: str,
|
||||||
|
message: AcquisitionSubscriptionMessage,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Отправить unsubscribe только при явной поддержке провайдером.
|
||||||
|
|
||||||
|
Dzengi ``trades.unsubscribe`` не поддерживает. Поэтому
|
||||||
|
``supports_unsubscribe`` по умолчанию равно False.
|
||||||
|
"""
|
||||||
|
self._validate_subscription_key(subscription_key)
|
||||||
|
self._validate_message(message)
|
||||||
|
|
||||||
|
if not self._supports_unsubscribe:
|
||||||
|
raise WebSocketUnsubscribeNotSupportedError(
|
||||||
|
"WebSocket provider does not support unsubscribe."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if subscription_key not in self._subscriptions:
|
||||||
|
return
|
||||||
|
|
||||||
|
if subscription_key in self._active_subscription_keys:
|
||||||
|
await self._send(message)
|
||||||
|
|
||||||
|
del self._subscriptions[subscription_key]
|
||||||
|
self._active_subscription_keys.discard(
|
||||||
|
subscription_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def restore_subscriptions(self) -> None:
|
||||||
|
"""
|
||||||
|
Последовательно восстановить подписки.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
self._active_subscription_keys.clear()
|
||||||
|
|
||||||
|
for subscription_key, message in (
|
||||||
|
self._subscriptions.items()
|
||||||
|
):
|
||||||
|
await self._send(message)
|
||||||
|
self._active_subscription_keys.add(
|
||||||
|
subscription_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def clear_subscriptions(self) -> None:
|
||||||
|
"""
|
||||||
|
Очистить реестр без отправки сообщений.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
self._subscriptions.clear()
|
||||||
|
self._active_subscription_keys.clear()
|
||||||
|
|
||||||
|
async def _send(
|
||||||
|
self,
|
||||||
|
message: AcquisitionSubscriptionMessage,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(message, TransportTextMessage):
|
||||||
|
await self._transport.send(message.payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._transport.send(message.payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_subscription_key(
|
||||||
|
subscription_key: str,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(subscription_key, str):
|
||||||
|
raise TypeError(
|
||||||
|
"subscription_key must be a string"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not subscription_key.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"subscription_key must not be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_message(
|
||||||
|
message: AcquisitionSubscriptionMessage,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(
|
||||||
|
message,
|
||||||
|
(
|
||||||
|
TransportTextMessage,
|
||||||
|
TransportBinaryMessage,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"message must be TransportTextMessage "
|
||||||
|
"or TransportBinaryMessage"
|
||||||
|
)
|
||||||
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.models.candle import Candle
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
from src.market_data.acquisition.models.instrument import Instrument
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
from src.market_data.acquisition.models.quote import Quote
|
||||||
from src.market_data.acquisition.registry import (
|
from src.market_data.acquisition.registry import (
|
||||||
|
CandlesFeedRegistry,
|
||||||
InstrumentFeedRegistry,
|
InstrumentFeedRegistry,
|
||||||
QuoteFeedRegistry,
|
QuoteFeedRegistry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Application-level сервис получения справочника инструментов.
|
|
||||||
class InstrumentAcquisitionService:
|
class InstrumentAcquisitionService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -23,19 +24,11 @@ class InstrumentAcquisitionService:
|
|||||||
self,
|
self,
|
||||||
source_name: str,
|
source_name: str,
|
||||||
) -> tuple[Instrument, ...]:
|
) -> tuple[Instrument, ...]:
|
||||||
"""
|
|
||||||
Получить Instrument Feed из Registry и загрузить справочник инструментов.
|
|
||||||
|
|
||||||
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
|
||||||
mapping, retry, кэширование или преобразование результата.
|
|
||||||
"""
|
|
||||||
|
|
||||||
feed = self._registry.get(source_name)
|
feed = self._registry.get(source_name)
|
||||||
|
|
||||||
return feed.load_instruments()
|
return feed.load_instruments()
|
||||||
|
|
||||||
|
|
||||||
# Application-level сервис получения текущих котировок.
|
|
||||||
class QuoteAcquisitionService:
|
class QuoteAcquisitionService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -49,14 +42,33 @@ class QuoteAcquisitionService:
|
|||||||
source_name: str,
|
source_name: str,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
) -> Quote:
|
) -> Quote:
|
||||||
"""
|
|
||||||
Получить Quotes Feed из Registry и загрузить текущую котировку.
|
|
||||||
|
|
||||||
Service не создаёт Feed, не выполняет transport, parsing, validation,
|
|
||||||
mapping, нормализацию symbol, retry, кэширование или преобразование
|
|
||||||
результата.
|
|
||||||
"""
|
|
||||||
|
|
||||||
feed = self._registry.get(source_name)
|
feed = self._registry.get(source_name)
|
||||||
|
|
||||||
return feed.load_quote(symbol)
|
return feed.load_quote(symbol)
|
||||||
|
|
||||||
|
|
||||||
|
class CandlesAcquisitionService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
registry: CandlesFeedRegistry,
|
||||||
|
) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def load_candles(
|
||||||
|
self,
|
||||||
|
source_name: str,
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
interval: str,
|
||||||
|
limit: int,
|
||||||
|
price_type: str,
|
||||||
|
) -> tuple[Candle, ...]:
|
||||||
|
feed = self._registry.get(source_name)
|
||||||
|
|
||||||
|
return feed.load_candles(
|
||||||
|
symbol,
|
||||||
|
interval=interval,
|
||||||
|
limit=limit,
|
||||||
|
price_type=price_type,
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Формирование транспортных подписок Market Data Acquisition.
|
||||||
|
|
||||||
|
Модули пакета преобразуют параметры предметной области в универсальные
|
||||||
|
Runtime-команды и не выполняют сетевые операции самостоятельно.
|
||||||
|
"""
|
||||||
111
app/src/market_data/acquisition/subscriptions/trades.py
Normal file
111
app/src/market_data/acquisition/subscriptions/trades.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# app/src/market_data/acquisition/subscriptions/trades.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from src.market_data.acquisition.runtime.runtime_commands import (
|
||||||
|
SubscribeCommand,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.runtime.transport_messages import (
|
||||||
|
TransportTextMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TRADE_SUBSCRIPTION_DESTINATION = "trades.subscribe"
|
||||||
|
TRADE_SUBSCRIPTION_KEY_PREFIX = "trades"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_trade_subscription_symbols(
|
||||||
|
symbols: Sequence[str],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""
|
||||||
|
Нормализовать символы Trade-подписки.
|
||||||
|
|
||||||
|
Удаляет внешние пробелы, пустые значения и дубликаты.
|
||||||
|
Возвращает символы в стабильном лексикографическом порядке.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_symbols = {
|
||||||
|
symbol.strip()
|
||||||
|
for symbol in symbols
|
||||||
|
if symbol.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
if not normalized_symbols:
|
||||||
|
raise ValueError(
|
||||||
|
"Trade subscription requires at least one non-empty symbol."
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(sorted(normalized_symbols))
|
||||||
|
|
||||||
|
|
||||||
|
def build_trade_subscription_key(
|
||||||
|
symbols: Sequence[str],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Построить стабильный идентификатор Trade-подписки.
|
||||||
|
|
||||||
|
Идентификатор не зависит от порядка входных символов и используется
|
||||||
|
Runtime для регистрации и последующего восстановления подписки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_symbols = _normalize_trade_subscription_symbols(symbols)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{TRADE_SUBSCRIPTION_KEY_PREFIX}:"
|
||||||
|
f"{','.join(normalized_symbols)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_trade_subscribe_message(
|
||||||
|
symbols: Sequence[str],
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> TransportTextMessage:
|
||||||
|
"""
|
||||||
|
Построить текстовое сообщение подписки Dzengi Trade WebSocket.
|
||||||
|
|
||||||
|
correlation_id может быть передан вызывающим кодом для детерминированных
|
||||||
|
тестов и сопоставления ACK. Если значение не передано, создаётся новый UUID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_symbols = _normalize_trade_subscription_symbols(symbols)
|
||||||
|
|
||||||
|
document = {
|
||||||
|
"correlationId": correlation_id or str(uuid4()),
|
||||||
|
"destination": TRADE_SUBSCRIPTION_DESTINATION,
|
||||||
|
"payload": {
|
||||||
|
"symbols": list(normalized_symbols),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return TransportTextMessage(
|
||||||
|
payload=json.dumps(
|
||||||
|
document,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_trade_subscribe_command(
|
||||||
|
symbols: Sequence[str],
|
||||||
|
*,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> SubscribeCommand:
|
||||||
|
"""
|
||||||
|
Построить универсальную Runtime-команду подписки на Trade Feed.
|
||||||
|
|
||||||
|
Runtime получает стабильный ключ и готовое транспортное сообщение,
|
||||||
|
не интерпретируя Dzengi-specific JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return SubscribeCommand(
|
||||||
|
subscription_key=build_trade_subscription_key(symbols),
|
||||||
|
message=build_trade_subscribe_message(
|
||||||
|
symbols,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user