Compare commits

...

60 Commits

Author SHA1 Message Date
49cc0e2fb8 CI-GATE.0-2: establish repository CI canary 2026-08-04 19:01:59 +03:00
64a5bdd04c Build 060.30: finalize Market Data Acquisition documentation 2026-08-04 00:29:56 +03:00
8c98de9acc Build 060.29: implement Market Data Access and Replay 2026-08-04 00:29:56 +03:00
8c485e32b1 Build 060.28: implement Persistent Checkpoint and Startup Recovery 2026-08-04 00:29:56 +03:00
58e5a12a4d Build 060.27: implement Persistent Market Data Storage 2026-08-04 00:29:56 +03:00
cb8acfe5fe Build 060.26: complete Integration and Regression 2026-08-04 00:29:56 +03:00
60bec1eaf9 Build 060.25: implement Production Runtime Integration 2026-08-04 00:29:56 +03:00
c142145361 Build 060.24: implement Runtime Recovery Architecture 2026-08-04 00:29:56 +03:00
ee8765b716 Build 060.23: integrate Trade Stream Acquisition 2026-08-04 00:29:56 +03:00
9953bab993 Build 060.22: implement Acquisition Runtime Service 2026-08-04 00:29:56 +03:00
433ac5d375 Build 060.21: integrate acquisition runtime protocols 2026-08-04 00:29:56 +03:00
ad6631f773 Build 060.20_1: align Trade Stream state ownership 2026-08-04 00:29:56 +03:00
14f698d48a Build 060.20.2: integrate Consistency with Trade Runtime 2026-08-04 00:29:56 +03:00
317b9683ef Build 060.20.1: implement Trade Runtime Layer 2026-08-04 00:29:56 +03:00
cf58fc223c Build 060.19: implement Trade Recovery subsystem 2026-08-04 00:29:56 +03:00
a891302eaf Docs: add Build 060.18 documentation and migration reports 2026-08-04 00:26:36 +03:00
618b716e71 Build 060.18: implement Trade Stream Consistency Controller 2026-08-04 00:18:18 +03:00
fe308b9e60 Build 060.17 — Trades Feed Core 2026-08-04 00:18:18 +03:00
be6beac560 Build 060.16: add Trade Subscription Layer and subscription builders 2026-08-04 00:18:18 +03:00
02868561d6 Build 060.15 — Unified WebSocket Routing 2026-08-04 00:18:18 +03:00
bf7645237e Build 060.14 — WebSocket Trade Adapter 2026-08-04 00:18:18 +03:00
b7bef85d20 Build 060.13 — WebSocket Trade Mapper 2026-08-04 00:18:18 +03:00
6d121abe96 Build 060.12: add WebSocket Trade Value Validation 2026-08-04 00:18:18 +03:00
d3f8680921 Build 060.11: add WebSocket Trade Parser 2026-08-04 00:18:18 +03:00
034f5984ca Build 060.10: add WebSocket Trade Schema Validation 2026-08-04 00:18:18 +03:00
0b4edca287 Build 060.9: add WebSocket Trade Transport Model 2026-08-04 00:18:18 +03:00
2523d715e7 Build 060.8: add Dzengi REST Trades Document Source 2026-08-04 00:18:18 +03:00
7eee8ce36c Build 060.7: implement REST Trade Adapter 2026-08-04 00:18:18 +03:00
6b0d5badce Build 060.6: add REST trade mapper 2026-08-04 00:18:18 +03:00
a0ae22cb2e Build 060.4-060.5: add REST trade validation pipeline 2026-08-04 00:18:18 +03:00
c0f69ee94a Build 060.3: add REST trade schema validation 2026-08-04 00:18:18 +03:00
013efd8a97 Build 060.2: add REST trade transport model 2026-08-04 00:18:18 +03:00
c52c099d54 Build 060.1: add canonical Trade model 2026-08-04 00:18:18 +03:00
f2bacb80c9 build 059.12: add runtime event models 2026-08-04 00:18:18 +03:00
d2d8f8fad4 build 059.11: add runtime command models 2026-08-04 00:18:18 +03:00
4977c7678a build 059.10: add runtime transport message models 2026-08-04 00:18:18 +03:00
8109ed6210 build 059.9: add websocket runtime protocols 2026-08-04 00:18:18 +03:00
e5a4efa5d1 build 059.8: add unified websocket adapter 2026-08-04 00:18:18 +03:00
20a70df1f0 build 059.7: add websocket OHLC adapter 2026-08-04 00:18:18 +03:00
85e9662f5b build 059.6: map websocket OHLC close events 2026-08-04 00:18:18 +03:00
5c7ecf9340 docs: fix build 059.5 whitespace 2026-08-04 00:18:18 +03:00
a1efa39517 build 059.5: add internal candle close event model 2026-08-04 00:18:18 +03:00
925b447d45 build 059.4: validate websocket OHLC values 2026-08-04 00:18:18 +03:00
c8c0bb6951 build 059.3: parse websocket OHLC events 2026-08-04 00:18:18 +03:00
e0aa04b32a build 059.1-059.2: add websocket OHLC transport and schema validation 2026-08-04 00:18:18 +03:00
ebb578db35 build 058: validate websocket OHLC market data 2026-08-04 00:18:18 +03:00
cd301586d3 build 057: validate trades websocket and document trade feed 2026-08-04 00:18:18 +03:00
eaa0eaf710 build 056: finalize canonical OHLCV architecture verification 2026-08-04 00:18:18 +03:00
ed69420196 build 055: remove legacy Kline models 2026-08-04 00:18:18 +03:00
09f3f66a4e build 054: remove legacy klines compatibility API 2026-08-04 00:18:18 +03:00
f027436e2f build 053: remove unused legacy market indicators 2026-08-04 00:18:18 +03:00
642d607162 build 052: audit remaining legacy OHLCV dependencies 2026-08-04 00:18:18 +03:00
0b2187dac4 build 051: switch HTF analysis to canonical candles 2026-08-04 00:18:18 +03:00
c8d33f8baa build 050: switch MarketAnalysisService to canonical candles 2026-08-04 00:18:18 +03:00
16ed64f7c6 build 049: expose canonical candles through ExchangeService 2026-08-04 00:18:18 +03:00
3a253d89a9 build 048: switch market analysis consumers to canonical Candle model 2026-08-04 00:18:18 +03:00
77e87c0504 build 047: switch ExchangeService klines to canonical candles feed 2026-08-04 00:18:18 +03:00
a10a760166 build 046: integrate canonical candles feed into acquisition service 2026-08-04 00:11:07 +03:00
0e86cca19d build 045: register canonical candles feed in acquisition registry 2026-08-04 00:11:07 +03:00
47c75d68b1 build 044: establish canonical candles feed foundation 2026-07-15 10:26:55 +03:00
377 changed files with 158985 additions and 2244 deletions

