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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user