From 5b8a10c730f8fd6b98d32f77a0bd4828f62fbef2 Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Mon, 31 Aug 2026 18:50:32 +0700 Subject: [PATCH] refactor(siamchart): momentum is a first-class factor INSIDE the score formula (owner rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/themes.py | 49 ++++++++++++++----- backend/tests/test_themes.py | 21 ++++++++ ...-08-29-data-source-expansion-and-ui-fix.md | 13 +++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/backend/app/themes.py b/backend/app/themes.py index b5042dc..58868fa 100644 --- a/backend/app/themes.py +++ b/backend/app/themes.py @@ -351,10 +351,37 @@ def factor_source_breakdown(fetched: dict, theme_id: str) -> list: return rows -_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. +# Siamchart score is a declarative weighted blend of fundamentals + momentum — +# each term is a first-class factor WITHIN the formula (not an out-of-formula +# adjustment). Weights are config in one place so tuning/auditing is trivial. +# - 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]: @@ -432,9 +459,10 @@ 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 (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. + Raw score = eps_growth*w + dividend_yield*w + momentum*w (see + ``siamchart_raw_score``) — momentum is a first-class factor INSIDE the + formula, then z-scored across the universe. Optional momentum (price trend) + contributes per its configured weight in ``_SIAMCHART_WEIGHTS``. """ out: dict[str, float] = {} for f in factors.get("factors", []): @@ -445,8 +473,7 @@ def build_siamchart_score(factors: dict, d = f.get("dividend_yield") or 0.0 g = float(g) if g is not None else 0.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 + out[sym] = siamchart_raw_score(g, d, 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)} @@ -577,7 +604,7 @@ def symbol_breakdown( d = fac.get("dividend_yield") or 0.0 g = float(g) if g is not None else 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 # 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 gg = float(gg) if gg is not None else 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_stdev = statistics.pstdev(raw_values) if raw_values else 0.0 diff --git a/backend/tests/test_themes.py b/backend/tests/test_themes.py index 323d21e..f3b15f5 100644 --- a/backend/tests/test_themes.py +++ b/backend/tests/test_themes.py @@ -29,6 +29,27 @@ class ThemesTest(unittest.TestCase): # X = 10 + 2*2 = 14 ; Y = -5 -> X higher 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: theme_scores = [{"A": 1.0, "B": -1.0}] siamchart = {"A": 2.0, "B": 0.0} diff --git a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md index f35bec7..48d30ff 100644 --- a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md +++ b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md @@ -143,3 +143,16 @@ New definition implemented: PIT backtest path (which doesn't provide them) still allocates. - Regression tests: load_price_snapshot picks most-recently-retrieved; negative 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.