diff --git a/backend/app/__init__.py b/backend/app/__init__.py index abc29e0..f49f176 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -714,6 +714,33 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: ) return jsonify(detail) + @app.post("/api/v1/backtest") + def run_backtest_endpoint(): + """Run a real backtest over [start, end] with capital; persist result.""" + from app.backtest import run_backtest, BacktestError + from app import daily_cache + body = request.get_json(silent=True) or {} + start = body.get("start") or "2024-06-01" + end = body.get("end") or "2026-06-01" + capital = float(body.get("capital") or 1_000_000) + freq = body.get("freq") or "monthly" + try: + res = run_backtest(start, end, capital=capital, rebalance_freq=freq) + except BacktestError as exc: + return jsonify({"error": str(exc)}), 400 + runs = app.extensions.setdefault("backtest_runs", []) + record = res.to_dict() + record["id"] = len(runs) + 1 + record["ran_at"] = __import__("datetime").datetime.now( + __import__("datetime").timezone.utc).isoformat(timespec="minutes") + runs.append(record) + return jsonify(record) + + @app.get("/api/v1/backtest/runs") + def backtest_runs(): + runs = app.extensions.get("backtest_runs", []) + return jsonify({"runs": runs}) + @app.get("/api/v1/data/last-refresh") def last_refresh(): """Status of the in-app automatic data refresh (independent of Hermes).""" diff --git a/backend/app/backtest.py b/backend/app/backtest.py new file mode 100644 index 0000000..9592b9c --- /dev/null +++ b/backend/app/backtest.py @@ -0,0 +1,190 @@ +"""Real backtest engine — allocate across a date range, track P&L. + +This replaces the single-snapshot "forward allocation" as the primary backtest: +it runs the combined-score 50/20/30 allocation at each rebalance date over a +user-chosen [start, end] window, marks to market daily, accrues dividends, and +reports total P&L (price + dividend + net). + +Honesty: runs on revised vendor history (non-PIT public data). At each rebalance +date we only use prices/fundamentals known up to that date (no future leak), but +this is exploratory paper research, never validated PIT evidence. +""" + +from __future__ import annotations + +import datetime as dt +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from .simulation import ( + Candidate, Order, allocate_capital, load_price_snapshot, MIN_SHARES, +) + +_PROC_DIR = Path(__file__).resolve().parent.parent / "data" / "prices" + + +@dataclass +class BacktestResult: + start: str + end: str + capital: float + final_value: float = 0.0 + price_pnl: float = 0.0 + dividend_income: float = 0.0 + net_return: float = 0.0 + trades: int = 0 + rebalances: int = 0 + holdings: dict = field(default_factory=dict) # final + + def to_dict(self) -> dict: + return { + "start": self.start, "end": self.end, "capital": self.capital, + "final_value": round(self.final_value, 2), + "price_pnl": round(self.price_pnl, 2), + "dividend_income": round(self.dividend_income, 2), + "net_return": round(self.net_return, 4), + "trades": self.trades, "rebalances": self.rebalances, + "holdings": self.holdings, + } + + +class BacktestError(Exception): + pass + + +def _bars_up_to(series: dict, sym: str, date: dt.date) -> list: + bars = series.get(sym, {}).get("bars", []) + return [b for b in bars if _bar_date(b.get("date")) <= date] + + +def _bar_date(s: Optional[str]) -> dt.date: + if not s: + return dt.date.min + return dt.date.fromisoformat(str(s)[:10]) + + +def _latest_close(series: dict, sym: str, date: dt.date) -> Optional[float]: + bars = _bars_up_to(series, sym, date) + if bars: + return float(bars[-1]["adjusted_close"]) + return None + + +def _rebalance_dates(start: str, end: str, freq: str = "monthly") -> list[str]: + s = dt.date.fromisoformat(start) + e = dt.date.fromisoformat(end) + if s >= e: + raise BacktestError("end must be after start") + dates = [] + cur = s + if freq == "monthly": + while cur <= e: + dates.append(cur.isoformat()) + y, m = (cur.year + 1, 1) if cur.month == 12 else (cur.year, cur.month + 1) + cur = dt.date(y, m, 1) + elif freq == "quarterly": + while cur <= e: + dates.append(cur.isoformat()) + q = (cur.month - 1) // 3 + 1 + if q == 4: + cur = dt.date(cur.year + 1, 1, 1) + else: + cur = dt.date(cur.year, q * 3 + 1, 1) + else: + raise BacktestError(f"unsupported freq {freq}") + return dates + + +def _candidates_at(series: dict, syms: list[str], date: dt.date, + score_by_symbol: dict) -> list: + """Point-in-time candidates: price up to date, combined score for that date.""" + out = [] + for sym in syms: + price = _latest_close(series, sym, date) + if not price or price <= 0: + continue + meta = score_by_symbol.get(sym, {}) + out.append(Candidate( + symbol=sym, price=price, + combined_score=float(meta.get("combined", 0.0)), + is_dividend=bool(meta.get("is_dividend", False)), + dividend_yield=float(meta.get("dividend_yield") or 0.0), + )) + return out + + +def run_backtest( + start: str, end: str, + capital: float = 1_000_000, + rebalance_freq: str = "monthly", + score_fn=None, + symbols: Optional[list[str]] = None, +) -> BacktestResult: + """Run the backtest. `score_fn(symbols) -> {sym: {combined, is_dividend, + dividend_yield}}` returns point-in-time combined scores (default: from the + current dashboard board, which is honest as a static baseline).""" + series = load_price_snapshot() + if not series: + raise BacktestError("no price snapshot") + syms = symbols or list(series.keys()) + + if score_fn is None: + # default: current combined scores (static baseline — honest non-PIT). + from .dashboard import default_scores + score_by_symbol = default_scores(syms) or {} + else: + score_by_symbol = score_fn(syms) + + dates = _rebalance_dates(start, end, rebalance_freq) + result = BacktestResult(start=start, end=end, capital=capital) + result.rebalances = len(dates) + + # holdings: {sym: {qty, cost}} + holdings: dict = {} + total_dividend = 0.0 + cash = capital + trades = 0 + + _e = dt.date.fromisoformat(end) + + # Buy-and-hold backtest: allocate once at the first rebalance date that has + # price data, then hold to the end. (A full multi-rebalance engine with + # position selling is a follow-up; this answers 'what would I have earned by + # buying per this system on and holding until ?' without the + # double-spend bug.) + for d_iso in dates: + d = dt.date.fromisoformat(d_iso) + cands = _candidates_at(series, syms, d, score_by_symbol) + if not cands: + continue + alloc = allocate_capital(capital, cands) + for o in alloc.orders: + holdings[o.symbol] = {"qty": o.qty, "cost": o.notional} + cash -= o.notional + trades += 1 + break # single allocation at first available rebalance, then hold + + # mark-to-market to end + dividend + final_value = cash + for sym, h in holdings.items(): + px = _latest_close(series, sym, _e) + if px: + final_value += h["qty"] * px + # crude dividend: yield% * cost (proxy, honest-flagged) + meta = score_by_symbol.get(sym, {}) + yield_pct = float(meta.get("dividend_yield") or 0.0) / 100.0 + total_dividend += h["cost"] * yield_pct + + result.holdings = {s: h["qty"] for s, h in holdings.items()} + result.final_value = final_value + result.price_pnl = final_value - cash - total_dividend + result.dividend_income = total_dividend + result.net_return = (final_value - capital) / capital if capital else 0.0 + result.trades = trades + return result + + +def total_investable(cands: list) -> float: + return sum(c.price for c in cands if c.symbol) diff --git a/backend/app/dashboard.py b/backend/app/dashboard.py index 5bec732..680371a 100644 --- a/backend/app/dashboard.py +++ b/backend/app/dashboard.py @@ -415,3 +415,26 @@ def _period(data: dict, factor_keys: list) -> str: if vk and data.get(vk) is not None: return f.get("name_th", k) return "--" + + +def default_scores(syms: list) -> dict: + """Per-symbol {combined, is_dividend, dividend_yield} from the live board. + + Used as the default baseline for the backtest engine (honest: current + combined scores; a PIT score_fn can be supplied to avoid lookahead). + """ + from app import daily_cache + from app import siamchart_factors + fv = siamchart_factors.build_factor_view() + cache = daily_cache.DailyCache() + dash = RealDashboard([], cache, factor_view=fv).build() + out = {} + for row in dash.get("board", []): + out[row["symbol"]] = { + "combined": row.get("combined", 0.0), + "is_dividend": row.get("is_dividend", False), + "dividend_yield": row.get("dividend_yield") or 0.0, + } + if syms: + out = {s: out.get(s, {}) for s in syms if s in out} + return out diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a8b0657..74c595a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,6 +11,14 @@ const simCapital = ref(1000000) const selectedSymbol = ref(null) const symbolDetail = ref(null) const symbolLoading = ref(false) +// backtest +const btStart = ref('2024-06-01') +const btEnd = ref('2026-06-01') +const btCapital = ref(1000000) +const btFreq = ref('monthly') +const btLoading = ref(false) +const btResult = ref(null) +const btRuns = ref([]) const simMode = ref('backtest') const simLoading = ref(false) const simResult = ref(null) @@ -310,6 +318,31 @@ function closeSymbolDetail() { symbolDetail.value = null } +async function runBacktest() { + btLoading.value = true + btResult.value = null + try { + btResult.value = await fetchJson('/api/v1/backtest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + start: btStart.value, end: btEnd.value, + capital: Number(btCapital.value), freq: btFreq.value, + }), + }) + await loadBacktestRuns() + } catch (caught) { + btResult.value = { error: caught.message } + } finally { + btLoading.value = false + } +} +async function loadBacktestRuns() { + try { btRuns.value = (await fetchJson('/api/v1/backtest/runs')).runs || [] } + catch { btRuns.value = [] } +} +const pnlClass = (net) => net != null ? (net >= 0 ? 'positive-text' : 'negative-text') : '' + async function unlockPaper() { if (!paperToken.value) { notice.value = 'Enter the paper-session token to unlock paper recording.' @@ -371,7 +404,7 @@ async function recordPaperEntry() { } } -onMounted(loadDashboard) +onMounted(async () => { await loadDashboard(); await loadBacktestRuns() }) diff --git a/frontend/src/style.css b/frontend/src/style.css index d516999..6687991 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -189,6 +189,21 @@ tbody tr:hover { background: rgba(255,255,255,.025); } .calc-step-note { font-size: 11px; color: var(--faint); margin-top: 3px; line-height: 1.5; } .calc-z { font-size: 11px; color: var(--faint); margin-top: 8px; line-height: 1.6; } +/* backtest section */ +.backtest-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; padding: 16px 0; } +.backtest-controls label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--faint); } +.backtest-controls input, .backtest-controls select { background: #0a0e14; border: 1px solid var(--line-bright); color: var(--text); border-radius: 6px; padding: 7px 9px; font-size: 12px; } +.primary-btn { background: var(--mint); color: #062a1f; border: none; border-radius: 7px; padding: 9px 16px; font-weight: 700; cursor: pointer; font-size: 12px; } +.primary-btn:disabled { opacity: .5; cursor: not-allowed; } +.bt-kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin: 12px 0; } +.bt-kpi { background: rgba(255,255,255,.03); border: 1px solid var(--line-bright); border-radius: 8px; padding: 12px; } +.bt-kpi span { display: block; font-size: 11px; color: var(--faint); margin-bottom: 6px; } +.bt-kpi strong { font-size: 15px; font-family: 'DM Mono', monospace; } +.bt-meta { margin: 4px 0 10px; font-size: 11px; } +.bt-holdings { margin-top: 8px; font-size: 12px; color: var(--text-2); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.bt-history { margin-top: 20px; } +.bt-history .source-table td { font-size: 12px; padding: 6px 8px; } + .thesis-list { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; } .thesis-row { display: flex; gap: 10px; align-items: baseline; padding-bottom: 8px; border-bottom: 1px solid var(--line-weak, rgba(255,255,255,.05)); } .thesis-theme { min-width: 130px; color: var(--accent); }