224 lines
15 KiB
Markdown
224 lines
15 KiB
Markdown
# Портфели ДРРК (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`.
|