Files
set50-system/backend/app/te_thailand.py
Kunthawat Greethong fcc0da9c8d feat(factor): te_thailand rate/credit/retail/property/confidence + thai_trade external sector; fix sign inversion on bearish factors
- add te_thailand collector (TradingEconomics) -> 8 factors: interest rate,
  business loan growth, consumer credit, household debt/GDP, retail sales YoY,
  consumer confidence, residential property prices, business confidence;
  feed banks/retail/consumer_staples/nonbank_finance/property/telecom/healthcare
- add thai_trade collector (TradingEconomics external sector) -> exports/
  imports/current-account factors (concurrent in-tree work, verified green)
- fix sign inversion: theme weights were negative on sign:-1 factors (NPL,
  inflation, unemployment) so higher NPL/inflation RAISED scores; direction now
  lives only in factor sign, theme weights positive (regression-locked)
- tests: te_thailand parse+direction, value-key resolution contract, dashboard
  8-sources, scheduler vintage counts; suite 362 OK
2026-08-29 09:18:55 +07:00

210 lines
9.2 KiB
Python

"""Thailand rates / credit / retail / confidence / property factors — TradingEconomics.
Source pages (server-rendered HTML, same infrastructure as `thai_trade` /
`auto_credit` — the "related indicators" table exposes a stable 5-column row
`[label, value, prev, unit, period]`):
- https://tradingeconomics.com/thailand/interest-rate
Interest Rate, Loans to Non Financial Corporations, Banks Balance Sheet
- https://tradingeconomics.com/thailand/consumer-confidence
Consumer Confidence, Retail Sales YoY, Consumer Credit, Households Debt
to GDP, Consumer Spending
- https://tradingeconomics.com/thailand/housing-index
Residential Property Prices, Housing Index, Housing Starts
- https://tradingeconomics.com/thailand/business-confidence
Business Confidence, Leading Economic Index, Private Investment MoM
This module deepens the macro-proxy themes that today rely only on the one BOT
Thai-Economy page:
Factor contributes to
---------------------------- -------------------------------------
te_interest_rate -> banks (rate/credit cycle)
te_loan_growth_level -> banks (credit demand)
te_consumer_credit -> nonbank_finance (household credit book)
te_household_debt_gdp -> nonbank_finance (household leverage risk)
te_retail_sales_yoy -> retail, consumer_staples (spending)
te_consumer_confidence -> retail, consumer_staples, nonbank (sentiment)
te_property_prices -> property (residential price YoY)
te_business_confidence -> telecom_it, property, healthcare (sentiment)
Every value is a single-page snapshot (current value + prev + period), so each
factor is a *level / latest-period* driver — 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_INTEREST = "https://tradingeconomics.com/thailand/interest-rate"
_URL_CONFIDENCE = "https://tradingeconomics.com/thailand/consumer-confidence"
_URL_HOUSING = "https://tradingeconomics.com/thailand/housing-index"
_URL_BIZCONF = "https://tradingeconomics.com/thailand/business-confidence"
class ThaiFactorsError(Exception):
"""Raised when a TradingEconomics Thailand page cannot be fetched/parsed."""
@dataclass(frozen=True)
class ThaiFactorsSnapshot:
interest_rate_pct: Optional[float] = None # policy-rate proxy (Aug 2026: 1.00)
loans_to_fin_corp: Optional[float] = None # THB mn (Jun 2026)
consumer_credit_thbmn: Optional[float] = None # THB mn (Jun 2025)
household_debt_gdp_pct: Optional[float] = None # % of GDP (Dec 2025)
retail_sales_yoy: Optional[float] = None # % YoY (May 2026)
consumer_confidence: Optional[float] = None # points (Jul 2026)
consumer_spending: Optional[float] = None # THB mn (Jun 2026)
property_prices_yoy: Optional[float] = None # residential prices % YoY (Mar 2026)
business_confidence: Optional[float] = None # points (Jul 2026)
periods: dict = field(default_factory=dict)
source: str = "tradingeconomics.thai-factors"
def to_dict(self) -> dict:
return {
"source": self.source,
"interest_rate_pct": self.interest_rate_pct,
"loans_to_fin_corp": self.loans_to_fin_corp,
"consumer_credit_thbmn": self.consumer_credit_thbmn,
"household_debt_gdp_pct": self.household_debt_gdp_pct,
"retail_sales_yoy": self.retail_sales_yoy,
"consumer_confidence": self.consumer_confidence,
"consumer_spending": self.consumer_spending,
"property_prices_yoy": self.property_prices_yoy,
"business_confidence": self.business_confidence,
"periods": self.periods,
}
def _fetch(url: str, 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 ThaiFactorsError(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"<td[^>]*>(.*?)</td>", row_html, re.S)
if td.strip()
]
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 _related_indicators(html_text: str) -> dict[str, dict]:
"""Map normalized-label -> {value, prev, unit, period} from the related table.
Rows are `[label, value, prev, unit, period]` (5 cols). Keys are
lowercased + stripped so a TradingEconomics label tweak (e.g. extra space /
case change) does not silently drop a series. A label may appear more than
once; first row that parses a numeric value wins.
"""
out: dict[str, dict] = {}
for table in re.findall(r"<table[^>]*>(.*?)</table>", html_text, re.S):
for row_html in re.findall(r"<tr[^>]*>(.*?)</tr>", table, re.S):
cells = _cells(row_html)
if len(cells) < 4:
continue
key = cells[0].strip().lower()
value = _to_float(cells[1])
if value is None:
continue
if key not in out or out[key].get("value") is None:
out[key] = {
"value": value,
"prev": _to_float(cells[2]) if len(cells) > 2 else None,
"unit": cells[3] if len(cells) > 3 else "",
"period": cells[4] if len(cells) > 4 else "",
}
return out
def parse_te_thailand_html(interest_html: str, confidence_html: str,
housing_html: str = "", bizconf_html: str = "") -> ThaiFactorsSnapshot:
"""Parse the TradingEconomics Thailand pages into one snapshot.
`housing_html` + `bizconf_html` are optional (Phase B property/business
factors); when omitted those two factors are simply None.
"""
ir = _related_indicators(interest_html)
cc = _related_indicators(confidence_html)
hi = _related_indicators(housing_html) if housing_html else {}
bc = _related_indicators(bizconf_html) if bizconf_html else {}
periods: dict = {}
snap = ThaiFactorsSnapshot(
interest_rate_pct=(ir.get("interest rate") or {}).get("value"),
loans_to_fin_corp=(ir.get("loans to non financial corporations") or {}).get("value"),
consumer_credit_thbmn=(cc.get("consumer credit") or {}).get("value"),
household_debt_gdp_pct=(cc.get("households debt to gdp") or {}).get("value"),
retail_sales_yoy=(cc.get("retail sales yoy") or {}).get("value"),
consumer_confidence=(cc.get("consumer confidence") or {}).get("value"),
# consumer_spending is surfaced here for display/debug only — it is not
# registered as a FACTORS factor, so it does not feed a theme surprise.
consumer_spending=(cc.get("consumer spending") or {}).get("value"),
property_prices_yoy=(hi.get("residential property prices") or {}).get("value"),
business_confidence=(bc.get("business confidence") or {}).get("value"),
)
for key, label, src in (
("interest_rate_pct", "interest rate", ir),
("retail_sales_yoy", "retail sales yoy", cc),
("consumer_confidence", "consumer confidence", cc),
("property_prices_yoy", "residential property prices", hi),
("business_confidence", "business confidence", bc),
):
meta = src.get(label) or {}
if meta and meta.get("period"):
periods[key] = meta.get("period")
if snap.interest_rate_pct is None and snap.consumer_confidence is None \
and snap.retail_sales_yoy is None:
raise ThaiFactorsError(
"no usable Thailand factor series found in TradingEconomics pages"
)
# attach parsed periods onto a copy (dataclass is frozen)
return ThaiFactorsSnapshot(
interest_rate_pct=snap.interest_rate_pct,
loans_to_fin_corp=snap.loans_to_fin_corp,
consumer_credit_thbmn=snap.consumer_credit_thbmn,
household_debt_gdp_pct=snap.household_debt_gdp_pct,
retail_sales_yoy=snap.retail_sales_yoy,
consumer_confidence=snap.consumer_confidence,
consumer_spending=snap.consumer_spending,
property_prices_yoy=snap.property_prices_yoy,
business_confidence=snap.business_confidence,
periods=periods,
)
def fetch_te_thailand(timeout: float = 30.0) -> ThaiFactorsSnapshot:
interest_html = _fetch(_URL_INTEREST, timeout=timeout)
confidence_html = _fetch(_URL_CONFIDENCE, timeout=timeout)
housing_html = _fetch(_URL_HOUSING, timeout=timeout)
bizconf_html = _fetch(_URL_BIZCONF, timeout=timeout)
return parse_te_thailand_html(interest_html, confidence_html,
housing_html, bizconf_html)