Files
MoFin/server.py
T

2480 lines
101 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
import sys
sys.path.insert(0, "/home/hmo/MoFin/scripts")
sys.path.insert(0, "/home/hmo/MoFin")
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
from flask import Flask, jsonify, send_from_directory, request
import socket
import time
import sqlite3
SPECS_DIR = Path(__file__).parent / "specs"
GATEWAY_TEMP = Path(__file__).parent / "gateway" / "temp"
START_TIME = time.time()
# ── Dashboard 监控服务列表 ──
DASH_SERVICES = [
{"name": "mofin_api", "label": "MoFin API", "port": 8899, "host": "127.0.0.1", "type": "http", "check": "/api/portfolio", "layer": "核心服务", "critical": True},
{"name": "zhiwei_gateway", "label": "知微 Gateway", "port": 8643, "host": "127.0.0.1", "type": "http", "check": "/v1/health", "layer": "AI 网关", "critical": True},
{"name": "ejabberd", "label": "ejabberd XMPP", "port": 5222, "host": "127.0.0.1", "type": "tcp", "check": None, "layer": "通信层", "critical": True},
{"name": "mofin_db", "label": "MoFin 数据库", "port": 0, "host": "127.0.0.1", "type": "db", "check": "/home/hmo/web-dashboard/data/mofin.db", "layer": "数据层", "critical": True},
]
def _chk_tcp(host, port, timeout=3):
try:
s = socket.create_connection((host, port), timeout=timeout)
s.close()
return True
except Exception:
return False
# ── 2026-08-15 温区数据按周期计算 + 缓存(现场温区归因约30秒/请求,缓存后毫秒级)──
# 键 = period_tag + strategy_research 最新 created_at(数据更新即失效)
_regime_winrates_cache = {"slots": {}} # slots: {period_tag: (data_version_key, data)}
def _compute_regime_winrates_cached(pt, _approx_univ):
"""按 period_tag 读预计算表 strategy_regime_perf_by_period2026-08-15 数据加工层)
数据由 regime_perf_by_period.py 每日盘后预计算(含线性年化),server 只读表,毫秒级。
无对应周期记录时回退到最近更长周期(1m/6m→1y2y→2y5y→5y10y→10y)。
"""
import sqlite3 as _sq
_pt_chain = {'1m': '1m', '6m': '6m', '1y': '1y', '2y': '2y', '5y': '5y', '10y': '10y'} # 2026-08-18 切窗周期直读(已预计算)
_pt_use = _pt_chain.get(pt or '2y', '2y')
_regime_winrates = {}
try:
_c = _sq.connect(str(DATA_DIR / "mofin.db"), timeout=10)
_c.execute("PRAGMA busy_timeout=10000")
for _r in _c.execute(
"SELECT strategy, market, regime, period_tag, trades, win_rate, avg_pnl, "
"avg_hold_days, total_return_pct, cagr_pct, portfolio_max_dd_pct, capital_final, "
"positions_taken, sharpe_ratio, profit_factor, universality_months, universality_years, "
"universality_valid_years, universality_score, universality_leave1 "
"FROM strategy_regime_perf_by_period "
"WHERE period_tag=? ORDER BY strategy, market, regime",
(_pt_use,)).fetchall():
_ver, _mkt, _reg = _r[0], _r[1], _r[2]
_cagr_v = _r[9]
_ret_v = _r[8]
_dd_v = _r[10]
_cf_v = _r[11]
_pt_v = _r[12]
_sh_v = _r[13]
_pf_v = _r[14]
_umon = _r[15]
_uyears = _r[16]
_uvalid = _r[17]
_uscore = _r[18]
_uleave1 = _r[19]
_regime_winrates.setdefault(_ver, {})[_reg] = {
"trades": _r[4], "win_rate": _r[5], "avg_pnl": _r[6],
"avg_hold_days": _r[7],
"total_return_pct": _ret_v, "cagr_pct": _cagr_v,
"max_dd_pct": _dd_v, "capital_final": _cf_v,
"positions_taken": _pt_v, "sharpe_ratio": _sh_v,
"profit_factor": _pf_v,
"period_tag": _pt_use,
"portfolio": {"cagr_pct": _cagr_v, "total_return_pct": _ret_v,
"portfolio_max_dd_pct": _dd_v, "capital_final": _cf_v,
"positions_taken": _pt_v, "sharpe_ratio": _sh_v,
"profit_factor": _pf_v},
"universality": _approx_univ(_ver, _reg, _r[4], _umon, _uscore, _uyears, _uvalid, _uleave1),
}
_c.close()
except Exception:
pass
return _regime_winrates
def _benchmark_annual(period_days, market='a'):
"""大盘年化基准:market_regime 指数 close,近 period_days 天涨幅年化(2026-08-15 淘汰策略用)"""
try:
import sqlite3 as _sq
from datetime import datetime as _dt, timedelta as _td
_c = _sq.connect(str(DATA_DIR / "mofin.db"), timeout=5)
_cutoff = (_dt.now() - _td(days=period_days)).strftime("%Y-%m-%d")
_last = _c.execute(
"SELECT close FROM market_regime WHERE market=? AND close IS NOT NULL ORDER BY date DESC LIMIT 1",
(market,)).fetchone()
_base = _c.execute(
"SELECT close FROM market_regime WHERE market=? AND close IS NOT NULL AND date>=? ORDER BY date LIMIT 1",
(market, _cutoff)).fetchone()
_c.close()
if not _last or not _base or not _base[0]:
return None
_pct = (_last[0] / _base[0] - 1) * 100
_yrs = period_days / 365.0
if _pct <= -100:
return None
return round(((1 + _pct / 100) ** (1 / _yrs) - 1) * 100, 1)
except Exception:
return None
_BENCH_2Y = None
_BENCH_10Y = None
def _get_benchmarks():
"""取大盘基准(模块级缓存)"""
global _BENCH_2Y, _BENCH_10Y
if _BENCH_2Y is None:
_BENCH_2Y = _benchmark_annual(730)
if _BENCH_10Y is None:
_BENCH_10Y = _benchmark_annual(3650)
return _BENCH_2Y, _BENCH_10Y
def _chk_http(host, port, path, timeout=3):
try:
url = f"http://{host}:{port}{path}"
urllib.request.urlopen(urllib.request.Request(url), timeout=timeout)
return True
except Exception:
return False
def _chk_db(db_path):
try:
conn = sqlite3.connect(db_path)
conn.execute("SELECT 1")
conn.close()
return True
except Exception:
return False
def _check_svc(svc):
if svc["type"] == "tcp":
return _chk_tcp(svc["host"], svc["port"])
elif svc["type"] == "http":
return _chk_http(svc["host"], svc["port"], svc["check"])
elif svc["type"] == "db":
return _chk_db(svc["check"])
return False
# 提示词管理模块
from prompt_manager.dashboard_views import register_routes
# MoFin 数据层(纯 DB,不再读 JSON)
from mo_data import read_portfolio, read_decisions, read_watchlist
from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy
app = Flask(__name__, static_folder="static", static_url_path="")
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # 禁静态缓存:前端迭代频繁,防浏览器旧版残留
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):
"""仅用于非核心文件(reports, stocks, market 等)。portfolio/decisions/watchlist 已迁移到 DB。"""
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):
"""仅用于非核心文件(reports, stocks, market 等)。portfolio/decisions/watchlist 已迁移到 DB。"""
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)
def _save_portfolio(data):
"""写入持仓数据到 DB。data 必须包含 holdings[] 和顶层 summary 字段。"""
conn = get_conn()
try:
write_holdings_batch(conn, data.get('holdings', []))
write_portfolio_summary(conn, data)
finally:
conn.close()
def _save_decision(code, name, data):
"""写入单条决策到 DB。"""
conn = get_conn()
try:
write_holding_strategy(conn, code, name, data)
finally:
conn.close()
def _save_watchlist(data):
"""写入自选股列表到 DB。"""
conn = get_conn()
for s in data.get('stocks', []):
s.setdefault('currency', 'CNY')
write_watchlist_stock(conn, s)
conn.close()
# ── API 路由 ──────────────────────────────────────────
@app.route("/")
def index():
# 2026-08-18 强制不缓存 index.html(浏览器缓存导致前端改动不生效,老莫反馈子Tab看不到)
resp = send_from_directory(app.static_folder, "index.html")
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
@app.route("/api/watch")
def get_watch():
"""盯盘:所有有效策略(持仓+自选),服务端排序"""
import sqlite3
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
conn.row_factory = sqlite3.Row
# 1) 所有 active 持仓策略 + 自选策略
rows = conn.execute("""
SELECT hs.code, hs.name, hs.decision_type, hs.timing_signal,
hs.action, hs.position_advice, hs.tag,
hs.entry_low, hs.entry_high, hs.stop_loss, hs.take_profit,
hs.rr_ratio, hs.rr_low, hs.rr_high, hs.full_analysis, hs.reassessed_at,
hs.rec_score,
lp.price, lp.change_pct,
h.shares, h.position_pct
FROM holding_strategies hs
LEFT JOIN live_prices lp ON hs.code = lp.code
LEFT JOIN holdings h ON hs.code = h.code AND h.is_active = 1
WHERE hs.status='active'
AND hs.decision_type IN ('持仓策略','自选策略')
""").fetchall()
conn.close()
# 信号强度排序映射
signal_rank = {
'买入': 1, '可买入': 2, '可加仓': 3, '止盈': 4, '卖出': 5,
'关注': 6, '观望': 7, '持有': 8, '弱势持有': 9, '信号不充分': 10,
}
results = []
for r in rows:
d = dict(r)
# 分类 sort_group
tag = d.get('tag') or ''
if tag in ('current_recommend', 'active_manual'):
d['sort_group'] = 0 # 推荐
elif d['decision_type'] == '持仓策略':
d['sort_group'] = 1 # 持仓
else:
d['sort_group'] = 2 # 自选
sig = d.get('timing_signal') or ''
d['_sig_rank'] = signal_rank.get(sig, 99)
# 持仓仓位(用于持仓组内排序)
d['_pos'] = d.get('position_pct') or 0
d['_rr'] = d.get('rr_ratio') or 0
# 截断 full_analysis
fa = d.get('full_analysis') or ''
if len(fa) > 4000:
fa = fa[:4000] + '\n...(已截断)'
d['full_analysis'] = fa
results.append(d)
# ── 推荐操作精选层(2026-07-21 老爸:太多推荐=没有推荐)──
# 候选 = tag 非空 且 72h 内有新鲜重评;按 RR 降序;贪心装入现金预算;最多 5 只。
# 落选者降回其自然分组(持仓/自选)。
import re as _re
from datetime import datetime as _dt, timedelta as _td
conn2 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
try:
_pr = conn2.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone()
_cash = float(_pr[0] or 0)
_total = float(_pr[1] or 0)
finally:
conn2.close()
_budget_pct = (_cash / _total * 100) if _total > 0 else 0
_fresh_cutoff = _dt.now() - _td(hours=72)
def _is_fresh(d):
ra = d.get('reassessed_at') or ''
if not ra:
return False
try:
return _dt.fromisoformat(str(ra)[:19]) >= _fresh_cutoff
except Exception:
return False
def _sugg_pct(d):
# 从 position_advice 解析百分比(如 "8%(理由...)"),失败默认 8%
m = _re.search(r'(\d+(?:\.\d+)?)\s*%', d.get('position_advice') or '')
if m:
try:
v = float(m.group(1))
if 0 < v <= 30:
return v
except Exception:
pass
return 8.0
_SELL_SIGS = ("卖出", "止盈")
_cands = [d for d in results if d['sort_group'] == 0 and _is_fresh(d)]
# 卖出类永远可执行(释放现金,不占买入预算),排最前;买入类按 RR 降序
_sells = [d for d in _cands if (d.get('timing_signal') or '') in _SELL_SIGS]
_buys = [d for d in _cands if (d.get('timing_signal') or '') not in _SELL_SIGS]
_buys.sort(key=lambda x: (x.get('rec_score') or 0, x.get('rr_ratio') or 0), reverse=True)
for d in _sells:
d['rec_exec'] = True # 卖出不需要现金,永远可执行
d['suggested_position_pct'] = 0.0
# 买入:score≥60 + RR≥2.0 才有可执行资格(2026-07-27 老爸:五维评分替代纯RR)
# 达标者贪心装入现金预算,预算外/不达标标记"排队"(不再降级隐藏)
_cum = 0.0
# 弱信号不可执行
_WEAK_SIGNALS = ('信号不充分', '关注', '弱势持有', '观望', '持有', '')
_TOP_N = 5 # 优中选优:推荐区只展示 Top 5(剩余排入自选区)
for d in _buys:
pct = _sugg_pct(d)
d['suggested_position_pct'] = pct
rr = d.get('rr_ratio') or 0
score = d.get('rec_score') or 0
sig_now = d.get('timing_signal') or ''
# 仓位必须明确%
_has_pos = bool(_re.search(r'(\d+(?:\.\d+)?)\s*%', d.get('position_advice') or ''))
if sig_now in _WEAK_SIGNALS:
d['rec_exec'] = False # 弱信号永远排队
elif not _has_pos:
d['rec_exec'] = False # 无明确仓位,排队
elif score >= 60 and rr >= 2.0 and _cum + pct <= _budget_pct + 1e-9:
d['rec_exec'] = True # 可执行(2026-07-24 老爸:门槛1.5→2.0,边缘推荐不算优)
_cum += pct
else:
d['rec_exec'] = False # 排队(现金不足或RR不达标)
# 落选(tag 但非新鲜)仍降回自然分组;新鲜者全部留在推荐区
for d in results:
if d['sort_group'] == 0 and not _is_fresh(d):
d['sort_group'] = 1 if d['decision_type'] == '持仓策略' else 2
# ── 优中选优(2026-07-27 老爸):推荐区只展示 Top 5 买入,太多选不过来 ──
# 卖出/止盈永远保留在推荐区;买入按评分降序,第6名起降入自选区
_rec_buys = [d for d in results if d['sort_group'] == 0
and (d.get('timing_signal') or '') not in _SELL_SIGS]
_rec_buys.sort(key=lambda x: (x.get('rec_score') or 0, x.get('rr_ratio') or 0), reverse=True)
for d in _rec_buys[_TOP_N:]:
d['sort_group'] = 2 # 超额买入降入自选区
# 排序:group → signal_rank → group-internal (持仓按position_pct desc, 自选按rr desc)
def skey(x):
g = x['sort_group']
sr = x['_sig_rank']
# 同 signal 时持仓按仓位、自选按RR
inner = x['_pos'] if g == 1 else x['_rr']
return (g, sr, -inner, x.get('code', ''))
results.sort(key=skey)
# 移除辅助排序键
for d in results:
d.pop('_sig_rank', None)
d.pop('_pos', None)
d.pop('_rr', None)
# 数据最新更新时间(price_monitor/live_prices
_data_time = ""
try:
_dtc = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
_r = _dtc.execute("SELECT MAX(updated_at) FROM live_prices").fetchone()
if _r and _r[0]:
_data_time = str(_r[0])
_dtc.close()
except Exception:
pass
return json.dumps({"stocks": results, "count": len(results),
"cash": _cash, "total_assets": _total,
"budget_pct": round(_budget_pct, 2),
"rec_used_pct": round(_cum, 2),
"data_time": _data_time}, ensure_ascii=False)
@app.route("/api/strategy_history/<code>")
def api_strategy_history(code):
"""某只股票最近 N 条策略记录(strategy_history 表)"""
limit = min(int(request.args.get('limit', 3)), 20)
import sqlite3
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
conn.row_factory = sqlite3.Row
try:
rows = conn.execute("""
SELECT id, code, name, decision_type, strategy_type,
full_analysis, action, timing_signal,
entry_low, entry_high, stop_loss, take_profit,
position_advice, rr_ratio, version, source_trigger,
reassessed_at, snapshotted_at
FROM strategy_history
WHERE code=?
ORDER BY snapshotted_at DESC
LIMIT ?
""", (code, limit)).fetchall()
history = [dict(r) for r in rows]
if not history:
raise LookupError("history empty, fallback to current row")
except Exception:
# 表不存在或查询失败 → 降级为当前 holding_strategies 行
history = []
try:
cur = conn.execute("""
SELECT code, name, decision_type, timing_signal, action,
entry_low, entry_high, stop_loss, take_profit,
rr_ratio, position_advice, full_analysis,
reassessed_at
FROM holding_strategies
WHERE code=? AND status='active'
""", (code,)).fetchone()
if cur:
d = dict(cur)
d['version'] = 'current'
d['snapshotted_at'] = d.get('reassessed_at', '')
d['is_current'] = True
history = [d]
except Exception:
history = []
finally:
conn.close()
return jsonify({"code": code, "count": len(history), "history": history})
_CRON_ID_MAP_CACHE = {"ts": 0, "map": {}}
def _cron_name_to_id():
"""从两个 profile 的 hermes jobs.json 构建 name->id 映射(60s 缓存)。"""
import time as _t, glob as _g, json as _j
now = _t.time()
if now - _CRON_ID_MAP_CACHE["ts"] < 60:
return _CRON_ID_MAP_CACHE["map"]
m = {}
for pj in _g.glob("/home/hmo/.hermes/profiles/*/cron/jobs.json"):
try:
with open(pj, encoding="utf-8") as f:
jobs = _j.load(f)
jobs = jobs if isinstance(jobs, list) else jobs.get("jobs", [])
for j in jobs:
jid, jname = str(j.get("id", "")), str(j.get("name", ""))
if jid:
m[jid] = jid
if jname and jid:
m[jname] = jid
except Exception:
pass
_CRON_ID_MAP_CACHE["ts"] = now
_CRON_ID_MAP_CACHE["map"] = m
return m
@app.route("/api/reports")
def api_reports():
"""历史报告列表,支持 ?cron=<pipeline名>&script=<脚本名>&limit=N 过滤
匹配链:pipeline名→jobs.json解析为job id→文件名前缀 cron_{id}_
→ pipeline名子串 → 脚本名(去.py)子串 → 报告title子串。
"""
reports_dir = DATA_DIR / "reports"
reports = []
if reports_dir.exists():
cron_key = (request.args.get("cron") or "").strip()
script_key = (request.args.get("script") or "").strip()
if script_key.endswith(".py"):
script_key = script_key[:-3]
limit = min(int(request.args.get("limit", 100)), 200)
job_id = ""
if cron_key:
job_id = _cron_name_to_id().get(cron_key, "")
for f in sorted(reports_dir.iterdir(), reverse=True):
if f.suffix != ".json":
continue
data = _load_json(f) if (cron_key or script_key) else None
if cron_key or script_key:
stem = f.stem
title = str(data.get("title", ""))
# 命中链:job id 前缀 → 名/脚本子串(文件名) → pipeline名子串(标题)
hit = False
if job_id and stem.startswith(f"cron_{job_id}_"):
hit = True
elif cron_key and cron_key in stem:
hit = True
elif script_key and script_key in stem:
hit = True
elif cron_key and cron_key in title:
hit = True
if not hit:
continue
if data is None:
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", ""),
"cron": data.get("cron") or data.get("job") or "",
})
if len(reports) >= limit:
break
return jsonify(reports)
@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({"error": "数据库查询失败"}), 500
@app.route("/api/watchlist")
def api_watchlist():
"""自选列表(从holding_strategies读取)"""
try:
import sqlite3
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT hs.code, hs.name, hs.entry_low, hs.entry_high, hs.stop_loss, hs.take_profit,
hs.timing_signal, hs.action, lp.price, lp.change_pct, hs.rr_ratio, hs.updated_at,
hs.tech_snapshot, hs.sector_context, hs.full_analysis, hs.position_advice
FROM holding_strategies hs
LEFT JOIN live_prices lp ON hs.code = lp.code
WHERE hs.status='active' AND hs.decision_type='自选策略'
ORDER BY
CASE
WHEN hs.timing_signal IN ('买入','可买入','可加仓') THEN 0
WHEN hs.timing_signal IN ('关注') THEN 1
WHEN hs.timing_signal IN ('信号不充分') THEN 2
WHEN hs.timing_signal IN ('持有') THEN 3
WHEN hs.timing_signal IN ('弱势持有') THEN 4
ELSE 5
END,
COALESCE(hs.rec_score, 0) DESC,
COALESCE(hs.rr_ratio,0) DESC,
hs.code
""").fetchall()
conn.close()
stocks = [dict(r) for r in rows]
return jsonify({"stocks": stocks, "total": len(stocks)})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/tracking")
def get_tracking():
"""策略追踪评估:所有推荐的历史记录和结果"""
import sqlite3
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT st.*, lp.price as current_price
FROM strategy_tracking st
LEFT JOIN live_prices lp ON st.code = lp.code
ORDER BY
CASE st.status WHEN 'active' THEN 0 WHEN 'hit_tp' THEN 1 WHEN 'hit_sl' THEN 2 ELSE 3 END,
st.tracked_at DESC
""").fetchall()
tracks = []
for r in rows:
d = dict(r)
# 理论盈亏:按买入区中值买入,当前价 vs 中值
if d.get('entry_mid') and d.get('current_price') and d.get('status') == 'active':
mid = float(d['entry_mid'])
price = float(d['current_price'])
d['theoretical_pnl'] = round((price - mid) / mid * 100, 2)
# 浮盈:有实操且 active 状态
if d.get('actual_entry') and d.get('actual_shares') and d.get('status') == 'active' and d.get('current_price'):
entry = float(d['actual_entry'])
price = float(d['current_price'])
shares = int(d['actual_shares'])
d['floating_pnl'] = round((price - entry) / entry * 100, 2)
d['floating_amount'] = round((price - entry) * shares, 2)
tracks.append(d)
conn.close()
return jsonify({
"tracks": tracks,
"stats": {
"total": len(tracks),
"active": sum(1 for r in tracks if r["status"] == "active"),
"hit_tp": sum(1 for r in tracks if r["status"] == "hit_tp"),
"hit_sl": sum(1 for r in tracks if r["status"] == "hit_sl"),
"expired": sum(1 for r in tracks if r["status"] == "expired"),
"manual": sum(1 for r in tracks if r["status"] == "manual_close"),
}
})
@app.route("/api/research/strategies")
def api_research_strategies():
"""策略版本列表(含回测结果摘要,支持 period_tag 区间过滤)"""
def _approx_regime_universality(strategy, regime, trades, umon=None, uscore=None, uyears=None, uvalid=None, uleave1=None):
"""温区级普适:优先用预计算真实值(trades entry_date 去重月份/温区总月份),
缺失时退回旧近似(信号数÷3估算,2026-08-16 修复——原估算对集中信号虚高)"""
if umon is not None and uscore is not None:
return {"months": umon, "score": uscore, "years": uyears or 0,
"valid_years": uvalid or 0, "leave1_avg": uleave1,
"regime_total_months": 0}
try:
import sqlite3 as _sq6
_c6 = _sq6.connect(str(DATA_DIR / "mofin.db"), timeout=10)
# 温区总月份
_rm = {r[0]: r[1] for r in _c6.execute(
"SELECT regime, COUNT(DISTINCT substr(date,1,7)) FROM market_regime WHERE market='a' GROUP BY regime").fetchall()}
_c6.close()
regime_total = _rm.get(regime, 0)
if not trades or regime_total == 0:
return {"months": 0, "score": 0, "regime_total_months": regime_total}
# 近似:温区内信号月份 ≈ trades / (温区月均笔数≈3),月份占比 = 信号月份/温区总月份
est_months = max(1, min(trades // 3, regime_total))
score = round(min(est_months / regime_total * 100, 100))
return {"months": est_months, "score": score, "regime_total_months": regime_total}
except Exception:
return {"months": 0, "score": 0, "regime_total_months": 0}
try:
from strategy_lab import list_strategies, STRATEGY_DESCRIPTIONS
# 2026-08-16 资格评估 + 手动可用性(标准A:长期10y/近期2y/当下1y 适应温区年化>大盘)
try:
sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
from strategy_qualify import get_benchmarks as _qbench, evaluate_all_regimes as _qeval, is_available as _qavail, load_availability as _qload
except Exception:
_qbench = _qeval = _qavail = _qload = None
# 2026-08-16 基准按市场:A股=全年大盘,港股=下跌市时段基准(老莫确认口径)
_qbench_a = _qbench('a') if _qbench else {}
_qbench_hk = _qbench('hk') if _qbench else {}
_qbench_data = _qbench_a # 默认 A股(兼容旧逻辑)
pt = request.args.get('period_tag')
strats = list_strategies(period_tag=pt)
# 2026-08-13 温区自适应:为每个策略附加各温区表现(strategy_regime_perf
# + 当前温区激活状态(regime_weights)。前端按"适应温区"展开多行。
_weights = _load_json(DATA_DIR / "strategy_weights.json", None) or {}
# 2026-08-14 港股接入:激活集合 = A股顶层 active + 港股 markets.hk.active
_active_set = set(_weights.get("active") or [])
_hk_market = (_weights.get("markets") or {}).get("hk") or {}
_active_set |= set(_hk_market.get("active") or [])
# 2026-08-18 按温区激活矩阵(老莫:资格够就激活,不合格绝不激活;当前温区实盘闸门)
# 构建 策略 -> 激活温区列表(A股 regime_active + 港股 markets.hk.regime_active
_strategy_active_regimes = {}
for _rgm, _acts in ((_weights.get("regime_active") or {}).items()):
for _sv in (_acts or []):
_strategy_active_regimes.setdefault(_sv, set()).add(_rgm)
for _rgm, _acts in ((_hk_market.get("regime_active") or {}).items()):
for _sv in (_acts or []):
_strategy_active_regimes.setdefault(_sv, set()).add("hk:" + _rgm)
# ── 2026-08-15 温区数据按所选周期计算(函数内缓存,替代全量表/内联重算)──
_regime_winrates = _compute_regime_winrates_cached(pt, _approx_regime_universality)
# 温区级普适前先建立日期→温区映射(A股 market_regime
_regime_map = {}
try:
import sqlite3 as _sq5
_c5 = _sq5.connect(str(DATA_DIR / "mofin.db"), timeout=10)
for _r in _c5.execute("SELECT date, regime FROM market_regime WHERE market='a'"):
_regime_map[_r[0]] = _r[1]
_c5.close()
except Exception:
pass
# 温区级普适(近似):温区内信号月份 ≈ trades/温区月均笔数×温区总月份,避免逐笔遍历(性能)
# 温区总月份数(A股: choppy35.4%/trend_down30.3%/trend_up34.3%;按市场 regime 时长统计)
try:
import sqlite3 as _sq3
_c3 = _sq3.connect(str(DATA_DIR / "mofin.db"), timeout=10)
_total_months = _c3.execute(
"SELECT COUNT(DISTINCT substr(date,1,7)) FROM market_regime WHERE market='a'").fetchone()[0]
_regime_months = {r[0]: r[1] for r in _c3.execute(
"SELECT regime, COUNT(DISTINCT substr(date,1,7)) FROM market_regime WHERE market='a' GROUP BY regime").fetchall()}
_c3.close()
except Exception:
_total_months = 0
_regime_months = {}
# 温区级普适已用近似计算(_approx_regime_universality),不逐笔遍历(性能)
_deprecated = {}
try:
import sqlite3 as _sq2
_c2 = _sq2.connect(str(DATA_DIR / "mofin.db"), timeout=10)
for _r in _c2.execute(
"SELECT version, MAX(deprecated) FROM strategy_research "
"WHERE deprecated IS NOT NULL AND deprecated != '' GROUP BY version"
).fetchall():
_deprecated[_r[0]] = _r[1]
_c2.close()
except Exception:
pass
_candidate_pool = set()
try:
from hk_strategies import HK_STRATEGIES as _HKS
_candidate_pool |= set(_HKS.keys())
except Exception:
pass
_candidate_pool |= set(_regime_winrates.keys())
# ── 2026-08-15 适应温区(best_regime):weights 优先,缺失从温区数据推断(2y最高胜率温区)──
_best_regime_map = {}
try:
for _v, _w in (_weights.get("weights") or {}).items():
_br = _w.get("best_regime")
if _br:
_best_regime_map[_v] = _br
except Exception:
pass
# 推断缺失的(港股/历史策略)
try:
import sqlite3 as _sqb
_cb = _sqb.connect(str(DATA_DIR / "mofin.db"), timeout=10)
for _v, _rws in _regime_winrates.items():
if _v in _best_regime_map or not _rws:
continue
_best_br = None
_best_wr = -1
for _reg, _rw in _rws.items():
_wr = _rw.get("win_rate")
if _wr is not None and _wr > _best_wr:
_best_wr = _wr
_best_br = _reg
if _best_br:
_best_regime_map[_v] = _best_br
_cb.close()
except Exception:
pass
# ── 淘汰预取:2y/10y 温区数据 + 大盘基准(循环外取一次,避免每策略重复查库)──
_elim_r2 = _compute_regime_winrates_cached('2y', _approx_regime_universality)
_elim_r10 = _compute_regime_winrates_cached('10y', _approx_regime_universality)
_elim_b2, _elim_b10 = _get_benchmarks()
for s in strats:
# 2026-08-15 温区加权综合/普适:整体行的综合/普适 = 按温区时长占比加权各温区表现
# (业务逻辑:策略只在特定温区激活,评估应该限定在温区范围才值得参考)
_rws = _regime_winrates.get(s['version'], {})
if _rws:
_total_weight = sum((r.get('portfolio') or {}).get('total_return_pct', 0) or 0 for r in _rws.values())
if _total_weight > 0:
_w_cagr = sum(
((r.get('portfolio') or {}).get('total_return_pct', 0) or 0) / _total_weight *
((r.get('portfolio') or {}).get('cagr_pct', 0) or 0)
for r in _rws.values())
_w_ret = sum(
((r.get('portfolio') or {}).get('total_return_pct', 0) or 0) / _total_weight *
((r.get('portfolio') or {}).get('total_return_pct', 0) or 0)
for r in _rws.values())
_w_dd = sum(
((r.get('portfolio') or {}).get('total_return_pct', 0) or 0) / _total_weight *
((r.get('portfolio') or {}).get('portfolio_max_dd_pct', 0) or 0)
for r in _rws.values())
_w_sharpe = sum(
((r.get('portfolio') or {}).get('total_return_pct', 0) or 0) / _total_weight *
((r.get('portfolio') or {}).get('sharpe_ratio', 0) or 0)
for r in _rws.values())
_w_pf = sum(
((r.get('portfolio') or {}).get('total_return_pct', 0) or 0) / _total_weight *
((r.get('portfolio') or {}).get('profit_factor', 0) or 0)
for r in _rws.values())
# 温区加权综合分(对齐整体综合分公式:ret30+wr20+sharpe20+pf15+dd15
_w_wr = sum(r.get('win_rate', 0) or 0 for r in _rws.values()) / max(len(_rws), 1)
_w_composite = round(
min(_w_ret, 100) / 100 * 30 + _w_wr / 100 * 20 +
min(max(_w_sharpe, 0), 20) / 20 * 20 +
min(_w_pf, 5) / 5 * 15 +
(1 - min(_w_dd, 50) / 50) * 15)
s['regime_weighted'] = {
'composite': _w_composite,
'cagr_pct': round(_w_cagr, 1),
'total_return_pct': round(_w_ret, 1),
'portfolio_max_dd_pct': round(_w_dd, 1),
'sharpe_ratio': round(_w_sharpe, 2),
'profit_factor': round(_w_pf, 2),
}
s['description'] = STRATEGY_DESCRIPTIONS.get(s['version'], {})
# 2026-08-15STRATEGY_DESCRIPTIONS 缺策略描述时,从 strategy_research 的 name/summary 生成简化描述(说明列不再空)
if not s['description'] and (s.get('name') or s.get('summary')):
s['description'] = {
'title': s.get('name') or s['version'],
'algorithm': s.get('summary') or '',
'rationale': s.get('hypothesis') or '',
'evidence': '',
}
s['regime_winrates'] = _regime_winrates.get(s['version'], {})
# current = 当前温区激活(替代旧的 CURRENT_VERSIONS 硬编码)
s['current'] = s['version'] in _active_set
# 2026-08-18 温区激活列表(前端按温区打状态,区分"该策略在当前表温区是否激活")
s['current_regimes'] = sorted(_strategy_active_regimes.get(s['version'], set()))
# 2026-08-15 适应温区(前端方案A:策略只在适应温区表显示一行)
s['best_regime'] = _best_regime_map.get(s['version'])
# 2026-08-16 资格评估(标准A)——per-regime,策略可适应多个温区
_mkt_v = (s.get('market') or 'a') if (s.get('market') or 'a') != 'all' else 'a'
_qbench_for_mkt = _qbench_hk if _mkt_v == 'hk' else _qbench_a
_quals = _qeval(s['version'], _mkt_v, bench=_qbench_for_mkt) if _qeval else {}
s['qualification'] = {}
for _rg, _q in _quals.items():
s['qualification'][_rg] = {
'long_ok': _q.get('long_ok'), 'mid_ok': _q.get('mid_ok'),
'short_ok': _q.get('short_ok'),
'cagr_10y': _q.get('cagr_10y'), 'cagr_2y': _q.get('cagr_2y'),
'cagr_1y': _q.get('cagr_1y'),
'bench_10y': _qbench_for_mkt.get('10y'), 'bench_2y': _qbench_for_mkt.get('2y'),
'bench_1y': _qbench_for_mkt.get('1y'),
}
# ── 2026-08-17 资格判定用【当前激活温区 weights.state】优先,best_regime 回退 ──
# 2026-08-16 原用 best_regime:择优激活按当前温区选策略(如trend_down激活v_lurk_v3)
# 但best_regime(choppy)判定会误判——v_lurk_v3在trend_down达标却被判不合格)
# 当前温区有资格数据 → 用当前温区;否则回退策略自身适应温区
_state_rg = _weights.get('state') or ''
_state_q = (s.get('qualification') or {}).get(_state_rg)
_best_rg = s.get('best_regime') or ''
_best_q = (s.get('qualification') or {}).get(_best_rg)
if _state_q and (_state_q.get('cagr_10y') is not None):
_qual_rg = _state_rg
else:
_qual_rg = _best_rg
_cur_regime_q = (s.get('qualification') or {}).get(_qual_rg)
_l_ok = bool(_cur_regime_q and _cur_regime_q.get('long_ok'))
_m_ok = bool(_cur_regime_q and _cur_regime_q.get('mid_ok'))
_s_ok = bool(_cur_regime_q and _cur_regime_q.get('short_ok'))
# 2026-08-16 手动可用性(老莫把关):激活只能是可用的策略
s['manual_available'] = _qavail(s['version']) if _qavail else True
# 2026-08-15 证伪数据驱动:从 strategy_research.deprecated 读(替代前端硬编码)
s['deprecated'] = _deprecated.get(s['version'])
_qual_ok_3 = _l_ok and _m_ok and _s_ok
_qual_ok_2 = _l_ok and _m_ok
if s['version'] in _active_set:
if s['manual_available'] and _qual_ok_3:
s['state'] = 'active'
s['state_reason'] = '当前温区+手动可用+三项达标'
else:
# 路由激活但不够格 → 降级展示(不真跑),reason 说明原因
s['state'] = 'available' if (s['manual_available'] and _qual_ok_2) else 'unavailable'
_why = []
if not s['manual_available']:
_why.append('手动不可用')
if not _qual_ok_3:
_why.append('未三项达标(长期%s/近期%s/当下%s)' % (
'✓' if _l_ok else '✗', '✓' if _m_ok else '✗', '✓' if _s_ok else '✗'))
s['state_reason'] = '路由激活但' + ';'.join(_why)
s['current'] = False # 激活降级,前端不高亮
elif s.get('deprecated'):
s['state'] = 'unavailable'
s['state_reason'] = '已证伪: ' + str(s['deprecated'])[:80]
elif s['version'] in _candidate_pool:
if not s['manual_available']:
s['state'] = 'unavailable'
s['state_reason'] = '手动不可用(可手动改回)'
elif not _l_ok:
s['state'] = 'unavailable'
s['state_reason'] = '长期不合格(10y适应温区年化%s%%<大盘%s%%)' % (
round(_cur_regime_q['cagr_10y'], 1) if _cur_regime_q and _cur_regime_q.get('cagr_10y') is not None else '?',
_qbench_for_mkt.get('10y'))
elif _qual_ok_2:
s['state'] = 'available'
s['state_reason'] = '长期+近期达标,待当下确认'
else:
s['state'] = 'available'
s['state_reason'] = '候选池内,长期达标'
else:
s['state'] = 'unavailable'
s['state_reason'] = '不在候选池(历史版本,路由不会选中)'
# 2026-08-16 警告标记:可用性下降(近期转差)/ 回升(不可用但近期转好)
s['qual_warning'] = None
if _cur_regime_q and s.get('manual_available'):
if not _m_ok and _l_ok:
s['qual_warning'] = '⚠️近期转差: 2y年化%s%%<大盘%s%%' % (
round(_cur_regime_q['cagr_2y'], 1) if _cur_regime_q.get('cagr_2y') is not None else '?',
_qbench_for_mkt.get('2y'))
if _cur_regime_q and not s.get('manual_available') and _s_ok:
s['qual_warning'] = '🔄近期回升: 1y年化%s%%>大盘%s%%,可考虑重新启用' % (
round(_cur_regime_q['cagr_1y'], 1) if _cur_regime_q.get('cagr_1y') is not None else '?',
_qbench_for_mkt.get('1y'))
# 2026-08-13 关键修复:激活策略必须始终在列表中(即使该 period 无回测记录)
# 否则 2y 等默认周期下激活策略缺失(只显示 v_oversold)。
# 缺失的激活策略用 10y 数据补全 summary_stats + regime_winrates。
_existing = {s['version'] for s in strats}
_missing_active = sorted(_active_set - _existing)
if _missing_active:
try:
_s10 = list_strategies(period_tag='10y')
_by10 = {s['version']: s for s in _s10}
except Exception:
_by10 = {}
for _mv in _missing_active:
_s10 = _by10.get(_mv)
if not _s10:
continue
_entry = dict(_s10)
_entry['description'] = STRATEGY_DESCRIPTIONS.get(_mv, {})
# 2026-08-15:激活但策略库无记录的策略,description fallback 到 name/summary(说明列不再空)
if not _entry['description'] and (_entry.get('name') or _entry.get('summary')):
_entry['description'] = {
'title': _entry.get('name') or _mv,
'algorithm': _entry.get('summary') or '',
'rationale': _entry.get('hypothesis') or '',
'evidence': '',
}
_entry['regime_winrates'] = _regime_winrates.get(_mv, {})
# 2026-08-16 补全条目必须设 best_regime——否则前端按 best_regime 分表时被踢出所有温区表
_entry['best_regime'] = _best_regime_map.get(_mv)
_entry['current'] = True
_entry['state'] = 'active'
_entry['state_reason'] = '当前温区路由激活'
_entry['note'] = f"({pt or '当前周期'}无回测记录,显示10y数据)"
strats.append(_entry)
return jsonify({'strategies': strats, 'regime_active': sorted(_active_set),
'regime_state': _weights.get('state', 'unknown')})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route("/api/research/backtest")
def api_research_backtest():
"""运行指定策略版本的回测"""
from datetime import datetime, timedelta
version = request.args.get('strategy', 'v4.1')
period = request.args.get('period', '6m')
market = request.args.get('market', 'all') # all | a | hk
capital = float(request.args.get('capital', 1000000))
end_date = '2026-07-24' # 数据完整截止日
days = {'1m': 30, '6m': 185, '1y': 365, '2y': 730, '5y': 1825, '10y': 3650}.get(period, 185)
start_date = (datetime.strptime(end_date, '%Y-%m-%d') - timedelta(days=days)).strftime('%Y-%m-%d')
try:
from strategy_lab import run_backtest, analyze_trade_list, save_analysis
result = run_backtest(version, start_date, end_date, capital, universe=market)
analysis = analyze_trade_list(result.get('trades', []), version)
save_analysis(version, analysis)
result['insights'] = analysis.get('insights', [])
return jsonify(result)
except ValueError as e:
return jsonify({'error': str(e)}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route("/api/research/availability", methods=["GET", "POST"])
def api_research_availability():
"""策略手动可用性状态(2026-08-16 老莫把关)
GET /api/research/availability → 全部状态
POST /api/research/availability → {version, available, note?} 设单个
POST /api/research/availability?batch=1 → {versions:[...], available, note?} 批量
POST /api/research/availability?init=1 → 按标准A自动初始化新策略
"""
import sys as _sy
_sy.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts")
from strategy_qualify import (load_availability, set_available,
auto_init_availability, get_benchmarks)
if request.method == "GET":
return jsonify(load_availability())
data = request.get_json(silent=True) or {}
try:
if request.args.get("init"):
from strategy_lab import list_strategies
versions = sorted({s["version"] for s in list_strategies(period_tag="10y")})
changed = auto_init_availability(versions, bench=get_benchmarks())
return jsonify({"initialized": changed, "count": len(changed)})
if request.args.get("batch"):
versions = data.get("versions") or []
available = data.get("available", True)
note = data.get("note", "")
out = {}
for v in versions:
out[v] = set_available(v, available, note)
return jsonify({"updated": out, "count": len(out)})
version = data.get("version")
if not version:
return jsonify({"error": "missing version"}), 400
entry = set_available(version, data.get("available", True), data.get("note", ""))
return jsonify({"updated": version, "entry": entry})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/research/analysis")
def api_research_analysis():
"""指定策略版本的因子归因分析"""
version = request.args.get('strategy', 'v4.1')
try:
from strategy_lab import analyze_trades
return jsonify(analyze_trades(version))
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route("/api/research/trades")
def api_research_trades():
"""指定策略版本的交易明细"""
import sqlite3 as _sq, json as _json
version = request.args.get('strategy', 'v4.1')
conn = _sq.connect("/home/hmo/MoFin/data/mofin.db")
row = conn.execute(
"SELECT results_json FROM strategy_research WHERE version=? ORDER BY id DESC LIMIT 1",
(version,)).fetchone()
conn.close()
if not row:
return jsonify({'error': f'无 {version} 回测结果'}), 404
res = _json.loads(row[0])
return jsonify({'strategy': version, 'trades': res.get('trades', [])})
@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],
# 2026-08-13 温区自适应:概览页横幅数据源
"regime_weights": _load_json(DATA_DIR / "strategy_weights.json", None),
"updated_at": summary.get("updated_at", ""),
})
except Exception:
return jsonify({"error": "数据库查询失败"}), 500
@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):
"""个股详情:DB策略(12维分析/action/三值RR)为主,JSON历史为辅。
根治:弹窗只读 data/stocks/{code}.json,与DB脱节,打开=空白。"""
stock_data = _load_json(DATA_DIR / "stocks" / f"{code}.json", {})
try:
conn = get_conn()
conn.row_factory = sqlite3.Row
r = conn.execute("""
SELECT hs.code, hs.name, hs.timing_signal, hs.action, hs.position_advice,
hs.entry_low, hs.entry_high, hs.stop_loss, hs.take_profit,
hs.rr_ratio, hs.rr_low, hs.rr_high, hs.full_analysis, hs.reassessed_at,
hs.tag, hs.decision_type, hs.rec_score,
lp.price AS live_price, lp.change_pct
FROM holding_strategies hs
LEFT JOIN live_prices lp ON hs.code = lp.code
WHERE hs.code=? AND hs.status='active'
""", (code,)).fetchone()
conn.close()
if r:
stock_data.update({
"code": r["code"], "name": r["name"],
"timing_signal": r["timing_signal"], "action": r["action"],
"position_advice": r["position_advice"],
"entry_low": r["entry_low"], "entry_high": r["entry_high"],
"stop_loss": r["stop_loss"], "take_profit": r["take_profit"],
"rr_ratio": r["rr_ratio"], "rr_low": r["rr_low"], "rr_high": r["rr_high"],
"full_analysis": r["full_analysis"], "reassessed_at": r["reassessed_at"],
"tag": r["tag"], "decision_type": r["decision_type"],
"price": r["live_price"], "change_pct": r["change_pct"],
})
except Exception as _e:
stock_data.setdefault("db_error", str(_e))
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():
"""小果扫描统计(2026-08-19 小果已废弃收尸,返回空)"""
try:
total = 0; found = 0; recent = []
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_portfolio(data)
return jsonify({"status": "ok"})
@app.route("/api/update/watchlist", methods=["POST"])
def update_watchlist():
data = request.get_json(force=True)
_save_watchlist(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 = read_portfolio()
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_portfolio(pf)
# 更新自选
if "watchlist" in data:
wl = read_watchlist()
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_watchlist(wl)
return jsonify({"status": "ok", "updated_at": datetime.now().isoformat()})
# ── 操作决策库API ──
@app.route("/api/decisions", methods=["GET"])
def get_decisions():
"""返回决策库数据,统一新旧格式"""
raw = read_decisions()
decisions = raw.get("decisions", [])
if not decisions and isinstance(raw, list):
decisions = raw
# portfolio 用来判断是持仓还是自选
portfolio = read_portfolio()
watchlist = read_watchlist()
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 = read_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_decision(code, entry.get('name',''), entry)
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 = read_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_decision(code, e.get('name',''), e)
return jsonify({"status": "ok", "code": code, "tag": tag})
@app.route("/api/decisions/pending")
def get_pending_decisions():
"""返回所有有未确认建议的条目"""
d = read_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 = read_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_decision(code, entry.get('name',''), entry)
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 = read_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_decision(code, e.get('name',''), e)
return jsonify({"status": "ok"})
return jsonify({"status": "error", "message": "not found"}), 404
# ── 准确率统计API ──
# ── 策略评估API ──
@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/candidates")
def get_candidates():
"""候选股管道数据"""
import sqlite3
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT code, name, reason, entry_range, stop_loss, target,
score_2nd, score_3rd, score_4th, score_5th, score_final,
pass_s2, pass_s3, pass_s4, pass_s5, pass_final,
promoted, promoted_at, log, created_at
FROM candidates WHERE dropped IS NULL OR dropped=0
ORDER BY COALESCE(score_final,0) DESC, created_at DESC
LIMIT 50
""").fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
@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):
"""优先用小果GLM-OCR-8bit识别,失败则降级到pytesseract"""
import sys
from PIL import Image, ImageEnhance, ImageFilter
import pytesseract
# 尝试小果OCRGLM-OCR-8bit
try:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "scripts"))
from ocr_client import ocr_image as xg_ocr
result = xg_ocr(image_path, "请识别这张图片中所有文字,包括股票名称、代码、价格、持股数、金额、百分比等。输出完整内容。")
if result.get("success") and len(result.get("text", "")) > 20:
return result["text"].strip()
except Exception:
pass # 降级到tesseract
# 降级:Tesseract(预处理优化中文表格识别)
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:
# DB 优先(price_monitor 维护的实时价)
db_prices = {}
try:
import sqlite3
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
db.row_factory = sqlite3.Row
for code in codes:
row = db.execute("SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone()
if row and row['price']:
db_prices[code] = (row['price'], row['change_pct'] or 0)
db.close()
except Exception:
pass
# Fallback: 腾讯 API
need_tencent = [c for c in codes if c not in db_prices]
if need_tencent:
qs = " ".join(
f"hk{c}" if len(c) == 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 need_tencent
)
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")
# 优先 DB 价格,再补腾讯
for stock in stocks:
code = stock.get("code", "")
if code in db_prices:
if not stock.get("price"):
stock["price"] = db_prices[code][0]
elif need_tencent and code in need_tencent:
prefix = "hk" if len(code) == 5 else "sz" if code.startswith(("0","3")) else "sh" if code.startswith("6") else "hk"
m = re.search(rf'{prefix}{code}="([^"]+)"', qt_text)
if m:
fields = m.group(1).split('~')
if not stock.get("name"):
stock["name"] = fields[1]
if not stock.get("price"):
stock["price"] = fields[3]
except:
pass # 行情获取失败不影响主流程
# 使用 trade_capture 协议写入
from trade_capture import execute_trade, validate_trade
import sqlite3 as _sq
_db = _sq.connect('/home/hmo/web-dashboard/data/mofin.db', timeout=30)
_cur = {}
for _r in _db.execute("SELECT code, shares, cost FROM holdings WHERE is_active=1"):
_cur[_r[0]] = {"shares": _r[1], "cost": _r[2]}
_db.close()
_results = []
for s in stocks:
_t = {"action": "买入" if int(s.get("shares", 0)) > 0 else "卖出",
"code": s.get("code", ""), "name": s.get("name", ""),
"shares": abs(int(s.get("shares", 0))), "price": float(s.get("price", 0))}
_e = validate_trade(_t, _cur)
if _e:
_results.append({"code": _t["code"], "ok": False, "errors": _e})
else:
_r = execute_trade(_t)
_results.append({"code": _t["code"], "ok": _r.get("ok"), "new_shares": _r.get("new_shares")})
try:
from clean_watchlist import main as _cwl
_cwl()
except Exception:
pass
return jsonify({"results": _results, "type": doc_type})
# 以下为旧逻辑(保留fallback)
# 更新对应数据文件
if doc_type == "portfolio":
existing = read_portfolio()
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:
# Use unified formula (includes frozen_cash)
from mo_models import calc_total_assets
existing["total_assets"] = calc_total_assets(existing)
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_portfolio(existing)
msg = f"更新了 {len(stocks)} 只持仓股"
elif doc_type == "watchlist":
existing = read_watchlist()
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_watchlist(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 = read_portfolio()
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 = read_watchlist()
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_portfolio(pf)
_save_watchlist(wl)
return jsonify({
"status": "ok",
"updated": updated,
"source": source,
"timestamp": datetime.now().isoformat(),
})
# ── Dashboard 管理门户 ──────────────────────────────
@app.route("/dashboard")
def dashboard_page():
return send_from_directory(str(Path(__file__).parent / "templates"), "dashboard.html")
@app.route("/api/health")
def api_health():
return jsonify({"status": "ok", "uptime": int(time.time() - START_TIME)})
@app.route("/api/services")
def api_services():
result = []
for svc in DASH_SERVICES:
ok = _check_svc(svc)
result.append({
"name": svc["name"], "label": svc["label"],
"port": svc["port"], "type": svc["type"], "layer": svc["layer"],
"critical": svc["critical"], "health": {"ok": ok},
})
ok_count = sum(1 for s in result if s["health"]["ok"])
return jsonify({"services": result, "summary": {"ok": ok_count, "total": len(result)}})
@app.route("/api/expected")
def api_expected():
expected = [{
"name": s["name"], "label": s["label"], "port": s["port"],
"expected": "running", "critical": s["critical"], "layer": s["layer"],
"check": f"{s['type']}:{s['port']}" if s["port"] else s["type"],
} for s in DASH_SERVICES]
actual = {}
for svc in DASH_SERVICES:
actual[svc["name"]] = "running" if _check_svc(svc) else "stopped"
return jsonify({"expected": expected, "actual": actual})
@app.route("/api/monitor")
def api_monitor():
tasks = []
tier1 = {"summary": {"ok": 0, "total": 0}, "services": []}
tier2 = {"summary": {"ok": 0, "total": 0}, "services": []}
t1_path = GATEWAY_TEMP / "last_health_check.json"
if t1_path.exists():
try:
with open(t1_path, encoding="utf-8") as f:
tier1 = json.load(f)
tasks.append({"name": "agents-health-check", "status": "cron_ok"})
except Exception:
tasks.append({"name": "agents-health-check", "status": "error"})
else:
tasks.append({"name": "agents-health-check", "status": "not_deployed"})
t2_path = GATEWAY_TEMP / "last_daily_health.json"
if t2_path.exists():
try:
with open(t2_path, encoding="utf-8") as f:
tier2 = json.load(f)
tasks.append({"name": "agents-daily-health", "status": "cron_ok"})
except Exception:
tasks.append({"name": "agents-daily-health", "status": "error"})
else:
tasks.append({"name": "agents-daily-health", "status": "not_deployed"})
tasks.append({"name": "dashboard", "status": "running"})
return jsonify({
"tasks": tasks, "tier1": tier1, "tier2": tier2,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
@app.route("/api/module-spec/<module>")
def api_module_spec(module):
spec_path = SPECS_DIR / f"{module.replace('..', '').replace('/', '').replace(chr(92), '')}.json"
if spec_path.exists():
try:
with open(spec_path, encoding="utf-8") as f:
return jsonify(json.load(f))
except Exception as e:
return jsonify({"error": str(e)}), 500
return jsonify({"error": f"Module '{module}' not found"}), 404
# ── XMPP 通信监控 API ─────────────────────────────────
@app.route("/api/xmpp/messages")
def api_xmpp_messages():
"""查询 XMPP 消息日志"""
since = request.args.get("since", "")
agent = request.args.get("agent", "")
status = request.args.get("status", "")
limit = int(request.args.get("limit", 50))
try:
from xmpp_logger import query
msgs = query(since=since or None, agent=agent or None, status=status or None, limit=limit)
return jsonify({"messages": msgs, "total": len(msgs)})
except ImportError:
return jsonify({"messages": [], "total": 0})
@app.route("/api/xmpp/health")
def api_xmpp_health():
"""XMPP 通道健康检查"""
try:
from xmpp_logger import health as xmpp_health
h = xmpp_health()
# 补充 ejabberd Docker 状态
import subprocess
r = subprocess.run(["docker", "ps", "--filter", "name=ejabberd", "--format", "{{.Status}}"],
capture_output=True, timeout=5, text=True)
h["ejabberd"] = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else "not_found"
return jsonify(h)
except ImportError:
return jsonify({"status": "no_logger", "last_message_age_sec": -1, "error_rate_1h": 0, "ejabberd": "unknown"})
@app.route("/api/xmpp/stats")
def api_xmpp_stats():
"""XMPP 消息统计"""
try:
from xmpp_logger import stats as xmpp_stats
return jsonify(xmpp_stats())
except ImportError:
return jsonify({"today": {"sent": 0, "failed": 0, "latency_avg": 0}, "week": {"sent": 0, "failed": 0}})
@app.route("/api/xmpp/autoheal", methods=["GET", "POST"])
def api_xmpp_autoheal():
"""自愈:检测异常并自动修复。GET=查看状态, POST=执行修复"""
try:
from xmpp_logger import auto_heal
if request.method == "POST":
result = auto_heal()
return jsonify(result)
else:
return jsonify({"usage": "POST to trigger auto-heal"})
except ImportError:
return jsonify({"error": "xmpp_logger not available"}), 500
@app.route("/api/xmpp/keys")
def api_xmpp_keys():
"""API Key 可用性:从 AgentsMeeting 获取并选择最佳 key"""
try:
from xmpp_logger import best_key
bk = best_key()
return jsonify({"best_key": bk} if bk else {"error": "no keys available"})
except ImportError:
return jsonify({"error": "xmpp_logger not available"}), 500
# ── 开发原则 Tab 端点(仿 AgentsMeeting: G规范/K测试/H需求)──
DOCS_DIR = Path(__file__).resolve().parent / "docs"
HEALTH_REPORT = Path(__file__).resolve().parent / "gateway" / "temp" / "last_health_check.json"
@app.route("/api/spec")
def api_spec():
"""G 规范:docs/dev-spec.md 内容"""
f = DOCS_DIR / "dev-spec.md"
if not f.exists():
return jsonify({"ok": False, "error": "dev-spec.md not found"})
return jsonify({"ok": True, "content": f.read_text(encoding="utf-8")})
@app.route("/api/spec/history")
def api_spec_history():
"""dev-spec.md 的 git 历史"""
import subprocess
try:
r = subprocess.run(
["git", "log", "--oneline", "-20", "--", "docs/dev-spec.md"],
capture_output=True, timeout=10, text=True,
cwd=str(Path(__file__).resolve().parent))
lines = [l for l in r.stdout.splitlines() if l.strip()]
return jsonify({"ok": True, "log": lines, "count": len(lines)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)[:100]})
@app.route("/api/tests")
def api_tests():
"""K 测试:agents_health_check 服务检查结果,渲染为 pass/fail 测试报告"""
if not HEALTH_REPORT.exists():
return jsonify({"ok": False, "error": "health report not found (cron not run yet)"})
try:
rep = json.loads(HEALTH_REPORT.read_text(encoding="utf-8"))
except Exception as e:
return jsonify({"ok": False, "error": str(e)[:100]})
tests = []
for svc in rep.get("services", []):
ok = svc.get("health", {}).get("ok", False)
tests.append({
"name": f"{svc.get('label', svc.get('name'))} ({svc.get('name')})",
"ok": ok,
"expected": False,
"detail": svc.get("detail", ""),
})
passed = sum(1 for t in tests if t["ok"])
return jsonify({
"ok": True,
"tests": tests,
"summary": {"total": len(tests), "passed": passed, "failed": len(tests) - passed},
"time": rep.get("generated_at", ""),
})
@app.route("/api/prd")
def api_prd():
"""H 需求:docs/prd.md(不存在则返回未建立)"""
f = DOCS_DIR / "prd.md"
if not f.exists():
return jsonify({"ok": False, "error": "prd.md 尚未建立"})
return jsonify({"ok": True, "content": f.read_text(encoding="utf-8")})
# 注册提示词管理路由
register_routes(app)
@app.route("/api/research/log")
def api_research_log():
"""AB路线每日研究日志(2026-08-18"""
try:
_c = sqlite3.connect(str(DATA_DIR / "mofin.db"), timeout=10)
_c.execute("PRAGMA busy_timeout=30000")
rows = _c.execute(
"SELECT log_date, market, weak_regime, finding, experiment, result, "
"produced_strategy, produced_verified FROM strategy_research_log "
"ORDER BY log_date DESC LIMIT 50").fetchall()
_c.close()
log = [{"log_date": r[0], "market": r[1], "weak_regime": r[2], "finding": r[3],
"experiment": r[4], "result": r[5], "produced_strategy": r[6],
"produced_verified": r[7]} for r in rows]
return jsonify({"log": log})
except Exception as e:
return jsonify({"log": [], "error": str(e)})
@app.route("/api/evolution/merge_b_group", methods=["POST"])
def api_evolution_merge_b_group():
"""AB融合:B组verified候选注册为策略版本(2026-08-16 方向二闭环)"""
import sys as _sy
_sy.path.insert(0, "/home/hmo/MoFin/evolution")
try:
from merge_b_group import merge, get_verified
data = request.get_json(silent=True) or {}
version = data.get("version")
res = merge(version)
return jsonify(res)
except Exception as e:
return jsonify({"error": str(e)}), 500
# ── 📚 文档 Tab 端点(2026-08-11 新增,开源项目式文档体系)──
@app.route("/api/docs/index")
def api_docs_index():
"""文档目录树:扫描 docs/ 返回分类结构(README/VERSIONS/INDEX + 按主题)"""
try:
if not DOCS_DIR.exists():
return jsonify({"ok": False, "error": "docs/ not found"})
index = []
# 核心文档(置顶)
core = ["README.md", "VERSIONS.md", "INDEX.md"]
for f in core:
p = DOCS_DIR / f
if p.exists():
index.append({"path": f, "title": f.replace(".md", ""), "category": "📖 核心", "core": True})
# 按主题分类
cats = {
"运维参考": ["QUICKSTART", "DEPLOY", "DASHBOARD", "HEALTH-PIPELINE", "doc-audit", "system-audit",
"operations-index", "scheduler-mechanism"],
"架构治理": ["data-flow-map", "cleansweep", "architecture-fix"],
"策略研究": ["strategy_research", "predictive_oversold", "deployment-plan", "cron-", "research/"],
"系统机制": ["dev-spec", "DEVELOPMENT_STANDARDS", "SELF_GROWTH", "lifecycle", "strategy-review", "morning-health", "zhiwei-ops", "portfolio-data-model"],
"决策记录": ["decisions/"],
}
for md in sorted(DOCS_DIR.rglob("*.md")):
rel = str(md.relative_to(DOCS_DIR))
if rel in core or "archive" in rel or "backup" in rel:
continue
cat = "📚 其他"
for cname, kws in cats.items():
if any(kw in rel for kw in kws):
cat = cname
break
index.append({"path": rel, "title": rel.replace(".md", "").replace("/", " / "), "category": cat})
return jsonify({"ok": True, "index": index, "count": len(index)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)[:100]})
@app.route("/api/docs/versions")
def api_docs_versions():
"""版本变更列表:解析 VERSIONS.md 的每个变更条目"""
try:
f = DOCS_DIR / "VERSIONS.md"
if not f.exists():
return jsonify({"ok": False, "error": "VERSIONS.md not found"})
content = f.read_text(encoding="utf-8")
versions = []
# 解析 "## 2026-08-11 — 标题" 格式
import re
parts = re.split(r"^##\s+", content, flags=re.M)
for p in parts:
if not p.strip() or p.startswith("阅读指南") or p.startswith("维护说明"):
continue
lines = p.strip().split("\n", 1)
title = lines[0].strip()
body = lines[1] if len(lines) > 1 else ""
versions.append({"title": title, "body": body[:4000]})
return jsonify({"ok": True, "versions": versions, "count": len(versions)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)[:100]})
@app.route("/api/docs/read")
def api_docs_read():
"""读取指定文档内容(markdown"""
from flask import request
path = request.args.get("path", "")
if not path or ".." in path or path.startswith("/"):
return jsonify({"ok": False, "error": "invalid path"})
f = DOCS_DIR / path
if not f.exists() or not f.suffix == ".md":
return jsonify({"ok": False, "error": "doc not found"})
return jsonify({"ok": True, "content": f.read_text(encoding="utf-8")})
# ── 策略评估 API ────────────────────────────────────────
@app.route("/api/research/effectiveness")
def api_research_effectiveness():
"""策略到期评估结果查询
GET /api/research/effectiveness → 全部评估记录
GET /api/research/effectiveness?code=600262 → 指定股票
GET /api/research/effectiveness?source=v_next → 指定策略来源
"""
import sqlite3
code = request.args.get("code", "")
source = request.args.get("source", "")
db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=30)
db.row_factory = sqlite3.Row
query = "SELECT * FROM strategy_effectiveness WHERE 1=1"
params = []
if code:
query += " AND code=?"
params.append(code)
if source:
query += " AND strategy_source=?"
params.append(source)
query += " ORDER BY created_at DESC LIMIT 100"
rows = db.execute(query, params).fetchall()
db.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/research/effectiveness/summary")
def api_research_effectiveness_summary():
"""策略评估汇总:按策略来源分组统计"""
import sqlite3
db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=30)
db.row_factory = sqlite3.Row
summary = db.execute("""
SELECT strategy_source,
COUNT(*) as total,
SUM(CASE WHEN buy_zone_accuracy='effective' THEN 1 ELSE 0 END) as buy_effective,
SUM(CASE WHEN stop_loss_accuracy='effective' THEN 1 ELSE 0 END) as sl_effective,
SUM(CASE WHEN take_profit_accuracy='effective' THEN 1 ELSE 0 END) as tp_effective,
AVG(CASE WHEN time_accuracy='on_time' THEN 1.0 WHEN time_accuracy='slightly_late' THEN 0.7 ELSE 0.3 END) as time_score
FROM strategy_effectiveness
GROUP BY strategy_source
ORDER BY total DESC
""").fetchall()
db.close()
return jsonify([dict(r) for r in summary])
@app.route("/api/research/recommendation_log")
def api_research_recommendation_log():
"""推荐历史查询"""
import sqlite3
code = request.args.get("code", "")
limit = int(request.args.get("limit", "50"))
db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=30)
db.row_factory = sqlite3.Row
query = "SELECT * FROM recommendation_log WHERE 1=1"
params = []
if code:
query += " AND code=?"
params.append(code)
query += f" ORDER BY recommend_time DESC LIMIT {limit}"
rows = db.execute(query, params).fetchall()
db.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/research/execution_log")
def api_research_execution_log():
"""执行历史查询"""
import sqlite3
code = request.args.get("code", "")
limit = int(request.args.get("limit", "50"))
db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=30)
db.row_factory = sqlite3.Row
query = "SELECT * FROM execution_log WHERE 1=1"
params = []
if code:
query += " AND code=?"
params.append(code)
query += f" ORDER BY execute_time DESC LIMIT {limit}"
rows = db.execute(query, params).fetchall()
db.close()
return jsonify([dict(r) for r in rows])
# ── Broadcast System API ──
@app.route("/api/broadcast/recent")
def api_broadcast_recent():
from broadcast import get_recent
hours = int(request.args.get("hours", "72"))
category = request.args.get("category")
limit = int(request.args.get("limit", "200"))
return jsonify(get_recent(hours=hours, category=category, limit=limit))
@app.route("/api/broadcast/search")
def api_broadcast_search():
from broadcast import search_history
start = request.args.get("start")
end = request.args.get("end")
keyword = request.args.get("keyword")
category = request.args.get("category")
limit = int(request.args.get("limit", "100"))
return jsonify(search_history(start_date=start, end_date=end, keyword=keyword, category=category, limit=limit))
@app.route("/api/broadcast/archive", methods=["POST"])
def api_broadcast_archive():
from broadcast import archive_old
days = int(request.args.get("days", "7"))
archive_old(days=days)
return jsonify({"ok": True})
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)