- 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
119 lines
4.9 KiB
Python
119 lines
4.9 KiB
Python
"""Разбор производственного календаря 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}. Файл повреждён или формат изменился."
|
||
)
|