Initial commit: stock analysis backend and prototype UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-13 02:26:22 +08:00
commit 8de37d5c2d
25 changed files with 4624 additions and 0 deletions

78
backend/backtest.py Normal file
View File

@@ -0,0 +1,78 @@
"""基于中台日线的简易回测引擎(均线交叉策略)。
读取 quotes_daily金叉满仓 / 死叉空仓,输出资金曲线与核心指标。
"""
from __future__ import annotations
from sqlalchemy import select
from db import get_session
from models import DailyQuote
def _ma(arr, n):
out = [None] * len(arr)
s = 0.0
for i, v in enumerate(arr):
s += v
if i >= n:
s -= arr[i - n]
if i >= n - 1:
out[i] = s / n
return out
def run_backtest(symbol: str, fast: int = 5, slow: int = 20, fee: float = 0.0005):
with get_session() as s:
rows = s.execute(
select(DailyQuote.date, DailyQuote.close)
.where(DailyQuote.code == symbol)
.order_by(DailyQuote.date)
).all()
if len(rows) < slow + 5:
return {"ok": False, "msg": "该股票库内日线不足,请先在数据中台入库", "have": len(rows)}
dates = [r[0].isoformat() for r in rows]
close = [float(r[1]) for r in rows]
maf, mas = _ma(close, fast), _ma(close, slow)
equity, bench = [], []
cash, pos, shares = 1.0, 0, 0.0
trades, wins, entry = 0, 0, 0.0
peak, max_dd = 1.0, 0.0
base = close[0]
for i in range(len(close)):
if maf[i] is not None and mas[i] is not None:
if pos == 0 and maf[i] > mas[i]:
shares = cash * (1 - fee) / close[i]
cash, pos, entry = 0.0, 1, close[i]
trades += 1
elif pos == 1 and maf[i] < mas[i]:
cash = shares * close[i] * (1 - fee)
shares, pos = 0.0, 0
if close[i] > entry:
wins += 1
nav = cash + shares * close[i]
equity.append(round(nav, 4))
bench.append(round(close[i] / base, 4))
peak = max(peak, nav)
max_dd = max(max_dd, (peak - nav) / peak)
total_ret = equity[-1] - 1
bench_ret = bench[-1] - 1
closed = trades - pos
win_rate = (wins / closed) if closed > 0 else 0.0
return {
"ok": True, "symbol": symbol, "fast": fast, "slow": slow,
"dates": dates, "equity": equity, "bench": bench,
"metrics": {
"total_return": round(total_ret * 100, 2),
"bench_return": round(bench_ret * 100, 2),
"excess": round((total_ret - bench_ret) * 100, 2),
"max_drawdown": round(max_dd * 100, 2),
"trades": trades,
"win_rate": round(win_rate * 100, 1),
},
}