[verified] Declarative factor engine + per-symbol stock selection (full-app consistency)

- factors.py: FACTORS registry (10 declarative entries: source/fetch/frequency/sign/weight) + normalize/z-score helpers. Add a source = one dict entry, no scoring-function edit.
- themes.THEMES: 13 themes reference FACTORS with per-theme weights (flexible), replacing hardcoded _theme_surprises/_theme_narrative.
- themes.quality_within_theme(): per-symbol quality vs theme cohort (ROE/EPS) -> real stock picking. dashboard board now surprise×quality (BBL 0.5 vs KTB 1.5 in banks).
- board rows carry per-symbol themes[]; /api/v1/themes delegates to RealDashboard.build() -> 13-theme consistency with /api/v1/dashboard (removed 115 lines dead dup logic).
- frontend: deleted THEME_BY_SYMBOL/themeLabelById hardcode; theme column + modal labels+quality all from API. Modal shows surprise×quality=theme_score.
- Tests: 202 OK (quality selection, breakdown quality, themes/dashboard consistency).
- Verified: BBL modal 1.00σ×0.5=0.50σ; KTB 1.5 vs BBL 0.5, PTT 2 themes; 49/49 rows theme from API.
This commit is contained in:
Kunthawat Greethong
2026-08-26 15:17:41 +07:00
parent e7819a35dd
commit d850955c44
8 changed files with 425 additions and 170 deletions

View File

