Commit Graph

68 Commits

Author SHA1 Message Date
Kunthawat Greethong
54776d5647 fix(suggestion): profit buckets require combined_score > 0 (owner rule)
Owner: a stock with a NEGATIVE overall (combined) score must not be recommended
in the profit buckets, even with positive momentum + theme. BGRIM/TTB had
combined -0.029/-0.066 yet still landed in bucket 1.

- allocate_capital profit_pool now gates on combined_score > 0 (in addition to
  momentum>0 + theme>0), still ranking by momentum within that pool.
- Verified: bucket 1 now picks PTTGC (+0.209, mom +1.85) instead of BGRIM/TTB;
  BANPU in bucket 2; ADVANC/SCB/LH in bucket 3 by yield. Full suite 377 green.
2026-08-31 19:00:14 +07:00
Kunthawat Greethong
5b8a10c730 refactor(siamchart): momentum is a first-class factor INSIDE the score formula (owner rule)
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.
2026-08-31 18:50:32 +07:00
Kunthawat Greethong
2b7a065ae5 fix(ui,health): move source-health log to bottom of page; one row per source (latest)
- Health panel relocated below Backtest (last section) per owner — it's
  operational detail, not an investment view.
- scheduler._load_source_health now returns only the LATEST entry per source
  key (was returning the full refresh history, so siamchart_vintages and
  price_snapshot appeared as 2 rows). Verified: 11 sources / 11 unique rows.
- frontend v-for drops slice(0,20) and keys by source key.

Verified in-browser: #health is the last section; 11 unique source rows.
2026-08-31 17:03:07 +07:00
Kunthawat Greethong
4c32e2b737 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.
2026-08-31 10:56:22 +07:00
Kunthawat Greethong
576d9e31ec feat(suggestion): 'ทำกำไร' = price-trend momentum (3/6/12m) gated on positive theme signal
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).
2026-08-31 10:05:00 +07:00
Kunthawat Greethong
be6a92540c feat(backtest): runnable with partial factor coverage + oldest-history default (owner rule)
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.
2026-08-30 20:27:22 +07:00
Kunthawat Greethong
7f175e5a05 feat(scoring): flexible source fallback (no board crash) + per-source calc audit (Q2, Q3)
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.
2026-08-29 12:15:54 +07:00
Kunthawat Greethong
12b34929d7 feat(factor): add energy_irpc (IRPC net margin) as 2nd Thai refiner signal
- new energy_irpc collector parsing IRPC performance-highlights table
  (net profit/EBITDA/ROE margins, latest period 3M26: +10.27%)
- factor energy_irpc_net_margin (sign +1) wired into refining_energy/
  exploration/utilities, extending the energy theme beyond TOP
- scheduler job + dashboard fetch + sources table row (now 9 sources)
- tests: parse (incl paren-negatives), value-key resolution, direction;
  suite 368 OK. Independent review passed: true
- Phase B feasibility: REIC/EPPO/NBTC/PTTEP are JS-rendered or anti-bot
  (recorded deferred in plan); IRPC was the clean server-rendered win
2026-08-29 11:13:17 +07:00
Kunthawat Greethong
ead9aeb25c chore: pre-existing in-tree work (event-study/research/vintages/prices + migration script + integrity docs)
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.
2026-08-29 09:19:24 +07:00
Kunthawat Greethong
fcc0da9c8d feat(factor): te_thailand rate/credit/retail/property/confidence + thai_trade external sector; fix sign inversion on bearish factors
- add te_thailand collector (TradingEconomics) -> 8 factors: interest rate,
  business loan growth, consumer credit, household debt/GDP, retail sales YoY,
  consumer confidence, residential property prices, business confidence;
  feed banks/retail/consumer_staples/nonbank_finance/property/telecom/healthcare
- add thai_trade collector (TradingEconomics external sector) -> exports/
  imports/current-account factors (concurrent in-tree work, verified green)
