- 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
134 lines
4.5 KiB
Python
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))
|