Files
set50-system/backend/app/auto_credit.py
Kunthawat Greethong f6dd6304cc [verified] Add Auto Credit (Trading Economics) + Refining/Energy (EIA) factor collectors
- auto_credit.py: scrape Trading Economics Thailand total-vehicle-sales HTML -> total sales + new car sales YoY + vehicle production/passenger/exports
- refining_energy.py: scrape EIA prices.php 3:2:1 crack spread (Gulf LLS) + WTI/Brent/gasoline
- Both server-rendered HTML scrapers (har-derived-api-client pattern); EIA used instead of RBN (RBN is Cloudflare-challenged, 403 via urllib; EIA is open US gov data, HTTP 200 direct)
- collect_auto_credit.py / collect_refining.py CLI -> JSON snapshot
- 8 tests; full backend suite 148 OK; live verified (auto 59198/20% YoY; crack 71.10 $/bbl); static scan clean
2026-08-25 11:05:13 +07:00

154 lines
5.7 KiB
Python

"""Auto Credit alternative factor — scrape real Thai vehicle sales from Trading Economics.
Source: https://tradingeconomics.com/thailand/total-vehicle-sales
Server-rendered HTML (per `har-derived-api-client` guidance, no JSON API — the
HAR capture shows only ads/analytics). Three tables carry the data:
Table 1 (announcements): "New Car Sales YoY" rows — latest YoY% per month.
Table 2 (related indicators): Auto Exports, Vehicle Production, Passenger
Car Sales current values.
Table 3 (main series): Total Vehicle Sales `[latest, prev, high, low,
range, units, freq, seasonality]`.
We parse these so the Auto Credit factor can use vehicle-sales momentum (YoY%)
and the current total, which is the real, non-mock input the user asked for.
"""
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://tradingeconomics.com/thailand/total-vehicle-sales"
class AutoCreditError(Exception):
"""Raised when the Trading Economics page cannot be fetched or parsed."""
@dataclass(frozen=True)
class AutoCreditSnapshot:
total_vehicle_sales: Optional[float] = None # latest units
prev_vehicle_sales: Optional[float] = None # prior period units
new_car_sales_yoy: Optional[float] = None # latest % YoY
prev_new_car_sales_yoy: Optional[float] = None
vehicle_production: Optional[float] = None
passenger_car_sales: Optional[float] = None
auto_exports: Optional[float] = None
as_of: str = ""
source: str = "tradingeconomics"
def to_dict(self) -> dict:
return {
"source": self.source,
"as_of": self.as_of,
"total_vehicle_sales": self.total_vehicle_sales,
"prev_vehicle_sales": self.prev_vehicle_sales,
"new_car_sales_yoy": self.new_car_sales_yoy,
"prev_new_car_sales_yoy": self.prev_new_car_sales_yoy,
"vehicle_production": self.vehicle_production,
"passenger_car_sales": self.passenger_car_sales,
"auto_exports": self.auto_exports,
}
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 AutoCreditError(f"failed to fetch {url}: {exc}") from exc
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("latin-1", "ignore")
def _tables(html_text: str) -> list[str]:
return re.findall(r"<table[^>]*>(.*?)</table>", html_text, re.S)
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)
]
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 parse_auto_credit_html(html_text: str) -> AutoCreditSnapshot:
"""Parse the Trading Economics Thailand total-vehicle-sales page."""
tables = _tables(html_text)
if not tables:
raise AutoCreditError("no tables found in Trading Economics page")
total = prev = None
prod = pass_sales = exports = None
car_yoy = prev_yoy = None
for table in tables:
rows = re.findall(r"<tr[^>]*>(.*?)</tr>", table, re.S)
for row_html in rows:
cells = _cells(row_html)
if not cells:
continue
joined = " | ".join(cells)
# Main series row: ['', '61244.00', '57765.00', '157529','5338','1980-2026','Units',...]
# Detect it as the row with 8+ cells where cell[6] == 'Units'.
if len(cells) >= 7 and cells[6].strip().lower() == "units" and "Monthly" in joined:
total = _to_float(cells[1])
prev = _to_float(cells[2])
# Related indicators table (label, value, prev, unit, period)
elif cells[0].strip().lower() == "vehicle production":
prod = _to_float(cells[1])
elif cells[0].strip().lower() == "passenger car sales":
pass_sales = _to_float(cells[1])
elif cells[0].strip().lower() == "auto exports":
exports = _to_float(cells[1])
# New Car Sales YoY announcements: [date,time,'New Car Sales YoY','May','10.60%',...]
elif cells[0].lower().startswith("202") and "New Car Sales YoY" in joined:
if len(cells) >= 5:
pct = _to_float(cells[4])
if pct is None:
continue # pending row (e.g. current period not reported yet)
if car_yoy is None:
car_yoy = pct
else:
prev_yoy = car_yoy
car_yoy = pct
if total is None and car_yoy is None and prod is None:
raise AutoCreditError("no auto-credit values found in Trading Economics page")
return AutoCreditSnapshot(
total_vehicle_sales=total,
prev_vehicle_sales=prev,
new_car_sales_yoy=car_yoy,
prev_new_car_sales_yoy=prev_yoy,
vehicle_production=prod,
passenger_car_sales=pass_sales,
auto_exports=exports,
)
def fetch_auto_credit(timeout: float = 30.0) -> AutoCreditSnapshot:
html_text = _fetch(timeout=timeout)
return parse_auto_credit_html(html_text)