From 1e75377732dd362fe4220077a5990305d3f52193 Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Tue, 25 Aug 2026 19:06:48 +0700 Subject: [PATCH] [verified] Add BOT auto NPL (credit-quality) factor; deepen auto_credit theme - auto_npl.py: parse BOT Gross NPLs by business (reportID=794); extract auto loan NPL (20,602 mn THB, 3.95% of NPLs, 2.06% of loans) - /api/v1/themes now exposes auto_npl_pct + auto_npl_amount alongside car-sales volume - 3 new tests; full suite 188 OK; live verified (themes shows auto_npl_pct 3.95) --- backend/app/__init__.py | 11 ++ backend/app/auto_npl.py | 118 +++++++++++++++++++++ backend/tests/test_auto_npl.py | 42 ++++++++ docs/alternative-factor-source-research.md | 3 + 4 files changed, 174 insertions(+) create mode 100644 backend/app/auto_npl.py create mode 100644 backend/tests/test_auto_npl.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index d280d59..a92c512 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -627,6 +627,17 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: except Exception as exc: theme_reads["auto_credit"] = {"source": "tradingeconomics", "error": str(exc), "frequency": "monthly"} + # auto NPL (credit-quality) from BOT — deepens auto theme + try: + from app import auto_npl + npl = cache.fetch_or_stale( + "auto_npl", lambda: auto_npl.fetch_auto_npl().to_dict()) + npl_d = npl["data"] if isinstance(npl, dict) and "data" in npl else npl + theme_reads["auto_credit"]["auto_npl_pct"] = npl_d.get("pct_of_npls") + theme_reads["auto_credit"]["auto_npl_amount"] = npl_d.get("npl_amount") + except Exception: + pass + # refining_energy: Thai Oil (TOP) quarterly financials try: en = cache.fetch_or_stale( diff --git a/backend/app/auto_npl.py b/backend/app/auto_npl.py new file mode 100644 index 0000000..b7d3ca1 --- /dev/null +++ b/backend/app/auto_npl.py @@ -0,0 +1,118 @@ +"""Auto NPL / credit-quality factor — BOT Gross NPLs by business type. + +Source: https://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=794 +(Bank of Thailand, FI_NP_003_S2 — Gross NPLs outstanding classified by business +type, ISIC Rev.4). Server-rendered HTML table. + +Table layout (quarterly, 3 metrics per period): + header2: [ , , 'ยอดคงค้าง NPL', '% ต่อ NPLs', '% ต่อสินเชื่อรวม', (repeat) ] + row: [no, sector_label, NPL_amount, pct_of_npls, pct_of_loans, (prev period repeat) ] + +This module extracts the **auto loan NPL** (sector 'รถยนต์') — the credit-quality +component that deepens the auto_credit theme beyond just new-car sales. +""" + +from __future__ import annotations + +import html +import re +from dataclasses import dataclass +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://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=794" +AUTO_LOAN_LABEL = "รถยนต์" + + +class AutoNplError(Exception): + """Raised when the BOT NPL page cannot be fetched or parsed.""" + + +@dataclass(frozen=True) +class AutoNplSnapshot: + # latest period auto-loan NPL (million baht), % of NPLs, % of total loans + npl_amount: Optional[float] = None + pct_of_npls: Optional[float] = None # % of total NPLs + pct_of_loans: Optional[float] = None # % of total loans + period: str = "" + source: str = "bot" + + def to_dict(self) -> dict: + return { + "source": self.source, + "period": self.period, + "npl_amount": self.npl_amount, + "pct_of_npls": self.pct_of_npls, + "pct_of_loans": self.pct_of_loans, + } + + +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 AutoNplError(f"failed to fetch {url}: {exc}") from exc + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("latin-1", "ignore") + + +def _to_float(text: str) -> Optional[float]: + text = text.replace(",", "").strip().replace("%", "") + if not text or text in ("-", "N/A"): + return None + try: + return float(text) + except ValueError: + return None + + +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 parse_auto_npl_html(html_text: str, label: str = AUTO_LOAN_LABEL) -> AutoNplSnapshot: + """Parse the BOT NPL table; return the auto-loan-sector row (latest period).""" + tables = re.findall(r"]*>(.*?)", html_text, re.S) + period = "" + npl = pct_n = pct_l = None + for table in tables: + trs = re.findall(r"]*>(.*?)", table, re.S) + for tr in trs: + cells = _cells(tr) + if not cells: + continue + # header row carries the period (e.g. Q2/2568); cells[0] may be '' or 'ยอด' + m = re.match(r"(Q\d/\d{4})", " ".join(cells)) + if m and period == "": + period = m.group(1) + continue + # data row: [no, label, value, pct_npl, pct_loan, ...] + if len(cells) >= 4 and cells[0].isdigit() and ("รถยนต์" in cells[1] or label in cells[1]): + npl = _to_float(cells[2]) + pct_n = _to_float(cells[3]) + pct_l = _to_float(cells[4]) if len(cells) > 4 else None + break + if npl is None and pct_n is None: + raise AutoNplError("no auto-loan NPL row found in BOT NPL page") + return AutoNplSnapshot( + npl_amount=npl, + pct_of_npls=pct_n, + pct_of_loans=pct_l, + period=period, + ) + + +def fetch_auto_npl(timeout: float = 30.0) -> AutoNplSnapshot: + return parse_auto_npl_html(_fetch(timeout=timeout)) diff --git a/backend/tests/test_auto_npl.py b/backend/tests/test_auto_npl.py new file mode 100644 index 0000000..fe9537d --- /dev/null +++ b/backend/tests/test_auto_npl.py @@ -0,0 +1,42 @@ +"""Tests for the auto NPL (BOT) collector.""" + +from __future__ import annotations + +import unittest + +from app import auto_npl + + +def _bot_npl_html() -> str: + return """ + + + + + + +
Q2/2568Q1/2568
ยอดคงค้าง NPL% ต่อ NPLs% ต่อสินเชื่อรวมยอดคงค้าง NPL% ต่อ NPLs% ต่อสินเชื่อรวม
1การเกษตร10,3251.9811.8110,6692.0711.94
12รถยนต์20,6023.952.0622,0464.102.15
+ + """ + + +class AutoNplParseTest(unittest.TestCase): + def test_parses_auto_npl(self) -> None: + snap = auto_npl.parse_auto_npl_html(_bot_npl_html()) + self.assertEqual(snap.npl_amount, 20602.0) + self.assertEqual(snap.pct_of_npls, 3.95) + self.assertEqual(snap.pct_of_loans, 2.06) + + def test_no_table_raises(self) -> None: + with self.assertRaises(auto_npl.AutoNplError): + auto_npl.parse_auto_npl_html("no data") + + def test_to_dict(self) -> None: + snap = auto_npl.parse_auto_npl_html(_bot_npl_html()) + d = snap.to_dict() + self.assertEqual(d["source"], "bot") + self.assertEqual(d["npl_amount"], 20602.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/alternative-factor-source-research.md b/docs/alternative-factor-source-research.md index 587b1b8..296da0d 100644 --- a/docs/alternative-factor-source-research.md +++ b/docs/alternative-factor-source-research.md @@ -16,6 +16,9 @@ The US EIA 3-2-1 crack spread is a **US** proxy; the user wants **Thai** refiner **Decision (final):** Use **Thai Oil (TOP) quarterly financial highlights** (`investor.thaioilgroup.com/en/financial-information/financial-highlights`) — real quarterly EBITDA/Net Profit/Sales of the largest Thai refinery (Million Baht), scraped from server-rendered HTML. Implemented in `backend/app/energy_thai.py`. This is Thai-specific and replaces both the US EIA crack spread and the Krungsri outlook projection. Frequency: **quarterly**. (Krungsri Research remains a qualitative backdrop; EIA is a US proxy — both superseded by TOP for the factor.) +## Auto NPL (credit-quality) — added 2026-08-25 +BOT Gross NPLs by business type (`ReportPage.aspx?reportID=794`, FI_NP_003_S2). Extracts **auto-loan NPL** (20,602 mn THB, **3.95% of NPLs**, 2.06% of loans, Q2/2568). Deepens the auto_credit theme beyond new-car sales with credit quality. Implemented in `backend/app/auto_npl.py`; merged into the auto_credit theme read. +