Compare commits

...

65 Commits

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

30
.dockerignore Normal file
View File

@@ -0,0 +1,30 @@
# В build context разрешены только production source и requirements.
**
!.dockerignore
!infra/
!infra/docker/
!infra/docker/Dockerfile
!infra/docker/postgres/
!infra/docker/postgres/Dockerfile
!infra/docker/postgres/healthcheck-application-role.sh
!infra/docker/postgres/init-application-role.sh
!app/
!app/requirements.txt
!app/requirements.lock
!app/src/
!app/src/**
# Локальные артефакты запрещены даже внутри разрешённого source tree.
app/src/**/__pycache__/
app/src/**/*.py[cod]
app/src/**/.DS_Store
app/src/**/.env
app/src/**/.env.*
app/src/**/*.pem
app/src/**/*.key
app/src/**/*.p12
app/src/**/*.pfx
app/src/**/*.log
app/src/**/*.dump
app/src/**/*.sqlite
app/src/**/*.sqlite3

View File

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

View File

@@ -1,4 +1,6 @@
{ {
"python.defaultInterpreterPath": "app/.venv/bin/python", "python.defaultInterpreterPath": "app/.venv/bin/python",
"python-envs.defaultEnvManager": "ms-python.python:system" "python-envs.defaultEnvManager": "ms-python.python:system",
"python.analysis.typeCheckingMode": "standard",
"python.analysis.diagnosticMode": "workspace"
} }

109
README.md
View File

@@ -1,33 +1,96 @@
# dzentra_bot # Dzentra
Telegram-бот для автоторговли криптовалютой. Dzentra — модульный монолит автоматической торговой системы с
Telegram-интерфейсом. В текущем принятом Production scope полностью
собрана вертикаль Trades Feed: получение сделок, проверка
последовательности, reconnect/recovery, опциональное сохранение в
PostgreSQL и восстановление после перезапуска.
## Bootstrap v2 ## Текущий scope
Это стабильный стартовый каркас проекта с:
- чистой структурой каталогов
- компактным верхним меню
- базовыми handlers
- документацией
- Docker-файлами
- опциональным созданием `.venv`, установкой зависимостей и первым git commit
## Верхнее меню - Production ingestion, Consistency, Recovery, persistent checkpoint и
- 🏠 Главная Startup Recovery реализованы для Trades.
- 📈 Рынок - Historical Access и deterministic Replay доступны как отдельные API и
- 💼 Портфель не запускаются автоматически.
- ⚡ Торговля - REST-потоки Quotes и Candles продолжают использоваться Trading/UI.
- 🤖 Авто Новые WebSocket Runtime, Consistency/Recovery и persistent writer
- 📒 Журнал wiring для них пока не подключены.
- ⚙️ Система - Retention и создание месячных партиций выполняются только явным вызовом.
- Обычный запуск приложения требует PostgreSQL для журнала и balance
snapshots, даже если Trade Stream и Market Data Storage выключены.
Подробное фактическое состояние описано в
[архитектуре Trades Feed](docs/architecture/trades_feed.md).
## Быстрый старт существующего checkout
Требуются Python 3.12, доступный PostgreSQL и Telegram Bot Token. Команды
выполняются из корня репозитория:
## Рекомендуемый запуск
```bash ```bash
/opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install python3.12 -m venv app/.venv
app/.venv/bin/python -m pip install \
--require-hashes \
-r app/requirements-dev.lock
app/.venv/bin/python -m pip check
test -e app/.env || cp app/.env.example app/.env
``` ```
## После bootstrap Заполните созданный `app/.env`, не добавляйте его в Git и не
перезаписывайте уже существующий файл. Затем запустите приложение:
```bash ```bash
cd app cd app
source .venv/bin/activate .venv/bin/python -m src.main
python -m src.main
``` ```
Настройки режимов, PostgreSQL и безопасный Docker-запуск приведены в
[эксплуатационном руководстве](docs/operations/trades_feed_runtime.md).
Более подробная инструкция для разработчика находится в
[app/README.md](app/README.md).
## Обязательные проверки
Из корня репозитория:
```bash
./scripts/check_python_types.sh
app/.venv/bin/python scripts/check_documentation_integrity.py
```
Ожидаемый результат Pyright — `0 errors, 0 warnings`, а documentation
gate — `issues=0`. Обычная offline-регрессия запускается из `app`:
```bash
cd app
.venv/bin/python -m pytest -q
```
Default pytest-набор исключает opt-in группы `integration`, `stress` и
`live`, но включает статическую проверку Pyright. Целевой тест не
заменяет полный type gate.
Локальный эквивалент первого CI gate запускает quality, offline regression
и безопасную loopback integration-проверку:
```bash
./scripts/run_ci_gate.sh all
```
Workflow пока запускается только вручную. Его границы, runner contract и
критерии зелёного canary описаны в [CI runbook](docs/operations/ci_gate.md).
## Навигация
- [Обзор архитектуры](docs/architecture/overview.md)
- [Структура проекта](docs/architecture/project_structure.md)
- [Текущая архитектура Trades Feed](docs/architecture/trades_feed.md)
- [Эксплуатация Trade Stream Runtime](docs/operations/trades_feed_runtime.md)
- [CI gate и первый canary](docs/operations/ci_gate.md)
- [Архитектура Build 060.30](docs/migrations/build_060_30_architecture.md)
## Исторический bootstrap
`bootstrap_project.py` — исторический генератор первоначального каркаса.
Для установки или запуска существующего checkout его использовать не
нужно.

View File

@@ -1,20 +1,73 @@
# Настройки приложения. Файл является примером: не храните здесь
# реальные секреты. Для каждого секрета задайте либо прямое значение,
# либо *_FILE, но не оба. *_FILE должен указывать на доступный для чтения
# UTF-8 файл с одним секретом.
# Telegram и приложение
BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE
# BOT_TOKEN_FILE=/run/secrets/bot_token
BOT_PARSE_MODE=HTML BOT_PARSE_MODE=HTML
APP_ENV=dev APP_ENV=dev
LOG_LEVEL=INFO LOG_LEVEL=INFO
TZ=Europe/Minsk TZ=Europe/Minsk
DEBUG_ENABLED=false
JOURNAL_DEBUG_ENABLED=false
# Только для контейнера с отдельным runtime-файлом, доступным для записи:
# DZENTRA_RUNTIME_ENV_FILE=/var/lib/dzentra/runtime.env
# PostgreSQL
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
DB_NAME=dzentra_bot DB_NAME=dzentra_bot
DB_USER=dzentra_bot DB_USER=dzentra_bot
DB_PASSWORD=change_me DB_PASSWORD=PUT_YOUR_DATABASE_PASSWORD_HERE
# DB_PASSWORD_FILE=/run/secrets/db_password
EXCHANGE_ENABLED=true # Постоянное хранилище Market Data
MARKET_DATA_STORAGE_ENABLED=false
MARKET_DATA_STORAGE_POOL_MIN_SIZE=1
MARKET_DATA_STORAGE_POOL_MAX_SIZE=4
MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS=10
# Биржа. Безопасный пример совпадает с выключенными значениями по умолчанию.
EXCHANGE_ENABLED=false
EXCHANGE_NAME=dzengi EXCHANGE_NAME=dzengi
EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com EXCHANGE_BASE_URL=
EXCHANGE_WS_URL=
EXCHANGE_API_KEY= EXCHANGE_API_KEY=
# EXCHANGE_API_KEY_FILE=/run/secrets/exchange_api_key
EXCHANGE_API_SECRET= EXCHANGE_API_SECRET=
# EXCHANGE_API_SECRET_FILE=/run/secrets/exchange_api_secret
EXCHANGE_TIMEOUT_SEC=10 EXCHANGE_TIMEOUT_SEC=10
EXCHANGE_TESTNET=true EXCHANGE_TESTNET=false
DEFAULT_SYMBOL=BTC/USD_LEVERAGE DEFAULT_SYMBOL=ETH/USD_LEVERAGE
# Поток сделок Production Trade Stream
TRADE_STREAM_ENABLED=false
TRADE_STREAM_WS_URL=
TRADE_STREAM_SYMBOLS=
TRADE_STREAM_OPEN_TIMEOUT_SECONDS=10
TRADE_STREAM_PROBE_TIMEOUT_SECONDS=20
TRADE_STREAM_CLOSE_TIMEOUT_SECONDS=10
TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS=30
TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS=5
TRADE_STREAM_RECOVERY_WINDOW_MS=3599999
TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS=10
TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY=10000
# Управляющие переменные Docker Compose передаются самому Compose,
# а не сохраняются в app/.env. Пути должны вести к защищённым файлам
# вне repository; BOT_STOP_GRACE_PERIOD задаётся после расчёта бюджета.
# Каталог файлов с секретами: mode 0700; сами файлы: mode 0444.
# DZENTRA_COMPOSE_PROJECT_NAME=dzentra-production
# DZENTRA_BOT_IMAGE=dzentra-bot:local
# DZENTRA_POSTGRES_IMAGE=dzentra-postgres:16.14-hardened
# BOT_TOKEN_SECRET_FILE=/absolute/protected/dzentra/bot_token
# POSTGRES_ADMIN_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/postgres_admin_password
# DB_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/db_password
# POSTGRES_ADMIN_USER=dzentra_admin
# BOT_STOP_GRACE_PERIOD=30m
# POSTGRES_STOP_GRACE_PERIOD=60s
# Только с infra/compose/docker-compose.exchange-auth.yml:
# EXCHANGE_API_KEY_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_key
# EXCHANGE_API_SECRET_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_secret

View File

@@ -1,3 +1,132 @@
# app # Приложение Dzentra
Здесь находятся исходный код приложения, env-файлы и зависимости. Каталог `app` содержит исполняемое Python-приложение, зависимости,
настройки и тесты Dzentra. Приложение объединяет Telegram-бота и
принятую Production-вертикаль Trades Feed.
## Реализованные границы
| Возможность | Текущее состояние |
|---|---|
| Live Trades, reconnect и REST Recovery | Подключены к Production Runtime |
| Persistent Trades и checkpoint | Опционально подключены через `MARKET_DATA_STORAGE_ENABLED` |
| Startup Hydration и Recovery | Выполняются при включённом Storage |
| Historical Access и Replay | Готовы для явного вызова; автоматически не запускаются |
| Quote/Candle REST feeds | Используются существующими Trading/UI сценариями |
| Quote/Candle WebSocket и persistence | Runtime/Consistency/Recovery и persistent writers не подключены |
| Retention и месячные партиции | Только явный вызов; Scheduler их не запускает |
Подробности приведены в
[текущей архитектуре Trades Feed](../docs/architecture/trades_feed.md).
## Требования
- Python 3.12;
- PostgreSQL, доступный по параметрам `DB_*`;
- Telegram Bot Token;
- отдельное виртуальное окружение `app/.venv`.
PostgreSQL нужен даже при выключенных Trade Stream и Market Data Storage:
обычный Bootstrap всегда инициализирует таблицы журнала и balance
snapshots.
## Подготовка окружения
Из корня репозитория:
```bash
python3.12 -m venv app/.venv
app/.venv/bin/python -m pip install \
--require-hashes \
-r app/requirements-dev.lock
app/.venv/bin/python -m pip check
test -e app/.env || cp app/.env.example app/.env
```
Замените значения-заглушки в `app/.env`. Не добавляйте этот файл в Git и
не перезаписывайте существующий `.env`. Для каждого секрета разрешён либо
прямой параметр, либо соответствующий `*_FILE`, но не оба одновременно.
`requirements-dev.txt` остаётся читаемым источником прямых developer
dependencies. Установка выполняется из `requirements-dev.lock`, который
сохраняет версии Production `requirements.lock` и добавляет pytest/Pyright.
Вручную редактировать dependency records обоих lock-файлов не следует.
## Режимы Trades Feed
| `TRADE_STREAM_ENABLED` | `MARKET_DATA_STORAGE_ENABLED` | Поведение |
|---:|---:|---|
| `false` | `false` | Trade Runtime и отдельный Market Data pool не создаются |
| `true` | `false` | Live/Recovery работают только с состоянием в памяти |
| `true` | `true` | Добавляются persistent Trades, checkpoint, Hydration и Startup Recovery |
| `false` | `true` | Недопустимая конфигурация; startup завершается ошибкой |
При включённом Trade Stream обязательны явные `TRADE_STREAM_WS_URL`,
`TRADE_STREAM_SYMBOLS` и `EXCHANGE_BASE_URL`. Полный контракт настроек и
Docker secrets находится в
[эксплуатационном руководстве](../docs/operations/trades_feed_runtime.md).
## Запуск
Из каталога `app`:
```bash
.venv/bin/python -m src.main
```
Ошибка включённого Trade Runtime, Storage или Startup Recovery является
фатальной и завершает всё приложение, а не оставляет Telegram polling в
частично работающем состоянии.
## Проверки разработчика
Обязательная отдельная проверка типов запускается из корня репозитория:
```bash
./scripts/check_python_types.sh
app/.venv/bin/python scripts/check_documentation_integrity.py
```
Ожидаемый результат — `0 errors, 0 warnings` для Pyright и `issues=0`
для documentation gate. Default offline regression запускается из `app`:
```bash
.venv/bin/python -m pytest -q
```
Default-набор исключает маркеры `integration`, `stress` и `live`. При
этом он включает `tests/static/test_python_type_gate.py`, поэтому полный
запуск дополнительно контролирует Pyright. Один целевой тест этот gate не
заменяет.
Полная локальная CI-последовательность запускается из корня repository:
```bash
./scripts/run_ci_gate.sh all
```
Подробный manual-canary contract приведён в
[CI runbook](../docs/operations/ci_gate.md).
Назначение и безопасные команды opt-in наборов:
- `integration` — локальные сетевые и PostgreSQL-сценарии;
- `stress` — fixed stress и opt-in soak;
- `live` — только явно разрешённая проверка внешних Dzengi endpoints.
Параметры этих запусков не дублируются здесь и поддерживаются в
[runbook](../docs/operations/trades_feed_runtime.md).
## Структура и навигация
- `src/` — production-код;
- `tests/unit/` и `tests/static/` — default offline regression;
- `tests/integration/`, `tests/stress/`, `tests/live/` — opt-in проверки;
- `scripts/` и `tools/` — диагностические и исследовательские утилиты,
не automatic Production Runtime;
- [обзор структуры проекта](../docs/architecture/project_structure.md);
- [границы пакета Storage](src/storage/README.md);
- [архитектура Build 060.30](../docs/migrations/build_060_30_architecture.md).
Корневой `bootstrap_project.py` является историческим генератором
каркаса. Для текущего checkout его запускать не нужно.

25
app/pytest.ini Normal file
View File

@@ -0,0 +1,25 @@
[pytest]
minversion = 8.0
testpaths =
tests
python_files =
test_*.py
python_classes =
Test*
python_functions =
test_*
addopts =
-ra
--strict-markers
-m
"not integration and not stress and not live"
markers =
integration: deterministic integration tests using local network endpoints
stress: explicit local stress and soak verification
live: opt-in verification against explicitly configured live endpoints

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

@@ -0,0 +1,929 @@
# Сгенерированный hash-lock developer/CI-зависимостей; вручную не редактировать.
# Источники: app/requirements-dev.txt и app/requirements.lock; Python 3.12.
# Команда регенерации приведена в docs/operations/ci_gate.md.
aiofiles==24.1.0 \
--hash=sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c \
--hash=sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5
aiogram==3.13.1 \
--hash=sha256:148cbba15c2b6e0bc482aff6ae86848ed43e023b7102336bad17698242693000 \
--hash=sha256:2674640ae74501ae30e68a5354e03f12e10db3adbcbd475bb56904536288c9c9
aiohappyeyeballs==2.7.1 \
--hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
--hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
aiohttp==3.10.11 \
--hash=sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d \
--hash=sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc \
--hash=sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261 \
--hash=sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b \
--hash=sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625 \
--hash=sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39 \
--hash=sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f \
--hash=sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636 \
--hash=sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac \
--hash=sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217 \
--hash=sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9 \
--hash=sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f \
--hash=sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006 \
--hash=sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e \
--hash=sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9 \
--hash=sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a \
--hash=sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695 \
--hash=sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730 \
--hash=sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e \
--hash=sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382 \
--hash=sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc \
--hash=sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00 \
--hash=sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8 \
--hash=sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658 \
--hash=sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339 \
--hash=sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a \
--hash=sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a \
--hash=sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e \
--hash=sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d \
--hash=sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e \
--hash=sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca \
--hash=sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038 \
--hash=sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461 \
--hash=sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829 \
--hash=sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31 \
--hash=sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4 \
--hash=sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127 \
--hash=sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3 \
--hash=sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97 \
--hash=sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710 \
--hash=sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2 \
--hash=sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674 \
--hash=sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb \
--hash=sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03 \
--hash=sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07 \
--hash=sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9 \
--hash=sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6 \
--hash=sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f \
--hash=sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298 \
--hash=sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b \
--hash=sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01 \
--hash=sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9 \
--hash=sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7 \
--hash=sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413 \
--hash=sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec \
--hash=sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7 \
--hash=sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4 \
--hash=sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb \
--hash=sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519 \
--hash=sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d \
--hash=sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6 \
--hash=sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27 \
--hash=sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1 \
--hash=sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087 \
--hash=sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067 \
--hash=sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f \
--hash=sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95 \
--hash=sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa \
--hash=sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa \
--hash=sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16 \
--hash=sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106 \
--hash=sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92 \
--hash=sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777 \
--hash=sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa \
--hash=sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e \
--hash=sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7 \
--hash=sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115 \
--hash=sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120 \
--hash=sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d \
--hash=sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b \
--hash=sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a \
--hash=sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725 \
--hash=sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a \
--hash=sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d \
--hash=sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288 \
--hash=sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8 \
--hash=sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385 \
--hash=sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24 \
--hash=sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71 \
--hash=sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138 \
--hash=sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177
aiosignal==1.4.0 \
--hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \
--hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
et-xmlfile==2.0.0 \
--hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \
--hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54
frozenlist==1.8.0 \
--hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \
--hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \
--hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \
--hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \
--hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \
--hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \
--hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \
--hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \
--hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \
--hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \
--hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \
--hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \
--hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \
--hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \
--hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \
--hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \
--hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \
--hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \
--hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \
--hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \
--hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \
--hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \
--hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \
--hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \
--hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \
--hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \
--hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \
--hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \
--hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \
--hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \
--hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \
--hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \
--hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \
--hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \
--hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \
--hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \
--hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \
--hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \
--hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \
--hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \
--hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \
--hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \
--hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \
--hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \
--hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \
--hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \
--hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \
--hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \
--hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \
--hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \
--hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \
--hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \
--hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \
--hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \
--hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \
--hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \
--hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \
--hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \
--hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \
--hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \
--hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \
--hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \
--hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \
--hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \
--hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \
--hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \
--hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \
--hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \
--hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \
--hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \
--hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \
--hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \
--hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \
--hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \
--hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \
--hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \
--hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \
--hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \
--hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \
--hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \
--hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \
--hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \
--hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \
--hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \
--hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \
--hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \
--hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \
--hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \
--hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \
--hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \
--hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \
--hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \
--hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \
--hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \
--hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \
--hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \
--hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \
--hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \
--hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \
--hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \
--hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \
--hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \
--hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \
--hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \
--hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \
--hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \
--hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \
--hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \
--hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \
--hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \
--hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \
--hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \
--hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \
--hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \
--hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \
--hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \
--hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \
--hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \
--hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \
--hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \
--hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \
--hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \
--hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \
--hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \
--hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \
--hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \
--hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \
--hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \
--hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \
--hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd
idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
magic-filter==1.0.12 \
--hash=sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9 \
--hash=sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6
multidict==6.7.1 \
--hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \
--hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \
--hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \
--hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \
--hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \
--hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \
--hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \
--hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \
--hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \
--hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \
--hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \
--hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \
--hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \
--hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \
--hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \
--hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \
--hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \
--hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \
--hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \
--hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \
--hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \
--hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \
--hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \
--hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \
--hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \
--hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \
--hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \
--hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \
--hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \
--hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \
--hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \
--hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \
--hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \
--hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \
--hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \
--hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \
--hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \
--hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \
--hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \
--hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \
--hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \
--hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \
--hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \
--hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \
--hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \
--hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \
--hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \
--hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \
--hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \
--hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \
--hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \
--hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \
--hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \
--hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \
--hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \
--hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \
--hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \
--hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \
--hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \
--hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \
--hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \
--hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \
--hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \
--hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \
--hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \
--hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \
--hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \
--hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \
--hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \
--hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \
--hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \
--hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \
--hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \
--hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \
--hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \
--hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \
--hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \
--hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \
--hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \
--hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \
--hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \
--hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \
--hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \
--hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \
--hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \
--hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \
--hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \
--hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \
--hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \
--hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \
--hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \
--hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \
--hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \
--hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \
--hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \
--hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \
--hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \
--hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \
--hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \
--hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \
--hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \
--hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \
--hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \
--hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \
--hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \
--hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \
--hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \
--hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \
--hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \
--hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \
--hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \
--hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \
--hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \
--hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \
--hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \
--hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \
--hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \
--hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \
--hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \
--hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \
--hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \
--hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \
--hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \
--hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \
--hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \
--hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \
--hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \
--hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \
--hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \
--hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \
--hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \
--hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \
--hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \
--hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \
--hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \
--hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \
--hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \
--hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \
--hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \
--hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \
--hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \
--hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \
--hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \
--hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \
--hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \
--hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
nodejs-wheel-binaries==24.16.0 \
--hash=sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd \
--hash=sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51 \
--hash=sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36 \
--hash=sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342 \
--hash=sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa \
--hash=sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37 \
--hash=sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c \
--hash=sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502 \
--hash=sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03
openpyxl==3.1.5 \
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
propcache==0.5.2 \
--hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \
--hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \
--hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \
--hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \
--hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \
--hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \
--hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \
--hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \
--hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \
--hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \
--hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \
--hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \
--hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \
--hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \
--hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \
--hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \
--hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \
--hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \
--hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \
--hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \
--hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \
--hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \
--hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \
--hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \
--hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \
--hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \
--hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \
--hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \
--hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \
--hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \
--hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \
--hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \
--hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \
--hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \
--hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \
--hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \
--hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \
--hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \
--hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \
--hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \
--hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \
--hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \
--hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \
--hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \
--hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \
--hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \
--hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \
--hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \
--hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \
--hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \
--hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \
--hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \
--hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \
--hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \
--hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \
--hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \
--hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \
--hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \
--hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \
--hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \
--hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \
--hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \
--hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \
--hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \
--hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \
--hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \
--hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \
--hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \
--hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \
--hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \
--hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \
--hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \
--hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \
--hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \
--hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \
--hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \
--hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \
--hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \
--hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \
--hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \
--hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \
--hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \
--hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \
--hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \
--hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \
--hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \
--hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \
--hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \
--hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \
--hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \
--hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \
--hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \
--hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \
--hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \
--hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \
--hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \
--hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \
--hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \
--hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \
--hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \
--hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \
--hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \
--hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \
--hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \
--hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \
--hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \
--hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \
--hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \
--hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \
--hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \
--hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \
--hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \
--hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \
--hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \
--hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \
--hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \
--hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \
--hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \
--hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \
--hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \
--hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6
psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
psycopg-binary==3.2.9 ; implementation_name != 'pypy' \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
psycopg-pool==3.3.1 \
--hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \
--hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c
pydantic==2.9.2 \
--hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \
--hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12
pydantic-core==2.23.4 \
--hash=sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36 \
--hash=sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05 \
--hash=sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071 \
--hash=sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327 \
--hash=sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c \
--hash=sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36 \
--hash=sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29 \
--hash=sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744 \
--hash=sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d \
--hash=sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec \
--hash=sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e \
--hash=sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e \
--hash=sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577 \
--hash=sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232 \
--hash=sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863 \
--hash=sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6 \
--hash=sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368 \
--hash=sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480 \
--hash=sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2 \
--hash=sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2 \
--hash=sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6 \
--hash=sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769 \
--hash=sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d \
--hash=sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 \
--hash=sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84 \
--hash=sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 \
--hash=sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271 \
--hash=sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5 \
--hash=sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb \
--hash=sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13 \
--hash=sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323 \
--hash=sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556 \
--hash=sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665 \
--hash=sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef \
--hash=sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb \
--hash=sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119 \
--hash=sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126 \
--hash=sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510 \
--hash=sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b \
--hash=sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87 \
--hash=sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f \
--hash=sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc \
--hash=sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8 \
--hash=sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21 \
--hash=sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f \
--hash=sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6 \
--hash=sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658 \
--hash=sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b \
--hash=sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3 \
--hash=sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb \
--hash=sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59 \
--hash=sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24 \
--hash=sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9 \
--hash=sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3 \
--hash=sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd \
--hash=sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753 \
--hash=sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55 \
--hash=sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad \
--hash=sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a \
--hash=sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605 \
--hash=sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e \
--hash=sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b \
--hash=sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433 \
--hash=sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8 \
--hash=sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07 \
--hash=sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728 \
--hash=sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0 \
--hash=sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327 \
--hash=sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555 \
--hash=sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64 \
--hash=sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6 \
--hash=sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea \
--hash=sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b \
--hash=sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df \
--hash=sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e \
--hash=sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd \
--hash=sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068 \
--hash=sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3 \
--hash=sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040 \
--hash=sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12 \
--hash=sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916 \
--hash=sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f \
--hash=sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f \
--hash=sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801 \
--hash=sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231 \
--hash=sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5 \
--hash=sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8 \
--hash=sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee \
--hash=sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
pyright==1.1.411 \
--hash=sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998 \
--hash=sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
python-dotenv==1.0.1 \
--hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \
--hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
tzdata==2026.3 ; sys_platform == 'win32' \
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
websockets==13.1 \
--hash=sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a \
--hash=sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54 \
--hash=sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23 \
--hash=sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7 \
--hash=sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135 \
--hash=sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700 \
--hash=sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf \
--hash=sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5 \
--hash=sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e \
--hash=sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c \
--hash=sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02 \
--hash=sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a \
--hash=sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418 \
--hash=sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f \
--hash=sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3 \
--hash=sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68 \
--hash=sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978 \
--hash=sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20 \
--hash=sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295 \
--hash=sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b \
--hash=sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6 \
--hash=sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb \
--hash=sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a \
--hash=sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa \
--hash=sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0 \
--hash=sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a \
--hash=sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238 \
--hash=sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c \
--hash=sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084 \
--hash=sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19 \
--hash=sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d \
--hash=sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7 \
--hash=sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9 \
--hash=sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79 \
--hash=sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96 \
--hash=sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6 \
--hash=sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe \
--hash=sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842 \
--hash=sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa \
--hash=sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3 \
--hash=sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d \
--hash=sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51 \
--hash=sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7 \
--hash=sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09 \
--hash=sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096 \
--hash=sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9 \
--hash=sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b \
--hash=sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5 \
--hash=sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678 \
--hash=sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea \
--hash=sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d \
--hash=sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49 \
--hash=sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc \
--hash=sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5 \
--hash=sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027 \
--hash=sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0 \
--hash=sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878 \
--hash=sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c \
--hash=sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa \
--hash=sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f \
--hash=sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6 \
--hash=sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2 \
--hash=sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf \
--hash=sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708 \
--hash=sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6 \
--hash=sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f \
--hash=sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd \
--hash=sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2 \
--hash=sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d \
--hash=sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7 \
--hash=sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f \
--hash=sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5 \
--hash=sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6 \
--hash=sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557 \
--hash=sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14 \
--hash=sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7 \
--hash=sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd \
--hash=sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c \
--hash=sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17 \
--hash=sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23 \
--hash=sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db \
--hash=sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6 \
--hash=sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d \
--hash=sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9 \
--hash=sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee \
--hash=sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6
yarl==1.24.5 \
--hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \
--hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \
--hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \
--hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \
--hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \
--hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \
--hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \
--hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \
--hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \
--hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \
--hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \
--hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \
--hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \
--hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \
--hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \
--hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \
--hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \
--hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \
--hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \
--hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \
--hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \
--hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \
--hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \
--hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \
--hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \
--hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \
--hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \
--hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \
--hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \
--hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \
--hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \
--hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \
--hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \
--hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \
--hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \
--hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \
--hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \
--hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \
--hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \
--hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \
--hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \
--hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \
--hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \
--hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \
--hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \
--hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \
--hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \
--hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \
--hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \
--hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \
--hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \
--hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \
--hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \
--hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \
--hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \
--hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \
--hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \
--hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \
--hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \
--hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \
--hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \
--hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \
--hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \
--hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \
--hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \
--hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \
--hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \
--hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \
--hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \
--hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \
--hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \
--hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \
--hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \
--hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \
--hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \
--hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \
--hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \
--hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \
--hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \
--hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \
--hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \
--hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \
--hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \
--hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \
--hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \
--hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \
--hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \
--hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \
--hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \
--hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \
--hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \
--hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \
--hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \
--hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \
--hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \
--hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \
--hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \
--hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \
--hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \
--hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \
--hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \
--hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \
--hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \
--hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104

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

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

