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