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

View File

@@ -130,6 +130,50 @@ class MomentumSelectionTest(unittest.TestCase):
b2 = [o.symbol for o in res.orders if o.bucket == 2]
self.assertEqual(b2, ["ND_UP"]) # rising price chosen over falling
def test_negative_momentum_excluded_from_profit_buckets(self):
# A rising-price name wins the profit bucket; a FALLING-price name —
# even with a positive theme — must NOT be picked as "ทำกำไร".
cands = [
self._cand("UP", 10.0, 5.0, False, 0.0, +1.5, 0.6), # rising, non-div
self._cand("DOWN", 10.0, 5.0, False, 0.0, -1.5, 0.6), # falling, same theme
]
res = allocate_capital(1_000_000, cands)
b2 = [o.symbol for o in res.orders if o.bucket == 2]
self.assertEqual(b2, ["UP"]) # DOWN (falling price) excluded
self.assertNotIn("DOWN", b2)
class PriceSnapshotTest(unittest.TestCase):
"""Regression: load_price_snapshot must pick the snapshot retrieved MOST
RECENTLY, not the last filename lexicographically (a partial 9-symbol file
can sort after a full 50-symbol one and silently drop the universe)."""
def test_load_picks_most_recently_retrieved(self):
import json, tempfile
from pathlib import Path
from unittest import mock
from app import simulation as sim
def _snap(symbols, retrieved_at):
return {
"schema_version": 1,
"source": {"retrieved_at": retrieved_at, "period_start": "2024-01-01", "period_end": "2026-08-29"},
"series": {s: {"bars": [{"date": "2026-08-29", "adjusted_close": 10.0}]} for s in symbols},
}
with tempfile.TemporaryDirectory() as td:
snap_dir = Path(td) / "snapshots"
snap_dir.mkdir()
# name that sorts LAST lexicographically, but is OLD (partial 9)
old_partial = snap_dir / "prices-yahoo-chart-2024-01-01-2026-08-24-zzz.json"
old_partial.write_text(json.dumps(_snap(["A", "B"], "2026-08-23T00:00:00+00:00")))
# full 50-symbol, retrieved MORE recently — should win
full = snap_dir / "prices-yahoo-chart-2023-08-28-2026-08-29-aaa.json"
full.write_text(json.dumps(_snap([f"S{i}" for i in range(50)], "2026-08-30T00:00:00+00:00")))
with mock.patch.object(sim, "_PRICES_DIR", Path(td)):
series = sim.load_price_snapshot()
self.assertEqual(len(series), 50) # the full universe, not the 9
if __name__ == "__main__":
unittest.main()

View File

@@ -129,3 +129,17 @@ New definition implemented:
- 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).
## Price-snapshot load fix + momentum gate (owner "เพื่อทดสอบ logic" — 2026-08-31)
- `load_price_snapshot` was picking the LAST snapshot by FILENAME (lexicographic),
which selected a stale 9-symbol collection over the full 50-symbol universe
(`2024-01-01..` sorts after `2023-08-28..`). Now it picks the snapshot with the
latest `source.retrieved_at`, so the full 50-symbol SET50 universe loads.
- Suggestion now allocates across all 50 names; verified it picks rising-price,
theme-positive dividend names in B1 (BGRIM, TTB), non-dividend rising in B2
(BANPU), and yield-top names in B3 (ADVANC, SCB, LH).
- Added momentum>0 gate to the profit buckets (a falling-price name must NOT be
picked as "ทำกำไร"), while keeping momentum/theme_signal as Optional so the
PIT backtest path (which doesn't provide them) still allocates.
- Regression tests: load_price_snapshot picks most-recently-retrieved; negative
momentum excluded from profit buckets. Full suite 374 green.