895
app/requirements.lock Normal file
View File

@@ -0,0 +1,895 @@
# Сгенерированный hash-lock production-зависимостей; вручную не редактировать.
# Источник: app/requirements.txt; Python 3.12; universal resolution.
# Команда приведена в docs/operations/trades_feed_runtime.md.
aiofiles==24.1.0 \
--hash=sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c \
--hash=sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5
aiogram==3.13.1 \
--hash=sha256:148cbba15c2b6e0bc482aff6ae86848ed43e023b7102336bad17698242693000 \
--hash=sha256:2674640ae74501ae30e68a5354e03f12e10db3adbcbd475bb56904536288c9c9
aiohappyeyeballs==2.7.1 \
--hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
--hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
aiohttp==3.10.11 \
--hash=sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d \
--hash=sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc \
--hash=sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261 \
--hash=sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b \
--hash=sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625 \
--hash=sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39 \
--hash=sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f \
--hash=sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636 \
--hash=sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac \
--hash=sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217 \
--hash=sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9 \
--hash=sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f \
--hash=sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006 \
--hash=sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e \
--hash=sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9 \
--hash=sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a \
--hash=sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695 \
--hash=sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730 \
--hash=sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e \
--hash=sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382 \
--hash=sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc \
--hash=sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00 \
--hash=sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8 \
--hash=sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658 \
--hash=sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339 \
--hash=sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a \
--hash=sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a \
--hash=sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e \
--hash=sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d \
--hash=sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e \
--hash=sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca \
--hash=sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038 \
--hash=sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461 \
--hash=sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829 \
--hash=sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31 \
--hash=sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4 \
--hash=sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127 \
--hash=sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3 \
--hash=sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97 \
--hash=sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710 \
--hash=sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2 \
--hash=sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674 \
--hash=sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb \
--hash=sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03 \
--hash=sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07 \
--hash=sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9 \
--hash=sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6 \
--hash=sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f \
--hash=sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298 \
--hash=sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b \
--hash=sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01 \
--hash=sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9 \
--hash=sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7 \
--hash=sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413 \
--hash=sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec \
--hash=sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7 \
--hash=sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4 \
--hash=sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb \
--hash=sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519 \
--hash=sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d \
--hash=sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6 \
--hash=sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27 \
--hash=sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1 \
--hash=sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087 \
--hash=sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067 \
--hash=sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f \
--hash=sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95 \
--hash=sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa \
--hash=sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa \
--hash=sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16 \
--hash=sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106 \
--hash=sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92 \
--hash=sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777 \
--hash=sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa \
--hash=sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e \
--hash=sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7 \
--hash=sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115 \
--hash=sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120 \
--hash=sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d \
--hash=sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b \
--hash=sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a \
--hash=sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725 \
--hash=sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a \
--hash=sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d \
--hash=sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288 \
--hash=sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8 \
--hash=sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385 \
--hash=sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24 \
--hash=sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71 \
--hash=sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138 \
--hash=sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177
aiosignal==1.4.0 \
--hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \
--hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
et-xmlfile==2.0.0 \
--hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \
--hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54
frozenlist==1.8.0 \
--hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \
--hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \
--hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \
--hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \
--hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \
--hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \
--hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \
--hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \
--hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \
--hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \
--hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \
--hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \
--hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \
--hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \
--hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \
--hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \
--hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \
--hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \
--hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \
--hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \
--hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \
--hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \
--hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \
--hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \
--hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \
--hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \
--hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \
--hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \
--hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \
--hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \
--hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \
--hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \
--hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \
--hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \
--hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \
--hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \
--hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \
--hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \
--hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \
--hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \
--hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \
--hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \
--hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \
--hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \
--hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \
--hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \
--hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \
--hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \
--hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \
--hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \
--hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \
--hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \
--hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \
--hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \
--hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \
--hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \
--hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \
--hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \
--hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \
--hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \
--hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \
--hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \
--hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \
--hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \
--hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \
--hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \
--hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \
--hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \
--hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \
--hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \
--hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \
--hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \
--hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \
--hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \
--hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \
--hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \
--hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \
--hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \
--hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \
--hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \
--hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \
--hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \
--hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \
--hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \
--hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \
--hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \
--hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \
--hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \
--hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \
--hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \
--hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \
--hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \
--hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \
--hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \
--hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \
--hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \
--hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \
--hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \
--hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \
--hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \
--hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \
--hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \
--hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \
--hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \
--hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \
--hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \
--hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \
--hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \
--hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \
--hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \
--hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \
--hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \
--hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \
--hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \
--hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \
--hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \
--hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \
--hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \
--hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \
--hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \
--hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \
--hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \
--hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \
--hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \
--hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \
--hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \
--hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \
--hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \
--hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \
--hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd
idna==3.18 \
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
magic-filter==1.0.12 \
--hash=sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9 \
--hash=sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6
multidict==6.7.1 \
--hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \
--hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \
--hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \
--hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \
--hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \
--hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \
--hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \
--hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \
--hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \
--hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \
--hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \
--hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \
--hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \
--hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \
--hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \
--hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \
--hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \
--hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \
--hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \
--hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \
--hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \
--hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \
--hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \
--hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \
--hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \
--hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \
--hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \
--hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \
--hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \
--hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \
--hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \
--hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \
--hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \
--hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \
--hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \
--hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \
--hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \
--hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \
--hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \
--hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \
--hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \
--hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \
--hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \
--hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \
--hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \
--hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \
--hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \
--hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \
--hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \
--hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \
--hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \
--hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \
--hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \
--hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \
--hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \
--hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \
--hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \
--hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \
--hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \
--hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \
--hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \
--hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \
--hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \
--hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \
--hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \
--hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \
--hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \
--hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \
--hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \
--hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \
--hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \
--hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \
--hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \
--hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \
--hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \
--hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \
--hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \
--hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \
--hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \
--hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \
--hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \
--hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \
--hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \
--hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \
--hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \
--hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \
--hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \
--hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \
--hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \
--hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \
--hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \
--hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \
--hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \
--hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \
--hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \
--hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \
--hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \
--hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \
--hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \
--hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \
--hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \
--hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \
--hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \
--hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \
--hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \
--hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \
--hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \
--hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \
--hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \
--hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \
--hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \
--hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \
--hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \
--hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \
--hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \
--hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \
--hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \
--hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \
--hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \
--hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \
--hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \
--hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \
--hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \
--hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \
--hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \
--hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \
--hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \
--hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \
--hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \
--hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \
--hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \
--hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \
--hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \
--hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \
--hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \
--hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \
--hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \
--hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \
--hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \
--hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \
--hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \
--hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \
--hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \
--hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \
--hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \
--hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19
openpyxl==3.1.5 \
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
propcache==0.5.2 \
--hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \
--hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \
--hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \
--hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \
--hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \
--hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \
--hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \
--hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \
--hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \
--hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \
--hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \
--hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \
--hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \
--hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \
--hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \
--hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \
--hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \
--hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \
--hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \
--hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \
--hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \
--hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \
--hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \
--hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \
--hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \
--hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \
--hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \
--hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \
--hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \
--hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \
--hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \
--hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \
--hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \
--hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \
--hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \
--hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \
--hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \
--hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \
--hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \
--hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \
--hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \
--hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \
--hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \
--hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \
--hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \
--hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \
--hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \
--hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \
--hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \
--hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \
--hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \
--hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \
--hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \
--hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \
--hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \
--hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \
--hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \
--hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \
--hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \
--hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \
--hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \
--hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \
--hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \
--hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \
--hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \
--hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \
--hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \
--hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \
--hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \
--hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \
--hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \
--hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \
--hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \
--hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \
--hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \
--hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \
--hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \
--hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \
--hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \
--hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \
--hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \
--hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \
--hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \
--hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \
--hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \
--hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \
--hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \
--hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \
--hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \
--hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \
--hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \
--hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \
--hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \
--hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \
--hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \
--hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \
--hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \
--hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \
--hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \
--hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \
--hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \
--hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \
--hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \
--hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \
--hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \
--hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \
--hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \
--hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \
--hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \
--hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \
--hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \
--hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \
--hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \
--hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \
--hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \
--hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \
--hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \
--hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \
--hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \
--hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \
--hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6
psycopg==3.2.9 \
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
psycopg-binary==3.2.9 ; implementation_name != 'pypy' \
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
psycopg-pool==3.3.1 \
--hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \
--hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c
pydantic==2.9.2 \
--hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \
--hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12
pydantic-core==2.23.4 \
--hash=sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36 \
--hash=sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05 \
--hash=sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071 \
--hash=sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327 \
--hash=sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c \
--hash=sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36 \
--hash=sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29 \
--hash=sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744 \
--hash=sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d \
--hash=sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec \
--hash=sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e \
--hash=sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e \
--hash=sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577 \
--hash=sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232 \
--hash=sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863 \
--hash=sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6 \
--hash=sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368 \
--hash=sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480 \
--hash=sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2 \
--hash=sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2 \
--hash=sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6 \
--hash=sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769 \
--hash=sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d \
--hash=sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 \
--hash=sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84 \
--hash=sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 \
--hash=sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271 \
--hash=sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5 \
--hash=sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb \
--hash=sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13 \
--hash=sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323 \
--hash=sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556 \
--hash=sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665 \
--hash=sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef \
--hash=sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb \
--hash=sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119 \
--hash=sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126 \
--hash=sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510 \
--hash=sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b \
--hash=sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87 \
--hash=sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f \
--hash=sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc \
--hash=sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8 \
--hash=sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21 \
--hash=sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f \
--hash=sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6 \
--hash=sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658 \
--hash=sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b \
--hash=sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3 \
--hash=sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb \
--hash=sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59 \
--hash=sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24 \
--hash=sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9 \
--hash=sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3 \
--hash=sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd \
--hash=sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753 \
--hash=sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55 \
--hash=sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad \
--hash=sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a \
--hash=sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605 \
--hash=sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e \
--hash=sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b \
--hash=sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433 \
--hash=sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8 \
--hash=sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07 \
--hash=sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728 \
--hash=sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0 \
--hash=sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327 \
--hash=sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555 \
--hash=sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64 \
--hash=sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6 \
--hash=sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea \
--hash=sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b \
--hash=sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df \
--hash=sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e \
--hash=sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd \
--hash=sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068 \
--hash=sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3 \
--hash=sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040 \
--hash=sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12 \
--hash=sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916 \
--hash=sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f \
--hash=sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f \
--hash=sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801 \
--hash=sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231 \
--hash=sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5 \
--hash=sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8 \
--hash=sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee \
--hash=sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607
python-dotenv==1.0.1 \
--hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \
--hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
tzdata==2026.3 ; sys_platform == 'win32' \
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
websockets==13.1 \
--hash=sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a \
--hash=sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54 \
--hash=sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23 \
--hash=sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7 \
--hash=sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135 \
--hash=sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700 \
--hash=sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf \
--hash=sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5 \
--hash=sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e \
--hash=sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c \
--hash=sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02 \
--hash=sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a \
--hash=sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418 \
--hash=sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f \
--hash=sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3 \
--hash=sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68 \
--hash=sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978 \
--hash=sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20 \
--hash=sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295 \
--hash=sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b \
--hash=sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6 \
--hash=sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb \
--hash=sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a \
--hash=sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa \
--hash=sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0 \
--hash=sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a \
--hash=sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238 \
--hash=sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c \
--hash=sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084 \
--hash=sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19 \
--hash=sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d \
--hash=sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7 \
--hash=sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9 \
--hash=sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79 \
--hash=sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96 \
--hash=sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6 \
--hash=sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe \
--hash=sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842 \
--hash=sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa \
--hash=sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3 \
--hash=sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d \
--hash=sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51 \
--hash=sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7 \
--hash=sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09 \
--hash=sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096 \
--hash=sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9 \
--hash=sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b \
--hash=sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5 \
--hash=sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678 \
--hash=sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea \
--hash=sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d \
--hash=sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49 \
--hash=sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc \
--hash=sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5 \
--hash=sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027 \
--hash=sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0 \
--hash=sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878 \
--hash=sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c \
--hash=sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa \
--hash=sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f \
--hash=sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6 \
--hash=sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2 \
--hash=sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf \
--hash=sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708 \
--hash=sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6 \
--hash=sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f \
--hash=sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd \
--hash=sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2 \
--hash=sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d \
--hash=sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7 \
--hash=sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f \
--hash=sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5 \
--hash=sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6 \
--hash=sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557 \
--hash=sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14 \
--hash=sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7 \
--hash=sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd \
--hash=sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c \
--hash=sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17 \
--hash=sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23 \
--hash=sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db \
--hash=sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6 \
--hash=sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d \
--hash=sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9 \
--hash=sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee \
--hash=sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6
yarl==1.24.5 \
--hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \
--hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \
--hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \
--hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \
--hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \
--hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \
--hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \
--hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \
--hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \
--hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \
--hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \
--hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \
--hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \
--hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \
--hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \
--hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \
--hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \
--hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \
--hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \
--hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \
--hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \
--hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \
--hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \
--hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \
--hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \
--hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \
--hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \
--hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \
--hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \
--hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \
--hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \
--hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \
--hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \
--hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \
--hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \
--hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \
--hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \
--hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \
--hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \
--hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \
--hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \
--hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \
--hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \
--hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \
--hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \
--hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \
--hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \
--hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \
--hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \
--hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \
--hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \
--hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \
--hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \
--hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \
--hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \
--hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \
--hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \
--hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \
--hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \
--hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \
--hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \
--hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \
--hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \
--hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \
--hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \
--hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \
--hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \
--hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \
--hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \
--hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \
--hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \
--hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \
--hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \
--hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \
--hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \
--hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \
--hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \
--hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \
--hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \
--hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \
--hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \
--hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \
--hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \
--hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \
--hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \
--hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \
--hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \
--hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \
--hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \
--hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \
--hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \
--hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \
--hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \
--hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \
--hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \
--hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \
--hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \
--hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \
--hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \
--hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \
--hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \
--hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \
--hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \
--hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104

View File

@@ -3,5 +3,6 @@
aiogram==3.13.1 aiogram==3.13.1
python-dotenv==1.0.1 python-dotenv==1.0.1
psycopg[binary]==3.2.9 psycopg[binary]==3.2.9
psycopg-pool==3.3.1
openpyxl==3.1.5 openpyxl==3.1.5
websockets==13.1 websockets==13.1

View File

