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