108 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
053b94b45f fix(ui): eliminate table/grid overflow breaking the frame at every width
Two root causes of tables and panels spilling past the window frame:

1. Global 'table { min-width: 850px }' forced EVERY table (source/score/modal/
   backtest) to 850px regardless of container -> now min-width:0 so tables
   shrink to 100%; only .factor-table (stock board, inside .table-wrap)
   keeps its 850px and scrolls. Small tables get table-layout:fixed +
   overflow-wrap on mobile so long content wraps instead of pushing out.

2. Grid/flex tracks lacked minmax(0,1fr) / min-width:0, so wide grid items
   (KPI numbers, theme cards) pushed the whole row past the viewport at
   768-1040px (doc overflow 208px). Fixed: all grid-template-columns use
   minmax(0,1fr); .content is now flex:1 1 auto + min-width:0; KPI/theme/bt
   cards get min-width:0 + overflow-wrap.

Verified programmatically in-browser: document overflow = 0 at 320/360/500/
600/720/760/768/820/900/1040/1200/1440px; score-table + source-table over=0
at all widths (incl inside the per-source modal). Board scrolls in .table-wrap
as designed. Backend suite 369 green (CSS-only change).
2026-08-30 09:10:21 +07:00
Kunthawat Greethong
de5dd29217 docs: record REIC deep-dive feasibility verdict (login-gated, empty XHR -> passed on) 2026-08-29 12:29:28 +07:00
Kunthawat Greethong
3010ab287f fix(ui): show theme weighted-average verification in per-source modal
After reviewer suggestion: per theme, display sum(contribution)/sum(|weight|) =
theme surprise so the owner can confirm the source audit matches the real score.
2026-08-29 12:18:40 +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
51400f20c7 docs: record IRPC review verdict (passed) + open no-fixture-fallback tradeoff 2026-08-29 11:14:02 +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
da671a9ba2 docs: mark additional-data-sources plan superseded (implemented via te_thailand + thai_trade) 2026-08-29 09:41:21 +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
dbb787c50a docs: data-source expansion plan + engineering log (te_thailand, sign fix, ui fix) 2026-08-29 09:19:06 +07:00
Kunthawat Greethong
9516a7a8e8 fix(ui): theme-card reads for new sources + padding/undefined-css-var fixes
- define missing design tokens (--card/--foreground/--accent/--font/--text-2)
  that theme/modal components referenced but :root never declared (they rendered
  transparent/wrong color)
- zero section-panel padding so .signal-header is the single top-spacing source
  (was double 22px+22px on theme/lineage/health/sim/backtest panels)
- theme cards now surface the new source reads (banks rate, retail sales YoY,
  nonbank household debt, property prices, telecom business confidence)
2026-08-29 09:19:01 +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
ffad4dfd8d fix(deploy): pin Flask to 5000 so it does not clash with nginx on EasyPanel PORT=80 2026-08-28 20:11:19 +07:00
Kunthawat Greethong
a17a8f3cc4 docs: per-source cadence + source-health log + UI (scheduler) 2026-08-28 11:42:11 +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
105ab86bf6 docs: record deploy-safe auto PIT vintage collection (scheduler) 2026-08-28 11:26:53 +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
17158742b7 docs: record cycle-4 passed=true + deferred per-symbol price edge case 2026-08-28 10:42:16 +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
d73a58b3d1 [verified] Task 7: frontend PIT readiness gate + event-driven backtest report 2026-08-28 10:32:10 +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
a7daf333b2 docs: record single-container Docker packaging session (2026-08-27) 2026-08-27 13:27:39 +07:00
Kunthawat Greethong
f9b8cd10b2 feat(deploy): single-container Docker packaging (nginx + Flask + prebuilt SPA)
Add self-contained Docker deployment for EasyPanel / docker-compose:

- Dockerfile: python:3.11-slim + nginx. Serves the PREBUILT Vue SPA
  (frontend/dist, committed) via nginx and reverse-proxies /api to the Flask
  backend on 127.0.0.1:5000. No node/npm in the image (avoids Vite/npm
  flakiness in Docker builds). VOLUME /app/backend/data for persistence
  (factor vintages, dividend ledger, forward runs, prices). HEALTHCHECK hits
  /api/v1/dashboard/summary. Exposes :80.
- deploy/nginx.conf: SPA fallback + /api proxy to 127.0.0.1:5000 + gzip.
- deploy/entrypoint.sh: starts Flask (HOST=0.0.0.0:5000) + nginx foreground,
  forwards signals.
- docker-compose.yml: single service 'set50', port 8080:80, mounts
  ./backend/data for persistence, healthcheck.
- .dockerignore excludes .git/.venv/backend/data/node_modules/docs.
- .gitignore now tracks frontend/dist/ (prebuilt bundle required by the
  image); backend/data stays untracked.
- Backend runtime verified: test_client GET /api/v1/dashboard/summary=200
  (the HEALTHCHECK path). entrypoint sh -n, compose YAML parse, and all
  Dockerfile-referenced files exist. NOTE: no local docker here, so the
  image itself was not built — that happens on EasyPanel.
2026-08-27 13:26:58 +07:00
Kunthawat Greethong
f3fad16183 feat(ui): surface dated_ledger vs proxy synthesis in backtest results
Backtest result card and saved-run history now show an explicit dividend
badge per run: green 'ตามวันจริง' for dated_ledger (real ex-date x qty cash
flow), amber 'Proxy (ต่อหุ้น)'/'Proxy' for estimates, plus a footnote that
matches the actual dividend_method. Frontend build passes (bundle
index-DDItjwYy.js); served bundle contains the new strings.
2026-08-27 12:54:44 +07:00
Kunthawat Greethong
28c111234e docs: record auto-refresh dated dividend ledger session (2026-08-27) 2026-08-27 12:52:10 +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
5667e96c40 docs: record real dated dividend collector session (2026-08-27) 2026-08-27 12:35:01 +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
59c97b5139 docs: record factor-learning validation gate session (2026-08-27) 2026-08-27 12:19:50 +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
9700f6b44f docs: record real forward-test lifecycle session (2026-08-27) 2026-08-27 12:09:22 +07:00
Kunthawat Greethong
80c6d792f4 feat(ui): real forward-test lifecycle panel (not cosmetic)
Forward mode in the simulation tab now calls the durable /api/v1/forward
lifecycle instead of the single-pass /api/v1/simulation (which only differed
by a mode string). runSimulation branches to /api/v1/forward for forward mode,
loads runs on mount, and renders a forward panel listing each paper run with
its status (frozen/executed/marked/matured), non-PIT badge, holdings and a
Mark / Mature action per run. Frontend build passes (bundle index-INuvPOCF.js).
2026-08-27 12:09:01 +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
5eeaed63ed docs: record PIT siamchart vintage store session (2026-08-27) 2026-08-27 11:55:55 +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
377771be65 docs: record dated dividend cash-flow ledger session (2026-08-27) 2026-08-27 11:48:17 +07:00
Kunthawat Greethong
5b8b6d12b7 feat(ui): disclose dividend method + honest estimate badge in backtest
Map dividend_method (dated_ledger / dps_annual_proxy / final_holdings_yield
_proxy) to an honest Thai label. Show a Proxy badge whenever the dividend is
an estimate (everything but dated_ledger) on both the result card and the
saved-run history rows, and set the footnote to match the actual method.
Frontend build passes.
2026-08-27 11:47:44 +07:00