fix(suggestion): profit buckets require combined_score > 0 (owner rule)

Owner: a stock with a NEGATIVE overall (combined) score must not be recommended
in the profit buckets, even with positive momentum + theme. BGRIM/TTB had
combined -0.029/-0.066 yet still landed in bucket 1.

- allocate_capital profit_pool now gates on combined_score > 0 (in addition to
  momentum>0 + theme>0), still ranking by momentum within that pool.
- Verified: bucket 1 now picks PTTGC (+0.209, mom +1.85) instead of BGRIM/TTB;
  BANPU in bucket 2; ADVANC/SCB/LH in bucket 3 by yield. Full suite 377 green.
This commit is contained in:
Kunthawat Greethong
2026-08-31 19:00:14 +07:00
parent 5b8a10c730
commit 54776d5647
3 changed files with 31 additions and 5 deletions

View File

@@ -135,13 +135,15 @@ def allocate_capital(
if not candidates:
raise SimulationError("no candidates to allocate")
# "ทำกำไร" = a price likely to rise in the next 3-6 months, measured by a
# POSITIVE price-trend momentum AND a positive theme signal. This is the
# owner's definition — NOT EPS growth / combined score. Buckets 1 & 2 rank by
# momentum among that pool; bucket 3 ranks purely by dividend yield.
# "ทำกำไร" = price likely to rise (POSITIVE momentum) + positive theme signal
# + a NON-NEGATIVE combined score (owner: a stock with a negative overall
# score must not be recommended in the profit buckets). This is the owner's
# definition — NOT EPS-growth ranking. Buckets 1 & 2 rank by momentum within
# this gated pool; bucket 3 ranks purely by dividend yield.
profit_pool = [c for c in candidates
if (c.theme_signal is None or c.theme_signal > 0.0)
and (c.momentum is None or c.momentum > 0.0)]
and (c.momentum is None or c.momentum > 0.0)
and c.combined_score > 0.0]
by_momentum = sorted(profit_pool, key=lambda c: -(c.momentum or 0.0))
by_yield = sorted(
(c for c in candidates if c.is_dividend and c.dividend_yield > 0),

View File

@@ -142,6 +142,18 @@ class MomentumSelectionTest(unittest.TestCase):
self.assertEqual(b2, ["UP"]) # DOWN (falling price) excluded
self.assertNotIn("DOWN", b2)
def test_negative_combined_excluded_from_profit_buckets(self):
# Owner rule: a stock whose OVERALL (combined) score is negative must not
# be recommended in the profit buckets, even with positive momentum/theme.
cands = [
self._cand("NEG_A", 10.0, -0.05, True, 2.0, +2.0, 0.6), # momentum up, theme +, but combined negative
self._cand("POS_B", 10.0, 0.20, True, 2.0, +1.5, 0.6), # combined positive
]
res = allocate_capital(1_000_000, cands)
b1 = [o.symbol for o in res.orders if o.bucket == 1]
self.assertNotIn("NEG_A", b1) # combined negative -> excluded
self.assertIn("POS_B", b1)
class PriceSnapshotTest(unittest.TestCase):
"""Regression: load_price_snapshot must pick the snapshot retrieved MOST

View File

@@ -156,3 +156,15 @@ constants + inline formula repeated in 3 places to a single declarative source:
already weighted 0.5 inside the formula.
- Added tests: raw score formula incl. momentum term; momentum raises siamchart
score given identical fundamentals. Full suite 376 green.
## Profit buckets now require a positive combined score (owner rule — 2026-08-31)
The owner flagged that BGRIM/TTB still got recommended in bucket 1 even though
their combined score was negative (-0.029 / -0.066): the profit pool only gated
on momentum>0 + theme>0, so a stock with good price trend but weak fundamentals
(EPS/yield lagging the cohort) could still be suggested.
- `allocate_capital` profit_pool now ALSO requires `combined_score > 0` — a stock
whose overall score is negative is never recommended in buckets 1/2, while
still ranking by momentum within that gated pool.
- Verified: suggestion now picks PTTGC (combined +0.209, mom +1.85) in bucket 1
instead of BGRIM/TTB; BANPU in bucket 2 (combined +0.70); ADVANC/SCB/LH in
bucket 3 by yield. Full suite 377 green.