Files
set50-system/backend/tests/test_simulation.py
Kunthawat Greethong 576d9e31ec 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).
2026-08-31 10:05:00 +07:00

136 lines
6.0 KiB
Python

"""Tests for the capital-allocation simulation engine."""
from __future__ import annotations
import unittest
from app.simulation import Candidate, allocate_capital
class AllocationTest(unittest.TestCase):
def make_candidates(self):
# dividend + high score
return [
Candidate("A", 10.0, 1.5, True, 5.0),
Candidate("B", 20.0, 1.2, True, 3.0),
Candidate("C", 5.0, 1.0, False, 0.0),
Candidate("D", 2.0, 0.5, False, 0.0),
Candidate("E", 50.0, 0.9, True, 8.0),
]
def test_allocates_three_buckets(self):
res = allocate_capital(200_000, self.make_candidates())
bucket_notional = res.bucket_notional
self.assertIn(1, bucket_notional)
self.assertIn(2, bucket_notional)
self.assertIn(3, bucket_notional)
# total invested + unallocated == capital
self.assertAlmostEqual(res.invested + res.unallocated_cash, 200_000, places=2)
# every order is a multiple of 100
for o in res.orders:
self.assertEqual(o.qty % 100, 0)
self.assertGreaterEqual(o.qty, 100)
def test_min_100_shares_respected(self):
# tiny capital: bucket1 50% still must afford 100 shares
res = allocate_capital(2_000, self.make_candidates())
for o in res.orders:
self.assertGreaterEqual(o.qty, 100)
self.assertEqual(o.notional, o.qty * o.price)
# never over-invest beyond capital
self.assertLessEqual(res.invested, 2_000)
def test_bucket3_excludes_already_bought(self):
cands = [Candidate("A", 10.0, 1.5, True, 5.0),
Candidate("B", 10.0, 0.1, True, 8.0)]
res = allocate_capital(300_000, cands)
# A (bought in bucket1) must NOT also appear in bucket3
bucket3_syms = [o.symbol for o in res.orders if o.bucket == 3]
bucket1_syms = [o.symbol for o in res.orders if o.bucket == 1]
overlap = set(bucket3_syms) & set(bucket1_syms)
self.assertEqual(overlap, set())
def test_bucket3_ranks_by_yield_ignoring_score(self):
# The spec: bucket3 = highest dividend yield, IGNORING score.
# A low-score but high-yield name must rank above a high-score low-yield name.
cands = [
Candidate("HIGH_SCORE", 10.0, 8.0, True, 2.0), # score 8, yield 2%
Candidate("HIGH_YIELD", 10.0, 0.1, True, 7.0), # score 0.1, yield 7%
Candidate("MID", 10.0, 5.0, True, 3.0),
]
# Big capital so bucket1 consumes only the top score name, leaving
# HIGH_YIELD (not HIGH_SCORE) to be the bucket3 top pick.
res = allocate_capital(1_000_000, cands)
b3 = [o.symbol for o in res.orders if o.bucket == 3]
# HIGH_YIELD (7%) should be selected in bucket3 before HIGH_SCORE (2%)
self.assertIn("HIGH_YIELD", b3)
if "HIGH_SCORE" in b3:
hi = b3.index("HIGH_SCORE")
hy = b3.index("HIGH_YIELD")
self.assertLess(hy, hi)
def test_invalid_capital_raises(self):
with self.assertRaises(Exception):
allocate_capital(0, self.make_candidates())
with self.assertRaises(Exception):
allocate_capital(-100, self.make_candidates())
def test_no_candidates_raises(self):
with self.assertRaises(Exception):
allocate_capital(100_000, [])
def test_cash_fallback_when_nothing_fits(self):
# only very high-priced names, tiny capital -> cannot buy 100 shares -> cash
cands = [Candidate("X", 1000.0, 2.0, True, 3.0)]
res = allocate_capital(50_000, cands) # 50% = 25k, can't buy 100*1000
self.assertEqual(res.orders, [])
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()