[verified] Add Siamchart SET50 fundamental scraper (financial table + stock-info ratios/income)
- fetch_financial(group): parse Siamchart JS store_real_data array -> EPS/Rev/NP (5yr) + PE for every symbol - parse_stock_info_html: head1/body key ratios (PE/P/BV/D/E/DPS/EPS/ROAA/ROAE/NPM/Yield) + income statement (QoQ/YoY) - collect_siamchart.py CLI: --group/--stock-info/--with-info, timestamped JSON snapshot - build_url validates group against ^[A-Z0-9]+(?:-[A-Z0-9]+)*$ (blocker closed) - 10 unit tests; full backend suite 135 OK; independent review deleg_ad2b8dc7 passed=true [verified] tags from requesting-code-review pipeline.
This commit is contained in:
389
backend/app/siamchart.py
Normal file
389
backend/app/siamchart.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""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)
|
||||
92
backend/scripts/collect_siamchart.py
Normal file
92
backend/scripts/collect_siamchart.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Collect Siamchart SET50 fundamental data into a local timestamped snapshot.
|
||||
|
||||
Two modes:
|
||||
|
||||
--group SET50 parse the whole-group financial table (EPS/Rev/NP/PE
|
||||
for every symbol in the group) into `rows`.
|
||||
--stock-info SYMBOL parse one symbol's key ratios + income statement into
|
||||
`info`.
|
||||
|
||||
With --with-info, after fetching the group table we also fetch each symbol's
|
||||
stock-info page (ratios + income) and attach them under `details[SYMBOL]`. This
|
||||
is the full "所有 in one" fundamental snapshot. The output is written to the
|
||||
`--out` path with a UTC timestamp and a data/parser provenance header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import siamchart
|
||||
|
||||
|
||||
def _now_utc() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--group", default=None,
|
||||
help="Siamchart financial group (e.g. SET50).")
|
||||
parser.add_argument("--stock-info", dest="stock_info", metavar="SYMBOL", default=None,
|
||||
help="Fetch one symbol's stock-info instead of a group table.")
|
||||
parser.add_argument("--with-info", action="store_true",
|
||||
help="After the group table, also fetch each symbol's stock-info.")
|
||||
parser.add_argument("--out", type=Path, default=None,
|
||||
help="Write the JSON snapshot to this file (default: stdout).")
|
||||
parser.add_argument("--timeout", type=float, default=30.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.stock_info and args.group:
|
||||
parser.error("choose either --group or --stock-info, not both")
|
||||
if not args.stock_info and not args.group:
|
||||
parser.error("provide --group or --stock-info")
|
||||
|
||||
payload: dict = {
|
||||
"source": "siamchart",
|
||||
"retrieved_at": _now_utc(),
|
||||
}
|
||||
|
||||
if args.stock_info:
|
||||
info = siamchart.fetch_stock_info(args.stock_info, timeout=args.timeout)
|
||||
payload.update({
|
||||
"mode": "stock-info",
|
||||
"symbol": info.symbol,
|
||||
"url": siamchart._build_stock_info_url(info.symbol),
|
||||
"info": info.to_dict(),
|
||||
})
|
||||
else:
|
||||
rows = siamchart.fetch_financial(args.group, timeout=args.timeout)
|
||||
payload.update({
|
||||
"mode": "group",
|
||||
"group": args.group,
|
||||
"url": siamchart.build_url(args.group),
|
||||
"count": len(rows),
|
||||
"rows": [row.to_dict() for row in rows],
|
||||
})
|
||||
if args.with_info:
|
||||
details = {}
|
||||
for row in rows:
|
||||
try:
|
||||
info = siamchart.fetch_stock_info(row.symbol, timeout=args.timeout)
|
||||
details[row.symbol] = info.to_dict()
|
||||
except siamchart.SiamchartError as exc:
|
||||
details[row.symbol] = {"error": str(exc)}
|
||||
payload["details"] = details
|
||||
payload["details_count"] = len(details)
|
||||
|
||||
text = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
if args.out:
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(text, encoding="utf-8")
|
||||
print(f"wrote {payload.get('count', payload.get('symbol', ''))} -> {args.out}")
|
||||
else:
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
154
backend/tests/test_siamchart.py
Normal file
154
backend/tests/test_siamchart.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tests for the Siamchart financial-table parser.
|
||||
|
||||
Siamchart renders its financial table client-side: the raw HTML ships a
|
||||
JavaScript array named ``store_real_data`` (a list of 22-position symbol rows)
|
||||
rather than a static ``<tbody>``. These tests exercise ``parse_financial_html``
|
||||
against that JS array literal form, which is what the real page provides.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app import siamchart
|
||||
|
||||
|
||||
def _row_js(symbol: str, name: str, eps: list[str], rev: list[str],
|
||||
np: list[str], pe: str) -> str:
|
||||
"""Build one 22-position store_real_data row as a JS array literal."""
|
||||
cells = [symbol, symbol, name, "0"] + eps + [""] + rev + [""] + np + [pe]
|
||||
return "[" + ",".join(repr(c) for c in cells) + "]"
|
||||
|
||||
|
||||
def _html(*rows: str) -> str:
|
||||
return "var store_real_data = [" + ",".join(rows) + "];"
|
||||
|
||||
|
||||
def _advanc_row() -> str:
|
||||
return _row_js(
|
||||
"ADVANC", "บริษัท แอดวานซ์ อินโฟร์ เซอร์วิส จำกัด (มหาชน)",
|
||||
["9.05", "8.75", "9.78", "11.79", "16.10"],
|
||||
["182605.54", "186142.92", "189720.27", "214147.68", "226998.24"],
|
||||
["26922.15", "26011.28", "29086.11", "35075.36", "47885.90"],
|
||||
"20.00",
|
||||
)
|
||||
|
||||
|
||||
class ParseFinancialHtmlTest(unittest.TestCase):
|
||||
def test_parses_full_row_with_metrics_and_pe(self) -> None:
|
||||
rows = siamchart.parse_financial_html(_html(_advanc_row()))
|
||||
self.assertEqual(len(rows), 1)
|
||||
r = rows[0]
|
||||
self.assertEqual(r.symbol, "ADVANC")
|
||||
self.assertEqual(r.no, 0)
|
||||
self.assertEqual(r.eps[1], 9.05)
|
||||
self.assertEqual(r.eps[5], 16.10)
|
||||
self.assertEqual(r.revenue[1], 182605.54)
|
||||
self.assertEqual(r.revenue[5], 226998.24)
|
||||
self.assertEqual(r.net_profit[1], 26922.15)
|
||||
self.assertEqual(r.net_profit[5], 47885.90)
|
||||
self.assertEqual(r.pe, 20.00)
|
||||
|
||||
def test_handles_negative_numbers(self) -> None:
|
||||
row = _row_js(
|
||||
"AOT", "บริษัท ท่าอากาศยานไทย จำกัด (มหาชน)",
|
||||
["-1.14", "-0.78", "0.62", "1.34", "1.27"],
|
||||
["7715.73", "16992.50", "48435.31", "67733.92", "67491.21"],
|
||||
["-16322.01", "-11087.87", "8790.87", "19182.39", "18125.21"],
|
||||
"51.25",
|
||||
)
|
||||
rows = siamchart.parse_financial_html(_html(row))
|
||||
r = rows[0]
|
||||
self.assertEqual(r.symbol, "AOT")
|
||||
self.assertEqual(r.eps[1], -1.14)
|
||||
self.assertEqual(r.net_profit[1], -16322.01)
|
||||
self.assertEqual(r.pe, 51.25)
|
||||
|
||||
def test_missing_symbol_rows_raises(self) -> None:
|
||||
with self.assertRaises(siamchart.SiamchartError):
|
||||
siamchart.parse_financial_html("<html>no data</html>")
|
||||
|
||||
def test_empty_array_raises(self) -> None:
|
||||
with self.assertRaises(siamchart.SiamchartError):
|
||||
siamchart.parse_financial_html("var store_real_data = [];")
|
||||
|
||||
def test_build_url(self) -> None:
|
||||
self.assertEqual(
|
||||
siamchart.build_url("SET50"),
|
||||
"https://siamchart.com/stock-financial/SET50",
|
||||
)
|
||||
with self.assertRaises(siamchart.SiamchartError):
|
||||
siamchart.build_url("")
|
||||
|
||||
def test_build_url_rejects_invalid_groups(self) -> None:
|
||||
for bad in ["../etc", "a b", "set50", "SET/50", "..", "SET..50", ""]:
|
||||
with self.assertRaises(siamchart.SiamchartError):
|
||||
siamchart.build_url(bad)
|
||||
|
||||
def test_build_url_accepts_hyphenated_group(self) -> None:
|
||||
self.assertEqual(
|
||||
siamchart.build_url("PF-REIT"),
|
||||
"https://siamchart.com/stock-financial/PF-REIT",
|
||||
)
|
||||
|
||||
|
||||
def _sti_html() -> str:
|
||||
"""A minimal stock-info HTML with head1/body ratio row + income rows."""
|
||||
ratio_header = (
|
||||
'<tr><td class="head1">PE</td><td class="head1">P/BV</td>'
|
||||
'<td class="head1">D/E</td><td class="head1">DPS</td>'
|
||||
'<td class="head1">EPS</td><td class="head1">ROAA %</td>'
|
||||
'<td class="head1">ROAE %</td><td class="head1">NPM %</td>'
|
||||
'<td class="head1">Yield %</td></tr>'
|
||||
)
|
||||
ratio_body = (
|
||||
'<tr><td class="body">9.42</td><td class="body">0.97</td>'
|
||||
'<td class="body">0.98</td><td class="body">1.40</td>'
|
||||
'<td class="body">4.33</td><td class="body">8.53</td>'
|
||||
'<td class="body">10.62</td><td class="body">9.31</td>'
|
||||
'<td class="body">5.64</td></tr>'
|
||||
)
|
||||
income_rows = (
|
||||
'<tr><td><b>รวมรายได้</b></td>'
|
||||
'<td class="remove_col">1,568,599.55<br>(<font color=green>+14.48</font>)<br>(<font color=green>+12.26</font>)</td>'
|
||||
"</tr>"
|
||||
'<tr><td><b>กำไรสุทธิ</b></td>'
|
||||
'<td class="remove_col">78,262.89<br>(<font color=green>+104.07</font>)<br>(<font color=green>+74.51</font>)</td>'
|
||||
"</tr>"
|
||||
)
|
||||
return ratio_header + ratio_body + income_rows
|
||||
|
||||
|
||||
class ParseStockInfoTest(unittest.TestCase):
|
||||
def test_parses_ratios_and_income(self) -> None:
|
||||
info = siamchart.parse_stock_info_html(_sti_html(), "PTT")
|
||||
self.assertEqual(info.symbol, "PTT")
|
||||
self.assertEqual(info.ratios["PE"], 9.42)
|
||||
self.assertEqual(info.ratios["P/BV"], 0.97)
|
||||
self.assertEqual(info.ratios["Yield %"], 5.64)
|
||||
rev = info.income["total_revenue"]
|
||||
self.assertEqual(rev[0], 1568599.55)
|
||||
self.assertEqual(rev[1], 14.48)
|
||||
self.assertEqual(rev[2], 12.26)
|
||||
np_ = info.income["net_profit"]
|
||||
self.assertEqual(np_[0], 78262.89)
|
||||
self.assertEqual(np_[1], 104.07)
|
||||
self.assertEqual(np_[2], 74.51)
|
||||
|
||||
def test_ratios_missing_table_returns_empty(self) -> None:
|
||||
# No head1/PE ratio table present -> ratios stays empty (no crash)
|
||||
html_text = '<tr><td><b>P/E</b></td><td class="remove_col">40.11</td></tr>'
|
||||
info = siamchart.parse_stock_info_html(html_text, "PTT")
|
||||
self.assertEqual(info.ratios, {})
|
||||
|
||||
def test_build_stock_info_url(self) -> None:
|
||||
self.assertEqual(
|
||||
siamchart._build_stock_info_url("ptt"),
|
||||
"https://siamchart.com/stock-info/PTT/",
|
||||
)
|
||||
with self.assertRaises(siamchart.SiamchartError):
|
||||
siamchart._build_stock_info_url("bad symbol!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user