目录重组:web/ scripts/ config/ tests/ 标准化
This commit is contained in:
+910
@@ -0,0 +1,910 @@
|
||||
#!/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)
|
||||
|
||||
# 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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MoFin - 持仓截图解析</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: -apple-system, 'PingFang SC', sans-serif; background: #0f0f1a; color: #e0e0e0; min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px; }
|
||||
.container { max-width: 800px; width: 100%; }
|
||||
h1 { font-size: 22px; margin-bottom: 8px; color: #00d4ff; }
|
||||
.sub { color: #888; font-size: 13px; margin-bottom: 30px; }
|
||||
.upload-box { border: 2px dashed #333; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all .3s; background: #1a1a2e; }
|
||||
.upload-box:hover { border-color: #00d4ff; background: #1e1e35; }
|
||||
.upload-box.dragover { border-color: #00d4ff; background: #1a1a3e; }
|
||||
.upload-box .icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.upload-box .hint { color: #888; font-size: 13px; margin-top: 8px; }
|
||||
input[type=file] { display: none; }
|
||||
#preview { display: none; max-width: 100%; max-height: 400px; margin: 15px auto; border-radius: 8px; }
|
||||
#loading { display: none; text-align: center; padding: 20px; }
|
||||
#loading .spinner { width: 40px; height: 40px; border: 3px solid #333; border-top-color: #00d4ff; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 12px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#result { display: none; margin-top: 24px; }
|
||||
.result-box { background: #1a1a2e; border-radius: 12px; padding: 20px; }
|
||||
.result-box h3 { color: #00d4ff; font-size: 16px; margin-bottom: 15px; }
|
||||
.result-box .type-badge { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; margin-bottom: 15px; }
|
||||
.badge-portfolio { background: #1a3a3a; color: #00ff88; }
|
||||
.badge-watchlist { background: #3a2a1a; color: #ffaa00; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #222; }
|
||||
th { color: #888; font-weight: 500; white-space: nowrap; }
|
||||
tr:hover td { background: #222; }
|
||||
.actions { margin-top: 20px; display: flex; gap: 10px; }
|
||||
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: all .2s; }
|
||||
.btn-primary { background: #00d4ff; color: #000; }
|
||||
.btn-primary:hover { background: #00b8e0; }
|
||||
.btn-secondary { background: #333; color: #e0e0e0; }
|
||||
.btn-secondary:hover { background: #444; }
|
||||
.btn-danger { background: #d63031; color: #fff; }
|
||||
.btn-danger:hover { background: #b22020; }
|
||||
#error { display: none; background: #2a1a1a; border: 1px solid #d63031; border-radius: 8px; padding: 15px; margin-top: 15px; color: #ff6b6b; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><a href="/" style="text-decoration:none;color:#00d4ff">📸 持仓截图解析</a></h1>
|
||||
<p class="sub"><a href="/" style="color:#666;text-decoration:none">← 返回仪表盘</a> · 上传持仓截图或自选截图,知微自动识别并更新数据</p>
|
||||
|
||||
<div class="upload-box" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
||||
<div class="icon">📤</div>
|
||||
<div>点击上传、拖拽图片、或 Ctrl+V 粘贴</div>
|
||||
<div class="hint">支持 PNG / JPG / JPEG,建议清晰截图</div>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/jpg">
|
||||
|
||||
<img id="preview">
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>🔍 知微正在分析图片...</div>
|
||||
</div>
|
||||
|
||||
<div id="error"></div>
|
||||
|
||||
<div id="result">
|
||||
<div class="result-box">
|
||||
<span class="type-badge" id="typeBadge">持仓</span>
|
||||
<h3 id="resultTitle"></h3>
|
||||
<div id="summaryBox"></div>
|
||||
<div id="resultTable"></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="confirmUpdate()">✅ 确认更新</button>
|
||||
<button class="btn btn-secondary" onclick="resetPage()">🔄 重新上传</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="success" style="display:none">
|
||||
<div class="result-box" style="text-align:center;padding:30px">
|
||||
<div style="font-size:48px;margin-bottom:12px">✅</div>
|
||||
<h3 style="color:#00ff88">数据已更新</h3>
|
||||
<p id="successMsg" style="color:#888;margin:8px 0"></p>
|
||||
<button class="btn btn-primary" onclick="resetPage()" style="margin-top:15px">📸 上传下一张</button>
|
||||
<a href="/" class="btn btn-secondary" style="display:inline-block;margin-top:10px;text-decoration:none">📊 返回仪表盘</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const preview = document.getElementById('preview');
|
||||
const loading = document.getElementById('loading');
|
||||
const result = document.getElementById('result');
|
||||
const success = document.getElementById('success');
|
||||
const error = document.getElementById('error');
|
||||
let lastResult = null;
|
||||
|
||||
// Drag & drop
|
||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||||
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); handleFile(e.dataTransfer.files[0]); });
|
||||
|
||||
// Paste support (截图后直接Ctrl+V)
|
||||
document.addEventListener('paste', e => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const blob = item.getAsFile();
|
||||
if (blob) handleFile(blob);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
|
||||
|
||||
function handleFile(file) {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) { showError('请上传图片文件'); return; }
|
||||
if (file.size > 10 * 1024 * 1024) { showError('图片太大,请小于10MB'); return; }
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
preview.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
dropZone.style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload
|
||||
loading.style.display = 'block';
|
||||
error.style.display = 'none';
|
||||
result.style.display = 'none';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
fetch('/api/upload/analyze', { method: 'POST', body: form })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
if (data.error) { showError(data.error); return; }
|
||||
lastResult = data;
|
||||
showResult(data);
|
||||
})
|
||||
.catch(err => {
|
||||
loading.style.display = 'none';
|
||||
showError('上传失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
function formatNum(n) {
|
||||
if (!n) return '-';
|
||||
const num = parseFloat(n);
|
||||
if (isNaN(num)) return n;
|
||||
return num.toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function showResult(data) {
|
||||
result.style.display = 'block';
|
||||
const badge = document.getElementById('typeBadge');
|
||||
const title = document.getElementById('resultTitle');
|
||||
const table = document.getElementById('resultTable');
|
||||
const summaryBox = document.getElementById('summaryBox');
|
||||
|
||||
const isPortfolio = data.type === 'portfolio';
|
||||
badge.className = 'type-badge ' + (isPortfolio ? 'badge-portfolio' : 'badge-watchlist');
|
||||
badge.textContent = isPortfolio ? '📊 持仓截图' : '⭐ 自选截图';
|
||||
title.textContent = `解析出 ${data.stocks.length} 只股票`;
|
||||
|
||||
// 显示汇总数据
|
||||
const s = data.summary || {};
|
||||
let sumHtml = '';
|
||||
if (isPortfolio && (s.total_assets || s.stock_value || s.cash || s.day_pnl)) {
|
||||
sumHtml = '<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:15px;padding:12px;background:#151525;border-radius:8px">';
|
||||
if (s.total_assets) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">总资产</div><div style="font-size:18px;font-weight:600;color:#00d4ff">${formatNum(s.total_assets)}</div></div>`;
|
||||
if (s.stock_value) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">股票市值</div><div style="font-size:18px;font-weight:600;color:#e0e0e0">${formatNum(s.stock_value)}</div></div>`;
|
||||
if (s.cash) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">可用资金</div><div style="font-size:18px;font-weight:600;color:#e0e0e0">${formatNum(s.cash)}</div></div>`;
|
||||
if (s.day_pnl) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">当日盈亏</div><div style="font-size:18px;font-weight:600;color:${(s.day_pnl||'').includes('-')?'#ff6b6b':'#00ff88'}">${formatNum(s.day_pnl)}</div></div>`;
|
||||
sumHtml += '</div>';
|
||||
} else if (isPortfolio) {
|
||||
sumHtml = '<div style="padding:12px;margin-bottom:15px;background:#151525;border-radius:8px;color:#888;font-size:13px">⚠️ 未识别到总资产/现金等汇总数据,确认更新后将保留原有数据</div>';
|
||||
}
|
||||
summaryBox.innerHTML = sumHtml;
|
||||
|
||||
let html = '<table><thead><tr><th>代码</th><th>名称</th><th>数量</th><th>现价</th><th>成本</th><th>盈亏</th><th>仓位%</th></tr></thead><tbody>';
|
||||
data.stocks.forEach(s => {
|
||||
html += `<tr>
|
||||
<td>${s.code || '-'}</td>
|
||||
<td>${s.name || '-'}</td>
|
||||
<td>${s.shares || '-'}</td>
|
||||
<td>${s.price || '-'}</td>
|
||||
<td>${s.cost || '-'}</td>
|
||||
<td style="color:${(s.pnl||'').includes('+')?'#00ff88':'#ff6b6b'}">${s.pnl || '-'}</td>
|
||||
<td>${s.position_pct || '-'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
table.innerHTML = html;
|
||||
}
|
||||
|
||||
function confirmUpdate() {
|
||||
if (!lastResult) return;
|
||||
loading.style.display = 'block';
|
||||
fetch('/api/upload/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(lastResult)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
if (data.error) { showError(data.error); return; }
|
||||
result.style.display = 'none';
|
||||
document.getElementById('successMsg').textContent = data.message;
|
||||
success.style.display = 'block';
|
||||
})
|
||||
.catch(err => {
|
||||
loading.style.display = 'none';
|
||||
showError('更新失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
error.textContent = msg;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
|
||||
function resetPage() {
|
||||
preview.style.display = 'none';
|
||||
dropZone.style.display = 'block';
|
||||
result.style.display = 'none';
|
||||
success.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
loading.style.display = 'none';
|
||||
lastResult = null;
|
||||
fileInput.value = '';
|
||||
preview.src = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user