Compare commits
2 Commits
main
...
295bf176e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 295bf176e5 | |||
| 8e696ef582 |
@@ -1,30 +0,0 @@
|
|||||||
# В build context разрешены только production source и requirements.
|
|
||||||
**
|
|
||||||
!.dockerignore
|
|
||||||
!infra/
|
|
||||||
!infra/docker/
|
|
||||||
!infra/docker/Dockerfile
|
|
||||||
!infra/docker/postgres/
|
|
||||||
!infra/docker/postgres/Dockerfile
|
|
||||||
!infra/docker/postgres/healthcheck-application-role.sh
|
|
||||||
!infra/docker/postgres/init-application-role.sh
|
|
||||||
!app/
|
|
||||||
!app/requirements.txt
|
|
||||||
!app/requirements.lock
|
|
||||||
!app/src/
|
|
||||||
!app/src/**
|
|
||||||
|
|
||||||
# Локальные артефакты запрещены даже внутри разрешённого source tree.
|
|
||||||
app/src/**/__pycache__/
|
|
||||||
app/src/**/*.py[cod]
|
|
||||||
app/src/**/.DS_Store
|
|
||||||
app/src/**/.env
|
|
||||||
app/src/**/.env.*
|
|
||||||
app/src/**/*.pem
|
|
||||||
app/src/**/*.key
|
|
||||||
app/src/**/*.p12
|
|
||||||
app/src/**/*.pfx
|
|
||||||
app/src/**/*.log
|
|
||||||
app/src/**/*.dump
|
|
||||||
app/src/**/*.sqlite
|
|
||||||
app/src/**/*.sqlite3
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
name: CI Required
|
|
||||||
|
|
||||||
# До отдельной приёмки canary workflow запускается только вручную.
|
|
||||||
on:
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
env:
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
|
||||||
PIP_NO_CACHE_DIR: "1"
|
|
||||||
PIP_NO_INPUT: "1"
|
|
||||||
PYTHONDONTWRITEBYTECODE: "1"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality:
|
|
||||||
name: quality
|
|
||||||
runs-on: dzentra-python312
|
|
||||||
steps:
|
|
||||||
- name: Checkout закреплённой revision
|
|
||||||
uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Проверка контракта runner
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(uname -s)" = "Linux"
|
|
||||||
command -v bash
|
|
||||||
command -v git
|
|
||||||
command -v grep
|
|
||||||
command -v node
|
|
||||||
command -v python3.12
|
|
||||||
command -v timeout
|
|
||||||
test "$(python3.12 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
|
|
||||||
case "$(node --version)" in
|
|
||||||
v20.*) ;;
|
|
||||||
*) printf 'Требуется Node.js 20.\n' >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
timeout --version | grep -q 'GNU coreutils'
|
|
||||||
python3.12 -c 'import socket; sock = socket.socket(); sock.bind(("127.0.0.1", 0)); sock.close()'
|
|
||||||
- name: Установка hash-locked environment
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
timeout --signal=TERM --kill-after=30s 2m \
|
|
||||||
python3.12 -m venv app/.venv
|
|
||||||
timeout --signal=TERM --kill-after=30s 10m \
|
|
||||||
app/.venv/bin/python -m pip install \
|
|
||||||
--require-hashes \
|
|
||||||
-r app/requirements-dev.lock
|
|
||||||
app/.venv/bin/python -m pip check
|
|
||||||
- name: Quality gate
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
timeout --signal=TERM --kill-after=30s 10m \
|
|
||||||
./scripts/run_ci_gate.sh quality
|
|
||||||
- name: Проверка отсутствия артефактов
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
changes="$(git status --short --untracked-files=all)"
|
|
||||||
if [[ -n "${changes}" ]]; then
|
|
||||||
printf '%s\n' "${changes}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
offline-regression:
|
|
||||||
name: offline-regression
|
|
||||||
runs-on: dzentra-python312
|
|
||||||
steps:
|
|
||||||
- name: Checkout закреплённой revision
|
|
||||||
uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Проверка контракта runner
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(uname -s)" = "Linux"
|
|
||||||
command -v bash
|
|
||||||
command -v git
|
|
||||||
command -v grep
|
|
||||||
command -v node
|
|
||||||
command -v python3.12
|
|
||||||
command -v timeout
|
|
||||||
test "$(python3.12 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
|
|
||||||
case "$(node --version)" in
|
|
||||||
v20.*) ;;
|
|
||||||
*) printf 'Требуется Node.js 20.\n' >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
timeout --version | grep -q 'GNU coreutils'
|
|
||||||
python3.12 -c 'import socket; sock = socket.socket(); sock.bind(("127.0.0.1", 0)); sock.close()'
|
|
||||||
- name: Установка hash-locked environment
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
timeout --signal=TERM --kill-after=30s 2m \
|
|
||||||
python3.12 -m venv app/.venv
|
|
||||||
timeout --signal=TERM --kill-after=30s 10m \
|
|
||||||
app/.venv/bin/python -m pip install \
|
|
||||||
--require-hashes \
|
|
||||||
-r app/requirements-dev.lock
|
|
||||||
app/.venv/bin/python -m pip check
|
|
||||||
- name: Offline regression
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
timeout --signal=TERM --kill-after=30s 20m \
|
|
||||||
./scripts/run_ci_gate.sh offline-regression
|
|
||||||
- name: Проверка отсутствия артефактов
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
changes="$(git status --short --untracked-files=all)"
|
|
||||||
if [[ -n "${changes}" ]]; then
|
|
||||||
printf '%s\n' "${changes}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
loopback-integration:
|
|
||||||
name: loopback-integration
|
|
||||||
runs-on: dzentra-python312
|
|
||||||
steps:
|
|
||||||
- name: Checkout закреплённой revision
|
|
||||||
uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Проверка контракта runner
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
test "$(uname -s)" = "Linux"
|
|
||||||
command -v bash
|
|
||||||
command -v git
|
|
||||||
command -v grep
|
|
||||||
command -v node
|
|
||||||
command -v python3.12
|
|
||||||
command -v timeout
|
|
||||||
test "$(python3.12 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
|
|
||||||
case "$(node --version)" in
|
|
||||||
v20.*) ;;
|
|
||||||
*) printf 'Требуется Node.js 20.\n' >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
timeout --version | grep -q 'GNU coreutils'
|
|
||||||
python3.12 -c 'import socket; sock = socket.socket(); sock.bind(("127.0.0.1", 0)); sock.close()'
|
|
||||||
- name: Установка hash-locked environment
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
timeout --signal=TERM --kill-after=30s 2m \
|
|
||||||
python3.12 -m venv app/.venv
|
|
||||||
timeout --signal=TERM --kill-after=30s 10m \
|
|
||||||
app/.venv/bin/python -m pip install \
|
|
||||||
--require-hashes \
|
|
||||||
-r app/requirements-dev.lock
|
|
||||||
app/.venv/bin/python -m pip check
|
|
||||||
- name: Loopback integration
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
timeout --signal=TERM --kill-after=30s 10m \
|
|
||||||
./scripts/run_ci_gate.sh loopback-integration
|
|
||||||
- name: Проверка отсутствия артефактов
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
changes="$(git status --short --untracked-files=all)"
|
|
||||||
if [[ -n "${changes}" ]]; then
|
|
||||||
printf '%s\n' "${changes}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,6 +1,4 @@
|
|||||||
{
|
{
|
||||||
"python.defaultInterpreterPath": "app/.venv/bin/python",
|
"python.defaultInterpreterPath": "app/.venv/bin/python",
|
||||||
"python-envs.defaultEnvManager": "ms-python.python:system",
|
"python-envs.defaultEnvManager": "ms-python.python:system"
|
||||||
"python.analysis.typeCheckingMode": "standard",
|
|
||||||
"python.analysis.diagnosticMode": "workspace"
|
|
||||||
}
|
}
|
||||||
|
|||||||
109
README.md
109
README.md
@@ -1,96 +1,33 @@
|
|||||||
# Dzentra
|
# dzentra_bot
|
||||||
|
|
||||||
Dzentra — модульный монолит автоматической торговой системы с
|
Telegram-бот для автоторговли криптовалютой.
|
||||||
Telegram-интерфейсом. В текущем принятом Production scope полностью
|
|
||||||
собрана вертикаль Trades Feed: получение сделок, проверка
|
|
||||||
последовательности, reconnect/recovery, опциональное сохранение в
|
|
||||||
PostgreSQL и восстановление после перезапуска.
|
|
||||||
|
|
||||||
## Текущий scope
|
## Bootstrap v2
|
||||||
|
Это стабильный стартовый каркас проекта с:
|
||||||
|
- чистой структурой каталогов
|
||||||
|
- компактным верхним меню
|
||||||
|
- базовыми handlers
|
||||||
|
- документацией
|
||||||
|
- Docker-файлами
|
||||||
|
- опциональным созданием `.venv`, установкой зависимостей и первым git commit
|
||||||
|
|
||||||
- Production ingestion, Consistency, Recovery, persistent checkpoint и
|
## Верхнее меню
|
||||||
Startup Recovery реализованы для Trades.
|
- 🏠 Главная
|
||||||
- Historical Access и deterministic Replay доступны как отдельные API и
|
- 📈 Рынок
|
||||||
не запускаются автоматически.
|
- 💼 Портфель
|
||||||
- REST-потоки Quotes и Candles продолжают использоваться Trading/UI.
|
- ⚡ Торговля
|
||||||
Новые WebSocket Runtime, Consistency/Recovery и persistent writer
|
- 🤖 Авто
|
||||||
wiring для них пока не подключены.
|
- 📒 Журнал
|
||||||
- Retention и создание месячных партиций выполняются только явным вызовом.
|
- ⚙️ Система
|
||||||
- Обычный запуск приложения требует PostgreSQL для журнала и balance
|
|
||||||
snapshots, даже если Trade Stream и Market Data Storage выключены.
|
|
||||||
|
|
||||||
Подробное фактическое состояние описано в
|
|
||||||
[архитектуре Trades Feed](docs/architecture/trades_feed.md).
|
|
||||||
|
|
||||||
## Быстрый старт существующего checkout
|
|
||||||
|
|
||||||
Требуются Python 3.12, доступный PostgreSQL и Telegram Bot Token. Команды
|
|
||||||
выполняются из корня репозитория:
|
|
||||||
|
|
||||||
|
## Рекомендуемый запуск
|
||||||
```bash
|
```bash
|
||||||
python3.12 -m venv app/.venv
|
/opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install
|
||||||
app/.venv/bin/python -m pip install \
|
|
||||||
--require-hashes \
|
|
||||||
-r app/requirements-dev.lock
|
|
||||||
app/.venv/bin/python -m pip check
|
|
||||||
test -e app/.env || cp app/.env.example app/.env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Заполните созданный `app/.env`, не добавляйте его в Git и не
|
## После bootstrap
|
||||||
перезаписывайте уже существующий файл. Затем запустите приложение:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app
|
cd app
|
||||||
.venv/bin/python -m src.main
|
source .venv/bin/activate
|
||||||
|
python -m src.main
|
||||||
```
|
```
|
||||||
|
|
||||||
Настройки режимов, PostgreSQL и безопасный Docker-запуск приведены в
|
|
||||||
[эксплуатационном руководстве](docs/operations/trades_feed_runtime.md).
|
|
||||||
Более подробная инструкция для разработчика находится в
|
|
||||||
[app/README.md](app/README.md).
|
|
||||||
|
|
||||||
## Обязательные проверки
|
|
||||||
|
|
||||||
Из корня репозитория:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/check_python_types.sh
|
|
||||||
app/.venv/bin/python scripts/check_documentation_integrity.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаемый результат Pyright — `0 errors, 0 warnings`, а documentation
|
|
||||||
gate — `issues=0`. Обычная offline-регрессия запускается из `app`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd app
|
|
||||||
.venv/bin/python -m pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Default pytest-набор исключает opt-in группы `integration`, `stress` и
|
|
||||||
`live`, но включает статическую проверку Pyright. Целевой тест не
|
|
||||||
заменяет полный type gate.
|
|
||||||
|
|
||||||
Локальный эквивалент первого CI gate запускает quality, offline regression
|
|
||||||
и безопасную loopback integration-проверку:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/run_ci_gate.sh all
|
|
||||||
```
|
|
||||||
|
|
||||||
Workflow пока запускается только вручную. Его границы, runner contract и
|
|
||||||
критерии зелёного canary описаны в [CI runbook](docs/operations/ci_gate.md).
|
|
||||||
|
|
||||||
## Навигация
|
|
||||||
|
|
||||||
- [Обзор архитектуры](docs/architecture/overview.md)
|
|
||||||
- [Структура проекта](docs/architecture/project_structure.md)
|
|
||||||
- [Текущая архитектура Trades Feed](docs/architecture/trades_feed.md)
|
|
||||||
- [Эксплуатация Trade Stream Runtime](docs/operations/trades_feed_runtime.md)
|
|
||||||
- [CI gate и первый canary](docs/operations/ci_gate.md)
|
|
||||||
- [Архитектура Build 060.30](docs/migrations/build_060_30_architecture.md)
|
|
||||||
|
|
||||||
## Исторический bootstrap
|
|
||||||
|
|
||||||
`bootstrap_project.py` — исторический генератор первоначального каркаса.
|
|
||||||
Для установки или запуска существующего checkout его использовать не
|
|
||||||
нужно.
|
|
||||||
|
|||||||
@@ -1,73 +1,20 @@
|
|||||||
# Настройки приложения. Файл является примером: не храните здесь
|
|
||||||
# реальные секреты. Для каждого секрета задайте либо прямое значение,
|
|
||||||
# либо *_FILE, но не оба. *_FILE должен указывать на доступный для чтения
|
|
||||||
# UTF-8 файл с одним секретом.
|
|
||||||
|
|
||||||
# Telegram и приложение
|
|
||||||
BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE
|
BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE
|
||||||
# BOT_TOKEN_FILE=/run/secrets/bot_token
|
|
||||||
BOT_PARSE_MODE=HTML
|
BOT_PARSE_MODE=HTML
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
TZ=Europe/Minsk
|
TZ=Europe/Minsk
|
||||||
DEBUG_ENABLED=false
|
|
||||||
JOURNAL_DEBUG_ENABLED=false
|
|
||||||
# Только для контейнера с отдельным runtime-файлом, доступным для записи:
|
|
||||||
# DZENTRA_RUNTIME_ENV_FILE=/var/lib/dzentra/runtime.env
|
|
||||||
|
|
||||||
# PostgreSQL
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=dzentra_bot
|
DB_NAME=dzentra_bot
|
||||||
DB_USER=dzentra_bot
|
DB_USER=dzentra_bot
|
||||||
DB_PASSWORD=PUT_YOUR_DATABASE_PASSWORD_HERE
|
DB_PASSWORD=change_me
|
||||||
# DB_PASSWORD_FILE=/run/secrets/db_password
|
|
||||||
|
|
||||||
# Постоянное хранилище Market Data
|
EXCHANGE_ENABLED=true
|
||||||
MARKET_DATA_STORAGE_ENABLED=false
|
|
||||||
MARKET_DATA_STORAGE_POOL_MIN_SIZE=1
|
|
||||||
MARKET_DATA_STORAGE_POOL_MAX_SIZE=4
|
|
||||||
MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS=10
|
|
||||||
|
|
||||||
# Биржа. Безопасный пример совпадает с выключенными значениями по умолчанию.
|
|
||||||
EXCHANGE_ENABLED=false
|
|
||||||
EXCHANGE_NAME=dzengi
|
EXCHANGE_NAME=dzengi
|
||||||
EXCHANGE_BASE_URL=
|
EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com
|
||||||
EXCHANGE_WS_URL=
|
|
||||||
EXCHANGE_API_KEY=
|
EXCHANGE_API_KEY=
|
||||||
# EXCHANGE_API_KEY_FILE=/run/secrets/exchange_api_key
|
|
||||||
EXCHANGE_API_SECRET=
|
EXCHANGE_API_SECRET=
|
||||||
# EXCHANGE_API_SECRET_FILE=/run/secrets/exchange_api_secret
|
|
||||||
EXCHANGE_TIMEOUT_SEC=10
|
EXCHANGE_TIMEOUT_SEC=10
|
||||||
EXCHANGE_TESTNET=false
|
EXCHANGE_TESTNET=true
|
||||||
DEFAULT_SYMBOL=ETH/USD_LEVERAGE
|
DEFAULT_SYMBOL=BTC/USD_LEVERAGE
|
||||||
|
|
||||||
# Поток сделок Production Trade Stream
|
|
||||||
TRADE_STREAM_ENABLED=false
|
|
||||||
TRADE_STREAM_WS_URL=
|
|
||||||
TRADE_STREAM_SYMBOLS=
|
|
||||||
TRADE_STREAM_OPEN_TIMEOUT_SECONDS=10
|
|
||||||
TRADE_STREAM_PROBE_TIMEOUT_SECONDS=20
|
|
||||||
TRADE_STREAM_CLOSE_TIMEOUT_SECONDS=10
|
|
||||||
TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS=30
|
|
||||||
TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS=5
|
|
||||||
TRADE_STREAM_RECOVERY_WINDOW_MS=3599999
|
|
||||||
TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS=10
|
|
||||||
TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY=10000
|
|
||||||
|
|
||||||
# Управляющие переменные Docker Compose передаются самому Compose,
|
|
||||||
# а не сохраняются в app/.env. Пути должны вести к защищённым файлам
|
|
||||||
# вне repository; BOT_STOP_GRACE_PERIOD задаётся после расчёта бюджета.
|
|
||||||
# Каталог файлов с секретами: mode 0700; сами файлы: mode 0444.
|
|
||||||
# DZENTRA_COMPOSE_PROJECT_NAME=dzentra-production
|
|
||||||
# DZENTRA_BOT_IMAGE=dzentra-bot:local
|
|
||||||
# DZENTRA_POSTGRES_IMAGE=dzentra-postgres:16.14-hardened
|
|
||||||
# BOT_TOKEN_SECRET_FILE=/absolute/protected/dzentra/bot_token
|
|
||||||
# POSTGRES_ADMIN_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/postgres_admin_password
|
|
||||||
# DB_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/db_password
|
|
||||||
# POSTGRES_ADMIN_USER=dzentra_admin
|
|
||||||
# BOT_STOP_GRACE_PERIOD=30m
|
|
||||||
# POSTGRES_STOP_GRACE_PERIOD=60s
|
|
||||||
# Только с infra/compose/docker-compose.exchange-auth.yml:
|
|
||||||
# EXCHANGE_API_KEY_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_key
|
|
||||||
# EXCHANGE_API_SECRET_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_secret
|
|
||||||
133
app/README.md
133
app/README.md
@@ -1,132 +1,3 @@
|
|||||||
# Приложение Dzentra
|
# app
|
||||||
|
|
||||||
Каталог `app` содержит исполняемое Python-приложение, зависимости,
|
Здесь находятся исходный код приложения, env-файлы и зависимости.
|
||||||
настройки и тесты Dzentra. Приложение объединяет Telegram-бота и
|
|
||||||
принятую Production-вертикаль Trades Feed.
|
|
||||||
|
|
||||||
## Реализованные границы
|
|
||||||
|
|
||||||
| Возможность | Текущее состояние |
|
|
||||||
|---|---|
|
|
||||||
| Live Trades, reconnect и REST Recovery | Подключены к Production Runtime |
|
|
||||||
| Persistent Trades и checkpoint | Опционально подключены через `MARKET_DATA_STORAGE_ENABLED` |
|
|
||||||
| Startup Hydration и Recovery | Выполняются при включённом Storage |
|
|
||||||
| Historical Access и Replay | Готовы для явного вызова; автоматически не запускаются |
|
|
||||||
| Quote/Candle REST feeds | Используются существующими Trading/UI сценариями |
|
|
||||||
| Quote/Candle WebSocket и persistence | Runtime/Consistency/Recovery и persistent writers не подключены |
|
|
||||||
| Retention и месячные партиции | Только явный вызов; Scheduler их не запускает |
|
|
||||||
|
|
||||||
Подробности приведены в
|
|
||||||
[текущей архитектуре Trades Feed](../docs/architecture/trades_feed.md).
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
- Python 3.12;
|
|
||||||
- PostgreSQL, доступный по параметрам `DB_*`;
|
|
||||||
- Telegram Bot Token;
|
|
||||||
- отдельное виртуальное окружение `app/.venv`.
|
|
||||||
|
|
||||||
PostgreSQL нужен даже при выключенных Trade Stream и Market Data Storage:
|
|
||||||
обычный Bootstrap всегда инициализирует таблицы журнала и balance
|
|
||||||
snapshots.
|
|
||||||
|
|
||||||
## Подготовка окружения
|
|
||||||
|
|
||||||
Из корня репозитория:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3.12 -m venv app/.venv
|
|
||||||
app/.venv/bin/python -m pip install \
|
|
||||||
--require-hashes \
|
|
||||||
-r app/requirements-dev.lock
|
|
||||||
app/.venv/bin/python -m pip check
|
|
||||||
test -e app/.env || cp app/.env.example app/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Замените значения-заглушки в `app/.env`. Не добавляйте этот файл в Git и
|
|
||||||
не перезаписывайте существующий `.env`. Для каждого секрета разрешён либо
|
|
||||||
прямой параметр, либо соответствующий `*_FILE`, но не оба одновременно.
|
|
||||||
|
|
||||||
`requirements-dev.txt` остаётся читаемым источником прямых developer
|
|
||||||
dependencies. Установка выполняется из `requirements-dev.lock`, который
|
|
||||||
сохраняет версии Production `requirements.lock` и добавляет pytest/Pyright.
|
|
||||||
Вручную редактировать dependency records обоих lock-файлов не следует.
|
|
||||||
|
|
||||||
## Режимы Trades Feed
|
|
||||||
|
|
||||||
| `TRADE_STREAM_ENABLED` | `MARKET_DATA_STORAGE_ENABLED` | Поведение |
|
|
||||||
|---:|---:|---|
|
|
||||||
| `false` | `false` | Trade Runtime и отдельный Market Data pool не создаются |
|
|
||||||
| `true` | `false` | Live/Recovery работают только с состоянием в памяти |
|
|
||||||
| `true` | `true` | Добавляются persistent Trades, checkpoint, Hydration и Startup Recovery |
|
|
||||||
| `false` | `true` | Недопустимая конфигурация; startup завершается ошибкой |
|
|
||||||
|
|
||||||
При включённом Trade Stream обязательны явные `TRADE_STREAM_WS_URL`,
|
|
||||||
`TRADE_STREAM_SYMBOLS` и `EXCHANGE_BASE_URL`. Полный контракт настроек и
|
|
||||||
Docker secrets находится в
|
|
||||||
[эксплуатационном руководстве](../docs/operations/trades_feed_runtime.md).
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
Из каталога `app`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.venv/bin/python -m src.main
|
|
||||||
```
|
|
||||||
|
|
||||||
Ошибка включённого Trade Runtime, Storage или Startup Recovery является
|
|
||||||
фатальной и завершает всё приложение, а не оставляет Telegram polling в
|
|
||||||
частично работающем состоянии.
|
|
||||||
|
|
||||||
## Проверки разработчика
|
|
||||||
|
|
||||||
Обязательная отдельная проверка типов запускается из корня репозитория:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/check_python_types.sh
|
|
||||||
app/.venv/bin/python scripts/check_documentation_integrity.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаемый результат — `0 errors, 0 warnings` для Pyright и `issues=0`
|
|
||||||
для documentation gate. Default offline regression запускается из `app`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.venv/bin/python -m pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Default-набор исключает маркеры `integration`, `stress` и `live`. При
|
|
||||||
этом он включает `tests/static/test_python_type_gate.py`, поэтому полный
|
|
||||||
запуск дополнительно контролирует Pyright. Один целевой тест этот gate не
|
|
||||||
заменяет.
|
|
||||||
|
|
||||||
Полная локальная CI-последовательность запускается из корня repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/run_ci_gate.sh all
|
|
||||||
```
|
|
||||||
|
|
||||||
Подробный manual-canary contract приведён в
|
|
||||||
[CI runbook](../docs/operations/ci_gate.md).
|
|
||||||
|
|
||||||
Назначение и безопасные команды opt-in наборов:
|
|
||||||
|
|
||||||
- `integration` — локальные сетевые и PostgreSQL-сценарии;
|
|
||||||
- `stress` — fixed stress и opt-in soak;
|
|
||||||
- `live` — только явно разрешённая проверка внешних Dzengi endpoints.
|
|
||||||
|
|
||||||
Параметры этих запусков не дублируются здесь и поддерживаются в
|
|
||||||
[runbook](../docs/operations/trades_feed_runtime.md).
|
|
||||||
|
|
||||||
## Структура и навигация
|
|
||||||
|
|
||||||
- `src/` — production-код;
|
|
||||||
- `tests/unit/` и `tests/static/` — default offline regression;
|
|
||||||
- `tests/integration/`, `tests/stress/`, `tests/live/` — opt-in проверки;
|
|
||||||
- `scripts/` и `tools/` — диагностические и исследовательские утилиты,
|
|
||||||
не automatic Production Runtime;
|
|
||||||
- [обзор структуры проекта](../docs/architecture/project_structure.md);
|
|
||||||
- [границы пакета Storage](src/storage/README.md);
|
|
||||||
- [архитектура Build 060.30](../docs/migrations/build_060_30_architecture.md).
|
|
||||||
|
|
||||||
Корневой `bootstrap_project.py` является историческим генератором
|
|
||||||
каркаса. Для текущего checkout его запускать не нужно.
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
minversion = 8.0
|
|
||||||
|
|
||||||
testpaths =
|
|
||||||
tests
|
|
||||||
|
|
||||||
python_files =
|
|
||||||
test_*.py
|
|
||||||
|
|
||||||
python_classes =
|
|
||||||
Test*
|
|
||||||
|
|
||||||
python_functions =
|
|
||||||
test_*
|
|
||||||
|
|
||||||
addopts =
|
|
||||||
-ra
|
|
||||||
--strict-markers
|
|
||||||
-m
|
|
||||||
"not integration and not stress and not live"
|
|
||||||
|
|
||||||
markers =
|
|
||||||
integration: deterministic integration tests using local network endpoints
|
|
||||||
stress: explicit local stress and soak verification
|
|
||||||
live: opt-in verification against explicitly configured live endpoints
|
|
||||||
@@ -1,929 +0,0 @@
|
|||||||
# Сгенерированный hash-lock developer/CI-зависимостей; вручную не редактировать.
|
|
||||||
# Источники: app/requirements-dev.txt и app/requirements.lock; Python 3.12.
|
|
||||||
# Команда регенерации приведена в docs/operations/ci_gate.md.
|
|
||||||
|
|
||||||
aiofiles==24.1.0 \
|
|
||||||
--hash=sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c \
|
|
||||||
--hash=sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5
|
|
||||||
aiogram==3.13.1 \
|
|
||||||
--hash=sha256:148cbba15c2b6e0bc482aff6ae86848ed43e023b7102336bad17698242693000 \
|
|
||||||
--hash=sha256:2674640ae74501ae30e68a5354e03f12e10db3adbcbd475bb56904536288c9c9
|
|
||||||
aiohappyeyeballs==2.7.1 \
|
|
||||||
--hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
|
|
||||||
--hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
|
|
||||||
aiohttp==3.10.11 \
|
|
||||||
--hash=sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d \
|
|
||||||
--hash=sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc \
|
|
||||||
--hash=sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261 \
|
|
||||||
--hash=sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b \
|
|
||||||
--hash=sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625 \
|
|
||||||
--hash=sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39 \
|
|
||||||
--hash=sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f \
|
|
||||||
--hash=sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636 \
|
|
||||||
--hash=sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac \
|
|
||||||
--hash=sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217 \
|
|
||||||
--hash=sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9 \
|
|
||||||
--hash=sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f \
|
|
||||||
--hash=sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006 \
|
|
||||||
--hash=sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e \
|
|
||||||
--hash=sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9 \
|
|
||||||
--hash=sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a \
|
|
||||||
--hash=sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695 \
|
|
||||||
--hash=sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730 \
|
|
||||||
--hash=sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e \
|
|
||||||
--hash=sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382 \
|
|
||||||
--hash=sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc \
|
|
||||||
--hash=sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00 \
|
|
||||||
--hash=sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8 \
|
|
||||||
--hash=sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658 \
|
|
||||||
--hash=sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339 \
|
|
||||||
--hash=sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a \
|
|
||||||
--hash=sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a \
|
|
||||||
--hash=sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e \
|
|
||||||
--hash=sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d \
|
|
||||||
--hash=sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e \
|
|
||||||
--hash=sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca \
|
|
||||||
--hash=sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038 \
|
|
||||||
--hash=sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461 \
|
|
||||||
--hash=sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829 \
|
|
||||||
--hash=sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31 \
|
|
||||||
--hash=sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4 \
|
|
||||||
--hash=sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127 \
|
|
||||||
--hash=sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3 \
|
|
||||||
--hash=sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97 \
|
|
||||||
--hash=sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710 \
|
|
||||||
--hash=sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2 \
|
|
||||||
--hash=sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674 \
|
|
||||||
--hash=sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb \
|
|
||||||
--hash=sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03 \
|
|
||||||
--hash=sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07 \
|
|
||||||
--hash=sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9 \
|
|
||||||
--hash=sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6 \
|
|
||||||
--hash=sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f \
|
|
||||||
--hash=sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298 \
|
|
||||||
--hash=sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b \
|
|
||||||
--hash=sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01 \
|
|
||||||
--hash=sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9 \
|
|
||||||
--hash=sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7 \
|
|
||||||
--hash=sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413 \
|
|
||||||
--hash=sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec \
|
|
||||||
--hash=sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7 \
|
|
||||||
--hash=sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4 \
|
|
||||||
--hash=sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb \
|
|
||||||
--hash=sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519 \
|
|
||||||
--hash=sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d \
|
|
||||||
--hash=sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6 \
|
|
||||||
--hash=sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27 \
|
|
||||||
--hash=sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1 \
|
|
||||||
--hash=sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087 \
|
|
||||||
--hash=sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067 \
|
|
||||||
--hash=sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f \
|
|
||||||
--hash=sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95 \
|
|
||||||
--hash=sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa \
|
|
||||||
--hash=sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa \
|
|
||||||
--hash=sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16 \
|
|
||||||
--hash=sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106 \
|
|
||||||
--hash=sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92 \
|
|
||||||
--hash=sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777 \
|
|
||||||
--hash=sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa \
|
|
||||||
--hash=sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e \
|
|
||||||
--hash=sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7 \
|
|
||||||
--hash=sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115 \
|
|
||||||
--hash=sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120 \
|
|
||||||
--hash=sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d \
|
|
||||||
--hash=sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b \
|
|
||||||
--hash=sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a \
|
|
||||||
--hash=sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725 \
|
|
||||||
--hash=sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a \
|
|
||||||
--hash=sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d \
|
|
||||||
--hash=sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288 \
|
|
||||||
--hash=sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8 \
|
|
||||||
--hash=sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385 \
|
|
||||||
--hash=sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24 \
|
|
||||||
--hash=sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71 \
|
|
||||||
--hash=sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138 \
|
|
||||||
--hash=sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177
|
|
||||||
aiosignal==1.4.0 \
|
|
||||||
--hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \
|
|
||||||
--hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7
|
|
||||||
annotated-types==0.8.0 \
|
|
||||||
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
|
|
||||||
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
|
||||||
attrs==26.1.0 \
|
|
||||||
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
|
|
||||||
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
|
|
||||||
certifi==2026.7.22 \
|
|
||||||
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
|
|
||||||
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
|
|
||||||
colorama==0.4.6 ; sys_platform == 'win32' \
|
|
||||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
|
||||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
|
||||||
et-xmlfile==2.0.0 \
|
|
||||||
--hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \
|
|
||||||
--hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54
|
|
||||||
frozenlist==1.8.0 \
|
|
||||||
--hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \
|
|
||||||
--hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \
|
|
||||||
--hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \
|
|
||||||
--hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \
|
|
||||||
--hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \
|
|
||||||
--hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \
|
|
||||||
--hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \
|
|
||||||
--hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \
|
|
||||||
--hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \
|
|
||||||
--hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \
|
|
||||||
--hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \
|
|
||||||
--hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \
|
|
||||||
--hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \
|
|
||||||
--hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \
|
|
||||||
--hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \
|
|
||||||
--hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \
|
|
||||||
--hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \
|
|
||||||
--hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \
|
|
||||||
--hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \
|
|
||||||
--hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \
|
|
||||||
--hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \
|
|
||||||
--hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \
|
|
||||||
--hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \
|
|
||||||
--hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \
|
|
||||||
--hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \
|
|
||||||
--hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \
|
|
||||||
--hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \
|
|
||||||
--hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \
|
|
||||||
--hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \
|
|
||||||
--hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \
|
|
||||||
--hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \
|
|
||||||
--hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \
|
|
||||||
--hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \
|
|
||||||
--hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \
|
|
||||||
--hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \
|
|
||||||
--hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \
|
|
||||||
--hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \
|
|
||||||
--hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \
|
|
||||||
--hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \
|
|
||||||
--hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \
|
|
||||||
--hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \
|
|
||||||
--hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \
|
|
||||||
--hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \
|
|
||||||
--hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \
|
|
||||||
--hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \
|
|
||||||
--hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \
|
|
||||||
--hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \
|
|
||||||
--hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \
|
|
||||||
--hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \
|
|
||||||
--hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \
|
|
||||||
--hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \
|
|
||||||
--hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \
|
|
||||||
--hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \
|
|
||||||
--hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \
|
|
||||||
--hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \
|
|
||||||
--hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \
|
|
||||||
--hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \
|
|
||||||
--hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \
|
|
||||||
--hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \
|
|
||||||
--hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \
|
|
||||||
--hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \
|
|
||||||
--hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \
|
|
||||||
--hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \
|
|
||||||
--hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \
|
|
||||||
--hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \
|
|
||||||
--hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \
|
|
||||||
--hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \
|
|
||||||
--hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \
|
|
||||||
--hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \
|
|
||||||
--hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \
|
|
||||||
--hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \
|
|
||||||
--hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \
|
|
||||||
--hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \
|
|
||||||
--hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \
|
|
||||||
--hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \
|
|
||||||
--hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \
|
|
||||||
--hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \
|
|
||||||
--hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \
|
|
||||||
--hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \
|
|
||||||
--hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \
|
|
||||||
--hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \
|
|
||||||
--hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \
|
|
||||||
--hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \
|
|
||||||
--hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \
|
|
||||||
--hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \
|
|
||||||
--hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \
|
|
||||||
--hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \
|
|
||||||
--hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \
|
|
||||||
--hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \
|
|
||||||
--hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \
|
|
||||||
--hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \
|
|
||||||
--hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \
|
|
||||||
--hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \
|
|
||||||
--hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \
|
|
||||||
--hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \
|
|
||||||
--hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \
|
|
||||||
--hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \
|
|
||||||
--hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \
|
|
||||||
--hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \
|
|
||||||
--hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \
|
|
||||||
--hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \
|
|
||||||
--hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \
|
|
||||||
--hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \
|
|
||||||
--hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \
|
|
||||||
--hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \
|
|
||||||
--hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \
|
|
||||||
--hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \
|
|
||||||
--hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \
|
|
||||||
--hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \
|
|
||||||
--hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \
|
|
||||||
--hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \
|
|
||||||
--hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \
|
|
||||||
--hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \
|
|
||||||
--hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \
|
|
||||||
--hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \
|
|
||||||
--hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \
|
|
||||||
--hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \
|
|
||||||
--hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \
|
|
||||||
--hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \
|
|
||||||
--hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \
|
|
||||||
--hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \
|
|
||||||
--hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \
|
|
||||||
--hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \
|
|
||||||
--hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \
|
|
||||||
--hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \
|
|
||||||
--hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \
|
|
||||||
--hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \
|
|
||||||
--hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \
|
|
||||||
--hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \
|
|
||||||
--hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd
|
|
||||||
idna==3.18 \
|
|
||||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
|
||||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
|
||||||
iniconfig==2.3.0 \
|
|
||||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
|
||||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
|
||||||
magic-filter==1.0.12 \
|
|
||||||
--hash=sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9 \
|
|
||||||
--hash=sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6
|
|
||||||
multidict==6.7.1 \
|
|
||||||
--hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \
|
|
||||||
--hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \
|
|
||||||
--hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \
|
|
||||||
--hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \
|
|
||||||
--hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \
|
|
||||||
--hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \
|
|
||||||
--hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \
|
|
||||||
--hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \
|
|
||||||
--hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \
|
|
||||||
--hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \
|
|
||||||
--hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \
|
|
||||||
--hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \
|
|
||||||
--hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \
|
|
||||||
--hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \
|
|
||||||
--hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \
|
|
||||||
--hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \
|
|
||||||
--hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \
|
|
||||||
--hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \
|
|
||||||
--hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \
|
|
||||||
--hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \
|
|
||||||
--hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \
|
|
||||||
--hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \
|
|
||||||
--hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \
|
|
||||||
--hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \
|
|
||||||
--hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \
|
|
||||||
--hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \
|
|
||||||
--hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \
|
|
||||||
--hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \
|
|
||||||
--hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \
|
|
||||||
--hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \
|
|
||||||
--hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \
|
|
||||||
--hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \
|
|
||||||
--hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \
|
|
||||||
--hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \
|
|
||||||
--hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \
|
|
||||||
--hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \
|
|
||||||
--hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \
|
|
||||||
--hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \
|
|
||||||
--hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \
|
|
||||||
--hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \
|
|
||||||
--hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \
|
|
||||||
--hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \
|
|
||||||
--hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \
|
|
||||||
--hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \
|
|
||||||
--hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \
|
|
||||||
--hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \
|
|
||||||
--hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \
|
|
||||||
--hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \
|
|
||||||
--hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \
|
|
||||||
--hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \
|
|
||||||
--hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \
|
|
||||||
--hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \
|
|
||||||
--hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \
|
|
||||||
--hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \
|
|
||||||
--hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \
|
|
||||||
--hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \
|
|
||||||
--hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \
|
|
||||||
--hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \
|
|
||||||
--hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \
|
|
||||||
--hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \
|
|
||||||
--hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \
|
|
||||||
--hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \
|
|
||||||
--hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \
|
|
||||||
--hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \
|
|
||||||
--hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \
|
|
||||||
--hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \
|
|
||||||
--hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \
|
|
||||||
--hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \
|
|
||||||
--hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \
|
|
||||||
--hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \
|
|
||||||
--hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \
|
|
||||||
--hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \
|
|
||||||
--hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \
|
|
||||||
--hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \
|
|
||||||
--hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \
|
|
||||||
--hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \
|
|
||||||
--hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \
|
|
||||||
--hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \
|
|
||||||
--hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \
|
|
||||||
--hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \
|
|
||||||
--hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \
|
|
||||||
--hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \
|
|
||||||
--hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \
|
|
||||||
--hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \
|
|
||||||
--hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \
|
|
||||||
--hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \
|
|
||||||
--hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \
|
|
||||||
--hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \
|
|
||||||
--hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \
|
|
||||||
--hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \
|
|
||||||
--hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \
|
|
||||||
--hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \
|
|
||||||
--hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \
|
|
||||||
--hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \
|
|
||||||
--hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \
|
|
||||||
--hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \
|
|
||||||
--hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \
|
|
||||||
--hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \
|
|
||||||
--hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \
|
|
||||||
--hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \
|
|
||||||
--hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \
|
|
||||||
--hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \
|
|
||||||
--hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \
|
|
||||||
--hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \
|
|
||||||
--hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \
|
|
||||||
--hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \
|
|
||||||
--hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \
|
|
||||||
--hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \
|
|
||||||
--hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \
|
|
||||||
--hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \
|
|
||||||
--hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \
|
|
||||||
--hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \
|
|
||||||
--hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \
|
|
||||||
--hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \
|
|
||||||
--hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \
|
|
||||||
--hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \
|
|
||||||
--hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \
|
|
||||||
--hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \
|
|
||||||
--hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \
|
|
||||||
--hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \
|
|
||||||
--hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \
|
|
||||||
--hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \
|
|
||||||
--hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \
|
|
||||||
--hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \
|
|
||||||
--hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \
|
|
||||||
--hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \
|
|
||||||
--hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \
|
|
||||||
--hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \
|
|
||||||
--hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \
|
|
||||||
--hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \
|
|
||||||
--hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \
|
|
||||||
--hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \
|
|
||||||
--hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \
|
|
||||||
--hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \
|
|
||||||
--hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \
|
|
||||||
--hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \
|
|
||||||
--hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \
|
|
||||||
--hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \
|
|
||||||
--hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \
|
|
||||||
--hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \
|
|
||||||
--hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \
|
|
||||||
--hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \
|
|
||||||
--hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \
|
|
||||||
--hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \
|
|
||||||
--hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \
|
|
||||||
--hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19
|
|
||||||
nodeenv==1.10.0 \
|
|
||||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
|
||||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
|
||||||
nodejs-wheel-binaries==24.16.0 \
|
|
||||||
--hash=sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd \
|
|
||||||
--hash=sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51 \
|
|
||||||
--hash=sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36 \
|
|
||||||
--hash=sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342 \
|
|
||||||
--hash=sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa \
|
|
||||||
--hash=sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37 \
|
|
||||||
--hash=sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c \
|
|
||||||
--hash=sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502 \
|
|
||||||
--hash=sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03
|
|
||||||
openpyxl==3.1.5 \
|
|
||||||
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
|
|
||||||
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
|
|
||||||
packaging==26.2 \
|
|
||||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
|
||||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
|
||||||
pluggy==1.6.0 \
|
|
||||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
|
||||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
|
||||||
propcache==0.5.2 \
|
|
||||||
--hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \
|
|
||||||
--hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \
|
|
||||||
--hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \
|
|
||||||
--hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \
|
|
||||||
--hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \
|
|
||||||
--hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \
|
|
||||||
--hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \
|
|
||||||
--hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \
|
|
||||||
--hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \
|
|
||||||
--hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \
|
|
||||||
--hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \
|
|
||||||
--hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \
|
|
||||||
--hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \
|
|
||||||
--hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \
|
|
||||||
--hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \
|
|
||||||
--hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \
|
|
||||||
--hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \
|
|
||||||
--hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \
|
|
||||||
--hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \
|
|
||||||
--hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \
|
|
||||||
--hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \
|
|
||||||
--hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \
|
|
||||||
--hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \
|
|
||||||
--hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \
|
|
||||||
--hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \
|
|
||||||
--hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \
|
|
||||||
--hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \
|
|
||||||
--hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \
|
|
||||||
--hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \
|
|
||||||
--hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \
|
|
||||||
--hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \
|
|
||||||
--hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \
|
|
||||||
--hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \
|
|
||||||
--hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \
|
|
||||||
--hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \
|
|
||||||
--hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \
|
|
||||||
--hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \
|
|
||||||
--hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \
|
|
||||||
--hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \
|
|
||||||
--hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \
|
|
||||||
--hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \
|
|
||||||
--hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \
|
|
||||||
--hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \
|
|
||||||
--hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \
|
|
||||||
--hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \
|
|
||||||
--hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \
|
|
||||||
--hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \
|
|
||||||
--hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \
|
|
||||||
--hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \
|
|
||||||
--hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \
|
|
||||||
--hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \
|
|
||||||
--hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \
|
|
||||||
--hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \
|
|
||||||
--hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \
|
|
||||||
--hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \
|
|
||||||
--hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \
|
|
||||||
--hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \
|
|
||||||
--hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \
|
|
||||||
--hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \
|
|
||||||
--hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \
|
|
||||||
--hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \
|
|
||||||
--hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \
|
|
||||||
--hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \
|
|
||||||
--hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \
|
|
||||||
--hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \
|
|
||||||
--hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \
|
|
||||||
--hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \
|
|
||||||
--hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \
|
|
||||||
--hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \
|
|
||||||
--hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \
|
|
||||||
--hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \
|
|
||||||
--hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \
|
|
||||||
--hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \
|
|
||||||
--hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \
|
|
||||||
--hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \
|
|
||||||
--hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \
|
|
||||||
--hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \
|
|
||||||
--hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \
|
|
||||||
--hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \
|
|
||||||
--hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \
|
|
||||||
--hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \
|
|
||||||
--hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \
|
|
||||||
--hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \
|
|
||||||
--hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \
|
|
||||||
--hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \
|
|
||||||
--hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \
|
|
||||||
--hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \
|
|
||||||
--hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \
|
|
||||||
--hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \
|
|
||||||
--hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \
|
|
||||||
--hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \
|
|
||||||
--hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \
|
|
||||||
--hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \
|
|
||||||
--hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \
|
|
||||||
--hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \
|
|
||||||
--hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \
|
|
||||||
--hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \
|
|
||||||
--hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \
|
|
||||||
--hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \
|
|
||||||
--hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \
|
|
||||||
--hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \
|
|
||||||
--hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \
|
|
||||||
--hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \
|
|
||||||
--hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \
|
|
||||||
--hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \
|
|
||||||
--hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \
|
|
||||||
--hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \
|
|
||||||
--hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \
|
|
||||||
--hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \
|
|
||||||
--hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \
|
|
||||||
--hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \
|
|
||||||
--hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \
|
|
||||||
--hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \
|
|
||||||
--hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \
|
|
||||||
--hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \
|
|
||||||
--hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \
|
|
||||||
--hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \
|
|
||||||
--hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \
|
|
||||||
--hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \
|
|
||||||
--hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \
|
|
||||||
--hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6
|
|
||||||
psycopg==3.2.9 \
|
|
||||||
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
|
|
||||||
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
|
|
||||||
psycopg-binary==3.2.9 ; implementation_name != 'pypy' \
|
|
||||||
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
|
|
||||||
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
|
|
||||||
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
|
|
||||||
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
|
|
||||||
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
|
|
||||||
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
|
|
||||||
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
|
|
||||||
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
|
|
||||||
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
|
|
||||||
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
|
|
||||||
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
|
|
||||||
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
|
|
||||||
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
|
|
||||||
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
|
|
||||||
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
|
|
||||||
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
|
|
||||||
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
|
|
||||||
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
|
|
||||||
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
|
|
||||||
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
|
|
||||||
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
|
|
||||||
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
|
|
||||||
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
|
|
||||||
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
|
|
||||||
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
|
|
||||||
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
|
|
||||||
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
|
|
||||||
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
|
|
||||||
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
|
|
||||||
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
|
|
||||||
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
|
|
||||||
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
|
|
||||||
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
|
|
||||||
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
|
|
||||||
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
|
|
||||||
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
|
|
||||||
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
|
|
||||||
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
|
|
||||||
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
|
|
||||||
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
|
|
||||||
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
|
|
||||||
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
|
|
||||||
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
|
|
||||||
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
|
|
||||||
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
|
|
||||||
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
|
|
||||||
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
|
|
||||||
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
|
|
||||||
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
|
|
||||||
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
|
|
||||||
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
|
|
||||||
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
|
|
||||||
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
|
|
||||||
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
|
|
||||||
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
|
|
||||||
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
|
|
||||||
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
|
|
||||||
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
|
|
||||||
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
|
|
||||||
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
|
|
||||||
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
|
|
||||||
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
|
|
||||||
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
|
|
||||||
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
|
|
||||||
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
|
|
||||||
psycopg-pool==3.3.1 \
|
|
||||||
--hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \
|
|
||||||
--hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c
|
|
||||||
pydantic==2.9.2 \
|
|
||||||
--hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \
|
|
||||||
--hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12
|
|
||||||
pydantic-core==2.23.4 \
|
|
||||||
--hash=sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36 \
|
|
||||||
--hash=sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05 \
|
|
||||||
--hash=sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071 \
|
|
||||||
--hash=sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327 \
|
|
||||||
--hash=sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c \
|
|
||||||
--hash=sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36 \
|
|
||||||
--hash=sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29 \
|
|
||||||
--hash=sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744 \
|
|
||||||
--hash=sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d \
|
|
||||||
--hash=sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec \
|
|
||||||
--hash=sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e \
|
|
||||||
--hash=sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e \
|
|
||||||
--hash=sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577 \
|
|
||||||
--hash=sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232 \
|
|
||||||
--hash=sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863 \
|
|
||||||
--hash=sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6 \
|
|
||||||
--hash=sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368 \
|
|
||||||
--hash=sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480 \
|
|
||||||
--hash=sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2 \
|
|
||||||
--hash=sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2 \
|
|
||||||
--hash=sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6 \
|
|
||||||
--hash=sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769 \
|
|
||||||
--hash=sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d \
|
|
||||||
--hash=sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 \
|
|
||||||
--hash=sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84 \
|
|
||||||
--hash=sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 \
|
|
||||||
--hash=sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271 \
|
|
||||||
--hash=sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5 \
|
|
||||||
--hash=sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb \
|
|
||||||
--hash=sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13 \
|
|
||||||
--hash=sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323 \
|
|
||||||
--hash=sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556 \
|
|
||||||
--hash=sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665 \
|
|
||||||
--hash=sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef \
|
|
||||||
--hash=sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb \
|
|
||||||
--hash=sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119 \
|
|
||||||
--hash=sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126 \
|
|
||||||
--hash=sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510 \
|
|
||||||
--hash=sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b \
|
|
||||||
--hash=sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87 \
|
|
||||||
--hash=sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f \
|
|
||||||
--hash=sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc \
|
|
||||||
--hash=sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8 \
|
|
||||||
--hash=sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21 \
|
|
||||||
--hash=sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f \
|
|
||||||
--hash=sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6 \
|
|
||||||
--hash=sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658 \
|
|
||||||
--hash=sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b \
|
|
||||||
--hash=sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3 \
|
|
||||||
--hash=sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb \
|
|
||||||
--hash=sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59 \
|
|
||||||
--hash=sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24 \
|
|
||||||
--hash=sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9 \
|
|
||||||
--hash=sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3 \
|
|
||||||
--hash=sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd \
|
|
||||||
--hash=sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753 \
|
|
||||||
--hash=sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55 \
|
|
||||||
--hash=sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad \
|
|
||||||
--hash=sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a \
|
|
||||||
--hash=sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605 \
|
|
||||||
--hash=sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e \
|
|
||||||
--hash=sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b \
|
|
||||||
--hash=sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433 \
|
|
||||||
--hash=sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8 \
|
|
||||||
--hash=sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07 \
|
|
||||||
--hash=sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728 \
|
|
||||||
--hash=sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0 \
|
|
||||||
--hash=sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327 \
|
|
||||||
--hash=sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555 \
|
|
||||||
--hash=sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64 \
|
|
||||||
--hash=sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6 \
|
|
||||||
--hash=sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea \
|
|
||||||
--hash=sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b \
|
|
||||||
--hash=sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df \
|
|
||||||
--hash=sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e \
|
|
||||||
--hash=sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd \
|
|
||||||
--hash=sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068 \
|
|
||||||
--hash=sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3 \
|
|
||||||
--hash=sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040 \
|
|
||||||
--hash=sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12 \
|
|
||||||
--hash=sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916 \
|
|
||||||
--hash=sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f \
|
|
||||||
--hash=sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f \
|
|
||||||
--hash=sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801 \
|
|
||||||
--hash=sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231 \
|
|
||||||
--hash=sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5 \
|
|
||||||
--hash=sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8 \
|
|
||||||
--hash=sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee \
|
|
||||||
--hash=sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607
|
|
||||||
pygments==2.20.0 \
|
|
||||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
|
||||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
|
||||||
pyright==1.1.411 \
|
|
||||||
--hash=sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998 \
|
|
||||||
--hash=sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9
|
|
||||||
pytest==9.1.1 \
|
|
||||||
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
|
|
||||||
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
|
||||||
python-dotenv==1.0.1 \
|
|
||||||
--hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \
|
|
||||||
--hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a
|
|
||||||
typing-extensions==4.16.0 \
|
|
||||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
|
||||||
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
|
||||||
tzdata==2026.3 ; sys_platform == 'win32' \
|
|
||||||
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
|
|
||||||
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
|
|
||||||
websockets==13.1 \
|
|
||||||
--hash=sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a \
|
|
||||||
--hash=sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54 \
|
|
||||||
--hash=sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23 \
|
|
||||||
--hash=sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7 \
|
|
||||||
--hash=sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135 \
|
|
||||||
--hash=sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700 \
|
|
||||||
--hash=sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf \
|
|
||||||
--hash=sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5 \
|
|
||||||
--hash=sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e \
|
|
||||||
--hash=sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c \
|
|
||||||
--hash=sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02 \
|
|
||||||
--hash=sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a \
|
|
||||||
--hash=sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418 \
|
|
||||||
--hash=sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f \
|
|
||||||
--hash=sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3 \
|
|
||||||
--hash=sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68 \
|
|
||||||
--hash=sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978 \
|
|
||||||
--hash=sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20 \
|
|
||||||
--hash=sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295 \
|
|
||||||
--hash=sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b \
|
|
||||||
--hash=sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6 \
|
|
||||||
--hash=sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb \
|
|
||||||
--hash=sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a \
|
|
||||||
--hash=sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa \
|
|
||||||
--hash=sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0 \
|
|
||||||
--hash=sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a \
|
|
||||||
--hash=sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238 \
|
|
||||||
--hash=sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c \
|
|
||||||
--hash=sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084 \
|
|
||||||
--hash=sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19 \
|
|
||||||
--hash=sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d \
|
|
||||||
--hash=sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7 \
|
|
||||||
--hash=sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9 \
|
|
||||||
--hash=sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79 \
|
|
||||||
--hash=sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96 \
|
|
||||||
--hash=sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6 \
|
|
||||||
--hash=sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe \
|
|
||||||
--hash=sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842 \
|
|
||||||
--hash=sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa \
|
|
||||||
--hash=sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3 \
|
|
||||||
--hash=sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d \
|
|
||||||
--hash=sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51 \
|
|
||||||
--hash=sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7 \
|
|
||||||
--hash=sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09 \
|
|
||||||
--hash=sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096 \
|
|
||||||
--hash=sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9 \
|
|
||||||
--hash=sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b \
|
|
||||||
--hash=sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5 \
|
|
||||||
--hash=sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678 \
|
|
||||||
--hash=sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea \
|
|
||||||
--hash=sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d \
|
|
||||||
--hash=sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49 \
|
|
||||||
--hash=sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc \
|
|
||||||
--hash=sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5 \
|
|
||||||
--hash=sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027 \
|
|
||||||
--hash=sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0 \
|
|
||||||
--hash=sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878 \
|
|
||||||
--hash=sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c \
|
|
||||||
--hash=sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa \
|
|
||||||
--hash=sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f \
|
|
||||||
--hash=sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6 \
|
|
||||||
--hash=sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2 \
|
|
||||||
--hash=sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf \
|
|
||||||
--hash=sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708 \
|
|
||||||
--hash=sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6 \
|
|
||||||
--hash=sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f \
|
|
||||||
--hash=sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd \
|
|
||||||
--hash=sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2 \
|
|
||||||
--hash=sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d \
|
|
||||||
--hash=sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7 \
|
|
||||||
--hash=sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f \
|
|
||||||
--hash=sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5 \
|
|
||||||
--hash=sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6 \
|
|
||||||
--hash=sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557 \
|
|
||||||
--hash=sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14 \
|
|
||||||
--hash=sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7 \
|
|
||||||
--hash=sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd \
|
|
||||||
--hash=sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c \
|
|
||||||
--hash=sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17 \
|
|
||||||
--hash=sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23 \
|
|
||||||
--hash=sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db \
|
|
||||||
--hash=sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6 \
|
|
||||||
--hash=sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d \
|
|
||||||
--hash=sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9 \
|
|
||||||
--hash=sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee \
|
|
||||||
--hash=sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6
|
|
||||||
yarl==1.24.5 \
|
|
||||||
--hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \
|
|
||||||
--hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \
|
|
||||||
--hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \
|
|
||||||
--hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \
|
|
||||||
--hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \
|
|
||||||
--hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \
|
|
||||||
--hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \
|
|
||||||
--hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \
|
|
||||||
--hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \
|
|
||||||
--hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \
|
|
||||||
--hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \
|
|
||||||
--hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \
|
|
||||||
--hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \
|
|
||||||
--hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \
|
|
||||||
--hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \
|
|
||||||
--hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \
|
|
||||||
--hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \
|
|
||||||
--hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \
|
|
||||||
--hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \
|
|
||||||
--hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \
|
|
||||||
--hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \
|
|
||||||
--hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \
|
|
||||||
--hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \
|
|
||||||
--hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \
|
|
||||||
--hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \
|
|
||||||
--hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \
|
|
||||||
--hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \
|
|
||||||
--hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \
|
|
||||||
--hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \
|
|
||||||
--hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \
|
|
||||||
--hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \
|
|
||||||
--hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \
|
|
||||||
--hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \
|
|
||||||
--hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \
|
|
||||||
--hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \
|
|
||||||
--hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \
|
|
||||||
--hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \
|
|
||||||
--hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \
|
|
||||||
--hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \
|
|
||||||
--hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \
|
|
||||||
--hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \
|
|
||||||
--hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \
|
|
||||||
--hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \
|
|
||||||
--hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \
|
|
||||||
--hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \
|
|
||||||
--hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \
|
|
||||||
--hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \
|
|
||||||
--hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \
|
|
||||||
--hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \
|
|
||||||
--hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \
|
|
||||||
--hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \
|
|
||||||
--hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \
|
|
||||||
--hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \
|
|
||||||
--hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \
|
|
||||||
--hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \
|
|
||||||
--hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \
|
|
||||||
--hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \
|
|
||||||
--hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \
|
|
||||||
--hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \
|
|
||||||
--hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \
|
|
||||||
--hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \
|
|
||||||
--hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \
|
|
||||||
--hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \
|
|
||||||
--hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \
|
|
||||||
--hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \
|
|
||||||
--hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \
|
|
||||||
--hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \
|
|
||||||
--hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \
|
|
||||||
--hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \
|
|
||||||
--hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \
|
|
||||||
--hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \
|
|
||||||
--hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \
|
|
||||||
--hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \
|
|
||||||
--hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \
|
|
||||||
--hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \
|
|
||||||
--hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \
|
|
||||||
--hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \
|
|
||||||
--hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \
|
|
||||||
--hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \
|
|
||||||
--hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \
|
|
||||||
--hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \
|
|
||||||
--hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \
|
|
||||||
--hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \
|
|
||||||
--hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \
|
|
||||||
--hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \
|
|
||||||
--hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \
|
|
||||||
--hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \
|
|
||||||
--hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \
|
|
||||||
--hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \
|
|
||||||
--hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \
|
|
||||||
--hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \
|
|
||||||
--hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \
|
|
||||||
--hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \
|
|
||||||
--hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \
|
|
||||||
--hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \
|
|
||||||
--hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \
|
|
||||||
--hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \
|
|
||||||
--hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \
|
|
||||||
--hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \
|
|
||||||
--hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \
|
|
||||||
--hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \
|
|
||||||
--hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \
|
|
||||||
--hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \
|
|
||||||
--hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-r requirements.txt
|
|
||||||
|
|
||||||
pytest==9.1.1
|
|
||||||
pyright[nodejs]==1.1.411
|
|
||||||
@@ -1,895 +0,0 @@
|
|||||||
# Сгенерированный hash-lock production-зависимостей; вручную не редактировать.
|
|
||||||
# Источник: app/requirements.txt; Python 3.12; universal resolution.
|
|
||||||
# Команда приведена в docs/operations/trades_feed_runtime.md.
|
|
||||||
|
|
||||||
aiofiles==24.1.0 \
|
|
||||||
--hash=sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c \
|
|
||||||
--hash=sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5
|
|
||||||
aiogram==3.13.1 \
|
|
||||||
--hash=sha256:148cbba15c2b6e0bc482aff6ae86848ed43e023b7102336bad17698242693000 \
|
|
||||||
--hash=sha256:2674640ae74501ae30e68a5354e03f12e10db3adbcbd475bb56904536288c9c9
|
|
||||||
aiohappyeyeballs==2.7.1 \
|
|
||||||
--hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \
|
|
||||||
--hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472
|
|
||||||
aiohttp==3.10.11 \
|
|
||||||
--hash=sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d \
|
|
||||||
--hash=sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc \
|
|
||||||
--hash=sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261 \
|
|
||||||
--hash=sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b \
|
|
||||||
--hash=sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625 \
|
|
||||||
--hash=sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39 \
|
|
||||||
--hash=sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f \
|
|
||||||
--hash=sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636 \
|
|
||||||
--hash=sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac \
|
|
||||||
--hash=sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217 \
|
|
||||||
--hash=sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9 \
|
|
||||||
--hash=sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f \
|
|
||||||
--hash=sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006 \
|
|
||||||
--hash=sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e \
|
|
||||||
--hash=sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9 \
|
|
||||||
--hash=sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a \
|
|
||||||
--hash=sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695 \
|
|
||||||
--hash=sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730 \
|
|
||||||
--hash=sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e \
|
|
||||||
--hash=sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382 \
|
|
||||||
--hash=sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc \
|
|
||||||
--hash=sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00 \
|
|
||||||
--hash=sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8 \
|
|
||||||
--hash=sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658 \
|
|
||||||
--hash=sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339 \
|
|
||||||
--hash=sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a \
|
|
||||||
--hash=sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a \
|
|
||||||
--hash=sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e \
|
|
||||||
--hash=sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d \
|
|
||||||
--hash=sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e \
|
|
||||||
--hash=sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca \
|
|
||||||
--hash=sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038 \
|
|
||||||
--hash=sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461 \
|
|
||||||
--hash=sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829 \
|
|
||||||
--hash=sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31 \
|
|
||||||
--hash=sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4 \
|
|
||||||
--hash=sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127 \
|
|
||||||
--hash=sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3 \
|
|
||||||
--hash=sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97 \
|
|
||||||
--hash=sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710 \
|
|
||||||
--hash=sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2 \
|
|
||||||
--hash=sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674 \
|
|
||||||
--hash=sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb \
|
|
||||||
--hash=sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03 \
|
|
||||||
--hash=sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07 \
|
|
||||||
--hash=sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9 \
|
|
||||||
--hash=sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6 \
|
|
||||||
--hash=sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f \
|
|
||||||
--hash=sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298 \
|
|
||||||
--hash=sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b \
|
|
||||||
--hash=sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01 \
|
|
||||||
--hash=sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9 \
|
|
||||||
--hash=sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7 \
|
|
||||||
--hash=sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413 \
|
|
||||||
--hash=sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec \
|
|
||||||
--hash=sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7 \
|
|
||||||
--hash=sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4 \
|
|
||||||
--hash=sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb \
|
|
||||||
--hash=sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519 \
|
|
||||||
--hash=sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d \
|
|
||||||
--hash=sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6 \
|
|
||||||
--hash=sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27 \
|
|
||||||
--hash=sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1 \
|
|
||||||
--hash=sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087 \
|
|
||||||
--hash=sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067 \
|
|
||||||
--hash=sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f \
|
|
||||||
--hash=sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95 \
|
|
||||||
--hash=sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa \
|
|
||||||
--hash=sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa \
|
|
||||||
--hash=sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16 \
|
|
||||||
--hash=sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106 \
|
|
||||||
--hash=sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92 \
|
|
||||||
--hash=sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777 \
|
|
||||||
--hash=sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa \
|
|
||||||
--hash=sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e \
|
|
||||||
--hash=sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7 \
|
|
||||||
--hash=sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115 \
|
|
||||||
--hash=sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120 \
|
|
||||||
--hash=sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d \
|
|
||||||
--hash=sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b \
|
|
||||||
--hash=sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a \
|
|
||||||
--hash=sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725 \
|
|
||||||
--hash=sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a \
|
|
||||||
--hash=sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d \
|
|
||||||
--hash=sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288 \
|
|
||||||
--hash=sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8 \
|
|
||||||
--hash=sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385 \
|
|
||||||
--hash=sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24 \
|
|
||||||
--hash=sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71 \
|
|
||||||
--hash=sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138 \
|
|
||||||
--hash=sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177
|
|
||||||
aiosignal==1.4.0 \
|
|
||||||
--hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \
|
|
||||||
--hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7
|
|
||||||
annotated-types==0.8.0 \
|
|
||||||
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
|
|
||||||
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
|
|
||||||
attrs==26.1.0 \
|
|
||||||
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
|
|
||||||
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
|
|
||||||
certifi==2026.7.22 \
|
|
||||||
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
|
|
||||||
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
|
|
||||||
et-xmlfile==2.0.0 \
|
|
||||||
--hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \
|
|
||||||
--hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54
|
|
||||||
frozenlist==1.8.0 \
|
|
||||||
--hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \
|
|
||||||
--hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \
|
|
||||||
--hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \
|
|
||||||
--hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \
|
|
||||||
--hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \
|
|
||||||
--hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \
|
|
||||||
--hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \
|
|
||||||
--hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \
|
|
||||||
--hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \
|
|
||||||
--hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \
|
|
||||||
--hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \
|
|
||||||
--hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \
|
|
||||||
--hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \
|
|
||||||
--hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \
|
|
||||||
--hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \
|
|
||||||
--hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \
|
|
||||||
--hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \
|
|
||||||
--hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \
|
|
||||||
--hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \
|
|
||||||
--hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \
|
|
||||||
--hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \
|
|
||||||
--hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \
|
|
||||||
--hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \
|
|
||||||
--hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \
|
|
||||||
--hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \
|
|
||||||
--hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \
|
|
||||||
--hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \
|
|
||||||
--hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \
|
|
||||||
--hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \
|
|
||||||
--hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \
|
|
||||||
--hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \
|
|
||||||
--hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \
|
|
||||||
--hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \
|
|
||||||
--hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \
|
|
||||||
--hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \
|
|
||||||
--hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \
|
|
||||||
--hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \
|
|
||||||
--hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \
|
|
||||||
--hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \
|
|
||||||
--hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \
|
|
||||||
--hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \
|
|
||||||
--hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \
|
|
||||||
--hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \
|
|
||||||
--hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \
|
|
||||||
--hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \
|
|
||||||
--hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \
|
|
||||||
--hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \
|
|
||||||
--hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \
|
|
||||||
--hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \
|
|
||||||
--hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \
|
|
||||||
--hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \
|
|
||||||
--hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \
|
|
||||||
--hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \
|
|
||||||
--hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \
|
|
||||||
--hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \
|
|
||||||
--hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \
|
|
||||||
--hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \
|
|
||||||
--hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \
|
|
||||||
--hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \
|
|
||||||
--hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \
|
|
||||||
--hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \
|
|
||||||
--hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \
|
|
||||||
--hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \
|
|
||||||
--hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \
|
|
||||||
--hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \
|
|
||||||
--hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \
|
|
||||||
--hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \
|
|
||||||
--hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \
|
|
||||||
--hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \
|
|
||||||
--hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \
|
|
||||||
--hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \
|
|
||||||
--hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \
|
|
||||||
--hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \
|
|
||||||
--hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \
|
|
||||||
--hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \
|
|
||||||
--hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \
|
|
||||||
--hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \
|
|
||||||
--hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \
|
|
||||||
--hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \
|
|
||||||
--hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \
|
|
||||||
--hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \
|
|
||||||
--hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \
|
|
||||||
--hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \
|
|
||||||
--hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \
|
|
||||||
--hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \
|
|
||||||
--hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \
|
|
||||||
--hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \
|
|
||||||
--hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \
|
|
||||||
--hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \
|
|
||||||
--hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \
|
|
||||||
--hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \
|
|
||||||
--hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \
|
|
||||||
--hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \
|
|
||||||
--hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \
|
|
||||||
--hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \
|
|
||||||
--hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \
|
|
||||||
--hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \
|
|
||||||
--hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \
|
|
||||||
--hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \
|
|
||||||
--hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \
|
|
||||||
--hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \
|
|
||||||
--hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \
|
|
||||||
--hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \
|
|
||||||
--hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \
|
|
||||||
--hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \
|
|
||||||
--hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \
|
|
||||||
--hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \
|
|
||||||
--hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \
|
|
||||||
--hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \
|
|
||||||
--hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \
|
|
||||||
--hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \
|
|
||||||
--hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \
|
|
||||||
--hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \
|
|
||||||
--hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \
|
|
||||||
--hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \
|
|
||||||
--hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \
|
|
||||||
--hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \
|
|
||||||
--hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \
|
|
||||||
--hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \
|
|
||||||
--hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \
|
|
||||||
--hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \
|
|
||||||
--hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \
|
|
||||||
--hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \
|
|
||||||
--hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \
|
|
||||||
--hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \
|
|
||||||
--hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \
|
|
||||||
--hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \
|
|
||||||
--hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \
|
|
||||||
--hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \
|
|
||||||
--hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd
|
|
||||||
idna==3.18 \
|
|
||||||
--hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
|
|
||||||
--hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
|
|
||||||
magic-filter==1.0.12 \
|
|
||||||
--hash=sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9 \
|
|
||||||
--hash=sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6
|
|
||||||
multidict==6.7.1 \
|
|
||||||
--hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \
|
|
||||||
--hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \
|
|
||||||
--hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \
|
|
||||||
--hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \
|
|
||||||
--hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \
|
|
||||||
--hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \
|
|
||||||
--hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \
|
|
||||||
--hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \
|
|
||||||
--hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \
|
|
||||||
--hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \
|
|
||||||
--hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \
|
|
||||||
--hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \
|
|
||||||
--hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \
|
|
||||||
--hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \
|
|
||||||
--hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \
|
|
||||||
--hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \
|
|
||||||
--hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \
|
|
||||||
--hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \
|
|
||||||
--hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \
|
|
||||||
--hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \
|
|
||||||
--hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \
|
|
||||||
--hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \
|
|
||||||
--hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \
|
|
||||||
--hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \
|
|
||||||
--hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \
|
|
||||||
--hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \
|
|
||||||
--hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \
|
|
||||||
--hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \
|
|
||||||
--hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \
|
|
||||||
--hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \
|
|
||||||
--hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \
|
|
||||||
--hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \
|
|
||||||
--hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \
|
|
||||||
--hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \
|
|
||||||
--hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \
|
|
||||||
--hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \
|
|
||||||
--hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \
|
|
||||||
--hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \
|
|
||||||
--hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \
|
|
||||||
--hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \
|
|
||||||
--hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \
|
|
||||||
--hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \
|
|
||||||
--hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \
|
|
||||||
--hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \
|
|
||||||
--hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \
|
|
||||||
--hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \
|
|
||||||
--hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \
|
|
||||||
--hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \
|
|
||||||
--hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \
|
|
||||||
--hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \
|
|
||||||
--hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \
|
|
||||||
--hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \
|
|
||||||
--hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \
|
|
||||||
--hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \
|
|
||||||
--hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \
|
|
||||||
--hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \
|
|
||||||
--hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \
|
|
||||||
--hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \
|
|
||||||
--hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \
|
|
||||||
--hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \
|
|
||||||
--hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \
|
|
||||||
--hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \
|
|
||||||
--hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \
|
|
||||||
--hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \
|
|
||||||
--hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \
|
|
||||||
--hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \
|
|
||||||
--hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \
|
|
||||||
--hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \
|
|
||||||
--hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \
|
|
||||||
--hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \
|
|
||||||
--hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \
|
|
||||||
--hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \
|
|
||||||
--hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \
|
|
||||||
--hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \
|
|
||||||
--hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \
|
|
||||||
--hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \
|
|
||||||
--hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \
|
|
||||||
--hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \
|
|
||||||
--hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \
|
|
||||||
--hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \
|
|
||||||
--hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \
|
|
||||||
--hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \
|
|
||||||
--hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \
|
|
||||||
--hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \
|
|
||||||
--hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \
|
|
||||||
--hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \
|
|
||||||
--hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \
|
|
||||||
--hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \
|
|
||||||
--hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \
|
|
||||||
--hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \
|
|
||||||
--hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \
|
|
||||||
--hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \
|
|
||||||
--hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \
|
|
||||||
--hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \
|
|
||||||
--hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \
|
|
||||||
--hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \
|
|
||||||
--hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \
|
|
||||||
--hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \
|
|
||||||
--hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \
|
|
||||||
--hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \
|
|
||||||
--hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \
|
|
||||||
--hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \
|
|
||||||
--hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \
|
|
||||||
--hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \
|
|
||||||
--hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \
|
|
||||||
--hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \
|
|
||||||
--hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \
|
|
||||||
--hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \
|
|
||||||
--hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \
|
|
||||||
--hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \
|
|
||||||
--hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \
|
|
||||||
--hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \
|
|
||||||
--hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \
|
|
||||||
--hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \
|
|
||||||
--hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \
|
|
||||||
--hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \
|
|
||||||
--hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \
|
|
||||||
--hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \
|
|
||||||
--hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \
|
|
||||||
--hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \
|
|
||||||
--hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \
|
|
||||||
--hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \
|
|
||||||
--hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \
|
|
||||||
--hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \
|
|
||||||
--hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \
|
|
||||||
--hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \
|
|
||||||
--hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \
|
|
||||||
--hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \
|
|
||||||
--hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \
|
|
||||||
--hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \
|
|
||||||
--hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \
|
|
||||||
--hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \
|
|
||||||
--hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \
|
|
||||||
--hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \
|
|
||||||
--hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \
|
|
||||||
--hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \
|
|
||||||
--hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \
|
|
||||||
--hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \
|
|
||||||
--hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \
|
|
||||||
--hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \
|
|
||||||
--hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \
|
|
||||||
--hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \
|
|
||||||
--hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \
|
|
||||||
--hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \
|
|
||||||
--hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \
|
|
||||||
--hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19
|
|
||||||
openpyxl==3.1.5 \
|
|
||||||
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
|
|
||||||
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
|
|
||||||
propcache==0.5.2 \
|
|
||||||
--hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \
|
|
||||||
--hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \
|
|
||||||
--hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \
|
|
||||||
--hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \
|
|
||||||
--hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \
|
|
||||||
--hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \
|
|
||||||
--hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \
|
|
||||||
--hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \
|
|
||||||
--hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \
|
|
||||||
--hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \
|
|
||||||
--hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \
|
|
||||||
--hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \
|
|
||||||
--hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \
|
|
||||||
--hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \
|
|
||||||
--hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \
|
|
||||||
--hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \
|
|
||||||
--hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \
|
|
||||||
--hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \
|
|
||||||
--hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \
|
|
||||||
--hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \
|
|
||||||
--hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \
|
|
||||||
--hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \
|
|
||||||
--hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \
|
|
||||||
--hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \
|
|
||||||
--hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \
|
|
||||||
--hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \
|
|
||||||
--hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \
|
|
||||||
--hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \
|
|
||||||
--hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \
|
|
||||||
--hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \
|
|
||||||
--hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \
|
|
||||||
--hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \
|
|
||||||
--hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \
|
|
||||||
--hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \
|
|
||||||
--hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \
|
|
||||||
--hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \
|
|
||||||
--hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \
|
|
||||||
--hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \
|
|
||||||
--hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \
|
|
||||||
--hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \
|
|
||||||
--hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \
|
|
||||||
--hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \
|
|
||||||
--hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \
|
|
||||||
--hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \
|
|
||||||
--hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \
|
|
||||||
--hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \
|
|
||||||
--hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \
|
|
||||||
--hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \
|
|
||||||
--hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \
|
|
||||||
--hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \
|
|
||||||
--hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \
|
|
||||||
--hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \
|
|
||||||
--hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \
|
|
||||||
--hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \
|
|
||||||
--hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \
|
|
||||||
--hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \
|
|
||||||
--hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \
|
|
||||||
--hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \
|
|
||||||
--hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \
|
|
||||||
--hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \
|
|
||||||
--hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \
|
|
||||||
--hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \
|
|
||||||
--hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \
|
|
||||||
--hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \
|
|
||||||
--hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \
|
|
||||||
--hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \
|
|
||||||
--hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \
|
|
||||||
--hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \
|
|
||||||
--hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \
|
|
||||||
--hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \
|
|
||||||
--hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \
|
|
||||||
--hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \
|
|
||||||
--hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \
|
|
||||||
--hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \
|
|
||||||
--hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \
|
|
||||||
--hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \
|
|
||||||
--hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \
|
|
||||||
--hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \
|
|
||||||
--hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \
|
|
||||||
--hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \
|
|
||||||
--hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \
|
|
||||||
--hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \
|
|
||||||
--hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \
|
|
||||||
--hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \
|
|
||||||
--hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \
|
|
||||||
--hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \
|
|
||||||
--hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \
|
|
||||||
--hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \
|
|
||||||
--hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \
|
|
||||||
--hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \
|
|
||||||
--hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \
|
|
||||||
--hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \
|
|
||||||
--hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \
|
|
||||||
--hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \
|
|
||||||
--hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \
|
|
||||||
--hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \
|
|
||||||
--hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \
|
|
||||||
--hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \
|
|
||||||
--hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \
|
|
||||||
--hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \
|
|
||||||
--hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \
|
|
||||||
--hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \
|
|
||||||
--hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \
|
|
||||||
--hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \
|
|
||||||
--hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \
|
|
||||||
--hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \
|
|
||||||
--hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \
|
|
||||||
--hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \
|
|
||||||
--hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \
|
|
||||||
--hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \
|
|
||||||
--hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \
|
|
||||||
--hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \
|
|
||||||
--hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \
|
|
||||||
--hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \
|
|
||||||
--hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \
|
|
||||||
--hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \
|
|
||||||
--hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \
|
|
||||||
--hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \
|
|
||||||
--hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \
|
|
||||||
--hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \
|
|
||||||
--hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6
|
|
||||||
psycopg==3.2.9 \
|
|
||||||
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
|
|
||||||
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
|
|
||||||
psycopg-binary==3.2.9 ; implementation_name != 'pypy' \
|
|
||||||
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
|
|
||||||
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
|
|
||||||
--hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \
|
|
||||||
--hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \
|
|
||||||
--hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \
|
|
||||||
--hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \
|
|
||||||
--hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \
|
|
||||||
--hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \
|
|
||||||
--hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \
|
|
||||||
--hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \
|
|
||||||
--hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \
|
|
||||||
--hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \
|
|
||||||
--hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \
|
|
||||||
--hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \
|
|
||||||
--hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \
|
|
||||||
--hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \
|
|
||||||
--hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \
|
|
||||||
--hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \
|
|
||||||
--hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \
|
|
||||||
--hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \
|
|
||||||
--hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \
|
|
||||||
--hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \
|
|
||||||
--hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \
|
|
||||||
--hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \
|
|
||||||
--hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \
|
|
||||||
--hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \
|
|
||||||
--hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \
|
|
||||||
--hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \
|
|
||||||
--hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \
|
|
||||||
--hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \
|
|
||||||
--hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \
|
|
||||||
--hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \
|
|
||||||
--hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \
|
|
||||||
--hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \
|
|
||||||
--hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \
|
|
||||||
--hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \
|
|
||||||
--hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \
|
|
||||||
--hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \
|
|
||||||
--hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \
|
|
||||||
--hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \
|
|
||||||
--hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \
|
|
||||||
--hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \
|
|
||||||
--hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \
|
|
||||||
--hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \
|
|
||||||
--hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \
|
|
||||||
--hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \
|
|
||||||
--hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \
|
|
||||||
--hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \
|
|
||||||
--hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \
|
|
||||||
--hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \
|
|
||||||
--hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \
|
|
||||||
--hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \
|
|
||||||
--hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \
|
|
||||||
--hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \
|
|
||||||
--hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \
|
|
||||||
--hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \
|
|
||||||
--hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \
|
|
||||||
--hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \
|
|
||||||
--hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \
|
|
||||||
--hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \
|
|
||||||
--hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \
|
|
||||||
--hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \
|
|
||||||
--hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \
|
|
||||||
--hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \
|
|
||||||
--hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb
|
|
||||||
psycopg-pool==3.3.1 \
|
|
||||||
--hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \
|
|
||||||
--hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c
|
|
||||||
pydantic==2.9.2 \
|
|
||||||
--hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \
|
|
||||||
--hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12
|
|
||||||
pydantic-core==2.23.4 \
|
|
||||||
--hash=sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36 \
|
|
||||||
--hash=sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05 \
|
|
||||||
--hash=sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071 \
|
|
||||||
--hash=sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327 \
|
|
||||||
--hash=sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c \
|
|
||||||
--hash=sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36 \
|
|
||||||
--hash=sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29 \
|
|
||||||
--hash=sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744 \
|
|
||||||
--hash=sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d \
|
|
||||||
--hash=sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec \
|
|
||||||
--hash=sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e \
|
|
||||||
--hash=sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e \
|
|
||||||
--hash=sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577 \
|
|
||||||
--hash=sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232 \
|
|
||||||
--hash=sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863 \
|
|
||||||
--hash=sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6 \
|
|
||||||
--hash=sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368 \
|
|
||||||
--hash=sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480 \
|
|
||||||
--hash=sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2 \
|
|
||||||
--hash=sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2 \
|
|
||||||
--hash=sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6 \
|
|
||||||
--hash=sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769 \
|
|
||||||
--hash=sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d \
|
|
||||||
--hash=sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 \
|
|
||||||
--hash=sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84 \
|
|
||||||
--hash=sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 \
|
|
||||||
--hash=sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271 \
|
|
||||||
--hash=sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5 \
|
|
||||||
--hash=sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb \
|
|
||||||
--hash=sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13 \
|
|
||||||
--hash=sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323 \
|
|
||||||
--hash=sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556 \
|
|
||||||
--hash=sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665 \
|
|
||||||
--hash=sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef \
|
|
||||||
--hash=sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb \
|
|
||||||
--hash=sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119 \
|
|
||||||
--hash=sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126 \
|
|
||||||
--hash=sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510 \
|
|
||||||
--hash=sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b \
|
|
||||||
--hash=sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87 \
|
|
||||||
--hash=sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f \
|
|
||||||
--hash=sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc \
|
|
||||||
--hash=sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8 \
|
|
||||||
--hash=sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21 \
|
|
||||||
--hash=sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f \
|
|
||||||
--hash=sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6 \
|
|
||||||
--hash=sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658 \
|
|
||||||
--hash=sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b \
|
|
||||||
--hash=sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3 \
|
|
||||||
--hash=sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb \
|
|
||||||
--hash=sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59 \
|
|
||||||
--hash=sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24 \
|
|
||||||
--hash=sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9 \
|
|
||||||
--hash=sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3 \
|
|
||||||
--hash=sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd \
|
|
||||||
--hash=sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753 \
|
|
||||||
--hash=sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55 \
|
|
||||||
--hash=sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad \
|
|
||||||
--hash=sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a \
|
|
||||||
--hash=sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605 \
|
|
||||||
--hash=sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e \
|
|
||||||
--hash=sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b \
|
|
||||||
--hash=sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433 \
|
|
||||||
--hash=sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8 \
|
|
||||||
--hash=sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07 \
|
|
||||||
--hash=sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728 \
|
|
||||||
--hash=sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0 \
|
|
||||||
--hash=sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327 \
|
|
||||||
--hash=sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555 \
|
|
||||||
--hash=sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64 \
|
|
||||||
--hash=sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6 \
|
|
||||||
--hash=sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea \
|
|
||||||
--hash=sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b \
|
|
||||||
--hash=sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df \
|
|
||||||
--hash=sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e \
|
|
||||||
--hash=sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd \
|
|
||||||
--hash=sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068 \
|
|
||||||
--hash=sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3 \
|
|
||||||
--hash=sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040 \
|
|
||||||
--hash=sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12 \
|
|
||||||
--hash=sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916 \
|
|
||||||
--hash=sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f \
|
|
||||||
--hash=sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f \
|
|
||||||
--hash=sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801 \
|
|
||||||
--hash=sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231 \
|
|
||||||
--hash=sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5 \
|
|
||||||
--hash=sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8 \
|
|
||||||
--hash=sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee \
|
|
||||||
--hash=sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607
|
|
||||||
python-dotenv==1.0.1 \
|
|
||||||
--hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \
|
|
||||||
--hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a
|
|
||||||
typing-extensions==4.16.0 \
|
|
||||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
|
||||||
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
|
||||||
tzdata==2026.3 ; sys_platform == 'win32' \
|
|
||||||
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
|
|
||||||
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
|
|
||||||
websockets==13.1 \
|
|
||||||
--hash=sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a \
|
|
||||||
--hash=sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54 \
|
|
||||||
--hash=sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23 \
|
|
||||||
--hash=sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7 \
|
|
||||||
--hash=sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135 \
|
|
||||||
--hash=sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700 \
|
|
||||||
--hash=sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf \
|
|
||||||
--hash=sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5 \
|
|
||||||
--hash=sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e \
|
|
||||||
--hash=sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c \
|
|
||||||
--hash=sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02 \
|
|
||||||
--hash=sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a \
|
|
||||||
--hash=sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418 \
|
|
||||||
--hash=sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f \
|
|
||||||
--hash=sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3 \
|
|
||||||
--hash=sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68 \
|
|
||||||
--hash=sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978 \
|
|
||||||
--hash=sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20 \
|
|
||||||
--hash=sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295 \
|
|
||||||
--hash=sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b \
|
|
||||||
--hash=sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6 \
|
|
||||||
--hash=sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb \
|
|
||||||
--hash=sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a \
|
|
||||||
--hash=sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa \
|
|
||||||
--hash=sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0 \
|
|
||||||
--hash=sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a \
|
|
||||||
--hash=sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238 \
|
|
||||||
--hash=sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c \
|
|
||||||
--hash=sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084 \
|
|
||||||
--hash=sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19 \
|
|
||||||
--hash=sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d \
|
|
||||||
--hash=sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7 \
|
|
||||||
--hash=sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9 \
|
|
||||||
--hash=sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79 \
|
|
||||||
--hash=sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96 \
|
|
||||||
--hash=sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6 \
|
|
||||||
--hash=sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe \
|
|
||||||
--hash=sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842 \
|
|
||||||
--hash=sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa \
|
|
||||||
--hash=sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3 \
|
|
||||||
--hash=sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d \
|
|
||||||
--hash=sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51 \
|
|
||||||
--hash=sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7 \
|
|
||||||
--hash=sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09 \
|
|
||||||
--hash=sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096 \
|
|
||||||
--hash=sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9 \
|
|
||||||
--hash=sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b \
|
|
||||||
--hash=sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5 \
|
|
||||||
--hash=sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678 \
|
|
||||||
--hash=sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea \
|
|
||||||
--hash=sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d \
|
|
||||||
--hash=sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49 \
|
|
||||||
--hash=sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc \
|
|
||||||
--hash=sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5 \
|
|
||||||
--hash=sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027 \
|
|
||||||
--hash=sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0 \
|
|
||||||
--hash=sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878 \
|
|
||||||
--hash=sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c \
|
|
||||||
--hash=sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa \
|
|
||||||
--hash=sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f \
|
|
||||||
--hash=sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6 \
|
|
||||||
--hash=sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2 \
|
|
||||||
--hash=sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf \
|
|
||||||
--hash=sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708 \
|
|
||||||
--hash=sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6 \
|
|
||||||
--hash=sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f \
|
|
||||||
--hash=sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd \
|
|
||||||
--hash=sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2 \
|
|
||||||
--hash=sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d \
|
|
||||||
--hash=sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7 \
|
|
||||||
--hash=sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f \
|
|
||||||
--hash=sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5 \
|
|
||||||
--hash=sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6 \
|
|
||||||
--hash=sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557 \
|
|
||||||
--hash=sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14 \
|
|
||||||
--hash=sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7 \
|
|
||||||
--hash=sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd \
|
|
||||||
--hash=sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c \
|
|
||||||
--hash=sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17 \
|
|
||||||
--hash=sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23 \
|
|
||||||
--hash=sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db \
|
|
||||||
--hash=sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6 \
|
|
||||||
--hash=sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d \
|
|
||||||
--hash=sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9 \
|
|
||||||
--hash=sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee \
|
|
||||||
--hash=sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6
|
|
||||||
yarl==1.24.5 \
|
|
||||||
--hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \
|
|
||||||
--hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \
|
|
||||||
--hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \
|
|
||||||
--hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \
|
|
||||||
--hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \
|
|
||||||
--hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \
|
|
||||||
--hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \
|
|
||||||
--hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \
|
|
||||||
--hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \
|
|
||||||
--hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \
|
|
||||||
--hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \
|
|
||||||
--hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \
|
|
||||||
--hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \
|
|
||||||
--hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \
|
|
||||||
--hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \
|
|
||||||
--hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \
|
|
||||||
--hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \
|
|
||||||
--hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \
|
|
||||||
--hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \
|
|
||||||
--hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \
|
|
||||||
--hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \
|
|
||||||
--hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \
|
|
||||||
--hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \
|
|
||||||
--hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \
|
|
||||||
--hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \
|
|
||||||
--hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \
|
|
||||||
--hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \
|
|
||||||
--hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \
|
|
||||||
--hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \
|
|
||||||
--hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \
|
|
||||||
--hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \
|
|
||||||
--hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \
|
|
||||||
--hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \
|
|
||||||
--hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \
|
|
||||||
--hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \
|
|
||||||
--hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \
|
|
||||||
--hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \
|
|
||||||
--hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \
|
|
||||||
--hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \
|
|
||||||
--hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \
|
|
||||||
--hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \
|
|
||||||
--hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \
|
|
||||||
--hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \
|
|
||||||
--hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \
|
|
||||||
--hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \
|
|
||||||
--hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \
|
|
||||||
--hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \
|
|
||||||
--hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \
|
|
||||||
--hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \
|
|
||||||
--hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \
|
|
||||||
--hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \
|
|
||||||
--hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \
|
|
||||||
--hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \
|
|
||||||
--hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \
|
|
||||||
--hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \
|
|
||||||
--hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \
|
|
||||||
--hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \
|
|
||||||
--hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \
|
|
||||||
--hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \
|
|
||||||
--hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \
|
|
||||||
--hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \
|
|
||||||
--hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \
|
|
||||||
--hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \
|
|
||||||
--hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \
|
|
||||||
--hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \
|
|
||||||
--hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \
|
|
||||||
--hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \
|
|
||||||
--hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \
|
|
||||||
--hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \
|
|
||||||
--hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \
|
|
||||||
--hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \
|
|
||||||
--hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \
|
|
||||||
--hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \
|
|
||||||
--hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \
|
|
||||||
--hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \
|
|
||||||
--hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \
|
|
||||||
--hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \
|
|
||||||
--hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \
|
|
||||||
--hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \
|
|
||||||
--hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \
|
|
||||||
--hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \
|
|
||||||
--hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \
|
|
||||||
--hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \
|
|
||||||
--hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \
|
|
||||||
--hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \
|
|
||||||
--hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \
|
|
||||||
--hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \
|
|
||||||
--hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \
|
|
||||||
--hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \
|
|
||||||
--hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \
|
|
||||||
--hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \
|
|
||||||
--hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \
|
|
||||||
--hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \
|
|
||||||
--hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \
|
|
||||||
--hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \
|
|
||||||
--hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \
|
|
||||||
--hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \
|
|
||||||
--hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \
|
|
||||||
--hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \
|
|
||||||
--hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \
|
|
||||||
--hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \
|
|
||||||
--hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \
|
|
||||||
--hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \
|
|
||||||
--hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
# app/requirements.txt
|
|
||||||
|
|
||||||
aiogram==3.13.1
|
aiogram==3.13.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
psycopg[binary]==3.2.9
|
psycopg[binary]==3.2.9
|
||||||
psycopg-pool==3.3.1
|
|
||||||
openpyxl==3.1.5
|
|
||||||
websockets==13.1
|
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
# app/scripts/check_ohlc_websocket.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from websockets.typing import Subprotocol
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
|
||||||
|
|
||||||
|
|
||||||
SYMBOLS = (
|
|
||||||
"BTC/USD_LEVERAGE",
|
|
||||||
)
|
|
||||||
|
|
||||||
INTERVALS = (
|
|
||||||
"1m",
|
|
||||||
"5m",
|
|
||||||
)
|
|
||||||
|
|
||||||
CANDLE_TYPE = "classic"
|
|
||||||
|
|
||||||
MAX_MESSAGES = 30
|
|
||||||
TOTAL_TIMEOUT_SECONDS = 300.0
|
|
||||||
RECEIVE_TIMEOUT_SECONDS = 15.0
|
|
||||||
|
|
||||||
|
|
||||||
def build_ws_url() -> str:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
raw_url = settings.exchange_ws_url or settings.exchange_base_url
|
|
||||||
|
|
||||||
if raw_url.startswith("https://"):
|
|
||||||
raw_url = raw_url.replace("https://", "wss://", 1)
|
|
||||||
elif raw_url.startswith("http://"):
|
|
||||||
raw_url = raw_url.replace("http://", "ws://", 1)
|
|
||||||
|
|
||||||
raw_url = raw_url.rstrip("/")
|
|
||||||
|
|
||||||
if raw_url.endswith("/connect"):
|
|
||||||
return raw_url
|
|
||||||
|
|
||||||
return f"{raw_url}/connect"
|
|
||||||
|
|
||||||
|
|
||||||
def build_headers() -> dict[str, str]:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Origin": settings.exchange_base_url.rstrip("/"),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.exchange_api_key:
|
|
||||||
headers["X-MBX-APIKEY"] = settings.exchange_api_key
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def build_subscribe_request(
|
|
||||||
symbols: tuple[str, ...],
|
|
||||||
intervals: tuple[str, ...],
|
|
||||||
candle_type: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"correlationId": str(uuid4()),
|
|
||||||
"destination": "OHLCMarketData.subscribe",
|
|
||||||
"payload": {
|
|
||||||
"intervals": list(intervals),
|
|
||||||
"symbols": list(symbols),
|
|
||||||
"type": candle_type,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def message_to_text(
|
|
||||||
raw_message: str | bytes | bytearray | memoryview,
|
|
||||||
) -> str:
|
|
||||||
if isinstance(raw_message, str):
|
|
||||||
return raw_message
|
|
||||||
|
|
||||||
if isinstance(raw_message, memoryview):
|
|
||||||
return raw_message.tobytes().decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
|
|
||||||
return bytes(raw_message).decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_document(
|
|
||||||
raw_message: str | bytes | bytearray | memoryview,
|
|
||||||
) -> object | None:
|
|
||||||
text = message_to_text(raw_message)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_json(
|
|
||||||
raw_message: str | bytes | bytearray | memoryview,
|
|
||||||
) -> str:
|
|
||||||
text = message_to_text(raw_message)
|
|
||||||
|
|
||||||
try:
|
|
||||||
document = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return text
|
|
||||||
|
|
||||||
return json.dumps(
|
|
||||||
document,
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def destination_from_document(document: object) -> str | None:
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
direct_destination = document.get("destination")
|
|
||||||
|
|
||||||
if isinstance(direct_destination, str):
|
|
||||||
return direct_destination
|
|
||||||
|
|
||||||
upper_destination = document.get("Destination")
|
|
||||||
|
|
||||||
if isinstance(upper_destination, str):
|
|
||||||
return upper_destination
|
|
||||||
|
|
||||||
payload = document.get("payload")
|
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
nested_destination = payload.get("destination")
|
|
||||||
|
|
||||||
if isinstance(nested_destination, str):
|
|
||||||
return nested_destination
|
|
||||||
|
|
||||||
nested_upper_destination = payload.get("Destination")
|
|
||||||
|
|
||||||
if isinstance(nested_upper_destination, str):
|
|
||||||
return nested_upper_destination
|
|
||||||
|
|
||||||
upper_payload = document.get("Payload")
|
|
||||||
|
|
||||||
if isinstance(upper_payload, dict):
|
|
||||||
nested_destination = upper_payload.get("destination")
|
|
||||||
|
|
||||||
if isinstance(nested_destination, str):
|
|
||||||
return nested_destination
|
|
||||||
|
|
||||||
nested_upper_destination = upper_payload.get("Destination")
|
|
||||||
|
|
||||||
if isinstance(nested_upper_destination, str):
|
|
||||||
return nested_upper_destination
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_ohlc_event(document: object) -> bool:
|
|
||||||
destination = destination_from_document(document)
|
|
||||||
|
|
||||||
if destination is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return destination.lower() == "ohlc.event"
|
|
||||||
|
|
||||||
|
|
||||||
def elapsed_seconds(
|
|
||||||
loop: asyncio.AbstractEventLoop,
|
|
||||||
started_at: float,
|
|
||||||
) -> float:
|
|
||||||
return max(0.0, loop.time() - started_at)
|
|
||||||
|
|
||||||
|
|
||||||
async def receive_ohlc() -> None:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
ws_url = build_ws_url()
|
|
||||||
headers = build_headers()
|
|
||||||
|
|
||||||
request = build_subscribe_request(
|
|
||||||
SYMBOLS,
|
|
||||||
INTERVALS,
|
|
||||||
CANDLE_TYPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"WebSocket URL: {ws_url}")
|
|
||||||
print(f"Symbols: {', '.join(SYMBOLS)}")
|
|
||||||
print(f"Intervals: {', '.join(INTERVALS)}")
|
|
||||||
print(f"Candle type: {CANDLE_TYPE}")
|
|
||||||
print(f"Maximum messages: {MAX_MESSAGES}")
|
|
||||||
print(f"Total timeout: {TOTAL_TIMEOUT_SECONDS:.0f} seconds")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("Subscription request:")
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
request,
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print()
|
|
||||||
|
|
||||||
async with websockets.connect(
|
|
||||||
ws_url,
|
|
||||||
extra_headers=headers,
|
|
||||||
subprotocols=[Subprotocol("json")],
|
|
||||||
ping_interval=20,
|
|
||||||
ping_timeout=float(settings.exchange_timeout_sec),
|
|
||||||
open_timeout=float(settings.exchange_timeout_sec),
|
|
||||||
close_timeout=float(settings.exchange_timeout_sec),
|
|
||||||
) as websocket:
|
|
||||||
await websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
request,
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
print("Subscription request sent.")
|
|
||||||
print("Waiting for acknowledgement and OHLC events...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
started_at = loop.time()
|
|
||||||
deadline = started_at + TOTAL_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
received_count = 0
|
|
||||||
ohlc_event_count = 0
|
|
||||||
timeout_count = 0
|
|
||||||
|
|
||||||
while received_count < MAX_MESSAGES:
|
|
||||||
remaining_seconds = deadline - loop.time()
|
|
||||||
|
|
||||||
if remaining_seconds <= 0:
|
|
||||||
print(
|
|
||||||
"Total timeout reached after "
|
|
||||||
f"{elapsed_seconds(loop, started_at):.1f} seconds."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
receive_timeout = min(
|
|
||||||
RECEIVE_TIMEOUT_SECONDS,
|
|
||||||
remaining_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_message = await asyncio.wait_for(
|
|
||||||
websocket.recv(),
|
|
||||||
timeout=receive_timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
timeout_count += 1
|
|
||||||
|
|
||||||
print(
|
|
||||||
"No OHLC event yet; connection is alive. "
|
|
||||||
f"Elapsed: {elapsed_seconds(loop, started_at):.1f}s, "
|
|
||||||
f"remaining: {max(0.0, remaining_seconds):.1f}s."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not isinstance(
|
|
||||||
raw_message,
|
|
||||||
(str, bytes, bytearray, memoryview),
|
|
||||||
):
|
|
||||||
print(
|
|
||||||
"Skipped unsupported WebSocket message type: "
|
|
||||||
f"{type(raw_message).__name__}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
received_count += 1
|
|
||||||
document = decode_document(raw_message)
|
|
||||||
|
|
||||||
if is_ohlc_event(document):
|
|
||||||
ohlc_event_count += 1
|
|
||||||
message_kind = "OHLC EVENT"
|
|
||||||
else:
|
|
||||||
message_kind = "CONTROL / ACKNOWLEDGEMENT"
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print(
|
|
||||||
f"Message #{received_count} — {message_kind} — "
|
|
||||||
f"elapsed {elapsed_seconds(loop, started_at):.1f}s"
|
|
||||||
)
|
|
||||||
print(format_json(raw_message))
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("Diagnostic summary")
|
|
||||||
print(f"Received messages: {received_count}")
|
|
||||||
print(f"OHLC events: {ohlc_event_count}")
|
|
||||||
print(f"Receive timeouts: {timeout_count}")
|
|
||||||
print(
|
|
||||||
"Elapsed time: "
|
|
||||||
f"{elapsed_seconds(loop, started_at):.1f} seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
try:
|
|
||||||
asyncio.run(receive_ohlc())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print()
|
|
||||||
print("Stopped by user.")
|
|
||||||
except Exception as exc:
|
|
||||||
print()
|
|
||||||
print(
|
|
||||||
"WebSocket diagnostic failed: "
|
|
||||||
f"{type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,214 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from websockets.typing import Subprotocol
|
|
||||||
|
|
||||||
|
|
||||||
SYMBOL = os.getenv(
|
|
||||||
"TRADE_TEST_SYMBOL",
|
|
||||||
"BTC/USD_LEVERAGE",
|
|
||||||
)
|
|
||||||
|
|
||||||
OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS = 30
|
|
||||||
OBSERVE_AFTER_UNSUBSCRIBE_SECONDS = 60
|
|
||||||
RECEIVE_TIMEOUT_SECONDS = 10
|
|
||||||
|
|
||||||
|
|
||||||
def build_ws_url() -> str:
|
|
||||||
raw_url = os.getenv(
|
|
||||||
"EXCHANGE_WS_URL",
|
|
||||||
"wss://api-adapter.dzengi.com",
|
|
||||||
).rstrip("/")
|
|
||||||
|
|
||||||
if raw_url.startswith("https://"):
|
|
||||||
raw_url = raw_url.replace("https://", "wss://", 1)
|
|
||||||
elif raw_url.startswith("http://"):
|
|
||||||
raw_url = raw_url.replace("http://", "ws://", 1)
|
|
||||||
|
|
||||||
if raw_url.endswith("/connect"):
|
|
||||||
return raw_url
|
|
||||||
|
|
||||||
return f"{raw_url}/connect"
|
|
||||||
|
|
||||||
|
|
||||||
def build_headers() -> dict[str, str]:
|
|
||||||
base_url = os.getenv(
|
|
||||||
"EXCHANGE_BASE_URL",
|
|
||||||
"https://api-adapter.dzengi.com",
|
|
||||||
).rstrip("/")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Origin": base_url,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
api_key = os.getenv("EXCHANGE_API_KEY", "").strip()
|
|
||||||
|
|
||||||
if api_key:
|
|
||||||
headers["X-MBX-APIKEY"] = api_key
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def build_request(destination: str) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"correlationId": str(uuid4()),
|
|
||||||
"destination": destination,
|
|
||||||
"payload": {
|
|
||||||
"symbols": [SYMBOL],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def decode_message(raw_message: str | bytes) -> Any:
|
|
||||||
if isinstance(raw_message, bytes):
|
|
||||||
raw_message = raw_message.decode("utf-8")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(raw_message)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return raw_message
|
|
||||||
|
|
||||||
|
|
||||||
def print_message(label: str, message: Any) -> None:
|
|
||||||
print()
|
|
||||||
print("=" * 80)
|
|
||||||
print(label)
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
if isinstance(message, (dict, list)):
|
|
||||||
print(json.dumps(message, indent=2, ensure_ascii=False))
|
|
||||||
else:
|
|
||||||
print(message)
|
|
||||||
|
|
||||||
|
|
||||||
def is_trade_event(message: Any) -> bool:
|
|
||||||
return (
|
|
||||||
isinstance(message, dict)
|
|
||||||
and message.get("destination") == "internal.trade"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def receive_until(
|
|
||||||
websocket: Any,
|
|
||||||
*,
|
|
||||||
duration_seconds: int,
|
|
||||||
phase: str,
|
|
||||||
) -> int:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
deadline = loop.time() + duration_seconds
|
|
||||||
trade_count = 0
|
|
||||||
|
|
||||||
while loop.time() < deadline:
|
|
||||||
remaining = deadline - loop.time()
|
|
||||||
timeout = min(RECEIVE_TIMEOUT_SECONDS, remaining)
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_message = await asyncio.wait_for(
|
|
||||||
websocket.recv(),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
print(
|
|
||||||
f"[{phase}] За последние {timeout:.0f} секунд "
|
|
||||||
"сообщений не получено; соединение остаётся открытым."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
message = decode_message(raw_message)
|
|
||||||
print_message(f"[{phase}] Получено сообщение", message)
|
|
||||||
|
|
||||||
if is_trade_event(message):
|
|
||||||
trade_count += 1
|
|
||||||
|
|
||||||
return trade_count
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
ws_url = build_ws_url()
|
|
||||||
headers = build_headers()
|
|
||||||
|
|
||||||
subscribe_request = build_request("trades.subscribe")
|
|
||||||
unsubscribe_request = build_request("trades.unsubscribe")
|
|
||||||
|
|
||||||
print(f"WebSocket URL: {ws_url}")
|
|
||||||
print(f"Symbol: {SYMBOL}")
|
|
||||||
|
|
||||||
async with websockets.connect(
|
|
||||||
ws_url,
|
|
||||||
extra_headers=headers,
|
|
||||||
subprotocols=[Subprotocol("json")],
|
|
||||||
ping_interval=20,
|
|
||||||
ping_timeout=20,
|
|
||||||
open_timeout=20,
|
|
||||||
) as websocket:
|
|
||||||
print_message(
|
|
||||||
"Отправляется trades.subscribe",
|
|
||||||
subscribe_request,
|
|
||||||
)
|
|
||||||
|
|
||||||
await websocket.send(
|
|
||||||
json.dumps(subscribe_request),
|
|
||||||
)
|
|
||||||
|
|
||||||
before_count = await receive_until(
|
|
||||||
websocket,
|
|
||||||
duration_seconds=OBSERVE_BEFORE_UNSUBSCRIBE_SECONDS,
|
|
||||||
phase="BEFORE UNSUBSCRIBE",
|
|
||||||
)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(
|
|
||||||
"Количество Trade Event до unsubscribe: "
|
|
||||||
f"{before_count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
print_message(
|
|
||||||
"Отправляется trades.unsubscribe",
|
|
||||||
unsubscribe_request,
|
|
||||||
)
|
|
||||||
|
|
||||||
await websocket.send(
|
|
||||||
json.dumps(unsubscribe_request),
|
|
||||||
)
|
|
||||||
|
|
||||||
after_count = await receive_until(
|
|
||||||
websocket,
|
|
||||||
duration_seconds=OBSERVE_AFTER_UNSUBSCRIBE_SECONDS,
|
|
||||||
phase="AFTER UNSUBSCRIBE",
|
|
||||||
)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 80)
|
|
||||||
print("РЕЗУЛЬТАТ НАБЛЮДЕНИЯ")
|
|
||||||
print("=" * 80)
|
|
||||||
print(f"Trade Event до unsubscribe: {before_count}")
|
|
||||||
print(f"Trade Event после unsubscribe: {after_count}")
|
|
||||||
|
|
||||||
if before_count == 0:
|
|
||||||
print(
|
|
||||||
"До unsubscribe не было получено ни одной сделки. "
|
|
||||||
"Эксперимент нельзя считать доказательным."
|
|
||||||
)
|
|
||||||
elif after_count == 0:
|
|
||||||
print(
|
|
||||||
"После trades.unsubscribe новые сделки не поступили. "
|
|
||||||
"Команда, вероятно, поддерживается, но необходимо "
|
|
||||||
"проверить ACK или повторить тест при высокой активности."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
"После trades.unsubscribe сделки продолжили поступать. "
|
|
||||||
"Команда либо не поддерживается, либо была отклонена, "
|
|
||||||
"либо имеет другой формат."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
# app/scripts/check_trades_websocket.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from websockets.typing import Subprotocol
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
|
||||||
|
|
||||||
|
|
||||||
SYMBOLS = (
|
|
||||||
"BTC/USD_LEVERAGE",
|
|
||||||
)
|
|
||||||
|
|
||||||
MAX_MESSAGES = 100
|
|
||||||
TOTAL_TIMEOUT_SECONDS = 3000.0
|
|
||||||
RECEIVE_TIMEOUT_SECONDS = 15.0
|
|
||||||
|
|
||||||
|
|
||||||
def build_ws_url() -> str:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
raw_url = settings.exchange_ws_url or settings.exchange_base_url
|
|
||||||
|
|
||||||
if raw_url.startswith("https://"):
|
|
||||||
raw_url = raw_url.replace("https://", "wss://", 1)
|
|
||||||
elif raw_url.startswith("http://"):
|
|
||||||
raw_url = raw_url.replace("http://", "ws://", 1)
|
|
||||||
|
|
||||||
raw_url = raw_url.rstrip("/")
|
|
||||||
|
|
||||||
if raw_url.endswith("/connect"):
|
|
||||||
return raw_url
|
|
||||||
|
|
||||||
return f"{raw_url}/connect"
|
|
||||||
|
|
||||||
|
|
||||||
def build_headers() -> dict[str, str]:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Origin": settings.exchange_base_url.rstrip("/"),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.exchange_api_key:
|
|
||||||
headers["X-MBX-APIKEY"] = settings.exchange_api_key
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def build_subscribe_request(
|
|
||||||
symbols: tuple[str, ...],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"correlationId": str(uuid4()),
|
|
||||||
"destination": "trades.subscribe",
|
|
||||||
"payload": {
|
|
||||||
"symbols": list(symbols),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def format_json(
|
|
||||||
raw_message: str | bytes | bytearray | memoryview,
|
|
||||||
) -> str:
|
|
||||||
if isinstance(raw_message, str):
|
|
||||||
text = raw_message
|
|
||||||
elif isinstance(raw_message, memoryview):
|
|
||||||
text = raw_message.tobytes().decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
text = bytes(raw_message).decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
document = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return text
|
|
||||||
|
|
||||||
return json.dumps(
|
|
||||||
document,
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_document(
|
|
||||||
raw_message: str | bytes | bytearray | memoryview,
|
|
||||||
) -> object | None:
|
|
||||||
if isinstance(raw_message, str):
|
|
||||||
text = raw_message
|
|
||||||
elif isinstance(raw_message, memoryview):
|
|
||||||
text = raw_message.tobytes().decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
text = bytes(raw_message).decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_trade_event(document: object) -> bool:
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return document.get("destination") == "internal.trade"
|
|
||||||
|
|
||||||
|
|
||||||
def elapsed_seconds(
|
|
||||||
loop: asyncio.AbstractEventLoop,
|
|
||||||
started_at: float,
|
|
||||||
) -> float:
|
|
||||||
return max(0.0, loop.time() - started_at)
|
|
||||||
|
|
||||||
|
|
||||||
async def receive_trades() -> None:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
ws_url = build_ws_url()
|
|
||||||
headers = build_headers()
|
|
||||||
request = build_subscribe_request(SYMBOLS)
|
|
||||||
|
|
||||||
print(f"WebSocket URL: {ws_url}")
|
|
||||||
print(f"Symbols: {', '.join(SYMBOLS)}")
|
|
||||||
print(f"Maximum messages: {MAX_MESSAGES}")
|
|
||||||
print(f"Total timeout: {TOTAL_TIMEOUT_SECONDS:.0f} seconds")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("Subscription request:")
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
request,
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
print()
|
|
||||||
|
|
||||||
async with websockets.connect(
|
|
||||||
ws_url,
|
|
||||||
extra_headers=headers,
|
|
||||||
subprotocols=[Subprotocol("json")],
|
|
||||||
ping_interval=20,
|
|
||||||
ping_timeout=float(settings.exchange_timeout_sec),
|
|
||||||
open_timeout=float(settings.exchange_timeout_sec),
|
|
||||||
close_timeout=float(settings.exchange_timeout_sec),
|
|
||||||
) as websocket:
|
|
||||||
await websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
request,
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
print("Subscription request sent.")
|
|
||||||
print("Waiting for acknowledgement and trade events...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
started_at = loop.time()
|
|
||||||
deadline = started_at + TOTAL_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
received_count = 0
|
|
||||||
trade_event_count = 0
|
|
||||||
timeout_count = 0
|
|
||||||
|
|
||||||
while received_count < MAX_MESSAGES:
|
|
||||||
remaining_seconds = deadline - loop.time()
|
|
||||||
|
|
||||||
if remaining_seconds <= 0:
|
|
||||||
print(
|
|
||||||
"Total timeout reached after "
|
|
||||||
f"{elapsed_seconds(loop, started_at):.1f} seconds."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
receive_timeout = min(
|
|
||||||
RECEIVE_TIMEOUT_SECONDS,
|
|
||||||
remaining_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_message = await asyncio.wait_for(
|
|
||||||
websocket.recv(),
|
|
||||||
timeout=receive_timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
timeout_count += 1
|
|
||||||
|
|
||||||
print(
|
|
||||||
"No trade event yet; connection is alive. "
|
|
||||||
f"Elapsed: {elapsed_seconds(loop, started_at):.1f}s, "
|
|
||||||
f"remaining: {max(0.0, remaining_seconds):.1f}s."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not isinstance(
|
|
||||||
raw_message,
|
|
||||||
(str, bytes, bytearray, memoryview),
|
|
||||||
):
|
|
||||||
print(
|
|
||||||
"Skipped unsupported WebSocket message type: "
|
|
||||||
f"{type(raw_message).__name__}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
received_count += 1
|
|
||||||
document = decode_document(raw_message)
|
|
||||||
|
|
||||||
if is_trade_event(document):
|
|
||||||
trade_event_count += 1
|
|
||||||
message_kind = "TRADE EVENT"
|
|
||||||
else:
|
|
||||||
message_kind = "CONTROL / ACKNOWLEDGEMENT"
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print(
|
|
||||||
f"Message #{received_count} — {message_kind} — "
|
|
||||||
f"elapsed {elapsed_seconds(loop, started_at):.1f}s"
|
|
||||||
)
|
|
||||||
print(format_json(raw_message))
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("Diagnostic summary")
|
|
||||||
print(f"Received messages: {received_count}")
|
|
||||||
print(f"Trade events: {trade_event_count}")
|
|
||||||
print(f"Receive timeouts: {timeout_count}")
|
|
||||||
print(
|
|
||||||
"Elapsed time: "
|
|
||||||
f"{elapsed_seconds(loop, started_at):.1f} seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
try:
|
|
||||||
asyncio.run(receive_trades())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print()
|
|
||||||
print("Stopped by user.")
|
|
||||||
except Exception as exc:
|
|
||||||
print()
|
|
||||||
print(
|
|
||||||
"WebSocket diagnostic failed: "
|
|
||||||
f"{type(exc).__name__}: {exc}"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# app/scripts/get_ticker_24hr.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
|
||||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
settings = load_settings()
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Получить реальный ответ Dzengi ticker/24hr.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"symbol",
|
|
||||||
nargs="?",
|
|
||||||
default=settings.default_symbol,
|
|
||||||
help=(
|
|
||||||
"Торговый символ. "
|
|
||||||
f"По умолчанию: {settings.default_symbol}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
symbol = str(args.symbol).strip()
|
|
||||||
|
|
||||||
if not symbol:
|
|
||||||
print(
|
|
||||||
"Торговый символ не должен быть пустым.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = ExchangeRestClient().get_json(
|
|
||||||
"/api/v1/ticker/24hr",
|
|
||||||
params={
|
|
||||||
"symbol": symbol,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
print(
|
|
||||||
f"Не удалось получить ticker/24hr для {symbol}: "
|
|
||||||
f"{type(exc).__name__}: {exc}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
payload,
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,28 +1,17 @@
|
|||||||
# app/src/bootstrap/app_factory.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
|
||||||
from src.bootstrap.application import ApplicationComposition
|
|
||||||
from src.bootstrap.logging import setup_logging
|
from src.bootstrap.logging import setup_logging
|
||||||
from src.bootstrap.market_data_storage import (
|
|
||||||
build_market_data_storage,
|
|
||||||
)
|
|
||||||
from src.bootstrap.trade_stream_runtime import (
|
|
||||||
build_trade_stream_production_runtime,
|
|
||||||
)
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.notifications.targets import NotificationTargetRegistry
|
|
||||||
from src.storage.schema import init_schema
|
from src.storage.schema import init_schema
|
||||||
from src.telegram.routers import setup_routers
|
from src.telegram.routers import setup_routers
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> ApplicationComposition:
|
def create_app() -> tuple[Bot, Dispatcher]:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
|
||||||
setup_logging(settings.log_level)
|
setup_logging(settings.log_level)
|
||||||
|
|
||||||
journal = JournalService()
|
journal = JournalService()
|
||||||
@@ -44,58 +33,25 @@ def create_app() -> ApplicationComposition:
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
market_data_storage = build_market_data_storage(settings)
|
try:
|
||||||
trade_stream_runtime = build_trade_stream_production_runtime(
|
journal.log_info(
|
||||||
settings,
|
"app_start",
|
||||||
trade_observation_sink=(
|
"Приложение запущено.",
|
||||||
market_data_storage.trade_observation_sink
|
{
|
||||||
if market_data_storage is not None
|
"env": settings.app_env,
|
||||||
else None
|
"exchange_name": settings.exchange_name,
|
||||||
),
|
"default_symbol": settings.default_symbol,
|
||||||
checkpoint_storage=(
|
},
|
||||||
market_data_storage.trade_repository
|
|
||||||
if market_data_storage is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
# журнал не должен ломать запуск приложения
|
||||||
|
pass
|
||||||
|
|
||||||
bot = Bot(
|
bot = Bot(
|
||||||
token=settings.bot_token,
|
token=settings.bot_token,
|
||||||
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),
|
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),
|
||||||
)
|
)
|
||||||
|
|
||||||
NotificationTargetRegistry.set_bot(bot)
|
|
||||||
|
|
||||||
dispatcher = Dispatcher()
|
dispatcher = Dispatcher()
|
||||||
|
|
||||||
setup_routers(dispatcher)
|
setup_routers(dispatcher)
|
||||||
|
|
||||||
try:
|
return bot, dispatcher
|
||||||
journal.log_info(
|
|
||||||
"app_started",
|
|
||||||
"Приложение запущено",
|
|
||||||
{
|
|
||||||
"env": settings.app_env,
|
|
||||||
"exchange_name": settings.exchange_name,
|
|
||||||
"default_symbol": settings.default_symbol,
|
|
||||||
"trade_stream_enabled": (
|
|
||||||
settings.trade_stream.enabled
|
|
||||||
),
|
|
||||||
"market_data_storage_enabled": (
|
|
||||||
settings.market_data_storage.enabled
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ApplicationComposition(
|
|
||||||
bot=bot,
|
|
||||||
dispatcher=dispatcher,
|
|
||||||
trade_stream_runtime=trade_stream_runtime,
|
|
||||||
market_data_storage_lifecycle=(
|
|
||||||
market_data_storage.lifecycle
|
|
||||||
if market_data_storage is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
|
||||||
|
|
||||||
from src.bootstrap.market_data_storage import (
|
|
||||||
MarketDataStorageLifecycleProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
|
||||||
TradeStreamProductionRuntimeProtocol,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ApplicationComposition:
|
|
||||||
"""Корневые компоненты одного запущенного экземпляра приложения."""
|
|
||||||
|
|
||||||
bot: Bot
|
|
||||||
dispatcher: Dispatcher
|
|
||||||
trade_stream_runtime: (
|
|
||||||
TradeStreamProductionRuntimeProtocol | None
|
|
||||||
)
|
|
||||||
market_data_storage_lifecycle: (
|
|
||||||
MarketDataStorageLifecycleProtocol | None
|
|
||||||
) = None
|
|
||||||
|
|
||||||
|
|
||||||
async def run_application(
|
|
||||||
application: ApplicationComposition,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Выполнять Telegram polling и опциональный Trade Stream как одно целое.
|
|
||||||
|
|
||||||
Ошибка включённого Trade Stream считается фатальной для всего
|
|
||||||
приложения. Остановка Telegram, Trade Stream и bot session
|
|
||||||
выполняется одним владельцем и не оставляет фоновых root tasks.
|
|
||||||
"""
|
|
||||||
polling_task: asyncio.Task[None] | None = None
|
|
||||||
runtime_task: asyncio.Task[None] | None = None
|
|
||||||
primary_error: BaseException | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
storage_lifecycle = (
|
|
||||||
application.market_data_storage_lifecycle
|
|
||||||
)
|
|
||||||
|
|
||||||
if storage_lifecycle is not None:
|
|
||||||
await _run_blocking_lifecycle_operation(
|
|
||||||
storage_lifecycle.start,
|
|
||||||
task_name="market-data-storage-startup",
|
|
||||||
)
|
|
||||||
|
|
||||||
polling_task = asyncio.create_task(
|
|
||||||
application.dispatcher.start_polling(
|
|
||||||
application.bot,
|
|
||||||
close_bot_session=False,
|
|
||||||
),
|
|
||||||
name="telegram-polling",
|
|
||||||
)
|
|
||||||
runtime_task = (
|
|
||||||
asyncio.create_task(
|
|
||||||
application.trade_stream_runtime.run(),
|
|
||||||
name="trade-stream-runtime",
|
|
||||||
)
|
|
||||||
if application.trade_stream_runtime is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
await _wait_for_root_tasks(
|
|
||||||
polling_task=polling_task,
|
|
||||||
runtime_task=runtime_task,
|
|
||||||
)
|
|
||||||
except BaseException as error:
|
|
||||||
primary_error = error
|
|
||||||
|
|
||||||
cleanup_task = asyncio.create_task(
|
|
||||||
_shutdown_application(
|
|
||||||
application=application,
|
|
||||||
polling_task=polling_task,
|
|
||||||
runtime_task=runtime_task,
|
|
||||||
primary_error=primary_error,
|
|
||||||
),
|
|
||||||
name="application-shutdown",
|
|
||||||
)
|
|
||||||
cleanup_error = await _await_cleanup(
|
|
||||||
cleanup_task,
|
|
||||||
primary_error=primary_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
if primary_error is not None:
|
|
||||||
if cleanup_error is not None:
|
|
||||||
primary_error.add_note(
|
|
||||||
"Application cleanup also failed: "
|
|
||||||
f"{type(cleanup_error).__name__}."
|
|
||||||
)
|
|
||||||
|
|
||||||
raise primary_error.with_traceback(
|
|
||||||
primary_error.__traceback__,
|
|
||||||
)
|
|
||||||
|
|
||||||
if cleanup_error is not None:
|
|
||||||
raise cleanup_error.with_traceback(
|
|
||||||
cleanup_error.__traceback__,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_root_tasks(
|
|
||||||
*,
|
|
||||||
polling_task: asyncio.Task[None],
|
|
||||||
runtime_task: asyncio.Task[None] | None,
|
|
||||||
) -> None:
|
|
||||||
if runtime_task is None:
|
|
||||||
await polling_task
|
|
||||||
return
|
|
||||||
|
|
||||||
done_tasks, _ = await asyncio.wait(
|
|
||||||
(
|
|
||||||
polling_task,
|
|
||||||
runtime_task,
|
|
||||||
),
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
|
|
||||||
if runtime_task in done_tasks:
|
|
||||||
if runtime_task.cancelled():
|
|
||||||
raise RuntimeError(
|
|
||||||
"Trade Stream Runtime was cancelled unexpectedly."
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime_error = runtime_task.exception()
|
|
||||||
|
|
||||||
if runtime_error is not None:
|
|
||||||
raise runtime_error.with_traceback(
|
|
||||||
runtime_error.__traceback__,
|
|
||||||
)
|
|
||||||
|
|
||||||
raise RuntimeError(
|
|
||||||
"Trade Stream Runtime terminated unexpectedly."
|
|
||||||
)
|
|
||||||
|
|
||||||
await polling_task
|
|
||||||
|
|
||||||
|
|
||||||
async def _shutdown_application(
|
|
||||||
*,
|
|
||||||
application: ApplicationComposition,
|
|
||||||
polling_task: asyncio.Task[None] | None,
|
|
||||||
runtime_task: asyncio.Task[None] | None,
|
|
||||||
primary_error: BaseException | None,
|
|
||||||
) -> BaseException | None:
|
|
||||||
cleanup_error: BaseException | None = None
|
|
||||||
polling_cancelled = False
|
|
||||||
runtime_cancelled = False
|
|
||||||
|
|
||||||
if polling_task is not None and not polling_task.done():
|
|
||||||
polling_cancelled = True
|
|
||||||
polling_task.cancel()
|
|
||||||
|
|
||||||
runtime = application.trade_stream_runtime
|
|
||||||
|
|
||||||
if runtime is not None:
|
|
||||||
try:
|
|
||||||
await runtime.stop()
|
|
||||||
except BaseException as error:
|
|
||||||
cleanup_error = _merge_cleanup_error(
|
|
||||||
cleanup_error,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
if runtime_task is not None and not runtime_task.done():
|
|
||||||
runtime_cancelled = True
|
|
||||||
runtime_task.cancel()
|
|
||||||
|
|
||||||
if polling_task is not None:
|
|
||||||
cleanup_error = await _observe_root_task(
|
|
||||||
polling_task,
|
|
||||||
expected_cancellation=polling_cancelled,
|
|
||||||
primary_error=primary_error,
|
|
||||||
cleanup_error=cleanup_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
if runtime_task is not None:
|
|
||||||
cleanup_error = await _observe_root_task(
|
|
||||||
runtime_task,
|
|
||||||
expected_cancellation=runtime_cancelled,
|
|
||||||
primary_error=primary_error,
|
|
||||||
cleanup_error=cleanup_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
storage_lifecycle = application.market_data_storage_lifecycle
|
|
||||||
|
|
||||||
if storage_lifecycle is not None:
|
|
||||||
try:
|
|
||||||
await _run_blocking_lifecycle_operation(
|
|
||||||
storage_lifecycle.stop,
|
|
||||||
task_name="market-data-storage-shutdown",
|
|
||||||
)
|
|
||||||
except BaseException as error:
|
|
||||||
cleanup_error = _merge_cleanup_error(
|
|
||||||
cleanup_error,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await application.bot.session.close()
|
|
||||||
except BaseException as error:
|
|
||||||
cleanup_error = _merge_cleanup_error(
|
|
||||||
cleanup_error,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return cleanup_error
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_blocking_lifecycle_operation(
|
|
||||||
operation: Callable[[], None],
|
|
||||||
*,
|
|
||||||
task_name: str,
|
|
||||||
) -> None:
|
|
||||||
operation_task = asyncio.create_task(
|
|
||||||
asyncio.to_thread(operation),
|
|
||||||
name=task_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.shield(operation_task)
|
|
||||||
except asyncio.CancelledError as cancellation:
|
|
||||||
try:
|
|
||||||
await _wait_for_blocking_operation(operation_task)
|
|
||||||
except BaseException as operation_error:
|
|
||||||
cancellation.add_note(
|
|
||||||
"Application blocking lifecycle operation also failed: "
|
|
||||||
f"{type(operation_error).__name__}."
|
|
||||||
)
|
|
||||||
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_blocking_operation(
|
|
||||||
operation_task: asyncio.Task[None],
|
|
||||||
) -> None:
|
|
||||||
while not operation_task.done():
|
|
||||||
try:
|
|
||||||
await asyncio.shield(operation_task)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
continue
|
|
||||||
except BaseException:
|
|
||||||
break
|
|
||||||
|
|
||||||
operation_task.result()
|
|
||||||
|
|
||||||
|
|
||||||
async def _observe_root_task(
|
|
||||||
task: asyncio.Task[None],
|
|
||||||
*,
|
|
||||||
expected_cancellation: bool,
|
|
||||||
primary_error: BaseException | None,
|
|
||||||
cleanup_error: BaseException | None,
|
|
||||||
) -> BaseException | None:
|
|
||||||
try:
|
|
||||||
await task
|
|
||||||
except asyncio.CancelledError as error:
|
|
||||||
if (
|
|
||||||
not expected_cancellation
|
|
||||||
and error is not primary_error
|
|
||||||
and not isinstance(primary_error, asyncio.CancelledError)
|
|
||||||
):
|
|
||||||
return _merge_cleanup_error(
|
|
||||||
cleanup_error,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
except BaseException as error:
|
|
||||||
if error is not primary_error:
|
|
||||||
return _merge_cleanup_error(
|
|
||||||
cleanup_error,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return cleanup_error
|
|
||||||
|
|
||||||
|
|
||||||
async def _await_cleanup(
|
|
||||||
cleanup_task: asyncio.Task[BaseException | None],
|
|
||||||
*,
|
|
||||||
primary_error: BaseException | None,
|
|
||||||
) -> BaseException | None:
|
|
||||||
interrupted_error: asyncio.CancelledError | None = None
|
|
||||||
|
|
||||||
while not cleanup_task.done():
|
|
||||||
try:
|
|
||||||
await asyncio.shield(cleanup_task)
|
|
||||||
except asyncio.CancelledError as error:
|
|
||||||
if primary_error is None:
|
|
||||||
primary_error = error
|
|
||||||
interrupted_error = error
|
|
||||||
continue
|
|
||||||
|
|
||||||
cleanup_error = cleanup_task.result()
|
|
||||||
|
|
||||||
if interrupted_error is not None:
|
|
||||||
if cleanup_error is not None:
|
|
||||||
interrupted_error.add_note(
|
|
||||||
"Application cleanup also failed: "
|
|
||||||
f"{type(cleanup_error).__name__}."
|
|
||||||
)
|
|
||||||
|
|
||||||
raise interrupted_error
|
|
||||||
|
|
||||||
return cleanup_error
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_cleanup_error(
|
|
||||||
current_error: BaseException | None,
|
|
||||||
new_error: BaseException,
|
|
||||||
) -> BaseException:
|
|
||||||
if current_error is None:
|
|
||||||
return new_error
|
|
||||||
|
|
||||||
current_error.add_note(
|
|
||||||
"Additional application cleanup failure: "
|
|
||||||
f"{type(new_error).__name__}."
|
|
||||||
)
|
|
||||||
return current_error
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# app/src/bootstrap/logging.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from psycopg.conninfo import make_conninfo
|
|
||||||
|
|
||||||
from src.core.config import Settings
|
|
||||||
from src.market_data.storage.postgres_trade_repository import (
|
|
||||||
PostgresTradeRepository,
|
|
||||||
)
|
|
||||||
from src.market_data.storage.trade_storage_observation_sink import (
|
|
||||||
TradeStorageObservationSink,
|
|
||||||
)
|
|
||||||
from src.storage.migrations import StorageMigrationRunner
|
|
||||||
from src.storage.postgres_pool import PostgresConnectionPool
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class MarketDataStorageLifecycleProtocol(Protocol):
|
|
||||||
"""Синхронный жизненный цикл под управлением сборки приложения."""
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
"""Открыть пул и применить миграции до запуска Runtime."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Закрыть пул после завершения всех записей Runtime."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class _ConnectionPoolLifecycleProtocol(Protocol):
|
|
||||||
def open(self) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class _MigrationRunnerProtocol(Protocol):
|
|
||||||
def run(self) -> tuple[int, ...]:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataStorageLifecycle:
|
|
||||||
"""Управляет запуском пула, миграциями и очисткой частичного запуска."""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"_connection_pool",
|
|
||||||
"_migration_runner",
|
|
||||||
"_started",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
connection_pool: _ConnectionPoolLifecycleProtocol,
|
|
||||||
migration_runner: _MigrationRunnerProtocol,
|
|
||||||
) -> None:
|
|
||||||
self._connection_pool = connection_pool
|
|
||||||
self._migration_runner = migration_runner
|
|
||||||
self._started = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def started(self) -> bool:
|
|
||||||
"""Показать, завершились ли запуск пула и миграции."""
|
|
||||||
return self._started
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
if self._started:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._connection_pool.open()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._migration_runner.run()
|
|
||||||
except BaseException as error:
|
|
||||||
try:
|
|
||||||
self._connection_pool.close()
|
|
||||||
except BaseException as cleanup_error:
|
|
||||||
error.add_note(
|
|
||||||
"Market Data Storage startup cleanup also failed: "
|
|
||||||
f"{type(cleanup_error).__name__}."
|
|
||||||
)
|
|
||||||
|
|
||||||
raise
|
|
||||||
|
|
||||||
self._started = True
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._started = False
|
|
||||||
self._connection_pool.close()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MarketDataStorageBootstrapComposition:
|
|
||||||
"""Неактивный граф зависимостей постоянного хранилища сделок."""
|
|
||||||
|
|
||||||
connection_pool: PostgresConnectionPool
|
|
||||||
migration_runner: StorageMigrationRunner
|
|
||||||
trade_repository: PostgresTradeRepository
|
|
||||||
trade_observation_sink: TradeStorageObservationSink
|
|
||||||
lifecycle: MarketDataStorageLifecycle
|
|
||||||
|
|
||||||
|
|
||||||
def build_market_data_storage(
|
|
||||||
settings: Settings,
|
|
||||||
) -> MarketDataStorageBootstrapComposition | None:
|
|
||||||
"""Собрать зависимости хранилища без открытия PostgreSQL."""
|
|
||||||
storage_settings = settings.market_data_storage
|
|
||||||
|
|
||||||
if not storage_settings.enabled:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not settings.trade_stream.enabled:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Trade Stream must be enabled when Market Data Storage "
|
|
||||||
"is enabled."
|
|
||||||
)
|
|
||||||
|
|
||||||
conninfo = make_conninfo(
|
|
||||||
host=settings.db_host,
|
|
||||||
port=settings.db_port,
|
|
||||||
dbname=settings.db_name,
|
|
||||||
user=settings.db_user,
|
|
||||||
password=settings.db_password,
|
|
||||||
)
|
|
||||||
connection_pool = PostgresConnectionPool(
|
|
||||||
conninfo=conninfo,
|
|
||||||
min_size=storage_settings.pool_min_size,
|
|
||||||
max_size=storage_settings.pool_max_size,
|
|
||||||
timeout_seconds=storage_settings.pool_timeout_seconds,
|
|
||||||
name="market-data-storage",
|
|
||||||
)
|
|
||||||
migration_runner = StorageMigrationRunner(
|
|
||||||
connection_provider=connection_pool.connection,
|
|
||||||
)
|
|
||||||
trade_repository = PostgresTradeRepository(
|
|
||||||
connection_provider=connection_pool.connection,
|
|
||||||
)
|
|
||||||
trade_observation_sink = TradeStorageObservationSink(
|
|
||||||
trade_storage=trade_repository,
|
|
||||||
venue=settings.exchange_name,
|
|
||||||
)
|
|
||||||
lifecycle = MarketDataStorageLifecycle(
|
|
||||||
connection_pool=connection_pool,
|
|
||||||
migration_runner=migration_runner,
|
|
||||||
)
|
|
||||||
|
|
||||||
return MarketDataStorageBootstrapComposition(
|
|
||||||
connection_pool=connection_pool,
|
|
||||||
migration_runner=migration_runner,
|
|
||||||
trade_repository=trade_repository,
|
|
||||||
trade_observation_sink=trade_observation_sink,
|
|
||||||
lifecycle=lifecycle,
|
|
||||||
)
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.core.config import Settings
|
|
||||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.rest import (
|
|
||||||
DzengiTradesDocumentSource,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
|
||||||
DzengiUnifiedWebSocketAdapter,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket_control_message_handler import (
|
|
||||||
DzengiWebSocketControlMessageHandler,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket_inbound_message_classifier import (
|
|
||||||
DzengiWebSocketInboundMessageClassifier,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket_transport import (
|
|
||||||
DzengiWebSocketTransport,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_observation_sink_protocol import (
|
|
||||||
TradeObservationSinkProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.runtime.acquisition_runtime_event_logging_consumer import (
|
|
||||||
AcquisitionRuntimeEventLoggingConsumer,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.runtime.acquisition_runtime_event_publisher import (
|
|
||||||
AcquisitionRuntimeEventPublisher,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
|
||||||
TradeStreamProductionRuntime,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.runtime.websocket_session import (
|
|
||||||
WebSocketSession,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.runtime.websocket_subscription_manager import (
|
|
||||||
WebSocketSubscriptionManager,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.trade_stream_runtime_composition import (
|
|
||||||
build_trade_stream_runtime_composition,
|
|
||||||
)
|
|
||||||
from src.market_data.storage.contracts import (
|
|
||||||
TradeCheckpointStorageProtocol,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_trade_stream_production_runtime(
|
|
||||||
settings: Settings,
|
|
||||||
*,
|
|
||||||
trade_observation_sink: TradeObservationSinkProtocol | None = None,
|
|
||||||
checkpoint_storage: TradeCheckpointStorageProtocol | None = None,
|
|
||||||
) -> TradeStreamProductionRuntime | None:
|
|
||||||
"""
|
|
||||||
Собрать Production Trade Stream Runtime без запуска lifecycle.
|
|
||||||
|
|
||||||
Выключенный feature flag возвращает ``None`` до создания
|
|
||||||
WebSocket/REST-зависимостей. Все stateful-компоненты включённого
|
|
||||||
Runtime создаются один раз и затем передаются по identity.
|
|
||||||
"""
|
|
||||||
trade_stream = settings.trade_stream
|
|
||||||
storage_enabled = settings.market_data_storage.enabled
|
|
||||||
|
|
||||||
if not trade_stream.enabled:
|
|
||||||
if storage_enabled:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Trade Stream must be enabled when Market Data "
|
|
||||||
"Storage is enabled."
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
if storage_enabled and (
|
|
||||||
trade_observation_sink is None
|
|
||||||
or checkpoint_storage is None
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"Enabled Market Data Storage requires both Trade "
|
|
||||||
"observation sink and checkpoint storage."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not storage_enabled and checkpoint_storage is not None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Checkpoint storage requires enabled Market Data Storage."
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Origin": settings.exchange_base_url.rstrip("/"),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.exchange_api_key:
|
|
||||||
headers["X-MBX-APIKEY"] = settings.exchange_api_key
|
|
||||||
|
|
||||||
transport = DzengiWebSocketTransport(
|
|
||||||
url=trade_stream.websocket_url,
|
|
||||||
headers=headers,
|
|
||||||
open_timeout=trade_stream.open_timeout_seconds,
|
|
||||||
ping_interval=None,
|
|
||||||
ping_timeout=None,
|
|
||||||
probe_timeout=trade_stream.probe_timeout_seconds,
|
|
||||||
close_timeout=trade_stream.close_timeout_seconds,
|
|
||||||
)
|
|
||||||
session = WebSocketSession(transport)
|
|
||||||
subscription_manager = WebSocketSubscriptionManager(
|
|
||||||
transport,
|
|
||||||
supports_unsubscribe=False,
|
|
||||||
)
|
|
||||||
event_publisher = AcquisitionRuntimeEventPublisher(
|
|
||||||
(
|
|
||||||
AcquisitionRuntimeEventLoggingConsumer(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
recovery_document_source = DzengiTradesDocumentSource(
|
|
||||||
ExchangeRestClient(settings=settings),
|
|
||||||
)
|
|
||||||
message_adapter = DzengiUnifiedWebSocketAdapter()
|
|
||||||
|
|
||||||
composition = build_trade_stream_runtime_composition(
|
|
||||||
session=session,
|
|
||||||
transport=transport,
|
|
||||||
subscription_manager=subscription_manager,
|
|
||||||
event_publisher=event_publisher,
|
|
||||||
message_adapter=message_adapter,
|
|
||||||
recovery_document_source=recovery_document_source,
|
|
||||||
symbols=trade_stream.symbols,
|
|
||||||
heartbeat_timeout_seconds=(
|
|
||||||
trade_stream.heartbeat_timeout_seconds
|
|
||||||
),
|
|
||||||
scheduler_interval_seconds=(
|
|
||||||
trade_stream.scheduler_interval_seconds
|
|
||||||
),
|
|
||||||
trade_observation_sink=trade_observation_sink,
|
|
||||||
max_recovery_window_ms=trade_stream.recovery_window_ms,
|
|
||||||
checkpoint_storage=checkpoint_storage,
|
|
||||||
checkpoint_venue=(
|
|
||||||
settings.exchange_name
|
|
||||||
if checkpoint_storage is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return TradeStreamProductionRuntime(
|
|
||||||
session=session,
|
|
||||||
transport=transport,
|
|
||||||
subscription_manager=subscription_manager,
|
|
||||||
event_publisher=event_publisher,
|
|
||||||
trade_stream_service=(
|
|
||||||
composition.trade_stream_acquisition_service
|
|
||||||
),
|
|
||||||
message_classifier=(
|
|
||||||
DzengiWebSocketInboundMessageClassifier()
|
|
||||||
),
|
|
||||||
control_message_handler=(
|
|
||||||
DzengiWebSocketControlMessageHandler()
|
|
||||||
),
|
|
||||||
live_processing_gate=composition.live_processing_gate,
|
|
||||||
reconnect_recovery_coordinator=(
|
|
||||||
composition.runtime_reconnect_recovery_coordinator
|
|
||||||
),
|
|
||||||
runtime_supervisor=composition.runtime_supervisor,
|
|
||||||
runtime_scheduler=composition.runtime_scheduler,
|
|
||||||
symbols=trade_stream.symbols,
|
|
||||||
startup_recovery_coordinator=(
|
|
||||||
composition.runtime_startup_recovery_coordinator
|
|
||||||
),
|
|
||||||
subscription_ack_timeout_seconds=(
|
|
||||||
trade_stream.subscription_ack_timeout_seconds
|
|
||||||
),
|
|
||||||
startup_market_buffer_capacity=(
|
|
||||||
trade_stream.startup_market_buffer_capacity
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,528 +1,64 @@
|
|||||||
# app/src/core/config.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from src.market_data.acquisition.symbols import normalize_symbol
|
|
||||||
|
|
||||||
|
|
||||||
# корень проекта
|
|
||||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
ENV_FILE = BASE_DIR / ".env"
|
||||||
|
|
||||||
def _resolve_env_file() -> Path:
|
|
||||||
"""Выбирает отдельный env-файл для записи либо обычный app/.env."""
|
|
||||||
|
|
||||||
raw_runtime_env_file = os.getenv("DZENTRA_RUNTIME_ENV_FILE")
|
|
||||||
|
|
||||||
if raw_runtime_env_file is None:
|
|
||||||
return BASE_DIR / ".env"
|
|
||||||
|
|
||||||
runtime_env_file = raw_runtime_env_file.strip()
|
|
||||||
|
|
||||||
if not runtime_env_file:
|
|
||||||
raise RuntimeError(
|
|
||||||
"DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path"
|
|
||||||
)
|
|
||||||
|
|
||||||
return Path(runtime_env_file)
|
|
||||||
|
|
||||||
|
|
||||||
# Файл должен быть выбран окружением процесса до импорта модуля настроек.
|
|
||||||
ENV_FILE = _resolve_env_file()
|
|
||||||
|
|
||||||
# загружаем переменные окружения
|
|
||||||
load_dotenv(ENV_FILE)
|
load_dotenv(ENV_FILE)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TradeStreamSettings:
|
|
||||||
"""Настройки Production Trade Stream Runtime."""
|
|
||||||
|
|
||||||
enabled: bool
|
|
||||||
websocket_url: str
|
|
||||||
symbols: tuple[str, ...]
|
|
||||||
open_timeout_seconds: float
|
|
||||||
probe_timeout_seconds: float
|
|
||||||
close_timeout_seconds: float
|
|
||||||
heartbeat_timeout_seconds: float
|
|
||||||
scheduler_interval_seconds: float
|
|
||||||
recovery_window_ms: int
|
|
||||||
subscription_ack_timeout_seconds: float = 10.0
|
|
||||||
startup_market_buffer_capacity: int = 10_000
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MarketDataStorageSettings:
|
|
||||||
"""Настройки persistent Canonical Market Data Storage."""
|
|
||||||
|
|
||||||
enabled: bool
|
|
||||||
pool_min_size: int
|
|
||||||
pool_max_size: int
|
|
||||||
pool_timeout_seconds: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
# Telegram
|
|
||||||
bot_token: str
|
bot_token: str
|
||||||
bot_parse_mode: str
|
bot_parse_mode: str
|
||||||
|
|
||||||
# Приложение
|
|
||||||
app_env: str
|
app_env: str
|
||||||
log_level: str
|
log_level: str
|
||||||
tz: str
|
tz: str
|
||||||
|
|
||||||
# Биржа
|
|
||||||
exchange_enabled: bool
|
exchange_enabled: bool
|
||||||
exchange_name: str
|
exchange_name: str
|
||||||
exchange_base_url: str
|
exchange_base_url: str
|
||||||
exchange_ws_url: str
|
|
||||||
exchange_api_key: str
|
exchange_api_key: str
|
||||||
exchange_api_secret: str
|
exchange_api_secret: str
|
||||||
exchange_timeout_sec: int
|
exchange_timeout_sec: int
|
||||||
exchange_testnet: bool
|
exchange_testnet: bool
|
||||||
default_symbol: str
|
default_symbol: str
|
||||||
trade_stream: TradeStreamSettings
|
|
||||||
|
|
||||||
# База данных
|
|
||||||
db_host: str
|
db_host: str
|
||||||
db_port: int
|
db_port: int
|
||||||
db_name: str
|
db_name: str
|
||||||
db_user: str
|
db_user: str
|
||||||
db_password: str
|
db_password: str
|
||||||
market_data_storage: MarketDataStorageSettings
|
|
||||||
|
|
||||||
# Отладочные параметры
|
|
||||||
debug_enabled: bool
|
|
||||||
journal_debug_enabled: bool
|
|
||||||
|
|
||||||
# Вспомогательное свойство режима demo/live
|
|
||||||
def is_demo_mode(self) -> bool:
|
def is_demo_mode(self) -> bool:
|
||||||
return "demo" in self.exchange_base_url.lower()
|
return "demo" in self.exchange_base_url.lower()
|
||||||
|
|
||||||
|
|
||||||
# Разбор булева значения
|
|
||||||
def _parse_bool(raw_value: str, default: bool = False) -> bool:
|
def _parse_bool(raw_value: str, default: bool = False) -> bool:
|
||||||
value = (raw_value or "").strip().lower()
|
value = (raw_value or "").strip().lower()
|
||||||
if not value:
|
if not value:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
return value in {"1", "true", "yes", "on"}
|
return value in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
# Разбор целого числа
|
|
||||||
def _parse_int(raw_value: str, default: int) -> int:
|
def _parse_int(raw_value: str, default: int) -> int:
|
||||||
value = (raw_value or "").strip()
|
value = (raw_value or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
def _parse_strict_bool(
|
|
||||||
raw_value: str,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
default: bool,
|
|
||||||
) -> bool:
|
|
||||||
value = (raw_value or "").strip().lower()
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
return default
|
|
||||||
|
|
||||||
if value in {"1", "true", "yes", "on"}:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if value in {"0", "false", "no", "off"}:
|
|
||||||
return False
|
|
||||||
|
|
||||||
raise ValueError(
|
|
||||||
f"{name} must be a boolean value"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_positive_float(
|
|
||||||
raw_value: str,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
default: float,
|
|
||||||
) -> float:
|
|
||||||
value = (raw_value or "").strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = (
|
|
||||||
float(value)
|
|
||||||
if value
|
|
||||||
else float(default)
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
raise ValueError(
|
|
||||||
f"{name} must be a number"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
if not math.isfinite(result) or result <= 0:
|
|
||||||
raise ValueError(
|
|
||||||
f"{name} must be positive and finite"
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_positive_int(
|
|
||||||
raw_value: str,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
default: int,
|
|
||||||
) -> int:
|
|
||||||
value = (raw_value or "").strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = (
|
|
||||||
int(value)
|
|
||||||
if value
|
|
||||||
else int(default)
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
raise ValueError(
|
|
||||||
f"{name} must be an integer"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
if result <= 0:
|
|
||||||
raise ValueError(
|
|
||||||
f"{name} must be positive"
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_trailing_newline(value: str) -> str:
|
|
||||||
"""Удаляет ровно один завершающий перевод строки из файла секрета."""
|
|
||||||
|
|
||||||
if value.endswith("\r\n"):
|
|
||||||
return value[:-2]
|
|
||||||
|
|
||||||
if value.endswith(("\n", "\r")):
|
|
||||||
return value[:-1]
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _load_secret(
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
required: bool = False,
|
|
||||||
) -> str:
|
|
||||||
"""Загружает секрет напрямую или из указанного переменной файла."""
|
|
||||||
|
|
||||||
file_name = f"{name}_FILE"
|
|
||||||
direct_value = os.getenv(name, "").strip()
|
|
||||||
raw_file_path = os.getenv(file_name)
|
|
||||||
|
|
||||||
if direct_value and raw_file_path is not None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{name} and {file_name} must not be set together"
|
|
||||||
)
|
|
||||||
|
|
||||||
if raw_file_path is not None:
|
|
||||||
file_path = raw_file_path.strip()
|
|
||||||
|
|
||||||
if not file_path:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{file_name} must contain a non-empty file path"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
secret = Path(file_path).read_text(encoding="utf-8")
|
|
||||||
except (OSError, UnicodeError):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Unable to read secret configured by {file_name}"
|
|
||||||
) from None
|
|
||||||
|
|
||||||
secret = _remove_trailing_newline(secret)
|
|
||||||
|
|
||||||
if not secret or secret.isspace():
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{file_name} contains an empty secret"
|
|
||||||
)
|
|
||||||
|
|
||||||
return secret
|
|
||||||
|
|
||||||
if direct_value:
|
|
||||||
return direct_value
|
|
||||||
|
|
||||||
if required:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{name} or {file_name} is required"
|
|
||||||
)
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_trade_stream_symbols(
|
|
||||||
raw_value: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
value = (raw_value or "").strip()
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
raise RuntimeError(
|
|
||||||
"TRADE_STREAM_SYMBOLS is required when "
|
|
||||||
"TRADE_STREAM_ENABLED is true"
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_symbols = value.split(",")
|
|
||||||
|
|
||||||
if any(not symbol.strip() for symbol in raw_symbols):
|
|
||||||
raise ValueError(
|
|
||||||
"TRADE_STREAM_SYMBOLS must not contain empty symbols"
|
|
||||||
)
|
|
||||||
|
|
||||||
return tuple(
|
|
||||||
sorted(
|
|
||||||
{
|
|
||||||
normalize_symbol(symbol)
|
|
||||||
for symbol in raw_symbols
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_trade_stream_settings(
|
|
||||||
*,
|
|
||||||
exchange_base_url: str,
|
|
||||||
) -> TradeStreamSettings:
|
|
||||||
enabled = _parse_strict_bool(
|
|
||||||
os.getenv("TRADE_STREAM_ENABLED", "false"),
|
|
||||||
name="TRADE_STREAM_ENABLED",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not enabled:
|
|
||||||
return TradeStreamSettings(
|
|
||||||
enabled=False,
|
|
||||||
websocket_url="",
|
|
||||||
symbols=(),
|
|
||||||
open_timeout_seconds=10.0,
|
|
||||||
probe_timeout_seconds=20.0,
|
|
||||||
close_timeout_seconds=10.0,
|
|
||||||
heartbeat_timeout_seconds=30.0,
|
|
||||||
scheduler_interval_seconds=5.0,
|
|
||||||
recovery_window_ms=3_599_999,
|
|
||||||
subscription_ack_timeout_seconds=10.0,
|
|
||||||
startup_market_buffer_capacity=10_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
websocket_url = os.getenv(
|
|
||||||
"TRADE_STREAM_WS_URL",
|
|
||||||
"",
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
if not websocket_url:
|
|
||||||
raise RuntimeError(
|
|
||||||
"TRADE_STREAM_WS_URL is required when "
|
|
||||||
"TRADE_STREAM_ENABLED is true"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not exchange_base_url:
|
|
||||||
raise RuntimeError(
|
|
||||||
"EXCHANGE_BASE_URL is required for Trade Stream Recovery"
|
|
||||||
)
|
|
||||||
|
|
||||||
return TradeStreamSettings(
|
|
||||||
enabled=True,
|
|
||||||
websocket_url=websocket_url,
|
|
||||||
symbols=_parse_trade_stream_symbols(
|
|
||||||
os.getenv("TRADE_STREAM_SYMBOLS", ""),
|
|
||||||
),
|
|
||||||
open_timeout_seconds=_parse_positive_float(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
|
|
||||||
"10",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_OPEN_TIMEOUT_SECONDS",
|
|
||||||
default=10.0,
|
|
||||||
),
|
|
||||||
probe_timeout_seconds=_parse_positive_float(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
|
|
||||||
"20",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_PROBE_TIMEOUT_SECONDS",
|
|
||||||
default=20.0,
|
|
||||||
),
|
|
||||||
close_timeout_seconds=_parse_positive_float(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
|
|
||||||
"10",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_CLOSE_TIMEOUT_SECONDS",
|
|
||||||
default=10.0,
|
|
||||||
),
|
|
||||||
heartbeat_timeout_seconds=_parse_positive_float(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
|
|
||||||
"30",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS",
|
|
||||||
default=30.0,
|
|
||||||
),
|
|
||||||
scheduler_interval_seconds=_parse_positive_float(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
|
|
||||||
"5",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS",
|
|
||||||
default=5.0,
|
|
||||||
),
|
|
||||||
recovery_window_ms=_parse_positive_int(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_RECOVERY_WINDOW_MS",
|
|
||||||
"3599999",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_RECOVERY_WINDOW_MS",
|
|
||||||
default=3_599_999,
|
|
||||||
),
|
|
||||||
subscription_ack_timeout_seconds=_parse_positive_float(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS",
|
|
||||||
"10",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS",
|
|
||||||
default=10.0,
|
|
||||||
),
|
|
||||||
startup_market_buffer_capacity=_parse_positive_int(
|
|
||||||
os.getenv(
|
|
||||||
"TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY",
|
|
||||||
"10000",
|
|
||||||
),
|
|
||||||
name="TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY",
|
|
||||||
default=10_000,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_market_data_storage_settings(
|
|
||||||
*,
|
|
||||||
trade_stream_enabled: bool,
|
|
||||||
) -> MarketDataStorageSettings:
|
|
||||||
enabled = _parse_strict_bool(
|
|
||||||
os.getenv("MARKET_DATA_STORAGE_ENABLED", "false"),
|
|
||||||
name="MARKET_DATA_STORAGE_ENABLED",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not enabled:
|
|
||||||
return MarketDataStorageSettings(
|
|
||||||
enabled=False,
|
|
||||||
pool_min_size=1,
|
|
||||||
pool_max_size=4,
|
|
||||||
pool_timeout_seconds=10.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not trade_stream_enabled:
|
|
||||||
raise RuntimeError(
|
|
||||||
"TRADE_STREAM_ENABLED must be true when "
|
|
||||||
"MARKET_DATA_STORAGE_ENABLED is true"
|
|
||||||
)
|
|
||||||
|
|
||||||
pool_min_size = _parse_positive_int(
|
|
||||||
os.getenv(
|
|
||||||
"MARKET_DATA_STORAGE_POOL_MIN_SIZE",
|
|
||||||
"1",
|
|
||||||
),
|
|
||||||
name="MARKET_DATA_STORAGE_POOL_MIN_SIZE",
|
|
||||||
default=1,
|
|
||||||
)
|
|
||||||
pool_max_size = _parse_positive_int(
|
|
||||||
os.getenv(
|
|
||||||
"MARKET_DATA_STORAGE_POOL_MAX_SIZE",
|
|
||||||
"4",
|
|
||||||
),
|
|
||||||
name="MARKET_DATA_STORAGE_POOL_MAX_SIZE",
|
|
||||||
default=4,
|
|
||||||
)
|
|
||||||
|
|
||||||
if pool_max_size < pool_min_size:
|
|
||||||
raise ValueError(
|
|
||||||
"MARKET_DATA_STORAGE_POOL_MAX_SIZE must not be smaller "
|
|
||||||
"than MARKET_DATA_STORAGE_POOL_MIN_SIZE"
|
|
||||||
)
|
|
||||||
|
|
||||||
return MarketDataStorageSettings(
|
|
||||||
enabled=True,
|
|
||||||
pool_min_size=pool_min_size,
|
|
||||||
pool_max_size=pool_max_size,
|
|
||||||
pool_timeout_seconds=_parse_positive_float(
|
|
||||||
os.getenv(
|
|
||||||
"MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS",
|
|
||||||
"10",
|
|
||||||
),
|
|
||||||
name="MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS",
|
|
||||||
default=10.0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Загрузка всех настроек
|
|
||||||
def load_settings() -> Settings:
|
def load_settings() -> Settings:
|
||||||
bot_token = _load_secret(
|
bot_token = os.getenv("BOT_TOKEN", "").strip()
|
||||||
"BOT_TOKEN",
|
if not bot_token:
|
||||||
required=True,
|
raise RuntimeError("BOT_TOKEN is not set in app/.env")
|
||||||
)
|
|
||||||
exchange_api_key = _load_secret("EXCHANGE_API_KEY")
|
|
||||||
exchange_api_secret = _load_secret("EXCHANGE_API_SECRET")
|
|
||||||
db_password = _load_secret("DB_PASSWORD")
|
|
||||||
|
|
||||||
exchange_base_url = os.getenv(
|
|
||||||
"EXCHANGE_BASE_URL",
|
|
||||||
"",
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
trade_stream = _load_trade_stream_settings(
|
|
||||||
exchange_base_url=exchange_base_url,
|
|
||||||
)
|
|
||||||
market_data_storage = _load_market_data_storage_settings(
|
|
||||||
trade_stream_enabled=trade_stream.enabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Settings(
|
return Settings(
|
||||||
# Telegram
|
|
||||||
bot_token=bot_token,
|
bot_token=bot_token,
|
||||||
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
|
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
|
||||||
|
|
||||||
# Приложение
|
|
||||||
app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
|
app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
|
||||||
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
|
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
|
||||||
tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk",
|
tz=os.getenv("TZ", "Europe/Minsk").strip() or "Europe/Minsk",
|
||||||
debug_enabled=_parse_bool(os.getenv("DEBUG_ENABLED", "false")),
|
|
||||||
journal_debug_enabled=_parse_bool(
|
|
||||||
os.getenv("JOURNAL_DEBUG_ENABLED", "false")
|
|
||||||
),
|
|
||||||
|
|
||||||
# Биржа
|
|
||||||
exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")),
|
exchange_enabled=_parse_bool(os.getenv("EXCHANGE_ENABLED", "false")),
|
||||||
exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
|
exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi",
|
||||||
exchange_base_url=exchange_base_url,
|
exchange_base_url=os.getenv("EXCHANGE_BASE_URL", "").strip(),
|
||||||
exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(),
|
exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(),
|
||||||
exchange_api_key=exchange_api_key,
|
exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(),
|
||||||
exchange_api_secret=exchange_api_secret,
|
|
||||||
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
|
exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10),
|
||||||
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")),
|
||||||
default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip()
|
default_symbol=os.getenv("DEFAULT_SYMBOL", "BTC/USD_LEVERAGE").strip() or "BTC/USD_LEVERAGE",
|
||||||
or "ETH/USD_LEVERAGE",
|
|
||||||
trade_stream=trade_stream,
|
|
||||||
|
|
||||||
# База данных
|
|
||||||
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
|
db_host=os.getenv("DB_HOST", "localhost").strip() or "localhost",
|
||||||
db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432),
|
db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432),
|
||||||
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",
|
db_name=os.getenv("DB_NAME", "dzentra_bot").strip() or "dzentra_bot",
|
||||||
db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot",
|
db_user=os.getenv("DB_USER", "dzentra_bot").strip() or "dzentra_bot",
|
||||||
db_password=db_password,
|
db_password=os.getenv("DB_PASSWORD", "").strip(),
|
||||||
market_data_storage=market_data_storage,
|
|
||||||
)
|
)
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
# app/src/core/constants.py
|
|
||||||
|
|
||||||
APP_NAME = "Dzentra Bot"
|
APP_NAME = "Dzentra Bot"
|
||||||
APP_VERSION = "2.0.0"
|
APP_VERSION = "2.0.0"
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# app/src/core/event_bus.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class EventBus:
|
|
||||||
_version: int = 0
|
|
||||||
_last_event_type: str | None = None
|
|
||||||
_last_payload: dict[str, Any] = {}
|
|
||||||
_events: list[tuple[int, str, dict[str, Any]]] = []
|
|
||||||
_max_events: int = 100
|
|
||||||
|
|
||||||
# зафиксировать важное событие системы
|
|
||||||
@classmethod
|
|
||||||
def emit(cls, event_type: str, payload: dict[str, Any] | None = None) -> None:
|
|
||||||
cls._version += 1
|
|
||||||
cls._last_event_type = event_type
|
|
||||||
cls._last_payload = payload or {}
|
|
||||||
|
|
||||||
cls._events.append(
|
|
||||||
(
|
|
||||||
cls._version,
|
|
||||||
event_type,
|
|
||||||
dict(cls._last_payload),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(cls._events) > cls._max_events:
|
|
||||||
cls._events = cls._events[-cls._max_events:]
|
|
||||||
|
|
||||||
# текущая версия событий
|
|
||||||
@classmethod
|
|
||||||
def version(cls) -> int:
|
|
||||||
return cls._version
|
|
||||||
|
|
||||||
# последнее событие
|
|
||||||
@classmethod
|
|
||||||
def last_event(cls) -> tuple[str | None, dict[str, Any]]:
|
|
||||||
return cls._last_event_type, dict(cls._last_payload)
|
|
||||||
|
|
||||||
# события после указанной версии
|
|
||||||
@classmethod
|
|
||||||
def events_after(cls, version: int) -> list[tuple[int, str, dict[str, Any]]]:
|
|
||||||
return [
|
|
||||||
(event_version, event_type, dict(payload))
|
|
||||||
for event_version, event_type, payload in cls._events
|
|
||||||
if event_version > version
|
|
||||||
]
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# app/src/core/event_titles.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
EVENT_TITLES = {
|
|
||||||
# Сигналы
|
|
||||||
"signal_summary": "Сигнал",
|
|
||||||
"signal_ready": "Сигнал",
|
|
||||||
|
|
||||||
# Execution
|
|
||||||
"position_opened": "Позиция",
|
|
||||||
"position_closed": "Позиция",
|
|
||||||
"position_flipped": "Позиция",
|
|
||||||
"position_flip_blocked": "Позиция",
|
|
||||||
|
|
||||||
# Настройки
|
|
||||||
"auto_settings_updated": "Автоторговля",
|
|
||||||
"auto_status_changed": "Автоторговля",
|
|
||||||
"risk_settings_updated": "Защита",
|
|
||||||
|
|
||||||
# Аналитика автоторговли
|
|
||||||
"market_state_changed": "Автоторговля",
|
|
||||||
"market_volatility_changed": "Автоторговля",
|
|
||||||
|
|
||||||
# Рыночные данные runtime
|
|
||||||
"market_monitor_started": "Автоторговля",
|
|
||||||
"market_monitor_stopped": "Автоторговля",
|
|
||||||
"market_stream_connected": "Автоторговля",
|
|
||||||
"market_stream_disconnected": "Автоторговля",
|
|
||||||
"market_symbol_changed": "Автоторговля",
|
|
||||||
|
|
||||||
# Мониторинг позиций
|
|
||||||
"entry_blocked": "Вход в позицию",
|
|
||||||
|
|
||||||
# Журнал
|
|
||||||
"journal_exported": "Журнал",
|
|
||||||
"journal_export_error": "Журнал",
|
|
||||||
"journal_cleared": "Журнал",
|
|
||||||
|
|
||||||
# Уведомления
|
|
||||||
"notification_sent": "Уведомление",
|
|
||||||
"notification_error": "Уведомление",
|
|
||||||
|
|
||||||
# Приложение
|
|
||||||
"app_started": "Приложение",
|
|
||||||
"app_bootstrap_failed": "Приложение",
|
|
||||||
|
|
||||||
# Legacy
|
|
||||||
"app_start": "Приложение",
|
|
||||||
|
|
||||||
"journal_open_requested": "Журнал",
|
|
||||||
"journal_export_csv_success": "Журнал",
|
|
||||||
"journal_export_csv_error": "Журнал",
|
|
||||||
"journal_export_xlsx_success": "Журнал",
|
|
||||||
"journal_export_xlsx_error": "Журнал",
|
|
||||||
"journal_cleared_old": "Журнал",
|
|
||||||
"journal_debug_changed": "Журнал",
|
|
||||||
|
|
||||||
"system_open_requested": "Система",
|
|
||||||
"system_open_alert": "Система",
|
|
||||||
"system_open_success": "Система",
|
|
||||||
"system_retry": "Система",
|
|
||||||
"system_about_opened": "Система",
|
|
||||||
|
|
||||||
"portfolio_open_requested": "Портфель",
|
|
||||||
"portfolio_open_success": "Портфель",
|
|
||||||
"portfolio_open_error": "Портфель",
|
|
||||||
"portfolio_partial_estimate": "Портфель",
|
|
||||||
|
|
||||||
"exchange_request_error": "Биржа",
|
|
||||||
|
|
||||||
"exchange_auth_error": "Аккаунт",
|
|
||||||
"exchange_auth_restored": "Аккаунт",
|
|
||||||
"exchange_time_sync_error": "Время биржи",
|
|
||||||
"exchange_time_sync_restored": "Время биржи",
|
|
||||||
|
|
||||||
"balance_summary_loaded": "Баланс",
|
|
||||||
"balance_summary_error": "Баланс",
|
|
||||||
|
|
||||||
"runtime_expired": "Runtime",
|
|
||||||
|
|
||||||
"market_status_unavailable": "Автоторговля",
|
|
||||||
"market_status_restored": "Автоторговля",
|
|
||||||
"market_closed": "Автоторговля",
|
|
||||||
"market_rest_fallback_available": "Автоторговля",
|
|
||||||
"market_rest_fallback_unavailable": "Автоторговля",
|
|
||||||
|
|
||||||
"ws_depth_alive": "WebSocket debug",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def event_title(event_type: object) -> str:
|
|
||||||
value = str(event_type or "").strip()
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
return "Событие"
|
|
||||||
|
|
||||||
return EVENT_TITLES.get(value, "Событие")
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# app/src/core/numbers.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def safe_float(
|
|
||||||
value: object,
|
|
||||||
default: float | None = None,
|
|
||||||
) -> float | None:
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return default
|
|
||||||
|
|
||||||
try:
|
|
||||||
return float(str(value).strip())
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def safe_round(
|
|
||||||
value: object,
|
|
||||||
digits: int,
|
|
||||||
) -> float | None:
|
|
||||||
"""
|
|
||||||
Безопасное округление.
|
|
||||||
|
|
||||||
None -> None
|
|
||||||
любое число -> round(...)
|
|
||||||
"""
|
|
||||||
number = safe_float(value)
|
|
||||||
|
|
||||||
if number is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return round(number, digits)
|
|
||||||
|
|
||||||
|
|
||||||
def get_value(value: object) -> object | None:
|
|
||||||
"""
|
|
||||||
Возвращает значение Enum или сам объект.
|
|
||||||
|
|
||||||
Enum -> .value
|
|
||||||
None -> None
|
|
||||||
Остальные типы -> без изменений.
|
|
||||||
"""
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return getattr(value, "value", value)
|
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
# app/src/core/system_status.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
from src.core.constants import APP_NAME, APP_VERSION
|
from src.core.constants import APP_NAME, APP_VERSION
|
||||||
from src.integrations.exchange.runtime_ui import build_runtime_exchange_alerts
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
from src.integrations.exchange.service import ExchangeService
|
||||||
from src.integrations.exchange.status import build_exchange_error_status
|
|
||||||
from src.storage.session import check_database_health
|
from src.storage.session import check_database_health
|
||||||
from src.trading.journal.service import JournalService
|
from src.trading.journal.service import JournalService
|
||||||
|
|
||||||
@@ -34,96 +28,6 @@ class SystemSnapshot:
|
|||||||
components: list[ComponentStatus]
|
components: list[ComponentStatus]
|
||||||
|
|
||||||
|
|
||||||
def _build_exchange_alert_components(
|
|
||||||
*,
|
|
||||||
default_symbol: str,
|
|
||||||
) -> list[ComponentStatus]:
|
|
||||||
exchange_service = ExchangeService()
|
|
||||||
|
|
||||||
try:
|
|
||||||
runtime_status = exchange_service.get_symbol_runtime_status(default_symbol)
|
|
||||||
except Exception as exc:
|
|
||||||
runtime_status = build_exchange_error_status(exc)
|
|
||||||
|
|
||||||
if not runtime_status.is_available:
|
|
||||||
return [
|
|
||||||
ComponentStatus(
|
|
||||||
name="Биржа",
|
|
||||||
state=runtime_status.ui_line,
|
|
||||||
details=runtime_status.message,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
alerts = build_runtime_exchange_alerts(symbol=default_symbol)
|
|
||||||
|
|
||||||
exchange_unavailable_alert = next(
|
|
||||||
(
|
|
||||||
alert
|
|
||||||
for alert in alerts
|
|
||||||
if str(alert.get("code") or "") == "EXCHANGE_UNAVAILABLE"
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if exchange_unavailable_alert is not None:
|
|
||||||
return [
|
|
||||||
ComponentStatus(
|
|
||||||
name="Биржа",
|
|
||||||
state=str(
|
|
||||||
exchange_unavailable_alert.get("ui_line")
|
|
||||||
or exchange_unavailable_alert.get("title")
|
|
||||||
or "⛔️ Биржа недоступна"
|
|
||||||
),
|
|
||||||
details=str(exchange_unavailable_alert.get("details") or ""),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
components: list[ComponentStatus] = [
|
|
||||||
ComponentStatus(
|
|
||||||
name="Биржа",
|
|
||||||
state="🟢",
|
|
||||||
details=runtime_status.message,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
has_account_alert = False
|
|
||||||
|
|
||||||
for alert in alerts:
|
|
||||||
code = str(alert.get("code") or "")
|
|
||||||
state = str(
|
|
||||||
alert.get("ui_line")
|
|
||||||
or alert.get("title")
|
|
||||||
or "⛔️ Ошибка биржи"
|
|
||||||
)
|
|
||||||
|
|
||||||
if code == "AUTH_ERROR":
|
|
||||||
has_account_alert = True
|
|
||||||
name = "Аккаунт"
|
|
||||||
elif code == "TIME_ERROR":
|
|
||||||
name = "Время биржи"
|
|
||||||
else:
|
|
||||||
name = "Биржа"
|
|
||||||
|
|
||||||
components.append(
|
|
||||||
ComponentStatus(
|
|
||||||
name=name,
|
|
||||||
state=state,
|
|
||||||
details=str(alert.get("details") or ""),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not has_account_alert:
|
|
||||||
components.append(
|
|
||||||
ComponentStatus(
|
|
||||||
name="Аккаунт",
|
|
||||||
state="🟢",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return components
|
|
||||||
|
|
||||||
|
|
||||||
# извлечь короткую версию PostgreSQL из строки health-check
|
|
||||||
def _extract_postgres_version(raw: str) -> str:
|
def _extract_postgres_version(raw: str) -> str:
|
||||||
if not raw:
|
if not raw:
|
||||||
return "PostgreSQL"
|
return "PostgreSQL"
|
||||||
@@ -135,72 +39,94 @@ def _extract_postgres_version(raw: str) -> str:
|
|||||||
return "PostgreSQL"
|
return "PostgreSQL"
|
||||||
|
|
||||||
|
|
||||||
# проверить подключение к БД и вернуть компонент + подпись версии
|
def _build_exchange_status(
|
||||||
|
exchange_service: ExchangeService, default_symbol: str
|
||||||
|
) -> ComponentStatus:
|
||||||
|
try:
|
||||||
|
symbol_validation = exchange_service.validate_symbol(default_symbol)
|
||||||
|
except Exception as exc:
|
||||||
|
return ComponentStatus(
|
||||||
|
name="Биржа",
|
||||||
|
state="🔴",
|
||||||
|
details=f"Не удалось проверить инструмент: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
exchange_health = exchange_service.get_health()
|
||||||
|
|
||||||
|
if exchange_health.ok and symbol_validation.is_valid:
|
||||||
|
return ComponentStatus(name="Биржа", state="🟢")
|
||||||
|
|
||||||
|
if not exchange_health.ok:
|
||||||
|
return ComponentStatus(
|
||||||
|
name="Биржа",
|
||||||
|
state="🔴",
|
||||||
|
details=exchange_health.message or "Ошибка подключения к API биржи.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ComponentStatus(
|
||||||
|
name="Биржа",
|
||||||
|
state="🔴",
|
||||||
|
details=symbol_validation.message or "Инструмент не прошёл проверку.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_account_status(exchange_service: ExchangeService) -> ComponentStatus:
|
||||||
|
private_auth_health = exchange_service.get_private_auth_health()
|
||||||
|
if private_auth_health.ok:
|
||||||
|
return ComponentStatus(name="Аккаунт", state="🟢")
|
||||||
|
|
||||||
|
return ComponentStatus(
|
||||||
|
name="Аккаунт",
|
||||||
|
state="🔴",
|
||||||
|
details=private_auth_health.message or "Ошибка private API.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_database_status() -> tuple[ComponentStatus, str]:
|
def _build_database_status() -> tuple[ComponentStatus, str]:
|
||||||
db_ok, db_message = check_database_health()
|
db_ok, db_message = check_database_health()
|
||||||
db_label = _extract_postgres_version(db_message)
|
db_label = _extract_postgres_version(db_message)
|
||||||
|
|
||||||
if db_ok:
|
if db_ok:
|
||||||
return (
|
return ComponentStatus(name="База данных", state="🟢"), db_label
|
||||||
ComponentStatus(
|
|
||||||
name="База данных",
|
|
||||||
state="🟢",
|
|
||||||
),
|
|
||||||
db_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
ComponentStatus(
|
ComponentStatus(
|
||||||
name="База данных",
|
name="База данных",
|
||||||
state="🔴 База данных недоступна",
|
state="🔴",
|
||||||
|
details=db_message or "Ошибка подключения к БД.",
|
||||||
),
|
),
|
||||||
db_label,
|
db_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# проверить доступность журнала событий
|
|
||||||
def _build_journal_status() -> ComponentStatus:
|
def _build_journal_status() -> ComponentStatus:
|
||||||
ok, _ = JournalService().get_journal_health()
|
ok, message = JournalService().get_journal_health()
|
||||||
|
|
||||||
if ok:
|
if ok:
|
||||||
return ComponentStatus(
|
return ComponentStatus(name="Журнал", state="🟢")
|
||||||
name="Журнал",
|
|
||||||
state="🟢",
|
|
||||||
)
|
|
||||||
|
|
||||||
return ComponentStatus(
|
return ComponentStatus(name="Журнал", state="🔴", details=message)
|
||||||
name="Журнал",
|
|
||||||
state="🔴 Журнал недоступен",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# определить runtime-режим по base_url биржи
|
def _resolve_mode_label(settings) -> str:
|
||||||
def get_runtime_mode_key() -> str:
|
is_demo = "demo" in settings.exchange_base_url.lower()
|
||||||
settings = load_settings()
|
return "ДЕМО аккаунт" if is_demo else "РЕАЛЬНЫЙ аккаунт"
|
||||||
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
|
|
||||||
|
|
||||||
|
|
||||||
# вернуть человекочитаемую подпись runtime-режима
|
|
||||||
def get_runtime_mode_label() -> str:
|
|
||||||
return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт"
|
|
||||||
|
|
||||||
|
|
||||||
# собрать полный snapshot системного экрана
|
|
||||||
def get_system_snapshot() -> SystemSnapshot:
|
def get_system_snapshot() -> SystemSnapshot:
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
exchange_service = ExchangeService()
|
||||||
|
|
||||||
database_status, db_label = _build_database_status()
|
database_status, db_label = _build_database_status()
|
||||||
|
exchange_status = _build_exchange_status(exchange_service, settings.default_symbol)
|
||||||
|
account_status = _build_account_status(exchange_service)
|
||||||
journal_status = _build_journal_status()
|
journal_status = _build_journal_status()
|
||||||
|
|
||||||
exchange_components = _build_exchange_alert_components(
|
|
||||||
default_symbol=settings.default_symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
components = [
|
components = [
|
||||||
ComponentStatus(name="Приложение", state="🟢"),
|
ComponentStatus(name="Приложение", state="🟢"),
|
||||||
database_status,
|
database_status,
|
||||||
ComponentStatus(name="Telegram", state="🟢"),
|
ComponentStatus(name="Telegram", state="🟢"),
|
||||||
*exchange_components,
|
exchange_status,
|
||||||
|
account_status,
|
||||||
journal_status,
|
journal_status,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -209,54 +135,32 @@ def get_system_snapshot() -> SystemSnapshot:
|
|||||||
app_version=APP_VERSION,
|
app_version=APP_VERSION,
|
||||||
db_label=db_label,
|
db_label=db_label,
|
||||||
timezone_name=settings.tz,
|
timezone_name=settings.tz,
|
||||||
mode_label=get_runtime_mode_label(),
|
mode_label=_resolve_mode_label(settings),
|
||||||
default_symbol=settings.default_symbol,
|
default_symbol=settings.default_symbol,
|
||||||
components=components,
|
components=components,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# определить, есть ли системные предупреждения
|
|
||||||
def has_system_alerts(snapshot: SystemSnapshot) -> bool:
|
|
||||||
return any(component.state != "🟢" for component in snapshot.components)
|
|
||||||
|
|
||||||
|
|
||||||
# отрендерить одну строку компонента системы
|
|
||||||
def _render_component(component: ComponentStatus) -> str:
|
def _render_component(component: ComponentStatus) -> str:
|
||||||
if component.state == "🟢":
|
if component.state == "🟢":
|
||||||
return f"{component.state} {component.name}"
|
return f"{component.state} <b>{component.name}</b>"
|
||||||
|
|
||||||
return component.state
|
return f"{component.state} <b>{component.name}</b>\n— {component.details}"
|
||||||
|
|
||||||
|
|
||||||
# получить текущее локальное время для подписи обновления
|
def build_system_text() -> str:
|
||||||
def _now_hhmmss() -> str:
|
|
||||||
settings = load_settings()
|
|
||||||
tz_name = settings.tz or "UTC"
|
|
||||||
|
|
||||||
try:
|
|
||||||
local_dt = datetime.now(ZoneInfo(tz_name))
|
|
||||||
except Exception:
|
|
||||||
local_dt = datetime.utcnow()
|
|
||||||
|
|
||||||
return local_dt.strftime("%H:%M:%S")
|
|
||||||
|
|
||||||
|
|
||||||
# собрать текст экрана "Система"
|
|
||||||
def build_system_text(*, include_updated_at: bool = False) -> str:
|
|
||||||
snapshot = get_system_snapshot()
|
snapshot = get_system_snapshot()
|
||||||
|
|
||||||
components_block = "\n".join(
|
components_block = "\n".join(
|
||||||
_render_component(component)
|
_render_component(component) for component in snapshot.components
|
||||||
for component in snapshot.components
|
|
||||||
)
|
)
|
||||||
|
|
||||||
text = (
|
return (
|
||||||
"<b>🖥️ Система</b>\n"
|
"<b>⚙️ Система</b>\n\n"
|
||||||
f"🔸 <b>{snapshot.mode_label}</b>\n\n"
|
f"{components_block}\n\n"
|
||||||
f"{components_block}"
|
"<b>🌐 Окружение</b>\n"
|
||||||
|
f"• приложение: {snapshot.app_name} {snapshot.app_version}\n"
|
||||||
|
f"• база данных: {snapshot.db_label}\n"
|
||||||
|
f"• часовой пояс: {snapshot.timezone_name}\n"
|
||||||
|
f"• режим: {snapshot.mode_label}\n"
|
||||||
|
f"• инструмент: {snapshot.default_symbol}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_updated_at:
|
|
||||||
text += f"\n\n<i>Обновлено: {_now_hhmmss()}</i>"
|
|
||||||
|
|
||||||
return text
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# app/src/core/telegram_errors.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
|
||||||
|
|
||||||
|
|
||||||
def is_message_not_modified(exc: TelegramBadRequest) -> bool:
|
|
||||||
return "message is not modified" in str(exc).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def is_message_to_edit_not_found(exc: TelegramBadRequest) -> bool:
|
|
||||||
text = str(exc).lower()
|
|
||||||
|
|
||||||
return (
|
|
||||||
"message to edit not found" in text
|
|
||||||
or "message_id_invalid" in text
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# app/src/core/types.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, TypeAlias
|
|
||||||
|
|
||||||
|
|
||||||
NumericLike: TypeAlias = float | int | str
|
|
||||||
|
|
||||||
JsonDict: TypeAlias = dict[str, Any]
|
|
||||||
|
|
||||||
JsonList: TypeAlias = list[Any]
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# app/src/integrations/exchange/exceptions.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
@@ -13,41 +11,3 @@ class ExchangeConnectionError(ExchangeError):
|
|||||||
|
|
||||||
class ExchangeResponseError(ExchangeError):
|
class ExchangeResponseError(ExchangeError):
|
||||||
"""Unexpected HTTP response or malformed JSON."""
|
"""Unexpected HTTP response or malformed JSON."""
|
||||||
|
|
||||||
|
|
||||||
def is_exchange_time_sync_error(exc: Exception) -> bool:
|
|
||||||
text = str(exc).lower()
|
|
||||||
return (
|
|
||||||
"-1021" in text
|
|
||||||
or "doesn't match server time" in text
|
|
||||||
or "server time" in text and "match" in text
|
|
||||||
or "рассинхрон" in text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_exchange_error_for_user(exc: Exception) -> str:
|
|
||||||
if is_exchange_time_sync_error(exc):
|
|
||||||
return (
|
|
||||||
"Биржа отклонила запрос из-за рассинхронизации времени. "
|
|
||||||
"Проверь системное время и повтори попытку."
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(exc, ExchangeConnectionError):
|
|
||||||
return (
|
|
||||||
"Не удалось получить данные биржи: таймаут или ошибка сети. "
|
|
||||||
"Попробуй ещё раз через несколько секунд."
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(exc, ExchangeResponseError):
|
|
||||||
return (
|
|
||||||
"Биржа вернула ошибку ответа. "
|
|
||||||
"Попробуй ещё раз через несколько секунд."
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(exc, ExchangeError):
|
|
||||||
return (
|
|
||||||
"Не удалось получить данные биржи. "
|
|
||||||
"Попробуй ещё раз через несколько секунд."
|
|
||||||
)
|
|
||||||
|
|
||||||
return "Временная ошибка получения данных биржи. Попробуй ещё раз через несколько секунд."
|
|
||||||
@@ -1,733 +0,0 @@
|
|||||||
# app/src/integrations/exchange/market_data_runner.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from src.core.types import JsonDict
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
|
||||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
|
||||||
DzengiWebSocketQuoteAdapter,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
MarketDataAcquisitionError,
|
|
||||||
)
|
|
||||||
from src.storage.quote_store import (
|
|
||||||
QUOTE_RUNTIME_SOURCE_NAME,
|
|
||||||
get_quote_store,
|
|
||||||
)
|
|
||||||
from src.trading.journal.service import JournalService
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MarketRuntimeContext:
|
|
||||||
runtime_key: str
|
|
||||||
task: asyncio.Task | None
|
|
||||||
interval_seconds: int
|
|
||||||
symbol_provider: Callable[[], str | None]
|
|
||||||
screen: str | None
|
|
||||||
action: str
|
|
||||||
runtime_label: str | None
|
|
||||||
last_market_status: str | None = None
|
|
||||||
|
|
||||||
# Dedup runtime-событий, чтобы журнал не разрастался одинаковыми ошибками.
|
|
||||||
last_status_error_key: str | None = None
|
|
||||||
last_stream_state: str | None = None
|
|
||||||
last_stream_error_key: str | None = None
|
|
||||||
last_rest_state: str | None = None
|
|
||||||
last_rest_error_key: str | None = None
|
|
||||||
|
|
||||||
last_ws_debug_logged_at: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataRunner:
|
|
||||||
_runtimes: dict[str, MarketRuntimeContext] = {}
|
|
||||||
|
|
||||||
# Global dedupe runtime-событий между start/stop runtime.
|
|
||||||
_global_runtime_event_timestamps: dict[str, float] = {}
|
|
||||||
|
|
||||||
# Минимальный интервал повторного логирования одного runtime-состояния.
|
|
||||||
# Состояние в UI может меняться чаще, но журнал не должен разрастаться.
|
|
||||||
_runtime_log_cooldown_seconds = 300
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_runtime_state(cls, runtime_key: str = "default") -> dict[str, object]:
|
|
||||||
context = cls._runtimes.get(runtime_key)
|
|
||||||
|
|
||||||
if context is None:
|
|
||||||
return {
|
|
||||||
"stream_state": None,
|
|
||||||
"stream_error": None,
|
|
||||||
"rest_state": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"stream_state": context.last_stream_state,
|
|
||||||
"stream_error": context.last_stream_error_key,
|
|
||||||
"rest_state": context.last_rest_state,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _can_log_runtime_event(
|
|
||||||
cls,
|
|
||||||
event_key: str,
|
|
||||||
) -> bool:
|
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
last_logged_at = cls._global_runtime_event_timestamps.get(event_key)
|
|
||||||
|
|
||||||
if last_logged_at is not None:
|
|
||||||
if (
|
|
||||||
now - last_logged_at
|
|
||||||
) < cls._runtime_log_cooldown_seconds:
|
|
||||||
return False
|
|
||||||
|
|
||||||
cls._global_runtime_event_timestamps[event_key] = now
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def start(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
symbol_provider: Callable[[], str | None],
|
|
||||||
interval_seconds: int = 1,
|
|
||||||
runtime_key: str = "default",
|
|
||||||
screen: str | None = None,
|
|
||||||
action: str = "market_data",
|
|
||||||
runtime_label: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
existing = cls._runtimes.get(runtime_key)
|
|
||||||
|
|
||||||
if (
|
|
||||||
existing is not None
|
|
||||||
and existing.task is not None
|
|
||||||
and not existing.task.done()
|
|
||||||
):
|
|
||||||
existing.symbol_provider = symbol_provider
|
|
||||||
existing.interval_seconds = interval_seconds
|
|
||||||
existing.screen = screen
|
|
||||||
existing.action = action
|
|
||||||
existing.runtime_label = runtime_label
|
|
||||||
return
|
|
||||||
|
|
||||||
context = MarketRuntimeContext(
|
|
||||||
runtime_key=runtime_key,
|
|
||||||
task=None,
|
|
||||||
interval_seconds=interval_seconds,
|
|
||||||
symbol_provider=symbol_provider,
|
|
||||||
screen=screen,
|
|
||||||
action=action,
|
|
||||||
runtime_label=runtime_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
cls._runtimes[runtime_key] = context
|
|
||||||
|
|
||||||
cls._log_info(
|
|
||||||
context,
|
|
||||||
"market_monitor_started",
|
|
||||||
"Мониторинг рынка запущен.",
|
|
||||||
{"interval_seconds": interval_seconds},
|
|
||||||
)
|
|
||||||
|
|
||||||
context.task = asyncio.create_task(cls._worker(context))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def stop(cls, runtime_key: str | None = None) -> None:
|
|
||||||
if runtime_key is None:
|
|
||||||
for key in list(cls._runtimes.keys()):
|
|
||||||
cls.stop(key)
|
|
||||||
return
|
|
||||||
|
|
||||||
context = cls._runtimes.get(runtime_key)
|
|
||||||
if context is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if context.task is not None:
|
|
||||||
context.task.cancel()
|
|
||||||
context.task = None
|
|
||||||
|
|
||||||
cls._log_info(
|
|
||||||
context,
|
|
||||||
"market_monitor_stopped",
|
|
||||||
"Мониторинг рынка остановлен.",
|
|
||||||
)
|
|
||||||
|
|
||||||
cls._runtimes.pop(runtime_key, None)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _worker(cls, context: MarketRuntimeContext) -> None:
|
|
||||||
last_symbol: str | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
symbol = context.symbol_provider()
|
|
||||||
|
|
||||||
if not symbol:
|
|
||||||
await asyncio.sleep(context.interval_seconds)
|
|
||||||
continue
|
|
||||||
|
|
||||||
cache_symbol = cls._cache_symbol(symbol)
|
|
||||||
ws_symbol = cls._ws_symbol(symbol)
|
|
||||||
|
|
||||||
if symbol != last_symbol:
|
|
||||||
last_symbol = symbol
|
|
||||||
|
|
||||||
if not cls._is_cache_symbol_used_by_other_runtime(
|
|
||||||
runtime_key=context.runtime_key,
|
|
||||||
cache_symbol=cache_symbol,
|
|
||||||
):
|
|
||||||
get_quote_store().clear(
|
|
||||||
source_name=QUOTE_RUNTIME_SOURCE_NAME,
|
|
||||||
symbol=cache_symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
market_status = ExchangeService().get_symbol_market_status(symbol)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
error_key = f"{type(exc).__name__}:{str(exc)}"
|
|
||||||
|
|
||||||
if context.last_status_error_key != error_key:
|
|
||||||
context.last_status_error_key = error_key
|
|
||||||
|
|
||||||
cls._log_warning(
|
|
||||||
context,
|
|
||||||
"market_status_unavailable",
|
|
||||||
"Статус рынка временно недоступен.",
|
|
||||||
{
|
|
||||||
"symbol": symbol,
|
|
||||||
"cache_symbol": cache_symbol,
|
|
||||||
"ws_symbol": ws_symbol,
|
|
||||||
"error": str(exc),
|
|
||||||
"error_type": type(exc).__name__,
|
|
||||||
"traceback": traceback.format_exc(limit=5),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(context.interval_seconds)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if context.last_status_error_key is not None:
|
|
||||||
context.last_status_error_key = None
|
|
||||||
|
|
||||||
cls._log_info(
|
|
||||||
context,
|
|
||||||
"market_status_restored",
|
|
||||||
"Статус рынка снова доступен.",
|
|
||||||
{
|
|
||||||
"symbol": symbol,
|
|
||||||
"cache_symbol": cache_symbol,
|
|
||||||
"ws_symbol": ws_symbol,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
status_key = str(market_status.get("status") or "UNKNOWN")
|
|
||||||
|
|
||||||
if not bool(market_status.get("is_open")):
|
|
||||||
if context.last_market_status != status_key:
|
|
||||||
context.last_market_status = status_key
|
|
||||||
|
|
||||||
cls._log_warning(
|
|
||||||
context,
|
|
||||||
"market_closed",
|
|
||||||
"Рынок закрыт. Мониторинг рыночных данных временно приостановлен.",
|
|
||||||
{
|
|
||||||
"symbol": symbol,
|
|
||||||
"market_status": status_key,
|
|
||||||
"message": market_status.get("message"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(context.interval_seconds)
|
|
||||||
continue
|
|
||||||
|
|
||||||
context.last_market_status = status_key
|
|
||||||
|
|
||||||
try:
|
|
||||||
await cls._run_websocket(context, symbol)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
|
|
||||||
|
|
||||||
should_log_disconnected = (
|
|
||||||
(
|
|
||||||
context.last_stream_state != "DISCONNECTED"
|
|
||||||
or context.last_stream_error_key != error_key
|
|
||||||
)
|
|
||||||
and cls._can_log_runtime_event(
|
|
||||||
f"market_stream_disconnected:{symbol}:{error_key}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
context.last_stream_state = "DISCONNECTED"
|
|
||||||
context.last_stream_error_key = error_key
|
|
||||||
|
|
||||||
if should_log_disconnected:
|
|
||||||
|
|
||||||
cls._log_warning(
|
|
||||||
context,
|
|
||||||
"market_stream_disconnected",
|
|
||||||
"Live-поток рыночных данных отключён. Используется REST-режим.",
|
|
||||||
{
|
|
||||||
"symbol": symbol,
|
|
||||||
"cache_symbol": cache_symbol,
|
|
||||||
"ws_symbol": ws_symbol,
|
|
||||||
"error": str(exc),
|
|
||||||
"error_type": type(exc).__name__,
|
|
||||||
"traceback": traceback.format_exc(limit=5),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await cls._rest_fallback_once(context, symbol)
|
|
||||||
await asyncio.sleep(context.interval_seconds)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# stop() уже пишет market_monitor_stopped.
|
|
||||||
# Здесь не логируем, чтобы в журнале не было дубля остановки.
|
|
||||||
raise
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _run_websocket(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
symbol: str,
|
|
||||||
) -> None:
|
|
||||||
cache_symbol = cls._cache_symbol(symbol)
|
|
||||||
ws_symbol = cls._ws_symbol(symbol)
|
|
||||||
|
|
||||||
valid_payload_count = 0
|
|
||||||
invalid_payload_count = 0
|
|
||||||
adapter = DzengiWebSocketQuoteAdapter()
|
|
||||||
|
|
||||||
async for payload in ExchangeWebSocketClient().stream_depth(
|
|
||||||
ws_symbol,
|
|
||||||
interval_seconds=context.interval_seconds,
|
|
||||||
):
|
|
||||||
current_symbol = context.symbol_provider()
|
|
||||||
if current_symbol and current_symbol != symbol:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
quote = adapter.map_message(payload)
|
|
||||||
except MarketDataAcquisitionError:
|
|
||||||
invalid_payload_count += 1
|
|
||||||
|
|
||||||
if invalid_payload_count >= 5:
|
|
||||||
raise RuntimeError(
|
|
||||||
"WebSocket depth stream does not contain valid quotes."
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
if quote.symbol.strip().upper() != cache_symbol.strip().upper():
|
|
||||||
invalid_payload_count += 1
|
|
||||||
|
|
||||||
if invalid_payload_count >= 5:
|
|
||||||
raise RuntimeError(
|
|
||||||
"WebSocket depth stream returned another symbol."
|
|
||||||
)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
invalid_payload_count = 0
|
|
||||||
best_bid = float(quote.bid_price)
|
|
||||||
best_ask = float(quote.ask_price)
|
|
||||||
|
|
||||||
if valid_payload_count == 0:
|
|
||||||
should_log_connected = (
|
|
||||||
context.last_stream_state != "CONNECTED"
|
|
||||||
and cls._can_log_runtime_event(
|
|
||||||
f"market_stream_connected:{symbol}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
context.last_stream_state = "CONNECTED"
|
|
||||||
context.last_stream_error_key = None
|
|
||||||
context.last_rest_state = None
|
|
||||||
context.last_rest_error_key = None
|
|
||||||
|
|
||||||
if should_log_connected:
|
|
||||||
cls._log_info(
|
|
||||||
context,
|
|
||||||
"market_stream_connected",
|
|
||||||
"Live-поток рыночных данных подключён.",
|
|
||||||
{
|
|
||||||
"requested_symbol": symbol,
|
|
||||||
"cache_symbol": cache_symbol,
|
|
||||||
"ws_symbol": ws_symbol,
|
|
||||||
"bid_price": best_bid,
|
|
||||||
"ask_price": best_ask,
|
|
||||||
"payload_keys": list(payload.keys()),
|
|
||||||
"payload_preview": cls._safe_payload_preview(
|
|
||||||
cls._extract_depth_payload(payload)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
valid_payload_count += 1
|
|
||||||
|
|
||||||
get_quote_store().set(
|
|
||||||
QUOTE_RUNTIME_SOURCE_NAME,
|
|
||||||
quote,
|
|
||||||
runtime_key=context.runtime_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
cls._log_ws_depth_debug(
|
|
||||||
context=context,
|
|
||||||
symbol=symbol,
|
|
||||||
cache_symbol=cache_symbol,
|
|
||||||
ws_symbol=ws_symbol,
|
|
||||||
best_bid=best_bid,
|
|
||||||
best_ask=best_ask,
|
|
||||||
valid_payload_count=valid_payload_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _rest_fallback_once(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
symbol: str,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
ExchangeService().refresh_quote_cache,
|
|
||||||
symbol,
|
|
||||||
runtime_key=context.runtime_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
should_log_rest_available = (
|
|
||||||
context.last_rest_state != "AVAILABLE"
|
|
||||||
and cls._can_log_runtime_event(
|
|
||||||
f"market_rest_fallback_available:{symbol}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
context.last_rest_state = "AVAILABLE"
|
|
||||||
context.last_rest_error_key = None
|
|
||||||
|
|
||||||
if should_log_rest_available:
|
|
||||||
|
|
||||||
cls._log_info(
|
|
||||||
context,
|
|
||||||
"market_rest_fallback_available",
|
|
||||||
"REST-режим рыночных данных доступен. Live-поток пока недоступен.",
|
|
||||||
{
|
|
||||||
"symbol": symbol,
|
|
||||||
"cache_symbol": cls._cache_symbol(symbol),
|
|
||||||
"ws_symbol": cls._ws_symbol(symbol),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
error_key = f"{symbol}:{type(exc).__name__}:{str(exc)}"
|
|
||||||
|
|
||||||
should_log_rest_unavailable = (
|
|
||||||
(
|
|
||||||
context.last_rest_state != "UNAVAILABLE"
|
|
||||||
or context.last_rest_error_key != error_key
|
|
||||||
)
|
|
||||||
and cls._can_log_runtime_event(
|
|
||||||
f"market_rest_fallback_unavailable:{symbol}:{error_key}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
context.last_rest_state = "UNAVAILABLE"
|
|
||||||
context.last_rest_error_key = error_key
|
|
||||||
|
|
||||||
if should_log_rest_unavailable:
|
|
||||||
|
|
||||||
cls._log_error(
|
|
||||||
context,
|
|
||||||
"market_rest_fallback_unavailable",
|
|
||||||
"Live-поток отключён. REST-режим рыночных данных недоступен.",
|
|
||||||
{
|
|
||||||
"symbol": symbol,
|
|
||||||
"error": str(exc),
|
|
||||||
"error_type": type(exc).__name__,
|
|
||||||
"traceback": traceback.format_exc(limit=5),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _is_cache_symbol_used_by_other_runtime(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
runtime_key: str,
|
|
||||||
cache_symbol: str,
|
|
||||||
) -> bool:
|
|
||||||
for key, context in cls._runtimes.items():
|
|
||||||
if key == runtime_key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
symbol = context.symbol_provider()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if symbol and cls._cache_symbol(symbol) == cache_symbol:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _cache_symbol(cls, symbol: str) -> str:
|
|
||||||
try:
|
|
||||||
validation = ExchangeService().validate_symbol(symbol)
|
|
||||||
if validation.is_valid:
|
|
||||||
return validation.normalized_symbol
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return symbol
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _ws_symbol(cls, symbol: str) -> str:
|
|
||||||
return cls._cache_symbol(symbol)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _extract_depth_payload(cls, payload: JsonDict) -> JsonDict:
|
|
||||||
data: object = payload
|
|
||||||
|
|
||||||
for key in ("payload", "Payload"):
|
|
||||||
if isinstance(data, dict) and isinstance(data.get(key), dict):
|
|
||||||
data = data.get(key)
|
|
||||||
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for key in ("payload", "Payload"):
|
|
||||||
nested = data.get(key)
|
|
||||||
if isinstance(nested, dict):
|
|
||||||
return nested
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _safe_payload_preview(cls, payload: JsonDict) -> JsonDict:
|
|
||||||
preview: JsonDict = {}
|
|
||||||
|
|
||||||
for key, value in payload.items():
|
|
||||||
if key in {"bids", "asks"} and isinstance(value, list):
|
|
||||||
preview[key] = value[:2]
|
|
||||||
|
|
||||||
elif key == "payload" and isinstance(value, dict):
|
|
||||||
inner_preview: JsonDict = {}
|
|
||||||
|
|
||||||
for inner_key, inner_value in value.items():
|
|
||||||
if (
|
|
||||||
inner_key in {"bids", "asks"}
|
|
||||||
and isinstance(inner_value, list)
|
|
||||||
):
|
|
||||||
inner_preview[inner_key] = inner_value[:2]
|
|
||||||
else:
|
|
||||||
inner_preview[inner_key] = inner_value
|
|
||||||
|
|
||||||
preview[key] = inner_preview
|
|
||||||
|
|
||||||
else:
|
|
||||||
preview[key] = value
|
|
||||||
|
|
||||||
return preview
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _log_ws_depth_debug(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
symbol: str,
|
|
||||||
cache_symbol: str,
|
|
||||||
ws_symbol: str,
|
|
||||||
best_bid: float,
|
|
||||||
best_ask: float,
|
|
||||||
valid_payload_count: int,
|
|
||||||
) -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
if now - context.last_ws_debug_logged_at < 60:
|
|
||||||
return
|
|
||||||
|
|
||||||
context.last_ws_debug_logged_at = now
|
|
||||||
|
|
||||||
cls._log_debug(
|
|
||||||
context,
|
|
||||||
"ws_depth_alive",
|
|
||||||
"WS depth поток активен.",
|
|
||||||
{
|
|
||||||
"symbol": symbol,
|
|
||||||
"cache_symbol": cache_symbol,
|
|
||||||
"ws_symbol": ws_symbol,
|
|
||||||
"runtime_key": context.runtime_key,
|
|
||||||
"bid_price": best_bid,
|
|
||||||
"ask_price": best_ask,
|
|
||||||
"spread_percent": cls._spread_percent(best_bid, best_ask),
|
|
||||||
"valid_payload_count": valid_payload_count,
|
|
||||||
"source": f"ws_depth:{context.runtime_key}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _spread_percent(cls, bid_price: float, ask_price: float) -> float | None:
|
|
||||||
mid_price = (bid_price + ask_price) / 2
|
|
||||||
|
|
||||||
if mid_price <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return round(((ask_price - bid_price) / mid_price) * 100, 5)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _log_debug(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
event_type: str,
|
|
||||||
message: str,
|
|
||||||
payload: JsonDict | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
if context.screen:
|
|
||||||
JournalService().log_ui_debug(
|
|
||||||
event_type=event_type,
|
|
||||||
message=cls._message(context, message),
|
|
||||||
screen=context.screen,
|
|
||||||
action=context.action,
|
|
||||||
payload=cls._payload(context, payload),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
JournalService().log_debug(
|
|
||||||
event_type,
|
|
||||||
cls._message(context, message),
|
|
||||||
cls._payload(context, payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _message(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
message: str,
|
|
||||||
) -> str:
|
|
||||||
return message
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _payload(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
payload: JsonDict | None = None,
|
|
||||||
) -> JsonDict:
|
|
||||||
result: JsonDict = dict(payload or {})
|
|
||||||
result.setdefault("runtime_key", context.runtime_key)
|
|
||||||
|
|
||||||
if context.screen:
|
|
||||||
result.setdefault("runtime_screen", context.screen)
|
|
||||||
|
|
||||||
if context.runtime_label:
|
|
||||||
result.setdefault("runtime_label", context.runtime_label)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _log_info(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
event_type: str,
|
|
||||||
message: str,
|
|
||||||
payload: JsonDict | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
if context.screen:
|
|
||||||
JournalService().log_ui_info(
|
|
||||||
event_type=event_type,
|
|
||||||
message=cls._message(context, message),
|
|
||||||
screen=context.screen,
|
|
||||||
action=context.action,
|
|
||||||
payload=cls._payload(context, payload),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
JournalService().log_info(
|
|
||||||
event_type,
|
|
||||||
cls._message(context, message),
|
|
||||||
cls._payload(context, payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _log_warning(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
event_type: str,
|
|
||||||
message: str,
|
|
||||||
payload: JsonDict | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
if context.screen:
|
|
||||||
JournalService().log_ui_warning(
|
|
||||||
event_type=event_type,
|
|
||||||
message=cls._message(context, message),
|
|
||||||
screen=context.screen,
|
|
||||||
action=context.action,
|
|
||||||
payload=cls._payload(context, payload),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
JournalService().log_warning(
|
|
||||||
event_type,
|
|
||||||
cls._message(context, message),
|
|
||||||
cls._payload(context, payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _log_error(
|
|
||||||
cls,
|
|
||||||
context: MarketRuntimeContext,
|
|
||||||
event_type: str,
|
|
||||||
message: str,
|
|
||||||
payload: JsonDict | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
error_type = None
|
|
||||||
raw_error = None
|
|
||||||
|
|
||||||
if payload:
|
|
||||||
error_type = payload.get("error_type")
|
|
||||||
raw_error = payload.get("error")
|
|
||||||
|
|
||||||
if context.screen:
|
|
||||||
JournalService().log_ui_error(
|
|
||||||
event_type=event_type,
|
|
||||||
message=cls._message(context, message),
|
|
||||||
screen=context.screen,
|
|
||||||
action=context.action,
|
|
||||||
payload=cls._payload(context, payload),
|
|
||||||
error_type=str(error_type) if error_type is not None else None,
|
|
||||||
raw_error=str(raw_error) if raw_error is not None else None,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
JournalService().log_error(
|
|
||||||
event_type,
|
|
||||||
cls._message(context, message),
|
|
||||||
cls._payload(context, payload),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# app/src/integrations/exchange/market_stream.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from src.core.config import load_settings
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
|
||||||
from src.integrations.exchange.ws_client import ExchangeWebSocketClient
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket import (
|
|
||||||
DzengiWebSocketQuoteAdapter,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
MarketDataAcquisitionError,
|
|
||||||
)
|
|
||||||
from src.storage.quote_store import (
|
|
||||||
QUOTE_RUNTIME_SOURCE_NAME,
|
|
||||||
get_quote_store,
|
|
||||||
)
|
|
||||||
from src.trading.journal.service import JournalService
|
|
||||||
|
|
||||||
|
|
||||||
# запускает постоянный websocket-поток рынка и обновляет Quote Store
|
|
||||||
async def start_market_stream() -> None:
|
|
||||||
settings = load_settings()
|
|
||||||
journal = JournalService()
|
|
||||||
|
|
||||||
if not settings.exchange_enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
service = ExchangeService()
|
|
||||||
validation = service.validate_symbol(settings.default_symbol)
|
|
||||||
|
|
||||||
if not validation.is_valid:
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
continue
|
|
||||||
|
|
||||||
symbol = validation.normalized_symbol
|
|
||||||
client = ExchangeWebSocketClient()
|
|
||||||
adapter = DzengiWebSocketQuoteAdapter()
|
|
||||||
|
|
||||||
journal.log_info(
|
|
||||||
"market_ws_started",
|
|
||||||
"WebSocket market stream запущен.",
|
|
||||||
{"symbol": symbol},
|
|
||||||
)
|
|
||||||
|
|
||||||
async for message in client.stream_depth(symbol):
|
|
||||||
try:
|
|
||||||
quote = adapter.map_message(message)
|
|
||||||
except MarketDataAcquisitionError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if quote.symbol.strip().upper() != symbol.strip().upper():
|
|
||||||
continue
|
|
||||||
|
|
||||||
get_quote_store().set(
|
|
||||||
QUOTE_RUNTIME_SOURCE_NAME,
|
|
||||||
quote,
|
|
||||||
runtime_key="default",
|
|
||||||
)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
try:
|
|
||||||
journal.log_warning(
|
|
||||||
"market_ws_reconnect",
|
|
||||||
f"WebSocket market stream будет переподключен: {exc}",
|
|
||||||
{"raw_error": str(exc)},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
# app/src/integrations/exchange/mock_data.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth
|
from src.integrations.exchange.models import BalanceSummary, ExchangeHealth, TickerPrice
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
|
|
||||||
|
|
||||||
def mock_exchange_health() -> ExchangeHealth:
|
def mock_exchange_health() -> ExchangeHealth:
|
||||||
@@ -17,23 +13,20 @@ def mock_exchange_health() -> ExchangeHealth:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def mock_quote(symbol: str) -> Quote:
|
def mock_ticker_price(symbol: str) -> TickerPrice:
|
||||||
normalized_symbol = symbol.upper().strip()
|
symbol = symbol.upper().strip()
|
||||||
fake_prices = {
|
fake_prices = {
|
||||||
"BTCUSDT": Decimal("68425.10"),
|
"BTCUSDT": 68425.10,
|
||||||
"ETHUSDT": Decimal("3521.44"),
|
"ETHUSDT": 3521.44,
|
||||||
"BNBUSDT": Decimal("612.33"),
|
"BNBUSDT": 612.33,
|
||||||
}
|
}
|
||||||
price = fake_prices.get(normalized_symbol, Decimal("100.00"))
|
price = fake_prices.get(symbol, 100.00)
|
||||||
|
updated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
return Quote(
|
return TickerPrice(
|
||||||
symbol=normalized_symbol,
|
symbol=symbol,
|
||||||
last_price=price,
|
price=price,
|
||||||
bid_price=price,
|
|
||||||
ask_price=price,
|
|
||||||
exchange_timestamp=None,
|
|
||||||
received_at=datetime.now(timezone.utc),
|
|
||||||
source="mock",
|
source="mock",
|
||||||
|
updated_at=updated_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
# app/src/integrations/exchange/models.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
|
||||||
|
|
||||||
|
|
||||||
# Состояние публичного API биржи.
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ExchangeHealth:
|
class ExchangeHealth:
|
||||||
ok: bool
|
ok: bool
|
||||||
@@ -18,40 +10,14 @@ class ExchangeHealth:
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# Состояние синхронизации времени сервера и биржи.
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class TimeSyncStatus:
|
class TickerPrice:
|
||||||
ok: bool
|
|
||||||
local_time: str
|
|
||||||
exchange_time: str | None
|
|
||||||
drift_seconds: float | None
|
|
||||||
hostname: str
|
|
||||||
local_ip: str | None
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Snapshot цен для execution layer.
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class ExecutionPriceSnapshot:
|
|
||||||
symbol: str
|
symbol: str
|
||||||
|
price: float
|
||||||
last_price: float
|
|
||||||
bid_price: float
|
|
||||||
ask_price: float
|
|
||||||
|
|
||||||
updated_at: str
|
|
||||||
source: str
|
source: str
|
||||||
|
updated_at: str
|
||||||
is_fresh: bool
|
|
||||||
|
|
||||||
age_seconds: float | None = None
|
|
||||||
freshness_status: str = "UNKNOWN"
|
|
||||||
spread_percent: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# Баланс актива аккаунта.
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class BalanceSummary:
|
class BalanceSummary:
|
||||||
currency: str
|
currency: str
|
||||||
@@ -60,40 +26,28 @@ class BalanceSummary:
|
|||||||
source: str
|
source: str
|
||||||
|
|
||||||
|
|
||||||
# Результат проверки торгового символа по каноническому справочнику Instrument.
|
@dataclass(slots=True)
|
||||||
|
class ExchangeSymbol:
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
base_asset: str
|
||||||
|
quote_asset: str
|
||||||
|
market_modes: list[str]
|
||||||
|
market_type: str
|
||||||
|
tick_size: float | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SymbolValidationResult:
|
class SymbolValidationResult:
|
||||||
requested_symbol: str
|
requested_symbol: str
|
||||||
normalized_symbol: str
|
normalized_symbol: str
|
||||||
|
|
||||||
is_valid: bool
|
is_valid: bool
|
||||||
message: str
|
message: str
|
||||||
|
symbol_info: ExchangeSymbol | None
|
||||||
symbol_info: Instrument | None
|
|
||||||
|
|
||||||
|
|
||||||
# Состояние приватного API аккаунта.
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PrivateAuthHealth:
|
class PrivateAuthHealth:
|
||||||
ok: bool
|
ok: bool
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# Runtime-статус рынка инструмента.
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class ExchangeMarketStatus:
|
|
||||||
symbol: str
|
|
||||||
is_open: bool
|
|
||||||
status: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
# Информация о торговой комиссии для инструмента.
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class TradingFee:
|
|
||||||
symbol: str
|
|
||||||
name: str
|
|
||||||
fee_percent: float | None = None
|
|
||||||
overnight_long_rate: float | None = None
|
|
||||||
overnight_short_rate: float | None = None
|
|
||||||
overnight_fee_timestamp: int | None = None
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# app/src/integrations/exchange/private_client.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src.core.config import load_settings
|
from src.core.config import load_settings
|
||||||
@@ -23,7 +21,7 @@ class ExchangePrivateClient:
|
|||||||
signed = self.auth.build_signed_params(params)
|
signed = self.auth.build_signed_params(params)
|
||||||
|
|
||||||
return self.client.get_json(
|
return self.client.get_json(
|
||||||
"/api/v1/account",
|
"/api/v2/account",
|
||||||
params=signed,
|
params=signed,
|
||||||
headers=self.auth.build_headers(),
|
headers=self.auth.build_headers(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# app/src/integrations/exchange/rest_client.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -7,7 +5,7 @@ from urllib.error import HTTPError, URLError
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from src.core.config import Settings, load_settings
|
from src.core.config import load_settings
|
||||||
from src.integrations.exchange.exceptions import (
|
from src.integrations.exchange.exceptions import (
|
||||||
ExchangeConnectionError,
|
ExchangeConnectionError,
|
||||||
ExchangeResponseError,
|
ExchangeResponseError,
|
||||||
@@ -15,74 +13,13 @@ from src.integrations.exchange.exceptions import (
|
|||||||
|
|
||||||
|
|
||||||
class ExchangeRestClient:
|
class ExchangeRestClient:
|
||||||
def __init__(
|
def __init__(self) -> None:
|
||||||
self,
|
self.settings = load_settings()
|
||||||
settings: Settings | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.settings = (
|
|
||||||
settings
|
|
||||||
if settings is not None
|
|
||||||
else load_settings()
|
|
||||||
)
|
|
||||||
if not self.settings.exchange_base_url:
|
if not self.settings.exchange_base_url:
|
||||||
raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.")
|
raise ExchangeConnectionError("EXCHANGE_BASE_URL is empty.")
|
||||||
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
self.base_url = self.settings.exchange_base_url.rstrip("/")
|
||||||
self.timeout = self.settings.exchange_timeout_sec
|
self.timeout = self.settings.exchange_timeout_sec
|
||||||
|
|
||||||
def get_payload(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
params: dict[str, str] | None = None,
|
|
||||||
headers: dict[str, str] | None = None,
|
|
||||||
) -> object:
|
|
||||||
query = f"?{urlencode(params)}" if params else ""
|
|
||||||
url = f"{self.base_url}{path}{query}"
|
|
||||||
|
|
||||||
request_headers = {
|
|
||||||
"Accept": "application/json",
|
|
||||||
"User-Agent": "dzentra-bot/2.0.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
if headers:
|
|
||||||
request_headers.update(headers)
|
|
||||||
|
|
||||||
request = Request(
|
|
||||||
url=url,
|
|
||||||
method="GET",
|
|
||||||
headers=request_headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urlopen(request, timeout=self.timeout) as response:
|
|
||||||
status = getattr(response, "status", 200)
|
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
except HTTPError as exc:
|
|
||||||
error_body = ""
|
|
||||||
try:
|
|
||||||
error_body = exc.read().decode("utf-8")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
message = f"HTTP {exc.code} from exchange: {exc.reason}"
|
|
||||||
if error_body:
|
|
||||||
message += f" | body: {error_body}"
|
|
||||||
|
|
||||||
raise ExchangeResponseError(message) from exc
|
|
||||||
except URLError as exc:
|
|
||||||
raise ExchangeConnectionError(
|
|
||||||
f"Network error while calling exchange: {exc.reason}"
|
|
||||||
) from exc
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise ExchangeConnectionError("Timeout while calling exchange.") from exc
|
|
||||||
|
|
||||||
if status != 200:
|
|
||||||
raise ExchangeResponseError(f"Unexpected HTTP status: {status}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(body)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ExchangeResponseError("Exchange returned non-JSON response.") from exc
|
|
||||||
|
|
||||||
def get_json(
|
def get_json(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
# app/src/integrations/exchange/runtime_ui.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.core.numbers import safe_float
|
|
||||||
from src.integrations.exchange.models import TimeSyncStatus
|
|
||||||
from src.integrations.exchange.status import (
|
|
||||||
ExchangeRuntimeStatus,
|
|
||||||
ExchangeStatusCode,
|
|
||||||
build_exchange_error_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_drift_seconds(value: float | int | None) -> str:
|
|
||||||
number = safe_float(value)
|
|
||||||
|
|
||||||
if number is None:
|
|
||||||
return "—"
|
|
||||||
|
|
||||||
sign = "-" if number < 0 else "+"
|
|
||||||
total_seconds = abs(int(round(number)))
|
|
||||||
|
|
||||||
minutes = total_seconds // 60
|
|
||||||
seconds = total_seconds % 60
|
|
||||||
|
|
||||||
if minutes > 0:
|
|
||||||
return f"{sign} {minutes} мин. {seconds} сек."
|
|
||||||
|
|
||||||
return f"{sign} {seconds} сек."
|
|
||||||
|
|
||||||
|
|
||||||
def build_time_sync_details(sync: TimeSyncStatus) -> str:
|
|
||||||
lines = [
|
|
||||||
"Проверь настройки времени на:",
|
|
||||||
f"Сервер: {sync.hostname}",
|
|
||||||
]
|
|
||||||
|
|
||||||
if sync.local_ip:
|
|
||||||
lines.append(f"IP: {sync.local_ip}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"Время сервера: {sync.local_time}")
|
|
||||||
|
|
||||||
if sync.exchange_time:
|
|
||||||
lines.append(f"Время биржи: {sync.exchange_time}")
|
|
||||||
|
|
||||||
lines.append(f"Расхождение: {format_drift_seconds(sync.drift_seconds)}")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_time_sync_status_from_service() -> TimeSyncStatus:
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
|
||||||
|
|
||||||
return ExchangeService().get_time_sync_status()
|
|
||||||
|
|
||||||
|
|
||||||
def build_time_sync_details_from_service() -> str:
|
|
||||||
return build_time_sync_details(get_time_sync_status_from_service())
|
|
||||||
|
|
||||||
|
|
||||||
def build_exchange_error_ui_parts(
|
|
||||||
exc: Exception,
|
|
||||||
) -> tuple[ExchangeRuntimeStatus, str, str]:
|
|
||||||
status = build_exchange_error_status(exc)
|
|
||||||
|
|
||||||
if status.code == ExchangeStatusCode.AUTH_ERROR:
|
|
||||||
return (
|
|
||||||
status,
|
|
||||||
"⛔️ Ошибка доступа к аккаунту",
|
|
||||||
"Проверь API-ключ, Secret Key, IP whitelist и права доступа.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if status.code == ExchangeStatusCode.TIME_ERROR:
|
|
||||||
return (
|
|
||||||
status,
|
|
||||||
"⛔️ Ошибка времени биржи",
|
|
||||||
build_time_sync_details_from_service(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if status.code == ExchangeStatusCode.EXCHANGE_UNAVAILABLE:
|
|
||||||
return status, "⛔️ Биржа недоступна", ""
|
|
||||||
|
|
||||||
return status, status.ui_line, status.message
|
|
||||||
|
|
||||||
|
|
||||||
def build_runtime_exchange_status(exc: Exception) -> dict[str, object]:
|
|
||||||
status = build_exchange_error_status(exc)
|
|
||||||
|
|
||||||
if status.code == ExchangeStatusCode.TIME_ERROR:
|
|
||||||
sync = get_time_sync_status_from_service()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"code": status.code.value,
|
|
||||||
"title": "Ошибка времени биржи",
|
|
||||||
"ui_line": "⛔️ Ошибка времени биржи",
|
|
||||||
"details": {
|
|
||||||
"hostname": sync.hostname,
|
|
||||||
"local_ip": sync.local_ip,
|
|
||||||
"local_time": sync.local_time,
|
|
||||||
"exchange_time": sync.exchange_time,
|
|
||||||
"drift_seconds": sync.drift_seconds,
|
|
||||||
},
|
|
||||||
"reason": status.reason,
|
|
||||||
"raw_error": status.raw_error,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, title, details = build_exchange_error_ui_parts(exc)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"code": status.code.value,
|
|
||||||
"title": title.replace("⛔️ ", "").strip(),
|
|
||||||
"ui_line": title,
|
|
||||||
"details": details,
|
|
||||||
"reason": status.reason,
|
|
||||||
"raw_error": status.raw_error,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_runtime_exchange_alerts(
|
|
||||||
*,
|
|
||||||
symbol: str | None = None,
|
|
||||||
exc: Exception | None = None,
|
|
||||||
include_exchange_unavailable: bool = True,
|
|
||||||
) -> list[dict[str, object]]:
|
|
||||||
from src.integrations.exchange.service import ExchangeService
|
|
||||||
|
|
||||||
alerts: list[dict[str, object]] = []
|
|
||||||
service = ExchangeService()
|
|
||||||
|
|
||||||
def add_alert(alert: dict[str, object] | None) -> None:
|
|
||||||
if not alert:
|
|
||||||
return
|
|
||||||
|
|
||||||
code = str(alert.get("code") or "")
|
|
||||||
reason = str(alert.get("reason") or "")
|
|
||||||
|
|
||||||
for existing in alerts:
|
|
||||||
if (
|
|
||||||
str(existing.get("code") or "") == code
|
|
||||||
and str(existing.get("reason") or "") == reason
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
alerts.append(alert)
|
|
||||||
|
|
||||||
if exc is not None:
|
|
||||||
add_alert(build_runtime_exchange_status(exc))
|
|
||||||
|
|
||||||
try:
|
|
||||||
runtime_status = service.get_symbol_runtime_status(symbol)
|
|
||||||
except Exception as status_exc:
|
|
||||||
if include_exchange_unavailable:
|
|
||||||
add_alert(build_runtime_exchange_status(status_exc))
|
|
||||||
else:
|
|
||||||
if include_exchange_unavailable and not runtime_status.is_available:
|
|
||||||
add_alert(
|
|
||||||
build_runtime_exchange_status(
|
|
||||||
Exception(runtime_status.raw_error or runtime_status.message)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
time_sync = service.get_time_sync_status()
|
|
||||||
except Exception as time_exc:
|
|
||||||
add_alert(build_runtime_exchange_status(time_exc))
|
|
||||||
else:
|
|
||||||
if not time_sync.ok:
|
|
||||||
add_alert(
|
|
||||||
{
|
|
||||||
"code": ExchangeStatusCode.TIME_ERROR.value,
|
|
||||||
"title": "Ошибка времени биржи",
|
|
||||||
"ui_line": "⛔️ Ошибка времени биржи",
|
|
||||||
"details": {
|
|
||||||
"hostname": time_sync.hostname,
|
|
||||||
"local_ip": time_sync.local_ip,
|
|
||||||
"local_time": time_sync.local_time,
|
|
||||||
"exchange_time": time_sync.exchange_time,
|
|
||||||
"drift_seconds": time_sync.drift_seconds,
|
|
||||||
},
|
|
||||||
"reason": "time_error",
|
|
||||||
"raw_error": time_sync.message,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
private_auth_health = service.get_private_auth_health()
|
|
||||||
except Exception as auth_exc:
|
|
||||||
add_alert(build_runtime_exchange_status(auth_exc))
|
|
||||||
else:
|
|
||||||
if not private_auth_health.ok:
|
|
||||||
add_alert(
|
|
||||||
build_runtime_exchange_status(
|
|
||||||
Exception(private_auth_health.message)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
priority = {
|
|
||||||
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value: 10,
|
|
||||||
ExchangeStatusCode.TIME_ERROR.value: 20,
|
|
||||||
ExchangeStatusCode.AUTH_ERROR.value: 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
alerts.sort(
|
|
||||||
key=lambda alert: priority.get(
|
|
||||||
str(alert.get("code") or ""),
|
|
||||||
999,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
|
|
||||||
|
|
||||||
def format_runtime_exchange_alert(alert: dict[str, object]) -> str:
|
|
||||||
title = str(
|
|
||||||
alert.get("ui_line")
|
|
||||||
or alert.get("title")
|
|
||||||
or "⛔️ Ошибка биржи"
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
details = alert.get("details")
|
|
||||||
code = str(alert.get("code") or "")
|
|
||||||
|
|
||||||
lines = [title]
|
|
||||||
|
|
||||||
if isinstance(details, dict):
|
|
||||||
lines.append("Проверь настройки времени на:")
|
|
||||||
|
|
||||||
hostname = details.get("hostname")
|
|
||||||
local_ip = details.get("local_ip")
|
|
||||||
local_time = details.get("local_time")
|
|
||||||
exchange_time = details.get("exchange_time")
|
|
||||||
drift_seconds = details.get("drift_seconds")
|
|
||||||
|
|
||||||
if hostname:
|
|
||||||
lines.append(f"• Сервер: {hostname}")
|
|
||||||
|
|
||||||
if local_ip:
|
|
||||||
lines.append(f"• IP: {local_ip}")
|
|
||||||
|
|
||||||
if local_time:
|
|
||||||
lines.append(f"• Время сервера: {local_time}")
|
|
||||||
|
|
||||||
if exchange_time:
|
|
||||||
lines.append(f"• Время биржи: {exchange_time}")
|
|
||||||
|
|
||||||
lines.append(f"• Расхождение: {format_drift_seconds(drift_seconds)}")
|
|
||||||
|
|
||||||
return "\n".join(lines).strip()
|
|
||||||
|
|
||||||
if code == ExchangeStatusCode.AUTH_ERROR.value:
|
|
||||||
lines.extend([
|
|
||||||
"Проверь:",
|
|
||||||
"• API-ключ, Secret Key",
|
|
||||||
"• IP whitelist и права доступа",
|
|
||||||
])
|
|
||||||
return "\n".join(lines).strip()
|
|
||||||
|
|
||||||
details_text = str(details or "").strip()
|
|
||||||
|
|
||||||
if details_text:
|
|
||||||
lines.append(details_text)
|
|
||||||
|
|
||||||
return "\n".join(lines).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def format_runtime_exchange_alerts(alerts: list[dict[str, object]]) -> str:
|
|
||||||
return "\n\n".join(
|
|
||||||
block
|
|
||||||
for block in (
|
|
||||||
format_runtime_exchange_alert(alert)
|
|
||||||
for alert in alerts
|
|
||||||
)
|
|
||||||
if block.strip()
|
|
||||||
).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def build_runtime_exchange_alert_lines(
|
|
||||||
*,
|
|
||||||
symbol: str | None = None,
|
|
||||||
include_exchange_unavailable: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
alerts = build_runtime_exchange_alerts(
|
|
||||||
symbol=symbol,
|
|
||||||
include_exchange_unavailable=include_exchange_unavailable,
|
|
||||||
)
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
for alert in alerts:
|
|
||||||
line = str(alert.get("ui_line") or alert.get("title") or "").strip()
|
|
||||||
|
|
||||||
if line and line not in lines:
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
return lines
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
|||||||
# app/src/integrations/exchange/status.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import StrEnum
|
|
||||||
|
|
||||||
from src.integrations.exchange.exceptions import (
|
|
||||||
ExchangeConnectionError,
|
|
||||||
ExchangeResponseError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.status import (
|
|
||||||
InstrumentTradingState,
|
|
||||||
classify_instrument_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExchangeStatusCode(StrEnum):
|
|
||||||
OPEN = "OPEN"
|
|
||||||
BREAK = "BREAK"
|
|
||||||
EXCHANGE_UNAVAILABLE = "EXCHANGE_UNAVAILABLE"
|
|
||||||
AUTH_ERROR = "AUTH_ERROR"
|
|
||||||
TIME_ERROR = "TIME_ERROR"
|
|
||||||
INVALID_SYMBOL = "INVALID_SYMBOL"
|
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class ExchangeRuntimeStatus:
|
|
||||||
code: ExchangeStatusCode
|
|
||||||
is_open: bool
|
|
||||||
is_available: bool
|
|
||||||
is_auth_ok: bool
|
|
||||||
title: str
|
|
||||||
message: str
|
|
||||||
ui_line: str
|
|
||||||
reason: str
|
|
||||||
symbol: str | None = None
|
|
||||||
raw_status: str | None = None
|
|
||||||
raw_error: str | None = None
|
|
||||||
|
|
||||||
# Вернуть статус в dict для старого UI-кода на время миграции.
|
|
||||||
def as_dict(self) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"code": self.code.value,
|
|
||||||
"status": self.code.value,
|
|
||||||
"symbol": self.symbol,
|
|
||||||
"is_open": self.is_open,
|
|
||||||
"is_available": self.is_available,
|
|
||||||
"is_auth_ok": self.is_auth_ok,
|
|
||||||
"title": self.title,
|
|
||||||
"message": self.message,
|
|
||||||
"ui_line": self.ui_line,
|
|
||||||
"reason": self.reason,
|
|
||||||
"raw_status": self.raw_status,
|
|
||||||
"raw_error": self.raw_error,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_market_stale_status(
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
age_seconds: float | None,
|
|
||||||
updated_at: str | None = None,
|
|
||||||
) -> ExchangeRuntimeStatus:
|
|
||||||
age_text = "неизвестно" if age_seconds is None else f"{age_seconds:.0f}с"
|
|
||||||
updated_text = f" Последнее обновление: {updated_at}." if updated_at else ""
|
|
||||||
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.BREAK,
|
|
||||||
is_open=False,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Рынок закрыт",
|
|
||||||
message=(
|
|
||||||
f"Котировки по инструменту не обновляются. "
|
|
||||||
f"Возраст данных: {age_text}.{updated_text}"
|
|
||||||
),
|
|
||||||
ui_line="⏸️ Рынок закрыт",
|
|
||||||
reason="market_data_stale",
|
|
||||||
raw_status="STALE_MARKET_DATA",
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Собрать статус mock-режима.
|
|
||||||
def build_mock_exchange_status(*, symbol: str) -> ExchangeRuntimeStatus:
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.OPEN,
|
|
||||||
is_open=True,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Mock exchange",
|
|
||||||
message="Mock market is open.",
|
|
||||||
ui_line="🟢 Mock биржа",
|
|
||||||
reason="mock_exchange",
|
|
||||||
symbol=symbol,
|
|
||||||
raw_status="OPEN",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Собрать статус ошибки авторизации аккаунта.
|
|
||||||
def build_account_auth_status(exc: Exception) -> ExchangeRuntimeStatus:
|
|
||||||
return build_exchange_error_status(exc)
|
|
||||||
|
|
||||||
|
|
||||||
# Собрать legacy runtime-статус по канонической классификации инструмента.
|
|
||||||
def build_market_status_from_symbol_status(
|
|
||||||
*,
|
|
||||||
raw_status: str | None,
|
|
||||||
symbol: str,
|
|
||||||
) -> ExchangeRuntimeStatus:
|
|
||||||
classification = classify_instrument_status(raw_status)
|
|
||||||
normalized_status = classification.normalized_status
|
|
||||||
|
|
||||||
if classification.state == InstrumentTradingState.OPEN:
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.OPEN,
|
|
||||||
is_open=True,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Биржа доступна",
|
|
||||||
message="Рынок открыт.",
|
|
||||||
ui_line="🟢 Биржа доступна",
|
|
||||||
reason="market_open",
|
|
||||||
raw_status=normalized_status,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
if classification.state == InstrumentTradingState.NOT_TRADABLE:
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.BREAK,
|
|
||||||
is_open=False,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Рынок недоступен",
|
|
||||||
message=f"Этот рынок недоступен для торговли: {symbol}.",
|
|
||||||
ui_line="⛔️ Рынок недоступен для торговли",
|
|
||||||
reason="market_not_tradable",
|
|
||||||
raw_status=normalized_status,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
if classification.state == InstrumentTradingState.BREAK:
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.BREAK,
|
|
||||||
is_open=False,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Перерыв в торгах",
|
|
||||||
message=f"Торги по {symbol} временно остановлены.",
|
|
||||||
ui_line="⏸️ Перерыв в торгах",
|
|
||||||
reason="market_break",
|
|
||||||
raw_status=normalized_status,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.UNKNOWN,
|
|
||||||
is_open=False,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Статус торгов неизвестен",
|
|
||||||
message=(
|
|
||||||
f"Биржа вернула неизвестный статус инструмента"
|
|
||||||
f"{f': {normalized_status}' if normalized_status else ''}."
|
|
||||||
),
|
|
||||||
ui_line="⚠️ Статус торгов неизвестен",
|
|
||||||
reason="market_status_unknown",
|
|
||||||
raw_status=normalized_status,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Собрать единый статус для неверного торгового инструмента.
|
|
||||||
def build_invalid_symbol_status(
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
message: str,
|
|
||||||
) -> ExchangeRuntimeStatus:
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.INVALID_SYMBOL,
|
|
||||||
is_open=False,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Инструмент недоступен",
|
|
||||||
message=message or f"Инструмент {symbol} недоступен.",
|
|
||||||
ui_line="⛔️ Инструмент недоступен",
|
|
||||||
reason="invalid_symbol",
|
|
||||||
raw_status="INVALID_SYMBOL",
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Собрать единый статус по ошибке exchange/API.
|
|
||||||
def build_exchange_error_status(exc: Exception) -> ExchangeRuntimeStatus:
|
|
||||||
error_type = classify_exchange_error(exc)
|
|
||||||
raw_error = str(exc)
|
|
||||||
|
|
||||||
if error_type == "auth":
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.AUTH_ERROR,
|
|
||||||
is_open=False,
|
|
||||||
is_available=True,
|
|
||||||
is_auth_ok=False,
|
|
||||||
title="Ошибка доступа к аккаунту",
|
|
||||||
message="Ошибка доступа к аккаунту.",
|
|
||||||
ui_line="⛔️ Ошибка доступа к аккаунту",
|
|
||||||
reason="auth_error",
|
|
||||||
raw_status="AUTH_ERROR",
|
|
||||||
raw_error=raw_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
if error_type == "time":
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.TIME_ERROR,
|
|
||||||
is_open=False,
|
|
||||||
is_available=False,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Ошибка времени",
|
|
||||||
message="Проверь синхронизацию времени.",
|
|
||||||
ui_line="⛔️ Ошибка времени биржи",
|
|
||||||
reason="time_error",
|
|
||||||
raw_status="TIME_ERROR",
|
|
||||||
raw_error=raw_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ExchangeRuntimeStatus(
|
|
||||||
code=ExchangeStatusCode.EXCHANGE_UNAVAILABLE,
|
|
||||||
is_open=False,
|
|
||||||
is_available=False,
|
|
||||||
is_auth_ok=True,
|
|
||||||
title="Биржа недоступна",
|
|
||||||
message="Не удалось получить данные с биржи.",
|
|
||||||
ui_line="⛔️ Биржа недоступна",
|
|
||||||
reason="exchange_unavailable",
|
|
||||||
raw_status="EXCHANGE_UNAVAILABLE",
|
|
||||||
raw_error=raw_error,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Классифицировать ошибку биржи для единого UI и логов.
|
|
||||||
def classify_exchange_error(exc: Exception) -> str:
|
|
||||||
text = str(exc).lower()
|
|
||||||
|
|
||||||
if any(
|
|
||||||
marker in text
|
|
||||||
for marker in [
|
|
||||||
"invalid api key",
|
|
||||||
"invalid api-key",
|
|
||||||
"api key",
|
|
||||||
"api-key",
|
|
||||||
"signature",
|
|
||||||
"unauthorized",
|
|
||||||
"forbidden",
|
|
||||||
"permissions",
|
|
||||||
"expired",
|
|
||||||
]
|
|
||||||
):
|
|
||||||
return "auth"
|
|
||||||
|
|
||||||
if any(
|
|
||||||
marker in text
|
|
||||||
for marker in [
|
|
||||||
"-1021",
|
|
||||||
"server time",
|
|
||||||
"doesn't match server time",
|
|
||||||
"рассинхрон",
|
|
||||||
]
|
|
||||||
):
|
|
||||||
return "time"
|
|
||||||
|
|
||||||
if isinstance(exc, ExchangeConnectionError):
|
|
||||||
return "network"
|
|
||||||
|
|
||||||
if isinstance(exc, ExchangeResponseError):
|
|
||||||
if "404" in text:
|
|
||||||
return "network"
|
|
||||||
|
|
||||||
if any(
|
|
||||||
marker in text
|
|
||||||
for marker in [
|
|
||||||
"404",
|
|
||||||
"timeout",
|
|
||||||
"timed out",
|
|
||||||
"connection error",
|
|
||||||
"network error",
|
|
||||||
"name or service not known",
|
|
||||||
"nodename nor servname",
|
|
||||||
"temporary failure",
|
|
||||||
]
|
|
||||||
):
|
|
||||||
return "network"
|
|
||||||
|
|
||||||
return "generic"
|
|
||||||
|
|
||||||
|
|
||||||
# Проверить, относится ли reason к unified exchange status layer.
|
|
||||||
def is_exchange_status_reason(reason: str | None) -> bool:
|
|
||||||
if not reason:
|
|
||||||
return False
|
|
||||||
|
|
||||||
normalized = str(reason).strip().upper()
|
|
||||||
|
|
||||||
return normalized in {
|
|
||||||
ExchangeStatusCode.OPEN.value,
|
|
||||||
ExchangeStatusCode.BREAK.value,
|
|
||||||
ExchangeStatusCode.EXCHANGE_UNAVAILABLE.value,
|
|
||||||
ExchangeStatusCode.AUTH_ERROR.value,
|
|
||||||
ExchangeStatusCode.TIME_ERROR.value,
|
|
||||||
ExchangeStatusCode.INVALID_SYMBOL.value,
|
|
||||||
ExchangeStatusCode.UNKNOWN.value,
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
# app/src/integrations/exchange/symbol_utils.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from src.market_data.acquisition.symbols import (
|
|
||||||
normalize_symbol,
|
def normalize_symbol(raw_symbol: str) -> str:
|
||||||
symbol_candidates,
|
return (raw_symbol or "").strip().upper()
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
def symbol_candidates(raw_symbol: str) -> list[str]:
|
||||||
"normalize_symbol",
|
value = normalize_symbol(raw_symbol)
|
||||||
"symbol_candidates",
|
if not value:
|
||||||
]
|
return []
|
||||||
|
|
||||||
|
candidates = [value]
|
||||||
|
|
||||||
|
compact = value.replace("%2F", "/")
|
||||||
|
if compact not in candidates:
|
||||||
|
candidates.append(compact)
|
||||||
|
|
||||||
|
no_spaces = compact.replace(" ", "")
|
||||||
|
if no_spaces not in candidates:
|
||||||
|
candidates.append(no_spaces)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# app/src/integrations/exchange/ws_client.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import AsyncIterator, cast
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from websockets.typing import Subprotocol
|
|
||||||
|
|
||||||
from src.core.config import load_settings
|
|
||||||
from src.core.numbers import safe_float
|
|
||||||
from src.core.types import JsonDict, JsonList, NumericLike
|
|
||||||
|
|
||||||
|
|
||||||
class ExchangeWebSocketClient:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.settings = load_settings()
|
|
||||||
self.base_url = self._build_ws_base_url()
|
|
||||||
|
|
||||||
# собрать корректный websocket URL из настроек
|
|
||||||
def _build_ws_base_url(self) -> str:
|
|
||||||
raw_url = self.settings.exchange_ws_url or self.settings.exchange_base_url
|
|
||||||
|
|
||||||
if raw_url.startswith("https://"):
|
|
||||||
raw_url = raw_url.replace("https://", "wss://", 1)
|
|
||||||
elif raw_url.startswith("http://"):
|
|
||||||
raw_url = raw_url.replace("http://", "ws://", 1)
|
|
||||||
|
|
||||||
raw_url = raw_url.rstrip("/")
|
|
||||||
|
|
||||||
if raw_url.endswith("/connect"):
|
|
||||||
return raw_url
|
|
||||||
|
|
||||||
return f"{raw_url}/connect"
|
|
||||||
|
|
||||||
# безопасно нормализовать паузу между websocket-запросами
|
|
||||||
def _interval_seconds(self, value: NumericLike | None) -> float:
|
|
||||||
interval = safe_float(value)
|
|
||||||
|
|
||||||
if interval is None or interval <= 0:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
return interval
|
|
||||||
|
|
||||||
# собрать headers для подключения к websocket
|
|
||||||
def _headers(self) -> dict[str, str]:
|
|
||||||
headers = {
|
|
||||||
"Origin": self.settings.exchange_base_url.rstrip("/"),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.settings.exchange_api_key:
|
|
||||||
headers["X-MBX-APIKEY"] = self.settings.exchange_api_key
|
|
||||||
|
|
||||||
return headers
|
|
||||||
|
|
||||||
# собрать payload запроса стакана
|
|
||||||
def _depth_request(self, symbol: str) -> JsonDict:
|
|
||||||
return {
|
|
||||||
"correlationId": str(uuid4()),
|
|
||||||
"destination": "/api/v1/depth",
|
|
||||||
"payload": {
|
|
||||||
"limit": 5,
|
|
||||||
"symbol": symbol,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# безопасно разобрать JSON от websocket
|
|
||||||
def _loads_json(self, raw_message: str | bytes) -> JsonDict | JsonList | None:
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw_message)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
return cast(JsonDict, payload)
|
|
||||||
|
|
||||||
if isinstance(payload, list):
|
|
||||||
return cast(JsonList, payload)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
# поток данных стакана по websocket
|
|
||||||
async def stream_depth(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
*,
|
|
||||||
interval_seconds: NumericLike = 1.0,
|
|
||||||
) -> AsyncIterator[JsonDict]:
|
|
||||||
interval = self._interval_seconds(interval_seconds)
|
|
||||||
headers = self._headers()
|
|
||||||
timeout_count = 0
|
|
||||||
max_timeouts = 3
|
|
||||||
|
|
||||||
async with websockets.connect(
|
|
||||||
self.base_url,
|
|
||||||
extra_headers=headers,
|
|
||||||
subprotocols=[Subprotocol("json")],
|
|
||||||
ping_interval=20,
|
|
||||||
open_timeout=self.settings.exchange_timeout_sec,
|
|
||||||
) as websocket:
|
|
||||||
last_ping_at = 0.0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
now = asyncio.get_running_loop().time()
|
|
||||||
|
|
||||||
if now - last_ping_at >= 5.0:
|
|
||||||
pong = await websocket.ping()
|
|
||||||
await asyncio.wait_for(
|
|
||||||
pong,
|
|
||||||
timeout=self.settings.exchange_timeout_sec,
|
|
||||||
)
|
|
||||||
last_ping_at = now
|
|
||||||
|
|
||||||
request = self._depth_request(symbol)
|
|
||||||
await websocket.send(json.dumps(request))
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_message = await asyncio.wait_for(
|
|
||||||
websocket.recv(),
|
|
||||||
timeout=self.settings.exchange_timeout_sec,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
timeout_count += 1
|
|
||||||
|
|
||||||
if timeout_count >= max_timeouts:
|
|
||||||
raise RuntimeError(
|
|
||||||
"WebSocket depth stream timed out repeatedly."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
continue
|
|
||||||
|
|
||||||
timeout_count = 0
|
|
||||||
|
|
||||||
if not isinstance(raw_message, (str, bytes)):
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
continue
|
|
||||||
|
|
||||||
payload = self._loads_json(raw_message)
|
|
||||||
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
yield payload
|
|
||||||
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
# app/src/main.py
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from src.bootstrap.application import run_application
|
|
||||||
from src.bootstrap.app_factory import create_app
|
from src.bootstrap.app_factory import create_app
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
application = create_app()
|
bot, dispatcher = create_app()
|
||||||
await run_application(application)
|
await dispatcher.start_polling(bot)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# app/src/market_data/__init__.py
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
from src.market_data.access.contracts import (
|
|
||||||
CandleRevisionHistoryReaderProtocol,
|
|
||||||
MarketDataHistoricalAccessProtocol,
|
|
||||||
QuoteHistoryReaderProtocol,
|
|
||||||
TradeHistoryReaderProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.access.exceptions import (
|
|
||||||
MarketDataAccessError,
|
|
||||||
MarketDataAccessIntegrityError,
|
|
||||||
MarketDataAccessOperationError,
|
|
||||||
MarketDataAccessValidationError,
|
|
||||||
MarketDataCursorError,
|
|
||||||
)
|
|
||||||
from src.market_data.access.market_data_historical_access import (
|
|
||||||
MarketDataHistoricalAccess,
|
|
||||||
)
|
|
||||||
from src.market_data.access.models import (
|
|
||||||
HISTORY_CURSOR_VERSION,
|
|
||||||
HISTORY_PAGE_LIMIT_DEFAULT,
|
|
||||||
HISTORY_PAGE_LIMIT_MAX,
|
|
||||||
CandleRevisionHistoryCursor,
|
|
||||||
CandleRevisionHistoryPage,
|
|
||||||
CandleRevisionHistoryQuery,
|
|
||||||
CandleRevisionHistoryRecord,
|
|
||||||
HistoricalTimeRange,
|
|
||||||
HistoryPage,
|
|
||||||
HistoryQuery,
|
|
||||||
HistoryRecord,
|
|
||||||
QuoteHistoryCursor,
|
|
||||||
QuoteHistoryPage,
|
|
||||||
QuoteHistoryQuery,
|
|
||||||
QuoteHistoryRecord,
|
|
||||||
TradeHistoryCursor,
|
|
||||||
TradeHistoryPage,
|
|
||||||
TradeHistoryQuery,
|
|
||||||
TradeHistoryRecord,
|
|
||||||
)
|
|
||||||
from src.market_data.access.postgres_candle_revision_history_repository import (
|
|
||||||
PostgresCandleRevisionHistoryRepository,
|
|
||||||
)
|
|
||||||
from src.market_data.access.postgres_history_support import (
|
|
||||||
PostgresHistoryConnectionProvider,
|
|
||||||
)
|
|
||||||
from src.market_data.access.postgres_quote_history_repository import (
|
|
||||||
PostgresQuoteHistoryRepository,
|
|
||||||
)
|
|
||||||
from src.market_data.access.postgres_trade_history_repository import (
|
|
||||||
PostgresTradeHistoryRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
"HISTORY_CURSOR_VERSION",
|
|
||||||
"HISTORY_PAGE_LIMIT_DEFAULT",
|
|
||||||
"HISTORY_PAGE_LIMIT_MAX",
|
|
||||||
"CandleRevisionHistoryCursor",
|
|
||||||
"CandleRevisionHistoryPage",
|
|
||||||
"CandleRevisionHistoryQuery",
|
|
||||||
"CandleRevisionHistoryReaderProtocol",
|
|
||||||
"CandleRevisionHistoryRecord",
|
|
||||||
"HistoricalTimeRange",
|
|
||||||
"HistoryPage",
|
|
||||||
"HistoryQuery",
|
|
||||||
"HistoryRecord",
|
|
||||||
"MarketDataAccessError",
|
|
||||||
"MarketDataAccessIntegrityError",
|
|
||||||
"MarketDataAccessOperationError",
|
|
||||||
"MarketDataAccessValidationError",
|
|
||||||
"MarketDataCursorError",
|
|
||||||
"MarketDataHistoricalAccess",
|
|
||||||
"MarketDataHistoricalAccessProtocol",
|
|
||||||
"PostgresCandleRevisionHistoryRepository",
|
|
||||||
"PostgresHistoryConnectionProvider",
|
|
||||||
"PostgresQuoteHistoryRepository",
|
|
||||||
"PostgresTradeHistoryRepository",
|
|
||||||
"QuoteHistoryCursor",
|
|
||||||
"QuoteHistoryPage",
|
|
||||||
"QuoteHistoryQuery",
|
|
||||||
"QuoteHistoryReaderProtocol",
|
|
||||||
"QuoteHistoryRecord",
|
|
||||||
"TradeHistoryCursor",
|
|
||||||
"TradeHistoryPage",
|
|
||||||
"TradeHistoryQuery",
|
|
||||||
"TradeHistoryReaderProtocol",
|
|
||||||
"TradeHistoryRecord",
|
|
||||||
)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from src.market_data.access.models import (
|
|
||||||
CandleRevisionHistoryPage,
|
|
||||||
CandleRevisionHistoryQuery,
|
|
||||||
QuoteHistoryPage,
|
|
||||||
QuoteHistoryQuery,
|
|
||||||
TradeHistoryPage,
|
|
||||||
TradeHistoryQuery,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class TradeHistoryReaderProtocol(Protocol):
|
|
||||||
"""Граница чтения одной страницы Canonical Trade History."""
|
|
||||||
|
|
||||||
def query_trades(
|
|
||||||
self,
|
|
||||||
query: TradeHistoryQuery,
|
|
||||||
) -> TradeHistoryPage:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class QuoteHistoryReaderProtocol(Protocol):
|
|
||||||
"""Граница чтения одной страницы Canonical Quote History."""
|
|
||||||
|
|
||||||
def query_quotes(
|
|
||||||
self,
|
|
||||||
query: QuoteHistoryQuery,
|
|
||||||
) -> QuoteHistoryPage:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class CandleRevisionHistoryReaderProtocol(Protocol):
|
|
||||||
"""Граница чтения одной страницы Canonical Candle revisions."""
|
|
||||||
|
|
||||||
def query_candle_revisions(
|
|
||||||
self,
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
) -> CandleRevisionHistoryPage:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class MarketDataHistoricalAccessProtocol(
|
|
||||||
TradeHistoryReaderProtocol,
|
|
||||||
QuoteHistoryReaderProtocol,
|
|
||||||
CandleRevisionHistoryReaderProtocol,
|
|
||||||
Protocol,
|
|
||||||
):
|
|
||||||
"""Объединённая DB-neutral граница Historical Access."""
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataAccessError(Exception):
|
|
||||||
"""Базовая ошибка доступа к историческим рыночным данным."""
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataAccessValidationError(MarketDataAccessError):
|
|
||||||
"""Недопустимый запрос или значение на публичной границе чтения."""
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataCursorError(MarketDataAccessValidationError):
|
|
||||||
"""Курсор повреждён либо принадлежит другому запросу."""
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataAccessIntegrityError(MarketDataAccessError):
|
|
||||||
"""Сохранённая строка не соответствует Canonical-контракту."""
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataAccessOperationError(MarketDataAccessError):
|
|
||||||
"""Ошибка backend-операции Historical Access."""
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.access.contracts import (
|
|
||||||
CandleRevisionHistoryReaderProtocol,
|
|
||||||
QuoteHistoryReaderProtocol,
|
|
||||||
TradeHistoryReaderProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.access.models import (
|
|
||||||
CandleRevisionHistoryPage,
|
|
||||||
CandleRevisionHistoryQuery,
|
|
||||||
QuoteHistoryPage,
|
|
||||||
QuoteHistoryQuery,
|
|
||||||
TradeHistoryPage,
|
|
||||||
TradeHistoryQuery,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MarketDataHistoricalAccess:
|
|
||||||
"""Независимый от базы данных фасад трёх модулей чтения истории."""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"_trade_reader",
|
|
||||||
"_quote_reader",
|
|
||||||
"_candle_revision_reader",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
trade_reader: TradeHistoryReaderProtocol,
|
|
||||||
quote_reader: QuoteHistoryReaderProtocol,
|
|
||||||
candle_revision_reader: CandleRevisionHistoryReaderProtocol,
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(trade_reader, TradeHistoryReaderProtocol):
|
|
||||||
raise TypeError(
|
|
||||||
"trade_reader must implement TradeHistoryReaderProtocol"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(quote_reader, QuoteHistoryReaderProtocol):
|
|
||||||
raise TypeError(
|
|
||||||
"quote_reader must implement QuoteHistoryReaderProtocol"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(
|
|
||||||
candle_revision_reader,
|
|
||||||
CandleRevisionHistoryReaderProtocol,
|
|
||||||
):
|
|
||||||
raise TypeError(
|
|
||||||
"candle_revision_reader must implement "
|
|
||||||
"CandleRevisionHistoryReaderProtocol"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._trade_reader = trade_reader
|
|
||||||
self._quote_reader = quote_reader
|
|
||||||
self._candle_revision_reader = candle_revision_reader
|
|
||||||
|
|
||||||
def query_trades(
|
|
||||||
self,
|
|
||||||
query: TradeHistoryQuery,
|
|
||||||
) -> TradeHistoryPage:
|
|
||||||
"""Передать запрос истории сделок соответствующему модулю."""
|
|
||||||
return self._trade_reader.query_trades(query)
|
|
||||||
|
|
||||||
def query_quotes(
|
|
||||||
self,
|
|
||||||
query: QuoteHistoryQuery,
|
|
||||||
) -> QuoteHistoryPage:
|
|
||||||
"""Передать запрос истории котировок соответствующему модулю."""
|
|
||||||
return self._quote_reader.query_quotes(query)
|
|
||||||
|
|
||||||
def query_candle_revisions(
|
|
||||||
self,
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
) -> CandleRevisionHistoryPage:
|
|
||||||
"""Передать запрос истории свечей соответствующему модулю."""
|
|
||||||
return self._candle_revision_reader.query_candle_revisions(query)
|
|
||||||
@@ -1,901 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.candle import Candle
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.symbols import normalize_symbol
|
|
||||||
|
|
||||||
|
|
||||||
HISTORY_CURSOR_VERSION = 1
|
|
||||||
HISTORY_PAGE_LIMIT_DEFAULT = 500
|
|
||||||
HISTORY_PAGE_LIMIT_MAX = 1_000
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_non_empty_text(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
uppercase: bool = False,
|
|
||||||
) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise TypeError(f"{field_name} must be a string")
|
|
||||||
|
|
||||||
normalized = value.strip()
|
|
||||||
|
|
||||||
if not normalized:
|
|
||||||
raise ValueError(f"{field_name} must not be empty")
|
|
||||||
|
|
||||||
return normalized.upper() if uppercase else normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_symbol(value: object, *, field_name: str) -> str:
|
|
||||||
raw_symbol = _normalize_non_empty_text(
|
|
||||||
value,
|
|
||||||
field_name=field_name,
|
|
||||||
)
|
|
||||||
normalized = normalize_symbol(raw_symbol)
|
|
||||||
|
|
||||||
if not normalized:
|
|
||||||
raise ValueError(f"{field_name} must not be empty")
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_utc_datetime(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
) -> datetime:
|
|
||||||
if not isinstance(value, datetime):
|
|
||||||
raise TypeError(f"{field_name} must be a datetime")
|
|
||||||
|
|
||||||
if value.tzinfo is None or value.utcoffset() is None:
|
|
||||||
raise ValueError(f"{field_name} must contain timezone")
|
|
||||||
|
|
||||||
return value.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_positive_integer(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
maximum: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
|
||||||
raise TypeError(f"{field_name} must be an integer")
|
|
||||||
|
|
||||||
if value <= 0:
|
|
||||||
raise ValueError(f"{field_name} must be positive")
|
|
||||||
|
|
||||||
if maximum is not None and value > maximum:
|
|
||||||
raise ValueError(
|
|
||||||
f"{field_name} must not exceed {maximum}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_observation_sources(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
primary_source: object,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
if not isinstance(value, tuple):
|
|
||||||
raise TypeError("observation_sources must be a tuple")
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
raise ValueError("observation_sources must not be empty")
|
|
||||||
|
|
||||||
normalized = tuple(
|
|
||||||
_normalize_non_empty_text(
|
|
||||||
source,
|
|
||||||
field_name="observation_sources item",
|
|
||||||
)
|
|
||||||
for source in value
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(set(normalized)) != len(normalized):
|
|
||||||
raise ValueError("observation_sources must be unique")
|
|
||||||
|
|
||||||
normalized_primary = _normalize_non_empty_text(
|
|
||||||
primary_source,
|
|
||||||
field_name="payload.source",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized[0] != normalized_primary:
|
|
||||||
raise ValueError(
|
|
||||||
"first observation source must match payload.source"
|
|
||||||
)
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_canonical_symbol_and_source(
|
|
||||||
*,
|
|
||||||
symbol: object,
|
|
||||||
source: object,
|
|
||||||
) -> None:
|
|
||||||
normalized_symbol = _normalize_symbol(
|
|
||||||
symbol,
|
|
||||||
field_name="payload.symbol",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_symbol != symbol:
|
|
||||||
raise ValueError("payload.symbol must be canonical")
|
|
||||||
|
|
||||||
normalized_source = _normalize_non_empty_text(
|
|
||||||
source,
|
|
||||||
field_name="payload.source",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_source != source:
|
|
||||||
raise ValueError("payload.source must be canonical")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class HistoricalTimeRange:
|
|
||||||
"""Полуоткрытый UTC-диапазон исторического запроса ``[start, end)``."""
|
|
||||||
|
|
||||||
start_time: datetime
|
|
||||||
end_time: datetime
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
normalized_start = _normalize_utc_datetime(
|
|
||||||
self.start_time,
|
|
||||||
field_name="start_time",
|
|
||||||
)
|
|
||||||
normalized_end = _normalize_utc_datetime(
|
|
||||||
self.end_time,
|
|
||||||
field_name="end_time",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_start >= normalized_end:
|
|
||||||
raise ValueError("start_time must be earlier than end_time")
|
|
||||||
|
|
||||||
object.__setattr__(self, "start_time", normalized_start)
|
|
||||||
object.__setattr__(self, "end_time", normalized_end)
|
|
||||||
|
|
||||||
def contains(self, instant: datetime) -> bool:
|
|
||||||
"""Проверить принадлежность времени полуоткрытому диапазону."""
|
|
||||||
normalized = _normalize_utc_datetime(
|
|
||||||
instant,
|
|
||||||
field_name="instant",
|
|
||||||
)
|
|
||||||
return self.start_time <= normalized < self.end_time
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TradeHistoryRecord:
|
|
||||||
"""Canonical Trade и неизменяемые технические данные его хранения."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
trade: Trade
|
|
||||||
first_observed_at: datetime
|
|
||||||
last_observed_at: datetime
|
|
||||||
observation_sources: tuple[str, ...]
|
|
||||||
replay_sequence: int
|
|
||||||
canonical_schema_version: int = 1
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
normalized_venue = _normalize_non_empty_text(
|
|
||||||
self.venue,
|
|
||||||
field_name="venue",
|
|
||||||
)
|
|
||||||
|
|
||||||
if type(self.trade) is not Trade:
|
|
||||||
raise TypeError("trade must be a Canonical Trade")
|
|
||||||
|
|
||||||
_validate_canonical_symbol_and_source(
|
|
||||||
symbol=self.trade.symbol,
|
|
||||||
source=self.trade.source,
|
|
||||||
)
|
|
||||||
_normalize_utc_datetime(
|
|
||||||
self.trade.executed_at,
|
|
||||||
field_name="trade.executed_at",
|
|
||||||
)
|
|
||||||
normalized_first = _normalize_utc_datetime(
|
|
||||||
self.first_observed_at,
|
|
||||||
field_name="first_observed_at",
|
|
||||||
)
|
|
||||||
normalized_last = _normalize_utc_datetime(
|
|
||||||
self.last_observed_at,
|
|
||||||
field_name="last_observed_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_last < normalized_first:
|
|
||||||
raise ValueError(
|
|
||||||
"last_observed_at must not precede first_observed_at"
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized_sources = _normalize_observation_sources(
|
|
||||||
self.observation_sources,
|
|
||||||
primary_source=self.trade.source,
|
|
||||||
)
|
|
||||||
normalized_sequence = _normalize_positive_integer(
|
|
||||||
self.replay_sequence,
|
|
||||||
field_name="replay_sequence",
|
|
||||||
)
|
|
||||||
normalized_version = _normalize_positive_integer(
|
|
||||||
self.canonical_schema_version,
|
|
||||||
field_name="canonical_schema_version",
|
|
||||||
)
|
|
||||||
|
|
||||||
object.__setattr__(self, "venue", normalized_venue)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"first_observed_at",
|
|
||||||
normalized_first,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"last_observed_at",
|
|
||||||
normalized_last,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"observation_sources",
|
|
||||||
normalized_sources,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"replay_sequence",
|
|
||||||
normalized_sequence,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"canonical_schema_version",
|
|
||||||
normalized_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def symbol(self) -> str:
|
|
||||||
return self.trade.symbol
|
|
||||||
|
|
||||||
@property
|
|
||||||
def event_time(self) -> datetime:
|
|
||||||
return self.trade.executed_at.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def order_key(self) -> tuple[datetime, int]:
|
|
||||||
return (self.event_time, self.replay_sequence)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class QuoteHistoryRecord:
|
|
||||||
"""Canonical Quote и неизменяемые технические данные его хранения."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
quote: Quote
|
|
||||||
observation_sources: tuple[str, ...]
|
|
||||||
replay_sequence: int
|
|
||||||
canonical_schema_version: int = 1
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
normalized_venue = _normalize_non_empty_text(
|
|
||||||
self.venue,
|
|
||||||
field_name="venue",
|
|
||||||
)
|
|
||||||
|
|
||||||
if type(self.quote) is not Quote:
|
|
||||||
raise TypeError("quote must be a Canonical Quote")
|
|
||||||
|
|
||||||
_validate_canonical_symbol_and_source(
|
|
||||||
symbol=self.quote.symbol,
|
|
||||||
source=self.quote.source,
|
|
||||||
)
|
|
||||||
_normalize_utc_datetime(
|
|
||||||
self.quote.received_at,
|
|
||||||
field_name="quote.received_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.quote.exchange_timestamp is not None:
|
|
||||||
_normalize_utc_datetime(
|
|
||||||
self.quote.exchange_timestamp,
|
|
||||||
field_name="quote.exchange_timestamp",
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized_sources = _normalize_observation_sources(
|
|
||||||
self.observation_sources,
|
|
||||||
primary_source=self.quote.source,
|
|
||||||
)
|
|
||||||
normalized_sequence = _normalize_positive_integer(
|
|
||||||
self.replay_sequence,
|
|
||||||
field_name="replay_sequence",
|
|
||||||
)
|
|
||||||
normalized_version = _normalize_positive_integer(
|
|
||||||
self.canonical_schema_version,
|
|
||||||
field_name="canonical_schema_version",
|
|
||||||
)
|
|
||||||
|
|
||||||
object.__setattr__(self, "venue", normalized_venue)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"observation_sources",
|
|
||||||
normalized_sources,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"replay_sequence",
|
|
||||||
normalized_sequence,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"canonical_schema_version",
|
|
||||||
normalized_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def symbol(self) -> str:
|
|
||||||
return self.quote.symbol
|
|
||||||
|
|
||||||
@property
|
|
||||||
def event_time(self) -> datetime:
|
|
||||||
return self.quote.received_at.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def order_key(self) -> tuple[datetime, int]:
|
|
||||||
return (self.event_time, self.replay_sequence)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CandleRevisionHistoryRecord:
|
|
||||||
"""Canonical Candle и метаданные одной наблюдавшейся ревизии."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
candle: Candle
|
|
||||||
observed_at: datetime
|
|
||||||
is_final: bool
|
|
||||||
observation_sources: tuple[str, ...]
|
|
||||||
replay_sequence: int
|
|
||||||
canonical_schema_version: int = 1
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
normalized_venue = _normalize_non_empty_text(
|
|
||||||
self.venue,
|
|
||||||
field_name="venue",
|
|
||||||
)
|
|
||||||
|
|
||||||
if type(self.candle) is not Candle:
|
|
||||||
raise TypeError("candle must be a Canonical Candle")
|
|
||||||
|
|
||||||
_validate_canonical_symbol_and_source(
|
|
||||||
symbol=self.candle.symbol,
|
|
||||||
source=self.candle.source,
|
|
||||||
)
|
|
||||||
normalized_interval = _normalize_non_empty_text(
|
|
||||||
self.candle.interval,
|
|
||||||
field_name="candle.interval",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_interval != self.candle.interval:
|
|
||||||
raise ValueError("candle.interval must be canonical")
|
|
||||||
normalized_open_time = _normalize_utc_datetime(
|
|
||||||
self.candle.open_time,
|
|
||||||
field_name="candle.open_time",
|
|
||||||
)
|
|
||||||
normalized_observed_at = _normalize_utc_datetime(
|
|
||||||
self.observed_at,
|
|
||||||
field_name="observed_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_observed_at < normalized_open_time:
|
|
||||||
raise ValueError("observed_at must not precede candle.open_time")
|
|
||||||
|
|
||||||
if not isinstance(self.is_final, bool):
|
|
||||||
raise TypeError("is_final must be a boolean")
|
|
||||||
|
|
||||||
normalized_sources = _normalize_observation_sources(
|
|
||||||
self.observation_sources,
|
|
||||||
primary_source=self.candle.source,
|
|
||||||
)
|
|
||||||
normalized_sequence = _normalize_positive_integer(
|
|
||||||
self.replay_sequence,
|
|
||||||
field_name="replay_sequence",
|
|
||||||
)
|
|
||||||
normalized_version = _normalize_positive_integer(
|
|
||||||
self.canonical_schema_version,
|
|
||||||
field_name="canonical_schema_version",
|
|
||||||
)
|
|
||||||
|
|
||||||
object.__setattr__(self, "venue", normalized_venue)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"observed_at",
|
|
||||||
normalized_observed_at,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"observation_sources",
|
|
||||||
normalized_sources,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"replay_sequence",
|
|
||||||
normalized_sequence,
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
|
||||||
"canonical_schema_version",
|
|
||||||
normalized_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def symbol(self) -> str:
|
|
||||||
return self.candle.symbol
|
|
||||||
|
|
||||||
@property
|
|
||||||
def interval(self) -> str:
|
|
||||||
return self.candle.interval
|
|
||||||
|
|
||||||
@property
|
|
||||||
def event_time(self) -> datetime:
|
|
||||||
"""Вернуть ось Historical Query — время открытия свечи."""
|
|
||||||
return self.candle.open_time.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def replay_at(self) -> datetime:
|
|
||||||
"""Вернуть ось Replay — время наблюдения конкретной ревизии."""
|
|
||||||
return self.observed_at
|
|
||||||
|
|
||||||
@property
|
|
||||||
def order_key(self) -> tuple[datetime, int]:
|
|
||||||
return (self.event_time, self.replay_sequence)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TradeHistoryCursor:
|
|
||||||
"""Структурированный keyset-курсор Trade History."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
symbol: str
|
|
||||||
time_range: HistoricalTimeRange
|
|
||||||
executed_at: datetime
|
|
||||||
replay_sequence: int
|
|
||||||
version: int = HISTORY_CURSOR_VERSION
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
normalized_venue, normalized_symbol = _normalize_cursor_scope(
|
|
||||||
venue=self.venue,
|
|
||||||
symbol=self.symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
normalized_time = _normalize_cursor_position(
|
|
||||||
cursor_time=self.executed_at,
|
|
||||||
replay_sequence=self.replay_sequence,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
_validate_cursor_version(self.version)
|
|
||||||
|
|
||||||
object.__setattr__(self, "venue", normalized_venue)
|
|
||||||
object.__setattr__(self, "symbol", normalized_symbol)
|
|
||||||
object.__setattr__(self, "executed_at", normalized_time)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class QuoteHistoryCursor:
|
|
||||||
"""Структурированный keyset-курсор Quote History."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
symbol: str
|
|
||||||
time_range: HistoricalTimeRange
|
|
||||||
received_at: datetime
|
|
||||||
replay_sequence: int
|
|
||||||
version: int = HISTORY_CURSOR_VERSION
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
normalized_venue, normalized_symbol = _normalize_cursor_scope(
|
|
||||||
venue=self.venue,
|
|
||||||
symbol=self.symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
normalized_time = _normalize_cursor_position(
|
|
||||||
cursor_time=self.received_at,
|
|
||||||
replay_sequence=self.replay_sequence,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
_validate_cursor_version(self.version)
|
|
||||||
|
|
||||||
object.__setattr__(self, "venue", normalized_venue)
|
|
||||||
object.__setattr__(self, "symbol", normalized_symbol)
|
|
||||||
object.__setattr__(self, "received_at", normalized_time)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CandleRevisionHistoryCursor:
|
|
||||||
"""Структурированный keyset-курсор Candle Revision History."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
symbol: str
|
|
||||||
interval: str
|
|
||||||
time_range: HistoricalTimeRange
|
|
||||||
open_time: datetime
|
|
||||||
replay_sequence: int
|
|
||||||
version: int = HISTORY_CURSOR_VERSION
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
normalized_venue, normalized_symbol = _normalize_cursor_scope(
|
|
||||||
venue=self.venue,
|
|
||||||
symbol=self.symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
normalized_interval = _normalize_non_empty_text(
|
|
||||||
self.interval,
|
|
||||||
field_name="interval",
|
|
||||||
)
|
|
||||||
normalized_time = _normalize_cursor_position(
|
|
||||||
cursor_time=self.open_time,
|
|
||||||
replay_sequence=self.replay_sequence,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
_validate_cursor_version(self.version)
|
|
||||||
|
|
||||||
object.__setattr__(self, "venue", normalized_venue)
|
|
||||||
object.__setattr__(self, "symbol", normalized_symbol)
|
|
||||||
object.__setattr__(self, "interval", normalized_interval)
|
|
||||||
object.__setattr__(self, "open_time", normalized_time)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_cursor_scope(
|
|
||||||
*,
|
|
||||||
venue: object,
|
|
||||||
symbol: object,
|
|
||||||
time_range: object,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
if type(time_range) is not HistoricalTimeRange:
|
|
||||||
raise TypeError("time_range must be HistoricalTimeRange")
|
|
||||||
|
|
||||||
return (
|
|
||||||
_normalize_non_empty_text(venue, field_name="venue"),
|
|
||||||
_normalize_symbol(symbol, field_name="symbol"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_cursor_position(
|
|
||||||
*,
|
|
||||||
cursor_time: object,
|
|
||||||
replay_sequence: object,
|
|
||||||
time_range: HistoricalTimeRange,
|
|
||||||
) -> datetime:
|
|
||||||
normalized_time = _normalize_utc_datetime(
|
|
||||||
cursor_time,
|
|
||||||
field_name="cursor time",
|
|
||||||
)
|
|
||||||
_normalize_positive_integer(
|
|
||||||
replay_sequence,
|
|
||||||
field_name="replay_sequence",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not time_range.contains(normalized_time):
|
|
||||||
raise ValueError("cursor time must belong to query range")
|
|
||||||
|
|
||||||
return normalized_time
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_cursor_version(version: object) -> None:
|
|
||||||
if isinstance(version, bool) or not isinstance(version, int):
|
|
||||||
raise TypeError("version must be an integer")
|
|
||||||
|
|
||||||
if version != HISTORY_CURSOR_VERSION:
|
|
||||||
raise ValueError("unsupported history cursor version")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TradeHistoryQuery:
|
|
||||||
"""Один forward-only запрос истории Trades."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
symbol: str
|
|
||||||
time_range: HistoricalTimeRange
|
|
||||||
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
|
|
||||||
cursor: TradeHistoryCursor | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
venue, symbol = _normalize_query(
|
|
||||||
venue=self.venue,
|
|
||||||
symbol=self.symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
limit=self.limit,
|
|
||||||
)
|
|
||||||
_validate_query_cursor(
|
|
||||||
cursor=self.cursor,
|
|
||||||
cursor_type=TradeHistoryCursor,
|
|
||||||
venue=venue,
|
|
||||||
symbol=symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
object.__setattr__(self, "venue", venue)
|
|
||||||
object.__setattr__(self, "symbol", symbol)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class QuoteHistoryQuery:
|
|
||||||
"""Один forward-only запрос истории Quotes."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
symbol: str
|
|
||||||
time_range: HistoricalTimeRange
|
|
||||||
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
|
|
||||||
cursor: QuoteHistoryCursor | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
venue, symbol = _normalize_query(
|
|
||||||
venue=self.venue,
|
|
||||||
symbol=self.symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
limit=self.limit,
|
|
||||||
)
|
|
||||||
_validate_query_cursor(
|
|
||||||
cursor=self.cursor,
|
|
||||||
cursor_type=QuoteHistoryCursor,
|
|
||||||
venue=venue,
|
|
||||||
symbol=symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
)
|
|
||||||
object.__setattr__(self, "venue", venue)
|
|
||||||
object.__setattr__(self, "symbol", symbol)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CandleRevisionHistoryQuery:
|
|
||||||
"""Один forward-only запрос истории ревизий Candles."""
|
|
||||||
|
|
||||||
venue: str
|
|
||||||
symbol: str
|
|
||||||
interval: str
|
|
||||||
time_range: HistoricalTimeRange
|
|
||||||
limit: int = HISTORY_PAGE_LIMIT_DEFAULT
|
|
||||||
cursor: CandleRevisionHistoryCursor | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
venue, symbol = _normalize_query(
|
|
||||||
venue=self.venue,
|
|
||||||
symbol=self.symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
limit=self.limit,
|
|
||||||
)
|
|
||||||
interval = _normalize_non_empty_text(
|
|
||||||
self.interval,
|
|
||||||
field_name="interval",
|
|
||||||
)
|
|
||||||
_validate_query_cursor(
|
|
||||||
cursor=self.cursor,
|
|
||||||
cursor_type=CandleRevisionHistoryCursor,
|
|
||||||
venue=venue,
|
|
||||||
symbol=symbol,
|
|
||||||
time_range=self.time_range,
|
|
||||||
interval=interval,
|
|
||||||
)
|
|
||||||
object.__setattr__(self, "venue", venue)
|
|
||||||
object.__setattr__(self, "symbol", symbol)
|
|
||||||
object.__setattr__(self, "interval", interval)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_query(
|
|
||||||
*,
|
|
||||||
venue: object,
|
|
||||||
symbol: object,
|
|
||||||
time_range: object,
|
|
||||||
limit: object,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
if type(time_range) is not HistoricalTimeRange:
|
|
||||||
raise TypeError("time_range must be HistoricalTimeRange")
|
|
||||||
|
|
||||||
_normalize_positive_integer(
|
|
||||||
limit,
|
|
||||||
field_name="limit",
|
|
||||||
maximum=HISTORY_PAGE_LIMIT_MAX,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
_normalize_non_empty_text(venue, field_name="venue"),
|
|
||||||
_normalize_symbol(symbol, field_name="symbol"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_query_cursor(
|
|
||||||
*,
|
|
||||||
cursor: object,
|
|
||||||
cursor_type: type[object],
|
|
||||||
venue: str,
|
|
||||||
symbol: str,
|
|
||||||
time_range: HistoricalTimeRange,
|
|
||||||
interval: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
if cursor is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if type(cursor) is not cursor_type:
|
|
||||||
raise TypeError(f"cursor must be {cursor_type.__name__} or None")
|
|
||||||
|
|
||||||
if (
|
|
||||||
getattr(cursor, "venue") != venue
|
|
||||||
or getattr(cursor, "symbol") != symbol
|
|
||||||
or getattr(cursor, "time_range") != time_range
|
|
||||||
):
|
|
||||||
raise ValueError("cursor does not belong to query scope")
|
|
||||||
|
|
||||||
if interval is not None and getattr(cursor, "interval") != interval:
|
|
||||||
raise ValueError("cursor does not belong to query interval")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TradeHistoryPage:
|
|
||||||
"""Одна типизированная страница Trade History."""
|
|
||||||
|
|
||||||
query: TradeHistoryQuery
|
|
||||||
items: tuple[TradeHistoryRecord, ...]
|
|
||||||
next_cursor: TradeHistoryCursor | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_validate_page(
|
|
||||||
query=self.query,
|
|
||||||
query_type=TradeHistoryQuery,
|
|
||||||
items=self.items,
|
|
||||||
item_type=TradeHistoryRecord,
|
|
||||||
next_cursor=self.next_cursor,
|
|
||||||
cursor_type=TradeHistoryCursor,
|
|
||||||
cursor_time_name="executed_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_more(self) -> bool:
|
|
||||||
return self.next_cursor is not None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class QuoteHistoryPage:
|
|
||||||
"""Одна типизированная страница Quote History."""
|
|
||||||
|
|
||||||
query: QuoteHistoryQuery
|
|
||||||
items: tuple[QuoteHistoryRecord, ...]
|
|
||||||
next_cursor: QuoteHistoryCursor | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_validate_page(
|
|
||||||
query=self.query,
|
|
||||||
query_type=QuoteHistoryQuery,
|
|
||||||
items=self.items,
|
|
||||||
item_type=QuoteHistoryRecord,
|
|
||||||
next_cursor=self.next_cursor,
|
|
||||||
cursor_type=QuoteHistoryCursor,
|
|
||||||
cursor_time_name="received_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_more(self) -> bool:
|
|
||||||
return self.next_cursor is not None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CandleRevisionHistoryPage:
|
|
||||||
"""Одна типизированная страница Candle Revision History."""
|
|
||||||
|
|
||||||
query: CandleRevisionHistoryQuery
|
|
||||||
items: tuple[CandleRevisionHistoryRecord, ...]
|
|
||||||
next_cursor: CandleRevisionHistoryCursor | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_validate_page(
|
|
||||||
query=self.query,
|
|
||||||
query_type=CandleRevisionHistoryQuery,
|
|
||||||
items=self.items,
|
|
||||||
item_type=CandleRevisionHistoryRecord,
|
|
||||||
next_cursor=self.next_cursor,
|
|
||||||
cursor_type=CandleRevisionHistoryCursor,
|
|
||||||
cursor_time_name="open_time",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_more(self) -> bool:
|
|
||||||
return self.next_cursor is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_page(
|
|
||||||
*,
|
|
||||||
query: object,
|
|
||||||
query_type: type[object],
|
|
||||||
items: object,
|
|
||||||
item_type: type[object],
|
|
||||||
next_cursor: object,
|
|
||||||
cursor_type: type[object],
|
|
||||||
cursor_time_name: str,
|
|
||||||
) -> None:
|
|
||||||
if type(query) is not query_type:
|
|
||||||
raise TypeError(f"query must be {query_type.__name__}")
|
|
||||||
|
|
||||||
if type(items) is not tuple:
|
|
||||||
raise TypeError("items must be a tuple")
|
|
||||||
|
|
||||||
if len(items) > getattr(query, "limit"):
|
|
||||||
raise ValueError("items must not exceed query limit")
|
|
||||||
|
|
||||||
if any(type(item) is not item_type for item in items):
|
|
||||||
raise TypeError(f"items must contain only {item_type.__name__}")
|
|
||||||
|
|
||||||
if next_cursor is not None and type(next_cursor) is not cursor_type:
|
|
||||||
raise TypeError(
|
|
||||||
f"next_cursor must be {cursor_type.__name__} or None"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
if next_cursor is not None:
|
|
||||||
raise ValueError("empty page must not have next_cursor")
|
|
||||||
return
|
|
||||||
|
|
||||||
for previous, current in zip(items, items[1:], strict=False):
|
|
||||||
if getattr(previous, "order_key") >= getattr(current, "order_key"):
|
|
||||||
raise ValueError("page items must be strictly ordered")
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
if (
|
|
||||||
getattr(item, "venue") != getattr(query, "venue")
|
|
||||||
or getattr(item, "symbol") != getattr(query, "symbol")
|
|
||||||
):
|
|
||||||
raise ValueError("page items must belong to one query scope")
|
|
||||||
|
|
||||||
if getattr(item, "interval", None) != getattr(
|
|
||||||
query,
|
|
||||||
"interval",
|
|
||||||
None,
|
|
||||||
):
|
|
||||||
raise ValueError("page items must belong to one interval")
|
|
||||||
|
|
||||||
if not getattr(query, "time_range").contains(
|
|
||||||
getattr(item, "event_time")
|
|
||||||
):
|
|
||||||
raise ValueError("page item time must belong to query range")
|
|
||||||
|
|
||||||
query_cursor = getattr(query, "cursor")
|
|
||||||
|
|
||||||
if query_cursor is not None:
|
|
||||||
incoming_key = (
|
|
||||||
getattr(query_cursor, cursor_time_name),
|
|
||||||
getattr(query_cursor, "replay_sequence"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if getattr(items[0], "order_key") <= incoming_key:
|
|
||||||
raise ValueError("page items must follow query cursor")
|
|
||||||
|
|
||||||
if next_cursor is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
last = items[-1]
|
|
||||||
|
|
||||||
if (
|
|
||||||
getattr(next_cursor, "venue") != getattr(query, "venue")
|
|
||||||
or getattr(next_cursor, "symbol") != getattr(query, "symbol")
|
|
||||||
or getattr(next_cursor, "time_range")
|
|
||||||
!= getattr(query, "time_range")
|
|
||||||
or getattr(next_cursor, "replay_sequence")
|
|
||||||
!= getattr(last, "replay_sequence")
|
|
||||||
or getattr(next_cursor, cursor_time_name)
|
|
||||||
!= getattr(last, "event_time")
|
|
||||||
):
|
|
||||||
raise ValueError("next_cursor must point to the last page item")
|
|
||||||
|
|
||||||
cursor_interval = getattr(next_cursor, "interval", None)
|
|
||||||
|
|
||||||
if cursor_interval != getattr(query, "interval", None):
|
|
||||||
raise ValueError("next_cursor must belong to page interval")
|
|
||||||
|
|
||||||
|
|
||||||
HistoryRecord = (
|
|
||||||
TradeHistoryRecord
|
|
||||||
| QuoteHistoryRecord
|
|
||||||
| CandleRevisionHistoryRecord
|
|
||||||
)
|
|
||||||
HistoryQuery = (
|
|
||||||
TradeHistoryQuery
|
|
||||||
| QuoteHistoryQuery
|
|
||||||
| CandleRevisionHistoryQuery
|
|
||||||
)
|
|
||||||
HistoryPage = (
|
|
||||||
TradeHistoryPage
|
|
||||||
| QuoteHistoryPage
|
|
||||||
| CandleRevisionHistoryPage
|
|
||||||
)
|
|
||||||
HistoryOrderKey = tuple[datetime, int]
|
|
||||||
HistoryOrderKeyFactory = Callable[[Any], HistoryOrderKey]
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from src.market_data.access.exceptions import (
|
|
||||||
MarketDataAccessError,
|
|
||||||
MarketDataAccessIntegrityError,
|
|
||||||
MarketDataAccessOperationError,
|
|
||||||
MarketDataAccessValidationError,
|
|
||||||
)
|
|
||||||
from src.market_data.access.models import (
|
|
||||||
CandleRevisionHistoryCursor,
|
|
||||||
CandleRevisionHistoryPage,
|
|
||||||
CandleRevisionHistoryQuery,
|
|
||||||
CandleRevisionHistoryRecord,
|
|
||||||
)
|
|
||||||
from src.market_data.access.postgres_history_support import (
|
|
||||||
PostgresHistoryConnectionProvider,
|
|
||||||
candle_history_record_from_row,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_CANDLE_REVISION_HISTORY_COLUMNS_SQL = """
|
|
||||||
venue,
|
|
||||||
symbol,
|
|
||||||
interval,
|
|
||||||
open_time,
|
|
||||||
observed_at,
|
|
||||||
open_price,
|
|
||||||
high_price,
|
|
||||||
low_price,
|
|
||||||
close_price,
|
|
||||||
volume,
|
|
||||||
is_final,
|
|
||||||
source,
|
|
||||||
observation_sources,
|
|
||||||
replay_sequence,
|
|
||||||
canonical_schema_version
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT_CANDLE_REVISION_HISTORY_SQL = f"""
|
|
||||||
SELECT
|
|
||||||
{_CANDLE_REVISION_HISTORY_COLUMNS_SQL}
|
|
||||||
FROM market_data.candle_revisions
|
|
||||||
WHERE venue = %s
|
|
||||||
AND symbol = %s
|
|
||||||
AND interval = %s
|
|
||||||
AND open_time >= %s
|
|
||||||
AND open_time < %s
|
|
||||||
ORDER BY open_time ASC,
|
|
||||||
replay_sequence ASC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT_CANDLE_REVISION_HISTORY_AFTER_CURSOR_SQL = f"""
|
|
||||||
SELECT
|
|
||||||
{_CANDLE_REVISION_HISTORY_COLUMNS_SQL}
|
|
||||||
FROM market_data.candle_revisions
|
|
||||||
WHERE venue = %s
|
|
||||||
AND symbol = %s
|
|
||||||
AND interval = %s
|
|
||||||
AND open_time >= %s
|
|
||||||
AND open_time < %s
|
|
||||||
AND (open_time, replay_sequence) > (%s, %s)
|
|
||||||
ORDER BY open_time ASC,
|
|
||||||
replay_sequence ASC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class PostgresCandleRevisionHistoryRepository:
|
|
||||||
"""PostgreSQL-адаптер одной страницы истории ревизий свечей."""
|
|
||||||
|
|
||||||
__slots__ = ("_connection_provider",)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
connection_provider: PostgresHistoryConnectionProvider,
|
|
||||||
) -> None:
|
|
||||||
if not callable(connection_provider):
|
|
||||||
raise TypeError("connection_provider must be callable")
|
|
||||||
|
|
||||||
self._connection_provider = connection_provider
|
|
||||||
|
|
||||||
def query_candle_revisions(
|
|
||||||
self,
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
) -> CandleRevisionHistoryPage:
|
|
||||||
"""Прочитать страницу в устойчивом порядке времени открытия."""
|
|
||||||
if type(query) is not CandleRevisionHistoryQuery:
|
|
||||||
raise MarketDataAccessValidationError(
|
|
||||||
"query must be CandleRevisionHistoryQuery"
|
|
||||||
)
|
|
||||||
|
|
||||||
sql, parameters = self._build_query(query)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._connection_provider() as connection:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute(sql, parameters)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
return self._page_from_rows(query=query, rows=rows)
|
|
||||||
except MarketDataAccessError:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise MarketDataAccessOperationError(
|
|
||||||
"Failed to query PostgreSQL Candle Revision History."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_query(
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
) -> tuple[str, tuple[object, ...]]:
|
|
||||||
fetch_limit = query.limit + 1
|
|
||||||
cursor = query.cursor
|
|
||||||
|
|
||||||
if cursor is None:
|
|
||||||
return (
|
|
||||||
_SELECT_CANDLE_REVISION_HISTORY_SQL,
|
|
||||||
(
|
|
||||||
query.venue,
|
|
||||||
query.symbol,
|
|
||||||
query.interval,
|
|
||||||
query.time_range.start_time,
|
|
||||||
query.time_range.end_time,
|
|
||||||
fetch_limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
_SELECT_CANDLE_REVISION_HISTORY_AFTER_CURSOR_SQL,
|
|
||||||
(
|
|
||||||
query.venue,
|
|
||||||
query.symbol,
|
|
||||||
query.interval,
|
|
||||||
query.time_range.start_time,
|
|
||||||
query.time_range.end_time,
|
|
||||||
cursor.open_time,
|
|
||||||
cursor.replay_sequence,
|
|
||||||
fetch_limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _page_from_rows(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
rows: object,
|
|
||||||
) -> CandleRevisionHistoryPage:
|
|
||||||
if not isinstance(rows, Sequence) or isinstance(
|
|
||||||
rows,
|
|
||||||
(str, bytes, bytearray),
|
|
||||||
):
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Candle Revision History rows."
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(rows) > query.limit + 1:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL exceeded the requested Candle History limit."
|
|
||||||
)
|
|
||||||
|
|
||||||
records = tuple(
|
|
||||||
cls._record_from_row(row=row, query=query)
|
|
||||||
for row in rows
|
|
||||||
)
|
|
||||||
cls._validate_records(records=records, query=query)
|
|
||||||
|
|
||||||
has_more = len(records) > query.limit
|
|
||||||
items = records[: query.limit]
|
|
||||||
next_cursor = (
|
|
||||||
cls._cursor_from_record(query=query, record=items[-1])
|
|
||||||
if has_more
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return CandleRevisionHistoryPage(
|
|
||||||
query=query,
|
|
||||||
items=items,
|
|
||||||
next_cursor=next_cursor,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned inconsistent Candle History page."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _record_from_row(
|
|
||||||
*,
|
|
||||||
row: object,
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
) -> CandleRevisionHistoryRecord:
|
|
||||||
record = candle_history_record_from_row(row)
|
|
||||||
|
|
||||||
if (
|
|
||||||
record.venue != query.venue
|
|
||||||
or record.symbol != query.symbol
|
|
||||||
or record.interval != query.interval
|
|
||||||
):
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned Candle revision outside query scope."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not query.time_range.contains(record.event_time):
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned Candle revision outside query range."
|
|
||||||
)
|
|
||||||
|
|
||||||
return record
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_records(
|
|
||||||
*,
|
|
||||||
records: tuple[CandleRevisionHistoryRecord, ...],
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
) -> None:
|
|
||||||
seen_sequences: set[int] = set()
|
|
||||||
previous_key: tuple[datetime, int] | None = None
|
|
||||||
incoming_key = (
|
|
||||||
(
|
|
||||||
query.cursor.open_time,
|
|
||||||
query.cursor.replay_sequence,
|
|
||||||
)
|
|
||||||
if query.cursor is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
for record in records:
|
|
||||||
if record.replay_sequence in seen_sequences:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Candle History contains duplicate replay_sequence."
|
|
||||||
)
|
|
||||||
|
|
||||||
if incoming_key is not None and record.order_key <= incoming_key:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Candle History row does not follow query cursor."
|
|
||||||
)
|
|
||||||
|
|
||||||
if previous_key is not None and record.order_key <= previous_key:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Candle History rows are not strictly ordered."
|
|
||||||
)
|
|
||||||
|
|
||||||
seen_sequences.add(record.replay_sequence)
|
|
||||||
previous_key = record.order_key
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _cursor_from_record(
|
|
||||||
*,
|
|
||||||
query: CandleRevisionHistoryQuery,
|
|
||||||
record: CandleRevisionHistoryRecord,
|
|
||||||
) -> CandleRevisionHistoryCursor:
|
|
||||||
try:
|
|
||||||
return CandleRevisionHistoryCursor(
|
|
||||||
venue=query.venue,
|
|
||||||
symbol=query.symbol,
|
|
||||||
interval=query.interval,
|
|
||||||
time_range=query.time_range,
|
|
||||||
open_time=record.event_time,
|
|
||||||
replay_sequence=record.replay_sequence,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Cannot create Candle History cursor from stored row."
|
|
||||||
) from error
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from contextlib import AbstractContextManager
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from src.market_data.access.exceptions import MarketDataAccessIntegrityError
|
|
||||||
from src.market_data.access.models import (
|
|
||||||
CandleRevisionHistoryRecord,
|
|
||||||
QuoteHistoryRecord,
|
|
||||||
TradeHistoryRecord,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.candle import Candle
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.market_data.acquisition.models.trade import (
|
|
||||||
Trade,
|
|
||||||
TradeAggressorSide,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.symbols import normalize_symbol
|
|
||||||
from src.market_data.acquisition.trade_id_sequence import (
|
|
||||||
validate_signed_trade_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CANONICAL_TRADE_SCHEMA_VERSION = 1
|
|
||||||
CANONICAL_QUOTE_SCHEMA_VERSION = 1
|
|
||||||
CANONICAL_CANDLE_SCHEMA_VERSION = 1
|
|
||||||
|
|
||||||
PostgresHistoryConnectionProvider = Callable[
|
|
||||||
[],
|
|
||||||
AbstractContextManager[Any],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_text(value: object, *, field_name: str) -> str:
|
|
||||||
"""Проверить каноническую непустую строку без скрытой нормализации."""
|
|
||||||
if type(value) is not str:
|
|
||||||
raise TypeError(f"{field_name} must be a string")
|
|
||||||
|
|
||||||
normalized = value.strip()
|
|
||||||
|
|
||||||
if not normalized or normalized != value:
|
|
||||||
raise ValueError(f"{field_name} must be canonical")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_symbol(value: object, *, field_name: str) -> str:
|
|
||||||
"""Проверить уже канонизированный символ сохранённой строки."""
|
|
||||||
raw_symbol = canonical_text(value, field_name=field_name)
|
|
||||||
normalized = normalize_symbol(raw_symbol)
|
|
||||||
|
|
||||||
if normalized != raw_symbol:
|
|
||||||
raise ValueError(f"{field_name} must be canonical")
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def aware_utc_datetime(value: object, *, field_name: str) -> datetime:
|
|
||||||
"""Проверить datetime с часовым поясом и привести его к UTC."""
|
|
||||||
if (
|
|
||||||
type(value) is not datetime
|
|
||||||
or value.tzinfo is None
|
|
||||||
or value.utcoffset() is None
|
|
||||||
):
|
|
||||||
raise TypeError(f"{field_name} must be timezone-aware datetime")
|
|
||||||
|
|
||||||
return value.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def positive_integer(value: object, *, field_name: str) -> int:
|
|
||||||
"""Проверить точный положительный целочисленный тип."""
|
|
||||||
if type(value) is not int:
|
|
||||||
raise TypeError(f"{field_name} must be an integer")
|
|
||||||
|
|
||||||
if value <= 0:
|
|
||||||
raise ValueError(f"{field_name} must be positive")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def signed_trade_id(value: object) -> int:
|
|
||||||
"""Проверить знаковый 32-битный идентификатор без изменения."""
|
|
||||||
if type(value) is not int:
|
|
||||||
raise TypeError("stored trade.trade_id must be an integer")
|
|
||||||
|
|
||||||
validate_signed_trade_id(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def decimal_value(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
allow_zero: bool = False,
|
|
||||||
) -> Decimal:
|
|
||||||
"""Проверить точный конечный Decimal и допустимый знак."""
|
|
||||||
if type(value) is not Decimal or not value.is_finite():
|
|
||||||
raise TypeError(f"{field_name} must be a finite Decimal")
|
|
||||||
|
|
||||||
if allow_zero:
|
|
||||||
if value < 0:
|
|
||||||
raise ValueError(f"{field_name} must not be negative")
|
|
||||||
elif value <= 0:
|
|
||||||
raise ValueError(f"{field_name} must be positive")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def observation_sources(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
primary_source: str,
|
|
||||||
field_name: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
"""Проверить порядок и уникальность источников наблюдения."""
|
|
||||||
if type(value) is not list:
|
|
||||||
raise TypeError(f"{field_name} must be a list")
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
raise ValueError(f"{field_name} must not be empty")
|
|
||||||
|
|
||||||
normalized = tuple(
|
|
||||||
canonical_text(
|
|
||||||
source,
|
|
||||||
field_name=f"{field_name} item",
|
|
||||||
)
|
|
||||||
for source in value
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(set(normalized)) != len(normalized):
|
|
||||||
raise ValueError(f"{field_name} must be unique")
|
|
||||||
|
|
||||||
if normalized[0] != primary_source:
|
|
||||||
raise ValueError(
|
|
||||||
"first observation source must match stored payload.source"
|
|
||||||
)
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def trade_history_record_from_row(row: object) -> TradeHistoryRecord:
|
|
||||||
"""Материализовать строго проверенную строку канонической сделки."""
|
|
||||||
if type(row) is not tuple or len(row) != 13:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Trade History row."
|
|
||||||
)
|
|
||||||
|
|
||||||
(
|
|
||||||
venue,
|
|
||||||
symbol,
|
|
||||||
trade_id,
|
|
||||||
executed_at,
|
|
||||||
price,
|
|
||||||
quantity,
|
|
||||||
aggressor_side,
|
|
||||||
source,
|
|
||||||
first_observed_at,
|
|
||||||
last_observed_at,
|
|
||||||
stored_sources,
|
|
||||||
replay_sequence,
|
|
||||||
canonical_schema_version,
|
|
||||||
) = row
|
|
||||||
|
|
||||||
try:
|
|
||||||
normalized_venue = canonical_text(
|
|
||||||
venue,
|
|
||||||
field_name="stored trade.venue",
|
|
||||||
)
|
|
||||||
normalized_symbol = canonical_symbol(
|
|
||||||
symbol,
|
|
||||||
field_name="stored trade.symbol",
|
|
||||||
)
|
|
||||||
normalized_trade_id = signed_trade_id(trade_id)
|
|
||||||
normalized_executed_at = aware_utc_datetime(
|
|
||||||
executed_at,
|
|
||||||
field_name="stored trade.executed_at",
|
|
||||||
)
|
|
||||||
normalized_price = decimal_value(
|
|
||||||
price,
|
|
||||||
field_name="stored trade.price",
|
|
||||||
)
|
|
||||||
normalized_quantity = decimal_value(
|
|
||||||
quantity,
|
|
||||||
field_name="stored trade.quantity",
|
|
||||||
)
|
|
||||||
|
|
||||||
if type(aggressor_side) is not str:
|
|
||||||
raise TypeError("stored trade.aggressor_side must be a string")
|
|
||||||
|
|
||||||
normalized_side = TradeAggressorSide(aggressor_side)
|
|
||||||
normalized_source = canonical_text(
|
|
||||||
source,
|
|
||||||
field_name="stored trade.source",
|
|
||||||
)
|
|
||||||
normalized_first_observed_at = aware_utc_datetime(
|
|
||||||
first_observed_at,
|
|
||||||
field_name="stored trade.first_observed_at",
|
|
||||||
)
|
|
||||||
normalized_last_observed_at = aware_utc_datetime(
|
|
||||||
last_observed_at,
|
|
||||||
field_name="stored trade.last_observed_at",
|
|
||||||
)
|
|
||||||
normalized_sources = observation_sources(
|
|
||||||
stored_sources,
|
|
||||||
primary_source=normalized_source,
|
|
||||||
field_name="stored trade.observation_sources",
|
|
||||||
)
|
|
||||||
normalized_sequence = positive_integer(
|
|
||||||
replay_sequence,
|
|
||||||
field_name="stored trade.replay_sequence",
|
|
||||||
)
|
|
||||||
normalized_version = positive_integer(
|
|
||||||
canonical_schema_version,
|
|
||||||
field_name="stored trade.canonical_schema_version",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_version != CANONICAL_TRADE_SCHEMA_VERSION:
|
|
||||||
raise ValueError("unsupported Canonical Trade schema version")
|
|
||||||
|
|
||||||
trade = Trade(
|
|
||||||
symbol=normalized_symbol,
|
|
||||||
trade_id=normalized_trade_id,
|
|
||||||
price=normalized_price,
|
|
||||||
quantity=normalized_quantity,
|
|
||||||
executed_at=normalized_executed_at,
|
|
||||||
aggressor_side=normalized_side,
|
|
||||||
source=normalized_source,
|
|
||||||
)
|
|
||||||
return TradeHistoryRecord(
|
|
||||||
venue=normalized_venue,
|
|
||||||
trade=trade,
|
|
||||||
first_observed_at=normalized_first_observed_at,
|
|
||||||
last_observed_at=normalized_last_observed_at,
|
|
||||||
observation_sources=normalized_sources,
|
|
||||||
replay_sequence=normalized_sequence,
|
|
||||||
canonical_schema_version=normalized_version,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError, ArithmeticError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Canonical Trade values."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
def quote_history_record_from_row(row: object) -> QuoteHistoryRecord:
|
|
||||||
"""Материализовать строго проверенную строку канонической котировки."""
|
|
||||||
if type(row) is not tuple or len(row) != 11:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Quote History row."
|
|
||||||
)
|
|
||||||
|
|
||||||
(
|
|
||||||
venue,
|
|
||||||
symbol,
|
|
||||||
received_at,
|
|
||||||
exchange_timestamp,
|
|
||||||
last_price,
|
|
||||||
bid_price,
|
|
||||||
ask_price,
|
|
||||||
source,
|
|
||||||
stored_sources,
|
|
||||||
replay_sequence,
|
|
||||||
canonical_schema_version,
|
|
||||||
) = row
|
|
||||||
|
|
||||||
try:
|
|
||||||
normalized_venue = canonical_text(
|
|
||||||
venue,
|
|
||||||
field_name="stored quote.venue",
|
|
||||||
)
|
|
||||||
normalized_symbol = canonical_symbol(
|
|
||||||
symbol,
|
|
||||||
field_name="stored quote.symbol",
|
|
||||||
)
|
|
||||||
normalized_received_at = aware_utc_datetime(
|
|
||||||
received_at,
|
|
||||||
field_name="stored quote.received_at",
|
|
||||||
)
|
|
||||||
normalized_exchange_timestamp = (
|
|
||||||
aware_utc_datetime(
|
|
||||||
exchange_timestamp,
|
|
||||||
field_name="stored quote.exchange_timestamp",
|
|
||||||
)
|
|
||||||
if exchange_timestamp is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
normalized_last_price = decimal_value(
|
|
||||||
last_price,
|
|
||||||
field_name="stored quote.last_price",
|
|
||||||
)
|
|
||||||
normalized_bid_price = decimal_value(
|
|
||||||
bid_price,
|
|
||||||
field_name="stored quote.bid_price",
|
|
||||||
)
|
|
||||||
normalized_ask_price = decimal_value(
|
|
||||||
ask_price,
|
|
||||||
field_name="stored quote.ask_price",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_bid_price > normalized_ask_price:
|
|
||||||
raise ValueError("stored quote.bid_price exceeds ask_price")
|
|
||||||
|
|
||||||
normalized_source = canonical_text(
|
|
||||||
source,
|
|
||||||
field_name="stored quote.source",
|
|
||||||
)
|
|
||||||
normalized_sources = observation_sources(
|
|
||||||
stored_sources,
|
|
||||||
primary_source=normalized_source,
|
|
||||||
field_name="stored quote.observation_sources",
|
|
||||||
)
|
|
||||||
normalized_sequence = positive_integer(
|
|
||||||
replay_sequence,
|
|
||||||
field_name="stored quote.replay_sequence",
|
|
||||||
)
|
|
||||||
normalized_version = positive_integer(
|
|
||||||
canonical_schema_version,
|
|
||||||
field_name="stored quote.canonical_schema_version",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_version != CANONICAL_QUOTE_SCHEMA_VERSION:
|
|
||||||
raise ValueError("unsupported Canonical Quote schema version")
|
|
||||||
|
|
||||||
quote = Quote(
|
|
||||||
symbol=normalized_symbol,
|
|
||||||
last_price=normalized_last_price,
|
|
||||||
bid_price=normalized_bid_price,
|
|
||||||
ask_price=normalized_ask_price,
|
|
||||||
exchange_timestamp=normalized_exchange_timestamp,
|
|
||||||
received_at=normalized_received_at,
|
|
||||||
source=normalized_source,
|
|
||||||
)
|
|
||||||
return QuoteHistoryRecord(
|
|
||||||
venue=normalized_venue,
|
|
||||||
quote=quote,
|
|
||||||
observation_sources=normalized_sources,
|
|
||||||
replay_sequence=normalized_sequence,
|
|
||||||
canonical_schema_version=normalized_version,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError, ArithmeticError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Canonical Quote values."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
def candle_history_record_from_row(
|
|
||||||
row: object,
|
|
||||||
) -> CandleRevisionHistoryRecord:
|
|
||||||
"""Материализовать строго проверенную строку ревизии свечи."""
|
|
||||||
if type(row) is not tuple or len(row) != 15:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Candle History row."
|
|
||||||
)
|
|
||||||
|
|
||||||
(
|
|
||||||
venue,
|
|
||||||
symbol,
|
|
||||||
interval,
|
|
||||||
open_time,
|
|
||||||
observed_at,
|
|
||||||
open_price,
|
|
||||||
high_price,
|
|
||||||
low_price,
|
|
||||||
close_price,
|
|
||||||
volume,
|
|
||||||
is_final,
|
|
||||||
source,
|
|
||||||
stored_sources,
|
|
||||||
replay_sequence,
|
|
||||||
canonical_schema_version,
|
|
||||||
) = row
|
|
||||||
|
|
||||||
try:
|
|
||||||
normalized_venue = canonical_text(
|
|
||||||
venue,
|
|
||||||
field_name="stored candle.venue",
|
|
||||||
)
|
|
||||||
normalized_symbol = canonical_symbol(
|
|
||||||
symbol,
|
|
||||||
field_name="stored candle.symbol",
|
|
||||||
)
|
|
||||||
normalized_interval = canonical_text(
|
|
||||||
interval,
|
|
||||||
field_name="stored candle.interval",
|
|
||||||
)
|
|
||||||
normalized_open_time = aware_utc_datetime(
|
|
||||||
open_time,
|
|
||||||
field_name="stored candle.open_time",
|
|
||||||
)
|
|
||||||
normalized_observed_at = aware_utc_datetime(
|
|
||||||
observed_at,
|
|
||||||
field_name="stored candle.observed_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_observed_at < normalized_open_time:
|
|
||||||
raise ValueError("stored candle.observed_at precedes open_time")
|
|
||||||
|
|
||||||
normalized_open_price = decimal_value(
|
|
||||||
open_price,
|
|
||||||
field_name="stored candle.open_price",
|
|
||||||
)
|
|
||||||
normalized_high_price = decimal_value(
|
|
||||||
high_price,
|
|
||||||
field_name="stored candle.high_price",
|
|
||||||
)
|
|
||||||
normalized_low_price = decimal_value(
|
|
||||||
low_price,
|
|
||||||
field_name="stored candle.low_price",
|
|
||||||
)
|
|
||||||
normalized_close_price = decimal_value(
|
|
||||||
close_price,
|
|
||||||
field_name="stored candle.close_price",
|
|
||||||
)
|
|
||||||
normalized_volume = decimal_value(
|
|
||||||
volume,
|
|
||||||
field_name="stored candle.volume",
|
|
||||||
allow_zero=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_low_price > normalized_high_price:
|
|
||||||
raise ValueError("stored candle.low_price exceeds high_price")
|
|
||||||
|
|
||||||
if not (
|
|
||||||
normalized_low_price
|
|
||||||
<= normalized_open_price
|
|
||||||
<= normalized_high_price
|
|
||||||
):
|
|
||||||
raise ValueError("stored candle.open_price is outside range")
|
|
||||||
|
|
||||||
if not (
|
|
||||||
normalized_low_price
|
|
||||||
<= normalized_close_price
|
|
||||||
<= normalized_high_price
|
|
||||||
):
|
|
||||||
raise ValueError("stored candle.close_price is outside range")
|
|
||||||
|
|
||||||
if type(is_final) is not bool:
|
|
||||||
raise TypeError("stored candle.is_final must be a boolean")
|
|
||||||
|
|
||||||
normalized_source = canonical_text(
|
|
||||||
source,
|
|
||||||
field_name="stored candle.source",
|
|
||||||
)
|
|
||||||
normalized_sources = observation_sources(
|
|
||||||
stored_sources,
|
|
||||||
primary_source=normalized_source,
|
|
||||||
field_name="stored candle.observation_sources",
|
|
||||||
)
|
|
||||||
normalized_sequence = positive_integer(
|
|
||||||
replay_sequence,
|
|
||||||
field_name="stored candle.replay_sequence",
|
|
||||||
)
|
|
||||||
normalized_version = positive_integer(
|
|
||||||
canonical_schema_version,
|
|
||||||
field_name="stored candle.canonical_schema_version",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_version != CANONICAL_CANDLE_SCHEMA_VERSION:
|
|
||||||
raise ValueError("unsupported Canonical Candle schema version")
|
|
||||||
|
|
||||||
candle = Candle(
|
|
||||||
symbol=normalized_symbol,
|
|
||||||
interval=normalized_interval,
|
|
||||||
open_time=normalized_open_time,
|
|
||||||
open_price=normalized_open_price,
|
|
||||||
high_price=normalized_high_price,
|
|
||||||
low_price=normalized_low_price,
|
|
||||||
close_price=normalized_close_price,
|
|
||||||
volume=normalized_volume,
|
|
||||||
source=normalized_source,
|
|
||||||
)
|
|
||||||
return CandleRevisionHistoryRecord(
|
|
||||||
venue=normalized_venue,
|
|
||||||
candle=candle,
|
|
||||||
observed_at=normalized_observed_at,
|
|
||||||
is_final=is_final,
|
|
||||||
observation_sources=normalized_sources,
|
|
||||||
replay_sequence=normalized_sequence,
|
|
||||||
canonical_schema_version=normalized_version,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError, ArithmeticError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Canonical Candle values."
|
|
||||||
) from error
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from src.market_data.access.exceptions import (
|
|
||||||
MarketDataAccessError,
|
|
||||||
MarketDataAccessIntegrityError,
|
|
||||||
MarketDataAccessOperationError,
|
|
||||||
MarketDataAccessValidationError,
|
|
||||||
)
|
|
||||||
from src.market_data.access.models import (
|
|
||||||
QuoteHistoryCursor,
|
|
||||||
QuoteHistoryPage,
|
|
||||||
QuoteHistoryQuery,
|
|
||||||
QuoteHistoryRecord,
|
|
||||||
)
|
|
||||||
from src.market_data.access.postgres_history_support import (
|
|
||||||
PostgresHistoryConnectionProvider,
|
|
||||||
quote_history_record_from_row,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_QUOTE_HISTORY_COLUMNS_SQL = """
|
|
||||||
venue,
|
|
||||||
symbol,
|
|
||||||
received_at,
|
|
||||||
exchange_timestamp,
|
|
||||||
last_price,
|
|
||||||
bid_price,
|
|
||||||
ask_price,
|
|
||||||
source,
|
|
||||||
observation_sources,
|
|
||||||
replay_sequence,
|
|
||||||
canonical_schema_version
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT_QUOTE_HISTORY_SQL = f"""
|
|
||||||
SELECT
|
|
||||||
{_QUOTE_HISTORY_COLUMNS_SQL}
|
|
||||||
FROM market_data.quotes
|
|
||||||
WHERE venue = %s
|
|
||||||
AND symbol = %s
|
|
||||||
AND received_at >= %s
|
|
||||||
AND received_at < %s
|
|
||||||
ORDER BY received_at ASC,
|
|
||||||
replay_sequence ASC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT_QUOTE_HISTORY_AFTER_CURSOR_SQL = f"""
|
|
||||||
SELECT
|
|
||||||
{_QUOTE_HISTORY_COLUMNS_SQL}
|
|
||||||
FROM market_data.quotes
|
|
||||||
WHERE venue = %s
|
|
||||||
AND symbol = %s
|
|
||||||
AND received_at >= %s
|
|
||||||
AND received_at < %s
|
|
||||||
AND (received_at, replay_sequence) > (%s, %s)
|
|
||||||
ORDER BY received_at ASC,
|
|
||||||
replay_sequence ASC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class PostgresQuoteHistoryRepository:
|
|
||||||
"""PostgreSQL-адаптер одной страницы канонической истории котировок."""
|
|
||||||
|
|
||||||
__slots__ = ("_connection_provider",)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
connection_provider: PostgresHistoryConnectionProvider,
|
|
||||||
) -> None:
|
|
||||||
if not callable(connection_provider):
|
|
||||||
raise TypeError("connection_provider must be callable")
|
|
||||||
|
|
||||||
self._connection_provider = connection_provider
|
|
||||||
|
|
||||||
def query_quotes(self, query: QuoteHistoryQuery) -> QuoteHistoryPage:
|
|
||||||
"""Прочитать ограниченную страницу с устойчивой сортировкой."""
|
|
||||||
if type(query) is not QuoteHistoryQuery:
|
|
||||||
raise MarketDataAccessValidationError(
|
|
||||||
"query must be QuoteHistoryQuery"
|
|
||||||
)
|
|
||||||
|
|
||||||
sql, parameters = self._build_query(query)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._connection_provider() as connection:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute(sql, parameters)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
return self._page_from_rows(query=query, rows=rows)
|
|
||||||
except MarketDataAccessError:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise MarketDataAccessOperationError(
|
|
||||||
"Failed to query PostgreSQL Quote History."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_query(
|
|
||||||
query: QuoteHistoryQuery,
|
|
||||||
) -> tuple[str, tuple[object, ...]]:
|
|
||||||
fetch_limit = query.limit + 1
|
|
||||||
cursor = query.cursor
|
|
||||||
|
|
||||||
if cursor is None:
|
|
||||||
return (
|
|
||||||
_SELECT_QUOTE_HISTORY_SQL,
|
|
||||||
(
|
|
||||||
query.venue,
|
|
||||||
query.symbol,
|
|
||||||
query.time_range.start_time,
|
|
||||||
query.time_range.end_time,
|
|
||||||
fetch_limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
_SELECT_QUOTE_HISTORY_AFTER_CURSOR_SQL,
|
|
||||||
(
|
|
||||||
query.venue,
|
|
||||||
query.symbol,
|
|
||||||
query.time_range.start_time,
|
|
||||||
query.time_range.end_time,
|
|
||||||
cursor.received_at,
|
|
||||||
cursor.replay_sequence,
|
|
||||||
fetch_limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _page_from_rows(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
query: QuoteHistoryQuery,
|
|
||||||
rows: object,
|
|
||||||
) -> QuoteHistoryPage:
|
|
||||||
if not isinstance(rows, Sequence) or isinstance(
|
|
||||||
rows,
|
|
||||||
(str, bytes, bytearray),
|
|
||||||
):
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Quote History rows."
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(rows) > query.limit + 1:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL exceeded the requested Quote History limit."
|
|
||||||
)
|
|
||||||
|
|
||||||
records = tuple(
|
|
||||||
cls._record_from_row(row=row, query=query)
|
|
||||||
for row in rows
|
|
||||||
)
|
|
||||||
cls._validate_records(records=records, query=query)
|
|
||||||
|
|
||||||
has_more = len(records) > query.limit
|
|
||||||
items = records[: query.limit]
|
|
||||||
next_cursor = (
|
|
||||||
cls._cursor_from_record(query=query, record=items[-1])
|
|
||||||
if has_more
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return QuoteHistoryPage(
|
|
||||||
query=query,
|
|
||||||
items=items,
|
|
||||||
next_cursor=next_cursor,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned inconsistent Quote History page."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _record_from_row(
|
|
||||||
*,
|
|
||||||
row: object,
|
|
||||||
query: QuoteHistoryQuery,
|
|
||||||
) -> QuoteHistoryRecord:
|
|
||||||
record = quote_history_record_from_row(row)
|
|
||||||
|
|
||||||
if record.venue != query.venue or record.symbol != query.symbol:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned Quote outside query scope."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not query.time_range.contains(record.event_time):
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned Quote outside query range."
|
|
||||||
)
|
|
||||||
|
|
||||||
return record
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_records(
|
|
||||||
*,
|
|
||||||
records: tuple[QuoteHistoryRecord, ...],
|
|
||||||
query: QuoteHistoryQuery,
|
|
||||||
) -> None:
|
|
||||||
seen_sequences: set[int] = set()
|
|
||||||
previous_key: tuple[datetime, int] | None = None
|
|
||||||
incoming_key = (
|
|
||||||
(
|
|
||||||
query.cursor.received_at,
|
|
||||||
query.cursor.replay_sequence,
|
|
||||||
)
|
|
||||||
if query.cursor is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
for record in records:
|
|
||||||
if record.replay_sequence in seen_sequences:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Quote History contains duplicate replay_sequence."
|
|
||||||
)
|
|
||||||
|
|
||||||
if incoming_key is not None and record.order_key <= incoming_key:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Quote History row does not follow query cursor."
|
|
||||||
)
|
|
||||||
|
|
||||||
if previous_key is not None and record.order_key <= previous_key:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Quote History rows are not strictly ordered."
|
|
||||||
)
|
|
||||||
|
|
||||||
seen_sequences.add(record.replay_sequence)
|
|
||||||
previous_key = record.order_key
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _cursor_from_record(
|
|
||||||
*,
|
|
||||||
query: QuoteHistoryQuery,
|
|
||||||
record: QuoteHistoryRecord,
|
|
||||||
) -> QuoteHistoryCursor:
|
|
||||||
try:
|
|
||||||
return QuoteHistoryCursor(
|
|
||||||
venue=query.venue,
|
|
||||||
symbol=query.symbol,
|
|
||||||
time_range=query.time_range,
|
|
||||||
received_at=record.event_time,
|
|
||||||
replay_sequence=record.replay_sequence,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Cannot create Quote History cursor from stored row."
|
|
||||||
) from error
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from src.market_data.access.exceptions import (
|
|
||||||
MarketDataAccessError,
|
|
||||||
MarketDataAccessIntegrityError,
|
|
||||||
MarketDataAccessOperationError,
|
|
||||||
MarketDataAccessValidationError,
|
|
||||||
)
|
|
||||||
from src.market_data.access.models import (
|
|
||||||
TradeHistoryCursor,
|
|
||||||
TradeHistoryPage,
|
|
||||||
TradeHistoryQuery,
|
|
||||||
TradeHistoryRecord,
|
|
||||||
)
|
|
||||||
from src.market_data.access.postgres_history_support import (
|
|
||||||
PostgresHistoryConnectionProvider,
|
|
||||||
trade_history_record_from_row,
|
|
||||||
)
|
|
||||||
|
|
||||||
_TRADE_HISTORY_COLUMNS_SQL = """
|
|
||||||
venue,
|
|
||||||
symbol,
|
|
||||||
trade_id,
|
|
||||||
executed_at,
|
|
||||||
price,
|
|
||||||
quantity,
|
|
||||||
aggressor_side,
|
|
||||||
source,
|
|
||||||
first_observed_at,
|
|
||||||
last_observed_at,
|
|
||||||
observation_sources,
|
|
||||||
replay_sequence,
|
|
||||||
canonical_schema_version
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT_TRADE_HISTORY_SQL = f"""
|
|
||||||
SELECT
|
|
||||||
{_TRADE_HISTORY_COLUMNS_SQL}
|
|
||||||
FROM market_data.trades
|
|
||||||
WHERE venue = %s
|
|
||||||
AND symbol = %s
|
|
||||||
AND executed_at >= %s
|
|
||||||
AND executed_at < %s
|
|
||||||
ORDER BY executed_at ASC,
|
|
||||||
replay_sequence ASC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SELECT_TRADE_HISTORY_AFTER_CURSOR_SQL = f"""
|
|
||||||
SELECT
|
|
||||||
{_TRADE_HISTORY_COLUMNS_SQL}
|
|
||||||
FROM market_data.trades
|
|
||||||
WHERE venue = %s
|
|
||||||
AND symbol = %s
|
|
||||||
AND executed_at >= %s
|
|
||||||
AND executed_at < %s
|
|
||||||
AND (executed_at, replay_sequence) > (%s, %s)
|
|
||||||
ORDER BY executed_at ASC,
|
|
||||||
replay_sequence ASC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class PostgresTradeHistoryRepository:
|
|
||||||
"""PostgreSQL-адаптер одной страницы канонической истории сделок."""
|
|
||||||
|
|
||||||
__slots__ = ("_connection_provider",)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
connection_provider: PostgresHistoryConnectionProvider,
|
|
||||||
) -> None:
|
|
||||||
if not callable(connection_provider):
|
|
||||||
raise TypeError("connection_provider must be callable")
|
|
||||||
|
|
||||||
self._connection_provider = connection_provider
|
|
||||||
|
|
||||||
def query_trades(self, query: TradeHistoryQuery) -> TradeHistoryPage:
|
|
||||||
"""Прочитать одну bounded страницу в устойчивом keyset-порядке."""
|
|
||||||
if type(query) is not TradeHistoryQuery:
|
|
||||||
raise MarketDataAccessValidationError(
|
|
||||||
"query must be TradeHistoryQuery"
|
|
||||||
)
|
|
||||||
|
|
||||||
sql, parameters = self._build_query(query)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self._connection_provider() as connection:
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute(sql, parameters)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
return self._page_from_rows(query=query, rows=rows)
|
|
||||||
except MarketDataAccessError:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise MarketDataAccessOperationError(
|
|
||||||
"Failed to query PostgreSQL Trade History."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_query(
|
|
||||||
query: TradeHistoryQuery,
|
|
||||||
) -> tuple[str, tuple[object, ...]]:
|
|
||||||
fetch_limit = query.limit + 1
|
|
||||||
cursor = query.cursor
|
|
||||||
|
|
||||||
if cursor is None:
|
|
||||||
return (
|
|
||||||
_SELECT_TRADE_HISTORY_SQL,
|
|
||||||
(
|
|
||||||
query.venue,
|
|
||||||
query.symbol,
|
|
||||||
query.time_range.start_time,
|
|
||||||
query.time_range.end_time,
|
|
||||||
fetch_limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
_SELECT_TRADE_HISTORY_AFTER_CURSOR_SQL,
|
|
||||||
(
|
|
||||||
query.venue,
|
|
||||||
query.symbol,
|
|
||||||
query.time_range.start_time,
|
|
||||||
query.time_range.end_time,
|
|
||||||
cursor.executed_at,
|
|
||||||
cursor.replay_sequence,
|
|
||||||
fetch_limit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _page_from_rows(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
query: TradeHistoryQuery,
|
|
||||||
rows: object,
|
|
||||||
) -> TradeHistoryPage:
|
|
||||||
if not isinstance(rows, Sequence) or isinstance(
|
|
||||||
rows,
|
|
||||||
(str, bytes, bytearray),
|
|
||||||
):
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Trade History rows."
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(rows) > query.limit + 1:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL exceeded the requested Trade History limit."
|
|
||||||
)
|
|
||||||
|
|
||||||
records = tuple(
|
|
||||||
cls._record_from_row(row=row, query=query)
|
|
||||||
for row in rows
|
|
||||||
)
|
|
||||||
cls._validate_records(records=records, query=query)
|
|
||||||
|
|
||||||
has_more = len(records) > query.limit
|
|
||||||
items = records[: query.limit]
|
|
||||||
next_cursor = (
|
|
||||||
cls._cursor_from_record(query=query, record=items[-1])
|
|
||||||
if has_more
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return TradeHistoryPage(
|
|
||||||
query=query,
|
|
||||||
items=items,
|
|
||||||
next_cursor=next_cursor,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned inconsistent Trade History page."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _record_from_row(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
row: object,
|
|
||||||
query: TradeHistoryQuery,
|
|
||||||
) -> TradeHistoryRecord:
|
|
||||||
record = trade_history_record_from_row(row)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if record.venue != query.venue or record.symbol != query.symbol:
|
|
||||||
raise ValueError("stored Trade is outside query scope")
|
|
||||||
|
|
||||||
if not query.time_range.contains(record.event_time):
|
|
||||||
raise ValueError("stored Trade is outside query range")
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"PostgreSQL returned invalid Canonical Trade values."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
return record
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_records(
|
|
||||||
*,
|
|
||||||
records: tuple[TradeHistoryRecord, ...],
|
|
||||||
query: TradeHistoryQuery,
|
|
||||||
) -> None:
|
|
||||||
seen_sequences: set[int] = set()
|
|
||||||
previous_key: tuple[datetime, int] | None = None
|
|
||||||
incoming_key = (
|
|
||||||
(
|
|
||||||
query.cursor.executed_at,
|
|
||||||
query.cursor.replay_sequence,
|
|
||||||
)
|
|
||||||
if query.cursor is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
for record in records:
|
|
||||||
if record.replay_sequence in seen_sequences:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Trade History contains duplicate replay_sequence."
|
|
||||||
)
|
|
||||||
|
|
||||||
if incoming_key is not None and record.order_key <= incoming_key:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Trade History row does not follow query cursor."
|
|
||||||
)
|
|
||||||
|
|
||||||
if previous_key is not None and record.order_key <= previous_key:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Trade History rows are not strictly ordered."
|
|
||||||
)
|
|
||||||
|
|
||||||
seen_sequences.add(record.replay_sequence)
|
|
||||||
previous_key = record.order_key
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _cursor_from_record(
|
|
||||||
*,
|
|
||||||
query: TradeHistoryQuery,
|
|
||||||
record: TradeHistoryRecord,
|
|
||||||
) -> TradeHistoryCursor:
|
|
||||||
try:
|
|
||||||
return TradeHistoryCursor(
|
|
||||||
venue=query.venue,
|
|
||||||
symbol=query.symbol,
|
|
||||||
time_range=query.time_range,
|
|
||||||
executed_at=record.event_time,
|
|
||||||
replay_sequence=record.replay_sequence,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise MarketDataAccessIntegrityError(
|
|
||||||
"Cannot create Trade History cursor from stored row."
|
|
||||||
) from error
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/adapters/dzengi/__init__.py
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket_trade_adapter import (
|
|
||||||
adapt_websocket_trade_document,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"adapt_websocket_trade_document",
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/adapters/dzengi/auth.py
|
|
||||||
@@ -1,664 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/adapters/dzengi/mapper.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.models import (
|
|
||||||
DzengiExchangeInfoResponse,
|
|
||||||
DzengiExchangeInfoSymbol,
|
|
||||||
DzengiInstrumentFilter,
|
|
||||||
DzengiKlinesResponse,
|
|
||||||
DzengiLotSizeFilter,
|
|
||||||
DzengiMinNotionalFilter,
|
|
||||||
DzengiRawNumeric,
|
|
||||||
DzengiRestAggTrade,
|
|
||||||
DzengiTicker24hrResponse,
|
|
||||||
DzengiWebSocketOhlcEvent,
|
|
||||||
DzengiWebSocketQuoteResponse,
|
|
||||||
DzengiWebSocketTradeEvent,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
CandleMappingError,
|
|
||||||
CandleWebSocketMappingError,
|
|
||||||
InstrumentReferenceMappingError,
|
|
||||||
QuoteMappingError,
|
|
||||||
TradeMappingError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.candle import Candle
|
|
||||||
from src.market_data.acquisition.models.candle_close import CandleCloseEvent
|
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.market_data.acquisition.models.trade import (
|
|
||||||
Trade,
|
|
||||||
TradeAggressorSide,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_DZENGI_SOURCE_NAME = "dzengi"
|
|
||||||
_DZENGI_WEBSOCKET_OHLC_SOURCE_NAME = "dzengi_websocket_ohlc"
|
|
||||||
_DZENGI_WEBSOCKET_TRADE_SOURCE_NAME = "dzengi_websocket_trade"
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_symbol_to_instrument(
|
|
||||||
symbol: DzengiExchangeInfoSymbol,
|
|
||||||
) -> Instrument:
|
|
||||||
"""
|
|
||||||
Преобразовать проверенную raw-модель инструмента Dzengi
|
|
||||||
во внутреннюю source-independent модель Instrument.
|
|
||||||
|
|
||||||
Функция предполагает, что до mapper уже были выполнены:
|
|
||||||
schema validation, parsing и value validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
lot_size = _find_single_filter(
|
|
||||||
symbol.filters,
|
|
||||||
DzengiLotSizeFilter,
|
|
||||||
filter_name="LOT_SIZE",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
)
|
|
||||||
min_notional = _find_single_filter(
|
|
||||||
symbol.filters,
|
|
||||||
DzengiMinNotionalFilter,
|
|
||||||
filter_name="MIN_NOTIONAL",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Instrument(
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
name=symbol.name,
|
|
||||||
status=symbol.status,
|
|
||||||
base_asset=symbol.base_asset,
|
|
||||||
quote_asset=symbol.quote_asset,
|
|
||||||
asset_type=_optional_text(symbol.asset_type),
|
|
||||||
market_type=symbol.market_type,
|
|
||||||
market_modes=symbol.market_modes,
|
|
||||||
order_types=symbol.order_types,
|
|
||||||
base_asset_precision=symbol.base_asset_precision,
|
|
||||||
quote_asset_precision=symbol.quote_precision,
|
|
||||||
tick_size=_optional_decimal(
|
|
||||||
symbol.tick_size,
|
|
||||||
field_name="tickSize",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
),
|
|
||||||
tick_value=_optional_decimal(
|
|
||||||
symbol.tick_value,
|
|
||||||
field_name="tickValue",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
),
|
|
||||||
step_size=_optional_decimal(
|
|
||||||
lot_size.step_size if lot_size is not None else None,
|
|
||||||
field_name="stepSize",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
),
|
|
||||||
min_qty=_optional_decimal(
|
|
||||||
lot_size.min_qty if lot_size is not None else None,
|
|
||||||
field_name="minQty",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
),
|
|
||||||
max_qty=_optional_decimal(
|
|
||||||
lot_size.max_qty if lot_size is not None else None,
|
|
||||||
field_name="maxQty",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
),
|
|
||||||
min_notional=_optional_decimal(
|
|
||||||
min_notional.min_notional
|
|
||||||
if min_notional is not None
|
|
||||||
else None,
|
|
||||||
field_name="minNotional",
|
|
||||||
symbol=symbol.symbol,
|
|
||||||
),
|
|
||||||
country=_optional_text(symbol.country),
|
|
||||||
sector=_optional_text(symbol.sector),
|
|
||||||
industry=_optional_text(symbol.industry),
|
|
||||||
trading_hours=_optional_text(symbol.trading_hours),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_exchange_info_to_instruments(
|
|
||||||
response: DzengiExchangeInfoResponse,
|
|
||||||
) -> tuple[Instrument, ...]:
|
|
||||||
"""
|
|
||||||
Преобразовать все инструменты exchangeInfo
|
|
||||||
во внутренние модели Instrument.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return tuple(
|
|
||||||
map_dzengi_symbol_to_instrument(symbol)
|
|
||||||
for symbol in response.payload.symbols
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_ticker_to_quote(
|
|
||||||
response: DzengiTicker24hrResponse,
|
|
||||||
*,
|
|
||||||
received_at: datetime,
|
|
||||||
) -> Quote:
|
|
||||||
"""
|
|
||||||
Преобразовать проверенную raw-модель Dzengi ticker/24hr
|
|
||||||
во внутреннюю source-independent модель Quote.
|
|
||||||
|
|
||||||
Функция предполагает, что до mapper уже были выполнены:
|
|
||||||
schema validation, parsing и value validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
normalized_received_at = _require_aware_datetime(
|
|
||||||
received_at,
|
|
||||||
field_name="received_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
return Quote(
|
|
||||||
symbol=response.symbol.strip(),
|
|
||||||
last_price=_required_quote_decimal(
|
|
||||||
response.last_price,
|
|
||||||
field_name="lastPrice",
|
|
||||||
),
|
|
||||||
bid_price=_required_quote_decimal(
|
|
||||||
response.bid_price,
|
|
||||||
field_name="bidPrice",
|
|
||||||
),
|
|
||||||
ask_price=_required_quote_decimal(
|
|
||||||
response.ask_price,
|
|
||||||
field_name="askPrice",
|
|
||||||
),
|
|
||||||
exchange_timestamp=_timestamp_ms_to_utc_datetime(
|
|
||||||
response.close_time,
|
|
||||||
),
|
|
||||||
received_at=normalized_received_at,
|
|
||||||
source=_DZENGI_SOURCE_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _timestamp_ms_to_utc_datetime(value: int) -> datetime:
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(
|
|
||||||
value / 1000,
|
|
||||||
tz=timezone.utc,
|
|
||||||
)
|
|
||||||
except (OverflowError, OSError, ValueError) as exc:
|
|
||||||
raise QuoteMappingError(
|
|
||||||
"Поле closeTime невозможно преобразовать "
|
|
||||||
"в UTC datetime."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _required_quote_decimal(
|
|
||||||
value: DzengiRawNumeric,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
) -> Decimal:
|
|
||||||
try:
|
|
||||||
result = Decimal(str(value))
|
|
||||||
except (InvalidOperation, ValueError) as exc:
|
|
||||||
raise QuoteMappingError(
|
|
||||||
f"Поле {field_name} котировки невозможно "
|
|
||||||
"преобразовать в Decimal."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not result.is_finite():
|
|
||||||
raise QuoteMappingError(
|
|
||||||
f"Поле {field_name} котировки должно быть "
|
|
||||||
"конечным числом."
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _require_aware_datetime(
|
|
||||||
value: datetime,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
) -> datetime:
|
|
||||||
if value.tzinfo is None or value.utcoffset() is None:
|
|
||||||
raise QuoteMappingError(
|
|
||||||
f"Поле {field_name} должно содержать timezone-aware datetime."
|
|
||||||
)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _find_single_filter[
|
|
||||||
FilterT: DzengiInstrumentFilter
|
|
||||||
](
|
|
||||||
filters: tuple[DzengiInstrumentFilter, ...],
|
|
||||||
filter_type: type[FilterT],
|
|
||||||
*,
|
|
||||||
filter_name: str,
|
|
||||||
symbol: str,
|
|
||||||
) -> FilterT | None:
|
|
||||||
matches = tuple(
|
|
||||||
instrument_filter
|
|
||||||
for instrument_filter in filters
|
|
||||||
if isinstance(instrument_filter, filter_type)
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(matches) > 1:
|
|
||||||
raise InstrumentReferenceMappingError(
|
|
||||||
f"Инструмент '{symbol}' содержит несколько "
|
|
||||||
f"фильтров {filter_name}."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return matches[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_decimal(
|
|
||||||
value: DzengiRawNumeric | None,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
symbol: str,
|
|
||||||
) -> Decimal | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = Decimal(str(value))
|
|
||||||
except (InvalidOperation, ValueError) as exc:
|
|
||||||
raise InstrumentReferenceMappingError(
|
|
||||||
f"Поле {field_name} инструмента '{symbol}' "
|
|
||||||
f"невозможно преобразовать в Decimal."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not result.is_finite():
|
|
||||||
raise InstrumentReferenceMappingError(
|
|
||||||
f"Поле {field_name} инструмента '{symbol}' "
|
|
||||||
f"должно быть конечным числом."
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_text(value: str | None) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized = value.strip()
|
|
||||||
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_websocket_quote_to_quote(
|
|
||||||
response: DzengiWebSocketQuoteResponse,
|
|
||||||
*,
|
|
||||||
received_at: datetime,
|
|
||||||
) -> Quote:
|
|
||||||
"""
|
|
||||||
Преобразовать проверенную WebSocket-модель Dzengi в канонический Quote.
|
|
||||||
|
|
||||||
Depth-сообщение не содержит цену последней сделки, поэтому временно
|
|
||||||
используется midpoint best bid / best ask — так же, как в legacy runtime.
|
|
||||||
"""
|
|
||||||
|
|
||||||
normalized_received_at = _require_aware_datetime(
|
|
||||||
received_at,
|
|
||||||
field_name="received_at",
|
|
||||||
)
|
|
||||||
bid_price = _required_quote_decimal(
|
|
||||||
response.bid_price,
|
|
||||||
field_name="bidPrice",
|
|
||||||
)
|
|
||||||
ask_price = _required_quote_decimal(
|
|
||||||
response.ask_price,
|
|
||||||
field_name="askPrice",
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange_timestamp = None
|
|
||||||
if response.timestamp is not None:
|
|
||||||
try:
|
|
||||||
exchange_timestamp = datetime.fromtimestamp(
|
|
||||||
response.timestamp / 1000,
|
|
||||||
tz=timezone.utc,
|
|
||||||
)
|
|
||||||
except (OverflowError, OSError, ValueError) as exc:
|
|
||||||
raise QuoteMappingError(
|
|
||||||
"Поле timestamp невозможно преобразовать в UTC datetime."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
return Quote(
|
|
||||||
symbol=response.symbol.strip(),
|
|
||||||
last_price=(bid_price + ask_price) / Decimal("2"),
|
|
||||||
bid_price=bid_price,
|
|
||||||
ask_price=ask_price,
|
|
||||||
exchange_timestamp=exchange_timestamp,
|
|
||||||
received_at=normalized_received_at,
|
|
||||||
source=_DZENGI_SOURCE_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_websocket_ohlc_to_candle_close_event(
|
|
||||||
event: DzengiWebSocketOhlcEvent,
|
|
||||||
*,
|
|
||||||
received_at: datetime,
|
|
||||||
) -> CandleCloseEvent:
|
|
||||||
"""
|
|
||||||
Преобразовать проверенное Dzengi WebSocket OHLC-событие
|
|
||||||
во внутреннюю модель CandleCloseEvent.
|
|
||||||
|
|
||||||
Функция предполагает, что до mapper уже были выполнены:
|
|
||||||
schema validation, parsing и value validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
normalized_received_at = _require_websocket_ohlc_aware_datetime(
|
|
||||||
received_at,
|
|
||||||
field_name="received_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
return CandleCloseEvent(
|
|
||||||
symbol=event.symbol.strip(),
|
|
||||||
interval=event.interval,
|
|
||||||
candle_type=event.candle_type,
|
|
||||||
open_time=_websocket_ohlc_timestamp_ms_to_utc_datetime(
|
|
||||||
event.open_time,
|
|
||||||
),
|
|
||||||
received_at=normalized_received_at,
|
|
||||||
open_price=_required_websocket_ohlc_decimal(
|
|
||||||
event.open_price,
|
|
||||||
field_name="open",
|
|
||||||
),
|
|
||||||
high_price=_required_websocket_ohlc_decimal(
|
|
||||||
event.high_price,
|
|
||||||
field_name="high",
|
|
||||||
),
|
|
||||||
low_price=_required_websocket_ohlc_decimal(
|
|
||||||
event.low_price,
|
|
||||||
field_name="low",
|
|
||||||
),
|
|
||||||
close_price=_required_websocket_ohlc_decimal(
|
|
||||||
event.close_price,
|
|
||||||
field_name="close",
|
|
||||||
),
|
|
||||||
source=_DZENGI_WEBSOCKET_OHLC_SOURCE_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _websocket_ohlc_timestamp_ms_to_utc_datetime(
|
|
||||||
value: int,
|
|
||||||
) -> datetime:
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(
|
|
||||||
value / 1000,
|
|
||||||
tz=timezone.utc,
|
|
||||||
)
|
|
||||||
except (OverflowError, OSError, ValueError) as exc:
|
|
||||||
raise CandleWebSocketMappingError(
|
|
||||||
"Поле t WebSocket OHLC-события невозможно "
|
|
||||||
"преобразовать в UTC datetime."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _required_websocket_ohlc_decimal(
|
|
||||||
value: DzengiRawNumeric,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
) -> Decimal:
|
|
||||||
try:
|
|
||||||
result = Decimal(str(value))
|
|
||||||
except (InvalidOperation, ValueError) as exc:
|
|
||||||
raise CandleWebSocketMappingError(
|
|
||||||
f"Поле {field_name} WebSocket OHLC-события невозможно "
|
|
||||||
"преобразовать в Decimal."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not result.is_finite():
|
|
||||||
raise CandleWebSocketMappingError(
|
|
||||||
f"Поле {field_name} WebSocket OHLC-события должно быть "
|
|
||||||
"конечным числом."
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _require_websocket_ohlc_aware_datetime(
|
|
||||||
value: datetime,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
) -> datetime:
|
|
||||||
if value.tzinfo is None or value.utcoffset() is None:
|
|
||||||
raise CandleWebSocketMappingError(
|
|
||||||
f"Поле {field_name} должно содержать "
|
|
||||||
"timezone-aware datetime."
|
|
||||||
)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_klines_to_candles(
|
|
||||||
response: DzengiKlinesResponse,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
interval: str,
|
|
||||||
source: str,
|
|
||||||
) -> tuple[Candle, ...]:
|
|
||||||
"""
|
|
||||||
Преобразовать проверенные raw-модели Dzengi klines
|
|
||||||
в канонический immutable-набор Candle.
|
|
||||||
|
|
||||||
Функция предполагает, что до mapper уже были выполнены:
|
|
||||||
schema validation, parsing и value validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
candles = tuple(
|
|
||||||
Candle(
|
|
||||||
symbol=symbol.strip(),
|
|
||||||
interval=interval.strip(),
|
|
||||||
open_time=_candle_timestamp_ms_to_utc_datetime(
|
|
||||||
item.open_time,
|
|
||||||
),
|
|
||||||
open_price=_required_candle_decimal(
|
|
||||||
item.open_price,
|
|
||||||
field_name="open",
|
|
||||||
),
|
|
||||||
high_price=_required_candle_decimal(
|
|
||||||
item.high_price,
|
|
||||||
field_name="high",
|
|
||||||
),
|
|
||||||
low_price=_required_candle_decimal(
|
|
||||||
item.low_price,
|
|
||||||
field_name="low",
|
|
||||||
),
|
|
||||||
close_price=_required_candle_decimal(
|
|
||||||
item.close_price,
|
|
||||||
field_name="close",
|
|
||||||
),
|
|
||||||
volume=_required_candle_decimal(
|
|
||||||
item.volume,
|
|
||||||
field_name="volume",
|
|
||||||
),
|
|
||||||
source=source.strip(),
|
|
||||||
)
|
|
||||||
for item in response.items
|
|
||||||
)
|
|
||||||
|
|
||||||
return tuple(
|
|
||||||
sorted(
|
|
||||||
candles,
|
|
||||||
key=lambda candle: candle.open_time,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _candle_timestamp_ms_to_utc_datetime(value: int) -> datetime:
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(
|
|
||||||
value / 1000,
|
|
||||||
tz=timezone.utc,
|
|
||||||
)
|
|
||||||
except (OverflowError, OSError, ValueError) as exc:
|
|
||||||
raise CandleMappingError(
|
|
||||||
"Поле openTime свечи невозможно преобразовать "
|
|
||||||
"в UTC datetime."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _required_candle_decimal(
|
|
||||||
value: DzengiRawNumeric,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
) -> Decimal:
|
|
||||||
try:
|
|
||||||
result = Decimal(str(value))
|
|
||||||
except (InvalidOperation, ValueError) as exc:
|
|
||||||
raise CandleMappingError(
|
|
||||||
f"Поле {field_name} свечи невозможно "
|
|
||||||
"преобразовать в Decimal."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not result.is_finite():
|
|
||||||
raise CandleMappingError(
|
|
||||||
f"Поле {field_name} свечи должно быть конечным числом."
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_rest_agg_trades_to_trades(
|
|
||||||
trades: tuple[DzengiRestAggTrade, ...],
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
) -> tuple[Trade, ...]:
|
|
||||||
"""
|
|
||||||
Преобразовать проверенные transport-модели Dzengi aggTrades
|
|
||||||
в канонический immutable-набор Trade.
|
|
||||||
|
|
||||||
Функция предполагает, что до mapper уже были выполнены:
|
|
||||||
schema validation, parsing и value validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return tuple(
|
|
||||||
_map_dzengi_rest_agg_trade(
|
|
||||||
trade,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
for trade in trades
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def map_dzengi_websocket_trade_to_trade(
|
|
||||||
event: DzengiWebSocketTradeEvent,
|
|
||||||
) -> Trade:
|
|
||||||
"""
|
|
||||||
Преобразовать проверенную transport-модель
|
|
||||||
WebSocket Trade в каноническую модель Trade.
|
|
||||||
|
|
||||||
Предполагается, что ранее успешно выполнены:
|
|
||||||
|
|
||||||
- Schema Validation
|
|
||||||
- Parser
|
|
||||||
- Value Validation
|
|
||||||
"""
|
|
||||||
|
|
||||||
return Trade(
|
|
||||||
symbol=event.symbol.strip(),
|
|
||||||
trade_id=event.trade_id,
|
|
||||||
price=_required_trade_decimal(
|
|
||||||
event.price,
|
|
||||||
field_name="price",
|
|
||||||
),
|
|
||||||
quantity=_required_trade_decimal(
|
|
||||||
event.size,
|
|
||||||
field_name="size",
|
|
||||||
),
|
|
||||||
executed_at=_trade_timestamp_ms_to_utc_datetime(
|
|
||||||
event.timestamp,
|
|
||||||
),
|
|
||||||
aggressor_side=_websocket_trade_aggressor_side(
|
|
||||||
event.buyer,
|
|
||||||
),
|
|
||||||
source=_DZENGI_WEBSOCKET_TRADE_SOURCE_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _map_dzengi_rest_agg_trade(
|
|
||||||
trade: DzengiRestAggTrade,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
) -> Trade:
|
|
||||||
return Trade(
|
|
||||||
symbol=symbol.strip(),
|
|
||||||
trade_id=trade.aggregate_trade_id,
|
|
||||||
price=_required_trade_decimal(
|
|
||||||
trade.price,
|
|
||||||
field_name="price",
|
|
||||||
),
|
|
||||||
quantity=_required_trade_decimal(
|
|
||||||
trade.quantity,
|
|
||||||
field_name="quantity",
|
|
||||||
),
|
|
||||||
executed_at=_trade_timestamp_ms_to_utc_datetime(
|
|
||||||
trade.timestamp,
|
|
||||||
),
|
|
||||||
aggressor_side=_trade_aggressor_side(
|
|
||||||
trade.buyer_is_maker,
|
|
||||||
),
|
|
||||||
source=_DZENGI_SOURCE_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_aggressor_side(
|
|
||||||
buyer_is_maker: bool,
|
|
||||||
) -> TradeAggressorSide:
|
|
||||||
if buyer_is_maker:
|
|
||||||
return TradeAggressorSide.SELL
|
|
||||||
|
|
||||||
return TradeAggressorSide.BUY
|
|
||||||
|
|
||||||
|
|
||||||
def _websocket_trade_aggressor_side(
|
|
||||||
buyer: bool,
|
|
||||||
) -> TradeAggressorSide:
|
|
||||||
"""
|
|
||||||
Преобразовать направление WebSocket Trade
|
|
||||||
в каноническую сторону агрессора.
|
|
||||||
|
|
||||||
WebSocket:
|
|
||||||
buyer=True -> BUY
|
|
||||||
buyer=False -> SELL
|
|
||||||
"""
|
|
||||||
|
|
||||||
if buyer:
|
|
||||||
return TradeAggressorSide.BUY
|
|
||||||
|
|
||||||
return TradeAggressorSide.SELL
|
|
||||||
|
|
||||||
|
|
||||||
def _required_trade_decimal(
|
|
||||||
value: DzengiRawNumeric,
|
|
||||||
*,
|
|
||||||
field_name: str,
|
|
||||||
) -> Decimal:
|
|
||||||
try:
|
|
||||||
result = Decimal(str(value))
|
|
||||||
except (InvalidOperation, ValueError) as exc:
|
|
||||||
raise TradeMappingError(
|
|
||||||
f"Поле {field_name} сделки невозможно "
|
|
||||||
"преобразовать в Decimal."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not result.is_finite():
|
|
||||||
raise TradeMappingError(
|
|
||||||
f"Поле {field_name} сделки должно быть "
|
|
||||||
"конечным числом."
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _trade_timestamp_ms_to_utc_datetime(
|
|
||||||
value: int,
|
|
||||||
) -> datetime:
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(
|
|
||||||
value / 1000,
|
|
||||||
tz=timezone.utc,
|
|
||||||
)
|
|
||||||
except (OverflowError, OSError, ValueError) as exc:
|
|
||||||
raise TradeMappingError(
|
|
||||||
"Поле timestamp сделки невозможно "
|
|
||||||
"преобразовать в UTC datetime."
|
|
||||||
) from exc
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/adapters/dzengi/models.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TypeAlias
|
|
||||||
|
|
||||||
|
|
||||||
# Число в исходном JSON-ответе Dzengi без предметного преобразования.
|
|
||||||
DzengiJsonNumber: TypeAlias = int | float
|
|
||||||
|
|
||||||
# Числовое значение, которое Dzengi может передать числом или строкой.
|
|
||||||
DzengiRawNumeric: TypeAlias = str | int | float
|
|
||||||
|
|
||||||
# Скалярное значение неизвестного поля транспортного ответа.
|
|
||||||
DzengiJsonScalar: TypeAlias = str | int | float | bool | None
|
|
||||||
|
|
||||||
|
|
||||||
# Лимит запросов из exchangeInfo.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiRateLimit:
|
|
||||||
interval: str
|
|
||||||
interval_num: int
|
|
||||||
limit: int
|
|
||||||
rate_limit_type: str
|
|
||||||
|
|
||||||
|
|
||||||
# Базовый контракт фильтра инструмента Dzengi.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiInstrumentFilter:
|
|
||||||
filter_type: str
|
|
||||||
|
|
||||||
|
|
||||||
# Ограничения размера заявки.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiLotSizeFilter(DzengiInstrumentFilter):
|
|
||||||
min_qty: DzengiRawNumeric | None
|
|
||||||
max_qty: DzengiRawNumeric | None
|
|
||||||
step_size: DzengiRawNumeric | None
|
|
||||||
|
|
||||||
|
|
||||||
# Ограничение минимальной стоимости заявки.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiMinNotionalFilter(DzengiInstrumentFilter):
|
|
||||||
min_notional: DzengiRawNumeric | None
|
|
||||||
|
|
||||||
|
|
||||||
# Неизвестный тип фильтра, который ещё не поддерживается адаптером.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiUnknownFilter(DzengiInstrumentFilter):
|
|
||||||
fields: tuple[tuple[str, DzengiJsonScalar], ...]
|
|
||||||
|
|
||||||
|
|
||||||
# Один инструмент из ответа Dzengi exchangeInfo.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiExchangeInfoSymbol:
|
|
||||||
symbol: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
|
|
||||||
asset_type: str | None
|
|
||||||
|
|
||||||
base_asset: str
|
|
||||||
base_asset_precision: int | None
|
|
||||||
|
|
||||||
quote_asset: str
|
|
||||||
quote_asset_id: str | None
|
|
||||||
quote_precision: int | None
|
|
||||||
|
|
||||||
order_types: tuple[str, ...]
|
|
||||||
filters: tuple[DzengiInstrumentFilter, ...]
|
|
||||||
|
|
||||||
market_modes: tuple[str, ...]
|
|
||||||
market_type: str
|
|
||||||
|
|
||||||
country: str | None
|
|
||||||
sector: str | None
|
|
||||||
industry: str | None
|
|
||||||
trading_hours: str | None
|
|
||||||
|
|
||||||
tick_size: DzengiJsonNumber | None
|
|
||||||
tick_value: DzengiJsonNumber | None
|
|
||||||
|
|
||||||
trading_fee: DzengiJsonNumber | None
|
|
||||||
exchange_fee: DzengiJsonNumber | None
|
|
||||||
|
|
||||||
long_rate: DzengiJsonNumber | None
|
|
||||||
short_rate: DzengiJsonNumber | None
|
|
||||||
swap_charge_interval: int | None
|
|
||||||
|
|
||||||
min_sl_gap: DzengiJsonNumber | None
|
|
||||||
max_sl_gap: DzengiJsonNumber | None
|
|
||||||
min_tp_gap: DzengiJsonNumber | None
|
|
||||||
max_tp_gap: DzengiJsonNumber | None
|
|
||||||
|
|
||||||
|
|
||||||
# Содержимое exchangeInfo независимо от внешней оболочки API.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiExchangeInfoPayload:
|
|
||||||
timezone: str | None
|
|
||||||
server_time: int | None
|
|
||||||
rate_limits: tuple[DzengiRateLimit, ...]
|
|
||||||
exchange_filters: tuple[DzengiUnknownFilter, ...]
|
|
||||||
symbols: tuple[DzengiExchangeInfoSymbol, ...]
|
|
||||||
|
|
||||||
|
|
||||||
# Нормализованное транспортное представление ответа exchangeInfo.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiExchangeInfoResponse:
|
|
||||||
payload: DzengiExchangeInfoPayload
|
|
||||||
|
|
||||||
# Поля присутствуют в wrapped-формате ответа и отсутствуют
|
|
||||||
# в фактическом unwrapped-ответе публичного REST endpoint.
|
|
||||||
status: str | None = None
|
|
||||||
correlation_id: str | None = None
|
|
||||||
|
|
||||||
# Транспортное представление ответа Dzengi GET /api/v1/ticker/24hr.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiTicker24hrResponse:
|
|
||||||
symbol: str
|
|
||||||
last_price: DzengiRawNumeric
|
|
||||||
bid_price: DzengiRawNumeric
|
|
||||||
ask_price: DzengiRawNumeric
|
|
||||||
close_time: int
|
|
||||||
|
|
||||||
|
|
||||||
# Нормализованное транспортное представление котировки из Dzengi WebSocket.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiWebSocketQuoteResponse:
|
|
||||||
symbol: str
|
|
||||||
bid_price: DzengiRawNumeric
|
|
||||||
ask_price: DzengiRawNumeric
|
|
||||||
timestamp: int | None
|
|
||||||
|
|
||||||
|
|
||||||
# Транспортное представление одной свечи Dzengi GET /api/v1/klines.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiKline:
|
|
||||||
open_time: int
|
|
||||||
open_price: str | int | float
|
|
||||||
high_price: str | int | float
|
|
||||||
low_price: str | int | float
|
|
||||||
close_price: str | int | float
|
|
||||||
volume: str | int | float
|
|
||||||
|
|
||||||
|
|
||||||
# Транспортное представление ответа Dzengi GET /api/v1/klines.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiKlinesResponse:
|
|
||||||
items: tuple[DzengiKline, ...]
|
|
||||||
|
|
||||||
|
|
||||||
# Транспортное представление одной агрегированной сделки
|
|
||||||
# Dzengi GET /api/v2/aggTrades.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiRestAggTrade:
|
|
||||||
aggregate_trade_id: int
|
|
||||||
price: DzengiRawNumeric
|
|
||||||
quantity: DzengiRawNumeric
|
|
||||||
timestamp: int
|
|
||||||
buyer_is_maker: bool
|
|
||||||
|
|
||||||
|
|
||||||
# Транспортное представление одного события
|
|
||||||
# Dzengi WebSocket internal.trade.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiWebSocketTradeEvent:
|
|
||||||
trade_id: int
|
|
||||||
price: DzengiRawNumeric
|
|
||||||
size: DzengiRawNumeric
|
|
||||||
timestamp: int
|
|
||||||
symbol: str
|
|
||||||
buyer: bool
|
|
||||||
order_id: str
|
|
||||||
|
|
||||||
|
|
||||||
# Транспортное представление одного события Dzengi WebSocket ohlc.event.
|
|
||||||
#
|
|
||||||
# Событие содержит завершённую OHLC-свечу без объёма и поэтому не является
|
|
||||||
# канонической моделью Candle. Поле candle_type принимает значения,
|
|
||||||
# подтверждённые runtime-контрактом Dzengi: classic или heikin-ashi.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DzengiWebSocketOhlcEvent:
|
|
||||||
symbol: str
|
|
||||||
interval: str
|
|
||||||
candle_type: str
|
|
||||||
|
|
||||||
open_time: int
|
|
||||||
|
|
||||||
open_price: DzengiRawNumeric
|
|
||||||
high_price: DzengiRawNumeric
|
|
||||||
low_price: DzengiRawNumeric
|
|
||||||
close_price: DzengiRawNumeric
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,215 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/adapters/dzengi/rest.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
from src.integrations.exchange.rest_client import ExchangeRestClient
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
CandleTransportError,
|
|
||||||
InstrumentReferenceTransportError,
|
|
||||||
QuoteTransportError,
|
|
||||||
TradeTransportError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_EXCHANGE_INFO_PATH = "/api/v1/exchangeInfo"
|
|
||||||
_TICKER_24HR_PATH = "/api/v1/ticker/24hr"
|
|
||||||
_KLINES_PATH = "/api/v1/klines"
|
|
||||||
_AGG_TRADES_PATH = "/api/v1/aggTrades"
|
|
||||||
|
|
||||||
|
|
||||||
# Минимальный транспортный контракт, необходимый Dzengi REST adapter.
|
|
||||||
class _PayloadRestClient(Protocol):
|
|
||||||
def get_payload(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
params: dict[str, str] | None = None,
|
|
||||||
headers: dict[str, str] | None = None,
|
|
||||||
) -> object:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class DzengiInstrumentDocumentSource:
|
|
||||||
"""
|
|
||||||
Источник сырого документа Instrument Reference Data через Dzengi REST API.
|
|
||||||
|
|
||||||
На переходном этапе использует legacy ExchangeRestClient.
|
|
||||||
Зависимость должна быть удалена после появления общего transport-клиента
|
|
||||||
или после полного вывода integrations/exchange из эксплуатации.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client: _PayloadRestClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._client = client
|
|
||||||
|
|
||||||
def fetch_instrument_document(self) -> object:
|
|
||||||
"""
|
|
||||||
Получить декодированный ответ Dzengi exchangeInfo без его обработки.
|
|
||||||
|
|
||||||
Метод не выполняет schema validation, parsing, value validation,
|
|
||||||
mapping или кэширование.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
client: _PayloadRestClient = (
|
|
||||||
self._client
|
|
||||||
if self._client is not None
|
|
||||||
else ExchangeRestClient()
|
|
||||||
)
|
|
||||||
|
|
||||||
return client.get_payload(_EXCHANGE_INFO_PATH)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
raise InstrumentReferenceTransportError(
|
|
||||||
"Не удалось получить Instrument Reference Data "
|
|
||||||
f"от Dzengi: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
class DzengiQuoteDocumentSource:
|
|
||||||
"""Источник сырого документа текущей котировки через Dzengi REST API."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client: _PayloadRestClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._client = client
|
|
||||||
|
|
||||||
def fetch_quote_document(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> object:
|
|
||||||
"""
|
|
||||||
Получить декодированный ответ Dzengi ticker/24hr без его обработки.
|
|
||||||
|
|
||||||
Метод не выполняет нормализацию symbol, schema validation, parsing,
|
|
||||||
value validation, mapping, retry или кэширование.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
client: _PayloadRestClient = (
|
|
||||||
self._client
|
|
||||||
if self._client is not None
|
|
||||||
else ExchangeRestClient()
|
|
||||||
)
|
|
||||||
|
|
||||||
return client.get_payload(
|
|
||||||
_TICKER_24HR_PATH,
|
|
||||||
params={
|
|
||||||
"symbol": symbol,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
raise QuoteTransportError(
|
|
||||||
"Не удалось получить текущую котировку "
|
|
||||||
f"от Dzengi для символа '{symbol}': {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
class DzengiCandlesDocumentSource:
|
|
||||||
"""Источник сырого документа свечей через Dzengi REST API."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client: _PayloadRestClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._client = client
|
|
||||||
|
|
||||||
def fetch_candles_document(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
*,
|
|
||||||
interval: str,
|
|
||||||
limit: int,
|
|
||||||
price_type: str,
|
|
||||||
) -> object:
|
|
||||||
"""
|
|
||||||
Получить декодированный ответ Dzengi klines без его обработки.
|
|
||||||
|
|
||||||
Метод не выполняет нормализацию параметров запроса, schema validation,
|
|
||||||
parsing, value validation, mapping, retry, сортировку или кэширование.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
client: _PayloadRestClient = (
|
|
||||||
self._client
|
|
||||||
if self._client is not None
|
|
||||||
else ExchangeRestClient()
|
|
||||||
)
|
|
||||||
|
|
||||||
return client.get_payload(
|
|
||||||
_KLINES_PATH,
|
|
||||||
params={
|
|
||||||
"symbol": symbol,
|
|
||||||
"interval": interval,
|
|
||||||
"limit": str(limit),
|
|
||||||
"priceType": price_type,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
raise CandleTransportError(
|
|
||||||
"Не удалось получить свечи "
|
|
||||||
f"от Dzengi для символа '{symbol}', "
|
|
||||||
f"интервала '{interval}' и типа цены '{price_type}': {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
class DzengiTradesDocumentSource:
|
|
||||||
"""Источник сырого документа агрегированных сделок через Dzengi REST API."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client: _PayloadRestClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._client = client
|
|
||||||
|
|
||||||
def fetch_trades_document(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
*,
|
|
||||||
start_time: int | None = None,
|
|
||||||
end_time: int | None = None,
|
|
||||||
limit: int | None = None,
|
|
||||||
) -> object:
|
|
||||||
"""
|
|
||||||
Получить декодированный ответ Dzengi aggTrades без его обработки.
|
|
||||||
|
|
||||||
Метод не выполняет schema validation, parsing, value validation,
|
|
||||||
mapping, сортировку, дедупликацию или кэширование.
|
|
||||||
"""
|
|
||||||
|
|
||||||
params: dict[str, str] = {
|
|
||||||
"symbol": symbol,
|
|
||||||
}
|
|
||||||
|
|
||||||
if start_time is not None:
|
|
||||||
params["startTime"] = str(start_time)
|
|
||||||
|
|
||||||
if end_time is not None:
|
|
||||||
params["endTime"] = str(end_time)
|
|
||||||
|
|
||||||
if limit is not None:
|
|
||||||
params["limit"] = str(limit)
|
|
||||||
|
|
||||||
try:
|
|
||||||
client: _PayloadRestClient = (
|
|
||||||
self._client
|
|
||||||
if self._client is not None
|
|
||||||
else ExchangeRestClient()
|
|
||||||
)
|
|
||||||
|
|
||||||
return client.get_payload(
|
|
||||||
_AGG_TRADES_PATH,
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
raise TradeTransportError(
|
|
||||||
"Не удалось получить агрегированные сделки "
|
|
||||||
f"от Dzengi для символа '{symbol}': {exc}"
|
|
||||||
) from exc
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# src/market_data/acquisition/adapters/dzengi/rest_trade_adapter.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
|
||||||
map_dzengi_rest_agg_trades_to_trades,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
|
||||||
parse_rest_agg_trades,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.validation.schema import (
|
|
||||||
ValidatedRestAggTradesDocument,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.validation.values import (
|
|
||||||
validate_rest_agg_trade_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def adapt_rest_agg_trades_document(
|
|
||||||
document: ValidatedRestAggTradesDocument,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
) -> tuple[Trade, ...]:
|
|
||||||
"""
|
|
||||||
Преобразовать структурно проверенный документ Dzengi aggTrades
|
|
||||||
в канонический immutable-набор Trade.
|
|
||||||
|
|
||||||
Функция последовательно выполняет parsing, value validation
|
|
||||||
и mapping. Schema validation должна быть выполнена вызывающим слоем.
|
|
||||||
Исключения отдельных этапов не перехватываются и не оборачиваются.
|
|
||||||
"""
|
|
||||||
|
|
||||||
trades = parse_rest_agg_trades(document)
|
|
||||||
|
|
||||||
validate_rest_agg_trade_values(trades)
|
|
||||||
|
|
||||||
return map_dzengi_rest_agg_trades_to_trades(
|
|
||||||
trades,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/adapters/dzengi/websocket.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
|
||||||
map_dzengi_websocket_ohlc_to_candle_close_event,
|
|
||||||
map_dzengi_websocket_quote_to_quote,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
|
||||||
parse_dzengi_websocket_ohlc,
|
|
||||||
parse_dzengi_websocket_quote,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.websocket_trade_adapter import (
|
|
||||||
adapt_websocket_trade_document,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
WebSocketMessageRoutingError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.candle_close import CandleCloseEvent
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.validation.schema import (
|
|
||||||
validate_dzengi_websocket_ohlc_schema,
|
|
||||||
validate_dzengi_websocket_quote_schema,
|
|
||||||
validate_dzengi_websocket_trade_schema,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.validation.values import (
|
|
||||||
validate_dzengi_websocket_ohlc_values,
|
|
||||||
validate_dzengi_websocket_quote_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Преобразует одно декодированное сообщение Dzengi WebSocket в Quote.
|
|
||||||
class DzengiWebSocketQuoteAdapter:
|
|
||||||
def map_message(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
*,
|
|
||||||
received_at: datetime | None = None,
|
|
||||||
) -> Quote:
|
|
||||||
validated = validate_dzengi_websocket_quote_schema(document)
|
|
||||||
response = parse_dzengi_websocket_quote(validated)
|
|
||||||
validate_dzengi_websocket_quote_values(response)
|
|
||||||
|
|
||||||
return map_dzengi_websocket_quote_to_quote(
|
|
||||||
response,
|
|
||||||
received_at=received_at or datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Преобразует одно декодированное сообщение Dzengi WebSocket
|
|
||||||
# в CandleCloseEvent.
|
|
||||||
class DzengiWebSocketOhlcAdapter:
|
|
||||||
def map_message(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
*,
|
|
||||||
received_at: datetime | None = None,
|
|
||||||
) -> CandleCloseEvent:
|
|
||||||
validated = validate_dzengi_websocket_ohlc_schema(document)
|
|
||||||
event = parse_dzengi_websocket_ohlc(validated)
|
|
||||||
validate_dzengi_websocket_ohlc_values(event)
|
|
||||||
|
|
||||||
return map_dzengi_websocket_ohlc_to_candle_close_event(
|
|
||||||
event,
|
|
||||||
received_at=received_at or datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Преобразует одно декодированное сообщение Dzengi WebSocket
|
|
||||||
# internal.trade в каноническую модель Trade.
|
|
||||||
class DzengiWebSocketTradeAdapter:
|
|
||||||
def map_message(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
) -> Trade:
|
|
||||||
validated = validate_dzengi_websocket_trade_schema(
|
|
||||||
document,
|
|
||||||
)
|
|
||||||
|
|
||||||
return adapt_websocket_trade_document(
|
|
||||||
validated,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Определяет тип декодированного сообщения Dzengi WebSocket
|
|
||||||
# и передаёт его соответствующему специализированному адаптеру.
|
|
||||||
class DzengiUnifiedWebSocketAdapter:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
quote_adapter: DzengiWebSocketQuoteAdapter | None = None,
|
|
||||||
ohlc_adapter: DzengiWebSocketOhlcAdapter | None = None,
|
|
||||||
trade_adapter: DzengiWebSocketTradeAdapter | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._quote_adapter = (
|
|
||||||
quote_adapter
|
|
||||||
or DzengiWebSocketQuoteAdapter()
|
|
||||||
)
|
|
||||||
|
|
||||||
self._ohlc_adapter = (
|
|
||||||
ohlc_adapter
|
|
||||||
or DzengiWebSocketOhlcAdapter()
|
|
||||||
)
|
|
||||||
|
|
||||||
self._trade_adapter = (
|
|
||||||
trade_adapter
|
|
||||||
or DzengiWebSocketTradeAdapter()
|
|
||||||
)
|
|
||||||
|
|
||||||
def map_message(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
*,
|
|
||||||
received_at: datetime | None = None,
|
|
||||||
) -> Quote | CandleCloseEvent | Trade:
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Сообщение Dzengi WebSocket должно быть объектом."
|
|
||||||
)
|
|
||||||
|
|
||||||
destination = document.get("destination")
|
|
||||||
|
|
||||||
if destination == "ohlc.event":
|
|
||||||
return self._ohlc_adapter.map_message(
|
|
||||||
document,
|
|
||||||
received_at=received_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
if destination == "internal.trade":
|
|
||||||
return self._trade_adapter.map_message(
|
|
||||||
document,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "Payload" in document:
|
|
||||||
return self._quote_adapter.map_message(
|
|
||||||
document,
|
|
||||||
received_at=received_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Не удалось определить тип сообщения Dzengi WebSocket."
|
|
||||||
)
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
WebSocketControlMessageError,
|
|
||||||
WebSocketMessageRoutingError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_TRADE_SUBSCRIPTION_DESTINATION = "trades.subscribe"
|
|
||||||
_SUCCESS_STATUS = "OK"
|
|
||||||
|
|
||||||
|
|
||||||
class DzengiWebSocketControlMessageHandler:
|
|
||||||
"""
|
|
||||||
Provider-specific проверка Dzengi control/ACK сообщений.
|
|
||||||
|
|
||||||
Handler не управляет lifecycle и не хранит pending requests.
|
|
||||||
Ожидаемый correlation ID передаётся владельцем Runtime.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ()
|
|
||||||
|
|
||||||
def handle(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
*,
|
|
||||||
expected_correlation_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Проверить ACK текущей Trade Stream подписки."""
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Control message Dzengi WebSocket должен быть объектом."
|
|
||||||
)
|
|
||||||
|
|
||||||
correlation_id = document.get("correlationId")
|
|
||||||
|
|
||||||
if correlation_id != expected_correlation_id:
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Получен control message с неизвестным correlationId."
|
|
||||||
)
|
|
||||||
|
|
||||||
status = document.get("status")
|
|
||||||
|
|
||||||
if not isinstance(status, str) or not status.strip():
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Control message Dzengi WebSocket должен содержать "
|
|
||||||
"непустой строковый status."
|
|
||||||
)
|
|
||||||
|
|
||||||
if status != _SUCCESS_STATUS:
|
|
||||||
raise WebSocketControlMessageError(
|
|
||||||
"Dzengi отклонил Trade Stream subscription ACK."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
document.get("destination")
|
|
||||||
!= _TRADE_SUBSCRIPTION_DESTINATION
|
|
||||||
):
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Получен успешный control message с неизвестным "
|
|
||||||
"destination."
|
|
||||||
)
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
WebSocketMessageRoutingError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.runtime.websocket_inbound_message import (
|
|
||||||
WebSocketInboundMessageKind,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_DZENGI_MARKET_DESTINATIONS = frozenset(
|
|
||||||
{
|
|
||||||
"internal.trade",
|
|
||||||
"ohlc.event",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DzengiWebSocketInboundMessageClassifier:
|
|
||||||
"""
|
|
||||||
Классификатор декодированных входящих сообщений Dzengi.
|
|
||||||
|
|
||||||
Market markers имеют приоритет над correlationId, чтобы market
|
|
||||||
document с дополнительными transport metadata не был ошибочно
|
|
||||||
принят за control response.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ()
|
|
||||||
|
|
||||||
def classify(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
) -> WebSocketInboundMessageKind:
|
|
||||||
"""Разделить market documents и ответы на Runtime-команды."""
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Сообщение Dzengi WebSocket должно быть объектом."
|
|
||||||
)
|
|
||||||
|
|
||||||
destination = document.get("destination")
|
|
||||||
|
|
||||||
if (
|
|
||||||
(
|
|
||||||
isinstance(destination, str)
|
|
||||||
and destination in _DZENGI_MARKET_DESTINATIONS
|
|
||||||
)
|
|
||||||
or "Payload" in document
|
|
||||||
):
|
|
||||||
return WebSocketInboundMessageKind.MARKET
|
|
||||||
|
|
||||||
if "correlationId" in document:
|
|
||||||
self._validate_correlation_id(
|
|
||||||
document["correlationId"],
|
|
||||||
)
|
|
||||||
return WebSocketInboundMessageKind.CONTROL
|
|
||||||
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"Не удалось определить категорию сообщения "
|
|
||||||
"Dzengi WebSocket."
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_correlation_id(
|
|
||||||
correlation_id: object,
|
|
||||||
) -> None:
|
|
||||||
if (
|
|
||||||
isinstance(correlation_id, bool)
|
|
||||||
or not isinstance(correlation_id, (str, int))
|
|
||||||
):
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"correlationId сообщения Dzengi WebSocket должен "
|
|
||||||
"быть строкой или целым числом."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
isinstance(correlation_id, str)
|
|
||||||
and not correlation_id.strip()
|
|
||||||
):
|
|
||||||
raise WebSocketMessageRoutingError(
|
|
||||||
"correlationId сообщения Dzengi WebSocket "
|
|
||||||
"не должен быть пустым."
|
|
||||||
)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# src/market_data/acquisition/adapters/dzengi/websocket_trade_adapter.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
|
||||||
map_dzengi_websocket_trade_to_trade,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
|
||||||
parse_dzengi_websocket_trade,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.validation.schema import (
|
|
||||||
ValidatedWebSocketTradeDocument,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.validation.values import (
|
|
||||||
validate_dzengi_websocket_trade_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def adapt_websocket_trade_document(
|
|
||||||
document: ValidatedWebSocketTradeDocument,
|
|
||||||
) -> Trade:
|
|
||||||
"""
|
|
||||||
Преобразовать структурно проверенный документ Dzengi WebSocket Trade
|
|
||||||
в каноническую immutable-модель Trade.
|
|
||||||
|
|
||||||
Функция последовательно выполняет parsing, value validation
|
|
||||||
и mapping. Schema validation должна быть выполнена вызывающим слоем.
|
|
||||||
Исключения отдельных этапов не перехватываются и не оборачиваются.
|
|
||||||
"""
|
|
||||||
|
|
||||||
event = parse_dzengi_websocket_trade(document)
|
|
||||||
|
|
||||||
validate_dzengi_websocket_trade_values(event)
|
|
||||||
|
|
||||||
return map_dzengi_websocket_trade_to_trade(event)
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import math
|
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
|
||||||
from typing import Protocol, cast
|
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
|
||||||
|
|
||||||
from websockets.asyncio.client import connect as websocket_connect
|
|
||||||
from websockets.protocol import State
|
|
||||||
from websockets.typing import Subprotocol
|
|
||||||
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
WebSocketTransportError,
|
|
||||||
WebSocketTransportNotConnectedError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _WebSocketConnectionProtocol(Protocol):
|
|
||||||
"""
|
|
||||||
Минимальный контракт соединения.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self) -> State:
|
|
||||||
"""Вернуть состояние WebSocket-соединения."""
|
|
||||||
...
|
|
||||||
|
|
||||||
async def close(
|
|
||||||
self,
|
|
||||||
code: int = 1000,
|
|
||||||
reason: str = "",
|
|
||||||
) -> None:
|
|
||||||
"""Закрыть WebSocket-соединение."""
|
|
||||||
...
|
|
||||||
|
|
||||||
async def send(
|
|
||||||
self,
|
|
||||||
message: str | bytes,
|
|
||||||
) -> None:
|
|
||||||
"""Отправить одно сообщение."""
|
|
||||||
...
|
|
||||||
|
|
||||||
async def recv(self) -> str | bytes:
|
|
||||||
"""Получить одно сообщение."""
|
|
||||||
...
|
|
||||||
|
|
||||||
async def ping(self) -> Awaitable[float]:
|
|
||||||
"""Отправить Ping и вернуть ожидание соответствующего Pong."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
WebSocketConnector = Callable[
|
|
||||||
...,
|
|
||||||
Awaitable[_WebSocketConnectionProtocol],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_dzengi_websocket_url(
|
|
||||||
raw_url: str,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Нормализовать Dzengi HTTP/WebSocket URL до endpoint ``/connect``.
|
|
||||||
"""
|
|
||||||
if not isinstance(raw_url, str):
|
|
||||||
raise TypeError("raw_url must be a string")
|
|
||||||
|
|
||||||
normalized_url = raw_url.strip()
|
|
||||||
|
|
||||||
if not normalized_url:
|
|
||||||
raise ValueError("raw_url must not be empty")
|
|
||||||
|
|
||||||
parsed = urlsplit(normalized_url)
|
|
||||||
|
|
||||||
scheme = {
|
|
||||||
"http": "ws",
|
|
||||||
"https": "wss",
|
|
||||||
"ws": "ws",
|
|
||||||
"wss": "wss",
|
|
||||||
}.get(parsed.scheme.lower())
|
|
||||||
|
|
||||||
if scheme is None or not parsed.netloc:
|
|
||||||
raise ValueError(
|
|
||||||
"raw_url must be an absolute HTTP or WebSocket URL"
|
|
||||||
)
|
|
||||||
|
|
||||||
if parsed.fragment:
|
|
||||||
raise ValueError("raw_url must not contain a fragment")
|
|
||||||
|
|
||||||
path = parsed.path.rstrip("/")
|
|
||||||
|
|
||||||
if not path.endswith("/connect"):
|
|
||||||
path = f"{path}/connect"
|
|
||||||
|
|
||||||
return urlunsplit(
|
|
||||||
(
|
|
||||||
scheme,
|
|
||||||
parsed.netloc,
|
|
||||||
path,
|
|
||||||
parsed.query,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DzengiWebSocketTransport:
|
|
||||||
"""
|
|
||||||
Низкоуровневый WebSocket-транспорт Dzengi.
|
|
||||||
|
|
||||||
Transport управляет только соединением и непрозрачными
|
|
||||||
``str``/``bytes`` сообщениями. JSON parsing, subscription routing,
|
|
||||||
recovery и lifecycle находятся за пределами этого компонента.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"_url",
|
|
||||||
"_headers",
|
|
||||||
"_open_timeout",
|
|
||||||
"_ping_interval",
|
|
||||||
"_ping_timeout",
|
|
||||||
"_probe_timeout",
|
|
||||||
"_close_timeout",
|
|
||||||
"_connector",
|
|
||||||
"_connection",
|
|
||||||
"_lifecycle_lock",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
url: str,
|
|
||||||
headers: Mapping[str, str] | None = None,
|
|
||||||
open_timeout: float | None = 10.0,
|
|
||||||
ping_interval: float | None = 20.0,
|
|
||||||
ping_timeout: float | None = 20.0,
|
|
||||||
probe_timeout: float | None = None,
|
|
||||||
close_timeout: float | None = 10.0,
|
|
||||||
connector: WebSocketConnector | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._url = build_dzengi_websocket_url(url)
|
|
||||||
self._headers = self._validate_headers(headers)
|
|
||||||
self._open_timeout = self._validate_timeout(
|
|
||||||
"open_timeout",
|
|
||||||
open_timeout,
|
|
||||||
)
|
|
||||||
self._ping_interval = self._validate_timeout(
|
|
||||||
"ping_interval",
|
|
||||||
ping_interval,
|
|
||||||
)
|
|
||||||
self._ping_timeout = self._validate_timeout(
|
|
||||||
"ping_timeout",
|
|
||||||
ping_timeout,
|
|
||||||
)
|
|
||||||
self._probe_timeout = self._validate_probe_timeout(
|
|
||||||
(
|
|
||||||
probe_timeout
|
|
||||||
if probe_timeout is not None
|
|
||||||
else (
|
|
||||||
ping_timeout
|
|
||||||
if ping_timeout is not None
|
|
||||||
else 20.0
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self._close_timeout = self._validate_timeout(
|
|
||||||
"close_timeout",
|
|
||||||
close_timeout,
|
|
||||||
)
|
|
||||||
self._connector = (
|
|
||||||
connector
|
|
||||||
if connector is not None
|
|
||||||
else cast(
|
|
||||||
WebSocketConnector,
|
|
||||||
websocket_connect,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._connection: _WebSocketConnectionProtocol | None = None
|
|
||||||
self._lifecycle_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url(self) -> str:
|
|
||||||
"""Вернуть нормализованный WebSocket URL."""
|
|
||||||
return self._url
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
"""Показывает, открыто ли соединение."""
|
|
||||||
connection = self._connection
|
|
||||||
|
|
||||||
return (
|
|
||||||
connection is not None
|
|
||||||
and connection.state is State.OPEN
|
|
||||||
)
|
|
||||||
|
|
||||||
async def connect(self) -> None:
|
|
||||||
"""
|
|
||||||
Идемпотентно открыть WebSocket-соединение Dzengi.
|
|
||||||
"""
|
|
||||||
async with self._lifecycle_lock:
|
|
||||||
if self.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._connection = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
connection = await self._connector(
|
|
||||||
self._url,
|
|
||||||
additional_headers=(
|
|
||||||
self._headers
|
|
||||||
if self._headers
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
subprotocols=(
|
|
||||||
Subprotocol("json"),
|
|
||||||
),
|
|
||||||
open_timeout=self._open_timeout,
|
|
||||||
ping_interval=self._ping_interval,
|
|
||||||
ping_timeout=self._ping_timeout,
|
|
||||||
close_timeout=self._close_timeout,
|
|
||||||
)
|
|
||||||
except Exception as error:
|
|
||||||
raise WebSocketTransportError(
|
|
||||||
"Не удалось открыть Dzengi WebSocket connection: "
|
|
||||||
f"{error}"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
self._connection = connection
|
|
||||||
|
|
||||||
if self.is_connected:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._connection = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
await connection.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
raise WebSocketTransportError(
|
|
||||||
"Dzengi WebSocket connection не перешло "
|
|
||||||
"в состояние OPEN."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""
|
|
||||||
Идемпотентно закрыть WebSocket-соединение.
|
|
||||||
"""
|
|
||||||
async with self._lifecycle_lock:
|
|
||||||
connection = self._connection
|
|
||||||
self._connection = None
|
|
||||||
|
|
||||||
if connection is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await connection.close()
|
|
||||||
except Exception as error:
|
|
||||||
raise WebSocketTransportError(
|
|
||||||
"Не удалось закрыть Dzengi WebSocket connection: "
|
|
||||||
f"{error}"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
async def send(
|
|
||||||
self,
|
|
||||||
message: str | bytes,
|
|
||||||
) -> None:
|
|
||||||
"""Отправить непрозрачное транспортное сообщение."""
|
|
||||||
if not isinstance(message, (str, bytes)):
|
|
||||||
raise TypeError("message must be str or bytes")
|
|
||||||
|
|
||||||
connection = self._require_connection()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await connection.send(message)
|
|
||||||
except Exception as error:
|
|
||||||
self._discard_closed_connection(connection)
|
|
||||||
|
|
||||||
raise WebSocketTransportError(
|
|
||||||
"Не удалось отправить Dzengi WebSocket message: "
|
|
||||||
f"{error}"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
async def receive(self) -> str | bytes:
|
|
||||||
"""Получить непрозрачное транспортное сообщение."""
|
|
||||||
connection = self._require_connection()
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await connection.recv()
|
|
||||||
except Exception as error:
|
|
||||||
self._discard_closed_connection(connection)
|
|
||||||
|
|
||||||
raise WebSocketTransportError(
|
|
||||||
"Не удалось получить Dzengi WebSocket message: "
|
|
||||||
f"{error}"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
async def probe(self) -> bool:
|
|
||||||
"""
|
|
||||||
Подтвердить liveness текущего connection через Ping/Pong.
|
|
||||||
|
|
||||||
Закрытое соединение и отсутствие Pong являются штатным
|
|
||||||
отрицательным результатом. Cancellation не перехватывается.
|
|
||||||
"""
|
|
||||||
connection = self._connection
|
|
||||||
|
|
||||||
if (
|
|
||||||
connection is None
|
|
||||||
or connection.state is not State.OPEN
|
|
||||||
):
|
|
||||||
self._connection = None
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
pong_waiter = await connection.ping()
|
|
||||||
|
|
||||||
await asyncio.wait_for(
|
|
||||||
asyncio.shield(
|
|
||||||
pong_waiter,
|
|
||||||
),
|
|
||||||
timeout=self._probe_timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return False
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
self._discard_closed_connection(connection)
|
|
||||||
|
|
||||||
if connection.state is not State.OPEN:
|
|
||||||
return False
|
|
||||||
|
|
||||||
raise WebSocketTransportError(
|
|
||||||
"Не удалось проверить Dzengi WebSocket liveness: "
|
|
||||||
f"{error}"
|
|
||||||
) from error
|
|
||||||
|
|
||||||
return (
|
|
||||||
self._connection is connection
|
|
||||||
and connection.state is State.OPEN
|
|
||||||
)
|
|
||||||
|
|
||||||
def _require_connection(
|
|
||||||
self,
|
|
||||||
) -> _WebSocketConnectionProtocol:
|
|
||||||
"""
|
|
||||||
Вернуть открытое соединение.
|
|
||||||
"""
|
|
||||||
connection = self._connection
|
|
||||||
|
|
||||||
if (
|
|
||||||
connection is None
|
|
||||||
or connection.state is not State.OPEN
|
|
||||||
):
|
|
||||||
self._connection = None
|
|
||||||
|
|
||||||
raise WebSocketTransportNotConnectedError(
|
|
||||||
"Dzengi WebSocket transport is not connected."
|
|
||||||
)
|
|
||||||
|
|
||||||
return connection
|
|
||||||
|
|
||||||
def _discard_closed_connection(
|
|
||||||
self,
|
|
||||||
connection: _WebSocketConnectionProtocol,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Удалить ссылку на закрытое соединение.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
self._connection is connection
|
|
||||||
and connection.state is not State.OPEN
|
|
||||||
):
|
|
||||||
self._connection = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_headers(
|
|
||||||
headers: Mapping[str, str] | None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
if headers is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if not isinstance(headers, Mapping):
|
|
||||||
raise TypeError("headers must be a mapping")
|
|
||||||
|
|
||||||
normalized_headers: dict[str, str] = {}
|
|
||||||
|
|
||||||
for name, value in headers.items():
|
|
||||||
if not isinstance(name, str) or not name.strip():
|
|
||||||
raise ValueError(
|
|
||||||
"header names must be non-empty strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise TypeError("header values must be strings")
|
|
||||||
|
|
||||||
normalized_headers[name] = value
|
|
||||||
|
|
||||||
return normalized_headers
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_timeout(
|
|
||||||
name: str,
|
|
||||||
value: float | None,
|
|
||||||
) -> float | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if (
|
|
||||||
isinstance(value, bool)
|
|
||||||
or not isinstance(value, (int, float))
|
|
||||||
):
|
|
||||||
raise TypeError(
|
|
||||||
f"{name} must be an integer, float, or None"
|
|
||||||
)
|
|
||||||
|
|
||||||
if value <= 0:
|
|
||||||
raise ValueError(
|
|
||||||
f"{name} must be positive"
|
|
||||||
)
|
|
||||||
|
|
||||||
return float(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_probe_timeout(
|
|
||||||
value: float,
|
|
||||||
) -> float:
|
|
||||||
if (
|
|
||||||
isinstance(value, bool)
|
|
||||||
or not isinstance(value, (int, float))
|
|
||||||
):
|
|
||||||
raise TypeError(
|
|
||||||
"probe_timeout must be an integer or float"
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized_value = float(value)
|
|
||||||
|
|
||||||
if (
|
|
||||||
not math.isfinite(normalized_value)
|
|
||||||
or normalized_value <= 0
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"probe_timeout must be positive and finite"
|
|
||||||
)
|
|
||||||
|
|
||||||
return normalized_value
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""Восстановление persistent Trade Stream checkpoint."""
|
|
||||||
|
|
||||||
from src.market_data.acquisition.checkpoint.trade_stream_state_hydrator import (
|
|
||||||
TradeStreamStateHydrator,
|
|
||||||
TradeStreamStateHydratorProtocol,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
"TradeStreamStateHydrator",
|
|
||||||
"TradeStreamStateHydratorProtocol",
|
|
||||||
)
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
|
||||||
TradeConsistencyError,
|
|
||||||
TradeOrderingError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
|
||||||
DEFAULT_DEDUPLICATION_WINDOW_SIZE,
|
|
||||||
TradeStreamState,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
|
||||||
TradeStreamStateStoreInitializationError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
|
||||||
TradeStreamStateStoreProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.symbols import normalize_symbol
|
|
||||||
from src.market_data.storage.contracts import (
|
|
||||||
PersistentTradeCheckpoint,
|
|
||||||
TradeCheckpointStorageProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.storage.exceptions import (
|
|
||||||
MarketDataCheckpointIntegrityError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class TradeStreamStateHydratorProtocol(Protocol):
|
|
||||||
"""Контракт восстановления Consistency state до запуска Runtime."""
|
|
||||||
|
|
||||||
def hydrate(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
symbols: tuple[str, ...],
|
|
||||||
) -> tuple[TradeStreamState, ...]:
|
|
||||||
"""Восстановить и атомарно опубликовать состояния symbols."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class TradeStreamStateHydrator:
|
|
||||||
"""Восстанавливает TradeStreamState из persistent checkpoint."""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"_checkpoint_storage",
|
|
||||||
"_state_store",
|
|
||||||
"_venue",
|
|
||||||
"_deduplication_window_size",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
checkpoint_storage: TradeCheckpointStorageProtocol,
|
|
||||||
state_store: TradeStreamStateStoreProtocol,
|
|
||||||
venue: str,
|
|
||||||
deduplication_window_size: int = (
|
|
||||||
DEFAULT_DEDUPLICATION_WINDOW_SIZE
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(
|
|
||||||
checkpoint_storage,
|
|
||||||
TradeCheckpointStorageProtocol,
|
|
||||||
):
|
|
||||||
raise TypeError(
|
|
||||||
"checkpoint_storage must implement "
|
|
||||||
"TradeCheckpointStorageProtocol"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(state_store, TradeStreamStateStoreProtocol):
|
|
||||||
raise TypeError(
|
|
||||||
"state_store must implement TradeStreamStateStoreProtocol"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(venue, str):
|
|
||||||
raise TypeError("venue must be a string")
|
|
||||||
|
|
||||||
normalized_venue = venue.strip().lower()
|
|
||||||
|
|
||||||
if not normalized_venue:
|
|
||||||
raise ValueError("venue must not be empty")
|
|
||||||
|
|
||||||
if (
|
|
||||||
isinstance(deduplication_window_size, bool)
|
|
||||||
or not isinstance(deduplication_window_size, int)
|
|
||||||
):
|
|
||||||
raise TypeError(
|
|
||||||
"deduplication_window_size must be an integer"
|
|
||||||
)
|
|
||||||
|
|
||||||
if deduplication_window_size <= 0:
|
|
||||||
raise ValueError(
|
|
||||||
"deduplication_window_size must be positive"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._checkpoint_storage = checkpoint_storage
|
|
||||||
self._state_store = state_store
|
|
||||||
self._venue = normalized_venue
|
|
||||||
self._deduplication_window_size = deduplication_window_size
|
|
||||||
|
|
||||||
def hydrate(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
symbols: tuple[str, ...],
|
|
||||||
) -> tuple[TradeStreamState, ...]:
|
|
||||||
normalized_symbols = self._normalize_symbols(symbols)
|
|
||||||
|
|
||||||
if not self._state_store.is_empty():
|
|
||||||
raise TradeStreamStateStoreInitializationError(
|
|
||||||
"Trade Stream State Store уже содержит состояния."
|
|
||||||
)
|
|
||||||
|
|
||||||
states = tuple(
|
|
||||||
self._prepare_state(symbol)
|
|
||||||
for symbol in normalized_symbols
|
|
||||||
)
|
|
||||||
self._state_store.initialize(states)
|
|
||||||
return states
|
|
||||||
|
|
||||||
def _prepare_state(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
checkpoint = self._checkpoint_storage.load_checkpoint(
|
|
||||||
venue=self._venue,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
if checkpoint is not None:
|
|
||||||
return self._state_from_checkpoint(
|
|
||||||
symbol=symbol,
|
|
||||||
checkpoint=checkpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
latest_tail = self._checkpoint_storage.load_latest_trade_tail(
|
|
||||||
venue=self._venue,
|
|
||||||
symbol=symbol,
|
|
||||||
limit=self._deduplication_window_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(latest_tail, tuple):
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Checkpoint storage returned non-tuple latest Trade tail."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not latest_tail:
|
|
||||||
concurrent_checkpoint = (
|
|
||||||
self._checkpoint_storage.load_checkpoint(
|
|
||||||
venue=self._venue,
|
|
||||||
symbol=symbol,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if concurrent_checkpoint is not None:
|
|
||||||
return self._state_from_checkpoint(
|
|
||||||
symbol=symbol,
|
|
||||||
checkpoint=concurrent_checkpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
return TradeStreamState.from_history(
|
|
||||||
symbol=symbol,
|
|
||||||
trades=(),
|
|
||||||
deduplication_window_size=(
|
|
||||||
self._deduplication_window_size
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
candidate_state = self._state_from_tail(
|
|
||||||
symbol=symbol,
|
|
||||||
trades=latest_tail,
|
|
||||||
)
|
|
||||||
candidate = candidate_state.last_trade
|
|
||||||
|
|
||||||
if candidate is None:
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Latest Trade tail unexpectedly produced empty state."
|
|
||||||
)
|
|
||||||
|
|
||||||
adopted = (
|
|
||||||
self._checkpoint_storage.adopt_existing_trade_as_checkpoint(
|
|
||||||
venue=self._venue,
|
|
||||||
trade=candidate,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._validate_checkpoint(
|
|
||||||
checkpoint=adopted,
|
|
||||||
symbol=symbol,
|
|
||||||
expected_trade=candidate,
|
|
||||||
)
|
|
||||||
return self._state_from_checkpoint(
|
|
||||||
symbol=symbol,
|
|
||||||
checkpoint=adopted,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _state_from_checkpoint(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
checkpoint: PersistentTradeCheckpoint,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
self._validate_checkpoint(
|
|
||||||
checkpoint=checkpoint,
|
|
||||||
symbol=symbol,
|
|
||||||
expected_trade=None,
|
|
||||||
)
|
|
||||||
tail = self._checkpoint_storage.load_checkpoint_tail(
|
|
||||||
venue=self._venue,
|
|
||||||
checkpoint=checkpoint,
|
|
||||||
limit=self._deduplication_window_size,
|
|
||||||
)
|
|
||||||
state = self._state_from_tail(
|
|
||||||
symbol=symbol,
|
|
||||||
trades=tail,
|
|
||||||
)
|
|
||||||
|
|
||||||
if state.last_trade is None or not self._same_market_trade(
|
|
||||||
state.last_trade,
|
|
||||||
checkpoint.trade,
|
|
||||||
):
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Hydrated tail does not end at persistent checkpoint."
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
def _state_from_tail(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
trades: object,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
if not isinstance(trades, tuple):
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Checkpoint storage returned non-tuple Trade tail."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return TradeStreamState.from_history(
|
|
||||||
symbol=symbol,
|
|
||||||
trades=trades,
|
|
||||||
deduplication_window_size=(
|
|
||||||
self._deduplication_window_size
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except (
|
|
||||||
TypeError,
|
|
||||||
ValueError,
|
|
||||||
TradeConsistencyError,
|
|
||||||
TradeOrderingError,
|
|
||||||
) as error:
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Persistent Trade tail cannot hydrate Consistency state."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
def _validate_checkpoint(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
checkpoint: object,
|
|
||||||
symbol: str,
|
|
||||||
expected_trade: Trade | None,
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(checkpoint, PersistentTradeCheckpoint):
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Checkpoint storage returned invalid checkpoint."
|
|
||||||
)
|
|
||||||
|
|
||||||
if checkpoint.venue.strip().lower() != self._venue:
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Persistent checkpoint venue does not match hydrator."
|
|
||||||
)
|
|
||||||
|
|
||||||
if checkpoint.trade.symbol.strip().upper() != symbol:
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Persistent checkpoint symbol does not match request."
|
|
||||||
)
|
|
||||||
|
|
||||||
if expected_trade is not None and not self._same_market_trade(
|
|
||||||
checkpoint.trade,
|
|
||||||
expected_trade,
|
|
||||||
):
|
|
||||||
raise MarketDataCheckpointIntegrityError(
|
|
||||||
"Adopted checkpoint does not match candidate Trade."
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _same_market_trade(
|
|
||||||
first: Trade,
|
|
||||||
second: Trade,
|
|
||||||
) -> bool:
|
|
||||||
return (
|
|
||||||
first.symbol == second.symbol
|
|
||||||
and first.trade_id == second.trade_id
|
|
||||||
and first.executed_at == second.executed_at
|
|
||||||
and first.price == second.price
|
|
||||||
and first.quantity == second.quantity
|
|
||||||
and first.aggressor_side is second.aggressor_side
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_symbols(
|
|
||||||
symbols: tuple[str, ...],
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
if not isinstance(symbols, tuple):
|
|
||||||
raise TypeError("symbols must be a tuple")
|
|
||||||
|
|
||||||
if not symbols:
|
|
||||||
raise ValueError("symbols must not be empty")
|
|
||||||
|
|
||||||
normalized: list[str] = []
|
|
||||||
|
|
||||||
for symbol in symbols:
|
|
||||||
if not isinstance(symbol, str):
|
|
||||||
raise TypeError("symbols must contain only strings")
|
|
||||||
|
|
||||||
value = normalize_symbol(symbol)
|
|
||||||
|
|
||||||
if not value:
|
|
||||||
raise ValueError("symbols must not contain empty values")
|
|
||||||
|
|
||||||
if value in normalized:
|
|
||||||
raise ValueError("symbols must not contain duplicates")
|
|
||||||
|
|
||||||
normalized.append(value)
|
|
||||||
|
|
||||||
return tuple(normalized)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/__init__.py
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class TradeObservationSinkProtocol(Protocol):
|
|
||||||
"""Надёжный приёмник одного наблюдения канонической сделки."""
|
|
||||||
|
|
||||||
def persist_accepted(
|
|
||||||
self,
|
|
||||||
trade: Trade,
|
|
||||||
*,
|
|
||||||
expected_trade: Trade | None,
|
|
||||||
) -> None:
|
|
||||||
"""Сохранить новую Trade и атомарно продвинуть checkpoint."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def persist_duplicate(
|
|
||||||
self,
|
|
||||||
trade: Trade,
|
|
||||||
) -> None:
|
|
||||||
"""Сохранить provenance дубликата без продвижения checkpoint."""
|
|
||||||
...
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/trade_stream_consistency_controller.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.consistency.trade_observation_sink_protocol import (
|
|
||||||
TradeObservationSinkProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_protocol import (
|
|
||||||
TradeStreamConsistencyProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
|
||||||
TradeStreamState,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
|
||||||
TradeStreamStateStoreProtocol,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
|
|
||||||
|
|
||||||
class TradeStreamConsistencyController(
|
|
||||||
TradeStreamConsistencyProtocol,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Контроллер проверки согласованности Canonical Trade Stream.
|
|
||||||
|
|
||||||
Для каждого торгового инструмента используется
|
|
||||||
специализированное хранилище состояний
|
|
||||||
Trade Stream Consistency.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
state_store: TradeStreamStateStoreProtocol,
|
|
||||||
*,
|
|
||||||
trade_observation_sink: TradeObservationSinkProtocol | None = None,
|
|
||||||
) -> None:
|
|
||||||
if (
|
|
||||||
trade_observation_sink is not None
|
|
||||||
and not isinstance(
|
|
||||||
trade_observation_sink,
|
|
||||||
TradeObservationSinkProtocol,
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise TypeError(
|
|
||||||
"trade_observation_sink must implement "
|
|
||||||
"TradeObservationSinkProtocol"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._state_store = state_store
|
|
||||||
self._trade_observation_sink = trade_observation_sink
|
|
||||||
|
|
||||||
def accept(
|
|
||||||
self,
|
|
||||||
trade: Trade,
|
|
||||||
) -> Trade | None:
|
|
||||||
state = self._get_state(trade.symbol)
|
|
||||||
|
|
||||||
sink = self._trade_observation_sink
|
|
||||||
|
|
||||||
return state.accept(
|
|
||||||
trade,
|
|
||||||
before_checkpoint=(
|
|
||||||
sink.persist_accepted
|
|
||||||
if sink is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
on_duplicate=(
|
|
||||||
sink.persist_duplicate
|
|
||||||
if sink is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_state(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
"""
|
|
||||||
Возвращает состояние проверки Trade Stream
|
|
||||||
для указанного торгового инструмента.
|
|
||||||
|
|
||||||
При первом обращении состояние автоматически
|
|
||||||
создаётся специализированным хранилищем.
|
|
||||||
"""
|
|
||||||
return self._state_store.get_or_create(symbol)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/trade_stream_exceptions.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.exceptions import (
|
|
||||||
MarketDataAcquisitionError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Нарушение монотонности Canonical Trade Stream.
|
|
||||||
class TradeOrderingError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Обнаружен конфликтующий дубликат сделки.
|
|
||||||
class TradeConsistencyError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/trade_stream_protocol.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
|
|
||||||
|
|
||||||
# Контракт проверки согласованности Canonical Trade Stream.
|
|
||||||
@runtime_checkable
|
|
||||||
class TradeStreamConsistencyProtocol(Protocol):
|
|
||||||
def accept(
|
|
||||||
self,
|
|
||||||
trade: Trade,
|
|
||||||
) -> Trade | None:
|
|
||||||
"""
|
|
||||||
Проверить согласованность поступившей сделки.
|
|
||||||
|
|
||||||
Возвращает исходную модель Trade, если сделка принята.
|
|
||||||
|
|
||||||
Возвращает None, если обнаружен полный дубликат.
|
|
||||||
|
|
||||||
Генерирует TradeOrderingError при нарушении порядка.
|
|
||||||
|
|
||||||
Генерирует TradeConsistencyError при обнаружении
|
|
||||||
конфликтующего дубликата.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/trade_stream_state.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import deque
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Protocol, Self
|
|
||||||
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_exceptions import (
|
|
||||||
TradeConsistencyError,
|
|
||||||
TradeOrderingError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.trade_id_sequence import (
|
|
||||||
is_trade_id_newer,
|
|
||||||
validate_signed_trade_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_DEDUPLICATION_WINDOW_SIZE = 10_000
|
|
||||||
TradeDuplicateCallback = Callable[[Trade], None]
|
|
||||||
|
|
||||||
|
|
||||||
class TradeCheckpointCallback(Protocol):
|
|
||||||
"""Callback атомарной записи перед in-memory checkpoint."""
|
|
||||||
|
|
||||||
def __call__(
|
|
||||||
self,
|
|
||||||
trade: Trade,
|
|
||||||
*,
|
|
||||||
expected_trade: Trade | None,
|
|
||||||
) -> None:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class TradeStreamState:
|
|
||||||
"""
|
|
||||||
Состояние проверки согласованности Canonical Trade Stream
|
|
||||||
для одного торгового инструмента.
|
|
||||||
"""
|
|
||||||
|
|
||||||
symbol: str
|
|
||||||
deduplication_window_size: int = DEFAULT_DEDUPLICATION_WINDOW_SIZE
|
|
||||||
|
|
||||||
last_trade_id: int | None = None
|
|
||||||
last_trade: Trade | None = None
|
|
||||||
|
|
||||||
_trade_window: deque[int] = field(init=False, repr=False)
|
|
||||||
_trades: dict[int, Trade] = field(init=False, repr=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not self.symbol:
|
|
||||||
raise ValueError("symbol must not be empty")
|
|
||||||
|
|
||||||
if self.deduplication_window_size <= 0:
|
|
||||||
raise ValueError(
|
|
||||||
"deduplication_window_size must be positive"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._trade_window = deque(
|
|
||||||
maxlen=self.deduplication_window_size,
|
|
||||||
)
|
|
||||||
self._trades = {}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_history(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
trades: tuple[Trade, ...],
|
|
||||||
deduplication_window_size: int = (
|
|
||||||
DEFAULT_DEDUPLICATION_WINDOW_SIZE
|
|
||||||
),
|
|
||||||
) -> Self:
|
|
||||||
"""Строго восстановить временное состояние из durable tail."""
|
|
||||||
if not isinstance(trades, tuple):
|
|
||||||
raise TypeError("trades must be a tuple")
|
|
||||||
|
|
||||||
if len(trades) > deduplication_window_size:
|
|
||||||
raise ValueError(
|
|
||||||
"trades must fit deduplication_window_size"
|
|
||||||
)
|
|
||||||
|
|
||||||
state = cls(
|
|
||||||
symbol=symbol,
|
|
||||||
deduplication_window_size=deduplication_window_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
for trade in trades:
|
|
||||||
if not isinstance(trade, Trade):
|
|
||||||
raise TypeError("trades must contain only Trade")
|
|
||||||
|
|
||||||
if state.accept(trade) is None:
|
|
||||||
raise TradeConsistencyError(
|
|
||||||
"Hydration history contains a duplicate Trade."
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
def accept(
|
|
||||||
self,
|
|
||||||
trade: Trade,
|
|
||||||
*,
|
|
||||||
before_checkpoint: TradeCheckpointCallback | None = None,
|
|
||||||
on_duplicate: TradeDuplicateCallback | None = None,
|
|
||||||
) -> Trade | None:
|
|
||||||
"""
|
|
||||||
Проверить сделку на согласованность.
|
|
||||||
|
|
||||||
Возвращает принятую сделку.
|
|
||||||
|
|
||||||
Возвращает None при полном дубликате.
|
|
||||||
|
|
||||||
Генерирует исключение при нарушении порядка
|
|
||||||
либо конфликтующем дубликате.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if before_checkpoint is not None and not callable(
|
|
||||||
before_checkpoint
|
|
||||||
):
|
|
||||||
raise TypeError("before_checkpoint must be callable")
|
|
||||||
|
|
||||||
if on_duplicate is not None and not callable(on_duplicate):
|
|
||||||
raise TypeError("on_duplicate must be callable")
|
|
||||||
|
|
||||||
if trade.symbol != self.symbol:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected symbol: {trade.symbol!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
trade_id = trade.trade_id
|
|
||||||
validate_signed_trade_id(trade_id)
|
|
||||||
|
|
||||||
if self.last_trade_id is not None:
|
|
||||||
if not is_trade_id_newer(
|
|
||||||
trade_id,
|
|
||||||
self.last_trade_id,
|
|
||||||
) and trade_id != self.last_trade_id:
|
|
||||||
previous = self._trades.get(trade_id)
|
|
||||||
|
|
||||||
if previous is None:
|
|
||||||
raise TradeOrderingError()
|
|
||||||
|
|
||||||
if self._is_same_market_trade(
|
|
||||||
previous,
|
|
||||||
trade,
|
|
||||||
):
|
|
||||||
self._run_duplicate_observation(
|
|
||||||
trade,
|
|
||||||
callback=on_duplicate,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
raise TradeConsistencyError()
|
|
||||||
|
|
||||||
previous = self._trades.get(trade_id)
|
|
||||||
|
|
||||||
if previous is not None:
|
|
||||||
if self._is_same_market_trade(
|
|
||||||
previous,
|
|
||||||
trade,
|
|
||||||
):
|
|
||||||
self._run_duplicate_observation(
|
|
||||||
trade,
|
|
||||||
callback=on_duplicate,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
raise TradeConsistencyError()
|
|
||||||
|
|
||||||
self._run_before_checkpoint(
|
|
||||||
trade,
|
|
||||||
expected_trade=self.last_trade,
|
|
||||||
callback=before_checkpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._append(trade)
|
|
||||||
|
|
||||||
self.last_trade_id = trade_id
|
|
||||||
self.last_trade = trade
|
|
||||||
|
|
||||||
return trade
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _run_before_checkpoint(
|
|
||||||
trade: Trade,
|
|
||||||
*,
|
|
||||||
expected_trade: Trade | None,
|
|
||||||
callback: TradeCheckpointCallback | None,
|
|
||||||
) -> None:
|
|
||||||
if callback is not None:
|
|
||||||
callback(
|
|
||||||
trade,
|
|
||||||
expected_trade=expected_trade,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _run_duplicate_observation(
|
|
||||||
trade: Trade,
|
|
||||||
*,
|
|
||||||
callback: TradeDuplicateCallback | None,
|
|
||||||
) -> None:
|
|
||||||
if callback is not None:
|
|
||||||
callback(trade)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_same_market_trade(
|
|
||||||
first: Trade,
|
|
||||||
second: Trade,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Сравнить биржевой факт независимо от transport source.
|
|
||||||
|
|
||||||
Одна сделка закономерно приходит через WebSocket и повторяется
|
|
||||||
через REST recovery. Поле ``source`` описывает путь доставки и
|
|
||||||
поэтому не является частью identity биржевой сделки.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
first.symbol == second.symbol
|
|
||||||
and first.trade_id == second.trade_id
|
|
||||||
and first.price == second.price
|
|
||||||
and first.quantity == second.quantity
|
|
||||||
and first.executed_at == second.executed_at
|
|
||||||
and first.aggressor_side is second.aggressor_side
|
|
||||||
)
|
|
||||||
|
|
||||||
def _append(
|
|
||||||
self,
|
|
||||||
trade: Trade,
|
|
||||||
) -> None:
|
|
||||||
if len(self._trade_window) == self.deduplication_window_size:
|
|
||||||
oldest_trade_id = self._trade_window[0]
|
|
||||||
self._trades.pop(oldest_trade_id, None)
|
|
||||||
|
|
||||||
self._trade_window.append(trade.trade_id)
|
|
||||||
self._trades[trade.trade_id] = trade
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/trade_stream_state_store.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""
|
|
||||||
Хранилище состояний Trade Stream Consistency.
|
|
||||||
|
|
||||||
Build 060.20.1 переносит владение инфраструктурным состоянием
|
|
||||||
из общего Runtime Registry в специализированное хранилище
|
|
||||||
подсистемы проверки согласованности потока сделок.
|
|
||||||
|
|
||||||
Хранилище отвечает исключительно за жизненный цикл объектов
|
|
||||||
TradeStreamState и не содержит логики проверки последовательности
|
|
||||||
или восстановления потока сделок.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
|
||||||
TradeStreamState,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state_store_exceptions import (
|
|
||||||
TradeStreamStateNotFoundError,
|
|
||||||
TradeStreamStateStoreInitializationError,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state_store_protocol import (
|
|
||||||
TradeStreamStateStoreProtocol,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TradeStreamStateStore(TradeStreamStateStoreProtocol):
|
|
||||||
"""
|
|
||||||
Хранилище состояний Trade Stream.
|
|
||||||
|
|
||||||
Для каждого торгового инструмента существует
|
|
||||||
единственный экземпляр TradeStreamState.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""
|
|
||||||
Создаёт пустое хранилище состояний.
|
|
||||||
"""
|
|
||||||
self._states: dict[str, TradeStreamState] = {}
|
|
||||||
self._initialized = False
|
|
||||||
|
|
||||||
def initialize(
|
|
||||||
self,
|
|
||||||
states: tuple[TradeStreamState, ...],
|
|
||||||
) -> None:
|
|
||||||
"""Однократно опубликовать полностью подготовленный набор."""
|
|
||||||
if not isinstance(states, tuple):
|
|
||||||
raise TypeError("states must be a tuple")
|
|
||||||
|
|
||||||
if self._initialized or self._states:
|
|
||||||
raise TradeStreamStateStoreInitializationError(
|
|
||||||
"Trade Stream State Store уже содержит состояния."
|
|
||||||
)
|
|
||||||
|
|
||||||
prepared: dict[str, TradeStreamState] = {}
|
|
||||||
|
|
||||||
for state in states:
|
|
||||||
if not isinstance(state, TradeStreamState):
|
|
||||||
raise TypeError(
|
|
||||||
"states must contain only TradeStreamState"
|
|
||||||
)
|
|
||||||
|
|
||||||
if state.symbol in prepared:
|
|
||||||
raise TradeStreamStateStoreInitializationError(
|
|
||||||
f"Состояние для символа {state.symbol!r} "
|
|
||||||
"передано повторно."
|
|
||||||
)
|
|
||||||
|
|
||||||
prepared[state.symbol] = state
|
|
||||||
|
|
||||||
self._states.update(prepared)
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
|
||||||
"""Проверить, что хранилище не содержит состояний."""
|
|
||||||
return not self._states and not self._initialized
|
|
||||||
|
|
||||||
def get_or_create(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
"""
|
|
||||||
Возвращает существующее состояние либо создаёт новое.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Экземпляр TradeStreamState.
|
|
||||||
"""
|
|
||||||
state = self._states.get(symbol)
|
|
||||||
|
|
||||||
if state is None:
|
|
||||||
state = TradeStreamState(symbol=symbol)
|
|
||||||
self._states[symbol] = state
|
|
||||||
|
|
||||||
return state
|
|
||||||
|
|
||||||
def get(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
"""
|
|
||||||
Возвращает существующее состояние.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TradeStreamStateNotFoundError:
|
|
||||||
Если состояние отсутствует.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return self._states[symbol]
|
|
||||||
except KeyError as error:
|
|
||||||
raise TradeStreamStateNotFoundError(
|
|
||||||
f"Состояние Trade Stream для символа {symbol!r} не найдено."
|
|
||||||
) from error
|
|
||||||
|
|
||||||
def contains(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Проверяет наличие состояния.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True, если состояние существует.
|
|
||||||
"""
|
|
||||||
return symbol in self._states
|
|
||||||
|
|
||||||
def remove(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Удаляет состояние.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TradeStreamStateNotFoundError:
|
|
||||||
Если состояние отсутствует.
|
|
||||||
"""
|
|
||||||
if symbol not in self._states:
|
|
||||||
raise TradeStreamStateNotFoundError(
|
|
||||||
f"Состояние Trade Stream для символа "
|
|
||||||
f"{symbol!r} не найдено."
|
|
||||||
)
|
|
||||||
|
|
||||||
del self._states[symbol]
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""
|
|
||||||
Полностью очищает хранилище состояний.
|
|
||||||
"""
|
|
||||||
self._states.clear()
|
|
||||||
self._initialized = False
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/trade_stream_state_store_exceptions.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""
|
|
||||||
Исключения хранилища состояний Trade Stream Consistency.
|
|
||||||
|
|
||||||
Build 060.20.1 вводит специализированное хранилище
|
|
||||||
состояния проверки согласованности потока сделок.
|
|
||||||
|
|
||||||
Исключения относятся исключительно к управлению
|
|
||||||
TradeStreamState и не описывают ошибки проверки
|
|
||||||
последовательности сделок.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class TradeStreamStateStoreError(Exception):
|
|
||||||
"""
|
|
||||||
Базовое исключение хранилища состояний Trade Stream.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class TradeStreamStateNotFoundError(
|
|
||||||
TradeStreamStateStoreError,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Состояние торгового инструмента отсутствует.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class TradeStreamStateStoreInitializationError(
|
|
||||||
TradeStreamStateStoreError,
|
|
||||||
):
|
|
||||||
"""Хранилище состояний нельзя безопасно инициализировать."""
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/consistency/trade_stream_state_store_protocol.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""
|
|
||||||
Протокол хранилища состояния Trade Stream Consistency.
|
|
||||||
|
|
||||||
Build 060.20.1 заменяет универсальный Trade Runtime Registry
|
|
||||||
специализированным типизированным хранилищем состояния
|
|
||||||
Canonical Trade Stream.
|
|
||||||
|
|
||||||
TradeStreamStateStoreProtocol определяет минимальный контракт
|
|
||||||
доступа к состоянию проверки согласованности потока сделок.
|
|
||||||
|
|
||||||
Протокол не описывает внутреннюю реализацию хранения
|
|
||||||
и не содержит бизнес-логики Trade Stream Consistency.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
from src.market_data.acquisition.consistency.trade_stream_state import (
|
|
||||||
TradeStreamState,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class TradeStreamStateStoreProtocol(Protocol):
|
|
||||||
"""
|
|
||||||
Протокол хранилища состояний Trade Stream Consistency.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def initialize(
|
|
||||||
self,
|
|
||||||
states: tuple[TradeStreamState, ...],
|
|
||||||
) -> None:
|
|
||||||
"""Однократно и атомарно опубликовать готовые состояния."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
|
||||||
"""Проверить, что ни одно состояние ещё не опубликовано."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_or_create(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
"""
|
|
||||||
Возвращает существующее состояние торгового инструмента
|
|
||||||
либо создаёт новое состояние при первом обращении.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Существующий либо созданный TradeStreamState.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> TradeStreamState:
|
|
||||||
"""
|
|
||||||
Возвращает существующее состояние торгового инструмента.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Зарегистрированный TradeStreamState.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TradeStreamStateNotFoundError:
|
|
||||||
Если состояние торгового инструмента отсутствует.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def contains(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Проверяет наличие состояния торгового инструмента.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True, если состояние существует,
|
|
||||||
иначе False.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def remove(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Удаляет состояние торгового инструмента.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol:
|
|
||||||
Идентификатор торгового инструмента.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TradeStreamStateNotFoundError:
|
|
||||||
Если состояние торгового инструмента отсутствует.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""
|
|
||||||
Полностью очищает хранилище состояний.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/exceptions.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
# Базовая ошибка подсистемы получения рыночных данных.
|
|
||||||
class MarketDataAcquisitionError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка получения Instrument Reference Data от внешнего источника.
|
|
||||||
class InstrumentReferenceTransportError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка структуры документа Instrument Reference Data.
|
|
||||||
class InstrumentReferenceSchemaError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования проверенного документа в raw-модели адаптера.
|
|
||||||
class InstrumentReferenceParseError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка допустимости значений Instrument Reference Data.
|
|
||||||
class InstrumentReferenceValueError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования raw-модели источника во внутреннюю модель Instrument.
|
|
||||||
class InstrumentReferenceMappingError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка регистрации или получения Instrument Feed.
|
|
||||||
class InstrumentFeedRegistryError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка получения Quotes Feed от внешнего источника.
|
|
||||||
class QuoteTransportError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка структуры документа Quotes Feed.
|
|
||||||
class QuoteSchemaError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования проверенного документа в raw-модель котировки.
|
|
||||||
class QuoteParseError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка допустимости значений Quotes Feed.
|
|
||||||
class QuoteValueError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования raw-модели источника во внутреннюю модель Quote.
|
|
||||||
class QuoteMappingError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка регистрации или получения Quotes Feed.
|
|
||||||
class QuoteFeedRegistryError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка получения Candles Feed от внешнего источника.
|
|
||||||
class CandleTransportError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка структуры документа Candles Feed.
|
|
||||||
class CandleSchemaError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка структуры события Candles Feed из WebSocket.
|
|
||||||
class CandleWebSocketSchemaError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования проверенного WebSocket OHLC в raw-модель адаптера.
|
|
||||||
class CandleWebSocketParseError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка допустимости значений WebSocket OHLC-события.
|
|
||||||
class CandleWebSocketValueError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования WebSocket OHLC-события
|
|
||||||
# во внутреннюю модель уведомления о закрытии свечи.
|
|
||||||
class CandleWebSocketMappingError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования проверенного документа в raw-модели свечей.
|
|
||||||
class CandleParseError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка допустимости значений Candles Feed.
|
|
||||||
class CandleValueError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования raw-модели источника во внутреннюю модель Candle.
|
|
||||||
class CandleMappingError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка регистрации или получения Candles Feed.
|
|
||||||
class CandleFeedRegistryError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка получения Trades Feed от внешнего источника.
|
|
||||||
class TradeTransportError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка структуры документа Trades Feed.
|
|
||||||
class TradeSchemaError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования проверенного документа в raw-модели сделок.
|
|
||||||
class TradeParseError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка проверки допустимости значений raw-моделей сделок.
|
|
||||||
class TradeValueError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка преобразования raw-модели источника
|
|
||||||
# во внутреннюю модель Trade.
|
|
||||||
class TradeMappingError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка регистрации или получения Trades Feed.
|
|
||||||
class TradeFeedRegistryError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка определения типа входящего WebSocket-сообщения
|
|
||||||
# и выбора специализированного адаптера.
|
|
||||||
class WebSocketMessageRoutingError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка декодирования сырого WebSocket-сообщения.
|
|
||||||
class WebSocketMessageDecodeError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка обработки control/ACK сообщения WebSocket-провайдера.
|
|
||||||
class WebSocketControlMessageError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Истекло время ожидания подтверждения стартовой WebSocket-подписки.
|
|
||||||
class WebSocketSubscriptionAckTimeoutError(
|
|
||||||
MarketDataAcquisitionError,
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Переполнен буфер market-сообщений до подтверждения подписки.
|
|
||||||
class WebSocketStartupMarketBufferOverflowError(
|
|
||||||
MarketDataAcquisitionError,
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Ошибка низкоуровневой WebSocket-инфраструктуры Acquisition Runtime.
|
|
||||||
class WebSocketTransportError(MarketDataAcquisitionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# Попытка использовать WebSocket-транспорт без открытого соединения.
|
|
||||||
class WebSocketTransportNotConnectedError(WebSocketTransportError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# WebSocket-провайдер не поддерживает транспортную операцию unsubscribe.
|
|
||||||
class WebSocketUnsubscribeNotSupportedError(
|
|
||||||
MarketDataAcquisitionError,
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/feeds/candles_feed.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.candle import Candle
|
|
||||||
from src.market_data.acquisition.protocol import (
|
|
||||||
CandlesDocumentHandler,
|
|
||||||
CandlesDocumentSource,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CandlesFeed:
|
|
||||||
"""
|
|
||||||
Read-only Feed канонических рыночных свечей.
|
|
||||||
|
|
||||||
Feed координирует получение и обработку документа, но не выполняет
|
|
||||||
transport parsing, value validation, mapping или кэширование.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
source: CandlesDocumentSource,
|
|
||||||
handler: CandlesDocumentHandler,
|
|
||||||
) -> None:
|
|
||||||
self._source = source
|
|
||||||
self._handler = handler
|
|
||||||
|
|
||||||
def load_candles(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
*,
|
|
||||||
interval: str,
|
|
||||||
limit: int,
|
|
||||||
price_type: str,
|
|
||||||
) -> tuple[Candle, ...]:
|
|
||||||
"""
|
|
||||||
Получить канонический immutable-набор свечей инструмента.
|
|
||||||
"""
|
|
||||||
|
|
||||||
document = self._source.fetch_candles_document(
|
|
||||||
symbol,
|
|
||||||
interval=interval,
|
|
||||||
limit=limit,
|
|
||||||
price_type=price_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._handler.handle_candles_document(
|
|
||||||
document,
|
|
||||||
symbol=symbol,
|
|
||||||
interval=interval,
|
|
||||||
source=f"rest_klines:{price_type}",
|
|
||||||
)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/feeds/instrument_feed.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
|
||||||
from src.market_data.acquisition.protocol import (
|
|
||||||
InstrumentDocumentHandler,
|
|
||||||
InstrumentDocumentSource,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Feed справочника инструментов: получает документ и передаёт его обработчику.
|
|
||||||
class InstrumentFeed:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
source: InstrumentDocumentSource,
|
|
||||||
handler: InstrumentDocumentHandler,
|
|
||||||
) -> None:
|
|
||||||
self._source = source
|
|
||||||
self._handler = handler
|
|
||||||
|
|
||||||
def load_instruments(self) -> tuple[Instrument, ...]:
|
|
||||||
"""
|
|
||||||
Получить документ от источника и преобразовать его в модели Instrument.
|
|
||||||
|
|
||||||
Feed не выполняет transport, parsing, validation, mapping,
|
|
||||||
кэширование или обработку ошибок самостоятельно.
|
|
||||||
"""
|
|
||||||
|
|
||||||
document = self._source.fetch_instrument_document()
|
|
||||||
|
|
||||||
return self._handler.handle_instrument_document(document)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/feeds/quotes_feed.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.market_data.acquisition.protocol import (
|
|
||||||
QuoteDocumentHandler,
|
|
||||||
QuoteDocumentSource,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Feed текущих котировок: получает документ и передаёт его обработчику.
|
|
||||||
class QuotesFeed:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
source: QuoteDocumentSource,
|
|
||||||
handler: QuoteDocumentHandler,
|
|
||||||
) -> None:
|
|
||||||
self._source = source
|
|
||||||
self._handler = handler
|
|
||||||
|
|
||||||
def load_quote(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> Quote:
|
|
||||||
"""
|
|
||||||
Получить документ котировки и преобразовать его в модель Quote.
|
|
||||||
|
|
||||||
Feed не выполняет transport, parsing, validation, mapping,
|
|
||||||
нормализацию symbol, retry, кэширование или обработку ошибок.
|
|
||||||
"""
|
|
||||||
|
|
||||||
document = self._source.fetch_quote_document(symbol)
|
|
||||||
|
|
||||||
return self._handler.handle_quote_document(document)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/feeds/status_feed.py
|
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/feeds/trades_feed.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.protocol import (
|
|
||||||
TradeDocumentHandler,
|
|
||||||
TradeDocumentSource,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Источник готовой внутренней модели Trade.
|
|
||||||
class TradesFeed:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
source: TradeDocumentSource,
|
|
||||||
handler: TradeDocumentHandler,
|
|
||||||
) -> None:
|
|
||||||
self._source = source
|
|
||||||
self._handler = handler
|
|
||||||
|
|
||||||
def load_trade(
|
|
||||||
self,
|
|
||||||
symbol: str,
|
|
||||||
) -> Trade:
|
|
||||||
"""
|
|
||||||
Получить последнюю сделку инструмента.
|
|
||||||
|
|
||||||
Feed получает сырой документ у источника и делегирует
|
|
||||||
его преобразование специализированному обработчику.
|
|
||||||
"""
|
|
||||||
|
|
||||||
document = self._source.fetch_trade_document(symbol)
|
|
||||||
|
|
||||||
return self._handler.handle_trade_document(document)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/handlers/candles_handler.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
|
||||||
map_dzengi_klines_to_candles,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
|
||||||
parse_candles,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.candle import Candle
|
|
||||||
from src.market_data.acquisition.validation.schema import (
|
|
||||||
validate_candles_schema,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.validation.values import (
|
|
||||||
validate_candles_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Обработчик документа Candles Feed формата Dzengi /api/v1/klines.
|
|
||||||
class DzengiCandlesDocumentHandler:
|
|
||||||
def handle_candles_document(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
interval: str,
|
|
||||||
source: str,
|
|
||||||
) -> tuple[Candle, ...]:
|
|
||||||
"""
|
|
||||||
Преобразовать сырой документ Dzengi в канонические модели Candle.
|
|
||||||
|
|
||||||
Порядок обработки:
|
|
||||||
|
|
||||||
schema validation
|
|
||||||
→
|
|
||||||
parsing
|
|
||||||
→
|
|
||||||
value validation
|
|
||||||
→
|
|
||||||
mapping
|
|
||||||
"""
|
|
||||||
|
|
||||||
validated_document = validate_candles_schema(document)
|
|
||||||
response = parse_candles(validated_document)
|
|
||||||
|
|
||||||
validate_candles_values(response)
|
|
||||||
|
|
||||||
return map_dzengi_klines_to_candles(
|
|
||||||
response,
|
|
||||||
symbol=symbol,
|
|
||||||
interval=interval,
|
|
||||||
source=source,
|
|
||||||
)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/handlers/instrument_handler.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
|
||||||
map_dzengi_exchange_info_to_instruments,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import (
|
|
||||||
parse_exchange_info,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.instrument import Instrument
|
|
||||||
from src.market_data.acquisition.validation.schema import (
|
|
||||||
validate_exchange_info_schema,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.validation.values import (
|
|
||||||
validate_exchange_info_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Обработчик документа Instrument Reference Data формата Dzengi exchangeInfo.
|
|
||||||
class DzengiInstrumentDocumentHandler:
|
|
||||||
def handle_instrument_document(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
) -> tuple[Instrument, ...]:
|
|
||||||
validated_document = validate_exchange_info_schema(document)
|
|
||||||
|
|
||||||
response = parse_exchange_info(validated_document)
|
|
||||||
|
|
||||||
validate_exchange_info_values(response)
|
|
||||||
|
|
||||||
return map_dzengi_exchange_info_to_instruments(response)
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/handlers/quotes_handler.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
|
||||||
map_dzengi_ticker_to_quote,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.adapters.dzengi.parser import parse_quote
|
|
||||||
from src.market_data.acquisition.models.quote import Quote
|
|
||||||
from src.market_data.acquisition.validation.schema import (
|
|
||||||
validate_quote_schema,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.validation.values import (
|
|
||||||
validate_quote_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Обработчик документа Quotes Feed формата Dzengi ticker/24hr.
|
|
||||||
class DzengiQuoteDocumentHandler:
|
|
||||||
def handle_quote_document(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
) -> Quote:
|
|
||||||
validated_document = validate_quote_schema(document)
|
|
||||||
|
|
||||||
response = parse_quote(validated_document)
|
|
||||||
|
|
||||||
validate_quote_values(response)
|
|
||||||
|
|
||||||
return map_dzengi_ticker_to_quote(
|
|
||||||
response,
|
|
||||||
received_at=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/handlers/status_handler.py
|
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/handlers/trades_handler.py
|
|
||||||
|
|
||||||
# app/src/market_data/acquisition/handlers/trades_handler.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from src.market_data.acquisition.adapters.dzengi import (
|
|
||||||
adapt_websocket_trade_document,
|
|
||||||
)
|
|
||||||
from src.market_data.acquisition.models.trade import Trade
|
|
||||||
from src.market_data.acquisition.validation.schema import (
|
|
||||||
validate_dzengi_websocket_trade_schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Обработчик документа Trades Feed формата Dzengi WebSocket internal.trade.
|
|
||||||
class DzengiTradeDocumentHandler:
|
|
||||||
def handle_trade_document(
|
|
||||||
self,
|
|
||||||
document: object,
|
|
||||||
) -> Trade:
|
|
||||||
validated_document = validate_dzengi_websocket_trade_schema(document)
|
|
||||||
|
|
||||||
return adapt_websocket_trade_document(validated_document)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/models/__init__.py
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# app/src/market_data/acquisition/models/candle.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
|
|
||||||
# Каноническая неизменяемая модель одной рыночной свечи OHLCV.
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Candle:
|
|
||||||
symbol: str
|
|
||||||
interval: str
|
|
||||||
|
|
||||||
open_time: datetime
|
|
||||||
|
|
||||||
open_price: Decimal
|
|
||||||
high_price: Decimal
|
|
||||||
low_price: Decimal
|
|
||||||
close_price: Decimal
|
|
||||||
volume: Decimal
|
|
||||||
|
|
||||||
source: str
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user