@@ -0,0 +1,328 @@
# app/scripts/check_ohlc_websocket.py
from __future__ import annotations
import asyncio
import json
from typing import Any
from uuid import uuid4
import websockets
from websockets.typing import Subprotocol
from src.core.config import load_settings
SYMBOLS = (
"BTC/USD_LEVERAGE",
)
INTERVALS = (
"1m",
"5m",
)
CANDLE_TYPE = "classic"
MAX_MESSAGES = 30
TOTAL_TIMEOUT_SECONDS = 300.0
RECEIVE_TIMEOUT_SECONDS = 15.0
def build_ws_url() -> str:
settings = load_settings()
raw_url = settings.exchange_ws_url or settings.exchange_base_url
if raw_url.startswith("https://"):
raw_url = raw_url.replace("https://", "wss://", 1)
elif raw_url.startswith("http://"):
raw_url = raw_url.replace("http://", "ws://", 1)
raw_url = raw_url.rstrip("/")
if raw_url.endswith("/connect"):
return raw_url
return f"{raw_url}/connect"
def build_headers() -> dict[str, str]:
settings = load_settings()
headers = {
"Origin": settings.exchange_base_url.rstrip("/"),
"Content-Type": "application/json",
}
if settings.exchange_api_key:
headers["X-MBX-APIKEY"] = settings.exchange_api_key
return headers
def build_subscribe_request(
symbols: tuple[str, ...],
intervals: tuple[str, ...],
candle_type: str,
) -> dict[str, Any]:
return {
"correlationId": str(uuid4()),
"destination": "OHLCMarketData.subscribe",
"payload": {
"intervals": list(intervals),
"symbols": list(symbols),
"type": candle_type,
},
}
def message_to_text(
raw_message: str | bytes | bytearray | memoryview,
) -> str:
if isinstance(raw_message, str):
return raw_message
if isinstance(raw_message, memoryview):
return raw_message.tobytes().decode(
"utf-8",
errors="replace",
)
return bytes(raw_message).decode(
"utf-8",
errors="replace",
)
def decode_document(
raw_message: str | bytes | bytearray | memoryview,
) -> object | None:
text = message_to_text(raw_message)
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def format_json(
raw_message: str | bytes | bytearray | memoryview,
) -> str:
text = message_to_text(raw_message)
try:
document = json.loads(text)
except json.JSONDecodeError:
return text
return json.dumps(
document,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
def destination_from_document(document: object) -> str | None:
if not isinstance(document, dict):
return None
direct_destination = document.get("destination")
if isinstance(direct_destination, str):
return direct_destination
upper_destination = document.get("Destination")
if isinstance(upper_destination, str):
return upper_destination
payload = document.get("payload")
if isinstance(payload, dict):
nested_destination = payload.get("destination")
if isinstance(nested_destination, str):
return nested_destination
nested_upper_destination = payload.get("Destination")
if isinstance(nested_upper_destination, str):
return nested_upper_destination
upper_payload = document.get("Payload")
if isinstance(upper_payload, dict):
nested_destination = upper_payload.get("destination")
if isinstance(nested_destination, str):
return nested_destination
nested_upper_destination = upper_payload.get("Destination")
if isinstance(nested_upper_destination, str):
return nested_upper_destination
return None
def is_ohlc_event(document: object) -> bool:
destination = destination_from_document(document)
if destination is None:
return False
return destination.lower() == "ohlc.event"
def elapsed_seconds(
loop: asyncio.AbstractEventLoop,
started_at: float,
) -> float:
return max(0.0, loop.time() - started_at)
async def receive_ohlc() -> None:
settings = load_settings()
ws_url = build_ws_url()
headers = build_headers()
request = build_subscribe_request(
SYMBOLS,
INTERVALS,
CANDLE_TYPE,
)
print(f"WebSocket URL: {ws_url}")
print(f"Symbols: {', '.join(SYMBOLS)}")
print(f"Intervals: {', '.join(INTERVALS)}")
print(f"Candle type: {CANDLE_TYPE}")
print(f"Maximum messages: {MAX_MESSAGES}")
print(f"Total timeout: {TOTAL_TIMEOUT_SECONDS:.0f} seconds")
print()
print("Subscription request:")
print(
json.dumps(
request,
ensure_ascii=False,
indent=2,
)
)
print()
async with websockets.connect(
ws_url,
extra_headers=headers,
subprotocols=[Subprotocol("json")],
ping_interval=20,
ping_timeout=float(settings.exchange_timeout_sec),
open_timeout=float(settings.exchange_timeout_sec),
close_timeout=float(settings.exchange_timeout_sec),
) as websocket:
await websocket.send(
json.dumps(
request,
ensure_ascii=False,
)
)
print("Subscription request sent.")
print("Waiting for acknowledgement and OHLC events...")
print()
loop = asyncio.get_running_loop()
started_at = loop.time()
deadline = started_at + TOTAL_TIMEOUT_SECONDS
received_count = 0
ohlc_event_count = 0
timeout_count = 0
while received_count < MAX_MESSAGES:
remaining_seconds = deadline - loop.time()
if remaining_seconds <= 0:
print(
"Total timeout reached after "
f"{elapsed_seconds(loop, started_at):.1f} seconds."
)
break
receive_timeout = min(
RECEIVE_TIMEOUT_SECONDS,
remaining_seconds,
)
try:
raw_message = await asyncio.wait_for(
websocket.recv(),
timeout=receive_timeout,
)
except asyncio.TimeoutError:
timeout_count += 1
print(
"No OHLC event yet; connection is alive. "
f"Elapsed: {elapsed_seconds(loop, started_at):.1f}s, "
f"remaining: {max(0.0, remaining_seconds):.1f}s."
)
continue
if not isinstance(
raw_message,
(str, bytes, bytearray, memoryview),
):
print(
"Skipped unsupported WebSocket message type: "
f"{type(raw_message).__name__}"
)
continue
received_count += 1
document = decode_document(raw_message)
if is_ohlc_event(document):
ohlc_event_count += 1
message_kind = "OHLC EVENT"
else:
message_kind = "CONTROL / ACKNOWLEDGEMENT"
print("=" * 80)
print(
f"Message #{received_count}{message_kind}"
f"elapsed {elapsed_seconds(loop, started_at):.1f}s"
)
print(format_json(raw_message))
print()
print("=" * 80)
print("Diagnostic summary")
print(f"Received messages: {received_count}")
print(f"OHLC events: {ohlc_event_count}")
print(f"Receive timeouts: {timeout_count}")
print(
"Elapsed time: "
f"{elapsed_seconds(loop, started_at):.1f} seconds"
)
def main() -> None:
try:
asyncio.run(receive_ohlc())
except KeyboardInterrupt:
print()
print("Stopped by user.")
except Exception as exc:
print()
print(
"WebSocket diagnostic failed: "
f"{type(exc).__name__}: {exc}"
)
raise
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
import asyncio
import json
import os
from typing import Any
from uuid import uuid4
import websockets
from websockets.typing import Subprotocol
SYMBOL = os.getenv(
"TRADE_TEST_SYMBOL",
"BTC/USD_LEVERAGE",
)
OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS = 30
OBSERVE_AFTER_UNSUBSCRIBE_SECONDS = 60
RECEIVE_TIMEOUT_SECONDS = 10
def build_ws_url() -> str:
raw_url = os.getenv(
"EXCHANGE_WS_URL",
"wss://api-adapter.dzengi.com",
).rstrip("/")
if raw_url.startswith("https://"):
raw_url = raw_url.replace("https://", "wss://", 1)
elif raw_url.startswith("http://"):
raw_url = raw_url.replace("http://", "ws://", 1)
if raw_url.endswith("/connect"):
return raw_url
return f"{raw_url}/connect"
def build_headers() -> dict[str, str]:
base_url = os.getenv(
"EXCHANGE_BASE_URL",
"https://api-adapter.dzengi.com",
).rstrip("/")
headers = {
"Origin": base_url,
"Content-Type": "application/json",
}
api_key = os.getenv("EXCHANGE_API_KEY", "").strip()
if api_key:
headers["X-MBX-APIKEY"] = api_key
return headers
def build_request(destination: str) -> dict[str, Any]:
return {
"correlationId": str(uuid4()),
"destination": destination,
"payload": {
"symbols": [SYMBOL],
},
}
def decode_message(raw_message: str | bytes) -> Any:
if isinstance(raw_message, bytes):
raw_message = raw_message.decode("utf-8")
try:
return json.loads(raw_message)
except json.JSONDecodeError:
return raw_message
def print_message(label: str, message: Any) -> None:
print()
print("=" * 80)
print(label)
print("=" * 80)
if isinstance(message, (dict, list)):
print(json.dumps(message, indent=2, ensure_ascii=False))
else:
print(message)
def is_trade_event(message: Any) -> bool:
return (
isinstance(message, dict)
and message.get("destination") == "internal.trade"
)
async def receive_until(
websocket: Any,
*,
duration_seconds: int,
phase: str,
) -> int:
loop = asyncio.get_running_loop()
deadline = loop.time() + duration_seconds
trade_count = 0
while loop.time() < deadline:
remaining = deadline - loop.time()
timeout = min(RECEIVE_TIMEOUT_SECONDS, remaining)
try:
raw_message = await asyncio.wait_for(
websocket.recv(),
timeout=timeout,
)
except asyncio.TimeoutError:
print(
f"[{phase}] За последние {timeout:.0f} секунд "
"сообщений не получено; соединение остаётся открытым."
)
continue
message = decode_message(raw_message)
print_message(f"[{phase}] Получено сообщение", message)
if is_trade_event(message):
trade_count += 1
return trade_count
async def main() -> None:
ws_url = build_ws_url()
headers = build_headers()
subscribe_request = build_request("trades.subscribe")
unsubscribe_request = build_request("trades.unsubscribe")
print(f"WebSocket URL: {ws_url}")
print(f"Symbol: {SYMBOL}")
async with websockets.connect(
ws_url,
extra_headers=headers,
subprotocols=[Subprotocol("json")],
ping_interval=20,
ping_timeout=20,
open_timeout=20,
) as websocket:
print_message(
"Отправляется trades.subscribe",
subscribe_request,
)
await websocket.send(
json.dumps(subscribe_request),
)
before_count = await receive_until(
websocket,
duration_seconds=OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS,
phase="BEFORE UNSUBSCRIBE",
)
print()
print(
"Количество Trade Event до unsubscribe: "
f"{before_count}"
)
print_message(
"Отправляется trades.unsubscribe",
unsubscribe_request,
)
await websocket.send(
json.dumps(unsubscribe_request),
)
after_count = await receive_until(
websocket,
duration_seconds=OBSERVE_AFTER_UNSUBSCRIBE_SECONDS,
phase="AFTER UNSUBSCRIBE",
)
print()
print("=" * 80)
print("РЕЗУЛЬТАТ НАБЛЮДЕНИЯ")
print("=" * 80)
print(f"Trade Event до unsubscribe: {before_count}")
print(f"Trade Event после unsubscribe: {after_count}")
if before_count == 0:
print(
"До unsubscribe не было получено ни одной сделки. "
"Эксперимент нельзя считать доказательным."
)
elif after_count == 0:
print(
"После trades.unsubscribe новые сделки не поступили. "
"Команда, вероятно, поддерживается, но необходимо "
"проверить ACK или повторить тест при высокой активности."
)
else:
print(
"После trades.unsubscribe сделки продолжили поступать. "
"Команда либо не поддерживается, либо была отклонена, "
"либо имеет другой формат."
)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,269 @@
# app/scripts/check_trades_websocket.py
from __future__ import annotations
import asyncio
import json
from typing import Any
from uuid import uuid4
import websockets
from websockets.typing import Subprotocol
from src.core.config import load_settings
SYMBOLS = (
"BTC/USD_LEVERAGE",
)
MAX_MESSAGES = 100
TOTAL_TIMEOUT_SECONDS = 3000.0
RECEIVE_TIMEOUT_SECONDS = 15.0
def build_ws_url() -> str:
settings = load_settings()
raw_url = settings.exchange_ws_url or settings.exchange_base_url
if raw_url.startswith("https://"):
raw_url = raw_url.replace("https://", "wss://", 1)
elif raw_url.startswith("http://"):
raw_url = raw_url.replace("http://", "ws://", 1)
raw_url = raw_url.rstrip("/")
if raw_url.endswith("/connect"):
return raw_url
return f"{raw_url}/connect"
def build_headers() -> dict[str, str]:
settings = load_settings()
headers = {
"Origin": settings.exchange_base_url.rstrip("/"),
"Content-Type": "application/json",
}
if settings.exchange_api_key:
headers["X-MBX-APIKEY"] = settings.exchange_api_key
return headers
def build_subscribe_request(
symbols: tuple[str, ...],
) -> dict[str, Any]:
return {
"correlationId": str(uuid4()),
"destination": "trades.subscribe",
"payload": {
"symbols": list(symbols),
},
}
def format_json(
raw_message: str | bytes | bytearray | memoryview,
) -> str:
if isinstance(raw_message, str):
text = raw_message
elif isinstance(raw_message, memoryview):
text = raw_message.tobytes().decode(
"utf-8",
errors="replace",
)
else:
text = bytes(raw_message).decode(
"utf-8",
errors="replace",
)
try:
document = json.loads(text)
except json.JSONDecodeError:
return text
return json.dumps(
document,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
def decode_document(
raw_message: str | bytes | bytearray | memoryview,
) -> object | None:
if isinstance(raw_message, str):
text = raw_message
elif isinstance(raw_message, memoryview):
text = raw_message.tobytes().decode(
"utf-8",
errors="replace",
)
else:
text = bytes(raw_message).decode(
"utf-8",
errors="replace",
)
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def is_trade_event(document: object) -> bool:
if not isinstance(document, dict):
return False
return document.get("destination") == "internal.trade"
def elapsed_seconds(
loop: asyncio.AbstractEventLoop,
started_at: float,
) -> float:
return max(0.0, loop.time() - started_at)
async def receive_trades() -> None:
settings = load_settings()
ws_url = build_ws_url()
headers = build_headers()
request = build_subscribe_request(SYMBOLS)
print(f"WebSocket URL: {ws_url}")
print(f"Symbols: {', '.join(SYMBOLS)}")
print(f"Maximum messages: {MAX_MESSAGES}")
print(f"Total timeout: {TOTAL_TIMEOUT_SECONDS:.0f} seconds")
print()
print("Subscription request:")
print(
json.dumps(
request,
ensure_ascii=False,
indent=2,
)
)
print()
async with websockets.connect(
ws_url,
extra_headers=headers,
subprotocols=[Subprotocol("json")],
ping_interval=20,
ping_timeout=float(settings.exchange_timeout_sec),
open_timeout=float(settings.exchange_timeout_sec),
close_timeout=float(settings.exchange_timeout_sec),
) as websocket:
await websocket.send(
json.dumps(
request,
ensure_ascii=False,
)
)
print("Subscription request sent.")
print("Waiting for acknowledgement and trade events...")
print()
loop = asyncio.get_running_loop()
started_at = loop.time()
deadline = started_at + TOTAL_TIMEOUT_SECONDS
received_count = 0
trade_event_count = 0
timeout_count = 0
while received_count < MAX_MESSAGES:
remaining_seconds = deadline - loop.time()
if remaining_seconds <= 0:
print(
"Total timeout reached after "
f"{elapsed_seconds(loop, started_at):.1f} seconds."
)
break
receive_timeout = min(
RECEIVE_TIMEOUT_SECONDS,
remaining_seconds,
)
try:
raw_message = await asyncio.wait_for(
websocket.recv(),
timeout=receive_timeout,
)
except asyncio.TimeoutError:
timeout_count += 1
print(
"No trade event yet; connection is alive. "
f"Elapsed: {elapsed_seconds(loop, started_at):.1f}s, "
f"remaining: {max(0.0, remaining_seconds):.1f}s."
)
continue
if not isinstance(
raw_message,
(str, bytes, bytearray, memoryview),
):
print(
"Skipped unsupported WebSocket message type: "
f"{type(raw_message).__name__}"
)
continue
received_count += 1
document = decode_document(raw_message)
if is_trade_event(document):
trade_event_count += 1
message_kind = "TRADE EVENT"
else:
message_kind = "CONTROL / ACKNOWLEDGEMENT"
print("=" * 80)
print(
f"Message #{received_count}{message_kind}"
f"elapsed {elapsed_seconds(loop, started_at):.1f}s"
)
print(format_json(raw_message))
print()
print("=" * 80)
print("Diagnostic summary")
print(f"Received messages: {received_count}")
print(f"Trade events: {trade_event_count}")
print(f"Receive timeouts: {timeout_count}")
print(
"Elapsed time: "
f"{elapsed_seconds(loop, started_at):.1f} seconds"
)
def main() -> None:
try:
asyncio.run(receive_trades())
except KeyboardInterrupt:
print()
print("Stopped by user.")
except Exception as exc:
print()
print(
"WebSocket diagnostic failed: "
f"{type(exc).__name__}: {exc}"
)
raise
if __name__ == "__main__":
main()

View File

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

@@ -5,7 +5,14 @@ from __future__ import annotations
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from src.bootstrap.application import ApplicationComposition
from src.bootstrap.logging import setup_logging from src.bootstrap.logging import setup_logging
from src.bootstrap.market_data_storage import (
build_market_data_storage,
)
from src.bootstrap.trade_stream_runtime import (
build_trade_stream_production_runtime,
)
from src.core.config import load_settings from src.core.config import load_settings
from src.notifications.targets import NotificationTargetRegistry from src.notifications.targets import NotificationTargetRegistry
from src.storage.schema import init_schema from src.storage.schema import init_schema
@@ -13,7 +20,7 @@ from src.telegram.routers import setup_routers
from src.trading.journal.service import JournalService from src.trading.journal.service import JournalService
def create_app() -> tuple[Bot, Dispatcher]: def create_app() -> ApplicationComposition:
settings = load_settings() settings = load_settings()
setup_logging(settings.log_level) setup_logging(settings.log_level)
@@ -37,18 +44,20 @@ def create_app() -> tuple[Bot, Dispatcher]:
pass pass
raise raise
try: market_data_storage = build_market_data_storage(settings)
journal.log_info( trade_stream_runtime = build_trade_stream_production_runtime(
"app_started", settings,
"Приложение запущено", trade_observation_sink=(
{ market_data_storage.trade_observation_sink
"env": settings.app_env, if market_data_storage is not None
"exchange_name": settings.exchange_name, else None
"default_symbol": settings.default_symbol, ),
}, checkpoint_storage=(
) market_data_storage.trade_repository
except Exception: if market_data_storage is not None
pass else None
),
)
bot = Bot( bot = Bot(
token=settings.bot_token, token=settings.bot_token,
@@ -61,4 +70,32 @@ def create_app() -> tuple[Bot, Dispatcher]:
setup_routers(dispatcher) setup_routers(dispatcher)
return bot, dispatcher try:
journal.log_info(
"app_started",
"Приложение запущено",
{
"env": settings.app_env,
"exchange_name": settings.exchange_name,
"default_symbol": settings.default_symbol,
"trade_stream_enabled": (
settings.trade_stream.enabled
),
"market_data_storage_enabled": (
settings.market_data_storage.enabled
),
},
)
except Exception:
pass
return ApplicationComposition(
bot=bot,
dispatcher=dispatcher,
trade_stream_runtime=trade_stream_runtime,
market_data_storage_lifecycle=(
market_data_storage.lifecycle
if market_data_storage is not None
else None
),
)

View File

@@ -0,0 +1,326 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from aiogram import Bot, Dispatcher
from src.bootstrap.market_data_storage import (
MarketDataStorageLifecycleProtocol,
)
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
TradeStreamProductionRuntimeProtocol,
)
@dataclass(frozen=True, slots=True)
class ApplicationComposition:
"""Корневые компоненты одного запущенного экземпляра приложения."""
bot: Bot
dispatcher: Dispatcher
trade_stream_runtime: (
TradeStreamProductionRuntimeProtocol | None
)
market_data_storage_lifecycle: (
MarketDataStorageLifecycleProtocol | None
) = None
async def run_application(
application: ApplicationComposition,
) -> None:
"""
Выполнять Telegram polling и опциональный Trade Stream как одно целое.
Ошибка включённого Trade Stream считается фатальной для всего
приложения. Остановка Telegram, Trade Stream и bot session
выполняется одним владельцем и не оставляет фоновых root tasks.
"""
polling_task: asyncio.Task[None] | None = None
runtime_task: asyncio.Task[None] | None = None
primary_error: BaseException | None = None
try:
storage_lifecycle = (
application.market_data_storage_lifecycle
)
if storage_lifecycle is not None:
await _run_blocking_lifecycle_operation(
storage_lifecycle.start,
task_name="market-data-storage-startup",
)
polling_task = asyncio.create_task(
application.dispatcher.start_polling(
application.bot,
close_bot_session=False,
),
name="telegram-polling",
)
runtime_task = (
asyncio.create_task(
application.trade_stream_runtime.run(),
name="trade-stream-runtime",
)
if application.trade_stream_runtime is not None
else None
)
await _wait_for_root_tasks(
polling_task=polling_task,
runtime_task=runtime_task,
)
except BaseException as error:
primary_error = error
cleanup_task = asyncio.create_task(
_shutdown_application(
application=application,
polling_task=polling_task,
runtime_task=runtime_task,
primary_error=primary_error,
),
name="application-shutdown",
)
cleanup_error = await _await_cleanup(
cleanup_task,
primary_error=primary_error,
)
if primary_error is not None:
if cleanup_error is not None:
primary_error.add_note(
"Application cleanup also failed: "
f"{type(cleanup_error).__name__}."
)
raise primary_error.with_traceback(
primary_error.__traceback__,
)
if cleanup_error is not None:
raise cleanup_error.with_traceback(
cleanup_error.__traceback__,
)
async def _wait_for_root_tasks(
*,
polling_task: asyncio.Task[None],
runtime_task: asyncio.Task[None] | None,
) -> None:
if runtime_task is None:
await polling_task
return
done_tasks, _ = await asyncio.wait(
(
polling_task,
runtime_task,
),
return_when=asyncio.FIRST_COMPLETED,
)
if runtime_task in done_tasks:
if runtime_task.cancelled():
raise RuntimeError(
"Trade Stream Runtime was cancelled unexpectedly."
)
runtime_error = runtime_task.exception()
if runtime_error is not None:
raise runtime_error.with_traceback(
runtime_error.__traceback__,
)
raise RuntimeError(
"Trade Stream Runtime terminated unexpectedly."
)
await polling_task
async def _shutdown_application(
*,
application: ApplicationComposition,
polling_task: asyncio.Task[None] | None,
runtime_task: asyncio.Task[None] | None,
primary_error: BaseException | None,
) -> BaseException | None:
cleanup_error: BaseException | None = None
polling_cancelled = False
runtime_cancelled = False
if polling_task is not None and not polling_task.done():
polling_cancelled = True
polling_task.cancel()
runtime = application.trade_stream_runtime
if runtime is not None:
try:
await runtime.stop()
except BaseException as error:
cleanup_error = _merge_cleanup_error(
cleanup_error,
error,
)
if runtime_task is not None and not runtime_task.done():
runtime_cancelled = True
runtime_task.cancel()
if polling_task is not None:
cleanup_error = await _observe_root_task(
polling_task,
expected_cancellation=polling_cancelled,
primary_error=primary_error,
cleanup_error=cleanup_error,
)
if runtime_task is not None:
cleanup_error = await _observe_root_task(
runtime_task,
expected_cancellation=runtime_cancelled,
primary_error=primary_error,
cleanup_error=cleanup_error,
)
storage_lifecycle = application.market_data_storage_lifecycle
if storage_lifecycle is not None:
try:
await _run_blocking_lifecycle_operation(
storage_lifecycle.stop,
task_name="market-data-storage-shutdown",
)
except BaseException as error:
cleanup_error = _merge_cleanup_error(
cleanup_error,
error,
)
try:
await application.bot.session.close()
except BaseException as error:
cleanup_error = _merge_cleanup_error(
cleanup_error,
error,
)
return cleanup_error
async def _run_blocking_lifecycle_operation(
operation: Callable[[], None],
*,
task_name: str,
) -> None:
operation_task = asyncio.create_task(
asyncio.to_thread(operation),
name=task_name,
)
try:
await asyncio.shield(operation_task)
except asyncio.CancelledError as cancellation:
try:
await _wait_for_blocking_operation(operation_task)
except BaseException as operation_error:
cancellation.add_note(
"Application blocking lifecycle operation also failed: "
f"{type(operation_error).__name__}."
)
raise
async def _wait_for_blocking_operation(
operation_task: asyncio.Task[None],
) -> None:
while not operation_task.done():
try:
await asyncio.shield(operation_task)
except asyncio.CancelledError:
continue
except BaseException:
break
operation_task.result()
async def _observe_root_task(
task: asyncio.Task[None],
*,
expected_cancellation: bool,
primary_error: BaseException | None,
cleanup_error: BaseException | None,
) -> BaseException | None:
try:
await task
except asyncio.CancelledError as error:
if (
not expected_cancellation
and error is not primary_error
and not isinstance(primary_error, asyncio.CancelledError)
):
return _merge_cleanup_error(
cleanup_error,
error,
)
except BaseException as error:
if error is not primary_error:
return _merge_cleanup_error(
cleanup_error,
error,
)
return cleanup_error
async def _await_cleanup(
cleanup_task: asyncio.Task[BaseException | None],
*,
primary_error: BaseException | None,
) -> BaseException | None:
interrupted_error: asyncio.CancelledError | None = None
while not cleanup_task.done():
try:
await asyncio.shield(cleanup_task)
except asyncio.CancelledError as error:
if primary_error is None:
primary_error = error
interrupted_error = error
continue
cleanup_error = cleanup_task.result()
if interrupted_error is not None:
if cleanup_error is not None:
interrupted_error.add_note(
"Application cleanup also failed: "
f"{type(cleanup_error).__name__}."
)
raise interrupted_error
return cleanup_error
def _merge_cleanup_error(
current_error: BaseException | None,
new_error: BaseException,
) -> BaseException:
if current_error is None:
return new_error
current_error.add_note(
"Additional application cleanup failure: "
f"{type(new_error).__name__}."
)
return current_error

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from psycopg.conninfo import make_conninfo
from src.core.config import Settings
from src.market_data.storage.postgres_trade_repository import (
PostgresTradeRepository,
)
from src.market_data.storage.trade_storage_observation_sink import (
TradeStorageObservationSink,
)
from src.storage.migrations import StorageMigrationRunner
from src.storage.postgres_pool import PostgresConnectionPool
@runtime_checkable
class MarketDataStorageLifecycleProtocol(Protocol):
"""Синхронный жизненный цикл под управлением сборки приложения."""
def start(self) -> None:
"""Открыть пул и применить миграции до запуска Runtime."""
...
def stop(self) -> None:
"""Закрыть пул после завершения всех записей Runtime."""
...
class _ConnectionPoolLifecycleProtocol(Protocol):
def open(self) -> None:
...
def close(self) -> None:
...
class _MigrationRunnerProtocol(Protocol):
def run(self) -> tuple[int, ...]:
...
class MarketDataStorageLifecycle:
"""Управляет запуском пула, миграциями и очисткой частичного запуска."""
__slots__ = (
"_connection_pool",
"_migration_runner",
"_started",
)
def __init__(
self,
*,
connection_pool: _ConnectionPoolLifecycleProtocol,
migration_runner: _MigrationRunnerProtocol,
) -> None:
self._connection_pool = connection_pool
self._migration_runner = migration_runner
self._started = False
@property
def started(self) -> bool:
"""Показать, завершились ли запуск пула и миграции."""
return self._started
def start(self) -> None:
if self._started:
return
self._connection_pool.open()
try:
self._migration_runner.run()
except BaseException as error:
try:
self._connection_pool.close()
except BaseException as cleanup_error:
error.add_note(
"Market Data Storage startup cleanup also failed: "
f"{type(cleanup_error).__name__}."
)
raise
self._started = True
def stop(self) -> None:
self._started = False
self._connection_pool.close()
@dataclass(frozen=True, slots=True)
class MarketDataStorageBootstrapComposition:
"""Неактивный граф зависимостей постоянного хранилища сделок."""
connection_pool: PostgresConnectionPool
migration_runner: StorageMigrationRunner
trade_repository: PostgresTradeRepository
trade_observation_sink: TradeStorageObservationSink
lifecycle: MarketDataStorageLifecycle
def build_market_data_storage(
settings: Settings,
) -> MarketDataStorageBootstrapComposition | None:
"""Собрать зависимости хранилища без открытия PostgreSQL."""
storage_settings = settings.market_data_storage
if not storage_settings.enabled:
return None
if not settings.trade_stream.enabled:
raise RuntimeError(
"Trade Stream must be enabled when Market Data Storage "
"is enabled."
)
conninfo = make_conninfo(
host=settings.db_host,
port=settings.db_port,
dbname=settings.db_name,
user=settings.db_user,
password=settings.db_password,
)
connection_pool = PostgresConnectionPool(
conninfo=conninfo,
min_size=storage_settings.pool_min_size,
max_size=storage_settings.pool_max_size,
timeout_seconds=storage_settings.pool_timeout_seconds,
name="market-data-storage",
)
migration_runner = StorageMigrationRunner(
connection_provider=connection_pool.connection,
)
trade_repository = PostgresTradeRepository(
connection_provider=connection_pool.connection,
)
trade_observation_sink = TradeStorageObservationSink(
trade_storage=trade_repository,
venue=settings.exchange_name,
)
lifecycle = MarketDataStorageLifecycle(
connection_pool=connection_pool,
migration_runner=migration_runner,
)
return MarketDataStorageBootstrapComposition(
connection_pool=connection_pool,
migration_runner=migration_runner,
trade_repository=trade_repository,
trade_observation_sink=trade_observation_sink,
lifecycle=lifecycle,
)

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
from src.core.config import Settings
from src.integrations.exchange.rest_client import ExchangeRestClient
from src.market_data.acquisition.adapters.dzengi.rest import (
DzengiTradesDocumentSource,
)
from src.market_data.acquisition.adapters.dzengi.websocket import (
DzengiUnifiedWebSocketAdapter,
)
from src.market_data.acquisition.adapters.dzengi.websocket_control_message_handler import (
DzengiWebSocketControlMessageHandler,
)
from src.market_data.acquisition.adapters.dzengi.websocket_inbound_message_classifier import (
DzengiWebSocketInboundMessageClassifier,
)
from src.market_data.acquisition.adapters.dzengi.websocket_transport import (
DzengiWebSocketTransport,
)
from src.market_data.acquisition.consistency.trade_observation_sink_protocol import (
TradeObservationSinkProtocol,
)
from src.market_data.acquisition.runtime.acquisition_runtime_event_logging_consumer import (
AcquisitionRuntimeEventLoggingConsumer,
)
from src.market_data.acquisition.runtime.acquisition_runtime_event_publisher import (
AcquisitionRuntimeEventPublisher,
)
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
TradeStreamProductionRuntime,
)
from src.market_data.acquisition.runtime.websocket_session import (
WebSocketSession,
)
from src.market_data.acquisition.runtime.websocket_subscription_manager import (
WebSocketSubscriptionManager,
)
from src.market_data.acquisition.trade_stream_runtime_composition import (
build_trade_stream_runtime_composition,
)
from src.market_data.storage.contracts import (
TradeCheckpointStorageProtocol,
)
def build_trade_stream_production_runtime(
settings: Settings,
*,
trade_observation_sink: TradeObservationSinkProtocol | None = None,
checkpoint_storage: TradeCheckpointStorageProtocol | None = None,
) -> TradeStreamProductionRuntime | None:
"""
Собрать Production Trade Stream Runtime без запуска lifecycle.
Выключенный feature flag возвращает ``None`` до создания
WebSocket/REST-зависимостей. Все stateful-компоненты включённого
Runtime создаются один раз и затем передаются по identity.
"""
trade_stream = settings.trade_stream
storage_enabled = settings.market_data_storage.enabled
if not trade_stream.enabled:
if storage_enabled:
raise RuntimeError(
"Trade Stream must be enabled when Market Data "
"Storage is enabled."
)
return None
if storage_enabled and (
trade_observation_sink is None
or checkpoint_storage is None
):
raise RuntimeError(
"Enabled Market Data Storage requires both Trade "
"observation sink and checkpoint storage."
)
if not storage_enabled and checkpoint_storage is not None:
raise RuntimeError(
"Checkpoint storage requires enabled Market Data Storage."
)
headers = {
"Origin": settings.exchange_base_url.rstrip("/"),
"Content-Type": "application/json",
}
if settings.exchange_api_key:
headers["X-MBX-APIKEY"] = settings.exchange_api_key
transport = DzengiWebSocketTransport(
url=trade_stream.websocket_url,
headers=headers,
open_timeout=trade_stream.open_timeout_seconds,
ping_interval=None,
ping_timeout=None,
probe_timeout=trade_stream.probe_timeout_seconds,
close_timeout=trade_stream.close_timeout_seconds,
)
session = WebSocketSession(transport)
subscription_manager = WebSocketSubscriptionManager(
transport,
supports_unsubscribe=False,
)
event_publisher = AcquisitionRuntimeEventPublisher(
(
AcquisitionRuntimeEventLoggingConsumer(),
)
)
recovery_document_source = DzengiTradesDocumentSource(
ExchangeRestClient(settings=settings),
)
message_adapter = DzengiUnifiedWebSocketAdapter()
composition = build_trade_stream_runtime_composition(
session=session,
transport=transport,
subscription_manager=subscription_manager,
event_publisher=event_publisher,
message_adapter=message_adapter,
recovery_document_source=recovery_document_source,
symbols=trade_stream.symbols,
heartbeat_timeout_seconds=(
trade_stream.heartbeat_timeout_seconds
),
scheduler_interval_seconds=(
trade_stream.scheduler_interval_seconds
),
trade_observation_sink=trade_observation_sink,
max_recovery_window_ms=trade_stream.recovery_window_ms,
checkpoint_storage=checkpoint_storage,
checkpoint_venue=(
settings.exchange_name
if checkpoint_storage is not None
else None
),
)
return TradeStreamProductionRuntime(
session=session,
transport=transport,
subscription_manager=subscription_manager,
event_publisher=event_publisher,
trade_stream_service=(
composition.trade_stream_acquisition_service
),
message_classifier=(
DzengiWebSocketInboundMessageClassifier()
),
control_message_handler=(
DzengiWebSocketControlMessageHandler()
),
live_processing_gate=composition.live_processing_gate,
reconnect_recovery_coordinator=(
composition.runtime_reconnect_recovery_coordinator
),
runtime_supervisor=composition.runtime_supervisor,
runtime_scheduler=composition.runtime_scheduler,
symbols=trade_stream.symbols,
startup_recovery_coordinator=(
composition.runtime_startup_recovery_coordinator
),
subscription_ack_timeout_seconds=(
trade_stream.subscription_ack_timeout_seconds
),
startup_market_buffer_capacity=(
trade_stream.startup_market_buffer_capacity
),
)

View File

@@ -2,35 +2,84 @@
from __future__ import annotations from __future__ import annotations
import math
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from src.market_data.acquisition.symbols import normalize_symbol
# корень проекта # корень проекта
BASE_DIR = Path(__file__).resolve().parents[2] BASE_DIR = Path(__file__).resolve().parents[2]
# .env файл
ENV_FILE = BASE_DIR / ".env" def _resolve_env_file() -> Path:
"""Выбирает отдельный env-файл для записи либо обычный app/.env."""
raw_runtime_env_file = os.getenv("DZENTRA_RUNTIME_ENV_FILE")
if raw_runtime_env_file is None:
return BASE_DIR / ".env"
runtime_env_file = raw_runtime_env_file.strip()
if not runtime_env_file:
raise RuntimeError(
"DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path"
)
return Path(runtime_env_file)
# Файл должен быть выбран окружением процесса до импорта модуля настроек.
ENV_FILE = _resolve_env_file()
# загружаем переменные окружения # загружаем переменные окружения
load_dotenv(ENV_FILE) load_dotenv(ENV_FILE)
@dataclass(frozen=True, slots=True)
class TradeStreamSettings:
"""Настройки Production Trade Stream Runtime."""
enabled: bool
websocket_url: str
symbols: tuple[str, ...]
open_timeout_seconds: float
probe_timeout_seconds: float
close_timeout_seconds: float
heartbeat_timeout_seconds: float
scheduler_interval_seconds: float
recovery_window_ms: int
subscription_ack_timeout_seconds: float = 10.0
startup_market_buffer_capacity: int = 10_000
@dataclass(frozen=True, slots=True)
class MarketDataStorageSettings:
"""Настройки persistent Canonical Market Data Storage."""
enabled: bool
pool_min_size: int
pool_max_size: int
pool_timeout_seconds: float
@dataclass(slots=True) @dataclass(slots=True)
class Settings: class Settings:
# Telegram # Telegram
bot_token: str bot_token: str
bot_parse_mode: str bot_parse_mode: str
# App # Приложение
app_env: str app_env: str
log_level: str log_level: str
tz: str tz: str
# Exchange # Биржа
exchange_enabled: bool exchange_enabled: bool
exchange_name: str exchange_name: str
exchange_base_url: str exchange_base_url: str
@@ -40,24 +89,26 @@ class Settings:
exchange_timeout_sec: int exchange_timeout_sec: int
exchange_testnet: bool exchange_testnet: bool
default_symbol: str default_symbol: str
trade_stream: TradeStreamSettings
# Database # База данных
db_host: str db_host: str
db_port: int db_port: int
db_name: str db_name: str
db_user: str db_user: str
db_password: str db_password: str
market_data_storage: MarketDataStorageSettings
# Debug helpers # Отладочные параметры
debug_enabled: bool debug_enabled: bool
journal_debug_enabled: bool journal_debug_enabled: bool
# helper: demo/live mode # Вспомогательное свойство режима demo/live
def is_demo_mode(self) -> bool: def is_demo_mode(self) -> bool:
return "demo" in self.exchange_base_url.lower() return "demo" in self.exchange_base_url.lower()
# parse bool # Разбор булева значения
def _parse_bool(raw_value: str, default: bool = False) -> bool: def _parse_bool(raw_value: str, default: bool = False) -> bool:
value = (raw_value or "").strip().lower() value = (raw_value or "").strip().lower()
if not value: if not value:
@@ -66,7 +117,7 @@ def _parse_bool(raw_value: str, default: bool = False) -> bool:
return value in {"1", "true", "yes", "on"} return value in {"1", "true", "yes", "on"}
# parse int # Разбор целого числа
def _parse_int(raw_value: str, default: int) -> int: def _parse_int(raw_value: str, default: int) -> int:
value = (raw_value or "").strip() value = (raw_value or "").strip()
if not value: if not value:
@@ -75,19 +126,377 @@ def _parse_int(raw_value: str, default: int) -> int:
return int(value) return int(value)
# load all settings def _parse_strict_bool(
def load_settings() -> Settings: raw_value: str,
bot_token = os.getenv("BOT_TOKEN", "").strip() *,
name: str,
default: bool,
) -> bool:
value = (raw_value or "").strip().lower()
if not bot_token: if not value:
raise RuntimeError("BOT_TOKEN is not set in app/.env") return default
if value in {"1", "true", "yes", "on"}:
return True
if value in {"0", "false", "no", "off"}:
return False
raise ValueError(
f"{name} must be a boolean value"
)
def _parse_positive_float(
raw_value: str,
*,
name: str,
default: float,
) -> float:
value = (raw_value or "").strip()
try:
result = (
float(value)
if value
else float(default)
)
except ValueError as error:
raise ValueError(
f"{name} must be a number"
) from error
if not math.isfinite(result) or result <= 0:
raise ValueError(
f"{name} must be positive and finite"
)
return result
def _parse_positive_int(
raw_value: str,
*,
name: str,
default: int,
) -> int:
value = (raw_value or "").strip()
try:
result = (
int(value)
if value
else int(default)
)
except ValueError as error:
raise ValueError(
f"{name} must be an integer"
) from error
if result <= 0:
raise ValueError(
f"{name} must be positive"
)
return result
def _remove_trailing_newline(value: str) -> str:
"""Удаляет ровно один завершающий перевод строки из файла секрета."""
if value.endswith("\r\n"):
return value[:-2]
if value.endswith(("\n", "\r")):
return value[:-1]
return value
def _load_secret(
name: str,
*,
required: bool = False,
) -> str:
"""Загружает секрет напрямую или из указанного переменной файла."""
file_name = f"{name}_FILE"
direct_value = os.getenv(name, "").strip()
raw_file_path = os.getenv(file_name)
if direct_value and raw_file_path is not None:
raise RuntimeError(
f"{name} and {file_name} must not be set together"
)
if raw_file_path is not None:
file_path = raw_file_path.strip()
if not file_path:
raise RuntimeError(
f"{file_name} must contain a non-empty file path"
)
try:
secret = Path(file_path).read_text(encoding="utf-8")
except (OSError, UnicodeError):
raise RuntimeError(
f"Unable to read secret configured by {file_name}"
) from None
secret = _remove_trailing_newline(secret)
if not secret or secret.isspace():
raise RuntimeError(
f"{file_name} contains an empty secret"
)
return secret
if direct_value:
return direct_value
if required:
raise RuntimeError(
f"{name} or {file_name} is required"
)
return ""
def _parse_trade_stream_symbols(
raw_value: str,
) -> tuple[str, ...]:
value = (raw_value or "").strip()
if not value:
raise RuntimeError(
"TRADE_STREAM_SYMBOLS is required when "
"TRADE_STREAM_ENABLED is true"
)
raw_symbols = value.split(",")
if any(not symbol.strip() for symbol in raw_symbols):
raise ValueError(
"TRADE_STREAM_SYMBOLS must not contain empty symbols"
)
return tuple(
sorted(
{
normalize_symbol(symbol)
for symbol in raw_symbols
}
)
)
def _load_trade_stream_settings(
*,
exchange_base_url: str,
) -> TradeStreamSettings:
enabled = _parse_strict_bool(
os.getenv("TRADE_STREAM_ENABLED", "false"),
name="TRADE_STREAM_ENABLED",
default=False,
)
if not enabled:
return TradeStreamSettings(
enabled=False,
websocket_url="",
symbols=(),
open_timeout_seconds=10.0,
probe_timeout_seconds=20.0,
close_timeout_seconds=10.0,
heartbeat_timeout_seconds=30.0,
scheduler_interval_seconds=5.0,
recovery_window_ms=3_599_999,
subscription_ack_timeout_seconds=10.0,
startup_market_buffer_capacity=10_000,
)
websocket_url = os.getenv(
"TRADE_STREAM_WS_URL",
"",
).strip()
if not websocket_url:
raise RuntimeError(
"TRADE_STREAM_WS_URL is required when "
"TRADE_STREAM_ENABLED is true"
)
if not exchange_base_url:
raise RuntimeError(
"EXCHANGE_BASE_URL is required for Trade Stream Recovery"
)
return TradeStreamSettings(
enabled=True,
websocket_url=websocket_url,
symbols=_parse_trade_stream_symbols(
os.getenv("TRADE_STREAM_SYMBOLS", ""),
),
open_timeout_seconds=_parse_positive_float(
os.getenv(
"TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
"10",
),
name="TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
default=10.0,
),
probe_timeout_seconds=_parse_positive_float(
os.getenv(
"TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
"20",
),
name="TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
default=20.0,
),
close_timeout_seconds=_parse_positive_float(
os.getenv(
"TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
"10",
),
name="TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
default=10.0,
),
heartbeat_timeout_seconds=_parse_positive_float(
os.getenv(
"TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
"30",
),
name="TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
default=30.0,
),
scheduler_interval_seconds=_parse_positive_float(
os.getenv(
"TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
"5",
),
name="TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
default=5.0,
),
recovery_window_ms=_parse_positive_int(
os.getenv(
"TRADE_STREAM_RECOVERY_WINDOW_MS",
"3599999",
),
name="TRADE_STREAM_RECOVERY_WINDOW_MS",
default=3_599_999,
),
subscription_ack_timeout_seconds=_parse_positive_float(
os.getenv(
"TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS",
"10",
),
name="TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS",
default=10.0,
),
startup_market_buffer_capacity=_parse_positive_int(
os.getenv(
"TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY",
"10000",
),
name="TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY",
default=10_000,
),
)
def _load_market_data_storage_settings(
*,
trade_stream_enabled: bool,
) -> MarketDataStorageSettings:
enabled = _parse_strict_bool(
os.getenv("MARKET_DATA_STORAGE_ENABLED", "false"),
name="MARKET_DATA_STORAGE_ENABLED",
default=False,
)
if not enabled:
return MarketDataStorageSettings(
enabled=False,
pool_min_size=1,
pool_max_size=4,
pool_timeout_seconds=10.0,
)
if not trade_stream_enabled:
raise RuntimeError(
"TRADE_STREAM_ENABLED must be true when "
"MARKET_DATA_STORAGE_ENABLED is true"
)
pool_min_size = _parse_positive_int(
os.getenv(
"MARKET_DATA_STORAGE_POOL_MIN_SIZE",
"1",
),
name="MARKET_DATA_STORAGE_POOL_MIN_SIZE",
default=1,
)
pool_max_size = _parse_positive_int(
os.getenv(
"MARKET_DATA_STORAGE_POOL_MAX_SIZE",
"4",
),
name="MARKET_DATA_STORAGE_POOL_MAX_SIZE",
default=4,
)
if pool_max_size < pool_min_size:
raise ValueError(
"MARKET_DATA_STORAGE_POOL_MAX_SIZE must not be smaller "
"than MARKET_DATA_STORAGE_POOL_MIN_SIZE"
)
return MarketDataStorageSettings(
enabled=True,
pool_min_size=pool_min_size,
pool_max_size=pool_max_size,
pool_timeout_seconds=_parse_positive_float(
os.getenv(
"MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS",
"10",
),
name="MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS",
default=10.0,
),
)
# Загрузка всех настроек
def load_settings() -> Settings:
bot_token = _load_secret(
"BOT_TOKEN",
required=True,
)
exchange_api_key = _load_secret("EXCHANGE_API_KEY")
exchange_api_secret = _load_secret("EXCHANGE_API_SECRET")
db_password = _load_secret("DB_PASSWORD")
exchange_base_url = os.getenv(
"EXCHANGE_BASE_URL",
"",
).strip()
trade_stream = _load_trade_stream_settings(
exchange_base_url=exchange_base_url,
)
market_data_storage = _load_market_data_storage_settings(
trade_stream_enabled=trade_stream.enabled,
)
return Settings( return Settings(
# Telegram # Telegram
bot_token=bot_token, bot_token=bot_token,
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML", bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
# App # Приложение
app_env=os.getenv("APP_ENV", "dev").strip() or "dev", app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk", tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk",
@@ -96,22 +505,24 @@ def load_settings() -> Settings:
os.getenv("JOURNAL_DEBUG_ENABLED", "false") os.getenv("JOURNAL_DEBUG_ENABLED", "false")
), ),
# Exchange # Биржа
exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")), exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")),
exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi", exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
exchange_base_url=os.getenv("EXCHANGE_BASE_URL", "").strip(), exchange_base_url=exchange_base_url,
exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(), exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(),
exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(), exchange_api_key=exchange_api_key,
exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(), exchange_api_secret=exchange_api_secret,
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10), exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")), exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip() default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip()
or "ETH/USD_LEVERAGE", or "ETH/USD_LEVERAGE",
trade_stream=trade_stream,
# Database # База данных
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost", db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432), db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432),
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot", db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",
db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot", db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot",
db_password=os.getenv("DB_PASSWORD", "").strip(), db_password=db_password,
) market_data_storage=market_data_storage,
)

