refactor(siamchart): momentum is a first-class factor INSIDE the score formula (owner rule)

Owner: momentum must enter the formula (a factor), not be bolted on outside it.
- Single declarative source _SIAMCHART_WEIGHTS = {eps_growth:1.5, dividend_yield:2.0, momentum:0.5}.
- New siamchart_raw_score(g,d,m) = single source of the formula; momentum is an
  explicit term inside it. All 3 call sites (build_siamchart_score + both
  symbol_breakdown spots) now share it — no duplicated arithmetic.
- Pure refactor: outputs unchanged (weights identical). Tests added for the
  momentum-inside-formula rule + momentum raising the score. Full suite 376 green.
This commit is contained in:
Kunthawat Greethong
2026-08-31 18:50:32 +07:00
parent 2b7a065ae5
commit 5b8a10c730
3 changed files with 72 additions and 11 deletions

View File

@@ -351,10 +351,37 @@ def factor_source_breakdown(fetched: dict, theme_id: str) -> list:
return rows return rows
_SIAMCHART_GROWTH_W = 1.5 # R1 (PEAD): EPS-growth dominates value; literature (Bernard-Thomas 1990, # Siamchart score is a declarative weighted blend of fundamentals + momentum —
# Livnat-Mendenhall 2006) shows drift follows earnings, not just yield. # each term is a first-class factor WITHIN the formula (not an out-of-formula
_SIAMCHART_YIELD_W = 2.0 # dividend floor for value names # adjustment). Weights are config in one place so tuning/auditing is trivial.
_SIAMCHART_MOMENTUM_W = 0.5 # R2: EM momentum exists but is noisy -> keep it a small trend boost. # - eps_growth (R1, PEAD): earnings drift dominates; literature Bernard-Thomas
# 1990, Livnat-Mendenhall 2006.
# - dividend_yield: value floor.
# - momentum: price-trend factor in the formula (R2); EM momentum is noisier
# so it stays small relative to EPS.
_SIAMCHART_WEIGHTS: dict[str, float] = {
"eps_growth": 1.5,
"dividend_yield": 2.0,
"momentum": 0.5,
}
_SIAMCHART_GROWTH_W = _SIAMCHART_WEIGHTS["eps_growth"]
_SIAMCHART_YIELD_W = _SIAMCHART_WEIGHTS["dividend_yield"]
_SIAMCHART_MOMENTUM_W = _SIAMCHART_WEIGHTS["momentum"]
def siamchart_raw_score(g: float, d: float, m: float) -> float:
"""Raw (pre-z) Siamchart score = single source of the scoring formula.
Momentum is an explicit term INSIDE the formula (per the owner's rule — it is
a factor that enters the formula, not an out-of-formula tweak). All three
weights come from ``_SIAMCHART_WEIGHTS`` so the board, the per-symbol detail,
and the population stats always use byte-identical arithmetic.
"""
return (
_SIAMCHART_WEIGHTS["eps_growth"] * g
+ _SIAMCHART_WEIGHTS["dividend_yield"] * d
+ _SIAMCHART_WEIGHTS["momentum"] * m
)
def _load_momentum(lookback_days: int = 252) -> dict[str, float]: def _load_momentum(lookback_days: int = 252) -> dict[str, float]:
@@ -432,9 +459,10 @@ def build_siamchart_score(factors: dict,
momentum: Optional[dict[str, float]] = None) -> dict[str, float]: momentum: Optional[dict[str, float]] = None) -> dict[str, float]:
"""Derive a normalized fundamental score from the Siamchart factor view. """Derive a normalized fundamental score from the Siamchart factor view.
Uses EPS growth YoY (weighted above yield per PEAD literature) and dividend Raw score = eps_growth*w + dividend_yield*w + momentum*w (see
yield. Optional momentum (12-1, from price snapshot) adds a low-weight ``siamchart_raw_score``) — momentum is a first-class factor INSIDE the
trend component; EM momentum is noisier, so it stays small. formula, then z-scored across the universe. Optional momentum (price trend)
contributes per its configured weight in ``_SIAMCHART_WEIGHTS``.
""" """
out: dict[str, float] = {} out: dict[str, float] = {}
for f in factors.get("factors", []): for f in factors.get("factors", []):
@@ -445,8 +473,7 @@ def build_siamchart_score(factors: dict,
d = f.get("dividend_yield") or 0.0 d = f.get("dividend_yield") or 0.0
g = float(g) if g is not None else 0.0 g = float(g) if g is not None else 0.0
m = (momentum or {}).get(sym, 0.0) m = (momentum or {}).get(sym, 0.0)
# R1+R2: growth dominates (PEAD), yield floors, momentum adds trend. out[sym] = siamchart_raw_score(g, d, m)
out[sym] = g * _SIAMCHART_GROWTH_W + d * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * m
syms = list(out.keys()) syms = list(out.keys())
z = _zscore([out[s] for s in syms]) z = _zscore([out[s] for s in syms])
return {s: z.get(i, 0.0) for i, s in enumerate(syms)} return {s: z.get(i, 0.0) for i, s in enumerate(syms)}
@@ -577,7 +604,7 @@ def symbol_breakdown(
d = fac.get("dividend_yield") or 0.0 d = fac.get("dividend_yield") or 0.0
g = float(g) if g is not None else 0.0 g = float(g) if g is not None else 0.0
m = (momentum or {}).get(symbol, 0.0) m = (momentum or {}).get(symbol, 0.0)
raw_siamchart = g * _SIAMCHART_GROWTH_W + d * _SIAMCHART_YIELD_W + _SIAMCHART_MOMENTUM_W * m raw_siamchart = siamchart_raw_score(g, d, m)
# z-score against the full universe (same as build_siamchart_score); capture # 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. # the population stats so the view can show HOW -2.8 became -0.588.
@@ -592,7 +619,7 @@ def symbol_breakdown(
dd = f.get("dividend_yield") or 0.0 dd = f.get("dividend_yield") or 0.0
gg = float(gg) if gg is not None else 0.0 gg = float(gg) if gg is not None else 0.0
mm = (momentum or {}).get(f.get("symbol"), 0.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) raw_values.append(siamchart_raw_score(gg, dd, mm))
pop_mean = statistics.mean(raw_values) if raw_values else 0.0 pop_mean = statistics.mean(raw_values) if raw_values else 0.0
pop_stdev = statistics.pstdev(raw_values) if raw_values else 0.0 pop_stdev = statistics.pstdev(raw_values) if raw_values else 0.0

View File

@@ -29,6 +29,27 @@ class ThemesTest(unittest.TestCase):
# X = 10 + 2*2 = 14 ; Y = -5 -> X higher # X = 10 + 2*2 = 14 ; Y = -5 -> X higher
self.assertGreater(sc["X"], sc["Y"]) self.assertGreater(sc["X"], sc["Y"])
def test_siamchart_raw_score_has_momentum_inside_formula(self) -> None:
# Momentum is a first-class factor WITHIN the formula (owner rule):
# raw = eps_growth*1.5 + dividend_yield*2.0 + momentum*0.5.
self.assertEqual(
round(themes.siamchart_raw_score(4.0, 1.0, 2.0), 6),
round(4.0 * 1.5 + 1.0 * 2.0 + 2.0 * 0.5, 6), # 6 + 2 + 1 = 9
)
self.assertEqual(themes._SIAMCHART_WEIGHTS["momentum"], 0.5)
def test_siamchart_momentum_raises_score(self) -> None:
factors = {
"factors": [
{"symbol": "UP", "eps_growth_yoy": 0.0, "dividend_yield": 0.0},
{"symbol": "DN", "eps_growth_yoy": 0.0, "dividend_yield": 0.0},
]
}
momentum = {"UP": 1.0, "DN": -1.0}
sc = themes.build_siamchart_score(factors, momentum=momentum)
# Same fundamentals, only momentum differs -> UP must win.
self.assertGreater(sc["UP"], sc["DN"])
def test_combine_60_40(self) -> None: def test_combine_60_40(self) -> None:
theme_scores = [{"A": 1.0, "B": -1.0}] theme_scores = [{"A": 1.0, "B": -1.0}]
siamchart = {"A": 2.0, "B": 0.0} siamchart = {"A": 2.0, "B": 0.0}

View File

@@ -143,3 +143,16 @@ New definition implemented:
PIT backtest path (which doesn't provide them) still allocates. PIT backtest path (which doesn't provide them) still allocates.
- Regression tests: load_price_snapshot picks most-recently-retrieved; negative - Regression tests: load_price_snapshot picks most-recently-retrieved; negative
momentum excluded from profit buckets. Full suite 374 green. momentum excluded from profit buckets. Full suite 374 green.
## Refactor: momentum is a factor INSIDE the Siamchart formula (owner rule — 2026-08-31)
The owner insisted momentum must be entered INTO the score formula (a first-class
factor), not bolted on outside it. Refactored `themes.py` from 3 loose weight
constants + inline formula repeated in 3 places to a single declarative source:
- `_SIAMCHART_WEIGHTS = {eps_growth:1.5, dividend_yield:2.0, momentum:0.5}` (config).
- `siamchart_raw_score(g,d,m)` = single source of the formula; momentum is a term
INSIDE it. All three call sites (build_siamchart_score, symbol_breakdown raw,
symbol_breakdown population) now use it — no more duplicated arithmetic.
- Purely a refactor: outputs unchanged (0.6/0.4 weighting intact), momentum
already weighted 0.5 inside the formula.
- Added tests: raw score formula incl. momentum term; momentum raises siamchart
score given identical fundamentals. Full suite 376 green.