- fix sign inversion: theme weights were negative on sign:-1 factors (NPL,
  inflation, unemployment) so higher NPL/inflation RAISED scores; direction now
  lives only in factor sign, theme weights positive (regression-locked)
- tests: te_thailand parse+direction, value-key resolution contract, dashboard
  8-sources, scheduler vintage counts; suite 362 OK
2026-08-29 09:18:55 +07:00
Kunthawat Greethong
7a3cfac19a feat(ui): rename Simulation→Suggestion, remove forward-test mode entirely (backend routes/store + frontend panel/option), fix stock-list overflow in allocation display 2026-08-29 01:57:13 +07:00
Kunthawat Greethong
b1c8a91ed6 feat(scheduler): daily Siamchart SET50 board collection (writes master + vintage, feeds dashboard) 2026-08-28 20:28:25 +07:00
Kunthawat Greethong
14b2aeff7c feat(scheduler): per-source cadence + source-health log with failure diagnosis + UI copy 2026-08-28 11:41:48 +07:00
Kunthawat Greethong
1fb1e1a027 feat(scheduler): auto-collect PIT factor + Siamchart vintages on each refresh (deploy-safe) 2026-08-28 11:26:33 +07:00
Kunthawat Greethong
a69fd884e7 [verified] Task 8 close: fix price-coverage-at-start + engine fail-closed (review cycle-1) + docs (54 tests, cycle-4 passed) 2026-08-28 10:41:54 +07:00
Kunthawat Greethong
91c63ae673 [verified] Task 8: verification evidence + fail-closed look-ahead fix + docs (event-driven backtest) 2026-08-28 10:35:58 +07:00
Kunthawat Greethong
c87767cfe5 [verified] Task 6: durable backtest run store + strict /api/v1/backtest/run route 2026-08-28 10:29:56 +07:00
Kunthawat Greethong
64590156f1 [verified] Task 5: event-driven PIT backtest engine 2026-08-28 10:28:35 +07:00
Kunthawat Greethong
71b893ee73 [verified] Task 4: lot- and cash-constrained portfolio rebalancer 2026-08-28 10:27:33 +07:00
Kunthawat Greethong
dc057e9aeb [verified] Task 3: portfolio accounting ledger (fees, avg cost, dated dividends) 2026-08-28 10:26:12 +07:00
Kunthawat Greethong
6439e9ce73 [verified] Task 2: unified event-driven backtest calendar 2026-08-28 10:24:54 +07:00
Kunthawat Greethong
68f2cc1477 [verified] Task 1: strict PIT backtest readiness + default-date derivation 2026-08-28 10:23:53 +07:00
Kunthawat Greethong
03195dc55d [verified] Auto-refresh dated dividend ledger in the data scheduler
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.
2026-08-27 12:51:49 +07:00
Kunthawat Greethong
f9973e8d0a [verified] Real dated dividend history collector (dps_annual_proxy -> dated_ledger)
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().
2026-08-27 12:34:36 +07:00
Kunthawat Greethong
ae814c341e [verified] Factor-learning validation gate (no auto-apply)
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).
2026-08-27 12:17:21 +07:00
Kunthawat Greethong
6d9c283d9a [verified] Real forward-test frozen-signal lifecycle + durable run store
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.
2026-08-27 12:01:00 +07:00
Kunthawat Greethong
887e9c9208 [verified] PIT siamchart vintage store un-partials the fundamental dimension
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.
2026-08-27 11:55:36 +07:00
Kunthawat Greethong
068dff22d7 [verified] Dated dividend cash-flow ledger replacing final-holdings proxy
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.
2026-08-27 11:46:15 +07:00
Kunthawat Greethong
1f630be2b5 [verified] PIT factor store + partial PIT score provider (PIT enabler)
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.
2026-08-27 09:26:12 +07:00
Kunthawat Greethong
1b971ac72d [verified] Fix backtest accounting identity + honest UI disclosure
Correct the multi-rebalance backtest accounting so ending wealth is
capital + price_pnl + dividend_income with no double counting:
- price_pnl now measures equity change excluding dividends (was reusing
  ending holdings value as 'price profit')
