Compare commits

..

2 Commits

995 changed files with 2633 additions and 294348 deletions

View File

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

@@ -1,162 +0,0 @@
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,6 +1,4 @@
{ {
"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,96 +1,33 @@
# Dzentra # dzentra_bot
Dzentra — модульный монолит автоматической торговой системы с Telegram-бот для автоторговли криптовалютой.
Telegram-интерфейсом. В текущем принятом Production scope полностью
собрана вертикаль Trades Feed: получение сделок, проверка
последовательности, reconnect/recovery, опциональное сохранение в
PostgreSQL и восстановление после перезапуска.
## Текущий scope ## Bootstrap v2
Это стабильный стартовый каркас проекта с:
- чистой структурой каталогов
- компактным верхним меню
- базовыми 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
python3.12 -m venv app/.venv /opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install
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 и не ## После bootstrap
перезаписывайте уже существующий файл. Затем запустите приложение:
```bash ```bash
cd app cd app
.venv/bin/python -m src.main source .venv/bin/activate
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,73 +1,20 @@
# Настройки приложения. Файл является примером: не храните здесь
# реальные секреты. Для каждого секрета задайте либо прямое значение,
# либо *_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=PUT_YOUR_DATABASE_PASSWORD_HERE DB_PASSWORD=change_me
# DB_PASSWORD_FILE=/run/secrets/db_password
# Постоянное хранилище Market Data EXCHANGE_ENABLED=true
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= EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com
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=false EXCHANGE_TESTNET=true
DEFAULT_SYMBOL=ETH/USD_LEVERAGE DEFAULT_SYMBOL=BTC/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,132 +1,3 @@
# Приложение Dzentra # app
Каталог `app` содержит исполняемое Python-приложение, зависимости, Здесь находятся исходный код приложения, env-файлы и зависимости.
настройки и тесты 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 его запускать не нужно.

View File

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

View File

@@ -1,929 +0,0 @@
# Сгенерированный 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

View File

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

View File

@@ -1,895 +0,0 @@
# Сгенерированный 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

@@ -1,8 +1,3 @@
# app/requirements.txt
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
websockets==13.1

View File

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

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

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

@@ -1,71 +0,0 @@
# app/scripts/get_ticker_24hr.py
from __future__ import annotations
import argparse
import json
import sys
from src.core.config import load_settings
from src.integrations.exchange.rest_client import ExchangeRestClient
def parse_args() -> argparse.Namespace:
settings = load_settings()
parser = argparse.ArgumentParser(
description="Получить реальный ответ Dzengi ticker/24hr.",
)
parser.add_argument(
"symbol",
nargs="?",
default=settings.default_symbol,
help=(
"Торговый символ. "
f"По умолчанию: {settings.default_symbol}"
),
)
return parser.parse_args()
def main() -> int:
args = parse_args()
symbol = str(args.symbol).strip()
if not symbol:
print(
"Торговый символ не должен быть пустым.",
file=sys.stderr,
)
return 2
try:
payload = ExchangeRestClient().get_json(
"/api/v1/ticker/24hr",
params={
"symbol": symbol,
},
)
except Exception as exc:
print(
f"Не удалось получить ticker/24hr для {symbol}: "
f"{type(exc).__name__}: {exc}",
file=sys.stderr,
)
return 1
print(
json.dumps(
payload,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,28 +1,17 @@
# app/src/bootstrap/app_factory.py
from __future__ import annotations 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.storage.schema import init_schema from src.storage.schema import init_schema
from src.telegram.routers import setup_routers from src.telegram.routers import setup_routers
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
def create_app() -> ApplicationComposition: def create_app() -> tuple[Bot, Dispatcher]:
settings = load_settings() settings = load_settings()
setup_logging(settings.log_level) setup_logging(settings.log_level)
journal = JournalService() journal = JournalService()
@@ -44,58 +33,25 @@ def create_app() -> ApplicationComposition:
pass pass
raise raise
market_data_storage = build_market_data_storage(settings) try:
trade_stream_runtime = build_trade_stream_production_runtime( journal.log_info(
settings, "app_start",
trade_observation_sink=( "Приложение запущено.",
market_data_storage.trade_observation_sink {
if market_data_storage is not None "env": settings.app_env,
else None "exchange_name": settings.exchange_name,
), "default_symbol": settings.default_symbol,
checkpoint_storage=( },
market_data_storage.trade_repository
if market_data_storage is not None
else None
),
) )
except Exception:
# журнал не должен ломать запуск приложения
pass
bot = Bot( bot = Bot(
token=settings.bot_token, token=settings.bot_token,
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode), default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),
) )
NotificationTargetRegistry.set_bot(bot)
dispatcher = Dispatcher() dispatcher = Dispatcher()
setup_routers(dispatcher) setup_routers(dispatcher)
try: return bot, dispatcher
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

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

@@ -1,5 +1,3 @@
# app/src/bootstrap/logging.py
from __future__ import annotations from __future__ import annotations
import logging import logging

View File

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

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

@@ -1,528 +1,64 @@
# app/src/core/config.py
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_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
bot_token: str bot_token: str
bot_parse_mode: str bot_parse_mode: str
# Приложение
app_env: str app_env: str
log_level: str log_level: str
tz: str tz: str
# Биржа
exchange_enabled: bool exchange_enabled: bool
exchange_name: str exchange_name: str
exchange_base_url: str exchange_base_url: str
exchange_ws_url: str
exchange_api_key: str exchange_api_key: str
exchange_api_secret: str exchange_api_secret: str
exchange_timeout_sec: int exchange_timeout_sec: int
exchange_testnet: bool exchange_testnet: bool
default_symbol: str default_symbol: str
trade_stream: TradeStreamSettings
# База данных
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_enabled: bool
journal_debug_enabled: bool
# Вспомогательное свойство режима 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()
# Разбор булева значения
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:
return default return default
return value in {"1", "true", "yes", "on"} return value in {"1", "true", "yes", "on"}
# Разбор целого числа
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:
return default return default
return int(value) return int(value)
def _parse_strict_bool(
raw_value: str,
*,
name: str,
default: bool,
) -> bool:
value = (raw_value or "").strip().lower()
if not value:
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: def load_settings() -> Settings:
bot_token = _load_secret( bot_token = os.getenv("BOT_TOKEN", "").strip()
"BOT_TOKEN", if not bot_token:
required=True, raise RuntimeError("BOT_TOKEN is not set in app/.env")
)
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
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_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",
debug_enabled=_parse_bool(os.getenv("DEBUG_ENABLED", "false")),
journal_debug_enabled=_parse_bool(
os.getenv("JOURNAL_DEBUG_ENABLED", "false")
),
# Биржа
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=exchange_base_url, exchange_base_url=os.getenv("EXCHANGE_BASE_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", "BTC/USD_LEVERAGE").strip() or "BTC/USD_LEVERAGE",
or "ETH/USD_LEVERAGE",
trade_stream=trade_stream,
# База данных
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=db_password, db_password=os.getenv("DB_PASSWORD", "").strip(),
market_data_storage=market_data_storage,
) )

View File

@@ -1,4 +1,2 @@
# app/src/core/constants.py
APP_NAME = "Dzentra Bot" APP_NAME = "Dzentra Bot"
APP_VERSION = "2.0.0" APP_VERSION = "2.0.0"

View File

@@ -1,50 +0,0 @@
# app/src/core/event_bus.py
from __future__ import annotations
from typing import Any
class EventBus:
_version: int = 0
_last_event_type: str | None = None
_last_payload: dict[str, Any] = {}
_events: list[tuple[int, str, dict[str, Any]]] = []
_max_events: int = 100
# зафиксировать важное событие системы
@classmethod
def emit(cls, event_type: str, payload: dict[str, Any] | None = None) -> None:
cls._version += 1
cls._last_event_type = event_type
cls._last_payload = payload or {}
cls._events.append(
(
cls._version,
event_type,
dict(cls._last_payload),
)
)
if len(cls._events) > cls._max_events:
cls._events = cls._events[-cls._max_events:]
# текущая версия событий
@classmethod
def version(cls) -> int:
return cls._version
# последнее событие
@classmethod
def last_event(cls) -> tuple[str | None, dict[str, Any]]:
return cls._last_event_type, dict(cls._last_payload)
# события после указанной версии
@classmethod
def events_after(cls, version: int) -> list[tuple[int, str, dict[str, Any]]]:
return [
(event_version, event_type, dict(payload))
for event_version, event_type, payload in cls._events
if event_version > version
]

View File

@@ -1,99 +0,0 @@
# app/src/core/event_titles.py
from __future__ import annotations
EVENT_TITLES = {
# Сигналы
"signal_summary": "Сигнал",
"signal_ready": "Сигнал",
# Execution
"position_opened": "Позиция",
"position_closed": "Позиция",
"position_flipped": "Позиция",
"position_flip_blocked": "Позиция",
# Настройки
"auto_settings_updated": "Автоторговля",
"auto_status_changed": "Автоторговля",
"risk_settings_updated": "Защита",
# Аналитика автоторговли
"market_state_changed": "Автоторговля",
"market_volatility_changed": "Автоторговля",
# Рыночные данные runtime
"market_monitor_started": "Автоторговля",
"market_monitor_stopped": "Автоторговля",
"market_stream_connected": "Автоторговля",
"market_stream_disconnected": "Автоторговля",
"market_symbol_changed": "Автоторговля",
# Мониторинг позиций
"entry_blocked": "Вход в позицию",
# Журнал
"journal_exported": "Журнал",
"journal_export_error": "Журнал",
"journal_cleared": "Журнал",
# Уведомления
"notification_sent": "Уведомление",
"notification_error": "Уведомление",
# Приложение
"app_started": "Приложение",
"app_bootstrap_failed": "Приложение",
# Legacy
"app_start": "Приложение",
"journal_open_requested": "Журнал",
"journal_export_csv_success": "Журнал",
"journal_export_csv_error": "Журнал",
"journal_export_xlsx_success": "Журнал",
"journal_export_xlsx_error": "Журнал",
"journal_cleared_old": "Журнал",
"journal_debug_changed": "Журнал",
"system_open_requested": "Система",
"system_open_alert": "Система",
"system_open_success": "Система",
"system_retry": "Система",
"system_about_opened": "Система",
"portfolio_open_requested": "Портфель",
"portfolio_open_success": "Портфель",
"portfolio_open_error": "Портфель",
"portfolio_partial_estimate": "Портфель",
"exchange_request_error": "Биржа",
"exchange_auth_error": "Аккаунт",
"exchange_auth_restored": "Аккаунт",
"exchange_time_sync_error": "Время биржи",
"exchange_time_sync_restored": "Время биржи",
"balance_summary_loaded": "Баланс",
"balance_summary_error": "Баланс",
"runtime_expired": "Runtime",
"market_status_unavailable": "Автоторговля",
"market_status_restored": "Автоторговля",
"market_closed": "Автоторговля",
"market_rest_fallback_available": "Автоторговля",
"market_rest_fallback_unavailable": "Автоторговля",
"ws_depth_alive": "WebSocket debug",
}
def event_title(event_type: object) -> str:
value = str(event_type or "").strip()
if not value:
return "Событие"
return EVENT_TITLES.get(value, "Событие")

