fix(suggestion): load full 50-symbol price snapshot + exclude falling-price names from profit buckets

- 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.
This commit is contained in:
Kunthawat Greethong
2026-08-31 10:56:22 +07:00
parent 576d9e31ec
commit 4c32e2b737
3 changed files with 89 additions and 14 deletions

View File

@@ -42,8 +42,9 @@ class Candidate:
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
# 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
@@ -80,13 +81,30 @@ class AllocationResult:
# B1: price-series loader
# ---------------------------------------------------------------------------
def load_price_snapshot(snapshot_path: Optional[Path] = None) -> dict:
"""Load the newest Yahoo price snapshot: {symbol: {bars: [...]}}."""
"""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 = sorted(snap_dir.glob("prices-yahoo-chart-*.json"))
files = list(snap_dir.glob("prices-yahoo-chart-*.json"))
if not files:
raise SimulationError("no Yahoo price snapshot found on disk")
snapshot_path = files[-1]
# 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", {})
@@ -117,15 +135,14 @@ def allocate_capital(
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).
# "ทำกำไร" = 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]
by_momentum = sorted(profit_pool, key=lambda c: -c.momentum)
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,
@@ -152,7 +169,7 @@ def allocate_capital(
if sort_by == "dividend_yield":
key = lambda c: -c.dividend_yield
else:
key = lambda c: -c.momentum
key = lambda c: -(c.momentum or 0.0)
for cand in sorted(eligible, key=key):
if cand.symbol in used:
continue