Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c05b1d54b3 | ||
|
|
4a9ea9ddd7 |
@@ -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)
|
||||
@@ -61,6 +66,12 @@ def page_stock(code):
|
||||
service=SERVICE_NAME, is_mock=IS_MOCK)
|
||||
|
||||
|
||||
@app.route("/analysis/<int:aid>")
|
||||
def page_analysis(aid):
|
||||
return render_template("analysis_detail.html", aid=aid,
|
||||
service=SERVICE_NAME, is_mock=IS_MOCK)
|
||||
|
||||
|
||||
# ===================================================================== 公共
|
||||
def _indicators(code):
|
||||
rows = query("SELECT date,open,high,low,close,volume FROM stock_daily "
|
||||
@@ -365,13 +376,32 @@ def api_analyze(code):
|
||||
def api_analyze_status(code):
|
||||
from engine import analyst
|
||||
st = analyst.report_status(code)
|
||||
if not st:
|
||||
cached = analyst.get_cached_report(code)
|
||||
if cached:
|
||||
return jsonify({"status": "done", "report": cached["report"],
|
||||
"cached": True, "created_at": cached["created_at"]})
|
||||
return jsonify({"status": "idle"})
|
||||
return jsonify(st)
|
||||
if st:
|
||||
return jsonify(st)
|
||||
# 无运行中任务:返回最近一次历史分析
|
||||
hist = analyst.list_history(code, limit=1)
|
||||
if hist:
|
||||
detail = analyst.get_history(hist[0]["id"])
|
||||
return jsonify({"status": "done", "report": detail["report"],
|
||||
"history_id": detail["id"], "created_at": detail["created_at"],
|
||||
"cached": True})
|
||||
return jsonify({"status": "idle"})
|
||||
|
||||
|
||||
@app.route("/api/stock/<code>/analyses")
|
||||
def api_stock_analyses(code):
|
||||
from engine import analyst
|
||||
return jsonify({"items": analyst.list_history(code)})
|
||||
|
||||
|
||||
@app.route("/api/analyses/<int:aid>")
|
||||
def api_analysis_detail(aid):
|
||||
from engine import analyst
|
||||
d = analyst.get_history(aid)
|
||||
if not d:
|
||||
return jsonify({"error": "记录不存在"}), 404
|
||||
stock = query_one("SELECT code, name, industry, board FROM stocks WHERE code=?", (d["code"],))
|
||||
return jsonify({"analysis": d, "stock": stock})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 自选
|
||||
@@ -454,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": {
|
||||
|
||||
+24
-1
@@ -100,6 +100,28 @@ CREATE TABLE IF NOT EXISTS analysis_cache (
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analysis_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL,
|
||||
stock_name TEXT DEFAULT '',
|
||||
focus TEXT DEFAULT '',
|
||||
report TEXT,
|
||||
sources TEXT DEFAULT '{}', -- JSON:大模型参考的数据源(RAG新闻/概况/指标/评级/持仓/提示词)
|
||||
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
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, -- 上证指数(点)
|
||||
@@ -165,7 +187,8 @@ def table_count(name):
|
||||
def wipe_all():
|
||||
"""清空业务表(保留结构)+ 重置自增序列,用于重灌数据"""
|
||||
for t in ("stock_daily", "inst_ratings", "fund_holdings", "news",
|
||||
"institutions", "stocks", "watchlist", "analysis_cache", "market_index"):
|
||||
"institutions", "stocks", "watchlist", "analysis_cache", "analysis_history",
|
||||
"market_index", "strategy_backtests"):
|
||||
with db() as conn:
|
||||
conn.execute(f'DELETE FROM "{t}"')
|
||||
with db() as conn:
|
||||
|
||||
+58
-1
@@ -59,6 +59,7 @@ def _rag_news(code, stock_name, query_text, top_k=6):
|
||||
"date": m.get("date", ""),
|
||||
"sentiment": m.get("sentiment", 0),
|
||||
"text": h.get("document", "")[:400],
|
||||
"distance": round(h.get("distance", 0), 3),
|
||||
})
|
||||
return out
|
||||
except Exception as e:
|
||||
@@ -140,7 +141,8 @@ def _build_prompt(stock, ind, news_hits, profile, ratings, holdings, score, focu
|
||||
|
||||
|
||||
def generate_report_sync(code, focus=""):
|
||||
"""同步生成报告(后台线程调用)"""
|
||||
"""同步生成报告(后台线程调用),并记录历史 + 数据源"""
|
||||
import json
|
||||
stock = query_one("SELECT * FROM stocks WHERE code=?", (code,))
|
||||
if not stock:
|
||||
return {"error": "股票不存在"}
|
||||
@@ -150,6 +152,17 @@ def generate_report_sync(code, focus=""):
|
||||
profile = _rag_profile(code)
|
||||
ratings, holdings = _inst_summary(code)
|
||||
prompt = _build_prompt(stock, ind, hits, profile, ratings, holdings, score, focus)
|
||||
# 记录大模型参考的数据源(供详情页展示)
|
||||
sources = {
|
||||
"focus": focus,
|
||||
"score": score,
|
||||
"indicators": _fmt_indicators(ind),
|
||||
"profile": profile or stock.get("description", ""),
|
||||
"news": hits,
|
||||
"ratings": ratings,
|
||||
"holdings": holdings,
|
||||
"prompt": prompt,
|
||||
}
|
||||
try:
|
||||
report = llm_chat([
|
||||
{"role": "system", "content": "你是一名严谨专业的A股投资顾问,输出结构化、简洁、可执行的研报。"},
|
||||
@@ -160,6 +173,10 @@ def generate_report_sync(code, focus=""):
|
||||
raise RuntimeError("LLM 返回为空")
|
||||
execute("INSERT OR REPLACE INTO analysis_cache(code, report, created_at) VALUES(?,?,datetime('now','localtime'))",
|
||||
(code, report))
|
||||
execute(
|
||||
"INSERT INTO analysis_history(code, stock_name, focus, report, sources, created_at) "
|
||||
"VALUES(?,?,?,?,?,datetime('now','localtime'))",
|
||||
(code, stock["name"], focus, report, json.dumps(sources, ensure_ascii=False)))
|
||||
return {"report": report, "ts": time.time()}
|
||||
except Exception as e:
|
||||
log.exception("gen report fail")
|
||||
@@ -221,3 +238,43 @@ def report_status(code):
|
||||
|
||||
def get_cached_report(code):
|
||||
return query_one("SELECT report, created_at FROM analysis_cache WHERE code=?", (code,))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 历史记录
|
||||
def list_history(code, limit=20):
|
||||
"""某股票的历史 AI 分析记录(不含正文,只返回摘要)"""
|
||||
rows = query(
|
||||
"SELECT id, code, stock_name, focus, created_at, sources, LENGTH(report) AS len, "
|
||||
"SUBSTR(report, 1, 60) AS excerpt FROM analysis_history "
|
||||
"WHERE code=? ORDER BY id DESC LIMIT ?", (code, limit))
|
||||
out = []
|
||||
for r in rows:
|
||||
try:
|
||||
import json
|
||||
src = json.loads(r.get("sources") or "{}")
|
||||
except Exception:
|
||||
src = {}
|
||||
out.append({
|
||||
"id": r["id"], "code": r["code"], "stock_name": r["stock_name"],
|
||||
"focus": r["focus"], "created_at": r["created_at"],
|
||||
"chars": r["len"], "excerpt": (r["excerpt"] or "").strip(),
|
||||
"news_count": len(src.get("news") or []),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_history(aid):
|
||||
"""单条分析详情:正文 + 数据源 JSON"""
|
||||
import json
|
||||
row = query_one("SELECT * FROM analysis_history WHERE id=?", (aid,))
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
src = json.loads(row.get("sources") or "{}")
|
||||
except Exception:
|
||||
src = {}
|
||||
return {
|
||||
"id": row["id"], "code": row["code"], "stock_name": row["stock_name"],
|
||||
"focus": row["focus"], "report": row["report"], "created_at": row["created_at"],
|
||||
"sources": src,
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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("数据库统计:")
|
||||
|
||||
@@ -208,3 +208,17 @@ tr:hover td { background: rgba(59,130,246,.05); }
|
||||
.score-bar .sb-track { flex: 1; height: 6px; background: #262d3a; border-radius: 3px; overflow: hidden; }
|
||||
.score-bar .sb-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #8b5cf6); border-radius: 3px; }
|
||||
.score-bar .sb-val { width: 28px; text-align: right; color: var(--text); }
|
||||
|
||||
/* ===== 数据源区块(分析详情页) ===== */
|
||||
.src-block { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 10px; background: var(--bg2); overflow: hidden; }
|
||||
.src-block summary { cursor: pointer; padding: 11px 14px; font-weight: 600; font-size: 13px; user-select: none; list-style: none; }
|
||||
.src-block summary::-webkit-details-marker { display: none; }
|
||||
.src-block summary:hover { background: rgba(59,130,246,.06); }
|
||||
.src-count { float: right; color: var(--accent); font-weight: 700; }
|
||||
.src-body { padding: 0 14px 12px; border-top: 1px solid #1f2630; }
|
||||
.src-news { padding: 10px 0; border-bottom: 1px dashed #1f2630; }
|
||||
.src-news:last-child { border-bottom: none; }
|
||||
.src-news-title { font-weight: 600; margin-bottom: 5px; font-size: 13px; }
|
||||
.src-news-meta { display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--muted); margin-bottom: 5px; flex-wrap: wrap; }
|
||||
.src-news-text { font-size: 12px; color: var(--text2); line-height: 1.7; }
|
||||
.src-pre { white-space: pre-wrap; word-break: break-word; font-size: 12px; color: var(--text2); line-height: 1.7; background: #0d1117; border: 1px solid #1f2630; border-radius: 8px; padding: 10px; overflow-x: auto; }
|
||||
+11
-1
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/* AI 分析详情页:研报正文 + 数据源 */
|
||||
const CODE_AID = parseInt(location.pathname.split('/').pop(), 10);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const d = await api('/api/analyses/' + CODE_AID);
|
||||
const a = d.analysis, stock = d.stock || {};
|
||||
$('#aStock').textContent = a.stock_name || stock.name || '—';
|
||||
$('#aCode').textContent = a.code;
|
||||
$('#aIndustry').textContent = stock ? (stock.name + ' · ' + (stock.industry || '') + ' · ' + (stock.board || '')) : '';
|
||||
$('#aTime').textContent = a.created_at;
|
||||
$('#aFocus').textContent = a.focus || '整体投资价值';
|
||||
$('#reportBox').innerHTML = mdRender(a.report);
|
||||
renderSources(a.sources || {});
|
||||
} catch (e) {
|
||||
$('#reportBox').innerHTML = '<div class="empty">加载失败:' + escapeHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function srcBlock(title, icon, inner, count) {
|
||||
return `<details class="src-block" ${inner ? 'open' : ''}>
|
||||
<summary>${icon} ${title} ${count !== undefined ? `<span class="src-count">${count}</span>` : ''}</summary>
|
||||
<div class="src-body">${inner || '<div class="empty">无</div>'}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderSources(s) {
|
||||
let html = '';
|
||||
|
||||
// 1. RAG 相关资讯
|
||||
const news = s.news || [];
|
||||
html += srcBlock('RAG 相关资讯(向量检索命中)', '📰', news.map(n => `
|
||||
<div class="src-news">
|
||||
<div class="src-news-title">${escapeHtml(n.title)}</div>
|
||||
<div class="src-news-meta">
|
||||
<span>${n.date || '—'}</span>
|
||||
<span class="tag tag-${n.sentiment > 0 ? '正' : n.sentiment < 0 ? '负' : '平'}">情感 ${Number(n.sentiment).toFixed(2)}</span>
|
||||
<span style="color:var(--muted)">相似度 ${(1 - (n.distance || 0)).toFixed(3)}</span>
|
||||
</div>
|
||||
<div class="src-news-text">${escapeHtml(n.text)}</div>
|
||||
</div>`).join(''), news.length);
|
||||
|
||||
// 2. 公司概况
|
||||
html += srcBlock('公司概况(RAG 检索)', '🏢', s.profile ? `<p>${escapeHtml(s.profile)}</p>` : '', s.profile ? 1 : 0);
|
||||
|
||||
// 3. 技术指标
|
||||
html += srcBlock('技术指标', '📈', s.indicators ? `<pre class="src-pre">${escapeHtml(s.indicators)}</pre>` : '', s.indicators ? 1 : 0);
|
||||
|
||||
// 4. 综合评分
|
||||
if (s.score) {
|
||||
const sc = s.score;
|
||||
html += srcBlock('综合评分', '🎯', `
|
||||
<div class="mini-stats" style="gap:14px">
|
||||
<div class="mini-stat"><div class="ms-label">总分</div><div class="ms-value" style="color:${sc.total >= 68 ? '#f59e0b' : '#3b82f6'}">${sc.total}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">评级</div><div class="ms-value"><span class="tag tag-${sc.rating}">${sc.rating}</span></div></div>
|
||||
<div class="mini-stat"><div class="ms-label">趋势</div><div class="ms-value">${sc.trend}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">动量</div><div class="ms-value">${sc.momentum}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">技术</div><div class="ms-value">${sc.technical}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">量能</div><div class="ms-value">${sc.volume}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">消息</div><div class="ms-value">${sc.news}</div></div>
|
||||
<div class="mini-stat"><div class="ms-label">机构</div><div class="ms-value">${sc.institutional}</div></div>
|
||||
</div>`, 1);
|
||||
}
|
||||
|
||||
// 5. 机构评级
|
||||
const ratings = s.ratings || [];
|
||||
html += srcBlock('机构评级', '🏦', ratings.length ? `<table>
|
||||
<tr><th>机构</th><th>评级</th><th>目标价</th><th>日期</th></tr>
|
||||
${ratings.map(r => `<tr><td>${escapeHtml(r.inst_name)}</td>
|
||||
<td><span class="tag tag-${r.rating}">${r.rating}</span></td>
|
||||
<td class="num">${r.target_price}</td><td>${r.rating_date}</td></tr>`).join('')}
|
||||
</table>` : '', ratings.length);
|
||||
|
||||
// 6. 基金持仓
|
||||
const holdings = s.holdings || [];
|
||||
html += srcBlock('基金持仓', '💼', holdings.length ? `<table>
|
||||
<tr><th>机构</th><th>季度</th><th>持仓市值</th><th>环比</th></tr>
|
||||
${holdings.map(h => `<tr><td>${escapeHtml(h.inst_name)}</td><td>${h.quarter}</td>
|
||||
<td class="num">${fmtNum(h.hold_value)}</td>
|
||||
<td class="num ${pctClass(h.change_pct)}">${fmtPct(h.change_pct)}</td></tr>`).join('')}
|
||||
</table>` : '', holdings.length);
|
||||
|
||||
// 7. 完整提示词
|
||||
html += srcBlock('完整提示词(Prompt)', '🧠', s.prompt ? `<pre class="src-pre">${escapeHtml(s.prompt)}</pre>` : '', s.prompt ? 1 : 0);
|
||||
|
||||
$('#sourceBox').innerHTML = html;
|
||||
}
|
||||
|
||||
load();
|
||||
@@ -232,7 +232,9 @@ async function pollReport() {
|
||||
clearInterval(timer);
|
||||
$('#analyzeBtn').disabled = false;
|
||||
$('#reportBox').innerHTML = `<div class="markdown-body">${mdRender(d.report)}</div>
|
||||
<div style="color:var(--muted);font-size:12px;margin-top:12px">${d.cached ? '(缓存报告 ' + d.created_at + ')' : '生成耗时 ' + Math.round((Date.now() - t0) / 1000) + ' 秒'}</div>`;
|
||||
<div style="color:var(--muted);font-size:12px;margin-top:12px">${d.cached ? '(最近一次分析记录,' + d.created_at + ')' : '生成耗时 ' + Math.round((Date.now() - t0) / 1000) + ' 秒'}
|
||||
${d.history_id ? ` · <a href="/analysis/${d.history_id}" target="_blank">查看数据源详情 ↗</a>` : ''}</div>`;
|
||||
loadHistory();
|
||||
} else if (d.status === 'error') {
|
||||
clearInterval(timer);
|
||||
$('#analyzeBtn').disabled = false;
|
||||
@@ -246,7 +248,32 @@ async function pollReport() {
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/* 历史分析记录 */
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const d = await api(`/api/stock/${CODE}/analyses`);
|
||||
const items = d.items || [];
|
||||
if (!items.length) {
|
||||
$('#historyBox').innerHTML = '<div class="empty">暂无历史分析,点击上方「生成研报」开始</div>';
|
||||
return;
|
||||
}
|
||||
$('#historyBox').innerHTML = `<table>
|
||||
<tr><th>时间</th><th>关注点</th><th>引用资讯</th><th>报告摘要</th><th>操作</th></tr>
|
||||
${items.map(h => `<tr>
|
||||
<td style="color:var(--muted)">${h.created_at}</td>
|
||||
<td>${escapeHtml(h.focus || '整体投资价值')}</td>
|
||||
<td class="num">${h.news_count} 条</td>
|
||||
<td style="max-width:300px;white-space:normal;color:var(--text2)">${escapeHtml(h.excerpt)}…</td>
|
||||
<td><button class="btn btn-primary" onclick="window.open('/analysis/${h.id}','_blank')">查看详情 ↗</button></td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
$('#historyBox').innerHTML = '<div class="empty">历史记录加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
$('#analyzeBtn').onclick = generateReport;
|
||||
$('#focusInput').addEventListener('keydown', e => { if (e.key === 'Enter') generateReport(); });
|
||||
|
||||
load();
|
||||
loadHistory();
|
||||
@@ -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) =>
|
||||
`<button class="pill ${i === 0 ? 'active' : ''}" data-k="${s.key}">${s.icon} ${s.name} <span style="color:var(--muted);font-size:11px">${s.params}</span></button>`
|
||||
).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 = '<div class="empty">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<b style="color:var(--text)">${cfg.icon} ${cfg.name}(${cfg.params})</b>:${escapeHtml(cfg.desc)} <span class="tag">${cfg.tags.join(' / ')}</span>`;
|
||||
const s = cfg.summary || {};
|
||||
$('#stratSummary').innerHTML = `
|
||||
<div class="kpi"><div class="kpi-label">全市场平均收益</div><div class="kpi-value num ${s.avg_return >= 0 ? 'up' : 'down'}">${fmtPct(s.avg_return)}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">平均夏普</div><div class="kpi-value num">${s.avg_sharpe ?? '--'}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">平均胜率</div><div class="kpi-value num">${s.avg_win_rate ?? '--'}%</div></div>
|
||||
<div class="kpi"><div class="kpi-label">覆盖股票</div><div class="kpi-value num">${s.n ?? '--'}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">最优标的</div><div class="kpi-value" style="font-size:15px">${s.best_name ? `<a href="/stock/${s.best_code}" target="_blank">${s.best_name}</a> <span class="num up">${fmtPct(s.best_return)}</span>` : '--'}</div></div>`;
|
||||
}
|
||||
|
||||
async function loadMarket(key) {
|
||||
$('#rankTb').innerHTML = '<tr><td colspan="10" class="loading">加载中…</td></tr>';
|
||||
try {
|
||||
const d = await api('/api/backtest/market?strategy=' + key);
|
||||
rankData = d.items || [];
|
||||
renderRank();
|
||||
// 默认选中第一名股票
|
||||
if (rankData.length) loadDetail(rankData[0].code);
|
||||
else $('#rankTb').innerHTML = '<tr><td colspan="10" class="empty">暂无回测数据,请到数据管理页重建</td></tr>';
|
||||
} catch (e) {
|
||||
$('#rankTb').innerHTML = '<tr><td colspan="10" class="empty">加载失败</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRank() {
|
||||
$('#rankHint').textContent = `(${rankData.length} 只股票)`;
|
||||
$('#rankTb').innerHTML = rankData.map((it, i) => `
|
||||
<tr class="row-link" onclick="loadDetail('${it.code}')">
|
||||
<td class="num">${i + 1}</td>
|
||||
<td><b>${it.name}</b><div style="color:var(--muted);font-size:12px">${it.code}</div></td>
|
||||
<td class="num ${pctClass(it.total_return)}"><b>${fmtPct(it.total_return)}</b></td>
|
||||
<td class="num ${pctClass(it.benchmark)}">${fmtPct(it.benchmark)}</td>
|
||||
<td class="num ${pctClass(it.excess)}">${fmtPct(it.excess)}</td>
|
||||
<td class="num down">${fmtPct(it.max_drawdown)}</td>
|
||||
<td class="num ${it.sharpe >= 0 ? 'up' : 'down'}">${it.sharpe}</td>
|
||||
<td class="num">${it.win_rate}%</td>
|
||||
<td class="num">${it.profit_factor}</td>
|
||||
<td class="num">${it.trades}</td>
|
||||
</tr>`).join('');
|
||||
// 同步下拉框
|
||||
fillStockSelect();
|
||||
}
|
||||
|
||||
function fillStockSelect() {
|
||||
const sel = $('#btStock');
|
||||
sel.innerHTML = rankData.map(it => `<option value="${it.code}">${it.name}(${it.code})</option>`).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 = '<div class="empty">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(d) {
|
||||
const m = d.metrics, st = d.stock || {};
|
||||
$('#btHead').innerHTML = `
|
||||
<div class="flex between wrap">
|
||||
<div>
|
||||
<b style="font-size:16px">${d.stock_name}</b> <span style="color:var(--muted)">${d.code} · ${st.industry || ''} · ${st.board || ''}</span>
|
||||
</div>
|
||||
<span style="color:var(--muted);font-size:12px">${d.config.icon} ${d.config.name} · 回测 ${m.days} 个交易日</span>
|
||||
</div>`;
|
||||
$('#btMetrics').innerHTML = `
|
||||
<div class="kpi"><div class="kpi-label">策略收益</div><div class="kpi-value num ${pctClass(m.total_return)}">${fmtPct(m.total_return)}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">年化收益</div><div class="kpi-value num ${pctClass(m.annualized)}">${fmtPct(m.annualized)}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">最大回撤</div><div class="kpi-value num down">${fmtPct(m.max_drawdown)}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">夏普比率</div><div class="kpi-value num ${m.sharpe >= 0 ? 'up' : 'down'}">${m.sharpe}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">胜率</div><div class="kpi-value num">${m.win_rate}%</div></div>
|
||||
<div class="kpi"><div class="kpi-label">盈亏比</div><div class="kpi-value num">${m.profit_factor}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">交易次数</div><div class="kpi-value num">${m.trades}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">买入持有</div><div class="kpi-value num ${pctClass(m.benchmark)}">${fmtPct(m.benchmark)}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">超额收益</div><div class="kpi-value num ${pctClass(m.excess)}">${fmtPct(m.excess)}</div></div>`;
|
||||
renderEquity(d.equity);
|
||||
renderTrades(d.trades);
|
||||
}
|
||||
|
||||
function renderEquity(equity) {
|
||||
const el = $('#equityChart');
|
||||
if (!window.echarts) { el.innerHTML = '<div class="empty">ECharts 加载失败</div>'; 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 = '<div class="empty">该策略在回测期内无交易信号</div>'; return; }
|
||||
$('#tradeBox').innerHTML = `<table>
|
||||
<tr><th>买入日期</th><th>买入价</th><th>卖出日期</th><th>卖出价</th><th>收益</th><th>持有(天)</th><th>信号</th></tr>
|
||||
${trades.map(t => `<tr>
|
||||
<td>${t.entry_date}</td><td class="num">${t.entry_price}</td>
|
||||
<td>${t.exit_date}</td><td class="num">${t.exit_price}</td>
|
||||
<td class="num ${pctClass(t.return)}"><b>${fmtPct(t.return)}</b></td>
|
||||
<td class="num">${t.days}</td>
|
||||
<td style="max-width:200px;white-space:normal;font-size:12px;color:var(--text2)">${escapeHtml(t.reason)}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => chart && chart.resize());
|
||||
loadStrategies();
|
||||
@@ -17,6 +17,7 @@
|
||||
<div class="card-title"><span class="bar" style="background:var(--gold)"></span>系统维护</div>
|
||||
<div class="flex wrap">
|
||||
<button class="btn btn-primary" onclick="reseed()">♻️ 一键重灌数据(含向量重建)</button>
|
||||
<button class="btn" onclick="rebuildBt()">📈 重建量化回测</button>
|
||||
<button class="btn" onclick="health()">🔌 依赖连通性检查</button>
|
||||
<button class="btn" onclick="refreshStats()">🔄 刷新统计</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AI 分析详情{% endblock %}
|
||||
{% block page_title %}AI 深度分析详情{% endblock %}
|
||||
{% block content %}
|
||||
<div id="topCard" class="card">
|
||||
<div class="flex between wrap">
|
||||
<div class="flex">
|
||||
<div>
|
||||
<div style="font-size:20px;font-weight:800"><span id="aStock">—</span> <span id="aCode" style="color:var(--muted);font-size:14px"></span></div>
|
||||
<div class="mt8 flex" style="gap:14px;color:var(--muted);font-size:13px">
|
||||
<span id="aIndustry">—</span>
|
||||
<span>生成时间 <b id="aTime" style="color:var(--text)"></b></span>
|
||||
<span>关注点 <b id="aFocus" style="color:var(--text)"></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button class="btn" onclick="location.href='/stock/' + CODE">← 返回股票页</button>
|
||||
<button class="btn btn-primary" onclick="location.href='/stock/' + CODE + '?analyze=1'">🤖 生成新分析</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2-1 mt16">
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="bar"></span>研报正文</div>
|
||||
<div id="reportBox" class="markdown-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="bar" style="background:var(--gold)"></span>数据源(大模型参考内容)</div>
|
||||
<div id="sourceBox" style="max-height:70vh;overflow:auto;padding-right:6px"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/analysis_detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -30,6 +30,7 @@
|
||||
<a href="/" class="nav-item" data-nav="/">📊 仪表盘</a>
|
||||
<a href="/stocks" class="nav-item" data-nav="/stocks">🏢 股票池</a>
|
||||
<a href="/recommend" class="nav-item" data-nav="/recommend">🎯 荐股中心</a>
|
||||
<a href="/strategies" class="nav-item" data-nav="/strategies">📈 量化策略</a>
|
||||
<a href="/news" class="nav-item" data-nav="/news">📰 财经新闻</a>
|
||||
<a href="/institutions" class="nav-item" data-nav="/institutions">🏦 机构动向</a>
|
||||
<a href="/admin" class="nav-item" data-nav="/admin">⚙️ 数据管理</a>
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
<div id="reportBox" class="mt16">
|
||||
<div class="empty">点击「生成研报」,系统将基于 新闻RAG检索 + 技术指标 + 机构数据 调用 DeepSeek 生成结构化研报(首次生成约需 30-90 秒)</div>
|
||||
</div>
|
||||
<div class="card-title mt16" style="font-size:14px"><span class="bar" style="background:var(--cyan)"></span>历史分析记录</div>
|
||||
<div id="historyBox"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt16">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}量化策略{% endblock %}
|
||||
{% block page_title %}量化策略回测{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="pill-bar" id="strategyPills">
|
||||
<div class="loading">加载策略中…</div>
|
||||
</div>
|
||||
<div id="stratDesc" class="mb8" style="color:var(--text2);font-size:13px"></div>
|
||||
<div class="kpi-grid" id="stratSummary" style="margin-bottom:4px"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2-1 mt16">
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="bar"></span>全市场回测榜 <span id="rankHint" style="font-weight:400;color:var(--muted);font-size:12px"></span></div>
|
||||
<div style="overflow:auto;max-height:560px">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>排名</th><th>股票</th><th>策略收益</th><th>基准</th><th>超额</th>
|
||||
<th>最大回撤</th><th>夏普</th><th>胜率</th><th>盈亏比</th><th>交易</th>
|
||||
</tr></thead>
|
||||
<tbody id="rankTb"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title"><span class="bar" style="background:var(--gold)"></span>单股回测详情</div>
|
||||
<div class="input-group mb8">
|
||||
<select class="input" id="btStock" style="flex:1"></select>
|
||||
</div>
|
||||
<div id="btHead"></div>
|
||||
<div id="equityChart" style="height:220px"></div>
|
||||
<div id="btMetrics" class="kpi-grid" style="margin-top:12px"></div>
|
||||
<div class="card-title mt16" style="font-size:14px"><span class="bar" style="background:var(--cyan)"></span>交易明细</div>
|
||||
<div id="tradeBox" style="overflow:auto;max-height:220px"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/strategies.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user