Files
set50-system/.hermes/plans/2026-08-26_factor-engine-architecture.md

9.3 KiB
Raw Permalink Blame History

Architecture Plan — Declarative Factor/Theme Analysis Engine

Status: DRAFT — pending user approval (decision gate G0). Author: Macky. Date: 2026-08-26.

Goal

Rebuild the scoring system so that adding/removing a data source or factor is a config change, not an edit to scoring functions. Every theme gets a full analyze-and-select pipeline built from registered, declarative factors. This makes the platform easy to extend as the user keeps finding new Thai data sources.

Why this is needed (current debt)

Today the analysis is hardcoded:

  • dashboard._theme_surprises() — per-theme surprise logic written inline (tourism special, auto NPL composite, energy TOP, then macro-proxy branches).
  • dashboard._theme_narrative() — per-theme narrative strings hardcoded.
  • dashboard.build() — proxy_reads dict hardcoded.
  • App.vueTHEME_BY_SYMBOL + themeLabelById hardcoded (duplicate of backend).
  • __init__.py — old /api/v1/themes still builds only 3 themes.

Every new source = editing functions and strings in 3+ places → exactly the "จุดนึงเสร็จ จุดอื่นไม่ตาม" problem.

Target Architecture

                           ┌──────────────────────────────┐
                           │   FACTOR REGISTRY (config)   │
        ┌──────────────────▶  (declarative, data-driven)  │
        │                  └──────────────────────────────┘
        │   reads                                 reads
        ▼                                            ▼
┌───────────────┐   fetch/parse         ┌──────────────────────┐
│  COLLECTORS   │─────────────▶         │   FACTOR PIPELINE    │
│ (per source)  │   raw observable      │ surprise / z-score /  │
└───────────────┘                       │ frequency alignment  │
        ▲                               └──────────────────────┘
        │ registry.fetcher                         │ per-factor value
        └──┘                                       ▼
                                          ┌──────────────────────┐
                                          │   THEME SCORER       │
                                          │ factors ✕ weights   │
                                          │ ✕ firm_quality       │
                                          └──────────────────────┘
                                                    │ theme_score
                                                    ▼
                                          ┌──────────────────────┐
                                          │  COMBINE (60/40) +   │
                                          │  SELECT / board      │
                                          └──────────────────────┘
                                                    │
                                                    ▼
                                     /api/v1/themes, /api/v1/dashboard,
                                     /api/v1/symbols/<x>, frontend (read-only)

Key Decisions

Decision Choice Rationale Alternatives
Factor = declarative unit FACTORS registry: dict of {key, source, fetch_fn, frequency, sign, weight, periods} add source = add one dict entry hardcoded functions (rejected)
Theme = composition of factors THEMES registry: {id, label_th, symbol_map, factors:[{factor_key, weight}]} theme reuses factors; new theme = add entry inline per-theme logic (rejected)
Source of truth themes.py/factors.py registries (backend) one place; frontend reads via API App.vue hardcode (remove)
Per-symbol selection quality_within_theme(symbol, theme, factor_view) differentiates strong vs weak within a theme flat surprise (rejected)
Frequency alignment each factor carries frequency; scorer aligns by period (as-of semantics), never mixes timestamps user's explicit requirement naive mixing (rejected)

Decision Gates (one per milestone → user approves before code)

G0 — Approve declarative registry design

Acceptance: user approves FACTORS + THEMES registry schema & that adding a source = one config entry. Exit: proceed to B0.

G1 — Per-symbol selection works

Acceptance: two banks with different fundamentals get different theme_score within banks theme (BBL strong > TTB weak), unit-tested. Exit: proceed to frontend.

G2 — Frontend no longer hardcodes themes

Acceptance: edit themes.py (add theme/factor) → /api/v1/themes, /api/v1/dashboard, /api/v1/symbols/<x>, and the board column/modal all pick it up with zero frontend change. Exit: done.

Data Model

FACTORS (in backend/app/factors.py):

