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 Highlights | 2024 | 2025 | 3M26 |
+ | Current Assets | 56,999 | 67,086 | 101,978 |
+ | Total Assets | 184,555 | 187,383 | 217,356 |
+ | EBITDA Margin | 1.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 = ""
+ 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"?``:n==="mathml"?``: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) }}%