# Handoff — Tourism Vertical Slice ## Project - Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform` - Branch: `main` - Verified code commit: pending; final M2.9 independent remediation review passed; no commit or push performed - Current milestone: M2.9 forward price observations and explicit research modes complete/blocked; observation-integrity follow-up implemented; exploratory research is usable, validated backtest remains blocked - Mode: research + paper only - Frontend: Vue 3 + Vite - Backend: Flask `0.5.0` - Current runtime source: BOT Tourism Indicators (`TOURISM_SOURCE=bot`) ## Completed - Tourism snapshot schema with source, publication, retrieval, vintage and raw hash fields. - Deterministic Tourism Pulse score: standardized surprise × exposure × confidence. - BOT ASP.NET form adapter with monthly history parsing and trailing 12-point YoY baseline. - Atomic raw HTML and normalized snapshot persistence under ignored `backend/data/`. - Read-only `/api/v1/data-health` endpoint. - Safe `/api/v1/replay/tourism?vintage_id=...` endpoint with identity and path validation. - Immutable `VintageStore` manifest with first-seen, last-seen, seen-count and revision metadata. - One-shot collector: `backend/scripts/collect_tourism_vintage.py`. - Point-in-time vintage query: `GET /api/v1/vintages?as_of=`. - Deterministic event-study engine with window, benchmark and cost calculations. - Backtest readiness gate: `GET /api/v1/backtest/tourism?min_events=12`. - Yahoo-backed daily price snapshot with SET symbol mapping and adjusted-close bars. - Price health: `GET /api/v1/prices/health`. - Forward price observation archive: `GET /api/v1/prices/observations`; repeated payloads reuse immutable snapshots while recording retrieval observations, first/last-seen times, prior hashes, revision status and structured diffs. - Frozen research runner: `POST /api/v1/research/tourism/run` and `GET /api/v1/research/tourism/latest`. - Research runner modes: explicit `validated` (PIT fail-closed) and `exploratory` (revised-history descriptive only); the UI calls exploratory mode explicitly and shows `result_scope`. - Headless runner: `backend/scripts/run_tourism_research.py`. - Durable atomic paper ledger under `backend/data/paper/ledger.json` when configured. - Raw and normalized snapshot integrity binding with explicit `sha256-json-canonical-v1` metadata. - Replay now loads through the canonical `VintageStore.load_snapshot()` path and fails closed on tampered snapshots. - Research reports carry canonical content hashes; manifest entries carry their own hashes binding report filename, report hash, and metadata. - Research reports from pre-hash trusted stores can be migrated explicitly with `backend/scripts/migrate_research_report_hashes.py`; automatic legacy reads remain disabled. - Research input lineage records normalized snapshot hashes and hash algorithms alongside raw payload hashes. - PIT daily price contract `pit-daily-v1` is implemented: explicit provider release/evidence metadata, per-series IANA timezone, per-bar session date/known-at/OHLC/adjusted-close/volume, and immutable manifest/hash binding are required before `point_in_time=true` is accepted. - PIT event-study mode rejects missing or market-locally future-known prices; the research runner enables this check for PIT snapshots. - Public price-provider feasibility is documented in `docs/engineering-log/2026-08-24-price-provider-feasibility.md`; SET Historical Data, SETSMART, SMART Marketplace, ICE SET, LSEG Tick History/S3 Direct, Databento, and Exchange Data International (EDI) have all been checked, but no candidate has yet supplied enough evidence to pass `pit-daily-v1`, so no new adapter was added. EDI is the closest conditional price-feed lead because its public docs expose SET coverage, a confirmed ETA, and correction/re-push behavior, but its public contract still lacks immutable release metadata. - VintageStore rejects non-object snapshots, invalid UTF-8 manifests/snapshots, and raw-payload read failures with `VintageStoreError`. - Tourism replay computation rejects non-list/non-object observation/exposure shapes as controlled semantic failures, so malformed exposure snapshots fail closed instead of leaking `AttributeError`. - Manifest JSON shape and every `list_runs()`/`latest()` entry are validated before metadata is returned or used for selection. - Ranked target weights and LONG/SHORT/NEUTRAL classification. - English dashboard with live/provisional source label, sign-aware surprise copy and lineage fields. - Explicit loopback-only paper demo mode with a visible warning, protected token mode for shared/network use, and internal paper ledger. - No external webhook or MT5 integration. Independent M1 review passed with no concrete security or logic blockers. Non-blocking backlog: add schema-drift, duplicate/reordered-row, and malformed-vintage regression fixtures. ## Current live vintage ```text source_id: bot.ec_ei_028_s2 published_at: 2026-07-31T14:30:00+07:00 as_of: 2026-06-30 status: provisional available_periods: 138 history_points: 12 vintage_id: bot.ec_ei_028_s2-2026-07-31-665981f88b4a seen_count: 16 raw_payload_hash: 665981f88b4a30c5bd30026cf1e96279c244ad83725558f4136d952b29756a31 theme_surprise: -0.35114754 ``` ## Verified commands and live checks ```text PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v Ran 125 tests ... OK PYTHONPATH=backend .venv/bin/python -m compileall -q backend/app backend/scripts backend/tests passed npm run build Vite build completed successfully. npm audit --omit=dev --audit-level=high found 0 vulnerabilities git diff --check passed python3 ~/.hermes/skills/research/grounded-citations/scripts/sources.py verify docs/engineering-log/2026-08-24-price-provider-feasibility.md --strict --evidence citations OK; 11 cited sources, all with evidence quotes GET /api/v1/health HTTP 200; {"mode":"research","status":"ok","version":"0.5.0"} GET /api/v1/data-health HTTP 200; source_mode=bot, status=provisional, replayable=true GET /api/v1/replay/tourism?vintage_id=bot.ec_ei_028_s2-2026-07-31-665981f88b4a HTTP 200; replay theme surprise matched live summary exactly. GET /api/v1/vintages?as_of=2026-07-01T00:00:00Z HTTP 200; count=0 GET /api/v1/vintages?as_of=2026-08-01T00:00:00Z HTTP 200; count=1; manifest seen_count=16 GET /api/v1/backtest/tourism?min_events=12 HTTP 409; status=blocked, available_events=1, required_events=12, price_series_required=true GET /api/v1/prices/health HTTP 200; available=true, quality=revised_vendor_history, point_in_time=false, symbols=9 POST /api/v1/research/tourism/run (validated default) HTTP 200; report status=blocked, reason=insufficient_vintages, immutable run_id persisted POST /api/v1/research/tourism/run (mode=exploratory, min_events=1) HTTP 200; revised-price report runs only as result_scope=non_pit_descriptive_only GET /api/v1/research/tourism/latest HTTP 200; same run_id returned on replay, report_hash present GET /api/v1/prices/observations HTTP 200 on empty archive; populated archives return bound observation IDs, prior hashes and revision diffs BOT collector rerun same published release; seen_count=16; independent releases remain=1 M2.9 remediation status 125 tests and current-data health smoke test pass; observation-integrity follow-up covers same-raw/normalized mismatch, malformed manifest entries, first-capture temporal ordering, and duplicate equal-time observations; focused independent review `deleg_0e2282c8` returned schema-valid `passed=true` with empty security/logic blocker arrays; no commit or push performed ``` Paper writes support two explicit policies: local `PAPER_AUTH_MODE=demo` requires a loopback bind and allows paper-only writes without a session token while showing a warning; protected `PAPER_AUTH_MODE=token` uses a server-side token exchange and HttpOnly `paper_session` cookie. The token is not embedded in the frontend bundle. ## Known limitations - BOT data is provisional and may be revised. The vintage/hash contract preserves the fetched version, but this is not a final-data guarantee. - Only foreign-arrival YoY is live in this slice; occupancy and airport passenger metrics are not yet connected. - Snapshot storage is local filesystem and single-process; shared persistence is required before multi-worker deployment. - No investment edge, transaction-cost result, or backtest conclusion has been established. - One independent source release is not enough for a valid event study; current historical rows are not treated as point-in-time vintages. - The event-study engine is deterministic and tested. Its PIT mode now enforces per-bar known-at semantics in the market timezone. Yahoo price history is connected for plumbing, but it is revised vendor history, not point-in-time data. - The PIT contract is implemented but provider release-time evidence is still absent. No configuration-only promotion of revised history is allowed; `point_in_time` remains false. - SET Historical Data, SETSMART, and SMART Marketplace document historical/API availability but do not publicly document the release-vintage and known-at semantics required by the PIT contract. ICE SET is a strong commercial candidate; LSEG S3 Direct has the strongest public PIT claim but remains conditional on SET coverage and vintage evidence. Databento lists the SET venue and documents PIT corporate-action history, but its public price-data material does not establish price release IDs, client availability/`known_at`, or correction-vintage replay. EDI documents confirmed/unconfirmed republish and `Repush1/2/3` correction files for SET-covered EOD pricing and publishes a confirmed Thailand ETA, but also states that it has no EOD time-series database; immutable release IDs, actual release timestamps, retention, and retrievable manifests remain unproven. All candidates require a provider evidence packet before implementation. - Validated research runner is operational and replayable, but correctly emits a blocked report until both evidence gates pass. Exploratory runner is operational against revised history only with an explicit descriptive-only scope. - Snapshot loads fail closed when raw files, normalized payloads or manifest metadata do not match their recorded hashes. - Research report and manifest-entry loads fail closed when persisted content or bound metadata is tampered; pre-existing research volumes require integrity metadata migration before they can be loaded. - Independent integrity review gate passed for the staged remediation scope under the local threat model: `deleg_c227c439` found a persist-wide manifest-validation gap in the prior staged tree; it is now fixed and covered by a regression test. The original `deleg_43348a3b` result was semantically positive but schema-invalid because it used `findings`; schema correction `deleg_a1c721d2` returned the exact required four-key verdict with `passed=true` and all arrays empty. Earlier timeouts/stale reviews (`deleg_cf1da27d`, `deleg_feb40653`, `deleg_87a0f686`) and interrupted parallel review `deleg_e2d54c30` are non-approving and contribute no additional evidence. - The hash boundary protects local artifacts against corruption/partial writes; hostile host-level rewrite of code, manifests and runtime environment is outside this local threat model. - Revisions are deduplicated by canonical `(source_id, published_at)`; the latest observed revision is selected once. - Event-study default execution is the next trading session, including weekend/holiday event dates. - Browser screenshot verification remains blocked by the Chrome remote-debugging permission prompt; served HTML/source, live API, fresh Vite build and replay integrity were verified instead. - Paper demo startup rejects non-loopback `PAPER_BIND_HOST`; token mode remains the required policy for shared/network deployment. - Fresh bounded independent review of the paper-auth scope passed with `security_concerns=[]` and `logic_errors=[]`; its remaining items are non-blocking test suggestions. Integrity remediation is tracked separately and passed via the schema-corrected exact verdict. - M2.9 remediation now covers malformed provider/config totalization, non-PIT series/raw lineage, PIT numeric overflow handling, UTC/equal-time predecessor ordering, same-raw/normalized mismatch rejection, malformed snapshot manifest rejection, first-capture observation timing, contract-backed observation count/timing/reference checks, descriptive-only exploratory status, and human-readable UI reason labels. Focused independent review `deleg_0e2282c8` returned schema-valid `passed=true` with empty security and logic blocker arrays; its only suggestion is to retain regression coverage. Process-level locking and focused helper/UI coverage remain deferred. No commit or push has been made. ## Exact next action Keep collecting independent BOT releases until 12 genuinely distinct `(source_id, published_at)` releases exist. In parallel request one complete market-data provider evidence packet (EDI first, or ICE/LSEG/SET fallback): raw sample, provider release ID/time, explicit known-at semantics, correction/revision replay example, SET symbol coverage, timezone/session rules, and immutable archive/manifest evidence. Databento is currently a reference/corporate-actions lead only and must not be used to claim PIT price bars. Revised Yahoo history remains `point_in_time=false`. Do not raise the PIT flag or promote a PIT adapter until that packet passes `pit-daily-v1`. The operator can run exploratory research now, but only validated mode may support a historical backtest claim. Keep local paper work in explicit demo mode and use token mode before any shared/network deployment. Add a trusted deployment signing key before treating the app as a multi-user or hostile-host service. NEVER include API keys, tokens, passwords, secrets, credentials, or connection strings in the summary — replace any that appear with [REDACTED]. --- ## Session 2026-08-28 — Event-driven PIT backtest (verified + pushed) **Branch:** `main` · **HEAD:** `d73a58b` (+cycle-2 fix pending) · **Pushed:** yes (tasks 1–7) ### Goal implemented Replace the calendar-rebalance backtest with a strict point-in-time, event-driven engine per the user's description. Confirmed decisions: 1. **Strict PIT** — block when factor / Siamchart / price coverage is incomplete (no fallback). 2. **Execution** — freeze signal at release date D, execute at next trading-day close after D. 3. **Dividend timing** — entitled on ex-date; cash available `ex_date + 30` days (`ex_date_plus_30d`). 4. **Average cost** — realized P&L uses weighted avg cost. 5. **Fee** — all-in 0.3% per trade, no added VAT. ### Shipped (commits on main) - `68f2cc1` Task 1 `backtest_readiness.py` — readiness + recommended start/end defaults, fail-closed. - `6439e9c` Task 2 `backtest_events.py` — event calendar + next-trading-day mapping. - `dc057e9` Task 3 `portfolio_ledger.py` — cash/positions/avg-cost/realized+unrealized P&L/dividend receivables/fees. - `71b893e` Task 4 `portfolio_rebalancer.py` — 50/20/30 → 100-lot executable orders, sells-first, no trade on unchanged target. - `6459015` Task 5 `backtest_engine.py` — event processor; refuses live-board fallback without a PIT scorer. - `c87767c` Task 6 `backtest_store.py` + strict `POST /api/v1/backtest/run` (readiness-gated) + `GET /api/v1/backtest/run(+)`. - `d73a58b` Task 7 frontend — readiness gate disables Run + shows missing coverage; detailed report. ### Verification - Standalone offline suite **54 tests** (readiness 10, events 14, ledger 15, rebalancer 5, engine 6, store 4). compileall + diff-check clean. - Static scan: no secrets/eval/debug (4 "secret" regex hits are the prose "missing token" string). - Live readiness on real data: `ready=false`, missing factor+siamchart, recommended_end=2026-08-27. - Live strict run on real data (no vintages): `POST /api/v1/backtest/run` → **400** + missing list (fail-closed). - Happy-path `scripts/probe_event_backtest.py` (synthetic PIT-complete): accounting_reconciled=True, identity lhs=rhs, leakage_guard=True, 100-lot holdings. - Independent reviews: cycle-1 `deleg_5225e9de` flagged 3 logic errors → fixed (engine fails closed without PIT scorer; readiness blocks explicit start before price history; coincident-day ex-date entitlement fixed by per-day ordering). Cycle-2 `deleg_b969075c` re-verified the fallback fix + 9/10 invariants, re-confirmed the invariant-4 breach. Cycle-3 `deleg_7f56df3b` confirmed the ordering fix. Cycle-4 `deleg_55f4e877` final review returned **passed=true**, empty logic_errors/security_concerns (suggestion: per-symbol price coverage at the exact intersection start — deferred edge case). - Evidence: `docs/test-evidence/2026-08-28-event-driven-backtest.md`. ### Honest scope / limitations - Current on-disk data has **no factor vintages / Siamchart manifest** → strict event backtest is blocked on real data until collection accumulates (this is the intended fail-closed behavior, matching "strict PIT or block"). - **Auto-collection is now live** (commit `1fb1e1a`): the scheduler writes PIT factor + Siamchart vintages every refresh on whichever machine the app runs, so strict runs become ready once enough data is knowable. Because `released_at=retrieved_at=now`, `ready=true` currently requires an explicit `end=today` window (auto end=yesterday stays blocked until real history accumulates — honest, no assumed reporting lag). - **Per-source cadence + source health** (commit `14b2aef`): slow factors refresh monthly/quarterly (cooldown markers), prices + Siamchart daily; every refresh logs per-source outcome to `data/scheduler/source_health.json`, auto-classified (network/timeout/http/parse/structure/auth), shown in the "สถานะข้อมูล" panel with one-click copy of the cause. - Dividend payment timing is the confirmed `ex_date + 30d` **assumption** (Siamchart gives ex-date+DPS, not pay date); every result exposes `dividend_timing=ex_date_plus_30d`. - Only the new `/api/v1/backtest/run` route is event-driven/strict; the legacy `POST /api/v1/backtest` remains as the descriptive non-PIT path. ## Exact next action Collect factor vintages + Siamchart snapshots over time (scheduler already seeds Siamchart baseline) until `/api/v1/backtest/readiness` reports `ready=true`, then run a real strict event backtest. Frontend already wires readiness → recommended dates. --- ## Session 2026-08-27 — Backtest Accounting Remediation **Branch:** `main` · **Base HEAD:** `b362cc3` · **Commit/push:** not performed ### Completed - Added RED/GREEN flat-price accounting invariants in `backend/tests/test_backtest.py`. - Corrected general backtest price P&L to compare ending pre-dividend equity with initial capital. - Included the final-holdings dividend-yield proxy in `final_value` and `net_return`. - Added machine-readable `dividend_method=final_holdings_yield_proxy`. - Updated the Vue backtest panel to label estimated dividends and warn when `leakage_guard=false`. ### Verified - RED: flat-price regressions failed with incorrect `price_pnl=20,000` and `49,000`. - GREEN: flat/no-dividend, flat/proxy-dividend, rising/no-dividend, and multi-rebalance accounting checks passed; targeted suite **11 passed**. - Full backend suite: **239 passed**. - Python compileall passed. - Vite production build passed. - npm audit reported 0 high-severity vulnerabilities. - Added-line security/dangerous-pattern scan and `git diff --check` passed. - Independent review cycle 1 failed closed on missing proxy/non-PIT disclosure in saved-run history. A fresh fix agent added `ประมาณการปันผล (Proxy)` and per-row `descriptive non-PIT` badges. Fresh final review `deleg_10918fed` inspected the current post-hardening diff and returned `passed=true` with empty security, logic, and suggestion arrays. ### Known limitations - `dividend_income` is still estimated from final holdings and final dividend yield; it is not a dated cash-flow ledger. - The default public backtest still applies current scores historically, so `leakage_guard=false` and the result remains descriptive non-PIT. - A supplied scorer toggles `leakage_guard=true`, but provenance and release-time semantics are not independently certified. - Backtest run history remains in process memory. ### Exact next action Implement dated dividend events and a provenance-validated PIT score provider before using results as strategy-performance evidence. Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-27 — Single-container Docker packaging (verified + pushed) **Branch:** main · **HEAD:** f9b8cd1 (pushed) ### Completed this session (verified + pushed) - **Dockerfile**: `python:3.11-slim` + nginx. Serves the PREBUILT `frontend/dist` SPA (committed bundle, no node/npm in the image), reverse-proxies `/api` → `127.0.0.1:5000`, `VOLUME [/app/backend/data]` for persistence (factor vintages, dividend ledger, forward runs, prices), HEALTHCHECK on `/api/v1/dashboard/summary`, exposes :80. - **deploy/nginx.conf**: SPA fallback + `/api` proxy + gzip. - **deploy/entrypoint.sh**: starts Flask (`HOST=0.0.0.0:5000`) + nginx in foreground, forwards INT/TERM. - **docker-compose.yml**: service `set50`, port `8080:80`, volume `./backend/data`, healthcheck. - **.dockerignore** (excludes .git/.venv/backend/data/node_modules/docs) + **.gitignore** now tracks `frontend/dist/` (prebuilt bundle); `backend/data/` stays untracked. ### Verified - Backend runtime: `test_client GET /api/v1/dashboard/summary` → 200 (the HEALTHCHECK path). - `sh -n deploy/entrypoint.sh`, compose YAML parses, all Dockerfile-referenced files exist on remote `origin/main`. ### Honest caveat - **No local docker on this machine** → the image itself was NOT built/run here. The first real build happens on EasyPanel (or any docker host). If the build fails there, the most likely causes are env-var / volume mapping, which the compose file already covers for self-host. ### Exact next action (user-side) 1. On EasyPanel create a service from this repo (build type **Dockerfile** — not Nixpacks). 2. Mount a **persistent volume** at `/app/backend/data` (or set `DATA_DIR` env to a mounted path) so the dividend ledger / factor vintages / forward runs survive redeploys. 3. Publish port **80** (default). Add a webhook in Gitea → EasyPanel deploy URL on push to `main` (Gitea Webhook, not Gitea Actions — no managed runners). 4. Optional: set `DIVIDEND_REFRESH_COOLDOWN_SECONDS` / `REFRESH_INTERVAL_SECONDS`. --- ## Session 2026-08-27 — Auto-refresh dated dividend ledger (verified + pushed) **Branch:** main · **HEAD:** 03195dc (pushed) ### Completed this session (verified + pushed) - `AppDataScheduler` now calls `_maybe_refresh_dated_dividends()` in `refresh_all` — a cooldown-gated (default 6h) fetch of real dated dividend history into `data/dividends/ledger.json`, so the ledger stays fresh automatically without manual `POST /api/v1/dividends/update` and without re-hammering Siamchart every refresh tick (dividend history changes only a few times a year). - `DIVIDEND_REFRESH_COOLDOWN_SECONDS` env toggle (default 21600). Network failure is non-fatal: the previous ledger stays intact. - **Tests**: cooldown fires once then skips, refetches after it elapses (2) — full backend **294 passed** (was 292). ### Verified - Backend **294 passed**; compileall, `git diff --check`, static scan clean. ### Exact next action None required — the SET50 alternative-data platform PIT/enabler work is complete. Optional UI-only polish: surface the dated-ledger synthesis (dated vs proxy) more explicitly in the backtest result card. Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-27 — Real dated dividend-history collector (verified + pushed) **Branch:** main · **HEAD:** f9973e8 (pushed `59c97b5..f9973e8`) ### Completed this session (verified + pushed) — the last PIT milestone - **Dated dividend collector** `backend/app/siamchart.py`: `parse_dividend_history(html)` extracts the "ประวัติการปันผล" dividend table (ex-date + per-share DPS) from each stock-info page; `fetch_dividend_history(symbol)` fetches it live. - **Ledger upgrade** `backend/app/dividend_ledger.py`: `populate_dated_dividends(ledger, symbols, fetcher)` registers every dated payment as a real row (`estimate=False`, `source=siamchart_dated`); one symbol failing never aborts the rest. - **API** `backend/app/__init__.py`: `DividendLedger` persisted at `data/dividends/ledger.json`; `POST /api/v1/dividends/update` fetches all snapshot symbols and saves; `use_ledger` backtests prefer the dated ledger (`dividend_method=dated_ledger`) and fall back to DPS estimates when unpopulated. - **Tests**: parser (4) + populate (2) — full backend **292 passed** (was 286). ### Verified (real network!) - **The sandbox HAS outbound network** (contrary to the earlier "needs external access" deferral): curl/https to siamchart.com returns 200. - Live `POST /api/v1/dividends/update`: fetched **49/49 symbols, 1410 dated payments** in ~11s. - Live `use_ledger` backtest then reports **`dividend_method=dated_ledger`** (real ex-date × qty) instead of DPS proxy. ### Honest scope / notes - Every snapshot symbol now has real dated dividend history (PTT: 45 rows from 2002; ADVANC: 51; AOT: 22; AWC: 6, etc.). - `use_pit` scoring remains non-PIT when a run is fed current-board scores; the dated ledger is orthogonal (cash-flow model), not a PIT-scorer flag. ### Exact next action All requested milestones are complete and pushed. Optional follow-ups: (a) schedule `/api/v1/dividends/update` into the data refresh loop, (b) surface the dated-ledger synthesis (dated vs proxy) more explicitly in the backtest UI. Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-27 — Factor-learning validation gate (verified + pushed) **Branch:** main · **HEAD:** ae814c3 (pushed `9700f6b..ae814c3`) ### Completed this session (verified + pushed) - **Validation gate** `backend/app/weight_learning.py`: `apply_validation_gate(train_ics, holdout_ics)` marks a factor `validated=True` only when total sample >= MIN_SAMPLE_PERIODS (12), each window >= its minimum, train AND holdout IC both beat baseline (BASELINE_IC=0) and agree in sign, and pooled |t| > MIN_IC_TSTAT (1.0). `FactorLearning` now carries `ic_train`/`ic_holdout`/`validated`/`gate_notes`. - **No auto-apply enforced**: `apply_weight_update` now keeps `new_weight == old_weight` for any unvalidated factor — weights only move after the gate passes with explicit evidence. - **Gated learner**: `learn_momentum_gated(...)` builds PIT momentum ICs then splits chronologically into train/holdout and applies the gate. Wired into `GET /api/v1/learning/momentum`, which now surfaces `ic_train`, `ic_holdout`, `validated`, `gate_notes`. - **Tests**: rewritten suite + gate (16) — full backend **286 passed** (was 280). ### Verified - Backend full suite **286 passed**; compileall, `git diff --check`, static scan clean. - Live probe on the current price archive: momentum `validated=false`, `ic_train=0.0711`, `ic_holdout=-0.1143`, `gate_notes=['IC not above baseline (0.0711/-0.1143)']`, `new_weight=None` — the honest outcome: momentum is not validated, weight stays unchanged. ### Honest scope / known limitations - `learn_momentum_gated` is the only factor with a real PIT series; macro/demographic factors are still unattributable (no vintages) and stay `blocked`. - The gate is intentionally strict; a validated factor merely *suggests* a weight delta — applying it to production is a separate, explicit step. ### Exact next action All six planned PIT/enabler milestones are complete and pushed. Remaining (deferred): a real per-stock **dividend-history collector** (siamchart `/stock-info//`) to upgrade symbols from `dps_annual_proxy` to `dated_ledger` (needs live network access). Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-27 — Real forward-test lifecycle (verified + pushed) **Branch:** main · **HEAD:** 80c6d79 (pushed `6d9c283` + `80c6d79`) ### Completed this session (verified + pushed) - **ForwardTestStore** `backend/app/forward_test.py`: durable, thread-safe JSON store of forward paper runs with an explicit lifecycle — `frozen` (signals snapshotted, immutable) → `executed` (fills 50/20/30 buckets at post-freeze prices) → `marked` (mark-to-market equity series) → `matured` (net_return finalised). Frozen signals are never re-readable/rewritten after creation, so later data cannot retroactively change the run's decision. - **API** `backend/app/__init__.py`: `GET /api/v1/forward(+)`, `POST /api/v1/forward` (create+execute, `use_pit` freeze option), `POST //mark`, `POST //mature`. Store extension backed by `data/forward/runs.json` (survives restarts). - **Frontend** `frontend/src/App.vue`: simulation tab's Forward mode now calls the real `/api/v1/forward` lifecycle (not the cosmetic `/api/v1/simulation` with a mode string), loads runs on mount, and renders a forward panel with status badge, non-PIT tag, holdings, and Mark / Mature per run. ### Verified - Backend full suite **280 passed** (was 273); new lifecycle tests (7). - compileall, `git diff --check`, static scan clean. - Live route probe (isolated store): create→execute (201, real holdings AWC/CRC/MINT/PTT), list, mark, mature all work; run persists across the probe. ### Honest scope / known limitations - The score source at CREATE time may be the current board (`non_pit=true`, tagged). A PIT scorer only marks `non_pit=false` when its scores assert `pit_meta.pit=true`. - Paper-only; never dispatches a real MT5 order. ### Exact next action Implement the **factor-learning validation gate** (PIT exposures + forward returns + Spearman IC + holdout/walk-forward + baseline, no auto-apply). Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-27 — PIT Siamchart vintage store (verified + pushed) **Branch:** main · **HEAD:** 887e9c9 (pushed) ### Completed this session (verified + pushed) - **Siamchart vintage store** `backend/app/siamchart_vintages.py`: append-only, hash-chained store of every collected Siamchart fundamental snapshot. `snapshot_at(as_of)` returns the newest snapshot whose `retrieved_at <= as_of` (anti-look-ahead, fail-closed when none). Deduplicates identical persists. - **Fundamental un-partialed** `backend/app/pit_scorer.py`: `PitScoreProvider` accepts `siamchart_store`; when wired, `siamchart_factor_view(as_of)` reads the snapshot knowable at as_of (`pit_grade='pit'`) instead of the current snapshot (`pit_grade='current'`). `score_board` sets `partial_pit` only when no store is present; theme dimension still fails closed (`pit=false`) unless every theme factor has a released PIT value by as_of. - **API** `backend/app/__init__.py`: `/api/v1/backtest use_pit` seeds the first vintage from the on-disk snapshot (idempotent) and wires the store. - **Tests**: store (6) + provider-with-store anti-look-ahead (1) — full backend **273 passed** (was 266). ### Verified - Backend full suite **273 passed**; compileall, `git diff --check`, static scan clean. - Live isolated probe: with siamchart store + one released factor, `score_board` returns `partial_pit=false` (fundamental PIT via store) and `pit=false` with a full `blocked_theme` list (themes still lack PIT releases — correct fail-closed). ### Honest scope / known limitations - Snapshot reconstruction is forward-only; no history before the first collected snapshot. - EPS year-keys inside a snapshot are not tied to calendar years, so EPS growth is latest-vs-prior, not fiscal-year-pinned. ### Exact next action Implement a real per-stock **dividend-history collector** (siamchart `/stock-info//`) that appends dated payments into `dividend_ledger` so symbols upgrade from `dps_annual_proxy` to `dated_ledger`. Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-27 — Dated dividend cash-flow ledger (verified + pushed) **Branch:** main · **HEAD:** 5b8b6d1 (pushed `068dff2` + `5b8b6d1`; backend `068dff2`, frontend `5b8b6d1`) ### Completed this session (verified + pushed) - **Dated dividend ledger** `backend/app/dividend_ledger.py`: per-symbol dated store with `ex_date` / `record_date` / `pay_date` / `per_share` / `source` / `estimate` flag, validation and persistence. `credit_dividends` credits `per_share × qty` once a payment is due (on/after ex-date and pay date); no ledger entry → no credit (fail closed). `build_dps_ledger` builds per-share estimate rows from the siamchart `ratios.DPS` (price-independent, a step up from the yield % × market-value proxy). - **Backtest integration** `backend/app/backtest.py`: `run_backtest` accepts `dividend_ledger`; `dividend_income` comes from the ledger when set and `dividend_method` reports `dated_ledger` (real rows) | `dps_annual_proxy` (estimate) | `final_holdings_yield_proxy` (legacy, kept when no ledger). `BacktestResult` gained a `dividend_method` field surfaced in `to_dict`. - **API** `backend/app/__init__.py`: `POST /api/v1/backtest` accepts `use_ledger`, wiring the DPS-built ledger (works independently and alongside `use_pit`). - **Frontend** `frontend/src/App.vue`: maps `dividend_method` to an honest Thai label (ปันผล (ตามวันจริง) / ประมาณการปันผล/หุ้น (DPS) / ประมาณการปันผล (Proxy)) and shows a **Proxy** badge whenever the dividend is an estimate, on both the result card and saved-run history rows. ### Verified - Backend full suite **266 passed** (was 255); new: ledger store/credit (9) + backtest ledger integration (2). - compileall, `git diff --check`, static secret/dangerous scan clean. - Live route probe: `use_ledger:true` → `dividend_method=dps_annual_proxy`, `dividend_income=4151.0` (per-share) vs default proxy `5041.96` (yield×value); price P&L unchanged (5802.89). - Frontend build passes (bundle `index-DDD-F_cZ.js`). ### Honest scope / known limitations - `build_dps_ledger` rows are **estimates** (no ex-date history in the snapshot) — `dividend_method=dps_annual_proxy`, flagged Proxy in UI. A symbol only reports `dated_ledger` once real ex-date/per-share history is collected for it. - Ledger credit is end-of-run on final holdings for this milestone (not mid-window on holdings-at-ex-date); a future pass can accrue per rebalance window as ex-date history arrives. ### Exact next action Add a real per-stock **dividend-history collector** (siamchart `/stock-info//`) that appends dated payments into the ledger so symbols can upgrade from `dps_annual_proxy` to `dated_ledger`. In parallel, add the **siamchart vintage chain** to un-partial the fundamental dimension. Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-27 — PIT factor store + partial PIT score provider (verified + pushed) **Branch:** main · **HEAD:** 1f630be (pushed `1b971ac..1f630be`) ### Completed this session (verified + pushed) - **PIT factor store** `backend/app/factor_vintages.py`: append-only, provenance-complete store with `observed_at` / `released_at` / `retrieved_at` and a SHA-256 canonical **hash chain** (tamper/reorder detectable). `value_at(as_of)` returns only rows whose `released_at <= as_of` — a real testable **anti-look-ahead** guarantee; no value released by `as_of` **fails closed** (None / `have_pit_value=False`). Provenance invariants enforced: `released >= observed`, `retrieved >= released`, non-finite / naive-timestamp rejected. - **Partial PIT score provider** `backend/app/pit_scorer.py`: `PitScoreProvider.score_board(as_of)` rebuilds the per-symbol board from PIT factor values (theme 60% dimension) plus a **partial** siamchart view (EPS growth from the 5-year series; current ratios flagged `partial_pit`). `make_pit_score_fn` adapts it to the engine's `ScoreFn`; date-only `as_of` normalized to midnight +07:00. - **Honest leakage gating** `backend/app/backtest.py`: `_resolve_scores` now sets `leakage_guard=True` **only** when the supplied score_fn's per-symbol meta asserts `pit_meta.pit=True`. An arbitrary callable with no PIT proof is no longer treated as PIT — closes the "supplied fn ⇒ PIT" hole. - **API** `backend/app/__init__.py`: `POST /api/v1/backtest` accepts `use_pit`, wiring the PIT provider; `_load_siamchart_snapshot` loads the SET50 fundamental snapshot. Empty factor store **fail-closes** (live probe: `leakage_guard=false`). ### Verified - Backend full suite **255 passed** (was 239); new tests 14 (factor store 9 + pit scorer 5) + 2 backtest leakage-gating + 1 corrected. - compileall, `git diff --check`, static secret/dangerous scan all clean. - Live route probe: `use_pit` with empty store → HTTP 200, `leakage_guard=false`, real 2-rebalance run. ### Honest scope / known limitations - Theme dimension is PIT **from this store forward**; **no factor history exists before 2026-08-27**, so any pre-today backtest remains non-PIT by construction. - Siamchart fundamental is **partial**: EPS 5-year growth is PIT-grade, but ratios (Yield %, PE, P/BV, ROE, DPS) are current-snapshot only and the snapshot has no dated vintage chain yet. Results are flagged `partial_pit`, never fully-PIT. - `dividend_income` still uses the final-holdings yield proxy (not a dated dividend ledger); that is the next milestone. ### Exact next action Implement the dated **dividend cash-flow ledger** (ex-date / record date / per-share / qty held) replacing the final-holdings proxy. After that (or in parallel), add a **siamchart vintage chain** so the fundamental dimension stops being partial. Do not reset or stage unrelated pre-existing working-tree changes. --- ## Session 2026-08-25 — Multi-theme + Simulation (appended) **Branch:** main · **HEAD:** aae1d13 (pushed `8a6991b..aae1d13`) ### Completed this session (verified + pushed) - 3 Thai alternative-factor themes: tourism (BOT), auto_credit (TradingEconomics Thai car sales), refining_energy (Thai Oil TOP quarterly) — all real Thai data, daily cache. - Theme registry + scoring 60/40 (theme / Siamchart fundamental); `GET /api/v1/themes` returns combined board + per-theme frequency (monthly/quarterly). - Capital-allocation simulation: `POST /api/v1/simulation` — 50/20/30 buckets, min 100 shares, bucket3 = highest dividend yield ignoring score (fix `aae1d13`), cash fallback, paper/backtest non-PIT label. - MT5 bridge interface (`app/mt5_bridge.py`): dry-run default; live dispatch needs `MT5_SEND_ORDERS=1` AND approval (Windows-only MetaTrader5). - Thai frontend: theme panel, stock board (Siamchart fundamentals + signal, dividend filter, sortable), simulation tab (capital input → 3 buckets), removed legacy Paper ledger section. - Dashboards fully localized to Thai (technical terms kept EN per user). ### Verified - Backend suite **185 tests OK**; compileall ok; static secret scan clean; `git diff --check` clean. - Independent reviews: `deleg_1682c760` (blocker: bucket3 ranked by score) → fixed → `deleg_43b165e0` passed=true. - Live: `/api/v1/themes` 200; `/api/v1/simulation` 200 (1M → b1 AOT/MINT/AWC, b3 CRC). ### Blockers / pending - Price universe = **8 symbols** (AOT,AWC,BEM,CPALL,CPN,CRC,MINT,PTT,SET50) from Yahoo snapshot; simulation can only allocate within these. **Next action: extend price collector to full SET50.** - `bot_regional` only north enabled (NE=955 is 0.0 BE-2570 placeholder); other regions pending. - M2.9 legacy files (prices.py, research.py, event_study.py, vintages.py, etc.) still **uncommitted/dirty** — unrelated, untouched this session. ### Exact next action Extend price-history collection to full SET50 universe so simulation allocates across all names (currently 8). Follow `engineering-handoff` at session end.