from __future__ import annotations import logging from datetime import date from decimal import Decimal import streamlit as st from app.db.base import run_db from app.services import fx_rates as fx_rates_service from app.services import market_data as market_data_service from app.services import securities as securities_service from app.services.dates import previous_business_day from app.services.fx_rates import FxRatesUpdateResult from app.services.manual_operations import ManualOperationError, parse_decimal from app.services.market_data import ( ManualQuoteInput, ManualQuoteRow, MarketDataError, MarketDataRow, MarketDataUpdateResult, ) from app.ui.common import as_table, call_db, current_user, decimal_text logger = logging.getLogger(__name__) _QUOTE_COLUMNS = { "isin": "ISIN", "name": "Наименование", "board": "Режим", "clean_price": "Чистая цена, %", "clean_price_source": "Источник цены", "yield_value": "Доходность, %", "yield_source": "Источник доходности", "duration_days": "Дюрация, дн.", "duration_source": "Источник дюрации", "accrued_interest": "НКД", "face_value": "Номинал (тек.)", "coupon_rate": "Купон, %", "num_trades": "Сделок", "volume": "Объём, шт.", } _MANUAL_COLUMNS = { "isin": "ISIN", "name": "Наименование", "clean_price": "Цена, ручн.", "moex_clean_price": "Цена, MOEX", "yield_value": "Доходность, ручн.", "moex_yield_value": "Доходность, MOEX", "duration_days": "Дюрация, ручн.", "moex_duration_days": "Дюрация, MOEX", "reason": "Причина", "updated_by": "Кто", "updated_at": "Когда", } _RATE_COLUMNS = { "rate_date": "Дата", "nominal": "Номинал", "rate": "Курс ЦБ", "rate_per_unit": "За 1 CNY", } def render() -> None: st.header("Рыночные данные") on_date: date = st.date_input( "Дата", value=previous_business_day(), max_value=date.today(), format="DD.MM.YYYY", ) # Внешние API — только по кнопке; ниже экран читает только БД. quotes_col, fx_col = st.columns(2) if quotes_col.button("Обновить котировки", use_container_width=True): with st.spinner("Запрос котировок MOEX…"): result = call_db( market_data_service.update(on_date, current_user()), "Не удалось обновить котировки", ) if result is not None: _show_market_result(result) if fx_col.button("Обновить курс CNY", use_container_width=True): with st.spinner("Запрос курса ЦБ…"): fx_result = call_db( fx_rates_service.update(on_date, current_user()), "Не удалось обновить курс CNY", ) if fx_result is not None: _show_fx_result(fx_result) st.subheader(f"Котировки на {on_date:%d.%m.%Y}") quotes = call_db( market_data_service.list_for_date(on_date), "Не удалось загрузить котировки" ) if quotes: st.dataframe(as_table(quotes, _QUOTE_COLUMNS), hide_index=True) elif quotes is not None: st.info("За эту дату котировок в базе нет.") _render_manual(on_date, quotes or []) st.subheader("Курс CNY (последние)") rates = call_db(fx_rates_service.list_recent(10), "Не удалось загрузить курсы") if rates: st.dataframe(as_table(rates, _RATE_COLUMNS), hide_index=True) elif rates is not None: st.info("Курсов CNY в базе пока нет.") def _render_manual(on_date: date, quotes: list[MarketDataRow]) -> None: st.subheader(f"Ручные правки на {on_date:%d.%m.%Y}") manual = call_db( market_data_service.list_manual_for_date(on_date), "Не удалось загрузить ручные правки", ) if manual is None: return if manual: st.dataframe(as_table(manual, _MANUAL_COLUMNS), hide_index=True) else: st.caption("Ручных правок за эту дату нет.") securities = call_db( securities_service.list_securities(), "Не удалось загрузить список бумаг" ) if not securities: return names = {s.isin: s.name for s in securities} with st.expander("Ручная правка цены, доходности, дюрации"): isin = st.selectbox( "Бумага", list(names), format_func=lambda i: f"{names[i]} ({i})", key="manual_quote_isin", ) current = next((row for row in manual if row.isin == isin), None) quote = next((row for row in quotes if row.isin == isin), None) _render_manual_form(on_date, isin, current, quote) def _render_manual_form( on_date: date, isin: str, current: ManualQuoteRow | None, quote: MarketDataRow | None, ) -> None: if current is not None: moex = ( current.moex_clean_price, current.moex_yield_value, current.moex_duration_days, ) elif quote is not None: moex = (quote.clean_price, quote.yield_value, quote.duration_days) else: moex = (None, None, None) st.caption( "Пустое поле — действует значение MOEX. Загрузка котировок ручные " "значения не перезаписывает." ) # Ключи с бумагой и датой: при смене бумаги или даты поля заполняются заново. suffix = f"{isin}_{on_date:%Y%m%d}" with st.form(f"manual_quote_{suffix}"): price_text = st.text_input( "Чистая цена, %", value=decimal_text(current.clean_price if current else None), placeholder=_moex_hint(moex[0]), key=f"manual_price_{suffix}", ) yield_text = st.text_input( "Доходность, %", value=decimal_text(current.yield_value if current else None), placeholder=_moex_hint(moex[1]), key=f"manual_yield_{suffix}", ) duration_text = st.text_input( "Дюрация, дн.", value=decimal_text(current.duration_days if current else None), placeholder=_moex_hint(moex[2]), key=f"manual_duration_{suffix}", ) reason = st.text_input( "Причина", value=current.reason if current else "", key=f"manual_reason_{suffix}", ) submitted = st.form_submit_button("Сохранить правку", type="primary") if submitted: _save_manual(on_date, isin, price_text, yield_text, duration_text, reason) def _moex_hint(value: Decimal | None) -> str: return f"MOEX: {decimal_text(value)}" if value is not None else "MOEX: нет" def _save_manual( on_date: date, isin: str, price_text: str, yield_text: str, duration_text: str, reason: str, ) -> None: try: value = ManualQuoteInput( isin=isin, market_date=on_date, clean_price=parse_decimal(price_text, "Чистая цена"), yield_value=parse_decimal(yield_text, "Доходность"), duration_days=parse_decimal(duration_text, "Дюрация"), reason=reason, ) run_db(market_data_service.set_manual_quote(value, current_user())) except (ManualOperationError, MarketDataError) 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} на {on_date:%d.%m.%Y} сохранена.") st.rerun() def _show_market_result(result: MarketDataUpdateResult) -> None: summary = ( f"Обновлено: {result.updated}, без торгов: {result.no_trades}, " f"ошибок: {result.errors}." ) if result.errors: st.error(summary) elif result.no_trades: st.warning(summary) else: st.success(summary) with st.expander("Подробности по бумагам", expanded=bool(result.errors)): for message in result.messages: st.write(f"- {message}") def _show_fx_result(result: FxRatesUpdateResult) -> None: show = st.error if not result.ok else st.success for message in result.messages: show(message)