30
.dockerignore Normal file
View 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

View File

@@ -0,0 +1,162 @@
name: CI Required
# До отдельной приёмки canary workflow запускается только вручную.
on:
workflow_dispatch: {}
env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_NO_CACHE_DIR: "1"
PIP_NO_INPUT: "1"
PYTHONDONTWRITEBYTECODE: "1"
jobs:
quality:
name: quality
runs-on: dzentra-python312
steps:
- name: Checkout закреплённой revision
uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 1
persist-credentials: false
- name: Проверка контракта runner
run: |
set -euo pipefail
test "$(uname -s)" = "Linux"
command -v bash
command -v git
command -v grep
command -v node
command -v python3.12
command -v timeout
test "$(python3.12 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
case "$(node --version)" in
v20.*) ;;
*) printf 'Требуется Node.js 20.\n' >&2; exit 1 ;;
esac
timeout --version | grep -q 'GNU coreutils'
python3.12 -c 'import socket; sock = socket.socket(); sock.bind(("127.0.0.1", 0)); sock.close()'
- name: Установка hash-locked environment
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=30s 2m \
python3.12 -m venv app/.venv
timeout --signal=TERM --kill-after=30s 10m \
app/.venv/bin/python -m pip install \
--require-hashes \
-r app/requirements-dev.lock
app/.venv/bin/python -m pip check
- name: Quality gate
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=30s 10m \
./scripts/run_ci_gate.sh quality
- name: Проверка отсутствия артефактов
run: |
set -euo pipefail
changes="$(git status --short --untracked-files=all)"
if [[ -n "${changes}" ]]; then
printf '%s\n' "${changes}" >&2
exit 1
fi
offline-regression:
name: offline-regression
runs-on: dzentra-python312
steps:
- name: Checkout закреплённой revision
uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 1
persist-credentials: false
- name: Проверка контракта runner
run: |
set -euo pipefail
test "$(uname -s)" = "Linux"
command -v bash
command -v git
command -v grep
command -v node
command -v python3.12
command -v timeout
test "$(python3.12 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
case "$(node --version)" in
v20.*) ;;
*) printf 'Требуется Node.js 20.\n' >&2; exit 1 ;;
esac
timeout --version | grep -q 'GNU coreutils'
python3.12 -c 'import socket; sock = socket.socket(); sock.bind(("127.0.0.1", 0)); sock.close()'
- name: Установка hash-locked environment
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=30s 2m \
python3.12 -m venv app/.venv
timeout --signal=TERM --kill-after=30s 10m \
app/.venv/bin/python -m pip install \
--require-hashes \
-r app/requirements-dev.lock
app/.venv/bin/python -m pip check
- name: Offline regression
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=30s 20m \
./scripts/run_ci_gate.sh offline-regression
- name: Проверка отсутствия артефактов
run: |
set -euo pipefail
changes="$(git status --short --untracked-files=all)"
if [[ -n "${changes}" ]]; then
printf '%s\n' "${changes}" >&2
exit 1
fi
loopback-integration:
name: loopback-integration
runs-on: dzentra-python312
steps:
- name: Checkout закреплённой revision
uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 1
persist-credentials: false
- name: Проверка контракта runner
run: |
set -euo pipefail
test "$(uname -s)" = "Linux"
command -v bash
command -v git
command -v grep
command -v node
command -v python3.12
command -v timeout
test "$(python3.12 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
case "$(node --version)" in
v20.*) ;;
*) printf 'Требуется Node.js 20.\n' >&2; exit 1 ;;
esac
timeout --version | grep -q 'GNU coreutils'
python3.12 -c 'import socket; sock = socket.socket(); sock.bind(("127.0.0.1", 0)); sock.close()'
- name: Установка hash-locked environment
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=30s 2m \
python3.12 -m venv app/.venv
timeout --signal=TERM --kill-after=30s 10m \
app/.venv/bin/python -m pip install \
--require-hashes \
-r app/requirements-dev.lock
app/.venv/bin/python -m pip check
- name: Loopback integration
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=30s 10m \
./scripts/run_ci_gate.sh loopback-integration
- name: Проверка отсутствия артефактов
run: |
set -euo pipefail
changes="$(git status --short --untracked-files=all)"
if [[ -n "${changes}" ]]; then
printf '%s\n' "${changes}" >&2
exit 1
fi

