From 64a5bdd04ce5008a07bf8184d6cc34ee99f8d280 Mon Sep 17 00:00:00 2001 From: Sergey Date: Mon, 3 Aug 2026 23:30:41 +0300 Subject: [PATCH] Build 060.30: finalize Market Data Acquisition documentation --- .dockerignore | 30 + README.md | 96 +- app/.env.example | 46 +- app/README.md | 122 +- app/requirements.lock | 895 ++++ app/src/core/config.py | 103 +- app/src/storage/README.md | 106 +- .../test_documentation_integrity_gate.py | 3555 ++++++++++++++ app/tests/unit/core/test_config.py | 303 ++ app/tests/unit/infra/test_docker_hardening.py | 396 ++ .../dzentra_target_architecture.md | 163 +- docs/architecture/overview.md | 124 +- docs/architecture/project_structure.md | 119 +- docs/architecture/trades_feed.md | 585 +++ docs/migrations/build_044.md | 13 + docs/migrations/build_060_19.md | 19 +- docs/migrations/build_060_19_architecture.md | 18 +- docs/migrations/build_060_20.md | 8 +- docs/migrations/build_060_20_1.md | 9 +- .../migrations/build_060_20_1_architecture.md | 27 +- docs/migrations/build_060_20_architecture.md | 13 +- docs/migrations/build_060_21.md | 23 +- docs/migrations/build_060_21_architecture.md | 21 +- docs/migrations/build_060_22.md | 26 +- docs/migrations/build_060_22_architecture.md | 25 +- docs/migrations/build_060_23.md | 32 +- docs/migrations/build_060_23_architecture.md | 19 +- docs/migrations/build_060_24.md | 26 +- docs/migrations/build_060_24_architecture.md | 4 +- docs/migrations/build_060_25.md | 23 +- docs/migrations/build_060_25_architecture.md | 2 +- docs/migrations/build_060_26.md | 19 +- docs/migrations/build_060_26_architecture.md | 2 +- docs/migrations/build_060_27.md | 18 +- docs/migrations/build_060_27_architecture.md | 7 +- docs/migrations/build_060_28.md | 17 +- docs/migrations/build_060_28_architecture.md | 7 +- docs/migrations/build_060_29.md | 17 +- docs/migrations/build_060_29_architecture.md | 7 +- docs/migrations/build_060_30.md | 313 ++ docs/migrations/build_060_30_architecture.md | 820 ++++ docs/operations/trades_feed_runtime.md | 1110 +++++ docs/roadmap/master-roadmap.md | 1749 +------ .../stage-01-bootstrap-foundation-roadmap.md | 11 +- .../stage-02-system-diagnostics-roadmap.md | 11 +- .../stage-03-exchange-integration-roadmap.md | 11 +- .../stage-04-storage-journal-roadmap.md | 11 +- ...-05-trading-ui-draft-validation-roadmap.md | 11 +- ...-06-journal-navigation-settings-roadmap.md | 11 +- docs/roadmap/stage-07-auto-trading-roadmap.md | 11 +- docs/stages/stage-03_3-exchange_info.md | 3 +- docs/stages/stage-04_3-repositories.md | 3 +- ...6_2-system-settings-navigation-and-info.md | 3 +- .../compose/docker-compose.exchange-auth.yml | 14 + infra/compose/docker-compose.yml | 123 +- infra/docker/Dockerfile | 39 +- infra/docker/postgres/Dockerfile | 9 + .../postgres/healthcheck-application-role.sh | 48 + .../docker/postgres/init-application-role.sh | 130 + pyrightconfig.json | 6 +- scripts/check_documentation_integrity.py | 4361 +++++++++++++++++ 61 files changed, 13980 insertions(+), 1873 deletions(-) create mode 100644 .dockerignore create mode 100644 app/requirements.lock create mode 100644 app/tests/static/test_documentation_integrity_gate.py create mode 100644 app/tests/unit/infra/test_docker_hardening.py create mode 100644 docs/architecture/trades_feed.md create mode 100644 docs/migrations/build_060_30.md create mode 100644 docs/migrations/build_060_30_architecture.md create mode 100644 docs/operations/trades_feed_runtime.md create mode 100644 infra/compose/docker-compose.exchange-auth.yml create mode 100644 infra/docker/postgres/Dockerfile create mode 100644 infra/docker/postgres/healthcheck-application-role.sh create mode 100644 infra/docker/postgres/init-application-role.sh create mode 100644 scripts/check_documentation_integrity.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3829bac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# В build context разрешены только production source и requirements. +** +!.dockerignore +!infra/ +!infra/docker/ +!infra/docker/Dockerfile +!infra/docker/postgres/ +!infra/docker/postgres/Dockerfile +!infra/docker/postgres/healthcheck-application-role.sh +!infra/docker/postgres/init-application-role.sh +!app/ +!app/requirements.txt +!app/requirements.lock +!app/src/ +!app/src/** + +# Локальные артефакты запрещены даже внутри разрешённого source tree. +app/src/**/__pycache__/ +app/src/**/*.py[cod] +app/src/**/.DS_Store +app/src/**/.env +app/src/**/.env.* +app/src/**/*.pem +app/src/**/*.key +app/src/**/*.p12 +app/src/**/*.pfx +app/src/**/*.log +app/src/**/*.dump +app/src/**/*.sqlite +app/src/**/*.sqlite3 diff --git a/README.md b/README.md index b64139a..004fdeb 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,83 @@ -# dzentra_bot +# Dzentra -Telegram-бот для автоторговли криптовалютой. +Dzentra — модульный монолит автоматической торговой системы с +Telegram-интерфейсом. В текущем принятом Production scope полностью +собрана вертикаль Trades Feed: получение сделок, проверка +последовательности, reconnect/recovery, опциональное сохранение в +PostgreSQL и восстановление после перезапуска. -## Bootstrap v2 -Это стабильный стартовый каркас проекта с: -- чистой структурой каталогов -- компактным верхним меню -- базовыми handlers -- документацией -- Docker-файлами -- опциональным созданием `.venv`, установкой зависимостей и первым git commit +## Текущий scope -## Верхнее меню -- 🏠 Главная -- 📈 Рынок -- 💼 Портфель -- ⚡ Торговля -- 🤖 Авто -- 📒 Журнал -- ⚙️ Система +- 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 -/opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install +python3.12 -m venv app/.venv +app/.venv/bin/python -m pip install --upgrade pip +app/.venv/bin/python -m pip install -r app/requirements-dev.txt +test -e app/.env || cp app/.env.example app/.env ``` -## После bootstrap +Заполните созданный `app/.env`, не добавляйте его в Git и не +перезаписывайте уже существующий файл. Затем запустите приложение: + ```bash cd app -source .venv/bin/activate -python -m src.main +.venv/bin/python -m src.main ``` + +Настройки режимов, PostgreSQL и безопасный Docker-запуск приведены в +[эксплуатационном руководстве](docs/operations/trades_feed_runtime.md). +Более подробная инструкция для разработчика находится в +[app/README.md](app/README.md). + +## Обязательные проверки + +Из корня репозитория: + +```bash +./scripts/check_python_types.sh +app/.venv/bin/python scripts/check_documentation_integrity.py +``` + +Ожидаемый результат Pyright — `0 errors, 0 warnings`, а documentation +gate — `issues=0`. Обычная offline-регрессия запускается из `app`: + +```bash +cd app +.venv/bin/python -m pytest -q +``` + +Default pytest-набор исключает opt-in группы `integration`, `stress` и +`live`, но включает статическую проверку Pyright. Целевой тест не +заменяет полный type gate. + +## Навигация + +- [Обзор архитектуры](docs/architecture/overview.md) +- [Структура проекта](docs/architecture/project_structure.md) +- [Текущая архитектура Trades Feed](docs/architecture/trades_feed.md) +- [Эксплуатация Trade Stream Runtime](docs/operations/trades_feed_runtime.md) +- [Архитектура Build 060.30](docs/migrations/build_060_30_architecture.md) + +## Исторический bootstrap + +`bootstrap_project.py` — исторический генератор первоначального каркаса. +Для установки или запуска существующего checkout его использовать не +нужно. diff --git a/app/.env.example b/app/.env.example index d2c3ebe..624ea08 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1,29 +1,48 @@ +# Настройки приложения. Файл является примером: не храните здесь +# реальные секреты. Для каждого секрета задайте либо прямое значение, +# либо *_FILE, но не оба. *_FILE должен указывать на доступный для чтения +# UTF-8 файл с одним секретом. + +# Telegram и приложение BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE +# BOT_TOKEN_FILE=/run/secrets/bot_token BOT_PARSE_MODE=HTML APP_ENV=dev LOG_LEVEL=INFO 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_PORT=5432 DB_NAME=dzentra_bot DB_USER=dzentra_bot -DB_PASSWORD=change_me +DB_PASSWORD=PUT_YOUR_DATABASE_PASSWORD_HERE +# DB_PASSWORD_FILE=/run/secrets/db_password +# Постоянное хранилище Market Data MARKET_DATA_STORAGE_ENABLED=false MARKET_DATA_STORAGE_POOL_MIN_SIZE=1 MARKET_DATA_STORAGE_POOL_MAX_SIZE=4 MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS=10 -EXCHANGE_ENABLED=true +# Биржа. Безопасный пример совпадает с выключенными значениями по умолчанию. +EXCHANGE_ENABLED=false EXCHANGE_NAME=dzengi -EXCHANGE_BASE_URL=https://demo-api-adapter.dzengi.com +EXCHANGE_BASE_URL= +EXCHANGE_WS_URL= EXCHANGE_API_KEY= +# EXCHANGE_API_KEY_FILE=/run/secrets/exchange_api_key EXCHANGE_API_SECRET= +# EXCHANGE_API_SECRET_FILE=/run/secrets/exchange_api_secret EXCHANGE_TIMEOUT_SEC=10 -EXCHANGE_TESTNET=true -DEFAULT_SYMBOL=BTC/USD_LEVERAGE +EXCHANGE_TESTNET=false +DEFAULT_SYMBOL=ETH/USD_LEVERAGE +# Поток сделок Production Trade Stream TRADE_STREAM_ENABLED=false TRADE_STREAM_WS_URL= TRADE_STREAM_SYMBOLS= @@ -35,3 +54,20 @@ 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 diff --git a/app/README.md b/app/README.md index 80c696f..c079106 100644 --- a/app/README.md +++ b/app/README.md @@ -1,3 +1,121 @@ -# app +# Приложение Dzentra -Здесь находятся исходный код приложения, env-файлы и зависимости. +Каталог `app` содержит исполняемое Python-приложение, зависимости, +настройки и тесты Dzentra. Приложение объединяет Telegram-бота и +принятую Production-вертикаль Trades Feed. + +## Реализованные границы + +| Возможность | Текущее состояние | +|---|---| +| Live Trades, reconnect и REST Recovery | Подключены к Production Runtime | +| Persistent Trades и checkpoint | Опционально подключены через `MARKET_DATA_STORAGE_ENABLED` | +| Startup Hydration и Recovery | Выполняются при включённом Storage | +| Historical Access и Replay | Готовы для явного вызова; автоматически не запускаются | +| Quote/Candle REST feeds | Используются существующими Trading/UI сценариями | +| Quote/Candle WebSocket и persistence | Runtime/Consistency/Recovery и persistent writers не подключены | +| Retention и месячные партиции | Только явный вызов; Scheduler их не запускает | + +Подробности приведены в +[текущей архитектуре Trades Feed](../docs/architecture/trades_feed.md). + +## Требования + +- Python 3.12; +- PostgreSQL, доступный по параметрам `DB_*`; +- Telegram Bot Token; +- отдельное виртуальное окружение `app/.venv`. + +PostgreSQL нужен даже при выключенных Trade Stream и Market Data Storage: +обычный Bootstrap всегда инициализирует таблицы журнала и balance +snapshots. + +## Подготовка окружения + +Из корня репозитория: + +```bash +python3.12 -m venv app/.venv +app/.venv/bin/python -m pip install --upgrade pip +app/.venv/bin/python -m pip install -r app/requirements-dev.txt +test -e app/.env || cp app/.env.example app/.env +``` + +Замените значения-заглушки в `app/.env`. Не добавляйте этот файл в Git и +не перезаписывайте существующий `.env`. Для каждого секрета разрешён либо +прямой параметр, либо соответствующий `*_FILE`, но не оба одновременно. + +`requirements-dev.txt` устанавливает production dependencies и +инструменты разработки. Production container использует проверяемый +hash-lock `requirements.lock`; вручную редактировать его dependency +records не следует. + +## Режимы Trades Feed + +| `TRADE_STREAM_ENABLED` | `MARKET_DATA_STORAGE_ENABLED` | Поведение | +|---:|---:|---| +| `false` | `false` | Trade Runtime и отдельный Market Data pool не создаются | +| `true` | `false` | Live/Recovery работают только с состоянием в памяти | +| `true` | `true` | Добавляются persistent Trades, checkpoint, Hydration и Startup Recovery | +| `false` | `true` | Недопустимая конфигурация; startup завершается ошибкой | + +При включённом Trade Stream обязательны явные `TRADE_STREAM_WS_URL`, +`TRADE_STREAM_SYMBOLS` и `EXCHANGE_BASE_URL`. Полный контракт настроек и +Docker secrets находится в +[эксплуатационном руководстве](../docs/operations/trades_feed_runtime.md). + +## Запуск + +Из каталога `app`: + +```bash +.venv/bin/python -m src.main +``` + +Ошибка включённого Trade Runtime, Storage или Startup Recovery является +фатальной и завершает всё приложение, а не оставляет Telegram polling в +частично работающем состоянии. + +## Проверки разработчика + +Обязательная отдельная проверка типов запускается из корня репозитория: + +```bash +./scripts/check_python_types.sh +app/.venv/bin/python scripts/check_documentation_integrity.py +``` + +Ожидаемый результат — `0 errors, 0 warnings` для Pyright и `issues=0` +для documentation gate. Default offline regression запускается из `app`: + +```bash +.venv/bin/python -m pytest -q +``` + +Default-набор исключает маркеры `integration`, `stress` и `live`. При +этом он включает `tests/static/test_python_type_gate.py`, поэтому полный +запуск дополнительно контролирует Pyright. Один целевой тест этот gate не +заменяет. + +Назначение и безопасные команды opt-in наборов: + +- `integration` — локальные сетевые и PostgreSQL-сценарии; +- `stress` — fixed stress и opt-in soak; +- `live` — только явно разрешённая проверка внешних Dzengi endpoints. + +Параметры этих запусков не дублируются здесь и поддерживаются в +[runbook](../docs/operations/trades_feed_runtime.md). + +## Структура и навигация + +- `src/` — production-код; +- `tests/unit/` и `tests/static/` — default offline regression; +- `tests/integration/`, `tests/stress/`, `tests/live/` — opt-in проверки; +- `scripts/` и `tools/` — диагностические и исследовательские утилиты, + не automatic Production Runtime; +- [обзор структуры проекта](../docs/architecture/project_structure.md); +- [границы пакета Storage](src/storage/README.md); +- [архитектура Build 060.30](../docs/migrations/build_060_30_architecture.md). + +Корневой `bootstrap_project.py` является историческим генератором +каркаса. Для текущего checkout его запускать не нужно. diff --git a/app/requirements.lock b/app/requirements.lock new file mode 100644 index 0000000..d01d7ed --- /dev/null +++ b/app/requirements.lock @@ -0,0 +1,895 @@ +# Сгенерированный hash-lock production-зависимостей; вручную не редактировать. +# Источник: app/requirements.txt; Python 3.12; universal resolution. +# Команда приведена в docs/operations/trades_feed_runtime.md. + +aiofiles==24.1.0 \ + --hash=sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c \ + --hash=sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5 +aiogram==3.13.1 \ + --hash=sha256:148cbba15c2b6e0bc482aff6ae86848ed43e023b7102336bad17698242693000 \ + --hash=sha256:2674640ae74501ae30e68a5354e03f12e10db3adbcbd475bb56904536288c9c9 +aiohappyeyeballs==2.7.1 \ + --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 +aiohttp==3.10.11 \ + --hash=sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d \ + --hash=sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc \ + --hash=sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261 \ + --hash=sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b \ + --hash=sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625 \ + --hash=sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39 \ + --hash=sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f \ + --hash=sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636 \ + --hash=sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac \ + --hash=sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217 \ + --hash=sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9 \ + --hash=sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f \ + --hash=sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006 \ + --hash=sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e \ + --hash=sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9 \ + --hash=sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a \ + --hash=sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695 \ + --hash=sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730 \ + --hash=sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e \ + --hash=sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382 \ + --hash=sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc \ + --hash=sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00 \ + --hash=sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8 \ + --hash=sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658 \ + --hash=sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339 \ + --hash=sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a \ + --hash=sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a \ + --hash=sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e \ + --hash=sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d \ + --hash=sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e \ + --hash=sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca \ + --hash=sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038 \ + --hash=sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461 \ + --hash=sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829 \ + --hash=sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31 \ + --hash=sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4 \ + --hash=sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127 \ + --hash=sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3 \ + --hash=sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97 \ + --hash=sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710 \ + --hash=sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2 \ + --hash=sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674 \ + --hash=sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb \ + --hash=sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03 \ + --hash=sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07 \ + --hash=sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9 \ + --hash=sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6 \ + --hash=sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f \ + --hash=sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298 \ + --hash=sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b \ + --hash=sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01 \ + --hash=sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9 \ + --hash=sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7 \ + --hash=sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413 \ + --hash=sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec \ + --hash=sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7 \ + --hash=sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4 \ + --hash=sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb \ + --hash=sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519 \ + --hash=sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d \ + --hash=sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6 \ + --hash=sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27 \ + --hash=sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1 \ + --hash=sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087 \ + --hash=sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067 \ + --hash=sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f \ + --hash=sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95 \ + --hash=sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa \ + --hash=sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa \ + --hash=sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16 \ + --hash=sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106 \ + --hash=sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92 \ + --hash=sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777 \ + --hash=sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa \ + --hash=sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e \ + --hash=sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7 \ + --hash=sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115 \ + --hash=sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120 \ + --hash=sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d \ + --hash=sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b \ + --hash=sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a \ + --hash=sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725 \ + --hash=sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a \ + --hash=sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d \ + --hash=sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288 \ + --hash=sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8 \ + --hash=sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385 \ + --hash=sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24 \ + --hash=sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71 \ + --hash=sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138 \ + --hash=sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177 +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ + --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 +et-xmlfile==2.0.0 \ + --hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \ + --hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54 +frozenlist==1.8.0 \ + --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \ + --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \ + --hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \ + --hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \ + --hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \ + --hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \ + --hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \ + --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \ + --hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \ + --hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \ + --hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \ + --hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \ + --hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \ + --hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \ + --hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \ + --hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \ + --hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \ + --hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \ + --hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \ + --hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \ + --hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \ + --hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \ + --hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \ + --hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \ + --hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \ + --hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \ + --hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \ + --hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \ + --hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \ + --hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \ + --hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \ + --hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \ + --hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \ + --hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \ + --hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \ + --hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \ + --hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \ + --hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \ + --hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \ + --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \ + --hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \ + --hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \ + --hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \ + --hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \ + --hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \ + --hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \ + --hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \ + --hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \ + --hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \ + --hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \ + --hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \ + --hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \ + --hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \ + --hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \ + --hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \ + --hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \ + --hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \ + --hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \ + --hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \ + --hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \ + --hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \ + --hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \ + --hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \ + --hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \ + --hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \ + --hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \ + --hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \ + --hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \ + --hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \ + --hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \ + --hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \ + --hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \ + --hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \ + --hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \ + --hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \ + --hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \ + --hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \ + --hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \ + --hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \ + --hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \ + --hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \ + --hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \ + --hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \ + --hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \ + --hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \ + --hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \ + --hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \ + --hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \ + --hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \ + --hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \ + --hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \ + --hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \ + --hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \ + --hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \ + --hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \ + --hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \ + --hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \ + --hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \ + --hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \ + --hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \ + --hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \ + --hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \ + --hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \ + --hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \ + --hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \ + --hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \ + --hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \ + --hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \ + --hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \ + --hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \ + --hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \ + --hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \ + --hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \ + --hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \ + --hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \ + --hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \ + --hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \ + --hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \ + --hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \ + --hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \ + --hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \ + --hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \ + --hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \ + --hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \ + --hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \ + --hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \ + --hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \ + --hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \ + --hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \ + --hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 +magic-filter==1.0.12 \ + --hash=sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9 \ + --hash=sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6 +multidict==6.7.1 \ + --hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \ + --hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \ + --hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \ + --hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \ + --hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \ + --hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \ + --hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \ + --hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \ + --hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \ + --hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \ + --hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \ + --hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \ + --hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \ + --hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \ + --hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \ + --hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \ + --hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \ + --hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \ + --hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \ + --hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \ + --hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \ + --hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \ + --hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \ + --hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \ + --hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \ + --hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \ + --hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \ + --hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \ + --hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \ + --hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \ + --hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \ + --hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \ + --hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \ + --hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \ + --hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \ + --hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \ + --hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \ + --hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \ + --hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \ + --hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \ + --hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \ + --hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \ + --hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \ + --hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \ + --hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \ + --hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \ + --hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \ + --hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \ + --hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \ + --hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \ + --hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \ + --hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \ + --hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \ + --hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \ + --hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \ + --hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \ + --hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \ + --hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \ + --hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \ + --hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \ + --hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \ + --hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \ + --hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \ + --hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \ + --hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \ + --hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \ + --hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \ + --hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \ + --hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \ + --hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \ + --hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \ + --hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \ + --hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \ + --hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \ + --hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \ + --hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \ + --hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \ + --hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \ + --hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \ + --hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \ + --hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \ + --hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \ + --hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \ + --hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \ + --hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \ + --hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \ + --hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \ + --hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \ + --hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \ + --hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \ + --hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \ + --hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \ + --hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \ + --hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \ + --hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \ + --hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \ + --hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \ + --hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \ + --hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \ + --hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \ + --hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \ + --hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \ + --hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \ + --hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \ + --hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \ + --hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \ + --hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \ + --hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \ + --hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \ + --hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \ + --hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \ + --hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \ + --hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \ + --hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \ + --hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \ + --hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \ + --hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \ + --hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \ + --hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \ + --hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \ + --hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \ + --hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \ + --hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \ + --hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \ + --hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \ + --hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \ + --hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \ + --hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \ + --hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \ + --hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \ + --hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \ + --hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \ + --hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \ + --hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \ + --hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \ + --hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \ + --hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \ + --hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \ + --hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \ + --hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \ + --hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \ + --hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \ + --hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \ + --hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \ + --hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \ + --hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 +openpyxl==3.1.5 \ + --hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \ + --hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050 +propcache==0.5.2 \ + --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \ + --hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \ + --hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \ + --hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \ + --hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \ + --hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \ + --hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \ + --hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \ + --hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \ + --hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \ + --hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \ + --hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \ + --hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \ + --hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \ + --hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \ + --hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \ + --hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \ + --hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \ + --hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \ + --hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \ + --hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \ + --hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \ + --hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \ + --hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \ + --hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \ + --hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \ + --hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \ + --hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \ + --hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \ + --hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \ + --hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \ + --hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \ + --hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \ + --hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \ + --hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \ + --hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \ + --hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \ + --hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \ + --hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \ + --hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \ + --hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \ + --hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \ + --hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \ + --hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \ + --hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \ + --hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \ + --hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \ + --hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \ + --hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \ + --hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \ + --hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \ + --hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \ + --hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \ + --hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \ + --hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \ + --hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \ + --hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \ + --hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \ + --hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \ + --hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \ + --hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \ + --hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \ + --hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \ + --hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \ + --hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \ + --hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \ + --hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \ + --hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \ + --hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \ + --hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \ + --hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \ + --hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \ + --hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \ + --hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \ + --hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \ + --hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \ + --hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \ + --hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \ + --hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \ + --hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \ + --hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \ + --hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \ + --hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \ + --hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \ + --hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \ + --hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \ + --hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \ + --hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \ + --hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \ + --hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \ + --hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \ + --hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \ + --hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \ + --hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \ + --hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \ + --hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \ + --hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \ + --hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \ + --hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \ + --hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \ + --hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \ + --hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \ + --hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \ + --hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \ + --hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \ + --hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \ + --hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \ + --hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \ + --hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \ + --hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \ + --hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \ + --hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \ + --hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \ + --hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \ + --hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \ + --hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \ + --hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \ + --hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \ + --hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \ + --hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \ + --hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6 +psycopg==3.2.9 \ + --hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \ + --hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700 +psycopg-binary==3.2.9 ; implementation_name != 'pypy' \ + --hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \ + --hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \ + --hash=sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b \ + --hash=sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab \ + --hash=sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4 \ + --hash=sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78 \ + --hash=sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4 \ + --hash=sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a \ + --hash=sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2 \ + --hash=sha256:1f1736d5b21f69feefeef8a75e8d3bf1f0a1e17c165a7488c3111af9d6936e91 \ + --hash=sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb \ + --hash=sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523 \ + --hash=sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4 \ + --hash=sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351 \ + --hash=sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd \ + --hash=sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a \ + --hash=sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee \ + --hash=sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a \ + --hash=sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074 \ + --hash=sha256:418f52b77b715b42e8ec43ee61ca74abc6765a20db11e8576e7f6586488a266f \ + --hash=sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2 \ + --hash=sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734 \ + --hash=sha256:4df22ec17390ec5ccb38d211fb251d138d37a43344492858cea24de8efa15003 \ + --hash=sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093 \ + --hash=sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40 \ + --hash=sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe \ + --hash=sha256:5918c0fab50df764812f3ca287f0d716c5c10bedde93d4da2cefc9d40d03f3aa \ + --hash=sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2 \ + --hash=sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7 \ + --hash=sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724 \ + --hash=sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0 \ + --hash=sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186 \ + --hash=sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5 \ + --hash=sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b \ + --hash=sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6 \ + --hash=sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9 \ + --hash=sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786 \ + --hash=sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7 \ + --hash=sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21 \ + --hash=sha256:7b617b81f08ad8def5edd110de44fd6d326f969240cc940c6f6b3ef21fe9c59f \ + --hash=sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8 \ + --hash=sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87 \ + --hash=sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07 \ + --hash=sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff \ + --hash=sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e \ + --hash=sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f \ + --hash=sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891 \ + --hash=sha256:a4d76e28df27ce25dc19583407f5c6c6c2ba33b443329331ab29b6ef94c8736d \ + --hash=sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04 \ + --hash=sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d \ + --hash=sha256:b2d7a6646d41228e9049978be1f3f838b557a1bde500b919906d54c4390f5086 \ + --hash=sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748 \ + --hash=sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695 \ + --hash=sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a \ + --hash=sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e \ + --hash=sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d \ + --hash=sha256:cf789be42aea5752ee396d58de0538d5fcb76795c85fb03ab23620293fb81b6f \ + --hash=sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc \ + --hash=sha256:e0f05b9dafa5670a7503abc715af081dbbb176a8e6770de77bccaeb9024206c5 \ + --hash=sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8 \ + --hash=sha256:eac3a6e926421e976c1c2653624e1294f162dc67ac55f9addbe8f7b8d08ce603 \ + --hash=sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b \ + --hash=sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95 \ + --hash=sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1 \ + --hash=sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb +psycopg-pool==3.3.1 \ + --hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \ + --hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c +pydantic==2.9.2 \ + --hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \ + --hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12 +pydantic-core==2.23.4 \ + --hash=sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36 \ + --hash=sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05 \ + --hash=sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071 \ + --hash=sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327 \ + --hash=sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c \ + --hash=sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36 \ + --hash=sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29 \ + --hash=sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744 \ + --hash=sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d \ + --hash=sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec \ + --hash=sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e \ + --hash=sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e \ + --hash=sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577 \ + --hash=sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232 \ + --hash=sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863 \ + --hash=sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6 \ + --hash=sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368 \ + --hash=sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480 \ + --hash=sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2 \ + --hash=sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2 \ + --hash=sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6 \ + --hash=sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769 \ + --hash=sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d \ + --hash=sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2 \ + --hash=sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84 \ + --hash=sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166 \ + --hash=sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271 \ + --hash=sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5 \ + --hash=sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb \ + --hash=sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13 \ + --hash=sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323 \ + --hash=sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556 \ + --hash=sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665 \ + --hash=sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef \ + --hash=sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb \ + --hash=sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119 \ + --hash=sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126 \ + --hash=sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510 \ + --hash=sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b \ + --hash=sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87 \ + --hash=sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f \ + --hash=sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc \ + --hash=sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8 \ + --hash=sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21 \ + --hash=sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f \ + --hash=sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6 \ + --hash=sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658 \ + --hash=sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b \ + --hash=sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3 \ + --hash=sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb \ + --hash=sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59 \ + --hash=sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24 \ + --hash=sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9 \ + --hash=sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3 \ + --hash=sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd \ + --hash=sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753 \ + --hash=sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55 \ + --hash=sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad \ + --hash=sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a \ + --hash=sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605 \ + --hash=sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e \ + --hash=sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b \ + --hash=sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433 \ + --hash=sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8 \ + --hash=sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07 \ + --hash=sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728 \ + --hash=sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0 \ + --hash=sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327 \ + --hash=sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555 \ + --hash=sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64 \ + --hash=sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6 \ + --hash=sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea \ + --hash=sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b \ + --hash=sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df \ + --hash=sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e \ + --hash=sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd \ + --hash=sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068 \ + --hash=sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3 \ + --hash=sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040 \ + --hash=sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12 \ + --hash=sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916 \ + --hash=sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f \ + --hash=sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f \ + --hash=sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801 \ + --hash=sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231 \ + --hash=sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5 \ + --hash=sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8 \ + --hash=sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee \ + --hash=sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607 +python-dotenv==1.0.1 \ + --hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \ + --hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 +tzdata==2026.3 ; sys_platform == 'win32' \ + --hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \ + --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 +websockets==13.1 \ + --hash=sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a \ + --hash=sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54 \ + --hash=sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23 \ + --hash=sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7 \ + --hash=sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135 \ + --hash=sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700 \ + --hash=sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf \ + --hash=sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5 \ + --hash=sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e \ + --hash=sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c \ + --hash=sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02 \ + --hash=sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a \ + --hash=sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418 \ + --hash=sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f \ + --hash=sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3 \ + --hash=sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68 \ + --hash=sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978 \ + --hash=sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20 \ + --hash=sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295 \ + --hash=sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b \ + --hash=sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6 \ + --hash=sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb \ + --hash=sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a \ + --hash=sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa \ + --hash=sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0 \ + --hash=sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a \ + --hash=sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238 \ + --hash=sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c \ + --hash=sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084 \ + --hash=sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19 \ + --hash=sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d \ + --hash=sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7 \ + --hash=sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9 \ + --hash=sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79 \ + --hash=sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96 \ + --hash=sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6 \ + --hash=sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe \ + --hash=sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842 \ + --hash=sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa \ + --hash=sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3 \ + --hash=sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d \ + --hash=sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51 \ + --hash=sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7 \ + --hash=sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09 \ + --hash=sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096 \ + --hash=sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9 \ + --hash=sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b \ + --hash=sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5 \ + --hash=sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678 \ + --hash=sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea \ + --hash=sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d \ + --hash=sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49 \ + --hash=sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc \ + --hash=sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5 \ + --hash=sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027 \ + --hash=sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0 \ + --hash=sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878 \ + --hash=sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c \ + --hash=sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa \ + --hash=sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f \ + --hash=sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6 \ + --hash=sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2 \ + --hash=sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf \ + --hash=sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708 \ + --hash=sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6 \ + --hash=sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f \ + --hash=sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd \ + --hash=sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2 \ + --hash=sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d \ + --hash=sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7 \ + --hash=sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f \ + --hash=sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5 \ + --hash=sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6 \ + --hash=sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557 \ + --hash=sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14 \ + --hash=sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7 \ + --hash=sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd \ + --hash=sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c \ + --hash=sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17 \ + --hash=sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23 \ + --hash=sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db \ + --hash=sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6 \ + --hash=sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d \ + --hash=sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9 \ + --hash=sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee \ + --hash=sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6 +yarl==1.24.5 \ + --hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \ + --hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \ + --hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \ + --hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \ + --hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \ + --hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \ + --hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \ + --hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \ + --hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \ + --hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \ + --hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \ + --hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \ + --hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \ + --hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \ + --hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \ + --hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \ + --hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \ + --hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \ + --hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \ + --hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \ + --hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \ + --hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \ + --hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \ + --hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \ + --hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \ + --hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \ + --hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \ + --hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \ + --hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \ + --hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \ + --hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \ + --hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \ + --hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \ + --hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \ + --hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \ + --hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \ + --hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \ + --hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \ + --hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \ + --hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \ + --hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \ + --hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \ + --hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \ + --hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \ + --hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \ + --hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \ + --hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \ + --hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \ + --hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \ + --hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \ + --hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \ + --hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \ + --hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \ + --hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \ + --hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \ + --hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \ + --hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \ + --hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \ + --hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \ + --hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \ + --hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \ + --hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \ + --hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \ + --hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \ + --hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \ + --hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \ + --hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \ + --hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \ + --hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \ + --hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \ + --hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \ + --hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \ + --hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \ + --hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \ + --hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \ + --hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \ + --hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \ + --hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \ + --hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \ + --hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \ + --hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \ + --hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \ + --hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \ + --hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \ + --hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \ + --hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \ + --hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \ + --hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \ + --hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \ + --hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \ + --hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \ + --hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \ + --hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \ + --hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \ + --hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \ + --hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \ + --hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \ + --hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \ + --hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \ + --hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \ + --hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \ + --hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \ + --hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \ + --hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104 diff --git a/app/src/core/config.py b/app/src/core/config.py index 7d9f76a..19bbe19 100644 --- a/app/src/core/config.py +++ b/app/src/core/config.py @@ -15,8 +15,27 @@ from src.market_data.acquisition.symbols import normalize_symbol # корень проекта BASE_DIR = Path(__file__).resolve().parents[2] -# .env файл -ENV_FILE = BASE_DIR / ".env" + +def _resolve_env_file() -> Path: + """Выбирает отдельный env-файл для записи либо обычный app/.env.""" + + raw_runtime_env_file = os.getenv("DZENTRA_RUNTIME_ENV_FILE") + + if raw_runtime_env_file is None: + return BASE_DIR / ".env" + + runtime_env_file = raw_runtime_env_file.strip() + + if not runtime_env_file: + raise RuntimeError( + "DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path" + ) + + return Path(runtime_env_file) + + +# Файл должен быть выбран окружением процесса до импорта модуля настроек. +ENV_FILE = _resolve_env_file() # загружаем переменные окружения load_dotenv(ENV_FILE) @@ -183,6 +202,69 @@ def _parse_positive_int( 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, ...]: @@ -389,10 +471,13 @@ def _load_market_data_storage_settings( # Загрузка всех настроек def load_settings() -> Settings: - bot_token = os.getenv("BOT_TOKEN", "").strip() - - if not bot_token: - raise RuntimeError("BOT_TOKEN is not set in app/.env") + bot_token = _load_secret( + "BOT_TOKEN", + required=True, + ) + exchange_api_key = _load_secret("EXCHANGE_API_KEY") + exchange_api_secret = _load_secret("EXCHANGE_API_SECRET") + db_password = _load_secret("DB_PASSWORD") exchange_base_url = os.getenv( "EXCHANGE_BASE_URL", @@ -425,8 +510,8 @@ def load_settings() -> Settings: exchange_name=os.getenv("EXCHANGE_NAME", "dzengi").strip() or "dzengi", exchange_base_url=exchange_base_url, exchange_ws_url=os.getenv("EXCHANGE_WS_URL", "").strip(), - exchange_api_key=os.getenv("EXCHANGE_API_KEY", "").strip(), - exchange_api_secret=os.getenv("EXCHANGE_API_SECRET", "").strip(), + exchange_api_key=exchange_api_key, + exchange_api_secret=exchange_api_secret, exchange_timeout_sec=_parse_int(os.getenv("EXCHANGE_TIMEOUT_SEC", "10"), 10), exchange_testnet=_parse_bool(os.getenv("EXCHANGE_TESTNET", "false")), default_symbol=os.getenv("DEFAULT_SYMBOL", "ETH/USD_LEVERAGE").strip() @@ -438,6 +523,6 @@ def load_settings() -> Settings: db_port=_parse_int(os.getenv("DB_PORT", "5432"), 5432), 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_password=os.getenv("DB_PASSWORD", "").strip(), + db_password=db_password, market_data_storage=market_data_storage, ) diff --git a/app/src/storage/README.md b/app/src/storage/README.md index d2edd27..3e0bc17 100644 --- a/app/src/storage/README.md +++ b/app/src/storage/README.md @@ -1 +1,105 @@ -Здесь будет слой доступа к данным. +# Storage: общая и историческая инфраструктура + +Пакет `src.storage` появился до канонической Market Data вертикали и +поэтому объединяет несколько разных обязанностей. Он не является пустым, +целиком устаревшим или взаимозаменяемым с `src.market_data.storage`. + +## Ownership + +| Компоненты | Фактическая роль | +|---|---| +| `session.py`, `schema.py` | Соединение на одну операцию и базовая схема приложения | +| `repositories/journal.py` | Запись событий общего журнала | +| `repositories/balance_snapshots.py` | Сохранение balance snapshots | +| `instrument_store.py`, `quote_store.py` | In-memory caches существующих consumers; не долговременная Market Data history | +| `postgres_pool.py` | Управляемый PostgreSQL pool для новой Market Data вертикали | +| `migrations.py` | Migration runner и migrations 1–9 канонической схемы `market_data` | +| `src.market_data.storage` | Persistent Trade/Quote/Candle contracts, repositories, Trade checkpoint, partitions и Retention | + +Физическое расположение `postgres_pool.py` и `migrations.py` в +историческом пакете не меняет ownership создаваемой ими схемы +`market_data`. Перенос этих модулей между пакетами не относится к +документационному Build. + +## Два пути startup + +Базовая схема используется всегда: + +```text +create_app() +→ init_schema() +→ journal_events + balance_snapshots +``` + +Поэтому PostgreSQL должен быть доступен даже при значениях: + +```text +TRADE_STREAM_ENABLED=false +MARKET_DATA_STORAGE_ENABLED=false +``` + +Канонический Market Data lifecycle включается отдельно: + +```text +MARKET_DATA_STORAGE_ENABLED=true +→ собрать pool/repository/lifecycle без I/O +→ Application startup открывает pool +→ применить migrations 1–9 +→ запустить Trade Runtime +``` + +Storage нельзя включить при выключенном Trade Stream. Ошибка pool или +migration является фатальной; частичный startup очищается до выхода. + +## Соединения PostgreSQL + +`session.get_connection()` создаёт отдельное соединение для legacy +repositories и завершает его после операции. Этот путь используется +базовой схемой и журналом. + +`PostgresConnectionPool` имеет управляемый lifecycle и используется +каноническими Market Data repositories. Pool создаётся при сборке графа +без сетевого I/O, открывается до Trade Runtime и закрывается после его +остановки. + +Эти два механизма пока существуют одновременно и должны быть явно +различимы в документации и диагностике. + +## Canonical Market Data Storage + +Пакет [`src.market_data.storage`](../market_data/storage/) содержит: + +- `PostgresTradeRepository` и persistent Trade checkpoint; +- `PostgresQuoteRepository`; +- `PostgresCandleRepository` с историей revisions; +- `PostgresMarketDataPartitionManager`; +- `PostgresMarketDataRetentionService`; +- write-only фасад `MarketDataStorage`; +- `TradeStorageObservationSink` для единственной Production write path Trades. + +Production Bootstrap подключает только Trade repository, sink и +checkpoint. Quote/Candle repositories реализованы и проверены, но +соответствующие persistent writer consumers отсутствуют. Существующие +REST-потоки Quotes/Candles в `ExchangeService` решают другую задачу и не +подключены к этой канонической Storage write path. + +`InMemoryQuoteStore` из `src.storage.quote_store` — runtime cache, а +`PostgresQuoteRepository` — долговременное каноническое хранилище. Они +решают разные задачи. + +Read-side не входит в `MarketDataStorage`: + +- [`src.market_data.access`](../market_data/access/) предоставляет Historical Access; +- [`src.market_data.replay`](../market_data/replay/) строит bounded plan и выполняет deterministic Replay. + +Retention, создание месячных партиций и Replay не запускаются +автоматически. Их выполняет только явный вызывающий код. + +## Связанные документы и тесты + +- [Текущая архитектура Trades Feed](../../../docs/architecture/trades_feed.md) +- [Эксплуатация Trade Stream Runtime](../../../docs/operations/trades_feed_runtime.md) +- [Обзор архитектуры](../../../docs/architecture/overview.md) +- [Unit-тесты Storage](../../tests/unit/storage/) +- [Unit-тесты Market Data Storage](../../tests/unit/market_data/storage/) +- [PostgreSQL integration tests](../../tests/integration/market_data/storage/) diff --git a/app/tests/static/test_documentation_integrity_gate.py b/app/tests/static/test_documentation_integrity_gate.py new file mode 100644 index 0000000..33f2724 --- /dev/null +++ b/app/tests/static/test_documentation_integrity_gate.py @@ -0,0 +1,3555 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import Callable, Iterator +from pathlib import Path, PureWindowsPath + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +GATE_SCRIPT = PROJECT_ROOT / "scripts" / "check_documentation_integrity.py" +sys.path.insert(0, str(PROJECT_ROOT)) + +import scripts.check_documentation_integrity as documentation_integrity +from scripts.check_documentation_integrity import ( + DocumentationIntegrityResult, + check_documentation_integrity, +) + +REQUIRED_DOCUMENTS = ( + "README.md", + "app/README.md", + "app/src/storage/README.md", + "docs/architecture/overview.md", + "docs/architecture/project_structure.md", + "docs/architecture/dzentra_target_architecture.md", + "docs/architecture/trades_feed.md", + "docs/operations/trades_feed_runtime.md", + "docs/roadmap/master-roadmap.md", + "docs/migrations/build_060_30_architecture.md", + "docs/migrations/build_060_20.md", + "docs/migrations/build_060_20_architecture.md", + "docs/migrations/build_060_20_1.md", + "docs/migrations/build_060_20_1_architecture.md", + "docs/migrations/build_060_21.md", + "docs/migrations/build_060_21_architecture.md", + "docs/migrations/build_060_22.md", + "docs/migrations/build_060_22_architecture.md", + "docs/migrations/build_060_23.md", + "docs/migrations/build_060_23_architecture.md", + "docs/migrations/build_060_24.md", + "docs/migrations/build_060_24_architecture.md", + "docs/migrations/build_060_25.md", + "docs/migrations/build_060_25_architecture.md", + "docs/migrations/build_060_26.md", + "docs/migrations/build_060_26_architecture.md", + "docs/migrations/build_060_27.md", + "docs/migrations/build_060_27_architecture.md", + "docs/migrations/build_060_28.md", + "docs/migrations/build_060_28_architecture.md", + "docs/migrations/build_060_29.md", + "docs/migrations/build_060_29_architecture.md", +) + + +def _run_gate( + repository_root: Path, + *, + environment: dict[str, str] | None = None, + timeout_seconds: float = 60.0, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ( + sys.executable, + str(GATE_SCRIPT), + "--repository-root", + str(repository_root), + ), + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + timeout=timeout_seconds, + env=environment, + ) + + +def _make_repository(tmp_path: Path) -> Path: + repository_root = tmp_path / "repository" + for relative_path in REQUIRED_DOCUMENTS: + target = repository_root / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"# {target.stem}\n", encoding="utf-8") + (repository_root / "app" / "tools").mkdir(parents=True, exist_ok=True) + return repository_root + + +def _write_readme(repository_root: Path, content: str) -> None: + (repository_root / "README.md").write_text(content, encoding="utf-8") + + +def _combined_output(result: subprocess.CompletedProcess[str]) -> str: + return "\n".join(part for part in (result.stdout, result.stderr) if part) + + +def test_real_repository_documentation_is_clean() -> None: + result = _run_gate(PROJECT_ROOT) + + assert result.returncode == 0, _combined_output(result) + assert "issues=0" in result.stdout + + +def test_public_api_supports_overrides_and_rejects_escaping_sources( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + + result = check_documentation_integrity( + repository_root, + required_documents=("README.md",), + markdown_sources=(Path("README.md"),), + ) + + assert isinstance(result, DocumentationIntegrityResult) + assert result.is_clean is True + assert result.scanned_documents == 1 + assert result.checked_links == 0 + assert result.issues == () + + for source in (Path("../outside"), Path("docs/../../outside"), Path("/tmp")): + with pytest.raises(ValueError, match="repository-relative"): + check_documentation_integrity( + repository_root, + required_documents=(), + markdown_sources=(source,), + ) + + +def test_missing_required_document_is_reported(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "architecture" / "trades_feed.md").unlink() + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MANIFEST_MISSING" in result.stderr + assert "docs/architecture/trades_feed.md" in result.stderr + + +def test_required_document_must_be_regular_file(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + document = repository_root / "docs" / "architecture" / "trades_feed.md" + document.unlink() + document.mkdir() + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MANIFEST_NOT_FILE" in result.stderr + + +def test_required_document_cannot_be_symlink(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + document = repository_root / "docs" / "architecture" / "trades_feed.md" + document.unlink() + document.symlink_to("overview.md") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "SYMLINK_TARGET" in result.stderr + + +def test_existing_file_directory_and_parent_link_are_accepted(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + guide = repository_root / "docs" / "guides" / "guide.md" + guide.parent.mkdir(parents=True) + guide.write_text( + "[root](../../README.md)\n" + "[directory](../architecture)\n" + "[file](../architecture/overview.md)\n", + encoding="utf-8", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_missing_relative_target_is_reported(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "[missing](docs/missing.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MISSING_TARGET" in result.stderr + assert "target='docs/missing.md'" in result.stderr + + +@pytest.mark.parametrize( + "relative_document", + ("CONTRIBUTING.md", "app/DEVELOPMENT.md"), +) +def test_all_root_and_app_level_markdown_files_are_scanned( + tmp_path: Path, + relative_document: str, +) -> None: + repository_root = _make_repository(tmp_path) + document = repository_root / relative_document + document.parent.mkdir(parents=True, exist_ok=True) + document.write_text("[missing](missing.md)\n", encoding="utf-8") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert f"{relative_document}:1: MISSING_TARGET" in result.stderr + + +@pytest.mark.parametrize( + "target", + ( + "../outside.md", + "%2e%2e/outside.md", + "docs/%2e%2e/%2e%2e/outside.md", + ), +) +def test_plain_and_encoded_repository_escape_is_rejected( + tmp_path: Path, + target: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"[outside]({target})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "OUTSIDE_REPOSITORY" in result.stderr + + +def test_symlink_cancelled_by_parent_segment_is_still_rejected( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + external = tmp_path / "outside" + nested = external / "nested" + nested.mkdir(parents=True) + (external / "README.md").write_text("outside\n", encoding="utf-8") + (repository_root / "alias").symlink_to(nested, target_is_directory=True) + _write_readme(repository_root, "[outside](alias/../README.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "SYMLINK_TARGET" in result.stderr + + +def test_parent_segment_through_real_directory_stays_inside_repository( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "[root](docs/../README.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + ("target", "expected_code"), + ( + ("/etc/passwd", "ABSOLUTE_TARGET"), + ("C:/Windows/system.ini", "ABSOLUTE_TARGET"), + ("//server/share", "ABSOLUTE_TARGET"), + (r"\\server\share", "ABSOLUTE_TARGET"), + (r"docs\architecture\overview.md", "BACKSLASH_TARGET"), + ("%43%3Afoo", "ABSOLUTE_TARGET"), + ("file:///tmp/document.md", "FORBIDDEN_SCHEME"), + ("data:text/plain,test", "FORBIDDEN_SCHEME"), + ("javascript:alert(1)", "FORBIDDEN_SCHEME"), + ("ftp://example.test/file", "FORBIDDEN_SCHEME"), + ), +) +def test_unsafe_absolute_and_scheme_targets_are_rejected( + tmp_path: Path, + target: str, + expected_code: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"[unsafe]({target})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert expected_code in result.stderr + + +@pytest.mark.parametrize( + "target", + ( + "docs/architecture/overview.md?raw=1", + "docs/architecture/overview.md?", + "docs/architecture/overview.md?#section", + ), +) +def test_local_query_string_is_rejected( + tmp_path: Path, + target: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"[query]({target})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "LOCAL_QUERY" in result.stderr + + +def test_malformed_url_is_reported_without_stopping_other_documents( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "[bad](http://[::1)\n" + "[missing](missing-after-malformed-url.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MALFORMED_TARGET" in result.stderr + assert "target='missing-after-malformed-url.md'" in result.stderr + assert "Traceback" not in result.stderr + + +def test_case_mismatch_is_reported_portably(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "[case](docs/Architecture/overview.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "PATH_CASE_MISMATCH" in result.stderr + + +def test_symlink_target_is_rejected(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + link = repository_root / "docs" / "architecture" / "overview-link.md" + link.symlink_to("overview.md") + _write_readme(repository_root, "[link](docs/architecture/overview-link.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "SYMLINK_TARGET" in result.stderr + + +def test_symlink_directory_component_is_rejected(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + alias = repository_root / "docs" / "architecture-alias" + alias.symlink_to("architecture", target_is_directory=True) + _write_readme(repository_root, "[link](docs/architecture-alias/overview.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "SYMLINK_TARGET" in result.stderr + + +def test_symlink_markdown_source_is_reported(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + source = repository_root / "docs" / "linked-source.md" + source.symlink_to("architecture/overview.md") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "SYMLINK_TARGET" in result.stderr + assert "docs/linked-source.md" in result.stderr + + +def test_symlink_directory_inside_markdown_scope_is_reported( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + external = tmp_path / "external-docs" + external.mkdir() + (external / "hidden.md").write_text( + "[hidden](missing.md)\n", + encoding="utf-8", + ) + linked_directory = repository_root / "docs" / "linked-directory" + linked_directory.symlink_to(external, target_is_directory=True) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "SYMLINK_SOURCE" in result.stderr + assert "docs/linked-directory" in result.stderr + + +def test_recursive_discovery_reports_io_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository_root = _make_repository(tmp_path) + + def broken_walk( + top: str | os.PathLike[str], + topdown: bool = True, + onerror: Callable[[OSError], object] | None = None, + followlinks: bool = False, + ) -> Iterator[tuple[str, list[str], list[str]]]: + del topdown, followlinks + if onerror is not None: + onerror(PermissionError(13, "denied", os.fspath(top))) + return iter(()) + + monkeypatch.setattr(os, "walk", broken_walk) + result = check_documentation_integrity( + repository_root, + required_documents=(), + markdown_sources=(Path("docs"),), + ) + + assert result.is_clean is False + assert [issue.code for issue in result.issues] == ["SOURCE_IO_ERROR"] + + +def test_shallow_discovery_reports_io_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository_root = _make_repository(tmp_path) + original_glob = Path.glob + + def broken_glob(self: Path, pattern: str) -> Iterator[Path]: + if self == repository_root: + raise PermissionError(13, "denied", os.fspath(self)) + return original_glob(self, pattern) + + monkeypatch.setattr(Path, "glob", broken_glob) + result = check_documentation_integrity(repository_root) + + assert result.is_clean is False + assert "SOURCE_IO_ERROR" in {issue.code for issue in result.issues} + + +def test_image_requires_file_while_normal_directory_link_is_allowed( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + image = repository_root / "docs" / "image.png" + image.write_bytes(b"not-a-real-image") + _write_readme( + repository_root, + "![file](docs/image.png)\n" + "[directory](docs/architecture)\n" + "![directory](docs/architecture)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert result.stderr.count("IMAGE_NOT_FILE") == 1 + + +@pytest.mark.parametrize( + "link", + ( + "[file](README.md/)", + "[file](README.md/.)", + "[file](README.md/missing/..)", + "![image](README.md/)", + ), +) +def test_file_target_cannot_have_directory_uri_intent( + tmp_path: Path, + link: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{link}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "FILE_AS_DIRECTORY" in result.stderr + + +def test_directory_target_may_have_trailing_slash(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "[directory](docs/architecture/)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "target", + ("README.md/child", "README.md/child/grandchild"), +) +def test_file_cannot_be_intermediate_path_component( + tmp_path: Path, + target: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"[child]({target})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "FILE_AS_DIRECTORY" in result.stderr + assert "PATH_IO_ERROR" not in result.stderr + + +def test_fragments_validate_base_path_but_not_heading_slug(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + plain = repository_root / "docs" / "plain.txt" + plain.write_text("plain\n", encoding="utf-8") + _write_readme( + repository_root, + "[same](#not-checked)\n" + "[cross](docs/architecture/overview.md#not-checked)\n" + "[directory](docs/architecture#section)\n" + "[plain](docs/plain.txt#section)\n" + "[empty](docs/architecture/overview.md#)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "DIRECTORY_FRAGMENT" in result.stderr + assert "NON_MARKDOWN_FRAGMENT" in result.stderr + assert "EMPTY_FRAGMENT" in result.stderr + assert "#not-checked" not in result.stderr + + +def test_reference_links_images_angle_targets_and_titles_are_supported( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "image.png").write_bytes(b"image") + _write_readme( + repository_root, + "[guide][guide-ref]\n" + "![image][image-ref]\n" + "[angle]( \"Overview\")\n" + "\n" + "[guide-ref]: docs/architecture/overview.md 'Guide'\n" + "[image-ref]: docs/image.png\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_reference_definitions_inside_quote_and_list_are_supported( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "> [quote][quote-ref]\n" + ">\n" + "> [quote-ref]: docs/architecture/overview.md\n" + "- [list][list-ref]\n" + "- [list-ref]: docs/architecture/project_structure.md\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize("marker", ("- ", "10. ")) +def test_reference_definition_on_list_continuation_uses_list_indent( + tmp_path: Path, + marker: str, +) -> None: + repository_root = _make_repository(tmp_path) + continuation_indent = " " * len(marker) + _write_readme( + repository_root, + f"{marker}[list][list-ref]\n" + f"{continuation_indent}\n" + f"{continuation_indent}[list-ref]: " + "docs/architecture/overview.md\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_nested_list_reference_definition_uses_full_container_path( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "- outer\n" + " - [nested][nested-ref]\n" + " \n" + " [nested-ref]: docs/architecture/overview.md\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_undefined_and_duplicate_references_are_reported(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "[missing][unknown]\n" + "\n" + "[known]: docs/architecture/overview.md\n" + "[known]: docs/architecture/project_structure.md\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNDEFINED_REFERENCE" in result.stderr + assert "DUPLICATE_REFERENCE" in result.stderr + + +def test_links_inside_inline_and_fenced_code_are_ignored(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "`[inline](missing-inline.md)`\n" + "```markdown\n" + "[fenced](missing-fenced.md)\n" + "```\n" + "[real](docs/architecture/overview.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_escaped_markdown_delimiters_use_backslash_parity(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "\\[literal](missing-literal.md)\n" + "\\`[real](missing-real.md)\\`\n" + "\\\\[also-real](missing-even.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "missing-literal.md" not in result.stderr + assert "target='missing-real.md'" in result.stderr + assert "target='missing-even.md'" in result.stderr + + +def test_links_inside_html_comments_are_ignored(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "\n" + "[real](docs/architecture/overview.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_comment_backtick_cannot_mask_link_after_comment(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "\n" + "[real](missing-after-comment.md)\n" + "`\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-after-comment.md'" in result.stderr + + +@pytest.mark.parametrize( + "paragraph", + ( + "> paragraph", + "- paragraph", + "- > paragraph", + ), +) +def test_html_comment_interrupts_lazy_container_paragraph( + tmp_path: Path, + paragraph: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + f"{paragraph}\n" + "\n" + "[outside](missing-after-html-comment.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "missing-inside-html-comment.md" not in result.stderr + assert "target='missing-after-html-comment.md'" in result.stderr + + +def test_comment_markers_inside_code_cannot_mask_link_between_code_spans( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "``\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-between-code.md'" in result.stderr + + +def test_fence_marker_inside_comment_cannot_hide_link_and_unclosed_fence( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "\n" + "[real](missing-after-block-comment.md)\n" + "```\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-after-block-comment.md'" in result.stderr + assert "README.md:5: UNCLOSED_CODE_FENCE" in result.stderr + + +def test_inline_backtick_before_fence_cannot_consume_block_opener( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "text ```\n" + "```\n" + "[inside](missing-inside-unclosed-fence.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "README.md:2: UNCLOSED_CODE_FENCE" in result.stderr + assert "missing-inside-unclosed-fence.md" not in result.stderr + + +def test_inline_code_cannot_cross_blank_line_paragraph_boundary( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "`\n" + "[real](missing-before-blank.md)\n" + "\n" + "`\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-before-blank.md'" in result.stderr + + +def test_semantic_blank_inside_blockquote_ends_inline_code_span( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "> `\n" + "> [real](missing-before-quote-blank.md)\n" + ">\n" + "> `\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-before-quote-blank.md'" in result.stderr + + +def test_fenced_block_interrupts_inline_span_and_masks_only_fence_content( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "text ```\n" + "```\n" + "[inside](missing-inside-fence.md)\n" + "```\n" + "[real](missing-after-fence.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "missing-inside-fence.md" not in result.stderr + assert "target='missing-after-fence.md'" in result.stderr + + +def test_atx_heading_ends_inline_span_before_next_paragraph( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "# `\n" + "[real](missing-after-heading.md)\n" + "`\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-after-heading.md'" in result.stderr + + +def test_multiline_inline_code_inside_one_paragraph_is_still_masked( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "paragraph `[inside](missing-inline-code.md)\n" + "continues here` after code\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_pipe_in_ordinary_inline_code_does_not_create_table_cells( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "paragraph `[inside](missing-inline-pipe.md) | continues` after\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_pipe_lines_without_delimiter_row_remain_one_inline_paragraph( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "paragraph `left | [inside](missing-pseudo-table.md)\n" + "right | continues` after\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_indented_continuation_cannot_hide_rendered_link(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "paragraph\n" + " [real](missing-indented-continuation.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-indented-continuation.md'" in result.stderr + + +def test_nested_list_continuation_cannot_hide_rendered_link(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "- item\n" + " [nested](missing-nested-list.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-nested-list.md'" in result.stderr + + +def test_blockquote_fence_is_recognised_as_unclosed_block(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "> ```\n" + "> [inside](missing-inside-blockquote-fence.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "README.md:1: UNCLOSED_CODE_FENCE" in result.stderr + assert "missing-inside-blockquote-fence.md" not in result.stderr + + +def test_blockquote_fence_cannot_be_closed_by_top_level_marker( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "> ```\n" + "> [inside](missing-inside-blockquote.md)\n" + "[outside](missing-outside-blockquote.md)\n" + "```\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "README.md:1: UNCLOSED_CODE_FENCE" in result.stderr + assert "missing-inside-blockquote.md" not in result.stderr + assert "target='missing-outside-blockquote.md'" in result.stderr + + +def test_top_level_fence_cannot_be_closed_by_blockquote_marker( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "```\n" + "> ```\n" + "[inside](missing-inside-top-level.md)\n" + "```\n" + "[outside](docs/architecture/overview.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_correctly_closed_blockquote_fence_masks_only_its_content( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "> ```\n" + "> [inside](missing-inside-closed-blockquote.md)\n" + "> ```\n" + "[outside](missing-after-closed-blockquote.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "missing-inside-closed-blockquote.md" not in result.stderr + assert "target='missing-after-closed-blockquote.md'" in result.stderr + + +def test_multiline_inline_code_inside_blockquote_remains_one_span( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "> paragraph `[inside](missing-inline-blockquote.md)\n" + "> continues` after\n" + "[outside](docs/architecture/overview.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_lazy_blockquote_continuation_keeps_rendered_link_visible( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "> paragraph\n" + " [real](missing-lazy-blockquote-link.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-lazy-blockquote-link.md'" in result.stderr + + +@pytest.mark.parametrize( + "content", + ( + "> paragraph `[inside](missing-lazy-quote-code.md)\n" + "continues` after\n", + "- paragraph `[inside](missing-lazy-list-code.md)\n" + "continues` after\n", + ), +) +def test_lazy_container_continuation_keeps_inline_code_span( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, content) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_multiline_inline_code_inside_list_item_remains_one_span( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "- paragraph `[inside](missing-inline-list.md)\n" + " continues` after\n" + "[outside](docs/architecture/overview.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_inline_code_cannot_cross_into_sibling_list_item( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "- first `\n" + "- [real](missing-sibling-list-item.md)\n" + " `\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-sibling-list-item.md'" in result.stderr + + +def test_inline_code_cannot_cross_nested_sibling_list_item( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "- outer\n" + " - first `\n" + " - [real](missing-nested-sibling-list-item.md)\n" + " `\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-nested-sibling-list-item.md'" in result.stderr + + +def test_ordered_list_fence_uses_its_continuation_indent( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "10. ```\n" + " [inside](missing-inside-list-fence.md)\n" + " ```\n" + "[outside](missing-after-list-fence.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "missing-inside-list-fence.md" not in result.stderr + assert "target='missing-after-list-fence.md'" in result.stderr + + +@pytest.mark.parametrize( + "content", + ( + "- > ```\n" + " > [inside](missing-list-quote-fence.md)\n" + " > ```\n", + "> - ```\n" + "> [inside](missing-quote-list-fence.md)\n" + "> ```\n", + ), +) +def test_fence_uses_full_ordered_container_path( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, content) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_blank_line_without_quote_marker_splits_list_quote_fences( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "- > ```\n" + " > hidden\n" + "\n" + " > ```\n" + " > [after](missing-inside-second-fence.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert result.stderr.count("UNCLOSED_CODE_FENCE") == 2 + assert "README.md:1: UNCLOSED_CODE_FENCE" in result.stderr + assert "README.md:4: UNCLOSED_CODE_FENCE" in result.stderr + assert "missing-inside-second-fence.md" not in result.stderr + + +@pytest.mark.parametrize( + ("content", "target"), + ( + ( + "- ```\n" + " ```\n" + " [real](missing-after-unordered-list-fence.md)\n", + "missing-after-unordered-list-fence.md", + ), + ( + "10. ```\n" + " ```\n" + " [real](missing-after-ordered-list-fence.md)\n", + "missing-after-ordered-list-fence.md", + ), + ( + "- \n" + " [real](missing-after-list-comment.md)\n", + "missing-after-list-comment.md", + ), + ), +) +def test_list_block_preserves_context_for_following_paragraph( + tmp_path: Path, + content: str, + target: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, content) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert f"target='{target}'" in result.stderr + + +@pytest.mark.parametrize( + "block", + ( + "- ```\n ```\n", + "- \n", + "10. ```\n ```\n", + ), +) +def test_inline_span_after_list_block_cannot_cross_dedent( + tmp_path: Path, + block: str, +) -> None: + repository_root = _make_repository(tmp_path) + marker_indent = 4 if block.startswith("10.") else 2 + _write_readme( + repository_root, + block + + " " * (marker_indent + 2) + + "paragraph `\n" + + "[outside](missing-after-list-dedent.md) `\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-after-list-dedent.md'" in result.stderr + + +def test_gfm_table_cells_are_independent_inline_containers(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "| A | B | C |\n" + "|---|---|---|\n" + "| ` | [real](missing-table-cell.md) | ` |\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-table-cell.md'" in result.stderr + + +def test_gfm_table_without_outer_pipes_is_confirmed_by_delimiter_row( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "A | B | C\n" + "---|---|---\n" + "` | [real](missing-table-without-outer-pipes.md) | `\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-table-without-outer-pipes.md'" in result.stderr + + +@pytest.mark.parametrize( + "header", + ( + "- `[inside](missing-lazy-table-code.md) | x`", + "> `[inside](missing-lazy-table-code.md) | x`", + "- > `[inside](missing-lazy-table-code.md) | x`", + ), +) +def test_table_delimiter_cannot_be_lazy_container_continuation( + tmp_path: Path, + header: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{header}\n---|---\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_indented_table_delimiter_remains_inside_list_container( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "- A | B\n" + " ---|---\n" + " [real](missing-inside-list-table.md) | value\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-inside-list-table.md'" in result.stderr + + +@pytest.mark.parametrize( + "content", + ( + "A | B\n" + "---|---\n" + "- paragraph `[inside](missing-after-table-list.md) | continues`\n", + "A | B\n" + "---|---\n" + "# heading `[inside](missing-after-table-heading.md) | continues`\n", + "- A | B\n" + " ---|---\n" + "- paragraph `[inside](missing-sibling-after-list-table.md) | continues`\n", + "- `[inside](missing-between-sibling-items.md) | text`\n" + "- ---|---\n", + ), +) +def test_gfm_table_does_not_cross_new_block_or_list_item( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, content) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_top_level_and_list_indented_code_are_masked_until_dedent( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + " [first](missing-top-level-indented.md)\n" + "paragraph\n" + "\n" + " [second](missing-after-blank-indented.md)\n" + "- item\n" + "\n" + " [third](missing-list-indented.md)\n" + "> quote block\n" + " [fourth](missing-after-block-indented.md)\n" + "[outside](missing-after-indented-code.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "missing-top-level-indented.md" not in result.stderr + assert "missing-after-blank-indented.md" not in result.stderr + assert "missing-list-indented.md" not in result.stderr + assert "target='missing-after-block-indented.md'" in result.stderr + assert "target='missing-after-indented-code.md'" in result.stderr + + +def test_indented_code_after_quote_setext_and_table_blocks_is_masked( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + ">\n" + "> [quote](missing-quote-indented.md)\n" + "Heading\n" + "=======\n" + " [setext](missing-setext-indented.md)\n" + "A | B\n" + "---|---\n" + "x | y\n" + " [table](missing-table-indented.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_many_unique_unmatched_backtick_runs_complete_within_cli_timeout( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "\n".join( + f"text {'`' * run_length} unmatched" + for run_length in range(1, 701) + ), + ) + + result = _run_gate(repository_root, timeout_seconds=15.0) + + assert result.returncode == 0, _combined_output(result) + + +def test_closing_parenthesis_inside_quoted_title_is_supported( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + '[valid](docs/architecture/overview.md "title ) remains title")\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_unclosed_fenced_code_is_reported_at_opening_line(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "# Title\n\n```python\nprint('open')\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "README.md:3: UNCLOSED_CODE_FENCE" in result.stderr + + +def test_raw_html_link_is_rejected(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + 'overview\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + + +@pytest.mark.parametrize( + "html_link", + ( + 'text', + ">", + ), +) +def test_raw_html_link_with_angle_bracket_inside_quoted_attribute_is_rejected( + tmp_path: Path, + html_link: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{html_link}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + + +def test_raw_html_attribute_name_inside_quoted_value_is_not_a_link( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + 'text without link\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "content", + ( + '
plain
', + "
plain
", + "< a href=missing.md>", + ), +) +def test_target_like_text_outside_real_raw_html_link_is_ignored( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{content}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "content", + ( + 'text', + 'text', + '[inside](missing-image-attribute.md)', + ), +) +def test_markdown_shape_inside_raw_html_attribute_is_not_a_link( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{content}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "content", + ( + '', + 'x', + '', + '', + '', + ), +) +def test_malformed_raw_html_tag_cannot_hide_markdown_link( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{content}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MISSING_TARGET" in result.stderr + + +@pytest.mark.parametrize( + "content", + ( + "text", + "", + "text", + ), +) +def test_valueless_raw_html_link_attribute_is_rejected( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{content}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + + +def test_tag_shaped_angle_destination_has_markdown_precedence( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "[inline]()\n" + "[reference][docs-ref]\n" + "\n" + "[docs-ref]: \n" + '[title](docs "")\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_multiline_raw_html_attribute_cannot_define_reference( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + 'text\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "definition", + ( + '[docs-ref]: README.md\n " [inside](missing-title.md)"', + '> [docs-ref]: README.md\n> " [inside](missing-title.md)"', + '- [docs-ref]: README.md\n " [inside](missing-title.md)"', + ), +) +def test_reference_title_on_next_line_stays_in_same_container( + tmp_path: Path, + definition: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + f"[reference][docs-ref]\n\n{definition}\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_reference_title_in_different_container_is_not_protected( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + '> [docs-ref]: README.md\n\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + + +@pytest.mark.parametrize( + "content", + ( + 'text', + 'text', + ), +) +def test_invalid_raw_html_spacing_does_not_create_link_tag( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{content}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_one_line_ending_inside_raw_html_tag_is_supported(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, 'text\n') + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + + +def test_many_unmatched_brackets_complete_within_cli_timeout( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "[" * 20_000 + "\n") + + result = _run_gate(repository_root, timeout_seconds=15.0) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "content", + ( + '[real text](README.md "")', + ), +) +def test_inline_tokens_inside_label_do_not_close_markdown_link( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"{content}\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_autolink_inside_label_deactivates_outer_link(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + '[real ](README.md "")\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + assert "links=1" in result.stderr + + +def test_escaped_autolink_does_not_receive_token_priority(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + r"[real \](missing.md)" "\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_inner_link_deactivates_outer_link_and_exposes_outer_title( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + '[outer [inner](missing-inner.md)](README.md "")\n', + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-inner.md'" in result.stderr + assert "UNSUPPORTED_HTML_LINK" in result.stderr + assert "links=1" in result.stderr + + +def test_image_inside_link_preserves_both_targets(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "[![alt](missing-image.png)](missing-outer.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-image.png'" in result.stderr + assert "target='missing-outer.md'" in result.stderr + assert "links=2" in result.stderr + + +def test_link_inside_image_preserves_both_targets(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "![[inner](missing-inner.md)](missing-image.png)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "target='missing-inner.md'" in result.stderr + assert "target='missing-image.png'" in result.stderr + assert "links=2" in result.stderr + + +def test_malformed_outer_angle_target_does_not_hide_inner_link( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "[bad](< [good](missing-good.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MALFORMED_LINK" in result.stderr + assert "target='missing-good.md'" in result.stderr + + +@pytest.mark.parametrize( + ("target", "filename"), + ( + ("", "foo(bar"), + ("", "foo)bar"), + (r"bar>", "foo>bar"), + ), +) +def test_angle_destination_supports_parentheses_and_escaped_closer( + tmp_path: Path, + target: str, + filename: str, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / filename).write_text("target\n", encoding="utf-8") + _write_readme(repository_root, f"[valid]({target})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "content", + ( + "[invalid]()\n", + "[invalid]()\n", + ), +) +def test_invalid_angle_destination_is_not_protected( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, content) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MALFORMED_LINK" in result.stderr + + +def test_angle_destination_tab_reaches_target_control_policy( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "[invalid]()\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "CONTROL_CHARACTER" in result.stderr + assert "MALFORMED_LINK" not in result.stderr + + +@pytest.mark.parametrize( + "content", + ( + '[real](\n\nREADME.md "")\n', + '[real](README.md\n\n"")\n', + '[real](README.md ""\n\n)\n', + '[real](\n\n"")\n', + ), +) +def test_blank_line_around_link_title_is_not_protected( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, content) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + + +@pytest.mark.parametrize( + "content", + ( + '[id]: README.md "\\"\n', + r"[id]: README.md (\)" "\n", + ), +) +def test_escaped_final_title_delimiter_is_not_protected( + tmp_path: Path, + content: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, content) + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNSUPPORTED_HTML_LINK" in result.stderr + + +def test_forbidden_uri_autolink_is_validated(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "FORBIDDEN_SCHEME" in result.stderr + + +def test_cr_only_line_numbers_are_reported_correctly(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "ok\r[x](missing.md)\r") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "README.md:2" in result.stderr + + +def test_form_feed_does_not_start_reference_definition(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "plain\f[id]: README.md\n[use][id]\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "UNDEFINED_REFERENCE" in result.stderr + + +def test_long_title_escape_parity_uses_forward_scan() -> None: + assert documentation_integrity._valid_link_title( + '"' + "\\" * 40_000 + '"' + ) + assert not documentation_integrity._valid_link_title( + '"' + "\\" * 40_001 + '"' + ) + + +@pytest.mark.parametrize("source", ("[a](<" * 20_000, '[a](x "' * 20_000)) +def test_many_semantic_link_failures_use_indexed_scan(source: str) -> None: + links, issues = documentation_integrity._parse_markdown_links( + source, + source="README.md", + ) + + assert links == () + assert len(issues) == 20_000 + + +@pytest.mark.parametrize("prefix", ("', + 'x]]>', + '">', + '', + ), +) +def test_non_rendered_html_inside_block_does_not_trigger_policy( + hidden: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"
\n{hidden}\n
\n", + source="README.md", + ) + + assert links == () + assert issues == () + + +def test_pre_block_still_rejects_rendered_raw_html_link() -> None: + links, issues = documentation_integrity._parse_markdown_links( + '
\nx\n
\n', + source="README.md", + ) + + assert links == () + assert [issue.code for issue in issues] == ["UNSUPPORTED_HTML_LINK"] + + +@pytest.mark.parametrize("prefix", ("", "x ")) +def test_inline_html_in_table_cell_does_not_break_table_boundary( + prefix: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + "A | B\n---|---\n" + f"{prefix}[not | a-link](missing.md)\n", + source="README.md", + ) + + assert links == () + assert issues == () + + +@pytest.mark.parametrize("ticks", ("`", "``")) +def test_backticks_inside_raw_html_attributes_do_not_open_code_span( + ticks: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f'[real](missing.md)' + f'{ticks}\n', + source="README.md", + ) + + assert [link.target for link in links] == ["missing.md"] + assert issues == () + + +@pytest.mark.parametrize("ticks", ("`", "``")) +def test_backticks_inside_autolinks_do_not_open_code_span( + ticks: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[real](missing.md)" + f"{ticks}\n", + source="README.md", + ) + + assert "missing.md" in {link.target for link in links} + assert issues == () + + +@pytest.mark.parametrize( + "would_be_token", + ( + '', + "", + ), +) +def test_earlier_backtick_takes_precedence_over_html_token( + would_be_token: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"`{would_be_token}[real](missing.md)`\n", + source="README.md", + ) + + assert [link.target for link in links] == ["missing.md"] + assert issues == () + + +@pytest.mark.parametrize( + ("opening", "closing"), + ( + ("`foo\\", "`"), + ("``foo\\", "``"), + ), +) +def test_backslash_does_not_escape_code_span_closer( + opening: str, + closing: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"{opening}{closing} [real](missing.md) {closing}\n", + source="README.md", + ) + + assert [link.target for link in links] == ["missing.md"] + assert issues == () + + +@pytest.mark.parametrize( + "argument", + ( + "foo`bar`baz.md", + "", + ), +) +def test_backticks_inside_link_destination_are_literal(argument: str) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[link]({argument})\n", + source="README.md", + ) + + assert [link.target for link in links] == ["foo`bar`baz.md"] + assert issues == () + + +def test_angle_inline_destination_may_contain_spaces(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + target = repository_root / "docs" / "my file.md" + target.write_text("space\n", encoding="utf-8") + _write_readme(repository_root, "[link]()\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_angle_destination_preserves_significant_trailing_space( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "foo.md").write_text( + "without space\n", + encoding="utf-8", + ) + _write_readme(repository_root, "[link]()\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MISSING_TARGET" in result.stderr + + +def test_angle_destination_can_resolve_filename_with_trailing_space( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "foo.md ").write_text( + "with space\n", + encoding="utf-8", + ) + _write_readme(repository_root, "[link]()\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_angle_destination_preserves_significant_leading_space( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, "[link](< README.md>)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MISSING_TARGET" in result.stderr + + +def test_angle_destination_can_resolve_filename_with_leading_space( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / " leading.md").write_text( + "with space\n", + encoding="utf-8", + ) + _write_readme(repository_root, "[link](< leading.md>)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "identifier", + ( + "", + "", + "", + ), +) +def test_reference_identifiers_are_not_rendered_as_html_or_autolinks( + identifier: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[{identifier}]: README.md\n\n[use][{identifier}]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["README.md", "README.md"] + assert issues == () + + +@pytest.mark.parametrize( + "identifier", + ( + "`id`", + "id", + ), +) +def test_reference_labels_are_matched_as_literal_identifiers( + identifier: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[{identifier}]: README.md\n\n[use][{identifier}]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["README.md", "README.md"] + assert issues == () + + +@pytest.mark.parametrize( + ("identifier", "trailing"), + ( + ("`id", " later `"), + (""), + (""), + ), +) +def test_resolved_full_reference_suffix_precedes_inline_tokens( + identifier: str, + trailing: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[{identifier}]: README.md\n\n[use][{identifier}]{trailing}\n", + source="README.md", + ) + + assert [link.target for link in links] == ["README.md", "README.md"] + assert issues == () + + +@pytest.mark.parametrize( + ("definition_label", "usage_label"), + ( + (r"foo\!", "foo!"), + ("a&b", "a&b"), + ("a&b", "a&b"), + ("a\N{NO-BREAK SPACE}b", "a b"), + ("a\fb", "a b"), + ), +) +def test_reference_labels_match_normalised_source_not_inline_content( + definition_label: str, + usage_label: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[{definition_label}]: README.md\n\n[use][{usage_label}]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["README.md"] + assert [issue.code for issue in issues] == ["UNDEFINED_REFERENCE"] + + +def test_unescaped_nested_bracket_does_not_form_full_reference_label() -> None: + links, issues = documentation_integrity._parse_markdown_links( + r"[ref\[bar\]]: missing.md" + "\n\n[use][ref[bar]]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["missing.md"] + assert issues == () + + +def test_escaped_brackets_form_full_reference_label() -> None: + links, issues = documentation_integrity._parse_markdown_links( + r"[ref\[bar\]]: missing.md" + + "\n\n" + + r"[use][ref\[bar\]]" + + "\n", + source="README.md", + ) + + assert [link.target for link in links] == ["missing.md", "missing.md"] + assert issues == () + + +def test_successful_later_reference_consumes_earlier_suffix() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[baz]: baz.md\n\n[foo][bar][baz]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["baz.md", "baz.md"] + assert issues == () + + +def test_selected_outer_reference_leaves_trailing_shortcut_available() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[bar]: bar.md\n[baz]: baz.md\n\n[foo][bar][baz]\n", + source="README.md", + ) + + assert [link.target for link in links] == [ + "bar.md", + "baz.md", + "bar.md", + "baz.md", + ] + assert issues == () + + +@pytest.mark.parametrize( + ("body", "expected_usages"), + ( + ("[[x][bar]][baz]", (("bar.md", False), ("baz.md", False))), + ("![[x][bar]][baz]", (("baz.md", True), ("bar.md", False))), + ("[![x][bar]][baz]", (("baz.md", False), ("bar.md", True))), + ), +) +def test_nested_reference_precedence_preserves_actual_targets( + body: str, + expected_usages: tuple[tuple[str, bool], ...], +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[bar]: bar.md\n[baz]: baz.md\n\n{body}\n", + source="README.md", + ) + + assert [(link.target, link.is_image) for link in links[2:]] == list( + expected_usages + ) + assert issues == () + + +def test_successful_inline_link_consumes_unresolved_outer_suffix() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[foo][bar](missing.md)\n", + source="README.md", + ) + + assert [link.target for link in links] == ["missing.md"] + assert issues == () + + +def test_unresolved_full_reference_does_not_fallback_to_shortcut() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[foo]: foo.md\n\n[foo][bar]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["foo.md"] + assert [issue.code for issue in issues] == ["UNDEFINED_REFERENCE"] + + +def test_unoverlapped_full_reference_remains_strictly_undefined() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[foo][missing]\n", + source="README.md", + ) + + assert links == () + assert [issue.code for issue in issues] == ["UNDEFINED_REFERENCE"] + + +def test_raw_html_in_rendered_reference_text_is_still_rejected() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[label]: README.md\n\n[][label]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["README.md", "README.md"] + assert [issue.code for issue in issues] == ["UNSUPPORTED_HTML_LINK"] + + +@pytest.mark.parametrize( + "destination", + ( + "foo)bar.md", + "(" * 33 + "target.md" + ")" * 33, + ), +) +def test_reference_definition_rejects_invalid_commonmark_destination( + destination: str, +) -> None: + links, issues = documentation_integrity._parse_markdown_links( + f"[identifier]: {destination}\n\n[identifier]\n", + source="README.md", + ) + + assert links == () + assert "MALFORMED_REFERENCE" in {issue.code for issue in issues} + + +def test_reference_definition_angle_destination_may_contain_spaces() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[identifier]: \n\n[identifier]\n", + source="README.md", + ) + + assert [link.target for link in links] == [ + "path with spaces.md", + "path with spaces.md", + ] + assert issues == () + + +def test_reference_definition_accepts_escaped_closing_parenthesis() -> None: + links, issues = documentation_integrity._parse_markdown_links( + r"[identifier]: foo\)bar.md" "\n\n[identifier]\n", + source="README.md", + ) + + assert [link.target for link in links] == [r"foo\)bar.md", r"foo\)bar.md"] + assert issues == () + + +def test_multiline_reference_definition_label_is_supported() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[foo\n bar]: README.md\n\n[use][foo bar]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["README.md", "README.md"] + assert issues == () + + +def test_reference_destination_may_start_after_one_line_ending() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[foo]:\n /url\n\n[foo]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["/url", "/url"] + assert issues == () + + +def test_indented_reference_destination_after_line_ending_is_supported() -> None: + links, issues = documentation_integrity._parse_markdown_links( + " [foo]:\n /url\n 'the title'\n\n[foo]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["/url", "/url"] + assert issues == () + + +def test_angle_reference_destination_may_contain_spaces() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[Foo bar]:\n\n'title'\n\n[Foo bar]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["my url", "my url"] + assert issues == () + + +def test_reference_title_may_contain_one_line_ending() -> None: + links, issues = documentation_integrity._parse_markdown_links( + '[foo]: /url "first\nsecond"\n\n[foo]\n', + source="README.md", + ) + + assert [link.target for link in links] == ["/url", "/url"] + assert issues == () + + +def test_reference_title_may_span_several_lines() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[foo]: /url '\n title\n line1\n line2\n '\n\n[foo]\n", + source="README.md", + ) + + assert [link.target for link in links] == ["/url", "/url"] + assert issues == () + + +def test_bare_reference_definition_requires_destination() -> None: + links, issues = documentation_integrity._parse_markdown_links( + "[foo]:\n\n[foo]\n", + source="README.md", + ) + + assert links == () + assert [issue.code for issue in issues] == ["MALFORMED_REFERENCE"] + + +@pytest.mark.parametrize( + "outer", + ( + '[bad](README.md "unterminated ', + "[bad](README.md 'unterminated ", + "[bad](README.md (unterminated ", + "[bad]( None: + links, issues = documentation_integrity._parse_markdown_links( + outer + "[good](missing.md)\n", + source="README.md", + ) + + assert [link.target for link in links] == ["missing.md"] + assert "MALFORMED_LINK" in {issue.code for issue in issues} + + +def test_long_valid_inner_link_is_not_replaced_by_complexity_policy() -> None: + target = "https://example.test/" + "x" * 5_000 + links, issues = documentation_integrity._parse_markdown_links( + f"[bad](< [good]({target}))\n", + source="README.md", + ) + + assert [link.target for link in links] == [target] + assert "MALFORMED_LINK" in {issue.code for issue in issues} + + +def test_encoded_reserved_characters_remain_part_of_local_filename( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + architecture = repository_root / "docs" / "architecture" + (architecture / "a#b.md").write_text("hash\n", encoding="utf-8") + (architecture / "a?b.md").write_text("query\n", encoding="utf-8") + _write_readme( + repository_root, + "[hash](docs/architecture/a%23b.md)\n" + "[query](docs/architecture/a%3Fb.md)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize( + "target", + ( + "docs%2Farchitecture%2Foverview.md", + "docs%5Carchitecture%5Coverview.md", + "docs%2F..%2FREADME.md", + ), +) +def test_encoded_path_separator_is_not_treated_as_hierarchy( + tmp_path: Path, + target: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"[encoded]({target})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "ENCODED_PATH_SEPARATOR" in result.stderr + + +def test_double_encoded_separator_remains_literal_filename( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "literal%2Fname.md").write_text( + "literal\n", + encoding="utf-8", + ) + _write_readme(repository_root, "[literal](docs/literal%252Fname.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_encoded_separator_remains_valid_inside_fragment( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "[same document](#section%2Fpart)\n" + "[other document](docs/architecture/overview.md#section%2Fpart)\n", + ) + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize("literal_name", ("a©.md", "a¬it;.md")) +def test_unterminated_or_unknown_entities_remain_literal_in_target( + tmp_path: Path, + literal_name: str, +) -> None: + repository_root = _make_repository(tmp_path) + target = repository_root / "docs" / literal_name + target.write_text("literal\n", encoding="utf-8") + _write_readme(repository_root, f"[literal](docs/{literal_name})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_commonmark_entities_are_decoded_exactly_once(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "a©.md").write_text( + "literal entity\n", + encoding="utf-8", + ) + _write_readme(repository_root, "[literal](docs/a&copy;.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_commonmark_entity_is_not_decoded_twice_to_other_filename( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "a©.md").write_text( + "copyright\n", + encoding="utf-8", + ) + _write_readme(repository_root, "[literal](docs/a&copy;.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "MISSING_TARGET" in result.stderr + + +def test_entity_produced_backslash_is_not_a_markdown_escape( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "target.md").write_text( + "target\n", + encoding="utf-8", + ) + _write_readme(repository_root, "[target](docs\/target.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "BACKSLASH_TARGET" in result.stderr + + +def test_backslash_escaped_entity_opener_remains_literal( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + (repository_root / "docs" / "©.md").write_text( + "literal\n", + encoding="utf-8", + ) + _write_readme(repository_root, r"[literal](docs/\©.md)" "\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +@pytest.mark.parametrize("entity", ("©", "©", "©")) +def test_terminated_entities_are_decoded_in_target( + tmp_path: Path, + entity: str, +) -> None: + repository_root = _make_repository(tmp_path) + target = repository_root / "docs" / "a©.md" + target.write_text("decoded\n", encoding="utf-8") + _write_readme(repository_root, f"[decoded](docs/a{entity}.md)\n") + + result = _run_gate(repository_root) + + assert result.returncode == 0, _combined_output(result) + + +def test_discovery_source_type_probe_error_is_reported( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository_root = _make_repository(tmp_path) + source = repository_root / "docs" + original_is_file = Path.is_file + + def broken_is_file(path: Path) -> bool: + if path == source: + raise PermissionError("denied") + return original_is_file(path) + + monkeypatch.setattr(Path, "is_file", broken_is_file) + result = check_documentation_integrity( + repository_root, + required_documents=(), + markdown_sources=(Path("docs"),), + ) + + assert [issue.code for issue in result.issues] == ["SOURCE_IO_ERROR"] + + +def test_discovery_candidate_type_probe_error_is_reported( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository_root = _make_repository(tmp_path) + candidate = repository_root / "docs" / "architecture" / "overview.md" + original_is_file = Path.is_file + + def broken_is_file(path: Path) -> bool: + if path == candidate: + raise PermissionError("denied") + return original_is_file(path) + + monkeypatch.setattr(Path, "is_file", broken_is_file) + result = check_documentation_integrity( + repository_root, + required_documents=(), + markdown_sources=(Path("docs"),), + ) + + assert "SOURCE_IO_ERROR" in {issue.code for issue in result.issues} + + +def test_manifest_type_probe_error_is_reported( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository_root = _make_repository(tmp_path) + manifest = repository_root / "README.md" + original_is_file = Path.is_file + + def broken_is_file(path: Path) -> bool: + if path == manifest: + raise PermissionError("denied") + return original_is_file(path) + + monkeypatch.setattr(Path, "is_file", broken_is_file) + result = check_documentation_integrity( + repository_root, + required_documents=("README.md",), + markdown_sources=(), + ) + + assert [issue.code for issue in result.issues] == ["MANIFEST_IO_ERROR"] + + +@pytest.mark.parametrize( + ("target", "expected_code"), + ( + ("docs/%ZZ/overview.md", "INVALID_PERCENT_ESCAPE"), + ("docs/architecture/over\x00view.md", "CONTROL_CHARACTER"), + ("docs/%09architecture/overview.md", "CONTROL_CHARACTER"), + ("docs/%0Aarchitecture/overview.md", "CONTROL_CHARACTER"), + ("docs/\x85architecture/overview.md", "CONTROL_CHARACTER"), + ("docs/%C2%85architecture/overview.md", "CONTROL_CHARACTER"), + ), +) +def test_malformed_encoded_and_control_targets_are_rejected( + tmp_path: Path, + target: str, + expected_code: str, +) -> None: + repository_root = _make_repository(tmp_path) + _write_readme(repository_root, f"[invalid]({target})\n") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert expected_code in result.stderr + + +def test_external_links_are_not_fetched(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + _write_readme( + repository_root, + "[https](https://unresolvable.invalid/document)\n" + "[mail](mailto:nobody@unresolvable.invalid)\n" + "\n", + ) + environment = dict(os.environ) + environment.update( + { + "HTTP_PROXY": "http://127.0.0.1:1", + "HTTPS_PROXY": "http://127.0.0.1:1", + "ALL_PROXY": "http://127.0.0.1:1", + } + ) + + result = _run_gate(repository_root, environment=environment) + + assert result.returncode == 0, _combined_output(result) + + +def test_invalid_utf8_document_is_reported(tmp_path: Path) -> None: + repository_root = _make_repository(tmp_path) + invalid = repository_root / "docs" / "invalid.md" + invalid.write_bytes(b"\xff\xfe") + + result = _run_gate(repository_root) + + assert result.returncode == 1 + assert "INVALID_UTF8" in result.stderr + + +def test_multiple_diagnostics_have_deterministic_path_and_line_order( + tmp_path: Path, +) -> None: + repository_root = _make_repository(tmp_path) + first = repository_root / "docs" / "a.md" + second = repository_root / "docs" / "z.md" + first.write_text("\n[missing](missing-a.md)\n", encoding="utf-8") + second.write_text("[missing](missing-z.md)\n", encoding="utf-8") + + first_run = _run_gate(repository_root) + second_run = _run_gate(repository_root) + + assert first_run.returncode == 1 + assert first_run.stderr == second_run.stderr + assert first_run.stderr.index("docs/a.md:2") < first_run.stderr.index("docs/z.md:1") + + +def test_invalid_repository_root_returns_cli_usage_error(tmp_path: Path) -> None: + result = _run_gate(tmp_path / "missing") + + assert result.returncode == 2 + assert "Repository root не существует" in result.stderr + + +def test_symlink_loop_repository_root_returns_cli_usage_error( + tmp_path: Path, +) -> None: + loop = tmp_path / "loop" + loop.symlink_to("loop") + + result = _run_gate(loop) + + assert result.returncode == 2 + assert "Repository root не существует" in result.stderr + assert "Traceback" not in result.stderr + + +def test_unresolvable_home_repository_root_returns_cli_usage_error() -> None: + result = _run_gate(Path("~dzentra_documentation_gate_missing_user")) + + assert result.returncode == 2 + assert "Repository root не существует" in result.stderr + assert "Traceback" not in result.stderr diff --git a/app/tests/unit/core/test_config.py b/app/tests/unit/core/test_config.py index cad3bfc..95405cb 100644 --- a/app/tests/unit/core/test_config.py +++ b/app/tests/unit/core/test_config.py @@ -1,10 +1,39 @@ from __future__ import annotations +import os +from pathlib import Path +import subprocess +import sys +import traceback + import pytest +from src.core import config as config_module from src.core.config import load_settings +APP_ROOT = Path(__file__).resolve().parents[3] + + +_SECRET_VARIABLES = ( + "BOT_TOKEN", + "BOT_TOKEN_FILE", + "DB_PASSWORD", + "DB_PASSWORD_FILE", + "EXCHANGE_API_KEY", + "EXCHANGE_API_KEY_FILE", + "EXCHANGE_API_SECRET", + "EXCHANGE_API_SECRET_FILE", +) + +_SECRET_ATTRIBUTES = ( + ("BOT_TOKEN", "bot_token"), + ("DB_PASSWORD", "db_password"), + ("EXCHANGE_API_KEY", "exchange_api_key"), + ("EXCHANGE_API_SECRET", "exchange_api_secret"), +) + + _TRADE_STREAM_VARIABLES = ( "TRADE_STREAM_ENABLED", "TRADE_STREAM_WS_URL", @@ -30,6 +59,9 @@ _MARKET_DATA_STORAGE_VARIABLES = ( def prepare_environment( monkeypatch: pytest.MonkeyPatch, ) -> None: + for variable in _SECRET_VARIABLES: + monkeypatch.delenv(variable, raising=False) + monkeypatch.setenv("BOT_TOKEN", "test-token") monkeypatch.delenv("EXCHANGE_BASE_URL", raising=False) monkeypatch.delenv("EXCHANGE_ENABLED", raising=False) @@ -389,3 +421,274 @@ def test_pool_max_size_must_not_be_smaller_than_min_size( match="POOL_MAX_SIZE", ): load_settings() + + +@pytest.mark.parametrize( + ("variable", "attribute"), + _SECRET_ATTRIBUTES, +) +def test_secret_can_be_loaded_from_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + variable: str, + attribute: str, +) -> None: + prepare_environment(monkeypatch) + secret_file = tmp_path / f"{variable.lower()}.secret" + secret_file.write_text("file-secret\n", encoding="utf-8") + monkeypatch.delenv(variable, raising=False) + monkeypatch.setenv(f"{variable}_FILE", str(secret_file)) + + settings = load_settings() + + assert getattr(settings, attribute) == "file-secret" + + +@pytest.mark.parametrize( + ("variable", "attribute"), + _SECRET_ATTRIBUTES, +) +def test_direct_secret_remains_supported( + monkeypatch: pytest.MonkeyPatch, + variable: str, + attribute: str, +) -> None: + prepare_environment(monkeypatch) + monkeypatch.setenv(variable, " direct-secret ") + + settings = load_settings() + + assert getattr(settings, attribute) == "direct-secret" + + +@pytest.mark.parametrize( + "variable", + tuple(variable for variable, _ in _SECRET_ATTRIBUTES), +) +def test_direct_and_file_secret_are_mutually_exclusive( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + variable: str, +) -> None: + prepare_environment(monkeypatch) + secret_file = tmp_path / "secret" + secret_file.write_text("file-secret\n", encoding="utf-8") + monkeypatch.setenv(variable, "direct-secret") + monkeypatch.setenv(f"{variable}_FILE", str(secret_file)) + + with pytest.raises( + RuntimeError, + match=f"{variable} and {variable}_FILE", + ): + load_settings() + + +@pytest.mark.parametrize( + "variable", + tuple(variable for variable, _ in _SECRET_ATTRIBUTES), +) +def test_secret_file_path_must_not_be_empty( + monkeypatch: pytest.MonkeyPatch, + variable: str, +) -> None: + prepare_environment(monkeypatch) + monkeypatch.delenv(variable, raising=False) + monkeypatch.setenv(f"{variable}_FILE", " ") + + with pytest.raises( + RuntimeError, + match=f"{variable}_FILE must contain a non-empty file path", + ): + load_settings() + + +def test_secret_file_read_error_is_fail_fast( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + prepare_environment(monkeypatch) + missing_file = tmp_path / "missing-secret" + monkeypatch.setenv("DB_PASSWORD_FILE", str(missing_file)) + + with pytest.raises(RuntimeError) as error_info: + load_settings() + + assert str(error_info.value) == ( + "Unable to read secret configured by DB_PASSWORD_FILE" + ) + assert str(missing_file) not in str(error_info.value) + assert error_info.value.__cause__ is None + assert error_info.value.__suppress_context__ is True + formatted_traceback = "".join( + traceback.format_exception(error_info.value) + ) + assert str(missing_file) not in formatted_traceback + + +@pytest.mark.parametrize( + "content", + ( + "", + "\n", + "\r\n", + " \n", + ), +) +def test_secret_file_rejects_empty_content( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + content: str, +) -> None: + prepare_environment(monkeypatch) + secret_file = tmp_path / "empty-secret" + secret_file.write_text(content, encoding="utf-8") + monkeypatch.setenv("DB_PASSWORD_FILE", str(secret_file)) + + with pytest.raises( + RuntimeError, + match="DB_PASSWORD_FILE contains an empty secret", + ): + load_settings() + + +@pytest.mark.parametrize( + ("content", "expected"), + ( + ("secret\n", "secret"), + ("secret\r\n", "secret"), + ("secret\n\n", "secret\n"), + (" secret \n", " secret "), + ), +) +def test_secret_file_removes_only_one_trailing_newline( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + content: str, + expected: str, +) -> None: + prepare_environment(monkeypatch) + secret_file = tmp_path / "secret" + secret_file.write_text(content, encoding="utf-8", newline="") + monkeypatch.setenv("DB_PASSWORD_FILE", str(secret_file)) + + settings = load_settings() + + assert settings.db_password == expected + + +def test_bot_token_requires_direct_or_file_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + prepare_environment(monkeypatch) + monkeypatch.delenv("BOT_TOKEN", raising=False) + + with pytest.raises( + RuntimeError, + match="BOT_TOKEN or BOT_TOKEN_FILE is required", + ): + load_settings() + + +def test_secret_error_and_logs_do_not_contain_secret_value( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + prepare_environment(monkeypatch) + unique_secret = "secret-never-log-06030-4b7c1a" + secret_file = tmp_path / "bot-token" + secret_file.write_text("file-secret\n", encoding="utf-8") + monkeypatch.setenv("BOT_TOKEN", unique_secret) + monkeypatch.setenv("BOT_TOKEN_FILE", str(secret_file)) + + with pytest.raises(RuntimeError) as error_info: + load_settings() + + assert str(error_info.value) == ( + "BOT_TOKEN and BOT_TOKEN_FILE must not be set together" + ) + assert unique_secret not in str(error_info.value) + assert unique_secret not in caplog.text + + +def test_runtime_env_file_can_be_selected_before_config_import( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + runtime_env_file = tmp_path / "runtime.env" + monkeypatch.setenv( + "DZENTRA_RUNTIME_ENV_FILE", + str(runtime_env_file), + ) + + assert config_module._resolve_env_file() == runtime_env_file + + +def test_blank_runtime_env_file_is_rejected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DZENTRA_RUNTIME_ENV_FILE", " ") + + with pytest.raises( + RuntimeError, + match="DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path", + ): + config_module._resolve_env_file() + + +def test_runtime_env_file_is_loaded_at_import_and_shared_with_writer( + tmp_path: Path, +) -> None: + runtime_env_file = tmp_path / "runtime.env" + runtime_env_file.write_text( + "DZENTRA_IMPORT_SENTINEL=loaded-from-runtime-file\n", + encoding="utf-8", + ) + environment = os.environ.copy() + environment.pop("DZENTRA_IMPORT_SENTINEL", None) + environment["DZENTRA_RUNTIME_ENV_FILE"] = str(runtime_env_file) + command = ( + "import os; " + "import src.telegram.routers; " + "from src.core.config import ENV_FILE; " + "import src.telegram.handlers.system as system_module; " + "print(ENV_FILE); " + "print(system_module.ENV_FILE); " + "print(os.environ.get('DZENTRA_IMPORT_SENTINEL', ''))" + ) + + completed = subprocess.run( + [sys.executable, "-c", command], + cwd=APP_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + assert completed.stdout.splitlines() == [ + str(runtime_env_file), + str(runtime_env_file), + "loaded-from-runtime-file", + ] + + +def test_blank_runtime_env_file_fails_during_import() -> None: + environment = os.environ.copy() + environment["DZENTRA_RUNTIME_ENV_FILE"] = " " + + completed = subprocess.run( + [sys.executable, "-c", "from src.core.config import ENV_FILE"], + cwd=APP_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode != 0 + assert ( + "DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path" + in completed.stderr + ) diff --git a/app/tests/unit/infra/test_docker_hardening.py b/app/tests/unit/infra/test_docker_hardening.py new file mode 100644 index 0000000..e154d90 --- /dev/null +++ b/app/tests/unit/infra/test_docker_hardening.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +from pathlib import Path +import re + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[4] +DOCKERIGNORE = REPOSITORY_ROOT / ".dockerignore" +DOCKERFILE = REPOSITORY_ROOT / "infra" / "docker" / "Dockerfile" +REQUIREMENTS_LOCK = REPOSITORY_ROOT / "app" / "requirements.lock" +POSTGRES_DOCKERFILE = ( + REPOSITORY_ROOT / "infra" / "docker" / "postgres" / "Dockerfile" +) +COMPOSE_FILE = ( + REPOSITORY_ROOT / "infra" / "compose" / "docker-compose.yml" +) +EXCHANGE_COMPOSE_FILE = ( + REPOSITORY_ROOT + / "infra" + / "compose" + / "docker-compose.exchange-auth.yml" +) +POSTGRES_INIT_SCRIPT = ( + REPOSITORY_ROOT + / "infra" + / "docker" + / "postgres" + / "init-application-role.sh" +) +POSTGRES_HEALTHCHECK_SCRIPT = ( + REPOSITORY_ROOT + / "infra" + / "docker" + / "postgres" + / "healthcheck-application-role.sh" +) + +PYTHON_IMAGE = ( + "python:3.12.13-slim-bookworm@sha256:" + "d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b" +) +POSTGRES_IMAGE = ( + "postgres:16.14-alpine@sha256:" + "57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777" +) + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def yaml_mapping_block( + content: str, + *, + key: str, + indentation: int, +) -> str: + lines = content.splitlines() + marker = f"{' ' * indentation}{key}:" + + try: + start = lines.index(marker) + except ValueError as error: + raise AssertionError(f"YAML block {key!r} not found") from error + + end = len(lines) + for index in range(start + 1, len(lines)): + line = lines[index] + if not line.strip(): + continue + current_indentation = len(line) - len(line.lstrip()) + if current_indentation <= indentation: + end = index + break + + return "\n".join(lines[start:end]) + + +def test_docker_context_is_an_explicit_production_allowlist() -> None: + content = read_text(DOCKERIGNORE) + active_lines = tuple( + line.strip() + for line in content.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + expected_inclusions = { + "!.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/**", + } + + assert active_lines[0] == "**" + assert { + line + for line in active_lines + if line.startswith("!") + } == expected_inclusions + assert not any( + line.startswith("!app/.env") + or line.startswith("!app/.venv") + or line.startswith("!app/tests") + for line in active_lines + ) + assert "app/src/**/__pycache__/" in active_lines + assert "app/src/**/*.py[cod]" in active_lines + assert { + "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", + }.issubset(active_lines) + + +def test_dockerfile_uses_pinned_minimal_non_root_image() -> None: + content = read_text(DOCKERFILE) + copy_or_add_instructions = tuple( + line.strip() + for line in content.splitlines() + if line.lstrip().startswith(("COPY ", "ADD ")) + ) + + assert content.splitlines()[0] == f"FROM {PYTHON_IMAGE}" + assert copy_or_add_instructions == ( + "COPY app/requirements.lock ./requirements.lock", + "COPY --chown=10001:10001 app/src ./src", + ) + assert "--require-hashes" in content + assert "--requirement requirements.lock" in content + assert "USER 10001:10001" in content + assert "STOPSIGNAL SIGINT" in content + assert "/var/lib/dzentra/runtime.env" in content + + +def test_compose_requires_explicit_project_namespace() -> None: + content = read_text(COMPOSE_FILE) + + assert content.splitlines()[0] == ( + "name: ${DZENTRA_COMPOSE_PROJECT_NAME:" + "?DZENTRA_COMPOSE_PROJECT_NAME must be set}" + ) + + +def test_production_dependencies_have_complete_hash_lock() -> None: + content = read_text(REQUIREMENTS_LOCK) + package_matches = tuple(re.finditer( + r"^([a-z0-9][a-z0-9._-]*)==([^ \\;]+)", + content, + re.MULTILINE, + )) + + assert len(package_matches) == 25 + for index, package_match in enumerate(package_matches): + block_end = ( + package_matches[index + 1].start() + if index + 1 < len(package_matches) + else len(content) + ) + assert "--hash=sha256:" in content[package_match.start():block_end] + + assert "pytest==" not in content + assert "pyright==" not in content + + +def test_compose_uses_pinned_internal_postgres() -> None: + content = read_text(COMPOSE_FILE) + postgres = yaml_mapping_block( + content, + key="postgres", + indentation=2, + ) + + postgres_dockerfile = read_text(POSTGRES_DOCKERFILE) + + assert postgres_dockerfile.splitlines()[0] == f"FROM {POSTGRES_IMAGE}" + assert postgres_dockerfile.count("COPY --chmod=0555") == 2 + assert "ADD " not in postgres_dockerfile + assert "init-application-role.sh" in postgres_dockerfile + assert "healthcheck-application-role.sh" in postgres_dockerfile + assert "dockerfile: infra/docker/postgres/Dockerfile" in postgres + assert "dzentra-postgres:16.14-hardened" in postgres + assert "pull_policy: build" in postgres + assert ( + "POSTGRES_PASSWORD_FILE: /run/secrets/postgres_admin_password" + in postgres + ) + assert "APP_DB_USER: ${DB_USER:-dzentra_bot}" in postgres + assert "postgres_admin_password" in postgres + assert "db_password" in postgres + assert not re.search(r"^\s+POSTGRES_PASSWORD:\s", postgres, re.MULTILINE) + assert "ports:" not in postgres + assert "container_name:" not in postgres + assert "privileged:" not in postgres + assert "network_mode:" not in postgres + assert yaml_mapping_block( + content, + key="networks", + indentation=0, + ).rstrip() == ( + "networks:\n" + " database:\n" + " internal: true\n" + " egress:" + ) + assert 'restart: "no"' in postgres + assert yaml_mapping_block( + postgres, + key="security_opt", + indentation=4, + ) == " security_opt:\n - no-new-privileges:true" + assert yaml_mapping_block( + postgres, + key="cap_drop", + indentation=4, + ) == " cap_drop:\n - ALL" + assert yaml_mapping_block( + postgres, + key="cap_add", + indentation=4, + ) == ( + " cap_add:\n" + " - CHOWN\n" + " - DAC_OVERRIDE\n" + " - FOWNER\n" + " - SETGID\n" + " - SETUID" + ) + assert yaml_mapping_block( + postgres, + key="networks", + indentation=4, + ) == " networks:\n - database" + assert yaml_mapping_block( + postgres, + key="volumes", + indentation=4, + ) == ( + " volumes:\n" + " - dzentra_postgres_data:/var/lib/postgresql/data" + ) + assert yaml_mapping_block( + postgres, + key="tmpfs", + indentation=4, + ) == ( + " tmpfs:\n" + " - /tmp:rw,noexec,nosuid,nodev,size=64m\n" + " - /var/run/postgresql:rw,nosuid,nodev,size=16m" + ) + assert "read_only: true" in postgres + assert "POSTGRES_STOP_GRACE_PERIOD must be set" in postgres + assert "/usr/local/bin/dzentra-postgres-healthcheck" in postgres + + +def test_compose_does_not_inject_direct_application_secrets() -> None: + content = read_text(COMPOSE_FILE) + bot = yaml_mapping_block( + content, + key="bot", + indentation=2, + ) + + assert "env_file:" not in bot + assert "privileged:" not in bot + assert "network_mode:" not in bot + assert "BOT_TOKEN_FILE: /run/secrets/bot_token" in bot + assert "DB_PASSWORD_FILE: /run/secrets/db_password" in bot + assert not re.search(r"^\s+BOT_TOKEN:\s", bot, re.MULTILINE) + assert not re.search(r"^\s+DB_PASSWORD:\s", bot, re.MULTILINE) + assert not re.search(r"^\s+EXCHANGE_API_KEY:\s", bot, re.MULTILINE) + assert not re.search( + r"^\s+EXCHANGE_API_SECRET:\s", + bot, + re.MULTILINE, + ) + + +def test_compose_hardens_bot_and_preserves_runtime_settings() -> None: + content = read_text(COMPOSE_FILE) + bot = yaml_mapping_block( + content, + key="bot", + indentation=2, + ) + + assert 'user: "10001:10001"' in bot + assert "pull_policy: build" in bot + assert 'restart: "no"' in bot + assert yaml_mapping_block( + bot, + key="security_opt", + indentation=4, + ) == " security_opt:\n - no-new-privileges:true" + assert yaml_mapping_block( + bot, + key="cap_drop", + indentation=4, + ) == " cap_drop:\n - ALL" + assert " cap_add:" not in bot + assert yaml_mapping_block( + bot, + key="networks", + indentation=4, + ) == " networks:\n - database\n - egress" + assert yaml_mapping_block( + bot, + key="volumes", + indentation=4, + ) == ( + " volumes:\n" + " - dzentra_runtime_config:/var/lib/dzentra" + ) + assert yaml_mapping_block( + bot, + key="tmpfs", + indentation=4, + ) == " tmpfs:\n - /tmp:rw,noexec,nosuid,nodev,size=64m" + assert "read_only: true" in bot + assert "init: true" in bot + assert "BOT_STOP_GRACE_PERIOD must be set" in bot + assert "DZENTRA_RUNTIME_ENV_FILE: /var/lib/dzentra/runtime.env" in bot + assert "dzentra_runtime_config:/var/lib/dzentra" in bot + + +def test_compose_declares_file_backed_secret_sources() -> None: + content = read_text(COMPOSE_FILE) + + assert "POSTGRES_ADMIN_PASSWORD_SECRET_FILE must be set" in content + assert "BOT_TOKEN_SECRET_FILE must be set" in content + assert "DB_PASSWORD_SECRET_FILE must be set" in content + assert "file: ${BOT_TOKEN_SECRET_FILE:" in content + assert "file: ${DB_PASSWORD_SECRET_FILE:" in content + + +def test_exchange_credentials_have_explicit_opt_in_override() -> None: + content = read_text(EXCHANGE_COMPOSE_FILE) + + assert "EXCHANGE_API_KEY_FILE: /run/secrets/exchange_api_key" in content + assert ( + "EXCHANGE_API_SECRET_FILE: /run/secrets/exchange_api_secret" + in content + ) + assert "EXCHANGE_API_KEY_SOURCE_FILE must be set" in content + assert "EXCHANGE_API_SECRET_SOURCE_FILE must be set" in content + assert not re.search(r"^\s+EXCHANGE_API_KEY:\s", content, re.MULTILINE) + assert not re.search( + r"^\s+EXCHANGE_API_SECRET:\s", + content, + re.MULTILINE, + ) + + +def test_postgres_bootstrap_separates_admin_and_application_roles() -> None: + content = read_text(POSTGRES_INIT_SCRIPT) + + assert 'if [ "$APP_DB_USER" = "$POSTGRES_USER" ]' in content + assert "Пароли PostgreSQL admin и application roles должны различаться" in content + assert "postgres_admin_password=$POSTGRES_PASSWORD" in content + assert "read_secret_file /run/secrets/postgres_admin_password" not in content + assert "--single-transaction" in content + assert "--no-psqlrc" in content + assert "\\getenv app_password DZENTRA_APP_DB_PASSWORD" in content + assert "NOSUPERUSER NOCREATEDB" in content + assert "NOCREATEROLE INHERIT NOREPLICATION NOBYPASSRLS" in content + assert "ALTER DATABASE %I OWNER TO %I" in content + assert "GRANT USAGE, CREATE ON SCHEMA public TO %I" in content + assert "--set app_password" not in content + + +def test_postgres_healthcheck_rejects_privileged_application_role() -> None: + content = read_text(POSTGRES_HEALTHCHECK_SCRIPT) + + assert "NOT rolsuper" in content + assert "NOT rolcreatedb" in content + assert "NOT rolcreaterole" in content + assert "NOT rolreplication" in content + assert "NOT rolbypassrls" in content + assert "pg_catalog.pg_auth_members" in content + assert "member_role.rolname = :'app_user'" in content + assert "pg_catalog.pg_get_userbyid" in content + assert "test \"$role_status\" = \"1\"" in content diff --git a/docs/architecture/dzentra_target_architecture.md b/docs/architecture/dzentra_target_architecture.md index c4084a6..97c336f 100644 --- a/docs/architecture/dzentra_target_architecture.md +++ b/docs/architecture/dzentra_target_architecture.md @@ -6,8 +6,8 @@ |---|---| | Тип | Target Architecture | | Статус | Active Baseline | -| Версия | 1.0 | -| Дата | 2026-07-31 | +| Версия | 1.1 | +| Дата | 2026-08-03 | | Проект | Dzentra | --- @@ -51,6 +51,12 @@ Dzentra является работающим modular monolith, который Любое изменение ownership или направления зависимостей выполняется отдельным Build после анализа фактических consumers. +Вертикаль Trades Feed принята от Production Runtime до persistent +Storage, Startup Recovery, Historical Access и deterministic Replay в +Builds 060.25–060.29. Build 060.30 актуализировал документацию этого +состояния. Завершение одной вертикали не означает готовность остальных +Market Data feeds или всей торговой платформы. + --- ## 3. Верхнеуровневая карта @@ -150,7 +156,8 @@ app/src/market_data/acquisition/ ``` **Текущий активный результат:** Production Runtime для Trades Feed -завершён в Build 060.25. +завершён в Build 060.25, а network/fault/stress/soak/live verification — +в Build 060.26. ### 4.2. Validation, Canonicalization and Data Quality @@ -195,6 +202,10 @@ Missing Data Recovery не должны смешиваться в один ун `TradeStreamState` принадлежит Consistency Layer, а не общему Market Data Storage и не WebSocket Transport. +Подтверждённая durable точка восстановления хранится отдельно как +Persistent Checkpoint. Она восстанавливает operational state после +перезапуска, но не заменяет долговременную историю рынка. + ### 4.4. Persistent Market Data Storage **Назначение:** долговременно хранить Canonical Market Data для: @@ -206,31 +217,106 @@ Data Storage и не WebSocket Transport. - backtesting; - аудита качества данных. -Хранилище должно определить: +Build 060.27 реализовал два явно разных слоя: -- raw и canonical retention policy; -- ключи идемпотентности; -- порядок и уникальность событий; -- партиционирование по типу данных, символу и времени; -- правила исправления и повторной загрузки; -- provenance и версию Canonical schema. +```text +app/src/storage/ + общий/исторический PostgreSQL foundation, pool и migrations -Существующий `app/src/storage/` нельзя автоматически считать готовым -Market Data Storage. Его фактические контракты проверяются в -Build 060.27. +app/src/market_data/storage/ + канонические contracts и repositories Market Data +``` -### 4.5. Historical Access and Replay +Канонический слой содержит idempotent PostgreSQL repositories для +Trades, Quotes и Candle revisions, provenance, schema versions, +write-only `MarketDataStorage`, Partition Manager и Retention Service. +Production writer wiring подключён только для Trades. Существующие REST +Quotes/Candles обслуживают Trading/UI, но не записываются этими +persistent repositories автоматически. -**Назначение:** +Физическое partitioning реализовано отдельным parent/default family для +каждого типа данных: -- исторические запросы; -- последовательное воспроизведение Canonical Market Data; -- управляемые виртуальные часы; -- одинаковые контракты данных для production и replay consumers; -- основа backtesting и воспроизводимой диагностики. +```text +market_data.trades + RANGE (executed_at) + ├── trades_default + └── trades_YYYY_MM -Replay должен воспроизводить порядок событий и не обходить Canonical -Models или validation guarantees. +market_data.quotes + RANGE (received_at) + ├── quotes_default + └── quotes_YYYY_MM + +market_data.candle_revisions + RANGE (open_time) + ├── candle_revisions_default + └── candle_revisions_YYYY_MM +``` + +`venue` и `symbol` участвуют в identity и queries, но не являются +уровнями физического partitioning. Месячный child имеет UTC-границы для +всех venue/symbol соответствующего parent. Partition Manager вызывается +явно, сериализует создание advisory lock, переносит подходящие строки из +default partition и регистрирует управляемый child в +`market_data.partition_registry` в одной транзакции. + +Retention также запускается только явно. Он не является Scheduler-задачей +Production Runtime. Raw storage, automatic partition creation, +historical backfill и гарантия полноты истории пока не реализованы. + +### 4.5. Persistent Checkpoint and Startup Recovery + +Build 060.28 реализовал постоянный operational checkpoint Trades: + +- identity checkpoint — `(venue, symbol)`; +- checkpoint ссылается на точную durable Trade; +- Trade и checkpoint продвигаются в одной PostgreSQL-транзакции; +- optimistic CAS revision защищает конкурентное продвижение; +- in-memory state изменяется только после durable commit; +- Hydration восстанавливает checkpoint и bounded deduplication tail; +- Startup Recovery заполняет разрыв при закрытом Live gate; +- buffered live data выпускаются только после успешного Recovery. + +Checkpoint существует только для Trades. Он не является второй копией +истории, не доказывает полноту Storage и не имеет отдельного feature +flag: persistent checkpoint включается вместе с Market Data Storage. + +### 4.6. Historical Access + +Build 060.29 создал отдельный synchronous read-side: + +```text +app/src/market_data/access/ +``` + +Trade, Quote и Candle revision history читаются forward keyset pages. +Каждый вызов Historical query читает одну текущую committed page; +единый snapshot между последовательными страницами не гарантируется. +Access не изменяет durable data или operational checkpoint, вызывается +явным consumer и не запускается из Bootstrap автоматически. + +### 4.7. Deterministic Replay + +Replay использует те же Canonical Models, что live processing, и не +обходит validation guarantees. Migration 9 добавила общий immutable +`replay_sequence` для Trades, Quotes и Candle revisions. Миграция +блокирующая и перед большой production-базой требует maintenance window, +backup и предварительного замера длительности. + +Replay Plan Builder материализует bounded snapshot в транзакции +`READ ONLY REPEATABLE READ`. Превышение `max_records` завершается явной +ошибкой. Смешанный порядок событий равен +`(replay_at, replay_sequence)`. + +`DeterministicReplayClock` принимает timezone-aware `datetime`, +канонизирует его в UTC, допускает равное время и запрещает движение +назад; naive `datetime` запрещён. `ReplaySession` — caller-owned one-shot +lifecycle с новым графом зависимостей для каждого запуска. + +Автоматический Replay startup, default consumer, отдельный Replay +endpoint, hidden task и historical backfill не реализованы. Backtesting +может использовать этот фундамент, но сам не входит в Build 060.29. --- @@ -436,6 +522,11 @@ Backtesting является отдельной capability, но переисп Отдельная копия торговой логики только для backtest не допускается. +Persistent Storage, Historical Access, Replay Plan, deterministic clock +и Replay Session уже создают необходимые data/time prerequisites. +Backtesting engine, exchange/OMS simulator и автоматическая composition +этой capability пока не реализованы. + --- ## 8. Правила зависимостей @@ -485,7 +576,10 @@ order, account и runtime events, а не через обратные импор | Market Data Acquisition | `app/src/market_data/acquisition/` | | Application Runtime Composition | `app/src/bootstrap/` | | Runtime Events | `app/src/runtime_events/` и локальные runtime events | -| Storage foundation | `app/src/storage/` | +| Общий/исторический Storage foundation | `app/src/storage/` | +| Canonical Market Data Storage | `app/src/market_data/storage/` | +| Historical Market Data Access | `app/src/market_data/access/` | +| Deterministic Replay | `app/src/market_data/replay/` | | Market Intelligence | `app/src/trading/market_intelligence/` | | Trading Decision | `app/src/trading/decision/` | | Execution | `app/src/trading/execution/` | @@ -501,9 +595,11 @@ order, account и runtime events, а не через обратные импор 4. подготовлен отдельный migration plan; 5. проверено направление импортов. -Предложенные имена вроде `analytics/features`, -`market_data/normalization` или `market_data/storage` являются -логическими ориентирами, а не заранее утверждённой файловой структурой. +Предложенные имена вроде `analytics/features` или +`market_data/normalization` являются логическими ориентирами, а не +заранее утверждённой файловой структурой. Каталоги +`market_data/storage`, `market_data/access` и `market_data/replay` уже +являются принятыми фактическими boundaries. --- @@ -511,18 +607,19 @@ order, account и runtime events, а не через обратные импор | Область | Состояние | |---|---| -| Market Data Acquisition foundation | Реализуется Build-by-Build | +| Market Data Acquisition foundation | Trades vertical slice завершён; остальные feeds развиваются Build-by-Build | | Trades Feed production runtime | Completed — Build 060.25 | -| Runtime integration/stress verification | Next — Build 060.26 | -| Persistent Market Data Storage | Planned — Build 060.27 | -| Persistent Checkpoint / Startup Recovery | Planned — Build 060.28 | -| Historical Access / Replay | Planned — Build 060.29 | +| Runtime integration/stress/live verification | Completed — Build 060.26 | +| Persistent Market Data Storage | Completed — Build 060.27 | +| Persistent Checkpoint / Startup Recovery | Completed — Build 060.28 | +| Historical Access / Replay | Completed — Build 060.29 | +| Market Data Acquisition Final Documentation | Completed — Build 060.30 | | Validation ownership audit/refactoring | Planned — Build 061.00 | | Market Intelligence | Существующая активная подсистема | | Decision / Execution / Position | Существующие подсистемы, требуют будущих boundary audits | | OMS / Reconciliation | Целевая ответственность, отдельный план не утверждён | -| Backtesting | Целевая capability после Storage и Replay | -| Operations / Deployment | Целевая cross-cutting программа | +| Backtesting | Storage/Replay prerequisites готовы; capability не реализована | +| Operations / Deployment | Docker hardening принят; deployment остаётся отдельной cross-cutting программой | `Completed` означает завершение утверждённого Build, а не окончательную сертификацию всей подсистемы для любой production-нагрузки. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 27204e5..80dca7e 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,11 +1,117 @@ -# Architecture Overview +# Обзор архитектуры Dzentra -Проект строится как modular monolith с разделением по слоям: +**Статус:** Current; Build 060.30.3 -- `telegram` — меню, handlers, routers -- `bootstrap` — сборка приложения -- `core` — конфигурация и базовые сущности -- `trading` — бизнес-логика торговли -- `storage` — доступ к данным -- `integrations` — внешние API -- `shared` — общие утилиты +Dzentra развивается как модульный монолит: приложение собирается одним +Composition Root, но ответственность разделена между пакетами и +связывается через явные контракты. + +Каноническое подробное описание готовой Market Data вертикали находится +в [архитектуре Trades Feed](trades_feed.md). Эта страница служит +верхнеуровневой картой всего приложения и не дублирует lifecycle и +recovery-сценарии. + +## Composition Root и текущие связи + +`src.bootstrap` загружает настройки, выполняет базовый `init_schema()` с +PostgreSQL I/O, собирает опциональный Market Data pool/repository graph +без I/O и управляет общим lifecycle Telegram, Trade Runtime и Market +Data Storage. + +Схема запуска и основных взаимодействий: + +```text +main.py → bootstrap + ├→ init_schema() → src.storage → PostgreSQL + ├→ Telegram polling → trading / integrations + └→ optional Trade Runtime + ├→ market_data/acquisition → Consistency / Recovery + └→ optional market_data/storage → PostgreSQL + +trading / integrations / notifications / runtime_events + → существующие связи модульного монолита +``` + +Это карта исполнения, а не утверждение о полностью однонаправленном +import graph. В исторических пакетах сохраняются перекрёстные связи между +`trading`, `integrations` и `notifications`. Новая Market Data вертикаль +имеет более строгие protocol/composition boundaries. Пакеты не должны +самостоятельно запускать общий Application lifecycle. + +## Основные пакеты `app/src` + +| Пакет | Ответственность | +|---|---| +| `bootstrap` | Composition Root, startup, graceful shutdown и feature-flag wiring | +| `core` | Settings, logging и общие базовые компоненты приложения | +| `telegram` | Routers, handlers, keyboards и Telegram UI | +| `trading` | Торговая бизнес-логика, журнал и существующие runtime-компоненты | +| `integrations` | Клиенты и адаптеры внешних API вне канонической Market Data вертикали | +| `market_data` | Acquisition, persistent Storage, Historical Access и deterministic Replay | +| `runtime_events` | Внутренние runtime-события и их публикация | +| `notifications` | Доставка и маршрутизация уведомлений | +| `storage` | Смешанный общий/исторический PostgreSQL foundation, migrations и in-memory caches | +| `shared` | Общие вспомогательные компоненты без владения lifecycle | + +## Market Data + +Пакет `market_data` разделён на четыре независимые области: + +| Область | Назначение | +|---|---| +| `acquisition` | WebSocket/REST ingestion, validation, Consistency, Recovery и Production Runtime Trades | +| `storage` | Канонические Trade/Quote/Candle repositories, persistent Trade checkpoint, partitions и Retention | +| `access` | Read-only Historical Access к сохранённым данным | +| `replay` | Ограниченный deterministic Replay через явно создаваемую one-shot session | + +WebSocket Runtime, Consistency/Recovery и persistence wiring завершены +только для Trades. Существующие REST-потоки Quotes/Candles продолжают +обслуживать Trading/UI. Quote и Candle repositories/readers реализованы, +но persistent writer consumers к ним пока не подключены. + +Historical Access, Replay, Retention и создание месячных партиций не +являются фоновыми задачами приложения и выполняются только явным +вызывающим кодом. + +## Две Storage-границы + +Историческое имя `src/storage` охватывает несколько разных обязанностей: + +- `session.py`, `schema.py` и repositories журнала/balance snapshots + обслуживают базовую схему приложения; +- `instrument_store.py` и `quote_store.py` предоставляют in-memory caches; +- `postgres_pool.py` и `migrations.py` являются общей + PostgreSQL-инфраструктурой, которую использует новая Market Data + вертикаль. + +`src/market_data/storage` владеет каноническими persistent Market Data +contracts и repositories. Поэтому `src/storage` нельзя считать целиком +устаревшим или неиспользуемым. Подробная карта ownership приведена в +[Storage README](../../app/src/storage/README.md). + +## Runtime и данные + +Краткая цепочка принятой Trades-вертикали: + +```text +Dzengi WebSocket + REST Recovery + ↓ +Canonical Trade → Consistency + ↓ +optional atomic Trade + checkpoint write + ↓ +next startup: Hydration → Startup Recovery → buffered live processing + +explicit caller → Historical Access / Replay +``` + +PostgreSQL доступен обычному Bootstrap даже при выключенных Trade Stream +и Market Data Storage, поскольку базовые таблицы журнала и balance +snapshots инициализируются всегда. + +## Связанные документы + +- [Структура проекта](project_structure.md) +- [Текущая архитектура Trades Feed](trades_feed.md) +- [Эксплуатация Trade Stream Runtime](../operations/trades_feed_runtime.md) +- [Архитектура Build 060.30](../migrations/build_060_30_architecture.md) diff --git a/docs/architecture/project_structure.md b/docs/architecture/project_structure.md index 91ad494..cf87ea0 100644 --- a/docs/architecture/project_structure.md +++ b/docs/architecture/project_structure.md @@ -1,15 +1,108 @@ -# Project Structure +# Структура проекта Dzentra -## Корневые папки -- `app/` — код приложения -- `docs/` — документация -- `infra/` — Docker и compose +**Статус:** Current; Build 060.30.3 -## Внутри `app/src` -- `bootstrap/` -- `core/` -- `telegram/` -- `trading/` -- `storage/` -- `integrations/` -- `shared/` +Документ показывает назначение верхних уровней текущего checkout. Это +навигационная карта, а не полный перечень файлов. + +## Корень репозитория + +```text +dzentra_bot/ +├── app/ Python-приложение, зависимости и тесты +├── docs/ архитектура, runbook, roadmap и история Builds +├── infra/ Dockerfile и Docker Compose +├── scripts/ общие quality-gate и вспомогательные scripts +├── pyrightconfig.json единая конфигурация статической проверки +├── README.md входная страница существующего checkout +└── bootstrap_project.py исторический генератор первоначального каркаса +``` + +`bootstrap_project.py` не является командой установки или запуска +текущего проекта. + +## Каталог `app` + +| Путь | Назначение | +|---|---| +| `src/` | Production-код модульного монолита | +| `tests/unit/` | Unit-тесты default offline regression | +| `tests/static/` | Обязательный Pyright gate внутри default pytest | +| `tests/integration/` | Opt-in локальные network/PostgreSQL сценарии | +| `tests/stress/` | Fixed stress и opt-in soak проверки | +| `tests/live/` | Только явно разрешённые проверки внешних endpoints | +| `tests/support/` | Общие test harness и безопасные opt-in helpers | +| `scripts/` | Ручные диагностические scripts | +| `tools/` | Исследовательские Dzengi probes и сохранённые samples | +| `.env.example` | Полный безопасный пример поддерживаемых настроек | +| `requirements.txt` | Прямые Production dependencies | +| `requirements.lock` | Полный hash-locked набор Production image | +| `requirements-dev.txt` | Production dependencies плюс pytest и Pyright | + +Диагностические `app/scripts` и `app/tools` не запускаются автоматически +вместе с Production Runtime. + +## Пакеты `app/src` + +```text +src/ +├── bootstrap/ Composition Root и общий lifecycle +├── core/ settings, logging и общие компоненты +├── integrations/ внешние API вне Market Data vertical slice +├── market_data/ acquisition, storage, access и replay +├── notifications/ доставка и маршрутизация уведомлений +├── runtime_events/ внутренняя Runtime Event система +├── shared/ общие вспомогательные компоненты +├── storage/ общий/исторический Storage foundation +├── telegram/ Telegram UI и handlers +├── trading/ торговая бизнес-логика и runtime-компоненты +└── main.py async entry point приложения +``` + +## Пакет `market_data` + +```text +market_data/ +├── acquisition/ Canonical models, protocols, validation, Recovery и Runtime +├── storage/ persistent repositories, checkpoint, partitions и Retention +├── access/ Historical Access read-side +└── replay/ Replay plan, deterministic clock и one-shot session +``` + +Фактические WebSocket Runtime, Consistency/Recovery и persistent writer +consumer существуют только для Trades. REST-потоки Quotes/Candles +продолжают использоваться Trading/UI; их persistence/read-side API +реализованы отдельно. + +## Storage ownership + +`src/storage` и `src/market_data/storage` не являются взаимозаменяемыми: + +| Путь | Фактическая роль | +|---|---| +| `src/storage/session.py`, `schema.py`, `repositories/` | Базовая PostgreSQL-схема журнала и balance snapshots | +| `src/storage/instrument_store.py`, `quote_store.py` | In-memory caches существующих consumers | +| `src/storage/postgres_pool.py`, `migrations.py` | Общая инфраструктура pool/migrations, используемая Market Data Storage | +| `src/market_data/storage/` | Канонические persistent Market Data contracts и repositories | +| `src/market_data/access/` | Отдельный read-side сохранённых данных | +| `src/market_data/replay/` | Отдельная deterministic Replay-вертикаль | + +Подробности приведены в [README пакета Storage](../../app/src/storage/README.md). + +## Документация + +| Каталог | Назначение | +|---|---| +| `docs/architecture/` | Current и target architecture | +| `docs/operations/` | Актуальные эксплуатационные руководства | +| `docs/migrations/` | Architecture/report документов отдельных Builds | +| `docs/roadmap/` | План развития и исторические stage roadmaps | +| `docs/stages/`, `docs/decisions/` | Исторические этапы и решения | +| `docs/market_intelligence/` | Отдельная развиваемая Market Intelligence область | + +## Связанные документы + +- [Обзор архитектуры](overview.md) +- [Текущая архитектура Trades Feed](trades_feed.md) +- [Эксплуатация Trade Stream Runtime](../operations/trades_feed_runtime.md) +- [Инструкция приложения](../../app/README.md) diff --git a/docs/architecture/trades_feed.md b/docs/architecture/trades_feed.md new file mode 100644 index 0000000..d33e636 --- /dev/null +++ b/docs/architecture/trades_feed.md @@ -0,0 +1,585 @@ +# Trades Feed (Time & Sales) — текущая архитектура + +**Статус:** Current; accepted in Build 060.30.1 + +**Область:** Production vertical slice Canonical Trades + +**Версия документа:** 1.0 + +--- + +## Связанные документы + +- [Архитектура Build 060.30](../migrations/build_060_30_architecture.md) +- [Эксплуатация Trade Stream Runtime](../operations/trades_feed_runtime.md) +- [Build 060.27 — Persistent Market Data Storage](../migrations/build_060_27.md) +- [Build 060.28 — Persistent Checkpoint](../migrations/build_060_28.md) +- [Build 060.29 — Market Data Access and Replay](../migrations/build_060_29.md) + +--- + +## 1. Назначение и фактический scope + +Trades Feed получает сделки Dzengi, преобразует их в Canonical `Trade`, +проверяет порядок и дубликаты, восстанавливает разрывы через REST и при +включённом Storage атомарно сохраняет сделку вместе с persistent +checkpoint. + +После перезапуска Runtime восстанавливает Consistency state из +PostgreSQL, выполняет Startup Recovery и только затем пропускает +накопленные live-сообщения. Сохранённые Canonical Market Data доступны +через отдельные Historical Access и deterministic Replay API. + +Краткая итоговая цепочка: + +```text +Dzengi WebSocket → Transport → validation/mapping → Canonical Trade ─┐ + ├→ Consistency +Dzengi REST Recovery → validation/mapping → Canonical Trade ─────────┘ + ↓ + optional PostgreSQL Trade + checkpoint + ├→ next persistent startup + │ → Hydration + │ → Consistency state + │ → Startup Recovery + ├→ explicit caller + │ → Historical Access + └→ explicit caller + → Replay Plan + → Replay Session → consumer +``` + +Production ingestion и persistence в этой цепочке реализованы только +для Trades. Наличие Quote/Candle моделей и repositories не означает, +что Quotes и Candles уже подключены как Production feeds. + +--- + +## 2. Матрица готовности Market Data + +| Возможность | Trades | Quotes | Candle revisions | +|---|---:|---:|---:| +| Canonical model и Dzengi adapters | Да | Да | Да | +| Production WebSocket Runtime consumer | Да | Нет | Нет | +| Live/Recovery Consistency | Да | Нет | Нет | +| Production persistence wiring | Да | Нет | Нет | +| PostgreSQL repository и schema | Да | Да | Да | +| Persistent operational checkpoint | Да | Нет | Нет | +| Historical reader | Да | Да | Да | +| Bounded deterministic Replay | Да | Да | Да | + +Другие acquisition feeds проекта не входят в завершённую production +вертикаль Trades Feed. + +--- + +## 3. Режимы по feature flags + +| Trade Stream | Market Data Storage | Фактическое поведение | +|---|---|---| +| выключен | выключен | Trade Runtime и Market Data pool не создаются | +| включён | выключен | Live, in-memory Consistency и reconnect Recovery без restart persistence | +| включён | включён | Durable Trades, checkpoint, Hydration и Startup Recovery | +| выключен | включён | Недопустимая конфигурация; settings завершаются ошибкой | + +Оба флага по умолчанию выключены. При включённом Trade Stream требуются +явные WebSocket URL, REST base URL и symbols. Скрытых URL или symbol +fallback нет. + +Без Storage первоначальный startup подписывается без persistent +Hydration, ожидания startup ACK и Startup REST Recovery. Reconnect +Recovery во время уже работающего процесса остаётся доступным. + +--- + +## 4. Карта компонентов + +| Граница | Ответственность | Основные компоненты | +|---|---|---| +| Transport | WebSocket lifecycle, send/receive, Ping/Pong probe | `DzengiWebSocketTransport`, `WebSocketSession` | +| Protocol | Команды, события, subscription и control routing | `AcquisitionRuntimeService`, `WebSocketSubscriptionManager` | +| Adapter | JSON/schema/value validation и Canonical mapping | `DzengiUnifiedWebSocketAdapter`, REST adapter | +| Runtime | Startup, receive loop, buffering, tasks и shutdown | `TradeStreamProductionRuntime` | +| Consistency | Порядок, deduplication и per-symbol state | `TradeStreamConsistencyController`, `TradeStreamStateStore` | +| Recovery | REST windows и повторная подача в Consistency | `RuntimeRecoveryCoordinator`, `TradeRecoveryController` | +| Persistence | Canonical write, provenance и checkpoint | `TradeStorageObservationSink`, `PostgresTradeRepository` | +| Startup | Hydration durable tail и Startup Recovery | `TradeStreamStateHydrator`, `RuntimeStartupRecoveryCoordinator` | +| Read-side | Historical pages | `MarketDataHistoricalAccess`, PostgreSQL readers | +| Replay | Snapshot, virtual clock и one-shot playback | `PostgresReplayPlanBuilder`, `ReplaySessionFactory`, `ReplaySession` | +| Composition | Concrete dependency graph и root lifecycle | `bootstrap`, `ApplicationComposition` | + +Transport не знает Canonical models, Recovery или PostgreSQL. Bootstrap +является внешним composition root и создаёт concrete adapters. + +--- + +## 5. Canonical Trade и Consistency + +Один `TradeStreamState` принадлежит одному нормализованному symbol. +Состояние хранит: + +- последнюю принятую сделку; +- in-memory checkpoint; +- ограниченное окно Trade ID для deduplication; +- Canonical payload уже наблюдавшихся сделок. + +Размер deduplication tail по умолчанию равен 10 000 Trades. + +Trade ID соответствует signed 32-bit контракту. Сравнение учитывает +rollover: + +- `INT32_MAX → INT32_MIN` является продвижением; +- `-1 → 0` является продвижением; +- расстояние ровно в половину 32-битного цикла неоднозначно и + отклоняется. + +Две доставки считаются одним биржевым фактом, если совпадают symbol, +Trade ID, цена, количество, execution time и aggressor side. Поле +`source` описывает путь доставки WebSocket/REST и не участвует в этом +сравнении. При этом `source` сохраняется как часть Canonical payload и +provenance. + +Конфликтующий дубликат и нарушение rollover-aware порядка являются +ошибками, а не молча отбрасываемыми данными. + +Исходники: + +- [TradeStreamState](../../app/src/market_data/acquisition/consistency/trade_stream_state.py) +- [Consistency Controller](../../app/src/market_data/acquisition/consistency/trade_stream_consistency_controller.py) +- [Trade ID sequence](../../app/src/market_data/acquisition/trade_id_sequence.py) + +--- + +## 6. Live processing + +Точная последовательность одного live-документа: + +```text +WebSocket receive +→ зафиксировать transport activity +→ опубликовать Runtime Event +→ JSON decode +→ войти в LiveProcessingGate +→ разделить control и market document +→ Dzengi adapter +→ Canonical Trade +→ общий Consistency Controller +→ optional durable sink +→ продвинуть in-memory state +``` + +Blocking adapter/Consistency/Persistence path выполняется через +`asyncio.to_thread`. Runtime владеет worker-задачей и при cancellation +дожидается уже начатой обработки, чтобы не оставить неизвестный +результат записи. + +При выключенном Storage Consistency продвигает только память процесса. +При включённом Storage durable callback завершается до изменения +in-memory checkpoint. + +Runtime events доставляются in-process последовательно. Publisher не +имеет фоновой очереди или отдельной root task; production composition +регистрирует logging consumer. + +Исходники: + +- [Production Runtime](../../app/src/market_data/acquisition/runtime/trade_stream_production_runtime.py) +- [Acquisition Service](../../app/src/market_data/acquisition/trade_stream_acquisition_service.py) +- [Runtime Event Publisher](../../app/src/market_data/acquisition/runtime/acquisition_runtime_event_publisher.py) + +--- + +## 7. Durable write и persistent checkpoint + +Для новой принятой сделки гарантия имеет следующий порядок: + +```text +BEGIN +→ insert либо validate identical Trade +→ compare-and-set persistent checkpoint +→ COMMIT +→ advance in-memory state +``` + +Trade и checkpoint записываются одним `PostgresTradeRepository` и одной +транзакцией. Ошибка вставки, payload conflict или checkpoint conflict +откатывает операцию и не продвигает in-memory state. + +Полный дубликат обрабатывается отдельно: + +```text +validate stored Trade +→ optional provenance update +→ persistent checkpoint не двигается +→ in-memory checkpoint не двигается +``` + +Идентичность строки Trade: + +```text +venue + symbol + trade_id + executed_at +``` + +Persistent checkpoint имеет один ключ `venue + symbol` и ссылается на +точную durable Trade identity через `DEFERRABLE INITIALLY DEFERRED` +foreign key с `NO ACTION`. + +Parent table Trades partitioned по диапазону `executed_at`; Quotes — по +`received_at`, Candle revisions — по `open_time`. Миграции всегда +создают default partition. Конкретная месячная UTC-partition появляется +только после явного вызова Partition Manager. Symbol и venue являются +identity/query scope, но не отдельными физическими уровнями +partitioning. + +Исходники: + +- [Trade Storage Sink](../../app/src/market_data/storage/trade_storage_observation_sink.py) +- [PostgreSQL Trade Repository](../../app/src/market_data/storage/postgres_trade_repository.py) +- [Storage migrations](../../app/src/storage/migrations.py) + +--- + +## 8. Startup с persistent state + +Application сначала открывает Market Data pool и применяет migrations. +Только после успешного Storage startup запускаются Telegram polling и +Trade Runtime tasks. + +Persistent Trade Runtime выполняет: + +```text +load checkpoint и bounded durable tail +→ либо атомарно принять последнюю durable Trade при первом запуске +→ опубликовать восстановленный StateStore целиком +→ WebSocket connect +→ отправить subscriptions +→ дождаться соответствующего ACK +→ сохранить ранние market documents в bounded FIFO +→ REST Recovery до одной зафиксированной границы времени +→ обработать FIFO в исходном порядке +→ запустить Supervisor, receive loop и Scheduler +``` + +Если для symbol нет checkpoint и durable Trades, state создаётся пустым; +исторический backfill с биржи до начала доступного REST-окна не +выполняется. + +Если durable Trades есть, а checkpoint отсутствует, Hydrator может +атомарно принять последнюю durable Trade как начальную точку. Corrupt, +orphan или несовместимый checkpoint является фатальной ошибкой. Тихого +сброса checkpoint нет. + +Startup buffer по умолчанию ограничен 10 000 documents. Negative ACK, +ACK timeout, overflow, Hydration error и Recovery error не открывают +Live gate. + +Исходники: + +- [State Hydrator](../../app/src/market_data/acquisition/checkpoint/trade_stream_state_hydrator.py) +- [Startup Recovery Coordinator](../../app/src/market_data/acquisition/runtime/runtime_startup_recovery_coordinator.py) +- [Application lifecycle](../../app/src/bootstrap/application.py) + +--- + +## 9. Reconnect и Recovery + +Transport receive error и Heartbeat timeout используют одну +generation-aware single-flight операцию: + +```text +закрыть общий Live gate +→ Disconnect +→ Connect +→ переотправить desired subscriptions +→ зафиксировать recovery_end_time +→ последовательно выполнить REST Recovery для symbols +→ тот же Consistency Controller и durable sink +→ открыть Live gate +``` + +Вторая причина reconnect для того же connection generation +присоединяется к уже выполняющейся операции. Старое transport error не +запускает новый reconnect после смены generation. + +Важные границы: + +- Reconnect выполняет одну попытку; retry loop и backoff отсутствуют. +- Restore subscriptions означает успешную повторную отправку; отдельное + ожидание ACK перед reconnect Recovery не реализовано. +- Recovery использует REST `/api/v1/aggTrades` через настроенный base URL. +- Окна ограничены `TRADE_STREAM_RECOVERY_WINDOW_MS`, по умолчанию + 3 599 999 ms. +- Возможный повтор на границе окон удаляет общий Consistency layer. +- Если state/checkpoint отсутствует, Recovery не придумывает начальную + историю и возвращает пустой результат. +- Ошибка reconnect или Recovery переводит gate в terminal failure и + распространяется в Application. + +Recovery гарантирует упорядоченную обработку фактически полученных REST +данных, но не может гарантировать полноту данных, которых нет в ответе +биржи. + +Исходники: + +- [Reconnect Coordinator](../../app/src/market_data/acquisition/runtime/reconnect.py) +- [Reconnect Recovery Coordinator](../../app/src/market_data/acquisition/runtime/runtime_reconnect_recovery_coordinator.py) +- [Runtime Recovery Coordinator](../../app/src/market_data/acquisition/runtime/runtime_recovery_coordinator.py) +- [Trade Recovery Controller](../../app/src/market_data/acquisition/recovery/trade_recovery_controller.py) + +--- + +## 10. Liveness, Heartbeat и Scheduler + +Встроенный WebSocket keepalive библиотеки отключён. Источником +transport liveness является явный Ping/Pong probe. + +Scheduler периодически: + +1. вызывает transport probe; +2. при положительном ответе обновляет Heartbeat activity; +3. проверяет Heartbeat timeout; +4. передаёт подтверждённый timeout Supervisor. + +Любое успешно полученное WebSocket-сообщение также считается activity. +Отрицательный probe сам по себе не запускает reconnect немедленно: +решение принимается по Heartbeat timeout. Supervisor не допускает +параллельный второй timeout reconnect. + +Scheduler не владеет своей asyncio task. Task принадлежит Production +Runtime, который предварительно `claim()`-ит Scheduler и освобождает его +после остановки. + +Исходники: + +- [Heartbeat](../../app/src/market_data/acquisition/runtime/heartbeat.py) +- [Scheduler](../../app/src/market_data/acquisition/runtime/scheduler.py) +- [Supervisor](../../app/src/market_data/acquisition/runtime/supervisor.py) + +--- + +## 11. Historical Access + +Historical Access является отдельной synchronous read-side границей и +не расширяет write-only Storage facade. + +Поддерживаются: + +- Trade History; +- Quote History; +- Candle Revision History. + +Общие свойства: + +- timezone-aware полуоткрытый диапазон `[start_time, end_time)`; +- forward-only keyset pagination; +- строгий cursor scope; +- Canonical validation прочитанных PostgreSQL rows; +- limit по умолчанию 500, максимум 1 000. + +Порядок страниц: + +| Тип | Порядок | +|---|---| +| Trade | `executed_at`, затем `replay_sequence` | +| Quote | `received_at`, затем `replay_sequence` | +| Candle revision | `open_time`, затем `replay_sequence` | + +Каждая страница выполняет отдельный запрос. Между страницами не +удерживается одна `REPEATABLE READ` транзакция, поэтому concurrent writes +или Retention могут изменить dataset следующего запроса. + +Исходники: + +- [Historical Access facade](../../app/src/market_data/access/market_data_historical_access.py) +- [Historical models](../../app/src/market_data/access/models.py) + +--- + +## 12. Deterministic Replay + +Replay отделён от live Runtime: + +```text +caller +→ ReplaySessionFactory.prepare_session() +→ PostgresReplayPlanBuilder +→ READ ONLY REPEATABLE READ snapshot +→ bounded immutable ReplayPlan +→ закрыть transaction и connection +→ fresh Clock + fresh consumer + fresh ReplaySession +→ caller await session.run() +``` + +ReplayPlan ограничен максимум 100 000 events. Превышение limit приводит +к явной ошибке без частичного плана. + +Глобальный порядок задаётся парой: + +```text +replay_at + replay_sequence +``` + +| Тип | `replay_at` | +|---|---| +| Trade | `executed_at` | +| Quote | `received_at` | +| Candle revision | `observed_at` | + +`replay_sequence` выдаётся одним PostgreSQL sequence для всех трёх +семейств и остаётся неизменяемым. + +Clock начинается в `request.time_range.start_time`, разрешает равное +время и запрещает движение назад. Session является one-shot: + +```text +CREATED → RUNNING → COMPLETED | FAILED | CANCELLED +``` + +Повторный `run()` запрещён. Отдельного `ReplayEngine`, default consumer, +background task, automatic startup и wall-clock pacing нет. Вызвавший +код предоставляет `ReplayConsumerFactoryProtocol`. Затем +`ReplaySessionFactory.prepare_session()` создаёт fresh consumer, Clock и +Session; caller владеет полученной Session и её `run()`. + +Исходники: + +- [Replay Plan Builder](../../app/src/market_data/replay/postgres_replay_plan_builder.py) +- [Replay Session Factory](../../app/src/market_data/replay/replay_session_factory.py) +- [Replay Session](../../app/src/market_data/replay/replay_session.py) + +--- + +## 13. Ownership и lifecycle + +| Ресурс | Владелец | +|---|---| +| PostgreSQL pool и migrations | Application через `MarketDataStorageLifecycle` | +| Telegram polling и root Runtime task | `run_application()` | +| startup, receive, scheduler и market-processing tasks | `TradeStreamProductionRuntime` | +| Heartbeat state | `RuntimeSupervisor` | +| reconnect/recovery single-flight | `RuntimeReconnectRecoveryCoordinator` | +| Hydration/Startup Recovery worker | Runtime через `RuntimeStartupRecoveryCoordinator` | +| WebSocket connection | `DzengiWebSocketTransport` через Session | +| Runtime Event delivery | caller `publish()`; фонового owner нет | +| Historical connection/cursor | один repository call | +| Replay snapshot transaction | `PostgresReplayPlanBuilder.create_plan()` | +| Replay execution | caller `session.run()` | + +Application shutdown: + +```text +отменить Telegram polling +→ runtime.stop() + → завершить startup worker, если он выполняется + → остановить и дождаться Scheduler + → остановить Supervisor + → отменить и дождаться receive loop + → остановить WebSocket Session + → очистить subscriptions +→ дождаться root Runtime task +→ закрыть PostgreSQL pool +→ закрыть bot HTTP session +``` + +Ошибка включённого Runtime или persistent write считается фатальной для +всего приложения. Degraded fallback к in-memory режиму не выполняется. + +--- + +## 14. Направление зависимостей + +```text +bootstrap + ↓ +concrete Dzengi adapters + Runtime composition + PostgreSQL adapters + +acquisition → Canonical models + own Protocol boundaries + → narrow Storage checkpoint contracts/exceptions +Dzengi REST adapter → integrations.exchange REST client +storage → Canonical models +access → Canonical models + read contracts +replay → Canonical models + access row materialization +``` + +Подтверждённые границы: + +- `market_data` не импортирует Telegram, Trading или Bootstrap; +- Bootstrap импортирует concrete adapters как composition root; +- Acquisition не зависит от Historical Access или Replay; +- Production Runtime не импортирует concrete PostgreSQL repository; +- Replay не зависит от Live Runtime и не вызывает Consistency; +- Dzengi-specific код преимущественно находится в + `acquisition/adapters/dzengi`, но provider subscription document и + часть validation/handlers физически размещены в соседних acquisition + пакетах. + +Canonical models сейчас физически находятся в +`market_data/acquisition/models`. Это фактическое расположение, а не +утверждение, что общая Domain package уже выделена. + +--- + +## 15. Failure policy + +| Ошибка | Поведение | +|---|---| +| Неверные settings | Fail fast до запуска Runtime | +| Storage pool или migration | Application startup завершается ошибкой | +| Corrupt/orphan checkpoint | Startup завершается ошибкой | +| Subscription ACK timeout/negative ACK при persistent startup | Live gate остаётся закрытым, Runtime завершается | +| Startup buffer overflow | Runtime завершается | +| WebSocket receive failure | Одна generation-aware reconnect/recovery попытка | +| Reconnect или REST Recovery failure | Terminal gate failure и завершение Application | +| Canonical conflict/order error | Не принимается и распространяется как ошибка | +| Persistent write/checkpoint conflict | Transaction rollback, in-memory state не двигается | +| Replay consumer failure | Session переходит в `FAILED` | +| Replay cancellation | Session переходит в `CANCELLED` | + +Ошибки не превращаются в молчаливое продолжение с потенциально +несогласованным состоянием. + +--- + +## 16. Доступный период и ограничения + +Доступная история начинается не с момента существования инструмента на +бирже, а с наиболее ранней Canonical записи, которая фактически +находится в PostgreSQL. Обычно это момент успешного включения Storage, +но история может также включать ранее импортированные durable rows. + +Граница может сдвигаться из-за явно применённой Retention Policy. + +Система не предоставляет: + +- initial exchange historical backfill; +- completeness metadata и доказательство полной биржевой истории; +- storage raw exchange documents; +- automatic monthly partition creation; +- automatic Retention Scheduler; +- Production persistence Quotes/Candles; +- общий snapshot между Historical pages; +- восстановление исходного порядка сетевых пакетов; +- Replay HTTP/CLI/UI endpoint; +- automatic Replay startup; +- infinite reconnect retry/backoff; +- HA, leader election, distributed lease или multi-instance ownership; +- автоматические backup, metrics и alerting. + +Default PostgreSQL partitions принимают строки без заранее созданной +месячной partition. Явный Partition Manager позднее может атомарно +перенести соответствующие строки. + +Migration 9 выполняет блокирующий backfill `replay_sequence`. Для +крупной базы нужны backup, измерение на сопоставимом объёме и отдельное +maintenance window. + +--- + +## 17. Безопасная итоговая формулировка + +Trades Feed реализует единый Canonical Trade pipeline с live-получением, +in-process consistency, generation-aware reconnect, REST gap recovery, +опциональной durable-записью и persistent startup recovery. Historical +Access и deterministic Replay работают над фактически сохранёнными +Canonical Market Data. Полнота истории ограничена моментом включения +persistence, доступностью REST Recovery и применяемой Retention Policy. diff --git a/docs/migrations/build_044.md b/docs/migrations/build_044.md index 41f2103..995ee0b 100644 --- a/docs/migrations/build_044.md +++ b/docs/migrations/build_044.md @@ -751,6 +751,19 @@ Build 044 считается завершённым, поскольку: # 12. Ориентировочный дальнейший план миграции +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> План ниже сохраняется как исторический снимок и больше не назначает +> номера будущим Build. Фактический цикл Trades Feed продолжился через +> [060.24](build_060_24.md) Runtime Recovery, +> [060.25](build_060_25.md) Production Runtime, +> [060.26](build_060_26.md) Integration & Regression, +> [060.27](build_060_27.md)–[060.29](build_060_29.md) +> Storage/Checkpoint/Access и [060.30](build_060_30_architecture.md) +> Final Documentation. После 060.30 утверждён только 061.00, а номера +> следующих Feed-веток ещё не назначены. Канонический источник: +> [Master Roadmap](../roadmap/master-roadmap.md). + Ниже приведён ориентировочный план. Номера и границы Build могут уточняться после обязательного аудита фактического кода перед каждым следующим Build. Главный принцип остаётся неизменным: diff --git a/docs/migrations/build_060_19.md b/docs/migrations/build_060_19.md index 10c6f25..cf0eb55 100644 --- a/docs/migrations/build_060_19.md +++ b/docs/migrations/build_060_19.md @@ -1617,4 +1617,21 @@ Build 060.19 успешно достиг всех поставленных це # Следующий Build -**Build 060.20 — Trade Recovery Registry** \ No newline at end of file +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Прогноз ниже был уточнён после архитектурного аудита. Фактический +> [Build 060.20](build_060_20.md) сформировал Trade Runtime Architecture, +> а владение состоянием было скорректировано в +> [Build 060.20.1](build_060_20_1.md). Этапы Runtime Protocol, Service и +> Acquisition Integration выполнены в +> [060.21](build_060_21.md)–[060.23](build_060_23.md), затем +> [060.24](build_060_24.md) завершил Runtime Recovery, +> [060.25](build_060_25.md) — Production Runtime, +> [060.26](build_060_26.md) — Integration & Regression, +> [060.27](build_060_27.md)–[060.29](build_060_29.md) — +> Storage/Checkpoint/Access, а [060.30](build_060_30_architecture.md) — +> Final Documentation. После 060.30 утверждён только 061.00; номера +> следующих Feed-веток ещё не назначены. Актуальная последовательность: +> [Master Roadmap](../roadmap/master-roadmap.md). + +**Build 060.20 — Trade Recovery Registry** diff --git a/docs/migrations/build_060_19_architecture.md b/docs/migrations/build_060_19_architecture.md index e25eeef..97ca50c 100644 --- a/docs/migrations/build_060_19_architecture.md +++ b/docs/migrations/build_060_19_architecture.md @@ -6005,6 +6005,22 @@ Build 060.19 считается завершённым только при вы # Зависимости от последующих Build +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Все последующие roadmap-таблицы этого документа сохраняют прогноз на +> момент проектирования 060.19. Фактически после +> [060.20](build_060_20.md) и [060.20.1](build_060_20_1.md) этапы +> Protocol, Runtime Service, Acquisition Integration и Recovery выполнены +> в [060.21](build_060_21.md)–[060.24](build_060_24.md), Production +> Runtime — в [060.25](build_060_25.md), Integration & +> Regression — в [060.26](build_060_26.md), Storage/Checkpoint/Access — +> в [060.27](build_060_27.md)–[060.29](build_060_29.md), а Final +> Documentation — в [060.30](build_060_30_architecture.md). Каноническая +> последовательность находится в +> [Master Roadmap](../roadmap/master-roadmap.md). +> После 060.30 утверждён только 061.00; номера следующих Feed-веток ещё +> не назначены. + Build 060.19 сознательно ограничивает собственную область ответственности. Следующие задачи не входят в его Scope и будут реализованы позже. @@ -6372,4 +6388,4 @@ Canonical Trade Stream После утверждения настоящего приложения Build 060.19 рассматривается как стабильный архитектурный фундамент для всех последующих работ по подсистеме Trades Feed. -Любые изменения, затрагивающие описанные в данном приложении архитектурные гарантии, должны сопровождаться новым Architecture Decision Record (ADR) и обновлением архитектурной спецификации. \ No newline at end of file +Любые изменения, затрагивающие описанные в данном приложении архитектурные гарантии, должны сопровождаться новым Architecture Decision Record (ADR) и обновлением архитектурной спецификации. diff --git a/docs/migrations/build_060_20.md b/docs/migrations/build_060_20.md index 5c4cbd7..4b70de5 100644 --- a/docs/migrations/build_060_20.md +++ b/docs/migrations/build_060_20.md @@ -20,8 +20,10 @@ # Связанные документы -- build_060_20_architecture.md — архитектурная спецификация Build. -- build_060_19.md — Engineering Migration Report предыдущего Build. +- [Build 060.20 Architecture](build_060_20_architecture.md) — + архитектурная спецификация Build. +- [Build 060.19 Engineering Migration Report](build_060_19.md) — + предыдущий Build. --- @@ -1400,4 +1402,4 @@ Build считается полностью завершённым. Все последующие stateful-модули должны использовать Runtime исключительно как инфраструктурный слой хранения состояния и не создавать собственных внутренних механизмов хранения. -Данный принцип считается базовым архитектурным инвариантом проекта. \ No newline at end of file +Данный принцип считается базовым архитектурным инвариантом проекта. diff --git a/docs/migrations/build_060_20_1.md b/docs/migrations/build_060_20_1.md index 9f6bbb7..ecad3f3 100644 --- a/docs/migrations/build_060_20_1.md +++ b/docs/migrations/build_060_20_1.md @@ -20,8 +20,10 @@ # Связанные документы -- build_060_20_1_architecture.md — архитектурная спецификация Build. -- build_060_20.md — Engineering Migration Report предыдущего Build. +- [Build 060.20.1 Architecture](build_060_20_1_architecture.md) — + архитектурная спецификация Build. +- [Build 060.20 Engineering Migration Report](build_060_20.md) — + предыдущий Build. --- @@ -1057,5 +1059,4 @@ Build 060.20.1 завершает архитектурную корректир Build **060.20.1** считается полностью завершённым. -Он фиксирует окончательную архитектуру владения инфраструктурным состоянием подсистемы **Trades Feed (Time & Sales)** и служит базой для перехода к следующему этапу — **Build 060.21 — Runtime Protocol Integration**. - +Он фиксирует окончательную архитектуру владения инфраструктурным состоянием подсистемы **Trades Feed (Time & Sales)** и служит базой для перехода к следующему этапу — [Build 060.21 — Runtime Protocol Integration](build_060_21.md). diff --git a/docs/migrations/build_060_20_1_architecture.md b/docs/migrations/build_060_20_1_architecture.md index 964744c..57e737b 100644 --- a/docs/migrations/build_060_20_1_architecture.md +++ b/docs/migrations/build_060_20_1_architecture.md @@ -1,6 +1,9 @@ # Build 060.20.1 — Trade Stream State Ownership Alignment -**Статус:** Architecture Specification +**Статус:** Accepted + +**Тип документа:** Architecture Specification + **Build:** 060.20.1 **Ветка:** Trades Feed (Time & Sales) **Документ:** `build_060_20_1_architecture.md` @@ -9,10 +12,10 @@ **Связанные документы:** -- `build_060_20_architecture.md`; -- `build_060_20.md`; -- `build_060_19_architecture.md`; -- `build_060_18_architecture.md`. +- [Build 060.20 Architecture](build_060_20_architecture.md); +- [Build 060.20 Engineering Migration Report](build_060_20.md); +- [Build 060.19 Architecture](build_060_19_architecture.md); +- [Build 060.18 Architecture](build_060_18_architecture.md). --- @@ -1355,6 +1358,20 @@ TradeStreamState # Взаимодействие с последующими Build +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Название 060.24 ниже отражает ранний прогноз. Фактический +> [Build 060.24](build_060_24.md) завершил внутреннюю Runtime Recovery +> Architecture, а production reconnect lifecycle был подключён в +> [Build 060.25](build_060_25.md). Integration & Regression выполнен в +> [060.26](build_060_26.md), Storage/Checkpoint/Access — в +> [060.27](build_060_27.md)–[060.29](build_060_29.md), а Final +> Documentation — в [060.30](build_060_30_architecture.md). Актуальная +> последовательность: +> [Master Roadmap](../roadmap/master-roadmap.md). +> После 060.30 утверждён только 061.00; номера следующих Feed-веток ещё +> не назначены. + Build 060.20.1 является обязательной коррекцией перед: ```text diff --git a/docs/migrations/build_060_20_architecture.md b/docs/migrations/build_060_20_architecture.md index 186a420..9185b02 100644 --- a/docs/migrations/build_060_20_architecture.md +++ b/docs/migrations/build_060_20_architecture.md @@ -1,6 +1,9 @@ # Build 060.20 — Trade Runtime Architecture -**Статус:** Architecture Specification +**Статус:** Accepted + +**Тип документа:** Architecture Specification + **Build:** 060.20 **Ветка:** Trades Feed (Time & Sales) **Документ:** `build_060_20_architecture.md` @@ -8,9 +11,9 @@ **Связанные документы:** -- `build_060_20.md` — план реализации Build; -- `build_060_19_architecture.md`; -- `build_060_18_architecture.md`. +- [Build 060.20 Engineering Migration Report](build_060_20.md); +- [Build 060.19 Architecture](build_060_19_architecture.md); +- [Build 060.18 Architecture](build_060_18_architecture.md). --- @@ -2456,4 +2459,4 @@ TradeRuntimeRegistry является инфраструктурным ядро Данная архитектура является базовой для всех последующих Build серии 060 и определяет единый подход к построению долгоживущих Runtime-подсистем Dzentra. -Любые последующие расширения Runtime должны соответствовать архитектурным принципам, инвариантам и ADR, закреплённым настоящей спецификацией. \ No newline at end of file +Любые последующие расширения Runtime должны соответствовать архитектурным принципам, инвариантам и ADR, закреплённым настоящей спецификацией. diff --git a/docs/migrations/build_060_21.md b/docs/migrations/build_060_21.md index ab79dfd..f70109e 100644 --- a/docs/migrations/build_060_21.md +++ b/docs/migrations/build_060_21.md @@ -20,9 +20,12 @@ # Связанные документы -- `build_060_21_architecture.md` — архитектурная спецификация Build. -- `build_060_20_1.md` — Engineering Migration Report предыдущего корректирующего Build. -- `build_060_20_1_architecture.md` — спецификация переноса владельца состояния Trade Stream. +- [Build 060.21 Architecture](build_060_21_architecture.md) — + архитектурная спецификация Build. +- [Build 060.20.1 Engineering Migration Report](build_060_20_1.md) — + предыдущий корректирующий Build. +- [Build 060.20.1 Architecture](build_060_20_1_architecture.md) — + спецификация переноса владельца состояния Trade Stream. --- @@ -758,6 +761,20 @@ AcquisitionRuntimeEventPublisherProtocol # Связь с последующими Build +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Названия ниже сохраняют план на момент завершения 060.21. Фактически +> [060.24](build_060_24.md) завершил внутреннюю Runtime Recovery, +> [060.25](build_060_25.md) — Production Runtime Integration, +> [060.26](build_060_26.md) — Integration & Regression, +> [060.27](build_060_27.md)–[060.29](build_060_29.md) — +> Storage/Checkpoint/Access, а Final Documentation выполняется в +> [060.30](build_060_30_architecture.md). +> Актуальная последовательность: +> [Master Roadmap](../roadmap/master-roadmap.md). +> После 060.30 утверждён только 061.00; номера следующих Feed-веток ещё +> не назначены. + ## Build 060.22 — Runtime Service Integration Будущая реализация Runtime Service должна использовать новые Protocol как публичные границы передачи команд и событий. diff --git a/docs/migrations/build_060_21_architecture.md b/docs/migrations/build_060_21_architecture.md index 404fefa..d3b7163 100644 --- a/docs/migrations/build_060_21_architecture.md +++ b/docs/migrations/build_060_21_architecture.md @@ -1,5 +1,7 @@ # Build 060.21 — Runtime Integration Contracts +**Статус:** Accepted + ## Архитектурное обоснование --- @@ -79,6 +81,20 @@ Build формализует публичные контракты взаимо # Место Build 060.21 в общей дорожной карте +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Схемы ниже сохраняют предварительную нумерацию на момент проектирования. +> Фактически [060.24](build_060_24.md) завершил внутреннюю Runtime +> Recovery Architecture, [060.25](build_060_25.md) — Production Runtime +> Integration, [060.26](build_060_26.md) — Integration & Regression, а +> Storage/Checkpoint/Access — в +> [060.27](build_060_27.md)–[060.29](build_060_29.md), а Final +> Documentation выполняется в +> [060.30](build_060_30_architecture.md). Текущая последовательность: +> [Master Roadmap](../roadmap/master-roadmap.md). +> После 060.30 утверждён только 061.00; номера следующих Feed-веток ещё +> не назначены. + Развитие подсистемы Trades Feed выполняется последовательно. ```text @@ -743,7 +759,8 @@ Feed останется неизменным. ## 5. Build 060.22 становится локальным -Следующий Build будет посвящён реализации Runtime Service. +Следующий [Build 060.22](build_060_22_architecture.md) будет посвящён +реализации Runtime Service. Поскольку интерфейсы уже определены в Build 060.21, изменения затронут только внутреннюю реализацию Runtime. @@ -928,4 +945,4 @@ Integration & Regression Final Documentation ``` -Таким образом Build 060.21 завершает исключительно проектирование и интеграцию контрактов Runtime Protocol Layer, создавая стабильную основу для последующей реализации Runtime Service без нарушения уже сформированной архитектуры подсистемы Market Data Acquisition. \ No newline at end of file +Таким образом Build 060.21 завершает исключительно проектирование и интеграцию контрактов Runtime Protocol Layer, создавая стабильную основу для последующей реализации Runtime Service без нарушения уже сформированной архитектуры подсистемы Market Data Acquisition. diff --git a/docs/migrations/build_060_22.md b/docs/migrations/build_060_22.md index 6342615..1c0ad4a 100644 --- a/docs/migrations/build_060_22.md +++ b/docs/migrations/build_060_22.md @@ -20,10 +20,14 @@ # Связанные документы -- `build_060_22_architecture.md` — архитектурная спецификация Build. -- `build_060_21.md` — Engineering Migration Report предыдущего Build. -- `build_060_21_architecture.md` — спецификация Runtime Integration Contracts. -- `build_060_20_1.md` — Engineering Migration Report корректирующего Build владения состоянием Trade Stream. +- [Build 060.22 Architecture](build_060_22_architecture.md) — + архитектурная спецификация Build. +- [Build 060.21 Engineering Migration Report](build_060_21.md) — + предыдущий Build. +- [Build 060.21 Architecture](build_060_21_architecture.md) — + спецификация Runtime Integration Contracts. +- [Build 060.20.1 Engineering Migration Report](build_060_20_1.md) — + корректирующий Build владения состоянием Trade Stream. --- @@ -1139,6 +1143,20 @@ AcquisitionRuntimeService # Связь с последующими Build +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Названия ниже сохраняют план на момент завершения 060.22. Фактически +> [060.24](build_060_24.md) завершил внутреннюю Runtime Recovery, +> [060.25](build_060_25.md) — Production Runtime Integration, +> [060.26](build_060_26.md) — Integration & Regression, +> [060.27](build_060_27.md)–[060.29](build_060_29.md) — +> Storage/Checkpoint/Access, а Final Documentation выполняется в +> [060.30](build_060_30_architecture.md). +> Актуальная последовательность: +> [Master Roadmap](../roadmap/master-roadmap.md). +> После 060.30 утверждён только 061.00; номера следующих Feed-веток ещё +> не назначены. + ## Build 060.23 — Acquisition Integration Новый Runtime Service будет подключён к верхнему уровню Acquisition и станет доступен через утверждённую композицию зависимостей. diff --git a/docs/migrations/build_060_22_architecture.md b/docs/migrations/build_060_22_architecture.md index db47a62..3f02c7b 100644 --- a/docs/migrations/build_060_22_architecture.md +++ b/docs/migrations/build_060_22_architecture.md @@ -1,14 +1,20 @@ # Build 060.22 — Acquisition Runtime Service -**Статус:** Architecture Specification +**Статус:** Accepted + +**Тип документа:** Architecture Specification + **Build:** 060.22 **Ветка:** Trades Feed (Time & Sales) **Документ:** `build_060_22_architecture.md` **Связанные документы:** -- `build_060_21_architecture.md` — архитектурная спецификация Runtime Integration Contracts; -- `build_060_21.md` — Engineering Migration Report предыдущего Build; -- `build_060_20_1_architecture.md` — спецификация владения состоянием Trade Stream. +- [Build 060.21 Architecture](build_060_21_architecture.md) — + архитектурная спецификация Runtime Integration Contracts; +- [Build 060.21 Engineering Migration Report](build_060_21.md) — + предыдущий Build; +- [Build 060.20.1 Architecture](build_060_20_1_architecture.md) — + спецификация владения состоянием Trade Stream. --- @@ -1411,14 +1417,9 @@ Build не должен изменять поведение существующ После завершения Build 060.22 архитектура Runtime впервые получит полноценный исполняемый сервисный слой. -Следующим этапом станет: - -```text -Build 060.23 - -Acquisition Integration -``` +Следующим этапом станет +[Build 060.23 — Acquisition Integration](build_060_23_architecture.md). На этом этапе `AcquisitionRuntimeService` будет интегрирован в подсистему получения рыночных данных и станет использоваться как единая точка исполнения инфраструктурных Runtime-команд. -После Build 060.23 Runtime перестанет существовать только как набор контрактов и сервисов и начнёт участвовать в реальном конвейере получения данных от биржи. \ No newline at end of file +После Build 060.23 Runtime перестанет существовать только как набор контрактов и сервисов и начнёт участвовать в реальном конвейере получения данных от биржи. diff --git a/docs/migrations/build_060_23.md b/docs/migrations/build_060_23.md index 18a85cf..f083389 100644 --- a/docs/migrations/build_060_23.md +++ b/docs/migrations/build_060_23.md @@ -20,12 +20,18 @@ # Связанные документы -- `build_060_23_architecture.md` — архитектурная спецификация Build. -- `build_060_22.md` — Engineering Migration Report предыдущего Build. -- `build_060_22_architecture.md` — спецификация Acquisition Runtime Service. -- `build_060_20_1.md` — Engineering Migration Report корректирующего Build владения состоянием Trade Stream. -- `build_060_19.md` — Engineering Migration Report Trade Recovery. -- `build_060_18.md` — Engineering Migration Report Trade Stream Consistency. +- [Build 060.23 Architecture](build_060_23_architecture.md) — + архитектурная спецификация Build. +- [Build 060.22 Engineering Migration Report](build_060_22.md) — + предыдущий Build. +- [Build 060.22 Architecture](build_060_22_architecture.md) — + спецификация Acquisition Runtime Service. +- [Build 060.20.1 Engineering Migration Report](build_060_20_1.md) — + корректирующий Build владения состоянием Trade Stream. +- [Build 060.19 Engineering Migration Report](build_060_19.md) — + Trade Recovery. +- [Build 060.18 Engineering Migration Report](build_060_18.md) — + Trade Stream Consistency. --- @@ -1304,6 +1310,20 @@ inbound Trade message processing # Связь с последующими Build +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Названия ниже сохраняют ранний прогноз. Фактически +> [060.24](build_060_24.md) завершил внутреннюю Runtime Recovery, +> [060.25](build_060_25.md) — Production Runtime Integration, +> [060.26](build_060_26.md) — Integration & Regression, +> [060.27](build_060_27.md)–[060.29](build_060_29.md) — +> Storage/Checkpoint/Access, а Final Documentation выполняется в +> [060.30](build_060_30_architecture.md). +> Актуальная последовательность: +> [Master Roadmap](../roadmap/master-roadmap.md). +> После 060.30 утверждён только 061.00; номера следующих Feed-веток ещё +> не назначены. + ## Build 060.24 — Reconnect & Runtime Recovery На основе текущего сервиса должны быть построены: diff --git a/docs/migrations/build_060_23_architecture.md b/docs/migrations/build_060_23_architecture.md index 6a4b89e..b176933 100644 --- a/docs/migrations/build_060_23_architecture.md +++ b/docs/migrations/build_060_23_architecture.md @@ -1,6 +1,6 @@ # Build 060.23 — Trade Stream Acquisition Integration Architecture -**Статус:** Architecture Approved +**Статус:** Accepted **Build:** 060.23 @@ -842,4 +842,19 @@ Trade | None Таким образом завершается построение сервисного слоя интеграции Runtime и Acquisition. -Следующий этап — **Build 060.24 — Reconnect & Runtime Recovery**, в рамках которого будет реализовано управление жизненным циклом WebSocket-соединения, автоматическое восстановление подписок и интеграция Recovery Layer с Runtime Infrastructure. \ No newline at end of file +> **Ретроспективное уточнение 060.30.5 (2026-08-03).** +> +> Прогноз следующего этапа ниже был разделён после архитектурного аудита: +> [060.24](build_060_24.md) завершил внутреннюю Runtime Recovery +> Architecture, а WebSocket lifecycle, восстановление подписок и +> production reconnect были подключены в [060.25](build_060_25.md). +> Integration & Regression выполнен в [060.26](build_060_26.md), +> Storage/Checkpoint/Access — в +> [060.27](build_060_27.md)–[060.29](build_060_29.md), а Final +> Documentation — в [060.30](build_060_30_architecture.md). Актуальная +> последовательность: +> [Master Roadmap](../roadmap/master-roadmap.md). +> После 060.30 утверждён только 061.00; номера следующих Feed-веток ещё +> не назначены. + +Следующий этап — **[Build 060.24 — Reconnect & Runtime Recovery](build_060_24_architecture.md)**, в рамках которого будет реализовано управление жизненным циклом WebSocket-соединения, автоматическое восстановление подписок и интеграция Recovery Layer с Runtime Infrastructure. diff --git a/docs/migrations/build_060_24.md b/docs/migrations/build_060_24.md index 8ae71f5..38a695c 100644 --- a/docs/migrations/build_060_24.md +++ b/docs/migrations/build_060_24.md @@ -18,13 +18,20 @@ ## Связанные документы -- `build_060_24_architecture.md` — итоговая архитектурная спецификация Build. -- `build_060_23.md` — Trade Stream Acquisition Integration. -- `build_060_23_architecture.md` — архитектура интеграции Trade Stream Acquisition. -- `build_060_22.md` — Runtime Service Integration. -- `build_060_20_1.md` — Trade Stream State Ownership Alignment. -- `build_060_19.md` — Trade Recovery. -- `build_060_18.md` — Trade Stream Consistency. +- [Build 060.24 Architecture](build_060_24_architecture.md) — итоговая + архитектурная спецификация Build. +- [Build 060.23 Engineering Migration Report](build_060_23.md) — Trade + Stream Acquisition Integration. +- [Build 060.23 Architecture](build_060_23_architecture.md) — архитектура + интеграции Trade Stream Acquisition. +- [Build 060.22 Engineering Migration Report](build_060_22.md) — Runtime + Service Integration. +- [Build 060.20.1 Engineering Migration Report](build_060_20_1.md) — + Trade Stream State Ownership Alignment. +- [Build 060.19 Engineering Migration Report](build_060_19.md) — Trade + Recovery. +- [Build 060.18 Engineering Migration Report](build_060_18.md) — Trade + Stream Consistency. --- @@ -548,5 +555,6 @@ Build 060.24 завершает внутреннюю Runtime Recovery Architectu - явным composition root с единым состоянием для live и recovery. Следующий этап не должен заново проектировать эти компоненты. -Задача Build 060.25 — подключить готовый граф к production WebSocket -lifecycle и определить end-to-end порядок запуска, reconnect и recovery. +Задача [Build 060.25](build_060_25.md) — подключить готовый граф к +production WebSocket lifecycle и определить end-to-end порядок запуска, +reconnect и recovery. diff --git a/docs/migrations/build_060_24_architecture.md b/docs/migrations/build_060_24_architecture.md index fdb82f2..67e8701 100644 --- a/docs/migrations/build_060_24_architecture.md +++ b/docs/migrations/build_060_24_architecture.md @@ -1006,5 +1006,5 @@ Recovery и фиксирует их границы. - собирает live и recovery поверх общего Consistency Layer; - не скрывает production lifecycle внутри factory-функции. -Следующий Build должен интегрировать эту архитектуру, а не менять её -ответственности. +Следующий [Build 060.25](build_060_25_architecture.md) должен интегрировать +эту архитектуру, а не менять её ответственности. diff --git a/docs/migrations/build_060_25.md b/docs/migrations/build_060_25.md index b5e9116..eae444e 100644 --- a/docs/migrations/build_060_25.md +++ b/docs/migrations/build_060_25.md @@ -19,13 +19,16 @@ ## Связанные документы -- `build_060_24.md` — итог внутренней Runtime Recovery Architecture; -- `build_060_24_architecture.md` — архитектурные границы Recovery Runtime; -- `build_060_25_architecture.md` — полная спецификация Production Runtime - Integration и принятые ADR; -- `dzentra_target_architecture.md` — место Trade Stream в целевой - архитектуре Dzentra; -- `master-roadmap.md` — дальнейшая последовательность Build. +- [Build 060.24 Engineering Migration Report](build_060_24.md) — итог + внутренней Runtime Recovery Architecture; +- [Build 060.24 Architecture](build_060_24_architecture.md) — + архитектурные границы Recovery Runtime; +- [Build 060.25 Architecture](build_060_25_architecture.md) — полная + спецификация Production Runtime Integration и принятые ADR; +- [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md) — + место Trade Stream в целевой архитектуре Dzentra; +- [Master Roadmap](../roadmap/master-roadmap.md) — дальнейшая + последовательность Build. --- @@ -414,6 +417,6 @@ WebSocket lifecycle, восстанавливает подписки и проп сохраняет порядок Recovery и Live processing и детерминированно освобождает принадлежащие ему ресурсы. -Следующий этап — Build 060.26, посвящённый интеграционным, fault, -reconnect, recovery и stress-сценариям за пределами детерминированного -in-process unit harness. +Следующий этап — [Build 060.26](build_060_26.md), посвящённый +интеграционным, fault, reconnect, recovery и stress-сценариям за +пределами детерминированного in-process unit harness. diff --git a/docs/migrations/build_060_25_architecture.md b/docs/migrations/build_060_25_architecture.md index cd9d94b..8dbd456 100644 --- a/docs/migrations/build_060_25_architecture.md +++ b/docs/migrations/build_060_25_architecture.md @@ -1,6 +1,6 @@ # Build 060.25 — Production Runtime Integration Architecture -**Статус:** Completed +**Статус:** Accepted **Build:** 060.25 diff --git a/docs/migrations/build_060_26.md b/docs/migrations/build_060_26.md index 4a1a663..f902aab 100644 --- a/docs/migrations/build_060_26.md +++ b/docs/migrations/build_060_26.md @@ -19,12 +19,14 @@ ## Связанные документы -- `build_060_25.md` — итог Production Runtime Integration; -- `build_060_26_architecture.md` — архитектура, решения и подробные - результаты подэтапов 060.26.0–060.26.6; -- `dzentra_target_architecture.md` — место Trade Stream в целевой - архитектуре Dzentra; -- `master-roadmap.md` — дальнейшая последовательность Build. +- [Build 060.25 Engineering Migration Report](build_060_25.md) — итог + Production Runtime Integration; +- [Build 060.26 Architecture](build_060_26_architecture.md) — архитектура, + решения и подробные результаты подэтапов 060.26.0–060.26.6; +- [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md) — + место Trade Stream в целевой архитектуре Dzentra; +- [Master Roadmap](../roadmap/master-roadmap.md) — дальнейшая + последовательность Build. --- @@ -216,5 +218,6 @@ Production Trade Stream Runtime проверен через настоящие обнаружили утечек задач, сокетов, потоков или памяти сверх принятого порога. -Следующий этап — Build 060.27, посвящённый долговременному хранению -Trades и формированию Persistent Market Data Storage. +Следующий этап — [Build 060.27](build_060_27.md), посвящённый +долговременному хранению Trades и формированию Persistent Market Data +Storage. diff --git a/docs/migrations/build_060_26_architecture.md b/docs/migrations/build_060_26_architecture.md index 68382f1..e28b161 100644 --- a/docs/migrations/build_060_26_architecture.md +++ b/docs/migrations/build_060_26_architecture.md @@ -1,6 +1,6 @@ # Build 060.26 — Integration and Regression Architecture -**Статус:** Completed +**Статус:** Accepted **Build:** 060.26 diff --git a/docs/migrations/build_060_27.md b/docs/migrations/build_060_27.md index cb63c0b..5b3b2e9 100644 --- a/docs/migrations/build_060_27.md +++ b/docs/migrations/build_060_27.md @@ -19,12 +19,14 @@ ## Связанные документы -- `build_060_27_architecture.md` — архитектура, решения и подробные - результаты подэтапов 060.27.0–060.27.8; -- `build_060_26.md` — итог Runtime Integration and Regression; -- `dzentra_target_architecture.md` — место Market Data Storage в целевой - архитектуре Dzentra; -- `master-roadmap.md` — дальнейшая последовательность Build. +- [Build 060.27 Architecture](build_060_27_architecture.md) — архитектура, + решения и подробные результаты подэтапов 060.27.0–060.27.8; +- [Build 060.26 Engineering Migration Report](build_060_26.md) — итог + Runtime Integration and Regression; +- [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md) — + место Market Data Storage в целевой архитектуре Dzentra; +- [Master Roadmap](../roadmap/master-roadmap.md) — дальнейшая + последовательность Build. --- @@ -220,5 +222,5 @@ Market Data и production persistence для Trades. Operational checkpoint теперь продвигается только после подтверждённой durable-записи, а ошибка Storage завершает Runtime без скрытой потери истории. -Следующий этап — Build 060.28, Persistent Checkpoint and Startup -Recovery. +Следующий этап — [Build 060.28](build_060_28.md), Persistent Checkpoint +and Startup Recovery. diff --git a/docs/migrations/build_060_27_architecture.md b/docs/migrations/build_060_27_architecture.md index 82a52bb..359c863 100644 --- a/docs/migrations/build_060_27_architecture.md +++ b/docs/migrations/build_060_27_architecture.md @@ -1,6 +1,6 @@ # Build 060.27 — Persistent Market Data Storage Architecture -**Статус:** Completed +**Статус:** Accepted **Build:** 060.27 @@ -505,5 +505,6 @@ failure, lifecycle pool и Runtime, opt-in isolation и test coverage. ## 12. Следующий Build -Build 060.27 завершён и принят. Следующий этап — Build 060.28, -Persistent Checkpoint and Startup Recovery. +Build 060.27 завершён и принят. Следующий этап — +[Build 060.28](build_060_28_architecture.md), Persistent Checkpoint and +Startup Recovery. diff --git a/docs/migrations/build_060_28.md b/docs/migrations/build_060_28.md index 86aa9f9..2ac61da 100644 --- a/docs/migrations/build_060_28.md +++ b/docs/migrations/build_060_28.md @@ -19,12 +19,14 @@ ## Связанные документы -- `build_060_28_architecture.md` — архитектура, решения и подробные - результаты подэтапов 060.28.0–060.28.8; -- `build_060_27.md` — Persistent Market Data Storage; -- `dzentra_target_architecture.md` — место Market Data Acquisition в - целевой архитектуре Dzentra; -- `master-roadmap.md` — дальнейшая последовательность Build. +- [Build 060.28 Architecture](build_060_28_architecture.md) — архитектура, + решения и подробные результаты подэтапов 060.28.0–060.28.8; +- [Build 060.27 Engineering Migration Report](build_060_27.md) — + Persistent Market Data Storage; +- [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md) — + место Market Data Acquisition в целевой архитектуре Dzentra; +- [Master Roadmap](../roadmap/master-roadmap.md) — дальнейшая + последовательность Build. --- @@ -241,4 +243,5 @@ PostgreSQL после перезапуска, заполняет downtime gap ч checkpoint и in-memory state теперь продвигаются в одном проверяемом порядке без скрытой потери последовательности. -Следующий этап — Build 060.29, Market Data Access and Replay. +Следующий этап — [Build 060.29](build_060_29.md), Market Data Access and +Replay. diff --git a/docs/migrations/build_060_28_architecture.md b/docs/migrations/build_060_28_architecture.md index 3c7607e..a493301 100644 --- a/docs/migrations/build_060_28_architecture.md +++ b/docs/migrations/build_060_28_architecture.md @@ -1,6 +1,6 @@ # Build 060.28 — Persistent Checkpoint and Startup Recovery Architecture -**Статус:** Completed +**Статус:** Accepted **Build:** 060.28 @@ -847,5 +847,6 @@ CAS/rollover, Startup Recovery, lifecycle/cancellation, Retention и ## 20. Следующий Build -Build 060.28 завершён и принят. Следующий этап — Build 060.29, Market -Data Access and Replay. +Build 060.28 завершён и принят. Следующий этап — +[Build 060.29](build_060_29_architecture.md), Market Data Access and +Replay. diff --git a/docs/migrations/build_060_29.md b/docs/migrations/build_060_29.md index 8d994e7..3297217 100644 --- a/docs/migrations/build_060_29.md +++ b/docs/migrations/build_060_29.md @@ -19,11 +19,14 @@ ## Связанные документы -- `build_060_29_architecture.md` — архитектура, решения и подробные - результаты подэтапов 060.29.0–060.29.8; -- `build_060_28.md` — Persistent Checkpoint and Startup Recovery; -- `dzentra_target_architecture.md` — целевая архитектура Dzentra; -- `master-roadmap.md` — дальнейшая последовательность Build. +- [Build 060.29 Architecture](build_060_29_architecture.md) — архитектура, + решения и подробные результаты подэтапов 060.29.0–060.29.8; +- [Build 060.28 Engineering Migration Report](build_060_28.md) — + Persistent Checkpoint and Startup Recovery; +- [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md) — + целевая архитектура Dzentra; +- [Master Roadmap](../roadmap/master-roadmap.md) — дальнейшая + последовательность Build. --- @@ -232,5 +235,5 @@ Dzentra получила отдельный Historical Access, общий дет скрытого lifecycle. Сохранённую рыночную историю теперь можно безопасно читать и воспроизводить одинаковыми Canonical объектами. -Следующий этап — Build 060.30, итоговый аудит и документальное закрытие -ветки Trades Feed. +Следующий этап — [Build 060.30](build_060_30_architecture.md), итоговый +аудит и документальное закрытие ветки Trades Feed. diff --git a/docs/migrations/build_060_29_architecture.md b/docs/migrations/build_060_29_architecture.md index a9188d0..c7113bf 100644 --- a/docs/migrations/build_060_29_architecture.md +++ b/docs/migrations/build_060_29_architecture.md @@ -1,6 +1,6 @@ # Build 060.29 — Market Data Access and Replay Architecture -**Статус:** Completed +**Статус:** Accepted **Build:** 060.29 @@ -1457,5 +1457,6 @@ Production-код в 060.29.8: без изменений ## 19. Следующий Build -Build 060.29 завершён и принят. Следующий этап — Build 060.30, итоговый -аудит и документальное закрытие ветки Trades Feed. +Build 060.29 завершён и принят. Следующий этап — +[Build 060.30](build_060_30_architecture.md), итоговый аудит и +документальное закрытие ветки Trades Feed. diff --git a/docs/migrations/build_060_30.md b/docs/migrations/build_060_30.md new file mode 100644 index 0000000..8318fb0 --- /dev/null +++ b/docs/migrations/build_060_30.md @@ -0,0 +1,313 @@ +# Build 060.30 — Market Data Acquisition Final Documentation + +**Engineering Migration Report** + +--- + +## Контроль документа + +| Свойство | Значение | +|---|---| +| Build | 060.30 | +| Статус | Completed | +| Подсистема | Market Data / Trades Feed (Time & Sales) | +| Компонент | Final Documentation and Production Safety | +| Дата завершения | 2026-08-03 | +| Версия | 1.0 | + +--- + +## Связанные документы + +- [Build 060.30 Architecture](build_060_30_architecture.md) — границы, + ADR и подробное evidence подэтапов 060.30.0–060.30.8; +- [Текущая архитектура Trades Feed](../architecture/trades_feed.md) — + фактический dependency graph и lifecycle; +- [Эксплуатация Trade Stream Runtime](../operations/trades_feed_runtime.md) — + settings, startup, recovery, PostgreSQL и verification procedures; +- [Целевая архитектура Dzentra](../architecture/dzentra_target_architecture.md) — + место Market Data в целевой системе; +- [Master Roadmap](../roadmap/master-roadmap.md) — фактические статусы и + следующий Build; +- [Build 060.29 Engineering Migration Report](build_060_29.md) — + Historical Access and Replay. + +--- + +## 1. Назначение Build + +Build 060.30 завершил документационный цикл Trades Feed Builds +060.20–060.30. Новое поведение Trade Runtime, Market Data SQL, Storage, +Historical Access или Replay в Build не добавлялось. + +Результат Build — согласованный набор источников истины: + +```text +production code + SQL migrations + tests + settings + ↓ +docs/architecture/trades_feed.md + ↓ +docs/operations/trades_feed_runtime.md + ↓ +overview + target architecture + master roadmap + ↓ +historические architecture и Build reports +``` + +Текущая архитектура и operations guide описывают фактическую систему. +Исторические Build-документы сохраняют принятые на соответствующем +этапе решения и test evidence без широкого переписывания задним числом. + +--- + +## 2. Завершённые подэтапы + +| Подэтап | Название | Статус | +|---|---|---| +| 060.30.0 | Documentation Baseline | Accepted | +| 060.30.1 | As-Built Trades Feed Architecture | Accepted | +| 060.30.2 | Production Operations Guide | Accepted | +| 060.30.3 | Project Entry Documentation | Accepted | +| 060.30.4 | Target Architecture Alignment | Accepted | +| 060.30.5 | Navigation and Historical Normalization | Accepted | +| 060.30.6 | Documentation Integrity Gate | Accepted | +| 060.30.7 | Final Documentation Verification | Accepted | +| 060.30.8 | Closure | Accepted | + +Перед 060.30.3 отдельно реализован и принят corrective gate Docker +Production Safety Hardening по ADR-060.30-008. Каждый основной подэтап и +corrective gate проходили отдельный read-only review. Воспроизведённые +findings исправлялись до принятия. + +--- + +## 3. Зафиксированная архитектура Trades Feed + +### 3.1. Production vertical slice + +Production Runtime завершён только для Trades и поддерживает два режима +включённого Trade Stream. При `TRADE_STREAM_ENABLED=true` и +`MARKET_DATA_STORAGE_ENABLED=true` действует полная durable-цепочка: + +```text +Dzengi WebSocket / REST Recovery + ↓ +Trade validation + Consistency + ↓ +atomic PostgreSQL Trade + checkpoint + ↓ +in-memory state advance + ↓ +Runtime Event publication +``` + +Live и Recovery в обоих режимах используют один Consistency owner. В +durable-режиме Canonical Trade и persistent checkpoint фиксируются одной +транзакцией; in-memory state продвигается только после успешного commit. + +При включённом Storage Startup выполняет Hydration до первого сетевого +I/O, получает subscription ACK, держит Live gate закрытым во время +Startup REST Recovery и разбирает buffered Live FIFO только после +восстановления. Без Storage первоначальный startup подписывается без +persistent Hydration, ожидания startup ACK и Startup REST Recovery; +Live, in-memory Consistency и reconnect Recovery остаются доступными без +restart persistence. + +### 3.2. Storage, Checkpoint, Access и Replay + +Границы подсистем разделены: + +- Storage владеет долговременной записью Canonical Market Data; +- persistent checkpoint хранит подтверждённую позицию Trade Stream; +- Historical Access только читает сохранённые данные; +- Replay строит bounded immutable plan и запускается явным caller; +- Retention и partition maintenance не запускаются автоматически. + +Historical Access и Replay не являются частью automatic Bootstrap, +не записывают воспроизводимые события обратно и не продвигают +operational checkpoint. + +### 3.3. Quotes и Candles + +Quotes и Candle revisions имеют Canonical models, PostgreSQL +repositories, Historical Access и Replay contracts. Они не подключены +как Production Runtime consumers и не являются завершёнными production +feeds. + +--- + +## 4. Production Operations Guide + +Создан единый runbook, который согласован с `config.py`, +`.env.example`, Compose, Dockerfiles и test harness: + +- host и Compose startup; +- feature flags и явные URL/symbols; +- PostgreSQL pool, migrations и lifecycle; +- startup recovery, reconnect и graceful shutdown; +- file-backed secrets и запрет repository `.env` для production; +- backup, restore и blocking migration 9; +- Retention, Historical Access и Replay procedures; +- unit, integration, stress и opt-in live verification; +- диагностика типовых startup и database failures. + +Migration 9 остаётся блокирующей. Перед применением к большой базе +обязательны backup, benchmark на сопоставимом объёме и maintenance +window. Online staged migration не реализована. + +--- + +## 5. Docker Production Safety Hardening + +ADR-060.30-008 устранил обнаруженный pre-production blocker: прежний +Docker context мог включить `app/.env` в image layer. + +Принятые меры: + +- корневой `.dockerignore` ограничивает build context; +- Dockerfile копирует только production requirements и `app/src`; +- production dependencies закреплены hash-lock; +- bot работает как non-root с read-only root filesystem; +- capabilities удалены, включён `no-new-privileges`; +- secrets передаются через явные file-backed contracts; +- Compose требует явный project namespace и закрывает PostgreSQL port; +- PostgreSQL bootstrap-admin отделён от application-role; +- healthcheck отклоняет privileged или неправильно настроенную role; +- shutdown использует явные budgets и ожидает blocking lifecycle work. + +Images, собранные до появления проверенного `.dockerignore`, считаются +потенциально скомпрометированными и не должны публиковаться. Удаление +старых images и rotation реальных credentials требуют отдельного +операционного разрешения. + +--- + +## 6. PostgreSQL version policy + +Единственный поддерживаемый production deployment baseline: + +```text +PostgreSQL 16.14 hardened image, закреплённый tag + digest +``` + +Изолированная verification matrix подтвердила application compatibility +с PostgreSQL 17.10 для migrations 1–9, repositories, checkpoint, +Historical Access, Replay, Trade Runtime integration и restart. + +Этот результат не принимает hardened PostgreSQL 17 image, Compose +cutover или upgrade существующего volume. Production-переход на +PostgreSQL 17 требует отдельного infrastructure gate. Major version +нельзя переключать на существующем data volume без проверенной процедуры +`pg_dump/restore` либо `pg_upgrade`. + +--- + +## 7. Documentation Integrity Gate + +Добавлен локальный offline gate: + +```text +scripts/check_documentation_integrity.py +app/tests/static/test_documentation_integrity_gate.py +``` + +Gate проверяет обязательные документы, относительные Markdown-ссылки, +выход path за repository root, URI safety и переносимость path rules. +Он не обращается к сети и входит в default offline regression и Pyright +scope. + +Историческая навигация нормализована без изменения старых test counts и +принятых решений. Stage roadmaps явно обозначены как архивные и больше +не определяют следующий Build. + +--- + +## 8. Финальная verification matrix + +060.30.7 и Closure подтвердили: + +```text +Documentation integrity gate: 366 documents, 259 links, 0 issues +Полный static-набор: 332 passed +Pyright: 0 errors, 0 warnings +Dependency integrity: pip check clean +Compileall во временном cache: 618 files, clean +Полная offline-регрессия: 3054 passed, 95 deselected +Target config/Docker tests: 73 passed +Compose base + exchange-auth config: clean +Shell syntax затронутых scripts: clean +PostgreSQL application compatibility: 17.10 / 170010 +PostgreSQL official image digest: sha256:8189a1f6e40904781fc9e2612687877791d21679866db58b1de996b31fc312e4 +Application-role dangerous flags: 5/5 disabled +Полный PostgreSQL 17 integration: 90 passed +Критические PG17 race/restart repeats: 10 x 6 = 60 passed +Durable restart probe: preserved +Test-only PostgreSQL 17 resources: removed +PostgreSQL 16.14 deployment baseline: unchanged +Git diff --check: clean +Text hygiene tracked/untracked: 61 Build files, clean +Staging candidates: 61 paths, .gitignore excluded +Git index: empty +Три независимых финальных review: clean +``` + +Test-only PG17 container, network, volume, derived image и временные +password-файлы удалены. Существующие Docker resources не изменялись. +Stress/soak и Dzengi live в 060.30.7 повторно не запускались: Runtime +behavior Build 060.30 не менял, а соответствующее acceptance evidence +зафиксировано в Build 060.26. + +--- + +## 9. Границы завершённого Build + +Build 060.30 не реализует: + +- новую Market Data feed; +- Production Runtime consumers для Quotes или Candles; +- initial historical backfill и completeness metadata; +- automatic Retention Scheduler; +- Replay Bootstrap или automatic startup; +- Backtesting, Analytics API или exchange simulator; +- online replacement migration 9; +- PostgreSQL 17 production deployment; +- Production deployment, backup automation или monitoring program. + +Доступная история начинается с наиболее ранней Canonical записи, +которая фактически находится в PostgreSQL. Обычно это момент успешного +включения Storage, но история может включать ранее импортированные +durable rows. Нижняя граница сдвигается только после явно применённой +Retention Policy. Документация не обещает полную биржевую историю за +более ранний период. + +Пользовательское изменение `.gitignore` не относится к Build 060.30 и +не должно входить в staging. + +--- + +## 10. Git и выпуск + +Closure подготавливает точный список файлов Build без `.gitignore`, но +не выполняет `git add`, commit или push. + +Локальный `main` до фиксации 060.30 опережает remote на 58 commits. +После отдельного commit Build 060.30 требуется отдельный read-only аудит +этих 58 прежних commits и нового commit, затем согласованное исправление +истории Build 045/046 и повторная проверка итоговой цепочки. Push +разрешается только отдельным поручением после этого аудита. + +--- + +## 11. Итог + +Build 060.30 завершён и принят. + +Trades Feed получил согласованную текущую архитектуру, единый production +runbook, безопасный Docker baseline, offline documentation gate и +финальное verification evidence. Документационный цикл Builds +060.20–060.30 закрыт без скрытого изменения Market Data Runtime или SQL. + +Следующий Build — 061.00 Validation and Canonicalization Boundary +Refactoring. Он начинается с отдельного read-only анализа фактического +ownership validation, parsing, normalization, mapping, consistency и +data-quality границ. diff --git a/docs/migrations/build_060_30_architecture.md b/docs/migrations/build_060_30_architecture.md new file mode 100644 index 0000000..2b87472 --- /dev/null +++ b/docs/migrations/build_060_30_architecture.md @@ -0,0 +1,820 @@ +# Build 060.30 — Market Data Acquisition Final Documentation Architecture + +**Статус:** Accepted + +**Build:** 060.30 + +**Подсистема:** Market Data / Trades Feed (Time & Sales) + +**Дата начала:** 2026-08-02 + +**Дата завершения:** 2026-08-03 + +**Версия документа:** 1.1 + +--- + +## Связанные документы + +- [Текущая архитектура Trades Feed](../architecture/trades_feed.md) +- [Эксплуатация Trade Stream Runtime](../operations/trades_feed_runtime.md) +- [Целевая архитектура Dzentra](../architecture/dzentra_target_architecture.md) +- [Master Roadmap](../roadmap/master-roadmap.md) +- [Итог Build 060.29](build_060_29.md) +- [Итог Build 060.30](build_060_30.md) + +--- + +## 1. Назначение + +Build 060.30 завершает ветку Trades Feed (Time & Sales) не новой +функциональностью, а согласованной документацией фактически +реализованной системы Builds 060.20–060.29. + +Build должен дать три уровня документации: + +1. одну текущую архитектурную страницу Trades Feed; +2. одно эксплуатационное руководство Production Runtime; +3. исторические Build-документы, сохраняющие принятые решения и + результаты проверок соответствующего этапа. + +По умолчанию Build 060.30 не меняет Production Runtime, SQL-схему, +Storage, Access или Replay. Изменение поведения допускается только при +воспроизведённом эксплуатационном дефекте, после отдельного анализа и +явного согласования. + +--- + +## 2. Статус подэтапов + +| Подэтап | Название | Статус | +|---|---|---| +| 060.30.0 | Documentation Baseline | Accepted | +| 060.30.1 | As-Built Trades Feed Architecture | Accepted | +| 060.30.2 | Production Operations Guide | Accepted | +| 060.30.3 | Project Entry Documentation | Accepted | +| 060.30.4 | Target Architecture Alignment | Accepted | +| 060.30.5 | Navigation and Historical Normalization | Accepted | +| 060.30.6 | Documentation Integrity Gate | Accepted | +| 060.30.7 | Final Documentation Verification | Accepted | +| 060.30.8 | Closure | Accepted | + +Все подэтапы 060.30.0–060.30.8 прошли формальный review и приняты. +Архитектурный документ имеет статус `Accepted`, итоговый report — +`Completed`; Build 060.30 закрыт 2026-08-03. + +Перед 060.30.3 обязателен отдельный corrective gate **Docker Production +Safety Hardening**. ADR-060.30-008 реализован, прошёл формальный +read-only review и принят 2026-08-03. + +--- + +## 3. Исходное состояние и инварианты + +1. Builds 060.20–060.29 завершены и имеют итоговые отчёты. +2. Фактическое поведение определяют текущие production-код, SQL + migrations, settings и тесты. +3. Принятый Build architecture фиксирует решения своего этапа, но не + обязан быть текущей обзорной страницей всей подсистемы. +4. Исторический список `out of scope` старого Build не является + описанием текущих ограничений после выполнения следующих Builds. +5. Production vertical slice завершён для Trades. +6. Quotes и Candles имеют Canonical models, PostgreSQL repositories, + Historical Access и Replay contracts, но не подключены как + Production Runtime consumers. +7. Storage write-side, Persistent Checkpoint, Historical Access и Replay + сохраняют разные границы ответственности и lifecycle. +8. История доступна только за фактически сохранённый период. Initial + exchange backfill и completeness metadata не реализованы. +9. Retention запускается только явно; автоматического scheduler нет. +10. Replay является caller-owned Python API без Bootstrap wiring и + automatic startup. +11. Пользовательское изменение `.gitignore` не относится к Build 060.30. +12. Перестановка локальных commits Build 045/046 выполняется отдельной + операцией после завершения Build 060.30 и до будущего push. +13. ADR-060.30-008 ограничивает Docker context production allowlist, + использует file-backed secrets и hardened containers. Images, + собранные до этого исправления, остаются потенциально + скомпрометированными и не должны публиковаться. + +--- + +## 4. Иерархия источников истины + +Для текущего поведения используется следующий приоритет: + +```text +production code + SQL migrations + tests + settings + ↓ +accepted current architecture + operations guide + ↓ +overview + project structure + target architecture + roadmap +``` + +Для исторического вопроса источником являются architecture и report +соответствующего Build. Старый report не имеет приоритета над текущей +as-built страницей при ответе на вопрос, как система работает сейчас. + +Исторические evidence, test counts и первоначальный scope не +переписываются задним числом. Устаревшая будущая нумерация получает +краткое retrospective-уточнение. + +--- + +## 5. Целевая структура документации + +```text +README.md + ↓ +docs/architecture/trades_feed.md + ├── фактическая архитектура и ownership + └── docs/operations/trades_feed_runtime.md + └── настройка, запуск, recovery и проверка + +docs/migrations/build_060_20...build_060_30 + └── исторические решения и verification evidence + +docs/roadmap/master-roadmap.md + └── текущая последовательность и фактические статусы +``` + +Текущая архитектура не должна дублировать длинную историю исправлений. +Operations Guide не должен подменять архитектуру или обещать +неавтоматизированные функции. + +--- + +## 6. Контракт текущей архитектуры Trades Feed + +[Текущая архитектура Trades Feed](../architecture/trades_feed.md) +обязана фиксировать: + +- точный scope Production Trades vertical slice; +- транспортную границу Dzengi и Canonical models; +- Live processing path; +- Startup Hydration и Startup Recovery; +- reconnect → subscription restore → Recovery → Live; +- единый Consistency state для Live и Recovery; +- durable write до продвижения checkpoint; +- Application, Runtime и task ownership; +- Scheduler, Supervisor и transport liveness; +- разделение Storage write-side, Historical Access и Replay; +- направление зависимостей между пакетами; +- доступный период истории; +- фактические ограничения Quotes, Candles, Retention и Replay. + +На этой странице запрещено объявлять весь Market Data Acquisition +завершённым: production-завершён только Trades Feed. + +--- + +## 7. Контракт Production Operations Guide + +[Эксплуатационное руководство](../operations/trades_feed_runtime.md) +обязано содержать: + +- проверенные требования Python и PostgreSQL; +- local и Docker Compose способы запуска; +- полный список Trade Stream и Market Data Storage settings; +- обязательную зависимость + `MARKET_DATA_STORAGE_ENABLED=true → TRADE_STREAM_ENABLED=true`; +- `DB_HOST=localhost` для host-run и `DB_HOST=postgres` для Compose; +- startup и graceful shutdown sequence; +- фатальность ошибок включённого Runtime и persistent write; +- автоматический запуск migrations 1–9; +- предупреждение о блокирующей migration 9; +- backup, benchmark и maintenance window для большой базы; +- ручной характер Retention; +- caller-owned характер Historical Access и Replay; +- безопасные unit, integration, PostgreSQL, stress и live-команды; +- troubleshooting и границу полноты истории; +- запрет публикации credentials и production DSN. + +Runbook обязан описывать hardened Docker baseline без преувеличения его +возможностей и отдельно предупреждать о старых images, собранных до +появления проверенного `.dockerignore`. + +Версионная политика PostgreSQL закрыта следующей границей: + +1. PostgreSQL 16.14 остаётся единственным поддерживаемым production + deployment baseline и закреплён в текущем hardened Compose. +2. Совместимость приложения с PostgreSQL 17.10 проверена изолированной + matrix 060.30.7 для migrations, repositories, checkpoint, Historical + Access, Replay и Trade Runtime integration. +3. Этот результат не является приёмкой hardened PostgreSQL 17 image, + Compose cutover или upgrade существующего volume. Такой production + переход требует отдельного infrastructure gate. + +--- + +## 8. Scope подэтапов + +### 060.30.0 — Documentation Baseline + +- инвентаризация README, architecture, roadmap и Builds 060.20–060.29; +- фиксация источников истины; +- определение стабильных и исторических документов; +- фиксация текущих расхождений и границ Build; +- создание настоящего документа. + +### 060.30.1 — As-Built Trades Feed Architecture + +- создание `docs/architecture/trades_feed.md`; +- описание фактических Live, Recovery, Persistence, Checkpoint, + Historical Access и Replay boundaries; +- описание startup, reconnect и shutdown; +- фиксация Production scope только для Trades. + +### 060.30.2 — Production Operations Guide + +- создание `docs/operations/trades_feed_runtime.md`; +- проверка settings и feature flags по коду; +- описание host/Compose конфигурации PostgreSQL; +- описание migrations, Retention, Replay и verification procedures; +- фиксация временной PostgreSQL 16/17 policy до 060.30.7. +- фиксация Docker secret-in-image blocker без скрытого расширения scope. + +### 060.30.3 — Project Entry Documentation + +- обновление корневого `README.md` и `app/README.md`; +- обновление `overview.md` и `project_structure.md`; +- отделение общего/исторического `storage` foundation от + `market_data/storage`; +- замена устаревшего `app/src/storage/README.md`; +- согласование `.env.example` с фактическими settings; +- описание обычного developer workflow и обязательного Pyright gate; +- добавление понятной навигации к архитектуре и runbook. + +### 060.30.4 — Target Architecture Alignment + +- актуализация статусов 060.26–060.29; +- добавление Persistent Storage, Checkpoint, Access и Replay; +- исправление фактической схемы partitioning; +- фиксация 060.30 как текущего Build до его закрытия; +- актуализация Master Roadmap по фактическим статусам; +- сокращение исторического дублирования в Master Roadmap до ссылок. + +### 060.30.5 — Navigation and Historical Normalization + +- рабочие относительные Markdown-ссылки; +- retrospective-уточнения ошибочной будущей нумерации; +- единая семантика статусов architecture/report; +- явная маркировка исторических Stage roadmaps как архивных; +- сохранение исторических scope и evidence без широкого переписывания. + +### 060.30.6 — Documentation Integrity Gate + +- локальная проверка обязательных документов и относительных ссылок; +- запрет ссылок за пределы repository; +- включение gate в обычную offline regression; +- отсутствие сетевой зависимости и изменения Production Runtime. + +### 060.30.7 — Final Documentation Verification + +- Pyright `0 errors, 0 warnings`; +- full offline regression и `compileall`; +- `pip check` и `git diff --check`; +- проверка команд, env names и Compose configuration; +- PostgreSQL 17 application compatibility verification с явным + сохранением PostgreSQL 16.14 production baseline; +- отдельные read-only review архитектуры, runbook и полного diff. + +### 060.30.8 — Closure + +- создание `docs/migrations/build_060_30.md`; +- перевод всех подэтапов в `Accepted`, Build в `Completed`; +- перевод Master Roadmap на `060.30 Completed / 061.00 Planned`; +- подготовка точного staging-списка без `.gitignore`. + +--- + +## 9. Политика исторических документов + +Исторические Build-документы сохраняют: + +- принятый на тот момент scope; +- архитектурные решения; +- результаты тестов; +- известные на тот момент ограничения. + +В них допускаются только ограниченные корректировки: + +- неверное имя или номер будущего Build; +- неработающая локальная ссылка; +- status metadata; +- trailing whitespace и окончание файла; +- краткое retrospective-пояснение, что downstream Build уже выполнен. + +Запрещено заменять историческое описание новым состоянием системы или +пересчитывать старые verification results. + +--- + +## 10. Политика языка, ссылок и секретов + +1. Документация и комментарии проекта пишутся на русском языке. +2. Названия классов, методов, settings, протоколов и команд сохраняются + в исходном английском написании. +3. Навигация использует настоящие относительные Markdown-ссылки. +4. Backticks используются для идентификаторов, а не вместо ссылок. +5. В примерах запрещены настоящие токены, пароли, API secrets и + production DSN. +6. Команды с destructive test cleanup допускаются только для harness, + который проверяет loopback endpoint и имя базы `dzentra_test_*`. + +--- + +## 11. Критерии приёмки 060.30.0–060.30.2 + +### 060.30.0 + +Подэтап может быть принят, если: + +1. зафиксированы источники истины и границы Build; +2. перечислены все подэтапы 060.30; +3. определены три канонических документа; +4. Production-код, SQL и `.gitignore` не изменены; +5. исправление Git history явно оставлено отдельной операцией. + +### 060.30.1 + +Подэтап может быть принят, если: + +1. current architecture соответствует фактическому dependency graph; +2. Live и Recovery используют общий Consistency owner; +3. durable write и checkpoint описаны в правильном порядке; +4. startup, reconnect и shutdown соответствуют production-коду; +5. Access и Replay не представлены как часть automatic Runtime; +6. Quotes и Candles не названы production feeds; +7. все локальные ссылки разрешаются. + +### 060.30.2 + +Подэтап может быть принят, если: + +1. settings сверены с `config.py` и `.env.example`; +2. указано, что обычный Application startup требует PostgreSQL для + базовой схемы даже при выключенном Market Data Storage; +3. host/Compose DB addressing описан однозначно; +4. PostgreSQL 16/17 status не преувеличен; +5. migration 9 имеет backup/benchmark/maintenance warning; +6. Retention и Replay не названы автоматическими; +7. команды соответствуют pytest markers и safety harness; +8. документ не содержит секретов; +9. текущий Compose build не представлен как production-safe до появления + проверенного `.dockerignore` и согласованного hardening. + +--- + +## 12. Критерии приёмки 060.30.3–060.30.8 + +### 060.30.3 + +1. README описывает запуск существующего checkout, а не генерацию нового. +2. Overview и Project Structure перечисляют фактические пакеты. +3. Общий/исторический `storage` и Canonical `market_data/storage` + разведены без ложного объявления всего `src.storage` устаревшим. +4. `.env.example` содержит все поддерживаемые settings без secrets. +5. Developer workflow включает requirements, Pyright и pytest markers. +6. Входные страницы ведут к current architecture и runbook. + +### 060.30.4 + +1. Target Architecture содержит фактические результаты 060.26–060.29. +2. Физическое partitioning описано как отдельный parent/default table + каждого типа и явно создаваемые UTC-month partitions. +3. Master Roadmap показывает 060.30 текущим до его завершения. +4. Подробная историческая спецификация не дублируется в roadmap. +5. После closure следующий этап указан как 061.00. + +### 060.30.5 + +1. Исправлена явно неверная downstream-нумерация Builds. +2. Architecture имеет статус `Accepted`, итоговый report — `Completed`. +3. Historical scope и test evidence сохранены. +4. Stage roadmaps явно обозначены как архивные. +5. Внутренние переходы являются настоящими Markdown-ссылками. + +### 060.30.6 + +1. Link gate работает локально без сети. +2. Проверяются обязательные документы, отсутствующие targets и выход за + repository root. +3. Gate входит в default offline regression. +4. Production Runtime и SQL не меняются. + +### 060.30.7 + +1. Pyright возвращает `0 errors, 0 warnings`. +2. Offline regression, `compileall` и `pip check` проходят. +3. Documentation link gate и `git diff --check` проходят. +4. Compose config проверен без раскрытия secrets. +5. PostgreSQL 17.10 application compatibility matrix проходит, а + PostgreSQL 17 явно не включается в поддерживаемый deployment baseline. +6. Независимые read-only review не содержат открытых findings. + +### 060.30.8 + +1. Создан итоговый `build_060_30.md` без преждевременных claims. +2. Все подэтапы переведены в `Accepted`, Build — в `Completed`. +3. Master Roadmap переведён на `060.30 Completed / 061.00 Planned`. +4. Подготовлен точный staging-список без `.gitignore`. +5. Commit и push выполняются только по отдельному поручению. + +--- + +## 13. Общие критерии закрытия Build + +Build 060.30 может быть закрыт, если: + +1. стабильные страницы согласованы между собой и с кодом; +2. README ведёт к текущей архитектуре и runbook; +3. Target Architecture и Master Roadmap имеют фактические статусы; +4. документационный static gate проходит без сети; +5. Pyright, offline regression, PostgreSQL verification и compileall + проходят; +6. `git diff --check` не содержит ошибок; +7. полный read-only review не содержит открытых findings; +8. итоговый report создан только после фактической приёмки; +9. `.gitignore` не входит в staging; +10. Docker secret-in-image blocker устранён либо Compose явно исключён из + поддерживаемых production deployment paths; +11. commit и push выполняются только по отдельному поручению. + +--- + +## 14. Архитектурные решения + +### ADR-060.30-001 — Current-state отделён от Build history + +**Статус:** Accepted + +Текущая архитектура описывается одной стабильной страницей, а Build +reports остаются историческими evidence. + +### ADR-060.30-002 — Trades Feed имеет одну каноническую страницу + +**Статус:** Accepted + +Обзорные документы и README ссылаются на +`docs/architecture/trades_feed.md`, а не копируют подробности Runtime. + +### ADR-060.30-003 — Operations имеют один канонический runbook + +**Статус:** Accepted + +Settings, startup, recovery, migrations и verification procedures +поддерживаются в `docs/operations/trades_feed_runtime.md`. + +### ADR-060.30-004 — Исторические документы не переписываются широко + +**Статус:** Accepted + +060.30 исправляет навигацию и явно устаревшие метаданные, но сохраняет +исторические решения и результаты. + +### ADR-060.30-005 — Documentation links получают static gate + +**Статус:** Accepted + +060.30.6 добавляет локальную проверку обязательных документов и +внутренних Markdown-ссылок. + +### ADR-060.30-006 — Production behavior не меняется без дефекта + +**Статус:** Accepted + +Финальная документация не является основанием для скрытого изменения +Runtime или SQL. Реальный дефект анализируется и согласуется отдельно. + +### ADR-060.30-007 — Git history repair отделён от Build 060.30 + +**Статус:** Accepted + +Перестановка Build 045/046 и повторная проверка локальных commits +выполняются после фиксации 060.30, но до push, с backup ref и отдельным +разрешением. + +### ADR-060.30-008 — Docker hardening обязателен до 060.30.3 + +**Статус решения:** Accepted + +**Статус реализации:** Accepted (2026-08-03) + +Утверждён отдельный corrective gate со следующими границами: + +- корневой `.dockerignore` исключает secrets, `.venv`, caches, tests, + logs, data и локальные artifacts; +- Dockerfile копирует только production requirements и `app/src`, + использует закреплённый image, полный hash-lock и non-root user; +- Compose не содержит placeholder secrets, не публикует PostgreSQL + наружу по умолчанию и не создаёт бесконечный fatal crash loop; +- Compose требует явный project namespace; оператор обязан проверить + его уникальность, потому что повторное имя подключит существующие + named volumes этого namespace; +- production secrets поддерживают явный file-backed contract `*_FILE` + с fail-fast проверками конфликтов и пустых значений; +- container получает `no-new-privileges`, минимальные capabilities, + read-only filesystem, отдельный writable `/tmp` и явный shutdown + budget; +- PostgreSQL 16.14 остаётся единственным поддерживаемым production + deployment baseline; совместимость приложения с PostgreSQL 17.10 + проверена отдельно, но production cutover требует нового gate; +- PostgreSQL bootstrap-admin отделён от непривилегированной application + role; role setup атомарен, а healthcheck отклоняет legacy superuser, + memberships и неверного owner; +- image использует `SIGINT`: до запуска Aiogram polling его принимает + `asyncio.run`, превращая остановку в cancellation с ожиданием уже + начатой blocking lifecycle operation; +- verification доказывает отсутствие `.env` и test secret во всех image + layers, non-root execution и clean Compose configuration; +- удаление старых images и rotation реальных credentials требуют + отдельного разрешения. + +Corrective gate не меняет Trade Runtime, SQL или Market Data contracts. + +--- + +## 15. План файлов + +Новые файлы 060.30.0–060.30.2: + +```text +docs/migrations/build_060_30_architecture.md +docs/architecture/trades_feed.md +docs/operations/trades_feed_runtime.md +``` + +Файлы corrective gate ADR-060.30-008: + +```text +.dockerignore +app/.env.example +app/requirements.lock +app/src/core/config.py +app/tests/unit/core/test_config.py +app/tests/unit/infra/test_docker_hardening.py +infra/compose/docker-compose.yml +infra/compose/docker-compose.exchange-auth.yml +infra/docker/Dockerfile +infra/docker/postgres/Dockerfile +infra/docker/postgres/init-application-role.sh +infra/docker/postgres/healthcheck-application-role.sh +docs/migrations/build_060_30_architecture.md +docs/operations/trades_feed_runtime.md +``` + +Планируемые изменения следующих подэтапов: + +```text +README.md +app/README.md +app/src/storage/README.md +docs/architecture/overview.md +docs/architecture/project_structure.md +docs/operations/trades_feed_runtime.md (точечная синхронизация) +docs/architecture/dzentra_target_architecture.md +docs/roadmap/master-roadmap.md +historical Build documents с точечными retrospective fixes +documentation integrity gate +``` + +Итоговый файл создаётся только в 060.30.8: + +```text +docs/migrations/build_060_30.md +``` + +--- + +## 16. Вне scope + +- изменение Trade Runtime, Storage, Access или Replay behavior; +- SQL migration 10; +- online replacement для migration 9; +- initial historical backfill; +- automatic Retention Scheduler; +- Production consumers Quotes и Candles; +- Replay Bootstrap или automatic startup; +- Backtesting, Analytics API и exchange simulator; +- новая Market Data feed; +- deployment, commit или push; +- изменение пользовательского `.gitignore`; +- переписывание Git history в составе документационного Build. + +Исправление Docker security finding не входит в принятые +060.30.0–060.30.2. Оно реализовано отдельным corrective gate перед +060.30.3 и принято 2026-08-03. Deployment, удаление старых images и +rotation реальных credentials по-прежнему находятся вне scope. + +--- + +## 17. Verification evidence + +Проверки реализации 060.30.0–060.30.2 на 2026-08-02: + +```text +Новые canonical documents: 3 +Проверено относительных Markdown links: 41, clean +Ссылки за пределы repository: 0 +Settings из config отражены в guide: 36/36 +Лишние core settings в guide: 0 +Синтаксис сложных shell recipes: clean +Whitespace новых файлов: clean +Production/SQL files changed: 0 +Git index: empty +Постороннее изменение .gitignore: сохранено вне Build +``` + +Независимые read-only review архитектурного контракта, as-built страницы +и исправленного operations guide завершены без открытых findings. +Единственный P2 основной схемы исправлен и прошёл узкий повторный review. +060.30.0–060.30.2 приняты 2026-08-02. + +Docker secret-in-image blocker зарегистрирован; отдельный hardening +contract реализован, прошёл отдельный приёмочный read-only review и +принят 2026-08-03. + +Проверки реализации ADR-060.30-008: + +```text +Target config/Docker tests: 73 passed +Pyright: 0 errors, 0 warnings +Full offline regression: 2723 passed, 95 deselected +Compose base + exchange override config: clean +Fail-fast обязательных Compose controls: 8/8 clean +Production dependency hash-lock: 25 packages, clean +Pinned bot/PostgreSQL image builds: clean +Image pip check: clean +Image layers: 9 checked +Project .env/.venv/tests/cache in layers: 0 +Sentinel secret content in layers: 0 +Container UID/GID: 10001:10001 +Read-only root + dropped capabilities: clean +PostgreSQL isolated network/public ports: clean / 0 +PostgreSQL application dangerous flags: 5/5 disabled +PostgreSQL owner + application DDL probe: clean +Normalized admin/application equality: rejected +Privileged-role healthcheck rejection: clean +PostgreSQL role membership rejection: clean +Atomic role-init rollback fault probe: clean +Cross-UID file-secret read probe: clean +Early-startup stop-signal probe: clean +Existing PostgreSQL 17 volume: detected, untouched +Controlled Docker cleanup: clean +``` + +Test-only Compose container, network, volumes, layer archives и +secret-файлы после проверки удалены. Старые локальные/registry images и +реальные credentials автоматически не удалялись и не менялись. + +Проверки реализации 060.30.3 на 2026-08-03: + +```text +Application settings в .env.example: 41/41 +Относительные ссылки touched pages: 46, clean +Documented command paths: clean +LF / final newline / trailing whitespace: 8 files, clean +Target config/Docker tests: 73 passed +Pyright: 0 errors, 0 warnings +Full offline regression: 2723 passed, 95 deselected +Новые Production/SQL/Docker изменения: 0 +Пользовательский .gitignore: сохранён вне scope +``` + +Первичный независимый architecture review обнаружил три P2 в описании +Quote/Candle scope, Bootstrap I/O и legacy import graph. Формулировки +исправлены; повторный review и два параллельных scope/settings review +завершены без открытых findings. 060.30.3 принят 2026-08-03. + +Проверки реализации 060.30.4 на 2026-08-03: + +```text +Итоговые reports 060.26–060.29: 4/4 Completed +Partitioned parent/default families: 3/3 +Partition/Retention wiring в Bootstrap: отсутствует +Относительные ссылки touched pages: 24, clean +Stale-фразы предыдущей редакции: 0 +Master Roadmap: 1751 -> 227 строк +Изменения отдельных Stage-roadmaps: 0 +LF / final newline / trailing whitespace: clean +Git diff --check: clean +Pyright: 0 errors, 0 warnings +Full offline regression: 2723 passed, 95 deselected +Новые Production/SQL/Docker изменения: 0 +Пользовательский .gitignore: сохранён вне scope +``` + +Три независимых read-only review обнаружили три уникальные P2: +преждевременное закрытие всего Market Data Acquisition в roadmap, +неверную гарантию межстраничного Historical snapshot и слишком узкий +UTC-only контракт Replay Clock. Формулировки исправлены; все три +повторных review завершены без открытых findings. 060.30.4 принят +2026-08-03. + +Проверки реализации 060.30.5 на 2026-08-03: + +```text +Architecture statuses 060.20–060.29: 11/11 Accepted +Final report statuses 060.20–060.29: 11/11 Completed +Retrospective notes: 9/9 +Архивные Stage roadmaps: 7/7 +Ссылки Master -> Stage: 7/7, clean +Ссылки Stage -> Master/Target: 14/14, clean +Relative links во всех docs: 229, broken 0 +Документы scope 060.30.5: 34 +CRLF / missing final newline: 0 / 0 +Stage 08/09 files: 0 +Git diff --check: clean +Новые Production/SQL/Docker изменения: 0 +Пользовательский .gitignore: сохранён вне scope +``` + +Первичный acceptance review выявил два класса P2: неполную фактическую +цепочку в части retrospective-примечаний и оставшиеся некликабельные +переходы/списки связанных документов. Все девять примечаний приведены к +единой цепочке до 060.30 и ограничению будущей нумерации только 061.00; +навигация нормализована. Три повторных review завершены без открытых +findings. 060.30.5 принят 2026-08-03. + +Проверки реализации 060.30.6 на 2026-08-03: + +```text +Целевой documentation static: 331 passed +Полный static-набор: 332 passed +Documentation integrity gate: 365 documents, 252 links, 0 issues +Pyright: 0 errors, 0 warnings +Полная offline-регрессия: 3054 passed, 95 deselected +Git diff --check: clean +Новые Production Runtime / SQL изменения: 0 / 0 +Пользовательский .gitignore: сохранён вне scope +``` + +Приёмочные read-only review отдельно проверили Markdown/CommonMark +precedence, безопасность URI и filesystem paths, переносимость Windows, +линейность обработки больших документов и wiring команд в README, +runbook, pytest и Pyright. Все воспроизведённые пограничные findings +исправлены с regression-тестами; повторные review завершены без открытых +замечаний. 060.30.6 и ADR-060.30-005 приняты 2026-08-03. + +Выполненная verification matrix 060.30.7: + +```text +Documentation integrity gate: 365 documents, 252 links, 0 issues +Полный static-набор: 332 passed +Pyright: 0 errors, 0 warnings +Dependency integrity: pip check clean +Compileall во временном cache: 618 files, clean +Полная offline-регрессия: 3054 passed, 95 deselected +Target config/Docker tests: 73 passed +Compose base + exchange-auth config: clean +Shell syntax затронутых scripts: clean +PostgreSQL application compatibility: 17.10 / 170010 +PostgreSQL official image digest: sha256:8189a1f6e40904781fc9e2612687877791d21679866db58b1de996b31fc312e4 +Application-role dangerous flags: 5/5 disabled +Полный PostgreSQL 17 integration: 90 passed +Критические PG17 race/restart repeats: 10 x 6 = 60 passed +Durable restart probe: preserved +Test-only container/network/volume/image: removed +Test-only secret files: removed +PostgreSQL 16.14 deployment baseline: unchanged +Существующие Docker resources: untouched +Git index: empty +Пользовательский .gitignore: сохранён вне scope +``` + +Предварительный review 060.30.7 обнаружил три P2: смешение application +compatibility с deployment acceptance, ложную гарантию уникальности +Compose namespace и backup-команду, привязанную к default database +settings. Границы и runbook исправлены без изменения Runtime, SQL или +Compose behavior. + +Три финальных read-only review дополнительно обнаружили четыре P2: +устаревший current-status, небезопасный lifetime cleanup trap в +`compileall` recipe, ослабленную offline regression-команду и +неоднозначное расширение точного PostgreSQL 16.14 baseline до +произвольного PostgreSQL 16. Все findings исправлены; повторные +architecture, runbook/security и full-diff review завершены без открытых +замечаний. 060.30.7 принят 2026-08-03. + +--- + +## 18. Итог + +060.30.0–060.30.8 и corrective gate ADR-060.30-008 формально приняты. +Итоговый report создан; Build 060.30 имеет статус `Completed`. + +Во время подготовки runbook был обнаружен pre-production finding: +прежний Docker context включал `app/.env`. ADR-060.30-008 исправил этот +дефект allowlist-контекстом, явным `COPY`, file-backed secrets, +non-root/read-only containers и закрытой database network. + +PostgreSQL 16.14 остаётся единственным production deployment baseline. +PostgreSQL 17.10 проверен только как application compatibility target. + +Следующий запланированный Build — 061.00 Validation and Canonicalization +Boundary Refactoring. Он не начат и требует отдельного read-only +архитектурного анализа. + +Commit, аудит локальной Git history и push не входят в Closure и +выполняются только по отдельным поручениям. diff --git a/docs/operations/trades_feed_runtime.md b/docs/operations/trades_feed_runtime.md new file mode 100644 index 0000000..ecf6c0f --- /dev/null +++ b/docs/operations/trades_feed_runtime.md @@ -0,0 +1,1110 @@ +# Trade Stream Production Runtime — эксплуатационное руководство + +**Статус:** Current; accepted in Build 060.30.2 + +**Область:** Trades Feed, persistent Market Data Storage и связанные проверки + +**Версия документа:** 1.0 + +--- + +## Связанные документы + +- [Текущая архитектура Trades Feed](../architecture/trades_feed.md) +- [Архитектура Build 060.30](../migrations/build_060_30_architecture.md) +- [Пример переменных окружения](../../app/.env.example) +- [Docker Compose](../../infra/compose/docker-compose.yml) +- [Storage migrations](../../app/src/storage/migrations.py) + +--- + +## 1. Назначение + +Документ описывает настройку, запуск, остановку и проверку Production +Trades Feed. Он не заменяет процедуру deployment конкретной среды и не +содержит production credentials. + +Главные правила: + +1. PostgreSQL нужен обычному запуску приложения даже при выключенном + Market Data Storage: Bootstrap всегда создаёт базовые таблицы журнала + и balance snapshots. +2. Trade Stream и persistent Storage выключены по умолчанию. +3. Storage разрешён только вместе с Trade Stream. +4. Persistent checkpoint включается вместе со Storage; отдельного флага + нет. +5. Ошибка включённого Trade Runtime, Storage или Recovery фатальна. +6. Retention, monthly partition creation и Replay не запускаются + автоматически. +7. Docker Compose использует hardened baseline из ADR-060.30-008; + старые images, собранные до появления `.dockerignore`, остаются + потенциально скомпрометированными и не должны публиковаться. + +--- + +## 2. Проверенная среда и version policy + +| Компонент | Статус | +|---|---| +| Python 3.12.13 | Закреплённый Production Docker base; Pyright target — 3.12 | +| PostgreSQL 16.14 | Полная Storage/Restart/Replay acceptance выполнена | +| PostgreSQL 17.10 | Application compatibility проверена в 060.30.7; текущим Compose не используется и deployment baseline не является | +| psycopg 3.2.9 | Закреплён в production requirements | +| psycopg-pool 3.3.1 | Закреплён в production requirements | +| websockets 13.1 | Закреплён в production requirements | + +PostgreSQL 16.14 остаётся единственным поддерживаемым production +deployment baseline. Изолированная matrix 060.30.7 подтвердила +совместимость приложения с PostgreSQL 17.10, включая migrations, +repositories, checkpoint, Historical Access, Replay, Trade Runtime +integration и restart. + +Эта проверка не принимает hardened PostgreSQL 17 image, Compose cutover +или upgrade существующего volume. Production deployment на PostgreSQL +17 требует отдельного infrastructure gate с повторной проверкой role +bootstrap, healthcheck, image и Compose path. + +Для первого production deployment допустимы два безопасных пути: + +1. использовать текущий проверенный hardened PostgreSQL 16.14 image, + закреплённый одновременно tag и digest; +2. переходить на PostgreSQL 17 только после отдельной приёмки точного + hardened deployment image и полного Compose path. + +Нельзя переключать major version на существующем data volume. Upgrade +требует проверенного `pg_dump/restore` либо отдельной процедуры +`pg_upgrade`. + +--- + +## 3. Подготовка Python environment + +Команды выполняются из корня repository: + +```bash +python3.12 -m venv app/.venv +app/.venv/bin/python -m pip install --upgrade pip +app/.venv/bin/python -m pip install -r app/requirements-dev.txt +``` + +`app/requirements.txt` остаётся читаемым списком прямых production +dependencies. Production image устанавливает полный транзитивный +`app/requirements.lock` только с `pip --require-hashes`. +`requirements-dev.txt` включает прямые production requirements и +инструменты pytest/Pyright для developer environment. + +Lock регенерируется намеренно и review-ится как dependency update: + +```bash +uv pip compile app/requirements.txt \ + --generate-hashes \ + --universal \ + --python-version 3.12 \ + --no-header \ + --no-annotate \ + --output-file app/requirements.lock +``` + +После генерации сохраняются только три русскоязычные служебные строки в +начале файла; dependency records и hashes остаются машинными данными. + +Нельзя вручную убирать hash или обновлять только транзитивную version без +повторных build, smoke, `pip check` и regression. + +Создайте `app/.env` на основе `app/.env.example`, но не перезаписывайте +существующий файл автоматически и никогда не добавляйте его в Git. + +--- + +## 4. Контракт переменных окружения + +### 4.1. Application и Telegram + +| Переменная | Default | Назначение | +|---|---|---| +| `BOT_TOKEN` | нет | Прямой token; взаимоисключающий с `BOT_TOKEN_FILE` | +| `BOT_TOKEN_FILE` | нет | UTF-8 файл обязательного Telegram token | +| `BOT_PARSE_MODE` | `HTML` | Режим форматирования Telegram | +| `APP_ENV` | `dev` | Имя среды | +| `LOG_LEVEL` | `INFO` | Уровень стандартного logging | +| `TZ` | `Europe/Minsk` | Часовой пояс общих компонентов | +| `DEBUG_ENABLED` | `false` | Общий debug-флаг | +| `JOURNAL_DEBUG_ENABLED` | `false` | Debug-события журнала | +| `DZENTRA_RUNTIME_ENV_FILE` | `app/.env` | Отдельный writable env-файл для runtime-переключателей | + +`DEBUG_ENABLED` и `JOURNAL_DEBUG_ENABLED` перечислены в `.env.example` +и по умолчанию выключены. + +Если `DZENTRA_RUNTIME_ENV_FILE` присутствует, его значение обязано быть +непустым. Явно пустая строка считается ошибкой, а не просьбой молча +вернуться к read-only `app/.env` внутри container. + +### 4.2. PostgreSQL + +| Переменная | Default | Назначение | +|---|---|---| +| `DB_HOST` | `localhost` | Hostname PostgreSQL | +| `DB_PORT` | `5432` | PostgreSQL port | +| `DB_NAME` | `dzentra_bot` | Database name | +| `DB_USER` | `dzentra_bot` | Database role | +| `DB_PASSWORD` | пусто | Database password | +| `DB_PASSWORD_FILE` | нет | UTF-8 файл database password вместо прямого значения | + +Для запуска приложения на host против опубликованного Compose port: + +```text +DB_HOST=127.0.0.1 +DB_PORT=5432 +``` + +Для bot container внутри одной Compose network: + +```text +DB_HOST=postgres +DB_PORT=5432 +``` + +`localhost` внутри bot container означает сам bot container, а не +PostgreSQL service. + +Текущий legacy DSN builder корректно обрабатывает не все специальные +символы пароля. До перехода этого пути на `make_conninfo` используйте +длинный случайный URL-safe пароль из букв, цифр, `-` и `_`. Значения +`DB_NAME` и `POSTGRES_DB` обозначают одну базу. `DB_USER` и +`DB_PASSWORD` относятся к отдельной непривилегированной роли приложения; +они не должны совпадать с `POSTGRES_ADMIN_USER` и паролем +bootstrap-admin. + +Изменение файлов с admin- или application-паролем после инициализации +named volume не меняет пароли уже существующих PostgreSQL roles. Для +ротации нужна отдельная контролируемая DBA-процедура. + +### 4.3. Exchange + +| Переменная | Default | Назначение | +|---|---|---| +| `EXCHANGE_ENABLED` | `false` | Legacy exchange feature flag | +| `EXCHANGE_NAME` | `dzengi` | Canonical venue для Storage | +| `EXCHANGE_BASE_URL` | пусто | REST base URL, обязательный для Recovery | +| `EXCHANGE_WS_URL` | пусто | Legacy WebSocket consumers, не Trade Runtime fallback | +| `EXCHANGE_API_KEY` | пусто | API key, если нужен endpoint | +| `EXCHANGE_API_KEY_FILE` | нет | UTF-8 файл API key вместо прямого значения | +| `EXCHANGE_API_SECRET` | пусто | API secret других exchange operations | +| `EXCHANGE_API_SECRET_FILE` | нет | UTF-8 файл API secret вместо прямого значения | +| `EXCHANGE_TIMEOUT_SEC` | `10` | REST timeout | +| `EXCHANGE_TESTNET` | `false` | Признак тестовой среды | +| `DEFAULT_SYMBOL` | `ETH/USD_LEVERAGE` | Общий symbol приложения | + +Production Trades Feed использует отдельный `TRADE_STREAM_WS_URL`. +`EXCHANGE_WS_URL` не подставляется вместо него. + +Для каждого из четырёх secrets разрешён ровно один источник: прямая +переменная либо соответствующая `*_FILE`. Одновременное задание двух +источников, пустой путь, unreadable/не-UTF-8 файл и пустое содержимое +завершают startup ошибкой. Из file value удаляется ровно один конечный +перевод строки; остальное содержимое не нормализуется. Текст ошибки не +содержит значение секрета. + +### 4.4. Trade Stream + +| Переменная | Default | Правило | +|---|---:|---| +| `TRADE_STREAM_ENABLED` | `false` | Явно включает Runtime | +| `TRADE_STREAM_WS_URL` | пусто | Обязателен при включении | +| `TRADE_STREAM_SYMBOLS` | пусто | Обязательный comma-separated список | +| `TRADE_STREAM_OPEN_TIMEOUT_SECONDS` | `10` | Положительное конечное число | +| `TRADE_STREAM_PROBE_TIMEOUT_SECONDS` | `20` | Ping/Pong timeout | +| `TRADE_STREAM_CLOSE_TIMEOUT_SECONDS` | `10` | Close timeout | +| `TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS` | `30` | Максимальное отсутствие activity | +| `TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS` | `5` | Интервал liveness checks | +| `TRADE_STREAM_RECOVERY_WINDOW_MS` | `3599999` | Максимальное REST recovery window | +| `TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS` | `10` | Startup ACK timeout | +| `TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY` | `10000` | Ранний FIFO до ACK/Recovery | + +Symbols нормализуются в uppercase, сортируются и удаляют дубликаты. +Пустые элементы запрещены. Все числовые параметры должны быть строго +положительными; float-параметры также должны быть конечными. + +Built-in keepalive библиотеки `websockets` отключён. Liveness проверяет +собственный Ping/Pong probe через Scheduler и Heartbeat. + +### 4.5. Market Data Storage + +| Переменная | Default | Правило | +|---|---:|---| +| `MARKET_DATA_STORAGE_ENABLED` | `false` | Persistent Trades и checkpoint | +| `MARKET_DATA_STORAGE_POOL_MIN_SIZE` | `1` | Положительное целое | +| `MARKET_DATA_STORAGE_POOL_MAX_SIZE` | `4` | Не меньше min size | +| `MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS` | `10` | Положительное конечное число | + +`MARKET_DATA_STORAGE_ENABLED=true` при выключенном Trade Stream +отклоняется во время загрузки settings. + +--- + +## 5. Режимы запуска + +### 5.1. Application без Trade Stream + +```text +TRADE_STREAM_ENABLED=false +MARKET_DATA_STORAGE_ENABLED=false +``` + +Trade Runtime и отдельный Market Data pool не создаются. PostgreSQL всё +равно должен быть доступен для legacy `init_schema()` и журнала. + +### 5.2. Volatile Trade Runtime + +```text +TRADE_STREAM_ENABLED=true +MARKET_DATA_STORAGE_ENABLED=false +``` + +Доступны live processing и reconnect REST Recovery. Consistency state и +checkpoint живут только в памяти; после restart durable continuation +нет. Startup не выполняет persistent Hydration и Startup Recovery. + +### 5.3. Durable Trades + +```text +TRADE_STREAM_ENABLED=true +MARKET_DATA_STORAGE_ENABLED=true +``` + +Приложение открывает отдельный pool, применяет migrations, сохраняет +Trades и persistent checkpoint, выполняет Hydration и Startup Recovery. + +### 5.4. Host startup + +Из каталога `app`: + +```bash +.venv/bin/python -m src.main +``` + +Ошибка включённого Trade Runtime или его неожиданное нормальное +завершение останавливает всё приложение, а не оставляет Telegram polling +в частично работающем режиме. + +--- + +## 6. Startup и graceful shutdown + +### 6.1. Application startup + +```text +load и validate settings +→ configure logging +→ legacy init_schema() через PostgreSQL +→ собрать Storage/Runtime dependency graphs без I/O +→ создать Bot и Dispatcher +→ открыть Market Data pool, если Storage включён +→ применить migrations 1–9 +→ запустить Telegram polling и root Trade Runtime task +``` + +### 6.2. Persistent Trade Runtime startup + +```text +hydrate checkpoint и deduplication tail +→ WebSocket connect +→ subscribe +→ дождаться matching ACK, буферизуя ранние market documents +→ REST Recovery при закрытом Live gate +→ обработать buffered FIFO +→ запустить Supervisor, receive loop и Scheduler +``` + +Corrupt/orphan checkpoint, ACK timeout, overflow и Recovery failure +фатальны. Тихого сброса persistent state нет. + +### 6.3. Reconnect + +```text +transport error или Heartbeat timeout +→ одна generation-aware single-flight операция +→ закрыть Live gate +→ Disconnect +→ Connect +→ restore subscriptions +→ REST Recovery +→ открыть Live gate +``` + +Reconnect выполняет одну попытку без retry/backoff loop. Restore +subscriptions не включает отдельное ожидание ACK. Ошибка терминальна до +внешнего контролируемого restart процесса. + +### 6.4. Graceful shutdown + +```text +отменить Telegram polling +→ runtime.stop() +→ завершить startup worker, если он уже начался +→ остановить Scheduler +→ остановить Supervisor +→ отменить и дождаться receive loop +→ остановить WebSocket Session +→ очистить subscriptions +→ дождаться root Runtime task +→ закрыть Market Data pool +→ закрыть bot HTTP session +``` + +Для externally managed service нельзя использовать универсальный +60-секундный kill deadline. Бюджет graceful shutdown должен покрывать: + +- уже начатые blocking Storage/migration operations; +- все запланированные Recovery windows для всех symbols с учётом + `EXCHANGE_TIMEOUT_SEC`; +- WebSocket close и дополнительный измеренный cleanup margin. + +Recovery gap заранее не ограничен, поэтому production timeout нужно +рассчитать по принятому максимальному gap или контролировать завершение +операции по logs. Migration 9 выполняется только в отдельном maintenance +window без короткого внешнего `SIGKILL` deadline. Повторная cancellation +не должна заменять ожидание уже начатой durable operation. + +Bot image использует `SIGINT` как container stop signal. До запуска +Aiogram polling его обрабатывает `asyncio.run`: корневая задача получает +cancellation и дожидается уже начатой blocking lifecycle operation. +`SIGTERM` для этого раннего startup-окна не применяется, потому что +обработчик Aiogram в этот момент ещё не установлен. + +--- + +## 7. Docker Compose: hardened baseline + +ADR-060.30-008 заменил development skeleton на контролируемый baseline +для одного Production instance. Это не готовая HA-платформа: у bot пока +нет HTTP readiness/health endpoint, resource policy, backup automation, +metrics и alerting. Эти ограничения нельзя компенсировать бесконечным +автоматическим restart. + +Защитные свойства текущей конфигурации: + +- корневой `.dockerignore` является allowlist: в context входят только + Dockerfiles, PostgreSQL hardening scripts, production requirements и + `app/src`; +- Python 3.12.13 и PostgreSQL 16.14 закреплены tag + digest; +- все 25 production packages закреплены полным hash-lock, а `pip` + запускается с `--require-hashes`; +- bot работает как UID/GID `10001:10001`, оба services имеют read-only + root filesystem, `no-new-privileges` и сброшенные capabilities; +- PostgreSQL получает только необходимые entrypoint capabilities и не + публикует port на host; +- database network изолирована, а внешний network подключён только bot; +- реальные token/password не передаются через YAML или `env_file`; +- fatal exit не превращается в бесконечный `restart` loop; +- обязательный project namespace изолирует volumes и networks разных + окружений и рабочих копий; +- bot и PostgreSQL shutdown budgets обязательны и не имеют опасного + универсального default; +- изменяемый `JOURNAL_DEBUG_ENABLED` хранится в отдельном named volume, + а не в read-only image filesystem. + +### 7.1. Secret files и Compose controls + +Secret manager или оператор создаёт три файла вне repository: + +```text +/absolute/protected/dzentra/bot_token +/absolute/protected/dzentra/postgres_admin_password +/absolute/protected/dzentra/db_password +``` + +`postgres_admin_password` используется только bootstrap/admin role, а +`db_password` — отдельной непривилегированной application-role. Значения +должны различаться. `POSTGRES_ADMIN_USER` и `DB_USER` также должны быть +разными; defaults — `dzentra_admin` и `dzentra_bot`. + +Каталог с secrets должен принадлежать deployment owner и иметь mode +`0700`. Обычный Linux Compose монтирует local `file:` без преобразования +UID/GID, а bot и PostgreSQL работают под разными UID. Поэтому обычные +файлы внутри защищённого каталога должны иметь mode `0444`: доступ на +host ограничивает каталог, а в каждый container монтируются только +нужные ему файлы. Owner-only mode `0600` без отдельного UID remapping +непереносим и может сорвать startup на Linux. Не вводите значения +secrets в shell command line и не сохраняйте их в `app/.env`, Compose +YAML или control-файле. + +Перед каждой Compose-командой задаются только пути и budgets: + +```bash +export DZENTRA_COMPOSE_PROJECT_NAME=dzentra-production +export BOT_TOKEN_SECRET_FILE=/absolute/protected/dzentra/bot_token +export POSTGRES_ADMIN_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/postgres_admin_password +export DB_PASSWORD_SECRET_FILE=/absolute/protected/dzentra/db_password +export BOT_STOP_GRACE_PERIOD=30m +export POSTGRES_STOP_GRACE_PERIOD=60s +``` + +Имя проекта должно быть стабильным для одного окружения и различаться у +Production, staging и каждой локальной проверки. Не используйте default +`compose`: смена имени выбирает другой набор named volumes, а повторное +использование имени из другой копии подключает её состояние. + +`30m` и `60s` являются только примерами формата. Фактические budgets +нужно рассчитать по правилам раздела 6.4. Пропущенный путь к secret либо +любой stop budget останавливает Compose ещё на этапе interpolation. + +Если controls хранятся в файле, этот файл должен находиться вне +repository, иметь mode `0600` и содержать только пути/несекретные +настройки. Его передают явно через `--env-file`; автоматический +repository `.env` для production deployment не используется. + +Проверка конфигурации не должна печатать развёрнутую модель: + +```bash +( + set -eu + compose_stderr="$(mktemp)" + trap 'rm -f "$compose_stderr"' EXIT + test -r "$BOT_TOKEN_SECRET_FILE" + test -r "$POSTGRES_ADMIN_PASSWORD_SECRET_FILE" + test -r "$DB_PASSWORD_SECRET_FILE" + docker compose -f infra/compose/docker-compose.yml \ + config --quiet 2>"$compose_stderr" + test ! -s "$compose_stderr" +) +``` + +Base Compose монтирует Telegram token, bootstrap-admin password и +отдельный application database password. +`EXCHANGE_API_KEY_FILE`/`EXCHANGE_API_SECRET_FILE` поддерживаются +Application contract. Когда authenticated exchange operations +действительно нужны, используется явный override: + +```bash +export EXCHANGE_API_KEY_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_key +export EXCHANGE_API_SECRET_SOURCE_FILE=/absolute/protected/dzentra/exchange_api_secret +docker compose \ + -f infra/compose/docker-compose.yml \ + -f infra/compose/docker-compose.exchange-auth.yml \ + config --quiet +``` + +Без override эти optional credentials в container не монтируются. + +### 7.2. Build и controlled rollout + +Рекомендуемая последовательность первого rollout: + +```bash +docker compose -f infra/compose/docker-compose.yml build --pull postgres bot +docker compose -f infra/compose/docker-compose.yml up --detach postgres +docker compose -f infra/compose/docker-compose.yml up --detach bot +``` + +После запуска порядок проверки такой: + +```text +PostgreSQL healthy +→ backup и точная версия подтверждены +→ запущен ровно один bot instance +→ startup/migration logs не содержат fatal error +→ migrations 1–9 и checkpoints проверены +``` + +Оба services имеют `pull_policy: build`: Compose собирает локальные +derived images только из закреплённых base images и allowlist context, +а не подменяет их одноимённым remote image. + +Внутри Compose `DB_HOST` всегда равен `postgres`. PostgreSQL доступен +только services в internal network. Для host admin access используйте +отдельный временный loopback-only override либо `docker compose exec`, +а не постоянный public port. + +Fresh volume создаёт bootstrap-admin и отдельную application-role, +передаёт последней ownership базы и снимает опасные role flags. На +каждом startup healthcheck проверяет, что application-role существует, +не является superuser/role creator/database creator/replication/RLS +bypass, не состоит в других ролях и остаётся владельцем базы. Настройка +ролей выполняется одной транзакцией; поздняя ошибка откатывает все её +изменения, а незавершённый fresh volume остаётся unhealthy и требует +проверяемого пересоздания, а не ручного продолжения наполовину +выполненного setup. Legacy volume, где прежний +`dzentra_bot` был bootstrap-superuser, намеренно останется unhealthy. +Его нельзя «исправлять» автоматическим `ALTER ROLE`: сначала нужен +backup, затем проверенный restore в новый hardened volume либо отдельная +DBA-процедура миграции владельца и admin-role. + +Старый Compose мог создать `compose_dzentra_postgres_data` на PostgreSQL +17. PostgreSQL 16.14 не должен открывать этот data directory: in-place +downgrade major version запрещён. Такой volume и работающий PostgreSQL 17 +не останавливаются и не изменяются hardening-проверкой. Сначала нужны +backup и точная проверка версии; production cutover безопаснее отложить +до отдельной приёмки hardened PostgreSQL 17 после 060.30.7. Любой logical +restore в другую major version выполняется только в новом project/volume +после отдельной проверки совместимости и восстановления. + +`docker compose down` сохраняет named volumes. `down --volumes` удаляет +database и runtime state и запрещён без отдельного подтверждения точных +targets, проверенного backup и принятой процедуры уничтожения данных. + +### 7.3. Если image уже был собран без `.dockerignore` + +Любой image, собранный при существующем `app/.env` и отсутствии +проверенного `.dockerignore`, считается потенциально содержащим secrets +в слоях, даже если текущий container их не показывает. + +До продолжения rollout необходимо: + +1. запретить push и deployment такого image; +2. сменить `BOT_TOKEN`, database password, API keys и другие credentials, + которые находились в build context; +3. удалить соответствующие local images/build cache и registry artifacts + по принятой инфраструктурной процедуре; +4. проверить registry и deployment audit logs; +5. после hardening собрать новый image из чистого context. + +Позднее добавление `.dockerignore` не удаляет secrets из старых layers. + +--- + +## 8. Storage migrations + +При включённом Market Data Storage migrations запускаются автоматически +до Trade Runtime. `StorageMigrationRunner`: + +- берёт PostgreSQL transaction advisory lock; +- сверяет migration history как непрерывный prefix; +- отклоняет неизвестную version и name mismatch; +- применяет pending migrations одной транзакцией; +- записывает результат в `public.storage_schema_migrations`. + +Migration table нельзя исправлять вручную. + +Проверка состояния: + +```sql +SELECT version, name, applied_at +FROM public.storage_schema_migrations +ORDER BY version; +``` + +Ожидается непрерывная последовательность versions 1–9. + +### 8.1. Migration 9 + +Migration 9 является блокирующей. Она: + +- берёт общий partition advisory lock; +- получает `ACCESS EXCLUSIVE` locks на Trades, Quotes и Candle revisions; +- назначает существующим строкам общий `replay_sequence`; +- добавляет defaults, `NOT NULL`, checks, triggers и indexes. + +Длительность, WAL, temporary files и требуемое место растут вместе со +всем уже накопленным Market Data объёмом. + +Для нетривиальной базы обязательно: + +1. остановить все writers и bot instances; +2. создать и проверить backup; +3. измерить migration на копии сопоставимого объёма и той же PostgreSQL + major version; +4. проверить свободное место и допустимое maintenance window; +5. запустить ровно один Application instance; +6. наблюдать locks и logs до commit; +7. проверить migrations, row counts и checkpoints после startup. + +Online/chunked replacement migration 9 не реализован. Transaction +failure откатывает migration, но Application startup остаётся fatal. + +### 8.2. Backup для Compose PostgreSQL + +Bot должен быть остановлен, PostgreSQL — оставаться запущенным: + +Создайте защищённый каталог вне repository и ограничьте к нему доступ. +В примере `/absolute/protected/backup` необходимо заменить на реальный +внешний путь. Команды выполняются в отдельной subshell: строгий `umask` +защищает новый файл, а любой неуспешный шаг завершает процедуру. + +```bash +( + set -eu + umask 077 + backup_dir=/absolute/protected/backup + mkdir -p "$backup_dir" + chmod 700 "$backup_dir" + + backup_stamp="$(date -u +%Y%m%dT%H%M%SZ)" + backup_file="$backup_dir/dzentra_before_migration_9_${backup_stamp}.dump" + backup_tmp="$(mktemp "$backup_dir/.dzentra_before_migration_9.tmp.XXXXXX")" + trap 'rm -f "$backup_tmp"' EXIT + trap 'exit 130' HUP INT TERM + test ! -e "$backup_file" + + docker compose -f infra/compose/docker-compose.yml exec -T postgres \ + sh -eu -c \ + 'exec pg_dump -U "$APP_DB_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-acl' \ + > "$backup_tmp" + test -s "$backup_tmp" + chmod 600 "$backup_tmp" + docker compose -f infra/compose/docker-compose.yml exec -T postgres \ + pg_restore --list < "$backup_tmp" > /dev/null + + mv -n "$backup_tmp" "$backup_file" + test ! -e "$backup_tmp" + test -s "$backup_file" + trap - EXIT HUP INT TERM +) +``` + +`APP_DB_USER` и `POSTGRES_DB` читаются внутри PostgreSQL container и +соответствуют фактическим `DB_USER` и `DB_NAME` текущего Compose +окружения. Поэтому backup не зависит от default-значений `dzentra_bot`. + +Временный файл находится на том же filesystem, что и итоговый dump. +Поэтому `mv -n` публикует проверенную копию атомарно и не перезаписывает +существующий backup; если перенос не состоялся, проверка оставшегося +temporary file завершает процедуру ошибкой. + +Проверка списка объектов не заменяет rehearsal restore. Восстановление +нужно заранее проверить в отдельной пустой базе или volume. Dump с +`--no-owner --no-acl` не создаёт cluster roles: нужную role и database +создают отдельно из secret-management процедуры. Если ownership и roles +нужно сохранять, защищённый `pg_dumpall --globals-only` формируют и +проверяют отдельно. + +Backup должен иметь согласованные encryption, retention и off-host copy. +После процедуры проверьте размер, checksum и тестовый restore. Named +volume является persistence, но не резервной копией. + +--- + +## 9. Partitions и Retention + +Parent tables имеют default partitions, поэтому запись работает без +предварительного создания monthly partitions. + +`PostgresMarketDataPartitionManager.ensure_month_partition(...)` +является явным Python API. Он сериализует callers, создаёт partition, +переносит подходящие default rows и атомарно attach-ит её. Bootstrap, +Scheduler и CLI его автоматически не вызывают. + +Retention также является только явным API: + +```text +PostgresMarketDataRetentionService.apply( + policy=MarketDataRetentionPolicy(...), + now=timezone_aware_utc_datetime, +) +``` + +Нет Retention env variables, background task или production CLI. +`enabled=False` ничего не удаляет; `None` для типа означает отсутствие +ограничения. + +При явном запуске Retention: + +- полные зарегистрированные partitions с `range_end <= cutoff` + удаляются; +- оставшиеся/default rows старше точного cutoff удаляются отдельно; +- выбранные типы обрабатываются одной транзакцией; +- попытка удалить активную checkpoint Trade откатывает всю операцию. + +Перед первым Retention run нужны backup, ручная проверка UTC cutoff, +maintenance window и последующая проверка checkpoint/result counters. + +--- + +## 10. Historical Access и Replay + +Historical Access и Replay не входят в Bootstrap и не запускаются вместе +с приложением. + +Historical query: + +- synchronous; +- использует отдельный repository call; +- возвращает keyset page; +- не удерживает общий snapshot между страницами. + +Replay preparation: + +- synchronous и blocking; +- выполняет `READ ONLY REPEATABLE READ` snapshot; +- полностью материализует bounded plan; +- закрывает PostgreSQL resources до playback. + +Если preparation вызывается из async-приложения, внешний owner должен +сам перенести blocking вызов с event loop. `ReplaySession.run()` является +async one-shot operation и требует явную consumer factory. Default +consumer и automatic startup отсутствуют. + +--- + +## 11. Наблюдение за состоянием + +Приложение пишет standard logs в stdout/stderr. Runtime logging намеренно +не раскрывает полный transport payload. Отдельного HTTP readiness, +metrics или alerting endpoint пока нет. + +Полезные PostgreSQL проверки: + +```sql +SELECT current_database(), current_user, + current_setting('server_version'); + +SELECT version, name, applied_at +FROM public.storage_schema_migrations +ORDER BY version; + +SELECT COUNT(*) AS trades +FROM market_data.trades; + +SELECT venue, symbol, trade_id, executed_at, revision, updated_at +FROM market_data.trade_stream_checkpoints +ORDER BY venue, symbol; + +SELECT data_type, partition_name, range_start, range_end +FROM market_data.partition_registry +ORDER BY data_type, range_start; +``` + +При подозрении на migration lock сначала исследуйте `pg_stat_activity` +и `pg_locks`. Не завершайте backend, пока не установлен точный владелец +и назначение transaction. + +--- + +## 12. Troubleshooting + +### `BOT_TOKEN or BOT_TOKEN_FILE is required` + +Telegram token обязателен для обычного Application startup независимо +от feature flags. На host задайте `BOT_TOKEN` в защищённом локальном +`app/.env` либо `BOT_TOKEN_FILE`; в Compose проверьте внешний +`BOT_TOKEN_SECRET_FILE` и mount `/run/secrets/bot_token`. Не задавайте +прямой token и file source одновременно. + +### PostgreSQL `connection refused` + +Сначала определите место запуска: + +- host application → `127.0.0.1` или `localhost` и published port; +- Compose bot → `postgres:5432`. + +Проверьте `pg_isready`, database name, role и network. + +### Authentication не меняется после правки Compose + +`POSTGRES_*` создаёт роль и базу только при инициализации пустого volume. +Изменение YAML или env не обновляет существующую роль. Выполните +контролируемое SQL/admin изменение либо restore в новый volume. + +### PostgreSQL остаётся `unhealthy` после обновления Compose + +Проверьте role flags и owner базы. Hardened healthcheck намеренно +отклоняет legacy volume, где application-role совпадает с bootstrap-admin +или остаётся superuser. Предпочтительный путь — backup и restore в новый +hardened volume; не отключайте healthcheck и не возвращайте bot доступ +superuser ради быстрого запуска. + +### Storage flag отклонён + +Storage требует включённый Trade Stream. Trade Stream требует явные +`TRADE_STREAM_WS_URL`, `TRADE_STREAM_SYMBOLS` и `EXCHANGE_BASE_URL`. + +### Pool startup timeout + +Проверьте DB health, `DB_HOST`, DNS, `max_connections`, pool min/max и +network. Ошибка является fatal; in-memory fallback нет. + +### Migration history mismatch + +Остановите Application, изучите backup и migration table. Не удаляйте и +не переименовывайте записи вручную. + +### Migration 9 выглядит зависшей + +Она может ждать `ACCESS EXCLUSIVE` lock либо выполнять полный +backfill/index build. Остановите crash-looping bot, проверьте +`pg_stat_activity`/`pg_locks` и не запускайте второй instance. + +### Startup ACK timeout + +Проверьте точный WSS `/connect`, `Origin` из `EXCHANGE_BASE_URL`, symbols, +credentials и `TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS`. + +### Startup buffer overflow + +Это обычно означает отсутствующий или слишком поздний ACK. Сначала +диагностируйте protocol path; увеличение capacity не исправляет причину. + +### Heartbeat/reconnect failure + +Runtime использует собственный Ping/Pong probe и одну reconnect attempt. +Исправьте endpoint/network до контролируемого restart; автоматического +backoff нет. + +### REST Recovery failure + +Production Recovery использует `/api/v1/aggTrades`. Проверьте REST base +URL, доступность endpoint и временное окно. Ошибка оставляет gate в +terminal failure и завершает Runtime. + +### Corrupt/orphan checkpoint + +Это ожидаемая integrity protection. Восстановите согласованный backup +или диагностируйте durable rows. Не удаляйте checkpoint вручную. + +### Retention не удаляет старую Trade + +Если Trade является активной checkpoint target, удаление запрещено, а +Retention transaction откатывается. Это защита restart recovery. + +### Таблица Trades пуста + +Проверьте оба feature flags. Сохраняются только принятые и идентичные +дубликаты Canonical Trades; raw WebSocket/REST documents не хранятся. + +--- + +## 13. Verification commands + +Если не указано иначе, команды выполняются из каталога `app`. + +### 13.1. Documentation and static typing + +Из корня repository: + +```bash +./scripts/check_python_types.sh +app/.venv/bin/python scripts/check_documentation_integrity.py +``` + +Допустимый результат — `0 errors, 0 warnings` для Pyright и `issues=0` +для локального documentation gate. Gate не обращается к внешним URL и +независимо от Git index проверяет `*.md` в корне и непосредственно в +`app`, а также рекурсивно в `app/src`, `app/tools` и `docs`. + +### 13.2. Default offline regression + +```bash +.venv/bin/python -m pytest -q -p no:cacheprovider \ + -W error::ResourceWarning +``` + +`pytest.ini` исключает `integration`, `stress` и `live` по умолчанию. +Acceptance-команда не создаёт `.pytest_cache` и считает +`ResourceWarning` ошибкой. + +### 13.3. Loopback integration + +```bash +.venv/bin/python -m pytest -q -p no:cacheprovider \ + -W error::ResourceWarning \ + -m integration \ + tests/integration/market_data/acquisition/runtime +``` + +### 13.4. PostgreSQL integration + +Harness разрушительно очищает `market_data` и собственную migration +history. Он не создаёт PostgreSQL server, role или database. Ниже полный +одноразовый локальный setup на проверенном PostgreSQL 16.14. Container +публикуется только на `127.0.0.1`, использует случайный test-only пароль +и автоматически удаляется после проверки: + +```bash +( + set -eu + container_name=dzentra-postgres-06030 + test_db_password="$(openssl rand -hex 24)" + test_db_dsn="postgresql://dzentra_test:${test_db_password}@127.0.0.1:55430/dzentra_test_060_30" + + test -z "$(docker ps -a \ + --filter "name=^/${container_name}$" \ + --format '{{.Names}}')" + + docker run --rm --detach \ + --name "$container_name" \ + --publish 127.0.0.1:55430:5432 \ + --env POSTGRES_USER=dzentra_test \ + --env POSTGRES_PASSWORD="$test_db_password" \ + --env POSTGRES_DB=dzentra_test_060_30 \ + --health-cmd='pg_isready -U dzentra_test -d dzentra_test_060_30' \ + --health-interval=2s \ + --health-timeout=2s \ + --health-retries=30 \ + postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + + trap 'docker stop -t 10 "$container_name" >/dev/null 2>&1 || true' EXIT + + test "$(docker port "$container_name" 5432/tcp)" = \ + '127.0.0.1:55430' + + attempt=0 + while test "$(docker inspect --format '{{.State.Health.Status}}' \ + "$container_name")" != healthy; do + attempt=$((attempt + 1)) + test "$attempt" -lt 60 + sleep 1 + done + + DZENTRA_RUN_POSTGRES_TESTS=1 \ + DZENTRA_TEST_POSTGRES_DSN="$test_db_dsn" \ + .venv/bin/python -m pytest -q -p no:cacheprovider \ + -W error::ResourceWarning \ + -m integration \ + tests/integration +) +``` + +`--rm` вместе с cleanup trap удаляет disposable container и его +anonymous data volume. Если container с таким именем или host port уже +занят, процедура должна остановиться, а не переиспользовать неизвестную +базу. Test password нельзя применять вне этого isolated harness. + +В 060.30.7 отдельный compatibility harness проверил официальный +PostgreSQL 17.10 image +`postgres@sha256:8189a1f6e40904781fc9e2612687877791d21679866db58b1de996b31fc312e4`: +полный integration-набор прошёл `90/90`, шесть критических +migration/checkpoint/snapshot/restart-сценариев — `10/10` прогонов, а +контрольная durable-запись сохранилась после restart. Это evidence +совместимости приложения, а не замена PostgreSQL 16.14 production +deployment baseline. + +Harness принимает только exact opt-in `1`, loopback/local socket и +безопасное имя базы. Проверка loopback DSN не ограничивает server bind, +поэтому bind `127.0.0.1:55430:5432` проверяется отдельно. Никогда не +передавайте production DSN. + +### 13.5. Fixed stress + +```bash +.venv/bin/python -m pytest -q -p no:cacheprovider \ + -W error::ResourceWarning \ + -m stress -k 'not soak' tests/stress +``` + +### 13.6. Standard и extended soak + +```bash +DZENTRA_SOAK_SECONDS=120 \ +.venv/bin/python -m pytest -q -p no:cacheprovider \ + -W error::ResourceWarning \ + -m stress -k soak \ + tests/stress/market_data/acquisition/runtime/test_trade_stream_runtime_stress.py +``` + +Для расширенной проверки замените `120` на `900`. + +### 13.7. Opt-in Dzengi live + +Live harness использует только публичные Market Data operations и один +явный symbol: + +```bash +DZENTRA_RUN_LIVE_TESTS=1 \ +DZENTRA_LIVE_REST_URL=https://api-adapter.dzengi.com \ +DZENTRA_LIVE_WS_URL=wss://api-adapter.dzengi.com/connect \ +DZENTRA_LIVE_SYMBOLS=BTC/USD_LEVERAGE \ +DZENTRA_LIVE_TRADE_TIMEOUT_SECONDS=600 \ +.venv/bin/python -m pytest -q -m live tests/live +``` + +Live verification требует доступ в интернет и не должна запускаться +неявно в обычной regression. + +### 13.8. Dependencies и compileall + +Из корня repository: + +```bash +app/.venv/bin/python -m pip check +( + set -eu + compile_cache_dir="$(mktemp -d)" + trap 'rm -rf -- "$compile_cache_dir"' EXIT + PYTHONPYCACHEPREFIX="$compile_cache_dir" \ + app/.venv/bin/python -m compileall -f -q \ + app/src app/tests scripts/check_documentation_integrity.py +) +``` + +Subshell ограничивает время жизни cleanup trap. `compile_cache_dir` +остаётся отдельным временным каталогом и никогда не указывает на +repository path. + +### 13.9. Docker hardening + +Static contract не требует Docker daemon: + +```bash +.venv/bin/python -m pytest -q \ + tests/unit/core/test_config.py \ + tests/unit/infra/test_docker_hardening.py +``` + +Compose interpolation проверяется test-only readable paths без вывода +развёрнутой конфигурации: + +```bash +DZENTRA_COMPOSE_PROJECT_NAME=dzentra-hardening-check \ +BOT_STOP_GRACE_PERIOD=30m \ +POSTGRES_STOP_GRACE_PERIOD=60s \ +BOT_TOKEN_SECRET_FILE=/dev/null \ +POSTGRES_ADMIN_PASSWORD_SECRET_FILE=/dev/null \ +DB_PASSWORD_SECRET_FILE=/dev/null \ +docker compose -f ../infra/compose/docker-compose.yml config --quiet +``` + +Перед выпуском конкретного image повторяются build, non-root/read-only +smoke и послойная проверка отсутствия project `.env`, `.venv`, tests, +caches и специально созданного sentinel secret. Проверка только +`docker history` недостаточна: нужно исследовать содержимое всех layers. + +--- + +## 14. Граница данных и product limitations + +- Production Runtime сохраняет только Trades. +- Quote/Candle repositories, Historical readers и Replay существуют, + но production writers не подключены. +- Initial historical backfill и completeness guarantee отсутствуют. +- История начинается с первой успешно сохранённой записи либо ранее + явно импортированной durable history. +- Recovery ограничен доступностью и полнотой Dzengi REST. +- Retention может сократить доступный период. +- Historical pagination не является одним snapshot между страницами. +- Replay воспроизводит deterministic storage order, а не исходный + network packet order. +- Replay полностью материализует bounded plan в памяти. +- Нет automatic partition/retention scheduler. +- Нет Replay API в Bootstrap, HTTP, Telegram или CLI. +- Нет HA, distributed lease и leader election. Не запускайте несколько + активных Runtime instances для одного `venue + symbol`. +- Нет infinite reconnect/recovery retry. +- Нет raw exchange-document storage. +- Нет автоматических backup, metrics и alerting. +- Online migration 9 не реализована. + +--- + +## 15. Security checklist + +- не добавлять `app/.env`, tokens, API keys, passwords или production + DSN в Git; +- не печатать `docker compose config` без `--quiet` в записываемый log; +- не расширять `.dockerignore` allowlist без повторной послойной проверки; +- хранить Compose secret files и control file вне repository с + mode `0444` внутри owner-only каталога mode `0700`; +- задавать уникальный стабильный `DZENTRA_COMPOSE_PROJECT_NAME` и не + подключать PostgreSQL data volume от другой major version; +- не использовать одну PostgreSQL role или один пароль одновременно для + bootstrap-admin и Application; +- считать ранее собранные без `.dockerignore` images скомпрометированными, + не публиковать их и сменить попавшие в context credentials; +- не публиковать PostgreSQL на внешние interfaces без необходимости; +- не использовать placeholder `change_me`; +- не направлять test harness на production database; +- хранить backup отдельно от основного named volume; +- перед migration 9 и первым Retention run проверять restore procedure; +- не заменять контролируемую диагностику fatal startup автоматическим + бесконечным restart loop; +- не использовать `down --volumes` без отдельного разрешения на удаление + точных database/runtime volumes. diff --git a/docs/roadmap/master-roadmap.md b/docs/roadmap/master-roadmap.md index 33b44bf..dd3beb1 100644 --- a/docs/roadmap/master-roadmap.md +++ b/docs/roadmap/master-roadmap.md @@ -6,178 +6,106 @@ |---|---| | Тип | Master Delivery Roadmap | | Статус | Active | -| Версия | 2.3 | -| Дата актуализации | 2026-08-02 | -| Текущий завершённый Build | 060.29 | -| Текущий Build | 060.30 — Planned | +| Версия | 2.5 | +| Дата актуализации | 2026-08-03 | +| Текущий завершённый Build | 060.30 | +| Текущий Build | отсутствует | +| Следующий Build | 061.00 — Planned | --- -## Цель проекта +## Назначение Dzentra развивается из работающего Telegram trading bot в модульную -алгоритмическую торговую платформу, которая поддерживает: +алгоритмическую торговую платформу. Этот документ хранит порядок Builds, +их фактический статус и ссылки на источники решений. -- получение и хранение рыночных данных; -- Market Intelligence и объяснимую аналитику; -- ручные и автоматические торговые решения; -- управление риском, исполнением и позициями; -- аудит и воспроизводимость решений; -- безопасный production runtime; -- исторический Replay и Backtesting. +Roadmap не дублирует архитектурные спецификации и test evidence. Для +этого используются: -Целевая карта доменов и направление зависимостей определены в: - -```text -docs/architecture/dzentra_target_architecture.md -``` - -Настоящий roadmap определяет порядок реализации. Подробные решения и -test evidence находятся в документах конкретных Build. +- [целевая архитектура Dzentra](../architecture/dzentra_target_architecture.md); +- [текущая архитектура Trades Feed](../architecture/trades_feed.md); +- architecture и итоговые reports в [`docs/migrations`](../migrations/); +- [итог Build 060.30](../migrations/build_060_30.md). --- -# Текущая контрольная точка +## Текущая контрольная точка ```text -Market Data Acquisition +060.20–060.25 Trade Runtime и Production Integration Completed ↓ -Market Data Access and Replay завершён +060.26 Integration / Stress / Live Verification Completed ↓ -Build 060.29 +060.27 Persistent Market Data Storage Completed ↓ -Completed → следующий Build 060.30 +060.28 Persistent Checkpoint / Startup Recovery Completed + ↓ +060.29 Historical Access / Replay Completed + ↓ +060.30 Final Documentation Completed + ↓ +061.00 Validation Boundary Refactoring Planned ``` -Build 060.29 завершён и принят: - -- Historical Access отделён от write-only Storage API; -- migration 9 добавила общий immutable Replay sequence; -- Trades, Quotes и Candle revisions читаются устойчивыми keyset pages; -- Replay строится как bounded `REPEATABLE READ` snapshot; -- Clock, Session и Composition остаются детерминированными и caller-owned; -- PostgreSQL failure paths и финальная регрессия приняты. - -Подробности: - -```text -docs/migrations/build_060_29.md -``` +Build 060.30 завершён созданием итогового report и приёмкой Closure +060.30.8. Следующий запланированный Build — 061.00; он ещё не начат. --- -# Активная программа — Market Data Acquisition +## Активная программа — Market Data Acquisition -## Завершённые Build ветки Trades Feed +### Последовательность Trades Feed | Build | Результат | Статус | |---|---|---| | 060.1–060.19 | Canonical Trade, REST/WS pipelines, Consistency и Recovery foundation | Completed | -| 060.20 | Trade Runtime Architecture | Completed | -| 060.20.1 | Trade Stream State Ownership Alignment | Completed | -| 060.21 | Runtime Protocol Integration | Completed | -| 060.22 | Runtime Service Integration | Completed | -| 060.23 | Trade Stream Acquisition Integration | Completed | -| 060.24 | Runtime Recovery Architecture | Completed | -| 060.25 | Production Runtime Integration | Completed | -| 060.26 | Integration and Regression | Completed | -| 060.27 | Persistent Market Data Storage | Completed | -| 060.28 | Persistent Checkpoint and Startup Recovery | Completed | -| 060.29 | Market Data Access and Replay | Completed | +| 060.20 | [Trade Runtime Architecture](../migrations/build_060_20.md) | Completed | +| 060.20.1 | [Trade Stream State Ownership Alignment](../migrations/build_060_20_1.md) | Completed | +| 060.21 | [Runtime Protocol Integration](../migrations/build_060_21.md) | Completed | +| 060.22 | [Runtime Service Integration](../migrations/build_060_22.md) | Completed | +| 060.23 | [Trade Stream Acquisition Integration](../migrations/build_060_23.md) | Completed | +| 060.24 | [Runtime Recovery Architecture](../migrations/build_060_24.md) | Completed | +| 060.25 | [Production Runtime Integration](../migrations/build_060_25.md) | Completed | +| 060.26 | [Integration and Regression](../migrations/build_060_26.md) | Completed | +| 060.27 | [Persistent Market Data Storage](../migrations/build_060_27.md) | Completed | +| 060.28 | [Persistent Checkpoint and Startup Recovery](../migrations/build_060_28.md) | Completed | +| 060.29 | [Market Data Access and Replay](../migrations/build_060_29.md) | Completed | +| 060.30 | [Market Data Acquisition Final Documentation](../migrations/build_060_30.md) | Completed | +| 061.00 | Validation and Canonicalization Boundary Refactoring | Planned | -## Build 060.26 — Integration and Regression +### Принятый результат 060.26–060.29 -**Статус:** Completed +- 060.26 проверил Runtime на loopback network, fault injection, + stress/soak и opt-in Dzengi live scenarios. +- 060.27 добавил persistent repositories Trades, Quotes и Candle + revisions; Production writer подключён только для Trades. +- 060.28 добавил atomic Trade/checkpoint, Hydration и Startup Recovery с + закрытым Live gate. +- 060.29 добавил Historical Access и caller-owned deterministic Replay в + общем порядке сохранённых Canonical events. -Назначение: - -- live exchange integration checks; -- длительные reconnect scenarios; -- recovery при реальных сетевых задержках; -- network fault injection; -- stress и soak testing; -- проверка отсутствия утечек задач и ресурсов; -- финальная verification Runtime documentation. - -Build не должен превращать сетевые сценарии в обязательную часть -обычного unit suite. - -## Текущий и следующие Build - -### Build 060.27 — Persistent Market Data Storage - -**Статус:** Completed - -Назначение: - -- долговременное хранение Canonical Trades; -- подготовка хранения Quotes и Candles; -- ключи идемпотентности и ordering; -- raw/canonical retention policy; -- Storage API; -- партиционирование, retention и data provenance. - -Результат: история рынка сохраняется независимо от торгового цикла и -времени жизни процесса. - -Подробный итог: `docs/migrations/build_060_27.md`. - -### Build 060.28 — Persistent Checkpoint and Startup Recovery - -**Статус:** Completed - -Назначение: - -- Checkpoint Service; -- сохранение runtime checkpoint; -- восстановление после перезапуска; -- сверка checkpoint с durable Market Data; -- Startup Recovery; -- защита от повторной обработки и пропусков. - -Persistent checkpoint не заменяет Market Data Storage и не должен -считаться более достоверным, чем подтверждённая сохранённая история. - -Результат: после перезапуска Trade Stream восстанавливает подтверждённый -checkpoint и bounded deduplication tail, заполняет downtime gap через -REST и только затем продолжает buffered Live processing. - -Подробный итог: `docs/migrations/build_060_28.md`. - -### Build 060.29 — Market Data Access and Replay - -**Статус:** Completed - -Назначение: - -- Data Access Layer; -- Historical Queries; -- Replay API; -- детерминированные replay-часы; -- одинаковые Canonical contracts для live и replay consumers. - -Полноценные аналитические вычисления не входят автоматически в этот -Build. Они принадлежат Market Data Processing и Feature Engineering и -получат отдельный scope после появления устойчивого Storage/Replay. - -Результат: сохранённые Canonical Trades, Quotes и Candle revisions -доступны через отдельный Historical Access и могут воспроизводиться в -детерминированном global order через caller-owned Replay Session. - -Подробный итог: `docs/migrations/build_060_29.md`. +Подробные контракты, ограничения и test evidence находятся только в +соответствующих итоговых reports, ссылки на которые приведены в таблице. ### Build 060.30 — Market Data Acquisition Final Documentation -**Статус:** Planned +**Статус:** Completed Назначение: -- итоговый аудит ветки Trades Feed; -- проверка контрактов и направления импортов; -- сверка фактической структуры файлов; -- эксплуатационная документация Runtime; -- обновление architecture overview и project structure; -- фиксация известных ограничений и следующей Feed-ветки. +- зафиксировать фактическую архитектуру Trades Feed; +- подготовить Production operations guide; +- актуализировать входные страницы, Target Architecture и roadmap; +- нормализовать историческую навигацию без переписывания evidence; +- добавить offline documentation integrity gate; +- выполнить финальную documentation/runtime verification; +- закрыть документационный цикл Trades Feed Builds 060.20–060.30 перед + 061.00. + +Все подэтапы 060.30.0–060.30.8 и corrective gate Docker Production +Safety Hardening приняты. Итоговый report создан; Build 060.30 завершён. +Следующий запланированный этап — read-only анализ 061.00. ### Build 061.00 — Validation and Canonicalization Boundary Refactoring @@ -199,11 +127,11 @@ missing data recovery Только после аудита определяется, нужен ли отдельный каталог `market_data/normalization`. Механический перенос существующих -валидаторов не является целью Build. +валидаторов заранее не утверждён. --- -# Дальнейшие программы +## Дальнейшие программы После 061.00 следующая Feed-ветка выбирается на основании фактических production consumers и доступных данных Dzengi. @@ -211,19 +139,21 @@ production consumers и доступных данных Dzengi. Кандидаты: - Quotes lifecycle consolidation; -- OHLCV/Candles production integration; +- OHLCV/Candles WebSocket, Consistency и persistence integration; - Order Book snapshot and delta reconciliation; - Derivatives Market Data; - Market Index; - Exchange Time; - Exchange and Instrument Status. -Нумерация и точный scope этих Build утверждаются только после отдельного -read-only архитектурного анализа. Старый ориентировочный план из -`build_044.md` сохраняет историческую ценность, но больше не является -актуальной нумерацией. +Нумерация и точный scope этих Builds утверждаются только после отдельного +read-only архитектурного анализа. -Следующие верхнеуровневые программы пока не имеют утверждённых Build: +Старый ориентировочный план из +[Build 044](../migrations/build_044.md) сохраняет историческую ценность, +но больше не определяет актуальную нумерацию. + +Следующие верхнеуровневые программы пока не имеют утверждённых Builds: - Market Data Processing; - Feature Engineering; @@ -235,9 +165,12 @@ read-only архитектурного анализа. Старый ориент - Backtesting and Simulation; - Production Deployment and Operations. +Persistent Storage и Replay уже создали prerequisites для Backtesting, +но сам Backtesting engine и exchange/OMS simulator не реализованы. + --- -# Правила ведения roadmap +## Правила ведения roadmap 1. Один Build изменяет одну подтверждённую архитектурную область. 2. Перед Build выполняется read-only анализ фактического кода и тестов. @@ -245,1507 +178,51 @@ read-only архитектурного анализа. Старый ориент 4. Каждый Build имеет критерии завершения и проверяемый test evidence. 5. Архитектурные findings исправляются до статуса Accepted. 6. Итоговый migration report создаётся после завершения Build. -7. Master roadmap хранит только последовательность и статус, а не +7. Master Roadmap хранит последовательность, статус и ссылки, а не дублирует подробную архитектурную спецификацию. 8. Посторонние изменения рабочего каталога не включаются в Build. +9. Текущий Build переводится в `Completed` только после принятого + итогового report. --- -# Исторический roadmap Stage 01–09 +## Исторический Stage-roadmap -Раздел ниже сохраняет историю развития первоначального Telegram bot. -Его локальные пометки `в работе`, `не начат` и прежняя нумерация -являются историческим снимком, а не текущим источником следующего шага. +Stage 01–07 сохраняются в отдельных исторических документах. Их локальные +пометки `в работе`, `не начат` и прежняя нумерация не определяют текущий +следующий Build: ---- +- [Stage 01 — Bootstrap / Foundation](stage-01-bootstrap-foundation-roadmap.md) +- [Stage 02 — System / Diagnostics](stage-02-system-diagnostics-roadmap.md) +- [Stage 03 — Exchange Integration](stage-03-exchange-integration-roadmap.md) +- [Stage 04 — Storage / Journal](stage-04-storage-journal-roadmap.md) +- [Stage 05 — Trading UI / Draft / Validation](stage-05-trading-ui-draft-validation-roadmap.md) +- [Stage 06 — Journal / Navigation / Settings](stage-06-journal-navigation-settings-roadmap.md) +- [Stage 07 — Auto Trading](stage-07-auto-trading-roadmap.md) -# Stage 01 — Bootstrap / Foundation -✔ app bootstrap -✔ config layer -✔ logging -✔ router bootstrap -✔ DB bootstrap +Каждый файл имеет явную архивную маркировку и ведёт обратно к Master +Roadmap и Target Architecture. Историческое содержимое сохранено без +переписывания. -✅ Статус: завершён +Для Stage 08–09 отдельных roadmap-файлов не создавалось. Сохраняются их +короткие исторические placeholders: ---- +### Stage 08 — Analytics / Reports -# Stage 02 — System / Diagnostics -✔ system screen -✔ exchange health -✔ DB health -✔ runtime info -✔ error rendering +- отчёты; +- PnL; +- win rate; +- drawdown. -✅ Статус: завершён +Исторический статус: не начат. Текущий будущий scope аналитики определяет +Target Architecture, а не прежняя Stage-нумерация. ---- +### Stage 09 — Production Readiness -# Stage 03 — Exchange Integration -✔ mock exchange -✔ time handling -✔ exchangeInfo -✔ private auth -✔ account + portfolio -✔ market live polling -⚠ websocket research / fallback polling +- deployment; +- monitoring; +- alerts; +- backups. -✅ Статус: завершён (fallback mode) - ---- - -# Stage 04 — Storage / Journal -✔ storage foundation -✔ event log -✔ repositories -✔ UI integration -✔ journal management - -✅ Статус: завершён - ---- - -# Stage 05 — Trading UI / Draft / Validation -✔ order draft flow -✔ interactive builder -✔ validation -✔ runtime mode helpers -✔ UI unification -✔ quantity normalization - -✅ Статус: завершён - ---- - -# Stage 06 — Journal / Navigation / Settings -✔ journal UI -✔ navigation improvements -✔ settings screen -✔ system info - -✅ Статус: завершён - ---- - -# Stage 07 — Auto Trading - -## 07.1 — Skeleton UI -✔ экран автоторговли -✔ state machine - -## 07.2 — Real settings -✔ стратегия -✔ риск -✔ символ -✔ leverage (default x2) - -## 07.3 — Analysis Cycle -✔ run_cycle() -✔ mock signals -✔ live updates - -### 07.3.1 — Background Runner -✔ asyncio loop - -### 07.3.2 — Live Screens -✔ market -✔ portfolio -✔ journal - -### 07.3.3 — Multi Live -✔ multi-screen -✔ duplicate protection - -### 07.3.4 — Monitoring redesign -✔ новый экран мониторинга - -### 07.3.5 — WebSocket probe -✔ ws skeleton -⚠ fallback REST polling - -## 07.4 — Strategy Plugin Architecture - -### 07.4.1 -✔ BaseStrategy -✔ SignalResult - -### 07.4.2 -✔ Strategy Registry - -### 07.4.3 — Trend Strategy -✔ signal generation -✔ repeat confirmation logic -✔ confidence scoring -✔ UI integration - -#### 07.4.3.1 — UI Optimization ✅ -✔ compact auto screen -✔ state-based rendering (OFF / RUNNING / OBSERVING) -✔ minimal trading layout -✔ duplicate info removal - -#### 07.4.3.2 — Engine Decoupling ✅ -✔ split analysis / UI refresh -✔ fast price polling (1s) -✔ slow UI updates (event-driven / 60s) -✔ anti-flood protection - -#### 07.4.3.3 — Paper Position & Execution Engine ✅ -- добавлен ExecutionEngine -- реализованы paper-позиции (LONG / SHORT) -- интеграция с AutoTradeService -- синхронизация с UI -- логирование paper execution -- EventBus события (paper_position_opened) - -#### Stage 07.4.3.4 — Telegram Strong Signal Alerts ✅ -- EventBus-driven уведомления -- Фильтрация READY сигналов -- Поддержка BUY / SELL -- Анти-спам (deduplication) -- Интеграция с Journal -- Runner полностью управляет Telegram-уведомлениями - -#### Stage 07.4.3.5 — Debug Commands & Test Mode ✅ -- DEBUG_ENABLED env flag -- debug_force_signal API -- instant EventBus processing -- Telegram debug commands -- state inspection (/debug_state) -- journal logging for debug actions -- full pipeline testing without market dependency - -#### Stage 07.4.3.6 - Smart Alert Throttling ✅ -- cooldown для Telegram сигналов -- suppression повторных BUY/SELL -- journal logging suppressed событий -- не влияет на execution pipeline - -#### 07.4.3.7 — Alert priority & UX improvements ✅ -- priority levels: HIGH / MEDIUM / LOW -- improved Telegram alert layout -- normalized symbol & leverage formatting -- compatible with cooldown & suppression -- extended debug_signal parameters - -#### 07.4.3.8 — Telegram Execution Alerts ✅ -- Telegram alerts for paper position opened -- Telegram alerts for paper position closed -- Entry / Exit / Size / PnL rendering -- readable USD formatting -- signal alerts separated from execution alerts - -#### 07.4.3.9 — Position flip flow ✅ -- instant LONG ↔ SHORT reversal (FLIP) -- new EventBus event: paper_position_flipped -- unified execution alert for flip -- improved execution realism (no idle gap) - -#### 07.4.3.10 — Auto UI Refactor & Live Screen ✅ - -- разделение auto.py → main.py + ui.py -- единый render-пайплайн через AutoTradeRunner -- live-обновление экрана без дублирования сообщений -- компактный UI: Signal / Decision / Position / PnL -- отображение Position Risk и Est. Size -- унификация форматирования (USD / price / leverage) -- защита от лишних edit (message is not modified) - -#### 07.4.3.11 — Risk Settings UI & UX ✅ - -- отдельный экран Risk Settings (SL / TP / Max Loss) -- FSM-ввод значений (проценты и USD) -- inline-редактирование (без новых сообщений) -- временные статусы (auto-clear через ~2.5 сек) -- защита от race condition (убран “скачок” экранов) -- reset risk controls (все параметры → off) -- интеграция в Auto screen (Controls строка) -- интеграция в Settings (Risk Controls summary) -- единая навигация: Auto ↔ Settings ↔ Risk -- UX-подсказки и валидация ввода - -#### 07.4.3.12 — Real Risk Engine (execution-level) ✅ -- risk checks внутри ExecutionEngine -- SL / TP / ML закрывают позицию -- forced close с risk_reason -- интеграция в основной цикл автоторговли -- Telegram execution alerts с причиной риска -- единая точка принятия решений (execution layer) - -#### 07.4.3.13 — Risk-Based Position Sizing & Margin Protection ✅ -- risk-based position sizing через SL distance -- размер позиции теперь рассчитывается от Risk % -- execution-level margin validation -- защита от oversized positions -- max reserved balance limit -- execution block reason state -- блокировка ENTRY / FLIP при превышении margin limit -- compact mobile UI redesign -- новый формат SL / TP / ML -- compact position rendering -- estimated margin preview -- max reserved preview -- execution blocked status в UI -- улучшенный mobile formatting -- SL стал обязательным для risk-engine sizing -- risk_percent теперь реально влияет на размер позиции -- flip теперь проходит через margin protection - -#### 07.4.3.14 — Auto UI, Realistic Pricing & Debug Live Tools ✅ -- redesigned RUNNING auto-trading UI -- HOLD / BUY / SELL / READY state separation -- compact signal rendering with real duration -- confidence hidden for HOLD state -- direction-aware LONG / SHORT UI blocks -- compact active position rendering -- removed zero-value UI noise without position -- realistic bid / ask pricing in auto UI -- realistic bid / ask execution pricing -- TREND strategy switched to mid-price analysis -- corrected own funds / margin calculations -- safer size rounding for margin protection -- signal_started_at support for real-time duration tracking -- improved auto screen refresh handling -- live UI refresh diagnostics in AutoTradeRunner -- new debug UI-state commands -- new paper execution debug commands -- automatic flip direction detection -- live paper execution monitoring commands -- integration testing flow for SL / TP / ML -- integration testing flow for execution alerts -- preparation for isolated debug runtime architecture - -#### 07.4.3.15 — Isolated Debug Runtime & Debug Auto Screen ✅ - -- isolated DebugTradeState -- isolated DebugPositionState -- isolated DebugTradeService -- isolated DebugExecutionEngine -- isolated DebugTradeRunner -- separate `/debug_auto_screen` -- separate debug auto UI -- debug commands no longer mutate AutoTradeService -- debug execution no longer mutates ExecutionEngine._position -- debug runner no longer uses AutoTradeRunner -- `/debug_live` disabled as production-runtime injector -- legacy debug commands redirected to isolated debug runtime -- debug LONG / SHORT / FLIP / CLOSE sandbox flow -- debug Start / Stop / Reset controls -- debug PnL live refresh -- debug margin / reserved rendering -- debug bid / ask execution pricing -- fresh REST snapshot support for debug execution -- debug_auto router added -- ordinary 🤖 Автоторговля screen remains unchanged by debug commands -- preparation for production execution pricing layer - -#### 07.4.3.16 — Production Execution Pricing Layer - -- added isolated runtime market caches -- separated AUTO and DEBUG websocket pricing -- added execution snapshot layer -- added freshness-aware execution pricing -- implemented websocket-first market sourcing -- added REST fallback pricing pipeline -- fixed signal timer reset after START -- removed shared market cache collisions -- stabilized AUTO/DEBUG UI market rendering - -#### 07.4.3.17 — Unified Active Screen Lifecycle - -- внедрён единый lifecycle основных экранов -- реализовано автоматическое закрытие предыдущего экрана -- устранено накопление Telegram UI-экранов -- унифицировано поведение всех основных экранов -- разделены UI lifecycle и background runtime -- сохранена фоновая работа AutoTradeRunner -- сохранена фоновая работа DebugTradeRunner -- стабилизирована работа live-экранов -- подготовлена архитектура для Telegram push-уведомлений -- подготовлена база для runtime event notifications - -#### 07.4.3.18.1 — Runtime Event Skeleton Architecture -- добавлен слой runtime_events -- добавлен слой notifications -- создана модель RuntimeEvent -- создана модель NotificationMessage -- добавлен RuntimeEventPublisher -- добавлен NotificationService skeleton -- добавлен TelegramNotificationChannel -- добавлен NotificationTargetRegistry -- добавлена базовая дедупликация уведомлений -- добавлены шаблоны signal/execution notifications -- зарегистрирован Telegram bot в notification target registry -- зарегистрирован default chat из AUTO/DEBUG runners -- исправлен circular import в package init файлах -- подготовлена архитектура для переноса strong signal alerts - -#### 07.4.3.18.2 ✅ Runtime Notification Migration -- strong signal alerts migrated to RuntimeEvent pipeline -- execution alerts migrated to RuntimeEvent pipeline -- detached Telegram delivery -- centralized notification logging -- runtime notification dedupe -- live runtime validation on real paper execution -- auto screen independence from notifications - ---- - -### 07.4.3.19 — Strategy Audit & Signal Quality Layer -- audit SCALP false flip behavior -- add position-aware signal handling -- prevent weak/medium signal flips -- add min hold time before flip -- add flip cooldown -- add spread/slippage buffer -- classify signals as ENTRY / HOLD / EXIT / FLIP -- tune SCALP thresholds - -#### 07.4.3.19.1 - Position-aware Flip Protection -- добавлена проверка confidence перед flip -- добавлено подтверждение flip по количеству повторов сигнала -- добавлена минимальная длительность удержания позиции перед flip -- добавлена защита от flip в убыточной позиции без сильного сигнала -- реализована блокировка flip через RuntimeEvent logging -- добавлен dedupe для повторяющихся событий flip-блокировки через _last_flip_block_key -- синхронизировано execution-state после ENTRY / FLIP / CLOSE -- исправлена очистка ghost-позиций после forced exit -- стабилизирован lifecycle paper execution во время ночного runtime-тестирования - -#### 07.4.3.19.2 — Journal Noise Reduction & Position-aware Signal Logg -Снижение шума журнала и position-aware signal logging ✅ -- отключено журналирование auto_screen_refresh_skipped -- отключено журналирование auto_screen_refreshed -- сохранено журналирование ошибок refresh-цикла -- добавлено подавление сигналов, совпадающих с открытой позицией -- добавлен throttled summary auto_position_aligned_signal_suppressed -- снижено количество повторных strong signal уведомлений -- сохранена обработка противоположных сигналов как reversal / flip candidates -- подготовлена база для Signal Intent Layer в следующем этапе - -#### 07.4.3.19.3 ✅ Strategy Noise Filter & Signal Intent Layer -- убрано журналирование одиночных BUY / SELL без серии -- HOLD-серии переведены с repeat-count на duration формат -- добавлен формат 🟡 HOLD 5м 36с завершён сигналом SELL -- добавлен signal_intent в payload сигналов -- добавлены intent-типы ENTRY_CANDIDATE, REVERSAL_CANDIDATE, REINFORCE_POSITION, HOLD_MARKET, NOISE -- добавлена position-aware интерпретация сигналов -- добавлено отдельное событие готового сигнала -- подготовлена база для стандартизации журнала в 07.4.3.19.4 - -#### 07.4.3.19.4 ✅ Journal Runtime Standardization & Export Layer -- унифицированы execution event_type -- удалены legacy paper_* события -- execution logging переведён в единый human-readable стиль -- унифицированы market runtime events -- стандартизирован export logging -- добавлены account-aware export filename -- добавлены [DEMO]/[LIVE] runtime prefixes -- унифицированы risk-control journal events -- централизован EVENT_TITLES mapping -- журнал подготовлен к filters/search layer - ---- - -### 07.4.4 - -#### 07.4.4.1.1 ✅ Market State Human UI + HOLD Lifecycle Fix -- добавлено короткое human-readable отображение состояния рынка -- технические market_state значения скрыты из основного Auto UI -- убраны `trend=...` и `volatility=...` из Telegram-экрана -- убран timeframe анализа из основного UI как лишняя техническая деталь -- добавлены UI-состояния `📈 Рынок · Рост`, `📉 Рынок · Падение`, `🟰 Рынок · Флэт`, `⚠️ Рынок · Волатильность`, `⏳ Рынок · Анализ` -- подтверждена работа REST klines как основы аналитики рынка -- Market State Engine переведён на анализ свечей OHLCV -- добавлены EMA20 / EMA50 для определения направления тренда -- добавлен ATR для оценки волатильности рынка -- добавлен RSI в аналитический payload -- TREND-стратегия стала market-aware и использует состояние рынка как фильтр входа -- market analysis синхронизируется в AutoTradeState -- исправлен баг ложного завершения `HOLD → HOLD` -- HOLD summary теперь пишется только при реальной смене сигнала -- этап подготовил основу для Market State Journal Events и BTC/ETH Relative Strength Layer - -#### 07.4.4.1.2 ✅ Market State Journal Events -- добавлено journal logging изменений состояния рынка -- реализован market-state transition tracking -- добавлены market_state_changed события -- добавлены market_trend_changed события -- добавлены market_volatility_changed события -- market-analysis интегрирован в auto runtime -- устранён spam logging market-analysis циклов -- реализовано logging только при реальной смене состояния -- добавлены human-readable market messages -- убраны raw enum/state значения из UI-журнала -- журнал переведён на explainable market-analysis стиль -- добавлена фиксация отсутствия выраженного направления рынка -- подготовлена база для market analytics layer -- подготовлена база для future AI market commentary -- журнал подготовлен к market filters/search layer - -#### 07.4.4.1.3 ✅ Journal Runtime Cleanup & Event Titles Layer -- унифицирована модель журнала: event_type / заголовок / сообщение -- заголовки событий сокращены до понятных сущностей -- подготовлен общий EVENT_TITLES mapping для journal UI и export layer -- убрано дублирование market_state_changed и market_trend_changed -- market_trend_changed исключён из обычного журналирования -- market trend сохранён в payload для аналитики и debug -- market-analysis сообщения приведены к единому формату -- Auto UI wording уточнён: падение заменено на нисходящий тренд -- сообщения настроек автоторговли приведены к короткому стилю -- символы активов очищены в journal messages: BTC вместо BTC/USD_LEVERAGE -- risk-control logging стал показывать SL / TP / ML в одном сообщении -- notification logging унифицирован через notification_sent / notification_error -- из market runtime сообщений убран лишний [AUTO] -- CSV / XLSX export очищен от эмодзи -- журнал подготовлен к централизованному event_titles.py и future filters/search layer - -#### 07.4.4.1.4 ✅ Entry Decision Diagnostics Layer -- добавлен диагностический слой причин не входа в позицию -- AutoTradeState расширен entry_block_reason и entry_block_message -- TrendStrategy начала передавать причины HOLD в payload -- добавлены entry_block_reason к market filter / live data / weak impulse сценариям -- AutoTradeService синхронизирует entry diagnostics в runtime state -- добавлено событие entry_blocked для журнала -- журнал пишет причины не входа только при изменении причины -- добавлена защита от spam logging одинаковых HOLD-причин -- Auto UI показывает строку Вход в позицию · причина -- strategy diagnostics отделены от execution diagnostics -- execution UI приведён к human-readable стилю -- добавлен EVENT_TITLES mapping для entry_blocked -- подготовлена база для анализа частоты причин отказа от входа -- подготовлена база для adaptive thresholds и настройки чувствительности стратегии - -#### 07.4.4.1.5 ✅ Runtime Window Cleanup & Symbol Lifecycle Isolation -- внедрён lifecycle cleanup runtime-окон стратегий -- BaseStrategy расширен методом reset_runtime() -- TrendStrategy и ScalpStrategy получили runtime cleanup API -- StrategyRegistry получил reset_runtime() и reset_all_runtime() -- устранено смешивание _price_window между разными активами -- устранено смешивание runtime между TREND и SCALP -- runtime окна теперь изолированы по symbol lifecycle -- runtime окна теперь изолированы по strategy lifecycle -- при смене symbol очищается runtime старого и нового актива -- при смене strategy очищается runtime обеих стратегий -- устранены ложные BUY/SELL после переключения актива -- устранены ложные сигналы после смены стратегии -- AutoTradeService теперь полностью сбрасывает market diagnostics при reset -- очищаются market_state / market_trend / market_volatility -- очищаются market_analysis_interval / market_analysis_reason -- очищаются entry_block_reason / entry_block_message -- устранено визуальное залипание TREND_UP / TREND_DOWN в UI -- после смены актива UI возвращается в состояние “⏳ Идёт анализ” -- удалено дублирующее journal событие market_symbol_changed -- journal приведён к single-result event модели -- runtime lifecycle отделён от user-facing settings events -- подготовлена база для multi-symbol runtime engine -- подготовлена база для signal aging/reset system -- подготовлена база для adaptive runtime memory management - -#### 07.4.4.1.6 ✅ Signal Aging & Runtime Expiration -- добавлены поля signal_updated_at и market_analysis_updated_at в AutoTradeState -- добавлены runtime_expired_reason и runtime_expired_message -- внедрён TTL для signal runtime -- внедрён TTL для market analysis runtime -- добавлен runtime expiration handler в AutoTradeService -- добавлено событие runtime_expired для журнала -- добавлена защита от spam logging одинаковых runtime expiration событий -- signal tracking теперь обновляет время последнего сигнала -- market analysis sync теперь обновляет время последней аналитики -- stale signal runtime сбрасывается при превышении TTL -- stale market diagnostics очищаются при превышении TTL -- TrendStrategy получила TTL для live price window -- ScalpStrategy получила отдельный TTL для live price window -- reset_runtime теперь очищает price window и timestamp window -- предотвращено использование старых цен после runtime-паузы -- HOLD timer сохранён как индикатор живого runtime цикла -- Telegram UI переведён на компактные market state labels -- entry diagnostics в UI разделены на Ожидание и Вход -- добавлен compact mapping для длинных entry_block_message -- подтверждена корректная работа runtime lifecycle на флэт-рынке -- выявлен uncovered HOLD diagnostic scenario для следующего этапа -- подготовлена база для advanced market diagnostics layer -- подготовлена база для adaptive thresholds -- подготовлена база для signal freshness-aware execution - -#### 07.4.4.1.7 ✅ Live Market Runtime & Advanced Trend Diagnostics -- внедрён полноценный live market runtime pipeline -- добавлен websocket-based realtime market runtime -- добавлен REST fallback для market runtime -- внедрён runtime-aware MarketPriceCache -- cache переведён на isolation по runtime_key -- добавлен market snapshot layer в ExchangeService -- добавлены get_market_snapshot и get_execution_snapshot -- добавлен get_fresh_market_snapshot -- добавлен refresh_market_snapshot_cache -- внедрена модель ExecutionPriceSnapshot -- execution pipeline получил поддержку bid/ask -- execution pipeline получил freshness tracking -- execution pipeline получил source tracking -- MarketDataRunner теперь обновляет runtime cache -- websocket runtime теперь автоматически reconnect'ится -- websocket runtime теперь безопасно fallback'ится в REST -- TrendStrategy переведена на market snapshot analysis -- TrendStrategy теперь использует bid/ask mid-price -- добавлен _analysis_price для execution-aware анализа -- добавлен direction_ratio analysis layer -- добавлен live impulse confirmation layer -- TREND теперь требует подтверждение live momentum -- live impulse теперь анализируется через runtime price windows -- улучшена фильтрация noise movement -- снижено количество ложных BUY/SELL импульсов -- TrendStrategy получила расширенные HOLD diagnostics -- HOLD ветки теперь передают entry_block_reason -- HOLD ветки теперь передают entry_block_message -- Telegram UI теперь отображает причины HOLD -- Telegram UI теперь отображает compact market states -- HOLD runtime теперь визуально подтверждает живой цикл -- HOLD timer сохранён как runtime heartbeat indicator -- AutoTradeRunner получил protected execution loop -- исключения стратегии больше не убивают runtime -- исключения UI refresh больше не убивают runtime -- исключения event handler больше не убивают runtime -- runtime loop теперь логирует auto_run_cycle_error -- runtime loop теперь логирует auto_refresh_loop_error -- защищён asyncio lifecycle автоторговли -- исправлен critical runtime bug с TrendStrategy.analyze -- восстановлен realtime refresh автоторговли -- исправлен freeze Telegram UI -- исправлено зависание market state на “Идёт анализ” -- исправлено зависание HOLD timer -- исправлена race condition между screen register и runner.start -- стабилизирована смена инструмента во время runtime -- подтверждена корректная работа websocket runtime -- подтверждена корректная работа REST fallback -- подготовлена база для spread-aware execution -- подготовлена база для stale snapshot protection -- подготовлена база для adaptive execution engine -- подготовлена база для execution quality metrics -- подготовлена база для advanced market diagnostics -- подготовлена база для multi-timeframe analysis - -#### 07.4.4.1.8 ✅ Execution Freshness & Market Quality Layer -- добавлен слой execution freshness diagnostics -- добавлен слой market quality diagnostics -- AutoTradeState расширен execution quality полями -- добавлены execution_quality и execution_quality_reason -- добавлены execution_quality_message, spread_percent и snapshot_age_seconds -- AutoTradeService начал синхронизировать execution quality в runtime state -- добавлена проверка наличия market snapshot -- добавлена диагностика SNAPSHOT_ERROR -- добавлена диагностика SNAPSHOT_UNAVAILABLE -- добавлена диагностика AGING_SNAPSHOT -- добавлена диагностика STALE_SNAPSHOT -- stale snapshot теперь блокирует вход -- aging snapshot теперь отображается как warning -- нормальный snapshot age больше не засоряет Telegram UI -- age отображается только для AGING_SNAPSHOT / STALE_SNAPSHOT сценариев -- добавлен расчёт bid/ask spread -- добавлен spread warning layer -- добавлен spread block layer -- высокий spread теперь блокирует вход -- повышенный spread теперь отображается как market warning -- execution diagnostics отделены от strategy entry diagnostics -- UI разделяет Условие, Рынок и Вход -- термин Исполнение заменён на более короткий Вход -- `нет market data` заменено на `нет данных рынка` -- Telegram UI получил строку market quality warning -- Telegram UI получил строку execution block по качеству рынка -- wide spread отображается компактно как `Рынок · spread ...` -- high spread отображается как `Вход · высокий spread ...` -- устранено дублирование age при нормальном snapshot -- добавлена база для spread-aware execution -- добавлена база для stale snapshot protection -- добавлена база для slippage protection -- добавлена база для execution quality analytics -- добавлена база для instrument quality scoring -- выявлена необходимость Spread Hysteresis Layer -- подготовлен следующий этап 07.4.4.1.8.1 Spread Hysteresis Layer - -#### 07.4.4.1.8.1 ✅ Spread Hysteresis Layer -- добавлен hysteresis layer для spread quality -- одиночные spread thresholds заменены на enter / exit thresholds -- добавлен порог входа в WARNING spread state -- добавлен порог выхода из WARNING spread state -- добавлен порог входа в BLOCKED spread state -- добавлен порог выхода из BLOCKED spread state -- добавлен helper `_spread_execution_quality` -- spread quality теперь учитывает предыдущее execution_quality состояние -- предотвращено мигание GOOD / WARNING около warning spread threshold -- предотвращено мигание WARNING / BLOCKED около block spread threshold -- execution gate стал устойчивее к микроколебаниям bid/ask -- Telegram UI стал стабильнее при пограничном spread -- состояние `Вход · высокий spread` теперь снимается только после нормализации spread -- состояние `Рынок · spread` теперь не исчезает от единичного тика -- снижено количество шумных execution_quality_changed событий -- исправлена типизация return value для market_runtime_degraded -- подтверждена стабильная работа на LTC -- подготовлена база для symbol-specific spread profiles -- подготовлена база для volatility-aware spread thresholds -- подготовлена база для adaptive execution quality model - -#### 07.4.4.1.9 ✅ Adaptive Market Diagnostics Layer -- добавлен расширенный слой диагностики рынка -- добавлены enum-модели TrendStrength, TrendQuality и MarketPhase -- MarketAnalysisResult расширен полями trend_strength, trend_quality и market_phase -- MarketAnalysisResult расширен полями trend_gap_percent и trend_consistency -- MarketAnalysisService получил расчёт EMA gap в процентах -- добавлен анализ силы тренда по EMA gap -- добавлена классификация WEAK / NORMAL / STRONG trend -- добавлен анализ trend consistency по последним свечам -- добавлена классификация CLEAN / NOISY trend -- добавлена классификация market phase -- добавлены фазы IMPULSE / PULLBACK / RANGE / SQUEEZE / UNKNOWN -- LOW volatility теперь интерпретируется как SQUEEZE phase -- FLAT trend теперь интерпретируется как RANGE phase -- слабый тренд теперь может блокировать TREND вход -- шумный тренд теперь может блокировать TREND вход -- откат внутри тренда теперь может блокировать TREND вход -- TrendStrategy получила поддержку adaptive market diagnostics -- TrendStrategy пробрасывает market_trend_strength в payload -- TrendStrategy пробрасывает market_trend_quality в payload -- TrendStrategy пробрасывает market_phase в payload -- TrendStrategy пробрасывает market_trend_gap_percent в payload -- TrendStrategy пробрасывает market_trend_consistency в payload -- добавлен HOLD reason WEAK_MARKET_TREND -- добавлен HOLD reason NOISY_MARKET_TREND -- добавлен HOLD reason MARKET_PULLBACK -- AutoTradeState расширен market_trend_strength -- AutoTradeState расширен market_trend_quality -- AutoTradeState расширен market_phase -- reset runtime очищает новые market diagnostics поля -- sync market analysis обновляет новые market diagnostics поля -- Telegram UI получил строку расширенной аналитики -- Telegram UI отображает силу тренда -- Telegram UI отображает качество тренда -- Telegram UI отображает фазу рынка -- HOLD diagnostics стали точнее -- причина HOLD теперь показывает слабый тренд -- причина HOLD теперь показывает шумный тренд -- причина HOLD теперь показывает откат -- исправлен auto_run_cycle_error после расширения MarketAnalysisResult -- исправлено зависание market state в “Идёт анализ” -- подтверждена работа live runtime после расширения аналитики -- подготовлена база для Market Semantic Runtime Layer -- подготовлена база для compact semantic UI labels -- подготовлена база для adaptive thresholds -- подготовлена база для semantic entry filters -- подготовлена база для более точного TREND execution - -#### 07.4.4.1.9.1 ✅ Market Semantic Runtime Layer -- добавлен semantic runtime layer поверх adaptive market diagnostics -- добавлена единая semantic-интерпретация market_state / trend / strength / quality / phase -- добавлена функция `_market_semantic_line()` в Telegram UI -- `_market_semantic_line()` заменяет связку `_market_state_line()` и `_market_diagnostics_line()` -- UI больше не показывает раздельно технические признаки тренда и фазы -- UI теперь показывает одно итоговое смысловое состояние рынка -- добавлена semantic-нормализация TREND_UP состояний -- добавлена semantic-нормализация TREND_DOWN состояний -- добавлена semantic-нормализация RANGE состояния -- добавлена semantic-нормализация LOW_VOLATILITY / SQUEEZE состояния -- добавлена semantic-нормализация HIGH_VOLATILITY состояния -- добавлена semantic-нормализация UNKNOWN состояния -- добавлено состояние `⚡ Рынок · сильный рост` -- добавлено состояние `⚡ Рынок · сильное снижение` -- добавлено состояние `📈 Рынок · рост` -- добавлено состояние `📉 Рынок · снижение` -- добавлено состояние `🟡 Рынок · слабый рост` -- добавлено состояние `🟡 Рынок · слабое снижение` -- добавлено состояние `⚠️ Рынок · шумный рост` -- добавлено состояние `⚠️ Рынок · шумное снижение` -- добавлено состояние `↘️ Рынок · коррекция` -- добавлено состояние `↗️ Рынок · откат вверх` -- добавлено состояние `🟰 Рынок · флэт` -- добавлено состояние `🟦 Рынок · сжатие` -- добавлено состояние `⚠️ Рынок · перегрев` -- добавлено состояние `⏳ Рынок · анализ` -- добавлен приоритет semantic-состояний рынка -- HIGH_VOLATILITY получает приоритет над обычным трендом -- LOW_VOLATILITY / SQUEEZE отображается как semantic-сжатие -- RANGE отображается как semantic-флэт -- PULLBACK отображается как коррекция или откат вверх -- NOISY trend отображается как шумный рост или шумное снижение -- WEAK trend отображается как слабый рост или слабое снижение -- STRONG + CLEAN + IMPULSE отображается как сильный рост или сильное снижение -- NORMAL + CLEAN + IMPULSE отображается как рост или снижение -- fallback по направлению тренда сохранён -- execution quality не смешивается с market semantic layer -- строки `Вход · spread`, `Вход · нет стакана`, `Вход · нет данных рынка` остались отдельными -- строка `Фильтр` осталась отдельной для причин HOLD -- экран ожидания AutoTrade переведён на semantic market line -- экран активной позиции AutoTrade переведён на semantic market line -- убрано дублирование `Тренд` + `Фаза` в UI -- экран автоторговли стал короче -- экран автоторговли стал понятнее для runtime-наблюдения -- HOLD diagnostics стали более смысловыми -- market state теперь отображается как торговый смысл, а не как raw enum -- подготовлена база для semantic entry filters -- подготовлена база для market regime scoring -- подготовлена база для adaptive threshold tuning -- подготовлена база для confidence scoring по состоянию рынка -- подготовлена база для объяснимого AutoTrade decision screen - -#### 07.4.4.1.9.2 ✅ Signal Confirmation Runtime -- добавлен Signal Confirmation Runtime layer -- confirmation runtime вынесен в отдельный stateful lifecycle -- BUY / SELL больше не переходят мгновенно в READY -- добавлена runtime-фаза CONFIRMING -- confirmation теперь требует repeat consistency -- confirmation теперь требует time persistence -- добавлен minimum signal lifetime filter -- добавлен `_confirm_min_duration_seconds` -- BUY / SELL теперь обязаны удерживаться во времени -- runtime теперь анализирует signal persistence -- runtime теперь анализирует directional continuity -- runtime теперь анализирует signal lifetime -- добавлен confirmation progress runtime -- добавлен `signal_confirmation_seconds` -- добавлен `signal_confirmation_required_seconds` -- добавлен `signal_confirmation_missing_repeats` -- добавлен `signal_confirmation_progress` -- добавлен `signal_confirmation_reason` -- confirmation progress теперь рассчитывается по repeat progress -- confirmation progress теперь рассчитывается по time progress -- BUY / SELL проходят lifecycle HOLD → CONFIRMING → READY -- HOLD теперь сбрасывает confirmation progress -- HOLD теперь очищает confirmation runtime state -- READY теперь требует repeats и удержания сигнала -- добавлена защита от micro-breakout noise -- добавлена защита от single candle reversal -- добавлена защита от volatility spikes -- TREND runtime стал устойчивее к ложным импульсам -- улучшено распознавание устойчивого directional movement -- улучшена фильтрация краткосрочного рыночного шума -- Telegram UI получил runtime confirmation line -- UI показывает `⏳ Подтверждение` -- UI показывает `✅ Подтверждение · готово` -- confirmation line отображается только для BUY / SELL -- HOLD больше не показывает confirmation line -- confirmation runtime интегрирован в decision lifecycle -- decision_status теперь полноценно использует CONFIRMING state -- signal confirmation runtime синхронизирован с READY state -- debug_force_signal обновляет confirmation runtime state -- signal_ready journal events расширены confirmation analytics -- signal runtime стал более stateful -- AutoTrade приблизился к professional signal confirmation flow -- подготовлена база для adaptive confirmation thresholds -- подготовлена база для probabilistic signal scoring -- подготовлена база для multi-timeframe confirmation -- подготовлена база для direction persistence engine -- подготовлена база для execution scoring system -- подготовлена база для probabilistic signal engine - -#### 07.4.4.1.9.3 ✅ Market Phase Transition Fix -- исправлен phase transition runtime между IMPULSE / PULLBACK / RANGE -- уменьшена чувствительность semantic phase engine к локальному noise -- уменьшена чувствительность semantic runtime к краткосрочным counter candles -- уменьшена чувствительность market phase к локальным RSI rollback -- directional trend получил повышенный semantic priority -- directional continuation теперь имеет приоритет над micro-pullback -- phase runtime стал менее reactive -- phase runtime стал более stable -- semantic runtime перестал преждевременно переходить в PULLBACK -- semantic runtime перестал слишком долго удерживать PULLBACK -- semantic runtime стал лучше совпадать с визуальным графиком -- улучшено распознавание устойчивого bearish continuation -- улучшено распознавание устойчивого bullish continuation -- улучшено распознавание momentum continuation -- улучшено распознавание directional persistence -- улучшено разделение pullback и slowdown movement -- улучшено разделение correction и normal trend continuation -- RSI больше не способен самостоятельно переключать market phase -- phase engine теперь требует дополнительного directional context -- phase engine теперь учитывает continuation structure -- semantic runtime стал устойчивее к volatility noise -- semantic runtime стал устойчивее к short rollback candles -- semantic runtime стал стабильнее при directional movement -- уменьшено количество ложных `↗️ Рынок · откат вверх` -- уменьшено количество ложных `↘️ Рынок · коррекция` -- увеличена стабильность `⚡️ Рынок · сильный рост` -- увеличена стабильность `⚡️ Рынок · сильное снижение` -- увеличена стабильность `📈 Рынок · рост` -- увеличена стабильность `📉 Рынок · снижение` -- semantic state transitions стали плавнее -- semantic market runtime стал менее дёрганым -- HOLD diagnostics стали визуально логичнее -- semantic layer стал лучше синхронизирован с execution runtime -- Telegram UI стал ближе к реальному рынку -- phase transitions стали explainable -- подготовлена база для execution semantic states -- подготовлена база для acceleration analysis -- подготовлена база для continuation scoring -- подготовлена база для adaptive execution thresholds -- подготовлена база для execution confidence engine -- подготовлена база для probabilistic execution runtime -- подготовлена база для multi-timeframe execution alignment - -#### 07.4.4.1.9.4 ✅ Execution Semantic Layer -- реализован execution semantic runtime -- реализован execution semantic orchestrator -- реализована execution semantic state machine -- добавлены execution semantic states -- добавлены semantic execution diagnostics -- добавлены semantic execution explanations -- execution runtime стал explainable -- execution runtime отделён от market semantic layer -- execution runtime синхронизирован с market runtime -- execution runtime синхронизирован с confirmation runtime -- execution runtime синхронизирован с Telegram UI -- execution layer получил semantic execution states -- execution layer получил semantic execution messages -- execution layer получил semantic execution reasons -- добавлена semantic execution интерпретация runtime -- добавлена semantic execution readiness логика -- добавлена semantic execution blocking логика -- добавлена semantic execution safety логика -- spread интегрирован в execution semantic layer -- snapshot freshness интегрирован в execution semantic layer -- liquidity diagnostics интегрированы в execution semantic runtime -- execution runtime теперь анализирует bid/ask spread -- execution runtime теперь анализирует stale snapshots -- execution runtime теперь анализирует degraded market runtime -- execution runtime теперь анализирует execution readiness -- execution runtime теперь умеет explainable blocking -- execution runtime теперь умеет explainable waiting -- execution runtime теперь умеет explainable readiness -- execution runtime теперь умеет explainable degradation -- execution runtime теперь умеет explainable unsafe market -- реализованы semantic execution states: - - IDLE - - WAITING_SIGNAL - - READY - - BLOCKED - - POSITION_OPEN -- реализованы semantic execution messages: - - ✅ Исполнение · готово - - ⏳ Исполнение · ждёт подтверждения - - ⏳ Исполнение · сигнал проверяется - - 📌 Исполнение · позиция открыта - - ⛔ Исполнение · высокий spread - - ⛔ Исполнение · рынок неактуален - - ⚠️ Исполнение · нет стакана - - ⛔ Исполнение · нет данных рынка -- HOLD diagnostics стали explainable -- HOLD diagnostics стали логичнее -- Telegram UI стал ближе к реальному execution runtime -- execution runtime стал менее "чёрным ящиком" -- execution runtime стал визуально понятнее -- execution runtime стал безопаснее -- execution runtime стал стабильнее -- execution runtime стал ближе к professional execution engine -- execution runtime подготовлен к probabilistic execution scoring -- execution runtime подготовлен к adaptive execution thresholds -- execution runtime подготовлен к smart execution routing -- execution runtime подготовлен к liquidity scoring -- execution runtime подготовлен к slippage prediction -- execution runtime подготовлен к execution protection layer -- execution runtime подготовлен к semantic trade lifecycle -- execution runtime подготовлен к professional execution diagnostics - -#### 07.4.4.1.9.5 ✅ Execution Confidence Engine -- реализован execution confidence runtime -- реализован probabilistic execution engine -- реализован execution confidence orchestrator -- реализован composite execution scoring -- execution runtime переведён на probabilistic scoring model -- execution runtime перестал зависеть только от signal confidence -- execution runtime теперь оценивает совокупное качество execution context -- execution runtime теперь анализирует вероятность качественного входа -- execution runtime теперь анализирует continuation quality -- execution runtime теперь анализирует market quality -- execution runtime теперь анализирует execution quality -- execution runtime теперь анализирует market structure quality -- execution runtime теперь анализирует directional continuation -- execution runtime теперь анализирует trend persistence -- execution runtime теперь анализирует market phase quality -- execution runtime теперь анализирует liquidity quality -- execution runtime теперь анализирует runtime degradation -- execution runtime теперь анализирует snapshot quality -- execution runtime теперь анализирует spread impact -- execution runtime теперь анализирует confirmation quality -- реализован weighted execution scoring -- signal confidence интегрирован в execution scoring -- signal confirmation интегрирован в execution scoring -- market semantic layer интегрирован в execution scoring -- execution quality layer интегрирован в execution scoring -- spread интегрирован в probabilistic execution scoring -- stale snapshots интегрированы в probabilistic execution scoring -- noisy market интегрирован в probabilistic execution scoring -- pullback market интегрирован в probabilistic execution scoring -- range market интегрирован в probabilistic execution scoring -- squeeze market интегрирован в probabilistic execution scoring -- continuation strength интегрирован в execution scoring -- trend quality интегрирован в execution scoring -- directional impulse интегрирован в execution scoring -- реализован execution confidence threshold -- реализован adaptive execution gating -- реализован probabilistic execution blocking -- execution runtime теперь умеет low-confidence blocking -- execution runtime теперь умеет explainable confidence diagnostics -- execution runtime теперь умеет explainable confidence scoring -- execution runtime теперь умеет explainable execution probability -- execution runtime теперь умеет explainable execution weakness -- execution runtime теперь умеет explainable unsafe execution context -- реализованы execution confidence levels: - - HIGH - - NORMAL - - LOW -- реализованы execution confidence diagnostics: - - 🧠 Уверенность входа · высокая - - 🧠 Уверенность входа · нормальная - - 🧠 Уверенность входа · низкая -- реализовано semantic execution состояние: - - ⛔ Исполнение · низкая уверенность -- execution runtime стал менее deterministic -- execution runtime стал менее reactive -- execution runtime стал explainable -- execution runtime стал probabilistic -- execution runtime стал ближе к professional execution engine -- execution runtime стал лучше фильтровать noise execution -- execution runtime стал лучше фильтровать weak continuation -- execution runtime стал безопаснее -- execution runtime стал стабильнее -- execution runtime стал ближе к institutional execution logic -- execution runtime подготовлен к adaptive position sizing -- execution runtime подготовлен к probabilistic trade selection -- execution runtime подготовлен к smart execution routing -- execution runtime подготовлен к multi-factor trade ranking -- execution runtime подготовлен к execution quality ranking -- execution runtime подготовлен к adaptive execution thresholds -- execution runtime подготовлен к portfolio-level execution scoring -- execution runtime подготовлен к execution AI layer - -#### 07.4.4.1.9.6 ✅ Adaptive Position Sizing -- реализован adaptive position sizing runtime -- реализован probabilistic position sizing engine -- реализован adaptive execution multiplier -- execution runtime переведён на context-aware sizing model -- execution runtime перестал использовать только fixed risk sizing -- execution runtime теперь адаптирует exposure под качество execution context -- execution runtime теперь адаптирует exposure под качество рынка -- execution runtime теперь адаптирует exposure под continuation quality -- execution runtime теперь адаптирует exposure под execution quality -- execution runtime теперь адаптирует exposure под volatility regime -- execution runtime теперь анализирует market trend strength -- execution runtime теперь анализирует market trend quality -- execution runtime теперь анализирует market phase -- execution runtime теперь анализирует directional continuation -- execution runtime теперь анализирует trend persistence -- execution runtime теперь анализирует noisy market conditions -- execution runtime теперь анализирует pullback conditions -- execution runtime теперь анализирует squeeze conditions -- execution runtime теперь анализирует range conditions -- execution runtime теперь анализирует execution degradation -- execution runtime теперь анализирует spread impact -- execution runtime теперь анализирует snapshot freshness -- execution runtime теперь анализирует liquidity quality -- execution confidence интегрирован в adaptive sizing -- market semantic layer интегрирован в adaptive sizing -- execution quality layer интегрирован в adaptive sizing -- spread интегрирован в adaptive exposure scaling -- stale snapshots интегрированы в adaptive exposure scaling -- noisy market интегрирован в adaptive sizing -- pullback market интегрирован в adaptive sizing -- range market интегрирован в adaptive sizing -- squeeze market интегрирован в adaptive sizing -- trend quality интегрирован в adaptive sizing -- directional impulse интегрирован в adaptive sizing -- реализован adaptive size reduction -- реализован adaptive size increase -- реализован adaptive size blocking -- реализован confidence-aware exposure scaling -- реализован margin-aware position protection -- реализован effective risk recalculation -- реализован semantic sizing diagnostics -- execution runtime стал менее deterministic -- execution runtime стал менее aggressive -- execution runtime стал probabilistic -- execution runtime стал context-aware -- execution runtime стал market-aware -- execution runtime стал continuation-aware -- execution runtime стал execution-quality-aware -- execution runtime стал safer -- execution runtime стал стабильнее -- execution runtime стал ближе к institutional execution logic -- execution runtime подготовлен к semantic market layer -- execution runtime подготовлен к semantic execution routing -- execution runtime подготовлен к smart exposure management -- execution runtime подготовлен к portfolio-level risk balancing -- execution runtime подготовлен к adaptive portfolio allocation -- execution runtime подготовлен к execution AI layer - -#### 07.4.4.1.9.6.1 ✅ Market Semantic Layer for Adaptive Sizing -- реализован semantic market abstraction layer -- реализован semantic market runtime -- реализован semantic market interpretation engine -- реализован semantic trend classification -- реализован semantic market phase classification -- реализован semantic continuation analysis -- реализован semantic trend persistence analysis -- реализован semantic trend cleanliness analysis -- реализован semantic trend degradation analysis -- реализован semantic pullback detection -- реализован semantic impulse detection -- реализован semantic squeeze detection -- реализован semantic range detection -- реализован semantic noisy market detection -- реализован semantic continuation quality analysis -- реализован semantic directional persistence analysis -- market runtime переведён на semantic market interpretation model -- market runtime перестал зависеть только от raw EMA direction -- market runtime перестал зависеть только от ATR state -- market runtime теперь анализирует semantic market structure -- market runtime теперь анализирует semantic continuation quality -- market runtime теперь анализирует semantic directional consistency -- market runtime теперь анализирует semantic market persistence -- market runtime теперь анализирует semantic counter-trend movement -- market runtime теперь анализирует semantic pullback probability -- market runtime теперь анализирует semantic market degradation -- market runtime теперь анализирует semantic market noise -- market runtime теперь анализирует semantic market compression -- реализован semantic market phase engine: - - IMPULSE - - PULLBACK - - RANGE - - SQUEEZE -- реализован semantic trend quality engine: - - CLEAN - - NOISY -- реализован semantic trend strength engine: - - WEAK - - NORMAL - - STRONG -- реализован semantic phase direction engine -- реализован semantic phase consistency engine -- реализован semantic counter-trend confirmation engine -- RSI интегрирован в semantic pullback confirmation -- trend consistency интегрирован в semantic trend quality -- directional persistence интегрирован в semantic continuation analysis -- market phase интегрирован в semantic execution filtering -- trend quality интегрирован в semantic execution filtering -- trend strength интегрирован в semantic execution filtering -- pullback semantics интегрирован в execution blocking -- noisy market semantics интегрирован в execution blocking -- squeeze semantics интегрирован в execution blocking -- range semantics интегрирован в execution blocking -- semantic market layer интегрирован в adaptive sizing -- semantic market layer интегрирован в execution confidence -- semantic market layer интегрирован в execution diagnostics -- semantic market layer интегрирован в runtime UI -- реализован explainable semantic market diagnostics -- реализован explainable semantic execution context -- реализован explainable semantic market state -- реализован semantic market UI rendering -- реализован semantic market visualization layer -- реализован semantic execution UI layer -- реализован event-driven semantic UI refresh -- реализовано разделение semantic UI state и volatile UI state -- реализован semantic refresh filtering -- реализован semantic refresh optimization -- реализована защита от volatile timer refresh spam -- реализован semantic screen state tracking -- execution runtime стал semantic-aware -- execution runtime стал continuation-aware -- execution runtime стал market-structure-aware -- execution runtime стал phase-aware -- execution runtime стал trend-quality-aware -- execution runtime стал менее deterministic -- execution runtime стал explainable -- execution runtime стал context-aware -- execution runtime стал ближе к institutional semantic execution logic -- execution runtime стал лучше фильтровать noisy continuation -- execution runtime стал лучше фильтровать weak market structure -- execution runtime стал лучше фильтровать pullback continuation -- execution runtime стал лучше различать impulse vs correction -- execution runtime подготовлен к semantic execution routing -- execution runtime подготовлен к AI market interpretation layer -- execution runtime подготовлен к semantic portfolio orchestration -- execution runtime подготовлен к semantic adaptive execution -- execution runtime подготовлен к probabilistic semantic ranking -- execution runtime подготовлен к AI-driven market reasoning - -#### 07.4.4.1.9.6.2 ✅ Momentum & Breakout Semantic Engine -- реализован semantic momentum abstraction layer -- реализован semantic breakout abstraction layer -- реализован semantic acceleration analysis engine -- реализован semantic momentum interpretation runtime -- реализован semantic breakout interpretation runtime -- реализован semantic directional acceleration analysis -- реализован semantic breakout persistence analysis -- реализован semantic breakout continuation analysis -- реализован semantic breakout degradation analysis -- реализован semantic momentum continuation analysis -- реализован semantic momentum persistence analysis -- реализован semantic momentum degradation analysis -- реализован semantic impulsive movement analysis -- реализован semantic fast continuation analysis -- реализован semantic breakout distance analysis -- реализован semantic breakout validation runtime -- реализован semantic momentum strength engine -- реализован semantic momentum direction engine -- реализован semantic breakout classification engine -- реализован semantic momentum classification engine -- реализован semantic momentum diagnostics layer -- реализован semantic breakout diagnostics layer -- реализован explainable momentum diagnostics -- реализован explainable breakout diagnostics -- реализован explainable acceleration diagnostics -- execution runtime теперь анализирует acceleration quality -- execution runtime теперь анализирует breakout persistence -- execution runtime теперь анализирует breakout continuation -- execution runtime теперь анализирует fast directional movement -- execution runtime теперь анализирует impulsive expansion -- execution runtime стал momentum-aware -- execution runtime стал breakout-aware -- execution runtime стал acceleration-aware -- execution runtime стал impulsive-structure-aware -- execution runtime стал лучше различать breakout vs noise -- execution runtime подготовлен к momentum-aware adaptive sizing -- execution runtime подготовлен к semantic breakout routing -- execution runtime подготовлен к AI-driven momentum interpretation - ---- - -### 07.4.4.1.10 Semantic Runtime Diagnostics & Observability - -#### 07.4.4.1.10.1 ✅ Semantic Diagnostic Snapshot Builder -- реализован semantic diagnostic snapshot builder -- реализован diagnostic runtime aggregation layer -- реализован единый semantic diagnostic snapshot -- реализована агрегация signal diagnostics -- реализована агрегация decision diagnostics -- реализована агрегация market semantic diagnostics -- реализована агрегация momentum / breakout diagnostics -- реализована агрегация execution quality diagnostics -- реализована агрегация execution confidence diagnostics -- реализована агрегация adaptive sizing diagnostics -- реализована агрегация runtime expiration diagnostics -- runtime diagnostics вынесены в отдельный read-only слой -- diagnostic layer не меняет торговую логику -- diagnostic layer подготовлен к Telegram Diagnostic Screen -- diagnostic layer подготовлен к Human-readable formatter -- diagnostic layer подготовлен к Diagnostic Journal Layer -- diagnostic layer подготовлен к Auto-refresh Diagnostic UI -- execution runtime стал explainability-ready -- execution runtime стал diagnostic-aware -- execution runtime стал semantic-state-aware -- execution runtime подготовлен к professional observability layer -- execution runtime подготовлен к institutional execution diagnostics - -#### 07.4.4.1.10.2 ✅ Human-readable formatter -- реализован human-readable diagnostic formatter -- реализован presentation layer для semantic diagnostics -- реализован человекочитаемый runtime diagnostic report -- реализовано форматирование signal diagnostics -- реализовано форматирование decision diagnostics -- реализовано форматирование market semantic diagnostics -- реализовано форматирование momentum / breakout diagnostics -- реализовано форматирование execution quality diagnostics -- реализовано форматирование execution confidence diagnostics -- реализовано форматирование adaptive sizing diagnostics -- реализовано форматирование runtime health diagnostics -- реализовано форматирование position diagnostics -- diagnostic snapshot теперь можно выводить в Telegram -- diagnostic snapshot теперь можно использовать для journal diagnostics -- diagnostic snapshot теперь можно использовать для debug output -- diagnostic layer стал explainable -- diagnostic layer стал user-readable -- diagnostic layer подготовлен к Telegram Diagnostic Screen -- diagnostic layer подготовлен к Diagnostic Journal Layer -- diagnostic layer подготовлен к Auto-refresh Diagnostic UI - -#### 07.4.4.1.10.3 ✅ Telegram Diagnostic Screen -- реализирован полноценный Telegram Diagnostic Screen -- реализован отдельный diagnostics Telegram UI layer -- реализован auto-refresh diagnostics screen -- реализована интеграция diagnostics screen с AutoTradeRunner -- реализована интеграция diagnostics screen с ActiveScreenManager -- реализован отдельный diagnostic navigation flow -- реализована отдельная diagnostics keyboard -- реализовано безопасное обновление diagnostic messages -- реализована защита Telegram diagnostics UI от TelegramBadRequest -- реализован explainable runtime diagnostic screen -- реализован explainable semantic diagnostic UI -- реализован explainable market diagnostics UI -- реализован explainable momentum diagnostics UI -- реализован explainable breakout diagnostics UI -- реализован explainable execution diagnostics UI -- реализован explainable adaptive sizing diagnostics UI -- реализован explainable runtime health diagnostics UI -- реализован explainable position diagnostics UI -- реализован explainable severity system -- реализована semantic severity hierarchy -- реализовано разделение WAITING / YELLOW / RED runtime states -- реализована логика semantic waiting state -- реализована логика runtime freshness interpretation -- реализована логика execution readiness interpretation -- реализована логика signal confirmation interpretation -- реализована логика market noise interpretation -- реализована логика market phase interpretation -- реализована логика breakout explanation -- реализована логика execution quality explanation -- реализована логика adaptive sizing explanation -- реализована логика runtime degradation explanation -- исключены ложные warning состояния при HOLD signal -- исключены ложные yellow состояния без momentum -- реализован semantic OFF diagnostics mode -- реализован lightweight diagnostics режим для OFF состояния -- реализована корректная diagnostics логика без RUNNING state -- реализована подготовка cycle pnl diagnostics -- реализована подготовка flip diagnostics -- реализована подготовка cumulative realized pnl diagnostics -- реализована подготовка old/new side flip diagnostics -- реализована подготовка flip pnl diagnostics -- реализована подготовка position cycle analytics -- semantic analytics layer стал explainable -- semantic analytics layer стал user-readable -- semantic analytics layer стал Telegram-ready -- diagnostics layer подготовлен к Diagnostic Journal Layer -- diagnostics layer подготовлен к persistent runtime analytics -- diagnostics layer подготовлен к advanced cycle analytics -- diagnostics layer подготовлен к semantic trade analytics -- diagnostics layer подготовлен к auto-refresh runtime dashboard - -#### 07.4.4.1.10.4 ✅ Telegram Runtime Stabilization & Type Safety Layer -- реализована унификация Telegram handler architecture -- реализована единая callback message validation layer -- реализован _require_message() runtime safety layer -- устранены unsafe callback.message обращения -- реализована защита Telegram handlers от InaccessibleMessage -- реализована стандартизация Telegram screen lifecycle -- реализована стандартизация ActiveScreenManager integration -- реализована стандартизация LiveScreenRunner integration -- реализована стандартизация StaticScreen registration -- реализована стандартизация render/edit Telegram flow -- реализована стандартизация edit_mode architecture -- реализована стандартизация Telegram runtime navigation -- реализована стандартизация retry flow architecture -- реализована стандартизация monitoring navigation flow -- реализована стандартизация portfolio/market/journal runtime screens -- реализована стандартизация auto runtime screen architecture -- реализована стандартизация auto diagnostics rendering -- реализована стандартизация risk controls runtime flow -- реализована стандартизация journal runtime rendering -- реализована стандартизация monitoring runtime rendering -- реализована стандартизация Telegram callback lifecycle -- реализована стандартизация runtime logging payloads -- реализована стандартизация JournalService logging payload structure -- реализована стандартизация Telegram runtime formatting -- реализована стандартизация keyboard rendering layer -- реализована стандартизация runtime message rendering -- реализована стандартизация safe runtime editing -- реализована стандартизация Telegram exception handling -- реализована стандартизация runtime error protection -- реализована стандартизация runtime fallback rendering -- реализована стандартизация runtime state cleanup -- реализована стандартизация runtime unregister flow -- реализована стандартизация runtime screen switching -- реализована стандартизация FSM cleanup flow -- реализована стандартизация risk runtime update flow -- реализована стандартизация diagnostics runtime refresh -- реализована стандартизация portfolio runtime refresh -- реализована стандартизация market runtime refresh -- реализована стандартизация journal pagination flow -- реализована стандартизация journal export flow -- реализована стандартизация journal cleanup flow -- реализована стандартизация runtime callback alerts -- реализована стандартизация runtime user notifications -- реализована стандартизация runtime retry notifications -- реализована стандартизация Telegram screen auto-refresh architecture -- реализована стандартизация runtime render callbacks -- реализована стандартизация auto runtime protection layer -- реализована стандартизация runtime semantic rendering -- реализована стандартизация runtime formatting utilities -- реализована стандартизация numeric formatting layer -- реализована стандартизация safe numeric parsing -- реализована стандартизация safe float conversion layer -- реализован global NumericLike typing layer -- реализован global JsonDict typing layer -- реализован global JsonList typing layer -- реализован centralized safe_float() conversion layer -- устранены raw float() conversions в Telegram runtime -- устранены unsafe numeric casts -- устранены implicit runtime numeric conversions -- устранены raw dict payload usages -- устранены runtime typing inconsistencies -- устранены Telegram runtime nullable access risks -- устранены duplicated callback validation patterns -- устранены duplicated Telegram screen preparation patterns -- устранены fragmented runtime formatting implementations -- устранены inconsistent runtime payload structures -- устранены inconsistent Telegram render flows -- устранены fragmented diagnostics rendering patterns -- устранены fragmented journal rendering patterns -- устранены fragmented risk controls rendering patterns -- реализована подготовка centralized runtime typing layer -- реализована подготовка advanced runtime telemetry -- реализована подготовка persistent Telegram runtime analytics -- реализована подготовка advanced runtime observability -- реализована подготовка unified runtime safety architecture -- реализована подготовка strict typing migration -- реализована подготовка runtime-safe analytics layer -- реализована подготовка advanced Telegram runtime dashboard -- реализована подготовка unified runtime infrastructure - -### 07.4.4.1.11 ✅ Advanced Trend Quality & EMA Distance Layer -- реализован advanced trend efficiency layer -- реализован trend consistency analysis -- реализован trend quality score engine -- реализован EMA distance semantic layer -- реализован ATR-normalized EMA distance analysis -- реализован EMA compression detection -- реализован EMA overextension detection -- реализован healthy EMA structure detection -- реализован advanced trend quality classification -- реализован noisy trend semantic analysis -- реализован weak trend semantic analysis -- реализован clean trend semantic analysis -- реализован semantic market structure analysis -- реализован semantic breakout analysis -- реализован breakout vs trend reasoning -- реализован counter-trend breakout protection -- реализован late entry detection layer -- реализован overextended entry detection -- реализован chasing movement protection -- реализован pullback entry zone detection -- реализован semantic entry timing classification -- реализован runtime trend structure rendering -- реализован runtime EMA diagnostics rendering -- реализован semantic market explanation engine -- реализован semantic momentum explanation layer -- реализован semantic diagnostics rendering -- реализован semantic runtime reasoning -- реализован semantic blockers compression -- реализован advanced signal explanation layer -- реализован runtime semantic payload propagation -- реализован runtime trend quality propagation -- реализован runtime EMA state propagation -- реализован runtime timing state propagation -- реализован advanced breakout runtime handling -- реализован semantic HOLD reasoning -- реализован advanced _human() semantic mapping layer -- реализована стандартизация semantic diagnostics rendering -- реализована стандартизация trend structure runtime formatting -- реализована стандартизация EMA diagnostics runtime formatting -- реализована стандартизация breakout semantic rendering -- реализована стандартизация runtime trend explanations -- реализована подготовка position-aware diagnostics -- реализована подготовка runtime position pressure analysis -- реализована подготовка semantic position health layer -- реализована подготовка runtime risk reasoning layer - -### 07.4.4.1.12 ✅ Position Health & Runtime Risk Layer -- реализован runtime position protection engine -- реализован position-aware execution layer -- реализован runtime protection lifecycle -- реализован break-even protection engine -- реализован automated break-even activation -- реализован runtime break-even synchronization -- реализован profit lock protection engine -- реализован runtime profit protection logic -- реализован trailing stop runtime engine -- реализован dynamic trailing stop synchronization -- реализован giveback protection engine -- реализован runtime peak pnl tracking -- реализован pnl deterioration analysis -- реализован giveback runtime exits -- реализован time decay protection engine -- реализован stale position detection -- реализован weak hold structure analysis -- реализован time-based runtime exit layer -- реализован runtime autonomous action engine -- реализованы runtime actions PROTECT / REDUCE / EXIT -- реализован runtime action cooldown layer -- реализован execution supervisor layer -- реализован emergency execution halt -- реализован execution cooldown after loss -- реализован degraded market execution blocker -- реализован stale snapshot execution blocker -- реализован signal conflict protection layer -- реализован runtime market regime protection -- реализован execution snapshot freshness validation -- реализован runtime flip protection upgrade -- реализован flip cooldown engine -- реализован breakout-aware flip protection -- реализован loss-aware flip protection -- реализован adaptive runtime risk engine -- реализован advanced adaptive sizing layer -- реализована execution quality-aware sizing logic -- реализован margin-aware effective risk engine -- реализован effective risk synchronization -- реализован runtime event propagation layer -- реализована runtime journal observability architecture -- реализован runtime semantic reasoning layer -- реализованы human-readable runtime explanations -- реализована runtime diagnostics propagation -- реализована preparation for partial exit engine -- реализована preparation for advanced runtime orchestration - -### 07.4.4.1.13 — AutoTrade Runtime Journal, Execution Refactor & Trade Analytics -- разобран ExecutionEngine на отдельные mixin-модули -- добавлены position open/close/flip actions -- добавлены runtime protection, risk close, supervisor, sizing, pricing, resets -- добавлен trade_id / trade_sequence / trade_cycle_number для связки open-close-flip -- добавлено логирование trade_opened / trade_closed / trade_flipped -- добавлено логирование запуска, наблюдения и остановки автоторговли -- добавлены фильтры экспорта журнала: all, auto, trades, errors, not_auto -- исправлен экспорт CSV/XLSX с фильтрами журнала -- снижено дублирование market stream / REST fallback событий -- снижено дублирование exchange/runtime ошибок -- добавлены human-readable event titles для новых событий -- улучшены уведомления AUTO_SIGNAL_READY -- добавлена цена входа по направлению сигнала: Ask для Long, Bid для Short -- добавлен контекст сигнала относительно открытой позиции -- удалены legacy trade/order handlers и order drafts -- вынесены auto runtime слои: lifecycle, signal, market, quality, semantic, health, intelligence, autonomous management -- добавлены exchange status/runtime UI helpers - ---- - -### 07.4.5 -⏳ Scalping Strategy - -### 07.3.6 — Signal Engine -⏳ реальные сигналы - -### 07.3.7 — Execution Engine -⏳ исполнение ордеров - -### 07.3.8 — Analytics -⏳ статистика торговли - -🟡 Статус: в работе - ---- - -# Stage 08 — Analytics / Reports -⏳ отчёты -⏳ pnl -⏳ winrate -⏳ drawdown - -⚪ Статус: не начат - ---- - -# Stage 09 — Production Readiness -⏳ deployment -⏳ monitoring -⏳ alerts -⏳ backups - -⚪ Статус: не начат - ---- - -## Текущий статус проекта - -Этот блок исторического Stage-roadmap больше не определяет текущий -следующий шаг. - -Актуальная контрольная точка: - -```text -Завершён: Build 060.29 — Market Data Access and Replay -Следующий: Build 060.30 — Market Data Acquisition Final Documentation -``` +Исторический статус: не начат. Production Deployment and Operations +остаются будущей cross-cutting программой без утверждённого Build. diff --git a/docs/roadmap/stage-01-bootstrap-foundation-roadmap.md b/docs/roadmap/stage-01-bootstrap-foundation-roadmap.md index 6335a72..ba93e63 100644 --- a/docs/roadmap/stage-01-bootstrap-foundation-roadmap.md +++ b/docs/roadmap/stage-01-bootstrap-foundation-roadmap.md @@ -1,5 +1,14 @@ # Stage 01 — Bootstrap / Foundation Roadmap +> **Статус: архивный исторический roadmap.** +> +> Документ сохраняет scope, отметки результатов и планы Stage на момент +> его ведения. Пометки «текущий статус», «следующий шаг», «в работе» и +> «не начат» ниже не определяют текущую последовательность проекта. +> +> Актуальные статусы: [Master Roadmap](master-roadmap.md). +> Целевые границы: [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md). + ## Цель Создать базовый каркас приложения: - запуск бота; @@ -63,4 +72,4 @@ ## Текущий статус 👉 Stage 01 завершён -👉 Следующий шаг: Stage 02 \ No newline at end of file +👉 Следующий шаг: Stage 02 diff --git a/docs/roadmap/stage-02-system-diagnostics-roadmap.md b/docs/roadmap/stage-02-system-diagnostics-roadmap.md index a06c60c..294be71 100644 --- a/docs/roadmap/stage-02-system-diagnostics-roadmap.md +++ b/docs/roadmap/stage-02-system-diagnostics-roadmap.md @@ -1,5 +1,14 @@ # Stage 02 — System / Diagnostics Roadmap +> **Статус: архивный исторический roadmap.** +> +> Документ сохраняет scope, отметки результатов и планы Stage на момент +> его ведения. Пометки «текущий статус», «следующий шаг», «в работе» и +> «не начат» ниже не определяют текущую последовательность проекта. +> +> Актуальные статусы: [Master Roadmap](master-roadmap.md). +> Целевые границы: [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md). + ## Цель Добавить системный слой: - статус приложения; @@ -59,4 +68,4 @@ ## Текущий статус 👉 Stage 02 завершён -👉 Следующий шаг: Stage 03 \ No newline at end of file +👉 Следующий шаг: Stage 03 diff --git a/docs/roadmap/stage-03-exchange-integration-roadmap.md b/docs/roadmap/stage-03-exchange-integration-roadmap.md index d0f8823..eabb820 100644 --- a/docs/roadmap/stage-03-exchange-integration-roadmap.md +++ b/docs/roadmap/stage-03-exchange-integration-roadmap.md @@ -1,5 +1,14 @@ # Stage 03 — Exchange Integration Roadmap +> **Статус: архивный исторический roadmap.** +> +> Документ сохраняет scope, отметки результатов и планы Stage на момент +> его ведения. Пометки «текущий статус», «следующий шаг», «в работе» и +> «не начат» ниже не определяют текущую последовательность проекта. +> +> Актуальные статусы: [Master Roadmap](master-roadmap.md). +> Целевые границы: [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md). + ## Цель Интеграция с биржей: - public API @@ -91,4 +100,4 @@ ## Текущий статус 👉 Завершён: 03.8 (fallback mode) -👉 Следующий шаг: 03.6 Orders \ No newline at end of file +👉 Следующий шаг: 03.6 Orders diff --git a/docs/roadmap/stage-04-storage-journal-roadmap.md b/docs/roadmap/stage-04-storage-journal-roadmap.md index b73c78b..3f009da 100644 --- a/docs/roadmap/stage-04-storage-journal-roadmap.md +++ b/docs/roadmap/stage-04-storage-journal-roadmap.md @@ -1,5 +1,14 @@ # Stage 04 — Storage / Journal Roadmap +> **Статус: архивный исторический roadmap.** +> +> Документ сохраняет scope, отметки результатов и планы Stage на момент +> его ведения. Пометки «текущий статус», «следующий шаг», «в работе» и +> «не начат» ниже не определяют текущую последовательность проекта. +> +> Актуальные статусы: [Master Roadmap](master-roadmap.md). +> Целевые границы: [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md). + ## Цель Добавить слой хранения и журналирования. @@ -44,4 +53,4 @@ ## Текущий статус 👉 Stage 04 завершён -👉 Следующий шаг: Stage 05 \ No newline at end of file +👉 Следующий шаг: Stage 05 diff --git a/docs/roadmap/stage-05-trading-ui-draft-validation-roadmap.md b/docs/roadmap/stage-05-trading-ui-draft-validation-roadmap.md index 8aa5c93..6a0d76c 100644 --- a/docs/roadmap/stage-05-trading-ui-draft-validation-roadmap.md +++ b/docs/roadmap/stage-05-trading-ui-draft-validation-roadmap.md @@ -1,5 +1,14 @@ # Stage 05 — Trading UI / Draft / Validation Roadmap +> **Статус: архивный исторический roadmap.** +> +> Документ сохраняет scope, отметки результатов и планы Stage на момент +> его ведения. Пометки «текущий статус», «следующий шаг», «в работе» и +> «не начат» ниже не определяют текущую последовательность проекта. +> +> Актуальные статусы: [Master Roadmap](master-roadmap.md). +> Целевые границы: [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md). + ## Цель Реализовать торговый UI: - draft orders; @@ -93,4 +102,4 @@ ## Текущий статус 👉 Stage 05 завершён -👉 Следующий шаг: Stage 06 \ No newline at end of file +👉 Следующий шаг: Stage 06 diff --git a/docs/roadmap/stage-06-journal-navigation-settings-roadmap.md b/docs/roadmap/stage-06-journal-navigation-settings-roadmap.md index 614ac86..3b6fe20 100644 --- a/docs/roadmap/stage-06-journal-navigation-settings-roadmap.md +++ b/docs/roadmap/stage-06-journal-navigation-settings-roadmap.md @@ -1,5 +1,14 @@ # Stage 06 — Journal / Navigation / Settings Roadmap +> **Статус: архивный исторический roadmap.** +> +> Документ сохраняет scope, отметки результатов и планы Stage на момент +> его ведения. Пометки «текущий статус», «следующий шаг», «в работе» и +> «не начат» ниже не определяют текущую последовательность проекта. +> +> Актуальные статусы: [Master Roadmap](master-roadmap.md). +> Целевые границы: [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md). + ## Цель Добавить журналирование и системную навигацию. @@ -46,4 +55,4 @@ ## Текущий статус 👉 Stage 06 завершён -👉 Следующий шаг: Stage 07 \ No newline at end of file +👉 Следующий шаг: Stage 07 diff --git a/docs/roadmap/stage-07-auto-trading-roadmap.md b/docs/roadmap/stage-07-auto-trading-roadmap.md index eca67a9..d4f3b64 100644 --- a/docs/roadmap/stage-07-auto-trading-roadmap.md +++ b/docs/roadmap/stage-07-auto-trading-roadmap.md @@ -1,5 +1,14 @@ # Stage 07 — Auto Trading Roadmap +> **Статус: архивный исторический roadmap.** +> +> Документ сохраняет scope, отметки результатов и планы Stage на момент +> его ведения. Пометки «текущий статус», «следующий шаг», «в работе» и +> «не начат» ниже не определяют текущую последовательность проекта. +> +> Актуальные статусы: [Master Roadmap](master-roadmap.md). +> Целевые границы: [Dzentra Target Architecture](../architecture/dzentra_target_architecture.md). + ## Цель Добавить автоторговлю. @@ -1571,4 +1580,4 @@ ## Текущий статус 👉 Завершён: 07.4.3.1 -👉 Следующий шаг: 07.4.3.2 — Decoupling + Price Polling \ No newline at end of file +👉 Следующий шаг: 07.4.3.2 — Decoupling + Price Polling diff --git a/docs/stages/stage-03_3-exchange_info.md b/docs/stages/stage-03_3-exchange_info.md index fa4d475..2d093f6 100644 --- a/docs/stages/stage-03_3-exchange_info.md +++ b/docs/stages/stage-03_3-exchange_info.md @@ -36,4 +36,5 @@ Добавлен метод: ```python -validate_symbol() \ No newline at end of file +validate_symbol() +``` diff --git a/docs/stages/stage-04_3-repositories.md b/docs/stages/stage-04_3-repositories.md index 3eec755..6a3e0d8 100644 --- a/docs/stages/stage-04_3-repositories.md +++ b/docs/stages/stage-04_3-repositories.md @@ -46,4 +46,5 @@ - логирование через Journal ```text -exchange → accounts service → repository → database \ No newline at end of file +exchange → accounts service → repository → database +``` diff --git a/docs/stages/stage-06_2-system-settings-navigation-and-info.md b/docs/stages/stage-06_2-system-settings-navigation-and-info.md index d04211d..17dd114 100644 --- a/docs/stages/stage-06_2-system-settings-navigation-and-info.md +++ b/docs/stages/stage-06_2-system-settings-navigation-and-info.md @@ -72,4 +72,5 @@ ```bash git add . -git commit -m "Stage 06.2 - system settings navigation and product information screens" \ No newline at end of file +git commit -m "Stage 06.2 - system settings navigation and product information screens" +``` diff --git a/infra/compose/docker-compose.exchange-auth.yml b/infra/compose/docker-compose.exchange-auth.yml new file mode 100644 index 0000000..31a47f6 --- /dev/null +++ b/infra/compose/docker-compose.exchange-auth.yml @@ -0,0 +1,14 @@ +services: + bot: + environment: + EXCHANGE_API_KEY_FILE: /run/secrets/exchange_api_key + EXCHANGE_API_SECRET_FILE: /run/secrets/exchange_api_secret + secrets: + - exchange_api_key + - exchange_api_secret + +secrets: + exchange_api_key: + file: ${EXCHANGE_API_KEY_SOURCE_FILE:?EXCHANGE_API_KEY_SOURCE_FILE must be set} + exchange_api_secret: + file: ${EXCHANGE_API_SECRET_SOURCE_FILE:?EXCHANGE_API_SECRET_SOURCE_FILE must be set} diff --git a/infra/compose/docker-compose.yml b/infra/compose/docker-compose.yml index b5a47fa..e451abe 100644 --- a/infra/compose/docker-compose.yml +++ b/infra/compose/docker-compose.yml @@ -1,33 +1,126 @@ +name: ${DZENTRA_COMPOSE_PROJECT_NAME:?DZENTRA_COMPOSE_PROJECT_NAME must be set} + services: postgres: - image: postgres:17 - container_name: dzentra_postgres - restart: unless-stopped + build: + context: ../.. + dockerfile: infra/docker/postgres/Dockerfile + image: ${DZENTRA_POSTGRES_IMAGE:-dzentra-postgres:16.14-hardened} + pull_policy: build + restart: "no" environment: - POSTGRES_DB: dzentra_bot - POSTGRES_USER: dzentra_bot - POSTGRES_PASSWORD: change_me - ports: - - "5432:5432" + POSTGRES_DB: ${DB_NAME:-dzentra_bot} + POSTGRES_USER: ${POSTGRES_ADMIN_USER:-dzentra_admin} + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_admin_password + APP_DB_USER: ${DB_USER:-dzentra_bot} + secrets: + - postgres_admin_password + - db_password volumes: - dzentra_postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U dzentra_bot -d dzentra_bot"] + test: + - CMD + - /usr/local/bin/dzentra-postgres-healthcheck interval: 5s timeout: 3s - retries: 10 + retries: 3 + start_period: 10s + networks: + - database + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m + - /var/run/postgresql:rw,nosuid,nodev,size=16m + stop_grace_period: ${POSTGRES_STOP_GRACE_PERIOD:?POSTGRES_STOP_GRACE_PERIOD must be set} bot: build: context: ../.. dockerfile: infra/docker/Dockerfile - container_name: dzentra_bot - restart: unless-stopped - env_file: - - ../../app/.env + image: ${DZENTRA_BOT_IMAGE:-dzentra-bot:local} + pull_policy: build + restart: "no" + init: true + user: "10001:10001" + environment: + BOT_TOKEN_FILE: /run/secrets/bot_token + BOT_PARSE_MODE: ${BOT_PARSE_MODE:-HTML} + APP_ENV: ${APP_ENV:-prod} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + TZ: ${TZ:-Europe/Minsk} + DEBUG_ENABLED: ${DEBUG_ENABLED:-false} + DZENTRA_RUNTIME_ENV_FILE: /var/lib/dzentra/runtime.env + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: ${DB_NAME:-dzentra_bot} + DB_USER: ${DB_USER:-dzentra_bot} + DB_PASSWORD_FILE: /run/secrets/db_password + MARKET_DATA_STORAGE_ENABLED: ${MARKET_DATA_STORAGE_ENABLED:-false} + MARKET_DATA_STORAGE_POOL_MIN_SIZE: ${MARKET_DATA_STORAGE_POOL_MIN_SIZE:-1} + MARKET_DATA_STORAGE_POOL_MAX_SIZE: ${MARKET_DATA_STORAGE_POOL_MAX_SIZE:-4} + MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS: ${MARKET_DATA_STORAGE_POOL_TIMEOUT_SECONDS:-10} + EXCHANGE_ENABLED: ${EXCHANGE_ENABLED:-false} + EXCHANGE_NAME: ${EXCHANGE_NAME:-dzengi} + EXCHANGE_BASE_URL: ${EXCHANGE_BASE_URL:-} + EXCHANGE_WS_URL: ${EXCHANGE_WS_URL:-} + EXCHANGE_TIMEOUT_SEC: ${EXCHANGE_TIMEOUT_SEC:-10} + EXCHANGE_TESTNET: ${EXCHANGE_TESTNET:-false} + DEFAULT_SYMBOL: ${DEFAULT_SYMBOL:-ETH/USD_LEVERAGE} + TRADE_STREAM_ENABLED: ${TRADE_STREAM_ENABLED:-false} + TRADE_STREAM_WS_URL: ${TRADE_STREAM_WS_URL:-} + TRADE_STREAM_SYMBOLS: ${TRADE_STREAM_SYMBOLS:-} + TRADE_STREAM_OPEN_TIMEOUT_SECONDS: ${TRADE_STREAM_OPEN_TIMEOUT_SECONDS:-10} + TRADE_STREAM_PROBE_TIMEOUT_SECONDS: ${TRADE_STREAM_PROBE_TIMEOUT_SECONDS:-20} + TRADE_STREAM_CLOSE_TIMEOUT_SECONDS: ${TRADE_STREAM_CLOSE_TIMEOUT_SECONDS:-10} + TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS: ${TRADE_STREAM_HEARTBEAT_TIMEOUT_SECONDS:-30} + TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS: ${TRADE_STREAM_SCHEDULER_INTERVAL_SECONDS:-5} + TRADE_STREAM_RECOVERY_WINDOW_MS: ${TRADE_STREAM_RECOVERY_WINDOW_MS:-3599999} + TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS: ${TRADE_STREAM_SUBSCRIPTION_ACK_TIMEOUT_SECONDS:-10} + TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY: ${TRADE_STREAM_STARTUP_MARKET_BUFFER_CAPACITY:-10000} + secrets: + - bot_token + - db_password + volumes: + - dzentra_runtime_config:/var/lib/dzentra depends_on: postgres: condition: service_healthy + networks: + - database + - egress + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m + stop_grace_period: ${BOT_STOP_GRACE_PERIOD:?BOT_STOP_GRACE_PERIOD must be set} volumes: - dzentra_postgres_data: \ No newline at end of file + dzentra_postgres_data: + dzentra_runtime_config: + +networks: + database: + internal: true + egress: + +secrets: + postgres_admin_password: + file: ${POSTGRES_ADMIN_PASSWORD_SECRET_FILE:?POSTGRES_ADMIN_PASSWORD_SECRET_FILE must be set} + bot_token: + file: ${BOT_TOKEN_SECRET_FILE:?BOT_TOKEN_SECRET_FILE must be set} + db_password: + file: ${DB_PASSWORD_SECRET_FILE:?DB_PASSWORD_SECRET_FILE must be set} diff --git a/infra/docker/Dockerfile b/infra/docker/Dockerfile index 7395b95..805da0f 100644 --- a/infra/docker/Dockerfile +++ b/infra/docker/Dockerfile @@ -1,12 +1,41 @@ -FROM python:3.12-slim +FROM python:3.12.13-slim-bookworm@sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV PIP_NO_CACHE_DIR=1 +ENV PIP_ROOT_USER_ACTION=ignore -WORKDIR /project - -COPY app /project/app -RUN pip install --no-cache-dir -r /project/app/requirements.txt +RUN groupadd --gid 10001 dzentra \ + && useradd --uid 10001 \ + --gid dzentra \ + --no-create-home \ + --home-dir /nonexistent \ + --shell /usr/sbin/nologin \ + dzentra \ + && install -d \ + -m 0700 \ + -o dzentra \ + -g dzentra \ + /var/lib/dzentra \ + && install \ + -m 0600 \ + -o dzentra \ + -g dzentra \ + /dev/null \ + /var/lib/dzentra/runtime.env WORKDIR /project/app + +COPY app/requirements.lock ./requirements.lock +RUN python -m pip install \ + --require-hashes \ + --requirement requirements.lock + +COPY --chown=10001:10001 app/src ./src + +USER 10001:10001 + +STOPSIGNAL SIGINT + CMD ["python", "-m", "src.main"] diff --git a/infra/docker/postgres/Dockerfile b/infra/docker/postgres/Dockerfile new file mode 100644 index 0000000..37ec1fe --- /dev/null +++ b/infra/docker/postgres/Dockerfile @@ -0,0 +1,9 @@ +FROM postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + +COPY --chmod=0555 \ + infra/docker/postgres/init-application-role.sh \ + /docker-entrypoint-initdb.d/20-init-application-role.sh + +COPY --chmod=0555 \ + infra/docker/postgres/healthcheck-application-role.sh \ + /usr/local/bin/dzentra-postgres-healthcheck diff --git a/infra/docker/postgres/healthcheck-application-role.sh b/infra/docker/postgres/healthcheck-application-role.sh new file mode 100644 index 0000000..b4b3047 --- /dev/null +++ b/infra/docker/postgres/healthcheck-application-role.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +set -eu + + +role_status="$( + psql \ + --no-psqlrc \ + --set ON_ERROR_STOP=1 \ + --tuples-only \ + --no-align \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" <<'SQL' +\getenv app_user APP_DB_USER + +SELECT CASE + WHEN :'app_user' <> current_user + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname = :'app_user' + AND rolcanlogin + AND NOT rolsuper + AND NOT rolcreatedb + AND NOT rolcreaterole + AND rolinherit + AND NOT rolreplication + AND NOT rolbypassrls + ) + AND pg_catalog.pg_get_userbyid(( + SELECT datdba + FROM pg_catalog.pg_database + WHERE datname = current_database() + )) = :'app_user' + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_auth_members AS membership + JOIN pg_catalog.pg_roles AS member_role + ON member_role.oid = membership.member + WHERE member_role.rolname = :'app_user' + ) + THEN 1 + ELSE 0 +END; +SQL +)" + +test "$role_status" = "1" diff --git a/infra/docker/postgres/init-application-role.sh b/infra/docker/postgres/init-application-role.sh new file mode 100644 index 0000000..fe88afd --- /dev/null +++ b/infra/docker/postgres/init-application-role.sh @@ -0,0 +1,130 @@ +#!/bin/sh + +set -eu + + +read_secret_file() { + secret_path=$1 + + if [ ! -r "$secret_path" ]; then + echo "Не удалось прочитать обязательный PostgreSQL secret file" >&2 + return 1 + fi + + # Маркер сохраняет переводы строк внутри command substitution. + secret_with_marker="$(cat "$secret_path"; printf '\001')" + secret=${secret_with_marker%?} + carriage_return="$(printf '\r')" + newline=' +' + + case "$secret" in + *"${carriage_return}${newline}") + secret=${secret%"${carriage_return}${newline}"} + ;; + *"${newline}") + secret=${secret%"${newline}"} + ;; + *"${carriage_return}") + secret=${secret%"${carriage_return}"} + ;; + esac + + if [ -z "$(printf '%s' "$secret" | tr -d '[:space:]')" ]; then + echo "PostgreSQL secret file содержит пустое значение" >&2 + return 1 + fi + + printf '%s' "$secret" +} + + +if [ -z "${APP_DB_USER:-}" ]; then + echo "APP_DB_USER должен содержать имя роли приложения" >&2 + exit 1 +fi + +if [ "$APP_DB_USER" = "$POSTGRES_USER" ]; then + echo "APP_DB_USER не должен совпадать с PostgreSQL admin role" >&2 + exit 1 +fi + +app_db_password="$(read_secret_file /run/secrets/db_password)" + +# Официальный entrypoint уже прочитал secret file и применил собственную +# нормализацию конечных переводов строк. Сравниваем именно фактический пароль. +if [ -z "$(printf '%s' "${POSTGRES_PASSWORD:-}" | tr -d '[:space:]')" ]; then + echo "POSTGRES_PASSWORD должен содержать пароль admin role" >&2 + exit 1 +fi + +postgres_admin_password=$POSTGRES_PASSWORD + +if [ "$app_db_password" = "$postgres_admin_password" ]; then + echo "Пароли PostgreSQL admin и application roles должны различаться" >&2 + exit 1 +fi + +export DZENTRA_APP_DB_PASSWORD="$app_db_password" + +psql \ + --no-psqlrc \ + --single-transaction \ + --set ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" <<'SQL' +\getenv app_user APP_DB_USER +\getenv app_password DZENTRA_APP_DB_PASSWORD + +SELECT format( + 'CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB ' + 'NOCREATEROLE INHERIT NOREPLICATION NOBYPASSRLS', + :'app_user', + :'app_password' +) +WHERE NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname = :'app_user' +) +\gexec + +SELECT format( + 'ALTER ROLE %I WITH LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB ' + 'NOCREATEROLE INHERIT NOREPLICATION NOBYPASSRLS', + :'app_user', + :'app_password' +) +\gexec + +SELECT format( + 'ALTER DATABASE %I OWNER TO %I', + current_database(), + :'app_user' +) +\gexec + +SELECT format( + 'REVOKE ALL ON DATABASE %I FROM PUBLIC', + current_database() +) +\gexec + +SELECT format( + 'GRANT CONNECT, TEMPORARY ON DATABASE %I TO %I', + current_database(), + :'app_user' +) +\gexec + +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +SELECT format( + 'GRANT USAGE, CREATE ON SCHEMA public TO %I', + :'app_user' +) +\gexec +SQL + +unset DZENTRA_APP_DB_PASSWORD +unset app_db_password +unset postgres_admin_password diff --git a/pyrightconfig.json b/pyrightconfig.json index 8d20805..27cadcc 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -18,7 +18,8 @@ "app/tests/unit/storage", "app/tests/unit/test_live_trade_stream_support.py", "app/tests/unit/test_postgres_market_data_support.py", - "app/tests/unit/test_trade_stream_runtime_support.py" + "app/tests/unit/test_trade_stream_runtime_support.py", + "scripts/check_documentation_integrity.py" ], "venvPath": "app", "venv": ".venv", @@ -28,7 +29,8 @@ { "root": "app", "extraPaths": [ - "app" + "app", + "." ] } ] diff --git a/scripts/check_documentation_integrity.py b/scripts/check_documentation_integrity.py new file mode 100644 index 0000000..e5c3358 --- /dev/null +++ b/scripts/check_documentation_integrity.py @@ -0,0 +1,4361 @@ +#!/usr/bin/env python3 +"""Локальная проверка обязательных документов и Markdown-ссылок.""" + +from __future__ import annotations + +import argparse +import heapq +import html +import os +import re +import sys +import unicodedata +from bisect import bisect_left, bisect_right +from dataclasses import dataclass, replace +from html.entities import html5 as HTML5_ENTITIES +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +REQUIRED_DOCUMENTS: tuple[str, ...] = ( + "README.md", + "app/README.md", + "app/src/storage/README.md", + "docs/architecture/overview.md", + "docs/architecture/project_structure.md", + "docs/architecture/dzentra_target_architecture.md", + "docs/architecture/trades_feed.md", + "docs/operations/trades_feed_runtime.md", + "docs/roadmap/master-roadmap.md", + "docs/migrations/build_060_30_architecture.md", + "docs/migrations/build_060_20.md", + "docs/migrations/build_060_20_architecture.md", + "docs/migrations/build_060_20_1.md", + "docs/migrations/build_060_20_1_architecture.md", + "docs/migrations/build_060_21.md", + "docs/migrations/build_060_21_architecture.md", + "docs/migrations/build_060_22.md", + "docs/migrations/build_060_22_architecture.md", + "docs/migrations/build_060_23.md", + "docs/migrations/build_060_23_architecture.md", + "docs/migrations/build_060_24.md", + "docs/migrations/build_060_24_architecture.md", + "docs/migrations/build_060_25.md", + "docs/migrations/build_060_25_architecture.md", + "docs/migrations/build_060_26.md", + "docs/migrations/build_060_26_architecture.md", + "docs/migrations/build_060_27.md", + "docs/migrations/build_060_27_architecture.md", + "docs/migrations/build_060_28.md", + "docs/migrations/build_060_28_architecture.md", + "docs/migrations/build_060_29.md", + "docs/migrations/build_060_29_architecture.md", +) + +DEFAULT_MARKDOWN_SOURCES: tuple[Path, ...] = ( + Path("."), + Path("app"), + Path("app/src"), + Path("app/tools"), + Path("docs"), +) + +_DEFAULT_SHALLOW_SOURCES = frozenset({Path("."), Path("app")}) + +_ALLOWED_EXTERNAL_SCHEMES = frozenset({"http", "https", "mailto", "ws", "wss"}) +_FENCE_START = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})(.*)$") +_REFERENCE_DEFINITION = re.compile( + r"^[ \t]{0,3}\[((?:\\[^\r\n]|[^\]\\\r\n])+)\]:[ \t]*(.*)$" +) +_ATX_HEADING = re.compile(r"^[ ]{0,3}#{1,6}(?:[ \t]+|$)") +_SETEXT_HEADING = re.compile(r"^[ ]{0,3}(?:=+|-+)[ \t]*$") +_THEMATIC_BREAK = re.compile( + r"^[ ]{0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$" +) +_LIST_OR_QUOTE = re.compile( + r"^[ ]{0,3}(?:>|[-+*][ \t]+|\d{1,9}[.)][ \t]+)" +) +_HTML_BLOCK_INTERRUPT = re.compile( + r"^[ ]{0,3}(?:" + elif text.startswith("" + elif text.startswith("" + elif ( + text.startswith("" + else: + raw_tag = ( + _RAW_HTML_BLOCK_TAG_START.match(text, start) + if include_raw_text + else None + ) + if raw_tag is not None and raw_tag.group(1).casefold() != "pre": + terminator = f"" + case_insensitive = True + + if terminator is None: + search_offset = start + 1 + continue + haystack = folded if case_insensitive else text + needle = terminator.casefold() if case_insensitive else terminator + terminator_key = (needle, case_insensitive) + closing_start = ( + -1 + if require_terminator and terminator_key in missing_terminators + else haystack.find(needle, start + 1) + ) + if closing_start < 0 and require_terminator: + missing_terminators.add(terminator_key) + search_offset = start + 1 + continue + end = len(text) if closing_start < 0 else closing_start + len(needle) + ranges.append((start, end)) + search_offset = end + return tuple(ranges) + + +def _raw_html_attribute_names( + text: str, + *, + match: re.Match[str], + tag_end: int | None, +) -> frozenset[str] | None: + """Проверяет грамматику HTML-тега CommonMark и возвращает атрибуты.""" + + if tag_end is None: + return None + content_end = tag_end - 1 + cursor = match.end() + + if match.group(1): + spacing_end = _consume_raw_html_spacing(text, cursor, content_end) + return frozenset() if spacing_end == content_end else None + + attributes: set[str] = set() + while True: + separator_start = cursor + spacing_end = _consume_raw_html_spacing(text, cursor, content_end) + if spacing_end is None: + return None + cursor = spacing_end + if cursor == content_end: + return frozenset(attributes) + if text.startswith("/>", cursor) and cursor + 1 == content_end: + return frozenset(attributes) + if cursor == separator_start: + return None + + name_start = cursor + if not ( + "A" <= text[cursor] <= "Z" + or "a" <= text[cursor] <= "z" + or text[cursor] in {"_", ":"} + ): + return None + cursor += 1 + while cursor < content_end and ( + "A" <= text[cursor] <= "Z" + or "a" <= text[cursor] <= "z" + or "0" <= text[cursor] <= "9" + or text[cursor] in {"_", ".", ":", "-"} + ): + cursor += 1 + attributes.add(text[name_start:cursor].casefold()) + + after_name = cursor + spacing_end = _consume_raw_html_spacing(text, cursor, content_end) + if spacing_end is None: + return None + cursor = spacing_end + if cursor >= content_end or text[cursor] != "=": + cursor = after_name + continue + + cursor += 1 + spacing_end = _consume_raw_html_spacing(text, cursor, content_end) + if spacing_end is None: + return None + cursor = spacing_end + if cursor == content_end: + return None + if text[cursor] in {'"', "'"}: + quote = text[cursor] + cursor += 1 + while cursor < content_end and text[cursor] != quote: + cursor += 1 + if cursor == content_end: + return None + cursor += 1 + continue + + value_start = cursor + while ( + cursor < content_end + and not text[cursor].isspace() + and text[cursor] not in {'"', "'", "=", "<", ">", "`"} + ): + cursor += 1 + if cursor == value_start: + return None + + +def _consume_raw_html_spacing( + text: str, + start: int, + end: int, +) -> int | None: + """Снимает пробелы, табуляции и не более одного окончания строки.""" + + cursor = start + line_ending_seen = False + while cursor < end: + character = text[cursor] + if character in {" ", "\t"}: + cursor += 1 + continue + if character not in {"\r", "\n"}: + break + if line_ending_seen: + return None + line_ending_seen = True + if character == "\r" and cursor + 1 < end and text[cursor + 1] == "\n": + cursor += 2 + else: + cursor += 1 + return cursor + + +def _table_row( + context: _LineContext, +) -> _TableRow | None: + """Разбирает строку-кандидат, но ещё не подтверждает таблицу.""" + + semantic_content = context.semantic_content + pipe_positions = _unescaped_pipe_positions(semantic_content) + if not pipe_positions: + return None + + cells: list[str] = [] + cell_start = 0 + for position in pipe_positions: + cells.append(semantic_content[cell_start:position].strip()) + cell_start = position + 1 + cells.append(semantic_content[cell_start:].strip()) + + first_non_space = len(semantic_content) - len(semantic_content.lstrip(" ")) + last_non_space = len(semantic_content.rstrip(" ")) - 1 + if pipe_positions[0] == first_non_space: + cells.pop(0) + if cells and pipe_positions[-1] == last_non_space: + cells.pop() + if not cells: + return None + + return _TableRow( + container_id=context.container_id, + cells=tuple(cells), + pipe_positions=tuple( + context.semantic_offset + position for position in pipe_positions + ), + ) + + +def _is_table_block_interrupt(context: _LineContext) -> bool: + """Показывает, что строка начинает новый блок вместо строки таблицы.""" + + content = context.semantic_content + return ( + _ATX_HEADING.match(content) is not None + or _THEMATIC_BREAK.match(content) is not None + or _REFERENCE_DEFINITION.match(content) is not None + or _HTML_BLOCK_INTERRUPT.match(content) is not None + or _is_complete_type_7_html_block_start(content) + ) + + +def _confirmed_table_rows( + contexts: tuple[_LineContext, ...], +) -> frozenset[int]: + """Подтверждает GFM-таблицы только через строку-разделитель.""" + + candidates = tuple(_table_row(context) for context in contexts) + confirmed: set[int] = set() + index = 0 + while index + 1 < len(contexts): + header = candidates[index] + delimiter = candidates[index + 1] + delimiter_is_valid = ( + header is not None + and delimiter is not None + and header.container_id == delimiter.container_id + and not _is_table_block_interrupt(contexts[index]) + and len(header.cells) == len(delimiter.cells) + and all( + _GFM_DELIMITER_CELL.fullmatch(cell) is not None + for cell in delimiter.cells + ) + ) + if not delimiter_is_valid: + index += 1 + continue + + assert header is not None + confirmed.update({index, index + 1}) + body_index = index + 2 + while body_index < len(contexts): + body = candidates[body_index] + if ( + body is None + or body.container_id != header.container_id + or _is_table_block_interrupt(contexts[body_index]) + ): + break + confirmed.add(body_index) + body_index += 1 + index = body_index + return frozenset(confirmed) + + +def _mask_indented_code( + text: str, + *, + buffer: list[str], + block_ranges: list[tuple[int, int]], + list_continuations: dict[int, _BlockContainer], + protected_line_indices: frozenset[int] = frozenset(), +) -> None: + """Маскирует код с отступом относительно контейнера цитат и списков.""" + + contexts = _build_line_contexts( + text, + list_continuations=list_continuations, + ) + table_rows = _confirmed_table_rows(contexts) + paragraph_open = False + previous_container_key: tuple[int, ...] | None = None + line_index = 0 + + while line_index < len(contexts): + context = contexts[line_index] + if line_index in protected_line_indices: + paragraph_open = False + previous_container_key = context.container_id + line_index += 1 + continue + if not context.semantic_content.strip(): + paragraph_open = False + previous_container_key = None + line_index += 1 + continue + + if ( + previous_container_key is not None + and previous_container_key != context.container_id + ): + paragraph_open = False + + semantic_indent, _ = _indent_columns(context.semantic_content) + if ( + not context.explicit_list_item + and semantic_indent >= 4 + and not paragraph_open + ): + block_end = context.line.end + search_index = line_index + 1 + while search_index < len(contexts): + search_context = contexts[search_index] + if search_context.container_id != context.container_id: + break + if not search_context.semantic_content.strip(): + block_end = search_context.line.end + search_index += 1 + continue + search_indent, _ = _indent_columns( + search_context.semantic_content + ) + if search_indent < 4: + break + block_end = search_context.line.end + search_index += 1 + _mask_range(buffer, context.line.start, block_end) + block_ranges.append((context.line.start, block_end)) + paragraph_open = False + previous_container_key = context.container_id + line_index = search_index + continue + + paragraph_open = _opens_paragraph( + context, + paragraph_was_open=paragraph_open, + ) + if line_index in table_rows: + paragraph_open = False + previous_container_key = context.container_id + line_index += 1 + + +def _mask_code( + text: str, + *, + source: str, +) -> tuple[ + str, + tuple[DocumentationIssue, ...], + tuple[_LineContext, ...], + tuple[tuple[int, int], ...], +]: + """Сначала разбирает блочные, затем внутристрочные конструкции.""" + + buffer = list(text) + issues: list[DocumentationIssue] = [] + block_ranges: list[tuple[int, int]] = [] + list_continuations: dict[int, _BlockContainer] = {} + initial_contexts = _build_line_contexts(text) + initial_reference_definitions = tuple( + definition + for definition in _reference_definitions(initial_contexts) + if definition.is_valid + ) + reference_definition_line_indices = frozenset( + line_index + for definition in initial_reference_definitions + for line_index in range( + definition.context_index, + definition.end_context_index + 1, + ) + ) + html_block_starts = _html_block_start_indices(initial_contexts) + raw_html_tags = _scan_raw_html_tags(text) + raw_html_tag_starts = tuple(tag.start for tag in raw_html_tags) + raw_html_policy_excluded = _raw_html_policy_excluded_ranges(text) + raw_html_policy_excluded_starts = tuple( + start for start, _ in raw_html_policy_excluded + ) + line_starts = _line_start_offsets(text) + + def record_raw_html_policy_issues(start: int, end: int) -> None: + tag_index = bisect_left(raw_html_tag_starts, start) + while tag_index < len(raw_html_tags) and raw_html_tags[tag_index].start < end: + tag = raw_html_tags[tag_index] + excluded_index = ( + bisect_right(raw_html_policy_excluded_starts, tag.start) - 1 + ) + policy_excluded = ( + excluded_index >= 0 + and tag.start < raw_html_policy_excluded[excluded_index][1] + ) + if ( + not policy_excluded + and not tag.is_closing + and tag.name in {"a", "img"} + and not {"href", "src"}.isdisjoint(tag.attribute_names) + ): + issues.append( + DocumentationIssue( + code="UNSUPPORTED_HTML_LINK", + source=source, + line=_line_number(line_starts, tag.start), + target=None, + message=( + "Используйте Markdown-ссылку вместо " + "необработанного HTML href/src." + ), + ) + ) + tag_index += 1 + + line_index = 0 + + while line_index < len(initial_contexts): + context = initial_contexts[line_index] + line = context.line + content = line.content + if not content.strip(): + line_index += 1 + continue + if line_index in reference_definition_line_indices: + line_index += 1 + continue + + container = context.container + block_content = context.semantic_content + + html_content = block_content.lstrip(" ") + html_indent = len(block_content) - len(html_content) + html_terminator: str | None = None + html_case_insensitive = False + html_tag: re.Match[str] | None = None + if html_indent <= 3: + html_tag = _RAW_HTML_BLOCK_TAG_START.match(html_content) + if html_tag is not None: + html_terminator = f"" + html_case_insensitive = True + elif html_content.startswith("" + elif re.match(r"" + elif html_content.startswith("" + + if html_terminator is not None: + block_end = len(text) + next_line_index = len(initial_contexts) + contextual = bool(container.path) + search_index = line_index + while search_index < len(initial_contexts): + search_line = initial_contexts[search_index].line + search_content = ( + block_content + if search_index == line_index + else _content_in_container(search_line.content, container) + ) + if search_content is None and contextual: + block_end = search_line.start + next_line_index = search_index + break + comparable = search_content or "" + terminator = html_terminator + if html_case_insensitive: + comparable = comparable.casefold() + terminator = terminator.casefold() + if terminator in comparable: + block_end = search_line.end + next_line_index = search_index + 1 + break + search_index += 1 + if html_tag is not None and html_tag.group(1).casefold() == "pre": + record_raw_html_policy_issues(line.start, block_end) + _mask_range(buffer, line.start, block_end) + block_ranges.append((line.start, block_end)) + if container.list_indent is not None: + list_continuations[next_line_index] = container + line_index = next_line_index + continue + + comment_content = block_content.lstrip(" ") + comment_indent = len(block_content) - len(comment_content) + if comment_indent <= 3 and comment_content.startswith("" in search_content: + block_end = search_line.end + next_line_index = search_index + 1 + break + search_index += 1 + _mask_range(buffer, line.start, block_end) + block_ranges.append((line.start, block_end)) + if container.list_indent is not None: + list_continuations[next_line_index] = container + line_index = next_line_index + continue + + if line_index in html_block_starts: + block_end = len(text) + next_line_index = len(initial_contexts) + contextual = bool(container.path) + search_index = line_index + 1 + while search_index < len(initial_contexts): + search_line = initial_contexts[search_index].line + search_content = _content_in_container( + search_line.content, + container, + ) + if search_content is None and contextual: + block_end = search_line.start + next_line_index = search_index + break + if search_content is not None and not search_content.strip(): + block_end = search_line.start + next_line_index = search_index + break + search_index += 1 + + record_raw_html_policy_issues(line.start, block_end) + + _mask_range(buffer, line.start, block_end) + block_ranges.append((line.start, block_end)) + if container.list_indent is not None: + list_continuations[next_line_index] = container + line_index = next_line_index + continue + + fence_match = _FENCE_START.match(block_content) + if fence_match is not None: + marker = fence_match.group(1) + marker_character = marker[0] + marker_length = len(marker) + if marker_character != "`" or "`" not in fence_match.group(2): + block_end = len(text) + next_line_index = len(initial_contexts) + found_closer = False + contextual = bool(container.path) + search_index = line_index + 1 + while search_index < len(initial_contexts): + search_line = initial_contexts[search_index].line + closing_content = _content_in_container( + search_line.content, + container, + ) + if closing_content is None and contextual: + block_end = search_line.start + next_line_index = search_index + break + if closing_content is not None and _is_fence_closer( + closing_content, + marker_character=marker_character, + marker_length=marker_length, + ): + block_end = search_line.end + next_line_index = search_index + 1 + found_closer = True + break + search_index += 1 + if not found_closer: + issues.append( + DocumentationIssue( + code="UNCLOSED_CODE_FENCE", + source=source, + line=line.number, + target=None, + message=( + "Блок кода не закрыт в исходном контейнере." + ), + ) + ) + _mask_range(buffer, line.start, block_end) + block_ranges.append((line.start, block_end)) + if container.list_indent is not None: + list_continuations[next_line_index] = container + line_index = next_line_index + continue + + line_index += 1 + + block_masked = "".join(buffer) + _mask_indented_code( + block_masked, + buffer=buffer, + block_ranges=block_ranges, + list_continuations=list_continuations, + protected_line_indices=reference_definition_line_indices, + ) + block_masked = "".join(buffer) + barriers = list(block_ranges) + masked_contexts = _build_line_contexts( + block_masked, + list_continuations=list_continuations, + ) + table_rows = _confirmed_table_rows(masked_contexts) + allowed_reference_indices = _allowed_reference_definition_indices( + masked_contexts + ) + remaining_html_block_starts = _html_block_start_indices(masked_contexts) + previous_container_key: tuple[int, ...] | None = None + paragraph_open = False + for masked_index, context in enumerate(masked_contexts): + line = context.line + semantic_content = context.semantic_content + if not semantic_content.strip(): + barriers.append((line.start, line.end)) + previous_container_key = None + paragraph_open = False + else: + if ( + previous_container_key is not None + and previous_container_key != context.container_id + and line.start > 0 + ): + barriers.append((line.start - 1, line.start)) + paragraph_open = False + previous_container_key = context.container_id + is_table_row = masked_index in table_rows + starts_html_block = masked_index in remaining_html_block_starts + standalone_block = ( + _ATX_HEADING.match(semantic_content) is not None + or _THEMATIC_BREAK.match(semantic_content) is not None + or masked_index in allowed_reference_indices + or starts_html_block + or is_table_row + ) + setext_boundary = ( + _SETEXT_HEADING.match(semantic_content) is not None + ) + if standalone_block and line.start > 0: + barriers.append((line.start - 1, line.start)) + if (standalone_block or setext_boundary) and line.end > line.content_end: + barriers.append((line.content_end, line.end)) + if is_table_row: + table_row = _table_row(context) + assert table_row is not None + barriers.extend( + ( + line.start + position, + line.start + position + 1, + ) + for position in table_row.pipe_positions + ) + paragraph_open = ( + False + if standalone_block or setext_boundary + else _opens_paragraph( + context, + paragraph_was_open=paragraph_open, + ) + ) + + merged_barriers: list[tuple[int, int]] = [] + for start, end in sorted(barriers): + if start == end: + continue + if merged_barriers and start <= merged_barriers[-1][1]: + previous_start, previous_end = merged_barriers[-1] + merged_barriers[-1] = (previous_start, max(previous_end, end)) + else: + merged_barriers.append((start, end)) + + run_lengths_by_start: dict[int, int] = {} + run_positions_by_length: dict[int, list[int]] = {} + index = 0 + while index < len(block_masked): + if block_masked[index] != "`": + index += 1 + continue + run_end = index + 1 + while run_end < len(block_masked) and block_masked[run_end] == "`": + run_end += 1 + run_length = run_end - index + run_lengths_by_start[index] = run_length + run_positions_by_length.setdefault(run_length, []).append(index) + index = run_end + + inline_special_ranges = dict( + _raw_html_policy_excluded_ranges( + block_masked, + include_raw_text=False, + require_terminator=True, + ) + ) + inline_token_ranges = { + **{ + tag.start: tag.end + for tag in _scan_raw_html_tags(block_masked) + }, + **dict(_external_autolink_ranges(block_masked)), + } + definition_ranges: dict[int, int] = {} + for definition in _reference_definitions(masked_contexts): + if definition.is_valid: + definition_ranges[definition.span_start] = definition.span_end + + def mask_inline_tokens(protected_ranges: dict[int, int]) -> str: + output = list(block_masked) + non_code_ranges = {**inline_token_ranges, **protected_ranges} + + def mask_inline_segment(start: int, end: int) -> None: + index = start + while index < end: + token_end = non_code_ranges.get(index) + if token_end is not None: + index = min(token_end, end) + continue + special_end = inline_special_ranges.get(index) + if special_end is not None: + closing_end = min(special_end, end) + _mask_range(output, index, closing_end) + index = closing_end + continue + run_length = run_lengths_by_start.get(index) + if run_length is not None: + if _is_escaped(block_masked, index): + index += run_length + continue + positions = run_positions_by_length[run_length] + next_position = bisect_right(positions, index) + if ( + next_position < len(positions) + and positions[next_position] < end + ): + closing_end = positions[next_position] + run_length + _mask_range(output, index, closing_end) + index = closing_end + continue + index += run_length + continue + index += 1 + + segment_start = 0 + for barrier_start, barrier_end in merged_barriers: + if segment_start < barrier_start: + mask_inline_segment(segment_start, barrier_start) + segment_start = max(segment_start, barrier_end) + if segment_start < len(block_masked): + mask_inline_segment(segment_start, len(block_masked)) + return "".join(output) + + declared_reference_labels = _declared_reference_labels( + masked_contexts, + allowed_indices=allowed_reference_indices, + ) + pre_inline_candidates = _analyse_inline_links( + block_masked, + reference_labels=declared_reference_labels, + reference_label_source=block_masked, + barriers=tuple(merged_barriers), + ) + argument_ranges = { + candidate.opening_parenthesis: candidate.closing_parenthesis + 1 + for candidate in pre_inline_candidates + if not candidate.shadowed_by_link + and not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + } + base_protected_ranges = {**definition_ranges, **argument_ranges} + preliminary_masked = mask_inline_tokens(base_protected_ranges) + preliminary_bracket_closers = _matching_square_brackets( + preliminary_masked, + barriers=tuple(merged_barriers), + ) + preliminary_references, _ = _reference_link_candidates( + preliminary_masked, + bracket_closers=preliminary_bracket_closers, + reference_labels=declared_reference_labels, + label_source=block_masked, + ) + preliminary_references, _ = _apply_reference_overlap_precedence( + preliminary_references, + reference_labels=declared_reference_labels, + label_source=block_masked, + successful_inline_openings=frozenset( + candidate.opening_bracket + for candidate in pre_inline_candidates + if not candidate.shadowed_by_link + and not candidate.is_image + and not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + ), + ) + suffix_ranges = { + candidate.closing_bracket + 1: candidate.suffix_end + 1 + for candidate in preliminary_references + if candidate.suffix_end is not None + and _normalise_reference_label(candidate.reference_label) + in declared_reference_labels + } + final_masked = mask_inline_tokens( + {**base_protected_ranges, **suffix_ranges} + ) + return ( + final_masked, + tuple(issues), + _build_line_contexts( + final_masked, + list_continuations=list_continuations, + ), + tuple(merged_barriers), + ) + + +def _inline_token_ranges(text: str) -> tuple[tuple[int, int], ...]: + """Возвращает участки HTML и автоссылок с приоритетом над скобками.""" + + raw_html_ranges = tuple( + (tag.start, tag.end) for tag in _scan_raw_html_tags(text) + ) + ranges = [ + *raw_html_ranges, + *_raw_html_policy_excluded_ranges( + text, + include_raw_text=False, + require_terminator=True, + ), + *_external_autolink_ranges(text, raw_html_ranges=raw_html_ranges), + ] + merged: list[tuple[int, int]] = [] + for start, end in sorted(ranges): + if merged and start <= merged[-1][1]: + previous_start, previous_end = merged[-1] + merged[-1] = (previous_start, max(previous_end, end)) + else: + merged.append((start, end)) + return tuple(merged) + + +def _external_autolink_ranges( + text: str, + *, + raw_html_ranges: tuple[tuple[int, int], ...] | None = None, +) -> tuple[tuple[int, int], ...]: + """Находит URI-автоссылки вне HTML и экранированной разметки.""" + + html_ranges = ( + tuple((tag.start, tag.end) for tag in _scan_raw_html_tags(text)) + if raw_html_ranges is None + else raw_html_ranges + ) + html_starts = tuple(start for start, _ in html_ranges) + + def inside_raw_html(offset: int) -> bool: + range_index = bisect_right(html_starts, offset) - 1 + return ( + range_index >= 0 + and offset < html_ranges[range_index][1] + ) + + return tuple( + (match.start(), match.end()) + for match in _EXTERNAL_AUTOLINK.finditer(text) + if not _is_escaped(text, match.start()) + and not inside_raw_html(match.start()) + ) + + +def _matching_delimiters( + text: str, + *, + opening: str, + closing: str, + barriers: tuple[tuple[int, int], ...] = (), +) -> dict[int, int]: + """Строит пары разделителей одним проходом с учётом токенов.""" + + ignored_ranges = _inline_token_ranges(text) + range_index = 0 + barrier_index = 0 + stack: list[int] = [] + closers: dict[int, int] = {} + index = 0 + while index < len(text): + while ( + barrier_index < len(barriers) + and barriers[barrier_index][1] <= index + ): + barrier_index += 1 + if ( + barrier_index < len(barriers) + and barriers[barrier_index][0] <= index + ): + stack.clear() + index = barriers[barrier_index][1] + continue + while ( + range_index < len(ignored_ranges) + and ignored_ranges[range_index][1] <= index + ): + range_index += 1 + if ( + range_index < len(ignored_ranges) + and ignored_ranges[range_index][0] <= index + ): + index = ignored_ranges[range_index][1] + continue + + character = text[index] + if character in {opening, closing} and not _is_escaped(text, index): + if character == opening: + stack.append(index) + elif stack: + closers[stack.pop()] = index + index += 1 + return closers + + +def _matching_square_brackets( + text: str, + *, + barriers: tuple[tuple[int, int], ...] = (), +) -> dict[int, int]: + """Строит пары ``[]`` одним проходом с учётом токенов.""" + + return _matching_delimiters( + text, + opening="[", + closing="]", + barriers=barriers, + ) + + +def _matching_link_parentheses( + text: str, + *, + link_openings: frozenset[int], + barriers: tuple[tuple[int, int], ...] = (), +) -> tuple[dict[int, int], frozenset[int]]: + """Индексирует ``()`` ссылок с учётом угловых адресов и заголовков.""" + + ignored_ranges = _inline_token_ranges(text) + range_index = 0 + barrier_index = 0 + stack: list[_ParenthesisFrame] = [] + closers: dict[int, int] = {} + invalid_openings: set[int] = set() + index = 0 + while index < len(text): + while ( + barrier_index < len(barriers) + and barriers[barrier_index][1] <= index + ): + barrier_index += 1 + if ( + barrier_index < len(barriers) + and barriers[barrier_index][0] <= index + ): + stack.clear() + index = barriers[barrier_index][1] + continue + while ( + range_index < len(ignored_ranges) + and ignored_ranges[range_index][1] <= index + ): + range_index += 1 + if ( + range_index < len(ignored_ranges) + and ignored_ranges[range_index][0] <= index + ): + if stack and stack[-1].is_link_argument: + frame = stack[-1] + if frame.quote is None and not frame.angle_destination: + frame.leading_content = False + frame.title_separator_seen = False + index = ignored_ranges[range_index][1] + continue + + character = text[index] + frame = stack[-1] if stack else None + if character == "\\": + if frame is not None and frame.is_link_argument: + frame.leading_content = False + if frame.quote is None and not frame.angle_destination: + frame.title_separator_seen = False + index += 2 + continue + + can_open_nested_candidate = ( + frame is None + or not frame.is_link_argument + or frame.invalid_argument + or (frame.quote is None and not frame.angle_destination) + ) + if ( + character == "(" + and index in link_openings + and can_open_nested_candidate + ): + if frame is not None and frame.is_link_argument: + if frame.title_closed: + frame.invalid_argument = True + frame.leading_content = False + if frame.quote is None and not frame.angle_destination: + frame.title_separator_seen = False + stack.append( + _ParenthesisFrame( + start=index, + is_link_argument=True, + ) + ) + index += 1 + continue + + if frame is not None and frame.is_link_argument: + if frame.quote is not None: + if character == frame.quote: + frame.quote = None + frame.title_closed = True + index += 1 + continue + if frame.angle_destination: + if character == ">": + frame.angle_destination = False + elif character in {"\r", "\n", "<"}: + frame.angle_destination = False + frame.invalid_argument = True + index += 1 + continue + if frame.leading_content: + if character in {" ", "\t", "\r", "\n"}: + frame.title_separator_seen = True + index += 1 + continue + frame.leading_content = False + if frame.title_separator_seen and character in {'"', "'", "("}: + frame.quote = ")" if character == "(" else character + frame.title_separator_seen = False + index += 1 + continue + if character == "<": + frame.angle_destination = True + frame.title_separator_seen = False + index += 1 + continue + frame.title_separator_seen = False + if frame.title_closed and character not in {" ", "\t", "\r", "\n", ")"}: + frame.invalid_argument = True + if frame.title_separator_seen and character in {'"', "'", "("}: + frame.quote = ")" if character == "(" else character + frame.title_separator_seen = False + index += 1 + continue + + if character == "(": + if frame is not None and frame.is_link_argument: + frame.leading_content = False + stack.append( + _ParenthesisFrame( + start=index, + is_link_argument=index in link_openings, + ) + ) + elif character == ")" and stack: + closed = stack.pop() + if closed.is_link_argument: + closers[closed.start] = index + if closed.invalid_argument: + invalid_openings.add(closed.start) + elif frame is not None and frame.is_link_argument: + frame.title_separator_seen = character in {" ", "\t", "\r", "\n"} + index += 1 + return closers, frozenset(invalid_openings) + + +def _inline_character_index(text: str) -> _InlineCharacterIndex: + whitespace: list[int] = [] + non_whitespace: list[int] = [] + line_endings: list[int] = [] + angle_openings: list[int] = [] + angle_closings: list[int] = [] + parentheses: list[int] = [] + double_quotes: list[int] = [] + single_quotes: list[int] = [] + backslash_run = 0 + for index, character in enumerate(text): + escaped = backslash_run % 2 == 1 + if character in {" ", "\t", "\r", "\n"}: + whitespace.append(index) + else: + non_whitespace.append(index) + if character in {"\r", "\n"}: + line_endings.append(index) + if not escaped: + if character == "<": + angle_openings.append(index) + elif character == ">": + angle_closings.append(index) + elif character == "(": + parentheses.append(index) + elif character == '"': + double_quotes.append(index) + elif character == "'": + single_quotes.append(index) + backslash_run = backslash_run + 1 if character == "\\" else 0 + return _InlineCharacterIndex( + whitespace=tuple(whitespace), + non_whitespace=tuple(non_whitespace), + line_endings=tuple(line_endings), + angle_openings=tuple(angle_openings), + angle_closings=tuple(angle_closings), + parentheses=tuple(parentheses), + double_quotes=tuple(double_quotes), + single_quotes=tuple(single_quotes), + ) + + +def _next_indexed_position( + positions: tuple[int, ...], + start: int, + end: int, +) -> int | None: + position_index = bisect_left(positions, start) + if position_index >= len(positions): + return None + position = positions[position_index] + return position if position < end else None + + +def _obvious_link_argument_error( + text: str, + *, + start: int, + end: int, + character_index: _InlineCharacterIndex, + parenthesis_closers: dict[int, int], +) -> str | None: + """Без повторного сканирования отклоняет явно сломанный аргумент.""" + + content_start = _next_indexed_position( + character_index.non_whitespace, + start, + end, + ) + if content_start is None: + return None + + if text[content_start] == "<": + destination_end = _next_indexed_position( + character_index.angle_closings, + content_start + 1, + end, + ) + nested_angle = _next_indexed_position( + character_index.angle_openings, + content_start + 1, + destination_end or end, + ) + embedded_line_ending = _next_indexed_position( + character_index.line_endings, + content_start + 1, + destination_end or end, + ) + if destination_end is None: + return "У углового адреса отсутствует закрывающий символ '>'." + if nested_angle is not None or embedded_line_ending is not None: + return "Угловой адрес содержит недопустимый символ." + destination_end += 1 + if destination_end < end and text[destination_end] not in { + " ", + "\t", + "\r", + "\n", + }: + return "После углового адреса отсутствует разделитель." + else: + separator = _next_indexed_position( + character_index.whitespace, + content_start, + end, + ) + if separator is None: + return None + destination_end = separator + + title_start = _next_indexed_position( + character_index.non_whitespace, + destination_end, + end, + ) + if title_start is None: + return None + delimiter = text[title_start] + if delimiter in {'"', "'"}: + quote_positions = ( + character_index.double_quotes + if delimiter == '"' + else character_index.single_quotes + ) + title_end = _next_indexed_position( + quote_positions, + title_start + 1, + end, + ) + elif delimiter == "(": + title_end = parenthesis_closers.get(title_start) + if title_end is not None and title_end >= end: + title_end = None + if title_end is not None and _next_indexed_position( + character_index.parentheses, + title_start + 1, + title_end, + ) is not None: + return "Название ссылки содержит незакрытую круглую скобку." + else: + return "После адреса указано некорректное название ссылки." + if title_end is None: + return "Название ссылки не закрыто." + trailing_content = _next_indexed_position( + character_index.non_whitespace, + title_end + 1, + end, + ) + if trailing_content is not None: + return "После названия ссылки присутствует лишний текст." + return None + + +def _has_blank_line(value: str) -> bool: + """Ищет два окончания строки CommonMark между пробелами и табуляциями.""" + + index = 0 + while index < len(value): + if value[index] not in {"\r", "\n"}: + index += 1 + continue + index += ( + 2 + if value[index] == "\r" and value[index + 1 : index + 2] == "\n" + else 1 + ) + while index < len(value) and value[index] in {" ", "\t"}: + index += 1 + if index < len(value) and value[index] in {"\r", "\n"}: + return True + return False + + +def _parse_destination_and_title( + content: str, +) -> tuple[str | None, bool, str | None]: + """Строго разбирает адрес и заголовок с учётом пустых строк.""" + + if _has_blank_line(content): + return None, False, "В Markdown-ссылке запрещена пустая строка." + + structural_whitespace = " \t\r\n" + leading_length = len(content) - len(content.lstrip(structural_whitespace)) + stripped = content.strip(structural_whitespace) + if not stripped: + return "", False, None + + if leading_length and _valid_link_title(stripped): + return "", True, None + + if stripped.startswith("<"): + cursor = 1 + closing: int | None = None + while cursor < len(stripped): + character = stripped[cursor] + if ( + character == "\\" + and cursor + 1 < len(stripped) + and stripped[cursor + 1] in _MARKDOWN_ESCAPABLE + ): + cursor += 2 + continue + if character in {"\r", "\n"}: + return None, False, "Angle-target не может содержать line ending." + if character == "<": + return None, False, "Angle-target содержит незакрытый символ '<'." + if character == ">": + closing = cursor + break + cursor += 1 + if closing is None: + return None, False, "У angle-target отсутствует закрывающий символ '>'." + destination = stripped[1:closing] + raw_remainder = stripped[closing + 1 :] + if raw_remainder and raw_remainder[0] not in structural_whitespace: + return None, False, "После angle-target отсутствует пробел перед title." + remainder = raw_remainder.strip(structural_whitespace) + else: + index = 0 + depth = 0 + while index < len(stripped): + character = stripped[index] + if ( + character == "\\" + and index + 1 < len(stripped) + and stripped[index + 1] in _MARKDOWN_ESCAPABLE + ): + index += 2 + continue + if character == "(": + depth += 1 + if depth > 32: + return ( + None, + False, + "В target допускается не более 32 уровней скобок.", + ) + elif character == ")": + if not depth: + return ( + None, + False, + "В target не сбалансированы круглые скобки.", + ) + depth -= 1 + elif character in structural_whitespace: + break + index += 1 + if depth: + return None, False, "В target не сбалансированы круглые скобки." + destination = stripped[:index] + remainder = stripped[index:].strip(structural_whitespace) + + if not remainder: + return destination, False, None + if not _valid_link_title(remainder): + return None, False, "После target указано некорректное название ссылки." + return destination, True, None + + +def _unclosed_link_title_closer(content: str) -> str | None: + """Возвращает ожидаемый delimiter незакрытого title без повторного разбора.""" + + structural_whitespace = " \t\r\n" + stripped = content.strip(structural_whitespace) + if not stripped: + return None + if stripped.startswith("<"): + cursor = 1 + while cursor < len(stripped): + if ( + stripped[cursor] == "\\" + and cursor + 1 < len(stripped) + and stripped[cursor + 1] in _MARKDOWN_ESCAPABLE + ): + cursor += 2 + continue + if stripped[cursor] == ">": + cursor += 1 + break + cursor += 1 + remainder = stripped[cursor:].lstrip(structural_whitespace) + else: + cursor = 0 + depth = 0 + while cursor < len(stripped): + character = stripped[cursor] + if ( + character == "\\" + and cursor + 1 < len(stripped) + and stripped[cursor + 1] in _MARKDOWN_ESCAPABLE + ): + cursor += 2 + continue + if character == "(": + depth += 1 + elif character == ")" and depth: + depth -= 1 + elif character in structural_whitespace and depth == 0: + break + cursor += 1 + remainder = stripped[cursor:].lstrip(structural_whitespace) + if not remainder or remainder[0] not in {'"', "'", "("}: + return None + return ")" if remainder[0] == "(" else remainder[0] + + +def _split_destination_and_title(content: str) -> tuple[str | None, str | None]: + target, _, error = _parse_destination_and_title(content) + return target, error + + +def _valid_link_title(value: str) -> bool: + """Проверяет разделители, экранирование и отсутствие пустой строки.""" + + if len(value) < 2: + return False + if _has_blank_line(value): + return False + if any( + _contains_control_character(character) + and character not in {"\t", "\r", "\n"} + for character in value + ): + return False + + opening = value[0] + closing = opening if opening in {'"', "'"} else ")" + if (opening not in {'"', "'", "("}) or value[-1] != closing: + return False + + backslash_run = 0 + for index, character in enumerate(value[1:], start=1): + if character == "\\": + backslash_run += 1 + continue + escaped = backslash_run % 2 == 1 + backslash_run = 0 + if index == len(value) - 1: + return character == closing and not escaped + if not escaped and ( + character == closing or (opening == "(" and character == "(") + ): + return False + return False + + +def _has_inline_link_title(content: str) -> bool: + """Показывает, что адрес уже содержит заголовок на той же строке.""" + + _, has_title, error = _parse_destination_and_title(content) + return error is None and has_title + + +def _reference_definition_candidate( + line_contexts: tuple[_LineContext, ...], + context_index: int, +) -> _ReferenceDefinition | None: + """Разбирает одно, в том числе многострочное, определение ссылки.""" + + context = line_contexts[context_index] + prefix = re.match(r"^[ \t]{0,3}\[", context.semantic_content) + if prefix is None: + return None + + label_parts: list[str] = [] + label_context_index = context_index + cursor = prefix.end() + definition_content: str | None = None + while label_context_index < len(line_contexts): + label_context = line_contexts[label_context_index] + if label_context.container_id != context.container_id: + return None + content = label_context.semantic_content + while cursor < len(content): + character = content[cursor] + if character == "[" and not _is_escaped(content, cursor): + return None + if character == "]" and not _is_escaped(content, cursor): + if content[cursor + 1 : cursor + 2] != ":": + return None + definition_content = content[cursor + 2 :] + break + label_parts.append(character) + cursor += 1 + if definition_content is not None: + break + next_index = label_context_index + 1 + if next_index >= len(line_contexts): + return None + next_context = line_contexts[next_index] + if ( + next_context.container_id != context.container_id + or not next_context.semantic_content.strip() + ): + return None + label_parts.append("\n") + label_context_index = next_index + cursor = 0 + + assert definition_content is not None + label = "".join(label_parts) + end_context_index = label_context_index + structural_whitespace = " \t\r\n" + + if not definition_content.strip(" \t"): + destination_index = end_context_index + 1 + if destination_index < len(line_contexts): + destination_context = line_contexts[destination_index] + if ( + destination_context.container_id == context.container_id + and destination_context.semantic_content.strip() + ): + definition_content += ( + "\n" + destination_context.semantic_content + ) + end_context_index = destination_index + + target, has_title, error = _parse_destination_and_title( + definition_content + ) + multiline_title_index = end_context_index + 1 + title_closer = ( + _unclosed_link_title_closer(definition_content) + if error is not None + else None + ) + title_parts = [definition_content] + while ( + error is not None + and title_closer is not None + and multiline_title_index < len(line_contexts) + ): + multiline_title_context = line_contexts[multiline_title_index] + if ( + multiline_title_context.container_id != context.container_id + or not multiline_title_context.semantic_content.strip() + ): + break + continuation = multiline_title_context.semantic_content + title_parts.append(continuation) + closing_present = any( + character == title_closer + and not _is_escaped(continuation, position) + for position, character in enumerate(continuation) + ) + if closing_present: + extended_content = "\n".join(title_parts) + extended_target, extended_has_title, extended_error = ( + _parse_destination_and_title(extended_content) + ) + if extended_error is None and extended_target is not None: + definition_content = extended_content + target = extended_target + has_title = extended_has_title + error = None + end_context_index = multiline_title_index + break + multiline_title_index += 1 + if ( + error is None + and target == "" + and not definition_content.strip(structural_whitespace).startswith("<>") + ): + error = "Определение reference-ссылки не содержит target." + + title_index = end_context_index + 1 + if ( + error is None + and target is not None + and not has_title + and title_index < len(line_contexts) + ): + title_context = line_contexts[title_index] + if ( + title_context.container_id == context.container_id + and _standalone_link_title(title_context.semantic_content) + ): + extended_content = ( + definition_content + "\n" + title_context.semantic_content + ) + extended_target, _, extended_error = _parse_destination_and_title( + extended_content + ) + if extended_error is None and extended_target is not None: + target = extended_target + end_context_index = title_index + + if not _valid_reference_label(label): + error = ( + "Метка определения ссылки должна содержать от 1 до 999 " + "значимых символов и неэкранированные квадратные скобки запрещены." + ) + + return _ReferenceDefinition( + context_index=context_index, + end_context_index=end_context_index, + label=label, + target=target, + error=error, + span_start=context.line.start, + span_end=line_contexts[end_context_index].line.content_end, + ) + + +def _reference_definitions( + line_contexts: tuple[_LineContext, ...], +) -> tuple[_ReferenceDefinition, ...]: + """Собирает определения, учитывая абзацы и пути контейнеров.""" + + definitions: list[_ReferenceDefinition] = [] + consumed_until = -1 + paragraph_open = False + previous_container_id: tuple[int, ...] | None = None + for context_index, context in enumerate(line_contexts): + if context_index <= consumed_until: + paragraph_open = False + previous_container_id = context.container_id + continue + content = context.semantic_content + if not content.strip(): + paragraph_open = False + previous_container_id = None + continue + if ( + previous_container_id is not None + and previous_container_id != context.container_id + ): + paragraph_open = False + + definition = ( + None + if paragraph_open + else _reference_definition_candidate(line_contexts, context_index) + ) + if definition is not None: + definitions.append(definition) + if definition.is_valid: + consumed_until = definition.end_context_index + paragraph_open = False + else: + paragraph_open = True + else: + paragraph_open = _opens_paragraph( + context, + paragraph_was_open=paragraph_open, + ) + previous_container_id = context.container_id + return tuple(definitions) + + +def _declared_reference_labels( + line_contexts: tuple[_LineContext, ...], + *, + allowed_indices: frozenset[int], +) -> frozenset[str]: + """Возвращает только синтаксически корректные метки определений.""" + + return frozenset( + _normalise_reference_label(definition.label) + for definition in _reference_definitions(line_contexts) + if definition.context_index in allowed_indices and definition.is_valid + ) + + +def _reference_definition_indices( + line_contexts: tuple[_LineContext, ...], +) -> tuple[frozenset[int], frozenset[int]]: + """Разделяет корректные и ошибочные определения вне абзацев.""" + + definitions = _reference_definitions(line_contexts) + return ( + frozenset( + definition.context_index + for definition in definitions + if definition.is_valid + ), + frozenset( + definition.context_index + for definition in definitions + if not definition.is_valid + ), + ) + + +def _allowed_reference_definition_indices( + line_contexts: tuple[_LineContext, ...], +) -> frozenset[int]: + """Возвращает корректные определения ссылок вне открытых абзацев.""" + + return _reference_definition_indices(line_contexts)[0] + + +def _reference_link_candidates( + text: str, + *, + bracket_closers: dict[int, int], + reference_labels: frozenset[str], + label_source: str | None = None, +) -> tuple[ + tuple[_ReferenceLinkCandidate, ...], + frozenset[int], +]: + """Разбирает полные и сокращённые ссылки и их скобочные суффиксы.""" + + labels = text if label_source is None else label_source + candidates: list[_ReferenceLinkCandidate] = [] + suffix_openings: set[int] = set() + for opening_bracket, closing_bracket in sorted(bracket_closers.items()): + cursor = closing_bracket + 1 + if cursor < len(text) and text[cursor] == "(": + continue + + reference_label: str | None = None + suffix_end: int | None = None + if ( + cursor < len(text) + and text[cursor] == "[" + and not _is_escaped(text, cursor) + ): + literal_suffix = _literal_reference_suffix(labels, cursor) + if literal_suffix is None: + continue + suffix_label, reference_end = literal_suffix + suffix_openings.add(cursor) + reference_label = suffix_label + if not reference_label: + reference_label = labels[ + opening_bracket + 1 : closing_bracket + ] + if not _valid_reference_label(reference_label): + continue + suffix_end = reference_end + elif reference_labels: + if closing_bracket - opening_bracket - 1 > 999: + continue + label_text = labels[opening_bracket + 1 : closing_bracket] + if not _valid_reference_label(label_text): + continue + if _normalise_reference_label(label_text) in reference_labels: + reference_label = label_text + + if reference_label is None: + continue + candidates.append( + _ReferenceLinkCandidate( + opening_bracket=opening_bracket, + closing_bracket=closing_bracket, + reference_label=reference_label, + is_image=( + opening_bracket > 0 + and text[opening_bracket - 1] == "!" + and not _is_escaped(text, opening_bracket - 1) + ), + suffix_end=suffix_end, + ) + ) + return tuple(candidates), frozenset(suffix_openings) + + +def _literal_reference_suffix( + text: str, + opening_bracket: int, +) -> tuple[str, int] | None: + """Разбирает суффикс reference как буквальный, нерендеримый label.""" + + cursor = opening_bracket + 1 + while cursor < len(text) and cursor - opening_bracket - 1 <= 999: + character = text[cursor] + if character == "[" and not _is_escaped(text, cursor): + return None + if character == "]" and not _is_escaped(text, cursor): + label = text[opening_bracket + 1 : cursor] + return ( + (label, cursor) + if not label or _valid_reference_label(label) + else None + ) + cursor += 1 + return None + + +def _apply_reference_overlap_precedence( + candidates: tuple[_ReferenceLinkCandidate, ...], + *, + reference_labels: frozenset[str], + label_source: str, + successful_inline_openings: frozenset[int] = frozenset(), +) -> tuple[ + tuple[_ReferenceLinkCandidate, ...], + frozenset[int], +]: + """Оставляет ссылки, реально создаваемые при перекрывающихся suffix.""" + + successful_candidates = tuple( + candidate + for candidate in candidates + if _normalise_reference_label(candidate.reference_label) + in reference_labels + ) + chosen_successful: list[_ReferenceLinkCandidate] = [] + consumed_suffix_openings: set[int] = set() + max_non_image_opening = -1 + sorted_inline_openings = tuple(sorted(successful_inline_openings)) + for candidate in sorted( + successful_candidates, + key=lambda item: (item.closing_bracket, item.opening_bracket), + ): + if candidate.opening_bracket in consumed_suffix_openings: + continue + nested_reference_link = max_non_image_opening > candidate.opening_bracket + inline_index = bisect_right( + sorted_inline_openings, + candidate.opening_bracket, + ) + nested_inline_link = ( + inline_index < len(sorted_inline_openings) + and sorted_inline_openings[inline_index] < candidate.closing_bracket + ) + if ( + not candidate.is_image + and (nested_reference_link or nested_inline_link) + ): + continue + chosen_successful.append(candidate) + if not candidate.is_image: + max_non_image_opening = max( + max_non_image_opening, + candidate.opening_bracket, + ) + if candidate.suffix_end is not None: + consumed_suffix_openings.add(candidate.closing_bracket + 1) + chosen_successful.sort(key=lambda item: item.opening_bracket) + chosen_openings = frozenset( + candidate.opening_bracket for candidate in chosen_successful + ) + effective: list[_ReferenceLinkCandidate] = [] + for candidate in candidates: + normalised = _normalise_reference_label(candidate.reference_label) + if normalised in reference_labels: + if candidate.opening_bracket in chosen_openings: + effective.append(candidate) + continue + if candidate.suffix_end is None: + effective.append(candidate) + continue + + suffix_opening = candidate.closing_bracket + 1 + if ( + suffix_opening in chosen_openings + or suffix_opening in successful_inline_openings + ): + continue + + effective.append(candidate) + + suffix_openings = frozenset( + candidate.closing_bracket + 1 + for candidate in effective + if candidate.suffix_end is not None + and _normalise_reference_label(candidate.reference_label) + in reference_labels + ) + return tuple(effective), suffix_openings + + +def _analyse_inline_links( + text: str, + *, + reference_labels: frozenset[str] = frozenset(), + reference_label_source: str | None = None, + barriers: tuple[tuple[int, int], ...] = (), +) -> tuple[_InlineLinkCandidate, ...]: + """Собирает внутристрочные ссылки с учётом вложенного приоритета.""" + + bracket_closers = _matching_square_brackets(text, barriers=barriers) + link_openings = frozenset( + closing_bracket + 1 + for closing_bracket in bracket_closers.values() + if closing_bracket + 1 < len(text) + and text[closing_bracket + 1] == "(" + and not _is_escaped(text, closing_bracket + 1) + ) + parenthesis_closers, invalid_parenthesis_openings = ( + _matching_link_parentheses( + text, + link_openings=link_openings, + barriers=barriers, + ) + ) + fallback_parenthesis_closers = _matching_delimiters( + text, + opening="(", + closing=")", + barriers=barriers, + ) + parenthesis_closers = { + opening: fallback_parenthesis_closers[opening] + for opening in link_openings + if opening in fallback_parenthesis_closers + } | parenthesis_closers + character_index = _inline_character_index(text) + + candidates: list[_InlineLinkCandidate] = [] + pending_argument_intervals: list[tuple[int, int]] = [] + furthest_argument_end = -1 + for opening_bracket, closing_bracket in sorted(bracket_closers.items()): + opening_parenthesis = closing_bracket + 1 + if opening_parenthesis not in link_openings: + continue + while ( + pending_argument_intervals + and pending_argument_intervals[0][0] < opening_bracket + ): + _, interval_end = heapq.heappop(pending_argument_intervals) + furthest_argument_end = max(furthest_argument_end, interval_end) + inside_link_argument = opening_bracket < furthest_argument_end + closing_parenthesis = parenthesis_closers.get(opening_parenthesis) + if inside_link_argument: + target = None + error = None + elif closing_parenthesis is None: + target = None + error = "У Markdown-ссылки отсутствует закрывающая скобка." + elif obvious_error := _obvious_link_argument_error( + text, + start=opening_parenthesis + 1, + end=closing_parenthesis, + character_index=character_index, + parenthesis_closers=fallback_parenthesis_closers, + ): + target = None + error = obvious_error + elif opening_parenthesis in invalid_parenthesis_openings: + target = None + error = ( + "Аргумент Markdown-ссылки содержит текст после " + "закрытого заголовка или некорректный угловой адрес." + ) + else: + target, error = _split_destination_and_title( + text[opening_parenthesis + 1 : closing_parenthesis] + ) + if error is None and target is not None: + heapq.heappush( + pending_argument_intervals, + (opening_parenthesis, closing_parenthesis + 1), + ) + candidates.append( + _InlineLinkCandidate( + opening_bracket=opening_bracket, + closing_bracket=closing_bracket, + opening_parenthesis=opening_parenthesis, + closing_parenthesis=closing_parenthesis, + is_image=( + opening_bracket > 0 + and text[opening_bracket - 1] == "!" + and not _is_escaped(text, opening_bracket - 1) + ), + target=target, + error=error, + inside_link_argument=inside_link_argument, + ) + ) + + argument_intervals = tuple( + sorted( + ( + candidate.opening_parenthesis, + candidate.closing_parenthesis + 1, + ) + for candidate in candidates + if not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + ) + ) + argument_starts = tuple(start for start, _ in argument_intervals) + + def inside_argument(offset: int) -> bool: + interval_index = bisect_right(argument_starts, offset) - 1 + return ( + interval_index >= 0 + and offset < argument_intervals[interval_index][1] + ) + + successful_link_starts = tuple( + candidate.opening_bracket + for candidate in candidates + if not candidate.is_image + and not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + ) + reference_candidates, _ = _reference_link_candidates( + text, + bracket_closers=bracket_closers, + reference_labels=reference_labels, + label_source=reference_label_source, + ) + successful_reference_starts = tuple( + candidate.opening_bracket + for candidate in reference_candidates + if not candidate.is_image + and not inside_argument(candidate.opening_bracket) + and _normalise_reference_label(candidate.reference_label) + in reference_labels + ) + successful_nested_starts = tuple( + sorted((*successful_link_starts, *successful_reference_starts)) + ) + autolink_starts = tuple( + start + for start, _ in _external_autolink_ranges(text) + if not inside_argument(start) + ) + + with_precedence: list[_InlineLinkCandidate] = [] + for candidate in candidates: + shadowed = False + if not candidate.is_image: + nested_index = bisect_right( + successful_nested_starts, + candidate.opening_bracket, + ) + shadowed = ( + nested_index < len(successful_nested_starts) + and successful_nested_starts[nested_index] + < candidate.closing_bracket + ) + autolink_index = bisect_right( + autolink_starts, + candidate.opening_bracket, + ) + shadowed = shadowed or ( + autolink_index < len(autolink_starts) + and autolink_starts[autolink_index] + < candidate.closing_bracket + ) + with_precedence.append( + replace(candidate, shadowed_by_link=shadowed) + ) + + return tuple(with_precedence) + + +def _standalone_link_title(content: str) -> bool: + """Проверяет заголовок определения ссылки на следующей строке.""" + + indent, _ = _indent_columns(content) + if indent > 3: + return False + stripped = content.strip(" \t") + return _valid_link_title(stripped) + + +def _reference_continuation_end( + contexts: tuple[_LineContext, ...], + context_index: int, + definition_content: str, +) -> int | None: + """Возвращает конец перенесённого заголовка в том же контейнере.""" + + if ( + _has_inline_link_title(definition_content) + or context_index + 1 >= len(contexts) + ): + return None + context = contexts[context_index] + continuation = contexts[context_index + 1] + if continuation.container_id != context.container_id: + return None + if not _standalone_link_title(continuation.semantic_content): + return None + return continuation.line.content_end + + +def _markdown_link_argument_ranges( + text: str, + *, + line_contexts: tuple[_LineContext, ...], + inline_candidates: tuple[_InlineLinkCandidate, ...], + allowed_reference_indices: frozenset[int], + reference_suffix_ranges: tuple[tuple[int, int], ...] = (), +) -> tuple[tuple[int, int], ...]: + """Защищает нерендеримые части ссылок от разбора как HTML.""" + + ranges: list[tuple[int, int]] = [ + (definition.span_start, definition.span_end) + for definition in _reference_definitions(line_contexts) + if definition.is_valid + and definition.context_index in allowed_reference_indices + ] + + ranges.extend(reference_suffix_ranges) + + ranges.extend( + ( + candidate.opening_parenthesis, + candidate.closing_parenthesis + 1, + ) + for candidate in inline_candidates + if not candidate.shadowed_by_link + and not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + ) + + merged: list[tuple[int, int]] = [] + for start, end in sorted(ranges): + if merged and start <= merged[-1][1]: + previous_start, previous_end = merged[-1] + merged[-1] = (previous_start, max(previous_end, end)) + else: + merged.append((start, end)) + return tuple(merged) + + +def _parse_markdown_links( + text: str, + *, + source: str, +) -> tuple[tuple[_MarkdownLink, ...], tuple[DocumentationIssue, ...]]: + code_masked, code_issues, line_contexts, barriers = _mask_code( + text, + source=source, + ) + ( + allowed_reference_indices, + _, + ) = _reference_definition_indices( + line_contexts + ) + declared_reference_labels = _declared_reference_labels( + line_contexts, + allowed_indices=allowed_reference_indices, + ) + inline_candidates = _analyse_inline_links( + code_masked, + reference_labels=declared_reference_labels, + reference_label_source=text, + barriers=barriers, + ) + pre_html_bracket_closers = _matching_square_brackets( + code_masked, + barriers=barriers, + ) + pre_html_reference_candidates, _ = _reference_link_candidates( + code_masked, + bracket_closers=pre_html_bracket_closers, + reference_labels=declared_reference_labels, + label_source=text, + ) + pre_html_reference_candidates, _ = _apply_reference_overlap_precedence( + pre_html_reference_candidates, + reference_labels=declared_reference_labels, + label_source=text, + successful_inline_openings=frozenset( + candidate.opening_bracket + for candidate in inline_candidates + if not candidate.shadowed_by_link + and not candidate.is_image + and not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + ), + ) + reference_suffix_ranges = tuple( + (candidate.closing_bracket + 1, candidate.suffix_end + 1) + for candidate in pre_html_reference_candidates + if candidate.suffix_end is not None + and _normalise_reference_label(candidate.reference_label) + in declared_reference_labels + ) + protected_ranges = _markdown_link_argument_ranges( + code_masked, + line_contexts=line_contexts, + inline_candidates=inline_candidates, + allowed_reference_indices=allowed_reference_indices, + reference_suffix_ranges=reference_suffix_ranges, + ) + masked, raw_html_issues = _mask_raw_html_tags( + code_masked, + source=source, + protected_ranges=protected_ranges, + ) + issues = [*code_issues, *raw_html_issues] + links: list[_MarkdownLink] = [] + references: dict[str, tuple[str, int]] = {} + + definition_ranges: list[tuple[int, int]] = [] + for definition in _reference_definitions(line_contexts): + line_number = line_contexts[definition.context_index].line.number + label = _normalise_reference_label(definition.label) + if not definition.is_valid: + issues.append( + DocumentationIssue( + code="MALFORMED_REFERENCE", + source=source, + line=line_number, + target=None, + message=( + definition.error or "Некорректное определение ссылки." + ), + ) + ) + else: + target = definition.target + assert target is not None + definition_ranges.append( + (definition.span_start, definition.span_end) + ) + if label in references: + issues.append( + DocumentationIssue( + code="DUPLICATE_REFERENCE", + source=source, + line=line_number, + target=target, + message=( + "Идентификатор reference-ссылки " + "объявлен повторно." + ), + ) + ) + else: + references[label] = (target, line_number) + links.append( + _MarkdownLink( + target=target, + line=line_number, + is_image=False, + ) + ) + + merged_definition_ranges: list[tuple[int, int]] = [] + for start, end in sorted(definition_ranges): + if ( + merged_definition_ranges + and start <= merged_definition_ranges[-1][1] + ): + previous_start, previous_end = merged_definition_ranges[-1] + merged_definition_ranges[-1] = ( + previous_start, + max(previous_end, end), + ) + else: + merged_definition_ranges.append((start, end)) + definition_starts = tuple( + start for start, _ in merged_definition_ranges + ) + + def inside_definition(offset: int) -> bool: + range_index = bisect_right(definition_starts, offset) - 1 + return ( + range_index >= 0 + and offset < merged_definition_ranges[range_index][1] + ) + + protected_starts = tuple(start for start, _ in protected_ranges) + + def inside_protected_range(offset: int) -> bool: + range_index = bisect_right(protected_starts, offset) - 1 + return ( + range_index >= 0 + and offset < protected_ranges[range_index][1] + ) + + bracket_closers = _matching_square_brackets(masked, barriers=barriers) + inline_by_bracket = { + candidate.opening_bracket: candidate + for candidate in inline_candidates + } + raw_reference_candidates, _ = ( + _reference_link_candidates( + masked, + bracket_closers=bracket_closers, + reference_labels=frozenset(references), + label_source=text, + ) + ) + reference_candidates, reference_suffix_openings = ( + _apply_reference_overlap_precedence( + raw_reference_candidates, + reference_labels=frozenset(references), + label_source=text, + successful_inline_openings=frozenset( + candidate.opening_bracket + for candidate in inline_candidates + if not candidate.shadowed_by_link + and not candidate.is_image + and not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + ), + ) + ) + reference_by_bracket = { + candidate.opening_bracket: candidate + for candidate in reference_candidates + } + autolink_ranges = tuple( + (start, end) + for start, end in _external_autolink_ranges(masked) + if not inside_protected_range(start) + ) + successful_nested_starts = tuple( + sorted( + ( + *( + candidate.opening_bracket + for candidate in inline_candidates + if not candidate.is_image + and not candidate.shadowed_by_link + and not candidate.inside_link_argument + and candidate.closing_parenthesis is not None + and candidate.error is None + and candidate.target is not None + ), + *( + candidate.opening_bracket + for candidate in reference_candidates + if not candidate.is_image + and not inside_protected_range(candidate.opening_bracket) + and _normalise_reference_label(candidate.reference_label) + in references + ), + *(start for start, _ in autolink_ranges), + ) + ) + ) + line_starts = _line_start_offsets(masked) + index = 0 + while index < len(masked): + image = ( + masked.startswith("![", index) + and not _is_escaped(masked, index) + and not _is_escaped(masked, index + 1) + ) + bracket = index + 1 if image else index + if masked[bracket : bracket + 1] != "[": + index += 1 + continue + if _is_escaped(masked, bracket): + index += 1 + continue + if ( + bracket > 0 + and masked[bracket - 1] == "!" + and not image + and not _is_escaped(masked, bracket - 1) + ): + index += 1 + continue + if inside_definition(bracket): + index += 1 + continue + if inside_protected_range(bracket): + index += 1 + continue + if bracket in reference_suffix_openings: + index += 1 + continue + + closing_bracket = bracket_closers.get(bracket) + if closing_bracket is None: + index += 1 + continue + cursor = closing_bracket + 1 + line_number = _line_number(line_starts, bracket) + + candidate = inline_by_bracket.get(bracket) + if candidate is not None: + if candidate.shadowed_by_link or candidate.inside_link_argument: + index += 1 + continue + if ( + candidate.closing_parenthesis is None + or candidate.error is not None + or candidate.target is None + ): + issues.append( + DocumentationIssue( + code="MALFORMED_LINK", + source=source, + line=line_number, + target=None, + message=( + candidate.error + or "Некорректная Markdown-ссылка." + ), + ) + ) + else: + links.append( + _MarkdownLink( + target=candidate.target, + line=line_number, + is_image=candidate.is_image, + ) + ) + index += 1 + continue + + reference_candidate = reference_by_bracket.get(bracket) + if reference_candidate is not None: + nested_index = bisect_right(successful_nested_starts, bracket) + shadowed = ( + not reference_candidate.is_image + and nested_index < len(successful_nested_starts) + and successful_nested_starts[nested_index] < closing_bracket + ) + if shadowed: + index += 1 + continue + normalised = _normalise_reference_label( + reference_candidate.reference_label + ) + reference = references.get(normalised) + if reference is None: + issues.append( + DocumentationIssue( + code="UNDEFINED_REFERENCE", + source=source, + line=line_number, + target=reference_candidate.reference_label, + message="Reference-ссылка не имеет определения.", + ) + ) + else: + links.append( + _MarkdownLink( + target=reference[0], + line=line_number, + is_image=reference_candidate.is_image, + ) + ) + index += 1 + continue + + if ( + cursor < len(masked) + and masked[cursor] == "[" + and bracket_closers.get(cursor) is None + ): + issues.append( + DocumentationIssue( + code="MALFORMED_REFERENCE", + source=source, + line=line_number, + target=None, + message="Reference-ссылка не закрыта.", + ) + ) + index += 1 + + for start, end in autolink_ranges: + match = _EXTERNAL_AUTOLINK.fullmatch(masked[start:end]) + assert match is not None + links.append( + _MarkdownLink( + target=match.group(1), + line=_line_number(line_starts, start), + is_image=False, + ) + ) + + return tuple(links), tuple(issues) + + +def _path_cache_key(path: os.PathLike[str]) -> str: + """Сохраняет точное написание и регистр компонентов path.""" + + return os.fspath(path) + + +def _inspect_exact_path( + repository_root: Path, + candidate: Path, + *, + directory_cache: dict[Path, _DirectoryIndex] | None = None, + inspection_cache: dict[str, _PathInspection] | None = None, +) -> _PathInspection: + cache_key = _path_cache_key(candidate) + if inspection_cache is not None: + cached = inspection_cache.get(cache_key) + if cached is not None: + return cached + + def finish(inspection: _PathInspection) -> _PathInspection: + if inspection_cache is not None: + inspection_cache[cache_key] = inspection + return inspection + + try: + relative_candidate = candidate.relative_to(repository_root) + except ValueError: + return finish( + _PathInspection( + path=None, + issue_code="OUTSIDE_REPOSITORY", + message="Ссылка выходит за пределы repository root.", + ) + ) + + current = repository_root + for component in relative_candidate.parts: + try: + current_is_directory = current.is_dir() + except OSError as error: + return finish( + _PathInspection( + path=None, + issue_code="PATH_IO_ERROR", + message=f"Не удалось проверить компонент пути: {error}.", + ) + ) + if not current_is_directory: + return finish( + _PathInspection( + path=None, + issue_code="FILE_AS_DIRECTORY", + message="Компонент URI path не является каталогом.", + ) + ) + if component in {"", "."}: + continue + if component == "..": + if current == repository_root: + return finish( + _PathInspection( + path=None, + issue_code="OUTSIDE_REPOSITORY", + message="Ссылка выходит за пределы repository root.", + ) + ) + current = current.parent + continue + try: + directory_index = ( + directory_cache.get(current) + if directory_cache is not None + else None + ) + if directory_index is None: + children = tuple( + sorted( + current.iterdir(), + key=lambda child: (child.name.casefold(), child.name), + ) + ) + casefold_names: dict[str, list[Path]] = {} + for child in children: + casefold_names.setdefault(child.name.casefold(), []).append(child) + directory_index = _DirectoryIndex( + exact_names={child.name: child for child in children}, + casefold_names={ + name: tuple(paths) + for name, paths in casefold_names.items() + }, + ) + if directory_cache is not None: + directory_cache[current] = directory_index + except OSError as error: + return finish( + _PathInspection( + path=None, + issue_code=( + "FILE_AS_DIRECTORY" + if isinstance(error, NotADirectoryError) + else "PATH_IO_ERROR" + ), + message=( + "Компонент URI path не является каталогом." + if isinstance(error, NotADirectoryError) + else f"Не удалось прочитать компонент пути: {error}." + ), + ) + ) + + exact = directory_index.exact_names.get(component) + if exact is None: + case_variants = directory_index.casefold_names.get( + component.casefold(), + (), + ) + case_variant = case_variants[0] if case_variants else None + if case_variant is not None: + return finish( + _PathInspection( + path=None, + issue_code="PATH_CASE_MISMATCH", + message=( + "Регистр компонента пути не совпадает: " + f"ожидалось '{case_variant.name}'." + ), + ) + ) + return finish( + _PathInspection( + path=None, + issue_code="MISSING_TARGET", + message="Целевой файл или каталог не существует.", + ) + ) + try: + is_indirection = exact.is_symlink() or exact.is_junction() + except OSError as error: + return finish( + _PathInspection( + path=None, + issue_code="PATH_IO_ERROR", + message=f"Не удалось проверить компонент пути: {error}.", + ) + ) + if is_indirection: + return finish( + _PathInspection( + path=None, + issue_code="SYMLINK_TARGET", + message="Ссылки через symlink запрещены.", + ) + ) + current = exact + + return finish(_PathInspection(path=current)) + + +def _decode_commonmark_destination(value: str) -> str: + """Одним проходом применяет escapes и завершённые entity CommonMark.""" + + decoded: list[str] = [] + cursor = 0 + while cursor < len(value): + character = value[cursor] + if ( + character == "\\" + and cursor + 1 < len(value) + and value[cursor + 1] in _MARKDOWN_ESCAPABLE + ): + decoded.append(value[cursor + 1]) + cursor += 2 + continue + if character == "&": + entity_match = _COMMONMARK_ENTITY.match(value, cursor) + if entity_match is not None: + entity = entity_match.group(0) + decoded.append( + html.unescape(entity) + if entity.startswith("&#") + else HTML5_ENTITIES.get(entity[1:], entity) + ) + cursor = entity_match.end() + continue + decoded.append(character) + cursor += 1 + return "".join(decoded) + + +def _decode_local_path( + raw_path: str, + *, + reject_encoded_separators: bool = True, +) -> tuple[str | None, str | None, str | None]: + value = raw_path + if not value: + return None, "EMPTY_TARGET", "Markdown-ссылка не содержит target." + if _contains_control_character(value): + return None, "CONTROL_CHARACTER", "Target содержит управляющий символ." + if _INVALID_PERCENT_ESCAPE.search(value): + return None, "INVALID_PERCENT_ESCAPE", "Target содержит неверное %-кодирование." + encoded_segments = tuple(value.split("/")) + try: + decoded_segments = tuple( + unquote(segment, encoding="utf-8", errors="strict") + for segment in encoded_segments + ) + except UnicodeDecodeError: + return None, "INVALID_PERCENT_ESCAPE", "Target не декодируется как UTF-8." + if reject_encoded_separators and any( + "/" in decoded_segment + or decoded_segment.count("\\") > encoded_segment.count("\\") + for encoded_segment, decoded_segment in zip( + encoded_segments, + decoded_segments, + strict=True, + ) + ): + return ( + None, + "ENCODED_PATH_SEPARATOR", + "Разделитель path не должен быть закодирован внутри сегмента.", + ) + decoded = "/".join(decoded_segments) + if _contains_control_character(decoded): + return None, "CONTROL_CHARACTER", "Target содержит управляющий символ." + return decoded, None, None + + +def _validate_link( + *, + repository_root: Path, + source_path: Path, + source: str, + link: _MarkdownLink, + directory_cache: dict[Path, _DirectoryIndex] | None = None, + inspection_cache: dict[str, _PathInspection] | None = None, +) -> DocumentationIssue | None: + raw_target = _decode_commonmark_destination(link.target) + if not raw_target: + return DocumentationIssue( + code="EMPTY_TARGET", + source=source, + line=link.line, + target=link.target, + message="Markdown-ссылка не содержит target.", + ) + if _contains_control_character(raw_target): + return DocumentationIssue( + code="CONTROL_CHARACTER", + source=source, + line=link.line, + target=link.target, + message="Target содержит управляющий символ.", + ) + if _INVALID_PERCENT_ESCAPE.search(raw_target): + return DocumentationIssue( + code="INVALID_PERCENT_ESCAPE", + source=source, + line=link.line, + target=link.target, + message="Target содержит неверное %-кодирование.", + ) + if link.target.startswith("\\\\") or raw_target.startswith("\\\\"): + return DocumentationIssue( + code="ABSOLUTE_TARGET", + source=source, + line=link.line, + target=link.target, + message="UNC paths запрещены.", + ) + if raw_target.startswith("//"): + return DocumentationIssue( + code="ABSOLUTE_TARGET", + source=source, + line=link.line, + target=link.target, + message="Protocol-relative и UNC paths запрещены.", + ) + if _WINDOWS_DRIVE.match(raw_target) or raw_target.startswith("/"): + return DocumentationIssue( + code="ABSOLUTE_TARGET", + source=source, + line=link.line, + target=link.target, + message="Абсолютные локальные paths запрещены.", + ) + leading_spaces = len(raw_target) - len(raw_target.lstrip(" ")) + split_target = "%20" * leading_spaces + raw_target[leading_spaces:] + try: + split = urlsplit(split_target) + except ValueError: + return DocumentationIssue( + code="MALFORMED_TARGET", + source=source, + line=link.line, + target=link.target, + message="Target содержит синтаксически некорректный URL.", + ) + scheme = split.scheme.casefold() + if scheme: + if scheme in _ALLOWED_EXTERNAL_SCHEMES: + return None + return DocumentationIssue( + code="FORBIDDEN_SCHEME", + source=source, + line=link.line, + target=link.target, + message=f"Схема '{scheme}' не разрешена documentation gate.", + ) + if split.netloc: + return DocumentationIssue( + code="ABSOLUTE_TARGET", + source=source, + line=link.line, + target=link.target, + message="Сетевой target без разрешённой схемы запрещён.", + ) + if "?" in raw_target.split("#", 1)[0]: + return DocumentationIssue( + code="LOCAL_QUERY", + source=source, + line=link.line, + target=link.target, + message="Query string у локальной ссылки не поддерживается.", + ) + if "#" in raw_target and not split.fragment: + return DocumentationIssue( + code="EMPTY_FRAGMENT", + source=source, + line=link.line, + target=link.target, + message="Fragment после '#' не может быть пустым.", + ) + + decoded_path: str | None = "" + if split.path: + decoded_path, error_code, error_message = _decode_local_path(split.path) + if decoded_path is None: + return DocumentationIssue( + code=error_code or "INVALID_TARGET", + source=source, + line=link.line, + target=link.target, + message=error_message or "Некорректный path.", + ) + if split.fragment: + decoded_fragment, error_code, error_message = _decode_local_path( + split.fragment, + reject_encoded_separators=False, + ) + if decoded_fragment is None: + return DocumentationIssue( + code=error_code or "INVALID_TARGET", + source=source, + line=link.line, + target=link.target, + message=error_message or "Некорректный fragment.", + ) + + assert decoded_path is not None + if decoded_path.startswith("//") or decoded_path.startswith("\\\\"): + return DocumentationIssue( + code="ABSOLUTE_TARGET", + source=source, + line=link.line, + target=link.target, + message="Protocol-relative и UNC paths запрещены.", + ) + if _WINDOWS_DRIVE.match(decoded_path) or decoded_path.startswith("/"): + return DocumentationIssue( + code="ABSOLUTE_TARGET", + source=source, + line=link.line, + target=link.target, + message="Абсолютные локальные paths запрещены.", + ) + if "\\" in decoded_path: + return DocumentationIssue( + code="BACKSLASH_TARGET", + source=source, + line=link.line, + target=link.target, + message="В локальной Markdown-ссылке используйте '/'.", + ) + + if not decoded_path and split.fragment: + if link.is_image: + return DocumentationIssue( + code="IMAGE_NOT_FILE", + source=source, + line=link.line, + target=link.target, + message="Изображение должно ссылаться на файл.", + ) + return None + + path_text = decoded_path + if not path_text: + return DocumentationIssue( + code="EMPTY_TARGET", + source=source, + line=link.line, + target=link.target, + message="Markdown-ссылка не содержит локальный path.", + ) + + inspection = _inspect_exact_path( + repository_root, + source_path.parent / Path(path_text), + directory_cache=directory_cache, + inspection_cache=inspection_cache, + ) + if inspection.path is None: + return DocumentationIssue( + code=inspection.issue_code or "INVALID_TARGET", + source=source, + line=link.line, + target=link.target, + message=inspection.message or "Некорректный target.", + ) + target_path = inspection.path + try: + target_is_file = target_path.is_file() + target_is_directory = target_path.is_dir() + except OSError as error: + return DocumentationIssue( + code="PATH_IO_ERROR", + source=source, + line=link.line, + target=link.target, + message=f"Не удалось определить тип target: {error}.", + ) + final_path_segment = decoded_path.rsplit("/", 1)[-1] + if target_is_file and ( + decoded_path.endswith("/") or final_path_segment in {".", ".."} + ): + return DocumentationIssue( + code="FILE_AS_DIRECTORY", + source=source, + line=link.line, + target=link.target, + message="Файл указан как каталог через URI path.", + ) + if link.is_image and not target_is_file: + return DocumentationIssue( + code="IMAGE_NOT_FILE", + source=source, + line=link.line, + target=link.target, + message="Изображение должно ссылаться на обычный файл.", + ) + if split.fragment: + if target_is_directory: + return DocumentationIssue( + code="DIRECTORY_FRAGMENT", + source=source, + line=link.line, + target=link.target, + message="Fragment у ссылки на каталог запрещён.", + ) + if target_path.suffix.casefold() != ".md": + return DocumentationIssue( + code="NON_MARKDOWN_FRAGMENT", + source=source, + line=link.line, + target=link.target, + message="Fragment разрешён только у Markdown-файла.", + ) + if not target_is_file and not target_is_directory: + return DocumentationIssue( + code="UNSUPPORTED_TARGET_TYPE", + source=source, + line=link.line, + target=link.target, + message="Target не является обычным файлом или каталогом.", + ) + return None + + +def _validate_relative_configuration_path(value: Path, *, field_name: str) -> None: + depth = 0 + escapes_root = False + for component in value.parts: + if component in {"", "."}: + continue + if component == "..": + depth -= 1 + if depth < 0: + escapes_root = True + break + else: + depth += 1 + if value.is_absolute() or escapes_root: + raise ValueError(f"{field_name} должен быть repository-relative: {value}") + if "\\" in os.fspath(value): + raise ValueError(f"{field_name} должен использовать '/': {value}") + + +def _discover_markdown_files( + repository_root: Path, + sources: tuple[Path, ...], + *, + shallow_sources: frozenset[Path] = frozenset(), + directory_cache: dict[Path, _DirectoryIndex] | None = None, + inspection_cache: dict[str, _PathInspection] | None = None, +) -> tuple[tuple[Path, ...], tuple[DocumentationIssue, ...]]: + files: set[Path] = set() + issues: list[DocumentationIssue] = [] + + for source in sources: + _validate_relative_configuration_path(source, field_name="markdown_sources") + inspection = _inspect_exact_path( + repository_root, + repository_root / source, + directory_cache=directory_cache, + inspection_cache=inspection_cache, + ) + if inspection.path is None: + issues.append( + DocumentationIssue( + code=inspection.issue_code or "SOURCE_MISSING", + source=source.as_posix(), + line=None, + target=None, + message=inspection.message or "Markdown source не существует.", + ) + ) + continue + resolved_source = inspection.path + source_is_file, source_is_directory, source_kind_error = _path_kind( + resolved_source + ) + if source_kind_error is not None: + issues.append( + DocumentationIssue( + code="SOURCE_IO_ERROR", + source=source.as_posix(), + line=None, + target=None, + message=( + "Не удалось определить тип Markdown source: " + f"{source_kind_error}." + ), + ) + ) + continue + if source_is_file: + if resolved_source.suffix == ".md": + files.add(resolved_source) + continue + if not source_is_directory: + issues.append( + DocumentationIssue( + code="INVALID_SOURCE_TYPE", + source=source.as_posix(), + line=None, + target=None, + message="Markdown source не является файлом или каталогом.", + ) + ) + continue + + def record_source_io_error( + error: OSError, + error_path: Path | None = None, + ) -> None: + error_path = ( + Path(error.filename) + if error.filename + else error_path or resolved_source + ) + try: + error_source = error_path.relative_to(repository_root).as_posix() + except ValueError: + error_source = source.as_posix() + issues.append( + DocumentationIssue( + code="SOURCE_IO_ERROR", + source=error_source, + line=None, + target=None, + message=f"Не удалось прочитать Markdown scope: {error}.", + ) + ) + + candidates: list[Path] = [] + if source in shallow_sources: + try: + candidates.extend(tuple(resolved_source.glob("*.md"))) + except OSError as error: + record_source_io_error(error) + else: + for current_directory, directory_names, file_names in os.walk( + resolved_source, + topdown=True, + followlinks=False, + onerror=record_source_io_error, + ): + current_path = Path(current_directory) + retained_directories: list[str] = [] + for directory_name in sorted( + directory_names, + key=lambda name: (name.casefold(), name), + ): + directory = current_path / directory_name + try: + directory_is_indirection = ( + directory.is_symlink() or directory.is_junction() + ) + except OSError as error: + record_source_io_error(error, directory) + continue + if directory_is_indirection: + issues.append( + DocumentationIssue( + code="SYMLINK_SOURCE", + source=directory.relative_to(repository_root).as_posix(), + line=None, + target=None, + message="Symlink-каталог внутри Markdown scope запрещён.", + ) + ) + else: + retained_directories.append(directory_name) + directory_names[:] = retained_directories + candidates.extend( + current_path / file_name + for file_name in sorted( + file_names, + key=lambda name: (name.casefold(), name), + ) + if Path(file_name).suffix == ".md" + ) + for candidate in candidates: + relative_candidate = candidate.relative_to(repository_root) + candidate_inspection = _inspect_exact_path( + repository_root, + candidate, + directory_cache=directory_cache, + inspection_cache=inspection_cache, + ) + if candidate_inspection.path is None: + issues.append( + DocumentationIssue( + code=candidate_inspection.issue_code or "INVALID_SOURCE", + source=relative_candidate.as_posix(), + line=None, + target=None, + message=( + candidate_inspection.message + or "Markdown source невозможно проверить." + ), + ) + ) + else: + candidate_is_file, _, candidate_kind_error = _path_kind( + candidate_inspection.path + ) + if candidate_kind_error is not None: + record_source_io_error( + candidate_kind_error, + candidate_inspection.path, + ) + elif candidate_is_file: + files.add(candidate_inspection.path) + + return tuple(sorted(files)), tuple(issues) + + +def _validate_required_documents( + repository_root: Path, + required_documents: tuple[str, ...], + *, + directory_cache: dict[Path, _DirectoryIndex] | None = None, + inspection_cache: dict[str, _PathInspection] | None = None, +) -> tuple[DocumentationIssue, ...]: + issues: list[DocumentationIssue] = [] + for document in required_documents: + path = Path(document) + try: + _validate_relative_configuration_path(path, field_name="required_documents") + except ValueError as error: + issues.append( + DocumentationIssue( + code="INVALID_MANIFEST_PATH", + source=document, + line=None, + target=document, + message=str(error), + ) + ) + continue + inspection = _inspect_exact_path( + repository_root, + repository_root / path, + directory_cache=directory_cache, + inspection_cache=inspection_cache, + ) + if inspection.path is None: + issues.append( + DocumentationIssue( + code=( + "MANIFEST_MISSING" + if inspection.issue_code == "MISSING_TARGET" + else inspection.issue_code or "INVALID_MANIFEST_PATH" + ), + source=document, + line=None, + target=document, + message=inspection.message or "Обязательный документ отсутствует.", + ) + ) + else: + manifest_is_file, _, manifest_kind_error = _path_kind( + inspection.path + ) + if manifest_kind_error is not None: + issues.append( + DocumentationIssue( + code="MANIFEST_IO_ERROR", + source=document, + line=None, + target=document, + message=( + "Не удалось определить тип обязательного " + f"документа: {manifest_kind_error}." + ), + ) + ) + elif not manifest_is_file: + issues.append( + DocumentationIssue( + code="MANIFEST_NOT_FILE", + source=document, + line=None, + target=document, + message=( + "Обязательный документ должен быть обычным файлом." + ), + ) + ) + return tuple(issues) + + +def check_documentation_integrity( + repository_root: Path, + *, + required_documents: tuple[str, ...] | None = None, + markdown_sources: tuple[Path, ...] | None = None, +) -> DocumentationIntegrityResult: + """Проверяет манифест и ссылки без сети, Git и записи на диск.""" + + try: + root = repository_root.expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise ValueError(f"Repository root не существует: {repository_root}") from error + _, root_is_directory, root_kind_error = _path_kind(root) + if root_kind_error is not None: + raise ValueError( + f"Не удалось определить тип repository root: {root_kind_error}" + ) from root_kind_error + if not root_is_directory: + raise ValueError(f"Repository root не является каталогом: {root}") + + manifest = REQUIRED_DOCUMENTS if required_documents is None else required_documents + sources = DEFAULT_MARKDOWN_SOURCES if markdown_sources is None else markdown_sources + directory_cache: dict[Path, _DirectoryIndex] = {} + inspection_cache: dict[str, _PathInspection] = {} + issues = list( + _validate_required_documents( + root, + manifest, + directory_cache=directory_cache, + inspection_cache=inspection_cache, + ) + ) + documents, discovery_issues = _discover_markdown_files( + root, + sources, + shallow_sources=( + _DEFAULT_SHALLOW_SOURCES + if markdown_sources is None + else frozenset() + ), + directory_cache=directory_cache, + inspection_cache=inspection_cache, + ) + issues.extend(discovery_issues) + checked_links = 0 + + for document in documents: + source = _relative_source(root, document) + try: + text = document.read_text(encoding="utf-8") + except UnicodeDecodeError: + issues.append( + DocumentationIssue( + code="INVALID_UTF8", + source=source, + line=None, + target=None, + message="Markdown-файл не декодируется как UTF-8.", + ) + ) + continue + except OSError as error: + issues.append( + DocumentationIssue( + code="SOURCE_IO_ERROR", + source=source, + line=None, + target=None, + message=f"Markdown-файл не удалось прочитать: {error}.", + ) + ) + continue + + links, parse_issues = _parse_markdown_links(text, source=source) + issues.extend(parse_issues) + checked_links += len(links) + for link in links: + issue = _validate_link( + repository_root=root, + source_path=document, + source=source, + link=link, + directory_cache=directory_cache, + inspection_cache=inspection_cache, + ) + if issue is not None: + issues.append(issue) + + return DocumentationIntegrityResult( + scanned_documents=len(documents), + checked_links=checked_links, + issues=tuple(sorted(issues, key=_issue_sort_key)), + ) + + +def _build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Проверяет обязательные документы и локальные Markdown-ссылки.", + ) + parser.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Корень репозитория; по умолчанию определяется по пути скрипта.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Запускает CLI и возвращает стабильный код завершения процесса.""" + + arguments = _build_argument_parser().parse_args(argv) + try: + result = check_documentation_integrity(arguments.repository_root) + except ValueError as error: + print(f"documentation-integrity: {error}", file=sys.stderr) + return 2 + + for issue in result.issues: + location = issue.source + if issue.line is not None: + location = f"{location}:{issue.line}" + target = f" target={issue.target!r}" if issue.target is not None else "" + print( + f"{location}: {issue.code}:{target} {issue.message}", + file=sys.stderr, + ) + + summary = ( + "documentation-integrity: " + f"documents={result.scanned_documents} " + f"links={result.checked_links} issues={len(result.issues)}" + ) + print(summary, file=sys.stdout if result.is_clean else sys.stderr) + return 0 if result.is_clean else 1 + + +if __name__ == "__main__": + raise SystemExit(main())