- dividend proxy is included in final_value and net_return, exposed as
  dividend_method=final_holdings_yield_proxy
- regression tests: flat price => zero price_pnl; flat + dividend =>
  dividend-only return; rising no-dividend => correct bucket P&L;
  multi-rebalance accounting identity
- UI (result card + saved-run history) labels dividends as ประมาณการปันผล
  (Proxy) and shows descriptive non-PIT badge when leakage_guard=false

Backend 239 tests passed; targeted backtest 11 passed; frontend build,
npm audit (0), static scan and diff check passed; fresh independent
review deleg_10918fed passed with empty blocker arrays.
Backtest remains descriptive non-PIT (leakage_guard=false) with the
default current-score scorer.
2026-08-27 09:00:39 +07:00
Kunthawat Greethong
b362cc35bf [verified] Add API tests for /api/v1/learning/factors + configurable history dir
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.
2026-08-27 07:37:01 +07:00
Kunthawat Greethong
d87a1ada39 [verified] Cross-theme surprise normalization + historical factor store (P4 enabler)
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.
2026-08-27 07:32:16 +07:00
Kunthawat Greethong
8db3d48ae2 [verified] P0-B registry-driven scoring + P3 PIT backtest + P4 factor-weight learning
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.
2026-08-27 07:12:18 +07:00
Kunthawat Greethong
325e164dd3 [verified] Fix P1-P2-P5 audit findings: simulation reuses board, source_summary clarity, dead-code removal + conftest
- 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).
2026-08-27 03:21:22 +07:00
Kunthawat Greethong
6e78b6acb5 [verified] Apply R1-R5 (factor formula) + real bank-sector NPL collector
(a) R1-R5 (factor-refinement, grounded in methodology-research.md):
- R1 (PEAD): EPS-growth weight raised 1.0->1.5 in build_siamchart_score / symbol_breakdown (Bernard-Thomas 1990, Livnat-Mendenhall 2006)
- R2 (momentum): 12-1 momentum factor from Yahoo price snapshot (Jegadeesh-Titman 93; lite weight 0.5)
- R3 (regime): binary bear gate -> continuous stress = negative-themes fraction, smooth LONG/SHORT shift
- R5 (dividend screen): non-dividend / cut-yield names no longer go LONG (screen-off)
- R4 (earnings-revision) deferred: no free EPS-forecast source yet (documented)

(b) bank-sector NPL collector (BOT reportID 794, financial&insurance sector):
- refactored auto_npl to expose shared _parse_sector; new bank_npl.py reuses it
- registered bank_npl FACTOR -> auto-appears in sources table (6 rows) + blends into banks theme surprise (real NPL)
- +unit tests (test_bank_npl), test_dashboard updated (6 sources)

