- 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.
155 lines
6.2 KiB
Python
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()
|