View File

@@ -1,51 +0,0 @@
# app/src/core/numbers.py
from __future__ import annotations
def safe_float(
value: object,
default: float | None = None,
) -> float | None:
if value is None:
return default
if isinstance(value, bool):
return default
try:
return float(str(value).strip())
except (TypeError, ValueError):
return default
def safe_round(
value: object,
digits: int,
) -> float | None:
"""
Безопасное округление.
None -> None
любое число -> round(...)
"""
number = safe_float(value)
if number is None:
return None
return round(number, digits)
def get_value(value: object) -> object | None:
"""
Возвращает значение Enum или сам объект.
Enum -> .value
None -> None
Остальные типы -> без изменений.
"""
if value is None:
return None
return getattr(value, "value", value)

View File

@@ -1,17 +1,11 @@
# app/src/core/system_status.py
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings from src.core.config import load_settings
from src.core.constants import APP_NAME, APP_VERSION from src.core.constants import APP_NAME, APP_VERSION
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.status import build_exchange_error_status
from src.storage.session import check_database_health from src.storage.session import check_database_health
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
@@ -34,96 +28,6 @@ class SystemSnapshot:
components: list[ComponentStatus] components: list[ComponentStatus]
def _build_exchange_alert_components(
*,
default_symbol: str,
) -> list[ComponentStatus]:
exchange_service = ExchangeService()
try:
runtime_status = exchange_service.get_symbol_runtime_status(default_symbol)
except Exception as exc:
runtime_status = build_exchange_error_status(exc)
if not runtime_status.is_available:
return [
ComponentStatus(
name="Биржа",
state=runtime_status.ui_line,
details=runtime_status.message,
)
]
alerts = build_runtime_exchange_alerts(symbol=default_symbol)
exchange_unavailable_alert = next(
(
alert
for alert in alerts
if str(alert.get("code") or "") == "EXCHANGE_UNAVAILABLE"
),
None,
)
if exchange_unavailable_alert is not None:
return [
ComponentStatus(
name="Биржа",
state=str(
exchange_unavailable_alert.get("ui_line")
or exchange_unavailable_alert.get("title")
or "⛔️ Биржа недоступна"
),
details=str(exchange_unavailable_alert.get("details") or ""),
)
]
components: list[ComponentStatus] = [
ComponentStatus(
name="Биржа",
state="🟢",
details=runtime_status.message,
)
]
has_account_alert = False
for alert in alerts:
code = str(alert.get("code") or "")
state = str(
alert.get("ui_line")
or alert.get("title")
or "⛔️ Ошибка биржи"
)
if code == "AUTH_ERROR":
has_account_alert = True
name = "Аккаунт"
elif code == "TIME_ERROR":
name = "Время биржи"
else:
name = "Биржа"
components.append(
ComponentStatus(
name=name,
state=state,
details=str(alert.get("details") or ""),
)
)
if not has_account_alert:
components.append(
ComponentStatus(
name="Аккаунт",
state="🟢",
)
)
return components
# извлечь короткую версию PostgreSQL из строки health-check
def _extract_postgres_version(raw: str) -> str: def _extract_postgres_version(raw: str) -> str:
if not raw: if not raw:
return "PostgreSQL" return "PostgreSQL"
@@ -135,72 +39,94 @@ def _extract_postgres_version(raw: str) -> str:
return "PostgreSQL" return "PostgreSQL"
# проверить подключение к БД и вернуть компонент + подпись версии def _build_exchange_status(
exchange_service: ExchangeService, default_symbol: str
) -> ComponentStatus:
try:
symbol_validation = exchange_service.validate_symbol(default_symbol)
except Exception as exc:
return ComponentStatus(
name="Биржа",
state="🔴",
details=f"Не удалось проверить инструмент: {exc}",
)
exchange_health = exchange_service.get_health()
if exchange_health.ok and symbol_validation.is_valid:
return ComponentStatus(name="Биржа", state="🟢")
if not exchange_health.ok:
return ComponentStatus(
name="Биржа",
state="🔴",
details=exchange_health.message or "Ошибка подключения к API биржи.",
)
return ComponentStatus(
name="Биржа",
state="🔴",
details=symbol_validation.message or "Инструмент не прошёл проверку.",
)
def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus:
private_auth_health = exchange_service.get_private_auth_health()
if private_auth_health.ok:
return ComponentStatus(name="Аккаунт", state="🟢")
return ComponentStatus(
name="Аккаунт",
state="🔴",
details=private_auth_health.message or "Ошибка private API.",
)
def _build_database_status() -> tuple[ComponentStatus, str]: def _build_database_status() -> tuple[ComponentStatus, str]:
db_ok, db_message = check_database_health() db_ok, db_message = check_database_health()
db_label = _extract_postgres_version(db_message) db_label = _extract_postgres_version(db_message)
if db_ok: if db_ok:
return ( return ComponentStatus(name="База данных", state="🟢"), db_label
ComponentStatus(
name="База данных",
state="🟢",
),
db_label,
)
return ( return (
ComponentStatus( ComponentStatus(
name="База данных", name="База данных",
state="🔴 База данных недоступна", state="🔴",
details=db_message or "Ошибка подключения к БД.",
), ),
db_label, db_label,
) )
# проверить доступность журнала событий
def _build_journal_status() -> ComponentStatus: def _build_journal_status() -> ComponentStatus:
ok, _ = JournalService().get_journal_health() ok, message = JournalService().get_journal_health()
if ok: if ok:
return ComponentStatus( return ComponentStatus(name="Журнал", state="🟢")
name="Журнал",
state="🟢",
)
return ComponentStatus( return ComponentStatus(name="Журнал", state="🔴", details=message)
name="Журнал",
state="🔴 Журнал недоступен",
)
# определить runtime-режим по base_url биржи def _resolve_mode_label(settings) -> str:
def get_runtime_mode_key() -> str: is_demo = "demo" in settings.exchange_base_url.lower()
settings = load_settings() return "ДЕМО аккаунт" if is_demo else "РЕАЛЬНЫЙ аккаунт"
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
# вернуть человекочитаемую подпись runtime-режима
def get_runtime_mode_label() -> str:
return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт"
# собрать полный snapshot системного экрана
def get_system_snapshot() -> SystemSnapshot: def get_system_snapshot() -> SystemSnapshot:
settings = load_settings() settings = load_settings()
exchange_service = ExchangeService()
database_status, db_label = _build_database_status() database_status, db_label = _build_database_status()
exchange_status = _build_exchange_status(exchange_service, settings.default_symbol)
account_status = _build_account_status(exchange_service)
journal_status = _build_journal_status() journal_status = _build_journal_status()
exchange_components = _build_exchange_alert_components(
default_symbol=settings.default_symbol,
)
components = [ components = [
ComponentStatus(name="Приложение", state="🟢"), ComponentStatus(name="Приложение", state="🟢"),
database_status, database_status,
ComponentStatus(name="Telegram", state="🟢"), ComponentStatus(name="Telegram", state="🟢"),
*exchange_components, exchange_status,
account_status,
journal_status, journal_status,
] ]
@@ -209,54 +135,32 @@ def get_system_snapshot() -> SystemSnapshot:
app_version=APP_VERSION, app_version=APP_VERSION,
db_label=db_label, db_label=db_label,
timezone_name=settings.tz, timezone_name=settings.tz,
mode_label=get_runtime_mode_label(), mode_label=_resolve_mode_label(settings),
default_symbol=settings.default_symbol, default_symbol=settings.default_symbol,
components=components, components=components,
) )
# определить, есть ли системные предупреждения
def has_system_alerts(snapshot: SystemSnapshot) -> bool:
return any(component.state != "🟢" for component in snapshot.components)
# отрендерить одну строку компонента системы
def _render_component(component: ComponentStatus) -> str: def _render_component(component: ComponentStatus) -> str:
if component.state == "🟢": if component.state == "🟢":
return f"{component.state} {component.name}" return f"{component.state} <b>{component.name}</b>"
return component.state return f"{component.state} <b>{component.name}</b>\n{component.details}"
# получить текущее локальное время для подписи обновления def build_system_text() -> str:
def _now_hhmmss() -> str:
settings = load_settings()
tz_name = settings.tz or "UTC"
try:
local_dt = datetime.now(ZoneInfo(tz_name))
except Exception:
local_dt = datetime.utcnow()
return local_dt.strftime("%H:%M:%S")
# собрать текст экрана "Система"
def build_system_text(*, include_updated_at: bool = False) -> str:
snapshot = get_system_snapshot() snapshot = get_system_snapshot()
components_block = "\n".join( components_block = "\n".join(
_render_component(component) _render_component(component) for component in snapshot.components
for component in snapshot.components
) )
text = ( return (
"<b>🖥 Система</b>\n" "<b> Система</b>\n\n"
f"🔸 <b>{snapshot.mode_label}</b>\n\n" f"{components_block}\n\n"
f"{components_block}" "<b>🌐 Окружение</b>\n"
f"• приложение: {snapshot.app_name} {snapshot.app_version}\n"
f"• база данных: {snapshot.db_label}\n"
f"• часовой пояс: {snapshot.timezone_name}\n"
f"• режим: {snapshot.mode_label}\n"
f"• инструмент: {snapshot.default_symbol}"
) )
if include_updated_at:
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
return text

View File

@@ -1,18 +0,0 @@
# app/src/core/telegram_errors.py
from __future__ import annotations
from aiogram.exceptions import TelegramBadRequest
def is_message_not_modified(exc: TelegramBadRequest) -> bool:
return "message is not modified" in str(exc).lower()
def is_message_to_edit_not_found(exc: TelegramBadRequest) -> bool:
text = str(exc).lower()
return (
"message to edit not found" in text
or "message_id_invalid" in text
)

View File

@@ -1,12 +0,0 @@
# app/src/core/types.py
from __future__ import annotations
from typing import Any, TypeAlias
NumericLike: TypeAlias = float | int | str
JsonDict: TypeAlias = dict[str, Any]
JsonList: TypeAlias = list[Any]

