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
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user