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:
@@ -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")
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user