[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)
This commit is contained in:
@@ -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(
|
||||
|
||||
118
backend/app/auto_npl.py
Normal file
118
backend/app/auto_npl.py
Normal file
@@ -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"<t[dh][^>]*>(.*?)</t[dh]>", 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"<table[^>]*>(.*?)</table>", html_text, re.S)
|
||||
period = ""
|
||||
npl = pct_n = pct_l = None
|
||||
for table in tables:
|
||||
trs = re.findall(r"<tr[^>]*>(.*?)</tr>", 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))
|
||||
42
backend/tests/test_auto_npl.py
Normal file
42
backend/tests/test_auto_npl.py
Normal file
@@ -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 """
|
||||
<html><body>
|
||||
<table>
|
||||
<tr><th></th><th></th><th>Q2/2568</th><th></th><th></th><th>Q1/2568</th><th></th><th></th></tr>
|
||||
<tr><th></th><th></th><th>ยอดคงค้าง NPL</th><th>% ต่อ NPLs</th><th>% ต่อสินเชื่อรวม</th><th>ยอดคงค้าง NPL</th><th>% ต่อ NPLs</th><th>% ต่อสินเชื่อรวม</th></tr>
|
||||
<tr><td>1</td><td>การเกษตร</td><td>10,325</td><td>1.98</td><td>11.81</td><td>10,669</td><td>2.07</td><td>11.94</td></tr>
|
||||
<tr><td>12</td><td>รถยนต์</td><td>20,602</td><td>3.95</td><td>2.06</td><td>22,046</td><td>4.10</td><td>2.15</td></tr>
|
||||
</table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
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("<html>no data</html>")
|
||||
|
||||
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()
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user