DRRK/README.md

224 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Портфели ДРРК (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`. Ошибка по одной
бумаге не останавливает остальные; ошибка ключа/пути шлюза прерывает обновление.
`FACEVALUE` пишется в историю номинала, если отличается от прошлого значения
MOEX.
- **Ручная правка котировки** (страница «Рыночные данные», D6): цена,
доходность, дюрация на дату с причиной -> `market_data_manual`. Пустое поле —
действует MOEX. В таблице котировок — действующие значения, источник
«ручная»; MOEX-значения остаются в `market_data` и видны рядом с правкой.
- **История номинала** (страница «Бумаги», D11): записи `moex` и `manual` с
датой начала действия. Номинал на дату — последняя запись не позже даты,
на одну дату ручная важнее.
- **Курс 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`),
`security_nominal_history` (unique `security_id, valid_from, source`),
`market_data_manual` (unique `security_id, market_date`).
## Переменные окружения
В Раисе — с префиксом `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`.