View File

@@ -1,111 +0,0 @@
# app/src/integrations/exchange/market_cache.py
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.config import load_settings
@dataclass(slots=True)
class MarketPriceSnapshot:
symbol: str
price: float
bid_price: float | None
ask_price: float | None
updated_at: str
source: str = "market-cache"
runtime_key: str = "default"
received_monotonic: float = 0.0
def age_seconds(self) -> float:
if self.received_monotonic <= 0:
return 999999.0
return max(0.0, time.monotonic() - self.received_monotonic)
def has_bid_ask(self) -> bool:
return (
self.bid_price is not None
and self.ask_price is not None
and self.bid_price > 0
and self.ask_price > 0
)
class MarketPriceCache:
_prices: dict[tuple[str, str], MarketPriceSnapshot] = {}
@classmethod
def _key(cls, *, symbol: str, runtime_key: str = "default") -> tuple[str, str]:
return runtime_key.strip().lower(), symbol.upper()
@classmethod
def set_price(
cls,
*,
symbol: str,
price: float,
bid_price: float | None = None,
ask_price: float | None = None,
updated_at: str | None = None,
source: str = "market-polling",
runtime_key: str = "default",
) -> None:
settings = load_settings()
if updated_at is None:
updated_at = datetime.now(ZoneInfo(settings.tz)).strftime("%d.%m.%Y %H:%M:%S")
normalized_runtime_key = runtime_key.strip().lower()
cls._prices[cls._key(symbol=symbol, runtime_key=normalized_runtime_key)] = MarketPriceSnapshot(
symbol=symbol.upper(),
price=float(price),
bid_price=float(bid_price) if bid_price is not None else None,
ask_price=float(ask_price) if ask_price is not None else None,
updated_at=updated_at,
source=source,
runtime_key=normalized_runtime_key,
received_monotonic=time.monotonic(),
)
@classmethod
def get_price(
cls,
symbol: str,
*,
runtime_key: str = "default",
) -> MarketPriceSnapshot | None:
return cls._prices.get(cls._key(symbol=symbol, runtime_key=runtime_key))
@classmethod
def clear(
cls,
symbol: str | None = None,
*,
runtime_key: str | None = None,
) -> None:
if symbol is None and runtime_key is None:
cls._prices.clear()
return
if symbol is not None and runtime_key is not None:
cls._prices.pop(cls._key(symbol=symbol, runtime_key=runtime_key), None)
return
keys_to_delete = []
for key_runtime, key_symbol in cls._prices.keys():
if runtime_key is not None and key_runtime == runtime_key.strip().lower():
keys_to_delete.append((key_runtime, key_symbol))
continue
if symbol is not None and key_symbol == symbol.upper():
keys_to_delete.append((key_runtime, key_symbol))
for key in keys_to_delete:
cls._prices.pop(key, None)

View File

