[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).
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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"<table[^>]*>(.*?)</table>", 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:
|
||||
|
||||
54
backend/app/bank_npl.py
Normal file
54
backend/app/bank_npl.py
Normal file
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
43
backend/tests/test_bank_npl.py
Normal file
43
backend/tests/test_bank_npl.py
Normal file
@@ -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 = """
|
||||
<table>
|
||||
<tr><th>ยอดคงค้าง NPL</th><th>% ต่อ NPLs</th><th>% ต่อสินเชื่อรวม</th></tr>
|
||||
<tr><td></td><td>Q2/2568</td><td></td></tr>
|
||||
<tr><td>1</td><td>การผลิต</td><td>12345.0</td><td>4.10</td><td>0.30</td></tr>
|
||||
<tr><td>2</td><td>กิจกรรมทางการเงินและการประกันภัย</td><td>5598.0</td><td>1.07</td><td>0.11</td></tr>
|
||||
<tr><td>3</td><td>รถยนต์</td><td>20602.0</td><td>3.95</td><td>1.20</td></tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user