diff --git a/app.py b/app.py index b0dd6d2..8a8767f 100644 --- a/app.py +++ b/app.py @@ -55,6 +55,11 @@ def page_strategies(): return render_template("strategies.html", service=SERVICE_NAME, is_mock=IS_MOCK) +@app.route("/tracking") +def page_tracking(): + return render_template("tracking.html", service=SERVICE_NAME, is_mock=IS_MOCK) + + @app.route("/admin") def page_admin(): return render_template("admin.html", service=SERVICE_NAME, is_mock=IS_MOCK) @@ -550,15 +555,17 @@ def api_backtest_rebuild(): # ------------------------------------------------------------------ 设置与舆情监控 @app.route("/api/settings") def api_settings(): - from settings import all_settings, mail_config, monitor_config, monitor_state + from settings import all_settings, mail_config, monitor_config, monitor_state, \ + tracking_config, tracking_state from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL s = all_settings() - # 返回带默认值的完整配置 mail = mail_config() mono = monitor_config() + track = tracking_config() return jsonify({ "mail": mail, "monitor": mono, + "tracking": {**track, **tracking_state()}, "llm": { "base_url": s.get("llm_base_url", LLM_BASE_URL), "api_key": s.get("llm_api_key", LLM_API_KEY), @@ -589,6 +596,11 @@ def api_settings_save(): "monitor_sentiment", "monitor_importance", "monitor_keywords"): if k in mono: set_setting(k, mono[k]) + # 持仓跟踪 + track = body.get("tracking") or {} + for k in ("tracking_enabled", "tracking_interval", "tracking_notify", "tracking_impact_threshold"): + if k in track: + set_setting(k, track[k]) return jsonify({"ok": True, "msg": "设置已保存"}) @@ -637,6 +649,62 @@ def api_monitor_log(): return jsonify({"items": notification_log()}) +# ------------------------------------------------------------------ 持仓跟踪 +@app.route("/api/tracking") +def api_tracking_list(): + from engine.agent import list_reports + return jsonify({"items": list_reports()}) + + +@app.route("/api/tracking/stock/") +def api_tracking_stock(code): + from engine.agent import latest_reports + return jsonify({"items": latest_reports(code)}) + + +@app.route("/api/tracking/") +def api_tracking_detail(rid): + from engine.agent import get_report + import json as _json + r = get_report(rid) + if not r: + return jsonify({"error": "记录不存在"}), 404 + r["meta"] = _json.loads(r["meta"] or "{}") + r["sources"] = _json.loads(r["sources"] or "{}") + return jsonify(r) + + +@app.route("/api/tracking/run", methods=["POST"]) +def api_tracking_run(): + """立即跟踪全部自选股(后台)""" + import threading + + def run(): + from engine.agent import track_watchlist + try: + track_watchlist() + except Exception as e: + log.error("track run fail: %s", e) + + threading.Thread(target=run, daemon=True).start() + return jsonify({"ok": True, "msg": "跟踪任务已启动(逐只分析,约每只30-60秒)"}) + + +@app.route("/api/tracking/run/", methods=["POST"]) +def api_tracking_run_one(code): + from engine.agent import track_stock + import threading + + def run(): + try: + track_stock(code) + except Exception as e: + log.error("track %s fail: %s", code, e) + + threading.Thread(target=run, daemon=True).start() + return jsonify({"ok": True, "msg": "跟踪已启动"}) + + # ------------------------------------------------------------------ 数据管理 @app.route("/api/admin/stats") def api_admin_stats(): @@ -694,6 +762,8 @@ def api_admin_healthcheck(): if __name__ == "__main__": init_db() from engine.notifier import start_monitor + from engine.agent import start_tracking start_monitor() + start_tracking() print(f"✅ {SERVICE_NAME} 启动: http://0.0.0.0:{SERVICE_PORT}") app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True) diff --git a/config.py b/config.py index 4ef775d..08635f2 100644 --- a/config.py +++ b/config.py @@ -65,3 +65,11 @@ MONITOR_DEFAULTS = { "monitor_importance": "45", # 重要度阈值(0-100),>= 则通知 "monitor_keywords": "回购,中标,减持,问询,停牌,上调,下调,超预期,不及预期,预警,重组,增持,定增,业绩,退市", } + +# ---------------- 持仓跟踪智能体(默认值,可在设置区修改) ---------------- +TRACKING_DEFAULTS = { + "tracking_enabled": "1", + "tracking_interval": "60", # 分钟 + "tracking_notify": "1", # 重大变化时邮件通知 + "tracking_impact_threshold": "65", # 影响度评分 >= 此值判定为重大变化 +} diff --git a/database.py b/database.py index c5f3105..ef5c6bf 100644 --- a/database.py +++ b/database.py @@ -140,6 +140,19 @@ CREATE TABLE IF NOT EXISTS notification_log ( sent_at TEXT DEFAULT (datetime('now','localtime')) ); +CREATE TABLE IF NOT EXISTS tracking_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + stock_name TEXT DEFAULT '', + industry TEXT DEFAULT '', + report TEXT DEFAULT '', -- 产业链深度分析(markdown) + meta TEXT DEFAULT '{}', -- JSON:significance/impact_score/summary/新闻统计 + sources TEXT DEFAULT '{}', -- JSON:个股/上游/下游/同业 采集的资讯 + 指标 + 评级 + status TEXT DEFAULT 'done', + created_at TEXT DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_tracking_code ON tracking_reports(code); + CREATE TABLE IF NOT EXISTS market_index ( date TEXT PRIMARY KEY, sh REAL DEFAULT 0, -- 上证指数(点) @@ -206,7 +219,7 @@ def wipe_all(): """清空业务表(保留结构)+ 重置自增序列,用于重灌数据""" for t in ("stock_daily", "inst_ratings", "fund_holdings", "news", "institutions", "stocks", "watchlist", "analysis_cache", "analysis_history", - "market_index", "strategy_backtests", "notification_log"): + "market_index", "strategy_backtests", "notification_log", "tracking_reports"): with db() as conn: conn.execute(f'DELETE FROM "{t}"') with db() as conn: diff --git a/engine/agent.py b/engine/agent.py new file mode 100644 index 0000000..2660a41 --- /dev/null +++ b/engine/agent.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- +""" +持仓跟踪智能体(AI Agent + 工作流) + +对持仓/自选股票进行定期深度跟踪,工作流: + 1. 上下文构建 读取产业链知识库(上游/下游/同业 + 关键词) + 2. 数据采集 个股动态 + 上游产业链 + 下游产业链 + 同业动态(DB + RAG 向量检索) + 3. 智能体分析 大模型扮演产业链跟踪分析师,输出深度专业分析 + 影响度判定 + 4. 沉淀与通知 结果入库 tracking_reports;重大变化(impact_score 达阈值)邮件通知 + +「智能体」体现在:大模型自主综合个股与产业链多环节信息,输出结构化的 +个股动态 / 上游供给成本 / 下游需求景气 / 同业竞争 / 传导影响 / 风险与关注要点, +并给出 0-100 影响度评分与变化性质判定。 +""" +import json +import logging +import re +import threading +import time + +from database import query, query_one, execute +from settings import tracking_config, tracking_state, set_tracking_state, mail_config +from engine.chain_data import get_chain +from engine.indicators import compute_indicators +from rag.vector_store import query_vectors +from config import CHROMA_NEWS_COLLECTION +from engine.analyst import llm_chat + +log = logging.getLogger("agent") + +_track_lock = threading.Lock() +_cycle_running = False +_jobs = {} # code -> {status, error, ts} + + +# ===================================================================== 数据采集 +def _db_news(codes, days=40, limit=8): + """按关联股票代码取新闻(近 days 天)""" + if not codes: + return [] + conds, args = [], [] + for c in codes: + conds.append("(related_stocks=? OR related_stocks LIKE ? OR related_stocks LIKE ?)") + args += [c, f"%,{c}", f"{c},%"] + args.append(limit) + return query( + f"SELECT id,title,content,source,category,publish_date,sentiment FROM news " + f"WHERE ({' OR '.join(conds)}) AND publish_date >= date('now','-{days} day') " + f"ORDER BY publish_date DESC LIMIT ?", args) + + +def _rag_news_text(question, where=None, top_k=4): + """向量语义检索,返回紧凑文本""" + try: + hits = query_vectors(question, n_results=top_k, where=where, name=CHROMA_NEWS_COLLECTION) + out = [] + for h in hits: + m = h.get("metadata", {}) + out.append(f" [{m.get('date','')}] {m.get('title','')} (情感{m.get('sentiment',0):+.2f}) " + f"{h.get('document','')[:90]}") + return out + except Exception as e: + log.warning("rag fail: %s", e) + return [] + + +def _fmt_news(items): + return "\n".join( + f" [{n['publish_date']}] {n['title']} (情感{n['sentiment']:+.2f}) {n['content'][:90]}" + for n in items) or " (暂无)" + + +def collect_chain(code): + """采集个股 + 产业链各环节资讯,返回结构化 dict""" + stock = query_one("SELECT * FROM stocks WHERE code=?", (code,)) + if not stock: + return None + chain = get_chain(stock["industry"]) + seg = {} + + # 1. 个股直接动态 + direct = _db_news([code], days=40, limit=8) + direct_rag = _rag_news_text(f"{stock['name']} 最新动态 业绩 公告 重大事项", where={"code": code}, top_k=4) + seg["direct"] = {"db": _fmt_news(direct), "rag": "\n".join(direct_rag) or " (暂无)"} + + # 2. 上游 + up_db = _db_news(chain["upstream"]["codes"], days=40, limit=6) + up_rag = _rag_news_text(f"{stock['industry']} 上游 {chain['upstream']['keywords']}", top_k=4) + seg["upstream"] = {"codes": chain["upstream"]["codes"], + "keywords": chain["upstream"]["keywords"], + "db": _fmt_news(up_db), "rag": "\n".join(up_rag) or " (暂无)"} + + # 3. 下游 + dn_db = _db_news(chain["downstream"]["codes"], days=40, limit=6) + dn_rag = _rag_news_text(f"{stock['industry']} 下游需求 景气 {chain['downstream']['keywords']}", top_k=4) + seg["downstream"] = {"codes": chain["downstream"]["codes"], + "keywords": chain["downstream"]["keywords"], + "db": _fmt_news(dn_db), "rag": "\n".join(dn_rag) or " (暂无)"} + + # 4. 同业 + peer_db = _db_news(chain["peers"], days=40, limit=5) + peer_rag = _rag_news_text(f"{stock['industry']} 竞争格局 同业 {chain['peers']}", top_k=3) + seg["peers"] = {"codes": chain["peers"], + "db": _fmt_news(peer_db), "rag": "\n".join(peer_rag) or " (暂无)"} + + # 5. 技术面 + 机构 + ind = compute_indicators(query("SELECT date,open,high,low,close,volume FROM stock_daily " + "WHERE code=? ORDER BY date ASC", (code,))) + ratings = query("SELECT inst_name, rating, target_price, rating_date FROM inst_ratings " + "WHERE stock_code=? ORDER BY rating_date DESC LIMIT 5", (code,)) + holdings = query("SELECT inst_name, quarter, hold_value, change_pct FROM fund_holdings " + "WHERE stock_code=? ORDER BY quarter DESC LIMIT 5", (code,)) + seg["stock"] = stock + seg["chain"] = chain + seg["indicators"] = ind + seg["ratings"] = ratings + seg["holdings"] = holdings + return seg + + +# ===================================================================== 分析提示词 +def _fmt_ind(ind): + return (f"最新价 {ind.get('close')}({ind.get('change_pct',0):+.2f}%),5日{ind.get('chg_5d',0):+.2f}% / " + f"20日{ind.get('chg_20d',0):+.2f}%,RSI={ind.get('rsi')},量比{ind.get('vol_ratio')}," + f"MA20={ind.get('ma20')}") + + +def build_prompt(seg): + s = seg["stock"] + chain = seg["chain"] + rated = "、".join(f"{r['inst_name']}({r['rating']},目标{r['target_price']})" for r in seg["ratings"]) or "暂无" + held = ";".join(f"{h['inst_name']} {h['quarter']}持仓{h['hold_value']:.0f}万 环比{h['change_pct']:+.1f}%" + for h in seg["holdings"]) or "暂无" + return f"""你是资深产业链跟踪分析师,正在对【持仓标的】{s['name']}({s['code']}) 进行深度跟踪。请综合【个股】与【产业链上下游/同业】的全部动态,输出一份专业、有洞察的产业链跟踪分析。 + +【个股基本面】 +{s.get('description','')} + +【技术面】{_fmt_ind(seg['indicators'])} +【机构动向】评级:{rated} | 持仓:{held} + +【一、个股直接动态】(公告/新闻/机构观点) +{seg['direct']['db']} +{seg['direct']['rag']} + +【二、上游产业链】(供给/原材料/成本端;关联股票 {seg['upstream']['codes'] or '无'},关键词:{seg['upstream']['keywords']}) +{seg['upstream']['db']} +{seg['upstream']['rag']} + +【三、下游产业链】(需求/客户/景气端;关联股票 {seg['downstream']['codes'] or '无'},关键词:{seg['downstream']['keywords']}) +{seg['downstream']['db']} +{seg['downstream']['rag']} + +【四、同业动态】(竞争格局;{seg['peers']['codes'] or '无'}) +{seg['peers']['db']} +{seg['peers']['rag']} + +【输出要求】 +第一步,先输出一个 json 代码块(必须最先输出,内容为本次判定,不要包含其他内容): +```json +{{"significance":"high|medium|low","impact_score":0到100的整数,"change_kind":"利好/利空/中性/震荡","summary":"一句话总结","chain_trend":"产业链趋势判断"}} +``` +第二步,再输出 Markdown 分析报告,结构如下: +## 一、个股最新动态 +## 二、上游产业链分析(供给、原材料、成本端变化及其传导) +## 三、下游产业链分析(需求、客户、景气度变化及其传导) +## 四、同行业竞争格局 +## 五、产业链传导与投资启示(上游→中游→下游,对{ s['name']}的影响路径) +## 六、风险提示 +## 关注要点(3-5条) + +分析须严格基于提供的资讯,避免编造。impact_score 反映本次跟踪发现的动态对股价的潜在影响程度:>=65 视为重大变化。""" + + +def parse_judge(text): + """从容错地从 LLM 输出中提取 JSON 判定(支持 json 代码块/截断/缺失字段)""" + t = text.strip() + # 0) 优先取 json 代码块 + m = re.search(r"```json\s*(.*?)\s*```", t, re.S) + if m: + try: + j = json.loads(m.group(1)) + if "significance" in j or "impact_score" in j: + return j + except Exception: + pass + # 1) 整体 JSON 对象解析 + for m in re.finditer(r"\{[^{}]*\}", t, re.S): + try: + j = json.loads(m.group(0)) + if "significance" in j or "impact_score" in j: + return j + except Exception: + continue + # 2) 逐字段容错提取(末尾被截断时也能拿到已输出字段) + out = {} + m = re.search(r'"significance"\s*:\s*"(high|medium|low)"', t) + if m: + out["significance"] = m.group(1) + m = re.search(r'"impact_score"\s*:\s*(\d+)', t) + if m: + out["impact_score"] = int(m.group(1)) + m = re.search(r'"change_kind"\s*:\s*"([^"]{1,20})"', t) + if m: + out["change_kind"] = m.group(1) + m = re.search(r'"summary"\s*:\s*"((?:[^"\\]|\\.){1,200})"', t) + if m: + out["summary"] = m.group(1) + m = re.search(r'"chain_trend"\s*:\s*"((?:[^"\\]|\\.){1,120})"', t) + if m: + out["chain_trend"] = m.group(1) + return out or None + + +def strip_json_block(text): + """从报告文本中剥离最前面的 json 代码块,保留纯 Markdown""" + m = re.search(r"```json\s*.*?```\s*", text, re.S) + if m: + return text[m.end():].strip() + return text + + +# ===================================================================== 执行 +def track_stock(code, focus=""): + """执行一次跟踪,返回 {ok, report_id, meta, ...}""" + with _track_lock: + seg = collect_chain(code) + if not seg: + return {"error": "股票不存在"} + s = seg["stock"] + prompt = build_prompt(seg) + try: + reply = llm_chat([ + {"role": "system", "content": "你是一名严谨专业的产业链跟踪分析师,输出结构化、有数据支撑的分析。"}, + {"role": "user", "content": prompt}, + ]).strip() + if not reply: + raise RuntimeError("LLM 返回为空") + judge = parse_judge(reply) + # 摘要兜底:解析不到则取报告首个标题;并剥离 json 块保留纯净 Markdown + clean_report = strip_json_block(reply) + summary = (judge or {}).get("summary") or _first_heading(clean_report) + meta = { + "significance": (judge or {}).get("significance", "medium"), + "impact_score": int((judge or {}).get("impact_score", 50)), + "change_kind": (judge or {}).get("change_kind", "中性"), + "summary": summary, + "chain_trend": (judge or {}).get("chain_trend", ""), + "news_counts": { + "direct": _count(seg["direct"]), + "upstream": _count(seg["upstream"]), + "downstream": _count(seg["downstream"]), + "peers": _count(seg["peers"]), + }, + "focus": focus, + } + sources = { + "direct": seg["direct"], "upstream": seg["upstream"], + "downstream": seg["downstream"], "peers": seg["peers"], + "indicators": _fmt_ind(seg["indicators"]), + "ratings": seg["ratings"], "holdings": seg["holdings"], + } + execute( + "INSERT INTO tracking_reports(code, stock_name, industry, report, meta, sources, status, created_at) " + "VALUES(?,?,?,?,?,?,'done',datetime('now','localtime'))", + (code, s["name"], s["industry"], clean_report, json.dumps(meta, ensure_ascii=False), + json.dumps(sources, ensure_ascii=False))) + rid = query_one("SELECT MAX(id) id FROM tracking_reports")["id"] + _notify_if_significant(rid, s, meta) + return {"ok": True, "report_id": rid, "meta": meta} + except Exception as e: + log.exception("track %s fail", code) + return {"error": str(e)} + + +def _count(seg): + return (seg["db"].count("[") + seg["rag"].count("[")) // 1 + + +def _first_heading(text): + """取报告第一行非空文本作为摘要兜底""" + for line in (text or "").splitlines(): + line = line.strip().lstrip("#* ").strip() + if line: + return line[:60] + return "" + + +def _notify_if_significant(rid, stock, meta): + """影响度达阈值且开启通知 → 邮件""" + cfg = tracking_config() + try: + if cfg["notify"] and int(meta["impact_score"]) >= int(cfg["impact_threshold"]): + from engine.notifier import send_email + mc = mail_config() + send_email( + f"[持仓跟踪] {stock['name']} 出现{meta.get('change_kind','')}动态(影响度{meta['impact_score']})", + f""" +
+
🧭 持仓跟踪 · {stock['name']}({stock['code']})
+
+

