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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user