View File

@@ -1,5 +1,3 @@
# app/src/integrations/exchange/exceptions.py
from __future__ import annotations from __future__ import annotations
@@ -13,41 +11,3 @@ class ExchangeConnectionError(ExchangeError):
class ExchangeResponseError(ExchangeError): class ExchangeResponseError(ExchangeError):
"""Unexpected HTTP response or malformed JSON.""" """Unexpected HTTP response or malformed JSON."""
def is_exchange_time_sync_error(exc: Exception) -> bool:
text = str(exc).lower()
return (
"-1021" in text
or "doesn't match server time" in text
or "server time" in text and "match" in text
or "рассинхрон" in text
)
def format_exchange_error_for_user(exc: Exception) -> str:
if is_exchange_time_sync_error(exc):
return (
"Биржа отклонила запрос из-за рассинхронизации времени. "
"Проверь системное время и повтори попытку."
)
if isinstance(exc, ExchangeConnectionError):
return (
"Не удалось получить данные биржи: таймаут или ошибка сети. "
"Попробуй ещё раз через несколько секунд."
)
if isinstance(exc, ExchangeResponseError):
return (
"Биржа вернула ошибку ответа. "
"Попробуй ещё раз через несколько секунд."
)
if isinstance(exc, ExchangeError):
return (
"Не удалось получить данные биржи. "
"Попробуй ещё раз через несколько секунд."
)
return "Временная ошибка получения данных биржи. Попробуй ещё раз через несколько секунд."

View File