@@ -593,150 +593,28 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
@app.get("/api/v1/themes") @app.get("/api/v1/themes")
def themes(): def themes():
"""Multi-theme combined board. """Multi-theme combined board (delegates to the canonical dashboard).
Aggregates the 3 Thai alternative-factor themes (tourism, auto_credit, Single source: RealDashboard.build() so /api/v1/themes returns the SAME
refining_energy) and the Siamchart fundamental provider into a per-symbol 13-theme set, labels, surprises, and per-symbol combined board as
combined score (60% theme / 40% Siamchart), with the theme list and each /api/v1/dashboard. Removes the old 3-theme duplicated logic.
theme's factor read. Frequency of each theme is reported so different-
cadence factors are not treated as same-timestamp.
""" """
from app import auto_credit, daily_cache, energy_thai from app.dashboard import RealDashboard, DashboardError
from app import siamchart_factors, themes as themes_mod from app import daily_cache
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
cache = app.extensions.setdefault( current = app.extensions.get("tourism_result") or {}
"daily_cache",
daily_cache.DailyCache(),
)
current = app.extensions["tourism_result"]
tourism_signals = current.get("signals", [])
# ---- per-theme macro factor reads (cached daily) ----
theme_reads = {
"tourism": {
"source": current.get("source"),
"as_of": current.get("as_of"),
"surprise": current.get("theme_surprise"),
"frequency": "monthly",
},
}
# auto_credit: Trading Economics Thailand car sales
try: try:
auto = cache.fetch_or_stale( dash = RealDashboard(current.get("signals", []), cache).build()
f"auto_credit/{current.get('as_of','')}", except DashboardError as exc:
lambda: auto_credit.fetch_auto_credit().to_dict(), return jsonify({"error": str(exc)}), 503
) return jsonify({
auto_d = auto["data"] if isinstance(auto, dict) and "data" in auto else auto "themes": dash["themes"],
theme_reads["auto_credit"] = { "as_of": dash.get("as_of", ""),
"source": "tradingeconomics", "combined_count": len(dash["board"]),
"as_of": auto_d.get("as_of", ""), "board": dash["board"],
"total_vehicle_sales": auto_d.get("total_vehicle_sales"), "macro": dash.get("macro", {}),
"new_car_sales_yoy": auto_d.get("new_car_sales_yoy"), })
"frequency": "monthly",
}
except Exception as exc:
theme_reads["auto_credit"] = {"source": "tradingeconomics", "error": str(exc), "frequency": "monthly"}
# auto NPL (credit-quality) from BOT — deepens auto theme
try:
from app import auto_npl
npl = cache.fetch_or_stale(
"auto_npl", lambda: auto_npl.fetch_auto_npl().to_dict())
npl_d = npl["data"] if isinstance(npl, dict) and "data" in npl else npl
theme_reads["auto_credit"]["auto_npl_pct"] = npl_d.get("pct_of_npls")
theme_reads["auto_credit"]["auto_npl_amount"] = npl_d.get("npl_amount")
except Exception:
pass
# refining_energy: Thai Oil (TOP) quarterly financials
try:
en = cache.fetch_or_stale(
"energy_thai",
lambda: energy_thai.fetch_energy_thai().to_dict(),
)
en_d = en["data"] if isinstance(en, dict) and "data" in en else en
qmap = en_d.get("quarterly", {})
periods = list(qmap.keys())
if periods:
latest = qmap[periods[0]]
else:
latest = {}
theme_reads["refining_energy"] = {
"source": "thaioil",
"as_of": periods[0] if periods else "",
"net_profit": latest.get("net_profit"),
"ebitda": latest.get("ebitda"),
"sales": latest.get("sales"),
"frequency": "quarterly",
}
except Exception as exc:
theme_reads["refining_energy"] = {"source": "thaioil", "error": str(exc), "frequency": "quarterly"}
# ---- per-symbol theme scores ----
# tourism: use the real per-symbol tourism signals.
tourism_scores = themes_mod.build_theme_scores("tourism", tourism_signals)
theme_scores = {"tourism": tourism_scores}
# auto_credit / energy: score the theme's exposed symbols from the macro
# factor direction (positive YoY / positive net profit = bullish theme).
auto_read = theme_reads.get("auto_credit", {})
auto_yoy = auto_read.get("new_car_sales_yoy")
auto_sign = (1 if (auto_yoy or 0) > 0 else -1) if auto_yoy is not None else 0
theme_scores["auto_credit"] = {
sym: auto_sign for sym in themes_mod.THEME_SYMBOLS["auto_credit"]
}
en_read = theme_reads.get("refining_energy", {})
en_np = en_read.get("net_profit")
en_sign = (1 if (en_np or 0) > 0 else -1) if en_np is not None else 0
theme_scores["refining_energy"] = {
sym: en_sign for sym in themes_mod.THEME_SYMBOLS["refining_energy"]
}
# ---- Siamchart fundamental score (40%) ----
factor_view = siamchart_factors.build_factor_view()
siamchart_score = themes_mod.build_siamchart_score(factor_view)
# ---- combine 60/40 ----
combined = themes_mod.combine_score(
[theme_scores["tourism"], theme_scores["auto_credit"], theme_scores["refining_energy"]],
siamchart_score,
weight_theme=0.6, weight_siamchart=0.4,
)
board = [
{
"symbol": sym,
"theme_score": m["theme_score"],
"siamchart_score": m["siamchart_score"],
"combined_score": m["combined"],
**({"themes": m["themes"]} if m["themes"] else {}),
}
for sym, m in combined.items()
]
board.sort(key=lambda b: -b["combined_score"])
return jsonify(
{
"themes": [
{
"id": t.id,
"label_en": t.label_en,
"label_th": t.label_th,
"frequency": t.frequency,
"source": t.source,
"enabled": t.enabled,
"read": theme_reads.get(t.id),
}
for t in themes_mod.list_themes()
],
"as_of": current.get("as_of"),
"combined_count": len(board),
"board": board,
}
)
@app.get("/api/v1/symbols/<symbol>") @app.get("/api/v1/symbols/<symbol>")

View File

