2536 lines
104 KiB
Python
2536 lines
104 KiB
Python
#!/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_period(2026-08-15 数据加工层)
|
||
数据由 regime_perf_by_period.py 每日盘后预计算(含线性年化),server 只读表,毫秒级。
|
||
无对应周期记录时回退到最近更长周期(1m/6m→1y,2y→2y,5y→5y,10y→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
|
||
|
||
|
||
def _get_recent_error_alerts(hours=24, limit=10):
|
||
"""从 broadcast_messages 读取24小时内的 system_error 类消息作为异常浮窗数据源。
|
||
统一消息源:broadcast 是唯一消息源,异常浮窗=其中的 system_error 类展示。"""
|
||
import sqlite3 as _sq
|
||
from datetime import datetime, timedelta
|
||
try:
|
||
conn = _sq.connect("/home/hmo/MoFin/data/mofin.db", timeout=30)
|
||
conn.row_factory = _sq.Row
|
||
since = (datetime.now() - timedelta(hours=hours)).isoformat()
|
||
rows = conn.execute(
|
||
"SELECT ts, title, content, source, category FROM broadcast_messages "
|
||
"WHERE category='system_error' AND ts >= ? ORDER BY ts DESC LIMIT ?",
|
||
(since, limit)).fetchall()
|
||
conn.close()
|
||
result = []
|
||
for r in rows:
|
||
ts = r["ts"] or ""
|
||
result.append({
|
||
"ts": 0,
|
||
"ts_str": ts[:19].replace("T", " "),
|
||
"level": "error",
|
||
"source": r["source"] or "system",
|
||
"title": r["title"] or "",
|
||
"detail": r["content"] or "",
|
||
"code": "",
|
||
})
|
||
return result
|
||
except Exception:
|
||
return _load_json(DATA_DIR / "alerts.json", [])[:limit]
|
||
|
||
|
||
@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-15:STRATEGY_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,
|
||
# 异常浮窗数据源:broadcast_messages 的 system_error 类(24小时内)
|
||
"alerts": _get_recent_error_alerts(24),
|
||
# 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():
|
||
"""市场观察(2026-08-22 重构:板块快照 + A股/港股温区 regime)"""
|
||
try:
|
||
from mofin_db import get_conn, query_latest_market
|
||
conn = get_conn()
|
||
data = query_latest_market(conn)
|
||
# 当前市场温区(每市场取最新一条 market_regime)
|
||
try:
|
||
rows = conn.execute("""
|
||
SELECT mr.market, mr.date, mr.regime, mr.close,
|
||
mr.above_ma20, mr.ma20_slope, mr.roc, mr.adx
|
||
FROM market_regime mr
|
||
JOIN (SELECT market, MAX(date) md FROM market_regime GROUP BY market) t
|
||
ON mr.market = t.market AND mr.date = t.md
|
||
ORDER BY mr.market
|
||
""").fetchall()
|
||
data["regimes"] = [dict(r) for r in rows]
|
||
except Exception:
|
||
data["regimes"] = []
|
||
conn.close()
|
||
if data and (data.get("sectors") or data.get("regimes")):
|
||
return jsonify(data)
|
||
except Exception:
|
||
pass
|
||
return jsonify(_load_json(DATA_DIR / "market.json", {}))
|
||
|
||
|
||
@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
|
||
|
||
# 尝试小果OCR(GLM-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)
|
||
|
||
# OCR:chip_sim+eng,PSM 6(统一文本块)
|
||
text = pytesseract.image_to_string(
|
||
img,
|
||
lang="chi_sim+eng",
|
||
config="--psm 6 --oem 3",
|
||
)
|
||
return text.strip()
|
||
|
||
|
||
ANALYZE_PROMPT = """你是股票持仓数据分析助手。以下是用户上传的持仓/自选截图经过OCR提取的文字,请从中提取所有股票信息。
|
||
|
||
判断这是「持仓截图」还是「自选截图」:
|
||
- 持仓截图:每支股票有"证券数量"(持股数)、成本价、盈亏
|
||
- 自选截图:只有股票列表和价格,没有持股数/成本
|
||
|
||
股票代码格式:
|
||
- A股:6位数字(如 600519, 000858, 300750)
|
||
- 港股:纯数字代码(如 0700, 3690, 1211),不带HK前缀
|
||
|
||
⚠️ 重要:截图顶部通常有汇总数据,如总资产、股票市值、可用资金、当日盈亏等。
|
||
如果OCR文字中有这些汇总数字,请一并提取到JSON的summary字段中。
|
||
不要自己计算汇总值,直接从OCR原文中提取。
|
||
|
||
请严格按照以下JSON格式回复,只输出JSON:
|
||
|
||
```json
|
||
{
|
||
"type": "portfolio" 或 "watchlist",
|
||
"summary": {
|
||
"total_assets": "总资产数字(可选,从截图中提取)",
|
||
"stock_value": "股票市值/持仓市值数字(可选,从截图中提取)",
|
||
"cash": "可用资金/现金数字(可选,从截图中提取)",
|
||
"day_pnl": "当日盈亏金额(可选,从截图中提取)"
|
||
},
|
||
"stocks": [
|
||
{
|
||
"code": "股票代码",
|
||
"name": "股票名称(中文)",
|
||
"price": "现价(数字)",
|
||
"shares": "持股数量(数字,持仓截图才有)",
|
||
"cost": "成本价(数字,持仓截图才有)",
|
||
"pnl": "盈亏百分比如+15.1%(持仓截图才有)",
|
||
"position_pct": "仓位占比数字如12.5(可选)"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
OCR原文:
|
||
"""
|
||
|
||
|
||
@app.route("/api/upload/analyze", methods=["POST"])
|
||
def upload_analyze():
|
||
"""接收图片,OCR提取文字 → LLM解析结构化数据"""
|
||
if "image" not in request.files:
|
||
return jsonify({"error": "请上传图片"}), 400
|
||
|
||
f = request.files["image"]
|
||
if not f.filename:
|
||
return jsonify({"error": "空文件"}), 400
|
||
|
||
# 保存到临时目录
|
||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
ext = Path(f.filename).suffix or ".png"
|
||
save_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{ext}"
|
||
f.save(str(save_path))
|
||
|
||
try:
|
||
# 第一步:OCR提取文字
|
||
raw_text = _ocr_image(str(save_path))
|
||
if not raw_text:
|
||
return jsonify({"error": "OCR未识别到文字,请确认图片清晰"}), 400
|
||
except Exception as e:
|
||
os.unlink(str(save_path))
|
||
return jsonify({"error": f"OCR失败: {e}"}), 500
|
||
|
||
# 第二步:LLM解析结构化数据(走文本API,不走视觉)
|
||
llm_text = _llm_parse(raw_text, ANALYZE_PROMPT)
|
||
|
||
os.unlink(str(save_path))
|
||
|
||
# 从LLM回复中提取JSON
|
||
json_match = re.search(r"```(?:json)?\s*({.*?})\s*```", llm_text, re.DOTALL)
|
||
if json_match:
|
||
try:
|
||
parsed = json.loads(json_match.group(1))
|
||
except json.JSONDecodeError:
|
||
return jsonify({"error": f"LLM解析JSON失败: {llm_text[:500]}"}), 500
|
||
else:
|
||
# 尝试直接找JSON(没被代码块包裹)
|
||
try:
|
||
parsed = json.loads(llm_text)
|
||
except json.JSONDecodeError:
|
||
return jsonify({"error": f"未提取到结构化数据: {raw_text[:300]}...\n\nLLM回复: {llm_text[:500]}"}), 500
|
||
|
||
return jsonify(parsed)
|
||
|
||
|
||
def _llm_parse(text, prompt_template):
|
||
"""发送OCR文本到Hermes LLM解析,返回JSON字符串"""
|
||
payload = json.dumps({
|
||
"model": "hermes-agent",
|
||
"messages": [
|
||
{"role": "system", "content": "你是一个数据提取助手。从OCR文字中提取结构化JSON数据。"},
|
||
{"role": "user", "content": prompt_template + "\n" + text},
|
||
],
|
||
"max_tokens": 4096,
|
||
}).encode()
|
||
|
||
req = urllib.request.Request(GATEWAY, data=payload, method="POST")
|
||
req.add_header("Content-Type", "application/json")
|
||
req.add_header("Authorization", f"Bearer {API_KEY}")
|
||
req.add_header("X-Hermes-Session-Id", "upload-ocr-parse")
|
||
|
||
try:
|
||
resp = urllib.request.urlopen(req, timeout=120)
|
||
data = json.loads(resp.read())
|
||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||
except Exception as e:
|
||
return f"ERROR: {e}"
|
||
|
||
|
||
@app.route("/api/upload/confirm", methods=["POST"])
|
||
def upload_confirm():
|
||
"""确认解析结果,更新数据文件"""
|
||
data = request.get_json(force=True)
|
||
stocks = data.get("stocks", [])
|
||
doc_type = data.get("type", "portfolio")
|
||
|
||
# 尝试获取实时行情补充数据
|
||
try:
|
||
codes = [s["code"] for s in stocks if s.get("code")]
|
||
if codes:
|
||
# 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})
|
||
|
||
|
||
|
||
@app.route("/api/broadcast/toggle_delivery", methods=["POST"])
|
||
def api_broadcast_toggle_delivery():
|
||
"""切换 cron job 的消息通道(broadcast/xmpp/both)
|
||
payload: {"name": "job名称"} 或 {"script": "脚本名"}
|
||
"""
|
||
import json as _json
|
||
data = request.get_json(force=True) or {}
|
||
jobs_path = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json"
|
||
with open(jobs_path, encoding="utf-8") as f:
|
||
jobs = _json.load(f)
|
||
update = None
|
||
for j in jobs.get("jobs", []):
|
||
if not isinstance(j, dict):
|
||
continue
|
||
match = False
|
||
if data.get("name") and j.get("name") == data.get("name"):
|
||
match = True
|
||
elif data.get("script") and str(j.get("script","")) == data.get("script"):
|
||
match = True
|
||
if match:
|
||
# 循环切换
|
||
cur = j.get("delivery", "broadcast")
|
||
nxt = {"broadcast":"xmpp", "xmpp":"both", "both":"broadcast"}.get(cur, "broadcast")
|
||
j["delivery"] = nxt
|
||
update = {"name": j.get("name"), "script": j.get("script"), "delivery": nxt}
|
||
break
|
||
if update:
|
||
with open(jobs_path, "w", encoding="utf-8") as f:
|
||
_json.dump(jobs, f, ensure_ascii=False, indent=2)
|
||
return jsonify({"ok": True, "job": update})
|
||
return jsonify({"ok": False, "error": "job not found"})
|
||
|
||
|
||
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)
|
||
|