#!/usr/bin/env python3 """MoFin Dashboard - 莫荷持仓情报可视化系统""" import base64 import json import os import re import uuid import urllib.request from datetime import datetime from pathlib import Path from flask import Flask, jsonify, send_from_directory, request # 提示词管理模块 from prompt_manager.dashboard_views import register_routes app = Flask(__name__, static_folder="static", static_url_path="") DATA_DIR = Path(__file__).parent / "data" UPLOAD_DIR = Path(__file__).parent / "uploads" # Hermes Gateway GATEWAY = "http://localhost:8642/v1/chat/completions" API_KEY = "hermes123" def _load_json(path, default=None): try: with open(path, encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} if default is None else default def _load_from_db(query_func, json_path, default=None): """优先从 SQLite 读取,失败回退 JSON""" try: from mofin_db import get_conn conn = get_conn() result = query_func(conn) conn.close() if result: return result except Exception: pass return _load_json(json_path, default) def _save_json(path, data): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) # ── API 路由 ────────────────────────────────────────── @app.route("/") def index(): return send_from_directory(app.static_folder, "index.html") @app.route("/api/portfolio") def api_portfolio(): """持仓列表""" try: from mofin_db import get_conn, query_holdings, query_portfolio_summary conn = get_conn() holdings = query_holdings(conn) summary = query_portfolio_summary(conn) conn.close() if holdings: data = dict(summary) data["holdings"] = holdings return jsonify(data) except Exception: pass return jsonify(_load_json(DATA_DIR / "portfolio.json")) @app.route("/api/watchlist") def api_watchlist(): """自选列表""" try: from mofin_db import get_conn, query_watchlist conn = get_conn() stocks = query_watchlist(conn) conn.close() if stocks: return jsonify({"stocks": stocks}) except Exception: pass return jsonify(_load_json(DATA_DIR / "watchlist.json")) @app.route("/api/overview") def api_overview(): """概览数据""" try: from mofin_db import get_conn, query_holdings, query_portfolio_summary, query_latest_market conn = get_conn() holdings = query_holdings(conn) summary = query_portfolio_summary(conn) market = query_latest_market(conn) conn.close() if holdings: total_assets = summary.get("total_assets", 0) or 0 stock_value = summary.get("stock_value", 0) or 0 cash = summary.get("cash", 0) or 0 position_pct = summary.get("position_pct", 0) or 0 total_pnl = summary.get("total_pnl", 0) or 0 top_movers = sorted( [h for h in holdings if abs(h.get("change_pct", 0) or 0) >= 3], key=lambda x: abs(x.get("change_pct", 0) or 0), reverse=True)[:5] return jsonify({ "total_assets": total_assets, "stock_value": stock_value, "cash": cash, "position_pct": position_pct, "total_pnl": total_pnl, "top_movers": top_movers, "market": market, "alerts": _load_json(DATA_DIR / "alerts.json", [])[:10], "updated_at": summary.get("updated_at", ""), }) except Exception: pass portfolio = _load_json(DATA_DIR / "portfolio.json", []) market = _load_json(DATA_DIR / "market.json", {}) alerts = _load_json(DATA_DIR / "alerts.json", []) total_assets = portfolio.get("total_assets", 0) stock_value = portfolio.get("stock_value", 0) cash = portfolio.get("cash", 0) position_pct = portfolio.get("position_pct", 0) total_pnl = portfolio.get("total_pnl", 0) holdings = portfolio.get("holdings", []) top_movers = sorted( [h for h in holdings if abs(h.get("change_pct", 0)) >= 3], key=lambda x: abs(x.get("change_pct", 0)), reverse=True)[:5] return jsonify({ "total_assets": total_assets, "stock_value": stock_value, "cash": cash, "position_pct": position_pct, "total_pnl": total_pnl, "top_movers": top_movers, "market": market, "alerts": alerts[:10], "updated_at": portfolio.get("updated_at", ""), }) @app.route("/api/reports") def api_reports(): """历史报告列表""" reports_dir = DATA_DIR / "reports" reports = [] if reports_dir.exists(): for f in sorted(reports_dir.iterdir(), reverse=True)[:100]: if f.suffix == ".json": data = _load_json(f) reports.append({ "id": f.stem, "title": data.get("title", f.stem), "type": data.get("type", "未知"), "created_at": data.get("created_at", ""), "summary": data.get("summary", ""), }) return jsonify(reports) @app.route("/api/report/") def api_report(report_id): """单个报告详情""" # Try exact file first path = DATA_DIR / "reports" / f"{report_id}.json" if path.exists(): return jsonify(_load_json(path)) # Try prefix match reports_dir = DATA_DIR / "reports" if reports_dir.exists(): for f in reports_dir.iterdir(): if f.stem.startswith(report_id) and f.suffix == ".json": return jsonify(_load_json(f)) return jsonify({"error": "report not found"}), 404 @app.route("/api/stock/") def api_stock(code): """个股详情 + 操作建议历史""" stock_data = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) return jsonify(stock_data) @app.route("/api/market") def api_market(): """市场观察""" try: from mofin_db import get_conn, query_latest_market conn = get_conn() data = query_latest_market(conn) conn.close() if data and data.get("sectors"): return jsonify(data) except Exception: pass return jsonify(_load_json(DATA_DIR / "market.json", {})) # ── 信号API(新增) ───────────────────────────────────── @app.route("/api/signals") def api_signals(): """最近信号 + 小果分析""" try: from mofin_db import get_conn conn = get_conn() signals = conn.execute(""" SELECT sn.id, sn.sector, sn.overall_sentiment, sn.summary, sn.source, sn.created_at, ss.signal_type, ss.severity FROM signal_news sn LEFT JOIN sector_signals ss ON sn.signal_id = ss.id ORDER BY sn.id DESC LIMIT 20 """).fetchall() conn.close() return jsonify([dict(r) for r in signals]) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/xiaoguo-scan") def api_xiaoguo_scan(): """小果扫描统计""" try: from mofin_db import get_conn conn = get_conn() total = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker").fetchone()[0] found = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker WHERE found_count>0").fetchone()[0] recent = conn.execute(""" SELECT code, name, last_scanned_at, found_count FROM xiaoguo_scan_tracker ORDER BY last_scanned_at DESC LIMIT 20 """).fetchall() source_count = conn.execute(""" SELECT source, COUNT(*) as cnt FROM signal_news WHERE datetime(created_at) > datetime('now', '-1 day') GROUP BY source """).fetchall() conn.close() return jsonify({ "total_scanned": total, "found_signals": found, "recent": [dict(r) for r in recent], "source_today": {r["source"]: r["cnt"] for r in source_count} }) except Exception as e: return jsonify({"error": str(e)}), 500 # ── 数据写入API ── @app.route("/api/update/portfolio", methods=["POST"]) def update_portfolio(): data = request.get_json(force=True) _save_json(DATA_DIR / "portfolio.json", data) return jsonify({"status": "ok"}) @app.route("/api/update/watchlist", methods=["POST"]) def update_watchlist(): data = request.get_json(force=True) _save_json(DATA_DIR / "watchlist.json", data) return jsonify({"status": "ok"}) @app.route("/api/update/report", methods=["POST"]) def update_report(): data = request.get_json(force=True) report_id = data.pop("_id", datetime.now().strftime("%Y%m%d_%H%M%S")) data["created_at"] = data.get("created_at", datetime.now().isoformat()) _save_json(DATA_DIR / "reports" / f"{report_id}.json", data) return jsonify({"status": "ok", "id": report_id}) @app.route("/api/update/stock/", methods=["POST"]) def update_stock(code): data = request.get_json(force=True) existing = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) history = existing.get("history", []) if data.get("entry"): history.append({ "time": datetime.now().isoformat(), "price": data.get("price"), "recommendation": data.get("recommendation"), "stop_loss": data.get("stop_loss"), "take_profit": data.get("take_profit"), "reason": data.get("reason"), }) existing.update(data) existing["history"] = history[-50:] _save_json(DATA_DIR / "stocks" / f"{code}.json", existing) return jsonify({"status": "ok"}) @app.route("/api/update/market", methods=["POST"]) def update_market(): data = request.get_json(force=True) or {} _save_json(DATA_DIR / "market.json", data) return jsonify({"status": "ok"}) # ── 知微分析结果写入API ── @app.route("/api/analysis/batch", methods=["POST"]) def analysis_batch(): """接收知微cron的分析结果,写回持仓/自选JSON的analysis字段""" data = request.get_json(force=True) or {} # 更新持仓 if "holdings" in data: pf = _load_json(DATA_DIR / "portfolio.json", {}) idx = {h["code"]: i for i, h in enumerate(pf.get("holdings", []))} for item in data["holdings"]: code = item.get("code", "") if code not in idx: continue h = pf["holdings"][idx[code]] h["analysis"] = { "suggestion": item.get("suggestion"), "stop_loss": item.get("stop_loss"), "take_profit": item.get("take_profit"), "buy_zone_low": item.get("buy_zone_low"), "buy_zone_high": item.get("buy_zone_high"), "position_suggested": item.get("position_suggested"), "reason": item.get("reason"), "updated_at": datetime.now().isoformat(), } _save_json(DATA_DIR / "portfolio.json", pf) # 更新自选 if "watchlist" in data: wl = _load_json(DATA_DIR / "watchlist.json", {}) idx = {s["code"]: i for i, s in enumerate(wl.get("stocks", []))} for item in data["watchlist"]: code = item.get("code", "") if code not in idx: continue s = wl["stocks"][idx[code]] s["analysis"] = { "buy_low": item.get("buy_low"), "buy_high": item.get("buy_high"), "position_recommend": item.get("position_recommend"), "reason": item.get("reason"), "updated_at": datetime.now().isoformat(), } _save_json(DATA_DIR / "watchlist.json", wl) return jsonify({"status": "ok", "updated_at": datetime.now().isoformat()}) # ── 操作决策库API ── @app.route("/api/decisions", methods=["GET"]) def get_decisions(): """返回决策库数据,统一新旧格式""" raw = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) decisions = raw.get("decisions", []) if not decisions and isinstance(raw, list): decisions = raw # portfolio 用来判断是持仓还是自选 portfolio = _load_json(DATA_DIR / "portfolio.json", {"holdings": []}) watchlist = _load_json(DATA_DIR / "watchlist.json", {"stocks": []}) holding_codes = {h.get("code","") for h in portfolio.get("holdings",[])} watch_codes = {s.get("code","") for s in watchlist.get("stocks",[])} normalized = [] for d in decisions: if not isinstance(d, dict): continue # 检测新旧格式:新格式有 stop_loss 顶层字段,旧格式有 trigger 对象 is_new = "stop_loss" in d and "trigger" not in d if is_new: code = d.get("code", "") name = d.get("name", "") price = d.get("price", 0) sl = d.get("stop_loss") tp = d.get("take_profit") el = d.get("entry_low") eh = d.get("entry_high") ts = d.get("tech_snapshot", "") # type: 持仓还是自选 if code in holding_codes: dtype = "持仓策略" elif code in watch_codes: dtype = "自选策略" else: dtype = "—" # 判断 active status_raw = d.get("status", "") status = "active" if status_raw in ("active", "updated", "") else "superseded" # trigger 对象 entry_zone_str = "" if el and eh: entry_zone_str = f"¥{el}~¥{eh}" elif el: entry_zone_str = f"≥¥{el}" trigger = {} if sl: trigger["stop_loss"] = f"¥{sl}" if isinstance(sl, (int,float)) else str(sl) if tp: trigger["take_profit"] = f"¥{tp}" if isinstance(tp, (int,float)) else str(tp) if entry_zone_str: trigger["entry_zone"] = entry_zone_str # current current = "" if price: current = f"现价¥{price}" if code and not code.startswith(("0","1")) else f"¥{price}" # zone_breach zone_breach = d.get("zone_breach", "") # updated_reason note = d.get("note", "") timing = d.get("timing_signal", "") reason_parts = [] if note: reason_parts.append(note) if timing and timing != "neutral": reason_parts.append(f"时机:{timing}") if d.get("rr_ratio"): reason_parts.append(f"盈亏比:{d['rr_ratio']}") # advice_timeline - 从新格式重建 timeline = [] entry = { "code": code, "name": name, "type": dtype, "status": status, "tag": d.get("tag", ""), "action": d.get("action", ""), "trigger": trigger, "current": current, "zone_breach": zone_breach, "updated_reason": " | ".join(reason_parts) if reason_parts else "", "advice_timeline": timeline, "changelog": d.get("changelog", []), "execution": d.get("execution", {}), "analysis": d.get("analysis", {}), "tech_snapshot": ts, "timestamp": d.get("timestamp", ""), "updated_by": "知微", } # 保留原始数据供前端扩展 entry["_raw_action"] = d.get("action", "") normalized.append(entry) else: # 旧格式:已有 trigger 等字段,直接保留 entry = dict(d) # 确保 status 正确 if entry.get("status") not in ("active", "superseded"): entry["status"] = "active" if not entry.get("type"): code = entry.get("code", "") if code in holding_codes: entry["type"] = "持仓策略" elif code in watch_codes: entry["type"] = "自选策略" else: entry["type"] = "—" normalized.append(entry) # 添加 execution 和 analysis 信息,按执行状态排序 for n in normalized: code = n.get("code", "") # 从原始数据中找到 execution 和 analysis raw_entry = next((d for d in decisions if isinstance(d, dict) and d.get("code") == code), {}) n["execution"] = raw_entry.get("execution", {"status": "none"}) n["analysis"] = raw_entry.get("analysis", {}) # 排序规则:推荐>执行中>观察>无标签 def sort_key(x): tag = x.get("tag", "") exec_status = x.get("execution", {}).get("status", "none") # 标签优先级(current_recommend才靠前,active_manual只是记录不升序) tag_order = {"current_recommend": 0} tag_priority = tag_order.get(tag, 50) # 执行状态优先级 exec_order = {"partial_exit": 0, "executing": 1, "observing": 2, "none": 99} exec_priority = exec_order.get(exec_status, 99) # 组合:先按标签排,再按执行状态排 return (tag_priority, exec_priority, x.get("code", "")) normalized.sort(key=sort_key) return jsonify({ "decisions": normalized, "total": len(normalized), "regenerated_at": raw.get("regenerated_at", ""), }) @app.route("/api/decisions/add", methods=["POST"]) def add_decision(): """新增/更新一条决策(新格式)""" data = request.get_json(force=True) or {} code = data.get("code", "") if not code: return jsonify({"status": "error", "message": "code required"}), 400 d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) # 同一股票旧决策标记为superseded for e in d["decisions"]: if e["code"] == code and e.get("status") in ("active", "updated"): e["status"] = "superseded" entry = { "code": code, "name": data.get("name", ""), "price": data.get("price", 0), "action": data.get("action", ""), "stop_loss": data.get("stop_loss"), "take_profit": data.get("take_profit"), "entry_low": data.get("entry_low"), "entry_high": data.get("entry_high"), "tech_snapshot": data.get("tech_snapshot", ""), "timing_signal": data.get("timing_signal", ""), "rr_ratio": data.get("rr_ratio"), "tag": data.get("tag", ""), "note": data.get("note", ""), "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), "updated_reason": data.get("updated_reason", ""), "status": "updated", "changelog": data.get("changelog", []), "execution": data.get("execution", {"status": "none"}), "analysis": data.get("analysis", {}), } d["decisions"].append(entry) _save_json(DATA_DIR / "decisions.json", d) return jsonify({"status": "ok", "entry": entry}) @app.route("/api/decisions/tag", methods=["POST"]) def set_decision_tag(): """设置/清除某只股票的推荐标签""" data = request.get_json(force=True) or {} code = data.get("code", "") tag = data.get("tag", "") # 'current_recommend', 'active_manual', or '' to clear if not code: return jsonify({"status": "error", "message": "code required"}), 400 d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) found = False for e in d.get("decisions", []): if e.get("code") == code: e["tag"] = tag e["tag_updated"] = datetime.now().isoformat() found = True break if not found: return jsonify({"status": "error", "message": f"stock {code} not found"}), 404 _save_json(DATA_DIR / "decisions.json", d) return jsonify({"status": "ok", "code": code, "tag": tag}) @app.route("/api/decisions/pending") def get_pending_decisions(): """返回所有有未确认建议的条目""" d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) pending = [] for entry in d["decisions"]: timeline = entry.get("advice_timeline", []) unconfirmed = [a for a in timeline if a.get("status") in (None, "pending")] if unconfirmed: pending.append({ "code": entry["code"], "name": entry["name"], "current": entry.get("current", ""), "pending_advice": unconfirmed, }) return jsonify(pending) @app.route("/api/advice/record", methods=["POST"]) def record_advice(): """记录一条分析建议,自动去重(相同code+同天+同方向=跳过)""" data = request.get_json(force=True) or {} code = data.get("code", "") if not code: return jsonify({"status": "error", "message": "code required"}), 400 direction = data.get("direction", "持有") today = datetime.now().strftime("%Y-%m-%d") d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) entry = None for e in d["decisions"]: if e["code"] == code and e["status"] in ("active", "updated"): entry = e break if not entry: return jsonify({"status": "error", "message": f"no active decision for {code}"}), 404 timeline = entry.setdefault("advice_timeline", []) # 去重:同一天+同方向+摘要前40字相似 → 跳过 summary_short = (data.get("summary", "") or "")[:40] for a in timeline: a_date = a.get("date", "")[:10] a_dir = a.get("direction", "") a_summary = (a.get("summary", "") or "")[:40] if a_date == today and a_dir == direction and a_summary == summary_short: return jsonify({"status": "skipped", "reason": "duplicate", "advice": a}) advice = { "date": datetime.now().strftime("%Y-%m-%d %H:%M"), "direction": direction, "price": data.get("price", ""), "summary": data.get("summary", ""), "status": "pending", } timeline.append(advice) _save_json(DATA_DIR / "decisions.json", d) return jsonify({"status": "ok", "advice": advice}) @app.route("/api/advice/confirm", methods=["POST"]) def confirm_advice(): """确认/忽略/标记已执行""" data = request.get_json(force=True) or {} code = data.get("code", "") idx = data.get("index", -1) action = data.get("action", "confirmed") # confirmed | ignored | executed result = data.get("result", "") d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) for e in d["decisions"]: if e["code"] == code and e["status"] == "active": timeline = e.get("advice_timeline", []) if 0 <= idx < len(timeline): timeline[idx]["status"] = action if action == "executed": timeline[idx]["evaluated"] = True timeline[idx]["evaluated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") if result: timeline[idx]["result"] = result _save_json(DATA_DIR / "decisions.json", d) return jsonify({"status": "ok"}) return jsonify({"status": "error", "message": "not found"}), 404 # ── 准确率统计API ── @app.route("/api/stats/accuracy") def get_accuracy_stats(): data = _load_json(DATA_DIR / "accuracy_stats.json", {}) return jsonify(data) # ── 策略评估API ── @app.route("/api/evaluation") def get_evaluation(): """返回所有策略的双维度评估结果""" # 主数据源:evaluation.json eval_data = _load_json(DATA_DIR / "evaluation.json", {}) strategies = eval_data.get("strategies", []) if strategies: return jsonify(strategies) # 备选:从 decisions.json 的 evaluation 字段读取(尚未反写时的兼容) decisions = _load_json(DECISIONS_PATH if 'DECISIONS_PATH' in dir() else DATA_DIR / "decisions.json", {"decisions": []}) evals = [] for d in decisions.get("decisions", []): e = d.get("evaluation", []) if e: evals.append({ "code": d["code"], "name": d["name"], "type": d.get("type", ""), "current": d.get("current", ""), "evaluations": e, }) return jsonify(evals) @app.route("/api/evaluation/trigger", methods=["POST"]) def trigger_evaluation(): """手动触发策略评估""" import subprocess try: r = subprocess.run( ["python3", str(DATA_DIR.parent / "strategy_evaluator.py")], capture_output=True, timeout=60, text=True, ) return jsonify({"status": "ok", "output": r.stdout, "error": r.stderr}) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 # ── 策略反馈API ── @app.route("/api/feedback") def get_feedback(): data = _load_json(DATA_DIR / "strategy_feedback.json", {}) return jsonify(data) # ── 持仓截图上传与解析 ──────────────────────────────── @app.route("/upload") def upload_page(): return send_from_directory(app.static_folder, "upload.html") def _ocr_image(image_path): """用Tesseract OCR提取图片中的文字(预处理优化中文表格识别)""" from PIL import Image, ImageEnhance, ImageFilter import pytesseract img = Image.open(image_path) # 预处理:放大 + 锐化 + 二值化,提升小字识别率 w, h = img.size if w < 2000 or h < 2000: scale = max(2, 2000 // min(w, h)) img = img.resize((w * scale, h * scale), Image.LANCZOS) # 转灰度 img = img.convert("L") # 增强对比度 enhancer = ImageEnhance.Contrast(img) img = enhancer.enhance(2.0) # 锐化 img = img.filter(ImageFilter.SHARPEN) # 二值化(自适应阈值) threshold = 128 img = img.point(lambda x: 255 if x > threshold else 0) # OCR:chip_sim+eng,PSM 6(统一文本块) text = pytesseract.image_to_string( img, lang="chi_sim+eng", config="--psm 6 --oem 3", ) return text.strip() ANALYZE_PROMPT = """你是股票持仓数据分析助手。以下是用户上传的持仓/自选截图经过OCR提取的文字,请从中提取所有股票信息。 判断这是「持仓截图」还是「自选截图」: - 持仓截图:每支股票有"证券数量"(持股数)、成本价、盈亏 - 自选截图:只有股票列表和价格,没有持股数/成本 股票代码格式: - A股:6位数字(如 600519, 000858, 300750) - 港股:纯数字代码(如 0700, 3690, 1211),不带HK前缀 ⚠️ 重要:截图顶部通常有汇总数据,如总资产、股票市值、可用资金、当日盈亏等。 如果OCR文字中有这些汇总数字,请一并提取到JSON的summary字段中。 不要自己计算汇总值,直接从OCR原文中提取。 请严格按照以下JSON格式回复,只输出JSON: ```json { "type": "portfolio" 或 "watchlist", "summary": { "total_assets": "总资产数字(可选,从截图中提取)", "stock_value": "股票市值/持仓市值数字(可选,从截图中提取)", "cash": "可用资金/现金数字(可选,从截图中提取)", "day_pnl": "当日盈亏金额(可选,从截图中提取)" }, "stocks": [ { "code": "股票代码", "name": "股票名称(中文)", "price": "现价(数字)", "shares": "持股数量(数字,持仓截图才有)", "cost": "成本价(数字,持仓截图才有)", "pnl": "盈亏百分比如+15.1%(持仓截图才有)", "position_pct": "仓位占比数字如12.5(可选)" } ] } ``` OCR原文: """ @app.route("/api/upload/analyze", methods=["POST"]) def upload_analyze(): """接收图片,OCR提取文字 → LLM解析结构化数据""" if "image" not in request.files: return jsonify({"error": "请上传图片"}), 400 f = request.files["image"] if not f.filename: return jsonify({"error": "空文件"}), 400 # 保存到临时目录 UPLOAD_DIR.mkdir(parents=True, exist_ok=True) ext = Path(f.filename).suffix or ".png" save_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{ext}" f.save(str(save_path)) try: # 第一步:OCR提取文字 raw_text = _ocr_image(str(save_path)) if not raw_text: return jsonify({"error": "OCR未识别到文字,请确认图片清晰"}), 400 except Exception as e: os.unlink(str(save_path)) return jsonify({"error": f"OCR失败: {e}"}), 500 # 第二步:LLM解析结构化数据(走文本API,不走视觉) llm_text = _llm_parse(raw_text, ANALYZE_PROMPT) os.unlink(str(save_path)) # 从LLM回复中提取JSON json_match = re.search(r"```(?:json)?\s*({.*?})\s*```", llm_text, re.DOTALL) if json_match: try: parsed = json.loads(json_match.group(1)) except json.JSONDecodeError: return jsonify({"error": f"LLM解析JSON失败: {llm_text[:500]}"}), 500 else: # 尝试直接找JSON(没被代码块包裹) try: parsed = json.loads(llm_text) except json.JSONDecodeError: return jsonify({"error": f"未提取到结构化数据: {raw_text[:300]}...\n\nLLM回复: {llm_text[:500]}"}), 500 return jsonify(parsed) def _llm_parse(text, prompt_template): """发送OCR文本到Hermes LLM解析,返回JSON字符串""" payload = json.dumps({ "model": "hermes-agent", "messages": [ {"role": "system", "content": "你是一个数据提取助手。从OCR文字中提取结构化JSON数据。"}, {"role": "user", "content": prompt_template + "\n" + text}, ], "max_tokens": 4096, }).encode() req = urllib.request.Request(GATEWAY, data=payload, method="POST") req.add_header("Content-Type", "application/json") req.add_header("Authorization", f"Bearer {API_KEY}") req.add_header("X-Hermes-Session-Id", "upload-ocr-parse") try: resp = urllib.request.urlopen(req, timeout=120) data = json.loads(resp.read()) return data.get("choices", [{}])[0].get("message", {}).get("content", "") except Exception as e: return f"ERROR: {e}" @app.route("/api/upload/confirm", methods=["POST"]) def upload_confirm(): """确认解析结果,更新数据文件""" data = request.get_json(force=True) stocks = data.get("stocks", []) doc_type = data.get("type", "portfolio") # 尝试获取实时行情补充数据 try: codes = [s["code"] for s in stocks if s.get("code")] if codes: qs = " ".join( f"hk{c}" if len(c) == 5 # 港股5位代码 else f"sz{c}" if c.startswith("0") or c.startswith("3") else f"sh{c}" if c.startswith("6") else f"hk{c}" for c in codes ) url = f"https://qt.gtimg.cn/q={qs}" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) resp = urllib.request.urlopen(req, timeout=10) qt_text = resp.read().decode("gbk", errors="replace") # map realtime prices for stock in stocks: code = stock.get("code", "") prefix = "hk" if len(code) == 5 else "sz" if code.startswith(("0","3")) else "sh" if code.startswith("6") else "hk" # 腾讯 API 格式: prefix+code="市场~名称~代码~当前价~昨收~今开~成交量~..." m = re.search(rf'{prefix}{code}="([^"]+)"', qt_text) if m: fields = m.group(1).split('~') name = fields[1] price = fields[3] # 当前价 if not stock.get("price"): stock["price"] = price if not stock.get("name"): stock["name"] = name except: pass # 行情获取失败不影响主流程 # 更新对应数据文件 if doc_type == "portfolio": existing = _load_json(DATA_DIR / "portfolio.json", {}) old_holdings = {h["code"]: h for h in existing.get("holdings", []) if h.get("code")} new_holdings = [] for s in stocks: code = s.get("code", "") old = old_holdings.get(code, {}) new_shares = int(s["shares"]) if str(s.get("shares", "")).lstrip('-').isdigit() else old.get("shares", 0) old_shares = old.get("shares", 0) # 股数突变检测:旧200→新0是合理卖出,但旧0→新200可能是OCR错读 if old_shares > 0 and new_shares == 0 and old_shares != new_shares: print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (卖出清仓)") elif abs(new_shares - old_shares) > max(old_shares * 0.5, 100) and old_shares > 0: print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (变动较大)") new_holdings.append({ "code": code, "name": s.get("name") or old.get("name", ""), "shares": new_shares, "price": float(s.get("price", 0)) or old.get("price", 0), "cost": float(s.get("cost", 0)) if s.get("cost") else old.get("cost", 0), "pnl": s.get("pnl") or old.get("pnl", ""), "position_pct": float(s.get("position_pct", 0)) if s.get("position_pct") else old.get("position_pct", 0), "change_pct": old.get("change_pct", 0), }) existing["holdings"] = new_holdings # 使用截图中的汇总数据(优先),没有则用旧数据 summary = data.get("summary", {}) if summary.get("stock_value"): existing["stock_value"] = float(summary["stock_value"]) else: existing["stock_value"] = round( sum(h["shares"] * h["price"] for h in existing["holdings"]), 2 ) if summary.get("cash"): existing["cash"] = float(summary["cash"]) if summary.get("total_assets"): existing["total_assets"] = float(summary["total_assets"]) else: existing["total_assets"] = existing["stock_value"] + existing.get("cash", 0) if summary.get("day_pnl"): existing["day_pnl"] = float(summary["day_pnl"]) existing["updated_at"] = datetime.now().isoformat() # 计算仓位% if existing["total_assets"] > 0: existing["position_pct"] = round(existing["stock_value"] / existing["total_assets"] * 100, 2) _save_json(DATA_DIR / "portfolio.json", existing) msg = f"更新了 {len(stocks)} 只持仓股" elif doc_type == "watchlist": existing = _load_json(DATA_DIR / "watchlist.json", {}) existing["stocks"] = [ { "code": s.get("code", ""), "name": s.get("name", ""), "price": float(s.get("price", 0)) if s.get("price") else 0, } for s in stocks ] existing["updated_at"] = datetime.now().isoformat() _save_json(DATA_DIR / "watchlist.json", existing) msg = f"更新了 {len(stocks)} 只自选股" else: return jsonify({"error": f"未知类型: {doc_type}"}), 400 return jsonify({"status": "ok", "message": msg}) # ── TDX中继实时行情接收API ── @app.route("/api/update/realtime", methods=["POST"]) def update_realtime(): """接收小小莫中继的实时行情数据""" data = request.get_json(force=True) or {} stocks = data.get("stocks", []) source = data.get("source", "unknown") if not stocks: return jsonify({"status": "error", "message": "没有股票数据"}), 400 # 更新 portfolio.json 中的实时价格(change_pct字段) pf = _load_json(DATA_DIR / "portfolio.json", {"holdings": []}) pf_holdings = {h["code"]: h for h in pf.get("holdings", [])} updated = 0 for s in stocks: code = s.get("code", "") if code in pf_holdings: pf_holdings[code]["price"] = float(s.get("price", pf_holdings[code].get("price", 0))) pf_holdings[code]["change_pct"] = float(s.get("change_pct", 0)) pf_holdings[code]["high"] = float(s.get("high", 0)) pf_holdings[code]["low"] = float(s.get("low", 0)) pf_holdings[code]["open"] = float(s.get("open", 0)) pf_holdings[code]["volume"] = int(s.get("volume", 0)) pf_holdings[code]["data_source"] = source pf_holdings[code]["updated_at"] = datetime.now().isoformat() updated += 1 # 也更新 watchlist.json wl = _load_json(DATA_DIR / "watchlist.json", {"stocks": []}) wl_stocks = {s["code"]: s for s in wl.get("stocks", [])} for s in stocks: code = s.get("code", "") if code in wl_stocks: wl_stocks[code]["price"] = float(s.get("price", wl_stocks[code].get("price", 0))) wl_stocks[code]["change_pct"] = float(s.get("change_pct", 0)) pf["updated_at"] = datetime.now().isoformat() wl["updated_at"] = datetime.now().isoformat() _save_json(DATA_DIR / "portfolio.json", pf) _save_json(DATA_DIR / "watchlist.json", wl) return jsonify({ "status": "ok", "updated": updated, "source": source, "timestamp": datetime.now().isoformat(), }) # 注册提示词管理路由 register_routes(app) if __name__ == "__main__": port = int(os.environ.get("PORT", 8899)) print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}") app.run(host="0.0.0.0", port=port, debug=False)