feat(suggestion): 'ทำกำไร' = price-trend momentum (3/6/12m) gated on positive theme signal

Owner rule: a stock that should be bought for profit is one whose PRICE is
likely to rise in the next 3-6 months — not one with high EPS growth (BTS had
EPS +137% yet flat/falling price). The old selection ranked buckets 1/2 by
 (60/40 theme+siamchart where siamchart was EPS-growth dominated).

- themes.price_trend_score(): blend of ~3/6/12-month price momentum, z-scored
  across the universe (heavier 3/6m weight per the 3-6 month tenure).
- allocate_capital: buckets 1/2 rank by momentum, gated on theme_signal > 0
  (mean surprise across the symbol's themes). theme_signal=None (backtest path)
  is not gated so PIT backtest still allocates. Bucket 3 unchanged (yield top).
- suggestion endpoint passes real momentum + theme_signal from the live board.
- Verified: bucket 1 now picks CRC/BEM (dividend + rising price); falling-price
  PTT/MINT go to bucket 3 by yield, not bucket 1. Full suite 372 green (3 new
  momentum/theme-gate tests).
This commit is contained in:
Kunthawat Greethong
2026-08-31 10:05:00 +07:00
parent be6a92540c
commit 576d9e31ec
5 changed files with 155 additions and 9 deletions

View File

@@ -645,6 +645,31 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
# No collector detail in the response (avoid leaking internal state).
return jsonify({"error": "dashboard scores unavailable"}), 503
# Owner's "ทำกำไร" = price likely to rise in 3-6 months -> price-trend
# momentum (NOT EPS growth). theme_signal = mean surprise of the themes a
# symbol belongs to; buckets 1/2 require theme_signal > 0.
try:
from app import themes as _th
trend = _th.price_trend_score(series)
except Exception:
trend = {}
theme_sig_by_sym: dict[str, float] = {}
try:
from app.dashboard import RealDashboard
from app import daily_cache as _dc
_cache = app.extensions.setdefault("daily_cache", _dc.DailyCache())
dash = RealDashboard((app.extensions.get("tourism_result") or {}).get("signals", []), _cache).build()
except Exception:
dash = {}
theme_surprises = {t.get("id"): t.get("surprise") for t in dash.get("themes", [])}
board_by_sym = {r.get("symbol"): r for r in dash.get("board", [])}
for sym in score_by_symbol:
ths = [s for s in (board_by_sym.get(sym, {}).get("themes") or [])
if theme_surprises.get(s) is not None]
theme_sig_by_sym[sym] = (
(sum(theme_surprises[s] for s in ths) / len(ths)) if ths else 0.0
)
candidates = []
for sym, meta in score_by_symbol.items():
price = prices.get(sym)
@@ -657,6 +682,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
combined_score=meta.get("combined", 0.0),
is_dividend=bool(meta.get("is_dividend")),
dividend_yield=float(meta.get("dividend_yield") or 0.0),
momentum=float(trend.get(sym, 0.0)),
theme_signal=float(theme_sig_by_sym.get(sym, 0.0)),
)
)

View File

