DRRK/app/imports/xmlcalendar.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

119 lines
4.9 KiB
Python
Raw 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.

"""Разбор производственного календаря xmlcalendar.ru (JSON на год) — без БД
и сети (D30).
Файл: https://xmlcalendar.ru/data/ru/{год}/calendar.json. В months[].days —
дни месяца через запятую: выходные и праздники, «+» — выходной по переносу,
«*» — сокращённый рабочий день (так помечаются и рабочие субботы). Нерабочие
дни — всё из списка без «*», остальные дни года рабочие.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
SOURCE = "xmlcalendar.ru"
_DAY = re.compile(r"^(\d{1,2})([*+]?)$")
_SHORT = "*"
_YEARS = range(2000, 2101)
class CalendarFormatError(ValueError):
"""Файл не похож на календарь xmlcalendar.ru; текст — для пользователя."""
@dataclass(frozen=True)
class ParsedCalendar:
year: int
non_working: tuple[date, ...]
shortened: tuple[date, ...]
@property
def weekday_holidays(self) -> tuple[date, ...]:
"""Нерабочие дни с понедельника по пятницу: праздники и переносы."""
return tuple(d for d in self.non_working if d.weekday() < 5)
@property
def working_weekends(self) -> tuple[date, ...]:
"""Рабочие субботы и воскресенья."""
return weekend_workdays(self.year, set(self.non_working))
def weekend_workdays(year: int, non_working: set[date]) -> tuple[date, ...]:
day, end, result = date(year, 1, 1), date(year, 12, 31), []
while day <= end:
if day.weekday() >= 5 and day not in non_working:
result.append(day)
day += timedelta(days=1)
return tuple(result)
def parse_calendar_json(content: bytes) -> ParsedCalendar:
try:
data = json.loads(content.decode("utf-8-sig"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CalendarFormatError(
"Файл не читается как JSON. Нужен файл calendar.json с xmlcalendar.ru."
) from exc
if not isinstance(data, dict):
raise CalendarFormatError(
"Это не календарь xmlcalendar.ru: нет года и месяцев."
)
year = data.get("year")
if not isinstance(year, int) or year not in _YEARS:
raise CalendarFormatError("В файле нет года или он неверный.")
months = data.get("months")
if not isinstance(months, list) or len(months) != 12:
raise CalendarFormatError(f"Календарь на {year}: должно быть 12 месяцев.")
non_working: set[date] = set()
shortened: set[date] = set()
seen_months: set[int] = set()
for item in months:
month = _month(item, year)
if month in seen_months:
raise CalendarFormatError(f"Календарь на {year}: месяц {month} дважды.")
seen_months.add(month)
for token in str(item.get("days") or "").split(","):
token = token.strip()
if not token:
continue
match = _DAY.match(token)
if match is None:
raise CalendarFormatError(
f"Календарь на {year}, месяц {month}: непонятный день «{token}»."
)
try:
day = date(year, month, int(match.group(1)))
except ValueError as exc:
raise CalendarFormatError(
f"Календарь на {year}: нет даты {match.group(1)}.{month:02d}."
) from exc
(shortened if match.group(2) == _SHORT else non_working).add(day)
_check_total(data.get("statistic"), year, len(non_working))
return ParsedCalendar(year, tuple(sorted(non_working)), tuple(sorted(shortened)))
def _month(item: Any, year: int) -> int:
month = item.get("month") if isinstance(item, dict) else None
if not isinstance(month, int) or not 1 <= month <= 12:
raise CalendarFormatError(f"Календарь на {year}: неверный номер месяца.")
return month
def _check_total(statistic: Any, year: int, count: int) -> None:
"""Итог «holidays» в файле — число нерабочих дней года; если он есть,
должен сойтись с разобранным списком."""
expected = statistic.get("holidays") if isinstance(statistic, dict) else None
if isinstance(expected, int) and expected != count:
raise CalendarFormatError(
f"Календарь на {year}: нерабочих дней в списке {count}, а в итоге "
f"файла {expected}. Файл повреждён или формат изменился."
)