[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
This commit is contained in:
@@ -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)."""
|
||||
|
||||
190
backend/app/backtest.py
Normal file
190
backend/app/backtest.py
Normal file
@@ -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 <start> and holding until <end>?' 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -636,6 +669,61 @@ onMounted(loadDashboard)
|
||||
<div v-if="!simResult" class="empty-research">กด 'คำนวณการจัดสรร' เพื่อดูว่า 50/20/30 จัดสรรทุนของคุณไปที่หุ้นไหนบ้าง</div>
|
||||
</section>
|
||||
|
||||
<section class="panel backtest-panel" id="backtest">
|
||||
<div class="panel-header signal-header">
|
||||
<div>
|
||||
<div class="section-kicker">การย้อนทดสอบ</div>
|
||||
<h2>Backtest (ย้อนทดสอบ)</h2>
|
||||
<p class="panel-subtitle">กำหนดช่วงวัน แล้วระบบจัดสรร 50/20/30 ณ วันที่เริ่ม ลงทุนและถือจนถึงวันสิ้นสุด — สรุปกำไร/ขาดทุนจากราคา + เงินปันผล.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backtest-controls">
|
||||
<label>ตั้งแต่ <input type="date" v-model="btStart" /></label>
|
||||
<label>ถึง <input type="date" v-model="btEnd" /></label>
|
||||
<label>ทุน <input type="number" v-model.number="btCapital" step="100000" /></label>
|
||||
<label>ความถี่
|
||||
<select v-model="btFreq">
|
||||
<option value="monthly">รายเดือน</option>
|
||||
<option value="quarterly">รายไตรมาส</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary-btn" :disabled="btLoading" @click="runBacktest">{{ btLoading ? 'กำลังย้อนทดสอบ…' : 'รัน Backtest' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="btResult?.error" class="state-card error-state">{{ btResult.error }}</div>
|
||||
<div v-else-if="btResult" class="backtest-results">
|
||||
<div class="bt-kpi-grid">
|
||||
<div class="bt-kpi"><span>กำไรจากราคา</span><strong :class="pnlClass(btResult.price_pnl)">{{ formatNumber(btResult.price_pnl) }} บาท</strong></div>
|
||||
<div class="bt-kpi"><span>เงินปันผล</span><strong class="positive-text">{{ formatNumber(btResult.dividend_income) }} บาท</strong></div>
|
||||
<div class="bt-kpi"><span>มูลค่าสุดท้าย</span><strong>{{ formatNumber(btResult.final_value) }} บาท</strong></div>
|
||||
<div class="bt-kpi"><span>ผลตอบแทนสุทธิ</span><strong :class="pnlClass(btResult.net_return)">{{ (btResult.net_return * 100).toFixed(2) }}%</strong></div>
|
||||
</div>
|
||||
<div class="bt-meta muted-cell">Trades: {{ btResult.trades }} · ช่วง {{ btResult.start }} → {{ btResult.end }}</div>
|
||||
<div v-if="Object.keys(btResult.holdings || {}).length" class="bt-holdings">
|
||||
<strong>พอร์ตสุดท้าย:</strong>
|
||||
<span v-for="(qty, sym) in btResult.holdings" :key="sym" class="theme-tag">{{ sym }} {{ qty }} หุ้น</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-research">กำหนดช่วงวันแล้วกด 'รัน Backtest' เพื่อดูผล (กำไร/ขาดทุนจากราคา + ปันผล)</div>
|
||||
|
||||
<div v-if="btRuns.length" class="bt-history">
|
||||
<div class="section-kicker">ประวัติการย้อนทดสอบ</div>
|
||||
<table class="source-table">
|
||||
<thead><tr><th>#</th><th>ช่วง</th><th>ทุน</th><th>กำไรราคา</th><th>ปันผล</th><th>ผลตอบแทน</th><th>รันเมื่อ</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in btRuns.slice().reverse()" :key="r.id">
|
||||
<td>{{ r.id }}</td><td>{{ r.start }} → {{ r.end }}</td>
|
||||
<td>{{ formatNumber(r.capital) }}</td>
|
||||
<td :class="pnlClass(r.price_pnl)">{{ formatNumber(r.price_pnl) }}</td>
|
||||
<td class="positive-text">{{ formatNumber(r.dividend_income) }}</td>
|
||||
<td :class="pnlClass(r.net_return)">{{ (r.net_return * 100).toFixed(2) }}%</td>
|
||||
<td class="muted-cell">{{ r.ran_at ? formatDate(r.ran_at) : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 05 / บันทึกการวิเคราะห์ removed per user (redundant with theme-panel narratives) -->
|
||||
</template>
|
||||
</main>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user