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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
133
backend/app/energy_irpc.py
Normal 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))
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
105
backend/tests/test_energy_irpc.py
Normal file
105
backend/tests/test_energy_irpc.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user