DRRK/app/ui/pages/market_data.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

267 lines
9.3 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.

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.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,
default_report_date,
download_xlsx,
)
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=default_report_date(),
max_value=date.today(),
format="DD.MM.YYYY",
)
# Внешние API — только по кнопке; ниже экран читает только БД.
quotes_col, fx_col = st.columns(2)
if quotes_col.button(
"Обновить котировки",
help="Только бумаги с открытой позицией на дату (остаток не 0).",
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)
download_xlsx(
quotes, _QUOTE_COLUMNS, "Котировки", f"котировки_{on_date:%Y%m%d}.xlsx"
)
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:
if not (result.updated or result.no_trades or result.errors):
for message in result.messages:
st.info(message)
return
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)