783 lines
29 KiB
Python
783 lines
29 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
智能荐股系统 - Flask 主应用
|
|
页面:仪表盘 / 股票池 / 荐股中心 / 财经新闻 / 机构动向 / 数据管理 / 个股详情
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
from flask import Flask, jsonify, render_template, request
|
|
|
|
from config import (IS_MOCK, SERVICE_HOST, SERVICE_NAME, SERVICE_PORT,
|
|
CHROMA_NEWS_COLLECTION, CHROMA_PROFILE_COLLECTION)
|
|
from database import init_db, query, query_one, execute, table_count
|
|
from engine import scoring
|
|
from engine.indicators import compute_indicators
|
|
from rag import vector_store as vs
|
|
|
|
logging.basicConfig(level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
log = logging.getLogger("app")
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
# ===================================================================== 页面
|
|
@app.route("/")
|
|
def page_index():
|
|
return render_template("index.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/stocks")
|
|
def page_stocks():
|
|
return render_template("stocks.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/recommend")
|
|
def page_recommend():
|
|
return render_template("recommend.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/news")
|
|
def page_news():
|
|
return render_template("news.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/institutions")
|
|
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("/tracking")
|
|
def page_tracking():
|
|
return render_template("tracking.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/automation")
|
|
def page_automation():
|
|
return render_template("automation.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)
|
|
|
|
|
|
@app.route("/settings")
|
|
def page_settings():
|
|
return render_template("settings.html", service=SERVICE_NAME, is_mock=IS_MOCK)
|
|
|
|
|
|
@app.route("/stock/<code>")
|
|
def page_stock(code):
|
|
return render_template("stock_detail.html", code=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 "
|
|
"WHERE code=? ORDER BY date ASC", (code,))
|
|
return compute_indicators(rows)
|
|
|
|
|
|
def _news_score(code):
|
|
n = query_one(
|
|
"SELECT AVG(sentiment) AS s FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
|
|
"AND publish_date >= date('now','-7 day')",
|
|
(code, f"%,{code}", f"{code},%"))
|
|
return n["s"] if n and n["s"] is not None else 0.0
|
|
|
|
|
|
def _inst_score(code):
|
|
st = query_one(
|
|
"SELECT COUNT(*) AS c FROM inst_ratings WHERE stock_code=? AND rating IN ('买入','增持') "
|
|
"AND rating_date >= date('now','-30 day')", (code,))
|
|
cnt = st["c"] if st else 0
|
|
return min(1.0, cnt / 4.0)
|
|
|
|
|
|
def _scored_stock(code):
|
|
stock = query_one("SELECT * FROM stocks WHERE code=?", (code,))
|
|
if not stock:
|
|
return None
|
|
ind = _indicators(code)
|
|
news_s = _news_score(code)
|
|
inst_s = _inst_score(code)
|
|
sc = scoring.score_stock(ind, news_s, inst_s)
|
|
# 机构正面评级数 / 增持数
|
|
up = query_one("SELECT COUNT(*) c FROM inst_ratings WHERE stock_code=? AND rating IN ('买入','增持') "
|
|
"AND rating_date >= date('now','-30 day')", (code,))
|
|
hold_up = query_one("SELECT COUNT(*) c FROM fund_holdings WHERE stock_code=? AND change_pct>0 AND quarter="
|
|
"(SELECT MAX(quarter) FROM fund_holdings)", (code,))
|
|
reasons = scoring.build_reasons(ind, news_s, up["c"] if up else 0, hold_up["c"] if hold_up else 0)
|
|
return {
|
|
"stock": stock, "ind": ind, "score": sc,
|
|
"reasons": reasons,
|
|
"inst_up": up["c"] if up else 0,
|
|
"hold_up": hold_up["c"] if hold_up else 0,
|
|
"news_score": round(news_s, 2),
|
|
"inst_score": round(inst_s, 2),
|
|
}
|
|
|
|
|
|
def _fmt(row):
|
|
"""转 JSON 安全(bytes 等)"""
|
|
return row
|
|
|
|
|
|
# ===================================================================== API
|
|
@app.route("/api/health")
|
|
def api_health():
|
|
return jsonify({"status": "ok", "service": SERVICE_NAME,
|
|
"is_mock": IS_MOCK, "time": time.strftime("%Y-%m-%d %H:%M:%S")})
|
|
|
|
|
|
@app.route("/api/overview")
|
|
def api_overview():
|
|
"""仪表盘:指数 / 市场情绪 / 行业热度 / 今日荐股 / 自选 / 要闻"""
|
|
idx = query("SELECT * FROM market_index ORDER BY date DESC LIMIT 2")
|
|
latest = idx[0] if idx else {}
|
|
prev = idx[1] if len(idx) > 1 else latest
|
|
inds = []
|
|
for k, label in (("sh", "上证指数"), ("sz", "深证成指"), ("cy", "创业板指")):
|
|
cur, old = latest.get(k, 0), prev.get(k, 0) or 1
|
|
inds.append({"key": k, "label": label, "value": cur,
|
|
"chg": round((cur - old) / old * 100, 2)})
|
|
|
|
# 今日涨跌统计
|
|
stat = query_one(
|
|
"SELECT COUNT(*) total, SUM(CASE WHEN change_pct>0 THEN 1 ELSE 0 END) up,"
|
|
"SUM(CASE WHEN change_pct<0 THEN 1 ELSE 0 END) down,"
|
|
"SUM(CASE WHEN change_pct>=9.8 THEN 1 ELSE 0 END) limit_up,"
|
|
"SUM(CASE WHEN change_pct<=-9.8 THEN 1 ELSE 0 END) limit_down,"
|
|
"ROUND(SUM(amount)/10000,2) amount_yi "
|
|
"FROM stock_daily WHERE date=(SELECT MAX(date) FROM stock_daily)")
|
|
|
|
# 行业热度(今日平均涨跌幅)
|
|
heat = query(
|
|
"SELECT s.industry, ROUND(AVG(d.change_pct),2) chg, COUNT(*) cnt "
|
|
"FROM stock_daily d JOIN stocks s ON s.code=d.code "
|
|
"WHERE d.date=(SELECT MAX(date) FROM stock_daily) GROUP BY s.industry "
|
|
"ORDER BY chg DESC LIMIT 8")
|
|
|
|
# 今日荐股 Top5(重算)
|
|
rec = _top_recommend(5, "all")
|
|
|
|
# 自选
|
|
watch = []
|
|
for w in query("SELECT code FROM watchlist ORDER BY added_at DESC"):
|
|
d = _scored_stock(w["code"])
|
|
if d:
|
|
watch.append({"code": w["code"], "name": d["stock"]["name"],
|
|
"close": d["ind"].get("close"), "chg": d["ind"].get("change_pct"),
|
|
"rating": d["score"]["rating"], "score": d["score"]["total"]})
|
|
|
|
news = query("SELECT id,title,source,category,sentiment,publish_date,related_stocks "
|
|
"FROM news ORDER BY publish_date DESC, id DESC LIMIT 6")
|
|
|
|
return jsonify({
|
|
"indexes": inds, "stat": stat, "heat": heat,
|
|
"top": rec, "watchlist": watch, "news": news,
|
|
})
|
|
|
|
|
|
@app.route("/api/stocks")
|
|
def api_stocks():
|
|
keyword = request.args.get("keyword", "").strip()
|
|
industry = request.args.get("industry", "").strip()
|
|
board = request.args.get("board", "").strip()
|
|
sort = request.args.get("sort", "code")
|
|
order = request.args.get("order", "asc")
|
|
page = max(1, int(request.args.get("page", 1)))
|
|
per = min(100, int(request.args.get("per", 20)))
|
|
rated = request.args.get("rating", "").strip()
|
|
|
|
conds, args = [], []
|
|
if keyword:
|
|
conds.append("(s.name LIKE ? OR s.code LIKE ?)")
|
|
args += [f"%{keyword}%", f"%{keyword}%"]
|
|
if industry:
|
|
conds.append("s.industry=?")
|
|
args.append(industry)
|
|
if board:
|
|
conds.append("s.board=?")
|
|
args.append(board)
|
|
where = ("WHERE " + " AND ".join(conds)) if conds else ""
|
|
|
|
all_rows = query(
|
|
f"SELECT s.*, d.close, d.change_pct, d.volume, d.amount "
|
|
f"FROM stocks s LEFT JOIN stock_daily d ON d.code=s.code AND d.date=(SELECT MAX(date) FROM stock_daily) "
|
|
f"{where}", args)
|
|
out = []
|
|
for r in all_rows:
|
|
sc = _scored_stock(r["code"])
|
|
if not sc:
|
|
continue
|
|
if rated and sc["score"]["rating"] != rated:
|
|
continue
|
|
out.append({
|
|
"code": r["code"], "name": r["name"], "industry": r["industry"],
|
|
"board": r["board"], "close": r["close"], "change_pct": r["change_pct"],
|
|
"amount": r["amount"], "market_cap": r["market_cap"], "pe": r["pe"],
|
|
"score": sc["score"]["total"], "rating": sc["score"]["rating"],
|
|
"reasons": sc["reasons"], "vol_ratio": sc["ind"].get("vol_ratio"),
|
|
"chg_5d": sc["ind"].get("chg_5d"),
|
|
})
|
|
|
|
allowed = {"code", "name", "close", "change_pct", "score", "amount", "market_cap"}
|
|
if sort in allowed:
|
|
out.sort(key=lambda x: (x[sort] is None, x[sort]), reverse=(order == "desc"))
|
|
total = len(out)
|
|
start = (page - 1) * per
|
|
return jsonify({"total": total, "page": page, "per": per,
|
|
"items": out[start:start + per]})
|
|
|
|
|
|
@app.route("/api/stock/<code>")
|
|
def api_stock(code):
|
|
d = _scored_stock(code)
|
|
if not d:
|
|
return jsonify({"error": "not found"}), 404
|
|
stock = d["stock"]
|
|
stock["is_watch"] = bool(query_one("SELECT 1 FROM watchlist WHERE code=?", (code,)))
|
|
stock["week_change"] = d["ind"].get("chg_5d")
|
|
stock["month_change"] = d["ind"].get("chg_20d")
|
|
return jsonify({"stock": stock, "ind": {k: v for k, v in d["ind"].items() if k != "bars"},
|
|
"score": d["score"], "reasons": d["reasons"],
|
|
"news_score": d["news_score"], "inst_score": d["inst_score"],
|
|
"inst_up": d["inst_up"], "hold_up": d["hold_up"]})
|
|
|
|
|
|
@app.route("/api/stock/<code>/kline")
|
|
def api_kline(code):
|
|
days = min(250, int(request.args.get("days", 120)))
|
|
rows = query("SELECT date,open,high,low,close,volume FROM stock_daily "
|
|
"WHERE code=? ORDER BY date DESC LIMIT ?", (code, days))
|
|
rows.reverse()
|
|
ind = compute_indicators(rows)
|
|
bars = ind.get("bars", [])
|
|
# 附 MA 序列
|
|
def ma_series(n):
|
|
out = []
|
|
for i in range(len(bars)):
|
|
seg = bars[max(0, i + 1 - n):i + 1]
|
|
if len(seg) < n:
|
|
out.append(None)
|
|
else:
|
|
out.append(round(sum(b["close"] for b in seg) / n, 2))
|
|
return out
|
|
return jsonify({
|
|
"dates": [b["date"] for b in bars],
|
|
"kline": [[b["open"], b["close"], b["low"], b["high"]] for b in bars],
|
|
"volumes": [b["volume"] for b in bars],
|
|
"ma5": ma_series(5), "ma10": ma_series(10), "ma20": ma_series(20),
|
|
"ma60": ma_series(60),
|
|
})
|
|
|
|
|
|
@app.route("/api/stock/<code>/news")
|
|
def api_stock_news(code):
|
|
rows = query("SELECT id,title,source,category,sentiment,publish_date,related_stocks "
|
|
"FROM news WHERE (related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?) "
|
|
"ORDER BY publish_date DESC LIMIT 30",
|
|
(code, f"%,{code}", f"{code},%"))
|
|
return jsonify({"items": rows})
|
|
|
|
|
|
@app.route("/api/stock/<code>/institutions")
|
|
def api_stock_inst(code):
|
|
ratings = query(
|
|
"SELECT inst_name, rating, target_price, rating_date, prev_rating FROM inst_ratings "
|
|
"WHERE stock_code=? ORDER BY rating_date DESC", (code,))
|
|
holdings = query(
|
|
"SELECT inst_name, quarter, hold_shares, hold_value, change_pct FROM fund_holdings "
|
|
"WHERE stock_code=? ORDER BY quarter DESC, hold_value DESC", (code,))
|
|
return jsonify({"ratings": ratings, "holdings": holdings})
|
|
|
|
|
|
@app.route("/api/news")
|
|
def api_news():
|
|
keyword = request.args.get("keyword", "").strip()
|
|
category = request.args.get("category", "").strip()
|
|
code = request.args.get("code", "").strip()
|
|
page = max(1, int(request.args.get("page", 1)))
|
|
per = 15
|
|
conds, args = [], []
|
|
if keyword:
|
|
conds.append("(title LIKE ? OR content LIKE ?)")
|
|
args += [f"%{keyword}%", f"%{keyword}%"]
|
|
if category:
|
|
conds.append("category=?")
|
|
args.append(category)
|
|
if code:
|
|
conds.append("(related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?)")
|
|
args += [code, f"%,{code}", f"{code},%"]
|
|
where = ("WHERE " + " AND ".join(conds)) if conds else ""
|
|
total = query_one(f"SELECT COUNT(*) c FROM news {where}", args)["c"]
|
|
items = query(f"SELECT * FROM news {where} ORDER BY publish_date DESC, id DESC LIMIT ? OFFSET ?",
|
|
args + [per, (page - 1) * per])
|
|
cats = query("SELECT category, COUNT(*) c FROM news GROUP BY category ORDER BY c DESC")
|
|
return jsonify({"total": total, "page": page, "per": per, "items": items, "cats": cats})
|
|
|
|
|
|
@app.route("/api/news/<int:nid>")
|
|
def api_news_detail(nid):
|
|
n = query_one("SELECT * FROM news WHERE id=?", (nid,))
|
|
if not n:
|
|
return jsonify({"error": "not found"}), 404
|
|
# 关联股票信息
|
|
stocks = []
|
|
for code in (n["related_stocks"] or "").split(","):
|
|
if not code:
|
|
continue
|
|
s = query_one("SELECT code,name,industry FROM stocks WHERE code=?", (code,))
|
|
if s:
|
|
stocks.append(s)
|
|
return jsonify({"news": n, "stocks": stocks})
|
|
|
|
|
|
def _top_recommend(limit=10, rating_filter="all"):
|
|
"""全市场评分排序"""
|
|
rows = query("SELECT code FROM stocks")
|
|
items = []
|
|
for r in rows:
|
|
d = _scored_stock(r["code"])
|
|
if not d:
|
|
continue
|
|
if rating_filter != "all" and d["score"]["rating"] != rating_filter:
|
|
continue
|
|
items.append({
|
|
"code": r["code"], "name": d["stock"]["name"], "industry": d["stock"]["industry"],
|
|
"close": d["ind"].get("close"), "change_pct": d["ind"].get("change_pct"),
|
|
"score": d["score"], "reasons": d["reasons"], "chg_5d": d["ind"].get("chg_5d"),
|
|
"vol_ratio": d["ind"].get("vol_ratio"),
|
|
})
|
|
items.sort(key=lambda x: x["score"]["total"], reverse=True)
|
|
return items[:limit]
|
|
|
|
|
|
@app.route("/api/recommend")
|
|
def api_recommend():
|
|
rating = request.args.get("rating", "all")
|
|
limit = min(80, int(request.args.get("limit", 30)))
|
|
return jsonify({"items": _top_recommend(limit, rating)})
|
|
|
|
|
|
# ------------------------------------------------------------------ AI 分析
|
|
@app.route("/api/stock/<code>/analyze", methods=["POST"])
|
|
def api_analyze(code):
|
|
from engine import analyst
|
|
body = request.get_json(silent=True) or {}
|
|
focus = (body.get("focus") or "").strip()
|
|
res = analyst.submit_report(code, focus)
|
|
return jsonify(res)
|
|
|
|
|
|
@app.route("/api/stock/<code>/analyze/status")
|
|
def api_analyze_status(code):
|
|
from engine import analyst
|
|
st = analyst.report_status(code)
|
|
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})
|
|
|
|
|
|
# ------------------------------------------------------------------ 自选
|
|
@app.route("/api/watchlist", methods=["GET"])
|
|
def api_watchlist():
|
|
items = []
|
|
for w in query("SELECT code, added_at FROM watchlist ORDER BY added_at DESC"):
|
|
d = _scored_stock(w["code"])
|
|
if d:
|
|
items.append({"code": w["code"], "name": d["stock"]["name"],
|
|
"industry": d["stock"]["industry"],
|
|
"close": d["ind"].get("close"), "change_pct": d["ind"].get("change_pct"),
|
|
"rating": d["score"]["rating"], "score": d["score"]["total"],
|
|
"added_at": w["added_at"]})
|
|
return jsonify({"items": items})
|
|
|
|
|
|
@app.route("/api/watchlist/<code>", methods=["POST"])
|
|
def api_watch_add(code):
|
|
execute("INSERT OR IGNORE INTO watchlist(code) VALUES(?)", (code,))
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/watchlist/<code>", methods=["DELETE"])
|
|
def api_watch_del(code):
|
|
execute("DELETE FROM watchlist WHERE code=?", (code,))
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ------------------------------------------------------------------ 机构
|
|
@app.route("/api/institutions")
|
|
def api_institutions():
|
|
typ = request.args.get("type", "").strip()
|
|
cond, args = "", []
|
|
if typ:
|
|
cond, args = "WHERE type=?", [typ]
|
|
items = query(f"SELECT * FROM institutions {cond} ORDER BY type, id", args)
|
|
return jsonify({"items": items})
|
|
|
|
|
|
@app.route("/api/institutions/<int:iid>")
|
|
def api_institution_detail(iid):
|
|
inst = query_one("SELECT * FROM institutions WHERE id=?", (iid,))
|
|
if not inst:
|
|
return jsonify({"error": "not found"}), 404
|
|
ratings = query(
|
|
"SELECT r.stock_code, s.name, r.rating, r.target_price, r.rating_date, r.prev_rating "
|
|
"FROM inst_ratings r JOIN stocks s ON s.code=r.stock_code WHERE r.inst_id=? "
|
|
"ORDER BY r.rating_date DESC LIMIT 20", (iid,))
|
|
holdings = query(
|
|
"SELECT h.stock_code, s.name, h.quarter, h.hold_value, h.change_pct "
|
|
"FROM fund_holdings h JOIN stocks s ON s.code=h.stock_code WHERE h.inst_id=? "
|
|
"ORDER BY h.quarter DESC, h.hold_value DESC LIMIT 20", (iid,))
|
|
return jsonify({"institution": inst, "ratings": ratings, "holdings": holdings})
|
|
|
|
|
|
@app.route("/api/ratings/upgrades")
|
|
def api_ratings_upgrades():
|
|
"""评级上调/下调榜"""
|
|
rows = query(
|
|
"SELECT r.stock_code, s.name, r.inst_name, r.rating, r.prev_rating, r.rating_date, r.target_price "
|
|
"FROM inst_ratings r JOIN stocks s ON s.code=r.stock_code "
|
|
"WHERE r.rating_date >= date('now','-45 day') "
|
|
"ORDER BY r.rating_date DESC, r.id DESC LIMIT 30")
|
|
return jsonify({"items": rows})
|
|
|
|
|
|
@app.route("/api/holdings/moves")
|
|
def api_holdings_moves():
|
|
q = query_one("SELECT MAX(quarter) q FROM fund_holdings")
|
|
latest = q["q"] if q else "2026Q2"
|
|
inc = query(
|
|
"SELECT h.stock_code, s.name, h.inst_name, h.hold_value, h.change_pct, h.quarter "
|
|
"FROM fund_holdings h JOIN stocks s ON s.code=h.stock_code "
|
|
"WHERE h.quarter=? AND h.change_pct>0 ORDER BY h.change_pct DESC LIMIT 10", (latest,))
|
|
dec = query(
|
|
"SELECT h.stock_code, s.name, h.inst_name, h.hold_value, h.change_pct, h.quarter "
|
|
"FROM fund_holdings h JOIN stocks s ON s.code=h.stock_code "
|
|
"WHERE h.quarter=? AND h.change_pct<0 ORDER BY h.change_pct ASC LIMIT 10", (latest,))
|
|
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/settings")
|
|
def api_settings():
|
|
from settings import all_settings, mail_config, monitor_config, monitor_state, \
|
|
tracking_config, tracking_state
|
|
from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
|
|
s = all_settings()
|
|
mail = mail_config()
|
|
mono = monitor_config()
|
|
track = tracking_config()
|
|
return jsonify({
|
|
"mail": mail,
|
|
"monitor": mono,
|
|
"tracking": {**track, **tracking_state()},
|
|
"llm": {
|
|
"base_url": s.get("llm_base_url", LLM_BASE_URL),
|
|
"api_key": s.get("llm_api_key", LLM_API_KEY),
|
|
"model": s.get("llm_model", LLM_MODEL),
|
|
},
|
|
"state": monitor_state(),
|
|
})
|
|
|
|
|
|
@app.route("/api/settings", methods=["POST"])
|
|
def api_settings_save():
|
|
from settings import save_all, set_setting, _bool_str
|
|
body = request.get_json(silent=True) or {}
|
|
mail = body.get("mail") or {}
|
|
llm = body.get("llm") or {}
|
|
mono = body.get("monitor") or {}
|
|
# 邮件(布尔规范化)
|
|
for k in ("smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_mode",
|
|
"email_to", "sender_name"):
|
|
if k in mail:
|
|
set_setting(k, mail[k])
|
|
if "email_enabled" in mail:
|
|
set_setting("email_enabled", _bool_str(mail["email_enabled"]))
|
|
# LLM
|
|
for k in ("llm_base_url", "llm_api_key", "llm_model"):
|
|
if k in llm:
|
|
set_setting(k, str(llm[k]).strip())
|
|
# 监控
|
|
for k in ("monitor_interval", "monitor_categories",
|
|
"monitor_sentiment", "monitor_importance", "monitor_keywords"):
|
|
if k in mono:
|
|
set_setting(k, mono[k])
|
|
if "monitor_enabled" in mono:
|
|
set_setting("monitor_enabled", _bool_str(mono["monitor_enabled"]))
|
|
# 持仓跟踪
|
|
track = body.get("tracking") or {}
|
|
for k in ("tracking_interval", "tracking_impact_threshold"):
|
|
if k in track:
|
|
set_setting(k, track[k])
|
|
if "tracking_enabled" in track:
|
|
set_setting("tracking_enabled", _bool_str(track["tracking_enabled"]))
|
|
if "tracking_notify" in track:
|
|
set_setting("tracking_notify", _bool_str(track["tracking_notify"]))
|
|
return jsonify({"ok": True, "msg": "设置已保存"})
|
|
|
|
|
|
@app.route("/api/settings/test-email", methods=["POST"])
|
|
def api_settings_test_email():
|
|
from settings import mail_config
|
|
from engine.notifier import send_email
|
|
body = request.get_json(silent=True) or {}
|
|
try:
|
|
cfg = mail_config()
|
|
send_email(
|
|
"[智能荐股] 邮件配置测试",
|
|
"<h3>✅ 邮件配置生效</h3><p>如果你收到这封邮件,说明 SMTP 设置正确,舆情通知将正常送达。</p>"
|
|
"<p style='color:#888'>发送时间:" + time.strftime("%Y-%m-%d %H:%M:%S") + "</p>",
|
|
cfg=cfg)
|
|
return jsonify({"ok": True, "msg": f"测试邮件已发送到 {cfg['email_to']}"})
|
|
except Exception as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/settings/test-llm", methods=["POST"])
|
|
def api_settings_test_llm():
|
|
from settings import llm_config
|
|
from engine.analyst import llm_chat
|
|
try:
|
|
cfg = llm_config()
|
|
rep = llm_chat([{"role": "user", "content": "回复'连接正常'四个字"}], max_tokens=20)
|
|
return jsonify({"ok": True, "msg": f"连接成功({cfg['model']}):{rep[:60]}"})
|
|
except Exception as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/monitor/scan", methods=["POST"])
|
|
def api_monitor_scan():
|
|
from engine.notifier import scan_news
|
|
try:
|
|
r = scan_news(force=True)
|
|
return jsonify({"ok": True, **r})
|
|
except Exception as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/monitor/log")
|
|
def api_monitor_log():
|
|
from engine.notifier import notification_log
|
|
return jsonify({"items": notification_log()})
|
|
|
|
|
|
# ------------------------------------------------------------------ 持仓跟踪
|
|
@app.route("/api/tracking")
|
|
def api_tracking_list():
|
|
from engine.agent import list_reports
|
|
return jsonify({"items": list_reports()})
|
|
|
|
|
|
@app.route("/api/tracking/stock/<code>")
|
|
def api_tracking_stock(code):
|
|
from engine.agent import latest_reports
|
|
return jsonify({"items": latest_reports(code)})
|
|
|
|
|
|
@app.route("/api/tracking/<int:rid>")
|
|
def api_tracking_detail(rid):
|
|
from engine.agent import get_report
|
|
import json as _json
|
|
r = get_report(rid)
|
|
if not r:
|
|
return jsonify({"error": "记录不存在"}), 404
|
|
r["meta"] = _json.loads(r["meta"] or "{}")
|
|
r["sources"] = _json.loads(r["sources"] or "{}")
|
|
return jsonify(r)
|
|
|
|
|
|
@app.route("/api/tracking/run", methods=["POST"])
|
|
def api_tracking_run():
|
|
"""立即跟踪全部自选股(后台)"""
|
|
import threading
|
|
|
|
def run():
|
|
from engine.agent import track_watchlist
|
|
try:
|
|
track_watchlist()
|
|
except Exception as e:
|
|
log.error("track run fail: %s", e)
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
return jsonify({"ok": True, "msg": "跟踪任务已启动(逐只分析,约每只30-60秒)"})
|
|
|
|
|
|
@app.route("/api/tracking/run/<code>", methods=["POST"])
|
|
def api_tracking_run_one(code):
|
|
from engine.agent import track_stock
|
|
import threading
|
|
|
|
def run():
|
|
try:
|
|
track_stock(code)
|
|
except Exception as e:
|
|
log.error("track %s fail: %s", code, 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", "analysis_history",
|
|
"strategy_backtests", "market_index")
|
|
return jsonify({
|
|
"tables": {t: table_count(t) for t in tables},
|
|
"vector": {
|
|
"news": vs.collection_count(CHROMA_NEWS_COLLECTION),
|
|
"profiles": vs.collection_count(CHROMA_PROFILE_COLLECTION),
|
|
},
|
|
"is_mock": IS_MOCK,
|
|
"db": "stock_advisor.db",
|
|
})
|
|
|
|
|
|
@app.route("/api/admin/reseed", methods=["POST"])
|
|
def api_admin_reseed():
|
|
"""一键重灌数据(含向量重建),后台执行"""
|
|
import subprocess
|
|
import threading
|
|
|
|
def run():
|
|
py = "/home/hz1/miniconda3/envs/openclaw/bin/python3"
|
|
subprocess.run([py, os.path.join(os.path.dirname(os.path.abspath(__file__)), "seed_data.py")],
|
|
cwd=os.path.dirname(os.path.abspath(__file__)),
|
|
stdout=open(os.path.join(LOG_DIR, "reseed.log"), "w"),
|
|
stderr=subprocess.STDOUT)
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
return jsonify({"ok": True, "msg": "重灌任务已启动,可在数据管理页刷新查看进度"})
|
|
|
|
|
|
@app.route("/api/admin/healthcheck")
|
|
def api_admin_healthcheck():
|
|
"""外部依赖连通性检查"""
|
|
import requests
|
|
from config import EMBEDDING_API_URL, CHROMA_HOST, CHROMA_PORT, LLM_BASE_URL
|
|
out = {}
|
|
try:
|
|
r = requests.get(f"http://{CHROMA_HOST}:{CHROMA_PORT}/api/v2/tenants/default_tenant/databases/default_database/collections", timeout=5)
|
|
out["chroma"] = "ok" if r.status_code == 200 else f"http {r.status_code}"
|
|
except Exception as e:
|
|
out["chroma"] = f"fail {e}"
|
|
try:
|
|
r = requests.post(EMBEDDING_API_URL, json={"model": "bge-large-zh-v1.5", "input": ["测试"]}, timeout=10)
|
|
out["embedding"] = "ok" if r.status_code == 200 else f"http {r.status_code}"
|
|
except Exception as e:
|
|
out["embedding"] = f"fail {e}"
|
|
out["llm"] = "配置已就绪(调用时校验)"
|
|
return jsonify(out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
init_db()
|
|
from engine.notifier import start_monitor
|
|
from engine.agent import start_tracking
|
|
start_monitor()
|
|
start_tracking()
|
|
print(f"✅ {SERVICE_NAME} 启动: http://0.0.0.0:{SERVICE_PORT}")
|
|
app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True)
|