- 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
115 lines
4.4 KiB
Python
115 lines
4.4 KiB
Python
"""Производственный календарь (D30): загрузка года из файла xmlcalendar.ru."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import date
|
||
|
||
import streamlit as st
|
||
|
||
from app.db.base import run_db
|
||
from app.imports.xmlcalendar import (
|
||
SOURCE,
|
||
CalendarFormatError,
|
||
ParsedCalendar,
|
||
parse_calendar_json,
|
||
)
|
||
from app.services import business_calendar
|
||
from app.ui.common import as_table, call_db, current_user
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Разобранный файл (ParsedCalendar) или текст ошибки — кладётся при загрузке.
|
||
_UPLOAD_KEY = "calendar_upload"
|
||
# Сброс поля загрузки после сохранения: у виджета меняется ключ.
|
||
_NONCE_KEY = "calendar_file_nonce"
|
||
|
||
_YEAR_COLUMNS = {
|
||
"year": "Год",
|
||
"non_working": "Нерабочих дней",
|
||
"weekday_holidays": "Праздники и переносы в будни",
|
||
"working_weekends": "Рабочие выходные",
|
||
"updated_by": "Кто загрузил",
|
||
"updated_at": "Когда",
|
||
}
|
||
|
||
|
||
def render() -> None:
|
||
st.header("Производственный календарь")
|
||
st.caption(
|
||
f"Файл на год с {SOURCE}: https://xmlcalendar.ru/data/ru/ГГГГ/calendar.json. "
|
||
"От календаря считается дата отчёта по умолчанию — предыдущий рабочий "
|
||
"день. Для года без календаря рабочими считаются будни. Повторная "
|
||
"загрузка года заменяет его целиком."
|
||
)
|
||
years = call_db(business_calendar.list_years(), "Не удалось загрузить календарь")
|
||
if years is None:
|
||
return
|
||
loaded = {row.year for row in years}
|
||
if date.today().year not in loaded:
|
||
st.warning(
|
||
f"Календарь на {date.today().year} не загружен: праздники при выборе "
|
||
"даты отчёта не учитываются."
|
||
)
|
||
|
||
widget_key = f"calendar_file_{st.session_state.get(_NONCE_KEY, 0)}"
|
||
st.file_uploader(
|
||
"Файл календаря (.json)",
|
||
type=["json"],
|
||
key=widget_key,
|
||
on_change=_on_upload,
|
||
args=(widget_key,),
|
||
)
|
||
upload = st.session_state.get(_UPLOAD_KEY)
|
||
if isinstance(upload, str):
|
||
st.error(upload)
|
||
elif isinstance(upload, ParsedCalendar):
|
||
_render_preview(upload, loaded)
|
||
|
||
st.subheader("Загруженные годы")
|
||
if years:
|
||
st.dataframe(as_table(years, _YEAR_COLUMNS), hide_index=True)
|
||
else:
|
||
st.caption("Календарь пока не загружен.")
|
||
|
||
|
||
def _on_upload(widget_key: str) -> None:
|
||
file = st.session_state.get(widget_key)
|
||
if file is None:
|
||
st.session_state.pop(_UPLOAD_KEY, None)
|
||
return
|
||
try:
|
||
st.session_state[_UPLOAD_KEY] = parse_calendar_json(file.getvalue())
|
||
except CalendarFormatError as exc:
|
||
st.session_state[_UPLOAD_KEY] = str(exc)
|
||
|
||
|
||
def _render_preview(parsed: ParsedCalendar, loaded: set[int]) -> None:
|
||
st.write(
|
||
f"Год {parsed.year}: нерабочих дней {len(parsed.non_working)}, "
|
||
f"сокращённых {len(parsed.shortened)}."
|
||
)
|
||
st.write(f"Праздники и переносы в будни: {_dates(parsed.weekday_holidays)}.")
|
||
st.write(f"Рабочие выходные: {_dates(parsed.working_weekends)}.")
|
||
if parsed.year in loaded:
|
||
st.warning(f"Календарь на {parsed.year} уже загружен — он будет заменён.")
|
||
if st.button(f"Сохранить календарь на {parsed.year}", type="primary"):
|
||
_save(parsed)
|
||
|
||
|
||
def _dates(days: tuple[date, ...]) -> str:
|
||
return ", ".join(f"{d:%d.%m}" for d in days) if days else "нет"
|
||
|
||
|
||
def _save(parsed: ParsedCalendar) -> None:
|
||
try:
|
||
run_db(business_calendar.save_year(parsed, current_user()))
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.exception("Ошибка сохранения календаря на %d", parsed.year)
|
||
st.error(f"Не удалось сохранить календарь: {exc}")
|
||
return
|
||
st.session_state.pop(_UPLOAD_KEY, None)
|
||
st.session_state[_NONCE_KEY] = st.session_state.get(_NONCE_KEY, 0) + 1
|
||
st.toast(f"Календарь на {parsed.year} сохранён.")
|
||
st.rerun()
|