DRRK/tests/smoke/test_ui_smoke.py
Raykov-MS c1c73e96f5 Add funding rates, positions, business calendar and xlsx export
- funding_rate table and "Фондирование" page: rate from date, history, rate on date (D27)
- positions by issue: BUY - SELL per portfolio, "Остатки" page (D29)
- quotes are loaded only for securities with an open position (D10)
- business calendar from xmlcalendar.ru: yearly file upload, report date
  and update job use previous working day (D30)
- xlsx download for positions, quotes and funding rate history
- manual operation type as radio buttons
- QUIK instruments with purchase marks ("МТС 2Р-18_1") match the issue,
  file name kept in operation.instrument (D17)
- migrations d3a6f8b1c254, e7b2c9d4a813, f1c4a7e9b352
- project.md: decisions and questions after customer meetings
2026-10-29 11:20:15 +03:00

630 lines
23 KiB
Python
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.

"""UI без браузера: streamlit.testing.v1.AppTest гоняет main.py и страницы
в этом же процессе. БД — временный SQLite (фикстура session_local),
MOEX/ЦБ — фейки на фикстурах; сеть не нужна."""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from streamlit.testing.v1 import AppTest
from app.integrations.cbr import parse_dynamic
from app.services import fx_rates, market_data, securities
from tests.unit.fakes import FakeCbr
ROOT = Path(__file__).resolve().parents[2]
USER = "analyst@rshb.ru"
TIMEOUT = 60
def _logged_in(at: AppTest) -> AppTest:
"""Профиль для mock-провайдера (LOCATION=local), как если бы его ввели в форме."""
at.session_state["local_user_email"] = USER
at.session_state["local_username"] = "analyst"
at.session_state["local_full_name"] = "Аналитик ДРРК"
return at
def _securities_page() -> None:
from app.ui.pages import securities as page
page.render()
def _market_page() -> None:
from app.ui.pages import market_data as page
page.render()
@pytest.fixture(autouse=True)
def _local_mode(monkeypatch):
monkeypatch.setenv("LOCATION", "local")
monkeypatch.delenv("OPENBAO__SETTINGS__LOCATION", raising=False)
@pytest.fixture
def fake_moex_from_settings(monkeypatch, fixture_moex):
"""Сервисы создают клиента через MoexClient.from_settings() — подменяем."""
class _Factory:
@staticmethod
def from_settings():
return fixture_moex
monkeypatch.setattr(securities, "MoexClient", _Factory)
monkeypatch.setattr(market_data, "MoexClient", _Factory)
return fixture_moex
def test_main_without_profile_shows_local_login_form(session_local) -> None:
at = AppTest.from_file(str(ROOT / "main.py"), default_timeout=TIMEOUT).run()
assert not at.exception
assert [w.label for w in at.text_input] == ["Email", "Username", "Full name"]
def test_main_renders_default_page_for_logged_in_user(session_local) -> None:
at = _logged_in(AppTest.from_file(str(ROOT / "main.py"), default_timeout=TIMEOUT))
at.run()
assert not at.exception
assert [h.value for h in at.header] == ["Бумаги"]
assert at.session_state["user_profile"]["email"] == USER
assert any("Бумаг пока нет" in i.value for i in at.info)
def test_securities_page_lookup_preview_and_save(
session_local, fake_moex_from_settings
) -> None:
at = _logged_in(AppTest.from_function(_securities_page, default_timeout=TIMEOUT))
at.session_state["user_profile"] = {"email": USER}
at.run()
at.text_input[0].input("ru000a106169")
at.button[0].click().run() # «Найти на Мосбирже» (submit формы)
assert not at.exception
assert not at.error, [e.value for e in at.error]
assert any("РЭО-02" in m.value for m in at.markdown)
save = next(b for b in at.button if b.label == "Сохранить")
save.click().run()
assert not at.exception
assert not at.error, [e.value for e in at.error]
table = at.dataframe[0].value
assert list(table["ISIN"]) == ["RU000A106169"]
assert list(table["Наименование"]) == ["РЭО-02"]
assert list(table["Валюта"]) == ["RUB"]
def test_securities_page_unknown_isin_shows_error(
session_local, fake_moex_from_settings
) -> None:
at = AppTest.from_function(_securities_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.run()
at.text_input[0].input("RU000A999999")
at.button[0].click().run()
assert not at.exception
assert any("не найдена на Мосбирже" in e.value for e in at.error)
def test_market_page_update_buttons_and_tables(
session_local, fake_moex_from_settings, add_trade, fixtures_dir: Path, monkeypatch
) -> None:
from app.db.base import run_db
run_db(securities.add_by_isin("RU000A106169", USER, moex=fake_moex_from_settings))
cbr = FakeCbr(parse_dynamic((fixtures_dir / "cbr_dynamic_R01375.xml").read_bytes()))
class _CbrFactory:
@staticmethod
def from_settings():
return cbr
monkeypatch.setattr(fx_rates, "CbrClient", _CbrFactory)
at = AppTest.from_function(_market_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.run()
at.date_input[0].set_value(date(2026, 10, 8)).run()
assert not at.exception
assert any("котировок в базе нет" in i.value for i in at.info)
next(b for b in at.button if b.label == "Обновить котировки").click().run()
assert not at.exception
assert any("Нет бумаг с открытой позицией" in i.value for i in at.info)
add_trade("RU000A106169", "BUY", date(2026, 10, 1), "100")
next(b for b in at.button if b.label == "Обновить котировки").click().run()
assert not at.exception
assert any("Обновлено: 1" in s.value for s in at.success)
quotes = at.dataframe[0].value
assert list(quotes["Чистая цена, %"]) == ["101.42"]
assert list(quotes["Источник цены"]) == ["WAPRICE"]
next(b for b in at.button if b.label == "Обновить курс CNY").click().run()
assert not at.exception
assert any("Сохранено курсов CNY: 6" in s.value for s in at.success)
rates = at.dataframe[1].value
assert list(rates["За 1 CNY"])[0] == "12.7516"
def _with_reo_quote(moex, add_trade) -> None: # noqa: ANN001
from app.db.base import run_db
run_db(securities.add_by_isin("RU000A106169", USER, moex=moex))
add_trade("RU000A106169", "BUY", date(2026, 1, 1), "100")
run_db(market_data.update(date(2026, 10, 8), "system", moex=moex))
def test_securities_page_nominal_history_and_manual_value(
session_local, fake_moex_from_settings, add_trade
) -> None:
_with_reo_quote(fake_moex_from_settings, add_trade)
at = AppTest.from_function(_securities_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.run()
history = at.dataframe[1].value
assert list(history["Номинал"]) == ["645.19"]
assert list(history["Источник"]) == ["MOEX"]
save = next(b for b in at.button if b.label == "Сохранить номинал")
at.text_input[1].input("640,5") # [0] — ISIN в форме добавления
save.click().run()
assert [e.value for e in at.error] == ["Укажите причину правки."]
at.date_input[0].set_value(date(2026, 10, 9))
at.text_input[1].input("640,5")
at.text_input[2].input("частичное погашение")
next(b for b in at.button if b.label == "Сохранить номинал").click().run()
assert not at.exception
assert not at.error, [e.value for e in at.error]
history = at.dataframe[1].value
assert list(history["Номинал"]) == ["640.5", "645.19"]
assert list(history["Источник"]) == ["ручной", "MOEX"]
assert list(history["Причина"])[0] == "частичное погашение"
def test_market_page_manual_price(
session_local, fake_moex_from_settings, add_trade
) -> None:
_with_reo_quote(fake_moex_from_settings, add_trade)
at = AppTest.from_function(_market_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.run()
at.date_input[0].set_value(date(2026, 10, 8)).run()
assert any("Ручных правок за эту дату нет" in c.value for c in at.caption)
assert at.text_input[0].placeholder == "MOEX: 101.42"
at.text_input[0].input("100,1")
at.text_input[3].input("цена по RFQ")
next(b for b in at.button if b.label == "Сохранить правку").click().run()
assert not at.exception
assert not at.error, [e.value for e in at.error]
quotes = at.dataframe[0].value
assert list(quotes["Чистая цена, %"]) == ["100.1"]
assert list(quotes["Источник цены"]) == ["ручная"]
assert list(quotes["Источник доходности"]) == ["YIELDATWAP"]
manual = at.dataframe[1].value
assert list(manual["Цена, MOEX"]) == ["101.42"]
assert list(manual["Причина"]) == ["цена по RFQ"]
assert list(manual["Кто"]) == [USER]
assert at.text_input[0].value == "100.1" # форма показывает действующую правку
# --- загрузка сделок QUIK ---------------------------------------------------
# AppTest не умеет загружать файлы в file_uploader, поэтому кладём результат
# разбора в session_state так же, как это делает on_change загрузчика.
def _quik_page() -> None:
from app.ui.pages import quik_import as page
page.render()
def _quik_app(upload) -> AppTest: # noqa: ANN001
at = AppTest.from_function(_quik_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
if upload is not None:
at.session_state["quik_upload"] = upload
return at.run()
def _fixture_upload(fixtures_dir: Path, comments: list[str] | None = None):
from dataclasses import replace
from app.imports.quik import parse_quik_xlsx
from app.ui.pages.quik_import import QuikUpload
result = parse_quik_xlsx(
(fixtures_dir / "quik_sample_300326_anon.xlsx").read_bytes()
)
if comments is not None:
result.trades = [
replace(t, comment=c) for t, c in zip(result.trades, comments, strict=True)
]
return QuikUpload("сделки_300326.xlsx", result)
def _add_fixture_security(session_local) -> None:
from app.db.base import run_db
from app.db.models import Security
async def _add() -> None:
async with session_local() as session:
session.add(
Security(
isin="RU000A10EVH2",
secid="RU000A10EVH2",
name="ЕврХол3P06",
currency_id=1,
created_by=USER,
updated_by=USER,
)
)
await session.commit()
run_db(_add())
def test_quik_page_without_file(session_local) -> None:
at = _quik_app(None)
assert not at.exception
assert [h.value for h in at.header] == ["Загрузка сделок QUIK"]
assert not at.info and not at.button
def test_quik_page_format_error_is_shown(session_local) -> None:
from app.ui.pages.quik_import import QuikUpload
at = _quik_app(QuikUpload("old.xls", None, "Не удалось открыть файл как .xlsx."))
assert not at.exception
assert [e.value for e in at.error] == ["Не удалось открыть файл как .xlsx."]
def test_quik_page_preview_of_real_fixture(session_local, fixtures_dir: Path) -> None:
_add_fixture_security(session_local)
at = _quik_app(_fixture_upload(fixtures_dir))
assert not at.exception
assert not at.error, [e.value for e in at.error]
assert at.selectbox[0].value == "SALES" # по комментарию /pp
assert [m.value for m in at.metric] == ["6", "0", "0", "6"]
rows = at.dataframe[0].value
assert list(rows["Строка"]) == [2, 3, 4, 5, 6, 7]
assert set(rows["ISIN"]) == {"RU000A10EVH2"}
assert list(rows["Комиссия ТС"])[0] == "8217.6"
totals = at.dataframe[1].value
assert list(totals["Операция"]) == ["BUY", "SELL"]
assert list(totals["Кол-во"]) == ["1098000", "1080000"]
assert any("Файл проверен: 6 строк в SALES" in s.value for s in at.success)
assert at.button[0].label == "Загрузить в SALES: 6 стр."
assert not at.button[0].disabled
def test_quik_page_saves_operations(session_local, fixtures_dir: Path) -> None:
from sqlalchemy import func, select
from app.db.base import run_db
from app.db.models import ImportBatch, Operation
_add_fixture_security(session_local)
at = _quik_app(_fixture_upload(fixtures_dir))
at.button[0].click().run()
assert not at.exception
assert not at.error, [e.value for e in at.error]
# После st.rerun() AppTest оставляет в дереве элементы прерванного прогона,
# поэтому проверяем первый элемент страницы, а не весь список.
assert at.success[0].value == (
"«сделки_300326.xlsx»: загружено в SALES 6 стр., исключено 0."
)
assert "quik_upload" not in at.session_state # файл сброшен
assert at.session_state["quik_file_nonce"] == 1 # поле загрузки — новое
async def _counts() -> tuple[int, int]:
async with session_local() as session:
return (
await session.scalar(select(func.count(ImportBatch.id))),
await session.scalar(select(func.count(Operation.id))),
)
assert run_db(_counts()) == (1, 6)
def test_quik_page_explains_why_save_is_disabled(
session_local, fixtures_dir: Path
) -> None:
at = _quik_app(_fixture_upload(fixtures_dir)) # бумаги нет в справочнике
assert not at.exception
assert at.button[0].disabled
assert any(
"Загрузить нельзя: нет в справочнике бумаг: ЕврХол3P06" in c.value
for c in at.caption
)
def test_quik_page_other_portfolio_and_unknown_rows(
session_local, fixtures_dir: Path
) -> None:
_add_fixture_security(session_local)
upload = _fixture_upload(fixtures_dir, ["/pp", "/pp", "/uw", "/trde", "", "/pp"])
at = _quik_app(upload)
assert not at.exception
assert any("Строки без портфеля: 2" in s.value for s in at.subheader)
assert any("Ждут решения строк без портфеля: 2" in i.value for i in at.info)
assert not at.success
at.radio[0].set_value("Исключить все").run()
assert not at.exception
assert any("Пойдёт в SALES: 3" in s.value for s in at.subheader)
assert any("исключено 2" in s.value for s in at.success)
at.radio[0].set_value("Отнести все к SALES").run()
assert any("Пойдёт в SALES: 5" in s.value for s in at.subheader)
at.selectbox[0].set_value("UW").run()
assert not at.exception
assert any("Пойдёт в UW: 1" in s.value for s in at.subheader)
def test_quik_page_rejects_same_file_same_portfolio(
session_local, fixtures_dir: Path
) -> None:
from datetime import date
from app.db.base import run_db
from app.repositories.import_batches import ImportBatchRepository
_add_fixture_security(session_local)
upload = _fixture_upload(fixtures_dir)
async def _loaded() -> None:
async with session_local() as session:
await ImportBatchRepository(session).add(
source="QUIK",
portfolio_id=2, # SALES
file_name="сделки_300326.xlsx",
content_sha256=upload.result.content_hash,
date_from=date(2026, 3, 30),
date_to=date(2026, 3, 30),
rows_total=6,
loaded_rows=[1, 2, 3, 4, 5, 6],
rows_excluded=0,
user=USER,
)
await session.commit()
run_db(_loaded())
at = _quik_app(upload)
assert not at.exception
assert any("Этот файл уже загружен в SALES" in e.value for e in at.error)
assert [m.value for m in at.metric] == ["6", "0", "6", "0"]
assert not at.success
# --- ручные операции -------------------------------------------------------
def _manual_page() -> None:
from app.ui.pages import manual_operations as page
page.render()
def _manual_app() -> AppTest:
at = AppTest.from_function(_manual_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
return at.run()
def test_manual_page_without_securities(session_local) -> None:
at = _manual_app()
assert not at.exception
assert any("Бумаг пока нет" in i.value for i in at.info)
assert any("Ручных операций пока нет" in c.value for c in at.caption)
def test_manual_page_adds_coupon(session_local) -> None:
_add_fixture_security(session_local)
at = _manual_app()
assert at.radio[0].value == "CPN"
assert at.button[0].label == "Сохранить: купон"
at.text_input[0].input("125 000,50")
at.text_area[0].input("купон №3")
at.button[0].click().run()
assert not at.exception
assert not at.error, [e.value for e in at.error]
assert any("Сохранено: купон, RU000A10EVH2" in s.value for s in at.success)
rows = at.dataframe[0].value
assert list(rows["Операция"]) == ["Купон"]
assert list(rows["Сумма"]) == ["125000.5"]
assert list(rows["Валюта"]) == ["RUB"]
assert list(rows["Кто ввёл"]) == [USER]
def test_manual_page_validation_and_uwfee_portfolio(session_local) -> None:
_add_fixture_security(session_local)
at = _manual_app()
at.button[0].click().run() # купон без суммы
assert [e.value for e in at.error] == ["Укажите сумму."]
at.radio[0].set_value("UWFEE").run()
assert not at.exception
assert at.selectbox[0].options == ["UW — Андеррайтинг"]
assert at.button[0].label == "Сохранить: комиссия за андеррайтинг"
assert len(at.text_input) == 1 # без количества
# --- фондирование ----------------------------------------------------------
def _funding_page() -> None:
from app.ui.pages import funding as page
page.render()
def test_funding_page_saves_rate_and_shows_it_on_date(session_local) -> None:
at = AppTest.from_function(_funding_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.run()
assert not at.exception
assert at.selectbox[0].options == ["UW — Андеррайтинг", "SALES — Продажи"]
assert any("Ставок пока нет" in c.value for c in at.caption)
on_date = at.dataframe[0].value
assert list(on_date["Валюта"]) == ["CNY", "RUB", "USD"]
assert on_date["Ставка, % год."].isna().all()
save = next(b for b in at.button if b.label == "Сохранить ставку")
save.click().run()
assert [e.value for e in at.error] == ["Укажите ставку."]
at.selectbox[1].set_value("RUB")
at.date_input[1].set_value(date(2026, 7, 28))
at.text_input[0].input("18,5")
next(b for b in at.button if b.label == "Сохранить ставку").click().run()
assert not at.exception
assert not at.error, [e.value for e in at.error]
history = at.dataframe[1].value
assert list(history["Ставка, % год."]) == ["18.5"]
assert list(history["Валюта"]) == ["RUB"]
assert list(history["Кто"]) == [USER]
at.date_input[0].set_value(date(2026, 7, 27)).run()
assert at.dataframe[0].value["Ставка, % год."].isna().all()
at.date_input[0].set_value(date(2026, 10, 8)).run()
assert list(at.dataframe[0].value["Ставка, % год."]) == [None, "18.5", None]
at.selectbox[0].set_value("SALES").run()
assert any("Ставок пока нет" in c.value for c in at.caption)
# --- остатки ---------------------------------------------------------------
def _positions_page() -> None:
from app.ui.pages import positions as page
page.render()
def test_positions_page(session_local, fixtures_dir: Path) -> None:
from app.db.base import run_db
from app.imports.quik import parse_quik_xlsx
from app.services import quik_import
at = AppTest.from_function(_positions_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.run()
assert not at.exception
assert any("Открытых позиций на эту дату нет" in c.value for c in at.caption)
_add_fixture_security(session_local)
parsed = parse_quik_xlsx(
(fixtures_dir / "quik_sample_300326_anon.xlsx").read_bytes()
)
plan = quik_import.build_plan(
parsed.trades, run_db(quik_import.load_context(parsed)), "SALES", {}
)
run_db(
quik_import.save_import(
parsed,
file_name="сделки_300326.xlsx",
portfolio_code="SALES",
decisions={},
expected_rows=[row.trade.ordinal for row in plan.to_load],
user=USER,
)
)
bought = sum(t.quantity for t in parsed.trades if t.operation == "BUY")
sold = sum(t.quantity for t in parsed.trades if t.operation == "SELL")
at.date_input[0].set_value(date(2026, 3, 30)).run()
assert not at.exception
rows = at.dataframe[0].value
assert list(rows["Портфель"]) == ["SALES"]
assert list(rows["ISIN"]) == ["RU000A10EVH2"]
assert list(rows["Остаток, шт"]) == [format((bought - sold).normalize(), "f")]
assert not at.warning
assert len(at.get("download_button")) == 1
at.selectbox[0].set_value("UW").run()
assert any("Открытых позиций на эту дату нет" in c.value for c in at.caption)
# --- производственный календарь ---------------------------------------------
def _calendar_page() -> None:
from app.ui.pages import business_calendar as page
page.render()
def test_calendar_page_saves_year(session_local, fixtures_dir: Path) -> None:
from app.imports.xmlcalendar import parse_calendar_json
at = AppTest.from_function(_calendar_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.run()
assert not at.exception
assert any("Календарь пока не загружен" in c.value for c in at.caption)
assert any("не загружен" in w.value for w in at.warning)
at.session_state["calendar_upload"] = parse_calendar_json(
(fixtures_dir / "xmlcalendar_ru_2025.json").read_bytes()
)
at.run()
assert not at.exception
text = " ".join(m.value for m in at.markdown)
assert "Год 2025: нерабочих дней 118, сокращённых 4." in text
assert "Рабочие выходные: 01.11." in text
next(b for b in at.button if b.label == "Сохранить календарь на 2025").click()
at.run()
assert not at.exception
assert not at.error, [e.value for e in at.error]
years = at.dataframe[0].value
assert list(years["Год"]) == [2025]
assert list(years["Рабочие выходные"]) == [1]
assert list(years["Кто загрузил"]) == [USER]
def test_calendar_page_shows_format_error(session_local) -> None:
at = AppTest.from_function(_calendar_page, default_timeout=TIMEOUT)
at.session_state["user_profile"] = {"email": USER}
at.session_state["calendar_upload"] = "Файл не читается как JSON."
at.run()
assert [e.value for e in at.error] == ["Файл не читается как JSON."]