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:
@@ -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)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user