DRRK/tests/unit/test_business_calendar.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

189 lines
6.7 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""Производственный календарь (D30): разбор файлов xmlcalendar.ru за 2025 и
2026 (сохранены как есть), предыдущий рабочий день, загрузка в SQLite."""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
import pytest
from app.db.base import run_db
from app.imports.xmlcalendar import CalendarFormatError, parse_calendar_json
from app.services import business_calendar
from app.services.dates import BusinessCalendar, previous_business_day
USER = "analyst@rshb.ru"
def _file(fixtures_dir: Path, year: int) -> bytes:
return (fixtures_dir / f"xmlcalendar_ru_{year}.json").read_bytes()
def _calendar(fixtures_dir: Path, *years: int) -> BusinessCalendar:
days = set()
for year in years:
days |= set(parse_calendar_json(_file(fixtures_dir, year)).non_working)
return BusinessCalendar(frozenset(years), frozenset(days))
# --- разбор файла ------------------------------------------------------------
def test_parse_2026(fixtures_dir: Path) -> None:
parsed = parse_calendar_json(_file(fixtures_dir, 2026))
assert parsed.year == 2026
assert len(parsed.non_working) == 118 # = statistic.holidays в файле
assert parsed.weekday_holidays == (
date(2026, 1, 1),
date(2026, 1, 2),
date(2026, 1, 5),
date(2026, 1, 6),
date(2026, 1, 7),
date(2026, 1, 8),
date(2026, 1, 9), # перенос с 03.01
date(2026, 2, 23),
date(2026, 3, 9), # 08.03 — воскресенье
date(2026, 5, 1),
date(2026, 5, 11),
date(2026, 6, 12),
date(2026, 11, 4),
date(2026, 12, 31), # перенос с 04.01
)
assert parsed.working_weekends == ()
assert date(2026, 4, 30) in parsed.shortened
assert date(2026, 4, 30) not in parsed.non_working
def test_parse_2025_working_saturday(fixtures_dir: Path) -> None:
"""01.11.2025 — рабочая суббота: в файле «1*», как сокращённый день."""
parsed = parse_calendar_json(_file(fixtures_dir, 2025))
assert len(parsed.non_working) == 118
assert parsed.working_weekends == (date(2025, 11, 1),)
assert date(2025, 11, 3) in parsed.non_working
def _json(**changes) -> bytes: # noqa: ANN003
data = {
"year": 2026,
"months": [{"month": m, "days": "1"} for m in range(1, 13)],
}
data.update(changes)
return json.dumps(data).encode()
@pytest.mark.parametrize(
("content", "message"),
[
(b"<calendar/>", "не читается как JSON"),
(b"[]", "нет года и месяцев"),
(_json(year="2026"), "нет года"),
(_json(months=[{"month": 1, "days": "1"}]), "12 месяцев"),
(
_json(months=[{"month": 1, "days": "1"} for _ in range(12)]),
"месяц 1 дважды",
),
(
_json(months=[{"month": 13, "days": "1"} for _ in range(12)]),
"номер месяца",
),
(
_json(months=[{"month": m, "days": "1,x"} for m in range(1, 13)]),
"непонятный день «x»",
),
(
_json(months=[{"month": m, "days": "31"} for m in range(1, 13)]),
"нет даты 31.02",
),
(_json(statistic={"holidays": 13}), "в списке 12, а в итоге файла 13"),
],
)
def test_parse_errors(content: bytes, message: str) -> None:
with pytest.raises(CalendarFormatError, match=message):
parse_calendar_json(content)
def test_parse_accepts_bom_and_spaces() -> None:
content = "".encode() + _json(
months=[{"month": m, "days": " 1, 2+ ,3*"} for m in range(1, 13)]
)
parsed = parse_calendar_json(content)
assert len(parsed.non_working) == 24
assert len(parsed.shortened) == 12
# --- предыдущий рабочий день -------------------------------------------------
@pytest.mark.parametrize(
("today", "expected"),
[
(date(2026, 1, 12), date(2025, 12, 30)), # 31.12.2025 — перенос
(date(2026, 2, 24), date(2026, 2, 20)), # 23.02 — праздник
(date(2026, 3, 10), date(2026, 3, 6)), # 09.03 — перенос
(date(2026, 5, 12), date(2026, 5, 8)), # 08.05 — сокращённый, рабочий
(date(2025, 11, 2), date(2025, 11, 1)), # рабочая суббота
(date(2025, 11, 5), date(2025, 11, 1)), # 03.11 и 04.11 — выходные
(date(2026, 10, 27), date(2026, 10, 26)),
],
)
def test_previous_business_day_by_calendar(
fixtures_dir: Path, today: date, expected: date
) -> None:
calendar = _calendar(fixtures_dir, 2025, 2026)
assert previous_business_day(today, calendar) == expected
def test_year_without_calendar_skips_weekends_only(fixtures_dir: Path) -> None:
only_2026 = _calendar(fixtures_dir, 2026)
# 2025 не загружен: 31.12.2025 — среда, считается рабочим.
assert previous_business_day(date(2026, 1, 12), only_2026) == date(2025, 12, 31)
assert previous_business_day(date(2027, 1, 4), only_2026) == date(2027, 1, 1)
# --- загрузка и дата отчёта (SQLite) ----------------------------------------
def test_save_year_and_report_date(session_local, fixtures_dir: Path) -> None:
assert run_db(business_calendar.list_years()) == []
assert run_db(business_calendar.report_date(date(2026, 1, 12))) == date(2026, 1, 9)
for year in (2025, 2026):
parsed = parse_calendar_json(_file(fixtures_dir, year))
run_db(business_calendar.save_year(parsed, USER))
assert run_db(business_calendar.report_date(date(2026, 1, 12))) == date(
2025, 12, 30
)
assert run_db(business_calendar.report_date(date(2025, 11, 2))) == date(2025, 11, 1)
rows = run_db(business_calendar.list_years())
assert [
(r.year, r.non_working, r.weekday_holidays, r.working_weekends, r.updated_by)
for r in rows
] == [(2026, 118, 14, 0, USER), (2025, 118, 15, 1, USER)]
def test_reload_replaces_year(session_local, fixtures_dir: Path) -> None:
run_db(
business_calendar.save_year(
parse_calendar_json(_file(fixtures_dir, 2026)), USER
)
)
fixed = parse_calendar_json(
_json(months=[{"month": m, "days": "1"} for m in range(1, 13)])
)
run_db(business_calendar.save_year(fixed, "other@rshb.ru"))
[row] = run_db(business_calendar.list_years())
assert (row.year, row.non_working, row.updated_by) == (2026, 12, "other@rshb.ru")
# 09.01.2026 теперь рабочий, 01.01 — нет.
assert run_db(business_calendar.report_date(date(2026, 1, 10))) == date(2026, 1, 9)