from __future__ import annotations import logging from datetime import date import streamlit as st from app.db.base import run_db from app.services import securities as securities_service from app.services.manual_operations import ManualOperationError, parse_decimal from app.services.securities import ( ManualNominalInput, SecurityPreview, SecurityRow, SecurityServiceError, ) from app.ui.common import as_table, call_db, current_user logger = logging.getLogger(__name__) _PREVIEW_KEY = "security_preview" _COLUMNS = { "isin": "ISIN", "secid": "SECID", "name": "Наименование", "currency": "Валюта", "initial_face_value": "Номинал (перв.)", "placement_date": "Начало торгов", "maturity_date": "Погашение", "coupon_rate": "Купон, %", "coupon_frequency": "Купонов в год", "is_active": "Обновлять", } _NOMINAL_COLUMNS = { "valid_from": "Действует с", "face_value": "Номинал", "source": "Источник", "reason": "Причина", "updated_by": "Кто", "updated_at": "Когда", } def render() -> None: st.header("Бумаги") rows = call_db( securities_service.list_securities(), "Не удалось загрузить список бумаг" ) if rows is not None: if rows: st.dataframe(as_table(rows, _COLUMNS), hide_index=True) else: st.info("Бумаг пока нет — добавьте первую по ISIN.") st.subheader("Добавить по ISIN") _render_lookup_form() preview = st.session_state.get(_PREVIEW_KEY) if isinstance(preview, SecurityPreview): _render_preview(preview) if rows: st.subheader("История номинала") _render_nominals(rows) def _render_nominals(rows: list[SecurityRow]) -> None: names = {row.isin: row.name for row in rows} isin = st.selectbox( "Бумага", list(names), format_func=lambda i: f"{names[i]} ({i})", key="nominal_isin", ) st.caption( "MOEX — из истории торгов, пишется при загрузке котировок, когда номинал " "меняется. Ручной на ту же дату важнее и действует до следующего " "изменения на MOEX." ) history = call_db( securities_service.nominal_history(isin), "Не удалось загрузить историю номинала", ) if history: st.dataframe(as_table(history, _NOMINAL_COLUMNS), hide_index=True) elif history is not None: st.caption("Записей пока нет: номинал появится после загрузки котировок.") with st.form("manual_nominal", clear_on_submit=True): valid_from = st.date_input( "Действует с", value=date.today(), max_value=date.today(), format="DD.MM.YYYY", ) face_value_text = st.text_input("Номинал", placeholder="например 645,19") reason = st.text_input("Причина") submitted = st.form_submit_button("Сохранить номинал") if submitted: _save_nominal(isin, valid_from, face_value_text, reason) def _save_nominal( isin: str, valid_from: date, face_value_text: str, reason: str ) -> None: try: value = ManualNominalInput( isin=isin, valid_from=valid_from, face_value=parse_decimal(face_value_text, "Номинал"), reason=reason, ) run_db(securities_service.set_manual_nominal(value, current_user())) except (ManualOperationError, SecurityServiceError) as exc: st.error(str(exc)) return except Exception as exc: # noqa: BLE001 logger.exception("Ошибка сохранения номинала %s", isin) st.error(f"Не удалось сохранить номинал: {exc}") return st.toast(f"Номинал {isin} с {valid_from:%d.%m.%Y} сохранён.") st.rerun() def _render_lookup_form() -> None: with st.form("security_lookup"): isin = st.text_input("ISIN", placeholder="RU000A106169", max_chars=12) submitted = st.form_submit_button("Найти на Мосбирже") if not submitted: return st.session_state.pop(_PREVIEW_KEY, None) try: with st.spinner("Запрос к MOEX…"): preview = run_db(securities_service.preview_by_isin(isin, current_user())) except SecurityServiceError as exc: st.error(str(exc)) return except Exception as exc: # noqa: BLE001 logger.exception("Ошибка предпросмотра бумаги %s", isin) st.error(f"Не удалось получить данные бумаги: {exc}") return st.session_state[_PREVIEW_KEY] = preview def _render_preview(preview: SecurityPreview) -> None: info = preview.info with st.container(border=True): st.markdown(f"**{info.short_name or info.secid}** — данные MOEX") st.dataframe( as_table( [ { "isin": preview.isin, "secid": info.secid, "currency": f"{preview.currency_code} ({info.face_unit})", "initial_face_value": info.initial_face_value, "face_value": info.face_value, "issue_date": info.issue_date, "mat_date": info.mat_date, "coupon_percent": info.coupon_percent, "coupon_frequency": info.coupon_frequency, } ], { "isin": "ISIN", "secid": "SECID", "currency": "Валюта", "initial_face_value": "Номинал (перв.)", "face_value": "Номинал (тек.)", "issue_date": "Начало торгов", "mat_date": "Погашение", "coupon_percent": "Купон, %", "coupon_frequency": "Купонов в год", }, ), hide_index=True, ) save_col, cancel_col = st.columns(2) if save_col.button("Сохранить", type="primary", use_container_width=True): _save(preview) if cancel_col.button("Отмена", use_container_width=True): st.session_state.pop(_PREVIEW_KEY, None) st.rerun() def _save(preview: SecurityPreview) -> None: try: result = run_db(securities_service.save_preview(preview, current_user())) except SecurityServiceError as exc: st.error(str(exc)) return except Exception as exc: # noqa: BLE001 logger.exception("Ошибка сохранения бумаги %s", preview.isin) st.error(f"Не удалось сохранить бумагу: {exc}") return st.session_state.pop(_PREVIEW_KEY, None) st.toast(f"Бумага {result.isin} ({result.name}) добавлена.") st.rerun()