feat(scoring): flexible source fallback (no board crash) + per-source calc audit (Q2, Q3)

Q2 flexible scoring: _fetch_with_cache now degrades instead of raising
DashboardError — a source that fails with no cached value returns {} so the
theme scorer drops that source's factors; a previously-good value is kept as
stale by the daily cache. Verified: all-sources-down still builds 13 themes.

Q3 per-source audit: new themes.factor_source_breakdown(fetched, theme) shows
per factor source/raw/normalized/weight/contribution; dashboard exposes
fetch_data + factor_sources; per-symbol modal renders symbolDetail.factor_sources
(e.g. retail: te_thailand ยอดขายปลีก -14.5 -> -1.0 x 0.7 = -0.7).

Suite 369 green; independent review passed: true.
Q1 (HAR for deferred sources) spike recorded: method works, REIC needs deeper
interaction; NBTC 403 likely unbpassable without a session.
This commit is contained in:
Kunthawat Greethong
2026-08-29 12:15:54 +07:00
parent 51400f20c7
commit 7f175e5a05
10 changed files with 216 additions and 20 deletions

View File

@@ -717,6 +717,7 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
from app import daily_cache
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
current = app.extensions.get("tourism_result")
dash = {}
try:
dash = RealDashboard((current or {}).get("signals", []), cache).build()
theme_surprises = {t["id"]: t.get("surprise") for t in dash.get("themes", [])}
@@ -738,6 +739,27 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
latest_price=price, price_date=price_date,
momentum=themes_mod._load_momentum(),
)
# per-source factor-level audit: for each theme this symbol belongs to,
# show source -> raw -> normalized -> weight -> contribution so the owner
# sees exactly how each source scored and how the weights were applied.
fetch_data = dash.get("fetch_data", {})
detail["factor_sources"] = {
tid: themes_mod.factor_source_breakdown(fetch_data, tid)
for tid in detail.get("themes", [])
}
# fallback: if the dashboard fetch was empty (degraded), rebuild it once
if not fetch_data:
try:
from app import daily_cache as _dc
cache2 = _dc.DailyCache()
dash2 = RealDashboard((current or {}).get("signals", []), cache2).build()
fetch_data2 = dash2.get("fetch_data", {})
detail["factor_sources"] = {
tid: themes_mod.factor_source_breakdown(fetch_data2, tid)
for tid in detail.get("themes", [])
}
except Exception:
pass
return jsonify(detail)
@app.get("/api/v1/backtest/readiness")

View File

@@ -32,13 +32,25 @@ def _fetch_with_cache(
fetcher: Callable[[], dict],
label: str,
) -> dict:
"""Fetch a source, degrading gracefully instead of crashing the board.
Flexible-scoring rule (user decision): if a source cannot be fetched AND
there is no previously-good cached value, return an empty dict so the
theme scorer simply drops that source's factors — the board still renders
from the sources that are available. If a previous good value exists the
daily cache returns it as stale (so the theme keeps using the last known
numbers). We never let one upstream failure take down the whole board.
"""
import logging
log = logging.getLogger("set50.dashboard")
try:
val = cache.fetch_or_stale(key, fetcher)
if isinstance(val, dict) and "data" in val:
return val["data"]
return val or {}
except Exception as exc:
raise DashboardError(f"no real data for {label}: {exc}") from exc
log.warning("dropping source %r (no cached value): %s", label, exc)
return {}
def _zscore(value: float, mean: float, stdev: float) -> float:
@@ -174,6 +186,16 @@ class RealDashboard:
"macro": macro_d,
"board": board,
"sources": sources,
# raw per-module fetched data (fetch_module -> source dict) so the
# symbol-detail endpoint can compute a per-source factor audit.
"fetch_data": fetched,
# per-theme factor-level contribution (source -> raw -> normalized ->
# weight -> contribution) so the owner can audit exactly how each
# theme score was built and tune weights.
"factor_sources": {
tid: themes_mod.factor_source_breakdown(fetched, tid)
for tid in themes_mod.THEMES
},
# unambiguous split so "7 vs 5" style confusion is impossible:
# distinct provider rows vs raw FACTORS-registry factor keys.
"source_summary": {

View File

@@ -296,6 +296,61 @@ def compute_theme_surprises(fetched: dict, tourism_surprise: Optional[float] = N
return out
def factor_source_breakdown(fetched: dict, theme_id: str) -> list:
"""Per-factor contribution detail for one theme (what the user asked for).
For each FACTOR a theme references, show exactly how it contributed to the
theme surprise:
- source: the fetch-module name (e.g. 'macro_thai', 'te_thailand')
- name_th: the factor's Thai label
- raw: the raw collected value
- normalized: the sign/center/span-normalized score in [-1, 1]
- weight: the per-theme weight (positive magnitude; direction is in sign)
- contribution: weight * normalized
- missing: True when the source had no value so the factor was dropped
This is the audit trail that lets the owner see "which source scored what,
and how the weight was applied" and tune weights/thesis more easily.
"""
from . import factors as factors_mod
tdef = THEMES.get(theme_id, {})
rows = []
for ref in tdef.get("factors", []):
fkey = ref.get("key")
fact = factors_mod.FACTORS.get(fkey)
if not fact:
continue
fetch_mod = fact.get("fetch")
val = factors_mod.factor_value(fact, fetched.get(fetch_mod))
w = float(ref.get("weight", 1.0))
if val is None:
rows.append({
"factor": fkey, "source": fetch_mod,
"name_th": fact.get("name_th", fkey),
"frequency": fact.get("frequency", "monthly"),
"sign": fact.get("sign", 1),
"raw": None, "normalized": None, "weight": w,
"contribution": None, "missing": True,
})
continue
norm = factors_mod.normalize(val, sign=fact.get("sign", 1),
center=fact.get("center", 0.0),
span=fact.get("span", 10.0))
rows.append({
"factor": fkey, "source": fetch_mod,
"name_th": fact.get("name_th", fkey),
"frequency": fact.get("frequency", "monthly"),
"sign": fact.get("sign", 1),
"raw": round(val, 4) if val is not None else None,
"normalized": norm,
"weight": w,
"contribution": round(w * (norm or 0.0), 4) if norm is not None else None,
"missing": False,
})
return rows
_SIAMCHART_GROWTH_W = 1.5 # R1 (PEAD): EPS-growth dominates value; literature (Bernard-Thomas 1990,
# Livnat-Mendenhall 2006) shows drift follows earnings, not just yield.
_SIAMCHART_YIELD_W = 2.0 # dividend floor for value names