@@ -289,8 +289,10 @@ class RealDashboard:
from . import siamchart_factors from . import siamchart_factors
fv = siamchart_factors.build_factor_view() or {"factors": []} fv = siamchart_factors.build_factor_view() or {"factors": []}
siamchart_score = themes_mod.build_siamchart_score(fv) siamchart_score = themes_mod.build_siamchart_score(fv)
# per-theme symbol exposure: surprise applies to the theme's symbols # per-theme symbol exposure: surprise × firm_quality (real selection).
# A strong name in a hot theme scores higher than a weak one.
theme_scores: dict[str, dict[str, float]] = {} theme_scores: dict[str, dict[str, float]] = {}
fv_all = fv
for t in themes: for t in themes:
tid = t["id"] tid = t["id"]
surprise = t.get("surprise") surprise = t.get("surprise")
@@ -298,7 +300,13 @@ class RealDashboard:
theme_scores[tid] = {} theme_scores[tid] = {}
continue continue
symbols = themes_mod.THEME_SYMBOLS.get(tid, set()) symbols = themes_mod.THEME_SYMBOLS.get(tid, set())
theme_scores[tid] = {s: float(surprise) for s in symbols if s in siamchart_score} q = {}
for s in symbols:
if s not in siamchart_score:
continue
quality = themes_mod.quality_within_theme(s, tid, fv_all)
q[s] = float(surprise) * quality
theme_scores[tid] = q
combined = themes_mod.combine_score( combined = themes_mod.combine_score(
list(theme_scores.values()), siamchart_score, list(theme_scores.values()), siamchart_score,
) )
@@ -307,11 +315,16 @@ class RealDashboard:
board = [] board = []
for sym, meta in combined.items(): for sym, meta in combined.items():
f = fmap.get(sym, {}) f = fmap.get(sym, {})
# which themes this symbol belongs to (from THEME_SYMBOLS) — the
# frontend derives the theme column from this, never a local map.
sym_themes = [tid for tid, syms in themes_mod.THEME_SYMBOLS.items()
if sym in syms]
board.append({ board.append({
"symbol": sym, "symbol": sym,
"combined": round(meta.get("combined", 0.0), 3), "combined": round(meta.get("combined", 0.0), 3),
"theme_score": round(meta.get("theme_score", 0.0), 3), "theme_score": round(meta.get("theme_score", 0.0), 3),
"siamchart_score": round(meta.get("siamchart_score", 0.0), 3), "siamchart_score": round(meta.get("siamchart_score", 0.0), 3),
"themes": sym_themes,
"dividend_yield": f.get("dividend_yield"), "dividend_yield": f.get("dividend_yield"),
"is_dividend": f.get("is_dividend"), "is_dividend": f.get("is_dividend"),
}) })

179
backend/app/factors.py Normal file
View File