View File

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

109
README.md
View File

@@ -1,33 +1,96 @@
# 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 \
--require-hashes \
-r app/requirements-dev.lock
app/.venv/bin/python -m pip check
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.
Локальный эквивалент первого CI gate запускает quality, offline regression
и безопасную loopback integration-проверку:
```bash
./scripts/run_ci_gate.sh all
```
Workflow пока запускается только вручную. Его границы, runner contract и
критерии зелёного canary описаны в [CI runbook](docs/operations/ci_gate.md).
## Навигация
- [Обзор архитектуры](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)
- [CI gate и первый canary](docs/operations/ci_gate.md)
- [Архитектура Build 060.30](docs/migrations/build_060_30_architecture.md)
## Исторический bootstrap
`bootstrap_project.py` — исторический генератор первоначального каркаса.
Для установки или запуска существующего checkout его использовать не
нужно.

View File

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

View File

@@ -1,3 +1,132 @@
# 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 \
--require-hashes \
-r app/requirements-dev.lock
app/.venv/bin/python -m pip check
test -e app/.env || cp app/.env.example app/.env
```
Замените значения-заглушки в `app/.env`. Не добавляйте этот файл в Git и
не перезаписывайте существующий `.env`. Для каждого секрета разрешён либо
прямой параметр, либо соответствующий `*_FILE`, но не оба одновременно.
`requirements-dev.txt` остаётся читаемым источником прямых developer
dependencies. Установка выполняется из `requirements-dev.lock`, который
сохраняет версии Production `requirements.lock` и добавляет pytest/Pyright.
Вручную редактировать dependency records обоих lock-файлов не следует.
## Режимы 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 не
заменяет.
Полная локальная CI-последовательность запускается из корня repository:
```bash
./scripts/run_ci_gate.sh all
```
Подробный manual-canary contract приведён в
[CI runbook](../docs/operations/ci_gate.md).
Назначение и безопасные команды 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
View 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

929
app/requirements-dev.lock Normal file
View File

@@ -0,0 +1,929 @@
# Сгенерированный hash-lock developer/CI-зависимостей; вручную не редактировать.
# Источники: app/requirements-dev.txt и app/requirements.lock; Python 3.12.
# Команда регенерации приведена в docs/operations/ci_gate.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
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
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
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
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
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
nodejs-wheel-binaries==24.16.0 \
--hash=sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd \
--hash=sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51 \
--hash=sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36 \
--hash=sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342 \
--hash=sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa \
--hash=sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37 \
--hash=sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c \
--hash=sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502 \
--hash=sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03
openpyxl==3.1.5 \
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
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
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
pyright==1.1.411 \
--hash=sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998 \
--hash=sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
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

4
app/requirements-dev.txt Normal file
View File

@@ -0,0 +1,4 @@
-r requirements.txt
pytest==9.1.1
pyright[nodejs]==1.1.411

895
app/requirements.lock Normal file
View 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

View File

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

View 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()

File diff suppressed because it is too large Load Diff

View 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())

View 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()

View File

@@ -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
except Exception: if market_data_storage is not None
pass else None
),
)
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
),
)

View 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

View 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,
)

View 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
),
)

View File

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

View File

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

View File

@@ -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("/")
@@ -133,4 +140,4 @@ class ExchangeRestClient:
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise ExchangeResponseError("Exchange response is not a JSON object.") raise ExchangeResponseError("Exchange response is not a JSON object.")
return payload return payload

View File

@@ -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,149 +552,40 @@ 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(
source=source,
handler=handler,
)
for item in raw_items: registry = CandlesFeedRegistry()
candle = self._parse_kline_item( registry.register(
item=item, _CANDLES_SOURCE_NAME,
symbol=symbol, feed,
interval=interval, )
source=source,
)
if candle is not None: acquisition_service = CandlesAcquisitionService(
candles.append(candle) registry=registry,
)
candles.sort(key=lambda item: item.open_time) return acquisition_service.load_candles(
_CANDLES_SOURCE_NAME,
return candles symbol,
interval=interval,
# Извлечь массив свечей из разных возможных форматов ответа API. limit=limit,
def _extract_klines_items(self, payload: object) -> list[object]: price_type=price_type,
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)
open_price = safe_float(item.get("open"))
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 (
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,
)
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:
@@ -1244,4 +1139,4 @@ class ExchangeService:
except Exception: except Exception:
pass pass
return None return None

View File

@@ -2,26 +2,14 @@
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__":
asyncio.run(main()) asyncio.run(main())

View File

@@ -0,0 +1 @@
# app/src/market_data/__init__.py

View 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",
)

View 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."""

View 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."""

View 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)

View 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]

View File

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

View 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

View 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

View 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

View File

@@ -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",
]

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/adapters/dzengi/auth.py

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
)

View File

@@ -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."
)

View File

@@ -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 "
"не должен быть пустым."
)

View File

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

View File

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

View 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",
)

View File

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

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/consistency/__init__.py

View File

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

View File

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

View File

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

View File

@@ -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 при обнаружении
конфликтующего дубликата.
"""
...

View File

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

View File

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

View File

@@ -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,
):
"""Хранилище состояний нельзя безопасно инициализировать."""

View File

@@ -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:
"""
Полностью очищает хранилище состояний.
"""
...

View File

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

View File

@@ -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}",
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
# app/src/market_data/acquisition/models/candle_close.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
# Внутреннее неизменяемое уведомление о закрытии OHLC-свечи.
#
# Модель не является канонической Candle, поскольку WebSocket-событие
# не содержит volume. Она используется как сигнал для последующего
# REST reconciliation и получения полной OHLCV-свечи.
@dataclass(frozen=True, slots=True)
class CandleCloseEvent:
symbol: str
interval: str
candle_type: str
open_time: datetime
received_at: datetime
open_price: Decimal
high_price: Decimal
low_price: Decimal
close_price: Decimal
source: str

View File

@@ -0,0 +1,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

View File

@@ -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.
@@ -83,4 +85,101 @@ 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.
"""
... ...

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/recovery/__init__.py

View File

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

View File

@@ -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):
"""
Ошибка контроллера восстановления сделок.
"""

View File

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

View File

@@ -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
Канонический результат восстановления.
"""
...

View File

@@ -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."
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
# app/src/market_data/acquisition/runtime/__init__.py

View File

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

View File

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

View File

@@ -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__}."
)

View File

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

View File

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

View 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()

View File

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

View 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

View 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

View File

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

View File

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

View File

@@ -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=(),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 сообщения.
"""
...

View 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.
"""
...

View 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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More