feat(factor): add energy_irpc (IRPC net margin) as 2nd Thai refiner signal

- new energy_irpc collector parsing IRPC performance-highlights table
  (net profit/EBITDA/ROE margins, latest period 3M26: +10.27%)
- factor energy_irpc_net_margin (sign +1) wired into refining_energy/
  exploration/utilities, extending the energy theme beyond TOP
- scheduler job + dashboard fetch + sources table row (now 9 sources)
- tests: parse (incl paren-negatives), value-key resolution, direction;
  suite 368 OK. Independent review passed: true
- Phase B feasibility: REIC/EPPO/NBTC/PTTEP are JS-rendered or anti-bot
  (recorded deferred in plan); IRPC was the clean server-rendered win
This commit is contained in:
Kunthawat Greethong
2026-08-29 11:13:17 +07:00
parent da671a9ba2
commit 12b34929d7
14 changed files with 309 additions and 24 deletions

View File

@@ -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.
---

View File

@@ -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",

133
backend/app/energy_irpc.py Normal file
View File

@@ -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"<t[dh][^>]*>(.*?)</t[dh]>", 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"<table[^>]*>(.*?)</table>", html_text, re.S):
for tr in re.findall(r"<tr[^>]*>(.*?)</tr>", 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))

View File

@@ -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)",

View File

@@ -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"},

View File

@@ -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},

View File

@@ -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)

View File

@@ -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 = """<table>
<tr><td>Financial Highlights</td><td>2024</td><td>2025</td><td>3M26</td></tr>
<tr><td>Current Assets</td><td>56,999</td><td>67,086</td><td>101,978</td></tr>
<tr><td>Total Assets</td><td>184,555</td><td>187,383</td><td>217,356</td></tr>
<tr><td>EBITDA Margin</td><td>1.42%</td><td>2.22%</td><td>19.19%</td></tr>
<tr><td>Net Profit Margin</td><td>(1.65%)</td><td>(1.28%)</td><td>10.27%</td></tr>
<tr><td>Return on Equity</td><td>(7.12%)</td><td>(5.26%)</td><td>7.75%</td></tr>
</table>"""
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("<td>10.27%</td>", "<td>(3.20%)</td>")
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 = "<table><tr><td>Nothing</td><td>1</td></tr></table>"
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()

View File

@@ -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,

View File

@@ -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())

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1018" />
<title>SET50 Signal Lab</title>
<script type="module" crossorigin src="/assets/index-eGeNIixN.js"></script>
<script type="module" crossorigin src="/assets/index-Bymd5oSf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CUx7tUuk.css">
</head>
<body>

View File

@@ -571,6 +571,7 @@ onMounted(async () => { await loadDashboard(); await Promise.all([loadBacktestRu
<div v-if="theme.id === 'auto_credit' && theme.read.new_car_sales_yoy != null" class="theme-read-value">{{ formatNumber(theme.read.new_car_sales_yoy) }}% YoY ยอดขายรถ</div>
<div v-else-if="theme.id === 'auto_credit' && theme.read.auto_npl_pct != null" class="theme-read-value">NPL {{ formatNumber(theme.read.auto_npl_pct) }}%</div>
<div v-else-if="theme.id === 'refining_energy' && (theme.read.quarterly || theme.read.net_profit)" class="theme-read-value">กำไรสุทธิ TOP (รายไตรมาส)</div>
<div v-else-if="theme.id === 'refining_energy' && theme.read.irpc_net_margin_pct != null" class="theme-read-value">กำไรสุทธิ IRPC {{ formatNumber(theme.read.irpc_net_margin_pct) }}%</div>
<div v-else-if="theme.id === 'tourism'" class="theme-read-value">signal tourism {{ theme.surprise != null ? formatNumber(theme.surprise,2) : '' }}σ</div>
<div v-else-if="theme.id === 'banks' && theme.read.interest_rate_pct != null" class="theme-read-value">ดอกเบี้ย {{ formatNumber(theme.read.interest_rate_pct) }}%</div>
<div v-else-if="theme.id === 'banks' && theme.read.bank_npl_pct != null" class="theme-read-value">NPL ภาคการเงิน {{ formatNumber(theme.read.bank_npl_pct) }}%</div>