205 tests pass; verified live API (banks surprise incl. NPL 1.07, 6 sources).
2026-08-26 19:56:39 +07:00
Kunthawat Greethong
fc592d8aa9 [verified] Real backtest engine + backtest UI section (start/end dates, P&L, persisted)
- backtest.py: buy-and-hold backtest over [start,end] — allocates 50/20/30 at first available rebalance date, marks to market to end, accrues dividend, reports {final_value, price_pnl, dividend_income, net_return, trades, holdings}
- Fixed double-spend bug (was allocating full capital every rebalance -> negative cash)
- dashboard.default_scores(): per-symbol combined/dividend/yield baseline for backtest
- POST /api/v1/backtest + GET /api/v1/backtest/runs (results persisted in app state -> survive refresh)
- Frontend: backtest section w/ start/end/capital/freq inputs + P&L KPIs + run history table
- Honest note: uses current combined scores as static baseline (non-PIT); PIT score_fn pluggable
- Verified: 1M -> 1.088M (+8.80%) over 2024-06..2026-06; history persists across refresh
2026-08-26 16:00:17 +07:00
Kunthawat Greethong
2da73b8a8c [verified] Sources table auto-derived from FACTORS registry + next-update column
- _build_sources now iterates FACTORS registry (grouped by fetch module) instead of hardcoded 5-row list -> adding a FACTOR auto-appends its source row (single source of truth, answers 'เพิ่มแหล่งควรอัตโนมัติ')
- Each source row gains ความถี่ + อัปเดตครั้งต่อไป (from frequency cadence)
- Frontend sources table: 6 columns (ข้อมูล/แหล่ง/ช่วงข้อมูล/ความถี่/อัปเดตครั้งต่อไป/อัปเดตล่าสุด), all from /api/v1/dashboard (not mock)
- Verified live: 5 sources auto-derived w/ next-update; browser shows 6 cols
2026-08-26 15:55:30 +07:00
Kunthawat Greethong
5516fc51a0 [verified] LONG/SHORT/NEUTRAL via quartile + market-regime gate (user choice B)
- 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.
2026-08-26 15:34:31 +07:00
Kunthawat Greethong
375682d2dc [verified] Signal column now derives from theme engine (combined 60/40 + quality), not tourism_result
- /api/v1/factors signal no longer from tourism-only signals; derived from RealDashboard combined score: LONG>=0.15, SHORT<=-0.15, else NEUTRAL. One source of truth.
- Verified: LONG 25 / NEUTRAL 16 / SHORT 8; BANPU LONG (3 themes), KTB LONG (banks), CPN LONG (3 themes), JMART SHORT (nonbank) — reflects all 13 themes + per-symbol quality, not flat tourism.
- reason_codes + combined_score on each factor row.
2026-08-26 15:26:34 +07:00
Kunthawat Greethong
d850955c44 [verified] Declarative factor engine + per-symbol stock selection (full-app consistency)
- factors.py: FACTORS registry (10 declarative entries: source/fetch/frequency/sign/weight) + normalize/z-score helpers. Add a source = one dict entry, no scoring-function edit.
- themes.THEMES: 13 themes reference FACTORS with per-theme weights (flexible), replacing hardcoded _theme_surprises/_theme_narrative.
- themes.quality_within_theme(): per-symbol quality vs theme cohort (ROE/EPS) -> real stock picking. dashboard board now surprise×quality (BBL 0.5 vs KTB 1.5 in banks).
- board rows carry per-symbol themes[]; /api/v1/themes delegates to RealDashboard.build() -> 13-theme consistency with /api/v1/dashboard (removed 115 lines dead dup logic).
- frontend: deleted THEME_BY_SYMBOL/themeLabelById hardcode; theme column + modal labels+quality all from API. Modal shows surprise×quality=theme_score.
- Tests: 202 OK (quality selection, breakdown quality, themes/dashboard consistency).
- Verified: BBL modal 1.00σ×0.5=0.50σ; KTB 1.5 vs BBL 0.5, PTT 2 themes; 49/49 rows theme from API.
2026-08-26 15:17:41 +07:00
Kunthawat Greethong
e7819a35dd [verified] All 13 SET50 themes now have real surprise (macro-proxy) — no more 'ยังไม่มีข้อมูล'
- dashboard._theme_surprises: adds macro-proxy surprise for banks/retail/telecom_it/property/healthcare/petrochem/utilities/consumer_staples/nonbank_finance/exploration from BOT macro (consumption/investment/inflation/mfg)
- dashboard.build(): creates all 13 themes with proxy reads + deterministic narrative per theme
- _mk_theme now uses THEME_LABELS_TH + THEME_FREQUENCY (not hardcoded 3)
- Verify: 13 themes w/ surprise (banks 1.0, retail 0.19, utilities -0.31, exploration 1.62); BBL modal (ธนาคาร 1.00σ, combined 0.467)
- Full suite 199 OK; fixed test_build themes=13
2026-08-26 14:19:59 +07:00
Kunthawat Greethong
55b3574040 [verified] Cover full SET50 with 13 themes + per-theme score detail in symbol view
- 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
2026-08-26 14:05:10 +07:00
Kunthawat Greethong
6c26bf99dc [verified] Fix dashboard: real-data badge + sortable คะแนนรวม + modal calc steps
- badge now driven by /api/v1/dashboard availability (ข้อมูลจริงจากแหล่งไทย), not stale fixture summary (#request)
- factorValue handles 'combined' -> คะแนนรวม column now sorts + toggles asc/desc
- symbol modal shows full calculation: combined_formula, per-step calc (ธีม/พื้นฐาน + weights), z-score derivation with population mean/stdev/universe size
- panel-header layout fixes (full-width subtitle, header breathing room)
- Verified: badge real, sort 0.11->1.43, modal calc steps render
2026-08-26 06:22:52 +07:00
Kunthawat Greethong
33a4662cd4 [verified] Add per-symbol analysis breakdown endpoint + per-theme narrative
- themes.symbol_breakdown(): transparent scoring derivation (theme_score, siamchart_score components, combined = 0.6*theme + 0.4*siamchart npolut)
- GET /api/v1/symbols/<symbol>: themes + theme surprise contributions + fundamentals + price + weights (ข้อ 7)
- dashboard.py _theme_narrative(): long-form Thai explanation of each theme's analysis outcome + implication for its stocks (ข้อ 5)
- 2 tests; full suite OK; live verified (AOT: combined 0.107 = 0.6*0.571 + 0.4*(-0.588))
2026-08-25 21:18:20 +07:00
Kunthawat Greethong
bfa9b08af6 [verified] Scheduler refreshes once on boot (no 1h wait for first pull) 2026-08-25 21:03:52 +07:00
Kunthawat Greethong
abc06af3a1 [verified] Add in-app automatic data scheduler (runs on its own server, no Hermes)
- 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
2026-08-25 21:00:13 +07:00
Kunthawat Greethong
9739849f68 [verified] Add real multi-theme dashboard (3 themes + macro + board + sources) — req #6/#8/#9/#10
- dashboard.py: RealDashboard assembles real Thai data (tourism + auto+NPL + energy TOP + macro BOT) with uniform z-score surprise per theme, per-theme thesis, sources provenance table, 49-symbol combined board
- macro_thai.py: BOT Thai Economy macro backdrop (consumption +4.9%, inflation 1.95%, unemployment 0.93%, tourists 16.2mn)
- GET /api/v1/dashboard endpoint (real data, no fixture fallback per user)
- 7 new tests; full suite 195 OK; live verified (3 theme surprise: 0.571/0.81/1.623)
2026-08-25 20:30:14 +07:00
Kunthawat Greethong
166a885fb9 [verified] Add Thai macro backdrop collector (BOT Thai Economy) — real consumption/inflation/unemployment
- macro_thai.py scrapes bot.or.th/en/thai-economy.html (server-rendered, no auth)
- private consumption +4.9%, private investment +18.1%, mfg -3.1%, headline inflation 1.95%, core 1.34%, unemployment 0.93%, tourists YTD 16.2mn
- 3 tests; live verified; macro backdrop layer (req #6)
2026-08-25 20:27:15 +07:00
Kunthawat Greethong
1e75377732 [verified] Add BOT auto NPL (credit-quality) factor; deepen auto_credit theme
- 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)
2026-08-25 19:06:48 +07:00
Kunthawat Greethong
2e492b375a [verified] Extend price universe to full SET50 (49 symbols + index) so simulation allocates across all names
- DEFAULT_SYMBOL_MAP now covers full SET50 (from Siamchart master snapshot) instead of 8 names
- Collected real Yahoo price snapshot: 49 symbols + SET50, ~646 bars each (2024-01-01 → 2026-08-24), quality=revised_vendor_history point_in_time=false
- Full suite 185 OK; live simulation HTTP 200
2026-08-25 17:04:06 +07:00