@@ -8,11 +8,19 @@ import traceback
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from typing import Callable
from src.core.numbers import safe_float from src.core.types import JsonDict
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient 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 from src.trading.journal.service import JournalService
@@ -173,7 +181,10 @@ class MarketDataRunner:
runtime_key=context.runtime_key, runtime_key=context.runtime_key,
cache_symbol=cache_symbol, cache_symbol=cache_symbol,
): ):
MarketPriceCache.clear(cache_symbol) get_quote_store().clear(
source_name=QUOTE_RUNTIME_SOURCE_NAME,
symbol=cache_symbol,
)
try: try:
market_status = ExchangeService().get_symbol_market_status(symbol) market_status = ExchangeService().get_symbol_market_status(symbol)
@@ -297,6 +308,7 @@ class MarketDataRunner:
valid_payload_count = 0 valid_payload_count = 0
invalid_payload_count = 0 invalid_payload_count = 0
adapter = DzengiWebSocketQuoteAdapter()
async for payload in ExchangeWebSocketClient().stream_depth( async for payload in ExchangeWebSocketClient().stream_depth(
ws_symbol, ws_symbol,
@@ -306,20 +318,31 @@ class MarketDataRunner:
if current_symbol and current_symbol != symbol: if current_symbol and current_symbol != symbol:
break break
best_bid = cls._extract_best_price(payload, "bids") try:
best_ask = cls._extract_best_price(payload, "asks") quote = adapter.map_message(payload)
except MarketDataAcquisitionError:
if best_bid is None or best_ask is None:
invalid_payload_count += 1 invalid_payload_count += 1
if invalid_payload_count >= 5: if invalid_payload_count >= 5:
raise RuntimeError( raise RuntimeError(
"WebSocket depth stream does not contain valid bids/asks." "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 continue
invalid_payload_count = 0 invalid_payload_count = 0
best_bid = float(quote.bid_price)
best_ask = float(quote.ask_price)
if valid_payload_count == 0: if valid_payload_count == 0:
should_log_connected = ( should_log_connected = (
@@ -354,12 +377,9 @@ class MarketDataRunner:
valid_payload_count += 1 valid_payload_count += 1
MarketPriceCache.set_price( get_quote_store().set(
symbol=cache_symbol, QUOTE_RUNTIME_SOURCE_NAME,
price=(best_bid + best_ask) / 2, quote,
bid_price=best_bid,
ask_price=best_ask,
source=f"ws_depth:{context.runtime_key}",
runtime_key=context.runtime_key, runtime_key=context.runtime_key,
) )
@@ -381,7 +401,7 @@ class MarketDataRunner:
) -> None: ) -> None:
try: try:
await asyncio.to_thread( await asyncio.to_thread(
ExchangeService().refresh_market_snapshot_cache, ExchangeService().refresh_quote_cache,
symbol, symbol,
runtime_key=context.runtime_key, runtime_key=context.runtime_key,
) )
@@ -476,36 +496,6 @@ class MarketDataRunner:
def _ws_symbol(cls, symbol: str) -> str: def _ws_symbol(cls, symbol: str) -> str:
return cls._cache_symbol(symbol) return cls._cache_symbol(symbol)
@classmethod
def _extract_best_price(
cls,
payload: JsonDict,
side_key: str,
) -> float | None:
data = cls._extract_depth_payload(payload)
values = data.get(side_key)
if not isinstance(values, list) or not values:
return None
first = values[0]
if isinstance(first, list) and first:
return cls._positive_float(first[0])
if isinstance(first, dict):
raw_price = (
first.get("price")
or first.get("p")
or first.get("bidPrice")
or first.get("askPrice")
)
return cls._positive_float(raw_price)
return None
@classmethod @classmethod
def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict: def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict:
data: object = payload data: object = payload
@@ -524,15 +514,6 @@ class MarketDataRunner:
return payload return payload
@classmethod
def _positive_float(cls, value: NumericLike | None) -> float | None:
number = safe_float(value)
if number is None or number <= 0:
return None
return number
@classmethod @classmethod
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict: def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
preview: JsonDict = {} preview: JsonDict = {}

View File

@@ -3,130 +3,23 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
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.numbers import safe_float
from src.core.types import JsonDict, NumericLike
from src.integrations.exchange.market_cache import MarketPriceCache
from src.integrations.exchange.service import ExchangeService from src.integrations.exchange.service import ExchangeService
from src.integrations.exchange.ws_client import ExchangeWebSocketClient 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 from src.trading.journal.service import JournalService
# безопасно форматирует timestamp биржи в локальное время # запускает постоянный websocket-поток рынка и обновляет Quote Store
def _format_timestamp(raw_timestamp: NumericLike | None) -> str | None:
timestamp = safe_float(raw_timestamp)
if timestamp is None:
return None
try:
settings = load_settings()
dt_utc = datetime.fromtimestamp(
int(timestamp) / 1000,
tz=ZoneInfo("UTC"),
)
return dt_utc.astimezone(
ZoneInfo(settings.tz),
).strftime("%d.%m.%Y %H:%M:%S")
except Exception:
return None
# достаёт внутренний payload из websocket-сообщения
def _payload_from_message(payload: JsonDict) -> JsonDict | None:
event = payload.get("Payload") or payload.get("payload")
if isinstance(event, dict) and "Payload" in event:
event = event.get("Payload")
if not isinstance(event, dict):
return None
return dict(event)
# извлекает best bid / best ask из формата depth
def _extract_depth_prices(event: JsonDict) -> tuple[float | None, float | None]:
bids = event.get("bids")
asks = event.get("asks")
bid_price = _extract_first_price(bids)
ask_price = _extract_first_price(asks)
return bid_price, ask_price
# извлекает первую цену из списка стакана
def _extract_first_price(value: object) -> float | None:
if not isinstance(value, list) or not value:
return None
first = value[0]
if isinstance(first, list) and first:
return _positive_float(first[0])
if isinstance(first, dict):
return _positive_float(
first.get("price")
or first.get("p")
or first.get("bidPrice")
or first.get("askPrice")
)
return None
# безопасно приводит число к float и отсекает нулевые/отрицательные цены
def _positive_float(value: NumericLike | None) -> float | None:
number = safe_float(value)
if number is None or number <= 0:
return None
return number
# нормализует websocket-сообщение рынка в единый формат для MarketPriceCache
def _extract_market_event(payload: JsonDict) -> JsonDict | None:
event = _payload_from_message(payload)
if event is None:
return None
symbol = (
event.get("symbolName")
or event.get("symbol")
or payload.get("symbol")
)
bid_price = _positive_float(event.get("bid"))
ask_price = _positive_float(event.get("ofr") or event.get("ask"))
if bid_price is None or ask_price is None:
bid_price, ask_price = _extract_depth_prices(event)
if symbol is None or bid_price is None or ask_price is None:
return None
price = (bid_price + ask_price) / 2
return {
"symbol": str(symbol).upper(),
"price": price,
"bid_price": bid_price,
"ask_price": ask_price,
"updated_at": _format_timestamp(event.get("timestamp")),
}
# запускает постоянный websocket-поток рынка и обновляет MarketPriceCache
async def start_market_stream() -> None: async def start_market_stream() -> None:
settings = load_settings() settings = load_settings()
journal = JournalService() journal = JournalService()
@@ -145,6 +38,7 @@ async def start_market_stream() -> None:
symbol = validation.normalized_symbol symbol = validation.normalized_symbol
client = ExchangeWebSocketClient() client = ExchangeWebSocketClient()
adapter = DzengiWebSocketQuoteAdapter()
journal.log_info( journal.log_info(
"market_ws_started", "market_ws_started",
@@ -153,29 +47,17 @@ async def start_market_stream() -> None:
) )
async for message in client.stream_depth(symbol): async for message in client.stream_depth(symbol):
event = _extract_market_event(message) try:
quote = adapter.map_message(message)
if event is None: except MarketDataAcquisitionError:
continue continue
price = safe_float(event.get("price")) if quote.symbol.strip().upper() != symbol.strip().upper():
bid_price = safe_float(event.get("bid_price"))
ask_price = safe_float(event.get("ask_price"))
if price is None or bid_price is None or ask_price is None:
continue continue
MarketPriceCache.set_price( get_quote_store().set(
symbol=symbol, QUOTE_RUNTIME_SOURCE_NAME,
price=price, quote,
bid_price=bid_price,
ask_price=ask_price,
updated_at=(
str(event.get("updated_at"))
if event.get("updated_at") is not None
else None
),
source="ws_market_stream",
runtime_key="default", runtime_key="default",
) )

View File

@@ -1,8 +1,12 @@
# 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, TickerPrice from src.integrations.exchange.models import BalanceSummary, ExchangeHealth
from src.market_data.acquisition.models.quote import Quote
def mock_exchange_health() -> ExchangeHealth: def mock_exchange_health() -> ExchangeHealth:
@@ -13,20 +17,23 @@ def mock_exchange_health() -> ExchangeHealth:
) )
def mock_ticker_price(symbol: str) -> TickerPrice: def mock_quote(symbol: str) -> Quote:
symbol = symbol.upper().strip() normalized_symbol = symbol.upper().strip()
fake_prices = { fake_prices = {
"BTCUSDT": 68425.10, "BTCUSDT": Decimal("68425.10"),
"ETHUSDT": 3521.44, "ETHUSDT": Decimal("3521.44"),
"BNBUSDT": 612.33, "BNBUSDT": Decimal("612.33"),
} }
price = fake_prices.get(symbol, 100.00) price = fake_prices.get(normalized_symbol, Decimal("100.00"))
updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return TickerPrice( return Quote(
symbol=symbol, symbol=normalized_symbol,
price=price, last_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

@@ -3,6 +3,11 @@
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 биржи. # Состояние публичного API биржи.
@@ -25,13 +30,6 @@ class TimeSyncStatus:
message: str message: str
# Текущая рыночная цена инструмента.
@dataclass(slots=True)
class TickerPrice:
symbol: str
price: float
source: str
updated_at: str
# Snapshot цен для execution layer. # Snapshot цен для execution layer.
@@ -62,26 +60,7 @@ 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
step_size: float | None
min_qty: float | None
min_notional: float | None
# Результат проверки символа.
@dataclass(slots=True) @dataclass(slots=True)
class SymbolValidationResult: class SymbolValidationResult:
requested_symbol: str requested_symbol: str
@@ -90,7 +69,7 @@ class SymbolValidationResult:
is_valid: bool is_valid: bool
message: str message: str
symbol_info: ExchangeSymbol | None symbol_info: Instrument | None
# Состояние приватного API аккаунта. # Состояние приватного API аккаунта.
@@ -109,31 +88,6 @@ class ExchangeMarketStatus:
message: str message: str
# Одна свеча OHLCV.
@dataclass(slots=True)
class Kline:
symbol: str
interval: str
open_time: int
open_price: float
high_price: float
low_price: float
close_price: float
volume: float
source: str
# Пакет свечей.
@dataclass(slots=True)
class KlineBatch:
symbol: str
interval: str
candles: list[Kline]
source: str
# Информация о торговой комиссии для инструмента. # Информация о торговой комиссии для инструмента.
@dataclass(slots=True) @dataclass(slots=True)
class TradingFee: class TradingFee:

View File

@@ -7,7 +7,7 @@ from urllib.error import HTTPError, URLError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from src.core.config import load_settings from src.core.config import Settings, load_settings
from src.integrations.exchange.exceptions import ( from src.integrations.exchange.exceptions import (
ExchangeConnectionError, ExchangeConnectionError,
ExchangeResponseError, ExchangeResponseError,
@@ -15,8 +15,15 @@ from src.integrations.exchange.exceptions import (
class ExchangeRestClient: class ExchangeRestClient:
def __init__(self) -> None: def __init__(
self.settings = load_settings() self,
settings: Settings | None = None,
) -> None:
self.settings = (
settings
if settings is not None
else load_settings()
)
if not self.settings.exchange_base_url: if not self.settings.exchange_base_url:
raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.") raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.")
self.base_url = self.settings.exchange_base_url.rstrip("/") self.base_url = self.settings.exchange_base_url.rstrip("/")
@@ -133,4 +140,4 @@ class ExchangeRestClient:
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise ExchangeResponseError("Exchange response is not a JSON object.") raise ExchangeResponseError("Exchange response is not a JSON object.")
return payload return payload

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,10 @@ from src.integrations.exchange.exceptions import (
ExchangeConnectionError, ExchangeConnectionError,
ExchangeResponseError, ExchangeResponseError,
) )
from src.market_data.acquisition.models.status import (
InstrumentTradingState,
classify_instrument_status,
)
class ExchangeStatusCode(StrEnum): class ExchangeStatusCode(StrEnum):
@@ -35,7 +39,7 @@ class ExchangeRuntimeStatus:
raw_status: str | None = None raw_status: str | None = None
raw_error: str | None = None raw_error: str | None = None
# вернуть статус в dict для старого UI-кода на время миграции # Вернуть статус в dict для старого UI-кода на время миграции.
def as_dict(self) -> dict[str, object]: def as_dict(self) -> dict[str, object]:
return { return {
"code": self.code.value, "code": self.code.value,
@@ -79,7 +83,7 @@ def build_market_stale_status(
) )
# собрать статус mock-режима # Собрать статус mock-режима.
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus: def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
return ExchangeRuntimeStatus( return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN, code=ExchangeStatusCode.OPEN,
@@ -95,48 +99,21 @@ def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
) )
# собрать статус ошибки авторизации аккаунта # Собрать статус ошибки авторизации аккаунта.
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus: def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
return build_exchange_error_status(exc) return build_exchange_error_status(exc)
OPEN_STATUSES = { # Собрать legacy runtime-статус по канонической классификации инструмента.
"TRADING",
"OPEN",
"ACTIVE",
"ENABLED",
"ONLINE",
}
BREAK_STATUSES = {
"BREAK",
"CLOSED",
"HALT",
"HALTED",
"PAUSED",
"SUSPENDED",
"DISABLED",
"SETTLING",
"POST_ONLY",
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
}
# определить единый runtime-статус по статусу инструмента биржи
def build_market_status_from_symbol_status( def build_market_status_from_symbol_status(
*, *,
raw_status: str | None, raw_status: str | None,
symbol: str, symbol: str,
) -> ExchangeRuntimeStatus: ) -> ExchangeRuntimeStatus:
normalized_status = str(raw_status or "").strip().upper() classification = classify_instrument_status(raw_status)
normalized_status = classification.normalized_status
if normalized_status in OPEN_STATUSES: if classification.state == InstrumentTradingState.OPEN:
return ExchangeRuntimeStatus( return ExchangeRuntimeStatus(
code=ExchangeStatusCode.OPEN, code=ExchangeStatusCode.OPEN,
is_open=True, is_open=True,
@@ -150,15 +127,7 @@ def build_market_status_from_symbol_status(
symbol=symbol, symbol=symbol,
) )
if normalized_status in { if classification.state == InstrumentTradingState.NOT_TRADABLE:
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
}:
return ExchangeRuntimeStatus( return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK, code=ExchangeStatusCode.BREAK,
is_open=False, is_open=False,
@@ -171,8 +140,8 @@ def build_market_status_from_symbol_status(
raw_status=normalized_status, raw_status=normalized_status,
symbol=symbol, symbol=symbol,
) )
if normalized_status in BREAK_STATUSES: if classification.state == InstrumentTradingState.BREAK:
return ExchangeRuntimeStatus( return ExchangeRuntimeStatus(
code=ExchangeStatusCode.BREAK, code=ExchangeStatusCode.BREAK,
is_open=False, is_open=False,
@@ -198,12 +167,12 @@ def build_market_status_from_symbol_status(
), ),
ui_line="⚠️ Статус торгов неизвестен", ui_line="⚠️ Статус торгов неизвестен",
reason="market_status_unknown", reason="market_status_unknown",
raw_status=normalized_status or None, raw_status=normalized_status,
symbol=symbol, symbol=symbol,
) )
# собрать единый статус для неверного торгового инструмента # Собрать единый статус для неверного торгового инструмента.
def build_invalid_symbol_status( def build_invalid_symbol_status(
*, *,
symbol: str, symbol: str,
@@ -223,7 +192,7 @@ def build_invalid_symbol_status(
) )
# собрать единый статус по ошибке exchange/API # Собрать единый статус по ошибке exchange/API.
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus: def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
error_type = classify_exchange_error(exc) error_type = classify_exchange_error(exc)
raw_error = str(exc) raw_error = str(exc)
@@ -270,7 +239,7 @@ def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
) )
# классифицировать ошибку биржи для единого UI и логов # Классифицировать ошибку биржи для единого UI и логов.
def classify_exchange_error(exc: Exception) -> str: def classify_exchange_error(exc: Exception) -> str:
text = str(exc).lower() text = str(exc).lower()
@@ -326,7 +295,7 @@ def classify_exchange_error(exc: Exception) -> str:
return "generic" return "generic"
# проверить, относится ли reason к unified exchange status layer # Проверить, относится ли reason к unified exchange status layer.
def is_exchange_status_reason(reason: str | None) -> bool: def is_exchange_status_reason(reason: str | None) -> bool:
if not reason: if not reason:
return False return False

View File

@@ -2,24 +2,13 @@
from __future__ import annotations from __future__ import annotations
from src.market_data.acquisition.symbols import (
def normalize_symbol(raw_symbol: str) -> str: normalize_symbol,
return (raw_symbol or "").strip().upper() symbol_candidates,
)
def symbol_candidates(raw_symbol: str) -> list[str]: __all__ = [
value = normalize_symbol(raw_symbol) "normalize_symbol",
if not value: "symbol_candidates",
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

@@ -2,26 +2,14 @@
import asyncio import asyncio
from src.bootstrap.application import run_application
from src.bootstrap.app_factory import create_app from src.bootstrap.app_factory import create_app
async def main() -> None: async def main() -> None:
# создаём bot + dispatcher application = create_app()
bot, dispatcher = create_app() await run_application(application)
# WebSocket stream временно отключён.
# Причина: Dzengi Swagger содержит wss:/api/v1/* endpoints,
# но runtime probe не нашёл endpoint с WebSocket Upgrade 101.
#
# Когда Dzengi подтвердит рабочий WS endpoint,
# можно будет вернуть запуск:
#
# from src.integrations.exchange.market_stream import start_market_stream
# market_stream_task = asyncio.create_task(start_market_stream())
# запускаем Telegram polling
await dispatcher.start_polling(bot)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View File

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

View File

@@ -0,0 +1,86 @@
from src.market_data.access.contracts import (
CandleRevisionHistoryReaderProtocol,
MarketDataHistoricalAccessProtocol,
QuoteHistoryReaderProtocol,
TradeHistoryReaderProtocol,
)
from src.market_data.access.exceptions import (
MarketDataAccessError,
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
MarketDataCursorError,
)
from src.market_data.access.market_data_historical_access import (
MarketDataHistoricalAccess,
)
from src.market_data.access.models import (
HISTORY_CURSOR_VERSION,
HISTORY_PAGE_LIMIT_DEFAULT,
HISTORY_PAGE_LIMIT_MAX,
CandleRevisionHistoryCursor,
CandleRevisionHistoryPage,
CandleRevisionHistoryQuery,
CandleRevisionHistoryRecord,
HistoricalTimeRange,
HistoryPage,
HistoryQuery,
HistoryRecord,
QuoteHistoryCursor,
QuoteHistoryPage,
QuoteHistoryQuery,
QuoteHistoryRecord,
TradeHistoryCursor,
TradeHistoryPage,
TradeHistoryQuery,
TradeHistoryRecord,
)
from src.market_data.access.postgres_candle_revision_history_repository import (
PostgresCandleRevisionHistoryRepository,
)
from src.market_data.access.postgres_history_support import (
PostgresHistoryConnectionProvider,
)
from src.market_data.access.postgres_quote_history_repository import (
PostgresQuoteHistoryRepository,
)
from src.market_data.access.postgres_trade_history_repository import (
PostgresTradeHistoryRepository,
)
__all__ = (
"HISTORY_CURSOR_VERSION",
"HISTORY_PAGE_LIMIT_DEFAULT",
"HISTORY_PAGE_LIMIT_MAX",
"CandleRevisionHistoryCursor",
"CandleRevisionHistoryPage",
"CandleRevisionHistoryQuery",
"CandleRevisionHistoryReaderProtocol",
"CandleRevisionHistoryRecord",
"HistoricalTimeRange",
"HistoryPage",
"HistoryQuery",
"HistoryRecord",
"MarketDataAccessError",
"MarketDataAccessIntegrityError",
"MarketDataAccessOperationError",
"MarketDataAccessValidationError",
"MarketDataCursorError",
"MarketDataHistoricalAccess",
"MarketDataHistoricalAccessProtocol",
"PostgresCandleRevisionHistoryRepository",
"PostgresHistoryConnectionProvider",
"PostgresQuoteHistoryRepository",
"PostgresTradeHistoryRepository",
"QuoteHistoryCursor",
"QuoteHistoryPage",
"QuoteHistoryQuery",
"QuoteHistoryReaderProtocol",
"QuoteHistoryRecord",
"TradeHistoryCursor",
"TradeHistoryPage",
"TradeHistoryQuery",
"TradeHistoryReaderProtocol",
"TradeHistoryRecord",
)

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.access.models import (
CandleRevisionHistoryPage,
CandleRevisionHistoryQuery,
QuoteHistoryPage,
QuoteHistoryQuery,
TradeHistoryPage,
TradeHistoryQuery,
)
@runtime_checkable
class TradeHistoryReaderProtocol(Protocol):
"""Граница чтения одной страницы Canonical Trade History."""
def query_trades(
self,
query: TradeHistoryQuery,
) -> TradeHistoryPage:
...
@runtime_checkable
class QuoteHistoryReaderProtocol(Protocol):
"""Граница чтения одной страницы Canonical Quote History."""
def query_quotes(
self,
query: QuoteHistoryQuery,
) -> QuoteHistoryPage:
...
@runtime_checkable
class CandleRevisionHistoryReaderProtocol(Protocol):
"""Граница чтения одной страницы Canonical Candle revisions."""
def query_candle_revisions(
self,
query: CandleRevisionHistoryQuery,
) -> CandleRevisionHistoryPage:
...
@runtime_checkable
class MarketDataHistoricalAccessProtocol(
TradeHistoryReaderProtocol,
QuoteHistoryReaderProtocol,
CandleRevisionHistoryReaderProtocol,
Protocol,
):
"""Объединённая DB-neutral граница Historical Access."""

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
class MarketDataAccessError(Exception):
"""Базовая ошибка доступа к историческим рыночным данным."""
class MarketDataAccessValidationError(MarketDataAccessError):
"""Недопустимый запрос или значение на публичной границе чтения."""
class MarketDataCursorError(MarketDataAccessValidationError):
"""Курсор повреждён либо принадлежит другому запросу."""
class MarketDataAccessIntegrityError(MarketDataAccessError):
"""Сохранённая строка не соответствует Canonical-контракту."""
class MarketDataAccessOperationError(MarketDataAccessError):
"""Ошибка backend-операции Historical Access."""

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from src.market_data.access.contracts import (
CandleRevisionHistoryReaderProtocol,
QuoteHistoryReaderProtocol,
TradeHistoryReaderProtocol,
)
from src.market_data.access.models import (
CandleRevisionHistoryPage,
CandleRevisionHistoryQuery,
QuoteHistoryPage,
QuoteHistoryQuery,
TradeHistoryPage,
TradeHistoryQuery,
)
class MarketDataHistoricalAccess:
"""Независимый от базы данных фасад трёх модулей чтения истории."""
__slots__ = (
"_trade_reader",
"_quote_reader",
"_candle_revision_reader",
)
def __init__(
self,
*,
trade_reader: TradeHistoryReaderProtocol,
quote_reader: QuoteHistoryReaderProtocol,
candle_revision_reader: CandleRevisionHistoryReaderProtocol,
) -> None:
if not isinstance(trade_reader, TradeHistoryReaderProtocol):
raise TypeError(
"trade_reader must implement TradeHistoryReaderProtocol"
)
if not isinstance(quote_reader, QuoteHistoryReaderProtocol):
raise TypeError(
"quote_reader must implement QuoteHistoryReaderProtocol"
)
if not isinstance(
candle_revision_reader,
CandleRevisionHistoryReaderProtocol,
):
raise TypeError(
"candle_revision_reader must implement "
"CandleRevisionHistoryReaderProtocol"
)
self._trade_reader = trade_reader
self._quote_reader = quote_reader
self._candle_revision_reader = candle_revision_reader
def query_trades(
self,
query: TradeHistoryQuery,
) -> TradeHistoryPage:
"""Передать запрос истории сделок соответствующему модулю."""
return self._trade_reader.query_trades(query)
def query_quotes(
self,
query: QuoteHistoryQuery,
) -> QuoteHistoryPage:
"""Передать запрос истории котировок соответствующему модулю."""
return self._quote_reader.query_quotes(query)
def query_candle_revisions(
self,
query: CandleRevisionHistoryQuery,
) -> CandleRevisionHistoryPage:
"""Передать запрос истории свечей соответствующему модулю."""
return self._candle_revision_reader.query_candle_revisions(query)

View File

@@ -0,0 +1,901 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.symbols import normalize_symbol
HISTORY_CURSOR_VERSION = 1
HISTORY_PAGE_LIMIT_DEFAULT = 500
HISTORY_PAGE_LIMIT_MAX = 1_000
def _normalize_non_empty_text(
value: object,
*,
field_name: str,
uppercase: bool = False,
) -> str:
if not isinstance(value, str):
raise TypeError(f"{field_name} must be a string")
normalized = value.strip()
if not normalized:
raise ValueError(f"{field_name} must not be empty")
return normalized.upper() if uppercase else normalized
def _normalize_symbol(value: object, *, field_name: str) -> str:
raw_symbol = _normalize_non_empty_text(
value,
field_name=field_name,
)
normalized = normalize_symbol(raw_symbol)
if not normalized:
raise ValueError(f"{field_name} must not be empty")
return normalized
def _normalize_utc_datetime(
value: object,
*,
field_name: str,
) -> datetime:
if not isinstance(value, datetime):
raise TypeError(f"{field_name} must be a datetime")
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must contain timezone")
return value.astimezone(timezone.utc)
def _normalize_positive_integer(
value: object,
*,
field_name: str,
maximum: int | None = None,
) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{field_name} must be an integer")
if value <= 0:
raise ValueError(f"{field_name} must be positive")
if maximum is not None and value > maximum:
raise ValueError(
f"{field_name} must not exceed {maximum}"
)
return value
def _normalize_observation_sources(
value: object,
*,
primary_source: object,
) -> tuple[str, ...]:
if not isinstance(value, tuple):
raise TypeError("observation_sources must be a tuple")
if not value:
raise ValueError("observation_sources must not be empty")
normalized = tuple(
_normalize_non_empty_text(
source,
field_name="observation_sources item",
)
for source in value
)
if len(set(normalized)) != len(normalized):
raise ValueError("observation_sources must be unique")
normalized_primary = _normalize_non_empty_text(
primary_source,
field_name="payload.source",
)
if normalized[0] != normalized_primary:
raise ValueError(
"first observation source must match payload.source"
)
return normalized
def _validate_canonical_symbol_and_source(
*,
symbol: object,
source: object,
) -> None:
normalized_symbol = _normalize_symbol(
symbol,
field_name="payload.symbol",
)
if normalized_symbol != symbol:
raise ValueError("payload.symbol must be canonical")
normalized_source = _normalize_non_empty_text(
source,
field_name="payload.source",
)
if normalized_source != source:
raise ValueError("payload.source must be canonical")
@dataclass(frozen=True, slots=True)
class HistoricalTimeRange:
"""Полуоткрытый UTC-диапазон исторического запроса ``[start, end)``."""
start_time: datetime
end_time: datetime
def __post_init__(self) -> None:
normalized_start = _normalize_utc_datetime(
self.start_time,
field_name="start_time",
)
normalized_end = _normalize_utc_datetime(
self.end_time,
field_name="end_time",
)
if normalized_start >= normalized_end:
raise ValueError("start_time must be earlier than end_time")
object.__setattr__(self, "start_time", normalized_start)
object.__setattr__(self, "end_time", normalized_end)
def contains(self, instant: datetime) -> bool:
"""Проверить принадлежность времени полуоткрытому диапазону."""
normalized = _normalize_utc_datetime(
instant,
field_name="instant",
)
return self.start_time <= normalized < self.end_time
@dataclass(frozen=True, slots=True)
class TradeHistoryRecord:
"""Canonical Trade и неизменяемые технические данные его хранения."""
venue: str
trade: Trade
first_observed_at: datetime
last_observed_at: datetime
observation_sources: tuple[str, ...]
replay_sequence: int
canonical_schema_version: int = 1
def __post_init__(self) -> None:
normalized_venue = _normalize_non_empty_text(
self.venue,
field_name="venue",
)
if type(self.trade) is not Trade:
raise TypeError("trade must be a Canonical Trade")
_validate_canonical_symbol_and_source(
symbol=self.trade.symbol,
source=self.trade.source,
)
_normalize_utc_datetime(
self.trade.executed_at,
field_name="trade.executed_at",
)
normalized_first = _normalize_utc_datetime(
self.first_observed_at,
field_name="first_observed_at",
)
normalized_last = _normalize_utc_datetime(
self.last_observed_at,
field_name="last_observed_at",
)
if normalized_last < normalized_first:
raise ValueError(
"last_observed_at must not precede first_observed_at"
)
normalized_sources = _normalize_observation_sources(
self.observation_sources,
primary_source=self.trade.source,
)
normalized_sequence = _normalize_positive_integer(
self.replay_sequence,
field_name="replay_sequence",
)
normalized_version = _normalize_positive_integer(
self.canonical_schema_version,
field_name="canonical_schema_version",
)
object.__setattr__(self, "venue", normalized_venue)
object.__setattr__(
self,
"first_observed_at",
normalized_first,
)
object.__setattr__(
self,
"last_observed_at",
normalized_last,
)
object.__setattr__(
self,
"observation_sources",
normalized_sources,
)
object.__setattr__(
self,
"replay_sequence",
normalized_sequence,
)
object.__setattr__(
self,
"canonical_schema_version",
normalized_version,
)
@property
def symbol(self) -> str:
return self.trade.symbol
@property
def event_time(self) -> datetime:
return self.trade.executed_at.astimezone(timezone.utc)
@property
def order_key(self) -> tuple[datetime, int]:
return (self.event_time, self.replay_sequence)
@dataclass(frozen=True, slots=True)
class QuoteHistoryRecord:
"""Canonical Quote и неизменяемые технические данные его хранения."""
venue: str
quote: Quote
observation_sources: tuple[str, ...]
replay_sequence: int
canonical_schema_version: int = 1
def __post_init__(self) -> None:
normalized_venue = _normalize_non_empty_text(
self.venue,
field_name="venue",
)
if type(self.quote) is not Quote:
raise TypeError("quote must be a Canonical Quote")
_validate_canonical_symbol_and_source(
symbol=self.quote.symbol,
source=self.quote.source,
)
_normalize_utc_datetime(
self.quote.received_at,
field_name="quote.received_at",
)
if self.quote.exchange_timestamp is not None:
_normalize_utc_datetime(
self.quote.exchange_timestamp,
field_name="quote.exchange_timestamp",
)
normalized_sources = _normalize_observation_sources(
self.observation_sources,
primary_source=self.quote.source,
)
normalized_sequence = _normalize_positive_integer(
self.replay_sequence,
field_name="replay_sequence",
)
normalized_version = _normalize_positive_integer(
self.canonical_schema_version,
field_name="canonical_schema_version",
)
object.__setattr__(self, "venue", normalized_venue)
object.__setattr__(
self,
"observation_sources",
normalized_sources,
)
object.__setattr__(
self,
"replay_sequence",
normalized_sequence,
)
object.__setattr__(
self,
"canonical_schema_version",
normalized_version,
)
@property
def symbol(self) -> str:
return self.quote.symbol
@property
def event_time(self) -> datetime:
return self.quote.received_at.astimezone(timezone.utc)
@property
def order_key(self) -> tuple[datetime, int]:
return (self.event_time, self.replay_sequence)
@dataclass(frozen=True, slots=True)
class CandleRevisionHistoryRecord:
"""Canonical Candle и метаданные одной наблюдавшейся ревизии."""
venue: str
candle: Candle
observed_at: datetime
is_final: bool
observation_sources: tuple[str, ...]
replay_sequence: int
canonical_schema_version: int = 1
def __post_init__(self) -> None:
normalized_venue = _normalize_non_empty_text(
self.venue,
field_name="venue",
)
if type(self.candle) is not Candle:
raise TypeError("candle must be a Canonical Candle")
_validate_canonical_symbol_and_source(
symbol=self.candle.symbol,
source=self.candle.source,
)
normalized_interval = _normalize_non_empty_text(
self.candle.interval,
field_name="candle.interval",
)
if normalized_interval != self.candle.interval:
raise ValueError("candle.interval must be canonical")
normalized_open_time = _normalize_utc_datetime(
self.candle.open_time,
field_name="candle.open_time",
)
normalized_observed_at = _normalize_utc_datetime(
self.observed_at,
field_name="observed_at",
)
if normalized_observed_at < normalized_open_time:
raise ValueError("observed_at must not precede candle.open_time")
if not isinstance(self.is_final, bool):
raise TypeError("is_final must be a boolean")
normalized_sources = _normalize_observation_sources(
self.observation_sources,
primary_source=self.candle.source,
)
normalized_sequence = _normalize_positive_integer(
self.replay_sequence,
field_name="replay_sequence",
)
normalized_version = _normalize_positive_integer(
self.canonical_schema_version,
field_name="canonical_schema_version",
)
object.__setattr__(self, "venue", normalized_venue)
object.__setattr__(
self,
"observed_at",
normalized_observed_at,
)
object.__setattr__(
self,
"observation_sources",
normalized_sources,
)
object.__setattr__(
self,
"replay_sequence",
normalized_sequence,
)
object.__setattr__(
self,
"canonical_schema_version",
normalized_version,
)
@property
def symbol(self) -> str:
return self.candle.symbol
@property
def interval(self) -> str:
return self.candle.interval
@property
def event_time(self) -> datetime:
"""Вернуть ось Historical Query — время открытия свечи."""
return self.candle.open_time.astimezone(timezone.utc)
@property
def replay_at(self) -> datetime:
"""Вернуть ось Replay — время наблюдения конкретной ревизии."""
return self.observed_at
@property
def order_key(self) -> tuple[datetime, int]:
return (self.event_time, self.replay_sequence)
@dataclass(frozen=True, slots=True)
class TradeHistoryCursor:
"""Структурированный keyset-курсор Trade History."""
venue: str
symbol: str
time_range: HistoricalTimeRange
executed_at: datetime
replay_sequence: int
version: int = HISTORY_CURSOR_VERSION
def __post_init__(self) -> None:
normalized_venue, normalized_symbol = _normalize_cursor_scope(
venue=self.venue,
symbol=self.symbol,
time_range=self.time_range,
)
normalized_time = _normalize_cursor_position(
cursor_time=self.executed_at,
replay_sequence=self.replay_sequence,
time_range=self.time_range,
)
_validate_cursor_version(self.version)
object.__setattr__(self, "venue", normalized_venue)
object.__setattr__(self, "symbol", normalized_symbol)
object.__setattr__(self, "executed_at", normalized_time)
@dataclass(frozen=True, slots=True)
class QuoteHistoryCursor:
"""Структурированный keyset-курсор Quote History."""
venue: str
symbol: str
time_range: HistoricalTimeRange
received_at: datetime
replay_sequence: int
version: int = HISTORY_CURSOR_VERSION
def __post_init__(self) -> None:
normalized_venue, normalized_symbol = _normalize_cursor_scope(
venue=self.venue,
symbol=self.symbol,
time_range=self.time_range,
)
normalized_time = _normalize_cursor_position(
cursor_time=self.received_at,
replay_sequence=self.replay_sequence,
time_range=self.time_range,
)
_validate_cursor_version(self.version)
object.__setattr__(self, "venue", normalized_venue)
object.__setattr__(self, "symbol", normalized_symbol)
object.__setattr__(self, "received_at", normalized_time)
@dataclass(frozen=True, slots=True)
class CandleRevisionHistoryCursor:
"""Структурированный keyset-курсор Candle Revision History."""
venue: str
symbol: str
interval: str
time_range: HistoricalTimeRange
open_time: datetime
replay_sequence: int
version: int = HISTORY_CURSOR_VERSION
def __post_init__(self) -> None:
normalized_venue, normalized_symbol = _normalize_cursor_scope(
venue=self.venue,
symbol=self.symbol,
time_range=self.time_range,
)
normalized_interval = _normalize_non_empty_text(
self.interval,
field_name="interval",
)
normalized_time = _normalize_cursor_position(
cursor_time=self.open_time,
replay_sequence=self.replay_sequence,
time_range=self.time_range,
)
_validate_cursor_version(self.version)
object.__setattr__(self, "venue", normalized_venue)
object.__setattr__(self, "symbol", normalized_symbol)
object.__setattr__(self, "interval", normalized_interval)
object.__setattr__(self, "open_time", normalized_time)
def _normalize_cursor_scope(
*,
venue: object,
symbol: object,
time_range: object,
) -> tuple[str, str]:
if type(time_range) is not HistoricalTimeRange:
raise TypeError("time_range must be HistoricalTimeRange")
return (
_normalize_non_empty_text(venue, field_name="venue"),
_normalize_symbol(symbol, field_name="symbol"),
)
def _normalize_cursor_position(
*,
cursor_time: object,
replay_sequence: object,
time_range: HistoricalTimeRange,
) -> datetime:
normalized_time = _normalize_utc_datetime(
cursor_time,
field_name="cursor time",
)
_normalize_positive_integer(
replay_sequence,
field_name="replay_sequence",
)
if not time_range.contains(normalized_time):
raise ValueError("cursor time must belong to query range")
return normalized_time
def _validate_cursor_version(version: object) -> None:
if isinstance(version, bool) or not isinstance(version, int):
raise TypeError("version must be an integer")
if version != HISTORY_CURSOR_VERSION:
raise ValueError("unsupported history cursor version")
@dataclass(frozen=True, slots=True)
class TradeHistoryQuery:
"""Один forward-only запрос истории Trades."""
venue: str
symbol: str
time_range: HistoricalTimeRange
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
cursor: TradeHistoryCursor | None = None
def __post_init__(self) -> None:
venue, symbol = _normalize_query(
venue=self.venue,
symbol=self.symbol,
time_range=self.time_range,
limit=self.limit,
)
_validate_query_cursor(
cursor=self.cursor,
cursor_type=TradeHistoryCursor,
venue=venue,
symbol=symbol,
time_range=self.time_range,
)
object.__setattr__(self, "venue", venue)
object.__setattr__(self, "symbol", symbol)
@dataclass(frozen=True, slots=True)
class QuoteHistoryQuery:
"""Один forward-only запрос истории Quotes."""
venue: str
symbol: str
time_range: HistoricalTimeRange
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
cursor: QuoteHistoryCursor | None = None
def __post_init__(self) -> None:
venue, symbol = _normalize_query(
venue=self.venue,
symbol=self.symbol,
time_range=self.time_range,
limit=self.limit,
)
_validate_query_cursor(
cursor=self.cursor,
cursor_type=QuoteHistoryCursor,
venue=venue,
symbol=symbol,
time_range=self.time_range,
)
object.__setattr__(self, "venue", venue)
object.__setattr__(self, "symbol", symbol)
@dataclass(frozen=True, slots=True)
class CandleRevisionHistoryQuery:
"""Один forward-only запрос истории ревизий Candles."""
venue: str
symbol: str
interval: str
time_range: HistoricalTimeRange
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
cursor: CandleRevisionHistoryCursor | None = None
def __post_init__(self) -> None:
venue, symbol = _normalize_query(
venue=self.venue,
symbol=self.symbol,
time_range=self.time_range,
limit=self.limit,
)
interval = _normalize_non_empty_text(
self.interval,
field_name="interval",
)
_validate_query_cursor(
cursor=self.cursor,
cursor_type=CandleRevisionHistoryCursor,
venue=venue,
symbol=symbol,
time_range=self.time_range,
interval=interval,
)
object.__setattr__(self, "venue", venue)
object.__setattr__(self, "symbol", symbol)
object.__setattr__(self, "interval", interval)
def _normalize_query(
*,
venue: object,
symbol: object,
time_range: object,
limit: object,
) -> tuple[str, str]:
if type(time_range) is not HistoricalTimeRange:
raise TypeError("time_range must be HistoricalTimeRange")
_normalize_positive_integer(
limit,
field_name="limit",
maximum=HISTORY_PAGE_LIMIT_MAX,
)
return (
_normalize_non_empty_text(venue, field_name="venue"),
_normalize_symbol(symbol, field_name="symbol"),
)
def _validate_query_cursor(
*,
cursor: object,
cursor_type: type[object],
venue: str,
symbol: str,
time_range: HistoricalTimeRange,
interval: str | None = None,
) -> None:
if cursor is None:
return
if type(cursor) is not cursor_type:
raise TypeError(f"cursor must be {cursor_type.__name__} or None")
if (
getattr(cursor, "venue") != venue
or getattr(cursor, "symbol") != symbol
or getattr(cursor, "time_range") != time_range
):
raise ValueError("cursor does not belong to query scope")
if interval is not None and getattr(cursor, "interval") != interval:
raise ValueError("cursor does not belong to query interval")
@dataclass(frozen=True, slots=True)
class TradeHistoryPage:
"""Одна типизированная страница Trade History."""
query: TradeHistoryQuery
items: tuple[TradeHistoryRecord, ...]
next_cursor: TradeHistoryCursor | None = None
def __post_init__(self) -> None:
_validate_page(
query=self.query,
query_type=TradeHistoryQuery,
items=self.items,
item_type=TradeHistoryRecord,
next_cursor=self.next_cursor,
cursor_type=TradeHistoryCursor,
cursor_time_name="executed_at",
)
@property
def has_more(self) -> bool:
return self.next_cursor is not None
@dataclass(frozen=True, slots=True)
class QuoteHistoryPage:
"""Одна типизированная страница Quote History."""
query: QuoteHistoryQuery
items: tuple[QuoteHistoryRecord, ...]
next_cursor: QuoteHistoryCursor | None = None
def __post_init__(self) -> None:
_validate_page(
query=self.query,
query_type=QuoteHistoryQuery,
items=self.items,
item_type=QuoteHistoryRecord,
next_cursor=self.next_cursor,
cursor_type=QuoteHistoryCursor,
cursor_time_name="received_at",
)
@property
def has_more(self) -> bool:
return self.next_cursor is not None
@dataclass(frozen=True, slots=True)
class CandleRevisionHistoryPage:
"""Одна типизированная страница Candle Revision History."""
query: CandleRevisionHistoryQuery
items: tuple[CandleRevisionHistoryRecord, ...]
next_cursor: CandleRevisionHistoryCursor | None = None
def __post_init__(self) -> None:
_validate_page(
query=self.query,
query_type=CandleRevisionHistoryQuery,
items=self.items,
item_type=CandleRevisionHistoryRecord,
next_cursor=self.next_cursor,
cursor_type=CandleRevisionHistoryCursor,
cursor_time_name="open_time",
)
@property
def has_more(self) -> bool:
return self.next_cursor is not None
def _validate_page(
*,
query: object,
query_type: type[object],
items: object,
item_type: type[object],
next_cursor: object,
cursor_type: type[object],
cursor_time_name: str,
) -> None:
if type(query) is not query_type:
raise TypeError(f"query must be {query_type.__name__}")
if type(items) is not tuple:
raise TypeError("items must be a tuple")
if len(items) > getattr(query, "limit"):
raise ValueError("items must not exceed query limit")
if any(type(item) is not item_type for item in items):
raise TypeError(f"items must contain only {item_type.__name__}")
if next_cursor is not None and type(next_cursor) is not cursor_type:
raise TypeError(
f"next_cursor must be {cursor_type.__name__} or None"
)
if not items:
if next_cursor is not None:
raise ValueError("empty page must not have next_cursor")
return
for previous, current in zip(items, items[1:], strict=False):
if getattr(previous, "order_key") >= getattr(current, "order_key"):
raise ValueError("page items must be strictly ordered")
for item in items:
if (
getattr(item, "venue") != getattr(query, "venue")
or getattr(item, "symbol") != getattr(query, "symbol")
):
raise ValueError("page items must belong to one query scope")
if getattr(item, "interval", None) != getattr(
query,
"interval",
None,
):
raise ValueError("page items must belong to one interval")
if not getattr(query, "time_range").contains(
getattr(item, "event_time")
):
raise ValueError("page item time must belong to query range")
query_cursor = getattr(query, "cursor")
if query_cursor is not None:
incoming_key = (
getattr(query_cursor, cursor_time_name),
getattr(query_cursor, "replay_sequence"),
)
if getattr(items[0], "order_key") <= incoming_key:
raise ValueError("page items must follow query cursor")
if next_cursor is None:
return
last = items[-1]
if (
getattr(next_cursor, "venue") != getattr(query, "venue")
or getattr(next_cursor, "symbol") != getattr(query, "symbol")
or getattr(next_cursor, "time_range")
!= getattr(query, "time_range")
or getattr(next_cursor, "replay_sequence")
!= getattr(last, "replay_sequence")
or getattr(next_cursor, cursor_time_name)
!= getattr(last, "event_time")
):
raise ValueError("next_cursor must point to the last page item")
cursor_interval = getattr(next_cursor, "interval", None)
if cursor_interval != getattr(query, "interval", None):
raise ValueError("next_cursor must belong to page interval")
HistoryRecord = (
TradeHistoryRecord
| QuoteHistoryRecord
| CandleRevisionHistoryRecord
)
HistoryQuery = (
TradeHistoryQuery
| QuoteHistoryQuery
| CandleRevisionHistoryQuery
)
HistoryPage = (
TradeHistoryPage
| QuoteHistoryPage
| CandleRevisionHistoryPage
)
HistoryOrderKey = tuple[datetime, int]
HistoryOrderKeyFactory = Callable[[Any], HistoryOrderKey]

View File

@@ -0,0 +1,269 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from src.market_data.access.exceptions import (
MarketDataAccessError,
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
)
from src.market_data.access.models import (
CandleRevisionHistoryCursor,
CandleRevisionHistoryPage,
CandleRevisionHistoryQuery,
CandleRevisionHistoryRecord,
)
from src.market_data.access.postgres_history_support import (
PostgresHistoryConnectionProvider,
candle_history_record_from_row,
)
_CANDLE_REVISION_HISTORY_COLUMNS_SQL = """
venue,
symbol,
interval,
open_time,
observed_at,
open_price,
high_price,
low_price,
close_price,
volume,
is_final,
source,
observation_sources,
replay_sequence,
canonical_schema_version
"""
_SELECT_CANDLE_REVISION_HISTORY_SQL = f"""
SELECT
{_CANDLE_REVISION_HISTORY_COLUMNS_SQL}
FROM market_data.candle_revisions
WHERE venue = %s
AND symbol = %s
AND interval = %s
AND open_time >= %s
AND open_time < %s
ORDER BY open_time ASC,
replay_sequence ASC
LIMIT %s
"""
_SELECT_CANDLE_REVISION_HISTORY_AFTER_CURSOR_SQL = f"""
SELECT
{_CANDLE_REVISION_HISTORY_COLUMNS_SQL}
FROM market_data.candle_revisions
WHERE venue = %s
AND symbol = %s
AND interval = %s
AND open_time >= %s
AND open_time < %s
AND (open_time, replay_sequence) > (%s, %s)
ORDER BY open_time ASC,
replay_sequence ASC
LIMIT %s
"""
class PostgresCandleRevisionHistoryRepository:
"""PostgreSQL-адаптер одной страницы истории ревизий свечей."""
__slots__ = ("_connection_provider",)
def __init__(
self,
*,
connection_provider: PostgresHistoryConnectionProvider,
) -> None:
if not callable(connection_provider):
raise TypeError("connection_provider must be callable")
self._connection_provider = connection_provider
def query_candle_revisions(
self,
query: CandleRevisionHistoryQuery,
) -> CandleRevisionHistoryPage:
"""Прочитать страницу в устойчивом порядке времени открытия."""
if type(query) is not CandleRevisionHistoryQuery:
raise MarketDataAccessValidationError(
"query must be CandleRevisionHistoryQuery"
)
sql, parameters = self._build_query(query)
try:
with self._connection_provider() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, parameters)
rows = cursor.fetchall()
return self._page_from_rows(query=query, rows=rows)
except MarketDataAccessError:
raise
except Exception as error:
raise MarketDataAccessOperationError(
"Failed to query PostgreSQL Candle Revision History."
) from error
@staticmethod
def _build_query(
query: CandleRevisionHistoryQuery,
) -> tuple[str, tuple[object, ...]]:
fetch_limit = query.limit + 1
cursor = query.cursor
if cursor is None:
return (
_SELECT_CANDLE_REVISION_HISTORY_SQL,
(
query.venue,
query.symbol,
query.interval,
query.time_range.start_time,
query.time_range.end_time,
fetch_limit,
),
)
return (
_SELECT_CANDLE_REVISION_HISTORY_AFTER_CURSOR_SQL,
(
query.venue,
query.symbol,
query.interval,
query.time_range.start_time,
query.time_range.end_time,
cursor.open_time,
cursor.replay_sequence,
fetch_limit,
),
)
@classmethod
def _page_from_rows(
cls,
*,
query: CandleRevisionHistoryQuery,
rows: object,
) -> CandleRevisionHistoryPage:
if not isinstance(rows, Sequence) or isinstance(
rows,
(str, bytes, bytearray),
):
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Candle Revision History rows."
)
if len(rows) > query.limit + 1:
raise MarketDataAccessIntegrityError(
"PostgreSQL exceeded the requested Candle History limit."
)
records = tuple(
cls._record_from_row(row=row, query=query)
for row in rows
)
cls._validate_records(records=records, query=query)
has_more = len(records) > query.limit
items = records[: query.limit]
next_cursor = (
cls._cursor_from_record(query=query, record=items[-1])
if has_more
else None
)
try:
return CandleRevisionHistoryPage(
query=query,
items=items,
next_cursor=next_cursor,
)
except (TypeError, ValueError) as error:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned inconsistent Candle History page."
) from error
@staticmethod
def _record_from_row(
*,
row: object,
query: CandleRevisionHistoryQuery,
) -> CandleRevisionHistoryRecord:
record = candle_history_record_from_row(row)
if (
record.venue != query.venue
or record.symbol != query.symbol
or record.interval != query.interval
):
raise MarketDataAccessIntegrityError(
"PostgreSQL returned Candle revision outside query scope."
)
if not query.time_range.contains(record.event_time):
raise MarketDataAccessIntegrityError(
"PostgreSQL returned Candle revision outside query range."
)
return record
@staticmethod
def _validate_records(
*,
records: tuple[CandleRevisionHistoryRecord, ...],
query: CandleRevisionHistoryQuery,
) -> None:
seen_sequences: set[int] = set()
previous_key: tuple[datetime, int] | None = None
incoming_key = (
(
query.cursor.open_time,
query.cursor.replay_sequence,
)
if query.cursor is not None
else None
)
for record in records:
if record.replay_sequence in seen_sequences:
raise MarketDataAccessIntegrityError(
"Candle History contains duplicate replay_sequence."
)
if incoming_key is not None and record.order_key <= incoming_key:
raise MarketDataAccessIntegrityError(
"Candle History row does not follow query cursor."
)
if previous_key is not None and record.order_key <= previous_key:
raise MarketDataAccessIntegrityError(
"Candle History rows are not strictly ordered."
)
seen_sequences.add(record.replay_sequence)
previous_key = record.order_key
@staticmethod
def _cursor_from_record(
*,
query: CandleRevisionHistoryQuery,
record: CandleRevisionHistoryRecord,
) -> CandleRevisionHistoryCursor:
try:
return CandleRevisionHistoryCursor(
venue=query.venue,
symbol=query.symbol,
interval=query.interval,
time_range=query.time_range,
open_time=record.event_time,
replay_sequence=record.replay_sequence,
)
except (TypeError, ValueError) as error:
raise MarketDataAccessIntegrityError(
"Cannot create Candle History cursor from stored row."
) from error

View File

@@ -0,0 +1,486 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
from src.market_data.access.exceptions import MarketDataAccessIntegrityError
from src.market_data.access.models import (
CandleRevisionHistoryRecord,
QuoteHistoryRecord,
TradeHistoryRecord,
)
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.models.trade import (
Trade,
TradeAggressorSide,
)
from src.market_data.acquisition.symbols import normalize_symbol
from src.market_data.acquisition.trade_id_sequence import (
validate_signed_trade_id,
)
CANONICAL_TRADE_SCHEMA_VERSION = 1
CANONICAL_QUOTE_SCHEMA_VERSION = 1
CANONICAL_CANDLE_SCHEMA_VERSION = 1
PostgresHistoryConnectionProvider = Callable[
[],
AbstractContextManager[Any],
]
def canonical_text(value: object, *, field_name: str) -> str:
"""Проверить каноническую непустую строку без скрытой нормализации."""
if type(value) is not str:
raise TypeError(f"{field_name} must be a string")
normalized = value.strip()
if not normalized or normalized != value:
raise ValueError(f"{field_name} must be canonical")
return value
def canonical_symbol(value: object, *, field_name: str) -> str:
"""Проверить уже канонизированный символ сохранённой строки."""
raw_symbol = canonical_text(value, field_name=field_name)
normalized = normalize_symbol(raw_symbol)
if normalized != raw_symbol:
raise ValueError(f"{field_name} must be canonical")
return normalized
def aware_utc_datetime(value: object, *, field_name: str) -> datetime:
"""Проверить datetime с часовым поясом и привести его к UTC."""
if (
type(value) is not datetime
or value.tzinfo is None
or value.utcoffset() is None
):
raise TypeError(f"{field_name} must be timezone-aware datetime")
return value.astimezone(timezone.utc)
def positive_integer(value: object, *, field_name: str) -> int:
"""Проверить точный положительный целочисленный тип."""
if type(value) is not int:
raise TypeError(f"{field_name} must be an integer")
if value <= 0:
raise ValueError(f"{field_name} must be positive")
return value
def signed_trade_id(value: object) -> int:
"""Проверить знаковый 32-битный идентификатор без изменения."""
if type(value) is not int:
raise TypeError("stored trade.trade_id must be an integer")
validate_signed_trade_id(value)
return value
def decimal_value(
value: object,
*,
field_name: str,
allow_zero: bool = False,
) -> Decimal:
"""Проверить точный конечный Decimal и допустимый знак."""
if type(value) is not Decimal or not value.is_finite():
raise TypeError(f"{field_name} must be a finite Decimal")
if allow_zero:
if value < 0:
raise ValueError(f"{field_name} must not be negative")
elif value <= 0:
raise ValueError(f"{field_name} must be positive")
return value
def observation_sources(
value: object,
*,
primary_source: str,
field_name: str,
) -> tuple[str, ...]:
"""Проверить порядок и уникальность источников наблюдения."""
if type(value) is not list:
raise TypeError(f"{field_name} must be a list")
if not value:
raise ValueError(f"{field_name} must not be empty")
normalized = tuple(
canonical_text(
source,
field_name=f"{field_name} item",
)
for source in value
)
if len(set(normalized)) != len(normalized):
raise ValueError(f"{field_name} must be unique")
if normalized[0] != primary_source:
raise ValueError(
"first observation source must match stored payload.source"
)
return normalized
def trade_history_record_from_row(row: object) -> TradeHistoryRecord:
"""Материализовать строго проверенную строку канонической сделки."""
if type(row) is not tuple or len(row) != 13:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Trade History row."
)
(
venue,
symbol,
trade_id,
executed_at,
price,
quantity,
aggressor_side,
source,
first_observed_at,
last_observed_at,
stored_sources,
replay_sequence,
canonical_schema_version,
) = row
try:
normalized_venue = canonical_text(
venue,
field_name="stored trade.venue",
)
normalized_symbol = canonical_symbol(
symbol,
field_name="stored trade.symbol",
)
normalized_trade_id = signed_trade_id(trade_id)
normalized_executed_at = aware_utc_datetime(
executed_at,
field_name="stored trade.executed_at",
)
normalized_price = decimal_value(
price,
field_name="stored trade.price",
)
normalized_quantity = decimal_value(
quantity,
field_name="stored trade.quantity",
)
if type(aggressor_side) is not str:
raise TypeError("stored trade.aggressor_side must be a string")
normalized_side = TradeAggressorSide(aggressor_side)
normalized_source = canonical_text(
source,
field_name="stored trade.source",
)
normalized_first_observed_at = aware_utc_datetime(
first_observed_at,
field_name="stored trade.first_observed_at",
)
normalized_last_observed_at = aware_utc_datetime(
last_observed_at,
field_name="stored trade.last_observed_at",
)
normalized_sources = observation_sources(
stored_sources,
primary_source=normalized_source,
field_name="stored trade.observation_sources",
)
normalized_sequence = positive_integer(
replay_sequence,
field_name="stored trade.replay_sequence",
)
normalized_version = positive_integer(
canonical_schema_version,
field_name="stored trade.canonical_schema_version",
)
if normalized_version != CANONICAL_TRADE_SCHEMA_VERSION:
raise ValueError("unsupported Canonical Trade schema version")
trade = Trade(
symbol=normalized_symbol,
trade_id=normalized_trade_id,
price=normalized_price,
quantity=normalized_quantity,
executed_at=normalized_executed_at,
aggressor_side=normalized_side,
source=normalized_source,
)
return TradeHistoryRecord(
venue=normalized_venue,
trade=trade,
first_observed_at=normalized_first_observed_at,
last_observed_at=normalized_last_observed_at,
observation_sources=normalized_sources,
replay_sequence=normalized_sequence,
canonical_schema_version=normalized_version,
)
except (TypeError, ValueError, ArithmeticError) as error:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Canonical Trade values."
) from error
def quote_history_record_from_row(row: object) -> QuoteHistoryRecord:
"""Материализовать строго проверенную строку канонической котировки."""
if type(row) is not tuple or len(row) != 11:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Quote History row."
)
(
venue,
symbol,
received_at,
exchange_timestamp,
last_price,
bid_price,
ask_price,
source,
stored_sources,
replay_sequence,
canonical_schema_version,
) = row
try:
normalized_venue = canonical_text(
venue,
field_name="stored quote.venue",
)
normalized_symbol = canonical_symbol(
symbol,
field_name="stored quote.symbol",
)
normalized_received_at = aware_utc_datetime(
received_at,
field_name="stored quote.received_at",
)
normalized_exchange_timestamp = (
aware_utc_datetime(
exchange_timestamp,
field_name="stored quote.exchange_timestamp",
)
if exchange_timestamp is not None
else None
)
normalized_last_price = decimal_value(
last_price,
field_name="stored quote.last_price",
)
normalized_bid_price = decimal_value(
bid_price,
field_name="stored quote.bid_price",
)
normalized_ask_price = decimal_value(
ask_price,
field_name="stored quote.ask_price",
)
if normalized_bid_price > normalized_ask_price:
raise ValueError("stored quote.bid_price exceeds ask_price")
normalized_source = canonical_text(
source,
field_name="stored quote.source",
)
normalized_sources = observation_sources(
stored_sources,
primary_source=normalized_source,
field_name="stored quote.observation_sources",
)
normalized_sequence = positive_integer(
replay_sequence,
field_name="stored quote.replay_sequence",
)
normalized_version = positive_integer(
canonical_schema_version,
field_name="stored quote.canonical_schema_version",
)
if normalized_version != CANONICAL_QUOTE_SCHEMA_VERSION:
raise ValueError("unsupported Canonical Quote schema version")
quote = Quote(
symbol=normalized_symbol,
last_price=normalized_last_price,
bid_price=normalized_bid_price,
ask_price=normalized_ask_price,
exchange_timestamp=normalized_exchange_timestamp,
received_at=normalized_received_at,
source=normalized_source,
)
return QuoteHistoryRecord(
venue=normalized_venue,
quote=quote,
observation_sources=normalized_sources,
replay_sequence=normalized_sequence,
canonical_schema_version=normalized_version,
)
except (TypeError, ValueError, ArithmeticError) as error:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Canonical Quote values."
) from error
def candle_history_record_from_row(
row: object,
) -> CandleRevisionHistoryRecord:
"""Материализовать строго проверенную строку ревизии свечи."""
if type(row) is not tuple or len(row) != 15:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Candle History row."
)
(
venue,
symbol,
interval,
open_time,
observed_at,
open_price,
high_price,
low_price,
close_price,
volume,
is_final,
source,
stored_sources,
replay_sequence,
canonical_schema_version,
) = row
try:
normalized_venue = canonical_text(
venue,
field_name="stored candle.venue",
)
normalized_symbol = canonical_symbol(
symbol,
field_name="stored candle.symbol",
)
normalized_interval = canonical_text(
interval,
field_name="stored candle.interval",
)
normalized_open_time = aware_utc_datetime(
open_time,
field_name="stored candle.open_time",
)
normalized_observed_at = aware_utc_datetime(
observed_at,
field_name="stored candle.observed_at",
)
if normalized_observed_at < normalized_open_time:
raise ValueError("stored candle.observed_at precedes open_time")
normalized_open_price = decimal_value(
open_price,
field_name="stored candle.open_price",
)
normalized_high_price = decimal_value(
high_price,
field_name="stored candle.high_price",
)
normalized_low_price = decimal_value(
low_price,
field_name="stored candle.low_price",
)
normalized_close_price = decimal_value(
close_price,
field_name="stored candle.close_price",
)
normalized_volume = decimal_value(
volume,
field_name="stored candle.volume",
allow_zero=True,
)
if normalized_low_price > normalized_high_price:
raise ValueError("stored candle.low_price exceeds high_price")
if not (
normalized_low_price
<= normalized_open_price
<= normalized_high_price
):
raise ValueError("stored candle.open_price is outside range")
if not (
normalized_low_price
<= normalized_close_price
<= normalized_high_price
):
raise ValueError("stored candle.close_price is outside range")
if type(is_final) is not bool:
raise TypeError("stored candle.is_final must be a boolean")
normalized_source = canonical_text(
source,
field_name="stored candle.source",
)
normalized_sources = observation_sources(
stored_sources,
primary_source=normalized_source,
field_name="stored candle.observation_sources",
)
normalized_sequence = positive_integer(
replay_sequence,
field_name="stored candle.replay_sequence",
)
normalized_version = positive_integer(
canonical_schema_version,
field_name="stored candle.canonical_schema_version",
)
if normalized_version != CANONICAL_CANDLE_SCHEMA_VERSION:
raise ValueError("unsupported Canonical Candle schema version")
candle = Candle(
symbol=normalized_symbol,
interval=normalized_interval,
open_time=normalized_open_time,
open_price=normalized_open_price,
high_price=normalized_high_price,
low_price=normalized_low_price,
close_price=normalized_close_price,
volume=normalized_volume,
source=normalized_source,
)
return CandleRevisionHistoryRecord(
venue=normalized_venue,
candle=candle,
observed_at=normalized_observed_at,
is_final=is_final,
observation_sources=normalized_sources,
replay_sequence=normalized_sequence,
canonical_schema_version=normalized_version,
)
except (TypeError, ValueError, ArithmeticError) as error:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Canonical Candle values."
) from error