FACTORS: dict[str, dict] = {
  "tourism_arrivals_yoy": {
    "name_th": "นักท่องเที่ยว",
    "source": "BOT", "frequency": "monthly",
    "fetch": "bot_tourism",        # module name; returns dict with numeric value
    "value_key": "arrivals_yoy",   # key of the numeric value
    "sign": 1,                     # +1 = higher is bullish for theme
    "weight": 1.0,                 # (themes can override)
  },
  "auto_sales_yoy":      {"source":"TradingEconomics","frequency":"monthly","fetch":"auto_credit","value_key":"new_car_sales_yoy","sign":1},
  "auto_npl":            {"source":"BOT","frequency":"quarterly","fetch":"auto_npl","value_key":"pct_of_npls","sign":-1},
  "energy_top_netmarg":  {"source":"TOP","frequency":"quarterly","fetch":"energy_thai","value_key":"net_margin","sign":1},
  "macro_consumption":   {"source":"BOT","frequency":"monthly","fetch":"macro_thai","value_key":"private_consumption_yoy","sign":1},
  "macro_investment":    {"source":"BOT","frequency":"monthly","fetch":"macro_thai","value_key":"private_investment_yoy","sign":1},
  "macro_inflation":     {"source":"BOT","frequency":"monthly","fetch":"macro_thai","value_key":"headline_inflation_yoy","sign":-1},
  "macro_mfg":           {"source":"BOT","frequency":"monthly","fetch":"macro_thai","value_key":"manufacturing_yoy","sign":1},
}

THEMES (in backend/app/themes.py — keeps symbol_map + label):

THEMES: dict[str, dict] = {
  "auto_credit": {
    "label_th": "รถยนต์/สินเชื่อ", "frequency": "monthly",
    "factors": [{"key":"auto_sales_yoy","weight":1.0},{"key":"auto_npl","weight":-0.5}],
    "symbols": {...},
  },
  "banks": {
    "label_th": "ธนาคาร", "frequency": "quarterly",
    "factors": [{"key":"macro_investment","weight":1.0},{"key":"macro_inflation","weight":-0.3}],
    "symbols": {...},
  },
  ...
}

surprise per theme = weighted, sign-aware blend of its factors (each factor z-scored / normalized per its frequency cohort → frequency-aligned, no naive mixing).

theme_score per symbol = theme_surprise × quality_within_theme(symbol, theme, factor_view).

combined = 0.6 × theme_score + 0.4 × siamchart_score.

Module Boundaries

factors.py   — FACTORS registry + normalize/z-score helper (single source)
themes.py    — THEMES registry (symbol_map, labels, factor refs), quality_within_theme, theme_of, label_of, combine_score
dashboard.py — orchestrator: fetch registered factors once, compute surprises, build board/macro/sources (no per-theme hardcode)
collectors/  — bot_tourism, auto_credit, auto_npl, energy_thai, macro_thai (return dict, registered in FACTORS.fetch)
__init__.py  — endpoints only; /api/v1/themes delegates to dashboard.build(); /api/v1/symbols uses themes helpers
App.vue      — PURE read-only; theme column/labels/board from /api/v1/dashboard; NO hardcoded theme maps

Explicit Non-Goals (v1)

  • No live trading / MT5 execution (paper/backtest only, unchanged).
  • No new collectors yet — this refactor enables easy addition; sources are added as follow-up (user will name them).
  • No LLM involvement in scoring (deterministic only).
  • No changing the 60/40 or 50/20/30 business rules — only making them data-driven.

Open Questions (for G0)

  1. Should factor weights live per-theme (override) or global? → proposal: per-theme override with global default (flexible).
  2. Do we keep the current 3 real collectors as the first factors registered (yes), and new sources added later by the user?

Acceptance (definition of done — all gates pass)

  1. Adding a new factor = 1 dict entry in FACTORS + (optionally) a theme factor line — no scoring-function edit.
  2. /api/v1/themes == /api/v1/dashboard theme set (13, same labels/surprises) — consistency test.
  3. BBL vs TTB get different banks theme_score by quality — selection proof test.
  4. Frontend has NO hardcoded theme maps; column + modal label from API.
  5. Modal shows theme surprise × firm quality = theme score per symbol.
  6. npm run build + full backend suite pass; browser BBL > TTB when quality differs.