From 12b34929d7a02dee248fa4db337b0e76a143ae3e Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Sat, 29 Aug 2026 11:13:17 +0700 Subject: [PATCH] feat(factor): add energy_irpc (IRPC net margin) as 2nd Thai refiner signal - new energy_irpc collector parsing IRPC performance-highlights table (net profit/EBITDA/ROE margins, latest period 3M26: +10.27%) - factor energy_irpc_net_margin (sign +1) wired into refining_energy/ exploration/utilities, extending the energy theme beyond TOP - scheduler job + dashboard fetch + sources table row (now 9 sources) - tests: parse (incl paren-negatives), value-key resolution, direction; suite 368 OK. Independent review passed: true - Phase B feasibility: REIC/EPPO/NBTC/PTTEP are JS-rendered or anti-bot (recorded deferred in plan); IRPC was the clean server-rendered win --- ...026-08-29_data-source-expansion-phase-2.md | 22 +-- backend/app/dashboard.py | 21 ++- backend/app/energy_irpc.py | 133 ++++++++++++++++++ backend/app/factors.py | 12 ++ backend/app/scheduler.py | 1 + backend/app/themes.py | 3 + backend/tests/test_dashboard.py | 6 +- backend/tests/test_energy_irpc.py | 105 ++++++++++++++ backend/tests/test_scheduler.py | 1 + backend/tests/test_themes.py | 4 +- ...-08-29-data-source-expansion-and-ui-fix.md | 20 ++- .../{index-eGeNIixN.js => index-Bymd5oSf.js} | 2 +- frontend/dist/index.html | 2 +- frontend/src/App.vue | 1 + 14 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 backend/app/energy_irpc.py create mode 100644 backend/tests/test_energy_irpc.py rename frontend/dist/assets/{index-eGeNIixN.js => index-Bymd5oSf.js} (64%) diff --git a/.hermes/plans/2026-08-29_data-source-expansion-phase-2.md b/.hermes/plans/2026-08-29_data-source-expansion-phase-2.md index d72e9c3..6269250 100644 --- a/.hermes/plans/2026-08-29_data-source-expansion-phase-2.md +++ b/.hermes/plans/2026-08-29_data-source-expansion-phase-2.md @@ -75,19 +75,23 @@ Each is a collector + FACTORS entry ×N + THEME wiring + tests. - Factor: `consumer_confidence` (sign +1). - Feasibility: spike — some sources require login; fallback to TradingEconomics "consumer confidence". -## Phase B — sector-specific (IMPLEMENTED 2026-08-29 via TradingEconomics single-page snapshots) +## Phase B — sector-specific (IMPLEMENTED 2026-08-29 via single-page snapshots) ### B1. Property: `te_property_prices` (residential property prices % YoY) — DONE, feeds property theme -### B2. Utilities: EPPO electricity — DEFERRED (no clean single-page TE snapshot; needs EPPO scraper) -### B3. Energy breadth: PTT/PTTEP/BCP quarterly — DEFERRED (needs company-IR scrapers, heavier) +### B3. Energy breadth: `energy_irpc` (IRPC net margin, 3M26 +10.27%) — DONE, feeds refining_energy/exploration/utilities ### B4. Telecom/backdrop: `te_business_confidence` — DONE, feeds telecom_it + property + healthcare ### B5. Healthcare: consumer/business backdrop wired in — DONE (macro + business confidence) -Both added to the existing `te_thailand.py` module (2 extra TE pages) — same reviewed pattern, +2 factors, +4 tests. +Added to `te_thailand.py` + new `energy_irpc.py`, same reviewed pattern. Full suite 368. -## Remaining backlog (needs dedicated scrapers, not single-page snapshots) -- REIC property transfer/housing supply (TH-specific, richer than a TE index) -- EPPO electricity demand/generation for utilities -- PTT/PTTEP/BCP/IRPC quarterly financials (beyond TOP) for energy breadth -- NBTC subscriber/data for a true telecom-specific series +## Deferred / blocked by feasibility (2026-08-29 spike results — all JS-rendered or anti-bot) +- **REIC** (property transfer): JS SPA, data loads via XHR — not plain-HTML scrapable. + Would need browser_exec or `har-derived-api-client` (XHR reverse-engineering). +- **EPPO** (utilities electricity): WordPress/JS pages, no static numeric table. +- **NBTC** (telecom data): HTTP 403 anti-bot block. +- **PTTEP** (energy): JS shell (no server-rendered tables); PTT/BCP URLs 404/DNS. + Only **IRPC** among the energy names exposed a server-rendered financial table. +These are NOT quick plain-HTML collectors — they need a browser/XHR approach or a +logged-in/authorized session. Do them as a separate effort if the analysis needs +them, not as simple additions to this collector family. --- diff --git a/backend/app/dashboard.py b/backend/app/dashboard.py index d965f85..21f5dfe 100644 --- a/backend/app/dashboard.py +++ b/backend/app/dashboard.py @@ -75,8 +75,8 @@ class RealDashboard: def build(self) -> dict: # 1) live theme data (real, no fallback) - from . import (auto_credit, auto_npl, bank_npl, energy_thai, bot_tourism, - macro_thai, te_thailand, thai_trade) + from . import (auto_credit, auto_npl, bank_npl, energy_irpc, energy_thai, + bot_tourism, macro_thai, te_thailand, thai_trade) tourism = None try: @@ -93,6 +93,8 @@ class RealDashboard: self.cache, "bank_npl", lambda: bank_npl.fetch_bank_npl().to_dict(), "bank_npl") en_d = _fetch_with_cache( self.cache, "energy_thai", lambda: energy_thai.fetch_energy_thai().to_dict(), "energy_thai") + irpc_d = _fetch_with_cache( + self.cache, "energy_irpc", lambda: energy_irpc.fetch_energy_irpc().to_dict(), "energy_irpc") macro_d = _fetch_with_cache( self.cache, "macro_thai", lambda: macro_thai.fetch_macro_thai().to_dict(), "macro_thai") trade_d = _fetch_with_cache( @@ -103,7 +105,8 @@ class RealDashboard: # 2) per-theme surprise — registry-driven (THE single source of truth) fetched = { "macro_thai": macro_d, "auto_credit": auto_d, - "auto_npl": npl_d, "energy_thai": en_d, "bank_npl": bnpl_d, + "auto_npl": npl_d, "energy_thai": en_d, "energy_irpc": irpc_d, + "bank_npl": bnpl_d, "thai_trade": trade_d, "te_thailand": te_d, } tourism_surprise = self._tourism_surprise() @@ -116,7 +119,9 @@ class RealDashboard: self._mk_theme("tourism", surprises.get("tourism"), tourism), self._mk_theme("auto_credit", surprises.get("auto_credit"), _auto_read(auto_d, npl_d, self.cache)), - self._mk_theme("refining_energy", surprises.get("refining_energy"), en_d), + self._mk_theme("refining_energy", surprises.get("refining_energy"), + {**en_d, "irpc_net_margin_pct": (irpc_d or {}).get("net_margin_pct"), + "source": "TOP + IRPC investor"}), ] # macro-proxy reads for the expanded SET50 themes (deterministic) proxy_reads = { @@ -162,7 +167,7 @@ class RealDashboard: board = self._build_board(themes, macro_d) # 5) source provenance table - sources = self._build_sources(auto_d, npl_d, en_d, macro_d, tourism, bnpl_d, trade_d, te_d) + sources = self._build_sources(auto_d, npl_d, en_d, macro_d, tourism, bnpl_d, trade_d, te_d, irpc_d) return { "themes": themes, @@ -300,7 +305,7 @@ class RealDashboard: board.sort(key=lambda r: r["combined"], reverse=True) return board - def _build_sources(self, auto_d, npl_d, en_d, macro_d, tourism, bnpl_d=None, trade_d=None, te_d=None) -> list: + def _build_sources(self, auto_d, npl_d, en_d, macro_d, tourism, bnpl_d=None, trade_d=None, te_d=None, irpc_d=None) -> list: """Sources derived from the FACTORS registry — adding a factor to factors.py auto-appends its source row here (no hardcoded list).""" import datetime as _dt @@ -309,7 +314,8 @@ class RealDashboard: fetched = { "auto_credit": auto_d, "auto_npl": npl_d, - "energy_thai": en_d, "macro_thai": macro_d, "bank_npl": bnpl_d, + "energy_thai": en_d, "energy_irpc": irpc_d, + "macro_thai": macro_d, "bank_npl": bnpl_d, "thai_trade": trade_d, "te_thailand": te_d, } # group FACTORS by fetch module -> one row per distinct source @@ -339,6 +345,7 @@ class RealDashboard: "auto_npl": "BOT FI_NP_003_S2", "bank_npl": "BOT FI_NP_003_S2", "energy_thai": "Thai Oil investor", + "energy_irpc": "IRPC investor", "macro_thai": "BOT Thai Economy", "bot_tourism": "BOT Tourism", "thai_trade": "TradingEconomics", diff --git a/backend/app/energy_irpc.py b/backend/app/energy_irpc.py new file mode 100644 index 0000000..354eb8e --- /dev/null +++ b/backend/app/energy_irpc.py @@ -0,0 +1,133 @@ +"""IRPC refining-margin factor — IRPC PCL quarterly performance highlights. + +Source: https://investor.irpc.co.th/en/financial-results/performance-highlights +(IRPC, Thailand's second-largest refiner). Server-rendered HTML table with +columns `[2024, 2025, 3M26]` (annual + latest quarter) and rows incl.: + + Net Profit Margin (1.65%) (1.28%) 10.27% + EBITDA Margin 1.42% 2.22% 19.19% + +This extends the energy/refining theme beyond TOP (`energy_thai.py`) with a +second real Thai refiner. The latest-period Net Profit Margin is the signal: +IRPC swung from -1.65% (2024) to +10.27% (3M26) → a strong refining-margin +recovery. Single-page snapshot, no history join required. +""" + +from __future__ import annotations + +import html +import re +from dataclasses import dataclass, field +from typing import Optional +from urllib.request import Request, urlopen + +_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) +_URL = "https://investor.irpc.co.th/en/financial-results/performance-highlights" + + +class EnergyIrpcError(Exception): + """Raised when the IRPC performance-highlights page cannot be fetched/parsed.""" + + +@dataclass(frozen=True) +class EnergyIrpcSnapshot: + net_margin_pct: Optional[float] = None # latest period net profit margin % + ebitda_margin_pct: Optional[float] = None # latest period EBITDA margin % + roe_pct: Optional[float] = None + period: str = "" + columns: list = field(default_factory=list) # e.g. ["2024","2025","3M26"] + source: str = "irpc" + + def to_dict(self) -> dict: + return { + "source": self.source, + "net_margin_pct": self.net_margin_pct, + "ebitda_margin_pct": self.ebitda_margin_pct, + "roe_pct": self.roe_pct, + "period": self.period, + "columns": self.columns, + } + + +def _fetch(url: str = _URL, timeout: float = 30.0) -> str: + req = Request(url, headers={"User-Agent": _USER_AGENT, "Accept": "text/html"}) + try: + with urlopen(req, timeout=timeout) as resp: + raw = resp.read() + except Exception as exc: + raise EnergyIrpcError(f"failed to fetch {url}: {exc}") from exc + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("latin-1", "ignore") + + +def _cells(row_html: str) -> list[str]: + return [ + html.unescape(re.sub(r"<[^>]+>", "", td)).strip() + for td in re.findall(r"]*>(.*?)", row_html, re.S) + if td.strip() + ] + + +def _to_pct(text: str) -> Optional[float]: + """Parse a percent string: '10.27%' -> 10.27, '(1.65%)' -> -1.65.""" + text = text.replace(",", "").strip() + neg = text.startswith("(") and text.endswith(")") + digits = text.strip("()% ") + try: + val = float(digits) + except ValueError: + return None + return -val if neg else val + + +def parse_energy_irpc_html(html_text: str) -> EnergyIrpcSnapshot: + """Parse the IRPC performance-highlights table.""" + columns: list[str] = [] + rows: dict[str, list] = {} + for table in re.findall(r"]*>(.*?)", html_text, re.S): + for tr in re.findall(r"]*>(.*?)", table, re.S): + cells = _cells(tr) + if not cells: + continue + # header row: "Financial Highlights" + period labels + if cells[0].lower().startswith("financial highlight"): + columns = cells[1:] + continue + label = cells[0].strip().lower() + rows[label] = cells[1:] + + if not columns: + raise EnergyIrpcError("no IRPC financial periods found on page") + + def _pick(label): + values = rows.get(label) + if not values: + return None + # latest period is the last column + return _to_pct(values[-1]) if values else None + + # Only `net_margin` is registered as a FACTORS factor; ebitda/roe are surfaced + # here for the dashboard read/display only (future-proof, not scored). + net_margin = _pick("net profit margin") + ebitda = _pick("ebitda margin") + roe = _pick("return on equity") + + if net_margin is None and ebitda is None: + raise EnergyIrpcError("no usable IRPC margin series found on page") + + return EnergyIrpcSnapshot( + net_margin_pct=net_margin, + ebitda_margin_pct=ebitda, + roe_pct=roe, + period=columns[-1] if columns else "", + columns=[c for c in columns], + ) + + +def fetch_energy_irpc(timeout: float = 30.0) -> EnergyIrpcSnapshot: + return parse_energy_irpc_html(_fetch(timeout=timeout)) diff --git a/backend/app/factors.py b/backend/app/factors.py index a180cd1..cb3b986 100644 --- a/backend/app/factors.py +++ b/backend/app/factors.py @@ -24,6 +24,7 @@ _FETCH_MODULE: dict[str, str] = { "auto_npl": "auto_npl", "bank_npl": "bank_npl", "energy_thai": "energy_thai", + "energy_irpc": "energy_irpc", "macro_thai": "macro_thai", "thai_trade": "thai_trade", "te_thailand": "te_thailand", @@ -103,6 +104,17 @@ FACTORS: dict[str, dict[str, Any]] = { "weight": 1.0, "center": 5.0, "span": 10.0, # net margin % (derived from quarterly) }, + # ---- second Thai refiner: IRPC net margin (extends energy beyond TOP) ---- + "energy_irpc_net_margin": { + "name_th": "กำไรสุทธิ IRPC", + "source": "IRPC", + "frequency": "quarterly", + "fetch": "energy_irpc", + "value_key": "net_margin_pct", + "sign": 1, + "weight": 0.6, + "center": 3.0, "span": 8.0, # net margin %, ~0-3% neutral, refiners volatile + }, # ---- macro backdrop (proxy for expanded SET50 themes) ---- "macro_consumption": { "name_th": "การบริโภคภาคเอกชน (YoY)", diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index b7df37e..20e008a 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -37,6 +37,7 @@ _REFRESH_JOBS: List[dict] = [ {"key": "auto_credit/tourism", "label": "ยอดขายรถ (TradingEconomics)", "module": "auto_credit", "fn": "fetch_auto_credit", "fetch_module": "auto_credit", "frequency": "monthly"}, {"key": "auto_npl", "label": "NPL รถยนต์ (BOT)", "module": "auto_npl", "fn": "fetch_auto_npl", "fetch_module": "auto_npl", "frequency": "quarterly"}, {"key": "energy_thai", "label": "โรงกลั่น TOP", "module": "energy_thai", "fn": "fetch_energy_thai", "fetch_module": "energy_thai", "frequency": "quarterly"}, + {"key": "energy_irpc", "label": "โรงกลั่น IRPC", "module": "energy_irpc", "fn": "fetch_energy_irpc", "fetch_module": "energy_irpc", "frequency": "quarterly"}, {"key": "macro_thai", "label": "ภาพรวมประเทศไทย (BOT)", "module": "macro_thai", "fn": "fetch_macro_thai", "fetch_module": "macro_thai", "frequency": "monthly"}, {"key": "bank_npl", "label": "NPL ภาคการเงิน (BOT)", "module": "bank_npl", "fn": "fetch_bank_npl", "fetch_module": "bank_npl", "frequency": "quarterly"}, {"key": "thai_trade", "label": "ดุลการค้า/ส่งออก (TradingEconomics)", "module": "thai_trade", "fn": "fetch_thai_trade", "fetch_module": "thai_trade", "frequency": "monthly"}, diff --git a/backend/app/themes.py b/backend/app/themes.py index c45492d..286a7ca 100644 --- a/backend/app/themes.py +++ b/backend/app/themes.py @@ -117,6 +117,7 @@ THEMES: dict[str, dict] = { "label_th": "พลังงาน/โรงกลั่น", "factors": [ {"key": "energy_net_margin", "weight": 1.0}, + {"key": "energy_irpc_net_margin", "weight": 0.6}, {"key": "macro_mfg", "weight": 0.3}, ], }, @@ -198,6 +199,7 @@ THEMES: dict[str, dict] = { "factors": [ {"key": "macro_mfg", "weight": 1.0}, {"key": "energy_net_margin", "weight": 0.3}, + {"key": "energy_irpc_net_margin", "weight": 0.2}, ], }, "nonbank_finance": { @@ -215,6 +217,7 @@ THEMES: dict[str, dict] = { "label_th": "สำรวจ/ผลิตพลังงาน", "factors": [ {"key": "energy_net_margin", "weight": 1.0}, + {"key": "energy_irpc_net_margin", "weight": 0.4}, {"key": "macro_inflation", "weight": 0.2}, {"key": "external_exports", "weight": 0.3}, {"key": "external_current_account", "weight": 0.2}, diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 92c9b9c..02de553 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -36,9 +36,10 @@ class DashboardTest(unittest.TestCase): @patch("app.auto_credit.fetch_auto_credit") @patch("app.auto_npl.fetch_auto_npl") @patch("app.energy_thai.fetch_energy_thai") + @patch("app.energy_irpc.fetch_energy_irpc") @patch("app.macro_thai.fetch_macro_thai") @patch("app.bank_npl.fetch_bank_npl") - def test_build_returns_structure(self, bnpl, macro, energy, npl, auto): + def test_build_returns_structure(self, bnpl, macro, energy, eirpc, npl, auto): class _Factory: def __init__(self, data): self._data = data def to_dict(self): return self._data @@ -46,6 +47,7 @@ class DashboardTest(unittest.TestCase): "private_consumption_yoy": 4.9, "headline_inflation_yoy": 1.95, "periods": {}}) energy.return_value = _Factory({ "quarterly": {"Q2/2026": {"net_profit": 8000.0, "ebitda": 9000.0}}}) + eirpc.return_value = _Factory({"net_margin_pct": 10.27, "period": "3M26"}) auto.return_value = _Factory({ "new_car_sales_yoy": 20.07, "total_vehicle_sales": 59000}) npl.return_value = _Factory({ @@ -55,7 +57,7 @@ class DashboardTest(unittest.TestCase): cache = _FakeCache({}) dash = RealDashboard([], cache).build() self.assertEqual(len(dash["themes"]), 13) # all SET50 themes - self.assertEqual(len(dash["sources"]), 8) # auto-derived from FACTORS (te_thailand added) + self.assertEqual(len(dash["sources"]), 9) # auto-derived from FACTORS (energy_irpc added) self.assertIn("macro", dash) self.assertIn("board", dash) diff --git a/backend/tests/test_energy_irpc.py b/backend/tests/test_energy_irpc.py new file mode 100644 index 0000000..65347b6 --- /dev/null +++ b/backend/tests/test_energy_irpc.py @@ -0,0 +1,105 @@ +"""Tests for the IRPC refining-margin collector + theme wiring.""" + +from __future__ import annotations + +import unittest + +from app import energy_irpc, themes +from app.energy_irpc import EnergyIrpcSnapshot, parse_energy_irpc_html + + +_PERF_HTML = """ + + + + + + +
Financial Highlights202420253M26
Current Assets56,99967,086101,978
Total Assets184,555187,383217,356
EBITDA Margin1.42%2.22%19.19%
Net Profit Margin(1.65%)(1.28%)10.27%
Return on Equity(7.12%)(5.26%)7.75%
""" + + +class EnergyIrpcParseTest(unittest.TestCase): + def test_parses_margins(self): + snap = parse_energy_irpc_html(_PERF_HTML) + self.assertIsInstance(snap, EnergyIrpcSnapshot) + self.assertEqual(snap.columns, ["2024", "2025", "3M26"]) + self.assertEqual(snap.net_margin_pct, 10.27) + self.assertEqual(snap.ebitda_margin_pct, 19.19) + self.assertEqual(snap.roe_pct, 7.75) + # latest period is the last column + self.assertEqual(snap.period, "3M26") + + def test_parses_negative_parens(self): + # "(1.65%)" -> -1.65 + snap = parse_energy_irpc_html(_PERF_HTML) + # net_margin picks the LAST column (3M26, +10.27), not the negative one; + # verify the paren parser separately on a table where the last col is negative + html_neg = _PERF_HTML.replace("10.27%", "(3.20%)") + snap2 = parse_energy_irpc_html(html_neg) + self.assertEqual(snap2.net_margin_pct, -3.20) + + def test_to_dict_full(self): + d = parse_energy_irpc_html(_PERF_HTML).to_dict() + self.assertIn("net_margin_pct", d) + self.assertIn("period", d) + self.assertIn("source", d) + + def test_missing_values_raise(self): + html_no = "
Nothing1
" + with self.assertRaises(energy_irpc.EnergyIrpcError): + parse_energy_irpc_html(html_no) + + def test_every_factor_value_key_resolves(self): + from app import factors + keys = set(EnergyIrpcSnapshot().to_dict().keys()) + for fkey, fact in factors.FACTORS.items(): + if fact.get("fetch") != "energy_irpc": + continue + self.assertIn( + fact.get("value_key"), keys, + f"factor {fkey!r} value_key not emitted by energy_irpc", + ) + + +class EnergyIrpcThemeDirectionTest(unittest.TestCase): + """IRPC margin must move energy themes the intended (bullish) direction.""" + + @staticmethod + def _fetched(**irpc): + base = { + "macro_thai": { + "private_consumption_yoy": 4.9, "private_investment_yoy": 18.1, + "headline_inflation_yoy": 1.95, "core_inflation_yoy": 1.0, + "unemployment_pct": 1.0, "manufacturing_yoy": -3.1, + "tourists_ytd_mn": 16.2, + }, + "auto_credit": {"new_car_sales_yoy": 20.07, "vehicle_production": 117383.0, + "auto_exports": 81526.0}, + "auto_npl": {"pct_of_npls": 3.0}, + "bank_npl": {"pct_of_npls": 1.0}, + "energy_thai": {"quarterly": {"Q1/2026": {"net_profit": 19481.0, "sales": 114809.0}}}, + "energy_irpc": {"net_margin_pct": irpc.get("net", 0.0)}, + "thai_trade": {"current_account_usdm": 500.0, "exports_usdm": 34000.0, + "imports_usdm": 38000.0}, + "te_thailand": { + "interest_rate_pct": 1.5, "loans_to_fin_corp": 10000000.0, + "consumer_credit_thbmn": 5000000.0, "household_debt_gdp_pct": 85.0, + "retail_sales_yoy": 0.0, "consumer_confidence": 50.0, + "property_prices_yoy": 0.0, "business_confidence": 50.0, + }, + } + # refresh energy_irpc from kwargs (neutral default 0.0 used above) + base["energy_irpc"] = {"net_margin_pct": irpc.get("net", 0.0)} + return base + + def test_higher_irpc_margin_raises_energy_themes(self): + from app import themes + low = themes.compute_theme_surprises(self._fetched(net=-3.0)) + high = themes.compute_theme_surprises(self._fetched(net=10.0)) + self.assertGreater(high["refining_energy"], low["refining_energy"]) + self.assertGreater(high["exploration"], low["exploration"]) + self.assertGreater(high["utilities"], low["utilities"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index 742981e..90859c7 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -65,6 +65,7 @@ class VintageCollectionTest(unittest.TestCase): "auto_npl": {"pct_of_npls": 3.0}, "bank_npl": {"pct_of_npls": 0.8}, "energy_thai": {"quarterly": {"q1": {"net_profit": 500.0, "sales": 10000.0}}}, + "energy_irpc": {"net_margin_pct": 5.0}, "thai_trade": {"current_account_usdm": 500.0, "exports_usdm": 34000.0, "imports_usdm": 38000.0}, "te_thailand": {"interest_rate_pct": 1.0, "loans_to_fin_corp": 10000000.0, diff --git a/backend/tests/test_themes.py b/backend/tests/test_themes.py index d95d373..bec018b 100644 --- a/backend/tests/test_themes.py +++ b/backend/tests/test_themes.py @@ -5,7 +5,7 @@ from __future__ import annotations import unittest from app import themes -from app import auto_credit, auto_npl, bank_npl, macro_thai, te_thailand, thai_trade +from app import auto_credit, auto_npl, bank_npl, energy_irpc, macro_thai, te_thailand, thai_trade class ThemesTest(unittest.TestCase): @@ -254,6 +254,8 @@ class RegistryDrivenSurpriseTest(unittest.TestCase): # top-level key — covered separately by factor_value(). if fetch_mod == "energy_thai": continue + if fetch_mod == "energy_irpc": + keys = set(energy_irpc.EnergyIrpcSnapshot().to_dict().keys()) # resolve the module's snapshot .to_dict() keys if fetch_mod == "macro_thai": keys = set(macro_thai.MacroThaiSnapshot().to_dict().keys()) 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 c68a46b..9aed943 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 @@ -41,9 +41,23 @@ Extended the same `te_thailand` module with 2 more TradingEconomics pages: **362 tests OK**; frontend build clean; live fetch confirmed (1.26 / 46.7). Cleared a stale daily-cache `te_thailand` entry so the new fields show immediately. -## Backlog (needs dedicated scrapers, not single-page snapshots) -REIC property supply, EPPO electricity (utilities), PTT/PTTEP/BCP quarterly breadth -(energy), NBTC subscriber/data (telecom). Marked deferred in the plan. +## Phase C (same day, follow-up "ทำ phase B ต่อได้เลย" — energy breadth) +Feasibility spike of the remaining Phase B sources found most are JS-rendered or +anti-bot (recorded in plan), but **IRPC** performance-highlights is server-rendered +and clean. Added: +- `backend/app/energy_irpc.py` — collector parsing IRPC net-profit/EBITDA/ROE + margin rows [2024,2025,3M26], latest period (3M26: net margin **+10.27%**) +- factor `energy_irpc_net_margin` (sign +1) wired into refining_energy / + exploration / utilities (2nd real Thai refiner beyond TOP) +- scheduler job + dashboard fetch + `_build_sources` row (now **9 sources**) +- tests: parse (incl. paren-negatives), value-key resolution, direction +- frontend theme card shows "กำไรสุทธิ IRPC 10.27%" +Full suite **368 tests OK**; frontend build clean; live dashboard refining_energy +surprise 0.764 driven partly by IRPC margin. + +Deferred (feasibility blocked): REIC (JS SPA/XHR), EPPO (JS/WordPress), NBTC (403 +anti-bot), PTTEP (JS shell), PTT/BCP (404/DNS). Need browser/XHR approach, not +plain-HTML — recorded in the plan as a separate effort. - Note: a **concurrent process** also landed `thai_trade.py` (external-sector exports/imports/current-account) and external_* factors mid-session; its 3 diff --git a/frontend/dist/assets/index-eGeNIixN.js b/frontend/dist/assets/index-Bymd5oSf.js similarity index 64% rename from frontend/dist/assets/index-eGeNIixN.js rename to frontend/dist/assets/index-Bymd5oSf.js index a5448ed..3de835d 100644 --- a/frontend/dist/assets/index-eGeNIixN.js +++ b/frontend/dist/assets/index-Bymd5oSf.js @@ -15,4 +15,4 @@ * (c) 2018-present Yuxi (Evan) You and Vue contributors * @license MIT **/let _n;const nl=typeof window<"u"&&window.trustedTypes;if(nl)try{_n=nl.createPolicy("vue",{createHTML:e=>e})}catch{}const wi=_n?e=>_n.createHTML(e):e=>e,kr="http://www.w3.org/2000/svg",Tr="http://www.w3.org/1998/Math/MathML",tt=typeof document<"u"?document:null,ll=tt&&tt.createElement("template"),Er={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const l=t==="svg"?tt.createElementNS(kr,e):t==="mathml"?tt.createElementNS(Tr,e):s?tt.createElement(e,{is:s}):tt.createElement(e);return e==="select"&&n&&n.multiple!=null&&l.setAttribute("multiple",n.multiple),l},createText:e=>tt.createTextNode(e),createComment:e=>tt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>tt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,l,i){const r=s?s.previousSibling:t.lastChild;if(l&&(l===i||l.nextSibling))for(;t.insertBefore(l.cloneNode(!0),s),!(l===i||!(l=l.nextSibling)););else{ll.innerHTML=wi(n==="svg"?`${e}`:n==="mathml"?`${e}`:e);const a=ll.content;if(n==="svg"||n==="mathml"){const u=a.firstChild;for(;u.firstChild;)a.appendChild(u.firstChild);a.removeChild(u)}t.insertBefore(a,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Or=Symbol("_vtc");function Pr(e,t,s){const n=e[Or];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const il=Symbol("_vod"),Ar=Symbol("_vsh"),Rr=Symbol(""),Mr=/(?:^|;)\s*display\s*:/;function Ir(e,t,s){const n=e.style,l=re(s);let i=!1;if(s&&!l){if(t)if(re(t))for(const r of t.split(";")){const a=r.slice(0,r.indexOf(":")).trim();s[a]==null&&Wt(n,a,"")}else for(const r in t)s[r]==null&&Wt(n,r,"");for(const r in s){r==="display"&&(i=!0);const a=s[r];a!=null?Dr(e,r,!re(t)&&t?t[r]:void 0,a)||Wt(n,r,a):Wt(n,r,"")}}else if(l){if(t!==s){const r=n[Rr];r&&(s+=";"+r),n.cssText=s,i=Mr.test(s)}}else t&&e.removeAttribute("style");il in e&&(e[il]=i?n.display:"",e[Ar]&&(n.display="none"))}const ol=/\s*!important$/;function Wt(e,t,s){if(N(s))s.forEach(n=>Wt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Fr(e,t);ol.test(s)?e.setProperty(Tt(n),s.replace(ol,""),"important"):e[n]=s}}const rl=["Webkit","Moz","ms"],ln={};function Fr(e,t){const s=ln[t];if(s)return s;let n=Ie(t);if(n!=="filter"&&n in e)return ln[t]=n;n=xl(n);for(let l=0;lon||(Vr.then(()=>on=0),on=Date.now());function Kr(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const l=s.value;if(N(l)){const i=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{i.call(n),n._stopped=!0};const r=l.slice(),a=[n];for(let u=0;ue.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ur=(e,t,s,n,l,i)=>{const r=l==="svg";t==="class"?Pr(e,n,r):t==="style"?Ir(e,s,n):Fs(t)?Ds(t)||$r(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Wr(e,t,n,r))?(ul(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&cl(e,t,n,r,i,t!=="value")):e._isVueCE&&(qr(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!re(n)))?ul(e,Ie(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),cl(e,t,n,r))};function Wr(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&dl(t)&&j(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const l=e.tagName;if(l==="IMG"||l==="VIDEO"||l==="CANVAS"||l==="SOURCE")return!1}return dl(t)&&re(s)?!1:t in e}function qr(e,t){const s=e._def.props;if(!s)return!1;const n=Ie(t);return Array.isArray(s)?s.some(l=>Ie(l)===n):Object.keys(s).some(l=>Ie(l)===n)}const Is=e=>{const t=e.props["onUpdate:modelValue"]||!1;return N(t)?s=>Ss(t,s):t};function zr(e){e.target.composing=!0}function pl(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const St=Symbol("_assign"),ys=Symbol("_initialValue");function rn(e,t,s){return t&&(e=e.trim()),s&&(e=xn(e)),e}const xs={created(e,{modifiers:{lazy:t,trim:s,number:n}},l){e.parentNode&&(e.type==="text"?e[ys]=e.defaultValue.replace(/[\r\n]/g,""):e.type==="textarea"&&(e[ys]=e.defaultValue.replace(/\r\n?/g,` -`))),e[St]=Is(l);const i=n||l.props&&l.props.type==="number";xt(e,t?"change":"input",r=>{r.target.composing||e[St](rn(e.value,s,i))}),(s||i)&&xt(e,"change",()=>{e.value=rn(e.value,s,i)}),t||(xt(e,"compositionstart",zr),xt(e,"compositionend",pl),xt(e,"change",pl))},mounted(e,{value:t,modifiers:{trim:s,number:n}}){const l=t??"",i=e[ys];delete e[ys],i!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==i?e[St](rn(e.value,s,n)):e.value=l},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:l,number:i}},r){if(e[St]=Is(r),e.composing)return;const a=(i||e.type==="number")&&!/^0\d/.test(e.value)?xn(e.value):e.value,u=t??"";if(a===u)return;const g=e.getRootNode();(g instanceof Document||g instanceof ShadowRoot)&&g.activeElement===e&&e.type!=="range"&&(n&&t===s||l&&e.value.trim()===u)||(e.value=u)}},hl={deep:!0,created(e,t,s){e[St]=Is(s),xt(e,"change",()=>{const n=e._modelValue,l=Yr(e),i=e.checked,r=e[St];if(N(n)){const a=Cl(n,l),u=a!==-1;if(i&&!u)r(n.concat(l));else if(!i&&u){const g=[...n];g.splice(a,1),r(g)}}else if(Ls(n)){const a=new Set(n);i?a.add(l):a.delete(l),r(a)}else r(Ci(e,i))})},mounted:gl,beforeUpdate(e,t,s){e[St]=Is(s),gl(e,t,s)}};function gl(e,{value:t,oldValue:s},n){e._modelValue=t;let l;if(N(t))l=Cl(t,n.props.value)>-1;else if(Ls(t))l=t.has(n.props.value);else{if(t===s)return;l=is(t,Ci(e,!0))}e.checked!==l&&(e.checked=l)}function Yr(e){return"_value"in e?e._value:e.value}function Ci(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const Gr=["ctrl","shift","alt","meta"],Jr={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Gr.some(s=>e[`${s}Key`]&&!t.includes(s))},Xr=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=((l,...i)=>{for(let r=0;r{const t=Qr().createApp(...e),{mount:s}=t;return t.mount=n=>{const l=sa(n);if(!l)return;const i=t._component;!j(i)&&!i.render&&!i.template&&(i.template=l.innerHTML),l.nodeType===1&&(l.textContent="");const r=s(l,!1,ta(l));return l instanceof Element&&(l.removeAttribute("v-cloak"),l.setAttribute("data-v-app","")),r},t});function ta(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function sa(e){return re(e)?document.querySelector(e):e}const na={class:"app-shell"},la={class:"content",id:"overview"},ia={class:"topbar"},oa={class:"topbar-meta"},ra={class:"as-of"},aa={key:0,class:"state-card"},ca={key:1,class:"state-card error-state"},ua={class:"kpi-grid","aria-label":"Signal summary"},fa={class:"kpi-card accent-card"},da={class:"kpi-value"},pa={class:"kpi-foot"},ha={class:"long-count"},ga={class:"short-count"},va={class:"neutral-count"},_a={class:"kpi-card"},ma={class:"kpi-value"},ba={class:"kpi-foot"},ya={class:"panel theme-panel",id:"themes"},xa={class:"panel-header signal-header"},Sa={class:"status-tag"},wa={class:"theme-grid"},Ca={class:"theme-card-head"},ka={class:"theme-chip"},Ta={class:"theme-label-th"},Ea={class:"theme-surprise"},Oa={class:"theme-surprise-value"},Pa={class:"theme-read"},Aa={key:0,class:"theme-read-value"},Ra={key:1,class:"theme-read-value"},Ma={key:2,class:"theme-read-value"},Ia={key:3,class:"theme-read-value"},Fa={key:4,class:"theme-read-value"},Da={key:5,class:"theme-read-value"},La={key:6,class:"theme-read-value"},$a={key:7,class:"theme-read-value"},Na={key:8,class:"theme-read-value"},ja={key:9,class:"theme-read-value"},Ha={key:10,class:"theme-read-value"},Va={key:11,class:"theme-read-value"},Ba={key:12,class:"theme-read-value"},Ka={key:0,class:"theme-narrative"},Ua={key:0,class:"macro-panel"},Wa={class:"macro-chips"},qa={class:"macro-chip"},za={class:"macro-chip"},Ya={class:"macro-chip"},Ga={class:"macro-chip"},Ja={class:"macro-chip"},Xa={class:"panel stock-panel",id:"stocks"},Za={class:"panel-header signal-header"},Qa={class:"stock-controls"},ec={class:"toggle-filter"},tc={key:0,class:"empty-research"},sc={key:1,class:"table-wrap"},nc={class:"factor-table"},lc=["onClick"],ic={key:1,class:"muted-cell"},oc={class:"combined-cell"},rc={class:"symbol-name"},ac={key:0,class:"muted-cell"},cc={class:"score-cell"},uc={key:0,class:"dividend-dot",title:"จ่ายปันผล"},fc={class:"panel lineage-panel",id:"lineage"},dc={class:"panel-header signal-header"},pc={class:"status-tag"},hc={class:"table-wrap"},gc={class:"source-table"},vc={class:"source-name"},_c={class:"muted-cell"},mc={class:"muted-cell"},bc={class:"muted-cell"},yc={class:"muted-cell"},xc={class:"panel health-panel",id:"health"},Sc={key:0,class:"empty-research muted-cell"},wc={key:1},Cc={class:"source-table"},kc={class:"source-name"},Tc={class:"muted-cell",style:{"font-size":"11px"}},Ec={key:0,class:"status-tag",style:{background:"#1a7f37",color:"#fff"}},Oc={key:1,class:"status-tag warning-tag"},Pc={key:0,class:"muted-cell",style:{"font-size":"11px","word-break":"break-word"}},Ac={class:"muted-cell"},Rc=["onClick"],Mc={class:"panel sim-panel",id:"suggestion"},Ic={class:"panel-header signal-header"},Fc={class:"status-tag neutral-tag"},Dc={class:"sim-controls"},Lc={class:"sim-field"},$c=["disabled"],Nc={key:0,class:"sim-result"},jc={class:"sim-sums"},Hc={class:"sim-sum"},Vc={class:"sim-sum"},Bc={class:"sim-note"},Kc={class:"sim-buckets"},Uc={class:"sim-bucket"},Wc={class:"sim-order-table"},qc={key:0},zc={class:"muted-cell"},Yc={class:"score-cell"},Gc={class:"score-cell"},Jc={key:1},Xc={class:"sim-bucket"},Zc={class:"sim-order-table"},Qc={key:0},eu={class:"muted-cell"},tu={class:"score-cell"},su={class:"score-cell"},nu={key:1},lu={class:"sim-bucket"},iu={class:"sim-order-table"},ou={key:0},ru={class:"muted-cell"},au={class:"score-cell"},cu={class:"score-cell"},uu={key:1},fu={key:1,class:"empty-research"},du={class:"panel backtest-panel",id:"backtest"},pu={class:"backtest-controls"},hu={class:"checkbox-label",style:{display:"flex","align-items":"center",gap:"6px"}},gu=["disabled"],vu={key:0,class:"state-card warning-state"},_u={class:"muted-cell",style:{"margin-top":"4px"}},mu={class:"muted-cell",style:{"margin-top":"2px"}},bu={key:1,class:"state-card error-state"},yu={key:2,class:"backtest-results"},xu={class:"bt-kpi-grid"},Su={class:"bt-kpi"},wu={class:"bt-kpi"},Cu={class:"bt-kpi"},ku={class:"positive-text"},Tu={class:"bt-kpi"},Eu={class:"negative-text"},Ou={class:"bt-kpi"},Pu={class:"bt-kpi"},Au={class:"bt-kpi"},Ru={class:"bt-meta muted-cell"},Mu={key:0,class:"bt-meta"},Iu={key:1,class:"bt-meta muted-cell"},Fu={key:2,class:"bt-holdings"},Du={class:"source-table",style:{"margin-top":"6px"}},Lu={class:"muted-cell"},$u={key:3,class:"empty-research"},Nu={key:4,class:"bt-history"},ju={class:"source-table"},Hu={key:0,class:"status-tag warning-tag",title:"ใช้คะแนนปัจจุบันย้อนหลัง ไม่ใช่ point-in-time"},Vu={class:"positive-text"},Bu=["title"],Ku={class:"muted-cell"},Uu={class:"modal-card"},Wu={class:"modal-head"},qu={key:0,class:"empty-research"},zu={key:1,class:"state-card error-state"},Yu={key:2,class:"modal-body"},Gu={class:"modal-section"},Ju={key:0,class:"modal-themes"},Xu={class:"contrib-name"},Zu={key:0,class:"contrib-calc"},Qu={key:1,class:"muted-cell"},ef={key:1,class:"muted-cell"},tf={class:"modal-section"},sf={class:"fund-grid"},nf={class:"modal-sub"},lf={class:"modal-section"},of={class:"calc-box"},rf={class:"calc-line"},af={class:"calc-step-head"},cf={class:"calc-step-note"},uf={key:0,class:"calc-z"},ff={class:"modal-sub"},df={__name:"App",setup(e){const t=B(null),s=B(null),n=B(null),l=B(null),i=B(null),r=B(null),a=B(1e6),u=B(null),g=B(null),h=B(!1),y=B(""),R=B(""),M=B(1e6),K=B(!1),A=B(null),ee=B([]),V=B(null),H=B(!0),U=B(!1),I=B(null),ie=B(!1),te=B("signal_score"),pe=B("desc"),$e=B({entries:[]}),Et=B(null),Ye=B(null),Ge=B(!0),Je=B(""),ut=B(""),Ht=B(!1),as=B("token"),ue=B(!0),se=B(""),G=ae(()=>{var v;return((v=l.value)==null?void 0:v.factors)??[]}),Ne=ae(()=>{var v;return((v=r.value)==null?void 0:v.themes)??[]}),pt=ae(()=>{var v;return((v=r.value)==null?void 0:v.sources)??[]}),Te=B([]),Ee=B(!1),Xe=ae(()=>{var v;return((v=r.value)==null?void 0:v.macro)??{}}),Vt=ae(()=>pt.value.length),cs=ae(()=>{var v,f;return((f=(v=r.value)==null?void 0:v.source_summary)==null?void 0:f.factor_keys)??Vt.value}),Ze=ae(()=>{var v;return((v=r.value)==null?void 0:v.available)??!1}),Ot=ae(()=>{var v;return((v=r.value)==null?void 0:v.board)??G.value}),ft=ae(()=>{const v={};for(const f of Ot.value)v[f.symbol]=f;return v}),ht=ae(()=>{var f;const v=(f=t.value)==null?void 0:f.signal_summary;return{long:(v==null?void 0:v.long)??0,short:(v==null?void 0:v.short)??0,neutral:(v==null?void 0:v.neutral)??0,total:(v==null?void 0:v.total)??0}}),gt=v=>({monthly:"รายเดือน",quarterly:"รายไตรมาส",annual:"รายปี",daily:"รายวัน"})[v]||v,zs=ae(()=>{const v={};for(const f of Ne.value)v[f.id]=f.label_th;return v});function c(v){const f=ft.value[v];return((f==null?void 0:f.themes)??[]).map(Pe=>zs.value[Pe]||Pe)}const d=ae(()=>{var v;return((v=l.value)==null?void 0:v.available)??!1}),b=ae(()=>{var v;return((v=l.value)==null?void 0:v.dividend_count)??0}),w=ae(()=>{var v;return((v=i.value)==null?void 0:v.combined_count)??0}),S=ae(()=>{let v=G.value;return ie.value&&(v=v.filter(f=>f.is_dividend)),v});function x(v,f){var he;return f==="signal_score"?v.signal_score??(v.signal_side==="LONG"?9999:0):f==="combined"?((he=ft.value[v.symbol])==null?void 0:he.combined)??-9999:f==="symbol"?v.symbol:f==="dividend_yield"?v.dividend_yield??-1:f==="eps_growth_yoy"?v.eps_growth_yoy??-1:f==="pe"?v.pe??0:f==="eps"?v.eps??0:f==="pbv"?v.pbv??0:f==="roe"?v.roe??0:v[f]}const O=ae(()=>{const v=[...S.value],f=pe.value==="asc"?1:-1;return v.sort((he,Pe)=>{const je=x(he,te.value),Qe=x(Pe,te.value);return typeof je=="string"?je.localeCompare(Qe)*f:je===Qe?he.symbol.localeCompare(Pe.symbol):je==null?1:Qe==null?-1:(je-Qe)*f}),v});function k(v){te.value===v?pe.value=pe.value==="asc"?"desc":"asc":(te.value=v,pe.value="desc")}function C(v){return te.value!==v?"":pe.value==="asc"?"↑":"↓"}function _(v,f=2){return Number(v??0).toFixed(f)}function D(v){return v==="dated_ledger"}function P(v){return D(v)?{label:"ตามวันจริง",cls:"status-tag",style:"background:#1a7f37;color:#fff"}:v==="dps_annual_proxy"?{label:"Proxy (ต่อหุ้น)",cls:"status-tag warning-tag"}:{label:"Proxy",cls:"status-tag warning-tag"}}function F(v){return D(v)?"ปันผลตามวันจริงจาก ledger (ex-date × จำนวนหุ้น) — กระแสเงินสดจริง":v==="dps_annual_proxy"?"ประมาณการปันผลต่อหุ้น (DPS ล่าสุด × จำนวนหุ้น) ไม่ใช่กระแสเงินสดตามวันจริง":"ประมาณจาก dividend yield ของพอร์ตสุดท้าย ไม่ใช่กระแสเงินสดปันผลจริง"}function L(v){return v?new Date(v).toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"}):"—"}async function $(v,f){const he=await fetch(v,f);if(!he.ok){const Pe=await he.json().catch(()=>({}));throw new Error(Pe.error||`Request failed: ${he.status}`)}return he.json()}async function J(){const v=await fetch("/api/v1/backtest/tourism?min_events=12"),f=await v.json().catch(()=>({}));if(![200,409].includes(v.status))throw new Error(f.error||`Request failed: ${v.status}`);return f}async function q(){const v=await fetch("/api/v1/research/tourism/latest");if(v.status===404)return null;const f=await v.json().catch(()=>({}));if(!v.ok)throw new Error(f.error||`Request failed: ${v.status}`);return f}const oe=ae(()=>{var v;return((v=I.value)==null?void 0:v.orders)??[]}),ce=ae(()=>{var v;return((v=I.value)==null?void 0:v.invested)??0}),Oe=ae(()=>{var v;return((v=I.value)==null?void 0:v.unallocated_cash)??0}),fe=v=>oe.value.filter(f=>f.bucket===v);async function vt(){U.value=!0,I.value=null;try{I.value=await $("/api/v1/suggestion",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({capital:Number(a.value)})})}catch(v){ut.value=v.message}finally{U.value=!1}}async function us(){Ge.value=!0,Je.value="";try{const[v,f,he,Pe,je,Qe,ps,_t,hs,gs]=await Promise.all([$("/api/v1/dashboard/summary"),$("/api/v1/factors/tourism/observations"),$("/api/v1/signals"),$("/api/v1/factors"),$("/api/v1/themes"),$("/api/v1/dashboard"),$("/api/v1/paper/ledger"),$("/api/v1/auth/paper",{credentials:"include"}),J(),q()]);t.value=v,s.value=f,n.value=he,l.value=Pe,i.value=je,r.value=Qe,$e.value=ps,Ht.value=!!_t.authenticated,as.value=_t.mode||"token",ue.value=_t.enabled!==!1,se.value=_t.warning||"",Et.value=hs,Ye.value=gs}catch(v){Je.value=v.message}finally{Ge.value=!1}}async function be(v){u.value=v,g.value=null,h.value=!0;try{g.value=await $(`/api/v1/symbols/${v}`)}catch(f){g.value={error:f.message,symbol:v}}finally{h.value=!1}}function Ce(){u.value=null,g.value=null}async function fs(){try{const v=await $("/api/v1/backtest/readiness");V.value=v,!y.value&&v.recommended_start&&(y.value=v.recommended_start),!R.value&&v.recommended_end&&(R.value=v.recommended_end)}catch{V.value=null}}async function ds(){try{const v=await $("/api/v1/scheduler/sources");Te.value=v.sources||[]}catch{Te.value=[]}Ee.value=!0}const Ys=v=>({ok:"ปกติ",network:"เครือข่ายขัดข้อง",timeout:"หมดเวลา",http:"HTTP error",parse:"รูปแบบข้อมูลผิด",structure:"หน้าเว็บเปลี่ยนโครงสร้าง",auth:"สิทธิ์/ยืนยันตัวตน",other:"อื่น ๆ"})[v]||v;async function ki(v){const f=`[${v.at}] ${v.label} (${v.key}) — ${v.ok?"OK":"FAIL: "+Ys(v.category)} ${v.detail?"| "+v.detail:""}`;try{await navigator.clipboard.writeText(f),ut.value=`คัดลอกสาเหตุของ ${v.key} แล้ว`}catch{ut.value=f}}function Ti(v){return v.ok?"":` (สาเหตุน่าจะ: ${Ys(v.category)})`}async function Ei(){K.value=!0,A.value=null;try{A.value=await $("/api/v1/backtest/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:y.value,end:R.value,capital:Number(M.value),use_ledger:H.value})}),await Dn()}catch(v){A.value={error:v.message}}finally{K.value=!1}}async function Dn(){try{ee.value=(await $("/api/v1/backtest/run")).runs||[]}catch{ee.value=[]}}const Pt=v=>v!=null?v>=0?"positive-text":"negative-text":"";return Zl(async()=>{await us(),await Promise.all([Dn(),fs(),ds()])}),(v,f)=>{var he,Pe,je,Qe,ps,_t,hs,gs,Ln,$n,Nn;return T(),E("div",na,[f[76]||(f[76]=pr('',1)),o("main",la,[o("header",ia,[f[16]||(f[16]=o("div",null,[o("div",{class:"eyebrow"},"Alternative data · SET50"),o("h1",null,"SET50 Signal Lab"),o("p",{class:"subtitle"},"ภาพรวม alternative factors ไทย ไปจนถึงสัญญาณลงทุนที่อธิบายได้ — research + paper only")],-1)),o("div",oa,[o("div",{class:ne(["freshness-pill",Ze.value?"pill-live":"pill-fixture"])},[f[15]||(f[15]=o("span",{class:"freshness-dot"},null,-1)),W(m(Ze.value?"ข้อมูลจริงจากแหล่งไทย":"ข้อมูลจำลอง (fixture)"),1)],2),o("div",ra,"ข้อมูล "+m(((he=t.value)==null?void 0:he.as_of)||"—"),1)])]),Ge.value?(T(),E("div",aa,"กำลังโหลดข้อมูล…")):Je.value?(T(),E("div",ca,m(Je.value),1)):(T(),E(le,{key:2},[o("section",ua,[o("article",fa,[f[19]||(f[19]=o("div",{class:"kpi-label"},"สัญญาณที่ใช้งาน",-1)),o("div",da,m(ht.value.long),1),o("div",pa,[o("span",ha,m(ht.value.long)+" ซื้อ",1),f[17]||(f[17]=W(" · ",-1)),o("span",ga,m(ht.value.short)+" ขาย",1),f[18]||(f[18]=W(" · ",-1)),o("span",va,m(ht.value.neutral)+" เป็นกลาง",1)])]),o("article",_a,[f[20]||(f[20]=o("div",{class:"kpi-label"},"แหล่งข้อมูลที่ใช้",-1)),o("div",ma,m(cs.value)+" ปัจจัย · "+m(Vt.value)+" แหล่ง",1),o("div",ba,"ข้อมูลจริงจากแหล่งไทย "+m(Ze.value?"(จริง)":"—"),1)])]),o("section",ya,[o("div",xa,[f[21]||(f[21]=o("div",null,[o("div",{class:"section-kicker"},"ธีม"),o("h2",null,"ธีม (Themes)"),o("p",{class:"panel-subtitle"},"ภาพรวม alternative factors ของไทย — แต่ละธีมมีความถี่ข้อมูลต่างกัน (monthly / quarterly) ดังนั้นอย่าเทียบเป็นจุดเวลาเดียวกัน.")],-1)),o("span",Sa,"รวม "+m(w.value)+" symbols",1)]),o("div",wa,[(T(!0),E(le,null,Ae(Ne.value,p=>(T(),E("article",{key:p.id,class:"theme-card"},[o("div",Ca,[o("span",ka,m(gt(p.frequency)),1),o("span",Ta,m(p.label_th),1)]),o("div",Ea,[f[22]||(f[22]=o("span",{class:"theme-surprise-label"},"ความต่าง (surprise)",-1)),o("span",Oa,m(p.surprise!=null?_(p.surprise,2)+"σ":"—"),1)]),o("div",Pa,[p.id==="auto_credit"&&p.read.new_car_sales_yoy!=null?(T(),E("div",Aa,m(_(p.read.new_car_sales_yoy))+"% YoY ยอดขายรถ",1)):p.id==="auto_credit"&&p.read.auto_npl_pct!=null?(T(),E("div",Ra,"NPL "+m(_(p.read.auto_npl_pct))+"%",1)):p.id==="refining_energy"&&(p.read.quarterly||p.read.net_profit)?(T(),E("div",Ma,"กำไรสุทธิ TOP (รายไตรมาส)")):p.id==="tourism"?(T(),E("div",Ia,"signal tourism "+m(p.surprise!=null?_(p.surprise,2):"—")+"σ",1)):p.id==="banks"&&p.read.interest_rate_pct!=null?(T(),E("div",Fa,"ดอกเบี้ย "+m(_(p.read.interest_rate_pct))+"%",1)):p.id==="banks"&&p.read.bank_npl_pct!=null?(T(),E("div",Da,"NPL ภาคการเงิน "+m(_(p.read.bank_npl_pct))+"%",1)):(p.id==="retail"||p.id==="consumer_staples")&&p.read.retail_sales_yoy!=null?(T(),E("div",La,"ยอดขายปลีก "+m(_(p.read.retail_sales_yoy))+"% YoY",1)):(p.id==="retail"||p.id==="consumer_staples")&&p.read.consumer_confidence!=null?(T(),E("div",$a,"เชื่อมั่นผู้บริโภค "+m(_(p.read.consumer_confidence,1)),1)):p.id==="nonbank_finance"&&p.read.consumer_credit!=null?(T(),E("div",Na,"สินเชื่อผู้บริโภค "+m(_(p.read.consumer_credit/1e6,2))+" ล้านลบ.",1)):p.id==="nonbank_finance"&&p.read.household_debt_gdp!=null?(T(),E("div",ja,"หนี้ครัวเรือน "+m(_(p.read.household_debt_gdp))+"% GDP",1)):p.id==="property"&&p.read.property_prices_yoy!=null?(T(),E("div",Ha,"ราคาอสังหา "+m(_(p.read.property_prices_yoy))+"% YoY",1)):p.id==="telecom_it"&&p.read.business_confidence!=null?(T(),E("div",Va,"เชื่อมั่นธุรกิจ "+m(_(p.read.business_confidence,1)),1)):p.id==="healthcare"&&p.read.consumption_yoy!=null?(T(),E("div",Ba,"บริโภค "+m(_(p.read.consumption_yoy))+"% YoY",1)):ge("",!0)]),p.narrative?(T(),E("div",Ka,m(p.narrative),1)):ge("",!0)]))),128))]),Object.keys(Xe.value).length?(T(),E("div",Ua,[f[28]||(f[28]=o("div",{class:"section-kicker"},"ภาพรวมประเทศไทย",-1)),o("div",Wa,[o("span",qa,[f[23]||(f[23]=W("การบริโภคภาคเอกชน ",-1)),o("strong",null,m(Xe.value.private_consumption_yoy)+"%",1)]),o("span",za,[f[24]||(f[24]=W("การลงทุนเอกชน ",-1)),o("strong",null,m(Xe.value.private_investment_yoy)+"%",1)]),o("span",Ya,[f[25]||(f[25]=W("เงินเฟ้อ ",-1)),o("strong",null,m(Xe.value.headline_inflation_yoy)+"%",1)]),o("span",Ga,[f[26]||(f[26]=W("การว่างงาน ",-1)),o("strong",null,m(Xe.value.unemployment_pct)+"%",1)]),o("span",Ja,[f[27]||(f[27]=W("นักท่องเที่ยว YTD ",-1)),o("strong",null,m(Xe.value.tourists_ytd_mn)+" ล้าน",1)])])])):ge("",!0)]),o("section",Xa,[o("div",Za,[f[29]||(f[29]=o("div",null,[o("div",{class:"section-kicker"},"ตารางหุ้น"),o("h2",null,"ตารางหุ้น"),o("p",{class:"panel-subtitle"},[W("ตารางเดียวรวมทุกธีม — สัญญาณ + คะแนนรวม (60% ธีม / 40% พื้นฐาน) + มูลค่าพื้นฐานจาก Siamchart. เรียงได้โดยคลิกหัวตาราง; เปิด "),o("em",null,"เฉพาะหุ้นปันผล"),W(" เพื่อกรองหุ้นที่จ่ายปันผล.")])],-1)),o("div",Qa,[o("label",ec,[Mt(o("input",{type:"checkbox","onUpdate:modelValue":f[0]||(f[0]=p=>ie.value=p)},null,512),[[hl,ie.value]]),o("span",null,"เฉพาะหุ้นปันผล ("+m(b.value)+")",1)]),o("span",{class:ne(["status-tag",d.value?"":"warning-tag"])},m(d.value?"Siamchart ใช้งานได้":"ไม่มี factor"),3)])]),d.value?(T(),E("div",sc,[o("table",nc,[o("thead",null,[o("tr",null,[o("th",{class:ne(["sortable",{active:te.value==="signal_score"}]),onClick:f[1]||(f[1]=p=>k("signal_score"))},"สัญญาณ "+m(C("signal_score")),3),o("th",{class:ne(["sortable",{active:te.value==="combined"}]),onClick:f[2]||(f[2]=p=>k("combined")),title:"60% ธีม + 40% พื้นฐาน"},"คะแนนรวม (60/40) "+m(C("combined")),3),o("th",{class:ne(["sortable",{active:te.value==="symbol"}]),onClick:f[3]||(f[3]=p=>k("symbol"))},"หุ้น "+m(C("symbol")),3),f[31]||(f[31]=o("th",null,"ธีม",-1)),o("th",{class:ne(["sortable",{active:te.value==="pe"}]),onClick:f[4]||(f[4]=p=>k("pe"))},"P/E "+m(C("pe")),3),o("th",{class:ne(["sortable",{active:te.value==="eps"}]),onClick:f[5]||(f[5]=p=>k("eps"))},"EPS "+m(C("eps")),3),o("th",{class:ne(["sortable",{active:te.value==="eps_growth_yoy"}]),onClick:f[6]||(f[6]=p=>k("eps_growth_yoy"))},"EPS YoY "+m(C("eps_growth_yoy")),3),o("th",{class:ne(["sortable",{active:te.value==="dividend_yield"}]),onClick:f[7]||(f[7]=p=>k("dividend_yield"))},"ปันผล % "+m(C("dividend_yield")),3),o("th",{class:ne(["sortable",{active:te.value==="pbv"}]),onClick:f[8]||(f[8]=p=>k("pbv"))},"P/BV "+m(C("pbv")),3),o("th",{class:ne(["sortable",{active:te.value==="roe"}]),onClick:f[9]||(f[9]=p=>k("roe"))},"ROE "+m(C("roe")),3)])]),o("tbody",null,[(T(!0),E(le,null,Ae(O.value,p=>{var At;return T(),E("tr",{key:p.symbol,class:"clickable-row",onClick:vs=>be(p.symbol)},[o("td",null,[p.signal_side?(T(),E("span",{key:0,class:ne(["side-pill",p.signal_side.toLowerCase()])},m(p.signal_side),3)):(T(),E("span",ic,"—"))]),o("td",oc,m(((At=ft.value[p.symbol])==null?void 0:At.combined)!=null?_(ft.value[p.symbol].combined):"—"),1),o("td",null,[o("strong",rc,m(p.symbol),1)]),o("td",null,[(T(!0),E(le,null,Ae(c(p.symbol),vs=>(T(),E("span",{key:vs,class:"theme-tag"},m(vs),1))),128)),c(p.symbol).length?ge("",!0):(T(),E("span",ac,"—"))]),o("td",cc,m(p.pe!=null?_(p.pe):"—"),1),o("td",null,m(p.eps!=null?_(p.eps):"—"),1),o("td",{class:ne(p.eps_growth_yoy>=0?"positive-text":"negative-text")},m(p.eps_growth_yoy!=null?(p.eps_growth_yoy>=0?"+":"")+_(p.eps_growth_yoy)+"%":"—"),3),o("td",{class:ne(p.dividend_yield>=0?"positive-text":"")},[W(m(p.dividend_yield!=null?_(p.dividend_yield)+"%":"—"),1),p.is_dividend?(T(),E("span",uc,"●")):ge("",!0)],2),o("td",null,m(p.pbv!=null?_(p.pbv):"—"),1),o("td",{class:ne(p.roe>=0?"positive-text":"negative-text")},m(p.roe!=null?_(p.roe)+"%":"—"),3)],8,lc)}),128))])])])):(T(),E("div",tc,[...f[30]||(f[30]=[W("Siamchart snapshot ไม่อยู่บน disk. รัน ",-1),o("code",null,"collect_siamchart.py --group SET50 --with-info",-1),W(" เพื่อเก็บข้อมูล.",-1)])]))]),o("section",fc,[o("div",dc,[f[32]||(f[32]=o("div",null,[o("div",{class:"section-kicker"},"ที่มาของข้อมูล"),o("h2",null,"แหล่งข้อมูลทั้งหมด"),o("p",{class:"panel-subtitle"},"รายการแหล่งข้อมูลจริงที่ใช้ — ดึงมาเมื่อใด และข้อมูลชุดไหน ข้อมูลทั้งหมดจากแหล่งไทย.")],-1)),o("span",pc,m(cs.value)+" ปัจจัย · "+m(Vt.value)+" แหล่ง",1)]),o("div",hc,[o("table",gc,[f[33]||(f[33]=o("thead",null,[o("tr",null,[o("th",null,"ข้อมูล"),o("th",null,"แหล่ง"),o("th",null,"ช่วงข้อมูล"),o("th",null,"ความถี่"),o("th",null,"อัปเดตครั้งต่อไป"),o("th",null,"อัปเดตล่าสุด")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(pt.value,(p,At)=>(T(),E("tr",{key:At},[o("td",null,m(p.จาก||p.ขอบเขต),1),o("td",vc,m(p.แหล่ง),1),o("td",_c,m(p.ข้อมูล),1),o("td",mc,m(p.ความถี่||"—"),1),o("td",bc,m(p.อัปเดตครั้งต่อไป?L(p.อัปเดตครั้งต่อไป):"—"),1),o("td",yc,m(p.dึงมาเมื่อ?L(p.dึงมาเมื่อ):"—"),1)]))),128))])])])]),o("section",xc,[f[35]||(f[35]=o("div",{class:"panel-header signal-header"},[o("div",null,[o("div",{class:"section-kicker"},"สถานะการดึงข้อมูล"),o("h2",null,"Log — สถานะแหล่งข้อมูล"),o("p",{class:"panel-subtitle"},'ผลการดึงข้อมูลครั้งล่าสุดของแต่ละแหล่ง โดยระบบวิเคราะห์สาเหตุให้อัตโนมัติ (เครือข่าย / หมดเวลา / หน้าเว็บเปลี่ยนโครงสร้าง / รูปแบบข้อมูล เป็นต้น) — กดปุ่ม "คัดลอก" เพื่อ copy สาเหตุไปแจ้ง/ตรวจสอบได้ทันที.')])],-1)),Te.value.length===0?(T(),E("div",Sc,"ยังไม่มี log — รอรอบ refresh ถัดไป (ปกติ ~ทุกวันสำหรับราคา, ~รายเดือน/ไตรมาสสำหรับปัจจัย).")):(T(),E("div",wc,[o("table",Cc,[f[34]||(f[34]=o("thead",null,[o("tr",null,[o("th",null,"แหล่ง"),o("th",null,"ผลลัพธ์"),o("th",null,"สาเหตุ"),o("th",null,"เวลา"),o("th")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(Te.value.slice(0,20),(p,At)=>(T(),E("tr",{key:At},[o("td",kc,[W(m(p.label),1),o("div",Tc,m(p.key),1)]),o("td",null,[p.ok?(T(),E("span",Ec,"OK")):(T(),E("span",Oc,"FAIL"))]),o("td",null,[p.ok?(T(),E(le,{key:0},[W("—")],64)):(T(),E(le,{key:1},[o("div",null,m(Ys(p.category))+m(Ti(p)),1),p.detail?(T(),E("div",Pc,m(p.detail.slice(0,160)),1)):ge("",!0)],64))]),o("td",Ac,m(p.at?L(p.at):"—"),1),o("td",null,[p.ok?ge("",!0):(T(),E("button",{key:0,class:"primary-btn",style:{padding:"2px 8px"},onClick:vs=>ki(p)},"คัดลอกสาเหตุ",8,Rc))])]))),128))])])]))]),o("section",Mc,[o("div",Ic,[f[36]||(f[36]=o("div",null,[o("div",{class:"section-kicker"},"คำแนะนำการลงทุน"),o("h2",null,"จัดสรรทุน (Suggestion)"),o("p",{class:"panel-subtitle"},"กรอกทุน และระบบแนะนำสัดส่วน 50 / 20 / 30 — หุ้นที่ทำกำไรได้มากสุดแล้วจ่ายปันผล, หุ้นทำกำไรแต่ไม่ปันผล, และหุ้นปันผลสูงสุด (ไม่ซ้ำ) — ขั้นต่ำ 100 หุ้นต่อตัว.")],-1)),o("span",Fc,m(I.value?"ใช้ได้":"รอใส่ทุน"),1)]),o("div",Dc,[o("div",Lc,[f[37]||(f[37]=o("label",null,"ทุน (บาท)",-1)),Mt(o("input",{"onUpdate:modelValue":f[10]||(f[10]=p=>a.value=p),type:"number",min:"1000",step:"1000"},null,512),[[xs,a.value]])]),o("button",{class:"primary-button",disabled:U.value,onClick:vt},m(U.value?"กำลังคำนวณ…":"คำนวณการจัดสรร"),9,$c)]),I.value?(T(),E("div",Nc,[o("div",jc,[o("div",Hc,[f[38]||(f[38]=o("span",null,"ลงทุนรวม",-1)),o("strong",null,m(_(ce.value,0))+" บาท",1)]),o("div",Vc,[f[39]||(f[39]=o("span",null,"เงินสดเหลือ",-1)),o("strong",null,m(_(Oe.value,0))+" บาท",1)])]),o("div",Bc,m(I.value.data_note),1),o("div",Kc,[o("div",Uc,[f[41]||(f[41]=o("div",{class:"sim-bucket-head"},[o("span",{class:"sim-bucket-tag b1"},"50%"),o("strong",null,"ทำกำไร + จ่ายปันผล")],-1)),o("table",Wc,[fe(1).length?(T(),E("tbody",qc,[(T(!0),E(le,null,Ae(fe(1),p=>(T(),E("tr",{key:"b1"+p.symbol},[o("td",null,m(p.symbol),1),o("td",zc,"qty "+m(p.qty),1),o("td",Yc,"@ "+m(_(p.price)),1),o("td",Gc,m(_(p.notional,0)),1)]))),128))])):(T(),E("tbody",Jc,[...f[40]||(f[40]=[o("tr",null,[o("td",{class:"muted-cell"},"ไม่มีหุ้นที่เข้าเกณฑ์")],-1)])]))])]),o("div",Xc,[f[43]||(f[43]=o("div",{class:"sim-bucket-head"},[o("span",{class:"sim-bucket-tag b2"},"20%"),o("strong",null,"ทำกำไร ไม่ปันผล")],-1)),o("table",Zc,[fe(2).length?(T(),E("tbody",Qc,[(T(!0),E(le,null,Ae(fe(2),p=>(T(),E("tr",{key:"b2"+p.symbol},[o("td",null,m(p.symbol),1),o("td",eu,"qty "+m(p.qty),1),o("td",tu,"@ "+m(_(p.price)),1),o("td",su,m(_(p.notional,0)),1)]))),128))])):(T(),E("tbody",nu,[...f[42]||(f[42]=[o("tr",null,[o("td",{class:"muted-cell"},"ไม่มีหุ้นที่เข้าเกณฑ์")],-1)])]))])]),o("div",lu,[f[45]||(f[45]=o("div",{class:"sim-bucket-head"},[o("span",{class:"sim-bucket-tag b3"},"30%"),o("strong",null,"ปันผลสูงสุด (ไม่ซ้ำ)")],-1)),o("table",iu,[fe(3).length?(T(),E("tbody",ou,[(T(!0),E(le,null,Ae(fe(3),p=>(T(),E("tr",{key:"b3"+p.symbol},[o("td",null,m(p.symbol),1),o("td",ru,"qty "+m(p.qty),1),o("td",au,"@ "+m(_(p.price)),1),o("td",cu,m(_(p.notional,0)),1)]))),128))])):(T(),E("tbody",uu,[...f[44]||(f[44]=[o("tr",null,[o("td",{class:"muted-cell"},"ไม่มีหุ้นที่เข้าเกณฑ์")],-1)])]))])])])])):ge("",!0),I.value?ge("",!0):(T(),E("div",fu,"กด 'คำนวณการจัดสรร' เพื่อดูว่า 50/20/30 จัดสรรทุนของคุณไปที่หุ้นไหนบ้าง"))]),o("section",du,[f[62]||(f[62]=o("div",{class:"panel-header signal-header"},[o("div",null,[o("div",{class:"section-kicker"},"การย้อนทดสอบ"),o("h2",null,"Backtest (ย้อนทดสอบ)"),o("p",{class:"panel-subtitle"},"กำหนดช่วงวัน แล้วระบบจัดสรร 50/20/30 ณ วันที่เริ่ม ลงทุน และปรับพอร์ตตามข้อมูลที่เผยแพร่ใหม่ (event-driven) จนถึงวันสิ้นสุด — สรุปกำไร/ขาดทุนจากราคา + เงินปันผล. มีค่าธรรมเนียม 0.3% ต่อรายการ และปันผลเข้าบัญชีใน 30 วันหลัง ex-date.")])],-1)),o("div",pu,[o("label",null,[f[46]||(f[46]=W("ตั้งแต่ ",-1)),Mt(o("input",{type:"date","onUpdate:modelValue":f[11]||(f[11]=p=>y.value=p)},null,512),[[xs,y.value]])]),o("label",null,[f[47]||(f[47]=W("ถึง ",-1)),Mt(o("input",{type:"date","onUpdate:modelValue":f[12]||(f[12]=p=>R.value=p)},null,512),[[xs,R.value]])]),o("label",null,[f[48]||(f[48]=W("ทุน ",-1)),Mt(o("input",{type:"number","onUpdate:modelValue":f[13]||(f[13]=p=>M.value=p),step:"100000"},null,512),[[xs,M.value,void 0,{number:!0}]])]),o("label",hu,[Mt(o("input",{type:"checkbox","onUpdate:modelValue":f[14]||(f[14]=p=>H.value=p)},null,512),[[hl,H.value]]),f[49]||(f[49]=W(" ใช้ ledger ปันผลตามวันที่จริง ",-1))]),o("button",{class:"primary-btn",disabled:K.value||V.value&&!V.value.ready,onClick:Ei},m(K.value?"กำลังย้อนทดสอบ…":"รัน Backtest"),9,gu)]),V.value&&!V.value.ready?(T(),E("div",vu,[f[50]||(f[50]=o("strong",null,"ยังรันย้อนทดสอบแบบ strict PIT ไม่ได้ — ขาดข้อมูล coverage:",-1)),o("div",_u,m((V.value.missing||[]).slice(0,8).join(", "))+m((V.value.missing||[]).length>8?"…":""),1),o("div",mu,"วันเริ่มที่แนะนำ: "+m(V.value.recommended_start||"—")+" · วันสิ้นสุด: "+m(V.value.recommended_end||"—"),1)])):ge("",!0),(Pe=A.value)!=null&&Pe.error?(T(),E("div",bu,m(A.value.error),1)):A.value&&!A.value.error?(T(),E("div",yu,[o("div",xu,[o("div",Su,[f[51]||(f[51]=o("span",null,"กำไรจากราคา (realized)",-1)),o("strong",{class:ne(Pt(A.value.realized_trading_pnl))},m(_(A.value.realized_trading_pnl))+" บาท",3)]),o("div",wu,[f[52]||(f[52]=o("span",null,"กำไรจากราคา (unrealized)",-1)),o("strong",{class:ne(Pt(A.value.unrealized_trading_pnl))},m(_(A.value.unrealized_trading_pnl))+" บาท",3)]),o("div",Cu,[f[53]||(f[53]=o("span",null,"เงินปันผลที่ได้รับ",-1)),o("strong",ku,m(_(A.value.dividend_cash_received))+" บาท",1)]),o("div",Tu,[f[54]||(f[54]=o("span",null,"ค่าธรรมเนียม (0.3%)",-1)),o("strong",Eu,"–"+m(_(A.value.transaction_costs))+" บาท",1)]),o("div",Ou,[f[55]||(f[55]=o("span",null,"เงินปันผลค้างรับ",-1)),o("strong",null,m(_(A.value.dividend_receivable))+" บาท",1)]),o("div",Pu,[f[56]||(f[56]=o("span",null,"มูลค่าสุดท้าย (equity)",-1)),o("strong",null,m(_(A.value.final_equity))+" บาท",1)]),o("div",Au,[f[57]||(f[57]=o("span",null,"ผลตอบแทนสุทธิ",-1)),o("strong",{class:ne(Pt(A.value.net_return))},m((A.value.net_return*100).toFixed(2))+"%",3)])]),o("div",Ru,"Rebalances: "+m(A.value.rebalances)+" · ปันผลตาม: "+m(A.value.dividend_timing)+" · ช่วง "+m(A.value.start)+" → "+m(A.value.end),1),A.value.leakage_guard?(T(),E("div",Mu,"✅ strict PIT (leakage guard active)")):(T(),E("div",Iu,"คำเตือน: ไม่ได้พิสูจน์ point-in-time (non-PIT)")),A.value.holdings&&A.value.holdings.length?(T(),E("div",Fu,[f[59]||(f[59]=o("strong",null,"พอร์ตสุดท้าย:",-1)),o("table",Du,[f[58]||(f[58]=o("thead",null,[o("tr",null,[o("th",null,"หุ้น"),o("th",null,"จำนวน"),o("th",null,"ต้นทุนเฉลี่ย"),o("th",null,"ราคาล่าสุด"),o("th",null,"มูลค่า"),o("th",null,"กำไร unrealized")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(A.value.holdings,p=>(T(),E("tr",{key:p.symbol},[o("td",Lu,m(p.symbol),1),o("td",null,m(p.qty),1),o("td",null,m(_(p.average_cost,2)),1),o("td",null,m(_(p.last_price,2)),1),o("td",null,m(_(p.market_value)),1),o("td",{class:ne(Pt(p.unrealized_pnl))},m(_(p.unrealized_pnl)),3)]))),128))])])])):ge("",!0)])):(T(),E("div",$u,"กำหนดช่วงวันแล้วกด 'รัน Backtest' เพื่อดูผล (กำไร/ขาดทุนจากราคา + ปันผล)")),ee.value.length?(T(),E("div",Nu,[f[61]||(f[61]=o("div",{class:"section-kicker"},"ประวัติการย้อนทดสอบ",-1)),o("table",ju,[f[60]||(f[60]=o("thead",null,[o("tr",null,[o("th",null,"#"),o("th",null,"ช่วง"),o("th",null,"ทุน"),o("th",null,"กำไรราคา"),o("th",null,"ปันผล"),o("th",null,"ผลตอบแทน"),o("th",null,"รันเมื่อ")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(ee.value.slice().reverse(),p=>(T(),E("tr",{key:p.id},[o("td",null,m(p.id),1),o("td",null,[W(m(p.start)+" → "+m(p.end)+" ",1),p.leakage_guard===!1?(T(),E("span",Hu,"descriptive non-PIT")):ge("",!0)]),o("td",null,m(_(p.capital)),1),o("td",{class:ne(Pt(p.price_pnl))},m(_(p.price_pnl)),3),o("td",Vu,[W(m(_(p.dividend_income)),1),o("span",{class:ne(["status-tag",P(p.dividend_method).cls]),style:js([P(p.dividend_method).style||void 0,{"margin-left":"4px"}]),title:F(p.dividend_method)},m(P(p.dividend_method).label),15,Bu)]),o("td",{class:ne(Pt(p.net_return))},m((p.net_return*100).toFixed(2))+"%",3),o("td",Ku,m(p.ran_at?L(p.ran_at):"—"),1)]))),128))])])])):ge("",!0)])],64))]),u.value?(T(),E("div",{key:0,class:"modal-overlay",onClick:Xr(Ce,["self"])},[o("div",Uu,[o("div",Wu,[o("div",null,[f[63]||(f[63]=o("div",{class:"modal-kicker"},"การวิเคราะห์รายหุ้น",-1)),o("h3",null,m(u.value),1)]),o("button",{class:"modal-close",onClick:Ce},"✕")]),h.value?(T(),E("div",qu,"กำลังโหลดการวิเคราะห์…")):(je=g.value)!=null&&je.error?(T(),E("div",zu,m(g.value.error),1)):g.value?(T(),E("div",Yu,[o("div",Gu,[f[67]||(f[67]=o("div",{class:"modal-section-title"},"ธีมที่เกี่ยวข้อง (คะแนนต่อธีม)",-1)),(Qe=g.value.themes)!=null&&Qe.length?(T(),E("div",Ju,[(T(!0),E(le,null,Ae(g.value.theme_contributions,p=>(T(),E("div",{key:p.theme,class:"contrib-line"},[o("span",Xu,m(p.label_th||zs.value[p.theme]||p.theme),1),p.surprise!=null?(T(),E("span",Zu,[o("em",null,m(_(p.surprise))+"σ",1),f[64]||(f[64]=W(" × คุณภาพ ",-1)),o("em",null,m(p.quality),1),f[65]||(f[65]=W(" = ",-1)),o("strong",null,m(_(p.theme_score))+"σ",1)])):(T(),E("strong",Qu,"ยังไม่มีข้อมูล"))]))),128)),f[66]||(f[66]=o("div",{class:"modal-sub"},"คะแนนธีม = ค่าเฉลี่ยของ (surprise × คุณภาพหุ้น) ที่หุ้นนี้อยู่ใน",-1))])):(T(),E("div",ef,"หุ้นนี้ยังไม่ได้จัดอยู่ในธีมใด (จะอัปเดตเมื่อเพิ่มธีม)"))]),o("div",tf,[f[73]||(f[73]=o("div",{class:"modal-section-title"},"มูลค่าพื้นฐาน (Siamchart)",-1)),o("div",sf,[o("span",null,[f[68]||(f[68]=W("P/E ",-1)),o("strong",null,m(((ps=g.value.fundamentals)==null?void 0:ps.pe)??"—"),1)]),o("span",null,[f[69]||(f[69]=W("EPS ",-1)),o("strong",null,m(((_t=g.value.fundamentals)==null?void 0:_t.eps)??"—"),1)]),o("span",null,[f[70]||(f[70]=W("P/BV ",-1)),o("strong",null,m(((hs=g.value.fundamentals)==null?void 0:hs.pbv)??"—"),1)]),o("span",null,[f[71]||(f[71]=W("ROE ",-1)),o("strong",null,m(((gs=g.value.fundamentals)==null?void 0:gs.roe)??"—"),1)]),o("span",null,[f[72]||(f[72]=W("ปันผล ",-1)),o("strong",null,m((Ln=g.value.fundamentals)!=null&&Ln.is_dividend?"จ่าย":"—"),1)])]),o("div",nf,"ภาพรวม: "+m(g.value.company_name||u.value),1)]),o("div",lf,[f[75]||(f[75]=o("div",{class:"modal-section-title"},"ขั้นตอนการคำนวณคะแนนรวม",-1)),o("div",of,[o("div",rf,m(g.value.combined_formula),1),(T(!0),E(le,null,Ae(g.value.combined_calc,p=>(T(),E("div",{key:p.label,class:"calc-step"},[o("div",af,[o("span",null,m(p.label),1),o("strong",null,m(_(p.value))+" × "+m(p.weight),1)]),o("div",cf,m(p.note),1)]))),128)),g.value.siamchart_z_note?(T(),E("div",uf,[W(" คะแนนพื้นฐานได้จาก z-score: z = (ค่า"+m(g.value.siamchart_z_note.raw_i)+" − ค่าเฉลี่ย "+m(g.value.siamchart_z_note.population_mean)+") / ค่าเบี่ยงเบน "+m(g.value.siamchart_z_note.population_stdev),1),f[74]||(f[74]=o("br",null,null,-1)),W("เทียบกับ "+m(g.value.siamchart_z_note.universe_size)+" หุ้นใน SET50 ",1)])):ge("",!0)]),o("div",ff,"ราคาล่าสุด: "+m((($n=g.value.price)==null?void 0:$n.latest)!=null?_(g.value.price.latest):"—")+" ("+m(((Nn=g.value.price)==null?void 0:Nn.date)||"—")+")",1)])])):ge("",!0)])])):ge("",!0)])}}};ea(df).mount("#app"); +`))),e[St]=Is(l);const i=n||l.props&&l.props.type==="number";xt(e,t?"change":"input",r=>{r.target.composing||e[St](rn(e.value,s,i))}),(s||i)&&xt(e,"change",()=>{e.value=rn(e.value,s,i)}),t||(xt(e,"compositionstart",zr),xt(e,"compositionend",pl),xt(e,"change",pl))},mounted(e,{value:t,modifiers:{trim:s,number:n}}){const l=t??"",i=e[ys];delete e[ys],i!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==i?e[St](rn(e.value,s,n)):e.value=l},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:l,number:i}},r){if(e[St]=Is(r),e.composing)return;const a=(i||e.type==="number")&&!/^0\d/.test(e.value)?xn(e.value):e.value,u=t??"";if(a===u)return;const g=e.getRootNode();(g instanceof Document||g instanceof ShadowRoot)&&g.activeElement===e&&e.type!=="range"&&(n&&t===s||l&&e.value.trim()===u)||(e.value=u)}},hl={deep:!0,created(e,t,s){e[St]=Is(s),xt(e,"change",()=>{const n=e._modelValue,l=Yr(e),i=e.checked,r=e[St];if(N(n)){const a=Cl(n,l),u=a!==-1;if(i&&!u)r(n.concat(l));else if(!i&&u){const g=[...n];g.splice(a,1),r(g)}}else if(Ls(n)){const a=new Set(n);i?a.add(l):a.delete(l),r(a)}else r(Ci(e,i))})},mounted:gl,beforeUpdate(e,t,s){e[St]=Is(s),gl(e,t,s)}};function gl(e,{value:t,oldValue:s},n){e._modelValue=t;let l;if(N(t))l=Cl(t,n.props.value)>-1;else if(Ls(t))l=t.has(n.props.value);else{if(t===s)return;l=is(t,Ci(e,!0))}e.checked!==l&&(e.checked=l)}function Yr(e){return"_value"in e?e._value:e.value}function Ci(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const Gr=["ctrl","shift","alt","meta"],Jr={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>Gr.some(s=>e[`${s}Key`]&&!t.includes(s))},Xr=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=((l,...i)=>{for(let r=0;r{const t=Qr().createApp(...e),{mount:s}=t;return t.mount=n=>{const l=sa(n);if(!l)return;const i=t._component;!j(i)&&!i.render&&!i.template&&(i.template=l.innerHTML),l.nodeType===1&&(l.textContent="");const r=s(l,!1,ta(l));return l instanceof Element&&(l.removeAttribute("v-cloak"),l.setAttribute("data-v-app","")),r},t});function ta(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function sa(e){return re(e)?document.querySelector(e):e}const na={class:"app-shell"},la={class:"content",id:"overview"},ia={class:"topbar"},oa={class:"topbar-meta"},ra={class:"as-of"},aa={key:0,class:"state-card"},ca={key:1,class:"state-card error-state"},ua={class:"kpi-grid","aria-label":"Signal summary"},fa={class:"kpi-card accent-card"},da={class:"kpi-value"},pa={class:"kpi-foot"},ha={class:"long-count"},ga={class:"short-count"},va={class:"neutral-count"},_a={class:"kpi-card"},ma={class:"kpi-value"},ba={class:"kpi-foot"},ya={class:"panel theme-panel",id:"themes"},xa={class:"panel-header signal-header"},Sa={class:"status-tag"},wa={class:"theme-grid"},Ca={class:"theme-card-head"},ka={class:"theme-chip"},Ta={class:"theme-label-th"},Ea={class:"theme-surprise"},Oa={class:"theme-surprise-value"},Pa={class:"theme-read"},Aa={key:0,class:"theme-read-value"},Ra={key:1,class:"theme-read-value"},Ma={key:2,class:"theme-read-value"},Ia={key:3,class:"theme-read-value"},Fa={key:4,class:"theme-read-value"},Da={key:5,class:"theme-read-value"},La={key:6,class:"theme-read-value"},$a={key:7,class:"theme-read-value"},Na={key:8,class:"theme-read-value"},ja={key:9,class:"theme-read-value"},Ha={key:10,class:"theme-read-value"},Va={key:11,class:"theme-read-value"},Ba={key:12,class:"theme-read-value"},Ka={key:13,class:"theme-read-value"},Ua={key:0,class:"theme-narrative"},Wa={key:0,class:"macro-panel"},qa={class:"macro-chips"},za={class:"macro-chip"},Ya={class:"macro-chip"},Ga={class:"macro-chip"},Ja={class:"macro-chip"},Xa={class:"macro-chip"},Za={class:"panel stock-panel",id:"stocks"},Qa={class:"panel-header signal-header"},ec={class:"stock-controls"},tc={class:"toggle-filter"},sc={key:0,class:"empty-research"},nc={key:1,class:"table-wrap"},lc={class:"factor-table"},ic=["onClick"],oc={key:1,class:"muted-cell"},rc={class:"combined-cell"},ac={class:"symbol-name"},cc={key:0,class:"muted-cell"},uc={class:"score-cell"},fc={key:0,class:"dividend-dot",title:"จ่ายปันผล"},dc={class:"panel lineage-panel",id:"lineage"},pc={class:"panel-header signal-header"},hc={class:"status-tag"},gc={class:"table-wrap"},vc={class:"source-table"},_c={class:"source-name"},mc={class:"muted-cell"},bc={class:"muted-cell"},yc={class:"muted-cell"},xc={class:"muted-cell"},Sc={class:"panel health-panel",id:"health"},wc={key:0,class:"empty-research muted-cell"},Cc={key:1},kc={class:"source-table"},Tc={class:"source-name"},Ec={class:"muted-cell",style:{"font-size":"11px"}},Oc={key:0,class:"status-tag",style:{background:"#1a7f37",color:"#fff"}},Pc={key:1,class:"status-tag warning-tag"},Ac={key:0,class:"muted-cell",style:{"font-size":"11px","word-break":"break-word"}},Rc={class:"muted-cell"},Mc=["onClick"],Ic={class:"panel sim-panel",id:"suggestion"},Fc={class:"panel-header signal-header"},Dc={class:"status-tag neutral-tag"},Lc={class:"sim-controls"},$c={class:"sim-field"},Nc=["disabled"],jc={key:0,class:"sim-result"},Hc={class:"sim-sums"},Vc={class:"sim-sum"},Bc={class:"sim-sum"},Kc={class:"sim-note"},Uc={class:"sim-buckets"},Wc={class:"sim-bucket"},qc={class:"sim-order-table"},zc={key:0},Yc={class:"muted-cell"},Gc={class:"score-cell"},Jc={class:"score-cell"},Xc={key:1},Zc={class:"sim-bucket"},Qc={class:"sim-order-table"},eu={key:0},tu={class:"muted-cell"},su={class:"score-cell"},nu={class:"score-cell"},lu={key:1},iu={class:"sim-bucket"},ou={class:"sim-order-table"},ru={key:0},au={class:"muted-cell"},cu={class:"score-cell"},uu={class:"score-cell"},fu={key:1},du={key:1,class:"empty-research"},pu={class:"panel backtest-panel",id:"backtest"},hu={class:"backtest-controls"},gu={class:"checkbox-label",style:{display:"flex","align-items":"center",gap:"6px"}},vu=["disabled"],_u={key:0,class:"state-card warning-state"},mu={class:"muted-cell",style:{"margin-top":"4px"}},bu={class:"muted-cell",style:{"margin-top":"2px"}},yu={key:1,class:"state-card error-state"},xu={key:2,class:"backtest-results"},Su={class:"bt-kpi-grid"},wu={class:"bt-kpi"},Cu={class:"bt-kpi"},ku={class:"bt-kpi"},Tu={class:"positive-text"},Eu={class:"bt-kpi"},Ou={class:"negative-text"},Pu={class:"bt-kpi"},Au={class:"bt-kpi"},Ru={class:"bt-kpi"},Mu={class:"bt-meta muted-cell"},Iu={key:0,class:"bt-meta"},Fu={key:1,class:"bt-meta muted-cell"},Du={key:2,class:"bt-holdings"},Lu={class:"source-table",style:{"margin-top":"6px"}},$u={class:"muted-cell"},Nu={key:3,class:"empty-research"},ju={key:4,class:"bt-history"},Hu={class:"source-table"},Vu={key:0,class:"status-tag warning-tag",title:"ใช้คะแนนปัจจุบันย้อนหลัง ไม่ใช่ point-in-time"},Bu={class:"positive-text"},Ku=["title"],Uu={class:"muted-cell"},Wu={class:"modal-card"},qu={class:"modal-head"},zu={key:0,class:"empty-research"},Yu={key:1,class:"state-card error-state"},Gu={key:2,class:"modal-body"},Ju={class:"modal-section"},Xu={key:0,class:"modal-themes"},Zu={class:"contrib-name"},Qu={key:0,class:"contrib-calc"},ef={key:1,class:"muted-cell"},tf={key:1,class:"muted-cell"},sf={class:"modal-section"},nf={class:"fund-grid"},lf={class:"modal-sub"},of={class:"modal-section"},rf={class:"calc-box"},af={class:"calc-line"},cf={class:"calc-step-head"},uf={class:"calc-step-note"},ff={key:0,class:"calc-z"},df={class:"modal-sub"},pf={__name:"App",setup(e){const t=B(null),s=B(null),n=B(null),l=B(null),i=B(null),r=B(null),a=B(1e6),u=B(null),g=B(null),h=B(!1),y=B(""),R=B(""),M=B(1e6),K=B(!1),A=B(null),ee=B([]),V=B(null),H=B(!0),U=B(!1),I=B(null),ie=B(!1),te=B("signal_score"),pe=B("desc"),$e=B({entries:[]}),Et=B(null),Ye=B(null),Ge=B(!0),Je=B(""),ut=B(""),Ht=B(!1),as=B("token"),ue=B(!0),se=B(""),G=ae(()=>{var v;return((v=l.value)==null?void 0:v.factors)??[]}),Ne=ae(()=>{var v;return((v=r.value)==null?void 0:v.themes)??[]}),pt=ae(()=>{var v;return((v=r.value)==null?void 0:v.sources)??[]}),Te=B([]),Ee=B(!1),Xe=ae(()=>{var v;return((v=r.value)==null?void 0:v.macro)??{}}),Vt=ae(()=>pt.value.length),cs=ae(()=>{var v,f;return((f=(v=r.value)==null?void 0:v.source_summary)==null?void 0:f.factor_keys)??Vt.value}),Ze=ae(()=>{var v;return((v=r.value)==null?void 0:v.available)??!1}),Ot=ae(()=>{var v;return((v=r.value)==null?void 0:v.board)??G.value}),ft=ae(()=>{const v={};for(const f of Ot.value)v[f.symbol]=f;return v}),ht=ae(()=>{var f;const v=(f=t.value)==null?void 0:f.signal_summary;return{long:(v==null?void 0:v.long)??0,short:(v==null?void 0:v.short)??0,neutral:(v==null?void 0:v.neutral)??0,total:(v==null?void 0:v.total)??0}}),gt=v=>({monthly:"รายเดือน",quarterly:"รายไตรมาส",annual:"รายปี",daily:"รายวัน"})[v]||v,zs=ae(()=>{const v={};for(const f of Ne.value)v[f.id]=f.label_th;return v});function c(v){const f=ft.value[v];return((f==null?void 0:f.themes)??[]).map(Pe=>zs.value[Pe]||Pe)}const d=ae(()=>{var v;return((v=l.value)==null?void 0:v.available)??!1}),b=ae(()=>{var v;return((v=l.value)==null?void 0:v.dividend_count)??0}),w=ae(()=>{var v;return((v=i.value)==null?void 0:v.combined_count)??0}),S=ae(()=>{let v=G.value;return ie.value&&(v=v.filter(f=>f.is_dividend)),v});function x(v,f){var he;return f==="signal_score"?v.signal_score??(v.signal_side==="LONG"?9999:0):f==="combined"?((he=ft.value[v.symbol])==null?void 0:he.combined)??-9999:f==="symbol"?v.symbol:f==="dividend_yield"?v.dividend_yield??-1:f==="eps_growth_yoy"?v.eps_growth_yoy??-1:f==="pe"?v.pe??0:f==="eps"?v.eps??0:f==="pbv"?v.pbv??0:f==="roe"?v.roe??0:v[f]}const O=ae(()=>{const v=[...S.value],f=pe.value==="asc"?1:-1;return v.sort((he,Pe)=>{const je=x(he,te.value),Qe=x(Pe,te.value);return typeof je=="string"?je.localeCompare(Qe)*f:je===Qe?he.symbol.localeCompare(Pe.symbol):je==null?1:Qe==null?-1:(je-Qe)*f}),v});function k(v){te.value===v?pe.value=pe.value==="asc"?"desc":"asc":(te.value=v,pe.value="desc")}function C(v){return te.value!==v?"":pe.value==="asc"?"↑":"↓"}function _(v,f=2){return Number(v??0).toFixed(f)}function D(v){return v==="dated_ledger"}function P(v){return D(v)?{label:"ตามวันจริง",cls:"status-tag",style:"background:#1a7f37;color:#fff"}:v==="dps_annual_proxy"?{label:"Proxy (ต่อหุ้น)",cls:"status-tag warning-tag"}:{label:"Proxy",cls:"status-tag warning-tag"}}function F(v){return D(v)?"ปันผลตามวันจริงจาก ledger (ex-date × จำนวนหุ้น) — กระแสเงินสดจริง":v==="dps_annual_proxy"?"ประมาณการปันผลต่อหุ้น (DPS ล่าสุด × จำนวนหุ้น) ไม่ใช่กระแสเงินสดตามวันจริง":"ประมาณจาก dividend yield ของพอร์ตสุดท้าย ไม่ใช่กระแสเงินสดปันผลจริง"}function L(v){return v?new Date(v).toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"}):"—"}async function $(v,f){const he=await fetch(v,f);if(!he.ok){const Pe=await he.json().catch(()=>({}));throw new Error(Pe.error||`Request failed: ${he.status}`)}return he.json()}async function J(){const v=await fetch("/api/v1/backtest/tourism?min_events=12"),f=await v.json().catch(()=>({}));if(![200,409].includes(v.status))throw new Error(f.error||`Request failed: ${v.status}`);return f}async function q(){const v=await fetch("/api/v1/research/tourism/latest");if(v.status===404)return null;const f=await v.json().catch(()=>({}));if(!v.ok)throw new Error(f.error||`Request failed: ${v.status}`);return f}const oe=ae(()=>{var v;return((v=I.value)==null?void 0:v.orders)??[]}),ce=ae(()=>{var v;return((v=I.value)==null?void 0:v.invested)??0}),Oe=ae(()=>{var v;return((v=I.value)==null?void 0:v.unallocated_cash)??0}),fe=v=>oe.value.filter(f=>f.bucket===v);async function vt(){U.value=!0,I.value=null;try{I.value=await $("/api/v1/suggestion",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({capital:Number(a.value)})})}catch(v){ut.value=v.message}finally{U.value=!1}}async function us(){Ge.value=!0,Je.value="";try{const[v,f,he,Pe,je,Qe,ps,_t,hs,gs]=await Promise.all([$("/api/v1/dashboard/summary"),$("/api/v1/factors/tourism/observations"),$("/api/v1/signals"),$("/api/v1/factors"),$("/api/v1/themes"),$("/api/v1/dashboard"),$("/api/v1/paper/ledger"),$("/api/v1/auth/paper",{credentials:"include"}),J(),q()]);t.value=v,s.value=f,n.value=he,l.value=Pe,i.value=je,r.value=Qe,$e.value=ps,Ht.value=!!_t.authenticated,as.value=_t.mode||"token",ue.value=_t.enabled!==!1,se.value=_t.warning||"",Et.value=hs,Ye.value=gs}catch(v){Je.value=v.message}finally{Ge.value=!1}}async function be(v){u.value=v,g.value=null,h.value=!0;try{g.value=await $(`/api/v1/symbols/${v}`)}catch(f){g.value={error:f.message,symbol:v}}finally{h.value=!1}}function Ce(){u.value=null,g.value=null}async function fs(){try{const v=await $("/api/v1/backtest/readiness");V.value=v,!y.value&&v.recommended_start&&(y.value=v.recommended_start),!R.value&&v.recommended_end&&(R.value=v.recommended_end)}catch{V.value=null}}async function ds(){try{const v=await $("/api/v1/scheduler/sources");Te.value=v.sources||[]}catch{Te.value=[]}Ee.value=!0}const Ys=v=>({ok:"ปกติ",network:"เครือข่ายขัดข้อง",timeout:"หมดเวลา",http:"HTTP error",parse:"รูปแบบข้อมูลผิด",structure:"หน้าเว็บเปลี่ยนโครงสร้าง",auth:"สิทธิ์/ยืนยันตัวตน",other:"อื่น ๆ"})[v]||v;async function ki(v){const f=`[${v.at}] ${v.label} (${v.key}) — ${v.ok?"OK":"FAIL: "+Ys(v.category)} ${v.detail?"| "+v.detail:""}`;try{await navigator.clipboard.writeText(f),ut.value=`คัดลอกสาเหตุของ ${v.key} แล้ว`}catch{ut.value=f}}function Ti(v){return v.ok?"":` (สาเหตุน่าจะ: ${Ys(v.category)})`}async function Ei(){K.value=!0,A.value=null;try{A.value=await $("/api/v1/backtest/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:y.value,end:R.value,capital:Number(M.value),use_ledger:H.value})}),await Dn()}catch(v){A.value={error:v.message}}finally{K.value=!1}}async function Dn(){try{ee.value=(await $("/api/v1/backtest/run")).runs||[]}catch{ee.value=[]}}const Pt=v=>v!=null?v>=0?"positive-text":"negative-text":"";return Zl(async()=>{await us(),await Promise.all([Dn(),fs(),ds()])}),(v,f)=>{var he,Pe,je,Qe,ps,_t,hs,gs,Ln,$n,Nn;return T(),E("div",na,[f[76]||(f[76]=pr('',1)),o("main",la,[o("header",ia,[f[16]||(f[16]=o("div",null,[o("div",{class:"eyebrow"},"Alternative data · SET50"),o("h1",null,"SET50 Signal Lab"),o("p",{class:"subtitle"},"ภาพรวม alternative factors ไทย ไปจนถึงสัญญาณลงทุนที่อธิบายได้ — research + paper only")],-1)),o("div",oa,[o("div",{class:ne(["freshness-pill",Ze.value?"pill-live":"pill-fixture"])},[f[15]||(f[15]=o("span",{class:"freshness-dot"},null,-1)),W(m(Ze.value?"ข้อมูลจริงจากแหล่งไทย":"ข้อมูลจำลอง (fixture)"),1)],2),o("div",ra,"ข้อมูล "+m(((he=t.value)==null?void 0:he.as_of)||"—"),1)])]),Ge.value?(T(),E("div",aa,"กำลังโหลดข้อมูล…")):Je.value?(T(),E("div",ca,m(Je.value),1)):(T(),E(le,{key:2},[o("section",ua,[o("article",fa,[f[19]||(f[19]=o("div",{class:"kpi-label"},"สัญญาณที่ใช้งาน",-1)),o("div",da,m(ht.value.long),1),o("div",pa,[o("span",ha,m(ht.value.long)+" ซื้อ",1),f[17]||(f[17]=W(" · ",-1)),o("span",ga,m(ht.value.short)+" ขาย",1),f[18]||(f[18]=W(" · ",-1)),o("span",va,m(ht.value.neutral)+" เป็นกลาง",1)])]),o("article",_a,[f[20]||(f[20]=o("div",{class:"kpi-label"},"แหล่งข้อมูลที่ใช้",-1)),o("div",ma,m(cs.value)+" ปัจจัย · "+m(Vt.value)+" แหล่ง",1),o("div",ba,"ข้อมูลจริงจากแหล่งไทย "+m(Ze.value?"(จริง)":"—"),1)])]),o("section",ya,[o("div",xa,[f[21]||(f[21]=o("div",null,[o("div",{class:"section-kicker"},"ธีม"),o("h2",null,"ธีม (Themes)"),o("p",{class:"panel-subtitle"},"ภาพรวม alternative factors ของไทย — แต่ละธีมมีความถี่ข้อมูลต่างกัน (monthly / quarterly) ดังนั้นอย่าเทียบเป็นจุดเวลาเดียวกัน.")],-1)),o("span",Sa,"รวม "+m(w.value)+" symbols",1)]),o("div",wa,[(T(!0),E(le,null,Ae(Ne.value,p=>(T(),E("article",{key:p.id,class:"theme-card"},[o("div",Ca,[o("span",ka,m(gt(p.frequency)),1),o("span",Ta,m(p.label_th),1)]),o("div",Ea,[f[22]||(f[22]=o("span",{class:"theme-surprise-label"},"ความต่าง (surprise)",-1)),o("span",Oa,m(p.surprise!=null?_(p.surprise,2)+"σ":"—"),1)]),o("div",Pa,[p.id==="auto_credit"&&p.read.new_car_sales_yoy!=null?(T(),E("div",Aa,m(_(p.read.new_car_sales_yoy))+"% YoY ยอดขายรถ",1)):p.id==="auto_credit"&&p.read.auto_npl_pct!=null?(T(),E("div",Ra,"NPL "+m(_(p.read.auto_npl_pct))+"%",1)):p.id==="refining_energy"&&(p.read.quarterly||p.read.net_profit)?(T(),E("div",Ma,"กำไรสุทธิ TOP (รายไตรมาส)")):p.id==="refining_energy"&&p.read.irpc_net_margin_pct!=null?(T(),E("div",Ia,"กำไรสุทธิ IRPC "+m(_(p.read.irpc_net_margin_pct))+"%",1)):p.id==="tourism"?(T(),E("div",Fa,"signal tourism "+m(p.surprise!=null?_(p.surprise,2):"—")+"σ",1)):p.id==="banks"&&p.read.interest_rate_pct!=null?(T(),E("div",Da,"ดอกเบี้ย "+m(_(p.read.interest_rate_pct))+"%",1)):p.id==="banks"&&p.read.bank_npl_pct!=null?(T(),E("div",La,"NPL ภาคการเงิน "+m(_(p.read.bank_npl_pct))+"%",1)):(p.id==="retail"||p.id==="consumer_staples")&&p.read.retail_sales_yoy!=null?(T(),E("div",$a,"ยอดขายปลีก "+m(_(p.read.retail_sales_yoy))+"% YoY",1)):(p.id==="retail"||p.id==="consumer_staples")&&p.read.consumer_confidence!=null?(T(),E("div",Na,"เชื่อมั่นผู้บริโภค "+m(_(p.read.consumer_confidence,1)),1)):p.id==="nonbank_finance"&&p.read.consumer_credit!=null?(T(),E("div",ja,"สินเชื่อผู้บริโภค "+m(_(p.read.consumer_credit/1e6,2))+" ล้านลบ.",1)):p.id==="nonbank_finance"&&p.read.household_debt_gdp!=null?(T(),E("div",Ha,"หนี้ครัวเรือน "+m(_(p.read.household_debt_gdp))+"% GDP",1)):p.id==="property"&&p.read.property_prices_yoy!=null?(T(),E("div",Va,"ราคาอสังหา "+m(_(p.read.property_prices_yoy))+"% YoY",1)):p.id==="telecom_it"&&p.read.business_confidence!=null?(T(),E("div",Ba,"เชื่อมั่นธุรกิจ "+m(_(p.read.business_confidence,1)),1)):p.id==="healthcare"&&p.read.consumption_yoy!=null?(T(),E("div",Ka,"บริโภค "+m(_(p.read.consumption_yoy))+"% YoY",1)):ge("",!0)]),p.narrative?(T(),E("div",Ua,m(p.narrative),1)):ge("",!0)]))),128))]),Object.keys(Xe.value).length?(T(),E("div",Wa,[f[28]||(f[28]=o("div",{class:"section-kicker"},"ภาพรวมประเทศไทย",-1)),o("div",qa,[o("span",za,[f[23]||(f[23]=W("การบริโภคภาคเอกชน ",-1)),o("strong",null,m(Xe.value.private_consumption_yoy)+"%",1)]),o("span",Ya,[f[24]||(f[24]=W("การลงทุนเอกชน ",-1)),o("strong",null,m(Xe.value.private_investment_yoy)+"%",1)]),o("span",Ga,[f[25]||(f[25]=W("เงินเฟ้อ ",-1)),o("strong",null,m(Xe.value.headline_inflation_yoy)+"%",1)]),o("span",Ja,[f[26]||(f[26]=W("การว่างงาน ",-1)),o("strong",null,m(Xe.value.unemployment_pct)+"%",1)]),o("span",Xa,[f[27]||(f[27]=W("นักท่องเที่ยว YTD ",-1)),o("strong",null,m(Xe.value.tourists_ytd_mn)+" ล้าน",1)])])])):ge("",!0)]),o("section",Za,[o("div",Qa,[f[29]||(f[29]=o("div",null,[o("div",{class:"section-kicker"},"ตารางหุ้น"),o("h2",null,"ตารางหุ้น"),o("p",{class:"panel-subtitle"},[W("ตารางเดียวรวมทุกธีม — สัญญาณ + คะแนนรวม (60% ธีม / 40% พื้นฐาน) + มูลค่าพื้นฐานจาก Siamchart. เรียงได้โดยคลิกหัวตาราง; เปิด "),o("em",null,"เฉพาะหุ้นปันผล"),W(" เพื่อกรองหุ้นที่จ่ายปันผล.")])],-1)),o("div",ec,[o("label",tc,[Mt(o("input",{type:"checkbox","onUpdate:modelValue":f[0]||(f[0]=p=>ie.value=p)},null,512),[[hl,ie.value]]),o("span",null,"เฉพาะหุ้นปันผล ("+m(b.value)+")",1)]),o("span",{class:ne(["status-tag",d.value?"":"warning-tag"])},m(d.value?"Siamchart ใช้งานได้":"ไม่มี factor"),3)])]),d.value?(T(),E("div",nc,[o("table",lc,[o("thead",null,[o("tr",null,[o("th",{class:ne(["sortable",{active:te.value==="signal_score"}]),onClick:f[1]||(f[1]=p=>k("signal_score"))},"สัญญาณ "+m(C("signal_score")),3),o("th",{class:ne(["sortable",{active:te.value==="combined"}]),onClick:f[2]||(f[2]=p=>k("combined")),title:"60% ธีม + 40% พื้นฐาน"},"คะแนนรวม (60/40) "+m(C("combined")),3),o("th",{class:ne(["sortable",{active:te.value==="symbol"}]),onClick:f[3]||(f[3]=p=>k("symbol"))},"หุ้น "+m(C("symbol")),3),f[31]||(f[31]=o("th",null,"ธีม",-1)),o("th",{class:ne(["sortable",{active:te.value==="pe"}]),onClick:f[4]||(f[4]=p=>k("pe"))},"P/E "+m(C("pe")),3),o("th",{class:ne(["sortable",{active:te.value==="eps"}]),onClick:f[5]||(f[5]=p=>k("eps"))},"EPS "+m(C("eps")),3),o("th",{class:ne(["sortable",{active:te.value==="eps_growth_yoy"}]),onClick:f[6]||(f[6]=p=>k("eps_growth_yoy"))},"EPS YoY "+m(C("eps_growth_yoy")),3),o("th",{class:ne(["sortable",{active:te.value==="dividend_yield"}]),onClick:f[7]||(f[7]=p=>k("dividend_yield"))},"ปันผล % "+m(C("dividend_yield")),3),o("th",{class:ne(["sortable",{active:te.value==="pbv"}]),onClick:f[8]||(f[8]=p=>k("pbv"))},"P/BV "+m(C("pbv")),3),o("th",{class:ne(["sortable",{active:te.value==="roe"}]),onClick:f[9]||(f[9]=p=>k("roe"))},"ROE "+m(C("roe")),3)])]),o("tbody",null,[(T(!0),E(le,null,Ae(O.value,p=>{var At;return T(),E("tr",{key:p.symbol,class:"clickable-row",onClick:vs=>be(p.symbol)},[o("td",null,[p.signal_side?(T(),E("span",{key:0,class:ne(["side-pill",p.signal_side.toLowerCase()])},m(p.signal_side),3)):(T(),E("span",oc,"—"))]),o("td",rc,m(((At=ft.value[p.symbol])==null?void 0:At.combined)!=null?_(ft.value[p.symbol].combined):"—"),1),o("td",null,[o("strong",ac,m(p.symbol),1)]),o("td",null,[(T(!0),E(le,null,Ae(c(p.symbol),vs=>(T(),E("span",{key:vs,class:"theme-tag"},m(vs),1))),128)),c(p.symbol).length?ge("",!0):(T(),E("span",cc,"—"))]),o("td",uc,m(p.pe!=null?_(p.pe):"—"),1),o("td",null,m(p.eps!=null?_(p.eps):"—"),1),o("td",{class:ne(p.eps_growth_yoy>=0?"positive-text":"negative-text")},m(p.eps_growth_yoy!=null?(p.eps_growth_yoy>=0?"+":"")+_(p.eps_growth_yoy)+"%":"—"),3),o("td",{class:ne(p.dividend_yield>=0?"positive-text":"")},[W(m(p.dividend_yield!=null?_(p.dividend_yield)+"%":"—"),1),p.is_dividend?(T(),E("span",fc,"●")):ge("",!0)],2),o("td",null,m(p.pbv!=null?_(p.pbv):"—"),1),o("td",{class:ne(p.roe>=0?"positive-text":"negative-text")},m(p.roe!=null?_(p.roe)+"%":"—"),3)],8,ic)}),128))])])])):(T(),E("div",sc,[...f[30]||(f[30]=[W("Siamchart snapshot ไม่อยู่บน disk. รัน ",-1),o("code",null,"collect_siamchart.py --group SET50 --with-info",-1),W(" เพื่อเก็บข้อมูล.",-1)])]))]),o("section",dc,[o("div",pc,[f[32]||(f[32]=o("div",null,[o("div",{class:"section-kicker"},"ที่มาของข้อมูล"),o("h2",null,"แหล่งข้อมูลทั้งหมด"),o("p",{class:"panel-subtitle"},"รายการแหล่งข้อมูลจริงที่ใช้ — ดึงมาเมื่อใด และข้อมูลชุดไหน ข้อมูลทั้งหมดจากแหล่งไทย.")],-1)),o("span",hc,m(cs.value)+" ปัจจัย · "+m(Vt.value)+" แหล่ง",1)]),o("div",gc,[o("table",vc,[f[33]||(f[33]=o("thead",null,[o("tr",null,[o("th",null,"ข้อมูล"),o("th",null,"แหล่ง"),o("th",null,"ช่วงข้อมูล"),o("th",null,"ความถี่"),o("th",null,"อัปเดตครั้งต่อไป"),o("th",null,"อัปเดตล่าสุด")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(pt.value,(p,At)=>(T(),E("tr",{key:At},[o("td",null,m(p.จาก||p.ขอบเขต),1),o("td",_c,m(p.แหล่ง),1),o("td",mc,m(p.ข้อมูล),1),o("td",bc,m(p.ความถี่||"—"),1),o("td",yc,m(p.อัปเดตครั้งต่อไป?L(p.อัปเดตครั้งต่อไป):"—"),1),o("td",xc,m(p.dึงมาเมื่อ?L(p.dึงมาเมื่อ):"—"),1)]))),128))])])])]),o("section",Sc,[f[35]||(f[35]=o("div",{class:"panel-header signal-header"},[o("div",null,[o("div",{class:"section-kicker"},"สถานะการดึงข้อมูล"),o("h2",null,"Log — สถานะแหล่งข้อมูล"),o("p",{class:"panel-subtitle"},'ผลการดึงข้อมูลครั้งล่าสุดของแต่ละแหล่ง โดยระบบวิเคราะห์สาเหตุให้อัตโนมัติ (เครือข่าย / หมดเวลา / หน้าเว็บเปลี่ยนโครงสร้าง / รูปแบบข้อมูล เป็นต้น) — กดปุ่ม "คัดลอก" เพื่อ copy สาเหตุไปแจ้ง/ตรวจสอบได้ทันที.')])],-1)),Te.value.length===0?(T(),E("div",wc,"ยังไม่มี log — รอรอบ refresh ถัดไป (ปกติ ~ทุกวันสำหรับราคา, ~รายเดือน/ไตรมาสสำหรับปัจจัย).")):(T(),E("div",Cc,[o("table",kc,[f[34]||(f[34]=o("thead",null,[o("tr",null,[o("th",null,"แหล่ง"),o("th",null,"ผลลัพธ์"),o("th",null,"สาเหตุ"),o("th",null,"เวลา"),o("th")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(Te.value.slice(0,20),(p,At)=>(T(),E("tr",{key:At},[o("td",Tc,[W(m(p.label),1),o("div",Ec,m(p.key),1)]),o("td",null,[p.ok?(T(),E("span",Oc,"OK")):(T(),E("span",Pc,"FAIL"))]),o("td",null,[p.ok?(T(),E(le,{key:0},[W("—")],64)):(T(),E(le,{key:1},[o("div",null,m(Ys(p.category))+m(Ti(p)),1),p.detail?(T(),E("div",Ac,m(p.detail.slice(0,160)),1)):ge("",!0)],64))]),o("td",Rc,m(p.at?L(p.at):"—"),1),o("td",null,[p.ok?ge("",!0):(T(),E("button",{key:0,class:"primary-btn",style:{padding:"2px 8px"},onClick:vs=>ki(p)},"คัดลอกสาเหตุ",8,Mc))])]))),128))])])]))]),o("section",Ic,[o("div",Fc,[f[36]||(f[36]=o("div",null,[o("div",{class:"section-kicker"},"คำแนะนำการลงทุน"),o("h2",null,"จัดสรรทุน (Suggestion)"),o("p",{class:"panel-subtitle"},"กรอกทุน และระบบแนะนำสัดส่วน 50 / 20 / 30 — หุ้นที่ทำกำไรได้มากสุดแล้วจ่ายปันผล, หุ้นทำกำไรแต่ไม่ปันผล, และหุ้นปันผลสูงสุด (ไม่ซ้ำ) — ขั้นต่ำ 100 หุ้นต่อตัว.")],-1)),o("span",Dc,m(I.value?"ใช้ได้":"รอใส่ทุน"),1)]),o("div",Lc,[o("div",$c,[f[37]||(f[37]=o("label",null,"ทุน (บาท)",-1)),Mt(o("input",{"onUpdate:modelValue":f[10]||(f[10]=p=>a.value=p),type:"number",min:"1000",step:"1000"},null,512),[[xs,a.value]])]),o("button",{class:"primary-button",disabled:U.value,onClick:vt},m(U.value?"กำลังคำนวณ…":"คำนวณการจัดสรร"),9,Nc)]),I.value?(T(),E("div",jc,[o("div",Hc,[o("div",Vc,[f[38]||(f[38]=o("span",null,"ลงทุนรวม",-1)),o("strong",null,m(_(ce.value,0))+" บาท",1)]),o("div",Bc,[f[39]||(f[39]=o("span",null,"เงินสดเหลือ",-1)),o("strong",null,m(_(Oe.value,0))+" บาท",1)])]),o("div",Kc,m(I.value.data_note),1),o("div",Uc,[o("div",Wc,[f[41]||(f[41]=o("div",{class:"sim-bucket-head"},[o("span",{class:"sim-bucket-tag b1"},"50%"),o("strong",null,"ทำกำไร + จ่ายปันผล")],-1)),o("table",qc,[fe(1).length?(T(),E("tbody",zc,[(T(!0),E(le,null,Ae(fe(1),p=>(T(),E("tr",{key:"b1"+p.symbol},[o("td",null,m(p.symbol),1),o("td",Yc,"qty "+m(p.qty),1),o("td",Gc,"@ "+m(_(p.price)),1),o("td",Jc,m(_(p.notional,0)),1)]))),128))])):(T(),E("tbody",Xc,[...f[40]||(f[40]=[o("tr",null,[o("td",{class:"muted-cell"},"ไม่มีหุ้นที่เข้าเกณฑ์")],-1)])]))])]),o("div",Zc,[f[43]||(f[43]=o("div",{class:"sim-bucket-head"},[o("span",{class:"sim-bucket-tag b2"},"20%"),o("strong",null,"ทำกำไร ไม่ปันผล")],-1)),o("table",Qc,[fe(2).length?(T(),E("tbody",eu,[(T(!0),E(le,null,Ae(fe(2),p=>(T(),E("tr",{key:"b2"+p.symbol},[o("td",null,m(p.symbol),1),o("td",tu,"qty "+m(p.qty),1),o("td",su,"@ "+m(_(p.price)),1),o("td",nu,m(_(p.notional,0)),1)]))),128))])):(T(),E("tbody",lu,[...f[42]||(f[42]=[o("tr",null,[o("td",{class:"muted-cell"},"ไม่มีหุ้นที่เข้าเกณฑ์")],-1)])]))])]),o("div",iu,[f[45]||(f[45]=o("div",{class:"sim-bucket-head"},[o("span",{class:"sim-bucket-tag b3"},"30%"),o("strong",null,"ปันผลสูงสุด (ไม่ซ้ำ)")],-1)),o("table",ou,[fe(3).length?(T(),E("tbody",ru,[(T(!0),E(le,null,Ae(fe(3),p=>(T(),E("tr",{key:"b3"+p.symbol},[o("td",null,m(p.symbol),1),o("td",au,"qty "+m(p.qty),1),o("td",cu,"@ "+m(_(p.price)),1),o("td",uu,m(_(p.notional,0)),1)]))),128))])):(T(),E("tbody",fu,[...f[44]||(f[44]=[o("tr",null,[o("td",{class:"muted-cell"},"ไม่มีหุ้นที่เข้าเกณฑ์")],-1)])]))])])])])):ge("",!0),I.value?ge("",!0):(T(),E("div",du,"กด 'คำนวณการจัดสรร' เพื่อดูว่า 50/20/30 จัดสรรทุนของคุณไปที่หุ้นไหนบ้าง"))]),o("section",pu,[f[62]||(f[62]=o("div",{class:"panel-header signal-header"},[o("div",null,[o("div",{class:"section-kicker"},"การย้อนทดสอบ"),o("h2",null,"Backtest (ย้อนทดสอบ)"),o("p",{class:"panel-subtitle"},"กำหนดช่วงวัน แล้วระบบจัดสรร 50/20/30 ณ วันที่เริ่ม ลงทุน และปรับพอร์ตตามข้อมูลที่เผยแพร่ใหม่ (event-driven) จนถึงวันสิ้นสุด — สรุปกำไร/ขาดทุนจากราคา + เงินปันผล. มีค่าธรรมเนียม 0.3% ต่อรายการ และปันผลเข้าบัญชีใน 30 วันหลัง ex-date.")])],-1)),o("div",hu,[o("label",null,[f[46]||(f[46]=W("ตั้งแต่ ",-1)),Mt(o("input",{type:"date","onUpdate:modelValue":f[11]||(f[11]=p=>y.value=p)},null,512),[[xs,y.value]])]),o("label",null,[f[47]||(f[47]=W("ถึง ",-1)),Mt(o("input",{type:"date","onUpdate:modelValue":f[12]||(f[12]=p=>R.value=p)},null,512),[[xs,R.value]])]),o("label",null,[f[48]||(f[48]=W("ทุน ",-1)),Mt(o("input",{type:"number","onUpdate:modelValue":f[13]||(f[13]=p=>M.value=p),step:"100000"},null,512),[[xs,M.value,void 0,{number:!0}]])]),o("label",gu,[Mt(o("input",{type:"checkbox","onUpdate:modelValue":f[14]||(f[14]=p=>H.value=p)},null,512),[[hl,H.value]]),f[49]||(f[49]=W(" ใช้ ledger ปันผลตามวันที่จริง ",-1))]),o("button",{class:"primary-btn",disabled:K.value||V.value&&!V.value.ready,onClick:Ei},m(K.value?"กำลังย้อนทดสอบ…":"รัน Backtest"),9,vu)]),V.value&&!V.value.ready?(T(),E("div",_u,[f[50]||(f[50]=o("strong",null,"ยังรันย้อนทดสอบแบบ strict PIT ไม่ได้ — ขาดข้อมูล coverage:",-1)),o("div",mu,m((V.value.missing||[]).slice(0,8).join(", "))+m((V.value.missing||[]).length>8?"…":""),1),o("div",bu,"วันเริ่มที่แนะนำ: "+m(V.value.recommended_start||"—")+" · วันสิ้นสุด: "+m(V.value.recommended_end||"—"),1)])):ge("",!0),(Pe=A.value)!=null&&Pe.error?(T(),E("div",yu,m(A.value.error),1)):A.value&&!A.value.error?(T(),E("div",xu,[o("div",Su,[o("div",wu,[f[51]||(f[51]=o("span",null,"กำไรจากราคา (realized)",-1)),o("strong",{class:ne(Pt(A.value.realized_trading_pnl))},m(_(A.value.realized_trading_pnl))+" บาท",3)]),o("div",Cu,[f[52]||(f[52]=o("span",null,"กำไรจากราคา (unrealized)",-1)),o("strong",{class:ne(Pt(A.value.unrealized_trading_pnl))},m(_(A.value.unrealized_trading_pnl))+" บาท",3)]),o("div",ku,[f[53]||(f[53]=o("span",null,"เงินปันผลที่ได้รับ",-1)),o("strong",Tu,m(_(A.value.dividend_cash_received))+" บาท",1)]),o("div",Eu,[f[54]||(f[54]=o("span",null,"ค่าธรรมเนียม (0.3%)",-1)),o("strong",Ou,"–"+m(_(A.value.transaction_costs))+" บาท",1)]),o("div",Pu,[f[55]||(f[55]=o("span",null,"เงินปันผลค้างรับ",-1)),o("strong",null,m(_(A.value.dividend_receivable))+" บาท",1)]),o("div",Au,[f[56]||(f[56]=o("span",null,"มูลค่าสุดท้าย (equity)",-1)),o("strong",null,m(_(A.value.final_equity))+" บาท",1)]),o("div",Ru,[f[57]||(f[57]=o("span",null,"ผลตอบแทนสุทธิ",-1)),o("strong",{class:ne(Pt(A.value.net_return))},m((A.value.net_return*100).toFixed(2))+"%",3)])]),o("div",Mu,"Rebalances: "+m(A.value.rebalances)+" · ปันผลตาม: "+m(A.value.dividend_timing)+" · ช่วง "+m(A.value.start)+" → "+m(A.value.end),1),A.value.leakage_guard?(T(),E("div",Iu,"✅ strict PIT (leakage guard active)")):(T(),E("div",Fu,"คำเตือน: ไม่ได้พิสูจน์ point-in-time (non-PIT)")),A.value.holdings&&A.value.holdings.length?(T(),E("div",Du,[f[59]||(f[59]=o("strong",null,"พอร์ตสุดท้าย:",-1)),o("table",Lu,[f[58]||(f[58]=o("thead",null,[o("tr",null,[o("th",null,"หุ้น"),o("th",null,"จำนวน"),o("th",null,"ต้นทุนเฉลี่ย"),o("th",null,"ราคาล่าสุด"),o("th",null,"มูลค่า"),o("th",null,"กำไร unrealized")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(A.value.holdings,p=>(T(),E("tr",{key:p.symbol},[o("td",$u,m(p.symbol),1),o("td",null,m(p.qty),1),o("td",null,m(_(p.average_cost,2)),1),o("td",null,m(_(p.last_price,2)),1),o("td",null,m(_(p.market_value)),1),o("td",{class:ne(Pt(p.unrealized_pnl))},m(_(p.unrealized_pnl)),3)]))),128))])])])):ge("",!0)])):(T(),E("div",Nu,"กำหนดช่วงวันแล้วกด 'รัน Backtest' เพื่อดูผล (กำไร/ขาดทุนจากราคา + ปันผล)")),ee.value.length?(T(),E("div",ju,[f[61]||(f[61]=o("div",{class:"section-kicker"},"ประวัติการย้อนทดสอบ",-1)),o("table",Hu,[f[60]||(f[60]=o("thead",null,[o("tr",null,[o("th",null,"#"),o("th",null,"ช่วง"),o("th",null,"ทุน"),o("th",null,"กำไรราคา"),o("th",null,"ปันผล"),o("th",null,"ผลตอบแทน"),o("th",null,"รันเมื่อ")])],-1)),o("tbody",null,[(T(!0),E(le,null,Ae(ee.value.slice().reverse(),p=>(T(),E("tr",{key:p.id},[o("td",null,m(p.id),1),o("td",null,[W(m(p.start)+" → "+m(p.end)+" ",1),p.leakage_guard===!1?(T(),E("span",Vu,"descriptive non-PIT")):ge("",!0)]),o("td",null,m(_(p.capital)),1),o("td",{class:ne(Pt(p.price_pnl))},m(_(p.price_pnl)),3),o("td",Bu,[W(m(_(p.dividend_income)),1),o("span",{class:ne(["status-tag",P(p.dividend_method).cls]),style:js([P(p.dividend_method).style||void 0,{"margin-left":"4px"}]),title:F(p.dividend_method)},m(P(p.dividend_method).label),15,Ku)]),o("td",{class:ne(Pt(p.net_return))},m((p.net_return*100).toFixed(2))+"%",3),o("td",Uu,m(p.ran_at?L(p.ran_at):"—"),1)]))),128))])])])):ge("",!0)])],64))]),u.value?(T(),E("div",{key:0,class:"modal-overlay",onClick:Xr(Ce,["self"])},[o("div",Wu,[o("div",qu,[o("div",null,[f[63]||(f[63]=o("div",{class:"modal-kicker"},"การวิเคราะห์รายหุ้น",-1)),o("h3",null,m(u.value),1)]),o("button",{class:"modal-close",onClick:Ce},"✕")]),h.value?(T(),E("div",zu,"กำลังโหลดการวิเคราะห์…")):(je=g.value)!=null&&je.error?(T(),E("div",Yu,m(g.value.error),1)):g.value?(T(),E("div",Gu,[o("div",Ju,[f[67]||(f[67]=o("div",{class:"modal-section-title"},"ธีมที่เกี่ยวข้อง (คะแนนต่อธีม)",-1)),(Qe=g.value.themes)!=null&&Qe.length?(T(),E("div",Xu,[(T(!0),E(le,null,Ae(g.value.theme_contributions,p=>(T(),E("div",{key:p.theme,class:"contrib-line"},[o("span",Zu,m(p.label_th||zs.value[p.theme]||p.theme),1),p.surprise!=null?(T(),E("span",Qu,[o("em",null,m(_(p.surprise))+"σ",1),f[64]||(f[64]=W(" × คุณภาพ ",-1)),o("em",null,m(p.quality),1),f[65]||(f[65]=W(" = ",-1)),o("strong",null,m(_(p.theme_score))+"σ",1)])):(T(),E("strong",ef,"ยังไม่มีข้อมูล"))]))),128)),f[66]||(f[66]=o("div",{class:"modal-sub"},"คะแนนธีม = ค่าเฉลี่ยของ (surprise × คุณภาพหุ้น) ที่หุ้นนี้อยู่ใน",-1))])):(T(),E("div",tf,"หุ้นนี้ยังไม่ได้จัดอยู่ในธีมใด (จะอัปเดตเมื่อเพิ่มธีม)"))]),o("div",sf,[f[73]||(f[73]=o("div",{class:"modal-section-title"},"มูลค่าพื้นฐาน (Siamchart)",-1)),o("div",nf,[o("span",null,[f[68]||(f[68]=W("P/E ",-1)),o("strong",null,m(((ps=g.value.fundamentals)==null?void 0:ps.pe)??"—"),1)]),o("span",null,[f[69]||(f[69]=W("EPS ",-1)),o("strong",null,m(((_t=g.value.fundamentals)==null?void 0:_t.eps)??"—"),1)]),o("span",null,[f[70]||(f[70]=W("P/BV ",-1)),o("strong",null,m(((hs=g.value.fundamentals)==null?void 0:hs.pbv)??"—"),1)]),o("span",null,[f[71]||(f[71]=W("ROE ",-1)),o("strong",null,m(((gs=g.value.fundamentals)==null?void 0:gs.roe)??"—"),1)]),o("span",null,[f[72]||(f[72]=W("ปันผล ",-1)),o("strong",null,m((Ln=g.value.fundamentals)!=null&&Ln.is_dividend?"จ่าย":"—"),1)])]),o("div",lf,"ภาพรวม: "+m(g.value.company_name||u.value),1)]),o("div",of,[f[75]||(f[75]=o("div",{class:"modal-section-title"},"ขั้นตอนการคำนวณคะแนนรวม",-1)),o("div",rf,[o("div",af,m(g.value.combined_formula),1),(T(!0),E(le,null,Ae(g.value.combined_calc,p=>(T(),E("div",{key:p.label,class:"calc-step"},[o("div",cf,[o("span",null,m(p.label),1),o("strong",null,m(_(p.value))+" × "+m(p.weight),1)]),o("div",uf,m(p.note),1)]))),128)),g.value.siamchart_z_note?(T(),E("div",ff,[W(" คะแนนพื้นฐานได้จาก z-score: z = (ค่า"+m(g.value.siamchart_z_note.raw_i)+" − ค่าเฉลี่ย "+m(g.value.siamchart_z_note.population_mean)+") / ค่าเบี่ยงเบน "+m(g.value.siamchart_z_note.population_stdev),1),f[74]||(f[74]=o("br",null,null,-1)),W("เทียบกับ "+m(g.value.siamchart_z_note.universe_size)+" หุ้นใน SET50 ",1)])):ge("",!0)]),o("div",df,"ราคาล่าสุด: "+m((($n=g.value.price)==null?void 0:$n.latest)!=null?_(g.value.price.latest):"—")+" ("+m(((Nn=g.value.price)==null?void 0:Nn.date)||"—")+")",1)])])):ge("",!0)])])):ge("",!0)])}}};ea(pf).mount("#app"); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index f78fd4e..a1f31d0 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -5,7 +5,7 @@ SET50 Signal Lab - + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4183185..b34f59f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -571,6 +571,7 @@ onMounted(async () => { await loadDashboard(); await Promise.all([loadBacktestRu
{{ formatNumber(theme.read.new_car_sales_yoy) }}% YoY ยอดขายรถ
NPL {{ formatNumber(theme.read.auto_npl_pct) }}%
กำไรสุทธิ TOP (รายไตรมาส)
+
กำไรสุทธิ IRPC {{ formatNumber(theme.read.irpc_net_margin_pct) }}%
signal tourism {{ theme.surprise != null ? formatNumber(theme.surprise,2) : '—' }}σ
ดอกเบี้ย {{ formatNumber(theme.read.interest_rate_pct) }}%
NPL ภาคการเงิน {{ formatNumber(theme.read.bank_npl_pct) }}%