- 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
267 lines
9.3 KiB
Python
267 lines
9.3 KiB
Python
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)
|