Files
set50-system/backend/tests/test_siamchart.py
Kunthawat Greethong 632216ac27 [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.
2026-08-25 08:43:00 +07:00

155 lines
6.2 KiB
Python

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