[verified] Task 2: unified event-driven backtest calendar
This commit is contained in:
334
backend/app/backtest_events.py
Normal file
334
backend/app/backtest_events.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""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
|
||||||
223
backend/tests/test_backtest_events.py
Normal file
223
backend/tests/test_backtest_events.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""Tests for the unified event-driven backtest calendar (Task 2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.backtest_events import (
|
||||||
|
DIVIDEND_PAYMENT_LAG_DAYS,
|
||||||
|
DividendEntitlementEvent,
|
||||||
|
DividendPaymentEvent,
|
||||||
|
EndValuationEvent,
|
||||||
|
ExecutionEvent,
|
||||||
|
SignalReleaseEvent,
|
||||||
|
build_event_calendar,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFactorStore:
|
||||||
|
"""factor_key -> rows with released_at (chronological)."""
|
||||||
|
|
||||||
|
def __init__(self, releases: dict[str, list[str]]):
|
||||||
|
self._releases = releases
|
||||||
|
|
||||||
|
def series(self, key: str) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"released_at": ts, "observed_at": ts, "value": 1.0}
|
||||||
|
for ts in self._releases.get(key, [])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSiamchartStore:
|
||||||
|
def __init__(self, retrieved: list[str]):
|
||||||
|
self._retrieved = sorted(retrieved)
|
||||||
|
|
||||||
|
def list_ids(self) -> list[str]:
|
||||||
|
return [str(i) for i in range(len(self._retrieved))]
|
||||||
|
|
||||||
|
def _load_manifest(self) -> dict:
|
||||||
|
return {
|
||||||
|
"snapshots": {
|
||||||
|
str(i): {"retrieved_at": ts} for i, ts in enumerate(self._retrieved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def snapshot_at(self, as_of: str) -> dict:
|
||||||
|
chosen = [t for t in self._retrieved if t <= as_of]
|
||||||
|
if not chosen:
|
||||||
|
return {}
|
||||||
|
return {"retrieved_at": chosen[-1], "_retrieved_at": chosen[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLedger:
|
||||||
|
def __init__(self, entries):
|
||||||
|
# entries: list of {symbol, ex_date, per_share, estimate}
|
||||||
|
self._by = {}
|
||||||
|
for e in entries:
|
||||||
|
self._by.setdefault(e["symbol"], []).append(e)
|
||||||
|
|
||||||
|
def symbols(self) -> list[str]:
|
||||||
|
return list(self._by.keys())
|
||||||
|
|
||||||
|
def entries(self, sym: str) -> list[dict]:
|
||||||
|
return self._by.get(sym, [])
|
||||||
|
|
||||||
|
|
||||||
|
class SignalReleaseTest(unittest.TestCase):
|
||||||
|
def test_factor_release_produces_signal_event(self):
|
||||||
|
store = FakeFactorStore({
|
||||||
|
"energy_net_margin": ["2026-03-15T09:00:00+07:00"],
|
||||||
|
})
|
||||||
|
events = build_event_calendar(
|
||||||
|
factor_store=store, start="2026-03-01", end="2026-04-01"
|
||||||
|
)
|
||||||
|
sigs = [e for e in events if isinstance(e, SignalReleaseEvent)]
|
||||||
|
self.assertEqual(len(sigs), 1)
|
||||||
|
self.assertEqual(sigs[0].released_at[:10], "2026-03-15")
|
||||||
|
self.assertIn("factor:energy_net_margin", sigs[0].sources)
|
||||||
|
|
||||||
|
def test_same_day_factor_releases_coalesce_into_one_signal(self):
|
||||||
|
store = FakeFactorStore({
|
||||||
|
"energy_net_margin": ["2026-03-15T09:00:00+07:00"],
|
||||||
|
"macro_consumption": ["2026-03-15T14:00:00+07:00"],
|
||||||
|
})
|
||||||
|
events = build_event_calendar(
|
||||||
|
factor_store=store, start="2026-03-01", end="2026-04-01"
|
||||||
|
)
|
||||||
|
sigs = [e for e in events if isinstance(e, SignalReleaseEvent)]
|
||||||
|
self.assertEqual(len(sigs), 1) # coalesced
|
||||||
|
self.assertEqual(len(sigs[0].sources), 2)
|
||||||
|
|
||||||
|
def test_release_outside_window_is_excluded(self):
|
||||||
|
store = FakeFactorStore({
|
||||||
|
"energy_net_margin": ["2026-03-15T09:00:00+07:00"],
|
||||||
|
})
|
||||||
|
events = build_event_calendar(
|
||||||
|
factor_store=store, start="2026-04-01", end="2026-05-01"
|
||||||
|
)
|
||||||
|
sigs = [e for e in events if isinstance(e, SignalReleaseEvent)]
|
||||||
|
self.assertEqual(len(sigs), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class SiamchartSignalTest(unittest.TestCase):
|
||||||
|
def test_snapshot_retrieval_produces_signal(self):
|
||||||
|
store = FakeSiamchartStore(["2026-03-20T09:00:00+07:00"])
|
||||||
|
events = build_event_calendar(
|
||||||
|
siamchart_store=store, start="2026-03-01", end="2026-04-01"
|
||||||
|
)
|
||||||
|
sigs = [e for e in events if isinstance(e, SignalReleaseEvent)]
|
||||||
|
sc = [s for s in sigs if "siamchart" in s.sources]
|
||||||
|
self.assertEqual(len(sc), 1)
|
||||||
|
self.assertEqual(sc[0].released_at[:10], "2026-03-20")
|
||||||
|
|
||||||
|
|
||||||
|
class DividendEventTest(unittest.TestCase):
|
||||||
|
def test_entitlement_and_payment_events(self):
|
||||||
|
ledger = FakeLedger([{
|
||||||
|
"symbol": "A", "ex_date": "2026-03-10",
|
||||||
|
"per_share": 1.5, "estimate": False,
|
||||||
|
}])
|
||||||
|
events = build_event_calendar(
|
||||||
|
dividend_ledger=ledger, start="2026-03-01", end="2026-05-01"
|
||||||
|
)
|
||||||
|
ents = [e for e in events if isinstance(e, DividendEntitlementEvent)]
|
||||||
|
pays = [e for e in events if isinstance(e, DividendPaymentEvent)]
|
||||||
|
self.assertEqual(len(ents), 1)
|
||||||
|
self.assertEqual(ents[0].ex_date, "2026-03-10")
|
||||||
|
self.assertEqual(ents[0].per_share, 1.5)
|
||||||
|
self.assertEqual(len(pays), 1)
|
||||||
|
expected_pay = dt.date(2026, 3, 10) + dt.timedelta(days=DIVIDEND_PAYMENT_LAG_DAYS)
|
||||||
|
self.assertEqual(pays[0].payment_date, expected_pay.isoformat())
|
||||||
|
self.assertEqual(pays[0].timing_method, "ex_date_plus_30d")
|
||||||
|
|
||||||
|
def test_estimate_entry_produces_no_dated_events(self):
|
||||||
|
ledger = FakeLedger([{
|
||||||
|
"symbol": "A", "ex_date": "2026-03-10",
|
||||||
|
"per_share": 1.5, "estimate": True,
|
||||||
|
}])
|
||||||
|
events = build_event_calendar(
|
||||||
|
dividend_ledger=ledger, start="2026-03-01", end="2026-05-01"
|
||||||
|
)
|
||||||
|
ents = [e for e in events if isinstance(e, DividendEntitlementEvent)]
|
||||||
|
pays = [e for e in events if isinstance(e, DividendPaymentEvent)]
|
||||||
|
self.assertEqual(len(ents), 0)
|
||||||
|
self.assertEqual(len(pays), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarShapeTest(unittest.TestCase):
|
||||||
|
def test_end_valuation_event_always_present(self):
|
||||||
|
events = build_event_calendar(start="2026-03-01", end="2026-04-01")
|
||||||
|
ends = [e for e in events if isinstance(e, EndValuationEvent)]
|
||||||
|
self.assertEqual(len(ends), 1)
|
||||||
|
self.assertEqual(ends[0].end_date, "2026-04-01")
|
||||||
|
|
||||||
|
def test_events_sorted_chronologically(self):
|
||||||
|
factor_store = FakeFactorStore({
|
||||||
|
"energy_net_margin": ["2026-03-15T09:00:00+07:00"],
|
||||||
|
})
|
||||||
|
sc = FakeSiamchartStore(["2026-03-20T09:00:00+07:00"])
|
||||||
|
ledger = FakeLedger([{
|
||||||
|
"symbol": "A", "ex_date": "2026-03-25",
|
||||||
|
"per_share": 1.0, "estimate": False,
|
||||||
|
}])
|
||||||
|
events = build_event_calendar(
|
||||||
|
factor_store=factor_store, siamchart_store=sc,
|
||||||
|
dividend_ledger=ledger, start="2026-03-01", end="2026-05-20",
|
||||||
|
)
|
||||||
|
dates = [e.date for e in events]
|
||||||
|
self.assertEqual(dates, sorted(dates))
|
||||||
|
|
||||||
|
def test_invalid_window_raises(self):
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
build_event_calendar(start="2026-05-01", end="2026-03-01")
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionEventTest(unittest.TestCase):
|
||||||
|
def test_execution_event_shape(self):
|
||||||
|
ev = ExecutionEvent(signal_date="2026-03-15", execution_date="2026-03-16")
|
||||||
|
self.assertEqual(ev.kind, "execution")
|
||||||
|
self.assertEqual(ev.date.isoformat(), "2026-03-16")
|
||||||
|
|
||||||
|
|
||||||
|
def make_daily_series(start: str, days: int) -> dict:
|
||||||
|
"""Random-free daily price series with one bar per calendar day."""
|
||||||
|
bars = []
|
||||||
|
s = dt.date.fromisoformat(start)
|
||||||
|
for i in range(days):
|
||||||
|
bars.append({"date": (s + dt.timedelta(days=i)).isoformat(),
|
||||||
|
"adjusted_close": 10.0})
|
||||||
|
return {"A": {"bars": bars}}
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionMappingTest(unittest.TestCase):
|
||||||
|
def test_next_trading_day_is_strictly_after_signal(self):
|
||||||
|
from app.backtest_events import next_trading_day
|
||||||
|
series = make_daily_series("2026-03-10", 10) # 03-10..03-19
|
||||||
|
nxt = next_trading_day(series, dt.date(2026, 3, 15))
|
||||||
|
self.assertEqual(nxt, dt.date(2026, 3, 16))
|
||||||
|
|
||||||
|
def test_no_trading_day_after_returns_none(self):
|
||||||
|
from app.backtest_events import next_trading_day
|
||||||
|
series = make_daily_series("2026-03-10", 10)
|
||||||
|
nxt = next_trading_day(series, dt.date(2026, 3, 25))
|
||||||
|
self.assertIsNone(nxt)
|
||||||
|
|
||||||
|
def test_pair_signal_executions(self):
|
||||||
|
from app.backtest_events import pair_signal_executions
|
||||||
|
series = make_daily_series("2026-03-10", 10)
|
||||||
|
sigs = [SignalReleaseEvent(released_at="2026-03-15T09:00:00+07:00")]
|
||||||
|
pairs = pair_signal_executions(sigs, series, dt.date(2026, 3, 19))
|
||||||
|
self.assertEqual(len(pairs), 1)
|
||||||
|
self.assertEqual(pairs[0].execution_date, "2026-03-16")
|
||||||
|
|
||||||
|
def test_signal_past_end_not_executed(self):
|
||||||
|
from app.backtest_events import pair_signal_executions
|
||||||
|
series = make_daily_series("2026-03-10", 10)
|
||||||
|
sigs = [SignalReleaseEvent(released_at="2026-03-15T09:00:00+07:00")]
|
||||||
|
pairs = pair_signal_executions(sigs, series, dt.date(2026, 3, 14))
|
||||||
|
self.assertEqual(len(pairs), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user