[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:
Kunthawat Greethong
2026-08-25 08:43:00 +07:00
parent ae78aa1014
commit 632216ac27
3 changed files with 635 additions and 0 deletions

389
backend/app/siamchart.py Normal file
View 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)

View 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())

View 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()