Files
set50-system/backend/app/energy_irpc.py
Kunthawat Greethong 12b34929d7 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
2026-08-29 11:13:17 +07:00

134 lines
4.5 KiB
Python

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