Files
MoFin/server.py
T

2228 lines
88 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")
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_research 对应周期 trades 现场温区归因(带缓存)"""
import sqlite3 as _sq
import math as _mth
from collections import defaultdict as _dd
from flask import request as _req
# 缓存键
import time as _tt
_t0 = _tt.time()
_cache_key = None
try:
_cc = _sq.connect(str(DATA_DIR / "mofin.db"), timeout=5)
_ver_s = _cc.execute("SELECT MAX(created_at) FROM strategy_research").fetchone()[0]
_cc.close()
_cache_key = (pt or "2y") + "|" + str(_ver_s)
except Exception as _e:
print(f"[REGIME_CACHE] key_err: {_e}", flush=True)
_cache_key = None
_slots = _regime_winrates_cache.get("slots") or {}
_slot = _slots.get(pt or "2y")
if _cache_key and _slot and _slot[0] == _cache_key:
print(f"[REGIME_CACHE] HIT pt={pt} cost={round(_tt.time()-_t0,3)}s", flush=True)
return _slot[1]
print(f"[REGIME_CACHE] MISS pt={pt} key={_cache_key} slots={list(_slots.keys())}", flush=True)
_pt_chain = {'1m': '1y', '6m': '1y', '1y': '1y', '2y': '2y', '5y': '5y', '10y': '10y'}
_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")
_row_map = {}
for _r in _c.execute(
"SELECT version, market, period_tag, results_json FROM strategy_research "
"ORDER BY version, market, "
"CASE period_tag WHEN '1y' THEN 1 WHEN '2y' THEN 2 WHEN '5y' THEN 3 WHEN '10y' THEN 4 ELSE 5 END"
).fetchall():
_k = (_r[0], _r[1])
if _k not in _row_map:
_row_map[_k] = {}
_row_map[_k][_r[2]] = _r[3]
_rmap_a = dict(_c.execute("SELECT date, regime FROM market_regime WHERE market='a'").fetchall())
_rmap_hk = dict(_c.execute("SELECT date, regime FROM market_regime WHERE market='hk'").fetchall())
_c.close()
def _calc_extra(_trades):
if not _trades:
return {}
_profits = [t.get("profit_pct", 0) for t in _trades]
_wins = [p for p in _profits if p > 0]
_losses = [p for p in _profits if p <= 0]
_wr = len(_wins) / len(_profits) * 100 if _profits else 0
_avg = sum(_profits) / len(_profits) if _profits else 0
_avg_w = sum(_wins) / len(_wins) if _wins else 0
_avg_l = abs(sum(_losses) / len(_losses)) if _losses else 1
_pf = _avg_w / _avg_l if _avg_l > 0 else 0
_mr = _avg / 100
_std = _mth.sqrt(sum((p / 100 - _mr) ** 2 for p in _profits) / (len(_profits) - 1)) if len(_profits) > 1 else 0
_sh = _mr / _std * _mth.sqrt(252) if _std > 0 else 0
_holds = [t.get("hold_days", 0) for t in _trades if t.get("hold_days")]
_ah = sum(_holds) / len(_holds) if _holds else 0
return {"win_rate": round(_wr, 1), "avg_pnl": round(_avg, 2),
"avg_hold_days": round(_ah, 1), "sharpe_ratio": round(_sh, 2),
"profit_factor": round(_pf, 2)}
def _portfolio_sim(_trades, _cap=1000000, _slots=10):
if not _trades:
return {}
try:
from strategy_lab import portfolio_sim
return portfolio_sim(_trades, capital=_cap, max_positions=_slots, cost=True)
except Exception:
return {}
for (_ver, _mkt), _periods in _row_map.items():
_js = None
_cand = None
for _p in [_pt_use, '5y', '10y']:
if _p in _periods and _periods[_p]:
_cand = _periods[_p]
break
if not _cand:
continue
try:
_trades = json.loads(_cand).get("trades", [])
except Exception:
continue
if not _trades:
continue
_rmap = _rmap_a if _mkt != 'hk' else _rmap_hk
_by_regime = _dd(list)
for _t in _trades:
_ed = _t.get("entry_date", "")
if _ed in _rmap:
_by_regime[_rmap[_ed]].append(_t)
for _reg, _reg_trades in _by_regime.items():
if len(_reg_trades) < 2:
continue
_extra = _calc_extra(_reg_trades)
_sim = _portfolio_sim(_reg_trades)
if not _sim:
continue
_wr_v = _extra.get("win_rate")
_cagr_v = _sim.get("cagr_pct")
_ret_v = _sim.get("total_return_pct")
_dd_v = _sim.get("portfolio_max_dd_pct")
_cf_v = _sim.get("capital_final")
_pt_v = _sim.get("positions_taken")
_sh_v = _extra.get("sharpe_ratio")
_pf_v = _extra.get("profit_factor")
_regime_winrates.setdefault(_ver, {})[_reg] = {
"trades": len(_reg_trades),
"win_rate": _wr_v, "avg_pnl": _extra.get("avg_pnl"),
"avg_hold_days": _extra.get("avg_hold_days"),
"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, len(_reg_trades)),
}
except Exception:
pass
if _cache_key:
try:
(_regime_winrates_cache.setdefault("slots", {}))[pt or "2y"] = (_cache_key, _regime_winrates)
except Exception:
pass
print(f"[REGIME_CACHE] DONE pt={pt} cost={round(_tt.time()-_t0,1)}s strategies={len(_regime_winrates)}", flush=True)
return _regime_winrates
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():
return send_from_directory(app.static_folder, "index.html")
@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):
"""温区级普适近似:温区内信号月份≈trades/温区月均笔数,温区总月份占比(避免逐笔遍历性能问题)"""
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
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-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())
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-15 证伪数据驱动:从 strategy_research.deprecated 读(替代前端硬编码)
s['deprecated'] = _deprecated.get(s['version'])
# 2026-08-15 三状态(派生,不存储):激活/可用未激活/不可用
if s['version'] in _active_set:
s['state'] = 'active'
s['state_reason'] = '当前温区路由激活'
elif s.get('deprecated'):
s['state'] = 'unavailable'
s['state_reason'] = '已证伪: ' + str(s['deprecated'])[:80]
elif s['version'] in _candidate_pool:
s['state'] = 'available'
s['state_reason'] = '候选池内,当前温区未激活(温区切换即可激活)'
else:
s['state'] = 'unavailable'
s['state_reason'] = '不在候选池(历史版本,路由不会选中)'
# 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, {})
_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/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():
"""小果扫描统计"""
try:
from mofin_db import get_conn
conn = get_conn()
total = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker").fetchone()[0]
found = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker WHERE found_count>0").fetchone()[0]
recent = conn.execute("""
SELECT code, name, last_scanned_at, found_count
FROM xiaoguo_scan_tracker
ORDER BY last_scanned_at DESC LIMIT 20
""").fetchall()
source_count = conn.execute("""
SELECT source, COUNT(*) as cnt FROM signal_news
WHERE datetime(created_at) > datetime('now', '-1 day')
GROUP BY source
""").fetchall()
conn.close()
return jsonify({
"total_scanned": total,
"found_signals": found,
"recent": [dict(r) for r in recent],
"source_today": {r["source"]: r["cnt"] for r in source_count}
})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ── 数据写入API ──
@app.route("/api/update/portfolio", methods=["POST"])
def update_portfolio():
data = request.get_json(force=True)
_save_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 ──
@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 = read_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/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 # 行情获取失败不影响主流程
# 更新对应数据文件
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/evolution/dashboard")
def api_evolution_dashboard():
"""进化模块 Dashboard"""
try:
sys.path.insert(0, '/home/hmo/MoFin/evolution')
from evolution_api import get_evolution_dashboard
return jsonify(get_evolution_dashboard())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route("/api/evolution/health_trend")
def api_evolution_health_trend():
"""健康度趋势"""
try:
version = request.args.get('version', 'v_next4')
days = int(request.args.get('days', 30))
sys.path.insert(0, '/home/hmo/MoFin/evolution')
from evolution_api import get_health_trend
return jsonify({'trend': get_health_trend(version, days)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route("/api/evolution/combo")
def api_evolution_combo():
"""组合方案 Dashboard (v_next4+v_mr regime分工 + v_combo回测)"""
try:
sys.path.insert(0, '/home/hmo/MoFin/evolution')
from evolution_api import get_combo_dashboard
return jsonify(get_combo_dashboard())
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"],
"策略研究": ["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")})
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)