"""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"]*>(.*?)", 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"]*>(.*?)", html_text, re.S): for row_html in re.findall(r"]*>(.*?)", 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)