@@ -1,733 +0,0 @@
# app/src/integrations/exchange/market_data_runner.py
from __future__ import annotations
import asyncio
import time
import traceback
from dataclasses import dataclass
from typing import Callable
from src.core.types import JsonDict
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
from src.market_data.acquisition.adapters.dzengi.websocket import (
DzengiWebSocketQuoteAdapter,
)
from src.market_data.acquisition.exceptions import (
MarketDataAcquisitionError,
)
from src.storage.quote_store import (
QUOTE_RUNTIME_SOURCE_NAME,
get_quote_store,
)
from src.trading.journal.service import JournalService
@dataclass
class MarketRuntimeContext:
runtime_key: str
task: asyncio.Task | None
interval_seconds: int
symbol_provider: Callable[[], str | None]
screen: str | None
action: str
runtime_label: str | None
last_market_status: str | None = None
# Dedup runtime-событий, чтобы журнал не разрастался одинаковыми ошибками.
last_status_error_key: str | None = None
last_stream_state: str | None = None
last_stream_error_key: str | None = None
last_rest_state: str | None = None
last_rest_error_key: str | None = None
last_ws_debug_logged_at: float = 0.0
class MarketDataRunner:
_runtimes: dict[str, MarketRuntimeContext] = {}
# Global dedupe runtime-событий между start/stop runtime.
_global_runtime_event_timestamps: dict[str, float] = {}
# Минимальный интервал повторного логирования одного runtime-состояния.
# Состояние в UI может меняться чаще, но журнал не должен разрастаться.
_runtime_log_cooldown_seconds = 300
@classmethod
def get_runtime_state(cls, runtime_key: str = "default") -> dict[str, object]:
context = cls._runtimes.get(runtime_key)
if context is None:
return {
"stream_state": None,
"stream_error": None,
"rest_state": None,
}
return {
"stream_state": context.last_stream_state,
"stream_error": context.last_stream_error_key,
"rest_state": context.last_rest_state,
}
@classmethod
def _can_log_runtime_event(
cls,
event_key: str,
) -> bool:
now = time.monotonic()
last_logged_at = cls._global_runtime_event_timestamps.get(event_key)
if last_logged_at is not None:
if (
now - last_logged_at
) < cls._runtime_log_cooldown_seconds:
return False
cls._global_runtime_event_timestamps[event_key] = now
return True
@classmethod
def start(
cls,
*,
symbol_provider: Callable[[], str | None],
interval_seconds: int = 1,
runtime_key: str = "default",
screen: str | None = None,
action: str = "market_data",
runtime_label: str | None = None,
) -> None:
existing = cls._runtimes.get(runtime_key)
if (
existing is not None
and existing.task is not None
and not existing.task.done()
):
existing.symbol_provider = symbol_provider
existing.interval_seconds = interval_seconds
existing.screen = screen
existing.action = action
existing.runtime_label = runtime_label
return
context = MarketRuntimeContext(
runtime_key=runtime_key,
task=None,
interval_seconds=interval_seconds,
symbol_provider=symbol_provider,
screen=screen,
action=action,
runtime_label=runtime_label,
)
cls._runtimes[runtime_key] = context
cls._log_info(
context,
"market_monitor_started",
"Мониторинг рынка запущен.",
{"interval_seconds": interval_seconds},
)
context.task = asyncio.create_task(cls._worker(context))
@classmethod
def stop(cls, runtime_key: str | None = None) -> None:
if runtime_key is None:
for key in list(cls._runtimes.keys()):
cls.stop(key)
return
context = cls._runtimes.get(runtime_key)
if context is None:
return
if context.task is not None:
context.task.cancel()
context.task = None
cls._log_info(
context,
"market_monitor_stopped",
"Мониторинг рынка остановлен.",
)
cls._runtimes.pop(runtime_key, None)
@classmethod
async def _worker(cls, context: MarketRuntimeContext) -> None:
last_symbol: str | None = None
try:
while True:
symbol = context.symbol_provider()
if not symbol:
await asyncio.sleep(context.interval_seconds)
continue
cache_symbol = cls._cache_symbol(symbol)
ws_symbol = cls._ws_symbol(symbol)
if symbol != last_symbol:
last_symbol = symbol
if not cls._is_cache_symbol_used_by_other_runtime(
runtime_key=context.runtime_key,
cache_symbol=cache_symbol,
):
get_quote_store().clear(
source_name=QUOTE_RUNTIME_SOURCE_NAME,
symbol=cache_symbol,
)
try:
market_status = ExchangeService().get_symbol_market_status(symbol)
except asyncio.CancelledError:
raise
except Exception as exc:
error_key = f"{type(exc).__name__}:{str(exc)}"
if context.last_status_error_key != error_key:
context.last_status_error_key = error_key
cls._log_warning(
context,
"market_status_unavailable",
"Статус рынка временно недоступен.",
{
"symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
)
await asyncio.sleep(context.interval_seconds)
continue
if context.last_status_error_key is not None:
context.last_status_error_key = None
cls._log_info(
context,
"market_status_restored",
"Статус рынка снова доступен.",
{
"symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
},
)
status_key = str(market_status.get("status") or "UNKNOWN")
if not bool(market_status.get("is_open")):
if context.last_market_status != status_key:
context.last_market_status = status_key
cls._log_warning(
context,
"market_closed",
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
{
"symbol": symbol,
"market_status": status_key,
"message": market_status.get("message"),
},
)
await asyncio.sleep(context.interval_seconds)
continue
context.last_market_status = status_key
try:
await cls._run_websocket(context, symbol)
except asyncio.CancelledError:
raise
except Exception as exc:
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
should_log_disconnected = (
(
context.last_stream_state != "DISCONNECTED"
or context.last_stream_error_key != error_key
)
and cls._can_log_runtime_event(
f"market_stream_disconnected:{symbol}:{error_key}"
)
)
context.last_stream_state = "DISCONNECTED"
context.last_stream_error_key = error_key
if should_log_disconnected:
cls._log_warning(
context,
"market_stream_disconnected",
"Live-поток рыночных данных отключён. Используется REST-режим.",
{
"symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
)
await cls._rest_fallback_once(context, symbol)
await asyncio.sleep(context.interval_seconds)
except asyncio.CancelledError:
# stop() уже пишет market_monitor_stopped.
# Здесь не логируем, чтобы в журнале не было дубля остановки.
raise
@classmethod
async def _run_websocket(
cls,
context: MarketRuntimeContext,
symbol: str,
) -> None:
cache_symbol = cls._cache_symbol(symbol)
ws_symbol = cls._ws_symbol(symbol)
valid_payload_count = 0
invalid_payload_count = 0
adapter = DzengiWebSocketQuoteAdapter()
async for payload in ExchangeWebSocketClient().stream_depth(
ws_symbol,
interval_seconds=context.interval_seconds,
):
current_symbol = context.symbol_provider()
if current_symbol and current_symbol != symbol:
break
try:
quote = adapter.map_message(payload)
except MarketDataAcquisitionError:
invalid_payload_count += 1
if invalid_payload_count >= 5:
raise RuntimeError(
"WebSocket depth stream does not contain valid quotes."
)
continue
if quote.symbol.strip().upper() != cache_symbol.strip().upper():
invalid_payload_count += 1
if invalid_payload_count >= 5:
raise RuntimeError(
"WebSocket depth stream returned another symbol."
)
continue
invalid_payload_count = 0
best_bid = float(quote.bid_price)
best_ask = float(quote.ask_price)
if valid_payload_count == 0:
should_log_connected = (
context.last_stream_state != "CONNECTED"
and cls._can_log_runtime_event(
f"market_stream_connected:{symbol}"
)
)
context.last_stream_state = "CONNECTED"
context.last_stream_error_key = None
context.last_rest_state = None
context.last_rest_error_key = None
if should_log_connected:
cls._log_info(
context,
"market_stream_connected",
"Live-поток рыночных данных подключён.",
{
"requested_symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"bid_price": best_bid,
"ask_price": best_ask,
"payload_keys": list(payload.keys()),
"payload_preview": cls._safe_payload_preview(
cls._extract_depth_payload(payload)
),
},
)
valid_payload_count += 1
get_quote_store().set(
QUOTE_RUNTIME_SOURCE_NAME,
quote,
runtime_key=context.runtime_key,
)
cls._log_ws_depth_debug(
context=context,
symbol=symbol,
cache_symbol=cache_symbol,
ws_symbol=ws_symbol,
best_bid=best_bid,
best_ask=best_ask,
valid_payload_count=valid_payload_count,
)
@classmethod
async def _rest_fallback_once(
cls,
context: MarketRuntimeContext,
symbol: str,
) -> None:
try:
await asyncio.to_thread(
ExchangeService().refresh_quote_cache,
symbol,
runtime_key=context.runtime_key,
)
should_log_rest_available = (
context.last_rest_state != "AVAILABLE"
and cls._can_log_runtime_event(
f"market_rest_fallback_available:{symbol}"
)
)
context.last_rest_state = "AVAILABLE"
context.last_rest_error_key = None
if should_log_rest_available:
cls._log_info(
context,
"market_rest_fallback_available",
"REST-режим рыночных данных доступен. Live-поток пока недоступен.",
{
"symbol": symbol,
"cache_symbol": cls._cache_symbol(symbol),
"ws_symbol": cls._ws_symbol(symbol),
},
)
except Exception as exc:
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
should_log_rest_unavailable = (
(
context.last_rest_state != "UNAVAILABLE"
or context.last_rest_error_key != error_key
)
and cls._can_log_runtime_event(
f"market_rest_fallback_unavailable:{symbol}:{error_key}"
)
)
context.last_rest_state = "UNAVAILABLE"
context.last_rest_error_key = error_key
if should_log_rest_unavailable:
cls._log_error(
context,
"market_rest_fallback_unavailable",
"Live-поток отключён. REST-режим рыночных данных недоступен.",
{
"symbol": symbol,
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(limit=5),
},
)
@classmethod
def _is_cache_symbol_used_by_other_runtime(
cls,
*,
runtime_key: str,
cache_symbol: str,
) -> bool:
for key, context in cls._runtimes.items():
if key == runtime_key:
continue
try:
symbol = context.symbol_provider()
except Exception:
continue
if symbol and cls._cache_symbol(symbol) == cache_symbol:
return True
return False
@classmethod
def _cache_symbol(cls, symbol: str) -> str:
try:
validation = ExchangeService().validate_symbol(symbol)
if validation.is_valid:
return validation.normalized_symbol
except Exception:
pass
return symbol
@classmethod
def _ws_symbol(cls, symbol: str) -> str:
return cls._cache_symbol(symbol)
@classmethod
def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict:
data: object = payload
for key in ("payload", "Payload"):
if isinstance(data, dict) and isinstance(data.get(key), dict):
data = data.get(key)
if isinstance(data, dict):
for key in ("payload", "Payload"):
nested = data.get(key)
if isinstance(nested, dict):
return nested
return data
return payload
@classmethod
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
preview: JsonDict = {}
for key, value in payload.items():
if key in {"bids", "asks"} and isinstance(value, list):
preview[key] = value[:2]
elif key == "payload" and isinstance(value, dict):
inner_preview: JsonDict = {}
for inner_key, inner_value in value.items():
if (
inner_key in {"bids", "asks"}
and isinstance(inner_value, list)
):
inner_preview[inner_key] = inner_value[:2]
else:
inner_preview[inner_key] = inner_value
preview[key] = inner_preview
else:
preview[key] = value
return preview
@classmethod
def _log_ws_depth_debug(
cls,
*,
context: MarketRuntimeContext,
symbol: str,
cache_symbol: str,
ws_symbol: str,
best_bid: float,
best_ask: float,
valid_payload_count: int,
) -> None:
now = time.monotonic()
if now - context.last_ws_debug_logged_at < 60:
return
context.last_ws_debug_logged_at = now
cls._log_debug(
context,
"ws_depth_alive",
"WS depth поток активен.",
{
"symbol": symbol,
"cache_symbol": cache_symbol,
"ws_symbol": ws_symbol,
"runtime_key": context.runtime_key,
"bid_price": best_bid,
"ask_price": best_ask,
"spread_percent": cls._spread_percent(best_bid, best_ask),
"valid_payload_count": valid_payload_count,
"source": f"ws_depth:{context.runtime_key}",
},
)
@classmethod
def _spread_percent(cls, bid_price: float, ask_price: float) -> float | None:
mid_price = (bid_price + ask_price) / 2
if mid_price <= 0:
return None
return round(((ask_price - bid_price) / mid_price) * 100, 5)
@classmethod
def _log_debug(
cls,
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: JsonDict | None = None,
) -> None:
try:
if context.screen:
JournalService().log_ui_debug(
event_type=event_type,
message=cls._message(context, message),
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
)
return
JournalService().log_debug(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass
@classmethod
def _message(
cls,
context: MarketRuntimeContext,
message: str,
) -> str:
return message
@classmethod
def _payload(
cls,
context: MarketRuntimeContext,
payload: JsonDict | None = None,
) -> JsonDict:
result: JsonDict = dict(payload or {})
result.setdefault("runtime_key", context.runtime_key)
if context.screen:
result.setdefault("runtime_screen", context.screen)
if context.runtime_label:
result.setdefault("runtime_label", context.runtime_label)
return result
@classmethod
def _log_info(
cls,
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: JsonDict | None = None,
) -> None:
try:
if context.screen:
JournalService().log_ui_info(
event_type=event_type,
message=cls._message(context, message),
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
)
return
JournalService().log_info(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass
@classmethod
def _log_warning(
cls,
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: JsonDict | None = None,
) -> None:
try:
if context.screen:
JournalService().log_ui_warning(
event_type=event_type,
message=cls._message(context, message),
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
)
return
JournalService().log_warning(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass
@classmethod
def _log_error(
cls,
context: MarketRuntimeContext,
event_type: str,
message: str,
payload: JsonDict | None = None,
) -> None:
try:
error_type = None
raw_error = None
if payload:
error_type = payload.get("error_type")
raw_error = payload.get("error")
if context.screen:
JournalService().log_ui_error(
event_type=event_type,
message=cls._message(context, message),
screen=context.screen,
action=context.action,
payload=cls._payload(context, payload),
error_type=str(error_type) if error_type is not None else None,
raw_error=str(raw_error) if raw_error is not None else None,
)
return
JournalService().log_error(
event_type,
cls._message(context, message),
cls._payload(context, payload),
)
except Exception:
pass

View File

@@ -1,77 +0,0 @@
# app/src/integrations/exchange/market_stream.py
from __future__ import annotations
import asyncio
from src.core.config import load_settings
from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
from src.market_data.acquisition.adapters.dzengi.websocket import (
DzengiWebSocketQuoteAdapter,
)
from src.market_data.acquisition.exceptions import (
MarketDataAcquisitionError,
)
from src.storage.quote_store import (
QUOTE_RUNTIME_SOURCE_NAME,
get_quote_store,
)
from src.trading.journal.service import JournalService
# запускает постоянный websocket-поток рынка и обновляет Quote Store
async def start_market_stream() -> None:
settings = load_settings()
journal = JournalService()
if not settings.exchange_enabled:
return
while True:
try:
service = ExchangeService()
validation = service.validate_symbol(settings.default_symbol)
if not validation.is_valid:
await asyncio.sleep(10)
continue
symbol = validation.normalized_symbol
client = ExchangeWebSocketClient()
adapter = DzengiWebSocketQuoteAdapter()
journal.log_info(
"market_ws_started",
"WebSocket market stream запущен.",
{"symbol": symbol},
)
async for message in client.stream_depth(symbol):
try:
quote = adapter.map_message(message)
except MarketDataAcquisitionError:
continue
if quote.symbol.strip().upper() != symbol.strip().upper():
continue
get_quote_store().set(
QUOTE_RUNTIME_SOURCE_NAME,
quote,
runtime_key="default",
)
except asyncio.CancelledError:
raise
except Exception as exc:
try:
journal.log_warning(
"market_ws_reconnect",
f"WebSocket market stream будет переподключен: {exc}",
{"raw_error": str(exc)},
)
except Exception:
pass
await asyncio.sleep(5)

View File

@@ -1,12 +1,8 @@
# app/src/integrations/exchange/mock_data.py
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice
from src.market_data.acquisition.models.quote import Quote
def mock_exchange_health() -> ExchangeHealth: def mock_exchange_health() -> ExchangeHealth:
@@ -17,23 +13,20 @@ def mock_exchange_health() -> ExchangeHealth:
) )
def mock_quote(symbol: str) -> Quote: def mock_ticker_price(symbol: str) -> TickerPrice:
normalized_symbol = symbol.upper().strip() symbol = symbol.upper().strip()
fake_prices = { fake_prices = {
"BTCUSDT": Decimal("68425.10"), "BTCUSDT": 68425.10,
"ETHUSDT": Decimal("3521.44"), "ETHUSDT": 3521.44,
"BNBUSDT": Decimal("612.33"), "BNBUSDT": 612.33,
} }
price = fake_prices.get(normalized_symbol, Decimal("100.00")) price = fake_prices.get(symbol, 100.00)
updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return Quote( return TickerPrice(
symbol=normalized_symbol, symbol=symbol,
last_price=price, price=price,
bid_price=price,
ask_price=price,
exchange_timestamp=None,
received_at=datetime.now(timezone.utc),
source="mock", source="mock",
updated_at=updated_at,
) )

View File

@@ -1,16 +1,8 @@
# app/src/integrations/exchange/models.py
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.market_data.acquisition.models.instrument import Instrument
# Состояние публичного API биржи.
@dataclass(slots=True) @dataclass(slots=True)
class ExchangeHealth: class ExchangeHealth:
ok: bool ok: bool
@@ -18,40 +10,14 @@ class ExchangeHealth:
message: str message: str
# Состояние синхронизации времени сервера и биржи.
@dataclass(slots=True) @dataclass(slots=True)
class TimeSyncStatus: class TickerPrice:
ok: bool
local_time: str
exchange_time: str | None
drift_seconds: float | None
hostname: str
local_ip: str | None
message: str
# Snapshot цен для execution layer.
@dataclass(slots=True)
class ExecutionPriceSnapshot:
symbol: str symbol: str
price: float
last_price: float
bid_price: float
ask_price: float
updated_at: str
source: str source: str
updated_at: str
is_fresh: bool
age_seconds: float | None = None
freshness_status: str = "UNKNOWN"
spread_percent: float | None = None
# Баланс актива аккаунта.
@dataclass(slots=True) @dataclass(slots=True)
class BalanceSummary: class BalanceSummary:
currency: str currency: str
@@ -60,40 +26,28 @@ class BalanceSummary:
source: str source: str
# Результат проверки торгового символа по каноническому справочнику Instrument. @dataclass(slots=True)
class ExchangeSymbol:
symbol: str
name: str
status: str
base_asset: str
quote_asset: str
market_modes: list[str]
market_type: str
tick_size: float | None
@dataclass(slots=True) @dataclass(slots=True)
class SymbolValidationResult: class SymbolValidationResult:
requested_symbol: str requested_symbol: str
normalized_symbol: str normalized_symbol: str
is_valid: bool is_valid: bool
message: str message: str
symbol_info: ExchangeSymbol | None
symbol_info: Instrument | None
# Состояние приватного API аккаунта.
@dataclass(slots=True) @dataclass(slots=True)
class PrivateAuthHealth: class PrivateAuthHealth:
ok: bool ok: bool
message: str message: str
# Runtime-статус рынка инструмента.
@dataclass(slots=True)
class ExchangeMarketStatus:
symbol: str
is_open: bool
status: str
message: str
# Информация о торговой комиссии для инструмента.
@dataclass(slots=True)
class TradingFee:
symbol: str
name: str
fee_percent: float | None = None
overnight_long_rate: float | None = None
overnight_short_rate: float | None = None
overnight_fee_timestamp: int | None = None

View File

@@ -1,5 +1,3 @@
# app/src/integrations/exchange/private_client.py
from __future__ import annotations from __future__ import annotations
from src.core.config import load_settings from src.core.config import load_settings
@@ -23,7 +21,7 @@ class ExchangePrivateClient:
signed = self.auth.build_signed_params(params) signed = self.auth.build_signed_params(params)
return self.client.get_json( return self.client.get_json(
"/api/v1/account", "/api/v2/account",
params=signed, params=signed,
headers=self.auth.build_headers(), headers=self.auth.build_headers(),
) )

View File

@@ -1,5 +1,3 @@
# app/src/integrations/exchange/rest_client.py
from __future__ import annotations from __future__ import annotations
import json import json
@@ -7,7 +5,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 Settings, load_settings from src.core.config import load_settings
from src.integrations.exchange.exceptions import ( from src.integrations.exchange.exceptions import (
ExchangeConnectionError, ExchangeConnectionError,
ExchangeResponseError, ExchangeResponseError,
@@ -15,74 +13,13 @@ from src.integrations.exchange.exceptions import (
class ExchangeRestClient: class ExchangeRestClient:
def __init__( def __init__(self) -> None:
self, self.settings = load_settings()
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("/")
self.timeout = self.settings.exchange_timeout_sec self.timeout = self.settings.exchange_timeout_sec
def get_payload(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> object:
query = f"?{urlencode(params)}" if params else ""
url = f"{self.base_url}{path}{query}"
request_headers = {
"Accept": "application/json",
"User-Agent": "dzentra-bot/2.0.0",
}
if headers:
request_headers.update(headers)
request = Request(
url=url,
method="GET",
headers=request_headers,
)
try:
with urlopen(request, timeout=self.timeout) as response:
status = getattr(response, "status", 200)
body = response.read().decode("utf-8")
except HTTPError as exc:
error_body = ""
try:
error_body = exc.read().decode("utf-8")
except Exception:
pass
message = f"HTTP {exc.code} from exchange: {exc.reason}"
if error_body:
message += f" | body: {error_body}"
raise ExchangeResponseError(message) from exc
except URLError as exc:
raise ExchangeConnectionError(
f"Network error while calling exchange: {exc.reason}"
) from exc
except TimeoutError as exc:
raise ExchangeConnectionError("Timeout while calling exchange.") from exc
if status != 200:
raise ExchangeResponseError(f"Unexpected HTTP status: {status}")
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise ExchangeResponseError("Exchange returned non-JSON response.") from exc
def get_json( def get_json(
self, self,
path: str, path: str,

View File

@@ -1,296 +0,0 @@
# app/src/integrations/exchange/runtime_ui.py
from __future__ import annotations
from src.core.numbers import safe_float
from src.integrations.exchange.models import TimeSyncStatus
from src.integrations.exchange.status import (
ExchangeRuntimeStatus,
ExchangeStatusCode,
build_exchange_error_status,
)
def format_drift_seconds(value: float | int | None) -> str:
number = safe_float(value)
if number is None:
return ""
sign = "-" if number < 0 else "+"
total_seconds = abs(int(round(number)))
minutes = total_seconds // 60
seconds = total_seconds % 60
if minutes > 0:
return f"{sign} {minutes} мин. {seconds} сек."
return f"{sign} {seconds} сек."
def build_time_sync_details(sync: TimeSyncStatus) -> str:
lines = [
"Проверь настройки времени на:",
f"Сервер: {sync.hostname}",
]
if sync.local_ip:
lines.append(f"IP: {sync.local_ip}")
lines.append("")
lines.append(f"Время сервера: {sync.local_time}")
if sync.exchange_time:
lines.append(f"Время биржи: {sync.exchange_time}")
lines.append(f"Расхождение: {format_drift_seconds(sync.drift_seconds)}")
return "\n".join(lines)
def get_time_sync_status_from_service() -> TimeSyncStatus:
from src.integrations.exchange.service import ExchangeService
return ExchangeService().get_time_sync_status()
def build_time_sync_details_from_service() -> str:
return build_time_sync_details(get_time_sync_status_from_service())
def build_exchange_error_ui_parts(
exc: Exception,
) -> tuple[ExchangeRuntimeStatus, str, str]:
status = build_exchange_error_status(exc)
if status.code == ExchangeStatusCode.AUTH_ERROR:
return (
status,
"⛔️ Ошибка доступа к аккаунту",
"Проверь API-ключ, Secret Key, IP whitelist и права доступа.",
)
if status.code == ExchangeStatusCode.TIME_ERROR:
return (
status,
"⛔️ Ошибка времени биржи",
build_time_sync_details_from_service(),
)
if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE:
return status, "⛔️ Биржа недоступна", ""
return status, status.ui_line, status.message
def build_runtime_exchange_status(exc: Exception) -> dict[str, object]:
status = build_exchange_error_status(exc)
if status.code == ExchangeStatusCode.TIME_ERROR:
sync = get_time_sync_status_from_service()
return {
"code": status.code.value,
"title": "Ошибка времени биржи",
"ui_line": "⛔️ Ошибка времени биржи",
"details": {
"hostname": sync.hostname,
"local_ip": sync.local_ip,
"local_time": sync.local_time,
"exchange_time": sync.exchange_time,
"drift_seconds": sync.drift_seconds,
},
"reason": status.reason,
"raw_error": status.raw_error,
}
_, title, details = build_exchange_error_ui_parts(exc)
return {
"code": status.code.value,
"title": title.replace("⛔️ ", "").strip(),
"ui_line": title,
"details": details,
"reason": status.reason,
"raw_error": status.raw_error,
}
def build_runtime_exchange_alerts(
*,
symbol: str | None = None,
exc: Exception | None = None,
include_exchange_unavailable: bool = True,
) -> list[dict[str, object]]:
from src.integrations.exchange.service import ExchangeService
alerts: list[dict[str, object]] = []
service = ExchangeService()
def add_alert(alert: dict[str, object] | None) -> None:
if not alert:
return
code = str(alert.get("code") or "")
reason = str(alert.get("reason") or "")
for existing in alerts:
if (
str(existing.get("code") or "") == code
and str(existing.get("reason") or "") == reason
):
return
alerts.append(alert)
if exc is not None:
add_alert(build_runtime_exchange_status(exc))
try:
runtime_status = service.get_symbol_runtime_status(symbol)
except Exception as status_exc:
if include_exchange_unavailable:
add_alert(build_runtime_exchange_status(status_exc))
else:
if include_exchange_unavailable and not runtime_status.is_available:
add_alert(
build_runtime_exchange_status(
Exception(runtime_status.raw_error or runtime_status.message)
)
)
try:
time_sync = service.get_time_sync_status()
except Exception as time_exc:
add_alert(build_runtime_exchange_status(time_exc))
else:
if not time_sync.ok:
add_alert(
{
"code": ExchangeStatusCode.TIME_ERROR.value,
"title": "Ошибка времени биржи",
"ui_line": "⛔️ Ошибка времени биржи",
"details": {
"hostname": time_sync.hostname,
"local_ip": time_sync.local_ip,
"local_time": time_sync.local_time,
"exchange_time": time_sync.exchange_time,
"drift_seconds": time_sync.drift_seconds,
},
"reason": "time_error",
"raw_error": time_sync.message,
}
)
try:
private_auth_health = service.get_private_auth_health()
except Exception as auth_exc:
add_alert(build_runtime_exchange_status(auth_exc))
else:
if not private_auth_health.ok:
add_alert(
build_runtime_exchange_status(
Exception(private_auth_health.message)
)
)
priority = {
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value: 10,
ExchangeStatusCode.TIME_ERROR.value: 20,
ExchangeStatusCode.AUTH_ERROR.value: 30,
}
alerts.sort(
key=lambda alert: priority.get(
str(alert.get("code") or ""),
999,
)
)
return alerts
def format_runtime_exchange_alert(alert: dict[str, object]) -> str:
title = str(
alert.get("ui_line")
or alert.get("title")
or "⛔️ Ошибка биржи"
).strip()
details = alert.get("details")
code = str(alert.get("code") or "")
lines = [title]
if isinstance(details, dict):
lines.append("Проверь настройки времени на:")
hostname = details.get("hostname")
local_ip = details.get("local_ip")
local_time = details.get("local_time")
exchange_time = details.get("exchange_time")
drift_seconds = details.get("drift_seconds")
if hostname:
lines.append(f"• Сервер: {hostname}")
if local_ip:
lines.append(f"• IP: {local_ip}")
if local_time:
lines.append(f"• Время сервера: {local_time}")
if exchange_time:
lines.append(f"• Время биржи: {exchange_time}")
lines.append(f"• Расхождение: {format_drift_seconds(drift_seconds)}")
return "\n".join(lines).strip()
if code == ExchangeStatusCode.AUTH_ERROR.value:
lines.extend([
"Проверь:",
"• API-ключ, Secret Key",
"• IP whitelist и права доступа",
])
return "\n".join(lines).strip()
details_text = str(details or "").strip()
if details_text:
lines.append(details_text)
return "\n".join(lines).strip()
def format_runtime_exchange_alerts(alerts: list[dict[str, object]]) -> str:
return "\n\n".join(
block
for block in (
format_runtime_exchange_alert(alert)
for alert in alerts
)
if block.strip()
).strip()
def build_runtime_exchange_alert_lines(
*,
symbol: str | None = None,
include_exchange_unavailable: bool = True,
) -> list[str]:
alerts = build_runtime_exchange_alerts(
symbol=symbol,
include_exchange_unavailable=include_exchange_unavailable,
)
lines: list[str] = []
for alert in alerts:
line = str(alert.get("ui_line") or alert.get("title") or "").strip()
if line and line not in lines:
lines.append(line)
return lines

File diff suppressed because it is too large Load Diff

View File

@@ -1,313 +0,0 @@
# app/src/integrations/exchange/status.py
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from src.integrations.exchange.exceptions import (
ExchangeConnectionError,
ExchangeResponseError,
)
from src.market_data.acquisition.models.status import (
InstrumentTradingState,
classify_instrument_status,
)
class ExchangeStatusCode(StrEnum):
OPEN = "OPEN"
BREAK = "BREAK"
EXCHANGE_UNAVAILABLE = "EXCHANGE_UNAVAILABLE"
AUTH_ERROR = "AUTH_ERROR"
TIME_ERROR = "TIME_ERROR"
INVALID_SYMBOL = "INVALID_SYMBOL"
UNKNOWN = "UNKNOWN"
@dataclass(slots=True)
class ExchangeRuntimeStatus:
code: ExchangeStatusCode
is_open: bool
is_available: bool
is_auth_ok: bool
title: str
message: str
ui_line: str
reason: str
symbol: str | None = None
raw_status: str | None = None
raw_error: str | None = None
# Вернуть статус в dict для старого UI-кода на время миграции.
def as_dict(self) -> dict[str, object]:
return {
"code": self.code.value,
"status": self.code.value,
"symbol": self.symbol,
"is_open": self.is_open,
"is_available": self.is_available,
"is_auth_ok": self.is_auth_ok,
"title": self.title,
"message": self.message,
"ui_line": self.ui_line,
"reason": self.reason,
"raw_status": self.raw_status,
"raw_error": self.raw_error,
}
def build_market_stale_status(
*,
symbol: str,
age_seconds: float | None,
updated_at: str | None = None,
) -> ExchangeRuntimeStatus:
age_text = "неизвестно" if age_seconds is None else f"{age_seconds:.0f}с"
updated_text = f" Последнее обновление: {updated_at}." if updated_at else ""
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Рынок закрыт",
message=(
f"Котировки по инструменту не обновляются. "
f"Возраст данных: {age_text}.{updated_text}"
),
ui_line="⏸️ Рынок закрыт",
reason="market_data_stale",
raw_status="STALE_MARKET_DATA",
symbol=symbol,
)
# Собрать статус mock-режима.
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN,
is_open=True,
is_available=True,
is_auth_ok=True,
title="Mock exchange",
message="Mock market is open.",
ui_line="🟢 Mock биржа",
reason="mock_exchange",
symbol=symbol,
raw_status="OPEN",
)
# Собрать статус ошибки авторизации аккаунта.
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
return build_exchange_error_status(exc)
# Собрать legacy runtime-статус по канонической классификации инструмента.
def build_market_status_from_symbol_status(
*,
raw_status: str | None,
symbol: str,
) -> ExchangeRuntimeStatus:
classification = classify_instrument_status(raw_status)
normalized_status = classification.normalized_status
if classification.state == InstrumentTradingState.OPEN:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN,
is_open=True,
is_available=True,
is_auth_ok=True,
title="Биржа доступна",
message="Рынок открыт.",
ui_line="🟢 Биржа доступна",
reason="market_open",
raw_status=normalized_status,
symbol=symbol,
)
if classification.state == InstrumentTradingState.NOT_TRADABLE:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Рынок недоступен",
message=f"Этот рынок недоступен для торговли: {symbol}.",
ui_line="⛔️ Рынок недоступен для торговли",
reason="market_not_tradable",
raw_status=normalized_status,
symbol=symbol,
)
if classification.state == InstrumentTradingState.BREAK:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Перерыв в торгах",
message=f"Торги по {symbol} временно остановлены.",
ui_line="⏸️ Перерыв в торгах",
reason="market_break",
raw_status=normalized_status,
symbol=symbol,
)
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.UNKNOWN,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Статус торгов неизвестен",
message=(
f"Биржа вернула неизвестный статус инструмента"
f"{f': {normalized_status}' if normalized_status else ''}."
),
ui_line="⚠️ Статус торгов неизвестен",
reason="market_status_unknown",
raw_status=normalized_status,
symbol=symbol,
)
# Собрать единый статус для неверного торгового инструмента.
def build_invalid_symbol_status(
*,
symbol: str,
message: str,
) -> ExchangeRuntimeStatus:
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.INVALID_SYMBOL,
is_open=False,
is_available=True,
is_auth_ok=True,
title="Инструмент недоступен",
message=message or f"Инструмент {symbol} недоступен.",
ui_line="⛔️ Инструмент недоступен",
reason="invalid_symbol",
raw_status="INVALID_SYMBOL",
symbol=symbol,
)
# Собрать единый статус по ошибке exchange/API.
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
error_type = classify_exchange_error(exc)
raw_error = str(exc)
if error_type == "auth":
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.AUTH_ERROR,
is_open=False,
is_available=True,
is_auth_ok=False,
title="Ошибка доступа к аккаунту",
message="Ошибка доступа к аккаунту.",
ui_line="⛔️ Ошибка доступа к аккаунту",
reason="auth_error",
raw_status="AUTH_ERROR",
raw_error=raw_error,
)
if error_type == "time":
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.TIME_ERROR,
is_open=False,
is_available=False,
is_auth_ok=True,
title="Ошибка времени",
message="Проверь синхронизацию времени.",
ui_line="⛔️ Ошибка времени биржи",
reason="time_error",
raw_status="TIME_ERROR",
raw_error=raw_error,
)
return ExchangeRuntimeStatus(
code=ExchangeStatusCode.EXCHANGE_UNAVAILABLE,
is_open=False,
is_available=False,
is_auth_ok=True,
title="Биржа недоступна",
message="Не удалось получить данные с биржи.",
ui_line="⛔️ Биржа недоступна",
reason="exchange_unavailable",
raw_status="EXCHANGE_UNAVAILABLE",
raw_error=raw_error,
)
# Классифицировать ошибку биржи для единого UI и логов.
def classify_exchange_error(exc: Exception) -> str:
text = str(exc).lower()
if any(
marker in text
for marker in [
"invalid api key",
"invalid api-key",
"api key",
"api-key",
"signature",
"unauthorized",
"forbidden",
"permissions",
"expired",
]
):
return "auth"
if any(
marker in text
for marker in [
"-1021",
"server time",
"doesn't match server time",
"рассинхрон",
]
):
return "time"
if isinstance(exc, ExchangeConnectionError):
return "network"
if isinstance(exc, ExchangeResponseError):
if "404" in text:
return "network"
if any(
marker in text
for marker in [
"404",
"timeout",
"timed out",
"connection error",
"network error",
"name or service not known",
"nodename nor servname",
"temporary failure",
]
):
return "network"
return "generic"
# Проверить, относится ли reason к unified exchange status layer.
def is_exchange_status_reason(reason: str | None) -> bool:
if not reason:
return False
normalized = str(reason).strip().upper()
return normalized in {
ExchangeStatusCode.OPEN.value,
ExchangeStatusCode.BREAK.value,
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value,
ExchangeStatusCode.AUTH_ERROR.value,
ExchangeStatusCode.TIME_ERROR.value,
ExchangeStatusCode.INVALID_SYMBOL.value,
ExchangeStatusCode.UNKNOWN.value,
}

View File

@@ -1,14 +1,23 @@
# app/src/integrations/exchange/symbol_utils.py
from __future__ import annotations from __future__ import annotations
from src.market_data.acquisition.symbols import (
normalize_symbol, def normalize_symbol(raw_symbol: str) -> str:
symbol_candidates, return (raw_symbol or "").strip().upper()
)
__all__ = [ def symbol_candidates(raw_symbol: str) -> list[str]:
"normalize_symbol", value = normalize_symbol(raw_symbol)
"symbol_candidates", if not value:
] return []
candidates = [value]
compact = value.replace("%2F", "/")
if compact not in candidates:
candidates.append(compact)
no_spaces = compact.replace(" ", "")
if no_spaces not in candidates:
candidates.append(no_spaces)
return candidates

View File

@@ -1,148 +0,0 @@
# app/src/integrations/exchange/ws_client.py
from __future__ import annotations
import asyncio
import json
from typing import AsyncIterator, cast
from uuid import uuid4
import websockets
from websockets.typing import Subprotocol
from src.core.config import load_settings
from src.core.numbers import safe_float
from src.core.types import JsonDict, JsonList, NumericLike
class ExchangeWebSocketClient:
def __init__(self) -> None:
self.settings = load_settings()
self.base_url = self._build_ws_base_url()
# собрать корректный websocket URL из настроек
def _build_ws_base_url(self) -> str:
raw_url = self.settings.exchange_ws_url or self.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"
# безопасно нормализовать паузу между websocket-запросами
def _interval_seconds(self, value: NumericLike | None) -> float:
interval = safe_float(value)
if interval is None or interval <= 0:
return 1.0
return interval
# собрать headers для подключения к websocket
def _headers(self) -> dict[str, str]:
headers = {
"Origin": self.settings.exchange_base_url.rstrip("/"),
"Content-Type": "application/json",
}
if self.settings.exchange_api_key:
headers["X-MBX-APIKEY"] = self.settings.exchange_api_key
return headers
# собрать payload запроса стакана
def _depth_request(self, symbol: str) -> JsonDict:
return {
"correlationId": str(uuid4()),
"destination": "/api/v1/depth",
"payload": {
"limit": 5,
"symbol": symbol,
},
}
# безопасно разобрать JSON от websocket
def _loads_json(self, raw_message: str | bytes) -> JsonDict | JsonList | None:
try:
payload = json.loads(raw_message)
except json.JSONDecodeError:
return None
if isinstance(payload, dict):
return cast(JsonDict, payload)
if isinstance(payload, list):
return cast(JsonList, payload)
return None
# поток данных стакана по websocket
async def stream_depth(
self,
symbol: str,
*,
interval_seconds: NumericLike = 1.0,
) -> AsyncIterator[JsonDict]:
interval = self._interval_seconds(interval_seconds)
headers = self._headers()
timeout_count = 0
max_timeouts = 3
async with websockets.connect(
self.base_url,
extra_headers=headers,
subprotocols=[Subprotocol("json")],
ping_interval=20,
open_timeout=self.settings.exchange_timeout_sec,
) as websocket:
last_ping_at = 0.0
while True:
now = asyncio.get_running_loop().time()
if now - last_ping_at >= 5.0:
pong = await websocket.ping()
await asyncio.wait_for(
pong,
timeout=self.settings.exchange_timeout_sec,
)
last_ping_at = now
request = self._depth_request(symbol)
await websocket.send(json.dumps(request))
try:
raw_message = await asyncio.wait_for(
websocket.recv(),
timeout=self.settings.exchange_timeout_sec,
)
except asyncio.TimeoutError as exc:
timeout_count += 1
if timeout_count >= max_timeouts:
raise RuntimeError(
"WebSocket depth stream timed out repeatedly."
) from exc
await asyncio.sleep(interval)
continue
timeout_count = 0
if not isinstance(raw_message, (str, bytes)):
await asyncio.sleep(interval)
continue
payload = self._loads_json(raw_message)
if isinstance(payload, dict):
yield payload
await asyncio.sleep(interval)

View File

@@ -1,14 +1,11 @@
# app/src/main.py
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:
application = create_app() bot, dispatcher = create_app()
await run_application(application) await dispatcher.start_polling(bot)
if __name__ == "__main__": if __name__ == "__main__":

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,664 +0,0 @@
# app/src/market_data/acquisition/adapters/dzengi/mapper.py
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from src.market_data.acquisition.adapters.dzengi.models import (
DzengiExchangeInfoResponse,
DzengiExchangeInfoSymbol,
DzengiInstrumentFilter,
DzengiKlinesResponse,
DzengiLotSizeFilter,
DzengiMinNotionalFilter,
DzengiRawNumeric,
DzengiRestAggTrade,
DzengiTicker24hrResponse,
DzengiWebSocketOhlcEvent,
DzengiWebSocketQuoteResponse,
DzengiWebSocketTradeEvent,
)
from src.market_data.acquisition.exceptions import (
CandleMappingError,
CandleWebSocketMappingError,
InstrumentReferenceMappingError,
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.quote import Quote
from src.market_data.acquisition.models.trade import (
Trade,
TradeAggressorSide,
)
_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(
symbol: DzengiExchangeInfoSymbol,
) -> Instrument:
"""
Преобразовать проверенную raw-модель инструмента Dzengi
во внутреннюю source-independent модель Instrument.
Функция предполагает, что до mapper уже были выполнены:
schema validation, parsing и value validation.
"""
lot_size = _find_single_filter(
symbol.filters,
DzengiLotSizeFilter,
filter_name="LOT_SIZE",
symbol=symbol.symbol,
)
min_notional = _find_single_filter(
symbol.filters,
DzengiMinNotionalFilter,
filter_name="MIN_NOTIONAL",
symbol=symbol.symbol,
)
return Instrument(
symbol=symbol.symbol,
name=symbol.name,
status=symbol.status,
base_asset=symbol.base_asset,
quote_asset=symbol.quote_asset,
asset_type=_optional_text(symbol.asset_type),
market_type=symbol.market_type,
market_modes=symbol.market_modes,
order_types=symbol.order_types,
base_asset_precision=symbol.base_asset_precision,
quote_asset_precision=symbol.quote_precision,
tick_size=_optional_decimal(
symbol.tick_size,
field_name="tickSize",
symbol=symbol.symbol,
),
tick_value=_optional_decimal(
symbol.tick_value,
field_name="tickValue",
symbol=symbol.symbol,
),
step_size=_optional_decimal(
lot_size.step_size if lot_size is not None else None,
field_name="stepSize",
symbol=symbol.symbol,
),
min_qty=_optional_decimal(
lot_size.min_qty if lot_size is not None else None,
field_name="minQty",
symbol=symbol.symbol,
),
max_qty=_optional_decimal(
lot_size.max_qty if lot_size is not None else None,
field_name="maxQty",
symbol=symbol.symbol,
),
min_notional=_optional_decimal(
min_notional.min_notional
if min_notional is not None
else None,
field_name="minNotional",
symbol=symbol.symbol,
),
country=_optional_text(symbol.country),
sector=_optional_text(symbol.sector),
industry=_optional_text(symbol.industry),
trading_hours=_optional_text(symbol.trading_hours),
)
def map_dzengi_exchange_info_to_instruments(
response: DzengiExchangeInfoResponse,
) -> tuple[Instrument, ...]:
"""
Преобразовать все инструменты exchangeInfo
во внутренние модели Instrument.
"""
return tuple(
map_dzengi_symbol_to_instrument(symbol)
for symbol in response.payload.symbols
)
def map_dzengi_ticker_to_quote(
response: DzengiTicker24hrResponse,
*,
received_at: datetime,
) -> Quote:
"""
Преобразовать проверенную raw-модель Dzengi ticker/24hr
во внутреннюю source-independent модель Quote.
Функция предполагает, что до mapper уже были выполнены:
schema validation, parsing и value validation.
"""
normalized_received_at = _require_aware_datetime(
received_at,
field_name="received_at",
)
return Quote(
symbol=response.symbol.strip(),
last_price=_required_quote_decimal(
response.last_price,
field_name="lastPrice",
),
bid_price=_required_quote_decimal(
response.bid_price,
field_name="bidPrice",
),
ask_price=_required_quote_decimal(
response.ask_price,
field_name="askPrice",
),
exchange_timestamp=_timestamp_ms_to_utc_datetime(
response.close_time,
),
received_at=normalized_received_at,
source=_DZENGI_SOURCE_NAME,
)
def _timestamp_ms_to_utc_datetime(value: int) -> datetime:
try:
return datetime.fromtimestamp(
value / 1000,
tz=timezone.utc,
)
except (OverflowError, OSError, ValueError) as exc:
raise QuoteMappingError(
"Поле closeTime невозможно преобразовать "
"в UTC datetime."
) from exc
def _required_quote_decimal(
value: DzengiRawNumeric,
*,
field_name: str,
) -> Decimal:
try:
result = Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise QuoteMappingError(
f"Поле {field_name} котировки невозможно "
"преобразовать в Decimal."
) from exc
if not result.is_finite():
raise QuoteMappingError(
f"Поле {field_name} котировки должно быть "
"конечным числом."
)
return result
def _require_aware_datetime(
value: datetime,
*,
field_name: str,
) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise QuoteMappingError(
f"Поле {field_name} должно содержать timezone-aware datetime."
)
return value
def _find_single_filter[
FilterT: DzengiInstrumentFilter
](
filters: tuple[DzengiInstrumentFilter, ...],
filter_type: type[FilterT],
*,
filter_name: str,
symbol: str,
) -> FilterT | None:
matches = tuple(
instrument_filter
for instrument_filter in filters
if isinstance(instrument_filter, filter_type)
)
if len(matches) > 1:
raise InstrumentReferenceMappingError(
f"Инструмент '{symbol}' содержит несколько "
f"фильтров {filter_name}."
)
if not matches:
return None
return matches[0]
def _optional_decimal(
value: DzengiRawNumeric | None,
*,
field_name: str,
symbol: str,
) -> Decimal | None:
if value is None:
return None
try:
result = Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise InstrumentReferenceMappingError(
f"Поле {field_name} инструмента '{symbol}' "
f"невозможно преобразовать в Decimal."
) from exc
if not result.is_finite():
raise InstrumentReferenceMappingError(
f"Поле {field_name} инструмента '{symbol}' "
f"должно быть конечным числом."
)
return result
def _optional_text(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
return None
return normalized
def map_dzengi_websocket_quote_to_quote(
response: DzengiWebSocketQuoteResponse,
*,
received_at: datetime,
) -> Quote:
"""
Преобразовать проверенную WebSocket-модель Dzengi в канонический Quote.
Depth-сообщение не содержит цену последней сделки, поэтому временно
используется midpoint best bid / best ask — так же, как в legacy runtime.
"""
normalized_received_at = _require_aware_datetime(
received_at,
field_name="received_at",
)
bid_price = _required_quote_decimal(
response.bid_price,
field_name="bidPrice",
)
ask_price = _required_quote_decimal(
response.ask_price,
field_name="askPrice",
)
exchange_timestamp = None
if response.timestamp is not None:
try:
exchange_timestamp = datetime.fromtimestamp(
response.timestamp / 1000,
tz=timezone.utc,
)
except (OverflowError, OSError, ValueError) as exc:
raise QuoteMappingError(
"Поле timestamp невозможно преобразовать в UTC datetime."
) from exc
return Quote(
symbol=response.symbol.strip(),
last_price=(bid_price + ask_price) / Decimal("2"),
bid_price=bid_price,
ask_price=ask_price,
exchange_timestamp=exchange_timestamp,
received_at=normalized_received_at,
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

@@ -1,193 +0,0 @@
# app/src/market_data/acquisition/adapters/dzengi/models.py
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeAlias
# Число в исходном JSON-ответе Dzengi без предметного преобразования.
DzengiJsonNumber: TypeAlias = int | float
# Числовое значение, которое Dzengi может передать числом или строкой.
DzengiRawNumeric: TypeAlias = str | int | float
# Скалярное значение неизвестного поля транспортного ответа.
DzengiJsonScalar: TypeAlias = str | int | float | bool | None
# Лимит запросов из exchangeInfo.
@dataclass(frozen=True, slots=True)
class DzengiRateLimit:
interval: str
interval_num: int
limit: int
rate_limit_type: str
# Базовый контракт фильтра инструмента Dzengi.
@dataclass(frozen=True, slots=True)
class DzengiInstrumentFilter:
filter_type: str
# Ограничения размера заявки.
@dataclass(frozen=True, slots=True)
class DzengiLotSizeFilter(DzengiInstrumentFilter):
min_qty: DzengiRawNumeric | None
max_qty: DzengiRawNumeric | None
step_size: DzengiRawNumeric | None
# Ограничение минимальной стоимости заявки.
@dataclass(frozen=True, slots=True)
class DzengiMinNotionalFilter(DzengiInstrumentFilter):
min_notional: DzengiRawNumeric | None
# Неизвестный тип фильтра, который ещё не поддерживается адаптером.
@dataclass(frozen=True, slots=True)
class DzengiUnknownFilter(DzengiInstrumentFilter):
fields: tuple[tuple[str, DzengiJsonScalar], ...]
# Один инструмент из ответа Dzengi exchangeInfo.
@dataclass(frozen=True, slots=True)
class DzengiExchangeInfoSymbol:
symbol: str
name: str
status: str
asset_type: str | None
base_asset: str
base_asset_precision: int | None
quote_asset: str
quote_asset_id: str | None
quote_precision: int | None
order_types: tuple[str, ...]
filters: tuple[DzengiInstrumentFilter, ...]
market_modes: tuple[str, ...]
market_type: str
country: str | None
sector: str | None
industry: str | None
trading_hours: str | None
tick_size: DzengiJsonNumber | None
tick_value: DzengiJsonNumber | None
trading_fee: DzengiJsonNumber | None
exchange_fee: DzengiJsonNumber | None
long_rate: DzengiJsonNumber | None
short_rate: DzengiJsonNumber | None
swap_charge_interval: int | None
min_sl_gap: DzengiJsonNumber | None
max_sl_gap: DzengiJsonNumber | None
min_tp_gap: DzengiJsonNumber | None
max_tp_gap: DzengiJsonNumber | None
# Содержимое exchangeInfo независимо от внешней оболочки API.
@dataclass(frozen=True, slots=True)
class DzengiExchangeInfoPayload:
timezone: str | None
server_time: int | None
rate_limits: tuple[DzengiRateLimit, ...]
exchange_filters: tuple[DzengiUnknownFilter, ...]
symbols: tuple[DzengiExchangeInfoSymbol, ...]
# Нормализованное транспортное представление ответа exchangeInfo.
@dataclass(frozen=True, slots=True)
class DzengiExchangeInfoResponse:
payload: DzengiExchangeInfoPayload
# Поля присутствуют в wrapped-формате ответа и отсутствуют
# в фактическом unwrapped-ответе публичного REST endpoint.
status: str | None = None
correlation_id: str | None = None
# Транспортное представление ответа Dzengi GET /api/v1/ticker/24hr.
@dataclass(frozen=True, slots=True)
class DzengiTicker24hrResponse:
symbol: str
last_price: DzengiRawNumeric
bid_price: DzengiRawNumeric
ask_price: DzengiRawNumeric
close_time: int
# Нормализованное транспортное представление котировки из Dzengi WebSocket.
@dataclass(frozen=True, slots=True)
class DzengiWebSocketQuoteResponse:
symbol: str
bid_price: DzengiRawNumeric
ask_price: DzengiRawNumeric
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,215 +0,0 @@
# app/src/market_data/acquisition/adapters/dzengi/rest.py
from __future__ import annotations
from typing import Protocol
from src.integrations.exchange.rest_client import ExchangeRestClient
from src.market_data.acquisition.exceptions import (
CandleTransportError,
InstrumentReferenceTransportError,
QuoteTransportError,
TradeTransportError,
)
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
_KLINES_PATH = "/api/v1/klines"
_AGG_TRADES_PATH = "/api/v1/aggTrades"
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
class _PayloadRestClient(Protocol):
def get_payload(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> object:
...
class DzengiInstrumentDocumentSource:
"""
Источник сырого документа Instrument Reference Data через Dzengi REST API.
На переходном этапе использует legacy ExchangeRestClient.
Зависимость должна быть удалена после появления общего transport-клиента
или после полного вывода integrations/exchange из эксплуатации.
"""
def __init__(
self,
client: _PayloadRestClient | None = None,
) -> None:
self._client = client
def fetch_instrument_document(self) -> object:
"""
Получить декодированный ответ Dzengi exchangeInfo без его обработки.
Метод не выполняет schema validation, parsing, value validation,
mapping или кэширование.
"""
try:
client: _PayloadRestClient = (
self._client
if self._client is not None
else ExchangeRestClient()
)
return client.get_payload(_EXCHANGE_INFO_PATH)
except Exception as exc:
raise InstrumentReferenceTransportError(
"Не удалось получить Instrument Reference Data "
f"от Dzengi: {exc}"
) from exc
class DzengiQuoteDocumentSource:
"""Источник сырого документа текущей котировки через Dzengi REST API."""
def __init__(
self,
client: _PayloadRestClient | None = None,
) -> None:
self._client = client
def fetch_quote_document(
self,
symbol: str,
) -> object:
"""
Получить декодированный ответ Dzengi ticker/24hr без его обработки.
Метод не выполняет нормализацию symbol, schema validation, parsing,
value validation, mapping, retry или кэширование.
"""
try:
client: _PayloadRestClient = (
self._client
if self._client is not None
else ExchangeRestClient()
)
return client.get_payload(
_TICKER_24HR_PATH,
params={
"symbol": symbol,
},
)
except Exception as exc:
raise QuoteTransportError(
"Не удалось получить текущую котировку "
f"от Dzengi для символа '{symbol}': {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

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

@@ -1,145 +0,0 @@
# app/src/market_data/acquisition/adapters/dzengi/websocket.py
from __future__ import annotations
from datetime import datetime, timezone
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_websocket_ohlc_to_candle_close_event,
map_dzengi_websocket_quote_to_quote,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_dzengi_websocket_ohlc,
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.trade import Trade
from src.market_data.acquisition.validation.schema import (
validate_dzengi_websocket_ohlc_schema,
validate_dzengi_websocket_quote_schema,
validate_dzengi_websocket_trade_schema,
)
from src.market_data.acquisition.validation.values import (
validate_dzengi_websocket_ohlc_values,
validate_dzengi_websocket_quote_values,
)
# Преобразует одно декодированное сообщение Dzengi WebSocket в Quote.
class DzengiWebSocketQuoteAdapter:
def map_message(
self,
document: object,
*,
received_at: datetime | None = None,
) -> Quote:
validated = validate_dzengi_websocket_quote_schema(document)
response = parse_dzengi_websocket_quote(validated)
validate_dzengi_websocket_quote_values(response)
return map_dzengi_websocket_quote_to_quote(
response,
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

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

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

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

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

@@ -1,11 +0,0 @@
"""Восстановление persistent Trade Stream checkpoint."""
from src.market_data.acquisition.checkpoint.trade_stream_state_hydrator import (
TradeStreamStateHydrator,
TradeStreamStateHydratorProtocol,
)
__all__ = (
"TradeStreamStateHydrator",
"TradeStreamStateHydratorProtocol",
)

View File

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

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

View File

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

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

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

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

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

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

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

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

@@ -1,197 +0,0 @@
# app/src/market_data/acquisition/exceptions.py
from __future__ import annotations
# Базовая ошибка подсистемы получения рыночных данных.
class MarketDataAcquisitionError(Exception):
pass
# Ошибка получения Instrument Reference Data от внешнего источника.
class InstrumentReferenceTransportError(MarketDataAcquisitionError):
pass
# Ошибка структуры документа Instrument Reference Data.
class InstrumentReferenceSchemaError(MarketDataAcquisitionError):
pass
# Ошибка преобразования проверенного документа в raw-модели адаптера.
class InstrumentReferenceParseError(MarketDataAcquisitionError):
pass
# Ошибка допустимости значений Instrument Reference Data.
class InstrumentReferenceValueError(MarketDataAcquisitionError):
pass
# Ошибка преобразования raw-модели источника во внутреннюю модель Instrument.
class InstrumentReferenceMappingError(MarketDataAcquisitionError):
pass
# Ошибка регистрации или получения Instrument Feed.
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
pass
# Ошибка получения Quotes Feed от внешнего источника.
class QuoteTransportError(MarketDataAcquisitionError):
pass
# Ошибка структуры документа Quotes Feed.
class QuoteSchemaError(MarketDataAcquisitionError):
pass
# Ошибка преобразования проверенного документа в raw-модель котировки.
class QuoteParseError(MarketDataAcquisitionError):
pass
# Ошибка допустимости значений Quotes Feed.
class QuoteValueError(MarketDataAcquisitionError):
pass
# Ошибка преобразования raw-модели источника во внутреннюю модель Quote.
class QuoteMappingError(MarketDataAcquisitionError):
pass
# Ошибка регистрации или получения Quotes Feed.
class QuoteFeedRegistryError(MarketDataAcquisitionError):
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

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

@@ -1,33 +0,0 @@
# app/src/market_data/acquisition/feeds/instrument_feed.py
from __future__ import annotations
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.protocol import (
InstrumentDocumentHandler,
InstrumentDocumentSource,
)
# Feed справочника инструментов: получает документ и передаёт его обработчику.
class InstrumentFeed:
def __init__(
self,
*,
source: InstrumentDocumentSource,
handler: InstrumentDocumentHandler,
) -> None:
self._source = source
self._handler = handler
def load_instruments(self) -> tuple[Instrument, ...]:
"""
Получить документ от источника и преобразовать его в модели Instrument.
Feed не выполняет transport, parsing, validation, mapping,
кэширование или обработку ошибок самостоятельно.
"""
document = self._source.fetch_instrument_document()
return self._handler.handle_instrument_document(document)

View File

@@ -1,36 +0,0 @@
# app/src/market_data/acquisition/feeds/quotes_feed.py
from __future__ import annotations
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.protocol import (
QuoteDocumentHandler,
QuoteDocumentSource,
)
# Feed текущих котировок: получает документ и передаёт его обработчику.
class QuotesFeed:
def __init__(
self,
*,
source: QuoteDocumentSource,
handler: QuoteDocumentHandler,
) -> None:
self._source = source
self._handler = handler
def load_quote(
self,
symbol: str,
) -> Quote:
"""
Получить документ котировки и преобразовать его в модель Quote.
Feed не выполняет transport, parsing, validation, mapping,
нормализацию symbol, retry, кэширование или обработку ошибок.
"""
document = self._source.fetch_quote_document(symbol)
return self._handler.handle_quote_document(document)

View File

@@ -1,2 +0,0 @@
# app/src/market_data/acquisition/feeds/status_feed.py

View File

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

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

@@ -1,32 +0,0 @@
# app/src/market_data/acquisition/handlers/instrument_handler.py
from __future__ import annotations
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_exchange_info_to_instruments,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_exchange_info,
)
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.validation.schema import (
validate_exchange_info_schema,
)
from src.market_data.acquisition.validation.values import (
validate_exchange_info_values,
)
# Обработчик документа Instrument Reference Data формата Dzengi exchangeInfo.
class DzengiInstrumentDocumentHandler:
def handle_instrument_document(
self,
document: object,
) -> tuple[Instrument, ...]:
validated_document = validate_exchange_info_schema(document)
response = parse_exchange_info(validated_document)
validate_exchange_info_values(response)
return map_dzengi_exchange_info_to_instruments(response)

View File

@@ -1,35 +0,0 @@
# app/src/market_data/acquisition/handlers/quotes_handler.py
from __future__ import annotations
from datetime import datetime, timezone
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_ticker_to_quote,
)
from src.market_data.acquisition.adapters.dzengi.parser import parse_quote
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.validation.schema import (
validate_quote_schema,
)
from src.market_data.acquisition.validation.values import (
validate_quote_values,
)
# Обработчик документа Quotes Feed формата Dzengi ticker/24hr.
class DzengiQuoteDocumentHandler:
def handle_quote_document(
self,
document: object,
) -> Quote:
validated_document = validate_quote_schema(document)
response = parse_quote(validated_document)
validate_quote_values(response)
return map_dzengi_ticker_to_quote(
response,
received_at=datetime.now(timezone.utc),
)

View File

@@ -1,2 +0,0 @@
# app/src/market_data/acquisition/handlers/status_handler.py

View File

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

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

View File

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

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