[verified] Declarative factor engine + per-symbol stock selection (full-app consistency)
- factors.py: FACTORS registry (10 declarative entries: source/fetch/frequency/sign/weight) + normalize/z-score helpers. Add a source = one dict entry, no scoring-function edit. - themes.THEMES: 13 themes reference FACTORS with per-theme weights (flexible), replacing hardcoded _theme_surprises/_theme_narrative. - themes.quality_within_theme(): per-symbol quality vs theme cohort (ROE/EPS) -> real stock picking. dashboard board now surprise×quality (BBL 0.5 vs KTB 1.5 in banks). - board rows carry per-symbol themes[]; /api/v1/themes delegates to RealDashboard.build() -> 13-theme consistency with /api/v1/dashboard (removed 115 lines dead dup logic). - frontend: deleted THEME_BY_SYMBOL/themeLabelById hardcode; theme column + modal labels+quality all from API. Modal shows surprise×quality=theme_score. - Tests: 202 OK (quality selection, breakdown quality, themes/dashboard consistency). - Verified: BBL modal 1.00σ×0.5=0.50σ; KTB 1.5 vs BBL 0.5, PTT 2 themes; 49/49 rows theme from API.
This commit is contained in:
@@ -593,150 +593,28 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
|
|||||||
|
|
||||||
@app.get("/api/v1/themes")
|
@app.get("/api/v1/themes")
|
||||||
def themes():
|
def themes():
|
||||||
"""Multi-theme combined board.
|
"""Multi-theme combined board (delegates to the canonical dashboard).
|
||||||
|
|
||||||
Aggregates the 3 Thai alternative-factor themes (tourism, auto_credit,
|
Single source: RealDashboard.build() so /api/v1/themes returns the SAME
|
||||||
refining_energy) and the Siamchart fundamental provider into a per-symbol
|
13-theme set, labels, surprises, and per-symbol combined board as
|
||||||
combined score (60% theme / 40% Siamchart), with the theme list and each
|
/api/v1/dashboard. Removes the old 3-theme duplicated logic.
|
||||||
theme's factor read. Frequency of each theme is reported so different-
|
|
||||||
cadence factors are not treated as same-timestamp.
|
|
||||||
"""
|
"""
|
||||||
from app import auto_credit, daily_cache, energy_thai
|
from app.dashboard import RealDashboard, DashboardError
|
||||||
from app import siamchart_factors, themes as themes_mod
|
from app import daily_cache
|
||||||
|
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
|
||||||
cache = app.extensions.setdefault(
|
current = app.extensions.get("tourism_result") or {}
|
||||||
"daily_cache",
|
|
||||||
daily_cache.DailyCache(),
|
|
||||||
)
|
|
||||||
|
|
||||||
current = app.extensions["tourism_result"]
|
|
||||||
tourism_signals = current.get("signals", [])
|
|
||||||
|
|
||||||
# ---- per-theme macro factor reads (cached daily) ----
|
|
||||||
theme_reads = {
|
|
||||||
"tourism": {
|
|
||||||
"source": current.get("source"),
|
|
||||||
"as_of": current.get("as_of"),
|
|
||||||
"surprise": current.get("theme_surprise"),
|
|
||||||
"frequency": "monthly",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# auto_credit: Trading Economics Thailand car sales
|
|
||||||
try:
|
try:
|
||||||
auto = cache.fetch_or_stale(
|
dash = RealDashboard(current.get("signals", []), cache).build()
|
||||||
f"auto_credit/{current.get('as_of','')}",
|
except DashboardError as exc:
|
||||||
lambda: auto_credit.fetch_auto_credit().to_dict(),
|
return jsonify({"error": str(exc)}), 503
|
||||||
)
|
return jsonify({
|
||||||
auto_d = auto["data"] if isinstance(auto, dict) and "data" in auto else auto
|
"themes": dash["themes"],
|
||||||
theme_reads["auto_credit"] = {
|
"as_of": dash.get("as_of", ""),
|
||||||
"source": "tradingeconomics",
|
"combined_count": len(dash["board"]),
|
||||||
"as_of": auto_d.get("as_of", ""),
|
"board": dash["board"],
|
||||||
"total_vehicle_sales": auto_d.get("total_vehicle_sales"),
|
"macro": dash.get("macro", {}),
|
||||||
"new_car_sales_yoy": auto_d.get("new_car_sales_yoy"),
|
})
|
||||||
"frequency": "monthly",
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
theme_reads["auto_credit"] = {"source": "tradingeconomics", "error": str(exc), "frequency": "monthly"}
|
|
||||||
|
|
||||||
# auto NPL (credit-quality) from BOT — deepens auto theme
|
|
||||||
try:
|
|
||||||
from app import auto_npl
|
|
||||||
npl = cache.fetch_or_stale(
|
|
||||||
"auto_npl", lambda: auto_npl.fetch_auto_npl().to_dict())
|
|
||||||
npl_d = npl["data"] if isinstance(npl, dict) and "data" in npl else npl
|
|
||||||
theme_reads["auto_credit"]["auto_npl_pct"] = npl_d.get("pct_of_npls")
|
|
||||||
theme_reads["auto_credit"]["auto_npl_amount"] = npl_d.get("npl_amount")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# refining_energy: Thai Oil (TOP) quarterly financials
|
|
||||||
try:
|
|
||||||
en = cache.fetch_or_stale(
|
|
||||||
"energy_thai",
|
|
||||||
lambda: energy_thai.fetch_energy_thai().to_dict(),
|
|
||||||
)
|
|
||||||
en_d = en["data"] if isinstance(en, dict) and "data" in en else en
|
|
||||||
qmap = en_d.get("quarterly", {})
|
|
||||||
periods = list(qmap.keys())
|
|
||||||
if periods:
|
|
||||||
latest = qmap[periods[0]]
|
|
||||||
else:
|
|
||||||
latest = {}
|
|
||||||
theme_reads["refining_energy"] = {
|
|
||||||
"source": "thaioil",
|
|
||||||
"as_of": periods[0] if periods else "",
|
|
||||||
"net_profit": latest.get("net_profit"),
|
|
||||||
"ebitda": latest.get("ebitda"),
|
|
||||||
"sales": latest.get("sales"),
|
|
||||||
"frequency": "quarterly",
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
theme_reads["refining_energy"] = {"source": "thaioil", "error": str(exc), "frequency": "quarterly"}
|
|
||||||
|
|
||||||
# ---- per-symbol theme scores ----
|
|
||||||
# tourism: use the real per-symbol tourism signals.
|
|
||||||
tourism_scores = themes_mod.build_theme_scores("tourism", tourism_signals)
|
|
||||||
theme_scores = {"tourism": tourism_scores}
|
|
||||||
|
|
||||||
# auto_credit / energy: score the theme's exposed symbols from the macro
|
|
||||||
# factor direction (positive YoY / positive net profit = bullish theme).
|
|
||||||
auto_read = theme_reads.get("auto_credit", {})
|
|
||||||
auto_yoy = auto_read.get("new_car_sales_yoy")
|
|
||||||
auto_sign = (1 if (auto_yoy or 0) > 0 else -1) if auto_yoy is not None else 0
|
|
||||||
theme_scores["auto_credit"] = {
|
|
||||||
sym: auto_sign for sym in themes_mod.THEME_SYMBOLS["auto_credit"]
|
|
||||||
}
|
|
||||||
|
|
||||||
en_read = theme_reads.get("refining_energy", {})
|
|
||||||
en_np = en_read.get("net_profit")
|
|
||||||
en_sign = (1 if (en_np or 0) > 0 else -1) if en_np is not None else 0
|
|
||||||
theme_scores["refining_energy"] = {
|
|
||||||
sym: en_sign for sym in themes_mod.THEME_SYMBOLS["refining_energy"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Siamchart fundamental score (40%) ----
|
|
||||||
factor_view = siamchart_factors.build_factor_view()
|
|
||||||
siamchart_score = themes_mod.build_siamchart_score(factor_view)
|
|
||||||
|
|
||||||
# ---- combine 60/40 ----
|
|
||||||
combined = themes_mod.combine_score(
|
|
||||||
[theme_scores["tourism"], theme_scores["auto_credit"], theme_scores["refining_energy"]],
|
|
||||||
siamchart_score,
|
|
||||||
weight_theme=0.6, weight_siamchart=0.4,
|
|
||||||
)
|
|
||||||
|
|
||||||
board = [
|
|
||||||
{
|
|
||||||
"symbol": sym,
|
|
||||||
"theme_score": m["theme_score"],
|
|
||||||
"siamchart_score": m["siamchart_score"],
|
|
||||||
"combined_score": m["combined"],
|
|
||||||
**({"themes": m["themes"]} if m["themes"] else {}),
|
|
||||||
}
|
|
||||||
for sym, m in combined.items()
|
|
||||||
]
|
|
||||||
board.sort(key=lambda b: -b["combined_score"])
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"themes": [
|
|
||||||
{
|
|
||||||
"id": t.id,
|
|
||||||
"label_en": t.label_en,
|
|
||||||
"label_th": t.label_th,
|
|
||||||
"frequency": t.frequency,
|
|
||||||
"source": t.source,
|
|
||||||
"enabled": t.enabled,
|
|
||||||
"read": theme_reads.get(t.id),
|
|
||||||
}
|
|
||||||
for t in themes_mod.list_themes()
|
|
||||||
],
|
|
||||||
"as_of": current.get("as_of"),
|
|
||||||
"combined_count": len(board),
|
|
||||||
"board": board,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/symbols/<symbol>")
|
@app.get("/api/v1/symbols/<symbol>")
|
||||||
|
|||||||
@@ -289,8 +289,10 @@ class RealDashboard:
|
|||||||
from . import siamchart_factors
|
from . import siamchart_factors
|
||||||
fv = siamchart_factors.build_factor_view() or {"factors": []}
|
fv = siamchart_factors.build_factor_view() or {"factors": []}
|
||||||
siamchart_score = themes_mod.build_siamchart_score(fv)
|
siamchart_score = themes_mod.build_siamchart_score(fv)
|
||||||
# per-theme symbol exposure: surprise applies to the theme's symbols
|
# 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]] = {}
|
theme_scores: dict[str, dict[str, float]] = {}
|
||||||
|
fv_all = fv
|
||||||
for t in themes:
|
for t in themes:
|
||||||
tid = t["id"]
|
tid = t["id"]
|
||||||
surprise = t.get("surprise")
|
surprise = t.get("surprise")
|
||||||
@@ -298,7 +300,13 @@ class RealDashboard:
|
|||||||
theme_scores[tid] = {}
|
theme_scores[tid] = {}
|
||||||
continue
|
continue
|
||||||
symbols = themes_mod.THEME_SYMBOLS.get(tid, set())
|
symbols = themes_mod.THEME_SYMBOLS.get(tid, set())
|
||||||
theme_scores[tid] = {s: float(surprise) for s in symbols if s in siamchart_score}
|
q = {}
|
||||||
|
for s in symbols:
|
||||||
|
if s not in siamchart_score:
|
||||||
|
continue
|
||||||
|
quality = themes_mod.quality_within_theme(s, tid, fv_all)
|
||||||
|
q[s] = float(surprise) * quality
|
||||||
|
theme_scores[tid] = q
|
||||||
combined = themes_mod.combine_score(
|
combined = themes_mod.combine_score(
|
||||||
list(theme_scores.values()), siamchart_score,
|
list(theme_scores.values()), siamchart_score,
|
||||||
)
|
)
|
||||||
@@ -307,11 +315,16 @@ class RealDashboard:
|
|||||||
board = []
|
board = []
|
||||||
for sym, meta in combined.items():
|
for sym, meta in combined.items():
|
||||||
f = fmap.get(sym, {})
|
f = fmap.get(sym, {})
|
||||||
|
# which themes this symbol belongs to (from THEME_SYMBOLS) — the
|
||||||
|
# frontend derives the theme column from this, never a local map.
|
||||||
|
sym_themes = [tid for tid, syms in themes_mod.THEME_SYMBOLS.items()
|
||||||
|
if sym in syms]
|
||||||
board.append({
|
board.append({
|
||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
"combined": round(meta.get("combined", 0.0), 3),
|
"combined": round(meta.get("combined", 0.0), 3),
|
||||||
"theme_score": round(meta.get("theme_score", 0.0), 3),
|
"theme_score": round(meta.get("theme_score", 0.0), 3),
|
||||||
"siamchart_score": round(meta.get("siamchart_score", 0.0), 3),
|
"siamchart_score": round(meta.get("siamchart_score", 0.0), 3),
|
||||||
|
"themes": sym_themes,
|
||||||
"dividend_yield": f.get("dividend_yield"),
|
"dividend_yield": f.get("dividend_yield"),
|
||||||
"is_dividend": f.get("is_dividend"),
|
"is_dividend": f.get("is_dividend"),
|
||||||
})
|
})
|
||||||
|
|||||||
179
backend/app/factors.py
Normal file
179
backend/app/factors.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""Declarative FACTORS registry — the single source of truth for every factor.
|
||||||
|
|
||||||
|
Each factor is a plain-data unit describing:
|
||||||
|
- where the data comes from (source + fetch module + which key holds the value)
|
||||||
|
- its data frequency (for frequency alignment, never naive mixing)
|
||||||
|
- its SIGN (+1 = higher value is bullish for a theme, -1 = bearish)
|
||||||
|
- a default weight (themes override per-theme)
|
||||||
|
|
||||||
|
ADDING A NEW DATA SOURCE/FACTOR = append one entry here + (optionally) a theme
|
||||||
|
factor line. It requires NO change to any scoring function. This is what makes the
|
||||||
|
analysis engine data-driven and auditable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import statistics
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
# fetch key -> module that exposes a fetch_<...>() callable returning .to_dict()
|
||||||
|
_FETCH_MODULE: dict[str, str] = {
|
||||||
|
"tourism": "bot_tourism",
|
||||||
|
"auto_credit": "auto_credit",
|
||||||
|
"auto_npl": "auto_npl",
|
||||||
|
"energy_thai": "energy_thai",
|
||||||
|
"macro_thai": "macro_thai",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Factor -> value key. Sign: +1 higher-is-bullish, -1 lower-is-bullish.
|
||||||
|
# weight: default global weight; themes may override.
|
||||||
|
FACTORS: dict[str, dict[str, Any]] = {
|
||||||
|
# ---- real Thai collectors ----
|
||||||
|
"tourism_arrivals_ytd": {
|
||||||
|
"name_th": "นักท่องเที่ยวสะสมปี",
|
||||||
|
"source": "BOT",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "macro_thai",
|
||||||
|
"value_key": "tourists_ytd_mn",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 1.0,
|
||||||
|
},
|
||||||
|
"auto_sales_yoy": {
|
||||||
|
"name_th": "ยอดขายรถยนต์ (YoY)",
|
||||||
|
"source": "TradingEconomics",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "auto_credit",
|
||||||
|
"value_key": "new_car_sales_yoy",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 1.0,
|
||||||
|
},
|
||||||
|
"auto_production": {
|
||||||
|
"name_th": "การผลิตรถยนต์",
|
||||||
|
"source": "TradingEconomics",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "auto_credit",
|
||||||
|
"value_key": "vehicle_production",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 0.4,
|
||||||
|
},
|
||||||
|
"auto_exports": {
|
||||||
|
"name_th": "ส่งออกรถยนต์",
|
||||||
|
"source": "TradingEconomics",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "auto_credit",
|
||||||
|
"value_key": "auto_exports",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 0.3,
|
||||||
|
},
|
||||||
|
"auto_npl": {
|
||||||
|
"name_th": "NPL รถยนต์",
|
||||||
|
"source": "BOT",
|
||||||
|
"frequency": "quarterly",
|
||||||
|
"fetch": "auto_npl",
|
||||||
|
"value_key": "pct_of_npls",
|
||||||
|
"sign": -1,
|
||||||
|
"weight": 1.0,
|
||||||
|
},
|
||||||
|
"energy_net_margin": {
|
||||||
|
"name_th": "กำไรสุทธิโรงกลั่น",
|
||||||
|
"source": "TOP",
|
||||||
|
"frequency": "quarterly",
|
||||||
|
"fetch": "energy_thai",
|
||||||
|
"value_key": "net_margin_quarter",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 1.0,
|
||||||
|
},
|
||||||
|
# ---- macro backdrop (proxy for expanded SET50 themes) ----
|
||||||
|
"macro_consumption": {
|
||||||
|
"name_th": "การบริโภคภาคเอกชน (YoY)",
|
||||||
|
"source": "BOT",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "macro_thai",
|
||||||
|
"value_key": "private_consumption_yoy",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 1.0,
|
||||||
|
},
|
||||||
|
"macro_investment": {
|
||||||
|
"name_th": "การลงทุนภาคเอกชน (YoY)",
|
||||||
|
"source": "BOT",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "macro_thai",
|
||||||
|
"value_key": "private_investment_yoy",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 1.0,
|
||||||
|
},
|
||||||
|
"macro_mfg": {
|
||||||
|
"name_th": "ผลผลิตภาคอุตสาหกรรม (MPI)",
|
||||||
|
"source": "BOT",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "macro_thai",
|
||||||
|
"value_key": "manufacturing_yoy",
|
||||||
|
"sign": 1,
|
||||||
|
"weight": 1.0,
|
||||||
|
},
|
||||||
|
"macro_inflation": {
|
||||||
|
"name_th": "เงินเฟ้อ",
|
||||||
|
"source": "BOT",
|
||||||
|
"frequency": "monthly",
|
||||||
|
"fetch": "macro_thai",
|
||||||
|
"value_key": "headline_inflation_yoy",
|
||||||
|
"sign": -1,
|
||||||
|
"weight": 0.5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FactorError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def factor_value(fact: dict, fetched: dict) -> Optional[float]:
|
||||||
|
"""Pull the numeric value out of a fetched collector dict for a factor."""
|
||||||
|
key = fact.get("value_key")
|
||||||
|
if fetched is None:
|
||||||
|
return None
|
||||||
|
if fact.get("fetch") == "energy_thai":
|
||||||
|
# derive a single metric from the quarterly dict
|
||||||
|
q = fetched.get("quarterly") or {}
|
||||||
|
if isinstance(q, dict):
|
||||||
|
row = next((v for v in q.values() if isinstance(v, dict)), {})
|
||||||
|
np_ = row.get("net_profit")
|
||||||
|
rev = row.get("sales")
|
||||||
|
if np_ is not None and rev:
|
||||||
|
try:
|
||||||
|
return float(np_) / float(rev) * 100.0 # net margin %
|
||||||
|
except (TypeError, ValueError, ZeroDivisionError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
if key is None:
|
||||||
|
return None
|
||||||
|
val = fetched.get(key)
|
||||||
|
try:
|
||||||
|
return float(val) if val is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(value: Optional[float], sign: int = 1,
|
||||||
|
center: float = 0.0, span: float = 10.0) -> Optional[float]:
|
||||||
|
"""Deterministic bounded normalization: sign-aware, clamped to [-1, +1].
|
||||||
|
|
||||||
|
value == center -> 0. positive beyond center (for sign=+1) -> positive.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if span <= 0:
|
||||||
|
span = 1.0
|
||||||
|
num = (float(value) - center) / span * float(sign)
|
||||||
|
return round(min(max(num, -1.0), 1.0), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def z_score(value: float, population: list[float]) -> float:
|
||||||
|
"""Population z-score with tiny-stdev guard (deterministic)."""
|
||||||
|
if not population:
|
||||||
|
return 0.0
|
||||||
|
mean = statistics.fmean(population)
|
||||||
|
stdev = statistics.pstdev(population)
|
||||||
|
if stdev < 1e-9:
|
||||||
|
return 0.0
|
||||||
|
return round((float(value) - mean) / stdev * 10.0, 4) # scale to decile-ish
|
||||||
@@ -92,6 +92,106 @@ THEME_LABELS_TH: dict[str, str] = {
|
|||||||
"exploration": "สำรวจ/ผลิตพลังงาน",
|
"exploration": "สำรวจ/ผลิตพลังงาน",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Declarative THEMES definition — which FACTORS drive each theme, with per-theme
|
||||||
|
# weight (flexible: how much that factor plausibly impacts stock valuation in
|
||||||
|
# this theme). A theme's surprise = weighted blend of its factors. ADDING a
|
||||||
|
# factor to a theme = edit this dict; no scoring-function change.
|
||||||
|
THEMES: dict[str, dict] = {
|
||||||
|
"tourism": {
|
||||||
|
"label_th": "ท่องเที่ยว",
|
||||||
|
"factors": [
|
||||||
|
{"key": "tourism_arrivals_ytd", "weight": 1.0},
|
||||||
|
{"key": "macro_consumption", "weight": 0.4},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"auto_credit": {
|
||||||
|
"label_th": "รถยนต์/สินเชื่อ",
|
||||||
|
"factors": [
|
||||||
|
{"key": "auto_sales_yoy", "weight": 1.0},
|
||||||
|
{"key": "auto_production", "weight": 0.4},
|
||||||
|
{"key": "auto_exports", "weight": 0.3},
|
||||||
|
{"key": "auto_npl", "weight": -0.6},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"refining_energy": {
|
||||||
|
"label_th": "พลังงาน/โรงกลั่น",
|
||||||
|
"factors": [
|
||||||
|
{"key": "energy_net_margin", "weight": 1.0},
|
||||||
|
{"key": "macro_mfg", "weight": 0.3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"banks": {
|
||||||
|
"label_th": "ธนาคาร",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_investment", "weight": 1.0},
|
||||||
|
{"key": "macro_inflation", "weight": -0.4},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"retail": {
|
||||||
|
"label_th": "ค้าปลีก",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_consumption", "weight": 1.0},
|
||||||
|
{"key": "macro_inflation", "weight": -0.3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"consumer_staples": {
|
||||||
|
"label_th": "อาหาร/อุปโภค",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_consumption", "weight": 1.0},
|
||||||
|
{"key": "macro_inflation", "weight": -0.2},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"telecom_it": {
|
||||||
|
"label_th": "สื่อสาร/ไอที",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_consumption", "weight": 0.8},
|
||||||
|
{"key": "macro_investment", "weight": 0.4},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"property": {
|
||||||
|
"label_th": "อสังหาริมทรัพย์",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_investment", "weight": 1.0},
|
||||||
|
{"key": "macro_consumption", "weight": 0.5},
|
||||||
|
{"key": "macro_inflation", "weight": -0.3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"healthcare": {
|
||||||
|
"label_th": "โรงพยาบาล",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_consumption", "weight": 0.5},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"petrochem_materials": {
|
||||||
|
"label_th": "ปิโตรเคมี/วัสดุ",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_mfg", "weight": 1.0},
|
||||||
|
{"key": "macro_inflation", "weight": -0.3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"utilities": {
|
||||||
|
"label_th": "สาธารณูปโภค",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_mfg", "weight": 1.0},
|
||||||
|
{"key": "energy_net_margin", "weight": 0.3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"nonbank_finance": {
|
||||||
|
"label_th": "การเงินนอกธนาคาร",
|
||||||
|
"factors": [
|
||||||
|
{"key": "macro_consumption", "weight": 1.0},
|
||||||
|
{"key": "auto_npl", "weight": -0.3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"exploration": {
|
||||||
|
"label_th": "สำรวจ/ผลิตพลังงาน",
|
||||||
|
"factors": [
|
||||||
|
{"key": "energy_net_margin", "weight": 1.0},
|
||||||
|
{"key": "macro_inflation", "weight": -0.2},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Theme:
|
class Theme:
|
||||||
@@ -204,6 +304,45 @@ def combine_score(theme_scores: list[dict[str, float]], siamchart_score: dict[st
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def quality_within_theme(symbol: str, theme_id: str, factor_view: dict) -> float:
|
||||||
|
"""Relative firm quality of a symbol within its theme cohort (stock picking).
|
||||||
|
|
||||||
|
Deterministic, bounded to [0.5, 1.5] around 1.0:
|
||||||
|
- compute the theme cohort = all THEME_SYMBOLS[theme_id] that have a factor row
|
||||||
|
- quality = 1.0 + 0.5 * z(ROE, cohort) (above cohort = >1, below = <1)
|
||||||
|
- damped by 0.3 * z(EPS growth, cohort)
|
||||||
|
Stronger names in a hot theme rank higher -> the theme actually "picks" stocks
|
||||||
|
instead of giving every member the same flat surprise.
|
||||||
|
"""
|
||||||
|
cohort = [s for s in THEME_SYMBOLS.get(theme_id, set()) if s != symbol]
|
||||||
|
fmap = {f.get("symbol"): f for f in factor_view.get("factors", [])}
|
||||||
|
fac = fmap.get(symbol)
|
||||||
|
if not fac:
|
||||||
|
return 1.0
|
||||||
|
roe = fac.get("roe")
|
||||||
|
epsg = fac.get("eps_growth_yoy")
|
||||||
|
|
||||||
|
def _cohort_z(val, getter):
|
||||||
|
vals = []
|
||||||
|
for c in cohort:
|
||||||
|
cf = fmap.get(c)
|
||||||
|
v = getter(cf)
|
||||||
|
if v is not None:
|
||||||
|
vals.append(float(v))
|
||||||
|
if not vals or val is None:
|
||||||
|
return 0.0
|
||||||
|
mean = statistics.fmean(vals)
|
||||||
|
stdev = statistics.pstdev(vals)
|
||||||
|
if stdev < 1e-9:
|
||||||
|
return 0.0
|
||||||
|
return (float(val) - mean) / stdev
|
||||||
|
|
||||||
|
z_roe = _cohort_z(roe, lambda f: f.get("roe") if f else None)
|
||||||
|
z_epsg = _cohort_z(epsg, lambda f: f.get("eps_growth_yoy") if f else None)
|
||||||
|
quality = 1.0 + 0.5 * max(min(z_roe, 2.0), -2.0) + 0.3 * max(min(z_epsg, 2.0), -2.0)
|
||||||
|
return round(max(min(quality, 1.5), 0.5), 3)
|
||||||
|
|
||||||
|
|
||||||
def symbol_breakdown(
|
def symbol_breakdown(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
*,
|
*,
|
||||||
@@ -231,18 +370,24 @@ def symbol_breakdown(
|
|||||||
theme_values = []
|
theme_values = []
|
||||||
for tid in member_themes:
|
for tid in member_themes:
|
||||||
s = theme_surprises.get(tid)
|
s = theme_surprises.get(tid)
|
||||||
|
q = quality_within_theme(symbol, tid, factor_view)
|
||||||
if s is not None:
|
if s is not None:
|
||||||
theme_values.append(float(s))
|
ts = round(float(s) * q, 3)
|
||||||
|
theme_values.append(ts)
|
||||||
theme_lines.append({
|
theme_lines.append({
|
||||||
"theme": tid,
|
"theme": tid,
|
||||||
"label_th": THEME_LABELS_TH.get(tid, tid),
|
"label_th": THEME_LABELS_TH.get(tid, tid),
|
||||||
"surprise": round(float(s), 3),
|
"surprise": round(float(s), 3),
|
||||||
|
"quality": q,
|
||||||
|
"theme_score": ts,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
theme_lines.append({
|
theme_lines.append({
|
||||||
"theme": tid,
|
"theme": tid,
|
||||||
"label_th": THEME_LABELS_TH.get(tid, tid),
|
"label_th": THEME_LABELS_TH.get(tid, tid),
|
||||||
"surprise": None,
|
"surprise": None,
|
||||||
|
"quality": q,
|
||||||
|
"theme_score": None,
|
||||||
})
|
})
|
||||||
theme_score = (sum(theme_values) / len(theme_values)) if theme_values else 0.0
|
theme_score = (sum(theme_values) / len(theme_values)) if theme_values else 0.0
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,31 @@ class ApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
payload = resp.get_json()
|
payload = resp.get_json()
|
||||||
theme_ids = [t["id"] for t in payload["themes"]]
|
theme_ids = [t["id"] for t in payload["themes"]]
|
||||||
self.assertEqual(set(theme_ids), {"tourism", "auto_credit", "refining_energy"})
|
self.assertEqual(len(theme_ids), 13) # full SET50 theme set
|
||||||
|
self.assertIn("tourism", theme_ids)
|
||||||
|
self.assertIn("banks", theme_ids)
|
||||||
self.assertEqual(payload["themes"][0]["frequency"], "monthly")
|
self.assertEqual(payload["themes"][0]["frequency"], "monthly")
|
||||||
self.assertGreaterEqual(payload["combined_count"], 1)
|
self.assertGreaterEqual(payload["combined_count"], 1)
|
||||||
self.assertIsInstance(payload["board"], list)
|
self.assertIsInstance(payload["board"], list)
|
||||||
|
# board rows carry per-symbol themes (frontend has no hardcoded map)
|
||||||
|
if payload["board"]:
|
||||||
|
self.assertIn("themes", payload["board"][0])
|
||||||
|
|
||||||
|
def test_themes_consistency_with_dashboard(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
class _FakeAuto:
|
||||||
|
def to_dict(self):
|
||||||
|
return {"source": "tradingeconomics", "total_vehicle_sales": 59000,
|
||||||
|
"new_car_sales_yoy": 15.0}
|
||||||
|
class _FakeEnergy:
|
||||||
|
def to_dict(self):
|
||||||
|
return {"source": "thaioil", "quarterly": {
|
||||||
|
"Q2/2026": {"net_profit": 8000.0, "ebitda": 9000.0, "sales": 120000.0}}}
|
||||||
|
with patch("app.auto_credit.fetch_auto_credit", return_value=_FakeAuto()), \
|
||||||
|
patch("app.energy_thai.fetch_energy_thai", return_value=_FakeEnergy()):
|
||||||
|
th = self.client.get("/api/v1/themes").get_json()
|
||||||
|
db = self.client.get("/api/v1/dashboard").get_json()
|
||||||
|
self.assertEqual({t["id"] for t in th["themes"]}, {t["id"] for t in db["themes"]})
|
||||||
|
|
||||||
def test_simulation_allocates_capital(self):
|
def test_simulation_allocates_capital(self):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|||||||
@@ -105,3 +105,30 @@ class SymbolBreakdownTest(unittest.TestCase):
|
|||||||
self.assertTrue(set(d["themes"]) >= {"refining_energy", "petrochem_materials", "utilities"})
|
self.assertTrue(set(d["themes"]) >= {"refining_energy", "petrochem_materials", "utilities"})
|
||||||
# no surprises set -> every contribution has surprise=None and theme_score 0
|
# no surprises set -> every contribution has surprise=None and theme_score 0
|
||||||
self.assertTrue(all(c["surprise"] is None for c in d["theme_contributions"]))
|
self.assertTrue(all(c["surprise"] is None for c in d["theme_contributions"]))
|
||||||
|
|
||||||
|
|
||||||
|
class QualitySelectionTest(unittest.TestCase):
|
||||||
|
def test_quality_differentiates_strong_vs_weak_in_theme(self):
|
||||||
|
from app import themes
|
||||||
|
fv = {"factors": [
|
||||||
|
{"symbol": "BBL", "roe": 12.0, "eps_growth_yoy": 8.0},
|
||||||
|
{"symbol": "KBANK", "roe": 10.0, "eps_growth_yoy": 5.0},
|
||||||
|
{"symbol": "KTB", "roe": 9.0, "eps_growth_yoy": 3.0},
|
||||||
|
{"symbol": "SCB", "roe": 8.0, "eps_growth_yoy": 2.0},
|
||||||
|
{"symbol": "TTB", "roe": 5.0, "eps_growth_yoy": -2.0},
|
||||||
|
]}
|
||||||
|
q_bbl = themes.quality_within_theme("BBL", "banks", fv)
|
||||||
|
q_ttb = themes.quality_within_theme("TTB", "banks", fv)
|
||||||
|
self.assertGreater(q_bbl, q_ttb) # strong bank outscores weak one
|
||||||
|
|
||||||
|
def test_breakdown_has_quality_and_theme_score_per_theme(self):
|
||||||
|
from app import themes
|
||||||
|
fv = {"factors": [
|
||||||
|
{"symbol": "BBL", "roe": 12.0, "eps_growth_yoy": 8.0},
|
||||||
|
{"symbol": "KTB", "roe": 9.0, "eps_growth_yoy": 3.0},
|
||||||
|
]}
|
||||||
|
d = themes.symbol_breakdown("BBL", factor_view=fv, theme_surprises={"banks": 1.0})
|
||||||
|
contrib = next(c for c in d["theme_contributions"] if c["theme"] == "banks")
|
||||||
|
self.assertIn("quality", contrib)
|
||||||
|
self.assertIn("theme_score", contrib)
|
||||||
|
self.assertAlmostEqual(contrib["theme_score"], contrib["surprise"] * contrib["quality"], places=3)
|
||||||
|
|||||||
@@ -58,31 +58,18 @@ const signalSummary = computed(() => {
|
|||||||
return { long: s?.long ?? 0, short: s?.short ?? 0, neutral: s?.neutral ?? 0, total: s?.total ?? 0 }
|
return { long: s?.long ?? 0, short: s?.short ?? 0, neutral: s?.neutral ?? 0, total: s?.total ?? 0 }
|
||||||
})
|
})
|
||||||
const freqLabel = (f) => ({ monthly: 'รายเดือน', quarterly: 'รายไตรมาส', annual: 'รายปี', daily: 'รายวัน' })[f] || f
|
const freqLabel = (f) => ({ monthly: 'รายเดือน', quarterly: 'รายไตรมาส', annual: 'รายปี', daily: 'รายวัน' })[f] || f
|
||||||
// theme id -> Thai label (for the theme column). Mirrors backend THEME_LABELS_TH.
|
// theme labels come from the API (dashData.themes[].label_th) — no hardcode.
|
||||||
const themeLabelById = {
|
const themeLabelById = computed(() => {
|
||||||
tourism: 'ท่องเที่ยว', auto_credit: 'รถยนต์/สินเชื่อ', refining_energy: 'พลังงาน/โรงกลั่น',
|
const m = {}
|
||||||
banks: 'ธนาคาร', retail: 'ค้าปลีก', telecom_it: 'สื่อสาร/ไอที', property: 'อสังหาริมทรัพย์',
|
for (const t of dashboardThemes.value) m[t.id] = t.label_th
|
||||||
healthcare: 'โรงพยาบาล', petrochem_materials: 'ปิโตรเคมี/วัสดุ', consumer_staples: 'อาหาร/อุปโภค',
|
return m
|
||||||
utilities: 'สาธารณูปโภค', nonbank_finance: 'การเงินนอกธนาคาร', exploration: 'สำรวจ/ผลิตพลังงาน',
|
})
|
||||||
}
|
// which themes a symbol belongs to — from the dashboard board (API), so editing
|
||||||
// which themes a symbol belongs to (mirrors backend THEME_SYMBOLS for full SET50)
|
// themes.py propagates to the UI with zero frontend change.
|
||||||
const THEME_BY_SYMBOL = {
|
|
||||||
'AOT':'tourism','CENTEL':'tourism','MINT':'tourism','AWC':'tourism','CPN':'tourism','CRC':'tourism','BEM':'tourism','BTS':'tourism',
|
|
||||||
'MTC':'auto_credit','SAWAD':'auto_credit','TISCO':'auto_credit',
|
|
||||||
'BANPU':'utilities','GPSC':'utilities','PTT':'utilities','PTTGC':'refining_energy','TOP':'refining_energy','IVL':'petrochem_materials',
|
|
||||||
'BBL':'banks','KBANK':'banks','KTB':'banks','SCB':'banks','TTB':'banks',
|
|
||||||
'COM7':'retail','CPALL':'retail','GLOBAL':'retail','HMPRO':'retail','OR':'retail','OSP':'retail',
|
|
||||||
'ADVANC':'telecom_it','TRUE':'telecom_it','DELTA':'telecom_it',
|
|
||||||
'LH':'property','BDMS':'healthcare','BH':'healthcare','SCC':'petrochem_materials','SCGP':'petrochem_materials',
|
|
||||||
'CPF':'consumer_staples','TU':'consumer_staples','CBG':'consumer_staples',
|
|
||||||
'BGRIM':'utilities','EGCO':'utilities','RATCH':'utilities','GULF':'utilities','EA':'utilities',
|
|
||||||
'JMT':'nonbank_finance','JMART':'nonbank_finance','KTC':'nonbank_finance','TIDLOR':'nonbank_finance',
|
|
||||||
'PTTEP':'exploration',
|
|
||||||
}
|
|
||||||
function symbolThemes(symbol) {
|
function symbolThemes(symbol) {
|
||||||
const id = THEME_BY_SYMBOL[symbol]
|
const row = boardBySymbol.value[symbol]
|
||||||
if (!id) return []
|
const ids = row?.themes ?? []
|
||||||
return [themeLabelById[id] || id]
|
return ids.map((id) => themeLabelById.value[id] || id)
|
||||||
}
|
}
|
||||||
const factorAvailable = computed(() => factorData.value?.available ?? false)
|
const factorAvailable = computed(() => factorData.value?.available ?? false)
|
||||||
const dividendCount = computed(() => factorData.value?.dividend_count ?? 0)
|
const dividendCount = computed(() => factorData.value?.dividend_count ?? 0)
|
||||||
@@ -669,10 +656,12 @@ onMounted(loadDashboard)
|
|||||||
<div v-if="symbolDetail.themes?.length" class="modal-themes">
|
<div v-if="symbolDetail.themes?.length" class="modal-themes">
|
||||||
<div v-for="c in symbolDetail.theme_contributions" :key="c.theme" class="contrib-line">
|
<div v-for="c in symbolDetail.theme_contributions" :key="c.theme" class="contrib-line">
|
||||||
<span class="contrib-name">{{ c.label_th || themeLabelById[c.theme] || c.theme }}</span>
|
<span class="contrib-name">{{ c.label_th || themeLabelById[c.theme] || c.theme }}</span>
|
||||||
<strong v-if="c.surprise != null">{{ formatNumber(c.surprise) }}σ</strong>
|
<span v-if="c.surprise != null" class="contrib-calc">
|
||||||
|
<em>{{ formatNumber(c.surprise) }}σ</em> × คุณภาพ <em>{{ c.quality }}</em> = <strong>{{ formatNumber(c.theme_score) }}σ</strong>
|
||||||
|
</span>
|
||||||
<strong v-else class="muted-cell">ยังไม่มีข้อมูล</strong>
|
<strong v-else class="muted-cell">ยังไม่มีข้อมูล</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-sub">คะแนนธีม = ค่าเฉลี่ยของค่าเหล่านี้ ที่หุ้นนี้อยู่ใน (เฉพาะธีมที่มีข้อมูล)</div>
|
<div class="modal-sub">คะแนนธีม = ค่าเฉลี่ยของ (surprise × คุณภาพหุ้น) ที่หุ้นนี้อยู่ใน</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="muted-cell">หุ้นนี้ยังไม่ได้จัดอยู่ในธีมใด (จะอัปเดตเมื่อเพิ่มธีม)</div>
|
<div v-else class="muted-cell">หุ้นนี้ยังไม่ได้จัดอยู่ในธีมใด (จะอัปเดตเมื่อเพิ่มธีม)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ tbody tr:hover { background: rgba(255,255,255,.025); }
|
|||||||
.modal-section-title { font: 700 11px 'DM Mono', monospace; color: var(--faint); margin-bottom: 8px; }
|
.modal-section-title { font: 700 11px 'DM Mono', monospace; color: var(--faint); margin-bottom: 8px; }
|
||||||
.modal-sub { font-size: 11px; color: var(--faint); margin-top: 6px; }
|
.modal-sub { font-size: 11px; color: var(--faint); margin-top: 6px; }
|
||||||
.contrib-line { display: flex; justify-content: space-between; padding: 3px 0; font-size: 13px; }
|
.contrib-line { display: flex; justify-content: space-between; padding: 3px 0; font-size: 13px; }
|
||||||
|
.contrib-calc em { font-style: normal; color: var(--accent); }
|
||||||
|
.contrib-calc strong { color: var(--mint); font-family: 'DM Mono', monospace; }
|
||||||
|
.contrib-calc { color: var(--text-2, #9aa); font-size: 12px; }
|
||||||
.fund-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 12px; }
|
.fund-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 12px; }
|
||||||
.fund-grid span { background: rgba(255,255,255,.03); border-radius: 6px; padding: 6px 8px; }
|
.fund-grid span { background: rgba(255,255,255,.03); border-radius: 6px; padding: 6px 8px; }
|
||||||
.fund-grid strong { color: var(--text); font-family: 'DM Mono', monospace; }
|
.fund-grid strong { color: var(--text); font-family: 'DM Mono', monospace; }
|
||||||
|
|||||||
Reference in New Issue
Block a user