202 lines
7.3 KiB
Python
202 lines
7.3 KiB
Python
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()
|