# Портфели ДРРК (BIQ-38115) Streamlit-сервис, который заменит два Excel-файла с VBA-макросами ДРРК для портфелей облигаций **UW** (андеррайтинг) и **SALES** (продажи). Сейчас есть: справочник бумаг, котировки MOEX, курс CNY ЦБ, загрузка выгрузки сделок QUIK в портфель, ручной ввод купонов, амортизации, комиссии за андеррайтинг и корректировок. Расчёт финрезультата, фондирование и отчёты — следующие итерации (`TODO(BIQ-38115)` в коде). Контекст проекта, принятые решения (D…) и открытые вопросы (Q…) — в `docs/project.md`; как устроены текущие Excel-файлы — `docs/legacy_excel.md`. Соглашения (конфиг, БД, Alembic, auth, линтеры, стиль тестов) повторяют `SFM-parser` (`../sfm`). ## Архитектура ```text main.py # load_dotenv -> set_page_config -> AuthService().run() -> st.navigation run_local.py # локальный запуск: LOCATION=local, миграции, streamlit run app/ core/config.py # Settings (pydantic-settings, OPENBAO__SETTINGS__*) core/logging.py auth/ # Keycloak (raisa-streamlit-oauth) / mock при LOCATION=local — из ../sfm db/base.py # async engine (NullPool), SessionLocal, Base, run_db() db/types.py # BigInteger PK (Integer в SQLite), JSON/JSONB, Numeric, аудит db/models/ # ORM-модели, реэкспорт в __init__.py integrations/gateway.py # requests-клиент к Gravitee: ключ, таймауты, повторы, журнал integrations/moex.py # MoexClient + разбор ответов ISS integrations/cbr.py # CbrClient + разбор XML ЦБ imports/quik.py # разбор выгрузки сделок QUIK (.xlsx) — без БД repositories/ # async-доступ к таблицам, upsert (ON CONFLICT) services/ # securities, market_data, fx_rates, quik_import, # manual_operations, dates ui/pages/ # «Бумаги», «Рыночные данные», «Загрузка сделок», # «Ручные операции» jobs/update_market.py # точка входа для расписания alembic/ # async env.py со схемой, ревизии tests/ fixtures/ # реальные ответы MOEX/ЦБ, обезличенная выгрузка QUIK unit/ # без сети и без Postgres smoke/ # UI через AppTest; реальный шлюз — по флагу ``` ### Потоки - **Добавить бумагу** (страница «Бумаги»): ISIN -> `GET /iss/securities.json?q={ISIN}` (строка с точно таким ISIN -> SECID) -> `GET /iss/securities/{SECID}.json` (блок `description`) -> предпросмотр -> «Сохранить». В `security` хранятся оба кода: ISIN — для пользователя, SECID — для запросов к ISS (у ОФЗ они разные, например `SU26238RMFS4`). Валюта `SUR` -> `RUB`. - **Котировки на дату**: по каждой бумаге с `is_active` — `GET /iss/history/engines/stock/markets/bonds/securities/{SECID}.json?from=D&till=D` (одна дата, не `limit=1`), строка по приоритету бордов `MOEX_PREFERRED_BOARDS`, цена `WAPRICE` -> `MARKETPRICE3`, доходность `YIELDATWAP` -> `YIELDCLOSE` (какое поле взяли — в `*_source`), upsert в `market_data`. Ошибка по одной бумаге не останавливает остальные; ошибка ключа/пути шлюза прерывает обновление. - **Курс CNY**: с дня после последнего сохранённого (или с 01.01) по дату — `GET /scripts/XML_dynamic.asp?...&VAL_NM_RQ=R01375`, upsert в `exchange_rate`. - **Загрузка сделок QUIK** (страница «Загрузка сделок», D17, D19): .xlsx -> поиск таблицы по заголовкам (формат «Лист2»; короткий «Лист1» отклоняется с подсказкой) -> разбор строк (даты/время Excel, `Decimal`, `Купля/Продажа` -> `BUY/SELL`, `SUR` -> `RUB`) с ошибками «строка N, колонка: что не так» -> предпросмотр: портфель (подсказка по `/uw`, `/pp`), строки без портфеля (пусто, `/trde`, неизвестный комментарий) — отнести или исключить явно, бумаги по краткому имени MOEX, итоги по операциям и валютам, сводка «загружено в UW / SALES / никуда». Повтор — по (хэш нормализованных строк, портфель) в `import_batch`; правил дедупликации по строкам нет. «Загрузить» активна, только когда всё решено и каждая бумага найдена однозначно; иначе под кнопкой — почему нельзя. Сохранение — `import_batch` и операции (каждая строка — отдельная операция, лотов нет, D20) одной транзакцией; план перед записью строится заново по БД, и если набор строк изменился с момента предпросмотра — отказ без записи. Отмены загрузки пока нет (D26). - **Ручные операции** (страница «Ручные операции», D21, D22): купон (CPN), амортизация (NOM), комиссия за андеррайтинг (UWFEE, только UW), корректировка (ADJ, со знаком и обязательным комментарием). Суммы вводятся текстом и хранятся в `Decimal`; валюта по умолчанию — валюта бумаги. Купля и продажа вручную не вводятся. - **Дата по умолчанию** — предыдущий рабочий день (пока только выходные, `TODO(BIQ-38115): производственный календарь`, Q15). - Внешние API вызываются **только по кнопке** (или джобой); экраны читают БД. Каждый запрос к шлюзу пишется в `external_request_log` (без тела ответа и заголовков, кроме `X-Gravitee-Transaction-Id` — по нему команда шлюза находит запрос в логах Gravitee, D18) — для разборов «откуда цифра» и отказов шлюза. ### БД и event loop Streamlit синхронный, async-код БД вызывается через `asyncio.run()` — новый event loop на каждый вызов. Пул asyncpg из прошлого loop'а ломал бы следующий вызов (в SFM-parser лечили `engine.dispose()` в каждой функции). Здесь движок с `poolclass=NullPool`, а UI и джоба ходят в БД только через `run_db(coro)` (`app/db/base.py`). Таблицы (все в `DATABASE_SCHEMA`, аудит `created_*`/`updated_*`, деньги и цены — `Numeric`): `currency`, `portfolio`, `operation_type` (справочники с сидом), `security`, `market_data` (unique `security_id, market_date`), `exchange_rate` (unique `currency_id, rate_date`; `rate` — курс за `nominal` единиц, как публикует ЦБ), `operation` (сделки, частичный unique `portfolio_id, trade_number`), `external_request_log`, `import_batch` (загрузки файлов сделок, unique `content_sha256, portfolio_id`). ## Переменные окружения В Раисе — с префиксом `OPENBAO__SETTINGS__` (приоритетнее), локально — без префикса из `.env`. Полный список с комментариями — `.env.example`. | Переменная | По умолчанию | Назначение | |---|---|---| | `LOCATION` | `prod` | `local` — mock-авторизация; `test`/`prod` — Keycloak | | `DATABASE_URL` | `sqlite+aiosqlite:///./drrk_local.db` | в Раисе `postgresql+asyncpg://...` | | `DATABASE_SCHEMA` | пусто | схема Postgres; для SQLite — пусто | | `GATEWAY_URL` | `https://gateway.rshbdev.ru` | прод: `https://gateway.rshb.pro` | | `MOEX_PATH` | `/external/moex-iss` | проксирует на `https://iss.moex.com` | | `CBR_PATH` | `/external/cbr` | проксирует на `https://cbr.ru` | | `MOEX_API_KEY` | — | ключ шлюза для MOEX (`X-Rshb-Api-Key`) | | `CBR_API_KEY` | — | ключ шлюза для ЦБ — **другой**, ключ ЦБ на MOEX даёт 401 | | `HTTP_TIMEOUT_SECONDS` | `15` | таймаут одного запроса | | `HTTP_RETRIES` | `3` | повторов после первой попытки (до 4 запросов) | | `MOEX_PREFERRED_BOARDS` | `["TQCB","TQOB"]` | приоритет режимов торгов (JSON) | Ключи и заголовки запросов не логируются никогда. Ошибки шлюза: 401/403 — ключ (без повторов); 404 с телом `No context-path matches the request URI` — неверный `*_PATH` (без повторов, это не «бумага не найдена»); 429/502/503/504 и сетевые сбои — повторы с паузой. ## Локальный запуск ```bash python -m venv .venv .\.venv\Scripts\activate pip install -r requirements.txt # raisa-streamlit-oauth есть только во внутреннем зеркале; # при LOCATION=local он не нужен copy .env.example .env # LOCATION=local, SQLite python run_local.py # миграции + streamlit run main.py ``` При `LOCATION=local` вместо Keycloak — форма ввода email/username. Для кнопок обновления нужны ключи шлюза в `.env` и доступ к `GATEWAY_URL`. ## Миграции ```bash alembic upgrade head # применить alembic downgrade -1 # откатить последнюю alembic revision -m "описание" # новая ревизия (стиль — alembic/versions/) alembic upgrade head --sql # SQL без применения (для DBA) ``` В Раисе миграции применяются из Jupyter: задать `DATABASE_URL` (и `DATABASE_SCHEMA`) целевой БД в окружении и выполнить `alembic upgrade head`. Схема (`DATABASE_SCHEMA`) создаётся миграцией (`CREATE SCHEMA IF NOT EXISTS`), `alembic_version` лежит в ней же. Сиды: валюты (RUB, CNY, USD), портфели (UW, SALES), типы операций (BUY, SELL, CPN, NOM, VAL; UWFEE и ADJ — в `7c2e5a9d1f30`). ## Джоба ```bash python -m app.jobs.update_market # предыдущий рабочий день python -m app.jobs.update_market --date 2026-10-08 ``` Котировки + курс CNY от пользователя `system`. Код выхода: `0` — без ошибок (отсутствие торгов — не ошибка), `1` — ошибки или сбой, `2` — неверные аргументы. Как запускать по расписанию в Раисе — решается отдельно. ## Тесты и качество ```bash pytest # unit + smoke UI; без сети и Postgres ruff check . black --check . isort --check-only --profile black . ``` Smoke против реального шлюза (тестовый контур, нужны ключи): ```bash DRRK_SMOKE_GATEWAY=1 pytest tests/smoke/test_gateway_smoke.py -v ``` Проверяет цепочку ISIN -> SECID -> history для РЭО-02 и для ОФЗ, курс CNY, 404 на неверном path и 401 при ключе ЦБ на MOEX. ### Исходные файлы заказчика (не в git) Файлы ДРРК содержат банковскую тайну и ПДн, поэтому в репозитории их нет (`.gitignore`). Взять у аналитика проекта и положить локально: | Файл | Куда | Зачем | |---|---|---| | Оригинал выгрузки QUIK за 30.03.2026 | `tests/fixtures/quik_sample_300326.xlsx` | `test_anonymized_fixture_matches_original` сверяет с ним обезличенную копию; без файла тест пропускается | | «Отчет по портфелю ДСМ_2026.xlsm», «Отчет по портфелю ПП_2026.xlsm», сводный отчёт | `docs/excel/` | сверка с Excel при разработке расчётов (`docs/legacy_excel.md`) | Тесты работают на обезличенной копии `tests/fixtures/quik_sample_300326_anon.xlsx`: счёт `S00+00000000`, контрагенты «Контрагент 1/2/3», номера заявок синтетические (сделки одной заявки остаются вместе), всё остальное — как в оригинале. ## Pre-commit ```bash pre-commit install pre-commit run --all-files ``` Хуки: `isort --profile black`, `black`, `ruff`.