Owner: momentum must enter the formula (a factor), not be bolted on outside it.
- Single declarative source _SIAMCHART_WEIGHTS = {eps_growth:1.5, dividend_yield:2.0, momentum:0.5}.
- New siamchart_raw_score(g,d,m) = single source of the formula; momentum is an
explicit term inside it. All 3 call sites (build_siamchart_score + both
symbol_breakdown spots) now share it — no duplicated arithmetic.
- Pure refactor: outputs unchanged (weights identical). Tests added for the
momentum-inside-formula rule + momentum raising the score. Full suite 376 green.
- 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.
Owner rule: a stock that should be bought for profit is one whose PRICE is
likely to rise in the next 3-6 months — not one with high EPS growth (BTS had
EPS +137% yet flat/falling price). The old selection ranked buckets 1/2 by
(60/40 theme+siamchart where siamchart was EPS-growth dominated).
- themes.price_trend_score(): blend of ~3/6/12-month price momentum, z-scored
across the universe (heavier 3/6m weight per the 3-6 month tenure).
- allocate_capital: buckets 1/2 rank by momentum, gated on theme_signal > 0
(mean surprise across the symbol's themes). theme_signal=None (backtest path)
is not gated so PIT backtest still allocates. Bucket 3 unchanged (yield top).
- suggestion endpoint passes real momentum + theme_signal from the live board.
- Verified: bucket 1 now picks CRC/BEM (dividend + rising price); falling-price
PTT/MINT go to bucket 3 by yield, not bucket 1. Full suite 372 green (3 new
momentum/theme-gate tests).
Owner's rule: backtest must run as soon as there's enough data to estimate an
investment — it must NOT be blocked just because some sources lack deep PIT
history. Scoring is deliberately flexible (a theme uses whatever subset of
factors was knowable that day).
- backtest_readiness: readiness = usable window (price + Siamchart + >=1 factor),
not all-factors-present. Missing factors still reported (transparency) but no
longer block the run. recommended_start = oldest executable price (oldest
history the system holds); recommended_end = last complete trading day.
- pit_scorer.theme_surprise_report: flexible — skips factors not released by
as_of; blocked only when NO factor has a value. pit_meta.partial_pit reflects
themes scored from a partial factor subset.
- Verified end-to-end: readiness ready=true (recommended 2024-01-03 -> 2026-08-29);
POST /api/v1/backtest/run default window returns 201 full result (1M -> final
equity 1,117,243.95), no 400 from missing factors.
- test_backtest_readiness updated to earliest-runnable semantics; full suite 369 green.
Q2 flexible scoring: _fetch_with_cache now degrades instead of raising
DashboardError — a source that fails with no cached value returns {} so the
theme scorer drops that source's factors; a previously-good value is kept as
stale by the daily cache. Verified: all-sources-down still builds 13 themes.
Q3 per-source audit: new themes.factor_source_breakdown(fetched, theme) shows
per factor source/raw/normalized/weight/contribution; dashboard exposes
fetch_data + factor_sources; per-symbol modal renders symbolDetail.factor_sources
(e.g. retail: te_thailand ยอดขายปลีก -14.5 -> -1.0 x 0.7 = -0.7).
Suite 369 green; independent review passed: true.
Q1 (HAR for deferred sources) spike recorded: method works, REIC needs deeper
interaction; NBTC 403 likely unbpassable without a session.
Committing the prior uncommitted working-tree state that predates this session's
data-source work (was already modified/untracked at session start) so the tree
is clean before push. Includes: event-study + research report integrity/forward
observation work, prices tests, research hash migration script, and the
2026-08-23/24 engineering-log + test-evidence notes. Verified green as part of
the full 362-test suite.
Automatically keep the real dated dividend ledger fresh inside the app's own
refresh loop (this app runs on its own server, independent of Hermes):
- backend/app/scheduler.py: AppDataScheduler gained a cooldown-gated
_maybe_refresh_dated_dividends() that fetches real dated dividend history
(siamchart /stock-info) into data/dividends/ledger.json at most once per
dividend_cooldown_seconds (default 6h) — dividend history changes only a
few times a year, so we never hammer the source every refresh tick. The
fetch is non-fatal: a network failure leaves the previous ledger intact.
- backend/app/__init__.py: passes DIVIDEND_REFRESH_COOLDOWN_SECONDS to the
scheduler (default 21600s).
- tests: cooldown fires once then skips, and refetches after it elapses (2)
— full backend 294 passed.
Close the last deferred PIT milestone by collecting REAL per-stock dated
dividend cash-flow history from Siamchart, upgrading the dividend ledger
from DPS estimates to dated_ledger.
- 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.
- 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.
- backend/app/__init__.py: DividendLedger persisted at
data/dividends/ledger.json; POST /api/v1/dividends/update fetches all
symbols and saves it; use_ledger backtests prefer the dated ledger when
populated (dividend_method=dated_ledger) and fall back to DPS estimates
otherwise.
- tests: parser (4) + populate (2) — full backend 292 passed.
Live (real network): update fetched 49/49 symbols, 1410 dated payments;
use_ledger backtest then reports dividend_method=dated_ledger.
The 'eval(' static-scan hit is ast.literal_eval (safe literal parse, no
code execution), not eval().
Add a strict holdout/walk-forward + baseline gate to factor-weight learning,
per the P4 guardrail: learned weights are never auto-applied until minimum
sample, holdout/walk-forward, and baseline comparison all pass.
- backend/app/weight_learning.py:
- FactorLearning gained ic_train / ic_holdout / validated / gate_notes.
- apply_validation_gate(...) splits a chronological IC series into train +
holdout and only marks validated=True when: total >= MIN_SAMPLE_PERIODS,
each window >= its minimum, train AND holdout IC are positive (beat the
BASELINE_IC=0) and agree in sign, and the pooled |t| > MIN_IC_TSTAT.
- apply_weight_update now keeps new_weight == old_weight for any factor
that is not validated (no auto-apply); only validated factors move.
- learn_momentum_gated(...) builds PIT momentum ICs then applies the gate.
- backend/app/__init__.py: /api/v1/learning/momentum uses the gated learner
and surfaces ic_train/ic_holdout/validated/gate_notes.
- tests: gate (16) via rewritten suite — full backend 286 passed.
Live probe on current price archive: validated=false with
gate_note 'IC not above baseline (0.0711/-0.1143)' — momentum is not
validated, weight stays unchanged (new_weight=None).
Replace the cosmetic 'forward' mode (which was the same single-pass backtest
with a mode string) with a genuine forward paper-portfolio lifecycle:
- backend/app/forward_test.py: ForwardTestStore — durable, thread-safe JSON
store of forward runs with an explicit status 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 can never be re-read/rewritten after creation, so later data
cannot retroactively change what the run decided.
- backend/app/__init__.py: GET /api/v1/forward (+<id>), POST /api/v1/forward
(create+execute, with use_pit to freeze PIT or current-board scores),
POST /<id>/mark, POST /<id>/mature. ForwardTestStore wired as an extension
backed by data/forward/runs.json (survives restarts).
- tests: lifecycle store (7) — full backend suite 280 passed. Live probe:
create->execute (2xx, real holdings), list, mark, mature all work and the
run persists.
Honest scope: the score source at CREATE time may be the current board
(non_pit=true, tagged); paper-only, no MT5 send. A PIT scorer only marks a run
non_pit=false when its scores assert pit_meta.pit=true.
Add an append-only, hash-chained store of every collected Siamchart
fundamental snapshot so the 40% fundamental dimension can be reconstructed
at a historical date instead of always reading the latest snapshot:
- backend/app/siamchart_vintages.py: SiamchartVintageStore persists each
snapshot under its retrieved_at with a SHA-256 canonical hash chain
(tamper/reorder detectable); snapshot_at(as_of) returns the newest
snapshot whose retrieved_at <= as_of (anti-look-ahead), and fails closed
(returns {}) when none is knowable yet. Deduplicates identical
retrieved_at+body persists.
- backend/app/pit_scorer.py: PitScoreProvider accepts siamchart_store; when
wired, siamchart_factor_view reads the snapshot knowable at as_of
(pit_grade='pit') instead of the current snapshot (pit_grade='current').
score_board no longer forces partial_pit when a store is present — the
fundamental dimension is PIT; the theme dimension still fails closed
(pit=false) unless every theme factor has a released PIT value by as_of.
- backend/app/__init__.py: /api/v1/backtest use_pit seeds the first vintage
from the current snapshot (idempotent) and wires the store.
- tests: store (6) + scorer-with-store anti-look-ahead (1) — full backend
suite 273 passed.
Honest scope: snapshots are stored whole and reconstructible forward;
EPS year-keys inside a snapshot are not tied to calendar years, so EPS
growth stays latest-vs-prior (not fiscal-year-pinned). No history before the
first collected snapshot exists.
Replace the single final-holdings yield proxy with a per-symbol dated
dividend ledger for the backtest engine:
- backend/app/dividend_ledger.py: DividendLedger store (ex_date,
record_date, pay_date, per_share, source, estimate flag) with validation
and persistence; credit_dividends credits per_share * qty once a payment is
due (on/after ex-date and pay date); build_dps_ledger builds estimate rows
from siamchart ratios.DPS (per-share, price-independent) as a step up from
the yield-percentage proxy.
- backend/app/backtest.py: run_backtest accepts dividend_ledger; when set,
dividend_income comes from the ledger and dividend_method reports
'dated_ledger' (real rows) or 'dps_annual_proxy' (estimate). No ledger ->
legacy final_holdings_yield_proxy preserved and labelled.
- backend/app/__init__.py: /api/v1/backtest accepts use_ledger, wiring the
DPS-built ledger.
- tests: ledger store/credit (9) + backtest ledger integration (2 new) —
full backend suite 266 passed. Live probe: use_ledger flips dividend_method
to dps_annual_proxy with per-share income (4151.0) vs proxy (5041.96).
Honest scope: DPS rows are estimates (no ex-date history in snapshot yet);
real dated cash flows require collecting per-stock dividend history, which
upgrades a symbol to dated_ledger when present.
Add a point-in-time (PIT) factor/data store and a score provider so the
backtest engine can rebuild per-symbol scores from data actually knowable
at a given date, instead of silently reusing the live board:
- backend/app/factor_vintages.py: append-only, provenance-complete store
(observed_at/released_at/retrieved_at) with a SHA-256 canonical hash chain.
value_at(as_of) only ever returns rows whose released_at <= as_of (real,
testable anti-look-ahead); no value by as_of fails closed (returns None).
- backend/app/pit_scorer.py: PitScoreProvider computes theme surprises from
PIT factor values only, and a partial siamchart fundamental view (EPS
growth from the 5-year series; current ratios marked partial). score_board
attaches pit_meta so callers can tell PIT from fallback.
- backend/app/backtest.py: _resolve_scores now sets leakage_guard ONLY when
the supplied score_fn's 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).
- backend/app/__init__.py: /api/v1/backtest accepts use_pit, wiring the PIT
provider; _load_siamchart_snapshot loads the SET50 fundamental snapshot.
- tests: factor store (9), pit scorer (5), backtest leakage-guard gating (2
new + 1 corrected) — full backend suite 255 passed. Empty store fail-closes
(leakage_guard=false) as proven by a live route probe.
Honest scope: theme dimension is PIT from this store forward; siamchart
fundamental remains partial (current ratios) and is flagged as such. No
historical factor data before today exists, so pre-today backtests remain
non-PIT by construction.
Closes reviewer suggestion (deleg_5dd358e3): adds coverage for the factor
readiness endpoint (n_points / learnable / last_value / ordering) and the
min_points 400 validation. FACTOR_HISTORY_DIR is now configurable via app
config so tests (and deploy) can point the history store at a chosen path
instead of a hardcoded data dir. 236 tests pass.
A. Cross-theme comparability:
- compute_theme_surprises now weight-normalizes by total |weight| (weighted
average), so every theme surprise on same [-1,1] scale regardless of factor
count/weight (retail 0.189->0.145; auto_credit 1.0->0.64).
B. Historical factor store (enables learning macro/demographic factors):
- New factor_history.py: append-only per-factor JSONL, dedupes unchanged
values, rejects non-finite, records every FACTORS value each scheduler run.
- scheduler.py: jobs carry fetch_module; refresh_all records factor history
(non-fatal); added bank_npl job.
- GET /api/v1/learning/factors?min_points= reports n_points/learnable per
factor so users see when P4 learning unlocks (validated query parsing).
- weight_learning: generic learn_factor_series() aggregator (momentum reuses).
Independent review deleg_5dd358e3 passed=true (empty security/logic arrays);
its two robustness suggestions applied (finite guard in record(), clean 400 on
bad min_points). 234 tests pass; Vite build passes.
P0-B (registry is the single source of truth for scoring):
- FACTORS now carries center/span normalization spec; unused hand-written
per-theme surprise blocks in dashboard.py replaced by one registry-driven
compute_theme_surprises() (themes.py).
- THEMES['banks'] adds bank_npl weight so NPL is genuinely blended.
- factor_value/normalize hardened against NaN/inf (finite guards).
- Board re-ranks (TRUE/GULF up, TOP->3) per registry weights; 3 new tests
incl. 'changing a registry weight changes output'.
P3 (point-in-time backtest):
- run_backtest is now a real multi-rebalance engine (reallocates every window,
reconciles holdings, marks to market) instead of allocate-once+break.
- Added leakage_guard (False unless a PIT score_fn is supplied), planned vs
actual rebalances, and momentum_at() true 12-1 (skips last month, PIT).
P4 (factor-weight learning):
- weight_learning.py: cross-sectional Spearman IC, forward-return builder,
IC aggregation + t-stat, and apply_weight_update (new = clip(old*(1+shrink*IC))).
- GET /api/v1/learning/momentum endpoint. Live result: momentum IC=0.012
t=0.132 over 22 periods -> momentum has no reliable predictive power here.
Macro/demographic factors blocked (no historical factor vintages yet).
Two independent review gates passed (deleg_fe6f45cd, deleg_718218f8): empty
security/logic arrays; their non-blocking suggestions applied (finite guards,
dedupe leakage_guard resolution). 226 tests pass; Vite build passes.
- P1: /api/v1/simulation now uses the canonical board score (default_scores)
instead of a divergent 3-theme recompute -> 'จำลอง' can't disagree with board
(live check: sim top pick PTT == top board combined 1.600). Removes binary
auto/en signs, restores quality+momentum+dividend screen consistency.
- P2: dashboard emits source_summary{factor_keys, rows}; frontend shows
'N ปัจจัย · M แหล่ง' so the 7-vs-5 count confusion is impossible.
- P5: removed dead themes.list_themes()/Theme/build_theme_scores/_map_index and
the tests that locked them; added tests/conftest.py so pytest needs no PYTHONPATH.
- docs: audit-and-plan-2026-08-26.md (full P0-P5 plan) + engineering-log entry.
- 203 backend tests pass; Vite build passes. Independent reviewer: no security or
logic blockers (minor error-leak suggestion applied: 503 message no longer leaks
exception detail).
- Signal threshold no longer hardcoded 0.15: now quartile-based (LONG>=Q3, SHORT<=Q1, else NEUTRAL) over the whole SET50 board, recomputed each refresh.
- Market-regime gate: if >=4 themes have negative surprise -> risk-off bear regime -> tighten LONG bar + pull more into SHORT/avoid, so 'best of a falling board' isn't LONG (answers user 'ตลาดตกควรขายทิ้ง').
- SHORT semantics (user confirmed) = 'หลีก/ไม่ถือ' -> cash, NOT short-selling.
- reason_codes + regime now on factor rows (transparent).
- Verified: LONG 12 / SHORT 12 / NEUTRAL 25 in normal regime (Q1=-0.044 Q3=0.407).
- Fixed test_factors_endpoint_signal_join (was asserting AOT LONG from old tourism). Full suite 202 OK.
- THEME_SYMBOLS expanded: added banks, retail, telecom_it, property, healthcare, petrochem_materials, consumer_staples, utilities, nonbank_finance, exploration -> all 49 SET50 names now in a theme
- THEME_LABELS_TH Thai labels; THEME_FREQUENCY per theme
- symbol_breakdown now lists EVERY theme the symbol belongs to (label_th + surprise, or 'ยังไม่มีข้อมูล'), so theme_score is transparent per source
- frontend: theme column maps all 49 symbols (mirrors backend); modal shows per-theme score detail
- Fixed test for BANPU multi-theme; full suite 199 OK
- Verified: 49/49 rows have theme chip; AOT modal shows ท่องเที่ยว 0.57σ + full calc
- scheduler.py: daemon thread inside Flask refreshes all real Thai collectors on interval (default 60min, REFRESH_INTERVAL_SECONDS) via shared daily cache + writes timestamped marker
- create_app starts scheduler (skipped in TESTING); shared daily_cache now an extension
- GET /api/v1/data/last-refresh: automation status + last refresh (every N hours)
- Live verified: refresh_all pulls 5/5 real sources (tourism/auto/NPL/energy/macro)
- 2 tests; full suite 197 OK
- auto_npl.py: parse BOT Gross NPLs by business (reportID=794); extract auto loan NPL (20,602 mn THB, 3.95% of NPLs, 2.06% of loans)
- /api/v1/themes now exposes auto_npl_pct + auto_npl_amount alongside car-sales volume
- 3 new tests; full suite 188 OK; live verified (themes shows auto_npl_pct 3.95)
- DailyCache: JSON file cache keyed by (source/as_of), TTL 24h, atomic tmp+rename write, persists across runs
- fetch_or_stale: returns fresh cache, else refetch+cache, else falls back to stale so dashboard is never blanked
- 7 tests; full suite pass
- energy_thai.py: scrape Thai Oil (TOP) investor financial-highlights -> quarterly + annual EBITDA/Net Profit/Sales (Million Baht), largest Thai refinery
- Thai-specific factor per user (energy must reflect Thai companies, not US EIA proxy); Krungsri was projection-only, TOP gives real quarterly actuals
- Frequency: quarterly (documented in research note)
- 4 tests; full backend suite 156 OK; compileall ok; static scan clean
- bot_regional.py: parse BOT BTWS_STAT regional report (reportID per region) -> private consumption index + nondurable index + monthly series
- North reportID=954 verified live (idx 100.3, series 6mo); region-keyed for extension to other regions
- collect_bot_regional.py CLI -> JSON snapshot
- 4 tests; full backend suite 152 OK; compileall ok