View File

@@ -0,0 +1,253 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from src.market_data.access.exceptions import (
MarketDataAccessError,
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
)
from src.market_data.access.models import (
QuoteHistoryCursor,
QuoteHistoryPage,
QuoteHistoryQuery,
QuoteHistoryRecord,
)
from src.market_data.access.postgres_history_support import (
PostgresHistoryConnectionProvider,
quote_history_record_from_row,
)
_QUOTE_HISTORY_COLUMNS_SQL = """
venue,
symbol,
received_at,
exchange_timestamp,
last_price,
bid_price,
ask_price,
source,
observation_sources,
replay_sequence,
canonical_schema_version
"""
_SELECT_QUOTE_HISTORY_SQL = f"""
SELECT
{_QUOTE_HISTORY_COLUMNS_SQL}
FROM market_data.quotes
WHERE venue = %s
AND symbol = %s
AND received_at >= %s
AND received_at < %s
ORDER BY received_at ASC,
replay_sequence ASC
LIMIT %s
"""
_SELECT_QUOTE_HISTORY_AFTER_CURSOR_SQL = f"""
SELECT
{_QUOTE_HISTORY_COLUMNS_SQL}
FROM market_data.quotes
WHERE venue = %s
AND symbol = %s
AND received_at >= %s
AND received_at < %s
AND (received_at, replay_sequence) > (%s, %s)
ORDER BY received_at ASC,
replay_sequence ASC
LIMIT %s
"""
class PostgresQuoteHistoryRepository:
"""PostgreSQL-адаптер одной страницы канонической истории котировок."""
__slots__ = ("_connection_provider",)
def __init__(
self,
*,
connection_provider: PostgresHistoryConnectionProvider,
) -> None:
if not callable(connection_provider):
raise TypeError("connection_provider must be callable")
self._connection_provider = connection_provider
def query_quotes(self, query: QuoteHistoryQuery) -> QuoteHistoryPage:
"""Прочитать ограниченную страницу с устойчивой сортировкой."""
if type(query) is not QuoteHistoryQuery:
raise MarketDataAccessValidationError(
"query must be QuoteHistoryQuery"
)
sql, parameters = self._build_query(query)
try:
with self._connection_provider() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, parameters)
rows = cursor.fetchall()
return self._page_from_rows(query=query, rows=rows)
except MarketDataAccessError:
raise
except Exception as error:
raise MarketDataAccessOperationError(
"Failed to query PostgreSQL Quote History."
) from error
@staticmethod
def _build_query(
query: QuoteHistoryQuery,
) -> tuple[str, tuple[object, ...]]:
fetch_limit = query.limit + 1
cursor = query.cursor
if cursor is None:
return (
_SELECT_QUOTE_HISTORY_SQL,
(
query.venue,
query.symbol,
query.time_range.start_time,
query.time_range.end_time,
fetch_limit,
),
)
return (
_SELECT_QUOTE_HISTORY_AFTER_CURSOR_SQL,
(
query.venue,
query.symbol,
query.time_range.start_time,
query.time_range.end_time,
cursor.received_at,
cursor.replay_sequence,
fetch_limit,
),
)
@classmethod
def _page_from_rows(
cls,
*,
query: QuoteHistoryQuery,
rows: object,
) -> QuoteHistoryPage:
if not isinstance(rows, Sequence) or isinstance(
rows,
(str, bytes, bytearray),
):
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Quote History rows."
)
if len(rows) > query.limit + 1:
raise MarketDataAccessIntegrityError(
"PostgreSQL exceeded the requested Quote History limit."
)
records = tuple(
cls._record_from_row(row=row, query=query)
for row in rows
)
cls._validate_records(records=records, query=query)
has_more = len(records) > query.limit
items = records[: query.limit]
next_cursor = (
cls._cursor_from_record(query=query, record=items[-1])
if has_more
else None
)
try:
return QuoteHistoryPage(
query=query,
items=items,
next_cursor=next_cursor,
)
except (TypeError, ValueError) as error:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned inconsistent Quote History page."
) from error
@staticmethod
def _record_from_row(
*,
row: object,
query: QuoteHistoryQuery,
) -> QuoteHistoryRecord:
record = quote_history_record_from_row(row)
if record.venue != query.venue or record.symbol != query.symbol:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned Quote outside query scope."
)
if not query.time_range.contains(record.event_time):
raise MarketDataAccessIntegrityError(
"PostgreSQL returned Quote outside query range."
)
return record
@staticmethod
def _validate_records(
*,
records: tuple[QuoteHistoryRecord, ...],
query: QuoteHistoryQuery,
) -> None:
seen_sequences: set[int] = set()
previous_key: tuple[datetime, int] | None = None
incoming_key = (
(
query.cursor.received_at,
query.cursor.replay_sequence,
)
if query.cursor is not None
else None
)
for record in records:
if record.replay_sequence in seen_sequences:
raise MarketDataAccessIntegrityError(
"Quote History contains duplicate replay_sequence."
)
if incoming_key is not None and record.order_key <= incoming_key:
raise MarketDataAccessIntegrityError(
"Quote History row does not follow query cursor."
)
if previous_key is not None and record.order_key <= previous_key:
raise MarketDataAccessIntegrityError(
"Quote History rows are not strictly ordered."
)
seen_sequences.add(record.replay_sequence)
previous_key = record.order_key
@staticmethod
def _cursor_from_record(
*,
query: QuoteHistoryQuery,
record: QuoteHistoryRecord,
) -> QuoteHistoryCursor:
try:
return QuoteHistoryCursor(
venue=query.venue,
symbol=query.symbol,
time_range=query.time_range,
received_at=record.event_time,
replay_sequence=record.replay_sequence,
)
except (TypeError, ValueError) as error:
raise MarketDataAccessIntegrityError(
"Cannot create Quote History cursor from stored row."
) from error

