- 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
189 lines
6.7 KiB
Python
189 lines
6.7 KiB
Python
"""Производственный календарь (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)
|