@@ -40,6 +40,11 @@ class Candidate:
combined_score: float
is_dividend: bool
dividend_yield: float
# Owner's "ทำกำไร" definition: a price-trend (momentum) score; and theme
# signal gate (when provided: must be > 0 to be eligible for profit buckets).
# theme_signal=None means "unspecified" (e.g. backtest path) -> not gated.
momentum: float = 0.0
theme_signal: Optional[float] = None
@dataclass
@@ -112,9 +117,15 @@ def allocate_capital(
if not candidates:
raise SimulationError("no candidates to allocate")
# sort all by combined_score desc (used for bucket 1 & 2 ranking)
by_score = sorted(candidates, key=lambda c: -c.combined_score)
# bucket 3 ranked by dividend yield desc among dividend payers
# "ทำกำไร" = a positive price trend (momentum) across candidates with a
# positive theme signal. This is the owner's definition of a price that is
# likely to rise in the next 3-6 months — NOT EPS growth / combined score.
# Buckets 1 & 2 rank by momentum, gated on theme_signal > 0 (when provided;
# theme_signal=None means unspecified and is not gated, e.g. backtest path);
# bucket 3 ranks purely by dividend yield (ignoring both score and momentum).
profit_pool = [c for c in candidates
if c.theme_signal is None or c.theme_signal > 0.0]
by_momentum = sorted(profit_pool, key=lambda c: -c.momentum)
by_yield = sorted(
(c for c in candidates if c.is_dividend and c.dividend_yield > 0),
key=lambda c: -c.dividend_yield,
@@ -136,11 +147,12 @@ def allocate_capital(
sort_by: str = "combined_score"):
nonlocal cash, used
remaining = cash[bucket_idx]
# bucket 3 must rank by dividend_yield (ignoring score); others by score.
# bucket 3 must rank by dividend_yield (ignoring score); buckets 1/2
# rank by price-trend momentum (the owner's "ทำกำไร" definition).
if sort_by == "dividend_yield":
key = lambda c: -c.dividend_yield
else:
key = lambda c: -c.combined_score
key = lambda c: -c.momentum
for cand in sorted(eligible, key=key):
if cand.symbol in used:
continue
@@ -165,10 +177,10 @@ def allocate_capital(
result.bucket_allocation[bucket_idx + 1] - remaining
)
# Bucket 1: dividend-paying, highest score
_fill(0, [c for c in by_score if c.is_dividend], require_dividend=True)
# Bucket 2: non-dividend, highest score
_fill(1, [c for c in by_score if not c.is_dividend], require_dividend=False)
# Bucket 1: dividend-paying, highest momentum (theme gate already applied)
_fill(0, [c for c in by_momentum if c.is_dividend], require_dividend=True)
# Bucket 2: non-dividend, highest momentum (theme gate already applied)
_fill(1, [c for c in by_momentum if not c.is_dividend], require_dividend=False)
# Bucket 3: highest dividend yield (ignoring score), excluding symbols bought
_fill(2, by_yield, require_dividend=True, sort_by="dividend_yield")

View File

@@ -385,6 +385,49 @@ def _load_momentum(lookback_days: int = 252) -> dict[str, float]:
return out
def price_trend_score(series: dict, lookbacks=(63, 126, 252), weights=(0.4, 0.35, 0.25)) -> dict[str, float]:
"""Mid-term price trend per symbol — the owner's definition of "ทำกำไร".
The owner wants buckets 1/2 to mean "a stock whose price is likely to rise in
the next 3-6 months", which is a *price-trend* signal, not EPS growth. This
blends multi-horizon momentum over ~3 / 6 / 12 months (trading days), then
z-scores across the universe so the score is comparable. Heavier weight on
the shorter horizons (3/6m) matches the 3-6 month tenure the owner named.
Returns {symbol: z(trend)}. Symbols without enough price history are omitted
(callers treat them as ineligible/momentum-neutral).
"""
import statistics
mom = {sym: [] for sym in series}
for sym, s in series.items():
bars = s.get("bars", [])
if not bars:
continue
todays = float(bars[-1]["adjusted_close"])
if todays <= 0:
continue
for lb in lookbacks:
if len(bars) > lb:
base = float(bars[-1 - lb]["adjusted_close"])
if base > 0:
mom[sym].append((todays / base) - 1.0)
else:
mom[sym].append(0.0)
else:
mom[sym].append(None)
raw: dict[str, float] = {}
for sym, vals in mom.items():
contrib = [w * (v or 0.0) for v, w in zip(vals, weights) if v is not None]
if contrib:
raw[sym] = sum(contrib)
if not raw:
return {}
vals = list(raw.values())
mean = statistics.mean(vals)
sd = statistics.pstdev(vals) or 1.0
return {sym: round((v - mean) / sd, 4) for sym, v in raw.items()}
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.

View File

@@ -87,5 +87,49 @@ class AllocationTest(unittest.TestCase):
self.assertAlmostEqual(res.unallocated_cash, 50_000, places=2)
class MomentumSelectionTest(unittest.TestCase):
"""Owner's "ทำกำไร" rule: bucket 1/2 rank by price-trend momentum and are
gated on a POSITIVE theme signal — NOT by combined/EPS score."""
def _cand(self, sym, price, combined, div, yield_, momentum, theme):
return Candidate(sym, price, combined, div, yield_,
momentum=momentum, theme_signal=theme)
def test_bucket1_ranks_by_momentum_not_score(self):
# A high-score but NEGATIVE-momentum dividend name must NOT win bucket 1
# over a positive-momentum one (owner: "ทำกำไร" = price likely to rise).
cands = [
self._cand("SLOW", 10.0, 9.0, True, 2.0, -1.5, 0.5), # high score, falling price
self._cand("FAST", 10.0, 0.5, True, 1.0, +2.0, 0.6), # low score, rising price
]
res = allocate_capital(1_000_000, cands)
b1 = [o.symbol for o in res.orders if o.bucket == 1]
self.assertEqual(b1, ["FAST"]) # FAST (momentum +2) beats SLOW (-1.5)
def test_negative_theme_is_excluded_from_profit_bucket(self):
# Even a high-momentum name is not eligible for bucket 1/2 when its
# theme_signal is <= 0 (owner gate). It may still land in bucket 3 (yield).
cands = [
self._cand("NO_THEME", 10.0, 5.0, True, 8.0, +3.0, -0.2), # momentum up but theme negative
self._cand("GOOD", 10.0, 5.0, True, 2.0, +1.0, 0.5),
]
res = allocate_capital(1_000_000, cands)
b1 = [o.symbol for o in res.orders if o.bucket == 1]
self.assertNotIn("NO_THEME", b1) # gated out of the profit bucket
self.assertIn("GOOD", b1)
# NO_THEME may still be picked by bucket 3 (highest yield, 8%)
b3 = [o.symbol for o in res.orders if o.bucket == 3]
self.assertIn("NO_THEME", b3)
def test_bucket2_uses_non_dividend_momentum(self):
cands = [
self._cand("ND_UP", 10.0, 9.0, False, 0.0, +2.5, 0.7), # non-div, momentum up
self._cand("ND_DN", 10.0, 9.0, False, 0.0, -2.5, 0.7), # non-div, momentum down
]
res = allocate_capital(1_000_000, cands)
b2 = [o.symbol for o in res.orders if o.bucket == 2]
self.assertEqual(b2, ["ND_UP"]) # rising price chosen over falling
if __name__ == "__main__":
unittest.main()

View File

@@ -109,3 +109,23 @@ Implemented (commit 2026-08-30):
(final_equity 1,117,243.95 on 1M), no 400 from missing factors.
- Tests: test_backtest_readiness updated to earliest-runnable semantics; full
suite 369 green.
## Capital-allocation selection rework (owner rule — 2026-08-31)
Owner clarified what the 3 buckets mean and flagged that the old selection
(rank by `combined` = 60/40 theme+siamchart where siamchart was EPS-growth
dominated) picked names like BTS whose EPS was up 137% but whose PRICE was flat/
falling — NOT the owner's "ทำกำไร" (price likely to rise in 3-6 months).
New definition implemented:
- "ทำกำไร" is now measured by a **price-trend (momentum) score**, not EPS growth.
Added `themes.price_trend_score()` blending ~3/6/12-month momentum (trading
days), z-scored across the universe (heavy weight on 3/6m per the 3-6 month
tenure).
- Buckets 1 & 2 rank by momentum, **gated on `theme_signal > 0`** (mean surprise
of the symbol's themes). theme_signal=None (backtest path) is not gated, so
the PIT backtest still allocates.
- Bucket 3 unchanged: highest dividend yield, ignores score/momentum.
- Suggestion endpoint passes real momentum + theme_signal from the live board.
- Verified: suggestion now picks CRC+BEM (dividend, rising price) in bucket 1;
PTT/MINT (falling price, theme positive) slide to bucket 3 by yield, not
bucket 1. Full suite 372 green (3 new momentum/gate tests).