Files
set50-system/backend/app/backtest_events.py
2026-08-28 10:24:54 +07:00

335 lines
11 KiB
Python

"""Unified event-driven backtest calendar (Task 2).
A backtest must rebalance when *information changes*, not on an arbitrary
monthly/quarterly grid. This module derives a chronological event calendar from
the actual PIT sources:
* factor releases (``FactorVintageStore`` rows carry ``released_at``)
* Siamchart snapshot retrievals (the vintage manifest)
* dividend entitlements (ex-date) and payments (ex-date + 30 calendar days
per the confirmed timing assumption ``ex_date_plus_30d``)
* the end-of-run valuation date
Each *signal* event (a release that may change the target) is paired with the
next available execution date after it — the trade must not see a price that
did not exist yet. Events are coalesced by day so a day with several releases
produces exactly one frozen signal and at most one rebalance.
All events are plain dataclasses so the engine (Task 5) can consume them
without coupling to the stores.
"""
from __future__ import annotations
import datetime as dt
from dataclasses import dataclass, field
from typing import Any, Iterable, Optional
# Timezone-aware default for timestamps the calendar normalises to dates.
_TZ_BANGKOK = dt.timezone(dt.timedelta(hours=7))
# Dividend cash becomes available this many calendar days after ex-date.
# Confirmed user decision (timing assumption, not an observed pay date).
DIVIDEND_PAYMENT_LAG_DAYS = 30
class BacktestEventError(ValueError):
pass
def _parse_date(value: Any) -> dt.date:
if isinstance(value, dt.date):
return value
try:
return dt.date.fromisoformat(str(value)[:10])
except (TypeError, ValueError) as exc:
raise BacktestEventError(f"invalid date: {value!r}") from exc
def _date_from_ts(value: Any) -> Optional[dt.date]:
if not value:
return None
try:
return _parse_date(value)
except BacktestEventError:
return None
# ---------------------------------------------------------------------------
# Event types
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SignalReleaseEvent:
"""One or more data sources produced new information on ``released_at``.
``sources`` names which changed (e.g. ``["factor:energy_net_margin"]`` or
``["siamchart"]``). The engine freezes the target using only data knowable
by ``released_at`` (anti-look-ahead).
"""
released_at: str
sources: list[str] = field(default_factory=list)
kind: str = "signal"
@property
def date(self) -> dt.date:
return _parse_date(self.released_at)
@dataclass(frozen=True)
class ExecutionEvent:
"""The next available trading date to execute the signal frozen on
``signal_date``. Never before the signal's release date."""
signal_date: str
execution_date: str
kind: str = "execution"
@property
def date(self) -> dt.date:
return _parse_date(self.execution_date)
@dataclass(frozen=True)
class DividendEntitlementEvent:
"""Shares held *before* ``ex_date`` qualify for this dividend."""
symbol: str
ex_date: str
per_share: float
kind: str = "dividend_entitlement"
@property
def date(self) -> dt.date:
return _parse_date(self.ex_date)
@dataclass(frozen=True)
class DividendPaymentEvent:
"""Cash becomes available on ``payment_date``.
``timing_method = ex_date_plus_30d`` because the Siamchart source supplies
ex-date + DPS but not an observed payment date; the confirmed assumption is
that cash settles exactly ``DIVIDEND_PAYMENT_LAG_DAYS`` after ex-date.
"""
symbol: str
ex_date: str
payment_date: str
per_share: float
timing_method: str = "ex_date_plus_30d"
kind: str = "dividend_payment"
@property
def date(self) -> dt.date:
return _parse_date(self.payment_date)
@dataclass(frozen=True)
class EndValuationEvent:
"""Mark holdings to market at/after ``end_date`` (final valuation)."""
end_date: str
kind: str = "end_valuation"
@property
def date(self) -> dt.date:
return _parse_date(self.end_date)
# ---------------------------------------------------------------------------
# Calendar builders
# ---------------------------------------------------------------------------
def _yield_signal_dates(factor_store: Any, start: dt.date, end: dt.date) -> list[SignalReleaseEvent]:
"""Factor releases within [start, end], one event per released_at day."""
from .backtest_readiness import _factor_keys_required
factors = _factor_keys_required()
by_day: dict[dt.date, list[str]] = {}
for key in factors:
try:
rows = factor_store.series(key)
except Exception:
rows = []
for r in rows:
d = _date_from_ts(r.get("released_at")) or _date_from_ts(r.get("observed_at"))
if d is None or d < start or d > end:
continue
by_day.setdefault(d, []).append(f"factor:{key}")
return [
SignalReleaseEvent(
released_at=d.isoformat(),
sources=sorted(set(srcs)),
)
for d, srcs in sorted(by_day.items())
]
def _yield_siamchart_signals(
siamchart_store: Any, start: dt.date, end: dt.date
) -> list[SignalReleaseEvent]:
"""Siamchart snapshot retrievals within [start, end], coalesced by day."""
by_day: dict[dt.date, int] = {}
try:
ids = siamchart_store.list_ids()
except Exception:
ids = []
for sid in ids:
# read each snapshot's retrieved_at from the manifest if available
d = None
try:
manifest = siamchart_store._load_manifest()
entry = manifest.get("snapshots", {}).get(str(sid), {})
d = _date_from_ts(entry.get("retrieved_at"))
except Exception:
d = None
if d is None:
# fallback: probe snapshot_at at a far-future cutoff for the newest
# (only usable when the store is single-snapshot).
try:
snap = siamchart_store.snapshot_at(
dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
)
d = _date_from_ts(snap.get("_retrieved_at") or snap.get("retrieved_at"))
except Exception:
d = None
if d is not None and start <= d <= end:
by_day[d] = by_day.get(d, 0) + 1
return [
SignalReleaseEvent(released_at=d.isoformat(), sources=["siamchart"])
for d in sorted(by_day)
]
def _yield_dividend_events(ledger: Any, start: dt.date, end: dt.date) -> list:
"""Dividend entitlement + payment events for dated (non-estimate) entries."""
events: list = []
if ledger is None:
return events
try:
symbols = ledger.symbols()
except Exception:
return events
for sym in symbols:
try:
entries = ledger.entries(sym)
except Exception:
entries = []
for e in entries:
if e.get("estimate"):
continue # estimates are not dated cash flows
ex_date = e.get("ex_date")
if not ex_date:
continue
try:
ex = _parse_date(ex_date)
except BacktestEventError:
continue
per_share = float(e.get("per_share") or 0.0)
if per_share <= 0:
continue
if start <= ex <= end:
events.append(DividendEntitlementEvent(
symbol=sym, ex_date=ex.isoformat(), per_share=per_share,
))
pay = ex + dt.timedelta(days=DIVIDEND_PAYMENT_LAG_DAYS)
if start <= pay <= end:
events.append(DividendPaymentEvent(
symbol=sym, ex_date=ex.isoformat(),
payment_date=pay.isoformat(), per_share=per_share,
))
return events
def build_event_calendar(
*,
factor_store: Any = None,
siamchart_store: Any = None,
dividend_ledger: Any = None,
start: str,
end: str,
) -> list:
"""Build the full chronological event list for [start, end].
Events are returned **sorted by date**, with signal events (factor/Siamchart
releases) interleaved with dividend and end events. Same-day releases are
coalesced so a single day yields one signal.
Execution pairing is the engine's job (Task 5), but this builder guarantees
only events within the window are produced and that the end valuation event
is present.
"""
s = _parse_date(start)
e = _parse_date(end)
if s > e:
raise BacktestEventError("end must be on/after start")
events: list = []
events.extend(_yield_signal_dates(factor_store, s, e))
events.extend(_yield_siamchart_signals(siamchart_store, s, e))
events.extend(_yield_dividend_events(dividend_ledger, s, e))
events.append(EndValuationEvent(end_date=e.isoformat()))
# stable sort by event date
events.sort(key=_event_sort_key)
return events
def _event_sort_key(ev: Any):
if hasattr(ev, "date"):
return ev.date.isoformat()
return ""
# ---------------------------------------------------------------------------
# Execution-date mapping
# ---------------------------------------------------------------------------
def _trading_days(price_series: dict[str, Any]) -> set[dt.date]:
"""Union of all distinct bar dates across symbols (the tradable calendar)."""
days: set[dt.date] = set()
for sym, s in (price_series or {}).items():
for b in (s or {}).get("bars", []):
d = _date_from_ts(b.get("date"))
if d is not None:
days.add(d)
return days
def next_trading_day(price_series: dict[str, Any], after: dt.date) -> Optional[dt.date]:
"""Strictly-first trading day after ``after`` in the price calendar.
Returns None if no trading day exists strictly after ``after``. This is the
anti-look-ahead execution rule: the trade cannot see a price that was not
yet known on the release day.
"""
trading = _trading_days(price_series)
future = [d for d in trading if d > after]
return min(future) if future else None
def pair_signal_executions(
signals: Iterable[SignalReleaseEvent],
price_series: dict[str, Any],
end: dt.date,
) -> list[ExecutionEvent]:
"""Map each signal to the next trading day after its release, capped at end.
A signal whose next trading day falls beyond the run end cannot execute and
is dropped (the engine simply values the final holdings at end instead).
"""
trading = _trading_days(price_series)
out: list[ExecutionEvent] = []
for sig in signals:
sdate = sig.date
future = [d for d in trading if d > sdate]
if not future:
continue
exec_day = min(future)
if exec_day > end:
continue # cannot execute within the backtest horizon
out.append(ExecutionEvent(
signal_date=sdate.isoformat(), execution_date=exec_day.isoformat(),
))
out.sort(key=lambda e: e.date)
return out