From 90a9f9b212c922de5280c750a02e8812b55caaca Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 19 Aug 2026 19:36:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=99=BA=E8=83=BD=E8=8D=90=E8=82=A1=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20v1.0.0=EF=BC=9A=E8=82=A1=E7=A5=A8=E6=B1=A0/?= =?UTF-8?q?=E8=A1=8C=E6=83=85/=E6=96=B0=E9=97=BBRAG/=E6=9C=BA=E6=9E=84?= =?UTF-8?q?=E6=8C=81=E4=BB=93/=E5=A4=9A=E5=9B=A0=E5=AD=90=E8=AF=84?= =?UTF-8?q?=E5=88=86/AI=E6=B7=B1=E5=BA=A6=E7=A0=94=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 + app.py | 513 ++++++++++++++++++++++++++++++++++++ config.py | 46 ++++ database.py | 170 ++++++++++++ engine/analyst.py | 223 ++++++++++++++++ engine/indicators.py | 164 ++++++++++++ engine/scoring.py | 150 +++++++++++ rag/vector_store.py | 143 ++++++++++ requirements.txt | 6 + seed_data.py | 474 +++++++++++++++++++++++++++++++++ start.sh | 46 ++++ static/css/style.css | 210 +++++++++++++++ static/js/admin.js | 59 +++++ static/js/common.js | 127 +++++++++ static/js/dashboard.js | 102 +++++++ static/js/institutions.js | 108 ++++++++ static/js/news.js | 57 ++++ static/js/recommend.js | 49 ++++ static/js/stock_detail.js | 252 ++++++++++++++++++ static/js/stocks.js | 72 +++++ static/lib/marked.min.js | 6 + templates/admin.html | 32 +++ templates/base.html | 59 +++++ templates/index.html | 33 +++ templates/institutions.html | 38 +++ templates/news.html | 25 ++ templates/recommend.html | 26 ++ templates/stock_detail.html | 88 +++++++ templates/stocks.html | 44 ++++ 29 files changed, 3332 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 config.py create mode 100644 database.py create mode 100644 engine/analyst.py create mode 100644 engine/indicators.py create mode 100644 engine/scoring.py create mode 100644 rag/vector_store.py create mode 100644 requirements.txt create mode 100644 seed_data.py create mode 100755 start.sh create mode 100644 static/css/style.css create mode 100644 static/js/admin.js create mode 100644 static/js/common.js create mode 100644 static/js/dashboard.js create mode 100644 static/js/institutions.js create mode 100644 static/js/news.js create mode 100644 static/js/recommend.js create mode 100644 static/js/stock_detail.js create mode 100644 static/js/stocks.js create mode 100644 static/lib/marked.min.js create mode 100644 templates/admin.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/institutions.html create mode 100644 templates/news.html create mode 100644 templates/recommend.html create mode 100644 templates/stock_detail.html create mode 100644 templates/stocks.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ce6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# 数据与日志不入库 +data/*.db +data/*.db-shm +data/*.db-wal +data/*.png +logs/ +__pycache__/ +*.pyc +.DS_Store +nohup.out diff --git a/app.py b/app.py new file mode 100644 index 0000000..abeef76 --- /dev/null +++ b/app.py @@ -0,0 +1,513 @@ +# -*- 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("/admin") +def page_admin(): + return render_template("admin.html", service=SERVICE_NAME, is_mock=IS_MOCK) + + +@app.route("/stock/") +def page_stock(code): + return render_template("stock_detail.html", code=code, + 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/") +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//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//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//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/") +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//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//analyze/status") +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) + + +# ------------------------------------------------------------------ 自选 +@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/", methods=["POST"]) +def api_watch_add(code): + execute("INSERT OR IGNORE INTO watchlist(code) VALUES(?)", (code,)) + return jsonify({"ok": True}) + + +@app.route("/api/watchlist/", 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/") +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/admin/stats") +def api_admin_stats(): + tables = ("stocks", "stock_daily", "news", "institutions", "inst_ratings", + "fund_holdings", "watchlist", "analysis_cache", "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() + print(f"✅ {SERVICE_NAME} 启动: http://0.0.0.0:{SERVICE_PORT}") + app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..54d8f2e --- /dev/null +++ b/config.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" +智能荐股系统 - 全局配置 +所有环境相关配置集中在此,便于迁移与扩展(后期接入真实行情/新闻只需替换数据层) +""" +import os + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +LOG_DIR = os.path.join(BASE_DIR, "logs") +STATIC_DIR = os.path.join(BASE_DIR, "static") +TEMPLATE_DIR = os.path.join(BASE_DIR, "templates") +DB_PATH = os.path.join(DATA_DIR, "stock_advisor.db") + +os.makedirs(DATA_DIR, exist_ok=True) +os.makedirs(LOG_DIR, exist_ok=True) + +# ---------------- 大模型(DeepSeek) ---------------- +LLM_BASE_URL = "https://api.deepseek.com" +LLM_API_KEY = "sk-edb9df58ff574f8c98df1cd6a425e97c" +LLM_MODEL = "deepseek-v4-flash" +LLM_TIMEOUT = 150 # 推理模型生成深度报告较慢,放宽超时 +LLM_MAX_TOKENS = 4096 +LLM_TEMPERATURE = 0.5 + +# ---------------- Embedding 服务(本地 16011,OpenAI 兼容) ---------------- +EMBEDDING_API_URL = "http://121.40.164.32:16011/v1/embeddings" +EMBEDDING_MODEL = "bge-large-zh-v1.5" # 1024 维 +EMBEDDING_DIM = 1024 + +# ---------------- Rerank 服务(本地 16011,Cohere 兼容,可选增强) ---------------- +RERANK_API_URL = "http://121.40.164.32:16011/v1/rerank" +RERANK_MODEL = "bge-reranker-v2-m3" +USE_RERANK = False # 荐股场景先关闭,纯检索足够 + +# ---------------- Chroma 向量库(本地 16010) ---------------- +CHROMA_HOST = "121.40.164.32" +CHROMA_PORT = 16010 +CHROMA_NEWS_COLLECTION = "stock_news_v1" # 财经新闻语义索引 +CHROMA_PROFILE_COLLECTION = "stock_profiles_v1" # 公司概况语义索引 + +# ---------------- 服务 ---------------- +SERVICE_PORT = 16095 +SERVICE_HOST = "0.0.0.0" +SERVICE_NAME = "智能荐股系统" +IS_MOCK = True # 当前数据为模拟数据(后期接入真实数据后改为 False) diff --git a/database.py b/database.py new file mode 100644 index 0000000..05994cd --- /dev/null +++ b/database.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" +SQLite 数据访问层:线程安全连接 + 轻量查询封装 +表结构:stocks / stock_daily / news / institutions / inst_ratings / fund_holdings + / watchlist(自选) / analysis_cache(深度报告缓存) +""" +import sqlite3 +import threading +from contextlib import contextmanager + +from config import DB_PATH + +_local = threading.local() + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS stocks ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + industry TEXT DEFAULT '', + board TEXT DEFAULT '主板', + total_shares REAL DEFAULT 0, -- 总股本(亿股) + float_shares REAL DEFAULT 0, -- 流通股本(亿股) + list_date TEXT DEFAULT '', + pe REAL DEFAULT 0, -- 市盈率(动) + pb REAL DEFAULT 0, -- 市净率 + market_cap REAL DEFAULT 0, -- 总市值(亿元) + description TEXT DEFAULT '' -- 公司简介 +); + +CREATE TABLE IF NOT EXISTS stock_daily ( + code TEXT NOT NULL, + date TEXT NOT NULL, + open REAL DEFAULT 0, + high REAL DEFAULT 0, + low REAL DEFAULT 0, + close REAL DEFAULT 0, + volume REAL DEFAULT 0, -- 成交量(万股) + amount REAL DEFAULT 0, -- 成交额(万元) + change_pct REAL DEFAULT 0, -- 涨跌幅 % + PRIMARY KEY (code, date) +); +CREATE INDEX IF NOT EXISTS idx_daily_date ON stock_daily(date); + +CREATE TABLE IF NOT EXISTS news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT DEFAULT '', + source TEXT DEFAULT '', + category TEXT DEFAULT '市场', + publish_date TEXT DEFAULT '', + related_stocks TEXT DEFAULT '', -- 关联股票代码,逗号分隔 + sentiment REAL DEFAULT 0, -- 情感 -1~1 + is_positive INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_news_date ON news(publish_date); + +CREATE TABLE IF NOT EXISTS institutions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + type TEXT DEFAULT '', -- 公募基金/券商研究/保险资管/外资机构/私募游资 + description TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS inst_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id INTEGER, + inst_name TEXT, + stock_code TEXT, + rating TEXT DEFAULT '中性', -- 买入/增持/中性/减持 + target_price REAL DEFAULT 0, + rating_date TEXT DEFAULT '', + prev_rating TEXT DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_ratings_code ON inst_ratings(stock_code); +CREATE INDEX IF NOT EXISTS idx_ratings_date ON inst_ratings(rating_date); + +CREATE TABLE IF NOT EXISTS fund_holdings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inst_id INTEGER, + inst_name TEXT, + stock_code TEXT, + quarter TEXT, -- 2026Q2 + hold_shares REAL DEFAULT 0, -- 持仓(万股) + hold_value REAL DEFAULT 0, -- 持仓市值(万元) + change_shares REAL DEFAULT 0, -- 环比增减(万股) + change_pct REAL DEFAULT 0, -- 环比增减 % + UNIQUE(inst_id, stock_code, quarter) +); +CREATE INDEX IF NOT EXISTS idx_holdings_code ON fund_holdings(stock_code); + +CREATE TABLE IF NOT EXISTS watchlist ( + code TEXT PRIMARY KEY, + added_at TEXT DEFAULT (datetime('now','localtime')) +); + +CREATE TABLE IF NOT EXISTS analysis_cache ( + code TEXT PRIMARY KEY, + report TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) +); + +CREATE TABLE IF NOT EXISTS market_index ( + date TEXT PRIMARY KEY, + sh REAL DEFAULT 0, -- 上证指数(点) + sz REAL DEFAULT 0, -- 深证成指(点) + cy REAL DEFAULT 0 -- 创业板指(点) +); +""" + + +def get_conn(): + conn = getattr(_local, "conn", None) + if conn is None: + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + _local.conn = conn + return conn + + +@contextmanager +def db(): + conn = get_conn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + + +def init_db(): + with db() as conn: + conn.executescript(SCHEMA) + + +def query(sql, args=()): + with db() as conn: + cur = conn.execute(sql, args) + return [dict(r) for r in cur.fetchall()] + + +def query_one(sql, args=()): + rows = query(sql, args) + return rows[0] if rows else None + + +def execute(sql, args=()): + with db() as conn: + cur = conn.execute(sql, args) + return cur.lastrowid + + +def executemany(sql, seq): + with db() as conn: + conn.executemany(sql, seq) + + +def table_count(name): + return query_one(f'SELECT COUNT(*) AS c FROM "{name}"')["c"] + + +def wipe_all(): + """清空业务表(保留结构),用于重灌数据""" + for t in ("stock_daily", "inst_ratings", "fund_holdings", "news", + "institutions", "stocks", "watchlist", "analysis_cache", "market_index"): + with db() as conn: + conn.execute(f'DELETE FROM "{t}"') diff --git a/engine/analyst.py b/engine/analyst.py new file mode 100644 index 0000000..422e8bf --- /dev/null +++ b/engine/analyst.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +""" +AI 分析引擎:DeepSeek 深度研报 + RAG 增强 +- 检索:股票相关新闻(向量语义)+ 公司概况 + 机构动向/基金持仓(结构化) +- 生成:结构化工研报(公司概况/基本面/技术面/消息面/机构动向/风险提示/操作建议) +""" +import logging +import threading +import time +import requests + +from config import (LLM_API_KEY, LLM_BASE_URL, LLM_MAX_TOKENS, LLM_MODEL, + LLM_TEMPERATURE, LLM_TIMEOUT, CHROMA_NEWS_COLLECTION, + CHROMA_PROFILE_COLLECTION) +from database import query, query_one, execute +from rag.vector_store import query_vectors + +log = logging.getLogger("analyst") + +_jobs = {} # code -> {status, report, error, ts} +_jobs_lock = threading.Lock() + + +# ------------------------------------------------------------------ LLM +def llm_chat(messages, max_tokens=None, temperature=None, timeout=None): + """调用 DeepSeek(OpenAI 兼容)。返回最终 content(忽略推理过程)""" + resp = requests.post( + f"{LLM_BASE_URL}/chat/completions", + headers={"Authorization": f"Bearer {LLM_API_KEY}"}, + json={ + "model": LLM_MODEL, + "messages": messages, + "max_tokens": max_tokens or LLM_MAX_TOKENS, + "temperature": LLM_TEMPERATURE if temperature is None else temperature, + "stream": False, + }, + timeout=timeout or LLM_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + try: + return data["choices"][0]["message"].get("content") or "" + except (KeyError, IndexError): + return "" + + +# ------------------------------------------------------------------ RAG 检索 +def _rag_news(code, stock_name, query_text, top_k=6): + """检索个股相关新闻(向量语义,按 code 过滤)""" + try: + where = {"code": code} + hits = query_vectors(query_text, n_results=top_k, where=where, + name=CHROMA_NEWS_COLLECTION) + out = [] + for h in hits: + m = h.get("metadata", {}) + out.append({ + "title": m.get("title", ""), + "date": m.get("date", ""), + "sentiment": m.get("sentiment", 0), + "text": h.get("document", "")[:400], + }) + return out + except Exception as e: + log.warning("RAG news fail: %s", e) + return [] + + +def _rag_profile(code): + try: + hits = query_vectors("公司主营业务与基本面", n_results=1, + where={"code": code}, name=CHROMA_PROFILE_COLLECTION) + if hits: + return hits[0].get("document", "") + except Exception: + pass + return "" + + +def _inst_summary(code): + """机构评级 + 基金持仓摘要(结构化)""" + ratings = query( + "SELECT inst_name, rating, target_price, rating_date, prev_rating " + "FROM inst_ratings WHERE stock_code=? ORDER BY rating_date DESC LIMIT 6", (code,)) + holdings = query( + "SELECT inst_name, quarter, hold_value, change_pct FROM fund_holdings " + "WHERE stock_code=? ORDER BY quarter DESC, hold_value DESC LIMIT 6", (code,)) + return ratings, holdings + + +# ------------------------------------------------------------------ 报告生成 +def _fmt_indicators(ind): + if not ind: + return "(暂无技术数据)" + lines = [ + f"- 最新价 {ind.get('close')},当日 {ind.get('change_pct', 0):+.2f}%", + f"- MA5={ind.get('ma5')} / MA10={ind.get('ma10')} / MA20={ind.get('ma20')} / MA60={ind.get('ma60')}", + f"- RSI(14)={ind.get('rsi')},KDJ K/D/J={ind.get('kdj_k')}/{ind.get('kdj_d')}/{ind.get('kdj_j')}", + f"- MACD DIF={ind.get('dif')} / DEA={ind.get('dea')} / 柱={ind.get('macd')}", + f"- 量比 {ind.get('vol_ratio')},5日涨幅 {ind.get('chg_5d', 0):+.2f}%,20日涨幅 {ind.get('chg_20d', 0):+.2f}%", + f"- 近120日区间 {ind.get('low_52w')} ~ {ind.get('high_52w')},20日波动率 {ind.get('volatility')}%", + ] + return "\n".join(lines) + + +def _build_prompt(stock, ind, news_hits, profile, ratings, holdings, score, focus): + rated = "、".join(f"{r['inst_name']}({r['rating']},目标{r['target_price']})" for r in ratings) or "暂无" + held = ";".join(f"{h['inst_name']} {h['quarter']}持仓{h['hold_value']:.0f}万 环比{h['change_pct']:+.1f}%" for h in holdings) or "暂无" + news_text = "\n\n".join( + f"【{n['date']}|{n['title']}】(情感{n['sentiment']:+.2f})\n{n['text']}" for n in news_hits + ) or "(检索到相关资讯较少)" + + return f"""你是资深A股投顾,请基于下方【资料】对股票 {stock['name']}({stock['code']}) 输出一份结构化工研报。 + +【资料】 +公司概况: +{profile or stock.get('description', '暂无')} + +技术面: +{_fmt_indicators(ind)} + +综合评分:{score.get('total')} 分(评级:{score.get('rating')}),分项:趋势{score.get('trend')}/动量{score.get('momentum')}/技术{score.get('technical')}/量能{score.get('volume')}/消息{score.get('news')}/机构{score.get('institutional')} + +机构评级:{rated} +基金持仓:{held} + +相关资讯(RAG 语义检索): +{news_text} + +用户关注点:{focus or '整体投资价值'} + +【输出要求】用 Markdown 输出,结构如下: +## 一、公司概况与基本面 +## 二、技术面解读 +## 三、消息面与市场情绪 +## 四、机构动向 +## 五、风险提示 +## 六、操作建议(给出 目标区间 / 支撑位 / 压力位,说明短线与中线思路) +注意:内容需严格基于上述资料,数据为模拟数据,结尾加一句「以上内容基于模拟数据生成,仅供系统演示,不构成投资建议」。""" + + +def generate_report_sync(code, focus=""): + """同步生成报告(后台线程调用)""" + stock = query_one("SELECT * FROM stocks WHERE code=?", (code,)) + if not stock: + return {"error": "股票不存在"} + ind = _indicators_for(code) + score = _score_for(code, ind) + hits = _rag_news(code, stock["name"], f"{stock['name']} {focus or '投资价值 业绩 利好利空'} {ind.get('close','')}") + profile = _rag_profile(code) + ratings, holdings = _inst_summary(code) + prompt = _build_prompt(stock, ind, hits, profile, ratings, holdings, score, focus) + try: + report = llm_chat([ + {"role": "system", "content": "你是一名严谨专业的A股投资顾问,输出结构化、简洁、可执行的研报。"}, + {"role": "user", "content": prompt}, + ]) + report = report.strip() + if not report: + raise RuntimeError("LLM 返回为空") + execute("INSERT OR REPLACE INTO analysis_cache(code, report, created_at) VALUES(?,?,datetime('now','localtime'))", + (code, report)) + return {"report": report, "ts": time.time()} + except Exception as e: + log.exception("gen report fail") + return {"error": str(e)} + + +def _indicators_for(code): + """从 DB 读取日线并算指标(避免循环依赖 app)""" + from engine.indicators import compute_indicators + rows = query("SELECT date,open,high,low,close,volume FROM stock_daily WHERE code=? ORDER BY date ASC", (code,)) + return compute_indicators(rows) + + +def _score_for(code, ind): + from engine.scoring import score_stock + # 新闻情感(与 app 端口径一致:精确/前缀/后缀三种匹配) + 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},%")) + news_score = n["s"] if n and n["s"] is not None else 0.0 + # 机构热度 + st = query_one( + "SELECT COUNT(*) AS c FROM inst_ratings WHERE stock_code=? AND rating IN ('买入','增持') " + "AND rating_date >= date('now','-30 day')", (code,)) + inst_count = st["c"] if st else 0 + inst_score = min(1.0, inst_count / 4.0) + return score_stock(ind, news_score, inst_score) + + +# ------------------------------------------------------------------ 异步任务 +def submit_report(code, focus=""): + """提交后台生成任务,立即返回""" + with _jobs_lock: + if _jobs.get(code, {}).get("status") == "running": + return {"status": "running"} + _jobs[code] = {"status": "running", "report": None, "error": None, "ts": time.time()} + threading.Thread(target=_run_job, args=(code, focus), daemon=True).start() + return {"status": "running"} + + +def _run_job(code, focus): + try: + res = generate_report_sync(code, focus) + with _jobs_lock: + if res.get("error"): + _jobs[code] = {"status": "error", "error": res["error"], "ts": time.time()} + else: + _jobs[code] = {"status": "done", "report": res["report"], "ts": time.time()} + except Exception as e: + with _jobs_lock: + _jobs[code] = {"status": "error", "error": str(e), "ts": time.time()} + + +def report_status(code): + with _jobs_lock: + return dict(_jobs.get(code, {})) + + +def get_cached_report(code): + return query_one("SELECT report, created_at FROM analysis_cache WHERE code=?", (code,)) diff --git a/engine/indicators.py b/engine/indicators.py new file mode 100644 index 0000000..d29b989 --- /dev/null +++ b/engine/indicators.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +技术指标计算:MA / RSI / MACD / KDJ / 量比 / 动量 / 波动率 +输入 bars:按日期升序的 [{date, open, high, low, close, volume}, ...] +""" +import math + + +def _sma(vals, n): + if len(vals) < n: + return None + return sum(vals[-n:]) / n + + +def _ema(vals, n): + if not vals: + return None + k = 2 / (n + 1) + e = vals[0] + for v in vals[1:]: + e = v * k + e * (1 - k) + return e + + +def _ema_series(vals, n): + out = [] + if not vals: + return out + k = 2 / (n + 1) + e = vals[0] + out.append(e) + for v in vals[1:]: + e = v * k + e * (1 - k) + out.append(e) + return out + + +def rsi14(closes): + """Wilder RSI(14)""" + if len(closes) < 15: + return 50.0 + 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[:14]) / 14 + avg_l = sum(losses[:14]) / 14 + for i in range(14, len(gains)): + avg_g = (avg_g * 13 + gains[i]) / 14 + avg_l = (avg_l * 13 + losses[i]) / 14 + if avg_l == 0: + return 100.0 + rs = avg_g / avg_l + return 100 - 100 / (1 + rs) + + +def kdj(bars, n=9, k_smooth=3, d_smooth=3): + """返回 (K, D, J)""" + if len(bars) < n: + return 50.0, 50.0, 50.0 + k, d = 50.0, 50.0 + for i in range(n - 1, len(bars)): + window = bars[i - n + 1:i + 1] + low_n = min(b["low"] for b in window) + high_n = max(b["high"] for b in window) + rsv = 0 if high_n == low_n else (bars[i]["close"] - low_n) / (high_n - low_n) * 100 + k = (k * (k_smooth - 1) + rsv) / k_smooth + d = (d * (d_smooth - 1) + k) / d_smooth + j = 3 * k - 2 * d + return k, d, j + + +def compute_indicators(bars): + """计算全部技术指标,返回 dict(最新值 + 序列用于画图)""" + if not bars: + return {} + closes = [b["close"] for b in bars] + last = bars[-1] + prev = bars[-2] if len(bars) > 1 else last + + ma5 = _sma(closes, 5) + ma10 = _sma(closes, 10) + ma20 = _sma(closes, 20) + ma60 = _sma(closes, 60) + + # MACD + ema12 = _ema_series(closes, 12) + ema26 = _ema_series(closes, 26) + dif_series = [e12 - e26 for e12, e26 in zip(ema12, ema26)] + dea_series = _ema_series(dif_series, 9) + dif = dif_series[-1] if dif_series else 0 + dea = dea_series[-1] if dea_series else 0 + macd = (dif - dea) * 2 + + rsi = rsi14(closes) + k, d, j = kdj(bars) + + # 涨跌幅 + chg_1d = (last["close"] - prev["close"]) / prev["close"] * 100 if prev["close"] else 0 + chg_5d = (last["close"] - closes[-6]) / closes[-6] * 100 if len(closes) > 6 else chg_1d + chg_10d = (last["close"] - closes[-11]) / closes[-11] * 100 if len(closes) > 11 else chg_1d + chg_20d = (last["close"] - closes[-21]) / closes[-21] * 100 if len(closes) > 21 else chg_1d + + # 量比 = 今日量 / 前5日均量 + vol_ratio = 1.0 + if len(bars) > 6: + avg5 = sum(b["volume"] for b in bars[-6:-1]) / 5 + if avg5 > 0: + vol_ratio = last["volume"] / avg5 + + # 20日波动率(年化近似省略,日波动) + returns = [] + for i in range(1, len(closes)): + if closes[i - 1]: + returns.append((closes[i] - closes[i - 1]) / closes[i - 1]) + vol20 = (sum(r * r for r in returns[-20:]) / max(len(returns[-20:]), 1)) ** 0.5 * 100 if returns else 0 + + # 区间高低(近120日) + window = bars[-120:] if len(bars) > 120 else bars + high52 = max(b["high"] for b in window) + low52 = min(b["low"] for b in window) + + # 均线多头排列 + if ma5 and ma10 and ma20: + bull = ma5 > ma10 > ma20 + partial = ma5 > ma10 or ma10 > ma20 + else: + bull, partial = False, False + + return { + "date": last["date"], + "close": last["close"], + "open": last["open"], + "high": last["high"], + "low": last["low"], + "volume": last["volume"], + "change_pct": round(chg_1d, 2), + "chg_5d": round(chg_5d, 2), + "chg_10d": round(chg_10d, 2), + "chg_20d": round(chg_20d, 2), + "ma5": round(ma5, 2) if ma5 else None, + "ma10": round(ma10, 2) if ma10 else None, + "ma20": round(ma20, 2) if ma20 else None, + "ma60": round(ma60, 2) if ma60 else None, + "rsi": round(rsi, 2), + "kdj_k": round(k, 2), + "kdj_d": round(d, 2), + "kdj_j": round(j, 2), + "dif": round(dif, 3), + "dea": round(dea, 3), + "macd": round(macd, 3), + "vol_ratio": round(vol_ratio, 2), + "volatility": round(vol20, 2), + "high_52w": round(high52, 2), + "low_52w": round(low52, 2), + "trend_bull": bull, + "trend_partial": partial, + "bars": [ + {"date": b["date"], "open": b["open"], "high": b["high"], + "low": b["low"], "close": b["close"], "volume": b["volume"]} + for b in bars + ], + } diff --git a/engine/scoring.py b/engine/scoring.py new file mode 100644 index 0000000..bd949b5 --- /dev/null +++ b/engine/scoring.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +荐股评分引擎:多因子打分模型(满分100) +- 趋势 25分:均线多头排列 + 站上MA20 +- 动量 20分:5日涨幅区间映射 +- 技术 15分:RSI健康区间 / 超买超卖 +- 量能 10分:量比 +- 消息 15分:近7日相关新闻情感均值(RAG 信号) +- 机构 15分:近30日评级上调 + 基金持仓环比增持 + +评级:>=82 强烈推荐 / >=68 推荐 / >=55 关注 / <55 观望 +""" +RATINGS = [ + (82, "强烈推荐"), + (68, "推荐"), + (55, "关注"), + (-1e9, "观望"), +] + + +def _rating(score): + for threshold, name in RATINGS: + if score >= threshold: + return name + return "观望" + + +def _bracket(v, cuts): + """v 落在 [val, 分数] 区间的第一个匹配""" + for hi, lo, score in cuts: + if hi is None or v <= hi: + if v >= lo: + return score + return 0 + + +def score_stock(ind, news_score, inst_score): + """ind: indicators dict;news_score: -1~1(无新闻用0);inst_score: 0~1 归一化机构热度""" + s = {} + + # 1. 趋势 25 + if ind.get("trend_bull"): + s["trend"] = 25 + elif ind.get("trend_partial"): + s["trend"] = 17 + else: + s["trend"] = 8 + ma20 = ind.get("ma20") + if ma20 and ind.get("close", 0) >= ma20: + s["trend"] = min(25, s["trend"] + 4) + + # 2. 动量 20(5日涨幅) + chg5 = ind.get("chg_5d", 0) + if chg5 > 12: + s["momentum"] = 16 # 过急,扣分防追高 + elif chg5 > 6: + s["momentum"] = 20 + elif chg5 > 2: + s["momentum"] = 15 + elif chg5 > -2: + s["momentum"] = 10 + elif chg5 > -6: + s["momentum"] = 6 + else: + s["momentum"] = 3 + + # 3. 技术 15(RSI) + rsi = ind.get("rsi", 50) + if 50 <= rsi <= 68: + s["technical"] = 15 + elif 40 <= rsi < 50: + s["technical"] = 11 + elif 68 < rsi <= 80: + s["technical"] = 8 # 接近超买 + elif rsi < 35: + s["technical"] = 9 # 超卖修复机会 + else: + s["technical"] = 5 + + # 4. 量能 10(量比) + vr = ind.get("vol_ratio", 1.0) + if vr >= 2.0: + s["volume"] = 10 + elif vr >= 1.3: + s["volume"] = 8 + elif vr >= 0.8: + s["volume"] = 6 + else: + s["volume"] = 4 + + # 5. 消息 15 + s["news"] = round(max(0, min(15, (news_score + 1) / 2 * 15)), 1) + + # 6. 机构 15 + s["institutional"] = round(inst_score * 15, 1) + + total = round(sum(s.values()), 1) + return { + "total": total, + "trend": s["trend"], + "momentum": s["momentum"], + "technical": s["technical"], + "volume": s["volume"], + "news": s["news"], + "institutional": s["institutional"], + "rating": _rating(total), + "score_parts": s, + } + + +def build_reasons(ind, news_score, inst_up, hold_up): + """生成规则化推荐理由(供列表直接展示,无需 LLM)""" + reasons = [] + if ind.get("trend_bull"): + reasons.append("均线呈多头排列,中短期趋势向上") + elif ind.get("close", 0) >= (ind.get("ma20") or 0): + reasons.append("股价站上20日均线,趋势转强") + else: + reasons.append("均线空头排列,趋势偏弱,注意风险") + + chg5 = ind.get("chg_5d", 0) + if chg5 >= 6: + reasons.append(f"5日涨幅{chg5:+.1f}%,动量强劲") + elif chg5 < -6: + reasons.append(f"5日跌幅{chg5:+.1f}%,弱势调整") + else: + reasons.append(f"5日涨跌{chg5:+.1f}%,动量平稳") + + rsi = ind.get("rsi", 50) + if rsi >= 70: + reasons.append(f"RSI {rsi:.0f} 超买,短线回调风险增大") + elif rsi <= 30: + reasons.append(f"RSI {rsi:.0f} 超卖,存在修复反弹机会") + + vr = ind.get("vol_ratio", 1.0) + if vr >= 1.5: + reasons.append(f"量比{vr:.2f},放量明显,资金活跃") + elif vr < 0.7: + reasons.append(f"量比{vr:.2f},缩量整理") + + if news_score > 0.25: + reasons.append("近期相关消息面偏正面,情绪回暖") + elif news_score < -0.25: + reasons.append("近期消息面偏空,注意利空扰动") + + if inst_up > 0: + reasons.append(f"近30日{inst_up}家机构给出正面评级") + if hold_up > 0: + reasons.append("基金最新季度环比增持") + return reasons[:4] diff --git a/rag/vector_store.py b/rag/vector_store.py new file mode 100644 index 0000000..0b37e28 --- /dev/null +++ b/rag/vector_store.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +""" +向量检索层:Embedding(16011) + Chroma(16010) 纯 REST 实现,无第三方客户端依赖。 +- embedding:OpenAI 兼容 /v1/embeddings(bge-large-zh-v1.5,1024维) +- chroma :REST /api/v2(add/query 用 UUID,get/delete 用名字) + +两个集合: +- stock_news_v1 财经新闻正文语义索引(metadata: code/title/date/category/sentiment/news_id) +- stock_profiles_v1 公司概况语义索引(metadata: code/name/industry) +""" +import json +import logging +import threading +import urllib.request +import urllib.error + +import requests + +from config import (CHROMA_HOST, CHROMA_PORT, EMBEDDING_API_URL, + EMBEDDING_MODEL, RERANK_API_URL, RERANK_MODEL, USE_RERANK) + +log = logging.getLogger("vector") + +_BASE = f"http://{CHROMA_HOST}:{CHROMA_PORT}/api/v2/tenants/default_tenant/databases/default_database/collections" +_lock = threading.Lock() + + +# ------------------------------------------------------------------ Embedding +def embed_texts(texts): + """返回 [[float...], ...] 向量列表""" + if isinstance(texts, str): + texts = [texts] + resp = requests.post(EMBEDDING_API_URL, + json={"model": EMBEDDING_MODEL, "input": list(texts)}, timeout=120) + resp.raise_for_status() + data = resp.json().get("data", []) + return [d["embedding"] for d in sorted(data, key=lambda x: x["index"])] + + +def rerank(query, docs, top_k=5): + if not USE_RERANK or not docs: + return docs + try: + payload = {"query": query, "documents": docs, "model": RERANK_MODEL, "top_k": top_k} + resp = requests.post(RERANK_API_URL, json=payload, timeout=60) + resp.raise_for_status() + data = resp.json().get("data", []) + return [{"id": d["document"]["id"], "text": d["document"]["text"], "score": d["score"]} for d in data] + except Exception as e: + log.warning("rerank failed: %s", e) + return docs + + +# ------------------------------------------------------------------ Chroma +def _http(method, url, payload=None, timeout=30): + req = urllib.request.Request(url, method=method) + if payload is not None: + req.add_header("Content-Type", "application/json") + req.data = json.dumps(payload).encode("utf-8") + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + body = r.read().decode("utf-8") + return r.status, (json.loads(body) if body else {}) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "ignore") + raise RuntimeError(f"Chroma {method} {url} -> {e.code}: {body[:300]}") + + +def _get_collection_id(name): + """按名字查集合,返回 (id, 是否存在)""" + try: + _, data = _http("GET", f"{_BASE}/{name}") + return data.get("id"), True + except RuntimeError: + return None, False + + +def ensure_collection(name, space="cosine"): + """获取或创建集合,返回 collection_id""" + with _lock: + cid, exists = _get_collection_id(name) + if exists: + return cid + _, data = _http("POST", _BASE, { + "name": name, "configuration": {"hnsw": {"space": space}}, + "get_or_create": True, + }) + return data["id"] + + +def collection_count(name): + try: + cid, exists = _get_collection_id(name) + if not exists: + return 0 + _, data = _http("GET", f"{_BASE}/{cid}/count") + return int(data) if isinstance(data, int) else int(data.get("count", 0)) + except Exception: + return 0 + + +def add_documents(ids, documents, metadatas, name): + """按文档批量写入(内部自动 embedding)""" + if not ids: + return + cid = ensure_collection(name) + vectors = embed_texts(documents) + payload = {"ids": list(ids), "embeddings": vectors, + "documents": list(documents), "metadatas": list(metadatas)} + _http("POST", f"{_BASE}/{cid}/add", payload, timeout=180) + + +def query_vectors(query_text, n_results=5, where=None, name=None): + """语义检索:返回 [{id, document, distance, metadata}, ...](升序按相似度)""" + try: + cid, exists = _get_collection_id(name) + if not exists: + return [] + except RuntimeError: + return [] + vec = embed_texts(query_text)[0] + payload = {"query_embeddings": [vec], "n_results": n_results, + "include": ["documents", "metadatas", "distances"]} + if where: + payload["where"] = where + _, data = _http("POST", f"{_BASE}/{cid}/query", payload) + out = [] + for i, doc in enumerate(data.get("documents", [[]])[0]): + out.append({ + "id": data["ids"][0][i], + "document": doc, + "distance": data["distances"][0][i], + "metadata": data["metadatas"][0][i] if data.get("metadatas") else {}, + }) + return out + + +def delete_collection(name): + """删除集合(用于重灌)""" + try: + _http("DELETE", f"{_BASE}/{name}") + except RuntimeError: + pass diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6c7f65e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +""" +智能荐股系统 - requirements +""" +flask>=3.0 +requests>=2.31 diff --git a/seed_data.py b/seed_data.py new file mode 100644 index 0000000..09d76dd --- /dev/null +++ b/seed_data.py @@ -0,0 +1,474 @@ +# -*- coding: utf-8 -*- +""" +模拟数据生成器(当前为演示数据,后期可替换为真实数据源) +生成内容: + 1. stocks 股票基础信息(60 只,覆盖 20+ 行业) + 2. stock_daily 180 个交易日 OHLCV 行情(随机游走 + 趋势/题材分化) + 3. market_index 上证/深证/创业板 三大指数序列 + 4. news 财经新闻(280+ 条,含情感标签/关联个股,作为 RAG 语料) + 5. institutions 机构实体(公募/券商/保险资管/外资/私募) + 6. inst_ratings 机构评级记录 + 7. fund_holdings 基金季度持仓 + 8. 向量索引:stock_news_v1(新闻正文)+ stock_profiles_v1(公司概况) + +用法:python seed_data.py [--skip-vector] +""" +import argparse +import datetime as dt +import math +import random +import sys + +from config import (CHROMA_NEWS_COLLECTION, CHROMA_PROFILE_COLLECTION, LOG_DIR) +from database import init_db, executemany, query_one, wipe_all +from rag import vector_store as vs + +random.seed(42) + +# ===================================================================== 股票池 +# (code, name, industry, board, base_price, float_shares(亿股), trend(0多/1平/2空), vol, biz) +STOCKS = [ + ("600519", "贵州茅台", "白酒", "主板", 1680, 12.56, 1, 0.020, "高端白酒龙头"), + ("000858", "五粮液", "白酒", "主板", 128, 38.8, 1, 0.021, "浓香型白酒头部企业"), + ("600809", "山西汾酒", "白酒", "主板", 198, 12.2, 0, 0.024, "清香型白酒代表"), + ("000568", "泸州老窖", "白酒", "主板", 118, 14.7, 1, 0.022, "国窖1573高端白酒"), + ("300750", "宁德时代", "动力电池", "创业板", 248, 43.9, 0, 0.026, "全球动力电池龙头"), + ("002594", "比亚迪", "新能源汽车", "主板", 235, 11.6, 0, 0.025, "新能源整车+电池一体化"), + ("601012", "隆基绿能", "光伏", "主板", 16.8, 75.8, 2, 0.028, "单晶硅片与组件龙头"), + ("600438", "通威股份", "光伏", "主板", 20.5, 45.0, 2, 0.027, "硅料+电池片双龙头"), + ("300274", "阳光电源", "光伏储能", "创业板", 78, 14.9, 0, 0.026, "光伏逆变器与储能龙头"), + ("002460", "赣锋锂业", "锂电材料", "主板", 36, 20.2, 2, 0.030, "锂盐龙头"), + ("688981", "中芯国际", "半导体", "科创板", 88, 26.5, 0, 0.025, "晶圆代工龙头"), + ("688012", "中微公司", "半导体设备", "科创板", 178, 6.2, 0, 0.029, "刻蚀设备龙头"), + ("002371", "北方华创", "半导体设备", "主板", 352, 5.3, 0, 0.028, "半导体设备平台型龙头"), + ("603986", "兆易创新", "半导体", "主板", 118, 6.7, 1, 0.027, "存储芯片+MCU"), + ("603501", "韦尔股份", "半导体", "主板", 96, 12.2, 1, 0.028, "CIS图像传感器龙头"), + ("688041", "海光信息", "芯片设计", "科创板", 128, 23.2, 0, 0.028, "国产CPU/DCU"), + ("600276", "恒瑞医药", "创新药", "主板", 46, 63.7, 1, 0.022, "创新药龙头"), + ("603259", "药明康德", "CXO", "主板", 56, 29.6, 1, 0.023, "医药研发外包龙头"), + ("300760", "迈瑞医疗", "医疗器械", "创业板", 268, 12.1, 1, 0.021, "医疗器械平台龙头"), + ("600085", "同仁堂", "中药", "主板", 42, 13.7, 1, 0.019, "老字号中药"), + ("000538", "云南白药", "中药", "主板", 52, 17.8, 1, 0.020, "中药+健康消费品"), + ("300015", "爱尔眼科", "医疗服务", "创业板", 13.5, 93.2, 1, 0.026, "连锁眼科医疗"), + ("600036", "招商银行", "银行", "主板", 36, 206.3, 1, 0.018, "零售银行龙头"), + ("601398", "工商银行", "银行", "主板", 6.1, 2696.0, 1, 0.015, "国有大行"), + ("601166", "兴业银行", "银行", "主板", 19.8, 207.7, 1, 0.017, "股份行"), + ("600000", "浦发银行", "银行", "主板", 8.9, 293.5, 2, 0.016, "股份行"), + ("600030", "中信证券", "券商", "主板", 28, 148.2, 1, 0.022, "券商龙头"), + ("601688", "华泰证券", "券商", "主板", 16.5, 90.8, 1, 0.021, "互联网券商"), + ("300059", "东方财富", "互联网金融", "创业板", 13.8, 158.0, 0, 0.027, "互联网财富管理"), + ("600999", "招商证券", "券商", "主板", 15.2, 86.9, 1, 0.020, "综合券商"), + ("601633", "长城汽车", "汽车", "主板", 24, 85.4, 1, 0.024, "SUV与新能源整车"), + ("000625", "长安汽车", "汽车", "主板", 14.5, 99.2, 1, 0.023, "自主品牌整车"), + ("601238", "广汽集团", "汽车", "主板", 8.6, 104.7, 2, 0.022, "整车集团"), + ("600104", "上汽集团", "汽车", "主板", 14.2, 115.7, 2, 0.020, "整车集团"), + ("002475", "立讯精密", "消费电子", "主板", 32, 72.1, 0, 0.026, "消费电子+汽车连接器"), + ("002241", "歌尔股份", "消费电子", "主板", 24, 34.2, 1, 0.026, "声学与VR整机"), + ("000725", "京东方A", "面板", "主板", 4.2, 376.0, 1, 0.024, "显示面板龙头"), + ("000100", "TCL科技", "面板", "主板", 4.8, 187.7, 1, 0.024, "面板+光伏材料"), + ("000333", "美的集团", "家电", "主板", 68, 68.3, 1, 0.019, "白电龙头"), + ("000651", "格力电器", "家电", "主板", 40, 56.3, 1, 0.020, "空调龙头"), + ("600690", "海尔智家", "家电", "主板", 28, 94.5, 1, 0.020, "白电+智慧家庭"), + ("600309", "万华化学", "化工", "主板", 78, 31.4, 1, 0.022, "MDI与新材料龙头"), + ("601899", "紫金矿业", "有色", "主板", 18, 263.0, 0, 0.023, "黄金铜矿龙头"), + ("002648", "卫星化学", "化工", "主板", 16.5, 33.7, 1, 0.024, "轻烃一体化化工"), + ("600031", "三一重工", "工程机械", "主板", 16.8, 84.9, 1, 0.023, "挖掘机龙头"), + ("000425", "徐工机械", "工程机械", "主板", 7.2, 118.3, 1, 0.022, "工程机械"), + ("600760", "中航沈飞", "军工", "主板", 52, 27.6, 1, 0.024, "战斗机主机厂"), + ("603288", "海天味业", "食品饮料", "主板", 42, 55.6, 1, 0.020, "调味品龙头"), + ("600887", "伊利股份", "食品饮料", "主板", 27, 63.7, 1, 0.019, "乳制品龙头"), + ("000895", "双汇发展", "食品饮料", "主板", 25, 34.6, 2, 0.019, "肉制品龙头"), + ("002415", "海康威视", "安防", "主板", 32, 92.3, 1, 0.022, "智能物联龙头"), + ("002230", "科大讯飞", "人工智能", "主板", 46, 23.1, 0, 0.028, "AI语音与大模型"), + ("688111", "金山办公", "软件", "科创板", 268, 4.6, 0, 0.027, "办公软件WPS"), + ("300033", "同花顺", "金融科技", "创业板", 158, 5.4, 0, 0.028, "金融信息服务"), + ("600570", "恒生电子", "软件", "主板", 26, 19.0, 1, 0.023, "金融IT龙头"), + ("601888", "中国中免", "免税", "主板", 68, 20.7, 1, 0.023, "免税龙头"), + ("600900", "长江电力", "电力", "主板", 28, 244.7, 1, 0.015, "水电龙头"), + ("601088", "中国神华", "煤炭", "主板", 42, 198.7, 1, 0.017, "煤电一体化"), + ("000002", "万科A", "房地产", "主板", 6.8, 119.3, 2, 0.026, "地产开发"), + ("600048", "保利发展", "房地产", "主板", 8.4, 119.7, 2, 0.025, "地产开发"), + ("600941", "中国移动", "通信运营", "主板", 102, 95.7, 1, 0.016, "通信运营龙头"), + ("000063", "中兴通讯", "通信设备", "主板", 28, 47.8, 1, 0.024, "通信设备"), + ("002714", "牧原股份", "养殖", "主板", 42, 54.6, 1, 0.026, "生猪养殖龙头"), + ("300498", "温氏股份", "养殖", "创业板", 18, 66.5, 1, 0.025, "生猪+禽养殖"), + ("601318", "中国平安", "保险", "主板", 48, 181.7, 1, 0.019, "综合金融"), + ("601628", "中国人寿", "保险", "主板", 30, 282.6, 1, 0.018, "寿险龙头"), + ("600111", "北方稀土", "稀土", "主板", 21, 36.2, 1, 0.027, "稀土龙头"), + ("601600", "中国铝业", "有色", "主板", 7.5, 171.6, 1, 0.022, "电解铝龙头"), +] + +# ===================================================================== 机构 +INSTITUTIONS = [ + # (name, type, desc, 重点行业) + ("易方达基金", "公募基金", "国内头部公募基金,管理规模超1.6万亿,深耕消费与科技赛道。", ["白酒", "消费电子", "医药"]), + ("华夏基金", "公募基金", "老牌公募,主动权益与ETF双轮驱动。", ["半导体", "新能源", "医药"]), + ("嘉实基金", "公募基金", "全牌照公募,均衡配置大盘蓝筹与成长。", ["银行", "食品饮料", "家电"]), + ("广发基金", "公募基金", "权益投资见长,聚焦科技成长。", ["半导体", "人工智能", "光伏"]), + ("南方基金", "公募基金", "老十家公募之一,稳健价值风格。", ["银行", "券商", "白酒"]), + ("富国基金", "公募基金", "主动权益明星基金较多,偏好行业景气。", ["医药", "半导体", "机械"]), + ("汇添富基金", "公募基金", "价值成长均衡,重仓消费与医药。", ["白酒", "医药", "消费电子"]), + ("中欧基金", "公募基金", "权益投资口碑公募,聚焦成长赛道。", ["新能源", "人工智能", "软件"]), + ("中信证券研究部", "券商研究", "国内综合实力领先的券商研究所,覆盖全行业。", ["券商", "半导体", "军工"]), + ("华泰证券研究所", "券商研究", "科技+金融双主线研究见长。", ["消费电子", "金融科技", "半导体"]), + ("国泰君安研究所", "券商研究", "老牌研究所,宏观策略与大金融研究扎实。", ["银行", "券商", "保险"]), + ("招商证券研究所", "券商研究", "以行业景气跟踪见长,深度覆盖成长板块。", ["汽车", "消费电子", "医药"]), + ("广发证券研究所", "券商研究", "策略+行业结合,偏成长挖掘。", ["人工智能", "软件", "新能源"]), + ("兴业证券研究所", "券商研究", "产业链研究见长,覆盖周期成长。", ["化工", "有色", "银行"]), + ("东吴证券研究所", "券商研究", "新经济研究活跃,覆盖TMT。", ["半导体", "软件", "通信"]), + ("浙商证券研究所", "券商研究", "后起之秀,新能源与制造研究领先。", ["光伏", "新能源", "机械"]), + ("中国人寿资管", "保险资管", "险资巨头,偏好高股息蓝筹与稳定现金流。", ["银行", "电力", "保险"]), + ("平安资管", "保险资管", "综合金融背景,长期资金代表。", ["银行", "食品饮料", "保险"]), + ("泰康资产", "保险资管", "保险资管头部,注重绝对收益。", ["医药", "家电", "白酒"]), + ("高盛", "外资机构", "全球投行,A股研究覆盖核心资产。", ["白酒", "新能源", "消费电子"]), + ("摩根士丹利", "外资机构", "全球投行,偏好大盘龙头与全球化公司。", ["消费电子", "新能源", "汽车"]), + ("瑞银证券", "外资机构", "外资券商,深入研究A股核心蓝筹。", ["银行", "白酒", "医药"]), + ("贝莱德", "外资机构", "全球最大资管,长期配置中国核心资产。", ["银行", "食品饮料", "电力"]), + ("高毅资产", "私募游资", "头部私募,逆向投资与深度研究。", ["医药", "消费", "科技"]), + ("淡水泉投资", "私募游资", "老牌私募,擅长困境反转与逆向布局。", ["化工", "地产", "养殖"]), + ("景林资产", "私募游资", "价值投资私募,重仓优质成长。", ["白酒", "互联网", "家电"]), + ("幻方量化", "私募游资", "头部量化私募,捕捉市场波动机会。", ["金融", "周期", "TMT"]), +] + +# ===================================================================== 新闻 +NEWS_SOURCES = ["证券时报", "上海证券报", "中国证券报", "财联社", "界面新闻", + "每日经济新闻", "澎湃新闻", "21世纪经济报道", "第一财经"] + +NEWS_TEMPLATES = [ + # (category, positive, title_tpl, content_tpls) + ("业绩", True, "{name}发布业绩预告:前三季度净利润同比增长{pct}%", + ["公司公告显示,受益于{industry}行业景气度提升,{name}核心业务收入实现较快增长,业绩超市场一致预期。", + "多家券商点评认为,{name}盈利质量改善明显,费用管控有效,全年业绩有望延续高增。", + "业内人士表示,行业需求回暖背景下,{name}作为{industry}领域头部公司,市占率有望进一步提升。"]), + ("业绩", True, "{name}半年度净利大增{pct}%,创上市以来新高", + ["{name}中报披露,报告期内实现营业收入同比增长{num}%,净利润同比增长{pct}%,均超市场预期。", + "公司称,新产能释放叠加产品结构优化,带动毛利率显著提升。", + "多家机构预计,随着行业景气延续,{name}未来业绩增长确定性较强。"]), + ("业绩", False, "{name}业绩不及预期:第三季度营收同比下滑{pct}%", + ["{name}三季报显示,受行业需求疲软影响,公司营业收入同比下滑{pct}%,净利润降幅扩大。", + "公司解释称,原材料成本上行及产品价格承压是主要原因。", + "市场人士认为,短期{name}基本面仍面临压力,需观察行业拐点信号。"]), + ("行业", True, "政策加码!{industry}行业迎来{num}亿产业基金支持", + ["相关部门发文明确,将设立{num}亿元产业投资基金,重点支持{industry}产业链关键环节技术攻关与产能建设。", + "分析人士指出,政策红利有望带动{industry}板块整体估值修复,相关龙头企业将直接受益。", + "板块内多只个股盘中异动,资金关注度明显提升。"]), + ("行业", False, "原材料价格波动,{industry}行业盈利承压", + ["近期上游原材料价格波动加大,{industry}行业部分企业毛利率受到侵蚀。", + "业内调研显示,中小企业已出现减产观望情绪,头部公司凭借成本优势影响相对可控。", + "机构提示,短期需关注库存去化进度与价格企稳信号。"]), + ("公司", True, "{name}拟回购{num}亿元股份,彰显发展信心", + ["{name}公告,拟以自有资金{num}亿元回购公司股份,用于员工持股计划或股权激励,回购价格不超过{price}元/股。", + "公司表示,回购基于对未来发展前景的信心及对公司价值的认可。", + "分析人士称,回购计划落地有望对股价形成支撑,彰显管理层信心。"]), + ("公司", True, "{name}中标{num}亿元重大项目,订单持续饱满", + ["{name}公告,近日中标{industry}领域重大工程项目,中标金额合计约{num}亿元。", + "公司称,该项目是公司在核心客户与重点市场的重要突破,有利于巩固行业地位。", + "机构预计,在手订单充足将支撑{name}未来收入增长确定性。"]), + ("公司", False, "{name}股东拟减持不超过{pct}%股份", + ["{name}公告,持股{num}%的股东计划在未来6个月内减持不超过公司总股本{pct}%的股份。", + "公司称,减持系股东自身资金安排,不影响公司正常经营。", + "市场对此反应谨慎,分析人士提醒关注减持节奏对股价的短期压制。"]), + ("机构观点", True, "{inst}上调{name}评级至「买入」,目标价{price}元", + ["{inst}发布研报认为,{name}受益于{industry}行业景气回升,业绩进入加速释放期,将评级由「增持」上调至「买入」。", + "研报给出目标价{price}元,较当前股价存在一定上行空间。", + "研报强调,{name}核心竞争力稳固,估值具备吸引力,建议积极配置。"]), + ("机构观点", False, "{inst}下调{name}评级至「中性」,提示估值风险", + ["{inst}研报指出,{name}短期涨幅较大,当前估值已透支部分预期,将评级由「买入」下调至「中性」。", + "研报认为,行业景气虽有支撑,但股价上行空间收窄,建议等待更好的介入时点。", + "市场人士表示,机构评级下调或引发短期情绪扰动。"]), + ("市场", True, "沪指放量上涨{pct}%,两市成交额突破{num}万亿", + ["A股市场情绪回暖,沪指放量上行{pct}%,深成指、创业板指同步走强。", + "盘面上,{industry}等板块领涨,赚钱效应明显,两市成交额突破{num}万亿元。", + "分析人士认为,市场风险偏好回升,中期趋势向好,可关注业绩确定性方向。"]), + ("市场", False, "大盘缩量回调{pct}%,市场观望情绪升温", + ["A股缩量调整,沪指收跌{pct}%,两市成交额较前期明显萎缩。", + "盘面上热点轮动加快,缺乏持续性主线,资金观望情绪浓厚。", + "机构提示,短期指数或以震荡为主,建议控制仓位、关注结构机会。"]), +] + +FILLERS = [ + "相关消息发布后,市场反应总体平稳。", + "多位市场人士对此进行了讨论。", + "后续进展值得持续跟踪。", + "公司方面暂未就此事进一步置评。", + "受此影响,相关产业链公司受到市场关注。", + "整体来看,基本面对股价中期走势具有决定性影响。", +] + + +def _gen_trading_dates(n=180): + """生成最近 n 个交易日(跳过周末),终止于最近工作日""" + dates = [] + d = dt.date.today() + # 回退到最近的非周末 + while d.weekday() >= 5: + d -= dt.timedelta(days=1) + while len(dates) < n: + if d.weekday() < 5: + dates.append(d.isoformat()) + d -= dt.timedelta(days=1) + return list(reversed(dates)) + + +# ===================================================================== 生成 +def gen_stocks(): + rows = [] + for code, name, industry, board, base, float_shares, trend, vol, biz in STOCKS: + total = round(float_shares * random.uniform(1.0, 1.6), 2) + pe = round(random.uniform(15, 60), 1) + pb = round(random.uniform(1.5, 8), 2) + desc = (f"{name}是{industry}领域{biz}。" + f"公司主营产品广泛应用于核心客户,行业地位稳固,近年来持续加大研发投入," + f"积极拓展新增长曲线。当前总股本约{total}亿股,流通市值位居行业前列。") + rows.append((code, name, industry, board, total, float_shares, + f"200{random.randint(0, 9)}-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}", + pe, pb, 0.0, desc)) + return rows + + +def gen_daily(dates): + """生成个股日线 + 指数序列""" + daily = [] + index = {} + sh, sz, cy = 3245.0, 10580.0, 2120.0 + price = {} + + for code, name, industry, board, base, float_shares, trend, vol, biz in STOCKS: + p = base + drift = {0: 0.0011, 1: 0.00025, 2: -0.00085}[trend] + # 最近30天加速(制造趋势分化,让荐股有区分度) + recent_drift = {0: 0.0045, 1: 0.0001, 2: -0.0045}[trend] + base_vol = float_shares * 10000 * random.uniform(0.8, 2.2) # 基准成交量(万股) + for i, d in enumerate(dates): + phase = max(0, i - (len(dates) - 30)) + dr = drift + (recent_drift if phase > 0 else 0) + r = random.gauss(dr, vol) + if random.random() < 0.02: # 偶发跳空 + r += random.gauss(0, vol * 1.6) + prev = p + p = max(0.8, p * (1 + r)) + open_p = prev * (1 + random.gauss(0, vol * 0.5)) + high = max(open_p, p) * (1 + abs(random.gauss(0, vol * 0.35))) + low = min(open_p, p) * (1 - abs(random.gauss(0, vol * 0.35))) + volume = base_vol * (1 + 3 * abs(r) / vol) * random.uniform(0.6, 1.4) + amount = volume * (open_p + p) / 2 # 万元 + chg = (p - prev) / prev * 100 + daily.append((code, d, round(open_p, 2), round(high, 2), round(low, 2), + round(p, 2), round(volume, 0), round(amount, 0), round(chg, 2))) + price[code] = p + + # 指数序列(独立随机游走) + for i, d in enumerate(dates): + if i == 0: + index[d] = {"sh": round(sh, 2), "sz": round(sz, 2), "cy": round(cy, 2)} + continue + sh_r = sum(random.gauss(0.0004, 0.008) for _ in range(6)) / 6 + sz_r = sh_r + random.gauss(0, 0.004) + cy_r = sh_r + random.gauss(0, 0.006) + sh *= (1 + sh_r); sz *= (1 + sz_r); cy *= (1 + cy_r) + index[d] = {"sh": round(sh, 2), "sz": round(sz, 2), "cy": round(cy, 2)} + return daily, index, price + + +def gen_news(dates, price): + """生成新闻(关联最近90天,偏近分布)""" + news = [] + recent_dates = dates[-95:] + stock_map = {s[0]: s for s in STOCKS} + for _ in range(300): + tpl = random.choice(NEWS_TEMPLATES) + category, positive, title_tpl, contents = tpl + s = random.choice(STOCKS) + code, name, industry = s[0], s[1], s[2] + pct = random.randint(5, 85) if positive else random.randint(5, 60) + num = random.choice([5, 10, 20, 30, 50, 80, 100, 120, 150, 200]) + price_t = round(price.get(code, 10) * random.uniform(1.02, 1.35), 2) + inst = random.choice(INSTITUTIONS)[0] + title = title_tpl.format(name=name, pct=pct, num=num, price=price_t, + inst=inst, industry=industry) + content = " ".join(c.format(name=name, pct=pct, num=num, price=price_t, + inst=inst, industry=industry) for c in contents) + content += " " + " ".join(random.sample(FILLERS, random.randint(1, 3))) + date = random.choice(recent_dates) + source = random.choice(NEWS_SOURCES) + sentiment = round(random.uniform(0.25, 0.85) if positive else random.uniform(-0.85, -0.25), 2) + # 关联股票:主股 + 同行业0~2只 + related = [code] + same = [x[0] for x in STOCKS if x[2] == industry and x[0] != code] + random.shuffle(same) + related += same[:random.randint(0, 2)] + news.append({ + "title": title, "content": content, "source": source, "category": category, + "publish_date": date, "related": ",".join(related), + "sentiment": sentiment, "is_positive": 1 if positive else 0, + }) + news.sort(key=lambda x: x["publish_date"]) + return news + + +def gen_institutions(): + rows = [] + for name, typ, desc, _focus in INSTITUTIONS: + rows.append((name, typ, desc)) + return rows + + +def gen_ratings(price): + """机构评级:每家机构关注重点行业内的股票""" + rows = [] + for name, typ, desc, focus in INSTITUTIONS: + pool = [s for s in STOCKS if s[2] in focus] + if not pool: + pool = STOCKS + picks = random.sample(pool, min(8, len(pool))) + for s in picks: + code = s[0] + r = random.choices(["买入", "增持", "中性", "减持"], weights=[5, 4, 2, 1])[0] + up = {"买入": random.uniform(0.15, 0.35), "增持": random.uniform(0.05, 0.18), + "中性": random.uniform(-0.05, 0.06), "减持": random.uniform(-0.18, -0.08)}[r] + tp = round(price.get(code, 10) * (1 + up), 2) + # 用近期交易日作为评级日 + days = _recent_days(45) + rows.append((0, name, code, r, tp, random.choice(days), + random.choice(["中性", "增持", "买入", "增持", "买入"]))) + return rows + + +def _recent_days(n): + dates = _gen_trading_dates(n) + return dates + + +def gen_holdings(price, dates): + """基金季度持仓:2025Q4 / 2026Q1 / 2026Q2""" + rows = [] + quarters = ["2025Q4", "2026Q1", "2026Q2"] + funds = [i for i in INSTITUTIONS if i[1] == "公募基金"] + \ + [i for i in INSTITUTIONS if i[1] == "保险资管"] + \ + [i for i in INSTITUTIONS if i[1] in ("外资机构", "私募游资")] + for name, typ, desc, focus in funds: + pool = [s for s in STOCKS if s[2] in focus] or STOCKS + picks = random.sample(pool, min(random.randint(6, 12), len(pool))) + for s in picks: + code = s[0] + last_hold = random.uniform(2000, 40000) # 万股 + prev_hold = None + for qi, q in enumerate(quarters): + if qi == 0: + hold = last_hold * random.uniform(0.5, 1.1) + else: + hold = last_hold + chg = 0.0 + if qi == 2: # 最新季度给出增减方向,与趋势挂钩 + trend = s[6] + delta = {0: random.uniform(5, 30), 1: random.uniform(-12, 12), 2: random.uniform(-25, -3)}[trend] + chg = hold * delta / 100 + hold = max(500, hold + chg) + value = hold * price.get(code, 10) + rows.append((0, name, code, q, round(hold, 0), round(value, 0), + round(chg, 0), round(chg / max(hold - chg, 1) * 100, 2))) + last_hold = hold + return rows + + +# ===================================================================== 入库 +def build_vectors(news, stocks): + """构建 Chroma 向量索引:新闻 + 公司概况""" + print(">>> 构建新闻向量索引 ...") + vs.delete_collection(CHROMA_NEWS_COLLECTION) + ids, docs, metas = [], [], [] + for n in news: + for code in n["related"].split(","): + ids.append(f"news-{n['title']}-{code}") + docs.append(f"{n['title']}\n{n['content']}") + metas.append({"code": code, "title": n["title"], "date": n["publish_date"], + "category": n["category"], "sentiment": n["sentiment"], + "news_id": 0}) + for i in range(0, len(ids), 16): + vs.add_documents(ids[i:i + 16], docs[i:i + 16], metas[i:i + 16], CHROMA_NEWS_COLLECTION) + print(f" news {min(i+16, len(ids))}/{len(ids)}") + + print(">>> 构建公司概况向量索引 ...") + vs.delete_collection(CHROMA_PROFILE_COLLECTION) + ids, docs, metas = [], [], [] + for s in stocks: + ids.append(f"profile-{s[0]}") + docs.append(f"{s[1]}({s[0]}),所属行业:{s[2]}。{s[10]}") + metas.append({"code": s[0], "name": s[1], "industry": s[2]}) + vs.add_documents(ids, docs, metas, CHROMA_PROFILE_COLLECTION) + print(f" profiles {len(ids)}") + print(f" 新闻索引条数: {vs.collection_count(CHROMA_NEWS_COLLECTION)}") + print(f" 概况索引条数: {vs.collection_count(CHROMA_PROFILE_COLLECTION)}") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--skip-vector", action="store_true", help="跳过向量索引重建") + args = parser.parse_args() + + print(">>> 初始化数据库 ...") + init_db() + wipe_all() + + dates = _gen_trading_dates() + print(f">>> 生成 {len(STOCKS)} 只股票日线行情({dates[0]} ~ {dates[-1]})...") + stocks = gen_stocks() + executemany( + "INSERT INTO stocks(code,name,industry,board,total_shares,float_shares,list_date,pe,pb,market_cap,description) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?)", + [(s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7], s[8], 0.0, s[10]) for s in stocks]) + daily, index, price = gen_daily(dates) + executemany( + "INSERT OR REPLACE INTO stock_daily(code,date,open,high,low,close,volume,amount,change_pct) " + "VALUES(?,?,?,?,?,?,?,?,?)", daily) + executemany("INSERT OR REPLACE INTO market_index(date,sh,sz,cy) VALUES(?,?,?,?)", + [(d, v["sh"], v["sz"], v["cy"]) for d, v in index.items()]) + # 回填市值 + for code, name, industry, board, base, fs, trend, vol, biz in STOCKS: + from database import execute as ex + ex("UPDATE stocks SET market_cap=ROUND((SELECT close FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 1)*total_shares,2) WHERE code=?", (code, code)) + + print(">>> 生成财经新闻 ...") + news = gen_news(dates, price) + executemany( + "INSERT INTO news(title,content,source,category,publish_date,related_stocks,sentiment,is_positive) " + "VALUES(?,?,?,?,?,?,?,?)", + [(n["title"], n["content"], n["source"], n["category"], n["publish_date"], + n["related"], n["sentiment"], n["is_positive"]) for n in news]) + + print(">>> 生成机构与评级 ...") + insts = gen_institutions() + executemany("INSERT INTO institutions(name,type,description) VALUES(?,?,?)", insts) + inst_map = {} + for i, (name, typ, desc) in enumerate(insts, start=1): + inst_map[name] = i + ratings = gen_ratings(price) + executemany( + "INSERT INTO inst_ratings(inst_id,inst_name,stock_code,rating,target_price,rating_date,prev_rating) " + "VALUES(?,?,?,?,?,?,?)", + [(inst_map[r[1]], r[1], r[2], r[3], r[4], r[5], r[6]) for r in ratings]) + + print(">>> 生成基金持仓 ...") + holdings = gen_holdings(price, dates) + executemany( + "INSERT INTO fund_holdings(inst_id,inst_name,stock_code,quarter,hold_shares,hold_value,change_shares,change_pct) " + "VALUES(?,?,?,?,?,?,?,?)", + [(inst_map.get(h[1], 0), h[1], h[2], h[3], h[4], h[5], h[6], h[7]) for h in holdings]) + + print(">>> 向量索引构建 ...") + if not args.skip_vector: + build_vectors(news, stocks) + else: + print(" (跳过)") + + from database import table_count + print("=" * 50) + print("数据库统计:") + for t in ("stocks", "stock_daily", "news", "institutions", "inst_ratings", + "fund_holdings", "watchlist", "analysis_cache", "market_index"): + print(f" {t:16s} {table_count(t)} 条") + print("✅ 数据生成完成") + + +if __name__ == "__main__": + main() diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..7497569 --- /dev/null +++ b/start.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# 智能荐股系统 启动脚本 +# 用法: ./start.sh [start|stop|restart|status|seed] +DIR="$(cd "$(dirname "$0")" && pwd)" +PORT=16095 +PY=/home/hz1/miniconda3/envs/openclaw/bin/python3 +LOG="$DIR/logs/app.log" +PID_FILE="$DIR/logs/app.pid" + +start() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "已在运行 PID=$(cat "$PID_FILE")" + return + fi + cd "$DIR" + nohup "$PY" app.py >> "$LOG" 2>&1 & + echo $! > "$PID_FILE" + sleep 2 + IP=$(hostname -I 2>/dev/null | awk '{print $1}') + echo "✅ 智能荐股系统 已启动 http://${IP}:$PORT (PID $(cat "$PID_FILE"))" + echo " 日志: $LOG" +} + +stop() { + if [ -f "$PID_FILE" ]; then + kill "$(cat "$PID_FILE")" 2>/dev/null + rm -f "$PID_FILE" + echo "已停止" + else + echo "未在运行" + fi +} + +case "${1:-start}" in + start) start ;; + stop) stop ;; + restart) stop; sleep 1; start ;; + status) + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "运行中 PID=$(cat "$PID_FILE")"; curl -s -m 5 "http://127.0.0.1:$PORT/api/health"; echo + else + echo "未运行" + fi ;; + seed) cd "$DIR" && "$PY" seed_data.py "${2:-}" ;; + *) echo "用法: $0 [start|stop|restart|status|seed]"; exit 1 ;; +esac diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..901f8fe --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,210 @@ +:root { + --bg: #0d1117; + --bg2: #161b22; + --bg3: #1c2230; + --card: #161b22; + --border: #262d3a; + --text: #e6edf3; + --text2: #9da7b3; + --muted: #6e7681; + --accent: #3b82f6; + --up: #ef4444; /* A股红涨 */ + --down: #22c55e; /* A股绿跌 */ + --gold: #f59e0b; + --cyan: #22d3ee; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ===== 布局 ===== */ +.layout { display: flex; min-height: 100vh; } +.sidebar { + width: 218px; background: var(--bg2); border-right: 1px solid var(--border); + display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; +} +.logo { display: flex; gap: 10px; align-items: center; padding: 18px 16px; border-bottom: 1px solid var(--border); } +.logo-badge { + width: 38px; height: 38px; border-radius: 10px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); + display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 700; color: #fff; +} +.logo-title { font-weight: 700; font-size: 15px; } +.logo-sub { font-size: 11px; color: var(--muted); } +.nav { flex: 1; padding: 12px 10px; display: flex; flex-direction: column; gap: 4px; } +.nav-item { + display: block; padding: 10px 14px; border-radius: 8px; color: var(--text2); + font-weight: 500; transition: .15s; +} +.nav-item:hover { background: var(--bg3); color: var(--text); text-decoration: none; } +.nav-item.active { background: rgba(59,130,246,.15); color: var(--accent); } +.sidebar-foot { padding: 14px; border-top: 1px solid var(--border); font-size: 12px; } +.mock-tag { color: var(--gold); margin-bottom: 6px; } +.side-time { color: var(--muted); } +.main { flex: 1; min-width: 0; } +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 24px; background: var(--bg2); border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 10; +} +.page-title { font-size: 18px; font-weight: 700; } +.sys-status { color: var(--text2); font-size: 13px; } +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--down); margin-right: 6px; } +.content { padding: 20px 24px 40px; } + +/* ===== 卡片 ===== */ +.card { + background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; +} +.card-title { font-size: 15px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; } +.card-title .bar { width: 4px; height: 16px; border-radius: 2px; background: var(--accent); display: inline-block; } +.grid { display: grid; gap: 16px; } +.grid-2 { grid-template-columns: 1fr 1fr; } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-4 { grid-template-columns: repeat(4, 1fr); } +.grid-1-2 { grid-template-columns: 1.2fr 1fr; } +.grid-2-1 { grid-template-columns: 1fr 1.2fr; } +@media (max-width: 1100px) { + .grid-4, .grid-3 { grid-template-columns: repeat(2, 1fr); } + .grid-1-2, .grid-2-1 { grid-template-columns: 1fr; } +} +.mt16 { margin-top: 16px; } +.mt8 { margin-top: 8px; } +.mb8 { margin-bottom: 8px; } + +/* ===== 表格 ===== */ +table { width: 100%; border-collapse: collapse; } +th { + text-align: left; padding: 10px 10px; color: var(--muted); font-weight: 600; font-size: 12px; + border-bottom: 1px solid var(--border); white-space: nowrap; +} +td { padding: 10px; border-bottom: 1px solid #1f2630; white-space: nowrap; } +tr:hover td { background: rgba(59,130,246,.05); } +.row-link { cursor: pointer; } + +/* ===== 徽标/标签 ===== */ +.tag { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 12px; font-weight: 600; } +.tag-强烈推荐 { background: rgba(239,68,68,.15); color: var(--up); } +.tag-推荐 { background: rgba(245,158,11,.15); color: var(--gold); } +.tag-关注 { background: rgba(59,130,246,.15); color: var(--accent); } +.tag-观望 { background: rgba(110,118,129,.15); color: var(--muted); } +.tag-买入 { background: rgba(239,68,68,.15); color: var(--up); } +.tag-增持 { background: rgba(245,158,11,.15); color: var(--gold); } +.tag-中性 { background: rgba(110,118,129,.15); color: var(--muted); } +.tag-减持 { background: rgba(34,197,94,.15); color: var(--down); } +.tag-正 { background: rgba(239,68,68,.12); color: var(--up); } +.tag-负 { background: rgba(34,197,94,.12); color: var(--down); } +.tag-平 { background: rgba(110,118,129,.15); color: var(--muted); } +.cat-tag { padding: 2px 8px; border-radius: 6px; font-size: 12px; background: var(--bg3); color: var(--text2); } +.cat-tag.业绩 { color: #f472b6; } +.cat-tag.行业 { color: #38bdf8; } +.cat-tag.公司 { color: #a78bfa; } +.cat-tag.机构观点 { color: #fbbf24; } +.cat-tag.市场 { color: #4ade80; } + +.up { color: var(--up); } +.down { color: var(--down); } +.flat { color: var(--text2); } +.num { font-variant-numeric: tabular-nums; } + +/* ===== 评分环 ===== */ +.score-wrap { display: flex; align-items: center; gap: 10px; } +.score-ring { position: relative; width: 46px; height: 46px; } +.score-ring svg { transform: rotate(-90deg); } +.score-ring .ring-val { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + font-size: 14px; font-weight: 700; +} + +/* ===== 按钮/输入 ===== */ +.btn { + display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 8px; + border: 1px solid var(--border); background: var(--bg3); color: var(--text); cursor: pointer; + font-size: 13px; font-weight: 500; transition: .15s; +} +.btn:hover { border-color: var(--accent); color: var(--accent); } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-primary:hover { background: #2f6fd6; color: #fff; } +.btn-danger { color: var(--up); } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.input, select.input { + padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); + background: var(--bg2); color: var(--text); font-size: 13px; outline: none; +} +.input:focus { border-color: var(--accent); } +.input-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + +/* ===== 指标小卡 ===== */ +.kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; } +.kpi { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 12px; } +.kpi .kpi-label { font-size: 12px; color: var(--muted); margin-bottom: 6px; } +.kpi .kpi-value { font-size: 18px; font-weight: 700; } +.kpi .kpi-sub { font-size: 12px; color: var(--text2); margin-top: 4px; } + +/* ===== 新闻 ===== */ +.news-item { + padding: 14px 4px; border-bottom: 1px solid #1f2630; cursor: pointer; transition: .15s; +} +.news-item:hover { background: rgba(59,130,246,.05); } +.news-item .news-title { font-weight: 600; margin-bottom: 6px; font-size: 14px; } +.news-item .news-meta { font-size: 12px; color: var(--muted); display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.news-item .news-summary { font-size: 13px; color: var(--text2); margin-top: 6px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + +/* ===== 弹窗 ===== */ +.modal-mask { + position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 100; + display: none; align-items: center; justify-content: center; padding: 20px; +} +.modal-mask.show { display: flex; } +.modal { + background: var(--bg2); border: 1px solid var(--border); border-radius: 14px; + max-width: 760px; width: 100%; max-height: 86vh; overflow: auto; padding: 22px; +} +.modal h3 { margin-bottom: 12px; } +.modal-close { float: right; cursor: pointer; color: var(--muted); font-size: 18px; } + +/* ===== Markdown 研报 ===== */ +.markdown-body { line-height: 1.75; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { + margin: 16px 0 8px; border-bottom: 1px solid var(--border); padding-bottom: 6px; +} +.markdown-body h2 { font-size: 17px; } +.markdown-body h3 { font-size: 15px; } +.markdown-body p { margin: 8px 0; color: var(--text); } +.markdown-body ul, .markdown-body ol { padding-left: 22px; margin: 8px 0; } +.markdown-body li { margin: 4px 0; } +.markdown-body strong { color: var(--gold); } +.markdown-body blockquote { border-left: 3px solid var(--accent); padding: 6px 12px; color: var(--text2); background: var(--bg3); border-radius: 4px; margin: 8px 0; } +.markdown-body table { border-collapse: collapse; margin: 10px 0; } +.markdown-body th, .markdown-body td { border: 1px solid var(--border); padding: 6px 10px; } + +/* ===== 其他 ===== */ +.empty { text-align: center; color: var(--muted); padding: 40px 0; } +.loading { text-align: center; color: var(--muted); padding: 30px 0; } +.spin { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.pagination { display: flex; gap: 8px; align-items: center; justify-content: center; margin-top: 16px; } +.pagination button { min-width: 60px; } +.pagination .page-info { color: var(--muted); font-size: 13px; } +.pill-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } +.pill { padding: 5px 14px; border-radius: 20px; border: 1px solid var(--border); background: var(--bg2); color: var(--text2); cursor: pointer; font-size: 13px; } +.pill.active { background: rgba(59,130,246,.15); border-color: var(--accent); color: var(--accent); } +.mini-stats { display: flex; gap: 24px; flex-wrap: wrap; } +.mini-stat .ms-label { color: var(--muted); font-size: 12px; } +.mini-stat .ms-value { font-size: 15px; font-weight: 700; margin-top: 2px; } +.flex { display: flex; gap: 10px; align-items: center; } +.between { justify-content: space-between; } +.wrap { flex-wrap: wrap; } + +/* ===== 评分条 ===== */ +.score-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 12px; } +.score-bar .sb-label { width: 36px; color: var(--text2); } +.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); } diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..0058272 --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,59 @@ +/* 数据管理 */ +const tableNames = { + stocks: '股票', stock_daily: '日线行情', news: '财经新闻', institutions: '机构', + inst_ratings: '机构评级', fund_holdings: '基金持仓', watchlist: '自选股', + analysis_cache: '研报缓存', market_index: '市场指数' +}; + +async function refreshStats() { + try { + const d = await api('/api/admin/stats'); + $('#dbBox').innerHTML = Object.entries(d.tables).map(([k, v]) => ` +
+ ${tableNames[k] || k}${v} +
`).join(''); + $('#vecBox').innerHTML = ` +
+ 📰 新闻向量索引 (stock_news_v1) + ${d.vector.news} +
+
+ 🏢 公司概况索引 (stock_profiles_v1) + ${d.vector.profiles} +
+
+ 数据模式 + ${d.is_mock ? '模拟数据' : '真实数据'} +
`; + } catch (e) { + $('#dbBox').innerHTML = '
加载失败
'; + } +} + +async function reseed() { + if (!confirm('将清空全部业务数据并重新生成(含向量索引重建,需 1-3 分钟),确定继续?')) return; + try { + await api('/api/admin/reseed', { method: 'POST' }); + toast('重灌任务已启动'); + } catch (e) { + toast('启动失败'); + } +} + +async function health() { + $('#healthBox').innerHTML = '
检查中…
'; + try { + const d = await api('/api/admin/healthcheck'); + $('#healthBox').innerHTML = `
+ ${Object.entries(d).map(([k, v]) => ` +
+
${k}
+
${escapeHtml(v)}
+
`).join('')} +
`; + } catch (e) { + $('#healthBox').innerHTML = '
检查失败
'; + } +} + +refreshStats(); diff --git a/static/js/common.js b/static/js/common.js new file mode 100644 index 0000000..a0437af --- /dev/null +++ b/static/js/common.js @@ -0,0 +1,127 @@ +/* 智能荐股系统 - 公共工具 */ +const $ = (sel, el) => (el || document).querySelector(sel); +const $$ = (sel, el) => Array.from((el || document).querySelectorAll(sel)); + +async function api(url, opts) { + const res = await fetch(url, opts); + const data = await res.json().catch(() => ({})); + if (!res.ok && data.error) throw new Error(data.error); + return data; +} + +function fmtPct(v) { + if (v === null || v === undefined) return '--'; + const n = Number(v); + return (n > 0 ? '+' : '') + n.toFixed(2) + '%'; +} + +function pctClass(v) { + const n = Number(v); + if (n > 0) return 'up'; + if (n < 0) return 'down'; + return 'flat'; +} + +function fmtNum(v, digits) { + if (v === null || v === undefined) return '--'; + const n = Number(v); + if (Math.abs(n) >= 10000) return (n / 10000).toFixed(2) + '万'; + return n.toFixed(digits === undefined ? 2 : digits); +} + +function fmtAmountYi(v) { + // 万元 -> 亿元 + const n = Number(v); + if (!n) return '--'; + return (n / 10000).toFixed(2) + '亿'; +} + +function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); +} + +function mdRender(text) { + if (window.marked) { + return marked.parse(text || ''); + } + return '
' + escapeHtml(text) + '
'; +} + +/* 评分环(SVG) */ +function scoreRing(el, score) { + const color = score >= 82 ? '#ef4444' : score >= 68 ? '#f59e0b' : score >= 55 ? '#3b82f6' : '#6e7681'; + const r = 18, c = 2 * Math.PI * r; + const off = c * (1 - Math.min(100, score) / 100); + el.innerHTML = ` + + + ${score}`; +} + +function scoreBars(parts) { + const map = [['trend', '趋势'], ['momentum', '动量'], ['technical', '技术'], + ['volume', '量能'], ['news', '消息'], ['institutional', '机构']]; + return map.map(([k, label]) => { + const v = parts ? (parts[k] || 0) : 0; + const pct = Math.min(100, v / (k === 'trend' ? 25 : k === 'momentum' ? 20 : 15) * 100); + return `
+
${label}
+
+
${v}
+
`; + }).join(''); +} + +/* 导航高亮 */ +(function nav() { + const path = location.pathname; + $$('.nav-item').forEach(a => { + const n = a.dataset.nav; + if (n && (path === n || (n !== '/' && path.startsWith(n)))) a.classList.add('active'); + }); + const t = setInterval(() => { + const el = $('#sideTime'); + if (!el) { clearInterval(t); return; } + el.textContent = new Date().toLocaleString('zh-CN', { hour12: false }); + }, 1000); +})(); + +/* 弹窗 */ +function openModal(html) { + let mask = $('.modal-mask'); + if (!mask) { + mask = document.createElement('div'); + mask.className = 'modal-mask'; + mask.innerHTML = ``; + document.body.appendChild(mask); + $('.modal-close', mask).onclick = () => mask.classList.remove('show'); + mask.onclick = e => { if (e.target === mask) mask.classList.remove('show'); }; + } + $('.modal-body', mask).innerHTML = html; + mask.classList.add('show'); + return mask; +} + +/* toast */ +function toast(msg, type) { + let box = $('#toastBox'); + if (!box) { + box = document.createElement('div'); + box.id = 'toastBox'; + box.style.cssText = 'position:fixed;top:20px;right:20px;z-index:999;display:flex;flex-direction:column;gap:8px;'; + document.body.appendChild(box); + } + const d = document.createElement('div'); + d.style.cssText = 'padding:10px 16px;border-radius:8px;background:#1c2230;border:1px solid #262d3a;color:#e6edf3;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.4);'; + d.textContent = msg; + box.appendChild(d); + setTimeout(() => d.remove(), 2600); +} + +function debounce(fn, ms) { + let t; + return function () { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), ms); }; +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..26d9c15 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,102 @@ +/* 仪表盘 */ +async function loadOverview() { + try { + const d = await api('/api/overview'); + renderIndexes(d.indexes, d.stat); + renderTop(d.top); + renderHeat(d.heat); + renderNews(d.news); + renderWatch(d.watchlist); + } catch (e) { + $('#indexBox').innerHTML = '
加载失败:' + escapeHtml(e.message) + '
'; + } +} + +function renderIndexes(indexes, stat) { + const cards = indexes.map(ix => { + const cls = pctClass(ix.chg); + return `
+
${ix.label}
+
${Number(ix.value).toFixed(2)}
+
${fmtPct(ix.chg)}
+
`; + }).join(''); + const s = stat || {}; + $('#indexBox').innerHTML = cards + `
+
市场情绪
+
+
上涨
${s.up || 0}
+
下跌
${s.down || 0}
+
涨停
${s.limit_up || 0}
+
跌停
${s.limit_down || 0}
+
成交额
${fmtAmountYi(s.amount_yi)}
+
+
股票池 ${s.total || 0} 只 · 综合评分由 趋势/动量/技术/量能/消息/机构 六因子加权得出
+
`; +} + +function renderTop(list) { + if (!list || !list.length) { $('#topRecBox').innerHTML = '
暂无数据
'; return; } + $('#topRecBox').innerHTML = ` + + ${list.map((it, i) => ` + + + + + + + + `).join('')} +
排名股票现价今日评分评级推荐理由
`; +} + +function renderHeat(heat) { + if (!heat || !heat.length) { $('#heatBox').innerHTML = '
暂无数据
'; return; } + const max = Math.max(...heat.map(h => Math.abs(h.chg)), 0.01); + $('#heatBox').innerHTML = heat.map(h => { + const w = Math.min(100, Math.abs(h.chg) / max * 100); + const cls = pctClass(h.chg); + return `
+
+ ${escapeHtml(h.industry)} (${h.cnt}) + ${fmtPct(h.chg)} +
+
+
+
+
`; + }).join(''); +} + +function renderNews(news) { + if (!news || !news.length) { $('#newsBox').innerHTML = '
暂无数据
'; return; } + $('#newsBox').innerHTML = news.map(n => ` +
+
${escapeHtml(n.title)}
+
+ ${n.category} + ${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'} + ${escapeHtml(n.source)}${n.publish_date} +
+
`).join(''); +} + +function renderWatch(list) { + if (!list || !list.length) { + $('#watchBox').innerHTML = `
暂无自选股
去股票池添加 →
`; + return; + } + $('#watchBox').innerHTML = ` + + ${list.map(it => ` + + + + + + `).join('')} +
股票现价今日评分评级
`; +} + +loadOverview(); diff --git a/static/js/institutions.js b/static/js/institutions.js new file mode 100644 index 0000000..171bee0 --- /dev/null +++ b/static/js/institutions.js @@ -0,0 +1,108 @@ +/* 机构动向 */ +let curType = ''; + +async function loadInsts() { + try { + const d = await api('/api/institutions?type=' + curType); + const items = d.items || []; + $('#tb').innerHTML = items.map(it => ` + + ${escapeHtml(it.name)} + ${escapeHtml(it.type)} + ${escapeHtml(it.description)} + … + … + + `).join(''); + items.forEach(it => fillCounts(it.id, it.name)); + } catch (e) { + $('#tb').innerHTML = '加载失败'; + } +} + +async function fillCounts(id, name) { + try { + const d = await api('/api/ratings/upgrades'); + const rc = d.items.filter(x => x.inst_name === name).length; + $('#rc-' + id).textContent = rc; + } catch (e) {} +} + +async function showInst(id) { + try { + const d = await api('/api/institutions/' + id); + const inst = d.institution; + const ratings = (d.ratings || []).map(r => ` + + ${r.name}(${r.stock_code}) + ${r.rating} + ${r.target_price}${r.rating_date} + `).join(''); + const holdings = (d.holdings || []).map(h => ` + + ${h.name}(${h.stock_code})${h.quarter} + ${fmtNum(h.hold_value)} + ${fmtPct(h.change_pct)} + `).join(''); + openModal(` +

${escapeHtml(inst.name)} ${escapeHtml(inst.type)}

+
${escapeHtml(inst.description)}
+

近期评级

+ ${ratings || ''}
暂无
+

基金持仓

+ ${holdings || ''}
暂无
+ `); + } catch (e) { toast('加载失败'); } +} + +async function loadMoves() { + try { + const d = await api('/api/holdings/moves'); + const q = d.quarter || ''; + const inc = (d.increase || []).map(h => ` + + ${h.name}${escapeHtml(h.inst_name)} + ${fmtPct(h.change_pct)} + `).join(''); + const dec = (d.decrease || []).map(h => ` + + ${h.name}${escapeHtml(h.inst_name)} + ${fmtPct(h.change_pct)} + `).join(''); + $('#holdBox').innerHTML = `
最新季度:${q}
+ + ${inc || ''} + + ${dec || ''} +
增持榜机构环比
暂无
减持榜机构环比
暂无
`; + } catch (e) {} +} + +async function loadUpgrades() { + try { + const d = await api('/api/ratings/upgrades'); + $('#upgradeBox').innerHTML = ` + + ${(d.items || []).slice(0, 14).map(r => ` + + + + + + `).join('')} +
股票机构评级日期
`; + } catch (e) {} +} + +$$('#typePills .pill').forEach(p => { + p.onclick = () => { + $$('#typePills .pill').forEach(x => x.classList.remove('active')); + p.classList.add('active'); + curType = p.dataset.t; + loadInsts(); + }; +}); + +loadMoves(); +loadUpgrades(); +loadInsts(); diff --git a/static/js/news.js b/static/js/news.js new file mode 100644 index 0000000..757b985 --- /dev/null +++ b/static/js/news.js @@ -0,0 +1,57 @@ +/* 财经新闻 */ +let page = 1, total = 0; + +async function load(p) { + if (p) page = p; + const q = new URLSearchParams({ keyword: $('#kw').value.trim(), category: $('#cat').value, page }); + try { + const d = await api('/api/news?' + q.toString()); + total = d.total; + render(d.items); + $('#pageInfo').textContent = `第 ${page} / ${Math.max(1, Math.ceil(total / 15))} 页`; + $('#count').textContent = `共 ${total} 条`; + $('#prevBtn').disabled = page <= 1; + $('#nextBtn').disabled = page * 15 >= total; + } catch (e) { + $('#listBox').innerHTML = '
加载失败
'; + } +} + +function render(items) { + if (!items.length) { $('#listBox').innerHTML = '
无匹配新闻
'; return; } + $('#listBox').innerHTML = items.map(n => ` +
+
${escapeHtml(n.title)}
+
+ ${n.category} + ${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'} + ${escapeHtml(n.source)}${n.publish_date} +
+
${escapeHtml(n.content)}
+
`).join(''); +} + +async function showDetail(id) { + try { + const d = await api('/api/news/' + id); + const n = d.news; + const stocks = (d.stocks || []).map(s => `${s.name}(${s.code})`).join(' · ') || '无'; + openModal(` +

${escapeHtml(n.title)}

+
+ ${n.category} + ${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'}(${Number(n.sentiment).toFixed(2)}) + ${escapeHtml(n.source)}${n.publish_date} +
+
${escapeHtml(n.content)}
+
+ 关联股票:${stocks} +
+ `); + } catch (e) { toast('加载失败'); } +} + +$('#kw').addEventListener('keydown', e => { if (e.key === 'Enter') load(1); }); +$('#kw').addEventListener('input', debounce(() => load(1), 400)); +$('#cat').onchange = () => load(1); +load(1); diff --git a/static/js/recommend.js b/static/js/recommend.js new file mode 100644 index 0000000..09c2823 --- /dev/null +++ b/static/js/recommend.js @@ -0,0 +1,49 @@ +/* 荐股中心 */ +let curRating = 'all'; + +async function load() { + try { + const d = await api('/api/recommend?rating=' + curRating + '&limit=80'); + render(d.items); + } catch (e) { + $('#tb').innerHTML = '加载失败:' + escapeHtml(e.message) + ''; + } +} + +function render(items) { + if (!items.length) { $('#tb').innerHTML = '该评级暂无股票'; return; } + $('#tb').innerHTML = items.map((it, i) => { + const sc = it.score; + return ` + ${i + 1} + ${it.name}
${it.code} · ${escapeHtml(it.industry)}
+ ${it.close} + ${fmtPct(it.change_pct)} + ${fmtPct(it.chg_5d)} +
+ ${sc.rating} + +
${scoreBars(sc.score_parts)}
+ + ${escapeHtml((it.reasons || []).join(';'))} + + `; + }).join(''); + $$('.score-ring', $('#tb')).forEach(el => scoreRing(el, el.dataset.score)); +} + +/* AI 深度分析:跳详情页并自动触发 */ +function analyze(code, name) { + location.href = `/stock/${code}?analyze=1`; +} + +$$('#ratingPills .pill').forEach(p => { + p.onclick = () => { + $$('#ratingPills .pill').forEach(x => x.classList.remove('active')); + p.classList.add('active'); + curRating = p.dataset.r; + load(); + }; +}); + +load(); diff --git a/static/js/stock_detail.js b/static/js/stock_detail.js new file mode 100644 index 0000000..3908d45 --- /dev/null +++ b/static/js/stock_detail.js @@ -0,0 +1,252 @@ +/* 股票详情页 */ +const CODE = location.pathname.split('/').pop(); +let stock = null, curTab = 'news'; + +async function load() { + try { + const d = await api('/api/stock/' + CODE); + stock = d; + renderHead(d); + renderKpi(d.ind); + renderScore(d); + renderTab(curTab); + loadKline(120); + initWatch(d.stock.is_watch); + if (new URLSearchParams(location.search).get('analyze') === '1') generateReport(); + } catch (e) { + document.querySelector('.content').innerHTML = '
股票不存在或加载失败
'; + } +} + +function renderHead(d) { + const s = d.stock, ind = d.ind; + $('#headName').textContent = s.name; + $('#stName').textContent = s.name; + $('#stCode').textContent = s.code; + $('#stIndustry').textContent = '行业:' + s.industry; + $('#stBoard').textContent = '板块:' + s.board; + $('#stCap').textContent = Number(s.market_cap).toFixed(0) + '亿'; + $('#stPe').textContent = s.pe; + $('#stPb').textContent = s.pb; + const price = ind.close, chg = ind.change_pct; + const cls = pctClass(chg); + $('#stPrice').textContent = price; + $('#stPrice').className = 'num ' + cls; + $('#stChg').textContent = fmtPct(chg); + $('#stChg').className = 'num ' + cls; + $('#stChg5').textContent = '5日 ' + fmtPct(ind.chg_5d); + $('#stChg5').className = 'num ' + pctClass(ind.chg_5d); + $('#stChg20').textContent = '20日 ' + fmtPct(ind.chg_20d); + $('#stChg20').className = 'num ' + pctClass(ind.chg_20d); + scoreRing($('#ring'), d.score.total); + const r = $('#stRating'); + r.textContent = d.score.rating; + r.className = 'tag tag-' + d.score.rating; +} + +function renderKpi(ind) { + const kpis = [ + ['现价', ind.close, 'MA5 ' + ind.ma5 + ' / MA10 ' + ind.ma10], + ['MA20 / MA60', ind.ma20 + ' / ' + (ind.ma60 || '--'), ind.trend_bull ? '多头排列' : (ind.close >= ind.ma20 ? '站上MA20' : '趋势偏弱')], + ['RSI(14)', ind.rsi, ind.rsi >= 70 ? '超买' : ind.rsi <= 30 ? '超卖' : '健康'], + ['MACD', ind.macd, 'DIF ' + ind.dif + ' / DEA ' + ind.dea], + ['KDJ', ind.kdj_k + ' / ' + ind.kdj_d + ' / ' + ind.kdj_j, ''], + ['量比', ind.vol_ratio, ind.vol_ratio >= 1.5 ? '放量' : ind.vol_ratio < 0.7 ? '缩量' : '平稳'], + ['5日/20日涨幅', fmtPct(ind.chg_5d) + ' / ' + fmtPct(ind.chg_20d), ''], + ['20日波动率', ind.volatility + '%', ''], + ['近120日区间', ind.low_52w + ' ~ ' + ind.high_52w, ''], + ]; + $('#kpiBox').innerHTML = kpis.map(([label, val, sub]) => ` +
+
${label}
+
${val}
+
${sub || ''}
+
`).join(''); +} + +function renderScore(d) { + $('#scoreBarsBox').innerHTML = scoreBars(d.score.score_parts); + $('#reasonBox').innerHTML = '推荐逻辑:' + + (d.reasons || []).map(r => escapeHtml(r)).join('') + + `机构正面评级 ${d.inst_up} 家 · 基金增持 ${d.hold_up} 家`; +} + +async function loadKline(days) { + try { + const d = await api(`/api/stock/${CODE}/kline?days=${days}`); + renderKline(d); + } catch (e) {} +} + +let chart = null; +function renderKline(d) { + const el = $('#klineChart'); + if (!window.echarts) { el.innerHTML = '
ECharts 加载失败(请检查网络)
'; return; } + if (!chart) chart = echarts.init(el); + const dates = d.dates; + const klineData = d.kline; + const volumes = d.volumes; + // 计算涨跌色 + const colors = klineData.map(k => (k[1] >= k[0] ? '#ef4444' : '#22c55e')); + chart.setOption({ + backgroundColor: 'transparent', + animation: false, + legend: { data: ['K线', 'MA5', 'MA10', 'MA20', 'MA60'], textStyle: { color: '#9da7b3' }, top: 0 }, + tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, + formatter: params => { + const i = params[0].dataIndex; + const k = klineData[i]; + let s = `${dates[i]}
开 ${k[0]} / 收 ${k[1]} / 低 ${k[2]} / 高 ${k[3]}`; + params.forEach(p => { if (p.seriesName !== '成交量') s += `
${p.seriesName}: ${p.value}`; }); + return s; + } }, + axisPointer: { link: [{ xAxisIndex: 'all' }] }, + grid: [{ left: 60, right: 16, top: 30, height: '58%' }, { left: 60, right: 16, top: '72%', height: '18%' }], + xAxis: [ + { type: 'category', data: dates, boundaryGap: true, axisLine: { lineStyle: { color: '#262d3a' } }, axisLabel: { color: '#6e7681' } }, + { type: 'category', gridIndex: 1, data: dates, axisLabel: { show: false }, axisLine: { lineStyle: { color: '#262d3a' } } }, + ], + yAxis: [ + { scale: true, splitLine: { lineStyle: { color: '#1f2630' } }, axisLabel: { color: '#6e7681' } }, + { gridIndex: 1, splitNumber: 2, axisLabel: { show: false }, splitLine: { show: false } }, + ], + dataZoom: [ + { type: 'inside', xAxisIndex: [0, 1], start: 55, end: 100 }, + { type: 'slider', xAxisIndex: [0, 1], bottom: 2, height: 16, start: 55, end: 100, + borderColor: '#262d3a', backgroundColor: '#161b22', fillerColor: 'rgba(59,130,246,.2)' }, + ], + series: [ + { name: 'K线', type: 'candlestick', data: klineData, + itemStyle: { color: '#ef4444', color0: '#22c55e', borderColor: '#ef4444', borderColor0: '#22c55e' } }, + { name: 'MA5', type: 'line', data: d.ma5, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#f59e0b' } }, + { name: 'MA10', type: 'line', data: d.ma10, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#22d3ee' } }, + { name: 'MA20', type: 'line', data: d.ma20, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#a78bfa' } }, + { name: 'MA60', type: 'line', data: d.ma60, smooth: true, showSymbol: false, lineStyle: { width: 1, color: '#f472b6' } }, + { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes, itemStyle: { color: c => colors[c.dataIndex] } }, + ], + }, true); +} + +window.addEventListener('resize', () => chart && chart.resize()); + +/* 自选 */ +function initWatch(isWatch) { + const b = $('#watchBtn'); + if (isWatch) { + b.textContent = '★ 已在自选'; + b.classList.add('btn-danger'); + b.onclick = async () => { + await api('/api/watchlist/' + CODE, { method: 'DELETE' }); + toast('已移出自选'); + initWatch(false); + }; + } else { + b.textContent = '⭐ 加入自选'; + b.classList.remove('btn-danger'); + b.onclick = async () => { + await api('/api/watchlist/' + CODE, { method: 'POST' }); + toast('已加入自选'); + initWatch(true); + }; + } +} + +/* Tab */ +async function renderTab(tab) { + curTab = tab; + $$('#tabs .pill').forEach(p => p.classList.toggle('active', p.dataset.tab === tab)); + $('#tabBox').innerHTML = '
加载中…
'; + if (tab === 'news') { + try { + const d = await api(`/api/stock/${CODE}/news`); + $('#tabBox').innerHTML = d.items.length ? d.items.map(n => ` +
+
${escapeHtml(n.title)}
+
+ ${n.category} + ${n.sentiment > 0 ? '利好' : n.sentiment < 0 ? '利空' : '中性'}(${Number(n.sentiment).toFixed(2)}) + ${escapeHtml(n.source)}${n.publish_date} +
+
${escapeHtml(n.content)}
+
`).join('') : '
暂无相关新闻
'; + } catch (e) { $('#tabBox').innerHTML = '
加载失败
'; } + } else if (tab === 'ratings') { + try { + const d = await api(`/api/stock/${CODE}/institutions`); + const rows = (d.ratings || []).map(r => ` + ${escapeHtml(r.inst_name)} + ${r.rating} + ${r.target_price}${r.rating_date}`).join(''); + $('#tabBox').innerHTML = ` + + ${rows || ''}
机构评级目标价日期
暂无评级
`; + } catch (e) { $('#tabBox').innerHTML = '
加载失败
'; } + } else if (tab === 'holdings') { + try { + const d = await api(`/api/stock/${CODE}/institutions`); + const rows = (d.holdings || []).map(h => ` + ${escapeHtml(h.inst_name)}${h.quarter} + ${fmtNum(h.hold_value)} + ${fmtPct(h.change_pct)}`).join(''); + $('#tabBox').innerHTML = ` + + ${rows || ''}
机构季度持仓市值环比
暂无持仓
`; + } catch (e) { $('#tabBox').innerHTML = '
加载失败
'; } + } else if (tab === 'profile') { + $('#tabBox').innerHTML = `
${escapeHtml(stock.stock.description || '暂无')}
`; + } +} + +$$('#tabs .pill').forEach(p => p.onclick = () => renderTab(p.dataset.tab)); +$$('.btn[data-days]').forEach(b => b.onclick = () => { + $$('.btn[data-days]').forEach(x => x.classList.remove('btn-primary')); + b.classList.add('btn-primary'); + loadKline(+b.dataset.days); +}); + +/* AI 研报 */ +async function generateReport() { + const btn = $('#analyzeBtn'); + btn.disabled = true; + $('#reportBox').innerHTML = `
AI 正在生成研报(RAG检索+DeepSeek 推理),约需 30-90 秒…
`; + try { + const focus = $('#focusInput').value.trim(); + await api('/api/stock/' + CODE + '/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ focus }) + }); + pollReport(); + } catch (e) { + $('#reportBox').innerHTML = '
提交失败:' + escapeHtml(e.message) + '
'; + btn.disabled = false; + } +} + +async function pollReport() { + const t0 = Date.now(); + const timer = setInterval(async () => { + try { + const d = await api(`/api/stock/${CODE}/analyze/status`); + if (d.status === 'done') { + clearInterval(timer); + $('#analyzeBtn').disabled = false; + $('#reportBox').innerHTML = `
${mdRender(d.report)}
+
${d.cached ? '(缓存报告 ' + d.created_at + ')' : '生成耗时 ' + Math.round((Date.now() - t0) / 1000) + ' 秒'}
`; + } else if (d.status === 'error') { + clearInterval(timer); + $('#analyzeBtn').disabled = false; + $('#reportBox').innerHTML = '
生成失败:' + escapeHtml(d.error || '未知错误') + '
'; + } + } catch (e) { + clearInterval(timer); + $('#analyzeBtn').disabled = false; + $('#reportBox').innerHTML = '
查询状态失败
'; + } + }, 4000); +} + +$('#analyzeBtn').onclick = generateReport; +$('#focusInput').addEventListener('keydown', e => { if (e.key === 'Enter') generateReport(); }); + +load(); diff --git a/static/js/stocks.js b/static/js/stocks.js new file mode 100644 index 0000000..ad2126a --- /dev/null +++ b/static/js/stocks.js @@ -0,0 +1,72 @@ +/* 股票池 */ +let page = 1, total = 0, industries = []; + +async function loadIndustries() { + try { + const d = await api('/api/overview'); + industries = d.heat.map(h => h.industry); + const sel = $('#industry'); + industries.forEach(i => { + const o = document.createElement('option'); + o.textContent = i; + sel.appendChild(o); + }); + } catch (e) {} +} + +async function load(p) { + if (p) page = p; + const q = new URLSearchParams({ + keyword: $('#kw').value.trim(), + industry: $('#industry').value, + board: $('#board').value, + rating: $('#rating').value, + sort: $('#sort').value, + order: $('#sort').value === 'score' ? 'desc' : 'desc', + page: page, per: 20 + }); + try { + const d = await api('/api/stocks?' + q.toString()); + total = d.total; + render(d.items); + $('#pageInfo').textContent = `第 ${page} / ${Math.max(1, Math.ceil(total / 20))} 页 · 共 ${total} 只`; + $('#stCount').textContent = `共 ${total} 只`; + $('#prevBtn').disabled = page <= 1; + $('#nextBtn').disabled = page * 20 >= total; + } catch (e) { + $('#tb').innerHTML = '加载失败'; + } +} + +function render(items) { + if (!items.length) { $('#tb').innerHTML = '无匹配股票'; return; } + $('#tb').innerHTML = items.map(it => ` + ${it.name}
${it.code}
+ ${escapeHtml(it.industry)} + ${it.board} + ${it.close} + ${fmtPct(it.change_pct)} + ${fmtPct(it.chg_5d)} + ${it.vol_ratio} + ${fmtAmountYi(it.amount)} + ${Number(it.market_cap).toFixed(0)}亿 + ${it.score} + ${it.rating} + + `).join(''); +} + +async function addWatch(code) { + try { await api('/api/watchlist/' + code, { method: 'POST' }); toast('已加入自选'); } + catch (e) { toast('操作失败'); } +} + +$('#kw').addEventListener('keydown', e => { if (e.key === 'Enter') load(1); }); +$('#kw').addEventListener('input', debounce(() => load(1), 400)); +$('#industry').onchange = () => load(1); +$('#board').onchange = () => load(1); +$('#rating').onchange = () => load(1); +$('#sort').onchange = () => load(1); + +loadIndustries(); +load(1); diff --git a/static/lib/marked.min.js b/static/lib/marked.min.js new file mode 100644 index 0000000..a91afe7 --- /dev/null +++ b/static/lib/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v12.0.2 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),q={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},Z=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...q,table:Z,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Z).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...q,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",C=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),M=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),O=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:C,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..920337b --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}数据管理{% endblock %} +{% block page_title %}数据管理{% endblock %} +{% block content %} +
    +
    +
    数据库统计(SQLite)
    +
    加载中…
    +
    +
    +
    向量库状态(Chroma + bge 1024维)
    +
    加载中…
    +
    +
    + +
    +
    系统维护
    +
    + + + +
    +
    +
    + 当前为模拟数据,用于功能演示与系统验证。接入真实数据后(行情/新闻/机构),修改 config.py 中的 IS_MOCK=False,并替换 seed_data.py 为真实数据源即可,其余分析/检索逻辑无需改动。
    + 技术栈:Python Flask + SQLite + Chroma 向量库 + bge-large-zh 语义检索 + DeepSeek 大模型(RAG 增强研报)。 +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..71ab0fb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,59 @@ + + + + + +{% block title %}智能荐股系统{% endblock %} · {{ service }} + + + + + + +
    + +
    +
    +
    +

    {% block page_title %}{% endblock %}

    +
    +
    + 系统运行中 +
    +
    +
    + {% block content %}{% endblock %} +
    +
    +
    + +{% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b8f96f8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}仪表盘{% endblock %} +{% block page_title %}市场总览{% endblock %} +{% block content %} +
    +
    加载中…
    +
    + +
    +
    +
    今日荐股 TOP5
    +
    加载中…
    +
    +
    +
    行业热度
    +
    加载中…
    +
    +
    + +
    +
    +
    最新要闻
    +
    加载中…
    +
    +
    +
    自选股
    +
    加载中…
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/institutions.html b/templates/institutions.html new file mode 100644 index 0000000..8bfadd4 --- /dev/null +++ b/templates/institutions.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}机构动向{% endblock %} +{% block page_title %}机构动向{% endblock %} +{% block content %} +
    +
    +
    评级变动(近45日)
    +
    加载中…
    +
    +
    +
    基金持仓变化
    +
    加载中…
    +
    +
    + +
    +
    +
    机构列表
    +
    + + + + + + +
    +
    +
    + + + +
    机构类型简介评级数持仓数操作
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/news.html b/templates/news.html new file mode 100644 index 0000000..e24b199 --- /dev/null +++ b/templates/news.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}财经新闻{% endblock %} +{% block page_title %}财经新闻{% endblock %} +{% block content %} +
    +
    + + + + +
    +
    加载中…
    + +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/recommend.html b/templates/recommend.html new file mode 100644 index 0000000..a7a0d38 --- /dev/null +++ b/templates/recommend.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}荐股中心{% endblock %} +{% block page_title %}荐股中心{% endblock %} +{% block content %} +
    +
    + + + + + +
    +
    + + + + + + +
    排名股票现价今日5日评分评级六因子推荐理由操作
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/stock_detail.html b/templates/stock_detail.html new file mode 100644 index 0000000..66d5b48 --- /dev/null +++ b/templates/stock_detail.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}股票详情{% endblock %} +{% block page_title %}加载中…{% endblock %} +{% block content %} +
    +
    +
    +
    +
    +
    + + 市值 + PE + PB +
    +
    +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    综合评分
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    K线走势(日线)
    +
    +
    + + + + +
    +
    +
    +
    六因子评分
    +
    +
    评分说明
    +
    + 趋势(25) 均线多头排列
    动量(20) 5日涨幅
    技术(15) RSI健康度
    量能(10) 量比
    消息(15) 近7日新闻情感
    机构(15) 评级+持仓动向 +
    +
    +
    + +
    +
    AI 深度分析(RAG 增强)
    +
    + + +
    +
    +
    点击「生成研报」,系统将基于 新闻RAG检索 + 技术指标 + 机构数据 调用 DeepSeek 生成结构化研报(首次生成约需 30-90 秒)
    +
    +
    + +
    +
    + + + + +
    +
    加载中…
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/stocks.html b/templates/stocks.html new file mode 100644 index 0000000..9000629 --- /dev/null +++ b/templates/stocks.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}股票池{% endblock %} +{% block page_title %}股票池{% endblock %} +{% block content %} +
    +
    + + + + + + + +
    +
    + + + + + + +
    代码/名称行业板块现价今日5日量比成交额市值评分评级操作
    +
    + +
    +{% endblock %} +{% block scripts %} + +{% endblock %}