424 lines
15 KiB
Python
424 lines
15 KiB
Python
"""Strict PIT backtest readiness + default-date derivation (Task 1).
|
|
|
|
The backtest must not silently start before advice is genuinely available.
|
|
This module answers two things:
|
|
|
|
* ``ready`` — whether every input the backtest needs is PIT-available over
|
|
some [start, end] window (factor releases, a Siamchart snapshot, and
|
|
executable prices for the universe).
|
|
* ``recommended_start`` / ``recommended_end`` — the defaults the API/UI
|
|
should prefill. The recommended end is "yesterday" in Bangkok (the last
|
|
complete trading availability), and the recommended start is the earliest
|
|
date at which every PIT input is release-available *and* at least one
|
|
executable price exists.
|
|
|
|
Fail-closed semantics: this module never manufactures a start date from data
|
|
that does not exist. If any required input has no coverage through the window
|
|
it reports ``ready=false`` and lists what is missing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Optional
|
|
|
|
_TZ_BANGKOK = dt.timezone(dt.timedelta(hours=7))
|
|
|
|
|
|
class BacktestReadinessError(Exception):
|
|
"""Raised when coverage cannot be evaluated safely."""
|
|
|
|
|
|
def bangkok_now() -> dt.datetime:
|
|
"""Current wall-clock in Bangkok (Asia/Bangkok, UTC+7, no DST)."""
|
|
return dt.datetime.now(dt.timezone.utc).astimezone(_TZ_BANGKOK)
|
|
|
|
|
|
def yesterday_bangkok() -> dt.date:
|
|
"""Recommended default end: yesterday in Bangkok."""
|
|
return (bangkok_now() - dt.timedelta(days=1)).date()
|
|
|
|
|
|
def _parse_date(value: Any) -> dt.date:
|
|
try:
|
|
if isinstance(value, str):
|
|
return dt.date.fromisoformat(str(value)[:10])
|
|
if isinstance(value, dt.datetime):
|
|
return value.date()
|
|
if isinstance(value, dt.date):
|
|
return value
|
|
except (TypeError, ValueError) as exc:
|
|
raise BacktestReadinessError(f"invalid date: {value!r}") from exc
|
|
raise BacktestReadinessError(f"invalid date: {value!r}")
|
|
|
|
|
|
def _date_from_ts(value: Any) -> Optional[dt.date]:
|
|
"""Extract a date from an ISO-8601 timestamp, tolerant of partial input."""
|
|
if not value:
|
|
return None
|
|
try:
|
|
return _parse_date(value)
|
|
except BacktestReadinessError:
|
|
return None
|
|
|
|
|
|
def _factor_keys_required() -> list[str]:
|
|
"""The registry factors the PIT scorer needs (single source of truth)."""
|
|
from .factors import FACTORS
|
|
if isinstance(FACTORS, dict):
|
|
return [str(k) for k in FACTORS.keys()]
|
|
# defensive fallback (registry is a dict today; keep a tolerant path)
|
|
keys: list[str] = []
|
|
for item in FACTORS: # type: ignore[union-attr]
|
|
if isinstance(item, dict) and isinstance(item.get("key"), str):
|
|
keys.append(str(item.get("key")))
|
|
return keys
|
|
|
|
|
|
def _required_symbols() -> list[str]:
|
|
"""The universe the backtest trades. Uses the board registry when present,
|
|
else falls back to anything found in the price series at evaluation time."""
|
|
try:
|
|
from .themes import THEME_SYMBOLS
|
|
out: list[str] = []
|
|
if isinstance(THEME_SYMBOLS, dict):
|
|
for v in THEME_SYMBOLS.values():
|
|
if isinstance(v, str):
|
|
out.append(v)
|
|
elif isinstance(v, list):
|
|
for s in v:
|
|
if isinstance(s, str):
|
|
out.append(s)
|
|
elif isinstance(THEME_SYMBOLS, list):
|
|
for s in THEME_SYMBOLS:
|
|
if isinstance(s, str):
|
|
out.append(s)
|
|
return sorted(set(out))
|
|
except Exception: # pragma: no cover - registry unavailable
|
|
return []
|
|
|
|
|
|
@dataclass
|
|
class DataCoverage:
|
|
"""Per-source earliest-available date summary for one requested window."""
|
|
|
|
source: str
|
|
available: bool
|
|
earliest: Optional[dt.date] = None
|
|
latest: Optional[dt.date] = None
|
|
detail: str = ""
|
|
missing_count: int = 0
|
|
|
|
|
|
@dataclass
|
|
class BacktestReadiness:
|
|
"""Strict PIT readiness verdict + recommended default dates."""
|
|
|
|
ready: bool
|
|
recommended_start: Optional[str] = None
|
|
recommended_end: Optional[str] = None
|
|
reason: str = ""
|
|
missing: list[str] = field(default_factory=list)
|
|
coverage: list[DataCoverage] = field(default_factory=list)
|
|
timezone: str = "Asia/Bangkok"
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"ready": self.ready,
|
|
"recommended_start": self.recommended_start,
|
|
"recommended_end": self.recommended_end,
|
|
"reason": self.reason,
|
|
"missing": self.missing,
|
|
"coverage": [
|
|
{
|
|
"source": c.source,
|
|
"available": c.available,
|
|
"earliest": c.earliest.isoformat() if c.earliest else None,
|
|
"latest": c.latest.isoformat() if c.latest else None,
|
|
"detail": c.detail,
|
|
"missing_count": c.missing_count,
|
|
}
|
|
for c in self.coverage
|
|
],
|
|
"timezone": self.timezone,
|
|
}
|
|
|
|
|
|
def _price_coverage(series: dict[str, Any]) -> DataCoverage:
|
|
"""Earliest/latest trading date shared across the available symbols.
|
|
|
|
Uses the intersection of symbol availability so that the recommended start
|
|
is a date every held symbol can actually be valued, not just one symbol.
|
|
"""
|
|
min_dates: list[dt.date] = []
|
|
max_dates: list[dt.date] = []
|
|
for sym, s in series.items():
|
|
bars = (s or {}).get("bars", [])
|
|
if not bars:
|
|
continue
|
|
dates = [b.get("date") for b in bars if b.get("date")]
|
|
dates = [d for d in dates if d is not None]
|
|
if not dates:
|
|
continue
|
|
parsed = [_parse_date(d) for d in dates]
|
|
min_dates.append(min(parsed))
|
|
max_dates.append(max(parsed))
|
|
if not min_dates:
|
|
return DataCoverage(
|
|
source="price", available=False, detail="no price bars on disk",
|
|
)
|
|
return DataCoverage(
|
|
source="price",
|
|
available=True,
|
|
earliest=max(min_dates),
|
|
latest=min(max_dates),
|
|
detail=f"{len(min_dates)} symbols",
|
|
)
|
|
|
|
|
|
def evaluate_readiness(
|
|
*,
|
|
factor_store: Any = None,
|
|
siamchart_store: Any = None,
|
|
price_series: Optional[dict[str, Any]] = None,
|
|
start: Optional[str] = None,
|
|
end: Optional[str] = None,
|
|
) -> BacktestReadiness:
|
|
"""Evaluate strict PIT readiness and derive recommended default dates.
|
|
|
|
Args:
|
|
factor_store: a FactorVintageStore (or object exposing ``series(key)``
|
|
and ``value_at(key, as_of)``).
|
|
siamchart_store: a SiamchartVintageStore exposing ``list_ids()`` and
|
|
``snapshot_at(as_of)``.
|
|
price_series: the Yahoo price series dict ``{sym: {bars: [...]}}``.
|
|
start/end: optional explicit window; if given, readiness is evaluated
|
|
only against ``[start, end]``.
|
|
|
|
"Ready" requires, within the window:
|
|
1. every registry factor has a released value by the start date;
|
|
2. a Siamchart snapshot retrieved no later than the start date;
|
|
3. at least one executable price in the window for every required symbol.
|
|
Missing inputs are reported explicitly rather than silently skipped.
|
|
"""
|
|
miss: list[str] = []
|
|
cov: list[DataCoverage] = []
|
|
|
|
today = yesterday_bangkok()
|
|
# When an explicit start is requested, coverage is evaluated *at* that
|
|
# start (the inputs must be knowable by then). Otherwise evaluate through
|
|
# the end date to discover whether a usable window exists at all.
|
|
if start is not None:
|
|
coverage_cutoff = _parse_date(start)
|
|
else:
|
|
coverage_cutoff = _date_from_ts(end) or today
|
|
|
|
# ---------------- price ----------------
|
|
if price_series is None:
|
|
from .simulation import load_price_snapshot
|
|
try:
|
|
price_series = load_price_snapshot()
|
|
except Exception as exc: # SimulationError / OSError / JSON
|
|
price_series = {}
|
|
cov.append(DataCoverage(
|
|
source="price", available=False, detail=f"cannot load: {exc}",
|
|
))
|
|
miss.append("price")
|
|
fac_cov, fac_miss, _earliest = _factor_coverage(
|
|
factor_store, coverage_cutoff
|
|
)
|
|
cov.extend(fac_cov)
|
|
miss.extend(fac_miss)
|
|
return BacktestReadiness(
|
|
ready=False, reason="price coverage missing", missing=miss,
|
|
coverage=cov, recommended_end=today.isoformat(),
|
|
)
|
|
pc = _price_coverage(price_series)
|
|
cov.append(pc)
|
|
if not pc.available:
|
|
miss.append("price")
|
|
|
|
# ---------------- factor ----------------
|
|
fac_cov, fac_miss, fac_earliest = _factor_coverage(
|
|
factor_store, coverage_cutoff
|
|
)
|
|
cov.extend(fac_cov)
|
|
miss.extend(fac_miss)
|
|
|
|
# ---------------- siamchart ----------------
|
|
scv, sc_earliest = _siamchart_coverage(siamchart_store, coverage_cutoff)
|
|
cov.append(scv)
|
|
if not scv.available:
|
|
miss.append("siamchart")
|
|
|
|
# derive recommended start = the latest earliest-available date among the
|
|
# inputs (the point at which *all* of them are simultaneously available).
|
|
candidates = [
|
|
d for d in (fac_earliest, sc_earliest, pc.earliest)
|
|
if d is not None
|
|
]
|
|
recommended_end = _date_from_ts(end) or today
|
|
if start is not None:
|
|
recommended_start = _parse_date(start)
|
|
elif candidates:
|
|
recommended_start = max(candidates)
|
|
else:
|
|
recommended_start = None
|
|
|
|
# cap recommended start so it never exceeds the end
|
|
if recommended_start and recommended_end and recommended_start > recommended_end:
|
|
recommended_start = recommended_end
|
|
|
|
# explicit window requested: readiness is whether the window is covered
|
|
if start is not None:
|
|
s = _parse_date(start)
|
|
e_ = _parse_date(end) if end else today
|
|
ready = (not miss) and s <= e_
|
|
else:
|
|
ready = (not miss) and bool(recommended_start)
|
|
|
|
reason = ""
|
|
rs = recommended_start.isoformat() if recommended_start else None
|
|
re_iso = recommended_end.isoformat()
|
|
if ready:
|
|
reason = (
|
|
f"strict PIT coverage from {rs} to {re_iso}"
|
|
)
|
|
elif miss:
|
|
reason = "missing inputs: " + ", ".join(sorted(set(miss)))
|
|
else:
|
|
reason = "no usable PIT-ready window"
|
|
|
|
return BacktestReadiness(
|
|
ready=ready,
|
|
recommended_start=recommended_start.isoformat() if recommended_start else None,
|
|
recommended_end=re_iso,
|
|
reason=reason,
|
|
missing=sorted(set(miss)),
|
|
coverage=cov,
|
|
)
|
|
|
|
|
|
def _factor_coverage(
|
|
store: Any, cutoff: Optional[dt.date]
|
|
) -> tuple[list[DataCoverage], list[str], Optional[dt.date]]:
|
|
"""Earliest released date across all registry factors, or missing list."""
|
|
if store is None:
|
|
return [
|
|
DataCoverage(
|
|
source="factor", available=False, detail="factor store not provided",
|
|
)
|
|
], ["factor"], None
|
|
factors = _factor_keys_required()
|
|
earliest_dates: list[dt.date] = []
|
|
missing_factor: list[str] = []
|
|
cutoff_ts = None
|
|
if cutoff is not None:
|
|
cutoff_ts = dt.datetime.combine(cutoff, dt.time.min, tzinfo=_TZ_BANGKOK)
|
|
for key in factors:
|
|
try:
|
|
rows = store.series(key)
|
|
except Exception:
|
|
rows = []
|
|
release_dates = [
|
|
_date_from_ts(r.get("released_at")) or _date_from_ts(r.get("observed_at"))
|
|
for r in rows
|
|
]
|
|
release_dates = [d for d in release_dates if d is not None]
|
|
if cutoff_ts is not None:
|
|
release_dates = [
|
|
d for d in release_dates
|
|
if _release_le(d, cutoff_ts)
|
|
]
|
|
if not release_dates:
|
|
missing_factor.append(key)
|
|
continue
|
|
if cutoff_ts is None:
|
|
earliest_dates.append(min(release_dates))
|
|
else:
|
|
earliest_dates.append(min(release_dates))
|
|
cov = DataCoverage(
|
|
source="factor",
|
|
available=not missing_factor,
|
|
earliest=min(earliest_dates) if earliest_dates else None,
|
|
missing_count=len(missing_factor),
|
|
detail=(
|
|
f"{len(earliest_dates)}/{len(factors)} factors have released values"
|
|
if earliest_dates
|
|
else "no factor has a released value"
|
|
),
|
|
)
|
|
# umbrella missing token so consumers see "factor" plus per-key detail
|
|
missing_tokens: list[str] = ["factor"] if missing_factor else []
|
|
missing_tokens.extend(missing_factor)
|
|
return [cov], missing_tokens, (min(earliest_dates) if earliest_dates else None)
|
|
|
|
|
|
def _release_le(d: dt.date, cutoff_dt: dt.datetime) -> bool:
|
|
"""True if a release date is at/before the cutoff (same-day counts)."""
|
|
day = dt.datetime.combine(d, dt.time.min, tzinfo=_TZ_BANGKOK)
|
|
return day <= cutoff_dt
|
|
|
|
|
|
def _siamchart_coverage(
|
|
store: Any, cutoff: Optional[dt.date] = None
|
|
) -> tuple[DataCoverage, Optional[dt.date]]:
|
|
"""Earliest retrieved Siamchart snapshot date (or missing).
|
|
|
|
When ``cutoff`` is given, the snapshot must have been retrieved by that
|
|
date (strict PIT: it must be knowable at the requested start).
|
|
"""
|
|
if store is None:
|
|
return DataCoverage(
|
|
source="siamchart", available=False, detail="siamchart store not provided"
|
|
), None
|
|
try:
|
|
ids = store.list_ids()
|
|
except Exception:
|
|
ids = []
|
|
if not ids:
|
|
return DataCoverage(
|
|
source="siamchart", available=False,
|
|
detail="no snapshots in siamchart vintage store",
|
|
), None
|
|
# Collect retrieval timestamps of every stored snapshot. The real store
|
|
# exposes its manifest; a fake may only answer snapshot_at(now) (newest).
|
|
retrieved_dates: list[dt.date] = []
|
|
try:
|
|
manifest = store._load_manifest() # real SiamchartVintageStore
|
|
for e in manifest.get("snapshots", {}).values():
|
|
d = _date_from_ts(e.get("retrieved_at"))
|
|
if d is not None:
|
|
retrieved_dates.append(d)
|
|
except Exception:
|
|
retrieved_dates = []
|
|
if not retrieved_dates:
|
|
# fallback: fake / minimal store -> newest snapshot's own retrieved_at
|
|
try:
|
|
snap = 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"))
|
|
if d is not None:
|
|
retrieved_dates.append(d)
|
|
except Exception:
|
|
pass
|
|
if not retrieved_dates:
|
|
return DataCoverage(
|
|
source="siamchart", available=True, detail=f"{len(ids)} snapshots stored"
|
|
), None
|
|
earliest = min(retrieved_dates)
|
|
if cutoff is not None:
|
|
known_by_cutoff = [d for d in retrieved_dates if d <= cutoff]
|
|
if not known_by_cutoff:
|
|
return DataCoverage(
|
|
source="siamchart", available=False, earliest=earliest,
|
|
detail=f"no snapshot retrieved by {cutoff.isoformat()}",
|
|
), earliest
|
|
earliest = min(known_by_cutoff)
|
|
return DataCoverage(
|
|
source="siamchart", available=True, earliest=earliest,
|
|
detail=f"{len(ids)} snapshots stored",
|
|
), earliest
|