Files
set50-system/backend/app/siamchart.py
Kunthawat Greethong f9973e8d0a [verified] Real dated dividend history collector (dps_annual_proxy -> dated_ledger)
Close the last deferred PIT milestone by collecting REAL per-stock dated
dividend cash-flow history from Siamchart, upgrading the dividend ledger
from DPS estimates to dated_ledger.

- backend/app/siamchart.py: parse_dividend_history(html) extracts the
  'ประวัติการปันผล' dividend table (ex_date + per-share DPS) from each
  stock-info page; fetch_dividend_history(symbol) fetches it live.
- backend/app/dividend_ledger.py: populate_dated_dividends(ledger,
  symbols, fetcher) registers every dated payment as a real row
  (estimate=False, source=siamchart_dated); one symbol failing never
  aborts the rest.
- backend/app/__init__.py: DividendLedger persisted at
  data/dividends/ledger.json; POST /api/v1/dividends/update fetches all
  symbols and saves it; use_ledger backtests prefer the dated ledger when
  populated (dividend_method=dated_ledger) and fall back to DPS estimates
  otherwise.
- tests: parser (4) + populate (2) — full backend 292 passed.

Live (real network): update fetched 49/49 symbols, 1410 dated payments;
use_ledger backtest then reports dividend_method=dated_ledger.
The 'eval(' static-scan hit is ast.literal_eval (safe literal parse, no
code execution), not eval().
2026-08-27 12:34:36 +07:00

438 lines
17 KiB
Python