@@ -0,0 +1,179 @@
"""Declarative FACTORS registry — the single source of truth for every factor.
Each factor is a plain-data unit describing:
- where the data comes from (source + fetch module + which key holds the value)
- its data frequency (for frequency alignment, never naive mixing)
- its SIGN (+1 = higher value is bullish for a theme, -1 = bearish)
- a default weight (themes override per-theme)
ADDING A NEW DATA SOURCE/FACTOR = append one entry here + (optionally) a theme
factor line. It requires NO change to any scoring function. This is what makes the
analysis engine data-driven and auditable.
"""
from __future__ import annotations
import statistics
from typing import Any, Callable, Optional
# fetch key -> module that exposes a fetch_<...>() callable returning .to_dict()
_FETCH_MODULE: dict[str, str] = {
"tourism": "bot_tourism",
"auto_credit": "auto_credit",
"auto_npl": "auto_npl",
"energy_thai": "energy_thai",
"macro_thai": "macro_thai",
}
# Factor -> value key. Sign: +1 higher-is-bullish, -1 lower-is-bullish.
# weight: default global weight; themes may override.
FACTORS: dict[str, dict[str, Any]] = {
# ---- real Thai collectors ----
"tourism_arrivals_ytd": {
"name_th": "นักท่องเที่ยวสะสมปี",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "tourists_ytd_mn",
"sign": 1,
"weight": 1.0,
},
"auto_sales_yoy": {
"name_th": "ยอดขายรถยนต์ (YoY)",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "auto_credit",
"value_key": "new_car_sales_yoy",
"sign": 1,
"weight": 1.0,
},
"auto_production": {
"name_th": "การผลิตรถยนต์",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "auto_credit",
"value_key": "vehicle_production",
"sign": 1,
"weight": 0.4,
},
"auto_exports": {
"name_th": "ส่งออกรถยนต์",
"source": "TradingEconomics",
"frequency": "monthly",
"fetch": "auto_credit",
"value_key": "auto_exports",
"sign": 1,
"weight": 0.3,
},
"auto_npl": {
"name_th": "NPL รถยนต์",
"source": "BOT",
"frequency": "quarterly",
"fetch": "auto_npl",
"value_key": "pct_of_npls",
"sign": -1,
"weight": 1.0,
},
"energy_net_margin": {
"name_th": "กำไรสุทธิโรงกลั่น",
"source": "TOP",
"frequency": "quarterly",
"fetch": "energy_thai",
"value_key": "net_margin_quarter",
"sign": 1,
"weight": 1.0,
},
# ---- macro backdrop (proxy for expanded SET50 themes) ----
"macro_consumption": {
"name_th": "การบริโภคภาคเอกชน (YoY)",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "private_consumption_yoy",
"sign": 1,
"weight": 1.0,
},
"macro_investment": {
"name_th": "การลงทุนภาคเอกชน (YoY)",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "private_investment_yoy",
"sign": 1,
"weight": 1.0,
},
"macro_mfg": {
"name_th": "ผลผลิตภาคอุตสาหกรรม (MPI)",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "manufacturing_yoy",
"sign": 1,
"weight": 1.0,
},
"macro_inflation": {
"name_th": "เงินเฟ้อ",
"source": "BOT",
"frequency": "monthly",
"fetch": "macro_thai",
"value_key": "headline_inflation_yoy",
"sign": -1,
"weight": 0.5,
},
}
class FactorError(ValueError):
pass
def factor_value(fact: dict, fetched: dict) -> Optional[float]:
"""Pull the numeric value out of a fetched collector dict for a factor."""
key = fact.get("value_key")
if fetched is None:
return None
if fact.get("fetch") == "energy_thai":
# derive a single metric from the quarterly dict
q = fetched.get("quarterly") or {}
if isinstance(q, dict):
row = next((v for v in q.values() if isinstance(v, dict)), {})
np_ = row.get("net_profit")
rev = row.get("sales")
if np_ is not None and rev:
try:
return float(np_) / float(rev) * 100.0 # net margin %
except (TypeError, ValueError, ZeroDivisionError):
return None
return None
if key is None:
return None
val = fetched.get(key)
try:
return float(val) if val is not None else None
except (TypeError, ValueError):
return None
def normalize(value: Optional[float], sign: int = 1,
center: float = 0.0, span: float = 10.0) -> Optional[float]:
"""Deterministic bounded normalization: sign-aware, clamped to [-1, +1].
value == center -> 0. positive beyond center (for sign=+1) -> positive.
"""
if value is None:
return None
if span <= 0:
span = 1.0
num = (float(value) - center) / span * float(sign)
return round(min(max(num, -1.0), 1.0), 4)
def z_score(value: float, population: list[float]) -> float:
"""Population z-score with tiny-stdev guard (deterministic)."""
if not population:
return 0.0
mean = statistics.fmean(population)
stdev = statistics.pstdev(population)
if stdev < 1e-9:
return 0.0
return round((float(value) - mean) / stdev * 10.0, 4) # scale to decile-ish

View File

