From c05b1d54b309e10cff464e004784d14dadc90858 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 19 Aug 2026 21:09:16 +0800 Subject: [PATCH] =?UTF-8?q?v1.2.0:=20=E6=96=B0=E5=A2=9E=E9=87=8F=E5=8C=96?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=A8=A1=E5=9D=97(6=E4=B8=BB=E6=B5=81?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=85=A8=E5=B8=82=E5=9C=BA=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?+=E5=8D=95=E8=82=A1=E5=87=80=E5=80=BC=E6=9B=B2=E7=BA=BF/?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=98=8E=E7=BB=86)=EF=BC=8C=E7=AD=96?= =?UTF-8?q?=E7=95=A5:=20=E5=8F=8C=E5=9D=87=E7=BA=BF/MACD/RSI/=E5=B8=83?= =?UTF-8?q?=E6=9E=97=E5=B8=A6/=E5=8A=A8=E9=87=8F/N=E6=97=A5=E7=AA=81?= =?UTF-8?q?=E7=A0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 66 +++++- database.py | 13 +- engine/strategies.py | 425 ++++++++++++++++++++++++++++++++++++++ seed_data.py | 9 + static/js/admin.js | 12 +- static/js/strategies.js | 148 +++++++++++++ templates/admin.html | 1 + templates/base.html | 1 + templates/strategies.html | 41 ++++ 9 files changed, 713 insertions(+), 3 deletions(-) create mode 100644 engine/strategies.py create mode 100644 static/js/strategies.js create mode 100644 templates/strategies.html diff --git a/app.py b/app.py index 1f2fb51..53a487c 100644 --- a/app.py +++ b/app.py @@ -50,6 +50,11 @@ def page_institutions(): return render_template("institutions.html", service=SERVICE_NAME, is_mock=IS_MOCK) +@app.route("/strategies") +def page_strategies(): + return render_template("strategies.html", service=SERVICE_NAME, is_mock=IS_MOCK) + + @app.route("/admin") def page_admin(): return render_template("admin.html", service=SERVICE_NAME, is_mock=IS_MOCK) @@ -479,11 +484,70 @@ def api_holdings_moves(): return jsonify({"quarter": latest, "increase": inc, "decrease": dec}) +# ------------------------------------------------------------------ 量化策略 +@app.route("/api/strategies") +def api_strategies(): + from engine.strategies import STRATEGIES, strategy_summary + items = [] + for key, cfg in STRATEGIES.items(): + items.append({"key": key, "name": cfg["name"], "icon": cfg["icon"], + "params": cfg["params"], "desc": cfg["desc"], "tags": cfg["tags"], + "summary": strategy_summary(key)}) + return jsonify({"items": items}) + + +@app.route("/api/backtest/market") +def api_backtest_market(): + from engine.strategies import market_rank + key = request.args.get("strategy", "ma_cross") + return jsonify({"items": market_rank(key)}) + + +@app.route("/api/backtest") +def api_backtest(): + """单股×单策略回测详情(从预计算表读取)""" + from engine.strategies import STRATEGIES + import json as _json + key = request.args.get("strategy", "ma_cross") + code = request.args.get("code", "") + if key not in STRATEGIES or not code: + return jsonify({"error": "参数错误"}), 400 + row = query_one("SELECT * FROM strategy_backtests WHERE strategy=? AND code=?", (key, code)) + if not row: + return jsonify({"error": "回测数据不存在,请先在数据管理页重建"}), 404 + stock = query_one("SELECT name, industry, board FROM stocks WHERE code=?", (code,)) + return jsonify({ + "strategy": key, + "config": STRATEGIES[key], + "code": code, "stock_name": row["stock_name"], "stock": stock, + "metrics": _json.loads(row["metrics"]), + "equity": _json.loads(row["equity"]), + "trades": _json.loads(row["trades"]), + "run_at": row["run_at"], + }) + + +@app.route("/api/backtest/rebuild", methods=["POST"]) +def api_backtest_rebuild(): + import threading + + def run(): + from engine.strategies import build_all + try: + build_all() + except Exception as e: + log.error("backtest rebuild fail: %s", e) + + threading.Thread(target=run, daemon=True).start() + return jsonify({"ok": True, "msg": "全市场回测重建已启动"}) + + # ------------------------------------------------------------------ 数据管理 @app.route("/api/admin/stats") def api_admin_stats(): tables = ("stocks", "stock_daily", "news", "institutions", "inst_ratings", - "fund_holdings", "watchlist", "analysis_cache", "market_index") + "fund_holdings", "watchlist", "analysis_cache", "analysis_history", + "strategy_backtests", "market_index") return jsonify({ "tables": {t: table_count(t) for t in tables}, "vector": { diff --git a/database.py b/database.py index aa77ba9..65d49e0 100644 --- a/database.py +++ b/database.py @@ -111,6 +111,17 @@ CREATE TABLE IF NOT EXISTS analysis_history ( ); CREATE INDEX IF NOT EXISTS idx_history_code ON analysis_history(code); +CREATE TABLE IF NOT EXISTS strategy_backtests ( + strategy TEXT NOT NULL, + code TEXT NOT NULL, + stock_name TEXT DEFAULT '', + metrics TEXT DEFAULT '{}', -- JSON:收益/回撤/夏普/胜率等 + equity TEXT DEFAULT '[]', -- JSON:[{date,value,bh}, ...] 净值曲线 + trades TEXT DEFAULT '[]', -- JSON:交易明细 + run_at TEXT DEFAULT (datetime('now','localtime')), + PRIMARY KEY (strategy, code) +); + CREATE TABLE IF NOT EXISTS market_index ( date TEXT PRIMARY KEY, sh REAL DEFAULT 0, -- 上证指数(点) @@ -177,7 +188,7 @@ def wipe_all(): """清空业务表(保留结构)+ 重置自增序列,用于重灌数据""" for t in ("stock_daily", "inst_ratings", "fund_holdings", "news", "institutions", "stocks", "watchlist", "analysis_cache", "analysis_history", - "market_index"): + "market_index", "strategy_backtests"): with db() as conn: conn.execute(f'DELETE FROM "{t}"') with db() as conn: diff --git a/engine/strategies.py b/engine/strategies.py new file mode 100644 index 0000000..25a833f --- /dev/null +++ b/engine/strategies.py @@ -0,0 +1,425 @@ +# -*- coding: utf-8 -*- +""" +量化策略引擎:主流策略信号生成 + 回测框架 +- 6 个主流策略:双均线 / MACD / RSI / 布林带 / 动量 / N日突破 +- 回测规则:收盘产生信号,次日开盘成交(全仓多头,避免未来函数) +- 指标:总收益 / 年化 / 最大回撤 / 夏普 / 胜率 / 盈亏比 / 交易次数 +- 结果持久化到 strategy_backtests 表,支持全市场批量回测 + +扩展新策略:在 STRATEGIES 注册 name/desc + 实现信号生成函数即可 +""" +import json +import math +import statistics + +from database import executemany, query_one + +# ===================================================================== 指标序列 +def ma_series(closes, n): + out = [] + s = 0.0 + for i, c in enumerate(closes): + s += c + if i >= n: + s -= closes[i - n] + out.append(s / n if i >= n - 1 else None) + return out + + +def ema_series(vals, n): + out = [] + k = 2 / (n + 1) + e = vals[0] if vals else 0 + for i, v in enumerate(vals): + e = v if i == 0 else v * k + e * (1 - k) + out.append(e) + return out + + +def macd_series(closes): + ema12 = ema_series(closes, 12) + ema26 = ema_series(closes, 26) + dif = [a - b for a, b in zip(ema12, ema26)] + dea = ema_series(dif, 9) + return dif, dea + + +def rsi_series(closes, n=14): + out = [None] * len(closes) + if len(closes) <= n: + return out + gains, losses = [], [] + for i in range(1, len(closes)): + chg = closes[i] - closes[i - 1] + gains.append(max(chg, 0)) + losses.append(max(-chg, 0)) + avg_g = sum(gains[:n]) / n + avg_l = sum(losses[:n]) / n + for i in range(n, len(gains)): + avg_g = (avg_g * (n - 1) + gains[i]) / n + avg_l = (avg_l * (n - 1) + losses[i]) / n + rs = 100 if avg_l == 0 else avg_g / avg_l + out[i + 1] = 100 - 100 / (1 + rs) + out[n] = 100 if avg_l == 0 else 100 - 100 / (1 + avg_g / max(avg_l, 1e-9)) + return out + + +def boll_series(closes, n=20, k=2.0): + mid, upper, lower = [], [], [] + for i in range(len(closes)): + if i >= n - 1: + seg = closes[i - n + 1:i + 1] + m = sum(seg) / n + sd = statistics.pstdev(seg) + mid.append(m); upper.append(m + k * sd); lower.append(m - k * sd) + else: + mid.append(None); upper.append(None); lower.append(None) + return mid, upper, lower + + +# ===================================================================== 策略注册 +STRATEGIES = { + "ma_cross": { + "name": "双均线金叉", + "icon": "📐", + "params": "MA5 / MA20", + "desc": "短均线MA5上穿长均线MA20买入(金叉),下穿卖出(死叉)。经典趋势跟踪策略。", + "tags": ["趋势"], + }, + "macd": { + "name": "MACD 金叉", + "icon": "🟢", + "params": "12/26/9", + "desc": "DIF 上穿 DEA 买入,下穿卖出。捕捉中线趋势拐点,过滤震荡噪音。", + "tags": ["趋势", "动量"], + }, + "rsi_rev": { + "name": "RSI 超买超卖", + "icon": "🔄", + "params": "RSI(14) 30/70", + "desc": "RSI 低于 30 超卖买入、高于 70 超买卖出。均值回归型反转策略。", + "tags": ["反转"], + }, + "boll": { + "name": "布林带回归", + "icon": "📦", + "params": "20日 / 2σ", + "desc": "价格跌破下轨买入、突破上轨卖出,赌价格向中轨回归。震荡市表现佳。", + "tags": ["回归"], + }, + "momentum": { + "name": "20日动量", + "icon": "🚀", + "params": "20日涨幅 / MA20", + "desc": "20日涨幅超阈值且站上MA20买入,跌破MA20卖出。顺势强者恒强。", + "tags": ["动量"], + }, + "breakout": { + "name": "N日新高突破", + "icon": "🧗", + "params": "20日高低点", + "desc": "收盘突破20日新高买入,跌破20日新低卖出。海龟式突破策略。", + "tags": ["突破"], + }, +} + + +def _signals(bars, key): + """生成信号列表 [{date, action:'buy'/'sell', close, reason}]""" + closes = [b["close"] for b in bars] + n = len(bars) + sigs = [] + + if key == "ma_cross": + ma5, ma20 = ma_series(closes, 5), ma_series(closes, 20) + prev_state = None + for i in range(n): + if ma5[i] is None or ma20[i] is None: + continue + state = ma5[i] > ma20[i] + if prev_state is not None and state != prev_state: + if state: + sigs.append({"date": bars[i]["date"], "action": "buy", "close": closes[i], + "reason": f"MA5({ma5[i]:.2f})上穿MA20({ma20[i]:.2f})金叉"}) + else: + sigs.append({"date": bars[i]["date"], "action": "sell", "close": closes[i], + "reason": f"MA5({ma5[i]:.2f})下穿MA20({ma20[i]:.2f})死叉"}) + prev_state = state + + elif key == "macd": + dif, dea = macd_series(closes) + prev = None + for i in range(n): + if dif[i] is None or dea[i] is None: + continue + state = dif[i] > dea[i] + if prev is not None and state != prev: + if state: + sigs.append({"date": bars[i]["date"], "action": "buy", "close": closes[i], + "reason": f"MACD金叉 DIF({dif[i]:.3f})上穿DEA({dea[i]:.3f})"}) + else: + sigs.append({"date": bars[i]["date"], "action": "sell", "close": closes[i], + "reason": f"MACD死叉 DIF({dif[i]:.3f})下穿DEA({dea[i]:.3f})"}) + prev = state + + elif key == "rsi_rev": + rsi = rsi_series(closes) + for i in range(1, n): + if rsi[i] is None or rsi[i - 1] is None: + continue + if rsi[i - 1] >= 30 and rsi[i] < 30: + sigs.append({"date": bars[i]["date"], "action": "buy", "close": closes[i], + "reason": f"RSI({rsi[i]:.1f})下穿30超卖"}) + elif rsi[i - 1] <= 70 and rsi[i] > 70: + sigs.append({"date": bars[i]["date"], "action": "sell", "close": closes[i], + "reason": f"RSI({rsi[i]:.1f})上穿70超买"}) + + elif key == "boll": + mid, upper, lower = boll_series(closes) + prev_state = None + for i in range(n): + if lower[i] is None: + continue + if closes[i] < lower[i]: + state = "buy" + elif closes[i] > upper[i]: + state = "sell" + else: + state = prev_state + if state != prev_state and state is not None: + if state == "buy": + sigs.append({"date": bars[i]["date"], "action": "buy", "close": closes[i], + "reason": f"收盘跌破下轨({lower[i]:.2f})"}) + else: + sigs.append({"date": bars[i]["date"], "action": "sell", "close": closes[i], + "reason": f"收盘突破上轨({upper[i]:.2f})"}) + prev_state = state + + elif key == "momentum": + ma20 = ma_series(closes, 20) + prev_state = None + for i in range(n): + if i < 20 or ma20[i] is None: + continue + mom = closes[i] / closes[i - 20] - 1 + state = "buy" if (mom > 0.03 and closes[i] > ma20[i]) else ("sell" if closes[i] < ma20[i] else prev_state) + if state != prev_state and state is not None: + if state == "buy": + sigs.append({"date": bars[i]["date"], "action": "buy", "close": closes[i], + "reason": f"20日动量{mom*100:+.1f}%且站上MA20"}) + else: + sigs.append({"date": bars[i]["date"], "action": "sell", "close": closes[i], + "reason": "跌破MA20止盈/止损"}) + prev_state = state + + elif key == "breakout": + N = 20 + for i in range(N, n): + window = closes[i - N:i] + if closes[i] > max(window) and closes[i] > closes[i - 1]: + sigs.append({"date": bars[i]["date"], "action": "buy", "close": closes[i], + "reason": f"突破{N}日新高({max(window):.2f})"}) + elif closes[i] < min(window): + sigs.append({"date": bars[i]["date"], "action": "sell", "close": closes[i], + "reason": f"跌破{N}日新低({min(window):.2f})"}) + + return sigs + + +# ===================================================================== 回测 +def backtest(bars, key): + """全仓多头回测。收盘信号 → 次日开盘成交。返回 metrics/equity/trades""" + signals = _signals(bars, key) + n = len(bars) + opens = [b["open"] for b in bars] + closes = [b["close"] for b in bars] + dates = [b["date"] for b in bars] + + # 信号按日期归类(同一天可能多个 buy/sell,取最后一个有效方向) + by_day = {} + for s in signals: + by_day[s["date"]] = s + + position = 0.0 # 持股数量(按买入价格折算) + cash = 1.0 # 初始资金=1 + trades = [] + entry = None + equity = [] + trade_idx = 0 + + for i in range(n): + d = dates[i] + # 当日开盘执行前一日收盘信号 + sig = by_day.get(d) + exec_price = opens[i] + if sig and sig["action"] == "buy" and position == 0: + position = cash / exec_price + cash = 0.0 + entry = {"date": d, "price": exec_price, "reason": sig["reason"]} + elif sig and sig["action"] == "sell" and position > 0: + cash = position * exec_price + position = 0.0 + if entry: + ret = (exec_price / entry["price"] - 1) + trades.append({"entry_date": entry["date"], "entry_price": round(entry["price"], 3), + "exit_date": d, "exit_price": round(exec_price, 3), + "return": round(ret * 100, 2), + "days": _day_diff(dates, entry["date"], d), + "reason": entry["reason"]}) + entry = None + + value = cash + position * closes[i] + equity.append({"date": d, "value": round(value, 4)}) + + # 期末仍持仓则平仓(按最后收盘) + if position > 0 and entry: + last = closes[-1] + cash = position * last + ret = last / entry["price"] - 1 + trades.append({"entry_date": entry["date"], "entry_price": round(entry["price"], 3), + "exit_date": dates[-1], "exit_price": round(last, 3), + "return": round(ret * 100, 2), "days": _day_diff(dates, entry["date"], dates[-1]), + "reason": entry["reason"] + "(期末平仓)"}) + position = 0.0 + equity[-1]["value"] = round(cash, 4) + + metrics = _calc_metrics(equity, trades, dates, bars) + # 买入持有基准 + bh = bars[0]["close"] + for e in equity: + e["bh"] = round(bars[n - 1]["close"] / bh, 4) if bh else 1.0 + return {"metrics": metrics, "equity": equity, "trades": trades} + + +def _day_diff(dates, start, end): + try: + from datetime import date + ds = date.fromisoformat(start) + de = date.fromisoformat(end) + return (de - ds).days + except Exception: + return 0 + + +def _calc_metrics(equity, trades, dates, bars): + n = len(equity) + final = equity[-1]["value"] if equity else 1.0 + total = final - 1 + years = n / 252.0 + ann = (final ** (1 / years) - 1) if (years > 0 and final > 0) else 0 + # 最大回撤 + peak, mdd = equity[0]["value"], 0.0 + for e in equity: + peak = max(peak, e["value"]) + mdd = min(mdd, (e["value"] - peak) / peak) + # 日收益 → 夏普 + rets = [] + for i in range(1, n): + prev = equity[i - 1]["value"] + if prev > 0: + rets.append(equity[i]["value"] / prev - 1) + sharpe = 0.0 + if rets and statistics.stdev(rets) > 0: + sharpe = statistics.mean(rets) / statistics.stdev(rets) * math.sqrt(252) + # 交易统计 + n_tr = len(trades) + wins = [t for t in trades if t["return"] > 0] + losses = [t for t in trades if t["return"] <= 0] + win_rate = len(wins) / n_tr if n_tr else 0.0 + gp = sum(t["return"] for t in wins) + gl = abs(sum(t["return"] for t in losses)) + pf = (gp / gl) if gl > 0 else (gp if gp > 0 else 0) + avg_hold = sum(t["days"] for t in trades) / n_tr if n_tr else 0 + # 基准(买入持有) + bh_ret = bars[-1]["close"] / bars[0]["close"] - 1 + return { + "total_return": round(total * 100, 2), + "annualized": round(ann * 100, 2), + "max_drawdown": round(mdd * 100, 2), + "sharpe": round(sharpe, 2), + "win_rate": round(win_rate * 100, 1), + "profit_factor": round(pf, 2), + "trades": n_tr, + "avg_hold_days": round(avg_hold, 1), + "benchmark": round(bh_ret * 100, 2), + "excess": round((total - bh_ret) * 100, 2), + "days": n, + } + + +# ===================================================================== 批量回测 +def run_one(code, name, bars, key): + """单只股票单策略回测,返回入库行""" + res = backtest(bars, key) + return { + "strategy": key, "code": code, "stock_name": name, + "metrics": json.dumps(res["metrics"], ensure_ascii=False), + "equity": json.dumps(res["equity"], ensure_ascii=False), + "trades": json.dumps(res["trades"], ensure_ascii=False), + } + + +def build_all(progress=None): + """全市场 × 全策略批量回测(覆盖写入 strategy_backtests)""" + from database import query, executemany + stocks = query("SELECT code, name FROM stocks") + rows = [] + for si, s in enumerate(stocks): + bars = query("SELECT date, open, high, low, close, volume FROM stock_daily " + "WHERE code=? ORDER BY date ASC", (s["code"],)) + if len(bars) < 30: + continue + for key in STRATEGIES: + rows.append(run_one(s["code"], s["name"], bars, key)) + if progress: + progress(si + 1, len(stocks)) + executemany( + "INSERT OR REPLACE INTO strategy_backtests(strategy, code, stock_name, metrics, equity, trades, run_at) " + "VALUES(?,?,?,?,?,?,datetime('now','localtime'))", + [(r["strategy"], r["code"], r["stock_name"], r["metrics"], r["equity"], r["trades"]) for r in rows]) + return len(rows) + + +def strategy_summary(key): + """某策略的全市场统计(用于列表页头部)""" + from database import query + rows = query("SELECT metrics FROM strategy_backtests WHERE strategy=?", (key,)) + if not rows: + return {} + best = None + sums = {"total": 0, "sharpe": 0, "win": 0, "n": 0} + for r in rows: + m = json.loads(r["metrics"]) + sums["total"] += m["total_return"] + sums["sharpe"] += m["sharpe"] + sums["win"] += m["win_rate"] + sums["n"] += 1 + if best is None or m["total_return"] > best["metrics"]["total_return"]: + best = {"code": None, "metrics": m} + # 顺便找最优个股(第二遍,轻量) + bcode, bname, bret = None, None, -1e9 + rows2 = query("SELECT code, stock_name, metrics FROM strategy_backtests WHERE strategy=?", (key,)) + for r in rows2: + m = json.loads(r["metrics"]) + if m["total_return"] > bret: + bret, bcode, bname = m["total_return"], r["code"], r["stock_name"] + nn = sums["n"] + return { + "avg_return": round(sums["total"] / nn, 2) if nn else 0, + "avg_sharpe": round(sums["sharpe"] / nn, 2) if nn else 0, + "avg_win_rate": round(sums["win"] / nn, 1) if nn else 0, + "n": nn, + "best_code": bcode, "best_name": bname, "best_return": round(bret, 2) if bcode else None, + } + + +def market_rank(key, limit=100): + """某策略全市场收益榜""" + from database import query + rows = query("SELECT code, stock_name, metrics FROM strategy_backtests WHERE strategy=? ORDER BY run_at DESC", (key,)) + items = [] + for r in rows: + m = json.loads(r["metrics"]) + items.append({"code": r["code"], "name": r["stock_name"], **m}) + items.sort(key=lambda x: x["total_return"], reverse=True) + return items[:limit] diff --git a/seed_data.py b/seed_data.py index 09d76dd..7ff48aa 100644 --- a/seed_data.py +++ b/seed_data.py @@ -404,6 +404,7 @@ def build_vectors(news, stocks): def main(): parser = argparse.ArgumentParser() parser.add_argument("--skip-vector", action="store_true", help="跳过向量索引重建") + parser.add_argument("--no-strategies", action="store_true", help="跳过量化策略回测") args = parser.parse_args() print(">>> 初始化数据库 ...") @@ -461,6 +462,14 @@ def main(): else: print(" (跳过)") + print(">>> 量化策略全市场回测 ...") + if not args.no_strategies: + from engine.strategies import build_all, STRATEGIES + cnt = build_all() + print(f" 回测记录 {cnt} 条({len(STRATEGIES)} 策略 × 全市场)") + else: + print(" (跳过)") + from database import table_count print("=" * 50) print("数据库统计:") diff --git a/static/js/admin.js b/static/js/admin.js index 0058272..d7e4624 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -2,7 +2,8 @@ const tableNames = { stocks: '股票', stock_daily: '日线行情', news: '财经新闻', institutions: '机构', inst_ratings: '机构评级', fund_holdings: '基金持仓', watchlist: '自选股', - analysis_cache: '研报缓存', market_index: '市场指数' + analysis_cache: '研报缓存', analysis_history: 'AI分析历史', + strategy_backtests: '策略回测', market_index: '市场指数' }; async function refreshStats() { @@ -30,6 +31,15 @@ async function refreshStats() { } } +async function rebuildBt() { + if (!confirm('将重新跑全市场 6 策略 × 全部股票回测(几秒完成),确定?')) return; + try { + await api('/api/backtest/rebuild', { method: 'POST' }); + toast('回测重建已启动'); + setTimeout(refreshStats, 3000); + } catch (e) { toast('启动失败'); } +} + async function reseed() { if (!confirm('将清空全部业务数据并重新生成(含向量索引重建,需 1-3 分钟),确定继续?')) return; try { diff --git a/static/js/strategies.js b/static/js/strategies.js new file mode 100644 index 0000000..1688fde --- /dev/null +++ b/static/js/strategies.js @@ -0,0 +1,148 @@ +/* 量化策略回测页 */ +let curStrategy = 'ma_cross'; +let rankData = []; +let strategiesData = []; +let chart = null; + +async function loadStrategies() { + try { + const d = await api('/api/strategies'); + strategiesData = d.items || []; + $('#strategyPills').innerHTML = strategiesData.map((s, i) => + `` + ).join(''); + $$('#strategyPills .pill').forEach(p => p.onclick = () => { + const cfg = strategiesData.find(s => s.key === p.dataset.k); + selectStrategy(p.dataset.k, cfg); + }); + selectStrategy(strategiesData[0].key, strategiesData[0]); + } catch (e) { + $('#strategyPills').innerHTML = '
加载失败
'; + } +} + +function selectStrategy(key, cfg) { + curStrategy = key; + $$('#strategyPills .pill').forEach(p => p.classList.toggle('active', p.dataset.k === key)); + if (cfg) renderDesc(cfg); + loadMarket(key); +} + +function renderDesc(cfg) { + $('#stratDesc').innerHTML = `${cfg.icon} ${cfg.name}(${cfg.params}):${escapeHtml(cfg.desc)} ${cfg.tags.join(' / ')}`; + const s = cfg.summary || {}; + $('#stratSummary').innerHTML = ` +
全市场平均收益
${fmtPct(s.avg_return)}
+
平均夏普
${s.avg_sharpe ?? '--'}
+
平均胜率
${s.avg_win_rate ?? '--'}%
+
覆盖股票
${s.n ?? '--'}
+
最优标的
${s.best_name ? `${s.best_name} ${fmtPct(s.best_return)}` : '--'}
`; +} + +async function loadMarket(key) { + $('#rankTb').innerHTML = '加载中…'; + try { + const d = await api('/api/backtest/market?strategy=' + key); + rankData = d.items || []; + renderRank(); + // 默认选中第一名股票 + if (rankData.length) loadDetail(rankData[0].code); + else $('#rankTb').innerHTML = '暂无回测数据,请到数据管理页重建'; + } catch (e) { + $('#rankTb').innerHTML = '加载失败'; + } +} + +function renderRank() { + $('#rankHint').textContent = `(${rankData.length} 只股票)`; + $('#rankTb').innerHTML = rankData.map((it, i) => ` + + ${i + 1} + ${it.name}
${it.code}
+ ${fmtPct(it.total_return)} + ${fmtPct(it.benchmark)} + ${fmtPct(it.excess)} + ${fmtPct(it.max_drawdown)} + ${it.sharpe} + ${it.win_rate}% + ${it.profit_factor} + ${it.trades} + `).join(''); + // 同步下拉框 + fillStockSelect(); +} + +function fillStockSelect() { + const sel = $('#btStock'); + sel.innerHTML = rankData.map(it => ``).join(''); + sel.onchange = () => loadDetail(sel.value); +} + +async function loadDetail(code) { + const sel = $('#btStock'); + if (sel.value !== code) sel.value = code; + try { + const d = await api(`/api/backtest?strategy=${curStrategy}&code=${code}`); + renderDetail(d); + } catch (e) { + $('#btHead').innerHTML = '
加载失败
'; + } +} + +function renderDetail(d) { + const m = d.metrics, st = d.stock || {}; + $('#btHead').innerHTML = ` +
+
+ ${d.stock_name} ${d.code} · ${st.industry || ''} · ${st.board || ''} +
+ ${d.config.icon} ${d.config.name} · 回测 ${m.days} 个交易日 +
`; + $('#btMetrics').innerHTML = ` +
策略收益
${fmtPct(m.total_return)}
+
年化收益
${fmtPct(m.annualized)}
+
最大回撤
${fmtPct(m.max_drawdown)}
+
夏普比率
${m.sharpe}
+
胜率
${m.win_rate}%
+
盈亏比
${m.profit_factor}
+
交易次数
${m.trades}
+
买入持有
${fmtPct(m.benchmark)}
+
超额收益
${fmtPct(m.excess)}
`; + renderEquity(d.equity); + renderTrades(d.trades); +} + +function renderEquity(equity) { + const el = $('#equityChart'); + if (!window.echarts) { el.innerHTML = '
ECharts 加载失败
'; return; } + if (!chart) chart = echarts.init(el); + chart.setOption({ + backgroundColor: 'transparent', animation: false, + tooltip: { trigger: 'axis', valueFormatter: v => (v * 100).toFixed(1) + '%' }, + legend: { data: ['策略净值', '买入持有'], textStyle: { color: '#9da7b3' }, top: 0 }, + grid: { left: 50, right: 14, top: 28, bottom: 24 }, + xAxis: { type: 'category', data: equity.map(e => e.date), axisLabel: { color: '#6e7681' }, axisLine: { lineStyle: { color: '#262d3a' } } }, + yAxis: { type: 'value', name: '净值', scale: true, splitLine: { lineStyle: { color: '#1f2630' } }, axisLabel: { color: '#6e7681', formatter: v => (v * 100).toFixed(0) + '%' } }, + series: [ + { name: '策略净值', type: 'line', data: equity.map(e => e.value), showSymbol: false, lineStyle: { width: 2, color: '#f59e0b' }, areaStyle: { color: 'rgba(245,158,11,.08)' } }, + { name: '买入持有', type: 'line', data: equity.map(e => e.bh), showSymbol: false, lineStyle: { width: 1.5, color: '#3b82f6', type: 'dashed' } }, + ], + }, true); +} + +function renderTrades(trades) { + if (!trades.length) { $('#tradeBox').innerHTML = '
该策略在回测期内无交易信号
'; return; } + $('#tradeBox').innerHTML = ` + + ${trades.map(t => ` + + + + + + `).join('')} +
买入日期买入价卖出日期卖出价收益持有(天)信号
${t.entry_date}${t.entry_price}${t.exit_date}${t.exit_price}${fmtPct(t.return)}${t.days}${escapeHtml(t.reason)}
`; +} + +window.addEventListener('resize', () => chart && chart.resize()); +loadStrategies(); diff --git a/templates/admin.html b/templates/admin.html index 920337b..e22595b 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -17,6 +17,7 @@
系统维护
+
diff --git a/templates/base.html b/templates/base.html index 71ab0fb..6270697 100644 --- a/templates/base.html +++ b/templates/base.html @@ -30,6 +30,7 @@ 📊 仪表盘 🏢 股票池 🎯 荐股中心 + 📈 量化策略 📰 财经新闻 🏦 机构动向 ⚙️ 数据管理 diff --git a/templates/strategies.html b/templates/strategies.html new file mode 100644 index 0000000..2c4529e --- /dev/null +++ b/templates/strategies.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}量化策略{% endblock %} +{% block page_title %}量化策略回测{% endblock %} +{% block content %} +
+
+
加载策略中…
+
+
+
+
+ +
+
+
全市场回测榜
+
+ + + + + + +
排名股票策略收益基准超额最大回撤夏普胜率盈亏比交易
+
+
+
+
单股回测详情
+
+ +
+
+
+
+
交易明细
+
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %}