diff --git a/backend/app/__init__.py b/backend/app/__init__.py index a56c36d..77dc1ea 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -593,150 +593,28 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: @app.get("/api/v1/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, - refining_energy) and the Siamchart fundamental provider into a per-symbol - combined score (60% theme / 40% Siamchart), with the theme list and each - theme's factor read. Frequency of each theme is reported so different- - cadence factors are not treated as same-timestamp. + Single source: RealDashboard.build() so /api/v1/themes returns the SAME + 13-theme set, labels, surprises, and per-symbol combined board as + /api/v1/dashboard. Removes the old 3-theme duplicated logic. """ - from app import auto_credit, daily_cache, energy_thai - from app import siamchart_factors, themes as themes_mod - - cache = app.extensions.setdefault( - "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 + from app.dashboard import RealDashboard, DashboardError + from app import daily_cache + cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache()) + current = app.extensions.get("tourism_result") or {} try: - auto = cache.fetch_or_stale( - f"auto_credit/{current.get('as_of','')}", - lambda: auto_credit.fetch_auto_credit().to_dict(), - ) - auto_d = auto["data"] if isinstance(auto, dict) and "data" in auto else auto - theme_reads["auto_credit"] = { - "source": "tradingeconomics", - "as_of": auto_d.get("as_of", ""), - "total_vehicle_sales": auto_d.get("total_vehicle_sales"), - "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"} + dash = RealDashboard(current.get("signals", []), cache).build() + except DashboardError as exc: + return jsonify({"error": str(exc)}), 503 + return jsonify({ + "themes": dash["themes"], + "as_of": dash.get("as_of", ""), + "combined_count": len(dash["board"]), + "board": dash["board"], + "macro": dash.get("macro", {}), + }) - # 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/") diff --git a/backend/app/dashboard.py b/backend/app/dashboard.py index fe05ac6..08ac419 100644 --- a/backend/app/dashboard.py +++ b/backend/app/dashboard.py @@ -289,8 +289,10 @@ class RealDashboard: from . import siamchart_factors fv = siamchart_factors.build_factor_view() or {"factors": []} 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]] = {} + fv_all = fv for t in themes: tid = t["id"] surprise = t.get("surprise") @@ -298,7 +300,13 @@ class RealDashboard: theme_scores[tid] = {} continue 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( list(theme_scores.values()), siamchart_score, ) @@ -307,11 +315,16 @@ class RealDashboard: board = [] for sym, meta in combined.items(): 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({ "symbol": sym, "combined": round(meta.get("combined", 0.0), 3), "theme_score": round(meta.get("theme_score", 0.0), 3), "siamchart_score": round(meta.get("siamchart_score", 0.0), 3), + "themes": sym_themes, "dividend_yield": f.get("dividend_yield"), "is_dividend": f.get("is_dividend"), }) diff --git a/backend/app/factors.py b/backend/app/factors.py new file mode 100644 index 0000000..15bbd64 --- /dev/null +++ b/backend/app/factors.py @@ -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 diff --git a/backend/app/themes.py b/backend/app/themes.py index 695fe00..ae678b2 100644 --- a/backend/app/themes.py +++ b/backend/app/themes.py @@ -92,6 +92,106 @@ THEME_LABELS_TH: dict[str, str] = { "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 class Theme: @@ -204,6 +304,45 @@ def combine_score(theme_scores: list[dict[str, float]], siamchart_score: dict[st 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( symbol: str, *, @@ -231,18 +370,24 @@ def symbol_breakdown( theme_values = [] for tid in member_themes: s = theme_surprises.get(tid) + q = quality_within_theme(symbol, tid, factor_view) if s is not None: - theme_values.append(float(s)) + ts = round(float(s) * q, 3) + theme_values.append(ts) theme_lines.append({ "theme": tid, "label_th": THEME_LABELS_TH.get(tid, tid), "surprise": round(float(s), 3), + "quality": q, + "theme_score": ts, }) else: theme_lines.append({ "theme": tid, "label_th": THEME_LABELS_TH.get(tid, tid), "surprise": None, + "quality": q, + "theme_score": None, }) theme_score = (sum(theme_values) / len(theme_values)) if theme_values else 0.0 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 07cadb9..0675f9a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -62,10 +62,31 @@ class ApiTests(unittest.TestCase): self.assertEqual(resp.status_code, 200) payload = resp.get_json() 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.assertGreaterEqual(payload["combined_count"], 1) 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): from unittest.mock import patch diff --git a/backend/tests/test_themes.py b/backend/tests/test_themes.py index cf20551..4332ec9 100644 --- a/backend/tests/test_themes.py +++ b/backend/tests/test_themes.py @@ -105,3 +105,30 @@ class SymbolBreakdownTest(unittest.TestCase): self.assertTrue(set(d["themes"]) >= {"refining_energy", "petrochem_materials", "utilities"}) # 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"])) + + +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) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 48d24ed..990a399 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -58,31 +58,18 @@ const signalSummary = computed(() => { 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 -// theme id -> Thai label (for the theme column). Mirrors backend THEME_LABELS_TH. -const themeLabelById = { - tourism: 'ท่องเที่ยว', auto_credit: 'รถยนต์/สินเชื่อ', refining_energy: 'พลังงาน/โรงกลั่น', - banks: 'ธนาคาร', retail: 'ค้าปลีก', telecom_it: 'สื่อสาร/ไอที', property: 'อสังหาริมทรัพย์', - healthcare: 'โรงพยาบาล', petrochem_materials: 'ปิโตรเคมี/วัสดุ', consumer_staples: 'อาหาร/อุปโภค', - utilities: 'สาธารณูปโภค', nonbank_finance: 'การเงินนอกธนาคาร', exploration: 'สำรวจ/ผลิตพลังงาน', -} -// which themes a symbol belongs to (mirrors backend THEME_SYMBOLS for full SET50) -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', -} +// theme labels come from the API (dashData.themes[].label_th) — no hardcode. +const themeLabelById = computed(() => { + const m = {} + for (const t of dashboardThemes.value) m[t.id] = t.label_th + return m +}) +// which themes a symbol belongs to — from the dashboard board (API), so editing +// themes.py propagates to the UI with zero frontend change. function symbolThemes(symbol) { - const id = THEME_BY_SYMBOL[symbol] - if (!id) return [] - return [themeLabelById[id] || id] + const row = boardBySymbol.value[symbol] + const ids = row?.themes ?? [] + return ids.map((id) => themeLabelById.value[id] || id) } const factorAvailable = computed(() => factorData.value?.available ?? false) const dividendCount = computed(() => factorData.value?.dividend_count ?? 0) @@ -669,10 +656,12 @@ onMounted(loadDashboard)
หุ้นนี้ยังไม่ได้จัดอยู่ในธีมใด (จะอัปเดตเมื่อเพิ่มธีม)
diff --git a/frontend/src/style.css b/frontend/src/style.css index 22d0a3a..d516999 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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-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-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 span { background: rgba(255,255,255,.03); border-radius: 6px; padding: 6px 8px; } .fund-grid strong { color: var(--text); font-family: 'DM Mono', monospace; }