影响度:{meta['impact_score']}/100({'🔴 重大' if meta['impact_score']>=65 else '🟡 关注'})
+性质:{meta.get('change_kind','')} | 显著性:{meta.get('significance','')}

+

摘要:{meta.get('summary','')}

+

产业链趋势:{meta.get('chain_trend','')}

+

个股资讯 {meta['news_counts']['direct']} 条 / 上游 {meta['news_counts']['upstream']} 条 / 下游 {meta['news_counts']['downstream']} 条 / 同业 {meta['news_counts']['peers']} 条

+
""", + cfg=mc) + set_tracking_state(last_alert=int(meta["impact_score"])) + except Exception as e: + log.warning("track notify fail: %s", e) + + +# ===================================================================== 批量与调度 +def track_watchlist(progress=None): + """串行跟踪自选股(持仓)。同一时刻只允许一个跟踪任务(防重复)""" + global _cycle_running + if _cycle_running: + return {"tracked": 0, "msg": "已有跟踪任务进行中,请稍后再试"} + _cycle_running = True + try: + stocks = query("SELECT w.code, s.name FROM watchlist w JOIN stocks s ON s.code=w.code ORDER BY w.added_at") + if not stocks: + return {"tracked": 0, "msg": "自选股为空,请先在股票池添加"} + results = [] + for i, st in enumerate(stocks): + r = track_stock(st["code"]) + results.append({"code": st["code"], "name": st["name"], **r}) + set_tracking_state(last_run=time.strftime("%Y-%m-%d %H:%M:%S"), last_stock=st["name"]) + if progress: + progress(i + 1, len(stocks)) + return {"tracked": len(results), "results": results} + finally: + _cycle_running = False + + +def latest_reports(code, limit=5): + return query("SELECT id, code, stock_name, industry, meta, status, created_at " + "FROM tracking_reports WHERE code=? ORDER BY id DESC LIMIT ?", (code, limit)) + + +def list_reports(limit=30): + return query("SELECT id, code, stock_name, industry, meta, status, created_at " + "FROM tracking_reports ORDER BY id DESC LIMIT ?", (limit,)) + + +def get_report(rid): + return query_one("SELECT * FROM tracking_reports WHERE id=?", (rid,)) + + +class TrackingThread(threading.Thread): + """后台调度:定期跟踪全部持仓股票""" + + def __init__(self): + super().__init__(daemon=True, name="tracking") + self._stop = threading.Event() + + def stop(self): + self._stop.set() + + def run(self): + log.info("持仓跟踪调度器启动") + first = True + while not self._stop.is_set(): + try: + cfg = tracking_config() + if first: + # 启动后等待一个完整间隔再首跑,避免重启即烧一轮 LLM、与手动操作冲突 + self._stop.wait(cfg.get("interval_min", 60) * 60) + first = False + continue + if cfg["enabled"]: + try: + r = track_watchlist() + log.info("tracking cycle: %s", r) + except Exception as e: + log.warning("tracking cycle error: %s", e) + except Exception as e: + log.warning("tracking loop error: %s", e) + self._stop.wait(cfg.get("interval_min", 60) * 60) + log.info("持仓跟踪调度器停止") + + +_tracking = None + + +def start_tracking(): + global _tracking + if _tracking and _tracking.is_alive(): + return _tracking + _tracking = TrackingThread() + _tracking.start() + return _tracking diff --git a/engine/chain_data.py b/engine/chain_data.py new file mode 100644 index 0000000..76acfbc --- /dev/null +++ b/engine/chain_data.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" +产业链知识库:行业 → 上游/下游/同业 + 检索关键词 +用于「持仓跟踪智能体」:跟踪某股票时,除个股动态外,还深度覆盖其 +上游(供给/成本)、下游(需求/客户)、同业竞争链的动态。 + +- codes:引用本系统股票池内的代码(便于 RAG 按 code 过滤 + 关联查询) +- keywords:行业/环节检索词(用于向量语义检索) +后期接入真实数据时,可在此补充真实产业链公司代码与关键词。 +""" +INDUSTRY_CHAIN = { + "白酒": { + "upstream": {"codes": [], "keywords": "高粱,小麦,粮食,基酒,包装,玻璃瓶,酿酒"}, + "downstream": {"codes": ["603288"], "keywords": "白酒消费,宴席,商务宴请,经销商,渠道,动销,开瓶"}, + "peers": ["000858", "600809", "000568"], + "keywords": "白酒,茅台,五粮液,提价,批价,库存,动销,高端白酒"}, + "动力电池": { + "upstream": {"codes": ["002460", "002648", "601899"], "keywords": "锂盐,碳酸锂,电解液,正极,负极,隔膜,铜箔,锂矿,六氟磷酸锂"}, + "downstream": {"codes": ["002594", "601633", "000625", "601238", "600104"], "keywords": "新能源车销量,装车量,动力电池装机,储能需求,电池排产"}, + "peers": [], + "keywords": "动力电池,宁德时代,电池装机,储能,电池价格,产能利用率"}, + "新能源汽车": { + "upstream": {"codes": ["300750", "002460", "601899"], "keywords": "动力电池,锂,芯片,钢材,铝,智能驾驶硬件"}, + "downstream": {"codes": [], "keywords": "新能源车销量,渗透率,出口,充电桩,以旧换新,价格战"}, + "peers": ["601633", "000625", "601238", "600104"], + "keywords": "新能源汽车,比亚迪,整车,销量,智能驾驶,渗透率"}, + "光伏": { + "upstream": {"codes": ["601600", "600309"], "keywords": "硅料,硅片,银浆,玻璃,铝框,石英砂,多晶硅"}, + "downstream": {"codes": ["300274", "600900"], "keywords": "装机量,组件招标,分布式,集中式,电网,海外订单"}, + "peers": ["600438", "601012"], + "keywords": "光伏,组件,硅料价格,逆变器,装机,产能出清"}, + "光伏储能": { + "upstream": {"codes": ["601012", "600438", "300750"], "keywords": "组件,硅料,储能电芯,逆变器材料"}, + "downstream": {"codes": ["600900"], "keywords": "储能装机,大储,海外储能,电网侧,工商业储能"}, + "peers": [], + "keywords": "光伏储能,阳光电源,储能系统,逆变器,出货量"}, + "锂电材料": { + "upstream": {"codes": ["601899"], "keywords": "锂矿,锂精矿,盐湖提锂,资源"}, + "downstream": {"codes": ["300750"], "keywords": "电池排产,锂盐需求,三元,磷酸铁锂,正极材料"}, + "peers": [], + "keywords": "锂电材料,赣锋锂业,碳酸锂价格,锂盐,氢氧化锂"}, + "半导体": { + "upstream": {"codes": ["688012", "002371"], "keywords": "晶圆,光刻,刻蚀,硅片,封装,EDA,靶材"}, + "downstream": {"codes": ["688041", "002475", "002241", "000725", "000063"], "keywords": "代工订单,产能利用率,下游需求,消费电子复苏,先进制程"}, + "peers": ["603986", "603501"], + "keywords": "半导体,中芯国际,晶圆代工,产能,国产替代,芯片"}, + "半导体设备": { + "upstream": {"codes": [], "keywords": "零部件,气体,靶材,精密机械,射频电源"}, + "downstream": {"codes": ["688981", "688041"], "keywords": "晶圆厂扩产,资本开支,设备招标,先进制程"}, + "peers": ["688012", "002371"], + "keywords": "半导体设备,北方华创,中微公司,刻蚀,薄膜沉积,国产化率"}, + "芯片设计": { + "upstream": {"codes": ["688981"], "keywords": "流片,先进制程,IP授权,代工"}, + "downstream": {"codes": ["002230"], "keywords": "服务器,信创,国产替代,算力,数据中心"}, + "peers": ["603986", "603501"], + "keywords": "芯片设计,海光信息,CPU,DCU,算力,国产CPU"}, + "创新药": { + "upstream": {"codes": ["603259"], "keywords": "临床,原料药,API,专利,生物药"}, + "downstream": {"codes": [], "keywords": "医保谈判,集采,销售放量,适应症,出海授权"}, + "peers": [], + "keywords": "创新药,恒瑞医药,临床,获批,医保,license-out"}, + "CXO": { + "upstream": {"codes": [], "keywords": "原料药,试剂,实验设备,动物实验"}, + "downstream": {"codes": ["600276"], "keywords": "订单,新签,产能,海外需求,生物医药融资"}, + "peers": [], + "keywords": "CXO,药明康德,新签订单,产能利用率,生物医药,研发外包"}, + "医疗器械": { + "upstream": {"codes": [], "keywords": "电子元器件,材料,芯片,传感器"}, + "downstream": {"codes": ["300015"], "keywords": "集采,医疗设备更新,政府采购,出海,装机"}, + "peers": [], + "keywords": "医疗器械,迈瑞医疗,集采,设备更新,海外,装机量"}, + "医疗服务": { + "upstream": {"codes": ["300760"], "keywords": "设备采购,耗材"}, + "downstream": {"codes": [], "keywords": "就诊量,消费医疗,医保,屈光,白内障"}, + "peers": [], + "keywords": "医疗服务,爱尔眼科,就诊量,消费医疗,连锁"}, + "中药": { + "upstream": {"codes": [], "keywords": "中药材,种植,提价,药材价格"}, + "downstream": {"codes": [], "keywords": "医保,OTC,养生需求,品牌,渠道"}, + "peers": ["000538", "600085"], + "keywords": "中药,同仁堂,云南白药,药材价格,品牌,医保"}, + "银行": { + "upstream": {"codes": [], "keywords": "存款,资金成本,同业,负债端"}, + "downstream": {"codes": ["000002", "600048"], "keywords": "信贷需求,净息差,不良率,零售,对公"}, + "peers": ["601398", "601166", "600000"], + "keywords": "银行,净息差,信贷,存款,不良率,息差收窄"}, + "券商": { + "upstream": {"codes": [], "keywords": "市场成交,两融,自营,利率"}, + "downstream": {"codes": ["300059", "300033"], "keywords": "市场成交额,IPO,经纪,投行,并购重组"}, + "peers": ["601688", "600999"], + "keywords": "券商,中信证券,市场成交,投行,经纪,两融"}, + "互联网金融": { + "upstream": {"codes": [], "keywords": "市场成交,基金,代销牌照"}, + "downstream": {"codes": ["300033"], "keywords": "市场成交额,基金销售,代销,用户数,财富管理"}, + "peers": ["300033"], + "keywords": "互联网金融,东方财富,基金销售,代销,市场成交,财富管理"}, + "金融科技": { + "upstream": {"codes": [], "keywords": "行情数据,云计算,AI大模型"}, + "downstream": {"codes": ["600036"], "keywords": "金融机构IT采购,量化交易,财富管理数字化"}, + "peers": ["300059"], + "keywords": "金融科技,同花顺,行情,量化,金融机构IT,AI"}, + "保险": { + "upstream": {"codes": [], "keywords": "利率,债券,投资收益,准备金"}, + "downstream": {"codes": [], "keywords": "保费,新业务价值,代理人,银保渠道,理赔,寿险"}, + "peers": ["601628", "601318"], + "keywords": "保险,中国平安,保费,新业务价值,投资收益率,寿险"}, + "汽车": { + "upstream": {"codes": ["300750", "002475", "601600"], "keywords": "电池,芯片,钢材,压铸,汽车电子"}, + "downstream": {"codes": [], "keywords": "汽车销量,新能源渗透率,出口,以旧换新,价格战"}, + "peers": ["000625", "601238", "600104", "002594"], + "keywords": "汽车,长城汽车,销量,出口,新能源,智能驾驶"}, + "消费电子": { + "upstream": {"codes": ["603501", "000725"], "keywords": "芯片,CIS,显示,PCB,声学,射频"}, + "downstream": {"codes": [], "keywords": "手机出货,AI终端,可穿戴,VR/AR,苹果链,折叠屏"}, + "peers": ["002241", "002475"], + "keywords": "消费电子,立讯精密,歌尔,苹果链,AI终端,可穿戴"}, + "面板": { + "upstream": {"codes": [], "keywords": "玻璃基板,偏光片,驱动IC,蒸镀"}, + "downstream": {"codes": ["002475"], "keywords": "面板价格,稼动率,TV,OLED,手机需求"}, + "peers": ["000100", "000725"], + "keywords": "面板,京东方,TCL,面板价格,稼动率,OLED"}, + "家电": { + "upstream": {"codes": ["600309"], "keywords": "铜,铝,压缩机,芯片,钢材"}, + "downstream": {"codes": [], "keywords": "空调排产,以旧换新,家电补贴,海外需求,地产竣工"}, + "peers": ["000651", "600690", "000333"], + "keywords": "家电,美的,格力,海尔,空调排产,以旧换新,海外"}, + "化工": { + "upstream": {"codes": [], "keywords": "原油,煤炭,天然气,MDI原料,纯苯"}, + "downstream": {"codes": ["000333", "600048"], "keywords": "MDI价格,聚氨酯需求,涂料,家电排产,地产需求"}, + "peers": ["002648", "600309"], + "keywords": "化工,万华化学,卫星化学,MDI,聚氨酯,景气度"}, + "有色": { + "upstream": {"codes": [], "keywords": "铜矿,金价,铝土矿,能源,品位"}, + "downstream": {"codes": ["300750", "002594", "601012"], "keywords": "铜价,铝价,金价,库存,新能源需求,电网"}, + "peers": ["601600", "601899"], + "keywords": "有色,紫金矿业,铜价,金价,铝价,库存"}, + "工程机械": { + "upstream": {"codes": [], "keywords": "钢材,液压件,发动机,轴承"}, + "downstream": {"codes": ["000002"], "keywords": "挖掘机销量,基建投资,地产开工,出口,更新周期"}, + "peers": ["000425", "600031"], + "keywords": "工程机械,三一重工,徐工,挖掘机销量,基建,出口"}, + "军工": { + "upstream": {"codes": [], "keywords": "钛合金,碳纤维,航发,军机零部件"}, + "downstream": {"codes": [], "keywords": "军费,军贸,订单,列装,航空发动机"}, + "peers": [], + "keywords": "军工,中航沈飞,军贸,订单,航空,军费"}, + "房地产": { + "upstream": {"codes": ["600036", "601166"], "keywords": "土地,建材,融资,按揭"}, + "downstream": {"codes": ["000333", "000651", "600690"], "keywords": "销售,竣工,政策,保交楼,土拍,去库存"}, + "peers": ["600048", "000002"], + "keywords": "房地产,万科,保利,销售,政策,保交楼,土拍"}, + "食品饮料": { + "upstream": {"codes": ["002714", "300498"], "keywords": "原奶,生猪,大豆,包装,原材料"}, + "downstream": {"codes": [], "keywords": "消费复苏,提价,渠道,餐饮,库存"}, + "peers": ["600519"], + "keywords": "食品饮料,海天,伊利,双汇,提价,消费复苏,渠道"}, + "养殖": { + "upstream": {"codes": [], "keywords": "饲料,玉米,豆粕,兽药,仔猪"}, + "downstream": {"codes": ["000895"], "keywords": "猪价,能繁母猪,出栏量,冻肉库存,生猪期货"}, + "peers": ["300498", "002714"], + "keywords": "养殖,牧原,温氏,猪价,能繁母猪,出栏"}, + "安防": { + "upstream": {"codes": ["603501", "000725"], "keywords": "芯片,CIS,传感器,镜头"}, + "downstream": {"codes": ["002230"], "keywords": "政府项目,数字化转型,海外安防,AI大模型,toB"}, + "peers": [], + "keywords": "安防,海康威视,大华,政府项目,海外,AIoT"}, + "人工智能": { + "upstream": {"codes": ["688041", "603986"], "keywords": "算力,GPU,大模型训练,芯片"}, + "downstream": {"codes": ["688111", "300033"], "keywords": "大模型落地,教育,医疗,toB订单,应用"}, + "peers": ["002415"], + "keywords": "人工智能,科大讯飞,大模型,算力,toB,语音"}, + "软件": { + "upstream": {"codes": ["002230", "688041"], "keywords": "算力,云,国产化,AI能力"}, + "downstream": {"codes": ["300033", "600036"], "keywords": "信创,订阅,付费用户,金融IT支出,国产替代"}, + "peers": ["600570", "688111"], + "keywords": "软件,金山办公,恒生电子,信创,订阅,金融IT"}, + "通信设备": { + "upstream": {"codes": ["688981", "688041"], "keywords": "芯片,基带,射频,光模块"}, + "downstream": {"codes": ["600941"], "keywords": "5G建设,运营商资本开支,算力网络,服务器,集采"}, + "peers": [], + "keywords": "通信设备,中兴通讯,5G,光模块,算力,运营商集采"}, + "通信运营": { + "upstream": {"codes": ["000063"], "keywords": "设备集采,5G建设,光缆"}, + "downstream": {"codes": [], "keywords": "移动用户,ARPU,算力,云业务,数字经济,AI"}, + "peers": [], + "keywords": "通信运营,中国移动,5G,ARPU,算力,云"}, + "免税": { + "upstream": {"codes": [], "keywords": "奢侈品,品牌商,机场租金,供货"}, + "downstream": {"codes": [], "keywords": "免税销售,出入境客流,离岛免税,消费复苏,口岸"}, + "peers": [], + "keywords": "免税,中国中免,离岛免税,出入境,客流"}, + "电力": { + "upstream": {"codes": [], "keywords": "来水,煤价,发电设备,水电"}, + "downstream": {"codes": ["601600"], "keywords": "用电量,绿电,电价,储能,高耗能"}, + "peers": [], + "keywords": "电力,长江电力,水电,来水,电价,用电量"}, + "煤炭": { + "upstream": {"codes": [], "keywords": "煤矿,安全生产,产能"}, + "downstream": {"codes": ["600900", "600309"], "keywords": "煤价,长协,库存,发电需求,火电"}, + "peers": [], + "keywords": "煤炭,中国神华,煤价,长协,库存,火电"}, + "稀土": { + "upstream": {"codes": [], "keywords": "稀土矿,离子型,配额,冶炼"}, + "downstream": {"codes": ["002594", "002475"], "keywords": "稀土价格,磁材,出口管制,新能源需求,电机"}, + "peers": [], + "keywords": "稀土,北方稀土,氧化镨钕,磁材,价格,配额"}, +} + +# 无明确产业链的兜底 +FALLBACK_CHAIN = { + "upstream": {"codes": [], "keywords": "原材料,供应链,成本"}, + "downstream": {"codes": [], "keywords": "需求,客户,景气度"}, + "peers": [], + "keywords": "行业动态,景气度,政策", +} + + +def get_chain(industry): + return INDUSTRY_CHAIN.get(industry) or FALLBACK_CHAIN diff --git a/settings.py b/settings.py index 6f9eea3..d9517b0 100644 --- a/settings.py +++ b/settings.py @@ -6,7 +6,7 @@ """ from database import query, execute, query_one from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_MAX_TOKENS, \ - LLM_TEMPERATURE, LLM_TIMEOUT, MAIL_DEFAULTS, MONITOR_DEFAULTS + LLM_TEMPERATURE, LLM_TIMEOUT, MAIL_DEFAULTS, MONITOR_DEFAULTS, TRACKING_DEFAULTS def get_setting(key, default=""): @@ -92,3 +92,30 @@ def set_monitor_state(last_news_id=None, last_scan=None, last_sent=None): set_setting("monitor_last_scan", last_scan) if last_sent is not None: set_setting("monitor_last_sent", last_sent) + + +# ===================================================================== 持仓跟踪 +def tracking_config(): + return { + "enabled": get_setting("tracking_enabled", TRACKING_DEFAULTS["tracking_enabled"]) == "1", + "interval_min": max(15, int(get_setting("tracking_interval", TRACKING_DEFAULTS["tracking_interval"]))), + "notify": get_setting("tracking_notify", TRACKING_DEFAULTS["tracking_notify"]) == "1", + "impact_threshold": float(get_setting("tracking_impact_threshold", TRACKING_DEFAULTS["tracking_impact_threshold"])), + } + + +def tracking_state(): + return { + "last_run": get_setting("tracking_last_run", ""), + "last_stock": get_setting("tracking_last_stock", ""), + "last_alert": int(get_setting("tracking_last_alert", "0")), + } + + +def set_tracking_state(last_run=None, last_stock=None, last_alert=None): + if last_run is not None: + set_setting("tracking_last_run", last_run) + if last_stock is not None: + set_setting("tracking_last_stock", last_stock) + if last_alert is not None: + set_setting("tracking_last_alert", last_alert) diff --git a/static/js/settings.js b/static/js/settings.js index 1c35d11..38f37ce 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -25,6 +25,13 @@ async function loadSettings() { const cats = mono.categories || []; $$('#monitorCats input').forEach(c => c.checked = cats.includes(c.value)); renderState(curSettings.state); + // 持仓跟踪 + const tr = curSettings.tracking || {}; + $('#trackingEnabled').checked = !!tr.enabled; + $('#trackingInterval').value = tr.interval_min; + $('#trackingImpact').value = tr.impact_threshold; + $('#trackingNotify').checked = !!tr.notify; + renderTrackingState(tr); loadLog(); } catch (e) { toast('设置加载失败'); @@ -38,6 +45,13 @@ function renderState(state) {
上次通知
${state.last_sent_count || 0} 条
`; } +function renderTrackingState(tr) { + $('#trackingState').innerHTML = ` +
上次运行
${tr.last_run || '—'}
+
最近跟踪
${tr.last_stock || '—'}
+
最近告警影响度
${tr.last_alert ? tr.last_alert + '/100' : '—'}
`; +} + function collectMail() { return { smtp_host: $('#smtpHost').value.trim(), smtp_port: $('#smtpPort').value, @@ -69,6 +83,12 @@ async function save(which) { llm_model: $('#llmModel').value.trim() }; if (which === 'monitor') body.monitor = collectMonitor(); + if (which === 'tracking') body.tracking = { + tracking_enabled: $('#trackingEnabled').checked, + tracking_interval: $('#trackingInterval').value, + tracking_impact_threshold: $('#trackingImpact').value, + tracking_notify: $('#trackingNotify').checked + }; const r = await api('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); toast(r.msg || '已保存'); } catch (e) { toast('保存失败:' + e.message); } diff --git a/static/js/tracking.js b/static/js/tracking.js new file mode 100644 index 0000000..4e9df11 --- /dev/null +++ b/static/js/tracking.js @@ -0,0 +1,143 @@ +/* 持仓跟踪智能体 */ +async function load() { + try { + const d = await api('/api/tracking'); + const items = d.items || []; + renderRecords(items); + loadPositions(items); + } catch (e) { + $('#recTb').innerHTML = '加载失败'; + } + loadState(); +} + +function loadState() { + api('/api/settings').then(d => { + const t = d.tracking || {}; + $('#trackState').innerHTML = ` +
自动跟踪
${t.enabled ? '✅ 开启' : '⏸ 关闭'}(每 ${t.interval_min} 分钟)
+
上次运行
${t.last_run || '—'}
+
最近跟踪标的
${t.last_stock || '—'}
+
最近告警影响度
${t.last_alert ? t.last_alert + '/100' : '—'}
+
重大判定阈值
≥ ${t.impact_threshold}
`; + }).catch(() => {}); +} + +async function loadPositions(all) { + try { + const w = await api('/api/watchlist'); + const pos = w.items || []; + if (!pos.length) { + $('#posBox').innerHTML = '
自选股为空。请先在 股票池 或个股详情页 ⭐ 添加持仓/关注标的
'; + return; + } + // 每只自选股取最近一条跟踪 + const rows = []; + for (const p of pos) { + const latest = all.find(r => r.code === p.code); + rows.push({ stock: p, latest }); + } + $('#posBox').innerHTML = ` + + ${rows.map(x => { + const p = x.stock, r = x.latest; + const meta = r ? (JSON.parse(r.meta || '{}')) : null; + const sigCls = meta ? (meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注') : 'tag-观望'; + return ` + + + + + + + + `; + }).join('')} +
股票现价最近跟踪影响度性质摘要操作
${p.name}
${p.code} · ${p.industry}
${p.close}${r ? r.created_at : '—'}${r && meta ? `${meta.impact_score}` : '未跟踪'}${meta ? `${meta.change_kind}` : '—'}${meta ? escapeHtml(meta.summary || '') : '—'}
+ + ${r ? `` : ''} +
`; + } catch (e) { + $('#posBox').innerHTML = '
加载失败
'; + } +} + +function renderRecords(items) { + if (!items.length) return; + $('#recTb').innerHTML = items.map(r => { + let meta = {}; + try { meta = JSON.parse(r.meta || '{}'); } catch (e) {} + const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注'; + return ` + ${r.created_at} + ${r.stock_name}
${r.code}
+ ${escapeHtml(r.industry)} + ${meta.impact_score || '—'} + ${meta.change_kind || '—'} + ${escapeHtml(meta.summary || '')} + + `; + }).join(''); +} + +async function runAll() { + if (!confirm('将对全部自选股执行一次产业链深度跟踪(每只约30-60秒,逐只进行),确定?')) return; + try { + const r = await api('/api/tracking/run', { method: 'POST' }); + toast(r.msg || '已启动'); + pollRefresh(); + } catch (e) { toast('启动失败:' + e.message); } +} + +async function runOne(code) { + try { + await api('/api/tracking/run/' + code, { method: 'POST' }); + toast('跟踪已启动,完成后自动刷新'); + setTimeout(() => { load(); toast('跟踪完成'); }, 80000); + } catch (e) { toast('启动失败:' + e.message); } +} + +/* 详情弹窗:报告 + 数据源 */ +async function showDetail(id) { + try { + const d = await api('/api/tracking/' + id); + const meta = d.meta || {}, src = d.sources || {}; + const sigCls = meta.impact_score >= 65 ? 'tag-强烈推荐' : meta.impact_score >= 45 ? 'tag-推荐' : 'tag-关注'; + const newsBlock = (label, icon, seg) => { + if (!seg) return ''; + const txt = ((seg.db || '') + (seg.rag || '')).trim(); + return `
${icon} ${label} ${txt.includes('[') ? txt.split('[').length - 1 : 0} +
${escapeHtml(txt || '暂无')}
`; + }; + openModal(` +

🧭 产业链跟踪:${d.stock_name}(${d.code})

+
+ ${escapeHtml(d.industry)} + 影响度 ${meta.impact_score || '—'}/100 + ${meta.change_kind || '—'} + ${d.created_at} +
+
${mdRender(d.report)}
+
+ 数据源(智能体参考内容) + ${newsBlock('个股直接动态', '📄', src.direct)} + ${newsBlock('上游产业链(供给/成本)', '⬆️', src.upstream)} + ${newsBlock('下游产业链(需求/景气)', '⬇️', src.downstream)} + ${newsBlock('同业竞争', '🏢', src.peers)} +
📈 技术面 / 机构动向
${escapeHtml(src.indicators || '')}
+
+
+ + 查看个股 → +
+ `); + } catch (e) { toast('加载失败'); } +} + +let pollTimer = null; +function pollRefresh() { + clearTimeout(pollTimer); + pollTimer = setTimeout(() => load(), 30000); +} + +load(); diff --git a/templates/base.html b/templates/base.html index b948055..e83173f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -30,6 +30,7 @@ 📊 仪表盘 🏢 股票池 🎯 荐股中心 + 🧭 持仓跟踪 📈 量化策略 📰 财经新闻 🏦 机构动向 diff --git a/templates/settings.html b/templates/settings.html index ea814d3..eef293b 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -76,6 +76,26 @@
+ +
+
+
🧭 持仓跟踪智能体
+ +
+
+
+
+
+ +
+
+
+ + +
+
+
+
通知日志
diff --git a/templates/tracking.html b/templates/tracking.html new file mode 100644 index 0000000..13597b0 --- /dev/null +++ b/templates/tracking.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}持仓跟踪{% endblock %} +{% block page_title %}持仓跟踪智能体{% endblock %} +{% block content %} +
+
+
+ 🤖 大模型智能体 + 工作流:定期跟踪自选股(持仓),除个股公告新闻外, + 深度分析其产业链上下游与同业动态(上游供给/成本、下游需求/景气、竞争格局), + 输出专业跟踪报告;发现重大变化自动邮件通知。 +
跟踪对象 = 自选股(⭐ 股票池/详情页添加)· 自动跟踪间隔可在「系统设置」配置
+
+
+ + +
+
+
+
+ +
+
持仓(自选股)最近跟踪
+
加载中…
+
+ +
+
全部跟踪记录
+
+ + + +
时间股票行业影响度性质摘要操作
暂无跟踪记录,点击上方「立即跟踪全部持仓」开始
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %}