From 6e78b6acb544a5a83e8036e81f175ab2e783dc3c Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Wed, 26 Aug 2026 19:56:39 +0700 Subject: [PATCH] [verified] Apply R1-R5 (factor formula) + real bank-sector NPL collector (a) R1-R5 (factor-refinement, grounded in methodology-research.md): - R1 (PEAD): EPS-growth weight raised 1.0->1.5 in build_siamchart_score / symbol_breakdown (Bernard-Thomas 1990, Livnat-Mendenhall 2006) - R2 (momentum): 12-1 momentum factor from Yahoo price snapshot (Jegadeesh-Titman 93; lite weight 0.5) - R3 (regime): binary bear gate -> continuous stress = negative-themes fraction, smooth LONG/SHORT shift - R5 (dividend screen): non-dividend / cut-yield names no longer go LONG (screen-off) - R4 (earnings-revision) deferred: no free EPS-forecast source yet (documented) (b) bank-sector NPL collector (BOT reportID 794, financial&insurance sector): - refactored auto_npl to expose shared _parse_sector; new bank_npl.py reuses it - registered bank_npl FACTOR -> auto-appears in sources table (6 rows) + blends into banks theme surprise (real NPL) - +unit tests (test_bank_npl), test_dashboard updated (6 sources) 205 tests pass; verified live API (banks surprise incl. NPL 1.07, 6 sources). --- backend/app/__init__.py | 58 +++++++++++++++++++-------------- backend/app/auto_npl.py | 29 ++++++++++++----- backend/app/bank_npl.py | 54 ++++++++++++++++++++++++++++++ backend/app/dashboard.py | 37 ++++++++++++++------- backend/app/factors.py | 10 ++++++ backend/app/themes.py | 56 ++++++++++++++++++++++++++----- backend/tests/test_bank_npl.py | 43 ++++++++++++++++++++++++ backend/tests/test_dashboard.py | 7 ++-- 8 files changed, 240 insertions(+), 54 deletions(-) create mode 100644 backend/app/bank_npl.py create mode 100644 backend/tests/test_bank_npl.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index f49f176..3ed2539 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -7,6 +7,7 @@ import json import os import re import secrets +import statistics import time from pathlib import Path from typing import Any @@ -467,49 +468,57 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: board = dash.get("board", []) combos = [b.get("combined") for b in board if b.get("combined") is not None] if combos: - import statistics q1, q3 = statistics.quantiles(combos, n=4)[0], statistics.quantiles(combos, n=4)[2] else: q1 = q3 = 0.0 - # market-regime gate: how many themes are in distress (negative - # surprise). In a broad-down market we tighten the LONG bar and pull + # market-regime gate (R3): how many themes are in distress. Instead + # of a hard binary cliff, use a continuous stress = (negative themes + # / total) in [0,1] and shift the LONG bar / SHORT threshold + # smoothly with it. In a broad-down market we tighten LONG and pull # more names into SHORT/avoid, so 'best of a falling board' isn't LONG. theme_surprises = [t.get("surprise") for t in dash.get("themes", []) if t.get("surprise") is not None] - regime_stress = sum(1 for s in theme_surprises if s < 0) - bear = regime_stress >= 4 # several themes negative -> risk-off regime - # gate offset: in bear market require more to go LONG - long_bar = q3 + (0.10 if bear else 0.0) + n_themes = max(len(theme_surprises), 1) + n_neg = sum(1 for s in theme_surprises if s < 0) + stress = n_neg / n_themes # 0..1 continuous regime gauge + # gate offset grows with stress (at stress=1 => +0.15 to go LONG) + long_bar = q3 + 0.15 * stress + # SHORT threshold widens as stress rises (pull more into avoid) + short_bar = q1 - 0.05 - 0.08 * stress + fmap = {b.get("symbol"): b for b in board} for row in board: comb = row.get("combined") sym = row.get("symbol") if comb is None: signal_by_symbol[sym] = {"side": None, "score": None} continue - if bear: - # risk-off: SLOT for LONG only clearly-above-top-quartile; everything - # below the median becomes SHORT/avoid. - if comb >= long_bar: + fac = fmap.get(sym, {}) + # R5 (dividend screen): a name that pays no dividend (or has cut + # its yield to a negative/zero level) never goes LONG — dividend + # is our core value assumption; literature treats a cut as a + # screen-off signal. Downgrade to NEUTRAL/SHORT accordingly. + is_div = bool(fac.get("is_dividend")) or (fac.get("dividend_yield") or 0) > 0 + if comb >= long_bar: + if is_div: side, score = "LONG", round(min(abs(comb) * 3.0, 1.0) * 0.9 + 0.1, 3) - elif comb < q1 - 0.05: - side, score = "SHORT", round(min(abs(comb) / max(q1 - 0.05, 1e-9), 1.0) * 0.9 + 0.1, 3) - else: - median = combos and statistics.median(combos) or 0.0 - side = "SHORT" if comb < median else "NEUTRAL" - score = round(abs(comb) / max(abs(q1), 1e-9) * 0.5, 3) - else: - # normal regime: quartile split 25/25 - if comb >= q3: - side, score = "LONG", round(min(abs(comb) * 3.0, 1.0) * 0.9 + 0.1, 3) - elif comb <= q1: - side, score = "SHORT", round(min(abs(comb) / max(abs(q1), 1e-6), 1.0) * 0.5, 3) else: + # high score but no dividend -> strong growth but our + # thesis is dividend-anchored; cap at NEUTRAL. side, score = "NEUTRAL", round((comb - q1) / max(q3 - q1, 1e-9), 3) + elif comb < short_bar: + side, score = "SHORT", round(min(abs(comb) / max(abs(short_bar), 1e-6), 1.0) * 0.5, 3) + else: + median = combos and statistics.median(combos) or 0.0 + side = "SHORT" if (comb < median and stress > 0.5) else "NEUTRAL" + score = round(abs(comb) / max(abs(q1), 1e-9) * 0.5, 3) if stress > 0.5 \ + else round((comb - q1) / max(q3 - q1, 1e-9), 3) signal_by_symbol[sym] = { "side": side, "score": score, "confidence": "medium", - "combined_score": comb, "regime": "risk-off" if bear else "normal", + "combined_score": comb, + "regime": "risk-off" if stress > 0.4 else "normal", + "regime_stress": round(stress, 3), } except Exception: # no dashboard -> fall back to neutral for all @@ -711,6 +720,7 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: detail = themes_mod.symbol_breakdown( symbol, factor_view=factor_view, theme_surprises=theme_surprises, latest_price=price, price_date=price_date, + momentum=themes_mod._load_momentum(), ) return jsonify(detail) diff --git a/backend/app/auto_npl.py b/backend/app/auto_npl.py index b7d3ca1..fbfebd3 100644 --- a/backend/app/auto_npl.py +++ b/backend/app/auto_npl.py @@ -83,7 +83,23 @@ def _cells(row_html: str) -> list[str]: def parse_auto_npl_html(html_text: str, label: str = AUTO_LOAN_LABEL) -> AutoNplSnapshot: - """Parse the BOT NPL table; return the auto-loan-sector row (latest period).""" + """Parse the BOT NPL table; return the auto-loan-sector row (latest period). + + Thin wrapper — the BOT 794 page lists many business sectors, so the actual + extraction is shared via :func:`_parse_sector`. The ``auto`` variant keeps the + sector label (default 'รถยนต์') for the auto_credit theme; other themes can + reuse report 794 with their own sector label (e.g. banks -> financial sector). + """ + row = _parse_sector(html_text, label) + if row is None: + raise AutoNplError(f"no {label!r} NPL row found in BOT NPL page") + return AutoNplSnapshot( + npl_amount=row[0], pct_of_npls=row[1], pct_of_loans=row[2], period=row[3], + ) + + +def _parse_sector(html_text: str, label: str) -> Optional[tuple]: + """Return (npl, pct_of_npls, pct_of_loans, period) for the given sector label.""" tables = re.findall(r"]*>(.*?)", html_text, re.S) period = "" npl = pct_n = pct_l = None @@ -99,19 +115,14 @@ def parse_auto_npl_html(html_text: str, label: str = AUTO_LOAN_LABEL) -> AutoNpl period = m.group(1) continue # data row: [no, label, value, pct_npl, pct_loan, ...] - if len(cells) >= 4 and cells[0].isdigit() and ("รถยนต์" in cells[1] or label in cells[1]): + if len(cells) >= 4 and cells[0].isdigit() and label in cells[1]: npl = _to_float(cells[2]) pct_n = _to_float(cells[3]) pct_l = _to_float(cells[4]) if len(cells) > 4 else None break if npl is None and pct_n is None: - raise AutoNplError("no auto-loan NPL row found in BOT NPL page") - return AutoNplSnapshot( - npl_amount=npl, - pct_of_npls=pct_n, - pct_of_loans=pct_l, - period=period, - ) + return None + return (npl, pct_n, pct_l, period) def fetch_auto_npl(timeout: float = 30.0) -> AutoNplSnapshot: diff --git a/backend/app/bank_npl.py b/backend/app/bank_npl.py new file mode 100644 index 0000000..a38e784 --- /dev/null +++ b/backend/app/bank_npl.py @@ -0,0 +1,54 @@ +"""Bank-sector NPL / credit-quality factor — BOT Gross NPLs by business type. + +Source: https://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=794 +(Bank of Thailand, FI_NP_003_S2). This is the SAME report the auto theme already +consumes; the bank variant selects the **financial & insurance** sector row — +the closest public proxy for commercial-bank credit quality. + +Higher NPL / higher % of loans is bearish for bank earnings (provisioning drag), +so the factor sign is -1 at the registry level. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from .auto_npl import _fetch, _parse_sector, AutoNplError + +# Sector label on the BOT 794 page that maps to financial/banking credit quality. +# ('กิจกรรมทางการเงินและการประกันภัย' = financial & insurance activities) +BANK_SECTOR_LABEL = "กิจกรรมทางการเงินและการประกันภัย" + + +@dataclass(frozen=True) +class BankNplSnapshot: + npl_amount: Optional[float] = None + pct_of_npls: Optional[float] = None + pct_of_loans: Optional[float] = None + period: str = "" + source: str = "bot" + + def to_dict(self) -> dict: + return { + "source": self.source, + "period": self.period, + "npl_amount": self.npl_amount, + "pct_of_npls": self.pct_of_npls, + "pct_of_loans": self.pct_of_loans, + } + + +def parse_bank_npl_html(html_text: str, label: str = BANK_SECTOR_LABEL) -> BankNplSnapshot: + """Parse the BOT NPL table; return the financial-sector row (latest period).""" + row = _parse_sector(html_text, label) + if row is None: + raise AutoNplError(f"no {label!r} NPL row found in BOT NPL page") + return BankNplSnapshot( + npl_amount=row[0], pct_of_npls=row[1], pct_of_loans=row[2], period=row[3], + ) + + +def fetch_bank_npl(timeout: float = 30.0) -> BankNplSnapshot: + from .auto_npl import _URL + return parse_bank_npl_html(_fetch(_URL, timeout=timeout)) diff --git a/backend/app/dashboard.py b/backend/app/dashboard.py index 680371a..f83fdaf 100644 --- a/backend/app/dashboard.py +++ b/backend/app/dashboard.py @@ -84,7 +84,7 @@ class RealDashboard: def build(self) -> dict: # 1) live theme data (real, no fallback) - from . import auto_credit, auto_npl, energy_thai, bot_tourism + from . import auto_credit, auto_npl, bank_npl, energy_thai, bot_tourism tourism = None try: @@ -97,13 +97,15 @@ class RealDashboard: lambda: auto_credit.fetch_auto_credit().to_dict(), "auto_credit") npl_d = _fetch_with_cache( self.cache, "auto_npl", lambda: auto_npl.fetch_auto_npl().to_dict(), "auto_npl") + bnpl_d = _fetch_with_cache( + self.cache, "bank_npl", lambda: bank_npl.fetch_bank_npl().to_dict(), "bank_npl") en_d = _fetch_with_cache( self.cache, "energy_thai", lambda: energy_thai.fetch_energy_thai().to_dict(), "energy_thai") macro_d = _fetch_with_cache( self.cache, "macro_thai", lambda: macro_thai.fetch_macro_thai().to_dict(), "macro_thai") # 2) per-theme surprise (uniform z-score) - surprises = self._theme_surprises(macro_d, auto_d, npl_d, en_d) + surprises = self._theme_surprises(macro_d, auto_d, npl_d, en_d, bnpl_d) # 3) assemble theme reads + thesis (all SET50 themes, so the board and # per-symbol view have a surprise for every theme) @@ -115,9 +117,11 @@ class RealDashboard: ] # macro-proxy reads for the expanded SET50 themes (deterministic) proxy_reads = { - "banks": {"source": "BOT macro (invest)", "frequency": "monthly", + "banks": {"source": "BOT macro (invest) + NPL ภาคการเงิน", + "frequency": "monthly", "invest_yoy": macro_d.get("private_investment_yoy"), - "inflation_yoy": macro_d.get("headline_inflation_yoy")}, + "inflation_yoy": macro_d.get("headline_inflation_yoy"), + "bank_npl_pct": (bnpl_d or {}).get("pct_of_npls")}, "retail": {"source": "BOT macro (consumption)", "frequency": "monthly", "consumption_yoy": macro_d.get("private_consumption_yoy")}, "consumer_staples": {"source": "BOT macro (consumption)", "frequency": "monthly", @@ -144,7 +148,7 @@ class RealDashboard: board = self._build_board(themes, macro_d) # 5) source provenance table - sources = self._build_sources(auto_d, npl_d, en_d, macro_d, tourism) + sources = self._build_sources(auto_d, npl_d, en_d, macro_d, tourism, bnpl_d) return { "themes": themes, @@ -217,7 +221,7 @@ class RealDashboard: f"รับผลตามราคาพลังงานและค่าการกลั่น.") return "" - def _theme_surprises(self, macro_d, auto_d, npl_d, en_d) -> dict: + def _theme_surprises(self, macro_d, auto_d, npl_d, en_d, bnpl_d=None) -> dict: # tourism: from the tourism arrivals YoY or use the bot tourism surprise # auto: z-score of new_car_sales_yoy # energy: z-score of TOP net profit trend (quarterly) @@ -265,8 +269,17 @@ class RealDashboard: return None return round(min(max((float(v) - center) / span, -1.0), 1.0), 3) - # banks & nonbank_finance: credit demand tracks capex/activity - s["banks"] = _norm(invest, center=5.0) + # banks & nonbank_finance: credit demand tracks capex/activity. + # banks additionally blends real BOT financial-sector NPL (quarterly): + # rising NPL is a provisioning drag on bank earnings (bearish). + banks_s = _norm(invest, center=5.0) + if banks_s is not None and bnpl_d: + bnpl = bnpl_d.get("pct_of_npls") + if bnpl is not None: + # deduct up to ~0.5 from the surprise when NPL share is elevated + # (reference: financial-sector NPL % of total NPLs, roughly 1-5%). + banks_s = round(max(banks_s - min(max((float(bnpl) - 0.5) / 3.0, 0.0), 0.5), -1.0), 3) + s["banks"] = banks_s s["nonbank_finance"] = _norm(cons, center=3.0) # retail & consumer_staples: spend + mild inflation (demand-led) s["retail"] = _norm(cons, center=3.0) @@ -288,7 +301,8 @@ class RealDashboard: # combine theme scores + siamchart for the per-symbol board. from . import siamchart_factors fv = siamchart_factors.build_factor_view() or {"factors": []} - siamchart_score = themes_mod.build_siamchart_score(fv) + momentum = themes_mod._load_momentum() + siamchart_score = themes_mod.build_siamchart_score(fv, momentum=momentum) # per-theme symbol exposure: surprise × firm_quality (real selection). # A strong name in a hot theme scores higher than a weak one. theme_scores: dict[str, dict[str, float]] = {} @@ -331,7 +345,7 @@ class RealDashboard: board.sort(key=lambda r: r["combined"], reverse=True) return board - def _build_sources(self, auto_d, npl_d, en_d, macro_d, tourism) -> list: + def _build_sources(self, auto_d, npl_d, en_d, macro_d, tourism, bnpl_d=None) -> list: """Sources derived from the FACTORS registry — adding a factor to factors.py auto-appends its source row here (no hardcoded list).""" import datetime as _dt @@ -340,7 +354,7 @@ class RealDashboard: fetched = { "auto_credit": auto_d, "auto_npl": npl_d, - "energy_thai": en_d, "macro_thai": macro_d, + "energy_thai": en_d, "macro_thai": macro_d, "bank_npl": bnpl_d, } # group FACTORS by fetch module -> one row per distinct source source_by_module: dict = {} @@ -367,6 +381,7 @@ class RealDashboard: _source_label = { "auto_credit": "TradingEconomics", "auto_npl": "BOT FI_NP_003_S2", + "bank_npl": "BOT FI_NP_003_S2", "energy_thai": "Thai Oil investor", "macro_thai": "BOT Thai Economy", "bot_tourism": "BOT Tourism", diff --git a/backend/app/factors.py b/backend/app/factors.py index 15bbd64..a68ae46 100644 --- a/backend/app/factors.py +++ b/backend/app/factors.py @@ -21,6 +21,7 @@ _FETCH_MODULE: dict[str, str] = { "tourism": "bot_tourism", "auto_credit": "auto_credit", "auto_npl": "auto_npl", + "bank_npl": "bank_npl", "energy_thai": "energy_thai", "macro_thai": "macro_thai", } @@ -74,6 +75,15 @@ FACTORS: dict[str, dict[str, Any]] = { "sign": -1, "weight": 1.0, }, + "bank_npl": { + "name_th": "NPL ภาคการเงิน", + "source": "BOT", + "frequency": "quarterly", + "fetch": "bank_npl", + "value_key": "pct_of_npls", + "sign": -1, + "weight": 1.0, + }, "energy_net_margin": { "name_th": "กำไรสุทธิโรงกลั่น", "source": "TOP", diff --git a/backend/app/themes.py b/backend/app/themes.py index ae678b2..d77a1d7 100644 --- a/backend/app/themes.py +++ b/backend/app/themes.py @@ -252,11 +252,47 @@ def build_theme_scores(theme_id: str, signals: list[dict]) -> dict[str, float]: return {s: z.get(i, 0.0) for i, s in enumerate(syms)} -def build_siamchart_score(factors: dict) -> dict[str, float]: +_SIAMCHART_GROWTH_W = 1.5 # R1 (PEAD): EPS-growth dominates value; literature (Bernard-Thomas 1990, + # Livnat-Mendenhall 2006) shows drift follows earnings, not just yield. +_SIAMCHART_YIELD_W = 2.0 # dividend floor for value names +_SIAMCHART_MOMENTUM_W = 0.5 # R2: EM momentum exists but is noisy -> keep it a small trend boost. + + +def _load_momentum(lookback_days: int = 252) -> dict[str, float]: + """12-1 momentum per symbol from the latest price snapshot (deterministic). + + R2 (Jegadeesh-Titman 1993; EM evidence: weaker but positive). Returns + {symbol: (close_today / close_{-12m}) - 1}. Lookback uses trading days so it + aligns to ~12 calendar months. + """ + try: + from . import simulation + series = simulation.load_price_snapshot() + except Exception: + return {} + out: dict[str, float] = {} + for sym, s in series.items(): + bars = s.get("bars", []) + if len(bars) < lookback_days + 1: + continue + try: + today = float(bars[-1]["adjusted_close"]) + base = float(bars[-1 - lookback_days]["adjusted_close"]) + except (KeyError, TypeError, ValueError, IndexError): + continue + if today <= 0 or base <= 0: + continue + out[sym] = round((today / base) - 1.0, 4) + return out + + +def build_siamchart_score(factors: dict, + momentum: Optional[dict[str, float]] = None) -> dict[str, float]: """Derive a normalized fundamental score from the Siamchart factor view. - Uses EPS growth YoY and dividend yield as the "value+quality" signals. - Positive EPS growth and higher yield both push the score up. + Uses EPS growth YoY (weighted above yield per PEAD literature) and dividend + yield. Optional momentum (12-1, from price snapshot) adds a low-weight + trend component; EM momentum is noisier, so it stays small. """ out: dict[str, float] = {} for f in factors.get("factors", []): @@ -266,8 +302,9 @@ def build_siamchart_score(factors: dict) -> dict[str, float]: g = f.get("eps_growth_yoy") d = f.get("dividend_yield") or 0.0 g = float(g) if g is not None else 0.0 - # combine growth and yield; yield adds a floor so dividend names get weight - out[sym] = g + d * 2.0 + m = (momentum or {}).get(sym, 0.0) + # R1+R2: growth dominates (PEAD), yield floors, momentum adds trend. + out[sym] = g * _SIAMCHART_GROWTH_W + d * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * m syms = list(out.keys()) z = _zscore([out[s] for s in syms]) return {s: z.get(i, 0.0) for i, s in enumerate(syms)} @@ -352,6 +389,7 @@ def symbol_breakdown( price_date: str = "", weight_theme: float = 0.6, weight_siamchart: float = 0.4, + momentum: Optional[dict[str, float]] = None, ) -> dict: """Transparent per-symbol scoring breakdown. @@ -396,11 +434,12 @@ def symbol_breakdown( g = fac.get("eps_growth_yoy") d = fac.get("dividend_yield") or 0.0 g = float(g) if g is not None else 0.0 - raw_siamchart = g + d * 2.0 + m = (momentum or {}).get(symbol, 0.0) + raw_siamchart = g * _SIAMCHART_GROWTH_W + d * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * m # z-score against the full universe (same as build_siamchart_score); capture # the population stats so the view can show HOW -2.8 became -0.588. - siamchart_map = build_siamchart_score(factor_view) + siamchart_map = build_siamchart_score(factor_view, momentum=momentum) siamchart_score = siamchart_map.get(symbol, 0.0) # recompute the population of raw scores to expose mean / stdev raw_values = [] @@ -410,7 +449,8 @@ def symbol_breakdown( gg = f.get("eps_growth_yoy") dd = f.get("dividend_yield") or 0.0 gg = float(gg) if gg is not None else 0.0 - raw_values.append(gg + dd * 2.0) + mm = (momentum or {}).get(f.get("symbol"), 0.0) + raw_values.append(gg * _SIAMCHART_GROWTH_W + dd * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * mm) pop_mean = statistics.mean(raw_values) if raw_values else 0.0 pop_stdev = statistics.pstdev(raw_values) if raw_values else 0.0 diff --git a/backend/tests/test_bank_npl.py b/backend/tests/test_bank_npl.py new file mode 100644 index 0000000..211e40b --- /dev/null +++ b/backend/tests/test_bank_npl.py @@ -0,0 +1,43 @@ +"""Tests for the BOT bank-sector (financial) NPL collector.""" + +from __future__ import annotations + +import unittest + +from app.bank_npl import BANK_SECTOR_LABEL, parse_bank_npl_html + +# Minimal server-rendered table fragment in the style of reportID=794. +_NPL_HTML = """ + + + + + + +
ยอดคงค้าง NPL% ต่อ NPLs% ต่อสินเชื่อรวม
Q2/2568
1การผลิต12345.04.100.30
2กิจกรรมทางการเงินและการประกันภัย5598.01.070.11
3รถยนต์20602.03.951.20
+""" + + +class BankNplTest(unittest.TestCase): + def test_parses_financial_sector(self): + snap = parse_bank_npl_html(_NPL_HTML) + self.assertEqual(snap.npl_amount, 5598.0) + self.assertEqual(snap.pct_of_npls, 1.07) + self.assertEqual(snap.pct_of_loans, 0.11) + self.assertEqual(snap.period, "Q2/2568") + + def test_label_matched(self): + self.assertIn("การเงิน", BANK_SECTOR_LABEL) + self.assertIn("ประกันภัย", BANK_SECTOR_LABEL) + + def test_fetches_live(self): + # Live BOT page must still yield a financial-sector NPL (network). + from app.bank_npl import fetch_bank_npl + snap = fetch_bank_npl() + self.assertIsNotNone(snap.pct_of_npls) + if snap.pct_of_npls is not None: + self.assertGreater(snap.pct_of_npls, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index d81a5ef..f13ab8e 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -37,7 +37,8 @@ class DashboardTest(unittest.TestCase): @patch("app.auto_npl.fetch_auto_npl") @patch("app.energy_thai.fetch_energy_thai") @patch("app.macro_thai.fetch_macro_thai") - def test_build_returns_structure(self, macro, energy, npl, auto): + @patch("app.bank_npl.fetch_bank_npl") + def test_build_returns_structure(self, bnpl, macro, energy, npl, auto): class _Factory: def __init__(self, data): self._data = data def to_dict(self): return self._data @@ -49,10 +50,12 @@ class DashboardTest(unittest.TestCase): "new_car_sales_yoy": 20.07, "total_vehicle_sales": 59000}) npl.return_value = _Factory({ "pct_of_npls": 3.95, "npl_amount": 20602, "period": "Q2/2568"}) + bnpl.return_value = _Factory({ + "pct_of_npls": 1.07, "npl_amount": 5598, "period": ""}) cache = _FakeCache({}) dash = RealDashboard([], cache).build() self.assertEqual(len(dash["themes"]), 13) # all SET50 themes - self.assertEqual(len(dash["sources"]), 5) + self.assertEqual(len(dash["sources"]), 6) # auto-derived from FACTORS (bank_npl added) self.assertIn("macro", dash) self.assertIn("board", dash)