Files
set50-system/backend/app/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

189 lines
7.2 KiB
Python

"""Capital-allocation engine for the "จัดสรรทุน (Suggestion)" endpoint.
Pure local research; never sends a real order. This is a live recommendation
on the current board (non-PIT) — forward-test mode was removed per the user.
Allocation rules (confirmed by the user, 2026-08-25):
Bucket 1 (50% of capital) : highest "profit-opportunity" score that pays a dividend
Bucket 2 (20% of capital) : highest "profit-opportunity" score that does NOT pay a dividend
Bucket 3 (30% of capital) : highest dividend yield among names NOT already bought in bucket 1 (ignores score)
Per symbol a minimum of 100 shares; rank candidates by combined score descending.
If a bucket's first pick can't afford 100 shares, try progressively cheaper eligible
names; if none fits, leave the remainder as cash.
The engine is honest about data provenance: it runs on *revised vendor history*
(non-PIT), so output must be labeled paper/backtest, never validated PIT evidence.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
_PRICES_DIR = Path(__file__).resolve().parent.parent / "data" / "prices"
MIN_SHARES = 100
class SimulationError(Exception):
"""Raised for invalid capital / allocation inputs."""
@dataclass
class Candidate:
symbol: str
price: float
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
class Order:
symbol: str
bucket: int
qty: int
price: float
notional: float
@dataclass
class AllocationResult:
capital: float
bucket_allocation: dict = field(default_factory=dict) # {1: amount, 2:..., 3:...}
orders: list = field(default_factory=list)
invested: float = 0.0
unallocated_cash: float = 0.0
bucket_notional: dict = field(default_factory=dict) # {1: notional, ...}
def to_dict(self) -> dict:
return {
"capital": self.capital,
"buckets": self.bucket_allocation,
"orders": [o.__dict__ for o in self.orders],
"invested": round(self.invested, 2),
"unallocated_cash": round(self.unallocated_cash, 2),
"bucket_notional": self.bucket_notional,
}
# ---------------------------------------------------------------------------
# B1: price-series loader
# ---------------------------------------------------------------------------
def load_price_snapshot(snapshot_path: Optional[Path] = None) -> dict:
"""Load the newest Yahoo price snapshot: {symbol: {bars: [...]}}."""
if snapshot_path is None:
snap_dir = _PRICES_DIR / "snapshots"
files = sorted(snap_dir.glob("prices-yahoo-chart-*.json"))
if not files:
raise SimulationError("no Yahoo price snapshot found on disk")
snapshot_path = files[-1]
data = json.loads(snapshot_path.read_text(encoding="utf-8"))
return data.get("series", {})
def latest_prices(series: dict) -> dict[str, float]:
"""Latest adjusted_close per symbol from the price series."""
out: dict[str, float] = {}
for sym, s in series.items():
bars = s.get("bars", [])
if bars:
out[sym] = float(bars[-1]["adjusted_close"])
return out
# ---------------------------------------------------------------------------
# B2: capital allocation core
# ---------------------------------------------------------------------------
def allocate_capital(
capital: float,
candidates: list[Candidate],
bucket_b1: float = 0.50,
bucket_b2: float = 0.20,
bucket_b3: float = 0.30,
) -> AllocationResult:
"""Allocate `capital` across the three dividend/profit buckets."""
if capital <= 0:
raise SimulationError("capital must be > 0")
if not candidates:
raise SimulationError("no candidates to allocate")
# "ทำกำไร" = 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,
)
b1_amount = capital * bucket_b1
b2_amount = capital * bucket_b2
b3_amount = capital * bucket_b3
result = AllocationResult(
capital=capital,
bucket_allocation={1: b1_amount, 2: b2_amount, 3: b3_amount},
)
cash = [b1_amount, b2_amount, b3_amount] # per-bucket remaining
used = set()
def _fill(bucket_idx: int, eligible: list[Candidate], require_dividend: bool,
sort_by: str = "combined_score"):
nonlocal cash, used
remaining = cash[bucket_idx]
# 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.momentum
for cand in sorted(eligible, key=key):
if cand.symbol in used:
continue
if require_dividend and not cand.is_dividend:
continue
if cand.price <= 0:
continue
# max shares affordable within this bucket, floor to 100-share lots
max_qty = int(remaining // cand.price)
qty = (max_qty // MIN_SHARES) * MIN_SHARES
if qty < MIN_SHARES:
continue # can't afford minimum; try cheaper name
notional = qty * cand.price
result.orders.append(
Order(cand.symbol, bucket_idx + 1, qty, cand.price, notional)
)
remaining -= notional
used.add(cand.symbol)
result.invested += notional
cash[bucket_idx] = remaining
result.bucket_notional[bucket_idx + 1] = b_st = (
result.bucket_allocation[bucket_idx + 1] - remaining
)
# 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")
result.unallocated_cash = sum(cash)
return result