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

@@ -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")