- load_price_snapshot picked the last snapshot by filename (lexicographic), selecting a stale 9-symbol collection over the full 50-symbol universe. Now picks the snapshot with the latest source.retrieved_at. - allocate_capital profit buckets now also require momentum > 0 (a falling-price name is not 'ทำกำไร'), while momentum/theme_signal stay Optional so the PIT backtest path (which doesn't provide them) still allocates. - Suggestion now allocates across all 50 SET50 names (B1: BGRIM,TTB; B2: BANPU; B3: ADVANC,SCB,LH). - Regression tests for both. Full suite 374 green.
206 lines
7.9 KiB
Python
206 lines
7.9 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).
|
|
# None = "unspecified" (e.g. backtest path) -> not gated. momentum=None means
|
|
# no price-trend signal given -> not gated (rank as 0).
|
|
momentum: Optional[float] = None
|
|
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: [...]}}.
|
|
|
|
'Newest' = the snapshot with the latest ``retrieved_at``, NOT the last
|
|
filename lexicographically (a partial 9-symbol collection can sort after a
|
|
full 50-symbol one, which would silently drop most of the universe).
|
|
"""
|
|
if snapshot_path is None:
|
|
snap_dir = _PRICES_DIR / "snapshots"
|
|
files = list(snap_dir.glob("prices-yahoo-chart-*.json"))
|
|
if not files:
|
|
raise SimulationError("no Yahoo price snapshot found on disk")
|
|
# pick the snapshot retrieved most recently by timestamp embedded in
|
|
# its source metadata (fall back to the newest filename on any error).
|
|
best: Optional[Path] = None
|
|
best_ts: Optional[str] = None
|
|
for f in files:
|
|
try:
|
|
blob = json.loads(f.read_text(encoding="utf-8"))
|
|
ts = (blob.get("source") or {}).get("retrieved_at") or ""
|
|
except Exception:
|
|
ts = ""
|
|
if best is None or (ts and ts > best_ts):
|
|
best, best_ts = f, ts
|
|
snapshot_path = best or 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 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.
|
|
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)]
|
|
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),
|
|
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 or 0.0)
|
|
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
|