"""Siamchart fundamental-data fetcher/parser for SET50 research factors.
Siamchart (siamchart.com) serves its stock financial table as **server-rendered
HTML** — the HAR capture (`har-derived-api-client`) shows only Cloudflare
speculation/rum telemetry, no JSON/XHR API. Per that skill's guidance we scrape
and parse the HTML directly instead of deriving an API client.
Primary page (all SET50 symbols in one table):
https://siamchart.com/stock-financial/SET50
├── table.tbl thead: Name, No., EPS21..EPS25 (YoY%), Rev21..Rev25 (YoY%),
│ NP21..NP25 (YoY%), PE
└── table.tbl tbody row: [Symbol, No.,
EPS21, EPS22, EPS23, EPS24, EPS25,
Rev21, Rev22, Rev23, Rev24, Rev25,
NP21, NP22, NP23, NP24, NP25,
PE]
Each metric cell is rendered as ``<value> (+YoY%)`` except the base row where a
``+Infinity`` / ``-Infinity`` / ``NaN`` YoY may appear. PE is a bare number.
Per-stock detail pages live at ``/stock-info/<SYMBOL>/`` (PE, P/BV, D/E, DPS,
EPS, ROAA/ROAE/NPM, Yield%, dividend history, full income statement with
QoQ/YoY) and ``/stock-chart/<SYMBOL>/`` for prices; both are out of scope for
this module and are exercised elsewhere.
This module is pure: it never touches the network itself. The URL is built from
a group name, the HTML is fetched by the fetching helper (or injected directly
for tests), and parsing is a deterministic pure function over the HTML string.
"""
from __future__ import annotations
import html
import re
from dataclasses import dataclass, field
from typing import Optional
from urllib.request import Request, urlopen
# Browser user-agent captured from the Siamchart HAR so the server treats us as
# a normal browser rather than a default library client (python-urllib gets 403).
_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"
)
_BASE_URL = "https://siamchart.com/stock-financial/"
# Column layout of table.tbl for a financial group page.
_FINANCIAL_COLUMNS = [
"symbol",
"no",
"eps21", "eps22", "eps23", "eps24", "eps25",
"rev21", "rev22", "rev23", "rev24", "rev25",
"np21", "np22", "np23", "np24", "np25",
"pe",
]
class SiamchartError(Exception):
"""Raised when a Siamchart HTML page cannot be fetched or parsed."""
@dataclass(frozen=True)
class SiamchartRow:
"""One parsed row of the Siamchart financial group table."""
symbol: str
no: int
eps: "dict[int, Optional[float]]" = field(default_factory=dict)
eps_yoy: "dict[int, Optional[float]]" = field(default_factory=dict)
revenue: "dict[int, Optional[float]]" = field(default_factory=dict)
revenue_yoy: "dict[int, Optional[float]]" = field(default_factory=dict)
net_profit: "dict[int, Optional[float]]" = field(default_factory=dict)
net_profit_yoy: "dict[int, Optional[float]]" = field(default_factory=dict)
pe: Optional[float] = None
raw: list = field(default_factory=list)
def to_dict(self) -> dict:
return {
"symbol": self.symbol,
"no": self.no,
"eps": self.eps,
"eps_yoy": self.eps_yoy,
"revenue": self.revenue,
"revenue_yoy": self.revenue_yoy,
"net_profit": self.net_profit,
"net_profit_yoy": self.net_profit_yoy,
"pe": self.pe,
}
def build_url(group: str = "SET50") -> str:
"""Build the Siamchart financial-group URL for a given group name.
The group is the string used in the URL path segment exactly as Siamchart
produces it when the ``fund_type`` dropdown is changed (e.g. ``SET50``).
It is validated to a strict token (uppercase alphanumerics and hyphen) so it
can never turn into partial-list/traversal URL (e.g. ``../etc``) and remains
safe if group ever reaches this builder from request input.
"""
if not group:
raise SiamchartError("group must be a non-empty string")
group = group.strip()
if not re.match(r"^[A-Z0-9]+(?:-[A-Z0-9]+)*$", group):
raise SiamchartError(
f"invalid Siamchart group: {group!r} "
"(expected uppercase alphanumerics, optionally hyphenated)"
)
return f"{_BASE_URL}{group}"
def _fetch(url: str, timeout: float = 30.0) -> str:
"""Fetch ``url`` and return its decoded HTML text body."""
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: # network errors are all non-recoverable here
raise SiamchartError(f"failed to fetch {url}: {exc}") from exc
# Siamchart serves UTF-8; fall back to latin-1 only if the header says so.
for encoding in ("utf-8", "latin-1"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
raise SiamchartError("unable to decode Siamchart response")
def _split_metric(value: str) -> tuple[Optional[float], Optional[float]]:
"""Parse a ``<value>`` cell into a (value, None) tuple.
The PE cell in ``store_real_data`` is a bare number; the YoY% is only
rendered in the web table and is not present in the raw array. We keep this
helper so PE parsing is explicit and consistent with the (value, yoy) shape
used elsewhere. An empty value returns (None, None).
"""
text = html.unescape(value).strip()
if not text:
return None, None
numbers = re.findall(r"[-+]?\d+(?:[.,]\d+)?", text.replace(",", ""))
try:
val = float(numbers[0]) if numbers else None
except ValueError:
val = None
return val, None
def parse_financial_html(html_text: str) -> list[SiamchartRow]:
"""Parse a Siamchart financial-group HTML page into rows.
Siamchart renders its financial table **client-side** — the raw HTML does
not contain a static ``<tbody>``; instead the data ships as a JavaScript
array literal named ``store_real_data`` (assigned from ``real_data``). This
is a pure ``[symbol_row, ...]`` structure decoder over that literal, so it
works without executing JS or a browser.
Each element is a 22-position list:
[0] symbol, [1] symbol, [2] full-name, [3] marker,
[4..8] EPS21..EPS25,
[9] separator,
[10..14] Rev21..Rev25,
[15] separator,
[16..20] NP21..NP25,
[21] PE (bare number)
Strings are unquoted numbers or empty; we coerce sensibly.
"""
anchor = re.search(r"var\s+store_real_data\s*=\s*(\[.*?\n\]\s*;)", html_text, re.S)
if not anchor:
# Fall back to any bare assignment (some Siamchart variants set
# `store_real_data = [...]` without the var keyword).
anchor = re.search(r"store_real_data\s*=\s*(\[.*?\]\s*;)", html_text, re.S)
if not anchor:
raise SiamchartError("no store_real_data array found in Siamchart HTML")
js_literal = anchor.group(1)
data = _parse_js_array(js_literal)
rows: list[SiamchartRow] = []
for entry in data:
if not isinstance(entry, list) or len(entry) < 22:
continue
symbol = str(entry[0]).strip() if entry[0] else ""
if not symbol or not re.match(r"^[A-Z0-9]+$", symbol):
continue
try:
no = int(entry[3])
except (ValueError, TypeError):
no = 0
eps_v, eps_y = _years(entry, 4)
rev_v, rev_y = _years(entry, 10)
np_v, np_y = _years(entry, 16)
pe, _ = _split_metric(str(entry[21]))
rows.append(
SiamchartRow(
symbol=symbol,
no=no,
eps=eps_v,
eps_yoy=eps_y,
revenue=rev_v,
revenue_yoy=rev_y,
net_profit=np_v,
net_profit_yoy=np_y,
pe=pe,
raw=list(entry),
)
)
if not rows:
raise SiamchartError("no symbol rows parsed from Siamchart financial table")
return rows
def _years(entry: list, start: int, count: int = 5) -> tuple[dict, dict]:
"""Extract ``count`` numeric values from a JS array row starting at ``start``.
Returns (values, yoy) pairs; in ``store_real_data`` each cell is a plain
number (YoY% is already baked into the adjacent web-column only when the
table is rendered — here YoY is not present, so yoy is left empty).
"""
values: dict[int, Optional[float]] = {}
yoy: dict[int, Optional[float]] = {}
for i in range(count):
idx = start + i
if idx >= len(entry):
break
cell = entry[idx]
if cell is None or cell == "":
values[i + 1] = None
continue
try:
values[i + 1] = float(str(cell).replace(",", ""))
except (ValueError, TypeError):
values[i + 1] = None
return values, yoy
def _parse_js_array(js: str) -> list:
"""Parse a JavaScript array literal into Python (JSON-compatible-safe).
Siamchart uses single-quoted strings and trailing commas in places; we
normalize by converting single quotes to double quotes for string tokens,
then use ``ast.literal_eval`` for safe parsing (no code execution).
Strings containing an apostrophe inside a name would break this, but stock
company names on Siamchart do not contain apostrophes.
"""
import ast
# Trim the leading '[' and trailing '];'
js = js.strip()
if js.endswith(";"):
js = js[:-1]
# Convert single-quoted strings to double-quoted: replace 'X' -> "X"
# (safe here because names have no inner single quotes or doubles).
converted = re.sub(r"'([^']*)'", lambda m: '"' + m.group(1).replace('"', '\\"') + '"', js)
try:
return ast.literal_eval(converted)
except (ValueError, SyntaxError) as exc:
raise SiamchartError(f"failed to parse store_real_data array: {exc}") from exc
def fetch_financial(group: str = "SET50", timeout: float = 30.0) -> list[SiamchartRow]:
"""Fetch and parse the Siamchart financial group table for ``group``."""
url = build_url(group)
html_text = _fetch(url, timeout=timeout)
return parse_financial_html(html_text)
def _build_stock_info_url(symbol: str) -> str:
symbol = symbol.strip().upper()
if not re.match(r"^[A-Z0-9]+$", symbol):
raise SiamchartError("symbol must be an uppercase SET ticker, e.g. PTT")
return f"https://siamchart.com/stock-info/{symbol}/"
@dataclass(frozen=True)
class StockInfo:
"""Key fundamental ratios and latest income-statement snapshot for a symbol."""
symbol: str
ratios: "dict[str, Optional[float]]" = field(default_factory=dict)
# Latest income statement: label -> (value, qoq_pct, yoy_pct)
income: "dict[str, tuple[Optional[float], Optional[float], Optional[float]]]" = (
field(default_factory=dict)
)
def to_dict(self) -> dict:
return {
"symbol": self.symbol,
"ratios": self.ratios,
"income": {
k: {"value": v[0], "qoq_pct": v[1], "yoy_pct": v[2]}
for k, v in self.income.items()
},
}
def parse_stock_info_html(html_text: str, symbol: str) -> StockInfo:
"""Parse key ratios + latest income statement from a stock-info page.
Siamchart renders the summary ratios as a **vertical table**: one row per
metric, ``<tr><td title=...><b>P/E</b></td><td class="remove_col">value
<br>(QoQ)<br>(YoY)</td>...`` across five periods. The first ``remove_col``
cell holds the latest period's value plus QoQ and YoY deltas on separate
lines.
The income statement rows embed the label in a ``<td><b>รวมรายได้</b></td>``
followed by the same five-period ``remove_col`` cells; we read the first one.
"""
ratios: dict[str, Optional[float]] = {}
income: dict[str, tuple[Optional[float], Optional[float], Optional[float]]] = {}
# --- Key ratios: head1 header row followed by a body row ---
# Located after "ราคาปิดที่ 41", i.e.:
# <tr><td class="head1">PE</td>...<td class="head1">Yield %</td></tr>
# <tr><td class="body">9.42</td><td class="body">0.97</td>...</tr>
hdr = re.search(
r'<td[^>]*class="head1"[^>]*>PE</td>(.*?)</tr>', html_text, re.S
)
if hdr:
labels = [
html.unescape(re.sub(r"<[^>]+>", "", x)).strip()
for x in re.findall(r'<td[^>]*class="head1"[^>]*>(.*?)</td>', hdr.group(0), re.S)
]
labels = [x for x in labels if x]
body_match = re.search(
r'<td[^>]*class="body"[^>]*>(.*?)</td>\s*</tr>',
html_text[hdr.end():],
re.S,
)
if body_match:
body_vals = [
html.unescape(re.sub(r"<[^>]+>", "", x)).strip()
for x in re.findall(r'<td[^>]*class="body"[^>]*>(.*?)</td>', body_match.group(0), re.S)
]
for label, val in zip(labels, body_vals):
ratios[label] = _float_or_none(val.split("\n")[0].strip())
# --- Income statement headline rows ---
# Each row is <tr><td><b>LABEL</b></td><td class="remove_col">...</td>.
# We match the full <b>...</b> label so a prefix like "กำไร" cannot bind to
# the wrong row, and we only fill a key once (a later lossy variant of the
# same row, e.g. "กำไร (ขาดทุน) สุทธิ", must not overwrite "กำไรสุทธิ").
income_labels = [
("รวมรายได้", "total_revenue"),
("รวมค่าใช้จ่าย", "total_cost"),
("กำไรขั้นต้น", "gross_profit"),
("กำไรสุทธิ", "net_profit"),
("กำไร (ขาดทุน) สุทธิ", "net_profit"),
]
for label, canonical in income_labels:
if canonical in income:
continue # already captured by an earlier full match
m = re.search(r"<b>" + re.escape(label) + r"</b></td>(.*?)</tr>", html_text, re.S)
if not m:
continue
cells = re.findall(r'class="remove_col"[^>]*>(.*?)</td>', m.group(1), re.S)
if not cells:
continue
first = html.unescape(re.sub(r"<[^>]+>", "", cells[0])) or ""
# "1,568,599.55(+14.48)(+12.26)" — the first full number (thousand-
# separated) is the value; bracketed tokens are QoQ and YoY percentages.
first_num = re.search(r"[-+]?\d{1,3}(?:,\d{3})*(?:\.\d+)?", first)
value = _float_or_none(first_num.group(0)) if first_num else None
qoq = yoy = None
for mm in re.finditer(r"\(([-+]?\d+(?:[.,]\d+)?)\)", first):
pct = _float_or_none(mm.group(1))
if qoq is None:
qoq = pct
elif yoy is None:
yoy = pct
income[canonical] = (value, qoq, yoy)
return StockInfo(symbol=symbol, ratios=ratios, income=income)
def _float_or_none(text: str) -> Optional[float]:
text = text.replace(",", "").strip()
if not text:
return None
try:
return float(text)
except ValueError:
return None
def fetch_stock_info(symbol: str, timeout: float = 30.0) -> StockInfo:
"""Fetch and parse a single symbol's stock-info page."""
url = _build_stock_info_url(symbol)
html_text = _fetch(url, timeout=timeout)
return parse_stock_info_html(html_text, symbol)
# ---------------------------------------------------------------------------
# Dividend history (per-stock ex-date + DPS)
# ---------------------------------------------------------------------------
def parse_dividend_history(html_text: str) -> list[tuple[str, float]]:
"""Extract the per-stock dividend history table from a stock-info page.
Siamchart renders a "ประวัติการปันผล" (dividend history) table on each
stock-info page:
<table>
<tr><td class="head1" colspan=4>ประวัติการปันผล</td></tr>
<tr><td class="head2">วันที่</td><td class="head2">ปันผล</td>...</tr>
<tr><td class="body">2002-04-04</td><td class="body">2.5</td>...</tr>
...
Returns a chronological list of ``(ex_date, dividend_per_share)`` pairs.
The date column is the dividend's dated cut-off (ex-date); the amount is
per-share (THB). This is REAL dated cash-flow history — exactly what the
``dividend_ledger`` needs to upgrade a symbol from a DPS estimate to a
``dated_ledger``.
"""
match = re.search(r"ประวัติการปันผล</td></tr>(.*?)</table>", html_text, re.S)
if not match:
return []
body = match.group(1)
rows: list[tuple[str, float]] = []
for tr in re.findall(r"<tr[^>]*>(.*?)</tr>", body, re.S):
cells = re.findall(r'<td[^>]*class="(?:body|remove_col)"[^>]*>(.*?)</td>', tr, re.S)
if len(cells) < 2:
continue
date = re.sub(r"<[^>]+>", "", cells[0]).strip()
amount = re.sub(r"<[^>]+>", "", cells[1]).strip()
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date):
continue
try:
rows.append((date, float(amount)))
except ValueError:
continue
return sorted(set(rows)) # dedupe + chronological
def fetch_dividend_history(symbol: str, timeout: float = 30.0) -> list[tuple[str, float]]:
"""Fetch a symbol's real dated dividend history from its stock-info page."""
url = _build_stock_info_url(symbol)
html_text = _fetch(url, timeout=timeout)
return parse_dividend_history(html_text)