Files
stock-advisor/app.py
T

845 lines
31 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, quiet_config
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()},
"quiet": { # 各自动化任务的静默期配置
"monitor": quiet_config("monitor"),
"tracking": quiet_config("tracking"),
"report": quiet_config("report"),
},
"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"]))
# 静默期(各自动化任务独立配置)
quiet = body.get("quiet") or {}
for prefix in ("monitor", "tracking", "report"):
q = quiet.get(prefix) or {}
if "enabled" in q:
set_setting(f"{prefix}_quiet_enabled", _bool_str(q["enabled"]))
if "ranges" in q:
set_setting(f"{prefix}_quiet_ranges", str(q["ranges"] or "").strip())
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_all
try:
track_all()
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/targets")
def api_targets_list():
from engine.agent import list_targets
return jsonify({"items": list_targets()})
@app.route("/api/targets", methods=["POST"])
def api_targets_add():
from engine.agent import add_target
body = request.get_json(silent=True) or {}
r = add_target(body.get("type", "concept"), body.get("name", ""),
body.get("code", ""), body.get("keywords", ""))
if r.get("error"):
return jsonify(r), 400
return jsonify(r)
@app.route("/api/targets/<int:tid>", methods=["DELETE"])
def api_targets_delete(tid):
from engine.agent import delete_target
return jsonify(delete_target(tid))
# ------------------------------------------------------------------ 定时报告
@app.route("/api/report/send", methods=["POST"])
def api_report_send():
"""手动触发定时报告(后台生成 + 邮件)"""
from engine.report import send_daily_report
import threading
body = request.get_json(silent=True) or {}
kind = body.get("kind", "premarket")
def run():
try:
r = send_daily_report(kind)
log.info("report send: %s", r)
except Exception as e:
log.error("report send fail: %s", e)
threading.Thread(target=run, daemon=True).start()
return jsonify({"ok": True, "msg": f"{'盘前分析' if kind == 'premarket' else '盘后总结'}生成已启动,约需 1-2 分钟"})
@app.route("/api/report/log")
def api_report_log():
from engine.report import report_log
return jsonify({"items": report_log()})
# ------------------------------------------------------------------ 数据管理
@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)