"""Calendar parsing + Set classification for M2D backtesting. Each trade is tagged with a ``Set`` derived from its date, RO-local time, and the economic-event calendar: - ``A1``: 16:35-17:00 RO, Tue/Wed/Thu - ``A2``: 17:00-18:00 RO, Tue/Wed/Thu (sweet spot) - ``A3``: 18:00-19:00 RO, Tue/Wed/Thu - ``B`` : 22:00-22:45 RO, Tue/Wed/Thu - ``C`` : inside the window of an event with severity in {extrem, mare} - ``D`` : Mon or Fri - ``Other``: anything else Priority: C > D > A1/A2/A3/B > Other. """ from __future__ import annotations from datetime import date, datetime, time from pathlib import Path from typing import Any import yaml from zoneinfo import ZoneInfo __all__ = [ "RO_TZ", "UTC_TZ", "utc_to_ro", "load_calendar", "is_in_news_window", "calc_set", ] RO_TZ = ZoneInfo("Europe/Bucharest") UTC_TZ = ZoneInfo("UTC") _DAY_SHORT = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") _HIGH_SEVERITY = frozenset({"extrem", "mare"}) _WEEKLY_DAY_MAP = { "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6, } def utc_to_ro(date_str: str, ora_utc_str: str) -> tuple[date, time, str]: """Convert ``(YYYY-MM-DD, HH:MM UTC)`` to ``(date_ro, time_ro, day_short)``. DST-aware via :mod:`zoneinfo`. ``day_short`` is one of ``Mon Tue Wed Thu Fri Sat Sun``. """ dt_utc = datetime.strptime(f"{date_str} {ora_utc_str}", "%Y-%m-%d %H:%M").replace( tzinfo=UTC_TZ ) dt_ro = dt_utc.astimezone(RO_TZ) return dt_ro.date(), dt_ro.time().replace(second=0, microsecond=0), _DAY_SHORT[dt_ro.weekday()] def load_calendar(path: Path | str = "calendar_evenimente.yaml") -> list[dict[str, Any]]: """Load a YAML calendar file. Validates ``schema_version == 1`` and returns the list of event dicts under the top-level ``events`` key. """ p = Path(path) with p.open("r", encoding="utf-8") as fh: doc = yaml.safe_load(fh) if not isinstance(doc, dict): raise ValueError(f"calendar file {p} is not a mapping") version = doc.get("schema_version") if version != 1: raise ValueError( f"unsupported calendar schema_version: {version!r} (expected 1)" ) events = doc.get("events") or [] if not isinstance(events, list): raise ValueError(f"calendar events must be a list, got {type(events).__name__}") return events def _minutes(t: time) -> int: return t.hour * 60 + t.minute def _parse_hhmm(s: str) -> time: return datetime.strptime(s, "%H:%M").time() def _is_first_friday_of_month(d: date) -> bool: return d.weekday() == 4 and d.day <= 7 def _event_matches_date(event: dict[str, Any], d: date) -> bool: cadence = event.get("cadence", "") if cadence == "scheduled": ev_date_raw = event.get("date") if isinstance(ev_date_raw, date): ev_date = ev_date_raw elif isinstance(ev_date_raw, str): ev_date = datetime.strptime(ev_date_raw, "%Y-%m-%d").date() else: return False return ev_date == d if cadence == "first_friday_monthly": return _is_first_friday_of_month(d) if cadence.startswith("weekly_"): day_name = cadence[len("weekly_") :].lower() target = _WEEKLY_DAY_MAP.get(day_name) if target is None: return False return d.weekday() == target # cadences below are not pinned down to a precise calendar day yet, so we # do not trigger Set C for them. ADP pre-NFP is also explicitly deferred. return False def is_in_news_window(d: date, t: time, calendar: list[dict[str, Any]]) -> bool: """Return True iff ``(d, t)`` falls inside the window of a high-severity event. Window: ``[time_ro - window_before_min, time_ro + window_after_min]`` (inclusive on both ends). Only events with ``severity`` in ``{extrem, mare}`` count. Cadences honoured: ``scheduled``, ``first_friday_monthly``, ``weekly_``. Other cadences (``monthly_mid``, ``monthly_end``, ``monthly_15``, ``wednesday_pre_nfp``, ``monthly_first_week`` etc.) are deferred and never trigger Set C. """ t_min = _minutes(t) for event in calendar: if event.get("severity") not in _HIGH_SEVERITY: continue if not _event_matches_date(event, d): continue ev_time_raw = event.get("time_ro") if isinstance(ev_time_raw, time): ev_time = ev_time_raw elif isinstance(ev_time_raw, str): ev_time = _parse_hhmm(ev_time_raw) else: continue center = _minutes(ev_time) before = int(event.get("window_before_min", 0)) after = int(event.get("window_after_min", 0)) if center - before <= t_min <= center + after: return True return False def _in_range(t: time, lo: time, hi: time) -> bool: """Half-open ``[lo, hi)`` containment.""" return _minutes(lo) <= _minutes(t) < _minutes(hi) def calc_set(d: date, t: time, day_of_week: str, calendar: list[dict[str, Any]]) -> str: """Classify a trade into one of ``A1 A2 A3 B C D Other``. Priority: ``C`` (news) > ``D`` (Mon/Fri) > ``A1/A2/A3/B`` (time bands on Tue/Wed/Thu) > ``Other``. """ if is_in_news_window(d, t, calendar): return "C" if day_of_week in ("Mon", "Fri"): return "D" if day_of_week in ("Tue", "Wed", "Thu"): if _in_range(t, time(16, 35), time(17, 0)): return "A1" if _in_range(t, time(17, 0), time(18, 0)): return "A2" if _in_range(t, time(18, 0), time(19, 0)): return "A3" if _in_range(t, time(22, 0), time(22, 45)): return "B" return "Other"