View File

@@ -0,0 +1,257 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from typing import Any
from src.market_data.access.exceptions import (
MarketDataAccessError,
MarketDataAccessIntegrityError,
MarketDataAccessOperationError,
MarketDataAccessValidationError,
)
from src.market_data.access.models import (
TradeHistoryCursor,
TradeHistoryPage,
TradeHistoryQuery,
TradeHistoryRecord,
)
from src.market_data.access.postgres_history_support import (
PostgresHistoryConnectionProvider,
trade_history_record_from_row,
)
_TRADE_HISTORY_COLUMNS_SQL = """
venue,
symbol,
trade_id,
executed_at,
price,
quantity,
aggressor_side,
source,
first_observed_at,
last_observed_at,
observation_sources,
replay_sequence,
canonical_schema_version
"""
_SELECT_TRADE_HISTORY_SQL = f"""
SELECT
{_TRADE_HISTORY_COLUMNS_SQL}
FROM market_data.trades
WHERE venue = %s
AND symbol = %s
AND executed_at >= %s
AND executed_at < %s
ORDER BY executed_at ASC,
replay_sequence ASC
LIMIT %s
"""
_SELECT_TRADE_HISTORY_AFTER_CURSOR_SQL = f"""
SELECT
{_TRADE_HISTORY_COLUMNS_SQL}
FROM market_data.trades
WHERE venue = %s
AND symbol = %s
AND executed_at >= %s
AND executed_at < %s
AND (executed_at, replay_sequence) > (%s, %s)
ORDER BY executed_at ASC,
replay_sequence ASC
LIMIT %s
"""
class PostgresTradeHistoryRepository:
"""PostgreSQL-адаптер одной страницы канонической истории сделок."""
__slots__ = ("_connection_provider",)
def __init__(
self,
*,
connection_provider: PostgresHistoryConnectionProvider,
) -> None:
if not callable(connection_provider):
raise TypeError("connection_provider must be callable")
self._connection_provider = connection_provider
def query_trades(self, query: TradeHistoryQuery) -> TradeHistoryPage:
"""Прочитать одну bounded страницу в устойчивом keyset-порядке."""
if type(query) is not TradeHistoryQuery:
raise MarketDataAccessValidationError(
"query must be TradeHistoryQuery"
)
sql, parameters = self._build_query(query)
try:
with self._connection_provider() as connection:
with connection.cursor() as cursor:
cursor.execute(sql, parameters)
rows = cursor.fetchall()
return self._page_from_rows(query=query, rows=rows)
except MarketDataAccessError:
raise
except Exception as error:
raise MarketDataAccessOperationError(
"Failed to query PostgreSQL Trade History."
) from error
@staticmethod
def _build_query(
query: TradeHistoryQuery,
) -> tuple[str, tuple[object, ...]]:
fetch_limit = query.limit + 1
cursor = query.cursor
if cursor is None:
return (
_SELECT_TRADE_HISTORY_SQL,
(
query.venue,
query.symbol,
query.time_range.start_time,
query.time_range.end_time,
fetch_limit,
),
)
return (
_SELECT_TRADE_HISTORY_AFTER_CURSOR_SQL,
(
query.venue,
query.symbol,
query.time_range.start_time,
query.time_range.end_time,
cursor.executed_at,
cursor.replay_sequence,
fetch_limit,
),
)
@classmethod
def _page_from_rows(
cls,
*,
query: TradeHistoryQuery,
rows: object,
) -> TradeHistoryPage:
if not isinstance(rows, Sequence) or isinstance(
rows,
(str, bytes, bytearray),
):
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Trade History rows."
)
if len(rows) > query.limit + 1:
raise MarketDataAccessIntegrityError(
"PostgreSQL exceeded the requested Trade History limit."
)
records = tuple(
cls._record_from_row(row=row, query=query)
for row in rows
)
cls._validate_records(records=records, query=query)
has_more = len(records) > query.limit
items = records[: query.limit]
next_cursor = (
cls._cursor_from_record(query=query, record=items[-1])
if has_more
else None
)
try:
return TradeHistoryPage(
query=query,
items=items,
next_cursor=next_cursor,
)
except (TypeError, ValueError) as error:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned inconsistent Trade History page."
) from error
@classmethod
def _record_from_row(
cls,
*,
row: object,
query: TradeHistoryQuery,
) -> TradeHistoryRecord:
record = trade_history_record_from_row(row)
try:
if record.venue != query.venue or record.symbol != query.symbol:
raise ValueError("stored Trade is outside query scope")
if not query.time_range.contains(record.event_time):
raise ValueError("stored Trade is outside query range")
except (TypeError, ValueError) as error:
raise MarketDataAccessIntegrityError(
"PostgreSQL returned invalid Canonical Trade values."
) from error
return record
@staticmethod
def _validate_records(
*,
records: tuple[TradeHistoryRecord, ...],
query: TradeHistoryQuery,
) -> None:
seen_sequences: set[int] = set()
previous_key: tuple[datetime, int] | None = None
incoming_key = (
(
query.cursor.executed_at,
query.cursor.replay_sequence,
)
if query.cursor is not None
else None
)
for record in records:
if record.replay_sequence in seen_sequences:
raise MarketDataAccessIntegrityError(
"Trade History contains duplicate replay_sequence."
)
if incoming_key is not None and record.order_key <= incoming_key:
raise MarketDataAccessIntegrityError(
"Trade History row does not follow query cursor."
)
if previous_key is not None and record.order_key <= previous_key:
raise MarketDataAccessIntegrityError(
"Trade History rows are not strictly ordered."
)
seen_sequences.add(record.replay_sequence)
previous_key = record.order_key
@staticmethod
def _cursor_from_record(
*,
query: TradeHistoryQuery,
record: TradeHistoryRecord,
) -> TradeHistoryCursor:
try:
return TradeHistoryCursor(
venue=query.venue,
symbol=query.symbol,
time_range=query.time_range,
executed_at=record.event_time,
replay_sequence=record.replay_sequence,
)
except (TypeError, ValueError) as error:
raise MarketDataAccessIntegrityError(
"Cannot create Trade History cursor from stored row."
) from error

View File

@@ -0,0 +1,9 @@
# app/src/market_data/acquisition/adapters/dzengi/__init__.py
from src.market_data.acquisition.adapters.dzengi.websocket_trade_adapter import (
adapt_websocket_trade_document,
)
__all__ = [
"adapt_websocket_trade_document",
]

View File

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

View File

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

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

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

@@ -0,0 +1,41 @@
# src/market_data/acquisition/adapters/dzengi/rest_trade_adapter.py
from __future__ import annotations
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_rest_agg_trades_to_trades,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_rest_agg_trades,
)
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.validation.schema import (
ValidatedRestAggTradesDocument,
)
from src.market_data.acquisition.validation.values import (
validate_rest_agg_trade_values,
)
def adapt_rest_agg_trades_document(
document: ValidatedRestAggTradesDocument,
*,
symbol: str,
) -> tuple[Trade, ...]:
"""
Преобразовать структурно проверенный документ Dzengi aggTrades
в канонический immutable-набор Trade.
Функция последовательно выполняет parsing, value validation
и mapping. Schema validation должна быть выполнена вызывающим слоем.
Исключения отдельных этапов не перехватываются и не оборачиваются.
"""
trades = parse_rest_agg_trades(document)
validate_rest_agg_trade_values(trades)
return map_dzengi_rest_agg_trades_to_trades(
trades,
symbol=symbol,
)

View File

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

@@ -0,0 +1,62 @@
from __future__ import annotations
from src.market_data.acquisition.exceptions import (
WebSocketControlMessageError,
WebSocketMessageRoutingError,
)
_TRADE_SUBSCRIPTION_DESTINATION = "trades.subscribe"
_SUCCESS_STATUS = "OK"
class DzengiWebSocketControlMessageHandler:
"""
Provider-specific проверка Dzengi control/ACK сообщений.
Handler не управляет lifecycle и не хранит pending requests.
Ожидаемый correlation ID передаётся владельцем Runtime.
"""
__slots__ = ()
def handle(
self,
document: object,
*,
expected_correlation_id: str,
) -> None:
"""Проверить ACK текущей Trade Stream подписки."""
if not isinstance(document, dict):
raise WebSocketMessageRoutingError(
"Control message Dzengi WebSocket должен быть объектом."
)
correlation_id = document.get("correlationId")
if correlation_id != expected_correlation_id:
raise WebSocketMessageRoutingError(
"Получен control message с неизвестным correlationId."
)
status = document.get("status")
if not isinstance(status, str) or not status.strip():
raise WebSocketMessageRoutingError(
"Control message Dzengi WebSocket должен содержать "
"непустой строковый status."
)
if status != _SUCCESS_STATUS:
raise WebSocketControlMessageError(
"Dzengi отклонил Trade Stream subscription ACK."
)
if (
document.get("destination")
!= _TRADE_SUBSCRIPTION_DESTINATION
):
raise WebSocketMessageRoutingError(
"Получен успешный control message с неизвестным "
"destination."
)

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from src.market_data.acquisition.exceptions import (
WebSocketMessageRoutingError,
)
from src.market_data.acquisition.runtime.websocket_inbound_message import (
WebSocketInboundMessageKind,
)
_DZENGI_MARKET_DESTINATIONS = frozenset(
{
"internal.trade",
"ohlc.event",
}
)
class DzengiWebSocketInboundMessageClassifier:
"""
Классификатор декодированных входящих сообщений Dzengi.
Market markers имеют приоритет над correlationId, чтобы market
document с дополнительными transport metadata не был ошибочно
принят за control response.
"""
__slots__ = ()
def classify(
self,
document: object,
) -> WebSocketInboundMessageKind:
"""Разделить market documents и ответы на Runtime-команды."""
if not isinstance(document, dict):
raise WebSocketMessageRoutingError(
"Сообщение Dzengi WebSocket должно быть объектом."
)
destination = document.get("destination")
if (
(
isinstance(destination, str)
and destination in _DZENGI_MARKET_DESTINATIONS
)
or "Payload" in document
):
return WebSocketInboundMessageKind.MARKET
if "correlationId" in document:
self._validate_correlation_id(
document["correlationId"],
)
return WebSocketInboundMessageKind.CONTROL
raise WebSocketMessageRoutingError(
"Не удалось определить категорию сообщения "
"Dzengi WebSocket."
)
@staticmethod
def _validate_correlation_id(
correlation_id: object,
) -> None:
if (
isinstance(correlation_id, bool)
or not isinstance(correlation_id, (str, int))
):
raise WebSocketMessageRoutingError(
"correlationId сообщения Dzengi WebSocket должен "
"быть строкой или целым числом."
)
if (
isinstance(correlation_id, str)
and not correlation_id.strip()
):
raise WebSocketMessageRoutingError(
"correlationId сообщения Dzengi WebSocket "
"не должен быть пустым."
)

View File

@@ -0,0 +1,36 @@
# src/market_data/acquisition/adapters/dzengi/websocket_trade_adapter.py
from __future__ import annotations
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_websocket_trade_to_trade,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_dzengi_websocket_trade,
)
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.validation.schema import (
ValidatedWebSocketTradeDocument,
)
from src.market_data.acquisition.validation.values import (
validate_dzengi_websocket_trade_values,
)
def adapt_websocket_trade_document(
document: ValidatedWebSocketTradeDocument,
) -> Trade:
"""
Преобразовать структурно проверенный документ Dzengi WebSocket Trade
в каноническую immutable-модель Trade.
Функция последовательно выполняет parsing, value validation
и mapping. Schema validation должна быть выполнена вызывающим слоем.
Исключения отдельных этапов не перехватываются и не оборачиваются.
"""
event = parse_dzengi_websocket_trade(document)
validate_dzengi_websocket_trade_values(event)
return map_dzengi_websocket_trade_to_trade(event)

View File

@@ -0,0 +1,445 @@
from __future__ import annotations
import asyncio
import math
from collections.abc import Awaitable, Callable, Mapping
from typing import Protocol, cast
from urllib.parse import urlsplit, urlunsplit
from websockets.asyncio.client import connect as websocket_connect
from websockets.protocol import State
from websockets.typing import Subprotocol
from src.market_data.acquisition.exceptions import (
WebSocketTransportError,
WebSocketTransportNotConnectedError,
)
class _WebSocketConnectionProtocol(Protocol):
"""
Минимальный контракт соединения.
"""
@property
def state(self) -> State:
"""Вернуть состояние WebSocket-соединения."""
...
async def close(
self,
code: int = 1000,
reason: str = "",
) -> None:
"""Закрыть WebSocket-соединение."""
...
async def send(
self,
message: str | bytes,
) -> None:
"""Отправить одно сообщение."""
...
async def recv(self) -> str | bytes:
"""Получить одно сообщение."""
...
async def ping(self) -> Awaitable[float]:
"""Отправить Ping и вернуть ожидание соответствующего Pong."""
...
WebSocketConnector = Callable[
...,
Awaitable[_WebSocketConnectionProtocol],
]
def build_dzengi_websocket_url(
raw_url: str,
) -> str:
"""
Нормализовать Dzengi HTTP/WebSocket URL до endpoint ``/connect``.
"""
if not isinstance(raw_url, str):
raise TypeError("raw_url must be a string")
normalized_url = raw_url.strip()
if not normalized_url:
raise ValueError("raw_url must not be empty")
parsed = urlsplit(normalized_url)
scheme = {
"http": "ws",
"https": "wss",
"ws": "ws",
"wss": "wss",
}.get(parsed.scheme.lower())
if scheme is None or not parsed.netloc:
raise ValueError(
"raw_url must be an absolute HTTP or WebSocket URL"
)
if parsed.fragment:
raise ValueError("raw_url must not contain a fragment")
path = parsed.path.rstrip("/")
if not path.endswith("/connect"):
path = f"{path}/connect"
return urlunsplit(
(
scheme,
parsed.netloc,
path,
parsed.query,
"",
)
)
class DzengiWebSocketTransport:
"""
Низкоуровневый WebSocket-транспорт Dzengi.
Transport управляет только соединением и непрозрачными
``str``/``bytes`` сообщениями. JSON parsing, subscription routing,
recovery и lifecycle находятся за пределами этого компонента.
"""
__slots__ = (
"_url",
"_headers",
"_open_timeout",
"_ping_interval",
"_ping_timeout",
"_probe_timeout",
"_close_timeout",
"_connector",
"_connection",
"_lifecycle_lock",
)
def __init__(
self,
*,
url: str,
headers: Mapping[str, str] | None = None,
open_timeout: float | None = 10.0,
ping_interval: float | None = 20.0,
ping_timeout: float | None = 20.0,
probe_timeout: float | None = None,
close_timeout: float | None = 10.0,
connector: WebSocketConnector | None = None,
) -> None:
self._url = build_dzengi_websocket_url(url)
self._headers = self._validate_headers(headers)
self._open_timeout = self._validate_timeout(
"open_timeout",
open_timeout,
)
self._ping_interval = self._validate_timeout(
"ping_interval",
ping_interval,
)
self._ping_timeout = self._validate_timeout(
"ping_timeout",
ping_timeout,
)
self._probe_timeout = self._validate_probe_timeout(
(
probe_timeout
if probe_timeout is not None
else (
ping_timeout
if ping_timeout is not None
else 20.0
)
),
)
self._close_timeout = self._validate_timeout(
"close_timeout",
close_timeout,
)
self._connector = (
connector
if connector is not None
else cast(
WebSocketConnector,
websocket_connect,
)
)
self._connection: _WebSocketConnectionProtocol | None = None
self._lifecycle_lock = asyncio.Lock()
@property
def url(self) -> str:
"""Вернуть нормализованный WebSocket URL."""
return self._url
@property
def is_connected(self) -> bool:
"""Показывает, открыто ли соединение."""
connection = self._connection
return (
connection is not None
and connection.state is State.OPEN
)
async def connect(self) -> None:
"""
Идемпотентно открыть WebSocket-соединение Dzengi.
"""
async with self._lifecycle_lock:
if self.is_connected:
return
self._connection = None
try:
connection = await self._connector(
self._url,
additional_headers=(
self._headers
if self._headers
else None
),
subprotocols=(
Subprotocol("json"),
),
open_timeout=self._open_timeout,
ping_interval=self._ping_interval,
ping_timeout=self._ping_timeout,
close_timeout=self._close_timeout,
)
except Exception as error:
raise WebSocketTransportError(
"Не удалось открыть Dzengi WebSocket connection: "
f"{error}"
) from error
self._connection = connection
if self.is_connected:
return
self._connection = None
try:
await connection.close()
except Exception:
pass
raise WebSocketTransportError(
"Dzengi WebSocket connection не перешло "
"в состояние OPEN."
)
async def disconnect(self) -> None:
"""
Идемпотентно закрыть WebSocket-соединение.
"""
async with self._lifecycle_lock:
connection = self._connection
self._connection = None
if connection is None:
return
try:
await connection.close()
except Exception as error:
raise WebSocketTransportError(
"Не удалось закрыть Dzengi WebSocket connection: "
f"{error}"
) from error
async def send(
self,
message: str | bytes,
) -> None:
"""Отправить непрозрачное транспортное сообщение."""
if not isinstance(message, (str, bytes)):
raise TypeError("message must be str or bytes")
connection = self._require_connection()
try:
await connection.send(message)
except Exception as error:
self._discard_closed_connection(connection)
raise WebSocketTransportError(
"Не удалось отправить Dzengi WebSocket message: "
f"{error}"
) from error
async def receive(self) -> str | bytes:
"""Получить непрозрачное транспортное сообщение."""
connection = self._require_connection()
try:
return await connection.recv()
except Exception as error:
self._discard_closed_connection(connection)
raise WebSocketTransportError(
"Не удалось получить Dzengi WebSocket message: "
f"{error}"
) from error
async def probe(self) -> bool:
"""
Подтвердить liveness текущего connection через Ping/Pong.
Закрытое соединение и отсутствие Pong являются штатным
отрицательным результатом. Cancellation не перехватывается.
"""
connection = self._connection
if (
connection is None
or connection.state is not State.OPEN
):
self._connection = None
return False
try:
pong_waiter = await connection.ping()
await asyncio.wait_for(
asyncio.shield(
pong_waiter,
),
timeout=self._probe_timeout,
)
except asyncio.TimeoutError:
return False
except asyncio.CancelledError:
raise
except Exception as error:
self._discard_closed_connection(connection)
if connection.state is not State.OPEN:
return False
raise WebSocketTransportError(
"Не удалось проверить Dzengi WebSocket liveness: "
f"{error}"
) from error
return (
self._connection is connection
and connection.state is State.OPEN
)
def _require_connection(
self,
) -> _WebSocketConnectionProtocol:
"""
Вернуть открытое соединение.
"""
connection = self._connection
if (
connection is None
or connection.state is not State.OPEN
):
self._connection = None
raise WebSocketTransportNotConnectedError(
"Dzengi WebSocket transport is not connected."
)
return connection
def _discard_closed_connection(
self,
connection: _WebSocketConnectionProtocol,
) -> None:
"""
Удалить ссылку на закрытое соединение.
"""
if (
self._connection is connection
and connection.state is not State.OPEN
):
self._connection = None
@staticmethod
def _validate_headers(
headers: Mapping[str, str] | None,
) -> dict[str, str]:
if headers is None:
return {}
if not isinstance(headers, Mapping):
raise TypeError("headers must be a mapping")
normalized_headers: dict[str, str] = {}
for name, value in headers.items():
if not isinstance(name, str) or not name.strip():
raise ValueError(
"header names must be non-empty strings"
)
if not isinstance(value, str):
raise TypeError("header values must be strings")
normalized_headers[name] = value
return normalized_headers
@staticmethod
def _validate_timeout(
name: str,
value: float | None,
) -> float | None:
if value is None:
return None
if (
isinstance(value, bool)
or not isinstance(value, (int, float))
):
raise TypeError(
f"{name} must be an integer, float, or None"
)
if value <= 0:
raise ValueError(
f"{name} must be positive"
)
return float(value)
@staticmethod
def _validate_probe_timeout(
value: float,
) -> float:
if (
isinstance(value, bool)
or not isinstance(value, (int, float))
):
raise TypeError(
"probe_timeout must be an integer or float"
)
normalized_value = float(value)
if (
not math.isfinite(normalized_value)
or normalized_value <= 0
):
raise ValueError(
"probe_timeout must be positive and finite"
)
return normalized_value

View File

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

View File

@@ -0,0 +1,327 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
TradeConsistencyError,
TradeOrderingError,
)
from src.market_data.acquisition.consistency.trade_stream_state import (
DEFAULT_DEDUPLICATION_WINDOW_SIZE,
TradeStreamState,
)
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
TradeStreamStateStoreInitializationError,
)
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
TradeStreamStateStoreProtocol,
)
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.symbols import normalize_symbol
from src.market_data.storage.contracts import (
PersistentTradeCheckpoint,
TradeCheckpointStorageProtocol,
)
from src.market_data.storage.exceptions import (
MarketDataCheckpointIntegrityError,
)
@runtime_checkable
class TradeStreamStateHydratorProtocol(Protocol):
"""Контракт восстановления Consistency state до запуска Runtime."""
def hydrate(
self,
*,
symbols: tuple[str, ...],
) -> tuple[TradeStreamState, ...]:
"""Восстановить и атомарно опубликовать состояния symbols."""
...
class TradeStreamStateHydrator:
"""Восстанавливает TradeStreamState из persistent checkpoint."""
__slots__ = (
"_checkpoint_storage",
"_state_store",
"_venue",
"_deduplication_window_size",
)
def __init__(
self,
*,
checkpoint_storage: TradeCheckpointStorageProtocol,
state_store: TradeStreamStateStoreProtocol,
venue: str,
deduplication_window_size: int = (
DEFAULT_DEDUPLICATION_WINDOW_SIZE
),
) -> None:
if not isinstance(
checkpoint_storage,
TradeCheckpointStorageProtocol,
):
raise TypeError(
"checkpoint_storage must implement "
"TradeCheckpointStorageProtocol"
)
if not isinstance(state_store, TradeStreamStateStoreProtocol):
raise TypeError(
"state_store must implement TradeStreamStateStoreProtocol"
)
if not isinstance(venue, str):
raise TypeError("venue must be a string")
normalized_venue = venue.strip().lower()
if not normalized_venue:
raise ValueError("venue must not be empty")
if (
isinstance(deduplication_window_size, bool)
or not isinstance(deduplication_window_size, int)
):
raise TypeError(
"deduplication_window_size must be an integer"
)
if deduplication_window_size <= 0:
raise ValueError(
"deduplication_window_size must be positive"
)
self._checkpoint_storage = checkpoint_storage
self._state_store = state_store
self._venue = normalized_venue
self._deduplication_window_size = deduplication_window_size
def hydrate(
self,
*,
symbols: tuple[str, ...],
) -> tuple[TradeStreamState, ...]:
normalized_symbols = self._normalize_symbols(symbols)
if not self._state_store.is_empty():
raise TradeStreamStateStoreInitializationError(
"Trade Stream State Store уже содержит состояния."
)
states = tuple(
self._prepare_state(symbol)
for symbol in normalized_symbols
)
self._state_store.initialize(states)
return states
def _prepare_state(
self,
symbol: str,
) -> TradeStreamState:
checkpoint = self._checkpoint_storage.load_checkpoint(
venue=self._venue,
symbol=symbol,
)
if checkpoint is not None:
return self._state_from_checkpoint(
symbol=symbol,
checkpoint=checkpoint,
)
latest_tail = self._checkpoint_storage.load_latest_trade_tail(
venue=self._venue,
symbol=symbol,
limit=self._deduplication_window_size,
)
if not isinstance(latest_tail, tuple):
raise MarketDataCheckpointIntegrityError(
"Checkpoint storage returned non-tuple latest Trade tail."
)
if not latest_tail:
concurrent_checkpoint = (
self._checkpoint_storage.load_checkpoint(
venue=self._venue,
symbol=symbol,
)
)
if concurrent_checkpoint is not None:
return self._state_from_checkpoint(
symbol=symbol,
checkpoint=concurrent_checkpoint,
)
return TradeStreamState.from_history(
symbol=symbol,
trades=(),
deduplication_window_size=(
self._deduplication_window_size
),
)
candidate_state = self._state_from_tail(
symbol=symbol,
trades=latest_tail,
)
candidate = candidate_state.last_trade
if candidate is None:
raise MarketDataCheckpointIntegrityError(
"Latest Trade tail unexpectedly produced empty state."
)
adopted = (
self._checkpoint_storage.adopt_existing_trade_as_checkpoint(
venue=self._venue,
trade=candidate,
)
)
self._validate_checkpoint(
checkpoint=adopted,
symbol=symbol,
expected_trade=candidate,
)
return self._state_from_checkpoint(
symbol=symbol,
checkpoint=adopted,
)
def _state_from_checkpoint(
self,
*,
symbol: str,
checkpoint: PersistentTradeCheckpoint,
) -> TradeStreamState:
self._validate_checkpoint(
checkpoint=checkpoint,
symbol=symbol,
expected_trade=None,
)
tail = self._checkpoint_storage.load_checkpoint_tail(
venue=self._venue,
checkpoint=checkpoint,
limit=self._deduplication_window_size,
)
state = self._state_from_tail(
symbol=symbol,
trades=tail,
)
if state.last_trade is None or not self._same_market_trade(
state.last_trade,
checkpoint.trade,
):
raise MarketDataCheckpointIntegrityError(
"Hydrated tail does not end at persistent checkpoint."
)
return state
def _state_from_tail(
self,
*,
symbol: str,
trades: object,
) -> TradeStreamState:
if not isinstance(trades, tuple):
raise MarketDataCheckpointIntegrityError(
"Checkpoint storage returned non-tuple Trade tail."
)
try:
return TradeStreamState.from_history(
symbol=symbol,
trades=trades,
deduplication_window_size=(
self._deduplication_window_size
),
)
except (
TypeError,
ValueError,
TradeConsistencyError,
TradeOrderingError,
) as error:
raise MarketDataCheckpointIntegrityError(
"Persistent Trade tail cannot hydrate Consistency state."
) from error
def _validate_checkpoint(
self,
*,
checkpoint: object,
symbol: str,
expected_trade: Trade | None,
) -> None:
if not isinstance(checkpoint, PersistentTradeCheckpoint):
raise MarketDataCheckpointIntegrityError(
"Checkpoint storage returned invalid checkpoint."
)
if checkpoint.venue.strip().lower() != self._venue:
raise MarketDataCheckpointIntegrityError(
"Persistent checkpoint venue does not match hydrator."
)
if checkpoint.trade.symbol.strip().upper() != symbol:
raise MarketDataCheckpointIntegrityError(
"Persistent checkpoint symbol does not match request."
)
if expected_trade is not None and not self._same_market_trade(
checkpoint.trade,
expected_trade,
):
raise MarketDataCheckpointIntegrityError(
"Adopted checkpoint does not match candidate Trade."
)
@staticmethod
def _same_market_trade(
first: Trade,
second: Trade,
) -> bool:
return (
first.symbol == second.symbol
and first.trade_id == second.trade_id
and first.executed_at == second.executed_at
and first.price == second.price
and first.quantity == second.quantity
and first.aggressor_side is second.aggressor_side
)
@staticmethod
def _normalize_symbols(
symbols: tuple[str, ...],
) -> tuple[str, ...]:
if not isinstance(symbols, tuple):
raise TypeError("symbols must be a tuple")
if not symbols:
raise ValueError("symbols must not be empty")
normalized: list[str] = []
for symbol in symbols:
if not isinstance(symbol, str):
raise TypeError("symbols must contain only strings")
value = normalize_symbol(symbol)
if not value:
raise ValueError("symbols must not contain empty values")
if value in normalized:
raise ValueError("symbols must not contain duplicates")
normalized.append(value)
return tuple(normalized)