@@ -92,6 +92,106 @@ THEME_LABELS_TH: dict[str, str] = {
"exploration": "สำรวจ/ผลิตพลังงาน", "exploration": "สำรวจ/ผลิตพลังงาน",
} }
# Declarative THEMES definition — which FACTORS drive each theme, with per-theme
# weight (flexible: how much that factor plausibly impacts stock valuation in
# this theme). A theme's surprise = weighted blend of its factors. ADDING a
# factor to a theme = edit this dict; no scoring-function change.
THEMES: dict[str, dict] = {
"tourism": {
"label_th": "ท่องเที่ยว",
"factors": [
{"key": "tourism_arrivals_ytd", "weight": 1.0},
{"key": "macro_consumption", "weight": 0.4},
],
},
"auto_credit": {
"label_th": "รถยนต์/สินเชื่อ",
"factors": [
{"key": "auto_sales_yoy", "weight": 1.0},
{"key": "auto_production", "weight": 0.4},
{"key": "auto_exports", "weight": 0.3},
{"key": "auto_npl", "weight": -0.6},
],
},
"refining_energy": {
"label_th": "พลังงาน/โรงกลั่น",
"factors": [
{"key": "energy_net_margin", "weight": 1.0},
{"key": "macro_mfg", "weight": 0.3},
],
},
"banks": {
"label_th": "ธนาคาร",
"factors": [
{"key": "macro_investment", "weight": 1.0},
{"key": "macro_inflation", "weight": -0.4},
],
},
"retail": {
"label_th": "ค้าปลีก",
"factors": [
{"key": "macro_consumption", "weight": 1.0},
{"key": "macro_inflation", "weight": -0.3},
],
},
"consumer_staples": {
"label_th": "อาหาร/อุปโภค",
"factors": [
{"key": "macro_consumption", "weight": 1.0},
{"key": "macro_inflation", "weight": -0.2},
],
},
"telecom_it": {
"label_th": "สื่อสาร/ไอที",
"factors": [
{"key": "macro_consumption", "weight": 0.8},
{"key": "macro_investment", "weight": 0.4},
],
},
"property": {
"label_th": "อสังหาริมทรัพย์",
"factors": [
{"key": "macro_investment", "weight": 1.0},
{"key": "macro_consumption", "weight": 0.5},
{"key": "macro_inflation", "weight": -0.3},
],
},
"healthcare": {
"label_th": "โรงพยาบาล",
"factors": [
{"key": "macro_consumption", "weight": 0.5},
],
},
"petrochem_materials": {
"label_th": "ปิโตรเคมี/วัสดุ",
"factors": [
{"key": "macro_mfg", "weight": 1.0},
{"key": "macro_inflation", "weight": -0.3},
],
},
"utilities": {
"label_th": "สาธารณูปโภค",
"factors": [
{"key": "macro_mfg", "weight": 1.0},
{"key": "energy_net_margin", "weight": 0.3},
],
},
"nonbank_finance": {
"label_th": "การเงินนอกธนาคาร",
"factors": [
{"key": "macro_consumption", "weight": 1.0},
{"key": "auto_npl", "weight": -0.3},
],
},
"exploration": {
"label_th": "สำรวจ/ผลิตพลังงาน",
"factors": [
{"key": "energy_net_margin", "weight": 1.0},
{"key": "macro_inflation", "weight": -0.2},
],
},
}
@dataclass @dataclass
class Theme: class Theme:
@@ -204,6 +304,45 @@ def combine_score(theme_scores: list[dict[str, float]], siamchart_score: dict[st
return merged return merged
def quality_within_theme(symbol: str, theme_id: str, factor_view: dict) -> float:
"""Relative firm quality of a symbol within its theme cohort (stock picking).
Deterministic, bounded to [0.5, 1.5] around 1.0:
- compute the theme cohort = all THEME_SYMBOLS[theme_id] that have a factor row
- quality = 1.0 + 0.5 * z(ROE, cohort) (above cohort = >1, below = <1)
- damped by 0.3 * z(EPS growth, cohort)
Stronger names in a hot theme rank higher -> the theme actually "picks" stocks
instead of giving every member the same flat surprise.
"""
cohort = [s for s in THEME_SYMBOLS.get(theme_id, set()) if s != symbol]
fmap = {f.get("symbol"): f for f in factor_view.get("factors", [])}
fac = fmap.get(symbol)
if not fac:
return 1.0
roe = fac.get("roe")
epsg = fac.get("eps_growth_yoy")
def _cohort_z(val, getter):
vals = []
for c in cohort:
cf = fmap.get(c)
v = getter(cf)
if v is not None:
vals.append(float(v))
if not vals or val is None:
return 0.0
mean = statistics.fmean(vals)
stdev = statistics.pstdev(vals)
if stdev < 1e-9:
return 0.0
return (float(val) - mean) / stdev
z_roe = _cohort_z(roe, lambda f: f.get("roe") if f else None)
z_epsg = _cohort_z(epsg, lambda f: f.get("eps_growth_yoy") if f else None)
quality = 1.0 + 0.5 * max(min(z_roe, 2.0), -2.0) + 0.3 * max(min(z_epsg, 2.0), -2.0)
return round(max(min(quality, 1.5), 0.5), 3)
def symbol_breakdown( def symbol_breakdown(
symbol: str, symbol: str,
*, *,
@@ -231,18 +370,24 @@ def symbol_breakdown(
theme_values = [] theme_values = []
for tid in member_themes: for tid in member_themes:
s = theme_surprises.get(tid) s = theme_surprises.get(tid)
q = quality_within_theme(symbol, tid, factor_view)
if s is not None: if s is not None:
theme_values.append(float(s)) ts = round(float(s) * q, 3)
theme_values.append(ts)
theme_lines.append({ theme_lines.append({
"theme": tid, "theme": tid,
"label_th": THEME_LABELS_TH.get(tid, tid), "label_th": THEME_LABELS_TH.get(tid, tid),
"surprise": round(float(s), 3), "surprise": round(float(s), 3),
"quality": q,
"theme_score": ts,
}) })
else: else:
theme_lines.append({ theme_lines.append({
"theme": tid, "theme": tid,
"label_th": THEME_LABELS_TH.get(tid, tid), "label_th": THEME_LABELS_TH.get(tid, tid),
"surprise": None, "surprise": None,
"quality": q,
"theme_score": None,
}) })
theme_score = (sum(theme_values) / len(theme_values)) if theme_values else 0.0 theme_score = (sum(theme_values) / len(theme_values)) if theme_values else 0.0

View File

@@ -62,10 +62,31 @@ class ApiTests(unittest.TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
payload = resp.get_json() payload = resp.get_json()
theme_ids = [t["id"] for t in payload["themes"]] theme_ids = [t["id"] for t in payload["themes"]]
self.assertEqual(set(theme_ids), {"tourism", "auto_credit", "refining_energy"}) self.assertEqual(len(theme_ids), 13) # full SET50 theme set
self.assertIn("tourism", theme_ids)
self.assertIn("banks", theme_ids)
self.assertEqual(payload["themes"][0]["frequency"], "monthly") self.assertEqual(payload["themes"][0]["frequency"], "monthly")
self.assertGreaterEqual(payload["combined_count"], 1) self.assertGreaterEqual(payload["combined_count"], 1)
self.assertIsInstance(payload["board"], list) self.assertIsInstance(payload["board"], list)
# board rows carry per-symbol themes (frontend has no hardcoded map)
if payload["board"]:
self.assertIn("themes", payload["board"][0])
def test_themes_consistency_with_dashboard(self):
from unittest.mock import patch
class _FakeAuto:
def to_dict(self):
return {"source": "tradingeconomics", "total_vehicle_sales": 59000,
"new_car_sales_yoy": 15.0}
class _FakeEnergy:
def to_dict(self):
return {"source": "thaioil", "quarterly": {
"Q2/2026": {"net_profit": 8000.0, "ebitda": 9000.0, "sales": 120000.0}}}
with patch("app.auto_credit.fetch_auto_credit", return_value=_FakeAuto()), \
patch("app.energy_thai.fetch_energy_thai", return_value=_FakeEnergy()):
th = self.client.get("/api/v1/themes").get_json()
db = self.client.get("/api/v1/dashboard").get_json()
self.assertEqual({t["id"] for t in th["themes"]}, {t["id"] for t in db["themes"]})
def test_simulation_allocates_capital(self): def test_simulation_allocates_capital(self):
from unittest.mock import patch from unittest.mock import patch

View File

@@ -105,3 +105,30 @@ class SymbolBreakdownTest(unittest.TestCase):
self.assertTrue(set(d["themes"]) >= {"refining_energy", "petrochem_materials", "utilities"}) self.assertTrue(set(d["themes"]) >= {"refining_energy", "petrochem_materials", "utilities"})
# no surprises set -> every contribution has surprise=None and theme_score 0 # no surprises set -> every contribution has surprise=None and theme_score 0
self.assertTrue(all(c["surprise"] is None for c in d["theme_contributions"])) self.assertTrue(all(c["surprise"] is None for c in d["theme_contributions"]))
class QualitySelectionTest(unittest.TestCase):
def test_quality_differentiates_strong_vs_weak_in_theme(self):
from app import themes
fv = {"factors": [
{"symbol": "BBL", "roe": 12.0, "eps_growth_yoy": 8.0},
{"symbol": "KBANK", "roe": 10.0, "eps_growth_yoy": 5.0},
{"symbol": "KTB", "roe": 9.0, "eps_growth_yoy": 3.0},
{"symbol": "SCB", "roe": 8.0, "eps_growth_yoy": 2.0},
{"symbol": "TTB", "roe": 5.0, "eps_growth_yoy": -2.0},
]}
q_bbl = themes.quality_within_theme("BBL", "banks", fv)
q_ttb = themes.quality_within_theme("TTB", "banks", fv)
self.assertGreater(q_bbl, q_ttb) # strong bank outscores weak one
def test_breakdown_has_quality_and_theme_score_per_theme(self):
from app import themes
fv = {"factors": [
{"symbol": "BBL", "roe": 12.0, "eps_growth_yoy": 8.0},
{"symbol": "KTB", "roe": 9.0, "eps_growth_yoy": 3.0},
]}
d = themes.symbol_breakdown("BBL", factor_view=fv, theme_surprises={"banks": 1.0})
contrib = next(c for c in d["theme_contributions"] if c["theme"] == "banks")
self.assertIn("quality", contrib)
self.assertIn("theme_score", contrib)
self.assertAlmostEqual(contrib["theme_score"], contrib["surprise"] * contrib["quality"], places=3)

View File

@@ -58,31 +58,18 @@ const signalSummary = computed(() => {
return { long: s?.long ?? 0, short: s?.short ?? 0, neutral: s?.neutral ?? 0, total: s?.total ?? 0 } return { long: s?.long ?? 0, short: s?.short ?? 0, neutral: s?.neutral ?? 0, total: s?.total ?? 0 }
}) })
const freqLabel = (f) => ({ monthly: 'รายเดือน', quarterly: 'รายไตรมาส', annual: 'รายปี', daily: 'รายวัน' })[f] || f const freqLabel = (f) => ({ monthly: 'รายเดือน', quarterly: 'รายไตรมาส', annual: 'รายปี', daily: 'รายวัน' })[f] || f
// theme id -> Thai label (for the theme column). Mirrors backend THEME_LABELS_TH. // theme labels come from the API (dashData.themes[].label_th) — no hardcode.
const themeLabelById = { const themeLabelById = computed(() => {
tourism: 'ท่องเที่ยว', auto_credit: 'รถยนต์/สินเชื่อ', refining_energy: 'พลังงาน/โรงกลั่น', const m = {}
banks: 'ธนาคาร', retail: 'ค้าปลีก', telecom_it: 'สื่อสาร/ไอที', property: 'อสังหาริมทรัพย์', for (const t of dashboardThemes.value) m[t.id] = t.label_th
healthcare: 'โรงพยาบาล', petrochem_materials: 'ปิโตรเคมี/วัสดุ', consumer_staples: 'อาหาร/อุปโภค', return m
utilities: 'สาธารณูปโภค', nonbank_finance: 'การเงินนอกธนาคาร', exploration: 'สำรวจ/ผลิตพลังงาน', })
} // which themes a symbol belongs to — from the dashboard board (API), so editing
// which themes a symbol belongs to (mirrors backend THEME_SYMBOLS for full SET50) // themes.py propagates to the UI with zero frontend change.
const THEME_BY_SYMBOL = {
'AOT':'tourism','CENTEL':'tourism','MINT':'tourism','AWC':'tourism','CPN':'tourism','CRC':'tourism','BEM':'tourism','BTS':'tourism',
'MTC':'auto_credit','SAWAD':'auto_credit','TISCO':'auto_credit',
'BANPU':'utilities','GPSC':'utilities','PTT':'utilities','PTTGC':'refining_energy','TOP':'refining_energy','IVL':'petrochem_materials',
'BBL':'banks','KBANK':'banks','KTB':'banks','SCB':'banks','TTB':'banks',
'COM7':'retail','CPALL':'retail','GLOBAL':'retail','HMPRO':'retail','OR':'retail','OSP':'retail',
'ADVANC':'telecom_it','TRUE':'telecom_it','DELTA':'telecom_it',
'LH':'property','BDMS':'healthcare','BH':'healthcare','SCC':'petrochem_materials','SCGP':'petrochem_materials',
'CPF':'consumer_staples','TU':'consumer_staples','CBG':'consumer_staples',
'BGRIM':'utilities','EGCO':'utilities','RATCH':'utilities','GULF':'utilities','EA':'utilities',
'JMT':'nonbank_finance','JMART':'nonbank_finance','KTC':'nonbank_finance','TIDLOR':'nonbank_finance',
'PTTEP':'exploration',
}
function symbolThemes(symbol) { function symbolThemes(symbol) {
const id = THEME_BY_SYMBOL[symbol] const row = boardBySymbol.value[symbol]
if (!id) return [] const ids = row?.themes ?? []
return [themeLabelById[id] || id] return ids.map((id) => themeLabelById.value[id] || id)
} }
const factorAvailable = computed(() => factorData.value?.available ?? false) const factorAvailable = computed(() => factorData.value?.available ?? false)
const dividendCount = computed(() => factorData.value?.dividend_count ?? 0) const dividendCount = computed(() => factorData.value?.dividend_count ?? 0)
@@ -669,10 +656,12 @@ onMounted(loadDashboard)
<div v-if="symbolDetail.themes?.length" class="modal-themes"> <div v-if="symbolDetail.themes?.length" class="modal-themes">
<div v-for="c in symbolDetail.theme_contributions" :key="c.theme" class="contrib-line"> <div v-for="c in symbolDetail.theme_contributions" :key="c.theme" class="contrib-line">
<span class="contrib-name">{{ c.label_th || themeLabelById[c.theme] || c.theme }}</span> <span class="contrib-name">{{ c.label_th || themeLabelById[c.theme] || c.theme }}</span>
<strong v-if="c.surprise != null">{{ formatNumber(c.surprise) }}σ</strong> <span v-if="c.surprise != null" class="contrib-calc">
<em>{{ formatNumber(c.surprise) }}σ</em> × ณภาพ <em>{{ c.quality }}</em> = <strong>{{ formatNumber(c.theme_score) }}σ</strong>
</span>
<strong v-else class="muted-cell">งไมอม</strong> <strong v-else class="muted-cell">งไมอม</strong>
</div> </div>
<div class="modal-sub">คะแนนธ = าเฉลยของาเหลาน นนอยใน (เฉพาะธมทอม)</div> <div class="modal-sub">คะแนนธ = าเฉลยของ (surprise × ณภาพห) นนอยใน</div>
</div> </div>
<div v-else class="muted-cell">นนงไมไดดอยในธมใด (จะอปเดตเมอเพมธ)</div> <div v-else class="muted-cell">นนงไมไดดอยในธมใด (จะอปเดตเมอเพมธ)</div>
</div> </div>

View File

@@ -170,6 +170,9 @@ tbody tr:hover { background: rgba(255,255,255,.025); }
.modal-section-title { font: 700 11px 'DM Mono', monospace; color: var(--faint); margin-bottom: 8px; } .modal-section-title { font: 700 11px 'DM Mono', monospace; color: var(--faint); margin-bottom: 8px; }
.modal-sub { font-size: 11px; color: var(--faint); margin-top: 6px; } .modal-sub { font-size: 11px; color: var(--faint); margin-top: 6px; }
.contrib-line { display: flex; justify-content: space-between; padding: 3px 0; font-size: 13px; } .contrib-line { display: flex; justify-content: space-between; padding: 3px 0; font-size: 13px; }
.contrib-calc em { font-style: normal; color: var(--accent); }
.contrib-calc strong { color: var(--mint); font-family: 'DM Mono', monospace; }
.contrib-calc { color: var(--text-2, #9aa); font-size: 12px; }
.fund-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 12px; } .fund-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 12px; }
.fund-grid span { background: rgba(255,255,255,.03); border-radius: 6px; padding: 6px 8px; } .fund-grid span { background: rgba(255,255,255,.03); border-radius: 6px; padding: 6px 8px; }
.fund-grid strong { color: var(--text); font-family: 'DM Mono', monospace; } .fund-grid strong { color: var(--text); font-family: 'DM Mono', monospace; }