[verified] Add Thai macro backdrop collector (BOT Thai Economy) — real consumption/inflation/unemployment
- macro_thai.py scrapes bot.or.th/en/thai-economy.html (server-rendered, no auth) - private consumption +4.9%, private investment +18.1%, mfg -3.1%, headline inflation 1.95%, core 1.34%, unemployment 0.93%, tourists YTD 16.2mn - 3 tests; live verified; macro backdrop layer (req #6)
This commit is contained in:
132
backend/app/macro_thai.py
Normal file
132
backend/app/macro_thai.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Thai macro backdrop factor — BOT Thai Economy overview page.
|
||||
|
||||
Source: https://www.bot.or.th/en/thai-economy.html (server-rendered HTML).
|
||||
Provides a real Thailand macro backdrop requested by the user (data ที่รอบด้าน
|
||||
ของประเทศ): private consumption %YoY, private investment %YoY, manufacturing
|
||||
production %YoY, headline/core inflation, unemployment, and tourism arrivals YTD.
|
||||
|
||||
The page renders each indicator as text like:
|
||||
"Private Consumption Index (%YoY) 4.9% Jun 2026"
|
||||
so we locate the label and grab the following percent + period.
|
||||
"""
|
||||
|
||||
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://www.bot.or.th/en/thai-economy.html"
|
||||
|
||||
|
||||
class MacroThaiError(Exception):
|
||||
"""Raised when the BOT Thai Economy page cannot be fetched/parsed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MacroThaiSnapshot:
|
||||
private_consumption_yoy: Optional[float] = None
|
||||
private_investment_yoy: Optional[float] = None
|
||||
manufacturing_yoy: Optional[float] = None
|
||||
headline_inflation_yoy: Optional[float] = None
|
||||
core_inflation_yoy: Optional[float] = None
|
||||
unemployment_pct: Optional[float] = None
|
||||
tourists_ytd_mn: Optional[float] = None
|
||||
periods: dict = field(default_factory=dict)
|
||||
source: str = "bot.thai-economy"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"source": self.source,
|
||||
"private_consumption_yoy": self.private_consumption_yoy,
|
||||
"private_investment_yoy": self.private_investment_yoy,
|
||||
"manufacturing_yoy": self.manufacturing_yoy,
|
||||
"headline_inflation_yoy": self.headline_inflation_yoy,
|
||||
"core_inflation_yoy": self.core_inflation_yoy,
|
||||
"unemployment_pct": self.unemployment_pct,
|
||||
"tourists_ytd_mn": self.tourists_ytd_mn,
|
||||
"periods": self.periods,
|
||||
}
|
||||
|
||||
|
||||
# label -> (attribute, expected %-unit)
|
||||
_LABELS = {
|
||||
"Private Consumption Index": "private_consumption_yoy",
|
||||
"Private Investment Index": "private_investment_yoy",
|
||||
"Manufacturing Production Index": "manufacturing_yoy",
|
||||
"Headline Inflation": "headline_inflation_yoy",
|
||||
"Core Inflation": "core_inflation_yoy",
|
||||
"Unemployment": "unemployment_pct",
|
||||
"No. of tourists year-to-date": "tourists_ytd_mn",
|
||||
}
|
||||
|
||||
|
||||
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 MacroThaiError(f"failed to fetch {url}: {exc}") from exc
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode("latin-1", "ignore")
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", "", text))).strip()
|
||||
|
||||
|
||||
def _to_pct(text: str) -> Optional[float]:
|
||||
m = re.search(r"([-+]?\d+(?:\.\d+)?)\s*%", text)
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _has_number(text: str) -> bool:
|
||||
return bool(re.search(r"\d", text))
|
||||
|
||||
|
||||
def parse_macro_thai_html(html_text: str) -> MacroThaiSnapshot:
|
||||
text = _plain(html_text)
|
||||
values: dict = {}
|
||||
periods: dict = {}
|
||||
for label, attr in _LABELS.items():
|
||||
idx = text.find(label)
|
||||
if idx < 0:
|
||||
continue
|
||||
seg = text[idx : idx + 120]
|
||||
pct = _to_pct(seg)
|
||||
if pct is not None:
|
||||
values[attr] = pct
|
||||
elif attr == "tourists_ytd_mn":
|
||||
# tourists YTD is a bare count (million, no %) e.g. "16.2"
|
||||
m = re.search(r"([-+]?\d+(?:\.\d+)?)", seg)
|
||||
if m:
|
||||
values[attr] = float(m.group(1))
|
||||
# period: e.g. "Jun 2026" / "Q1/2026" following the number
|
||||
m = re.search(r"(?:\d+(?:\.\d+)?\s*%?\s*)?([A-Z][a-z]{2}/\d{4}|[A-Z][a-z]{2}\s\d{4}|Q\d/\d{4})", seg)
|
||||
if m:
|
||||
periods[attr] = m.group(1)
|
||||
if not values:
|
||||
raise MacroThaiError("no recognizable macro indicators found in BOT Thai Economy page")
|
||||
return MacroThaiSnapshot(
|
||||
private_consumption_yoy=values.get("private_consumption_yoy"),
|
||||
private_investment_yoy=values.get("private_investment_yoy"),
|
||||
manufacturing_yoy=values.get("manufacturing_yoy"),
|
||||
headline_inflation_yoy=values.get("headline_inflation_yoy"),
|
||||
core_inflation_yoy=values.get("core_inflation_yoy"),
|
||||
unemployment_pct=values.get("unemployment_pct"),
|
||||
tourists_ytd_mn=values.get("tourists_ytd_mn"),
|
||||
periods=periods,
|
||||
)
|
||||
|
||||
|
||||
def fetch_macro_thai(timeout: float = 30.0) -> MacroThaiSnapshot:
|
||||
return parse_macro_thai_html(_fetch(timeout=timeout))
|
||||
45
backend/tests/test_macro_thai.py
Normal file
45
backend/tests/test_macro_thai.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Tests for the Thai macro backdrop collector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app import macro_thai
|
||||
|
||||
|
||||
def _page_html() -> str:
|
||||
return """
|
||||
<html><body>
|
||||
<div>Private Consumption Index (%YoY) 4.9% Jun 2026</div>
|
||||
<div>Private Investment Index (%YoY) 18.1% Jun 2026</div>
|
||||
<div>Manufacturing Production Index (%YoY) -3.1% Jun 2026</div>
|
||||
<div>Headline Inflation (%YoY) 1.95% July 2026</div>
|
||||
<div>Core Inflation (%YoY) 1.34% July 2026</div>
|
||||
<div>Unemployment (%share to Labor force) 0.93% Q1/2026</div>
|
||||
<div>No. of tourists year-to-date (million) 16.2 Jun 2026</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class MacroThaiParseTest(unittest.TestCase):
|
||||
def test_parses_all_indicators(self) -> None:
|
||||
snap = macro_thai.parse_macro_thai_html(_page_html())
|
||||
self.assertEqual(snap.private_consumption_yoy, 4.9)
|
||||
self.assertEqual(snap.private_investment_yoy, 18.1)
|
||||
self.assertEqual(snap.manufacturing_yoy, -3.1)
|
||||
self.assertEqual(snap.headline_inflation_yoy, 1.95)
|
||||
self.assertEqual(snap.unemployment_pct, 0.93)
|
||||
self.assertEqual(snap.tourists_ytd_mn, 16.2)
|
||||
|
||||
def test_no_data_raises(self) -> None:
|
||||
with self.assertRaises(macro_thai.MacroThaiError):
|
||||
macro_thai.parse_macro_thai_html("<html>nothing here</html>")
|
||||
|
||||
def test_to_dict_full(self) -> None:
|
||||
d = macro_thai.parse_macro_thai_html(_page_html()).to_dict()
|
||||
self.assertEqual(d["source"], "bot.thai-economy")
|
||||
self.assertIn("periods", d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user