View File

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

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.models.trade import Trade
@runtime_checkable
class TradeObservationSinkProtocol(Protocol):
"""Надёжный приёмник одного наблюдения канонической сделки."""
def persist_accepted(
self,
trade: Trade,
*,
expected_trade: Trade | None,
) -> None:
"""Сохранить новую Trade и атомарно продвинуть checkpoint."""
...
def persist_duplicate(
self,
trade: Trade,
) -> None:
"""Сохранить provenance дубликата без продвижения checkpoint."""
...

View File

@@ -0,0 +1,85 @@
# app/src/market_data/acquisition/consistency/trade_stream_consistency_controller.py
from __future__ import annotations
from src.market_data.acquisition.consistency.trade_observation_sink_protocol import (
TradeObservationSinkProtocol,
)
from src.market_data.acquisition.consistency.trade_stream_protocol import (
TradeStreamConsistencyProtocol,
)
from src.market_data.acquisition.consistency.trade_stream_state import (
TradeStreamState,
)
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
TradeStreamStateStoreProtocol,
)
from src.market_data.acquisition.models.trade import Trade
class TradeStreamConsistencyController(
TradeStreamConsistencyProtocol,
):
"""
Контроллер проверки согласованности Canonical Trade Stream.
Для каждого торгового инструмента используется
специализированное хранилище состояний
Trade Stream Consistency.
"""
def __init__(
self,
state_store: TradeStreamStateStoreProtocol,
*,
trade_observation_sink: TradeObservationSinkProtocol | None = None,
) -> None:
if (
trade_observation_sink is not None
and not isinstance(
trade_observation_sink,
TradeObservationSinkProtocol,
)
):
raise TypeError(
"trade_observation_sink must implement "
"TradeObservationSinkProtocol"
)
self._state_store = state_store
self._trade_observation_sink = trade_observation_sink
def accept(
self,
trade: Trade,
) -> Trade | None:
state = self._get_state(trade.symbol)
sink = self._trade_observation_sink
return state.accept(
trade,
before_checkpoint=(
sink.persist_accepted
if sink is not None
else None
),
on_duplicate=(
sink.persist_duplicate
if sink is not None
else None
),
)
def _get_state(
self,
symbol: str,
) -> TradeStreamState:
"""
Возвращает состояние проверки Trade Stream
для указанного торгового инструмента.
При первом обращении состояние автоматически
создаётся специализированным хранилищем.
"""
return self._state_store.get_or_create(symbol)

View File

@@ -0,0 +1,17 @@
# app/src/market_data/acquisition/consistency/trade_stream_exceptions.py
from __future__ import annotations
from src.market_data.acquisition.exceptions import (
MarketDataAcquisitionError,
)
# Нарушение монотонности Canonical Trade Stream.
class TradeOrderingError(MarketDataAcquisitionError):
pass
# Обнаружен конфликтующий дубликат сделки.
class TradeConsistencyError(MarketDataAcquisitionError):
pass

View File

@@ -0,0 +1,29 @@
# app/src/market_data/acquisition/consistency/trade_stream_protocol.py
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.models.trade import Trade
# Контракт проверки согласованности Canonical Trade Stream.
@runtime_checkable
class TradeStreamConsistencyProtocol(Protocol):
def accept(
self,
trade: Trade,
) -> Trade | None:
"""
Проверить согласованность поступившей сделки.
Возвращает исходную модель Trade, если сделка принята.
Возвращает None, если обнаружен полный дубликат.
Генерирует TradeOrderingError при нарушении порядка.
Генерирует TradeConsistencyError при обнаружении
конфликтующего дубликата.
"""
...

View File

@@ -0,0 +1,237 @@
# app/src/market_data/acquisition/consistency/trade_stream_state.py
from __future__ import annotations
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Protocol, Self
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
TradeConsistencyError,
TradeOrderingError,
)
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.trade_id_sequence import (
is_trade_id_newer,
validate_signed_trade_id,
)
DEFAULT_DEDUPLICATION_WINDOW_SIZE = 10_000
TradeDuplicateCallback = Callable[[Trade], None]
class TradeCheckpointCallback(Protocol):
"""Callback атомарной записи перед in-memory checkpoint."""
def __call__(
self,
trade: Trade,
*,
expected_trade: Trade | None,
) -> None:
...
@dataclass(slots=True)
class TradeStreamState:
"""
Состояние проверки согласованности Canonical Trade Stream
для одного торгового инструмента.
"""
symbol: str
deduplication_window_size: int = DEFAULT_DEDUPLICATION_WINDOW_SIZE
last_trade_id: int | None = None
last_trade: Trade | None = None
_trade_window: deque[int] = field(init=False, repr=False)
_trades: dict[int, Trade] = field(init=False, repr=False)
def __post_init__(self) -> None:
if not self.symbol:
raise ValueError("symbol must not be empty")
if self.deduplication_window_size <= 0:
raise ValueError(
"deduplication_window_size must be positive"
)
self._trade_window = deque(
maxlen=self.deduplication_window_size,
)
self._trades = {}
@classmethod
def from_history(
cls,
*,
symbol: str,
trades: tuple[Trade, ...],
deduplication_window_size: int = (
DEFAULT_DEDUPLICATION_WINDOW_SIZE
),
) -> Self:
"""Строго восстановить временное состояние из durable tail."""
if not isinstance(trades, tuple):
raise TypeError("trades must be a tuple")
if len(trades) > deduplication_window_size:
raise ValueError(
"trades must fit deduplication_window_size"
)
state = cls(
symbol=symbol,
deduplication_window_size=deduplication_window_size,
)
for trade in trades:
if not isinstance(trade, Trade):
raise TypeError("trades must contain only Trade")
if state.accept(trade) is None:
raise TradeConsistencyError(
"Hydration history contains a duplicate Trade."
)
return state
def accept(
self,
trade: Trade,
*,
before_checkpoint: TradeCheckpointCallback | None = None,
on_duplicate: TradeDuplicateCallback | None = None,
) -> Trade | None:
"""
Проверить сделку на согласованность.
Возвращает принятую сделку.
Возвращает None при полном дубликате.
Генерирует исключение при нарушении порядка
либо конфликтующем дубликате.
"""
if before_checkpoint is not None and not callable(
before_checkpoint
):
raise TypeError("before_checkpoint must be callable")
if on_duplicate is not None and not callable(on_duplicate):
raise TypeError("on_duplicate must be callable")
if trade.symbol != self.symbol:
raise ValueError(
f"Unexpected symbol: {trade.symbol!r}"
)
trade_id = trade.trade_id
validate_signed_trade_id(trade_id)
if self.last_trade_id is not None:
if not is_trade_id_newer(
trade_id,
self.last_trade_id,
) and trade_id != self.last_trade_id:
previous = self._trades.get(trade_id)
if previous is None:
raise TradeOrderingError()
if self._is_same_market_trade(
previous,
trade,
):
self._run_duplicate_observation(
trade,
callback=on_duplicate,
)
return None
raise TradeConsistencyError()
previous = self._trades.get(trade_id)
if previous is not None:
if self._is_same_market_trade(
previous,
trade,
):
self._run_duplicate_observation(
trade,
callback=on_duplicate,
)
return None
raise TradeConsistencyError()
self._run_before_checkpoint(
trade,
expected_trade=self.last_trade,
callback=before_checkpoint,
)
self._append(trade)
self.last_trade_id = trade_id
self.last_trade = trade
return trade
@staticmethod
def _run_before_checkpoint(
trade: Trade,
*,
expected_trade: Trade | None,
callback: TradeCheckpointCallback | None,
) -> None:
if callback is not None:
callback(
trade,
expected_trade=expected_trade,
)
@staticmethod
def _run_duplicate_observation(
trade: Trade,
*,
callback: TradeDuplicateCallback | None,
) -> None:
if callback is not None:
callback(trade)
@staticmethod
def _is_same_market_trade(
first: Trade,
second: Trade,
) -> bool:
"""
Сравнить биржевой факт независимо от transport source.
Одна сделка закономерно приходит через WebSocket и повторяется
через REST recovery. Поле ``source`` описывает путь доставки и
поэтому не является частью identity биржевой сделки.
"""
return (
first.symbol == second.symbol
and first.trade_id == second.trade_id
and first.price == second.price
and first.quantity == second.quantity
and first.executed_at == second.executed_at
and first.aggressor_side is second.aggressor_side
)
def _append(
self,
trade: Trade,
) -> None:
if len(self._trade_window) == self.deduplication_window_size:
oldest_trade_id = self._trade_window[0]
self._trades.pop(oldest_trade_id, None)
self._trade_window.append(trade.trade_id)
self._trades[trade.trade_id] = trade

View File

@@ -0,0 +1,168 @@
# app/src/market_data/acquisition/consistency/trade_stream_state_store.py
from __future__ import annotations
"""
Хранилище состояний Trade Stream Consistency.
Build 060.20.1 переносит владение инфраструктурным состоянием
из общего Runtime Registry в специализированное хранилище
подсистемы проверки согласованности потока сделок.
Хранилище отвечает исключительно за жизненный цикл объектов
TradeStreamState и не содержит логики проверки последовательности
или восстановления потока сделок.
"""
from src.market_data.acquisition.consistency.trade_stream_state import (
TradeStreamState,
)
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
TradeStreamStateNotFoundError,
TradeStreamStateStoreInitializationError,
)
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
TradeStreamStateStoreProtocol,
)
class TradeStreamStateStore(TradeStreamStateStoreProtocol):
"""
Хранилище состояний Trade Stream.
Для каждого торгового инструмента существует
единственный экземпляр TradeStreamState.
"""
def __init__(self) -> None:
"""
Создаёт пустое хранилище состояний.
"""
self._states: dict[str, TradeStreamState] = {}
self._initialized = False
def initialize(
self,
states: tuple[TradeStreamState, ...],
) -> None:
"""Однократно опубликовать полностью подготовленный набор."""
if not isinstance(states, tuple):
raise TypeError("states must be a tuple")
if self._initialized or self._states:
raise TradeStreamStateStoreInitializationError(
"Trade Stream State Store уже содержит состояния."
)
prepared: dict[str, TradeStreamState] = {}
for state in states:
if not isinstance(state, TradeStreamState):
raise TypeError(
"states must contain only TradeStreamState"
)
if state.symbol in prepared:
raise TradeStreamStateStoreInitializationError(
f"Состояние для символа {state.symbol!r} "
"передано повторно."
)
prepared[state.symbol] = state
self._states.update(prepared)
self._initialized = True
def is_empty(self) -> bool:
"""Проверить, что хранилище не содержит состояний."""
return not self._states and not self._initialized
def get_or_create(
self,
symbol: str,
) -> TradeStreamState:
"""
Возвращает существующее состояние либо создаёт новое.
Args:
symbol:
Идентификатор торгового инструмента.
Returns:
Экземпляр TradeStreamState.
"""
state = self._states.get(symbol)
if state is None:
state = TradeStreamState(symbol=symbol)
self._states[symbol] = state
return state
def get(
self,
symbol: str,
) -> TradeStreamState:
"""
Возвращает существующее состояние.
Args:
symbol:
Идентификатор торгового инструмента.
Raises:
TradeStreamStateNotFoundError:
Если состояние отсутствует.
"""
try:
return self._states[symbol]
except KeyError as error:
raise TradeStreamStateNotFoundError(
f"Состояние Trade Stream для символа {symbol!r} не найдено."
) from error
def contains(
self,
symbol: str,
) -> bool:
"""
Проверяет наличие состояния.
Args:
symbol:
Идентификатор торгового инструмента.
Returns:
True, если состояние существует.
"""
return symbol in self._states
def remove(
self,
symbol: str,
) -> None:
"""
Удаляет состояние.
Args:
symbol:
Идентификатор торгового инструмента.
Raises:
TradeStreamStateNotFoundError:
Если состояние отсутствует.
"""
if symbol not in self._states:
raise TradeStreamStateNotFoundError(
f"Состояние Trade Stream для символа "
f"{symbol!r} не найдено."
)
del self._states[symbol]
def clear(self) -> None:
"""
Полностью очищает хранилище состояний.
"""
self._states.clear()
self._initialized = False

View File

@@ -0,0 +1,34 @@
# app/src/market_data/acquisition/consistency/trade_stream_state_store_exceptions.py
from __future__ import annotations
"""
Исключения хранилища состояний Trade Stream Consistency.
Build 060.20.1 вводит специализированное хранилище
состояния проверки согласованности потока сделок.
Исключения относятся исключительно к управлению
TradeStreamState и не описывают ошибки проверки
последовательности сделок.
"""
class TradeStreamStateStoreError(Exception):
"""
Базовое исключение хранилища состояний Trade Stream.
"""
class TradeStreamStateNotFoundError(
TradeStreamStateStoreError,
):
"""
Состояние торгового инструмента отсутствует.
"""
class TradeStreamStateStoreInitializationError(
TradeStreamStateStoreError,
):
"""Хранилище состояний нельзя безопасно инициализировать."""

View File

@@ -0,0 +1,118 @@
# app/src/market_data/acquisition/consistency/trade_stream_state_store_protocol.py
from __future__ import annotations
"""
Протокол хранилища состояния Trade Stream Consistency.
Build 060.20.1 заменяет универсальный Trade Runtime Registry
специализированным типизированным хранилищем состояния
Canonical Trade Stream.
TradeStreamStateStoreProtocol определяет минимальный контракт
доступа к состоянию проверки согласованности потока сделок.
Протокол не описывает внутреннюю реализацию хранения
и не содержит бизнес-логики Trade Stream Consistency.
"""
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.consistency.trade_stream_state import (
TradeStreamState,
)
@runtime_checkable
class TradeStreamStateStoreProtocol(Protocol):
"""
Протокол хранилища состояний Trade Stream Consistency.
"""
def initialize(
self,
states: tuple[TradeStreamState, ...],
) -> None:
"""Однократно и атомарно опубликовать готовые состояния."""
...
def is_empty(self) -> bool:
"""Проверить, что ни одно состояние ещё не опубликовано."""
...
def get_or_create(
self,
symbol: str,
) -> TradeStreamState:
"""
Возвращает существующее состояние торгового инструмента
либо создаёт новое состояние при первом обращении.
Args:
symbol:
Идентификатор торгового инструмента.
Returns:
Существующий либо созданный TradeStreamState.
"""
...
def get(
self,
symbol: str,
) -> TradeStreamState:
"""
Возвращает существующее состояние торгового инструмента.
Args:
symbol:
Идентификатор торгового инструмента.
Returns:
Зарегистрированный TradeStreamState.
Raises:
TradeStreamStateNotFoundError:
Если состояние торгового инструмента отсутствует.
"""
...
def contains(
self,
symbol: str,
) -> bool:
"""
Проверяет наличие состояния торгового инструмента.
Args:
symbol:
Идентификатор торгового инструмента.
Returns:
True, если состояние существует,
иначе False.
"""
...
def remove(
self,
symbol: str,
) -> None:
"""
Удаляет состояние торгового инструмента.
Args:
symbol:
Идентификатор торгового инструмента.
Raises:
TradeStreamStateNotFoundError:
Если состояние торгового инструмента отсутствует.
"""
...
def clear(self) -> None:
"""
Полностью очищает хранилище состояний.
"""
...

View File

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

@@ -0,0 +1,53 @@
# app/src/market_data/acquisition/feeds/candles_feed.py
from __future__ import annotations
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.protocol import (
CandlesDocumentHandler,
CandlesDocumentSource,
)
class CandlesFeed:
"""
Read-only Feed канонических рыночных свечей.
Feed координирует получение и обработку документа, но не выполняет
transport parsing, value validation, mapping или кэширование.
"""
def __init__(
self,
*,
source: CandlesDocumentSource,
handler: CandlesDocumentHandler,
) -> None:
self._source = source
self._handler = handler
def load_candles(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
"""
Получить канонический immutable-набор свечей инструмента.
"""
document = self._source.fetch_candles_document(
symbol,
interval=interval,
limit=limit,
price_type=price_type,
)
return self._handler.handle_candles_document(
document,
symbol=symbol,
interval=interval,
source=f"rest_klines:{price_type}",
)

View File

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

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

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

View File

@@ -0,0 +1,35 @@
# app/src/market_data/acquisition/feeds/trades_feed.py
from __future__ import annotations
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.protocol import (
TradeDocumentHandler,
TradeDocumentSource,
)
# Источник готовой внутренней модели Trade.
class TradesFeed:
def __init__(
self,
source: TradeDocumentSource,
handler: TradeDocumentHandler,
) -> None:
self._source = source
self._handler = handler
def load_trade(
self,
symbol: str,
) -> Trade:
"""
Получить последнюю сделку инструмента.
Feed получает сырой документ у источника и делегирует
его преобразование специализированному обработчику.
"""
document = self._source.fetch_trade_document(symbol)
return self._handler.handle_trade_document(document)

View File

@@ -0,0 +1,54 @@
# app/src/market_data/acquisition/handlers/candles_handler.py
from __future__ import annotations
from src.market_data.acquisition.adapters.dzengi.mapper import (
map_dzengi_klines_to_candles,
)
from src.market_data.acquisition.adapters.dzengi.parser import (
parse_candles,
)
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.validation.schema import (
validate_candles_schema,
)
from src.market_data.acquisition.validation.values import (
validate_candles_values,
)
# Обработчик документа Candles Feed формата Dzengi /api/v1/klines.
class DzengiCandlesDocumentHandler:
def handle_candles_document(
self,
document: object,
*,
symbol: str,
interval: str,
source: str,
) -> tuple[Candle, ...]:
"""
Преобразовать сырой документ Dzengi в канонические модели Candle.
Порядок обработки:
schema validation
parsing
value validation
mapping
"""
validated_document = validate_candles_schema(document)
response = parse_candles(validated_document)
validate_candles_values(response)
return map_dzengi_klines_to_candles(
response,
symbol=symbol,
interval=interval,
source=source,
)

View File

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

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

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

View File

@@ -0,0 +1,24 @@
# app/src/market_data/acquisition/handlers/trades_handler.py
# app/src/market_data/acquisition/handlers/trades_handler.py
from __future__ import annotations
from src.market_data.acquisition.adapters.dzengi import (
adapt_websocket_trade_document,
)
from src.market_data.acquisition.models.trade import Trade
from src.market_data.acquisition.validation.schema import (
validate_dzengi_websocket_trade_schema,
)
# Обработчик документа Trades Feed формата Dzengi WebSocket internal.trade.
class DzengiTradeDocumentHandler:
def handle_trade_document(
self,
document: object,
) -> Trade:
validated_document = validate_dzengi_websocket_trade_schema(document)
return adapt_websocket_trade_document(validated_document)

View File

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

View File

@@ -0,0 +1,24 @@
# app/src/market_data/acquisition/models/candle.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
# Каноническая неизменяемая модель одной рыночной свечи OHLCV.
@dataclass(frozen=True, slots=True)
class Candle:
symbol: str
interval: str
open_time: datetime
open_price: Decimal
high_price: Decimal
low_price: Decimal
close_price: Decimal
volume: Decimal
source: str

View File

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

View File

@@ -0,0 +1,38 @@
# app/src/market_data/acquisition/models/instrument.py
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
# Независимое от источника справочное описание торгового инструмента.
@dataclass(frozen=True, slots=True)
class Instrument:
symbol: str
name: str
status: str
base_asset: str
quote_asset: str
asset_type: str | None
market_type: str
market_modes: tuple[str, ...]
order_types: tuple[str, ...]
base_asset_precision: int | None
quote_asset_precision: int | None
tick_size: Decimal | None
tick_value: Decimal | None
step_size: Decimal | None
min_qty: Decimal | None
max_qty: Decimal | None
min_notional: Decimal | None
country: str | None
sector: str | None
industry: str | None
trading_hours: str | None

View File

@@ -0,0 +1,22 @@
# app/src/market_data/acquisition/models/quote.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
# Независимый от источника снимок текущей рыночной котировки инструмента.
@dataclass(frozen=True, slots=True)
class Quote:
symbol: str
last_price: Decimal
bid_price: Decimal
ask_price: Decimal
exchange_timestamp: datetime | None
received_at: datetime
source: str

View File

@@ -0,0 +1,88 @@
# app/src/market_data/acquisition/models/status.py
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
# Каноническое состояние торговой доступности инструмента.
class InstrumentTradingState(StrEnum):
OPEN = "OPEN"
BREAK = "BREAK"
NOT_TRADABLE = "NOT_TRADABLE"
UNKNOWN = "UNKNOWN"
# Результат классификации сырого статуса инструмента.
@dataclass(frozen=True, slots=True)
class InstrumentStatusClassification:
state: InstrumentTradingState
normalized_status: str | None
_OPEN_STATUSES = frozenset(
{
"TRADING",
"OPEN",
"ACTIVE",
"ENABLED",
"ONLINE",
}
)
_NOT_TRADABLE_STATUSES = frozenset(
{
"NOT_TRADABLE",
"TRADING_DISABLED",
"MARKET_DISABLED",
"UNAVAILABLE_FOR_TRADING",
"CLOSE_ONLY",
"REDUCE_ONLY",
"VIEW_ONLY",
}
)
_BREAK_STATUSES = frozenset(
{
"BREAK",
"CLOSED",
"HALT",
"HALTED",
"PAUSED",
"SUSPENDED",
"DISABLED",
"SETTLING",
"POST_ONLY",
}
)
# Классифицировать сырой статус торгового инструмента.
def classify_instrument_status(
raw_status: str | None,
) -> InstrumentStatusClassification:
normalized_status = str(raw_status or "").strip().upper()
if normalized_status in _OPEN_STATUSES:
return InstrumentStatusClassification(
state=InstrumentTradingState.OPEN,
normalized_status=normalized_status,
)
if normalized_status in _NOT_TRADABLE_STATUSES:
return InstrumentStatusClassification(
state=InstrumentTradingState.NOT_TRADABLE,
normalized_status=normalized_status,
)
if normalized_status in _BREAK_STATUSES:
return InstrumentStatusClassification(
state=InstrumentTradingState.BREAK,
normalized_status=normalized_status,
)
return InstrumentStatusClassification(
state=InstrumentTradingState.UNKNOWN,
normalized_status=normalized_status or None,
)

View File

@@ -0,0 +1,31 @@
# app/src/market_data/acquisition/models/trade.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum
# Сторона агрессора, инициировавшего исполнение биржевой сделки.
class TradeAggressorSide(Enum):
BUY = "buy"
SELL = "sell"
# Каноническая неизменяемая модель одной исполненной биржевой сделки.
@dataclass(frozen=True, slots=True)
class Trade:
symbol: str
trade_id: int
price: Decimal
quantity: Decimal
executed_at: datetime
aggressor_side: TradeAggressorSide
source: str

View File

@@ -0,0 +1,185 @@
# app/src/market_data/acquisition/protocol.py
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.market_data.acquisition.models.candle import Candle
from src.market_data.acquisition.models.instrument import Instrument
from src.market_data.acquisition.models.quote import Quote
from src.market_data.acquisition.models.trade import Trade
# Источник сырого документа Instrument Reference Data.
@runtime_checkable
class InstrumentDocumentSource(Protocol):
def fetch_instrument_document(self) -> object:
"""
Получить декодированный транспортный документ Instrument Reference Data.
Источник не выполняет schema validation, parsing, value validation
или mapping во внутреннюю модель Instrument.
"""
...
# Обработчик сырого документа Instrument Reference Data.
@runtime_checkable
class InstrumentDocumentHandler(Protocol):
def handle_instrument_document(
self,
document: object,
) -> tuple[Instrument, ...]:
"""
Преобразовать сырой документ в проверенные внутренние модели Instrument.
"""
...
# Источник готового справочника инструментов для Acquisition Service.
@runtime_checkable
class InstrumentFeedProtocol(Protocol):
def load_instruments(self) -> tuple[Instrument, ...]:
"""
Получить полный immutable-набор внутренних моделей Instrument.
"""
...
# Источник сырого документа Quotes Feed.
@runtime_checkable
class QuoteDocumentSource(Protocol):
def fetch_quote_document(
self,
symbol: str,
) -> object:
"""
Получить декодированный транспортный документ текущей котировки.
Источник не выполняет schema validation, parsing, value validation
или mapping во внутреннюю модель Quote.
"""
...
# Обработчик сырого документа Quotes Feed.
@runtime_checkable
class QuoteDocumentHandler(Protocol):
def handle_quote_document(
self,
document: object,
) -> Quote:
"""
Преобразовать сырой документ в проверенную внутреннюю модель Quote.
"""
...
# Источник готовой текущей котировки для Acquisition Service.
@runtime_checkable
class QuoteFeedProtocol(Protocol):
def load_quote(
self,
symbol: str,
) -> Quote:
"""
Получить внутреннюю модель текущей котировки инструмента.
"""
...
# Источник сырого документа Trades Feed.
@runtime_checkable
class TradeDocumentSource(Protocol):
def fetch_trade_document(
self,
symbol: str,
) -> object:
"""
Получить декодированный транспортный документ сделок.
Источник не выполняет schema validation, parsing,
value validation или mapping во внутреннюю модель Trade.
"""
...
# Обработчик сырого документа Trades Feed.
@runtime_checkable
class TradeDocumentHandler(Protocol):
def handle_trade_document(
self,
document: object,
) -> Trade:
"""
Преобразовать сырой документ
в проверенную внутреннюю модель Trade.
"""
...
# Источник готовой модели Trade.
@runtime_checkable
class TradesFeedProtocol(Protocol):
def load_trade(
self,
symbol: str,
) -> Trade:
"""
Получить внутреннюю модель последней сделки инструмента.
"""
...
# Источник сырого документа Candles Feed.
@runtime_checkable
class CandlesDocumentSource(Protocol):
def fetch_candles_document(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> object:
"""
Получить декодированный транспортный документ рыночных свечей.
Источник не выполняет schema validation, parsing, value validation
или mapping во внутреннюю модель Candle.
"""
...
# Обработчик сырого документа Candles Feed.
@runtime_checkable
class CandlesDocumentHandler(Protocol):
def handle_candles_document(
self,
document: object,
*,
symbol: str,
interval: str,
source: str,
) -> tuple[Candle, ...]:
"""
Преобразовать сырой документ в проверенный immutable-набор Candle.
"""
...
# Источник готового набора свечей.
@runtime_checkable
class CandlesFeedProtocol(Protocol):
def load_candles(
self,
symbol: str,
*,
interval: str,
limit: int,
price_type: str,
) -> tuple[Candle, ...]:
"""
Получить immutable-набор внутренних моделей Candle.
"""
...

View File

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

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