Портфели ДРРК (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).
Архитектура
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 и сетевые сбои — повторы с паузой.
Локальный запуск
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.
Миграции
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).
Джоба
python -m app.jobs.update_market # предыдущий рабочий день
python -m app.jobs.update_market --date 2026-10-08
Котировки + курс CNY от пользователя system. Код выхода: 0 — без ошибок
(отсутствие торгов — не ошибка), 1 — ошибки или сбой, 2 — неверные
аргументы. Как запускать по расписанию в Раисе — решается отдельно.
Тесты и качество
pytest # unit + smoke UI; без сети и Postgres
ruff check .
black --check .
isort --check-only --profile black .
Smoke против реального шлюза (тестовый контур, нужны ключи):
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
pre-commit install
pre-commit run --all-files
Хуки: isort --profile black, black, ruff.