diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 1fb7789..ecbb441 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -645,6 +645,31 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: # No collector detail in the response (avoid leaking internal state). return jsonify({"error": "dashboard scores unavailable"}), 503 + # Owner's "ทำกำไร" = price likely to rise in 3-6 months -> price-trend + # momentum (NOT EPS growth). theme_signal = mean surprise of the themes a + # symbol belongs to; buckets 1/2 require theme_signal > 0. + try: + from app import themes as _th + trend = _th.price_trend_score(series) + except Exception: + trend = {} + theme_sig_by_sym: dict[str, float] = {} + try: + from app.dashboard import RealDashboard + from app import daily_cache as _dc + _cache = app.extensions.setdefault("daily_cache", _dc.DailyCache()) + dash = RealDashboard((app.extensions.get("tourism_result") or {}).get("signals", []), _cache).build() + except Exception: + dash = {} + theme_surprises = {t.get("id"): t.get("surprise") for t in dash.get("themes", [])} + board_by_sym = {r.get("symbol"): r for r in dash.get("board", [])} + for sym in score_by_symbol: + ths = [s for s in (board_by_sym.get(sym, {}).get("themes") or []) + if theme_surprises.get(s) is not None] + theme_sig_by_sym[sym] = ( + (sum(theme_surprises[s] for s in ths) / len(ths)) if ths else 0.0 + ) + candidates = [] for sym, meta in score_by_symbol.items(): price = prices.get(sym) @@ -657,6 +682,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: combined_score=meta.get("combined", 0.0), is_dividend=bool(meta.get("is_dividend")), dividend_yield=float(meta.get("dividend_yield") or 0.0), + momentum=float(trend.get(sym, 0.0)), + theme_signal=float(theme_sig_by_sym.get(sym, 0.0)), ) ) diff --git a/backend/app/simulation.py b/backend/app/simulation.py index 2925c04..6e1e5f3 100644 --- a/backend/app/simulation.py +++ b/backend/app/simulation.py @@ -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") diff --git a/backend/app/themes.py b/backend/app/themes.py index 27d8a61..b5042dc 100644 --- a/backend/app/themes.py +++ b/backend/app/themes.py @@ -385,6 +385,49 @@ def _load_momentum(lookback_days: int = 252) -> dict[str, float]: return out +def price_trend_score(series: dict, lookbacks=(63, 126, 252), weights=(0.4, 0.35, 0.25)) -> dict[str, float]: + """Mid-term price trend per symbol — the owner's definition of "ทำกำไร". + + The owner wants buckets 1/2 to mean "a stock whose price is likely to rise in + the next 3-6 months", which is a *price-trend* signal, not EPS growth. This + blends multi-horizon momentum over ~3 / 6 / 12 months (trading days), then + z-scores across the universe so the score is comparable. Heavier weight on + the shorter horizons (3/6m) matches the 3-6 month tenure the owner named. + + Returns {symbol: z(trend)}. Symbols without enough price history are omitted + (callers treat them as ineligible/momentum-neutral). + """ + import statistics + mom = {sym: [] for sym in series} + for sym, s in series.items(): + bars = s.get("bars", []) + if not bars: + continue + todays = float(bars[-1]["adjusted_close"]) + if todays <= 0: + continue + for lb in lookbacks: + if len(bars) > lb: + base = float(bars[-1 - lb]["adjusted_close"]) + if base > 0: + mom[sym].append((todays / base) - 1.0) + else: + mom[sym].append(0.0) + else: + mom[sym].append(None) + raw: dict[str, float] = {} + for sym, vals in mom.items(): + contrib = [w * (v or 0.0) for v, w in zip(vals, weights) if v is not None] + if contrib: + raw[sym] = sum(contrib) + if not raw: + return {} + vals = list(raw.values()) + mean = statistics.mean(vals) + sd = statistics.pstdev(vals) or 1.0 + return {sym: round((v - mean) / sd, 4) for sym, v in raw.items()} + + def build_siamchart_score(factors: dict, momentum: Optional[dict[str, float]] = None) -> dict[str, float]: """Derive a normalized fundamental score from the Siamchart factor view. diff --git a/backend/tests/test_simulation.py b/backend/tests/test_simulation.py index 71ca943..c0511c9 100644 --- a/backend/tests/test_simulation.py +++ b/backend/tests/test_simulation.py @@ -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() diff --git a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md index 5e29830..38c0df5 100644 --- a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md +++ b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md @@ -109,3 +109,23 @@ Implemented (commit 2026-08-30): (final_equity 1,117,243.95 on 1M), no 400 from missing factors. - Tests: test_backtest_readiness updated to earliest-runnable semantics; full suite 369 green. + +## Capital-allocation selection rework (owner rule — 2026-08-31) +Owner clarified what the 3 buckets mean and flagged that the old selection +(rank by `combined` = 60/40 theme+siamchart where siamchart was EPS-growth +dominated) picked names like BTS whose EPS was up 137% but whose PRICE was flat/ +falling — NOT the owner's "ทำกำไร" (price likely to rise in 3-6 months). + +New definition implemented: +- "ทำกำไร" is now measured by a **price-trend (momentum) score**, not EPS growth. + Added `themes.price_trend_score()` blending ~3/6/12-month momentum (trading + days), z-scored across the universe (heavy weight on 3/6m per the 3-6 month + tenure). +- Buckets 1 & 2 rank by momentum, **gated on `theme_signal > 0`** (mean surprise + of the symbol's themes). theme_signal=None (backtest path) is not gated, so + the PIT backtest still allocates. +- Bucket 3 unchanged: highest dividend yield, ignores score/momentum. +- Suggestion endpoint passes real momentum + theme_signal from the live board. +- Verified: suggestion now picks CRC+BEM (dividend, rising price) in bucket 1; + PTT/MINT (falling price, theme positive) slide to bucket 3 by yield, not + bucket 1. Full suite 372 green (3 new momentum/gate tests).