diff --git a/backend/app/backtest_readiness.py b/backend/app/backtest_readiness.py index 18fbd19..f5f255a 100644 --- a/backend/app/backtest_readiness.py +++ b/backend/app/backtest_readiness.py @@ -196,11 +196,18 @@ def evaluate_readiness( 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; + "Ready" requires only a *usable* window — NOT that every factor is present + (the owner's rule: scoring is deliberately flexible, a theme scores from + whatever subset of factors was knowable that day, so a backtest may run as + soon as there is enough data to estimate, even if some sources are missing + or don't provide history): + 1. at least one 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. + Missing inputs are still reported for transparency, but a partial window is + runnable rather than rejected. ``recommended_start`` is the EARLIEST date at + which usable data exists (the oldest history the system holds), and + ``recommended_end`` defaults to the last complete trading day (yesterday). """ miss: list[str] = [] cov: list[DataCoverage] = [] @@ -252,46 +259,77 @@ def evaluate_readiness( 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 start = the EARLIEST date a run is genuinely executable from — + # i.e. the oldest price data (you must be able to value the holdings). Factors + # and Siamchart are flexible (they may start later; scoring just uses whatever + # subset was known by each day), but price is the hard floor. So the default + # start is the oldest available executable price, per the owner's wish to + # backtest over the oldest history the system holds. recommended_end = _date_from_ts(end) or today if start is not None: recommended_start = _parse_date(start) - elif candidates: - recommended_start = max(candidates) + elif pc.available and pc.earliest is not None: + recommended_start = pc.earliest else: - recommended_start = None + candidates = [d for d in (fac_earliest, sc_earliest) if d is not None] + recommended_start = min(candidates) if candidates else 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 + # ---- usable-window gate (NOT all-factors-present) --------------------- + # Scoring is flexible: a theme scores from whatever factors were known by + # as_of, so the backtest is runnable once we have a price series, a Siamchart + # snapshot, and *at least one* released factor. Missing individual factors + # are still reported in `missing` for transparency but do NOT block the run. + hard_block = ( + not pc.available + or not scv.available + or fac_earliest is None # no factor has any released value at all + ) if start is not None: s = _parse_date(start) e_ = _parse_date(end) if end else today # price must be executable AT the start: an explicit start that predates - # all usable price history would otherwise manufacture a false PIT - # window (factor + Siamchart are already checked at start above). + # all usable price history would otherwise manufacture a false PIT window. if pc.available and pc.earliest is not None and s < pc.earliest: + hard_block = True miss.append("price") - ready = (not miss) and s <= e_ + # an explicit start with no factor released by it is unusable + if fac_earliest is None or s < fac_earliest: + hard_block = True + if "factor" not in miss: + miss.append("factor") + ready = (not hard_block) and s <= e_ else: - ready = (not miss) and bool(recommended_start) + ready = (not hard_block) and bool(recommended_start) + if not scv.available and "siamchart" not in miss: + miss.append("siamchart") + if not pc.available and "price" not in miss: + miss.append("price") + if fac_earliest is None and "factor" not in miss: + miss.append("factor") reason = "" rs = recommended_start.isoformat() if recommended_start else None re_iso = recommended_end.isoformat() if ready: + fac_missing_n = fac_cov[0].missing_count if (fac_cov and fac_cov[0].source == "factor") else 0 reason = ( - f"strict PIT coverage from {rs} to {re_iso}" + f"usable PIT coverage from {rs} to {re_iso}" + + (f" ({fac_missing_n} factors without history)" + if fac_missing_n else "") ) - elif miss: - reason = "missing inputs: " + ", ".join(sorted(set(miss))) + elif hard_block: + blockers = [] + if not pc.available: + blockers.append("price") + if not scv.available: + blockers.append("siamchart") + if fac_earliest is None: + blockers.append("no factor has any released value") + reason = "missing hard inputs: " + ", ".join(blockers) else: reason = "no usable PIT-ready window" diff --git a/backend/app/pit_scorer.py b/backend/app/pit_scorer.py index 382b0ef..d5f85c9 100644 --- a/backend/app/pit_scorer.py +++ b/backend/app/pit_scorer.py @@ -123,19 +123,29 @@ class PitScoreProvider: # -- theme PIT score ------------------------------------------------- def theme_surprise_report(self, theme_key: str, as_of: str) -> dict[str, Any]: - """Weighted-average theme surprise from PIT factor values only. + """Weighted-average theme surprise from the factors that ARE released by + ``as_of`` — flexible, not all-or-nothing. - Returns {theme, surprise, blocked, partial_pit}. ``blocked`` is True - when at least one of the theme's factors had no value released by - ``as_of`` — a caller must never treat a blocked theme as PIT. + ``theme_surprise_report`` intentionally matches the live dashboard's + ``compute_theme_surprises`` semantics: any factor with no PIT value by + ``as_of`` is simply omitted from the weighted average, so a theme scores + from whatever subset of its factors was genuinely knowable that day and + is never blocked by a single missing source. ``blocked`` is only True + when NO factor in the theme had a released value by ``as_of`` (nothing + to compute). ``used``/``total`` report how many factors contributed, so + callers can judge how thin the estimate is. + + PIT integrity is preserved: we only ever use values released <= ``as_of`` + (``factor_at``), never future knowledge. """ factors = self.theme_factor_map.get(theme_key, []) if not factors: - return {"theme": theme_key, "surprise": None, "blocked": True, "partial_pit": True} + return {"theme": theme_key, "surprise": None, "blocked": True, + "partial_pit": True, "used": 0, "total": 0} from . import factors as factors_mod weighted = 0.0 w_sum = 0.0 - blocked = False + used = 0 for spec in factors: fkey = spec.get("key") if not isinstance(fkey, str) or not fkey: @@ -145,8 +155,7 @@ class PitScoreProvider: continue value = self.factor_at(fkey, as_of) if value is None: - blocked = True - continue + continue # not released yet -> flexibly skip (no blanket block) norm = factors_mod.normalize( value, sign=fact.get("sign", 1), center=fact.get("center", 0.0), span=fact.get("span", 10.0), @@ -155,17 +164,21 @@ class PitScoreProvider: continue weighted += spec.get("weight", 1.0) * norm w_sum += abs(spec.get("weight", 1.0)) - surprise: Optional[float] + used += 1 + total = len([f for f in factors if isinstance(f.get("key"), str) and f.get("key")]) if w_sum == 0: surprise = None - blocked = True + blocked = True # nothing had a released value -> cannot score else: surprise = round(min(1.0, max(-1.0, weighted / w_sum)), 3) + blocked = False return { "theme": theme_key, "surprise": surprise, "blocked": blocked, - "partial_pit": blocked, + "partial_pit": used < total, + "used": used, + "total": total, } # -- per-symbol combined board as of a date ---------------------------- @@ -189,6 +202,7 @@ class PitScoreProvider: theme_scores: dict[str, dict[str, float]] = {} blocked_themes: list[str] = [] + partial_themes: list[str] = [] fv = self.siamchart_factor_view(as_of) # reverse-map symbol -> fundamental for quality_within_theme's factor view fv_for_quality = { @@ -203,6 +217,8 @@ class PitScoreProvider: blocked_themes.append(tid) theme_scores[tid] = {} continue + if rep["partial_pit"]: + partial_themes.append(tid) q = {} for sym in themes_mod.THEME_SYMBOLS.get(tid, set()): if sym not in fv: @@ -212,7 +228,7 @@ class PitScoreProvider: theme_scores[tid] = q combined = themes_mod.combine_score(list(theme_scores.values()), siamchart_score) - is_full_pit = not blocked_themes + is_full_pit = not blocked_themes and not partial_themes # fundamental is PIT only when a siamchart vintage store is wired; # otherwise the current snapshot makes the overall result partial. fundamental_pit = self.siamchart_store is not None @@ -227,11 +243,17 @@ class PitScoreProvider: "dividend_yield": fm.get("dividend_yield") or 0.0, "pit_meta": { "pit": is_full_pit and fundamental_pit, - "partial_pit": not fundamental_pit, + "partial_pit": (not fundamental_pit) or bool(partial_themes), "blocked_theme": blocked_themes, - "note": ("theme surprises PIT + siamchart fundamental PIT (store)" - if fundamental_pit else - "theme surprises PIT; siamchart fundamental partial (current snapshot)"), + "partial_themes": partial_themes, + "note": ( + "theme surprises PIT + siamchart fundamental PIT (store)" + if (fundamental_pit and not partial_themes) else + ("theme surprises PIT; some themes partial (missing factors); " + "siamchart fundamental PIT (store)" + if fundamental_pit else + "theme surprises PIT; siamchart fundamental partial (current snapshot)") + ), }, } # fall back to the current board for any symbol the PIT path could not diff --git a/backend/tests/test_backtest_readiness.py b/backend/tests/test_backtest_readiness.py index 706abe9..357f4a6 100644 --- a/backend/tests/test_backtest_readiness.py +++ b/backend/tests/test_backtest_readiness.py @@ -123,7 +123,9 @@ class NoSiamchartSnapshotTest(unittest.TestCase): class RecommendedStartTest(unittest.TestCase): - def test_start_is_latest_of_first_ready_dates(self): + def test_start_is_oldest_runnable_price(self): + # Owner rule: backtest default start = oldest history the system holds + # (the oldest executable price), NOT the date every input co-exists. # factors ready 2025-01-01, siamchart ready 2025-06-01, price from 2024 store = full_factor_store("2025-01-01") sc = FakeSiamchartStore(["2025-06-01T09:00:00+07:00"]) @@ -132,7 +134,9 @@ class RecommendedStartTest(unittest.TestCase): factor_store=store, siamchart_store=sc, price_series=series ) self.assertTrue(res.ready) - self.assertEqual(res.recommended_start, "2025-06-01") + # oldest runnable history = oldest price (2024-01-01), even though + # factors/siamchart start later (flexible scoring uses what's known). + self.assertEqual(res.recommended_start, "2024-01-01") self.assertEqual(res.recommended_end, today_iso()) def test_start_limited_by_price_when_price_latest(self): diff --git a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md index cd7bbfc..5e29830 100644 --- a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md +++ b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md @@ -88,3 +88,24 @@ needs-deeper-capture and left as a decision for the owner. - Note: a **concurrent process** also landed `thai_trade.py` (external-sector exports/imports/current-account) and external_* factors mid-session; its 3 initially-broken tests were fixed to reach the 360-green baseline here. + +## Flexible PIT backtest (owner rule — 2026-08-30) +Owner clarified the backtest contract: it must run as soon as there is enough +data to estimate an investment, NOT only when EVERY factor has PIT history. +Some sources may not provide deep history — scoring is deliberately flexible +(a theme scores from whatever subset of factors was knowable that day). + +Implemented (commit 2026-08-30): +- `backtest_readiness.evaluate_readiness`: readiness is now a *usable* window + (price + Siamchart + >=1 released factor), not all-factors-present. Missing + factors are still reported in `missing` for transparency but no longer block. + `recommended_start` = oldest executable price (the oldest history held); + `recommended_end` = last complete trading day (yesterday Bangkok). +- `pit_scorer.theme_surprise_report`: flexible — skips any factor with no release + by `as_of` (no blanket theme block); `blocked` only when NO factor has a value. + `pit_meta.partial_pit` reflects themes that scored from a partial subset. +- Verified: `/api/v1/backtest/readiness` → ready=true, recommended 2024-01-03 → + 2026-08-29. `POST /api/v1/backtest/run` (default window) → 201 full result + (final_equity 1,117,243.95 on 1M), no 400 from missing factors. +- Tests: test_backtest_readiness updated to earliest-runnable semantics; full + suite 369 green.