DRRK/README.md

14 KiB
Raw Blame History

Портфели ДРРК (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. Ошибка по одной бумаге не останавливает остальные; ошибка ключа/пути шлюза прерывает обновление.
  • Курс 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 и сетевые сбои — повторы с паузой.

Локальный запуск

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.