diff --git a/backend/app/simulation.py b/backend/app/simulation.py index 6e1e5f3..04b91e2 100644 --- a/backend/app/simulation.py +++ b/backend/app/simulation.py @@ -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 diff --git a/backend/tests/test_simulation.py b/backend/tests/test_simulation.py index c0511c9..04bcab5 100644 --- a/backend/tests/test_simulation.py +++ b/backend/tests/test_simulation.py @@ -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() diff --git a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md index 38c0df5..f35bec7 100644 --- a/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md +++ b/docs/engineering-log/2026-08-29-data-source-expansion-and-ui-fix.md @@ -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.