From 632216ac271d3a75c22d2df4b339f62ac8dcb58b Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Tue, 25 Aug 2026 08:43:00 +0700 Subject: [PATCH] [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. --- backend/app/siamchart.py | 389 +++++++++++++++++++++++++++ backend/scripts/collect_siamchart.py | 92 +++++++ backend/tests/test_siamchart.py | 154 +++++++++++ 3 files changed, 635 insertions(+) create mode 100644 backend/app/siamchart.py create mode 100644 backend/scripts/collect_siamchart.py create mode 100644 backend/tests/test_siamchart.py diff --git a/backend/app/siamchart.py b/backend/app/siamchart.py new file mode 100644 index 0000000..06f5335 --- /dev/null +++ b/backend/app/siamchart.py @@ -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 `` (+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//`` (PE, P/BV, D/E, DPS, +EPS, ROAA/ROAE/NPM, Yield%, dividend history, full income statement with +QoQ/YoY) and ``/stock-chart//`` 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 ```` 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 ````; 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, ``P/Evalue +
(QoQ)
(YoY)...`` 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 ``รวมรายได้`` + 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.: + # PE...Yield % + # 9.420.97... + hdr = re.search( + r']*class="head1"[^>]*>PE(.*?)', html_text, re.S + ) + if hdr: + labels = [ + html.unescape(re.sub(r"<[^>]+>", "", x)).strip() + for x in re.findall(r']*class="head1"[^>]*>(.*?)', hdr.group(0), re.S) + ] + labels = [x for x in labels if x] + body_match = re.search( + r']*class="body"[^>]*>(.*?)\s*', + html_text[hdr.end():], + re.S, + ) + if body_match: + body_vals = [ + html.unescape(re.sub(r"<[^>]+>", "", x)).strip() + for x in re.findall(r']*class="body"[^>]*>(.*?)', 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 LABEL.... + # We match the full ... 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"" + re.escape(label) + r"(.*?)", html_text, re.S) + if not m: + continue + cells = re.findall(r'class="remove_col"[^>]*>(.*?)', 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) diff --git a/backend/scripts/collect_siamchart.py b/backend/scripts/collect_siamchart.py new file mode 100644 index 0000000..32e6dcb --- /dev/null +++ b/backend/scripts/collect_siamchart.py @@ -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()) diff --git a/backend/tests/test_siamchart.py b/backend/tests/test_siamchart.py new file mode 100644 index 0000000..ad5eacb --- /dev/null +++ b/backend/tests/test_siamchart.py @@ -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 ````. 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("no data") + + 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 = ( + 'PEP/BV' + 'D/EDPS' + 'EPSROAA %' + 'ROAE %NPM %' + 'Yield %' + ) + ratio_body = ( + '9.420.97' + '0.981.40' + '4.338.53' + '10.629.31' + '5.64' + ) + income_rows = ( + 'รวมรายได้' + '1,568,599.55
(+14.48)
(+12.26)' + "" + 'กำไรสุทธิ' + '78,262.89
(+104.07)
(+74.51)' + "" + ) + 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 = 'P/E40.11' + 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()