Files
MoFin/web/server.py
T
2026-06-20 12:11:33 +08:00

910 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 _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():
"""持仓列表"""
data = _load_json(DATA_DIR / "portfolio.json")
return jsonify(data)
@app.route("/api/watchlist")
def api_watchlist():
"""自选列表"""
data = _load_json(DATA_DIR / "watchlist.json")
return jsonify(data)
@app.route("/api/overview")
def api_overview():
"""概览数据"""
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/<report_id>")
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/<code>")
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():
"""市场观察"""
data = _load_json(DATA_DIR / "market.json", {})
return jsonify(data)
# ── 数据写入API(供 cron/update_data.py 调用) ──────────
@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/<code>", 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():
"""记录一条分析建议到 decisions.json 的 advice_timeline"""
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": []})
entry = None
for e in d["decisions"]:
if e["code"] == code and e["status"] == "active":
entry = e
break
if not entry:
return jsonify({"status": "error", "message": f"no active decision for {code}"}), 404
timeline = entry.setdefault("advice_timeline", [])
advice = {
"date": datetime.now().strftime("%Y-%m-%d %H:%M"),
"direction": data.get("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
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
_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)
# OCRchip_sim+engPSM 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)