From 63b53f5c5650fe729f0a9e8fdab34e796f16ce96 Mon Sep 17 00:00:00 2001 From: hmo Date: Tue, 28 Jul 2026 22:53:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AD=96=E7=95=A5=E7=A0=94=E7=A9=B6Tab?= =?UTF-8?q?=E5=85=A8=E6=B5=81=E7=A8=8B=E5=9B=9E=E6=B5=8B=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=20+=20API=E7=AB=AF=E7=82=B9=20+=20=E5=89=8D=E7=AB=AF=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=20+=20=E7=9B=98=E4=B8=AD=E5=A4=A7=E8=B7=8C=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyst-knowledge-log.md | 21 + backtest_framework.py | 488 ++++ deploy/profile-scripts/mo_data.py | 316 +++ deploy/profile-scripts/mofin_db.py | 2341 +++++++++++++++++++ scripts/mo_data.py | 316 +++ scripts/mofin_db.py | 2341 +++++++++++++++++++ server.py | 3499 ++++++++++++++-------------- static/index.html | 85 + 8 files changed, 7669 insertions(+), 1738 deletions(-) create mode 100644 backtest_framework.py create mode 100644 deploy/profile-scripts/mo_data.py create mode 100644 deploy/profile-scripts/mofin_db.py create mode 100644 scripts/mo_data.py create mode 100644 scripts/mofin_db.py diff --git a/analyst-knowledge-log.md b/analyst-knowledge-log.md index 7756a285..2cc40ddf 100644 --- a/analyst-knowledge-log.md +++ b/analyst-knowledge-log.md @@ -310,3 +310,24 @@ cron prompt for 系统健康检查-开盘前 (job_id: 37c02f4d7df9) - **总资产**: 896,562 - **仓位**: 73.08% - **截图头部659,354与xls的655,231差异来源**: 截图是13:01价格,xls是23:30价格,中间价格波动导致 + +## 2026-07-28 13:51 盘中系统性大跌应对 — 韩国二次熔断+亚太科技股暴跌 + +**发现了什么:** +今天KOSPI两度触发熔断(一度跌超8%),日经-4%,MSCI亚太较6月高点跌10%,科创50-5.52%/创业板-6.55%。但上证仅-1.35%、恒指-0.34%、比亚迪/腾讯逆势上涨。 + +**关键判断:** +- 定性为板块冲击(B类),非全面系统性。上证/恒指有韧性是关键区分信号 +- 比亚迪+0.51%、腾讯+0.23%逆势上涨验证了A/H股科技脱钩叙事 +- 信号id=1441已处理(processed=1),自愈执行器报警的是状态文件过期导致的重复触发 + +**止损距扫描结果:** +- 中芯国际(688981)现价138.41,跌破旧止损141.21(-2.0%),但策略状态为closed,需发布新策略 +- 丘钛科技(01478)距止损仅1.2%,临界 +- 中国神华(01088)距止损仅1.6%,临界 +- 中际旭创(300308)距止损5.2%,策略已closed + +**萃取知识:** +1. KOSPI熔断+日韩暴跌传导至A股科技股,但上证/恒指未破位=非系统性+板块冲击。判断A股的不可一概而论。 +2. 比亚迪、腾讯在科技暴跌日逆势上涨,说明个股基本面>板块情绪。不能简单用"科技股暴跌"标签覆盖所有科技持仓。 +3. 自愈执行器重复报警的根本原因是state文件expired后未及时更新,导致管道认为这是未处理的新问题。修复措施:更新state文件。 diff --git a/backtest_framework.py b/backtest_framework.py new file mode 100644 index 00000000..841ad7d9 --- /dev/null +++ b/backtest_framework.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +"""MoFin 回测框架 v2 — 从 stock_daily 原始行情计算全部指标,模拟全流程""" +import sqlite3, json, math +from datetime import datetime, timedelta +from collections import defaultdict + +DB_PATH = "/home/hmo/MoFin/data/mofin.db" + +# ── 技术指标计算(纯 Python,不依赖第三方库)── + +def calc_ma(series, n): + """简单移动平均""" + result = [] + for i in range(len(series)): + if i < n - 1: + result.append(None) + else: + result.append(sum(series[i-n+1:i+1]) / n) + return result + +def calc_ema(series, n): + """指数移动平均""" + result = [] + k = 2 / (n + 1) + for i in range(len(series)): + if i == 0: + result.append(series[i]) + else: + result.append(series[i] * k + result[-1] * (1 - k)) + return result + +def calc_rsi(series, n=14): + """RSI""" + deltas = [series[i] - series[i-1] for i in range(1, len(series))] + gains = [d if d > 0 else 0 for d in deltas] + losses = [-d if d < 0 else 0 for d in deltas] + result = [None] * (n + 1) + avg_gain = sum(gains[:n]) / n + avg_loss = sum(losses[:n]) / n + if avg_loss == 0: + result.append(100) + else: + rs = avg_gain / avg_loss + result.append(100 - 100 / (1 + rs)) + for i in range(n, len(gains)): + avg_gain = (avg_gain * (n-1) + gains[i]) / n + avg_loss = (avg_loss * (n-1) + losses[i]) / n + if avg_loss == 0: + result.append(100) + else: + rs = avg_gain / avg_loss + result.append(100 - 100 / (1 + rs)) + # Pad front to match original length + while len(result) < len(series): + result.insert(0, None) + return result[:len(series)] + +def calc_macd(series, fast=12, slow=26, signal=9): + """MACD: 返回 macd_line, signal_line, histogram""" + ema_fast = calc_ema(series, fast) + ema_slow = calc_ema(series, slow) + macd_line = [ema_fast[i] - ema_slow[i] if ema_fast[i] is not None and ema_slow[i] is not None else None for i in range(len(series))] + # Recompute signal line from macd_line (only non-None values) + macd_clean = [v for v in macd_line if v is not None] + if not macd_clean: + return [None]*len(series), [None]*len(series), [None]*len(series) + signal_raw = calc_ema(macd_clean, signal) + signal_line = [None] * (len(series) - len(macd_clean)) + signal_raw + histogram = [macd_line[i] - signal_line[i] if macd_line[i] is not None and signal_line[i] is not None else None for i in range(len(series))] + return macd_line, signal_line, histogram + +def calc_tr(highs, lows, closes): + """True Range""" + tr = [highs[0] - lows[0]] + for i in range(1, len(highs)): + hl = highs[i] - lows[i] + hc = abs(highs[i] - closes[i-1]) + lc = abs(lows[i] - closes[i-1]) + tr.append(max(hl, hc, lc)) + return tr + +def calc_atr(highs, lows, closes, n=14): + """ATR""" + tr = calc_tr(highs, lows, closes) + return calc_ema(tr, n) + +def calc_trend_strength(highs, lows, closes, n=14): + """简易趋势强度(替代 ADX): (DI+ - DI-) / (DI+ + DI-) 归一化""" + tr = calc_tr(highs, lows, closes) + up = [highs[i] - highs[i-1] for i in range(1, len(highs))] + down = [lows[i-1] - lows[i] for i in range(1, len(lows))] + # Directional indicators (raw) + di_plus_raw = [0.0] * len(up) + di_minus_raw = [0.0] * len(up) + for i in range(len(up)): + if up[i] > down[i] and up[i] > 0: + di_plus_raw[i] = up[i] + if down[i] > up[i] and down[i] > 0: + di_minus_raw[i] = down[i] + # Normalize by TR + tr_val = tr[i+1] if (i+1) < len(tr) else tr[-1] + if tr_val > 0: + di_plus_raw[i] = di_plus_raw[i] / tr_val * 100 + di_minus_raw[i] = di_minus_raw[i] / tr_val * 100 + + # SMA of DMI + result = [] + for i in range(len(di_plus_raw)): + if i < n - 1: + result.append(None) + else: + avg_plus = sum(di_plus_raw[i-n+1:i+1]) / n + avg_minus = sum(di_minus_raw[i-n+1:i+1]) / n + if avg_plus + avg_minus > 0: + dx = abs(avg_plus - avg_minus) / (avg_plus + avg_minus) * 100 + else: + dx = 0 + result.append(dx) + # Pad front to match original highs length + return [None] * (len(highs) - len(result)) + result + +def calc_obv(closes, volumes): + """OBV""" + obv = [volumes[0]] + for i in range(1, len(closes)): + if closes[i] > closes[i-1]: + obv.append(obv[-1] + volumes[i]) + elif closes[i] < closes[i-1]: + obv.append(obv[-1] - volumes[i]) + else: + obv.append(obv[-1]) + return obv + +def calc_roc(series, n=10): + """Rate of Change""" + result = [] + for i in range(len(series)): + if i < n: + result.append(None) + else: + result.append((series[i] - series[i-n]) / series[i-n] * 100 if series[i-n] != 0 else 0) + return result + +def prepare_bars(code, start_date, end_date): + """获取原始数据并计算全部指标""" + conn = sqlite3.connect(DB_PATH) + rows = conn.execute(""" + SELECT date, open, close, high, low, volume, amount + FROM stock_daily WHERE code=? AND date>=? AND date<=? + ORDER BY date + """, (code, start_date, end_date)).fetchall() + conn.close() + if not rows or len(rows) < 30: + return None + dates = [r[0] for r in rows] + opens = [r[1] for r in rows] + closes = [r[2] for r in rows] + highs = [r[3] for r in rows] + lows = [r[4] for r in rows] + volumes = [r[5] for r in rows] + + # 计算全部指标 + ma5 = calc_ma(closes, 5) + ma10 = calc_ma(closes, 10) + ma20 = calc_ma(closes, 20) + ma60 = calc_ma(closes, 60) + rsi = calc_rsi(closes) + macd_line, signal_line, macd_hist = calc_macd(closes) + atr = calc_atr(highs, lows, closes) + trend_strength = calc_trend_strength(highs, lows, closes) + obv = calc_obv(closes, volumes) + roc = calc_roc(closes) + + bars = [] + for i in range(len(dates)): + bars.append({ + 'date': dates[i], + 'open': opens[i], + 'close': closes[i], + 'high': highs[i], + 'low': lows[i], + 'volume': volumes[i], + 'ma5': ma5[i] if i < len(ma5) else None, + 'ma10': ma10[i] if i < len(ma10) else None, + 'ma20': ma20[i] if i < len(ma20) else None, + 'ma60': ma60[i] if i < len(ma60) else None, + 'rsi': rsi[i] if i < len(rsi) else None, + 'macd': macd_line[i] if i < len(macd_line) else None, + 'macd_signal': signal_line[i] if i < len(signal_line) else None, + 'macd_hist': macd_hist[i] if i < len(macd_hist) else None, + 'atr': atr[i] if i < len(atr) else None, + 'adx': trend_strength[i] if i < len(trend_strength) else None, + 'obv': obv[i] if i < len(obv) else None, + 'roc': roc[i] if i < len(roc) else None, + }) + return bars + + +def compute_single_score(bars): + """对单个股票计算多因子评分(与 MoFin 现有一致)""" + if not bars or len(bars) < 15: + return None + last = bars[-1] + prev5 = bars[-5] if len(bars) >= 5 else bars[-2] if len(bars) >= 2 else last + prev10 = bars[-10] if len(bars) >= 10 else bars[0] + + def v(val): return val if val is not None else 0 + score = {'trend': 0, 'momentum': 0, 'volume': 0, 'volatility': 0, 'risk': 0} + + # 趋势分 (0-25) + ts = 0 + adx = v(last.get('adx')) + if adx > 25: ts += 10 + elif adx > 20: ts += 5 + mh = v(last.get('macd_hist')) + pmh = v(prev5.get('macd_hist')) + if mh > 0 and mh > pmh: ts += 10 + elif mh > 0: ts += 5 + elif mh < 0 and mh < pmh: ts -= 5 + ma5 = v(last.get('ma5')) + ma10 = v(last.get('ma10')) + ma20 = v(last.get('ma20')) + if ma5 > ma10 > ma20 and ma5 > 0 and ma10 > 0: ts += 5 + elif ma5 < ma10 < ma20 and ma5 > 0 and ma10 > 0: ts -= 5 + score['trend'] = max(0, min(25, ts)) + + # 动量分 (0-20) + ms = 0 + roc = v(last.get('roc')) + if roc > 5: ms += 5 + elif roc > 2: ms += 2 + elif roc < -5: ms -= 5 + elif roc < -2: ms -= 2 + rsi = v(last.get('rsi')) + if 30 <= rsi <= 70: ms += 5 + if 50 < rsi <= 65: ms += 3 + elif rsi > 70: ms -= 3 + elif rsi < 30: ms -= 3 + close = v(last.get('close')) + if ma20 > 0: + pct = (close - ma20) / ma20 * 100 + if -3 <= pct <= 5: ms += 7 + elif pct < -10: ms -= 3 + score['momentum'] = max(0, min(20, ms)) + + # 量能分 (0-20) + vs = 0 + obv_cur = v(last.get('obv')) + obv_prev = v(prev5.get('obv')) + vol_cur = v(last.get('volume')) + vol_prev = v(prev5.get('volume')) + if obv_cur > obv_prev and vol_cur > 0 and vol_prev > 0: + vr = vol_cur / vol_prev + if vr > 1.5: vs += 10 + elif vr > 1.2: vs += 5 + if obv_cur > obv_prev: vs += 5 + if vol_prev > 0 and vol_cur / vol_prev < 0.5: vs -= 5 + score['volume'] = max(0, min(20, vs)) + + # 波动率分 (0-15) + vls = 5 + atr = v(last.get('atr')) + if close > 0 and atr > 0: + atr_pct = atr / close * 100 + if 1.5 <= atr_pct <= 3.5: vls += 5 + elif atr_pct < 1: vls += 3 + elif atr_pct > 5: vls -= 5 + if len(bars) >= 20: + cls = [v(b.get('close')) for b in bars[-20:]] + if min(cls) > 0: + vol_range = (max(cls) - min(cls)) / min(cls) * 100 + if vol_range < 10: vls += 5 + elif vol_range > 30: vls -= 5 + score['volatility'] = max(0, min(15, vls)) + + # 风险分 (0-20) + rs = 10 + if len(bars) >= 10: + low5 = min(bars[-5:], key=lambda x: v(x.get('low'))) + low10 = min(bars[-10:-5], key=lambda x: v(x.get('low'))) if len(bars) >= 10 else low5 + if v(low5.get('close')) < v(low10.get('close')) and v(low5.get('macd_hist')) > v(low10.get('macd_hist')): + rs += 5 + if v(low5.get('close')) > v(low10.get('close')) and v(low5.get('macd_hist')) < v(low10.get('macd_hist')): + rs -= 5 + if ma20 > 0: + dist = (close - ma20) / ma20 * 100 + if dist < 2: rs += 5 + elif dist > 15: rs -= 5 + score['risk'] = max(0, min(20, rs)) + + total = sum(score.values()) + return total, score + + +def compute_kelly(total_score, target_pct, stop_pct): + """半 Kelly 仓位""" + p = min(0.6, total_score / 100) + if stop_pct <= 0: + return 0 + b = target_pct / stop_pct + if b <= 1: + return 0 + kelly = (p * b - (1 - p)) / b + return max(0, min(0.5, kelly)) * 0.5 # 半 Kelly + + +def run_strategy_research(start_date, end_date, capital=1000000): + """主回测入口""" + conn = sqlite3.connect(DB_PATH) + + # 1. 获取所有有历史数据的股票 + stocks = conn.execute(""" + SELECT DISTINCT sd.code, COALESCE(s.name, sd.code) as name + FROM stock_daily sd + LEFT JOIN stocks s ON sd.code = s.code + WHERE sd.date>=? AND sd.date<=? + """, (start_date, end_date)).fetchall() + + results = { + 'period': f"{start_date} ~ {end_date}", + 'capital': capital, + 'total_stocks_screened': len(stocks), + 'scored_stocks': 0, + 'buy_signals': 0, + 'trades': [], + 'summary': {} + } + + trades = [] + code_names = {} + eval_dates = [] # collect evaluation dates + + for code, name in stocks: + code_names[code] = name + bars = prepare_bars(code, start_date, end_date) + if not bars or len(bars) < 20: + continue + + # 2. 滚动评估:每周评分一次 + window_start = 20 # need at least 20 bars for indicators + while window_start < len(bars): + window = bars[:window_start] + score_result = compute_single_score(window) + if score_result is None: + window_start += 5 + continue + total_score, score_components = score_result + results['scored_stocks'] += 1 + + last = window[-1] + close = v_close = last.get('close', 0) or 0 + ma20 = last.get('ma20', 0) or 0 + + # 3. 买入条件:总评分 >= 45 且 momentum >= 8 + buy_signal = total_score >= 45 and score_components['momentum'] >= 8 + + if buy_signal: + results['buy_signals'] += 1 + entry_price = close + atr_val = last.get('atr', 0) or 0 + if atr_val > 0: + target_price = entry_price * 1.10 + stop_price = entry_price - atr_val * 2.0 + else: + target_price = entry_price * 1.10 + stop_price = entry_price * 0.93 + + kelly = compute_kelly(total_score, 0.10, (entry_price - stop_price) / entry_price) + + # 4. 模拟未来走势 + future = bars[window_start:min(window_start+20, len(bars))] + exit_price = None + exit_reason = None + max_high = entry_price + min_low = entry_price + + for fb in future: + fb_high = fb.get('high', 0) or 0 + fb_low = fb.get('low', 0) or 0 + fb_close = fb.get('close', 0) or 0 + max_high = max(max_high, fb_high) + min_low = min(min_low, fb_low) + + if fb_high >= target_price: + exit_price = target_price + exit_reason = 'target' + break + elif fb_low <= stop_price: + exit_price = fb_close + exit_reason = 'stop' + break + + if exit_price is None: + if future: + exit_price = future[-1].get('close', entry_price) + else: + exit_price = entry_price + exit_reason = 'keep' + + hold_days = 0 + if exit_reason and exit_reason != 'keep' and future: + for idx, fb in enumerate(future): + fb_high = fb.get('high', 0) or 0 + fb_low = fb.get('low', 0) or 0 + if (exit_reason == 'target' and fb_high >= target_price) or \ + (exit_reason == 'stop' and fb_low <= stop_price): + hold_days = idx + 1 + break + elif exit_reason == 'keep': + hold_days = len(future) + + profit_pct = (exit_price - entry_price) / entry_price * 100 if entry_price > 0 else 0 + + trades.append({ + 'code': code, + 'name': name, + 'entry_date': last.get('date'), + 'entry_price': round(entry_price, 2), + 'exit_price': round(exit_price, 2), + 'profit_pct': round(profit_pct, 2), + 'exit_reason': exit_reason, + 'hold_days': hold_days, + 'score': total_score, + 'kelly': round(kelly, 3), + 'stop_loss': round(stop_price, 2), + 'target': round(target_price, 2), + 'max_high': round(max_high, 2), + 'min_low': round(min_low, 2) + }) + + window_start += 5 # 每 5 个交易日评估一次 + + conn.close() + + # 5. 汇总 + if trades: + profits = [t['profit_pct'] for t in trades] + wins = [t for t in trades if t['profit_pct'] > 0] + losses = [t for t in trades if t['profit_pct'] <= 0] + + win_rate = len(wins) / len(trades) * 100 if trades else 0 + avg_profit = sum(profits) / len(profits) if profits else 0 + avg_win = sum(t['profit_pct'] for t in wins) / len(wins) if wins else 0 + avg_loss = sum(t['profit_pct'] for t in losses) / len(losses) if losses else 0 + + mean_ret = avg_profit / 100 + if len(profits) > 1: + variance = sum((p/100 - mean_ret)**2 for p in profits) / (len(profits) - 1) + std_ret = math.sqrt(variance) + else: + std_ret = 0 + sharpe = mean_ret / std_ret * math.sqrt(252) if std_ret > 0 else 0 + + # 最大回撤:用实际资本曲线计算 + capital_curve = [capital] + for t in trades: + new_cap = capital_curve[-1] * (1 + t['profit_pct'] / 100) + capital_curve.append(new_cap) + peak_cap = capital + max_dd = 0 + for c in capital_curve: + peak_cap = max(peak_cap, c) + dd = (peak_cap - c) / peak_cap * 100 + max_dd = max(max_dd, dd) + + results['trades'] = sorted(trades, key=lambda x: abs(x['profit_pct']), reverse=True)[:50] + results['summary'] = { + 'total_trades': len(trades), + 'win_rate': round(win_rate, 1), + 'avg_profit_pct': round(avg_profit, 2), + 'avg_win_pct': round(avg_win, 2), + 'avg_loss_pct': round(avg_loss, 2), + 'sharpe_ratio': round(sharpe, 2), + 'max_drawdown_pct': round(max_dd, 2), + 'profit_factor': round(abs(avg_win / avg_loss), 2) if avg_loss != 0 else float('inf'), + 'wins': len(wins), + 'losses': len(losses), + 'capital_end': round(capital * (1 + avg_profit/100), 2) + } + + return results + + +if __name__ == '__main__': + end = datetime.now().strftime('%Y-%m-%d') + start = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d') + r = run_strategy_research(start, end) + print(json.dumps(r, indent=2, ensure_ascii=False)) diff --git a/deploy/profile-scripts/mo_data.py b/deploy/profile-scripts/mo_data.py new file mode 100644 index 00000000..cb168a13 --- /dev/null +++ b/deploy/profile-scripts/mo_data.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +mo_data.py — MoFin 统一数据层(纯 DB) + +所有数据从 SQLite 读取。不做 JSON fallback。 +JSON 文件已弃用,仅保留为历史备份。 + +用法: + from mo_data import read_portfolio, read_decisions, read_watchlist + + pf = read_portfolio() # 返回和 portfolio.json 一样的 dict 结构 + dec = read_decisions() # 返回和 decisions.json 一样的 dict 结构 + wl = read_watchlist() # 返回和 watchlist.json 一样的 dict 结构 +""" + +import sqlite3, json, sys +from datetime import datetime +from pathlib import Path + +DB_PATH = '/home/hmo/MoFin/data/mofin.db' +SCRIPT_DIR = Path('/home/hmo/MoFin/scripts') + + +def _get_db(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + return db + + +# ── portfolio ───────────────────────────────────────────────────── + +def read_portfolio(): + """返回 portfolio.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, shares, cost, price, market_value, " + "change_pct, currency, position_pct " + "FROM holdings WHERE is_active=1" + ).fetchall() + holdings = [] + for r in rows: + h = dict(r) + h['_currency'] = h.get('currency', 'CNY') + holdings.append(h) + + sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() + summary = dict(sum_row) if sum_row else {} + + db.close() + + return { + "holdings": holdings, + "total_assets": summary.get("total_assets", 0), + "total_mv": summary.get("total_mv", 0), + "stock_value": summary.get("stock_value", summary.get("total_mv", 0)), + "cash": summary.get("cash", 0), + "frozen_cash": summary.get("frozen_cash", 0), + "position_pct": summary.get("position_pct", 0), + "currency": summary.get("currency", "CNY"), + "updated_at": summary.get("updated_at", ""), + } + + +# ── decisions ───────────────────────────────────────────────────── + +def _parse_json(val, default): + if val: + try: return json.loads(val) + except: pass + return default + + +def read_decisions(): + """返回 decisions.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, version, price, cost, shares, " + "stop_loss, take_profit, entry_low, entry_high, " + "currency, strategy_type, action, timing_signal, " + "rr_ratio, tech_snapshot, stock_category, sector_context, " + "status, trigger_json, changelog_json, source, reason, " + "created_at, updated_at, " + "avg_price, decision_timestamp, note, quality_check, " + "quality_checked_at, quality_issues_json, position_advice, " + "signal_factors_json, time_horizon, decision_type, tag " + "FROM holding_strategies WHERE status IN ('active','updated') " + "ORDER BY code" + ).fetchall() + + decisions = [] + for r in rows: + d = dict(r) + d['trigger'] = _parse_json(r['trigger_json'], {}) + d['changelog'] = _parse_json(r['changelog_json'], []) + d['quality_issues'] = _parse_json(r['quality_issues_json'], {}) + d['signal_factors'] = _parse_json(r['signal_factors_json'], []) + d['timestamp'] = r['decision_timestamp'] or r['created_at'] or '' + d['type'] = r['decision_type'] or r['strategy_type'] or '持仓策略' + decisions.append(d) + + db.close() + + return { + "decisions": decisions, + "total": len(decisions), + "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } + + +# ── watchlist ───────────────────────────────────────────────────── + +def read_watchlist(): + """返回 watchlist 等价 dict。纯 DB。 + 从 holding_strategies(自选策略)读取,watchlist_stocks 已废弃。""" + db = _get_db() + # 主数据源:holding_strategies 自选策略 + rows = db.execute( + "SELECT code, name, price, entry_low, entry_high, " + "stop_loss, currency, updated_at " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略'" + ).fetchall() + + stocks = [] + seen = set() + for r in rows: + code = str(r["code"]) + if code in seen: + continue + seen.add(code) + stocks.append({ + "code": code, + "name": r["name"] or "", + "price": r["price"] or 0, + "entry_low": r["entry_low"] or 0, + "entry_high": r["entry_high"] or 0, + "stop_loss": r["stop_loss"] or 0, + "currency": r["currency"] or "CNY", + "added_at": r["updated_at"] or "", + "analysis": {}, + }) + + return {"stocks": stocks, "total": len(stocks)} + + db.close() + + return { + "stocks": stocks, + "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } + + +# ── 便捷别名 ─────────────────────────────────────────────────────── + +def read_portfolio_json(): + return read_portfolio() + +def read_decisions_json(): + return read_decisions() + +def read_watchlist_json(): + return read_watchlist() + + +# ── 统一价格获取(唯一入口,禁止各脚本自拉API)── + +def get_price(code, max_age_minutes=5, use_stale_fallback=True): + """获取单只股票最新价格。 + + 优先级: live_prices(DB) → stock_quote(API兜底) + - live_prices 有且不超过 max_age_minutes → 直接返回 + - 没有或过期 → 调 stock_quote 拉,写回 live_prices + - 都失败 → 返回 (None, None) + + 返回 (price, change_pct),两值都是 float 或 None。 + """ + from mofin_db import get_price_from_db + from datetime import datetime, timedelta + + # 1. 先读 DB + try: + db_price, db_chg = get_price_from_db(code) + if db_price is not None and db_price > 0: + # 检查时效性 + conn = __import__('sqlite3').connect(str(DB_PATH)) + row = conn.execute( + "SELECT updated_at FROM live_prices WHERE code=?", + (str(code).strip(),) + ).fetchone() + conn.close() + if row and row[0]: + try: + updated = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S") + age = (datetime.now() - updated).total_seconds() / 60 + if age <= max_age_minutes: + return (db_price, db_chg) + except: + pass + else: + return (db_price, db_chg) + except Exception: + pass + + # 2. DB 没有或过期 → 调 stock_quote + if not use_stale_fallback: + return (None, None) + + try: + import subprocess, json + r = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "stock_quote.py"), str(code)], + capture_output=True, text=True, timeout=15 + ) + if r.returncode == 0: + data = json.loads(r.stdout.strip()) + price = float(data.get("price", 0)) + chg = float(data.get("change_pct", 0)) + if price > 0: + # 写回 live_prices + try: + conn = __import__('sqlite3').connect(str(DB_PATH)) + conn.execute(""" + INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) + VALUES (?, ?, ?, datetime('now','localtime')) + """, (str(code).strip(), price, chg)) + conn.commit() + conn.close() + except: + pass + return (price, chg) + except Exception: + pass + + return (None, None) + + +def get_prices_batch(codes, max_age_minutes=5): + """批量获取价格,返回 {code: (price, change_pct)}""" + from mofin_db import get_prices_batch_from_db + + result = {} + need_api = [] + + # 1. 批量读 DB + try: + db_prices = get_prices_batch_from_db(codes) + for code in codes: + cs = str(code).strip() + if cs in db_prices: + p, c = db_prices[cs] + if p and p > 0: + result[cs] = (p, c) + continue + need_api.append(cs) + except: + need_api = [str(c).strip() for c in codes] + + # 2. 缺失的调 API + if need_api: + try: + import subprocess, json + r = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "stock_quote.py")] + need_api, + capture_output=True, text=True, timeout=30 + ) + if r.returncode == 0: + for line in r.stdout.strip().split("\n"): + if not line: + continue + try: + data = json.loads(line) + code = str(data.get("code", "")).strip() + price = float(data.get("price", 0)) + chg = float(data.get("change_pct", 0)) + if code and price > 0: + result[code] = (price, chg) + except: + pass + except: + pass + + return result + + +# ── cash_log 写入 ────────────────────────────────────────────────── + +def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, + source, note, verified=0): + """记录现金变更到 cash_log 表。""" + change_amount = round(cash_after - cash_before, 2) if cash_after is not None and cash_before is not None else 0 + db = sqlite3.connect(DB_PATH) + try: + cur = db.execute( + """INSERT INTO cash_log + (timestamp, cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + VALUES (datetime('now','localtime'), ?, ?, ?, ?, ?, ?, ?, ?)""", + (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + ) + db.commit() + return cur.lastrowid + finally: + db.close() + + +# ── 自检 ─────────────────────────────────────────────────────────── + +if __name__ == "__main__": + pf = read_portfolio() + print(f"portfolio: {len(pf.get('holdings',[]))} holdings, total_assets={pf.get('total_assets',0)}") + + dec = read_decisions() + print(f"decisions: {len(dec.get('decisions',[]))} entries") + + wl = read_watchlist() + print(f"watchlist: {len(wl.get('stocks',[]))} stocks") diff --git a/deploy/profile-scripts/mofin_db.py b/deploy/profile-scripts/mofin_db.py new file mode 100644 index 00000000..42c7cb6a --- /dev/null +++ b/deploy/profile-scripts/mofin_db.py @@ -0,0 +1,2341 @@ +#!/usr/bin/env python3 +"""mofin_db.py — MoFin 统一数据库访问层 + +所有脚本通过此模块访问 mofin.db,避免重复建表/连接逻辑。 + +用法: + from mofin_db import get_conn, write_market_snapshot, write_klines, ... + +设计原则: + - 幂等建表(CREATE TABLE IF NOT EXISTS) + - WAL 模式 + 外键约束 + - 所有写操作返回 (success: bool, detail: str) + - JSON 写入由调用方负责,本模块只写 SQLite +""" + +import sqlite3 +import json +import time +import functools +from datetime import datetime +from pathlib import Path +from typing import Optional, Callable + +DATA_DIR = Path("/home/hmo/MoFin/data") # 绝对路径:全系统唯一权威数据目录 +DB_PATH = DATA_DIR / "mofin.db" + +# ═══════════════════════════════════════════════════════════ +# 连接管理 +# ═══════════════════════════════════════════════════════════ + +def get_conn() -> sqlite3.Connection: + """获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁,autocommit模式)""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA synchronous=NORMAL") + # 每次连接时清理WAL:防止被kill的进程留下残留事务导致后续全部卡死 + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + return conn + + +def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (), + max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor: + """执行SQL并自动重试(捕获 database is locked)""" + last_err = None + for attempt in range(max_retries + 1): + try: + return conn.execute(sql, params) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise # 非锁错误直接抛 + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB锁重试{max_retries}次仍失败: {e}" + ) + # unreachable -- both paths in loop either return or raise + if last_err: + raise last_err # type: ignore[misc] + + +def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3, + base_delay: float = 1.0) -> None: + """提交事务并自动重试""" + last_err = None + for attempt in range(max_retries + 1): + try: + conn.commit() + return + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB提交重试{max_retries}次仍失败: {e}" + ) + raise last_err + + +def retry_db_write(func: Callable) -> Callable: + """装饰器:为 DB 写函数自动添加重试""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + max_retries = 3 + base_delay = 1.0 + last_err = None + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB写重试{max_retries}次仍失败({func.__name__}): {e}" + ) + raise last_err + return wrapper + + +# ═══════════════════════════════════════════════════════════ +# 建表(幂等) +# ═══════════════════════════════════════════════════════════ + +def init_all_tables(conn: sqlite3.Connection): + """创建全部表(幂等,已存在则跳过)""" + conn.executescript(""" + -- 市场快照 + CREATE TABLE IF NOT EXISTS market_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'ths', + up_ratio REAL, + mood TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_snapshots_time ON market_snapshots(timestamp); + + -- 板块快照 + CREATE TABLE IF NOT EXISTS sector_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL REFERENCES market_snapshots(id), + name TEXT NOT NULL, + change_pct REAL, + up_count INTEGER, + down_count INTEGER, + net_inflow REAL, + lead_stock TEXT, + lead_stock_change REAL, + volume REAL, + turnover REAL + ); + CREATE INDEX IF NOT EXISTS idx_sector_name ON sector_snapshots(name); + CREATE INDEX IF NOT EXISTS idx_sector_snapshot ON sector_snapshots(snapshot_id); + CREATE INDEX IF NOT EXISTS idx_sector_name_time ON sector_snapshots(name, snapshot_id); + + -- 个股 + CREATE TABLE IF NOT EXISTS stocks ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + exchange TEXT DEFAULT 'SH', + type TEXT DEFAULT 'A', + updated_at TEXT + ); + + -- K线(日/周/月) + CREATE TABLE IF NOT EXISTS stock_daily ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, amount REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_weekly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_monthly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + + -- 基本面 + CREATE TABLE IF NOT EXISTS stock_fundamentals ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + pe REAL, pb REAL, eps REAL, + mcap_total REAL, mcap_flow REAL, + updated_at TEXT + ); + + -- 板块成分映射 + CREATE TABLE IF NOT EXISTS stock_sectors ( + code TEXT NOT NULL REFERENCES stocks(code), + sector_name TEXT NOT NULL, + source TEXT DEFAULT 'ths', + updated_at TEXT DEFAULT (datetime('now','localtime')), + PRIMARY KEY (code, sector_name) + ); + CREATE INDEX IF NOT EXISTS idx_stock_sector ON stock_sectors(sector_name); + + -- 持仓 + CREATE TABLE IF NOT EXISTS holdings ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + shares INTEGER NOT NULL, + cost REAL, + price REAL, -- 当前价格 (CNY) + market_value REAL, -- 市值 = shares * price + change_pct REAL, -- 涨跌幅 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + position_pct REAL, + added_at TEXT, + is_active INTEGER DEFAULT 1, + closed_at TEXT, + close_pnl REAL + ); + + -- 持仓策略(对应 decisions.json decisions[]) + CREATE TABLE IF NOT EXISTS holding_strategies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES holdings(code), + name TEXT, + version INTEGER DEFAULT 1, + price REAL, + cost REAL, + shares INTEGER DEFAULT 0, + stop_loss REAL, + take_profit REAL, + entry_low REAL, + entry_high REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + strategy_type TEXT DEFAULT 'holding', + action TEXT, + timing_signal TEXT, + rr_ratio REAL, + tech_snapshot TEXT, + stock_category TEXT, + sector_context TEXT, + status TEXT DEFAULT 'active', + trigger_json TEXT, + changelog_json TEXT, + source TEXT, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT, + superseded_at TEXT, + -- 以下为 decisions.json→DB 迁移新增列 + avg_price REAL, + decision_timestamp TEXT, + note TEXT, + quality_check TEXT, + quality_checked_at TEXT, + quality_issues_json TEXT, + position_advice TEXT, + signal_factors_json TEXT, + time_horizon TEXT, + decision_type TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code); + CREATE INDEX IF NOT EXISTS idx_strategy_status ON holding_strategies(status); + + -- 策略历史快照(每次覆写前自动记录) + CREATE TABLE IF NOT EXISTS strategy_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT, + decision_type TEXT, + strategy_type TEXT, + full_analysis TEXT, + action TEXT, + timing_signal TEXT, + entry_low REAL, + entry_high REAL, + stop_loss REAL, + take_profit REAL, + position_advice TEXT, + rr_ratio REAL, + version INTEGER, + source_trigger TEXT, + reassessed_at TEXT, + snapshotted_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_strategy_history_code ON strategy_history(code, snapshotted_at); + + -- 策略追踪评估(2026-07-27 老爸:每条推荐操作的完整生命周期跟踪) + -- 每个版本一条记录,策略变更时自动追加新版本 + CREATE TABLE IF NOT EXISTS strategy_tracking ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT, + version_seq INTEGER DEFAULT 1, -- 该股票的第几个策略版本 + -- 推荐时的快照 + tracked_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + timing_signal TEXT, + rec_score INTEGER DEFAULT 0, + rr_ratio REAL, + entry_low REAL, + entry_high REAL, + entry_mid REAL, + stop_loss REAL, + take_profit REAL, + position_advice TEXT, + price_at_track REAL, -- 记录时的市价 + -- 区别于前一版本的变化摘要 + change_summary TEXT, + -- 结果跟踪 + status TEXT DEFAULT 'active' CHECK(status IN ('active','hit_tp','hit_sl','expired','manual_close')), + closed_at TEXT, + close_price REAL, + close_reason TEXT, + theoretical_pnl REAL, -- 理论盈亏%(基于中值买入价) + -- 实操数据(由用户或导入脚本填入) + actual_action TEXT, -- "买入600股@148.86" + actual_entry REAL, + actual_shares INTEGER, + actual_exit REAL, + actual_pnl REAL, + actual_exit_reason TEXT, + notes TEXT + ); + CREATE INDEX IF NOT EXISTS idx_track_code ON strategy_tracking(code); + CREATE INDEX IF NOT EXISTS idx_track_status ON strategy_tracking(status); + CREATE INDEX IF NOT EXISTS idx_track_date ON strategy_tracking(tracked_at); + + -- 自选股 + CREATE TABLE IF NOT EXISTS watchlist_stocks ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + price REAL, -- 当前价格 + entry_low REAL, -- 买入区下限 + entry_high REAL, -- 买入区上限 + stop_loss REAL, -- 止损 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + source TEXT, -- 来源: alpha_sift/xiaoguo/manual + source_detail TEXT, -- 来源详情 JSON + notes TEXT, -- 备注 + added_by TEXT, -- 谁加的 + added_at TEXT DEFAULT (datetime('now','localtime')), + is_active INTEGER DEFAULT 1, + analysis_json TEXT -- 分析结果 JSON + ); + + -- 候选池 + CREATE TABLE IF NOT EXISTS candidates ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + sector TEXT, + reason TEXT, + entry_range TEXT, + stop_loss REAL, + target REAL, + zhiwei_star REAL, + zhiwei_reviewed INTEGER DEFAULT 0, + zhiwei_reviewed_at TEXT, + promoted INTEGER DEFAULT 0, + promoted_at TEXT, + dropped INTEGER DEFAULT 0, + drop_reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 候选评分历史 + CREATE TABLE IF NOT EXISTS candidate_score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES candidates(code), + score REAL NOT NULL, + source TEXT NOT NULL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_candidate_history ON candidate_score_history(code, created_at); + + -- 价格事件 + CREATE TABLE IF NOT EXISTS price_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + event_type TEXT NOT NULL, + price REAL, + trigger_value TEXT, + event_label TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + date TEXT + ); + CREATE INDEX IF NOT EXISTS idx_events_code ON price_events(code); + CREATE INDEX IF NOT EXISTS idx_events_date ON price_events(date); + + -- 策略评估记录 + CREATE TABLE IF NOT EXISTS strategy_evaluations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + eval_type TEXT NOT NULL, + status TEXT DEFAULT 'pending', + old_stop_loss REAL, + new_stop_loss REAL, + old_tp REAL, + new_tp REAL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 持仓汇总(portfolio.json 顶层字段) + CREATE TABLE IF NOT EXISTS portfolio_summary ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total_assets REAL, + total_mv REAL, -- 持仓总市值 + stock_value REAL, + cash REAL, -- 可用现金 + frozen_cash REAL DEFAULT 0, -- 冻结资金 + position_pct REAL, + total_pnl REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + updated_at TEXT + ); + + -- 现金变更日志(每次买卖/出入金记录) + CREATE TABLE IF NOT EXISTS cash_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now','localtime')), + cash_before REAL, -- 变更前可用现金 + cash_after REAL, -- 变更后可用现金 + frozen_before REAL, -- 变更前冻结资金 + frozen_after REAL, -- 变更后冻结资金 + change_amount REAL, -- 现金变动额(正=入金/卖股,负=出金/买股) + source TEXT NOT NULL, -- 来源: screenshot/manual/import_xls/trade + note TEXT, -- 备注: 例如 "卖出法拉电子 200股" + verified INTEGER DEFAULT 0 -- 是否已验证(0=未验证,1=Dad确认) + ); + + -- 建议时间线(decisions.json advice_timeline[]) + CREATE TABLE IF NOT EXISTS advice_timeline ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT, + direction TEXT, + price REAL, + summary TEXT, + status TEXT, + evaluated INTEGER DEFAULT 0, + result TEXT, + evaluated_at TEXT, + report_id TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_advice_code ON advice_timeline(code); + + -- 准确率统计(accuracy_stats.json) + CREATE TABLE IF NOT EXISTS accuracy_stats ( + id INTEGER PRIMARY KEY CHECK (id = 1), + period_start TEXT, + period_end TEXT, + total_advice INTEGER DEFAULT 0, + correct INTEGER DEFAULT 0, + wrong INTEGER DEFAULT 0, + partial INTEGER DEFAULT 0, + unknown INTEGER DEFAULT 0, + pending INTEGER DEFAULT 0, + ignored INTEGER DEFAULT 0, + evaluated INTEGER DEFAULT 0, + accuracy_pct REAL, + phase1_correct INTEGER DEFAULT 0, + phase1_wrong INTEGER DEFAULT 0, + phase1_pending INTEGER DEFAULT 0, + phase1_accuracy REAL, + phase2_correct INTEGER DEFAULT 0, + phase2_wrong INTEGER DEFAULT 0, + phase2_pending INTEGER DEFAULT 0, + phase2_accuracy REAL, + total_evaluated INTEGER DEFAULT 0, + updated_at TEXT + ); + + -- 策略反馈(strategy_feedback.json) + CREATE TABLE IF NOT EXISTS strategy_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + evaluated_at TEXT, + phase1_completed INTEGER DEFAULT 0, + phase1_result TEXT, + phase1_completed_at TEXT, + phase1_price REAL, + phase2_completed INTEGER DEFAULT 0, + phase2_result TEXT, + phase2_completed_at TEXT, + days_in_phase1 INTEGER, + adjustments_json TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_feedback_code ON strategy_feedback(code); + + -- 板块信号(trend_detector 产出) + CREATE TABLE IF NOT EXISTS sector_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_type TEXT NOT NULL, + sector TEXT NOT NULL, + severity TEXT DEFAULT 'medium', + related_stocks TEXT, + holdings_in_sector TEXT, + watchlist_in_sector TEXT, + trigger_reason TEXT, + snapshot_id INTEGER, + processed INTEGER DEFAULT 0, + detected_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_processed ON sector_signals(processed); + CREATE INDEX IF NOT EXISTS idx_signal_sector ON sector_signals(sector); + + -- 小果情报(xiaoguo_news_processor 产出) + CREATE TABLE IF NOT EXISTS signal_news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_id INTEGER REFERENCES sector_signals(id), + sector TEXT NOT NULL, + overall_sentiment TEXT, + summary TEXT, + key_articles TEXT, + searched_stocks TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_news_signal ON signal_news(signal_id); + + -- 小果扫描跟踪(去重用) + CREATE TABLE IF NOT EXISTS xiaoguo_scan_tracker ( + code TEXT PRIMARY KEY, + name TEXT, + last_scanned_at TEXT, + found_count INTEGER DEFAULT 0 + ); + + -- 实时价格快照(替代 live_prices.json) + CREATE TABLE IF NOT EXISTS live_prices ( + code TEXT PRIMARY KEY, + price REAL, + change_pct REAL, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 多周期缓存(替代 multi_tf_cache.json) + CREATE TABLE IF NOT EXISTS mtf_cache ( + code TEXT PRIMARY KEY, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 资金流缓存(替代 capital_flow_cache.json) + CREATE TABLE IF NOT EXISTS capital_flow_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- Self-TODO 自动化任务表 + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'pending', + priority TEXT DEFAULT 'medium', + source TEXT DEFAULT 'manual', + fix_action TEXT, + retry_count INTEGER DEFAULT 0, + note TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() + + # 迁移:给 signal_news 加 source 字段(幂等) + try: + conn.execute("ALTER TABLE signal_news ADD COLUMN source TEXT DEFAULT 'trend'") + except sqlite3.OperationalError: + pass + + # cash_log migration (2026-07-01) + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_before REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_after REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN verified INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass + + # ── 币种约束迁移(2026-06-30)──────────────────────────────── + _currency_migrations = [ + ("holdings", ["price REAL", "market_value REAL", "change_pct REAL", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("holding_strategies", ["name TEXT", "price REAL", "cost REAL", "shares INTEGER DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'", + "action TEXT", "timing_signal TEXT", "rr_ratio REAL", + "tech_snapshot TEXT", "stock_category TEXT", + "sector_context TEXT", "status TEXT DEFAULT 'active'", + "trigger_json TEXT", "changelog_json TEXT", + "updated_at TEXT"]), + ("portfolio_summary", ["total_mv REAL", "frozen_cash REAL DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("watchlist_stocks", ["price REAL", "entry_low REAL", "entry_high REAL", + "stop_loss REAL", "currency TEXT NOT NULL DEFAULT 'CNY'", + "source TEXT", "source_detail TEXT", "notes TEXT", + "added_by TEXT", "analysis_json TEXT"]), + ] + for table, columns in _currency_migrations: + for col_def in columns: + col_name = col_def.split()[0] + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}") + except sqlite3.OperationalError: + pass # column already exists + + # ── tag 迁移(2026-07-20):推荐标签 current_recommend / active_manual ── + # 此前 strategy_lifecycle 在 dict 里设置 tag 但 write_holding_strategy 无此列, + # 导致标签在写入时被静默丢弃。补列 + 写入保留。 + try: + conn.execute("ALTER TABLE holding_strategies ADD COLUMN tag TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + # ── 三值 RR 迁移(2026-07-22 老爸):买入区下沿/中值/上沿三个 RR ── + # rr_ratio 保留=中值 RR(排序/门槛沿用),新增 rr_low / rr_high 展示用。 + for _col in ("rr_low REAL DEFAULT 0", "rr_high REAL DEFAULT 0"): + try: + conn.execute(f"ALTER TABLE holding_strategies ADD COLUMN {_col}") + except sqlite3.OperationalError: + pass + # ── rec_score 迁移(2026-07-27):五维复合推荐评分 0-100 ── + try: + conn.execute("ALTER TABLE holding_strategies ADD COLUMN rec_score INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass + conn.commit() + + +# ═══════════════════════════════════════════════════════════ +# 市场快照写入 +# ═══════════════════════════════════════════════════════════ + +def write_market_snapshot(conn: sqlite3.Connection, market_data: dict) -> tuple[bool, str, Optional[int]]: + """写入一次市场采集到 market_snapshots + sector_snapshots + + Returns: (ok, message, snapshot_id) + """ + try: + cur = conn.execute( + "INSERT INTO market_snapshots (timestamp, source, up_ratio, mood) VALUES (?, ?, ?, ?)", + (market_data["timestamp"], market_data.get("source", "unknown"), + market_data.get("up_ratio", 0), market_data.get("mood", "unknown")), + ) + sid = cur.lastrowid + + sectors = market_data.get("sectors", []) + rows = [(sid, s.get("name", ""), s.get("change", 0), + s.get("up_count"), s.get("down_count"), s.get("net_inflow"), + s.get("lead_stock"), s.get("lead_stock_change"), + s.get("volume"), s.get("turnover")) for s in sectors] + if rows: + conn.executemany( + "INSERT INTO sector_snapshots (snapshot_id, name, change_pct, up_count, down_count, " + "net_inflow, lead_stock, lead_stock_change, volume, turnover) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) + conn.commit() + return True, f"snapshot_id={sid}, sectors={len(rows)}", sid + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False, str(e), None + + +# ═══════════════════════════════════════════════════════════ +# K线写入 +# ═══════════════════════════════════════════════════════════ + +def write_klines(conn: sqlite3.Connection, code: str, name: str, + daily: list = None, weekly: list = None, monthly: list = None, + fundamentals: dict = None) -> bool: + """将个股K线数据双写 SQLite + + Args: + code: 股票代码 + name: 股票名称 + daily/weekly/monthly: [{date, open, close, high, low, volume}, ...] + fundamentals: {pe, pb, eps, mcap_total, mcap_flow} + """ + try: + # 判断交易所 + raw = str(code) + if len(raw) == 5 and raw.isdigit(): + exchange, stype = "HK", "H" + elif raw.startswith(("6", "5", "9")): + exchange, stype = "SH", "A" + else: + exchange, stype = "SZ", "A" + + # stocks 表(INSERT OR REPLACE) + conn.execute( + "INSERT OR REPLACE INTO stocks (code, name, exchange, type, updated_at) VALUES (?, ?, ?, ?, ?)", + (code, name, exchange, stype, datetime.now().isoformat())) + + # K线数据 + for period, table, data in [ + ("daily", "stock_daily", daily), + ("weekly", "stock_weekly", weekly), + ("monthly", "stock_monthly", monthly), + ]: + if not data: + continue + rows = [(code, d.get("date", ""), d.get("open"), d.get("close"), + d.get("high"), d.get("low"), d.get("volume"), + d.get("amount") if period == "daily" else None) for d in data] + if period == "daily": + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume, amount) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows) + else: + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [(r[0], r[1], r[2], r[3], r[4], r[5], r[6]) for r in rows]) + + # 基本面 + if fundamentals: + conn.execute( + "INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, fundamentals.get("pe"), fundamentals.get("pb"), + fundamentals.get("eps"), fundamentals.get("mcap_total"), + fundamentals.get("mcap_flow"), datetime.now().isoformat())) + + conn.commit() + return True + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 价格事件写入 +# ═══════════════════════════════════════════════════════════ + +def write_price_event(conn: sqlite3.Connection, code: str, name: str, + event_type: str, price: float, trigger_value: str, + event_label: str = "") -> bool: + """写入一条价格事件""" + try: + now = datetime.now() + conn.execute( + "INSERT INTO price_events (code, name, event_type, price, trigger_value, event_label, date) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, name, event_type, round(price, 2), trigger_value, + event_label, now.strftime("%Y-%m-%d"))) + conn.commit() + return True + except Exception: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 板块成分迁移 +# ═══════════════════════════════════════════════════════════ + +def migrate_stock_sectors(conn: sqlite3.Connection) -> tuple[int, int]: + """从 stock_sector_map.json 迁移到 stock_sectors 表 + + Returns: (migrated_stocks, total_mappings) + """ + sector_map_path = DATA_DIR / "stock_sector_map.json" + if not sector_map_path.exists(): + return 0, 0 + + try: + with open(sector_map_path, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return 0, 0 + + # 过滤元数据字段 + mappings = [(code, sectors) for code, sectors in data.items() + if not code.startswith("_") and isinstance(sectors, list)] + + total = 0 + for code, sectors in mappings: + for sector in sectors: + try: + conn.execute( + "INSERT OR IGNORE INTO stock_sectors (code, sector_name, source) VALUES (?, ?, 'ths')", + (code, sector)) + total += 1 + except Exception: + pass + conn.commit() + return len(mappings), total + + +# ═══════════════════════════════════════════════════════════ +# 查询辅助 +# ═══════════════════════════════════════════════════════════ + +def query_sector_trend(conn: sqlite3.Connection, name: str, limit: int = 5) -> list[dict]: + """板块最近N次趋势""" + rows = conn.execute(""" + SELECT s.timestamp, ss.change_pct, ss.net_inflow, + ss.up_count, ss.down_count, ss.lead_stock, ss.lead_stock_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE ss.name = ? ORDER BY s.timestamp DESC LIMIT ? + """, (name, limit)).fetchall() + return [dict(r) for r in rows] + + +def query_top_inflow(conn: sqlite3.Connection, limit: int = 5) -> list[dict]: + """最新一次资金净流入排行""" + rows = conn.execute(""" + SELECT ss.name, ss.change_pct, ss.net_inflow, ss.lead_stock, s.timestamp + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id = (SELECT MAX(id) FROM market_snapshots) + AND ss.net_inflow IS NOT NULL + ORDER BY ss.net_inflow DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_consecutive_inflow(conn: sqlite3.Connection, days: int = 3) -> list[dict]: + """连续N次净流入的板块""" + rows = conn.execute(""" + SELECT name, COUNT(*) as times, ROUND(AVG(net_inflow), 2) as avg_inflow, + ROUND(AVG(change_pct), 2) as avg_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id > (SELECT MAX(id) - ? FROM market_snapshots) + AND net_inflow > 0 + GROUP BY name HAVING COUNT(*) >= ? + ORDER BY avg_inflow DESC + """, (days, days)).fetchall() + return [dict(r) for r in rows] + + +def query_market_mood(conn: sqlite3.Connection, limit: int = 10) -> list[dict]: + """市场情绪趋势""" + rows = conn.execute(""" + SELECT timestamp, source, up_ratio, mood + FROM market_snapshots ORDER BY timestamp DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_db_stats(conn: sqlite3.Connection) -> dict: + """数据库概览""" + snap_count = conn.execute("SELECT COUNT(*) FROM market_snapshots").fetchone()[0] + sector_count = conn.execute("SELECT COUNT(*) FROM sector_snapshots").fetchone()[0] + stock_count = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] + kline_count = conn.execute("SELECT COUNT(*) FROM stock_daily").fetchone()[0] + event_count = conn.execute("SELECT COUNT(*) FROM price_events").fetchone()[0] + holding_count = conn.execute("SELECT COUNT(*) FROM holdings").fetchone()[0] + candidate_count = conn.execute("SELECT COUNT(*) FROM candidates").fetchone()[0] + latest = conn.execute( + "SELECT timestamp, source FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + return { + "snapshots": snap_count, "sector_rows": sector_count, + "stocks": stock_count, "daily_klines": kline_count, + "price_events": event_count, "holdings": holding_count, + "candidates": candidate_count, + "latest_snapshot": dict(latest) if latest else None, + } + + +# ═══════════════════════════════════════════════════════════ +# 持仓查询 +# ═══════════════════════════════════════════════════════════ + +def query_holdings(conn: sqlite3.Connection) -> list[dict]: + """持仓列表(含最新策略)""" + rows = conn.execute(""" + SELECT h.code, h.name, h.shares, h.cost, h.position_pct, h.is_active, + h.price, h.change_pct, h.currency, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action, hs.created_at as strategy_updated + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +def query_holding_by_code(conn: sqlite3.Connection, code: str) -> dict | None: + """单只持仓""" + row = conn.execute(""" + SELECT h.*, hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.code = ? + """, (code,)).fetchone() + return dict(row) if row else None + + +def query_portfolio_summary(conn: sqlite3.Connection) -> dict: + """持仓汇总""" + row = conn.execute("SELECT * FROM portfolio_summary WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 自选股查询 +# ═══════════════════════════════════════════════════════════ + +def query_watchlist(conn: sqlite3.Connection) -> list[dict]: + """自选股列表(含策略)""" + rows = conn.execute(""" + SELECT w.code, w.name, w.added_at, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM watchlist_stocks w + LEFT JOIN holding_strategies hs ON w.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = w.code AND strategy_type = 'watch') + WHERE w.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 决策/策略查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategies(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略列表(按版本倒序)""" + if code: + rows = conn.execute( + "SELECT * FROM holding_strategies WHERE code = ? ORDER BY version DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM holding_strategies ORDER BY code, version DESC").fetchall() + return [dict(r) for r in rows] + + +def query_advice_timeline(conn: sqlite3.Connection, code: str = None, limit: int = 50) -> list[dict]: + """建议时间线""" + if code: + rows = conn.execute( + "SELECT * FROM advice_timeline WHERE code = ? ORDER BY date DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM advice_timeline ORDER BY date DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 候选池查询 +# ═══════════════════════════════════════════════════════════ + +def query_candidates(conn: sqlite3.Connection, active_only: bool = True) -> list[dict]: + """候选池列表(含最新评分)""" + where = "WHERE c.dropped = 0" if active_only else "" + rows = conn.execute(f""" + SELECT c.*, (SELECT score FROM candidate_score_history + WHERE code = c.code ORDER BY created_at DESC LIMIT 1) as latest_score + FROM candidates c {where} + ORDER BY c.zhiwei_star DESC NULLS LAST + """).fetchall() + return [dict(r) for r in rows] + + +def query_candidate_scores(conn: sqlite3.Connection, code: str) -> list[dict]: + """某候选的评分历史""" + rows = conn.execute( + "SELECT * FROM candidate_score_history WHERE code = ? ORDER BY created_at", + (code,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 价格事件查询 +# ═══════════════════════════════════════════════════════════ + +def query_price_events(conn: sqlite3.Connection, code: str = None, limit: int = 100) -> list[dict]: + """价格事件""" + if code: + rows = conn.execute( + "SELECT * FROM price_events WHERE code = ? ORDER BY created_at DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM price_events ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_price_events_by_date(conn: sqlite3.Connection, date: str) -> list[dict]: + """某天的价格事件""" + rows = conn.execute( + "SELECT * FROM price_events WHERE date = ? ORDER BY created_at DESC", (date,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 板块成分查询 +# ═══════════════════════════════════════════════════════════ + +def query_stock_sectors(conn: sqlite3.Connection, code: str) -> list[str]: + """某只股票所属板块""" + rows = conn.execute( + "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,)).fetchall() + return [r[0] for r in rows] + + +def query_sector_stocks(conn: sqlite3.Connection, sector_name: str) -> list[str]: + """某板块包含的股票""" + rows = conn.execute( + "SELECT code FROM stock_sectors WHERE sector_name = ?", (sector_name,)).fetchall() + return [r[0] for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 准确率统计查询 +# ═══════════════════════════════════════════════════════════ + +def query_accuracy_stats(conn: sqlite3.Connection) -> dict: + """准确率统计""" + row = conn.execute("SELECT * FROM accuracy_stats WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 策略反馈查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_feedback(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略反馈""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_feedback WHERE code = ? ORDER BY evaluated_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_feedback ORDER BY evaluated_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 策略评估查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_evaluations(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略评估记录""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_evaluations WHERE code = ? ORDER BY created_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_evaluations ORDER BY created_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 市场快照查询(最新) +# ═══════════════════════════════════════════════════════════ + +def query_latest_market(conn: sqlite3.Connection) -> dict: + """获取最新一次市场快照(含 sector 详情)""" + row = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not row: + return {} + snap = dict(row) + # 关联 sectors + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 通用工具 +# ═══════════════════════════════════════════════════════════════════ + +def get_price_from_db(code: str) -> tuple[float | None, float | None]: + """从 DB 读取最新价格(price_monitor 维护)。 + 返回 (price, change_pct) 或 (None, None) + + 所有脚本应优先调用此函数,DB 无数据时才拉腾讯 API。 + """ + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + db.close() + if row: + return (row['price'], row['change_pct'] if 'change_pct' in row.keys() else None) + except Exception: + pass + return (None, None) + + +def get_prices_batch_from_db(codes: list[str]) -> dict: + """从 DB 批量读取价格。返回 {code: (price, change_pct)}""" + results = {} + if not codes: + return results + 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", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + if row and row['price']: + results[str(code)] = (row['price'], row['change_pct'] if 'change_pct' in row.keys() else 0) + db.close() + except Exception: + pass + return results + """最新一次市场快照(含板块数据)""" + snap = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not snap: + return {} + snap = dict(snap) + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + # 计算 top_gainers / top_losers + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 核心写函数 — 替代 json.dump(),强制币种约束 +# ═══════════════════════════════════════════════════════════════════ + +def reconcile_signal_from_analysis(conn, code: str) -> str: + """以已存 full_analysis 为唯一事实源,重算 timing_signal 并写回。 + 根治"信号与分析脱节"(per_stock 分开写信号/分析导致的 信号=买入但分析=观望)。 + 返回最终信号。无裁决行 → 清空动作级信号(防陈旧买入残留)。""" + try: + row = conn.execute("SELECT full_analysis, timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if not row: + return "" + fa, old_sig = row[0] or "", row[1] or "" + verdict = "" + for line in fa.split("\n"): + if "【综合结论】" in line: + for s in ("买入", "可买入", "可加仓", "卖出", "止盈", "关注", "观望", "持有", "弱势持有"): + if s in line: + verdict = s + break + break + if verdict: + new_sig = verdict + # ── 矛盾降级(2026-07-24 老爸:688660综合结论=买入但操作建议说"继续空仓观望暂不执行")── + # 【综合结论】给方向,【操作建议】/【建议仓位】给执行。执行层明确否定买入时, + # 以执行为准——信号降级为关注,tag/exec 一律不许升。 + if new_sig in ("买入", "可买入", "可加仓"): + _NEG_ADVICE = ("继续空仓", "暂不执行", "不宜买入", "不买入", "不建仓", "不新建仓", + "等待企稳", "暂缓买入", "保持空仓", "维持空仓", "不建议买入", "空仓观望", + "不建议操作", "等待价格回落", "等待回调", "高于买入区上沿") + for line in fa.split("\n"): + if "【建议仓位】" in line and ("不新建仓" in line or "不建仓" in line): + new_sig = "关注" + print(f" [RECONCILE] {code} 建议仓位=不建仓('{line.strip()[:40]}'),信号降级为关注", flush=True) + break + if ("【操作建议】" in line or line.strip().startswith("【操作建议】")) \ + and any(k in line for k in _NEG_ADVICE): + new_sig = "关注" + print(f" [RECONCILE] {code} 操作建议否定买入('{line.strip()[:40]}'),信号降级为关注", flush=True) + break + elif old_sig in ("买入", "可买入", "可加仓"): + new_sig = "" # 无裁决且陈旧动作信号 → 清除 + else: + new_sig = old_sig + if new_sig != old_sig: + conn.execute("UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", + (new_sig, code)) + conn.commit() + # 信号变了 → tag 跟着对齐 + sync_recommend_tag(conn, code, new_sig) + print(f" [RECONCILE] {code} 信号 {old_sig}→{new_sig}(以分析为准)", flush=True) + return new_sig + except Exception as e: + print(f" [RECONCILE] {code} 异常: {e}", flush=True) + return "" + + +def recompute_rr(conn, code: str) -> float: + """用买入区+已存止损/止盈重算三值 RR 并写回(rr_low/rr_ratio中值/rr_high)。 + 根治"LLM 不输出 RR → rr_ratio 永远 0"的断链(红线:RR 由系统算,不信 LLM)。 + 公式: RR(x) = (上方目标 - x) / (x - 止损);x 分别取买入区下沿/中值/上沿。 + 上方目标基于中值参考价统一定(不因 x 不同而漂移),保证三值自洽: + rr_low > rr_ratio > rr_high 恒成立,展示"在同一阻力下不同入场价的敏感度"。 + 目标 = min(止盈, 20日新高若高于中值)(2026-07-24 老爸: + 前高挡在中间时止盈是放空炮,真实RR必须对最近上方阻力先结算)。 + rr_ratio=中值 RR 用于排序与2.0门槛;rr_low/rr_high 展示入场价敏感度。 + 区间缺失 → 中值兜底现价(low/high=0);损/盈缺失或 x<=止损 → 该值=0。""" + try: + row = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not row: + return 0.0 + el, eh, sl, tp = (row[0] or 0), (row[1] or 0), (row[2] or 0), (row[3] or 0) + + # 卖出/止盈信号:RR(买在区间的盈亏比)对卖出无意义,直接返回0(2026-07-27 老爸) + try: + _sig_r = conn.execute("SELECT timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _sig_r and _sig_r[0] in ("卖出", "止盈"): + conn.execute("UPDATE holding_strategies SET rr_ratio=0, rr_low=0, rr_high=0 WHERE code=? AND status='active'", (code,)) + conn.commit() + return 0.0 + except Exception: + pass + + # 20日新高(前高阻力) + high_20d = 0.0 + try: + r20 = conn.execute( + "SELECT MAX(high) FROM (SELECT high FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 20)", + (code,)).fetchone() + if r20 and r20[0]: + high_20d = float(r20[0]) + except Exception: + pass + + # 基准参考价 = 区间中值(决定上方目标,三值共用) + ref = (el + eh) / 2.0 if el > 0 and eh > el else 0 + target = tp + # 已持仓股不适用20日新高阻力(用户已按原始推荐买入,RR应保持原值) + _owned = conn.execute("SELECT shares FROM holdings WHERE code=? AND is_active=1 AND shares>0", (code,)).fetchone() + if not _owned and ref > 0 and high_20d > ref and high_20d < tp: + target = high_20d + + def _rr(x): + if sl > 0 and target > 0 and x > sl and target > x: + v = round((target - x) / (x - sl), 2) + return v if v > 0 else 0.0 + return 0.0 + + rr_low = rr_mid = rr_high = 0.0 + if el > 0 and eh > el: + rr_low = _rr(el) # 下沿买入:最乐观 + rr_mid = _rr((el + eh) / 2.0) + rr_high = _rr(eh) # 上沿买入:最保守 + else: + # 区间缺失 → 中值兜底现价 + try: + pr = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if pr and (pr[0] or 0) > 0: + rr_mid = _rr(float(pr[0])) + except Exception: + pass + conn.execute( + "UPDATE holding_strategies SET rr_ratio=?, rr_low=?, rr_high=? WHERE code=? AND status='active'", + (rr_mid, rr_low, rr_high, code)) + conn.commit() + compute_rec_score(conn, code) # RR 变→评分同步刷新 + return rr_mid + except Exception as e: + print(f" [RR] {code} 重算失败: {e}", flush=True) + return 0.0 + + +def compute_rec_score(conn, code: str) -> int: + """五维复合推荐评分 0-100。RR高≠值得买,趋势+行业+信号综合判断。 + 维度:RR(0-35) + 信号(0-25) + 趋势(0-20) + 行业(0-10) + 区间(0-10)""" + try: + row = conn.execute( + "SELECT rr_ratio, timing_signal, tech_snapshot, sector_context, entry_low, entry_high " + "FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if not row: + return 0 + rr, sig, tech, sector, el, eh = row + rr = rr or 0; el = el or 0; eh = eh or 0 + + # ── 1. RR (0-35) ── + if rr >= 3.0: s_rr = 35 + elif rr >= 2.5: s_rr = 28 + elif rr >= 2.0: s_rr = 20 + elif rr >= 1.5: s_rr = 10 + else: s_rr = 0 + + # ── 2. 信号强度 (0-25) ── + sig_map = {"买入": 25, "可买入": 20, "可加仓": 15} + s_sig = sig_map.get(sig, 0) + + # ── 3. 技术趋势 (0-20) ── + tech_str = str(tech or '') + # 形态判定 + if '/bullish' in tech_str or '看涨' in tech_str: + s_trend = 15 + elif '/bearish' in tech_str or '看跌' in tech_str: + s_trend = 8 + else: + s_trend = 12 + # MA 排列加成 + import re as _re_ma + ma_vals = {} + for m in _re_ma.finditer(r'MA(\d+)=([\d.]+)', tech_str): + ma_vals[int(m.group(1))] = float(m.group(2)) + if all(k in ma_vals for k in [5,10,20,60]): + if ma_vals[5] > ma_vals[10] > ma_vals[20] > ma_vals[60]: + s_trend += 5 # 多头排列 + elif ma_vals[5] < ma_vals[10] < ma_vals[20] < ma_vals[60]: + s_trend -= 3 # 空头排列 + s_trend = max(0, min(20, s_trend)) + + # ── 4. 行业强弱 (0-10) ── + sec_str = str(sector or '') + if '领涨' in sec_str: + s_sec = 9 + elif '偏强' in sec_str or '上涨' in sec_str: + s_sec = 7 + elif '偏弱' in sec_str or '下跌' in sec_str: + s_sec = 3 + else: + s_sec = 5 + + # ── 5. 买入区间质量 (0-10) ── + s_zone = 0 + if el > 0 and eh > el: + zone_pct = (eh - el) / el * 100 + if zone_pct >= 5: s_zone = 10 + elif zone_pct >= 3: s_zone = 7 + elif zone_pct >= 2: s_zone = 4 + else: s_zone = 2 + + total = s_rr + s_sig + s_trend + s_sec + s_zone + conn.execute( + "UPDATE holding_strategies SET rec_score=? WHERE code=? AND status='active'", + (total, code)) + conn.commit() + # 高评分自动打推荐 tag(补 LLM 未打 tag 的缺口) + if total >= 50 and rr >= 2.0 and sig in ("买入", "可买入", "可加仓"): + _pos_v = conn.execute( + "SELECT position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if _pos_v and _pos_v[0] and '%' in str(_pos_v[0]): + conn.execute( + "UPDATE holding_strategies SET tag='current_recommend' WHERE code=? AND status='active' AND (tag IS NULL OR tag='')", + (code,)) + conn.commit() + return total + except Exception as e: + print(f" [SCORE] {code} 评分失败: {e}", flush=True) + return 0 + + +def sync_recommend_tag(conn, code: str, timing_signal: str): + """裸 SQL 调用方(batch_reassess / per_stock_reassess)的推荐 tag 同步。 + 动作级信号 → current_recommend;信号降级 → 清除 current_recommend; + active_manual(人工标记)永不动。与 XMPP 动作级告警同源(红线#12)。""" + try: + recompute_rr(conn, code) # 先入先算:保证 tag/入队/盯盘排序拿到新鲜 RR + _row = conn.execute( + "SELECT tag, timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + _old_tag = (_row[0] or '') if _row else '' + _old_sig = (_row[1] or '') if _row else '' + # 卖出/止盈 仅对持仓股算动作信号(没持仓卖什么) + _ACTION_BUY = ("买入", "可买入", "可加仓") + _ACTION_SELL = ("卖出", "止盈") + if timing_signal in _ACTION_SELL: + _h = conn.execute("SELECT shares FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + if not (_h and (_h[0] or 0) > 0): + timing_signal = "" # 非持仓的卖出/止盈不算动作信号 + # ── 仓位自动补全(2026-07-27 老爸:不明确就让它明确,不是丢弃)── + # LLM 经常输出"减仓或观望/中等仓位"等模糊表述,系统按公式自动计算。 + # 基础仓位 by RR(<1.5→不推荐,1.5~3→8%,3~5→12%,5+→15%) × 成长系数0.85(兜底) + # → 最终范围5-20% + if timing_signal in _ACTION_BUY: + import re as _re2 + _pos_r = conn.execute( + "SELECT rr_ratio, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _pos_r and not _re2.search(r'\d+(?:\.\d+)?\s*%', _pos_r[1] or ''): + _rr_pos = float(_pos_r[0] or 0) + if _rr_pos < 1.5: + _pct = 0 # RR不推荐 + elif _rr_pos < 3: + _pct = 8 + elif _rr_pos < 5: + _pct = 12 + else: + _pct = 15 + # 系数兜底:成长股0.85(最保守),大盘系数1.0(中性) + _pct = round(_pct * 0.85, 0) + _pct = max(5, min(20, _pct)) + _pos_auto = f"{int(_pct)}%(系统按RR{_rr_pos:.1f}自动计算,见原建议仓位)" + # ── 股数换算(2026-07-27 老爸:方便快速操作)── + # 2026-07-27 修正:按总资产算仓位,不用现金(现金波动剧烈) + try: + _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + _ta = conn.execute("SELECT total_assets FROM portfolio_summary WHERE id=1").fetchone() + if _lp and _lp[0] and _ta and _ta[0]: + _price = float(_lp[0]) + _total = float(_ta[0]) + _shares_raw = _total * _pct / 100.0 / _price + if _shares_raw >= 100: + _shares = int(_shares_raw / 100) * 100 # A股整手 + else: + _shares = int(_shares_raw) + if _shares > 0: + _pos_auto = f"{int(_pct)}% ≈ {_shares}股(系统按RR{_rr_pos:.1f}自动计算)" + except Exception: + pass + conn.execute( + "UPDATE holding_strategies SET position_advice=? WHERE code=? AND status='active'", + (_pos_auto, code)) + conn.commit() + print(f" [AUTO-POS] {code} 仓位'{_pos_r[1]}'→'{_pos_auto}'", flush=True) + if timing_signal in _ACTION_BUY or timing_signal in _ACTION_SELL: + # ── 买入RR质量门禁(2026-07-27 老爸:RR<2.0的买入不值得推荐,tag都不能打,防盯盘垃圾)── + # 此前tag先打、enqueue再查RR,结果RR<2.0的tag已落盯盘,与XMPP不一致。 + # 已持仓股不再重复推荐(2026-07-28 老爸:已买了的票该在持仓不在推荐) + _should_tag = True + _owned = conn.execute( + "SELECT shares FROM holdings WHERE code=? AND is_active=1 AND shares>0", + (code,)).fetchone() + if _owned and _owned[0] and _owned[0] > 0: + _should_tag = False # 已持仓,不再重复推荐 + if _old_tag == 'current_recommend': + conn.execute("UPDATE holding_strategies SET tag='' WHERE code=? AND status='active'", (code,)) + conn.commit() + elif timing_signal in _ACTION_BUY: + _rr_chk = conn.execute( + "SELECT rr_ratio FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _rr_chk and (_rr_chk[0] or 0) < 2.0: + print(f" [TAG SYNC] {code} RR={_rr_chk[0]:.2f}<2.0,不打推荐tag", flush=True) + _should_tag = False + if _should_tag: + conn.execute( + "UPDATE holding_strategies SET tag='current_recommend' " + "WHERE code=? AND status='active' AND (tag IS NULL OR tag != 'active_manual')", + (code,)) + conn.commit() + else: + # RR不达标 → 清除旧tag(如果打了) + if _old_tag == 'current_recommend': + conn.execute("UPDATE holding_strategies SET tag='' WHERE code=? AND status='active'", (code,)) + conn.commit() + # 入队条件(2026-07-27 老爸:tag新鲜转成、信号转入动作级、或卖出信号 — 三个场景均需入队) + _old_action = _old_sig in _ACTION_BUY or _old_sig in _ACTION_SELL + _new_action = timing_signal in _ACTION_BUY or timing_signal in _ACTION_SELL + if (_should_tag and _old_tag != 'current_recommend') or (not _old_action and _new_action): + enqueue_recommend(conn, code) # 新推荐 → 摘要队列(batch 结束统一发) + elif _old_tag == 'current_recommend': + # 空信号或非动作信号 → 清除自动推荐(active_manual 不动) + conn.execute( + "UPDATE holding_strategies SET tag='' " + "WHERE code=? AND status='active' AND tag='current_recommend'", + (code,)) + conn.commit() + # ── 策略版本追踪(2026-07-27 老爸:每次tag变更都记录到评估表)── + track_strategy_version(conn, code) + except Exception as e: + print(f" [TAG SYNC] {code} 失败: {e}", flush=True) + + +def enqueue_recommend(conn, code: str): + """新推荐入摘要队列(防逐只轰炸)。batch_reassess 跑完后 flush_rec_digest 统一发一条。 + 校验(2026-07-24 老爸"阿猫阿狗"事件后加严): + 1. tag=current_recommend 且信号为动作级 + 2. RR(中值)>=2.0(1.5边缘的平庸推荐一律拦下) + 3. position_advice 必须含明确仓位%("减仓或观望/不新建仓"不算推荐) + 4. 买入区必须有效(区—~—/0~0 不入) + 5. 买入信号时现价不得在区上沿 5% 以上(追高不买)""" + try: + import json as _j, re as _re + from datetime import datetime as _dt + row = conn.execute( + "SELECT name, timing_signal, tag, entry_low, entry_high, stop_loss, take_profit, " + "rr_ratio, rr_low, rr_high, position_advice, full_analysis, rec_score FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not row: + return + name, sig, tag, el, eh, sl, tp, rr, rr_lo, rr_hi, pos, fa, score = row + if tag != 'current_recommend' or sig not in ("买入", "可买入", "可加仓", "卖出", "止盈"): + print(f" [REC] {code} 非有效推荐(tag={tag},sig={sig}),不入队", flush=True) + return False + # ── 买入类质量闸(卖出/止盈不受 RR/仓位限制——那是风控动作)── + if sig in ("买入", "可买入", "可加仓"): + if (rr or 0) < 2.0: + print(f" [REC] {code} RR={rr}<2.0 平庸推荐,不入队", flush=True) + return False + if not _re.search(r'\d+(?:\.\d+)?\s*%', pos or ''): + print(f" [REC] {code} 仓位非明确%({pos}),不入队", flush=True) + return False + if not (el and eh and el > 0 and eh > el): + print(f" [REC] {code} 买入区缺失/无效({el}~{eh}),不入队", flush=True) + return False + try: + _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if _lp and _lp[0] and _lp[0] > eh * 1.05: + print(f" [REC] {code} 现价{_lp[0]}超区上沿{eh}5%,追高不入队", flush=True) + return False + except Exception: + pass + # 提取【最终新策略】段作为推荐依据摘要 + fa_text = fa or "" + strat = "" + for marker in ("【最终新策略】", "【综合结论】"): + idx = fa_text.find(marker) + if idx >= 0: + strat = fa_text[idx:idx + 450] + break + qf = '/home/hmo/MoFin/gateway/logs/rec_digest_queue.jsonl' + import os as _os + _os.makedirs(_os.path.dirname(qf), exist_ok=True) + with open(qf, 'a', encoding='utf-8') as f: + f.write(_j.dumps({"code": code, "name": name, "signal": sig, + "entry_low": el, "entry_high": eh, "stop_loss": sl, + "take_profit": tp, "rr": rr, "rr_low": rr_lo, "rr_high": rr_hi, + "position": pos, "score": score or 0, + "strategy_excerpt": strat, + "full_analysis": fa_text[:2500], + "ts": _dt.now().isoformat()}, ensure_ascii=False) + "\n") + print(f" [REC] {code} 已入推荐摘要队列", flush=True) + return True + except Exception as e: + print(f" [REC] {code} 入队失败: {e}", flush=True) + return False + + +def track_strategy_version(conn, code: str): + """版本化策略追踪:每次策略变更自动记录新版本。 + 跟踪所有策略状态(不限于 tag='current_recommend'),tag 清除时也记录。""" + try: + row = conn.execute( + "SELECT name, timing_signal, rec_score, rr_ratio, entry_low, entry_high, " + "stop_loss, take_profit, position_advice, tag FROM holding_strategies " + "WHERE code=? AND status='active'", (code,)).fetchone() + if not row: + return + name, sig, score, rr, el, eh, sl, tp, pos, tag = row + # 空壳策略(无信号/无买入区)不追踪 + if not sig or (not el and not eh): + return + + lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + price = lp[0] if lp and lp[0] else 0 + mid = round((el + eh) / 2, 2) if el > 0 and eh > el else 0 + + # 查上一个版本 + prev = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit, rr_ratio, rec_score, " + "status FROM strategy_tracking WHERE code=? ORDER BY id DESC LIMIT 1", + (code,)).fetchone() + + # 计算版本号 + # 计算版本号 + last_ver = conn.execute( + "SELECT MAX(version_seq) FROM strategy_tracking WHERE code=?", (code,)).fetchone() + ver = (last_ver[0] or 0) + 1 if last_ver else 1 + + if prev and prev[6] == 'active': # 上一版本还在进行中 + if (abs((prev[0] or 0) - (el or 0)) < 0.01 and + abs((prev[1] or 0) - (eh or 0)) < 0.01 and + abs((prev[2] or 0) - (sl or 0)) < 0.01 and + abs((prev[3] or 0) - (tp or 0)) < 0.01): + # 参数没变,只更新评分和RR + conn.execute( + "UPDATE strategy_tracking SET rec_score=?, rr_ratio=?, price_at_track=?, " + "timing_signal=?, position_advice=? WHERE id=(" + "SELECT id FROM strategy_tracking WHERE code=? ORDER BY id DESC LIMIT 1)", + (score, rr, price, sig, pos, code)) + conn.commit() + return + + # 有变更 → 追加新版本 + change = "" + if prev: + parts = [] + if abs((prev[0] or 0) - (el or 0)) > 0.5: parts.append(f"区{prev[0]}→{el}") + if abs((prev[2] or 0) - (sl or 0)) > 0.5: parts.append(f"损{prev[2]}→{sl}") + if abs((prev[3] or 0) - (tp or 0)) > 0.5: parts.append(f"盈{prev[3]}→{tp}") + if abs((prev[5] or 0) - (score or 0)) >= 5: parts.append(f"评分{prev[5]}→{score}") + change = "; ".join(parts) if parts else "" + + conn.execute(""" + INSERT INTO strategy_tracking + (code, name, version_seq, tracked_at, timing_signal, rec_score, rr_ratio, + entry_low, entry_high, entry_mid, stop_loss, take_profit, + position_advice, price_at_track, change_summary) + VALUES (?,?,?,datetime('now','localtime'),?,?,?,?,?,?,?,?,?,?,?) + """, (code, name, ver, sig, score, rr, el, eh, mid, sl, tp, pos, price, change)) + conn.commit() + if change: + print(f" [TRACK] {code} v{ver}: {change}", flush=True) + except Exception as e: + print(f" [TRACK] {code} 版本记录失败: {e}", flush=True) + + +def check_strategy_outcomes(conn): + """检查所有 active 追踪版本是否触发 SL/TP,自动关闭并记录理论盈亏""" + active = conn.execute(""" + SELECT id, code, entry_mid, stop_loss, take_profit, rr_ratio + FROM strategy_tracking WHERE status='active' + """).fetchall() + + updated = 0 + for r in active: + tid, code, mid, sl, tp, rr = r + lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if not lp or not lp[0]: + continue + price = float(lp[0]) + mid = mid or price # fallback + + closed = False + if tp and tp > 0 and price >= tp: + pnl_pct = round((tp - mid) / mid * 100, 1) if mid > 0 else 0 + conn.execute(""" + UPDATE strategy_tracking SET status='hit_tp', closed_at=datetime('now','localtime'), + close_price=?, close_reason='止盈触发', theoretical_pnl=? + WHERE id=? + """, (price, pnl_pct, tid)) + print(f" [TRACK] {code} v{tid} 止盈! {price}≥{tp} +{pnl_pct}%", flush=True) + closed = True + elif sl and sl > 0 and price <= sl: + pnl_pct = round((sl - mid) / mid * 100, 1) if mid > 0 else -5 + conn.execute(""" + UPDATE strategy_tracking SET status='hit_sl', closed_at=datetime('now','localtime'), + close_price=?, close_reason='止损触发', theoretical_pnl=? + WHERE id=? + """, (price, pnl_pct, tid)) + print(f" [TRACK] {code} v{tid} 止损! {price}≤{sl} {pnl_pct}%", flush=True) + closed = True + + if closed: + updated += 1 + + if updated: + conn.commit() + # ── 统计验证(2026-07-28 老爸:胜率/夏普/最大回撤)── + try: + closed = conn.execute(""" + SELECT theoretical_pnl FROM strategy_tracking + WHERE status IN ("hit_tp", "hit_sl", "manual_close") AND theoretical_pnl IS NOT NULL + """).fetchall() + if closed: + wins = [p[0] for p in closed if p[0] > 0] + losses = [abs(p[0]) for p in closed if p[0] < 0] + win_rate = len(wins) / len(closed) if closed else 0.55 + avg_win = sum(wins) / len(wins) if wins else 0 + avg_loss = sum(losses) / len(losses) if losses else 0 + sharpe = (avg_win * win_rate - avg_loss * (1 - win_rate)) / (avg_loss if avg_loss > 0 else 1) if avg_loss > 0 else 0 + max_dd = max([abs(p[0]) for p in closed if p[0] < 0], default=0) + print(f" [STATS] 胜率{win_rate:.0%} 夏普{sharpe:.2f} 最大回撤{max_dd:.1f}%", flush=True) + except Exception as _se: + print(f" [STATS] 统计失败: {_se}", flush=True) + return updated + + +def flush_rec_digest(max_items=5): + """把队列里的新推荐聚成一条 XMPP 摘要(RR 降序,最多 max_items 只)。 + 头部 1-2 只附带策略依据摘要+按现金的操盘建议。""" + import json as _j, os as _os, sqlite3 as _sq + qf = '/home/hmo/MoFin/gateway/logs/rec_digest_queue.jsonl' + if not _os.path.exists(qf): + return 0 + try: + with open(qf, encoding='utf-8') as f: + items = [_j.loads(l) for l in f if l.strip()] + except Exception: + return 0 + if not items: + return 0 + _os.remove(qf) + + # ── 快照回库校验(2026-07-24 老爸:推荐和XMPP同步)── + # 队列是打标瞬间的快照;flush 前回库读实时 信号/RR/tag, + # 信号降级为弱信号或RR跌破2.0的条目直接丢弃——XMPP说的必须和盯盘一致。 + import sqlite3 as _sq0 + from datetime import datetime as _ddt + _now = _ddt.now() + _h, _m, _w = _now.hour, _now.minute, _now.weekday() + _market_open = _w < 5 and ((_h == 9 and _m >= 30) or (10 <= _h < 15)) + _vconn = _sq0.connect("/home/hmo/MoFin/data/mofin.db") + _WEAK = ("信号不充分", "关注", "弱势持有", "观望", "持有", "") + _live = [] + for it in items: + r = _vconn.execute( + "SELECT timing_signal, rr_ratio, tag, reassessed_at FROM holding_strategies WHERE code=? AND status='active'", + (it['code'],)).fetchone() + if not r: + print(f" [REC] {it['code']} 已不在库,丢弃", flush=True) + continue + cur_sig, cur_rr, cur_tag, cur_ra = r[0] or "", r[1] or 0, r[2] or "", r[3] or "" + if cur_tag != 'current_recommend': + print(f" [REC] {it['code']} tag已撤销({cur_tag}),丢弃", flush=True) + continue + # ── 数据时效校验(2026-07-27 老爸:盘前分析盘中推送=过期数据误导)── + # 分析时间在开盘前且现在已开盘 → 触发盘中重评(用实时数据分析,不推旧分析) + if _market_open and cur_ra: + try: + _ra_dt = _ddt.fromisoformat(str(cur_ra)[:19]) + # 分析在 08:00-09:29 之间做的 = 盘前分析 → 盘中触发重评 + if (_ra_dt.hour >= 8 and (_ra_dt.hour < 9 or (_ra_dt.hour == 9 and _ra_dt.minute < 30))) \ + and (_ddt.now() - _ra_dt).total_seconds() > 120: + try: + import subprocess as _sp + _re = _sp.run( + ["python3", "/home/hmo/MoFin/deploy/profile-scripts/per_stock_reassess.py", it['code']], + capture_output=True, text=True, timeout=90) + if _re.returncode == 0: + print(f" [REC] {it['code']} 盘中重评完成,用实时数据更新策略", flush=True) + # 重新读库获取更新后数据 + r = _vconn.execute( + "SELECT timing_signal, rr_ratio, tag FROM holding_strategies WHERE code=? AND status='active'", + (it['code'],)).fetchone() + if r: + cur_sig, cur_rr, cur_tag = r[0] or "", r[1] or 0, r[2] or "" + if cur_tag != 'current_recommend': + print(f" [REC] {it['code']} 重评后tag撤销,丢弃", flush=True) + continue + else: + print(f" [REC] {it['code']} 盘中重评失败(rc={_re.returncode}),标记警告", flush=True) + it['_stale_warn'] = "⚠️ 盘前分析(已开盘未能及时重评,请结合实时盘面判断)" + except subprocess.TimeoutExpired: + print(f" [REC] {it['code']} 盘中重评超时,标记警告", flush=True) + it['_stale_warn'] = "⚠️ 盘前分析(已开盘未能及时重评,请结合实时盘面判断)" + except Exception: + pass + if it.get('signal') in ("买入", "可买入", "可加仓"): + if cur_sig in _WEAK: + print(f" [REC] {it['code']} 信号降级为'{cur_sig}',丢弃", flush=True) + continue + if cur_rr < 2.0: + print(f" [REC] {it['code']} 实时RR={cur_rr}<2.0,丢弃", flush=True) + continue + it['signal'] = cur_sig # 用实时信号发 + it['rr'] = cur_rr + _live.append(it) + _vconn.close() + items = _live + if not items: + print(" [REC] 快照校验后无有效推荐,不发digest", flush=True) + return 0 + + _SELL_SIGS = ("卖出", "止盈") + # 卖出/止盈是释放现金的操作,不占买入预算,单独一组排最前 + sells = [x for x in items if x.get('signal') in _SELL_SIGS] + buys_all = [x for x in items if x.get('signal') not in _SELL_SIGS] + buys_all.sort(key=lambda x: (x.get('score') or 0, x.get('rr') or 0), reverse=True) + items = sells + buys_all + top = items[:max_items] + + # ── 现金预算(决定操盘建议 + 换仓策略):只对买入项计算,卖出不占预算 ── + cash_note = "" + rotation_note = "" + buys = [] + queued = [] + try: + conn = _sq.connect("/home/hmo/MoFin/data/mofin.db") + conn.row_factory = _sq.Row + r = conn.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone() + if r and r[1]: + cash, total = r[0] or 0, r[1] + budget_pct = cash / total * 100 + cum = 0.0 + buys = [] + queued = [] + for it in buys_all: # 只遍历买入项;sells 永远可执行 + import re as _re + m = _re.search(r'(\d+(?:\.\d+)?)\s*%', it.get('position') or '') + pct = float(m.group(1)) if m else 8.0 + if (it.get('rr') or 0) >= 2.0 and cum + pct <= budget_pct + 1e-9: + buys.append((it, pct)) + cum += pct + else: + queued.append((it, pct)) + cash_note = (f"现金{cash/10000:.1f}万({budget_pct:.1f}%)|按预算本次可执行: " + + ("、".join(f"{b[0].get('name') or b[0]['code']}≈{b[1]:.0f}%" for b in buys) if buys else "无") + + (f"(合计≈{cum:.0f}%)" if buys else "")) + # ── 换仓策略:有排队推荐时,找可减的弱持仓来腾挪 ── + if queued: + weak = conn.execute(""" + SELECT hs.code, hs.name, hs.timing_signal, h.position_pct, h.cost, lp.price, lp.change_pct + FROM holding_strategies hs + JOIN holdings h ON hs.code = h.code AND h.is_active = 1 + LEFT JOIN live_prices lp ON hs.code = lp.code + WHERE hs.status='active' AND h.shares > 0 + AND hs.timing_signal IN ('弱势持有','观望','持有') + ORDER BY CASE hs.timing_signal WHEN '弱势持有' THEN 0 WHEN '观望' THEN 1 ELSE 2 END, + h.position_pct DESC + """).fetchall() + if weak: + need_pct = queued[0][1] + plan = [] + freed = 0.0 + for w in weak: + if freed >= need_pct: + break + plan.append(w) + freed += w["position_pct"] or 0 + q0 = queued[0][0] + _names = "+".join(str(w['name']) for w in plan) + _sigs = ",".join(sorted({w['timing_signal'] for w in plan})) + rotation_note = ("🔄 换仓建议:现金不足买 " + str(q0.get('name') or q0['code']) + + f"(RR={q0.get('rr') or 0})→ 可减 {_names}" + + f"({_sigs},腾出≈{freed:.0f}%仓位)换入") + conn.close() + except Exception as _re: + print(f" [REC] 换仓计算异常: {_re}", flush=True) + + lines = [f"📈 新增推荐 {len(items)} 只(按RR排序):"] + # 与盯盘推荐区一致的 可执行/排队 徽章(2026-07-24 老爸:推荐和XMPP同步) + _exec_codes = {b[0]['code'] for b in buys} | {s['code'] for s in sells} + for i, it in enumerate(top): + _rr_mid = it.get('rr') or 0 + _rr_lo, _rr_hi = it.get('rr_low') or 0, it.get('rr_high') or 0 + if _rr_lo and _rr_hi and _rr_lo != _rr_hi: + _lo_val = min(_rr_lo, _rr_hi) + _hi_val = max(_rr_lo, _rr_hi) + _rr_txt = f"RR={_rr_mid}({_lo_val}~{_hi_val})" + else: + _rr_txt = f"RR={_rr_mid}" + _el = it.get('entry_low') or 0 + _eh = it.get('entry_high') or 0 + _mid = f"{(_el+_eh)/2:.2f}" if _el > 0 and _eh > _el else "—" + _badge = "💰可执行" if it['code'] in _exec_codes else "⏳排队" + _score = it.get('score') or 0 + _score_txt = f" [{_score}分]" if _score else "" + lines.append(f"• {_badge}{_score_txt} {it.get('name') or it['code']}({it['code']}) {it['signal']}" + f" 区{_el or '—'}→{_mid}←{_eh or '—'}" + f" 损{it.get('stop_loss') or '—'} 盈{it.get('take_profit') or '—'}" + f" {_rr_txt} 仓位{it.get('position') or '—'}") + if it.get('_stale_warn'): + lines.append(f" ⚠️ {it['_stale_warn']}") + # 所有推荐都附完整策略依据 + if it.get('strategy_excerpt'): + lines.append(f" 依据: {it['strategy_excerpt']}") + elif it.get('full_analysis'): + lines.append(f" 依据: {it['full_analysis']}") + if len(items) > max_items: + lines.append(f"…另有 {len(items) - max_items} 只详见盯盘推荐操作区") + if cash_note: + lines.append("💰 " + cash_note) + if rotation_note: + lines.append(rotation_note) + try: + import sys as _s, os as _o2 + _s.path.insert(0, '/home/hmo/MoFin/deploy/profile-scripts') + from alert_helper import notify, ACTION + return notify("推荐操作", "\n".join(lines), ACTION) + except Exception as e: + print(f" [REC] 摘要推送失败: {e}", flush=True) + return False + + +def push_recommend_alert(conn, code: str): + """推荐操作 XMPP 推送(tag 转为 current_recommend 时调用,全路径统一)。 + 质量门禁:实时价>0、区间有效(下沿<上沿<下沿x3)、现价不超上沿5%、 + 损<下沿且在(0.5x~1.0x)现价内、盈>上沿>损。不过不推。""" + try: + row = conn.execute( + "SELECT name, timing_signal, entry_low, entry_high, stop_loss, take_profit, " + "rr_ratio, position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not row: + return False + name, sig, el, eh, sl, tp, rr, pos = row + lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + price = lp[0] if lp and lp[0] else 0 + el, eh, sl, tp = el or 0, eh or 0, sl or 0, tp or 0 + # ── 门禁 ── + if price <= 0: + print(f" [ALERT] {code} 无实时价,不推", flush=True); return False + if not (el > 0 and eh > el and eh < el * 3): + print(f" [ALERT] {code} 区间无效({el}~{eh}),不推", flush=True); return False + if price > eh * 1.05: + print(f" [ALERT] {code} 现价{price}高超上沿{eh}5%,不推", flush=True); return False + if not (sl > 0 and sl < el and price * 0.5 <= sl <= price): + print(f" [ALERT] {code} 止损{sl}不合理,不推", flush=True); return False + if not (tp > eh and tp > sl): + print(f" [ALERT] {code} 止盈{tp}不合理,不推", flush=True); return False + import sys as _s, os as _o + _s.path.insert(0, _o.path.dirname(_o.path.abspath(__file__))) + from alert_helper import notify, ACTION + _mid_xmpp = f"{(el+eh)/2:.2f}" if el > 0 and eh > el else "—" + msg = (f"📈 {name or code}({code}) 价{price}→12维{sig}!" + f"区间{el}→{_mid_xmpp}←{eh} 损{sl} 盈{tp} RR={rr or 0} 仓位{pos or '-'}") + return notify("买入信号", msg, ACTION) + except Exception as e: + print(f" [ALERT] {code} 推送异常: {e}", flush=True) + return False + + +def snapshot_strategy_history(conn, code: str, source_trigger: str = "write_holding_strategy"): + """在修改前快照当前策略到 strategy_history 表。永不抛异常。""" + try: + row = conn.execute( + "SELECT code, name, decision_type, strategy_type, full_analysis, " + "action, timing_signal, entry_low, entry_high, stop_loss, take_profit, " + "position_advice, rr_ratio, version, reassessed_at " + "FROM holding_strategies WHERE code=? AND status='active'", + (code,) + ).fetchone() + if not row: + return + now = datetime.now().isoformat() + conn.execute(""" + INSERT INTO strategy_history + (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) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + row[0], row[1], row[2], row[3], + row[4], row[5], row[6], + row[7], row[8], row[9], row[10], + row[11], row[12], row[13], + source_trigger, row[14], now + )) + conn.commit() + # 每只股票只保留最近20条历史 + conn.execute(""" + DELETE FROM strategy_history WHERE code=? AND id NOT IN ( + SELECT id FROM strategy_history WHERE code=? ORDER BY snapshotted_at DESC LIMIT 20 + ) + """, (code, code)) + conn.commit() + except Exception as e: + print(f" [SNAPSHOT] {code} 快照失败: {e}", flush=True) + + +def write_holding_strategy(conn, code: str, name: str, data: dict, + source_trigger: str = "write_holding_strategy") -> tuple[bool, str]: + """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" + try: + # ── 覆写前快照旧行 ── + snapshot_strategy_history(conn, code, source_trigger) + + + currency = data.get('currency', 'CNY') + # Serialize JSON fields + import json as _json + trigger_j = _json.dumps(data.get('trigger', {}), ensure_ascii=False) if isinstance(data.get('trigger'), dict) else str(data.get('trigger', '{}')) + changelog_j = _json.dumps(data.get('changelog', []), ensure_ascii=False) if isinstance(data.get('changelog'), list) else str(data.get('changelog', '[]')) + quality_issues_j = _json.dumps(data.get('quality_issues', {}), ensure_ascii=False) if isinstance(data.get('quality_issues'), dict) else data.get('quality_issues_json', '') + signal_factors_j = _json.dumps(data.get('signal_factors', []), ensure_ascii=False) if isinstance(data.get('signal_factors'), list) else data.get('signal_factors_json', '') + + # ── 推荐操作 tag 同步语义(与 XMPP 动作级信号同源,红线#12)── + # 动作级信号 → tag=current_recommend(进盯盘"推荐操作"区) + # 信号降级 → 清除 current_recommend(区域同步消失) + # active_manual(人工标记)永远不被自动流程覆盖或清除 + _ACTION_SIGNALS = ("买入", "可买入", "可加仓", "卖出", "止盈") + _RISK_SIGNALS = ("卖出", "止盈") + _existing_fa = data.get('full_analysis', '') + _existing_ra = data.get('reassessed_at', '') + _tag_absent = 'tag' not in data + _new_sig = data.get('timing_signal', '') or '' + _explicit_tag = data.get('tag', None) + _old_tag = '' + _old_sig = '' + _old_ra = '' + _old_action = '' + if True: + try: + _old = conn.execute("SELECT full_analysis, reassessed_at, tag, timing_signal, action FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone() + if _old: + if not _existing_fa: + if _old[0]: _existing_fa = _old[0] + if _old[1]: _existing_ra = _old[1] + _old_tag = _old[2] or '' + _old_sig = _old[3] or '' + _old_ra = _old[1] or '' + _old_action = _old[4] or '' + except: + pass + # ── 信号权威层级(2026-07-22):新鲜(<20h)12维动作级信号, + # 技术路径(regenerate_all/price_monitor)无权降级为 关注/信号不充分/持有。 + # 只有 LLM 路径(batch_12d/per_stock_12d)可以覆盖。 + # 2026-07-27 老爸补:卖出/止盈的保护漏了(技术路径把卖出→持有导致盯盘显示矛盾)── + _TECHNICAL_PATHS = ('write_holding_strategy',) + if source_trigger in _TECHNICAL_PATHS \ + and _old_sig in ("买入", "可买入", "可加仓", "卖出", "止盈") \ + and _new_sig not in _ACTION_SIGNALS and _old_ra: + try: + from datetime import datetime as _ddt, timedelta as _dtd + _ra_dt = _ddt.fromisoformat(str(_old_ra)[:19]) + if (_ddt.now() - _ra_dt) < _dtd(hours=20): + print(f" [AUTHORITY] {code} 保留新鲜12维信号'{_old_sig}'({_old_ra[:16]})," + f"拒绝技术路径降级为'{_new_sig}'", flush=True) + _new_sig = _old_sig + data['timing_signal'] = _old_sig + except Exception: + pass + # ── 策略参数权威保护(2026-07-27 老爸:技术路径每2分钟覆写12维的Zone/SL/TP/Position→RR波动→盯盘和XMPP不一致)── + # 新鲜(<20h)12维分析的技术参数+仓位不允许被技术路径覆写。 + # 2026-07-27 坑:_old_ra=None 时权威保护永不触发(很多股票的reassessed_at为空), + # 导致系统自动计算的仓位被反复踩回"中等仓位"。加入兜底:仓位含"%(系统按"即永保。 + if source_trigger in _TECHNICAL_PATHS: + # 兜底:系统自动计算的仓位永久保护(不含 %(系统按 的不保护,即只有 LLM 仓和系统仓被保护) + _old_pos = conn.execute( + "SELECT position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + _old_pos_val = (_old_pos[0] or '') if _old_pos else '' + if _old_pos_val and '系统按' in str(_old_pos_val): + data['position_advice'] = _old_pos_val + # 被保护仓位触发时顺便保护参数(无论 _old_ra 是否空) + _op = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if _op and float(_op[0] or 0) > 0: + data['entry_low'] = float(_op[0]) + data['entry_high'] = float(_op[1]) + data['stop_loss'] = float(_op[2]) + data['take_profit'] = float(_op[3]) + print(f" [AUTHORITY-POS] {code} 保护系统仓位'{_old_pos_val[:30]}'", flush=True) + elif _old_ra: + try: + from datetime import datetime as _ddt3, timedelta as _dtd3 + _ra_dt3 = _ddt3.fromisoformat(str(_old_ra)[:19]) + if (_ddt3.now() - _ra_dt3) < _dtd3(hours=20): + _old_params = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit, position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if _old_params: + _keys = ['entry_low','entry_high','stop_loss','take_profit','position_advice'] + _vals = [v if v else '' for v in _old_params] + for i, k in enumerate(_keys): + if i < 4 and float(_vals[i] or 0) > 0: + data[k] = float(_vals[i]) + elif i == 4 and str(_vals[i]).strip(): + data[k] = str(_vals[i]) + print(f" [AUTHORITY-PARAM] {code} 保留12维参数(区{_vals[0]}~{_vals[1]} 损{_vals[2]} 盈{_vals[3]} pos={_vals[4]})", flush=True) + except Exception: + pass + # ── action 权限保护(与信号同一权威层级,2026-07-22)── + # 技术路径不得覆盖新鲜(<20h)12维 action。 + # 根治:技术路径写的"盈亏比不足1:1.5不建议买入"旧 action 与12维买入分析同框矛盾。 + if source_trigger not in ('batch_12d', 'per_stock_12d') and _old_action and _old_ra: + try: + from datetime import datetime as _ddt2, timedelta as _dtd2 + if (_ddt2.now() - _ddt2.fromisoformat(str(_old_ra)[:19])) < _dtd2(hours=20): + data['action'] = _old_action + except Exception: + pass + if _old_tag == 'active_manual': + _existing_tag = 'active_manual' # 人工标记不可动 + elif _explicit_tag is not None: + _existing_tag = _explicit_tag # 显式传入优先(含''清除) + elif _new_sig in _ACTION_SIGNALS and source_trigger in ('batch_12d', 'per_stock_12d'): + _existing_tag = 'current_recommend' # 仅 LLM 路径可创建推荐(防技术路径抖动) + elif _new_sig and _old_tag == 'current_recommend' and source_trigger in ('batch_12d', 'per_stock_12d'): + _existing_tag = '' # 仅 LLM 路径可撤销推荐 + else: + _existing_tag = _old_tag # 技术路径一律不动 tag + + # ── 类型守卫:shares 必须是数值,防止字符串写入导致下游崩溃 ── + _shares = data.get('shares', 0) + if not isinstance(_shares, (int, float)): + print(f" [TYPE GUARD] {code} shares类型异常({type(_shares).__name__}={_shares!r}),重置为0", flush=True) + _shares = 0 + + # ── action 权限保护已在上方信号权威块中统一处理 ── + + # ── UPSERT(2026-07-23 老爸:新增计算列不该用DELETE+INSERT,该用UPDATE)── + # 只写本函数拥有的列;rr_low/rr_high(recompute_rr拥有)、superseded_at(data_governance + # 拥有)、created_at(创建时间)不在写集内 → 天然保留,未来新增计算列自动免疫。 + # 同时消除 DELETE→INSERT 之间崩溃=行丢失的原子性窗口,以及 created_at 被重置的副作用。 + conn.execute(""" + INSERT INTO holding_strategies + (code, name, version, price, cost, shares, stop_loss, take_profit, + entry_low, entry_high, currency, strategy_type, action, + timing_signal, rr_ratio, tech_snapshot, stock_category, + sector_context, status, trigger_json, changelog_json, + source, reason, updated_at, + avg_price, decision_timestamp, note, quality_check, + quality_checked_at, quality_issues_json, position_advice, + signal_factors_json, time_horizon, decision_type, + full_analysis, reassessed_at, tag) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, + datetime('now','localtime'), + ?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, version=excluded.version, price=excluded.price, + cost=excluded.cost, shares=excluded.shares, + stop_loss=excluded.stop_loss, take_profit=excluded.take_profit, + entry_low=excluded.entry_low, entry_high=excluded.entry_high, + currency=excluded.currency, strategy_type=excluded.strategy_type, + action=excluded.action, timing_signal=excluded.timing_signal, + rr_ratio=excluded.rr_ratio, tech_snapshot=excluded.tech_snapshot, + stock_category=excluded.stock_category, sector_context=excluded.sector_context, + status=excluded.status, trigger_json=excluded.trigger_json, + changelog_json=excluded.changelog_json, source=excluded.source, + reason=excluded.reason, updated_at=excluded.updated_at, + avg_price=excluded.avg_price, decision_timestamp=excluded.decision_timestamp, + note=excluded.note, quality_check=excluded.quality_check, + quality_checked_at=excluded.quality_checked_at, + quality_issues_json=excluded.quality_issues_json, + position_advice=excluded.position_advice, + signal_factors_json=excluded.signal_factors_json, + time_horizon=excluded.time_horizon, decision_type=excluded.decision_type, + full_analysis=excluded.full_analysis, reassessed_at=excluded.reassessed_at, + tag=excluded.tag + """, ( + code, name, + data.get('version', 1), data.get('price'), data.get('cost'), + _shares, data.get('stop_loss'), data.get('take_profit'), + data.get('entry_low'), data.get('entry_high'), currency, + data.get('strategy_type', 'holding'), data.get('action'), + data.get('timing_signal'), data.get('rr_ratio'), + data.get('tech_snapshot'), data.get('stock_category'), + data.get('sector_context'), data.get('status', 'active'), + trigger_j, changelog_j, + data.get('source'), data.get('reason'), + # new columns + data.get('avg_price', 0), + data.get('timestamp') or data.get('created_at', ''), + data.get('note', ''), + data.get('quality_check', ''), + data.get('quality_checked_at', ''), + quality_issues_j, + data.get('position_advice', ''), + signal_factors_j, + data.get('time_horizon', ''), + data.get('type', data.get('strategy_type', 'holding')), + # 保留full_analysis和reassessed_at(合并逻辑在上方完成) + _existing_fa, + _existing_ra, + _existing_tag, + )) + conn.commit() + # ── 策略版本追踪(每次策略写入后自动记录,2026-07-27)── + if _existing_tag == 'current_recommend': + track_strategy_version(conn, code) + # ── 推荐转场:LLM路径新转为 current_recommend → 记入摘要队列(不逐只推送)── + if _existing_tag == 'current_recommend' and _old_tag != 'current_recommend' \ + and source_trigger in ('batch_12d', 'per_stock_12d'): + enqueue_recommend(conn, code) + return True, f"策略 {code} 已写入" + except sqlite3.IntegrityError as e: + return False, f"币种约束: {e}" + except Exception as e: + return False, str(e) + + +def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]: + """批量写入持仓(替代 portfolio.json holdings[])""" + try: + conn.execute("BEGIN IMMEDIATE") + for h in holdings: + currency = str(h.get('currency', 'CNY')).upper() + if currency not in ('CNY', 'HKD'): + return False, f"非法币种: {currency}(必须 CNY 或 HKD)" + conn.execute(""" + INSERT INTO holdings (code, name, shares, cost, price, market_value, + change_pct, currency, position_pct, added_at, is_active) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, shares=excluded.shares, cost=excluded.cost, + price=excluded.price, market_value=excluded.market_value, + change_pct=excluded.change_pct, currency=excluded.currency, + position_pct=excluded.position_pct + """, ( + h.get('code'), h.get('name'), h.get('shares', 0), + h.get('cost'), h.get('price'), + h.get('market_value'), h.get('change_pct'), + h.get('currency', 'CNY'), h.get('position_pct'), + )) + conn.commit() + # ── 同步 holding_strategies(2026-07-27 老爸:导入持仓后盯盘应即时出现)── + for h in holdings: + code = h.get('code') + name = h.get('name', '') + shares = h.get('shares') or 0 + if not code or shares <= 0: + continue + existing = conn.execute( + "SELECT id, decision_type FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not existing: + # 全新持仓:创建基础策略条目 + conn.execute(""" + INSERT INTO holding_strategies (code, name, decision_type, strategy_type, + status, timing_signal, created_at) + VALUES (?, ?, '持仓策略', 'holding', 'active', '关注', datetime('now','localtime')) + """, (code, name)) + elif existing[1] != '持仓策略': + # 已有条目但类型不对(自选转持仓) + conn.execute( + "UPDATE holding_strategies SET decision_type='持仓策略' WHERE code=? AND status='active'", + (code,)) + conn.commit() + return True, f"已写入 {len(holdings)} 条持仓" + except sqlite3.IntegrityError as e: + conn.rollback() + return False, f"币种约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突(重试耗尽): {e}" +def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]: + """写入持仓汇总(替代 portfolio.json 顶层)""" + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute(""" + INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, + cash, frozen_cash, position_pct, total_pnl, currency, updated_at) + VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(id) DO UPDATE SET + total_assets=excluded.total_assets, total_mv=excluded.total_mv, + stock_value=excluded.stock_value, cash=excluded.cash, + frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, + total_pnl=excluded.total_pnl, currency=excluded.currency, + updated_at=datetime('now','localtime') + """, ( + data.get('total_assets'), data.get('total_mv'), data.get('stock_value'), + data.get('cash'), data.get('frozen_cash', 0), data.get('position_pct'), + data.get('total_pnl'), data.get('currency', 'CNY'), + )) + conn.commit() + return True, "汇总已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突: {e}" + + +def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]: + """写入自选股(写入 watchlist_stocks 表)""" + try: + conn.execute(""" + INSERT INTO watchlist_stocks (code, name, price, entry_low, entry_high, + stop_loss, currency, source, source_detail, notes, added_by, added_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, price=excluded.price, entry_low=excluded.entry_low, + entry_high=excluded.entry_high, stop_loss=excluded.stop_loss, + currency=excluded.currency, source=excluded.source, + source_detail=excluded.source_detail, notes=excluded.notes, + added_by=excluded.added_by + """, ( + stock.get('code'), stock.get('name'), stock.get('price'), + stock.get('entry_low'), stock.get('entry_high'), stock.get('stop_loss'), + stock.get('currency', 'CNY'), stock.get('source'), stock.get('source_detail'), + stock.get('notes'), stock.get('added_by'), + )) + conn.commit() + return True, f"自选 {stock.get('code')} 已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + + +def write_cash_log(conn, data: dict) -> tuple[bool, str]: + """记录现金变更(替代手动改 portfolio.json cash 字段)""" + try: + conn.execute(""" + INSERT INTO cash_log (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note) + VALUES (?,?,?,?,?,?,?) + """, ( + data.get('cash_before'), data.get('cash_after'), + data.get('frozen_before'), data.get('frozen_after'), + data.get('change_amount'), data.get('source', 'manual'), + data.get('note', ''), + )) + conn.commit() + return True, "现金变更已记录" + except Exception as e: + return False, str(e) + + +def query_cash_log(conn, limit: int = 20) -> list[dict]: + rows = conn.execute( + "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +# ═══ live_prices / mtf_cache / capital_flow_cache 写函数 ═══ + +def write_live_prices(conn, prices: dict): + """写入实时价格快照(替代 live_prices.json)""" + import json + for code, info in prices.items(): + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) VALUES (?,?,?,datetime('now','localtime'))", + (code, info.get('price'), info.get('change_pct')) + ) + +def read_live_prices(conn) -> dict: + rows = conn.execute("SELECT code, price, change_pct FROM live_prices").fetchall() + return {r['code']: {'price': r['price'], 'change_pct': r['change_pct']} for r in rows} + + +def write_mtf_cache(conn, code: str, data: dict): + """写入多周期缓存(替代 multi_tf_cache.json 单条)""" + import json + conn.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + +def read_mtf_cache(conn, code: str) -> dict: + import json + r = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() + return json.loads(r['cache_json']) if r else {} + + +def write_capital_flow_cache(conn, data: dict): + """写入资金流缓存(替代 capital_flow_cache.json)""" + import json + conn.execute("DELETE FROM capital_flow_cache") + conn.execute( + "INSERT INTO capital_flow_cache (cache_json, updated_at) VALUES (?,datetime('now','localtime'))", + (json.dumps(data, ensure_ascii=False),) + ) + +def read_capital_flow_cache(conn) -> dict: + import json + r = conn.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + return json.loads(r['cache_json']) if r else {} diff --git a/scripts/mo_data.py b/scripts/mo_data.py new file mode 100644 index 00000000..cb168a13 --- /dev/null +++ b/scripts/mo_data.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +mo_data.py — MoFin 统一数据层(纯 DB) + +所有数据从 SQLite 读取。不做 JSON fallback。 +JSON 文件已弃用,仅保留为历史备份。 + +用法: + from mo_data import read_portfolio, read_decisions, read_watchlist + + pf = read_portfolio() # 返回和 portfolio.json 一样的 dict 结构 + dec = read_decisions() # 返回和 decisions.json 一样的 dict 结构 + wl = read_watchlist() # 返回和 watchlist.json 一样的 dict 结构 +""" + +import sqlite3, json, sys +from datetime import datetime +from pathlib import Path + +DB_PATH = '/home/hmo/MoFin/data/mofin.db' +SCRIPT_DIR = Path('/home/hmo/MoFin/scripts') + + +def _get_db(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + return db + + +# ── portfolio ───────────────────────────────────────────────────── + +def read_portfolio(): + """返回 portfolio.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, shares, cost, price, market_value, " + "change_pct, currency, position_pct " + "FROM holdings WHERE is_active=1" + ).fetchall() + holdings = [] + for r in rows: + h = dict(r) + h['_currency'] = h.get('currency', 'CNY') + holdings.append(h) + + sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() + summary = dict(sum_row) if sum_row else {} + + db.close() + + return { + "holdings": holdings, + "total_assets": summary.get("total_assets", 0), + "total_mv": summary.get("total_mv", 0), + "stock_value": summary.get("stock_value", summary.get("total_mv", 0)), + "cash": summary.get("cash", 0), + "frozen_cash": summary.get("frozen_cash", 0), + "position_pct": summary.get("position_pct", 0), + "currency": summary.get("currency", "CNY"), + "updated_at": summary.get("updated_at", ""), + } + + +# ── decisions ───────────────────────────────────────────────────── + +def _parse_json(val, default): + if val: + try: return json.loads(val) + except: pass + return default + + +def read_decisions(): + """返回 decisions.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, version, price, cost, shares, " + "stop_loss, take_profit, entry_low, entry_high, " + "currency, strategy_type, action, timing_signal, " + "rr_ratio, tech_snapshot, stock_category, sector_context, " + "status, trigger_json, changelog_json, source, reason, " + "created_at, updated_at, " + "avg_price, decision_timestamp, note, quality_check, " + "quality_checked_at, quality_issues_json, position_advice, " + "signal_factors_json, time_horizon, decision_type, tag " + "FROM holding_strategies WHERE status IN ('active','updated') " + "ORDER BY code" + ).fetchall() + + decisions = [] + for r in rows: + d = dict(r) + d['trigger'] = _parse_json(r['trigger_json'], {}) + d['changelog'] = _parse_json(r['changelog_json'], []) + d['quality_issues'] = _parse_json(r['quality_issues_json'], {}) + d['signal_factors'] = _parse_json(r['signal_factors_json'], []) + d['timestamp'] = r['decision_timestamp'] or r['created_at'] or '' + d['type'] = r['decision_type'] or r['strategy_type'] or '持仓策略' + decisions.append(d) + + db.close() + + return { + "decisions": decisions, + "total": len(decisions), + "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } + + +# ── watchlist ───────────────────────────────────────────────────── + +def read_watchlist(): + """返回 watchlist 等价 dict。纯 DB。 + 从 holding_strategies(自选策略)读取,watchlist_stocks 已废弃。""" + db = _get_db() + # 主数据源:holding_strategies 自选策略 + rows = db.execute( + "SELECT code, name, price, entry_low, entry_high, " + "stop_loss, currency, updated_at " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略'" + ).fetchall() + + stocks = [] + seen = set() + for r in rows: + code = str(r["code"]) + if code in seen: + continue + seen.add(code) + stocks.append({ + "code": code, + "name": r["name"] or "", + "price": r["price"] or 0, + "entry_low": r["entry_low"] or 0, + "entry_high": r["entry_high"] or 0, + "stop_loss": r["stop_loss"] or 0, + "currency": r["currency"] or "CNY", + "added_at": r["updated_at"] or "", + "analysis": {}, + }) + + return {"stocks": stocks, "total": len(stocks)} + + db.close() + + return { + "stocks": stocks, + "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } + + +# ── 便捷别名 ─────────────────────────────────────────────────────── + +def read_portfolio_json(): + return read_portfolio() + +def read_decisions_json(): + return read_decisions() + +def read_watchlist_json(): + return read_watchlist() + + +# ── 统一价格获取(唯一入口,禁止各脚本自拉API)── + +def get_price(code, max_age_minutes=5, use_stale_fallback=True): + """获取单只股票最新价格。 + + 优先级: live_prices(DB) → stock_quote(API兜底) + - live_prices 有且不超过 max_age_minutes → 直接返回 + - 没有或过期 → 调 stock_quote 拉,写回 live_prices + - 都失败 → 返回 (None, None) + + 返回 (price, change_pct),两值都是 float 或 None。 + """ + from mofin_db import get_price_from_db + from datetime import datetime, timedelta + + # 1. 先读 DB + try: + db_price, db_chg = get_price_from_db(code) + if db_price is not None and db_price > 0: + # 检查时效性 + conn = __import__('sqlite3').connect(str(DB_PATH)) + row = conn.execute( + "SELECT updated_at FROM live_prices WHERE code=?", + (str(code).strip(),) + ).fetchone() + conn.close() + if row and row[0]: + try: + updated = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S") + age = (datetime.now() - updated).total_seconds() / 60 + if age <= max_age_minutes: + return (db_price, db_chg) + except: + pass + else: + return (db_price, db_chg) + except Exception: + pass + + # 2. DB 没有或过期 → 调 stock_quote + if not use_stale_fallback: + return (None, None) + + try: + import subprocess, json + r = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "stock_quote.py"), str(code)], + capture_output=True, text=True, timeout=15 + ) + if r.returncode == 0: + data = json.loads(r.stdout.strip()) + price = float(data.get("price", 0)) + chg = float(data.get("change_pct", 0)) + if price > 0: + # 写回 live_prices + try: + conn = __import__('sqlite3').connect(str(DB_PATH)) + conn.execute(""" + INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) + VALUES (?, ?, ?, datetime('now','localtime')) + """, (str(code).strip(), price, chg)) + conn.commit() + conn.close() + except: + pass + return (price, chg) + except Exception: + pass + + return (None, None) + + +def get_prices_batch(codes, max_age_minutes=5): + """批量获取价格,返回 {code: (price, change_pct)}""" + from mofin_db import get_prices_batch_from_db + + result = {} + need_api = [] + + # 1. 批量读 DB + try: + db_prices = get_prices_batch_from_db(codes) + for code in codes: + cs = str(code).strip() + if cs in db_prices: + p, c = db_prices[cs] + if p and p > 0: + result[cs] = (p, c) + continue + need_api.append(cs) + except: + need_api = [str(c).strip() for c in codes] + + # 2. 缺失的调 API + if need_api: + try: + import subprocess, json + r = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "stock_quote.py")] + need_api, + capture_output=True, text=True, timeout=30 + ) + if r.returncode == 0: + for line in r.stdout.strip().split("\n"): + if not line: + continue + try: + data = json.loads(line) + code = str(data.get("code", "")).strip() + price = float(data.get("price", 0)) + chg = float(data.get("change_pct", 0)) + if code and price > 0: + result[code] = (price, chg) + except: + pass + except: + pass + + return result + + +# ── cash_log 写入 ────────────────────────────────────────────────── + +def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, + source, note, verified=0): + """记录现金变更到 cash_log 表。""" + change_amount = round(cash_after - cash_before, 2) if cash_after is not None and cash_before is not None else 0 + db = sqlite3.connect(DB_PATH) + try: + cur = db.execute( + """INSERT INTO cash_log + (timestamp, cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + VALUES (datetime('now','localtime'), ?, ?, ?, ?, ?, ?, ?, ?)""", + (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + ) + db.commit() + return cur.lastrowid + finally: + db.close() + + +# ── 自检 ─────────────────────────────────────────────────────────── + +if __name__ == "__main__": + pf = read_portfolio() + print(f"portfolio: {len(pf.get('holdings',[]))} holdings, total_assets={pf.get('total_assets',0)}") + + dec = read_decisions() + print(f"decisions: {len(dec.get('decisions',[]))} entries") + + wl = read_watchlist() + print(f"watchlist: {len(wl.get('stocks',[]))} stocks") diff --git a/scripts/mofin_db.py b/scripts/mofin_db.py new file mode 100644 index 00000000..42c7cb6a --- /dev/null +++ b/scripts/mofin_db.py @@ -0,0 +1,2341 @@ +#!/usr/bin/env python3 +"""mofin_db.py — MoFin 统一数据库访问层 + +所有脚本通过此模块访问 mofin.db,避免重复建表/连接逻辑。 + +用法: + from mofin_db import get_conn, write_market_snapshot, write_klines, ... + +设计原则: + - 幂等建表(CREATE TABLE IF NOT EXISTS) + - WAL 模式 + 外键约束 + - 所有写操作返回 (success: bool, detail: str) + - JSON 写入由调用方负责,本模块只写 SQLite +""" + +import sqlite3 +import json +import time +import functools +from datetime import datetime +from pathlib import Path +from typing import Optional, Callable + +DATA_DIR = Path("/home/hmo/MoFin/data") # 绝对路径:全系统唯一权威数据目录 +DB_PATH = DATA_DIR / "mofin.db" + +# ═══════════════════════════════════════════════════════════ +# 连接管理 +# ═══════════════════════════════════════════════════════════ + +def get_conn() -> sqlite3.Connection: + """获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁,autocommit模式)""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA synchronous=NORMAL") + # 每次连接时清理WAL:防止被kill的进程留下残留事务导致后续全部卡死 + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + return conn + + +def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (), + max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor: + """执行SQL并自动重试(捕获 database is locked)""" + last_err = None + for attempt in range(max_retries + 1): + try: + return conn.execute(sql, params) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise # 非锁错误直接抛 + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB锁重试{max_retries}次仍失败: {e}" + ) + # unreachable -- both paths in loop either return or raise + if last_err: + raise last_err # type: ignore[misc] + + +def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3, + base_delay: float = 1.0) -> None: + """提交事务并自动重试""" + last_err = None + for attempt in range(max_retries + 1): + try: + conn.commit() + return + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB提交重试{max_retries}次仍失败: {e}" + ) + raise last_err + + +def retry_db_write(func: Callable) -> Callable: + """装饰器:为 DB 写函数自动添加重试""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + max_retries = 3 + base_delay = 1.0 + last_err = None + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB写重试{max_retries}次仍失败({func.__name__}): {e}" + ) + raise last_err + return wrapper + + +# ═══════════════════════════════════════════════════════════ +# 建表(幂等) +# ═══════════════════════════════════════════════════════════ + +def init_all_tables(conn: sqlite3.Connection): + """创建全部表(幂等,已存在则跳过)""" + conn.executescript(""" + -- 市场快照 + CREATE TABLE IF NOT EXISTS market_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'ths', + up_ratio REAL, + mood TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_snapshots_time ON market_snapshots(timestamp); + + -- 板块快照 + CREATE TABLE IF NOT EXISTS sector_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL REFERENCES market_snapshots(id), + name TEXT NOT NULL, + change_pct REAL, + up_count INTEGER, + down_count INTEGER, + net_inflow REAL, + lead_stock TEXT, + lead_stock_change REAL, + volume REAL, + turnover REAL + ); + CREATE INDEX IF NOT EXISTS idx_sector_name ON sector_snapshots(name); + CREATE INDEX IF NOT EXISTS idx_sector_snapshot ON sector_snapshots(snapshot_id); + CREATE INDEX IF NOT EXISTS idx_sector_name_time ON sector_snapshots(name, snapshot_id); + + -- 个股 + CREATE TABLE IF NOT EXISTS stocks ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + exchange TEXT DEFAULT 'SH', + type TEXT DEFAULT 'A', + updated_at TEXT + ); + + -- K线(日/周/月) + CREATE TABLE IF NOT EXISTS stock_daily ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, amount REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_weekly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_monthly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + + -- 基本面 + CREATE TABLE IF NOT EXISTS stock_fundamentals ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + pe REAL, pb REAL, eps REAL, + mcap_total REAL, mcap_flow REAL, + updated_at TEXT + ); + + -- 板块成分映射 + CREATE TABLE IF NOT EXISTS stock_sectors ( + code TEXT NOT NULL REFERENCES stocks(code), + sector_name TEXT NOT NULL, + source TEXT DEFAULT 'ths', + updated_at TEXT DEFAULT (datetime('now','localtime')), + PRIMARY KEY (code, sector_name) + ); + CREATE INDEX IF NOT EXISTS idx_stock_sector ON stock_sectors(sector_name); + + -- 持仓 + CREATE TABLE IF NOT EXISTS holdings ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + shares INTEGER NOT NULL, + cost REAL, + price REAL, -- 当前价格 (CNY) + market_value REAL, -- 市值 = shares * price + change_pct REAL, -- 涨跌幅 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + position_pct REAL, + added_at TEXT, + is_active INTEGER DEFAULT 1, + closed_at TEXT, + close_pnl REAL + ); + + -- 持仓策略(对应 decisions.json decisions[]) + CREATE TABLE IF NOT EXISTS holding_strategies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES holdings(code), + name TEXT, + version INTEGER DEFAULT 1, + price REAL, + cost REAL, + shares INTEGER DEFAULT 0, + stop_loss REAL, + take_profit REAL, + entry_low REAL, + entry_high REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + strategy_type TEXT DEFAULT 'holding', + action TEXT, + timing_signal TEXT, + rr_ratio REAL, + tech_snapshot TEXT, + stock_category TEXT, + sector_context TEXT, + status TEXT DEFAULT 'active', + trigger_json TEXT, + changelog_json TEXT, + source TEXT, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT, + superseded_at TEXT, + -- 以下为 decisions.json→DB 迁移新增列 + avg_price REAL, + decision_timestamp TEXT, + note TEXT, + quality_check TEXT, + quality_checked_at TEXT, + quality_issues_json TEXT, + position_advice TEXT, + signal_factors_json TEXT, + time_horizon TEXT, + decision_type TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code); + CREATE INDEX IF NOT EXISTS idx_strategy_status ON holding_strategies(status); + + -- 策略历史快照(每次覆写前自动记录) + CREATE TABLE IF NOT EXISTS strategy_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT, + decision_type TEXT, + strategy_type TEXT, + full_analysis TEXT, + action TEXT, + timing_signal TEXT, + entry_low REAL, + entry_high REAL, + stop_loss REAL, + take_profit REAL, + position_advice TEXT, + rr_ratio REAL, + version INTEGER, + source_trigger TEXT, + reassessed_at TEXT, + snapshotted_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_strategy_history_code ON strategy_history(code, snapshotted_at); + + -- 策略追踪评估(2026-07-27 老爸:每条推荐操作的完整生命周期跟踪) + -- 每个版本一条记录,策略变更时自动追加新版本 + CREATE TABLE IF NOT EXISTS strategy_tracking ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT, + version_seq INTEGER DEFAULT 1, -- 该股票的第几个策略版本 + -- 推荐时的快照 + tracked_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + timing_signal TEXT, + rec_score INTEGER DEFAULT 0, + rr_ratio REAL, + entry_low REAL, + entry_high REAL, + entry_mid REAL, + stop_loss REAL, + take_profit REAL, + position_advice TEXT, + price_at_track REAL, -- 记录时的市价 + -- 区别于前一版本的变化摘要 + change_summary TEXT, + -- 结果跟踪 + status TEXT DEFAULT 'active' CHECK(status IN ('active','hit_tp','hit_sl','expired','manual_close')), + closed_at TEXT, + close_price REAL, + close_reason TEXT, + theoretical_pnl REAL, -- 理论盈亏%(基于中值买入价) + -- 实操数据(由用户或导入脚本填入) + actual_action TEXT, -- "买入600股@148.86" + actual_entry REAL, + actual_shares INTEGER, + actual_exit REAL, + actual_pnl REAL, + actual_exit_reason TEXT, + notes TEXT + ); + CREATE INDEX IF NOT EXISTS idx_track_code ON strategy_tracking(code); + CREATE INDEX IF NOT EXISTS idx_track_status ON strategy_tracking(status); + CREATE INDEX IF NOT EXISTS idx_track_date ON strategy_tracking(tracked_at); + + -- 自选股 + CREATE TABLE IF NOT EXISTS watchlist_stocks ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + price REAL, -- 当前价格 + entry_low REAL, -- 买入区下限 + entry_high REAL, -- 买入区上限 + stop_loss REAL, -- 止损 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + source TEXT, -- 来源: alpha_sift/xiaoguo/manual + source_detail TEXT, -- 来源详情 JSON + notes TEXT, -- 备注 + added_by TEXT, -- 谁加的 + added_at TEXT DEFAULT (datetime('now','localtime')), + is_active INTEGER DEFAULT 1, + analysis_json TEXT -- 分析结果 JSON + ); + + -- 候选池 + CREATE TABLE IF NOT EXISTS candidates ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + sector TEXT, + reason TEXT, + entry_range TEXT, + stop_loss REAL, + target REAL, + zhiwei_star REAL, + zhiwei_reviewed INTEGER DEFAULT 0, + zhiwei_reviewed_at TEXT, + promoted INTEGER DEFAULT 0, + promoted_at TEXT, + dropped INTEGER DEFAULT 0, + drop_reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 候选评分历史 + CREATE TABLE IF NOT EXISTS candidate_score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES candidates(code), + score REAL NOT NULL, + source TEXT NOT NULL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_candidate_history ON candidate_score_history(code, created_at); + + -- 价格事件 + CREATE TABLE IF NOT EXISTS price_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + event_type TEXT NOT NULL, + price REAL, + trigger_value TEXT, + event_label TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + date TEXT + ); + CREATE INDEX IF NOT EXISTS idx_events_code ON price_events(code); + CREATE INDEX IF NOT EXISTS idx_events_date ON price_events(date); + + -- 策略评估记录 + CREATE TABLE IF NOT EXISTS strategy_evaluations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + eval_type TEXT NOT NULL, + status TEXT DEFAULT 'pending', + old_stop_loss REAL, + new_stop_loss REAL, + old_tp REAL, + new_tp REAL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 持仓汇总(portfolio.json 顶层字段) + CREATE TABLE IF NOT EXISTS portfolio_summary ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total_assets REAL, + total_mv REAL, -- 持仓总市值 + stock_value REAL, + cash REAL, -- 可用现金 + frozen_cash REAL DEFAULT 0, -- 冻结资金 + position_pct REAL, + total_pnl REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + updated_at TEXT + ); + + -- 现金变更日志(每次买卖/出入金记录) + CREATE TABLE IF NOT EXISTS cash_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now','localtime')), + cash_before REAL, -- 变更前可用现金 + cash_after REAL, -- 变更后可用现金 + frozen_before REAL, -- 变更前冻结资金 + frozen_after REAL, -- 变更后冻结资金 + change_amount REAL, -- 现金变动额(正=入金/卖股,负=出金/买股) + source TEXT NOT NULL, -- 来源: screenshot/manual/import_xls/trade + note TEXT, -- 备注: 例如 "卖出法拉电子 200股" + verified INTEGER DEFAULT 0 -- 是否已验证(0=未验证,1=Dad确认) + ); + + -- 建议时间线(decisions.json advice_timeline[]) + CREATE TABLE IF NOT EXISTS advice_timeline ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT, + direction TEXT, + price REAL, + summary TEXT, + status TEXT, + evaluated INTEGER DEFAULT 0, + result TEXT, + evaluated_at TEXT, + report_id TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_advice_code ON advice_timeline(code); + + -- 准确率统计(accuracy_stats.json) + CREATE TABLE IF NOT EXISTS accuracy_stats ( + id INTEGER PRIMARY KEY CHECK (id = 1), + period_start TEXT, + period_end TEXT, + total_advice INTEGER DEFAULT 0, + correct INTEGER DEFAULT 0, + wrong INTEGER DEFAULT 0, + partial INTEGER DEFAULT 0, + unknown INTEGER DEFAULT 0, + pending INTEGER DEFAULT 0, + ignored INTEGER DEFAULT 0, + evaluated INTEGER DEFAULT 0, + accuracy_pct REAL, + phase1_correct INTEGER DEFAULT 0, + phase1_wrong INTEGER DEFAULT 0, + phase1_pending INTEGER DEFAULT 0, + phase1_accuracy REAL, + phase2_correct INTEGER DEFAULT 0, + phase2_wrong INTEGER DEFAULT 0, + phase2_pending INTEGER DEFAULT 0, + phase2_accuracy REAL, + total_evaluated INTEGER DEFAULT 0, + updated_at TEXT + ); + + -- 策略反馈(strategy_feedback.json) + CREATE TABLE IF NOT EXISTS strategy_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + evaluated_at TEXT, + phase1_completed INTEGER DEFAULT 0, + phase1_result TEXT, + phase1_completed_at TEXT, + phase1_price REAL, + phase2_completed INTEGER DEFAULT 0, + phase2_result TEXT, + phase2_completed_at TEXT, + days_in_phase1 INTEGER, + adjustments_json TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_feedback_code ON strategy_feedback(code); + + -- 板块信号(trend_detector 产出) + CREATE TABLE IF NOT EXISTS sector_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_type TEXT NOT NULL, + sector TEXT NOT NULL, + severity TEXT DEFAULT 'medium', + related_stocks TEXT, + holdings_in_sector TEXT, + watchlist_in_sector TEXT, + trigger_reason TEXT, + snapshot_id INTEGER, + processed INTEGER DEFAULT 0, + detected_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_processed ON sector_signals(processed); + CREATE INDEX IF NOT EXISTS idx_signal_sector ON sector_signals(sector); + + -- 小果情报(xiaoguo_news_processor 产出) + CREATE TABLE IF NOT EXISTS signal_news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_id INTEGER REFERENCES sector_signals(id), + sector TEXT NOT NULL, + overall_sentiment TEXT, + summary TEXT, + key_articles TEXT, + searched_stocks TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_news_signal ON signal_news(signal_id); + + -- 小果扫描跟踪(去重用) + CREATE TABLE IF NOT EXISTS xiaoguo_scan_tracker ( + code TEXT PRIMARY KEY, + name TEXT, + last_scanned_at TEXT, + found_count INTEGER DEFAULT 0 + ); + + -- 实时价格快照(替代 live_prices.json) + CREATE TABLE IF NOT EXISTS live_prices ( + code TEXT PRIMARY KEY, + price REAL, + change_pct REAL, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 多周期缓存(替代 multi_tf_cache.json) + CREATE TABLE IF NOT EXISTS mtf_cache ( + code TEXT PRIMARY KEY, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 资金流缓存(替代 capital_flow_cache.json) + CREATE TABLE IF NOT EXISTS capital_flow_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- Self-TODO 自动化任务表 + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'pending', + priority TEXT DEFAULT 'medium', + source TEXT DEFAULT 'manual', + fix_action TEXT, + retry_count INTEGER DEFAULT 0, + note TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() + + # 迁移:给 signal_news 加 source 字段(幂等) + try: + conn.execute("ALTER TABLE signal_news ADD COLUMN source TEXT DEFAULT 'trend'") + except sqlite3.OperationalError: + pass + + # cash_log migration (2026-07-01) + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_before REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_after REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN verified INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass + + # ── 币种约束迁移(2026-06-30)──────────────────────────────── + _currency_migrations = [ + ("holdings", ["price REAL", "market_value REAL", "change_pct REAL", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("holding_strategies", ["name TEXT", "price REAL", "cost REAL", "shares INTEGER DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'", + "action TEXT", "timing_signal TEXT", "rr_ratio REAL", + "tech_snapshot TEXT", "stock_category TEXT", + "sector_context TEXT", "status TEXT DEFAULT 'active'", + "trigger_json TEXT", "changelog_json TEXT", + "updated_at TEXT"]), + ("portfolio_summary", ["total_mv REAL", "frozen_cash REAL DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("watchlist_stocks", ["price REAL", "entry_low REAL", "entry_high REAL", + "stop_loss REAL", "currency TEXT NOT NULL DEFAULT 'CNY'", + "source TEXT", "source_detail TEXT", "notes TEXT", + "added_by TEXT", "analysis_json TEXT"]), + ] + for table, columns in _currency_migrations: + for col_def in columns: + col_name = col_def.split()[0] + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}") + except sqlite3.OperationalError: + pass # column already exists + + # ── tag 迁移(2026-07-20):推荐标签 current_recommend / active_manual ── + # 此前 strategy_lifecycle 在 dict 里设置 tag 但 write_holding_strategy 无此列, + # 导致标签在写入时被静默丢弃。补列 + 写入保留。 + try: + conn.execute("ALTER TABLE holding_strategies ADD COLUMN tag TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + # ── 三值 RR 迁移(2026-07-22 老爸):买入区下沿/中值/上沿三个 RR ── + # rr_ratio 保留=中值 RR(排序/门槛沿用),新增 rr_low / rr_high 展示用。 + for _col in ("rr_low REAL DEFAULT 0", "rr_high REAL DEFAULT 0"): + try: + conn.execute(f"ALTER TABLE holding_strategies ADD COLUMN {_col}") + except sqlite3.OperationalError: + pass + # ── rec_score 迁移(2026-07-27):五维复合推荐评分 0-100 ── + try: + conn.execute("ALTER TABLE holding_strategies ADD COLUMN rec_score INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass + conn.commit() + + +# ═══════════════════════════════════════════════════════════ +# 市场快照写入 +# ═══════════════════════════════════════════════════════════ + +def write_market_snapshot(conn: sqlite3.Connection, market_data: dict) -> tuple[bool, str, Optional[int]]: + """写入一次市场采集到 market_snapshots + sector_snapshots + + Returns: (ok, message, snapshot_id) + """ + try: + cur = conn.execute( + "INSERT INTO market_snapshots (timestamp, source, up_ratio, mood) VALUES (?, ?, ?, ?)", + (market_data["timestamp"], market_data.get("source", "unknown"), + market_data.get("up_ratio", 0), market_data.get("mood", "unknown")), + ) + sid = cur.lastrowid + + sectors = market_data.get("sectors", []) + rows = [(sid, s.get("name", ""), s.get("change", 0), + s.get("up_count"), s.get("down_count"), s.get("net_inflow"), + s.get("lead_stock"), s.get("lead_stock_change"), + s.get("volume"), s.get("turnover")) for s in sectors] + if rows: + conn.executemany( + "INSERT INTO sector_snapshots (snapshot_id, name, change_pct, up_count, down_count, " + "net_inflow, lead_stock, lead_stock_change, volume, turnover) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) + conn.commit() + return True, f"snapshot_id={sid}, sectors={len(rows)}", sid + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False, str(e), None + + +# ═══════════════════════════════════════════════════════════ +# K线写入 +# ═══════════════════════════════════════════════════════════ + +def write_klines(conn: sqlite3.Connection, code: str, name: str, + daily: list = None, weekly: list = None, monthly: list = None, + fundamentals: dict = None) -> bool: + """将个股K线数据双写 SQLite + + Args: + code: 股票代码 + name: 股票名称 + daily/weekly/monthly: [{date, open, close, high, low, volume}, ...] + fundamentals: {pe, pb, eps, mcap_total, mcap_flow} + """ + try: + # 判断交易所 + raw = str(code) + if len(raw) == 5 and raw.isdigit(): + exchange, stype = "HK", "H" + elif raw.startswith(("6", "5", "9")): + exchange, stype = "SH", "A" + else: + exchange, stype = "SZ", "A" + + # stocks 表(INSERT OR REPLACE) + conn.execute( + "INSERT OR REPLACE INTO stocks (code, name, exchange, type, updated_at) VALUES (?, ?, ?, ?, ?)", + (code, name, exchange, stype, datetime.now().isoformat())) + + # K线数据 + for period, table, data in [ + ("daily", "stock_daily", daily), + ("weekly", "stock_weekly", weekly), + ("monthly", "stock_monthly", monthly), + ]: + if not data: + continue + rows = [(code, d.get("date", ""), d.get("open"), d.get("close"), + d.get("high"), d.get("low"), d.get("volume"), + d.get("amount") if period == "daily" else None) for d in data] + if period == "daily": + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume, amount) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows) + else: + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [(r[0], r[1], r[2], r[3], r[4], r[5], r[6]) for r in rows]) + + # 基本面 + if fundamentals: + conn.execute( + "INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, fundamentals.get("pe"), fundamentals.get("pb"), + fundamentals.get("eps"), fundamentals.get("mcap_total"), + fundamentals.get("mcap_flow"), datetime.now().isoformat())) + + conn.commit() + return True + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 价格事件写入 +# ═══════════════════════════════════════════════════════════ + +def write_price_event(conn: sqlite3.Connection, code: str, name: str, + event_type: str, price: float, trigger_value: str, + event_label: str = "") -> bool: + """写入一条价格事件""" + try: + now = datetime.now() + conn.execute( + "INSERT INTO price_events (code, name, event_type, price, trigger_value, event_label, date) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, name, event_type, round(price, 2), trigger_value, + event_label, now.strftime("%Y-%m-%d"))) + conn.commit() + return True + except Exception: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 板块成分迁移 +# ═══════════════════════════════════════════════════════════ + +def migrate_stock_sectors(conn: sqlite3.Connection) -> tuple[int, int]: + """从 stock_sector_map.json 迁移到 stock_sectors 表 + + Returns: (migrated_stocks, total_mappings) + """ + sector_map_path = DATA_DIR / "stock_sector_map.json" + if not sector_map_path.exists(): + return 0, 0 + + try: + with open(sector_map_path, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return 0, 0 + + # 过滤元数据字段 + mappings = [(code, sectors) for code, sectors in data.items() + if not code.startswith("_") and isinstance(sectors, list)] + + total = 0 + for code, sectors in mappings: + for sector in sectors: + try: + conn.execute( + "INSERT OR IGNORE INTO stock_sectors (code, sector_name, source) VALUES (?, ?, 'ths')", + (code, sector)) + total += 1 + except Exception: + pass + conn.commit() + return len(mappings), total + + +# ═══════════════════════════════════════════════════════════ +# 查询辅助 +# ═══════════════════════════════════════════════════════════ + +def query_sector_trend(conn: sqlite3.Connection, name: str, limit: int = 5) -> list[dict]: + """板块最近N次趋势""" + rows = conn.execute(""" + SELECT s.timestamp, ss.change_pct, ss.net_inflow, + ss.up_count, ss.down_count, ss.lead_stock, ss.lead_stock_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE ss.name = ? ORDER BY s.timestamp DESC LIMIT ? + """, (name, limit)).fetchall() + return [dict(r) for r in rows] + + +def query_top_inflow(conn: sqlite3.Connection, limit: int = 5) -> list[dict]: + """最新一次资金净流入排行""" + rows = conn.execute(""" + SELECT ss.name, ss.change_pct, ss.net_inflow, ss.lead_stock, s.timestamp + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id = (SELECT MAX(id) FROM market_snapshots) + AND ss.net_inflow IS NOT NULL + ORDER BY ss.net_inflow DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_consecutive_inflow(conn: sqlite3.Connection, days: int = 3) -> list[dict]: + """连续N次净流入的板块""" + rows = conn.execute(""" + SELECT name, COUNT(*) as times, ROUND(AVG(net_inflow), 2) as avg_inflow, + ROUND(AVG(change_pct), 2) as avg_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id > (SELECT MAX(id) - ? FROM market_snapshots) + AND net_inflow > 0 + GROUP BY name HAVING COUNT(*) >= ? + ORDER BY avg_inflow DESC + """, (days, days)).fetchall() + return [dict(r) for r in rows] + + +def query_market_mood(conn: sqlite3.Connection, limit: int = 10) -> list[dict]: + """市场情绪趋势""" + rows = conn.execute(""" + SELECT timestamp, source, up_ratio, mood + FROM market_snapshots ORDER BY timestamp DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_db_stats(conn: sqlite3.Connection) -> dict: + """数据库概览""" + snap_count = conn.execute("SELECT COUNT(*) FROM market_snapshots").fetchone()[0] + sector_count = conn.execute("SELECT COUNT(*) FROM sector_snapshots").fetchone()[0] + stock_count = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] + kline_count = conn.execute("SELECT COUNT(*) FROM stock_daily").fetchone()[0] + event_count = conn.execute("SELECT COUNT(*) FROM price_events").fetchone()[0] + holding_count = conn.execute("SELECT COUNT(*) FROM holdings").fetchone()[0] + candidate_count = conn.execute("SELECT COUNT(*) FROM candidates").fetchone()[0] + latest = conn.execute( + "SELECT timestamp, source FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + return { + "snapshots": snap_count, "sector_rows": sector_count, + "stocks": stock_count, "daily_klines": kline_count, + "price_events": event_count, "holdings": holding_count, + "candidates": candidate_count, + "latest_snapshot": dict(latest) if latest else None, + } + + +# ═══════════════════════════════════════════════════════════ +# 持仓查询 +# ═══════════════════════════════════════════════════════════ + +def query_holdings(conn: sqlite3.Connection) -> list[dict]: + """持仓列表(含最新策略)""" + rows = conn.execute(""" + SELECT h.code, h.name, h.shares, h.cost, h.position_pct, h.is_active, + h.price, h.change_pct, h.currency, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action, hs.created_at as strategy_updated + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +def query_holding_by_code(conn: sqlite3.Connection, code: str) -> dict | None: + """单只持仓""" + row = conn.execute(""" + SELECT h.*, hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.code = ? + """, (code,)).fetchone() + return dict(row) if row else None + + +def query_portfolio_summary(conn: sqlite3.Connection) -> dict: + """持仓汇总""" + row = conn.execute("SELECT * FROM portfolio_summary WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 自选股查询 +# ═══════════════════════════════════════════════════════════ + +def query_watchlist(conn: sqlite3.Connection) -> list[dict]: + """自选股列表(含策略)""" + rows = conn.execute(""" + SELECT w.code, w.name, w.added_at, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM watchlist_stocks w + LEFT JOIN holding_strategies hs ON w.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = w.code AND strategy_type = 'watch') + WHERE w.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 决策/策略查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategies(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略列表(按版本倒序)""" + if code: + rows = conn.execute( + "SELECT * FROM holding_strategies WHERE code = ? ORDER BY version DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM holding_strategies ORDER BY code, version DESC").fetchall() + return [dict(r) for r in rows] + + +def query_advice_timeline(conn: sqlite3.Connection, code: str = None, limit: int = 50) -> list[dict]: + """建议时间线""" + if code: + rows = conn.execute( + "SELECT * FROM advice_timeline WHERE code = ? ORDER BY date DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM advice_timeline ORDER BY date DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 候选池查询 +# ═══════════════════════════════════════════════════════════ + +def query_candidates(conn: sqlite3.Connection, active_only: bool = True) -> list[dict]: + """候选池列表(含最新评分)""" + where = "WHERE c.dropped = 0" if active_only else "" + rows = conn.execute(f""" + SELECT c.*, (SELECT score FROM candidate_score_history + WHERE code = c.code ORDER BY created_at DESC LIMIT 1) as latest_score + FROM candidates c {where} + ORDER BY c.zhiwei_star DESC NULLS LAST + """).fetchall() + return [dict(r) for r in rows] + + +def query_candidate_scores(conn: sqlite3.Connection, code: str) -> list[dict]: + """某候选的评分历史""" + rows = conn.execute( + "SELECT * FROM candidate_score_history WHERE code = ? ORDER BY created_at", + (code,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 价格事件查询 +# ═══════════════════════════════════════════════════════════ + +def query_price_events(conn: sqlite3.Connection, code: str = None, limit: int = 100) -> list[dict]: + """价格事件""" + if code: + rows = conn.execute( + "SELECT * FROM price_events WHERE code = ? ORDER BY created_at DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM price_events ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_price_events_by_date(conn: sqlite3.Connection, date: str) -> list[dict]: + """某天的价格事件""" + rows = conn.execute( + "SELECT * FROM price_events WHERE date = ? ORDER BY created_at DESC", (date,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 板块成分查询 +# ═══════════════════════════════════════════════════════════ + +def query_stock_sectors(conn: sqlite3.Connection, code: str) -> list[str]: + """某只股票所属板块""" + rows = conn.execute( + "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,)).fetchall() + return [r[0] for r in rows] + + +def query_sector_stocks(conn: sqlite3.Connection, sector_name: str) -> list[str]: + """某板块包含的股票""" + rows = conn.execute( + "SELECT code FROM stock_sectors WHERE sector_name = ?", (sector_name,)).fetchall() + return [r[0] for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 准确率统计查询 +# ═══════════════════════════════════════════════════════════ + +def query_accuracy_stats(conn: sqlite3.Connection) -> dict: + """准确率统计""" + row = conn.execute("SELECT * FROM accuracy_stats WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 策略反馈查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_feedback(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略反馈""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_feedback WHERE code = ? ORDER BY evaluated_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_feedback ORDER BY evaluated_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 策略评估查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_evaluations(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略评估记录""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_evaluations WHERE code = ? ORDER BY created_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_evaluations ORDER BY created_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 市场快照查询(最新) +# ═══════════════════════════════════════════════════════════ + +def query_latest_market(conn: sqlite3.Connection) -> dict: + """获取最新一次市场快照(含 sector 详情)""" + row = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not row: + return {} + snap = dict(row) + # 关联 sectors + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 通用工具 +# ═══════════════════════════════════════════════════════════════════ + +def get_price_from_db(code: str) -> tuple[float | None, float | None]: + """从 DB 读取最新价格(price_monitor 维护)。 + 返回 (price, change_pct) 或 (None, None) + + 所有脚本应优先调用此函数,DB 无数据时才拉腾讯 API。 + """ + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + db.close() + if row: + return (row['price'], row['change_pct'] if 'change_pct' in row.keys() else None) + except Exception: + pass + return (None, None) + + +def get_prices_batch_from_db(codes: list[str]) -> dict: + """从 DB 批量读取价格。返回 {code: (price, change_pct)}""" + results = {} + if not codes: + return results + 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", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + if row and row['price']: + results[str(code)] = (row['price'], row['change_pct'] if 'change_pct' in row.keys() else 0) + db.close() + except Exception: + pass + return results + """最新一次市场快照(含板块数据)""" + snap = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not snap: + return {} + snap = dict(snap) + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + # 计算 top_gainers / top_losers + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 核心写函数 — 替代 json.dump(),强制币种约束 +# ═══════════════════════════════════════════════════════════════════ + +def reconcile_signal_from_analysis(conn, code: str) -> str: + """以已存 full_analysis 为唯一事实源,重算 timing_signal 并写回。 + 根治"信号与分析脱节"(per_stock 分开写信号/分析导致的 信号=买入但分析=观望)。 + 返回最终信号。无裁决行 → 清空动作级信号(防陈旧买入残留)。""" + try: + row = conn.execute("SELECT full_analysis, timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if not row: + return "" + fa, old_sig = row[0] or "", row[1] or "" + verdict = "" + for line in fa.split("\n"): + if "【综合结论】" in line: + for s in ("买入", "可买入", "可加仓", "卖出", "止盈", "关注", "观望", "持有", "弱势持有"): + if s in line: + verdict = s + break + break + if verdict: + new_sig = verdict + # ── 矛盾降级(2026-07-24 老爸:688660综合结论=买入但操作建议说"继续空仓观望暂不执行")── + # 【综合结论】给方向,【操作建议】/【建议仓位】给执行。执行层明确否定买入时, + # 以执行为准——信号降级为关注,tag/exec 一律不许升。 + if new_sig in ("买入", "可买入", "可加仓"): + _NEG_ADVICE = ("继续空仓", "暂不执行", "不宜买入", "不买入", "不建仓", "不新建仓", + "等待企稳", "暂缓买入", "保持空仓", "维持空仓", "不建议买入", "空仓观望", + "不建议操作", "等待价格回落", "等待回调", "高于买入区上沿") + for line in fa.split("\n"): + if "【建议仓位】" in line and ("不新建仓" in line or "不建仓" in line): + new_sig = "关注" + print(f" [RECONCILE] {code} 建议仓位=不建仓('{line.strip()[:40]}'),信号降级为关注", flush=True) + break + if ("【操作建议】" in line or line.strip().startswith("【操作建议】")) \ + and any(k in line for k in _NEG_ADVICE): + new_sig = "关注" + print(f" [RECONCILE] {code} 操作建议否定买入('{line.strip()[:40]}'),信号降级为关注", flush=True) + break + elif old_sig in ("买入", "可买入", "可加仓"): + new_sig = "" # 无裁决且陈旧动作信号 → 清除 + else: + new_sig = old_sig + if new_sig != old_sig: + conn.execute("UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", + (new_sig, code)) + conn.commit() + # 信号变了 → tag 跟着对齐 + sync_recommend_tag(conn, code, new_sig) + print(f" [RECONCILE] {code} 信号 {old_sig}→{new_sig}(以分析为准)", flush=True) + return new_sig + except Exception as e: + print(f" [RECONCILE] {code} 异常: {e}", flush=True) + return "" + + +def recompute_rr(conn, code: str) -> float: + """用买入区+已存止损/止盈重算三值 RR 并写回(rr_low/rr_ratio中值/rr_high)。 + 根治"LLM 不输出 RR → rr_ratio 永远 0"的断链(红线:RR 由系统算,不信 LLM)。 + 公式: RR(x) = (上方目标 - x) / (x - 止损);x 分别取买入区下沿/中值/上沿。 + 上方目标基于中值参考价统一定(不因 x 不同而漂移),保证三值自洽: + rr_low > rr_ratio > rr_high 恒成立,展示"在同一阻力下不同入场价的敏感度"。 + 目标 = min(止盈, 20日新高若高于中值)(2026-07-24 老爸: + 前高挡在中间时止盈是放空炮,真实RR必须对最近上方阻力先结算)。 + rr_ratio=中值 RR 用于排序与2.0门槛;rr_low/rr_high 展示入场价敏感度。 + 区间缺失 → 中值兜底现价(low/high=0);损/盈缺失或 x<=止损 → 该值=0。""" + try: + row = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not row: + return 0.0 + el, eh, sl, tp = (row[0] or 0), (row[1] or 0), (row[2] or 0), (row[3] or 0) + + # 卖出/止盈信号:RR(买在区间的盈亏比)对卖出无意义,直接返回0(2026-07-27 老爸) + try: + _sig_r = conn.execute("SELECT timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _sig_r and _sig_r[0] in ("卖出", "止盈"): + conn.execute("UPDATE holding_strategies SET rr_ratio=0, rr_low=0, rr_high=0 WHERE code=? AND status='active'", (code,)) + conn.commit() + return 0.0 + except Exception: + pass + + # 20日新高(前高阻力) + high_20d = 0.0 + try: + r20 = conn.execute( + "SELECT MAX(high) FROM (SELECT high FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 20)", + (code,)).fetchone() + if r20 and r20[0]: + high_20d = float(r20[0]) + except Exception: + pass + + # 基准参考价 = 区间中值(决定上方目标,三值共用) + ref = (el + eh) / 2.0 if el > 0 and eh > el else 0 + target = tp + # 已持仓股不适用20日新高阻力(用户已按原始推荐买入,RR应保持原值) + _owned = conn.execute("SELECT shares FROM holdings WHERE code=? AND is_active=1 AND shares>0", (code,)).fetchone() + if not _owned and ref > 0 and high_20d > ref and high_20d < tp: + target = high_20d + + def _rr(x): + if sl > 0 and target > 0 and x > sl and target > x: + v = round((target - x) / (x - sl), 2) + return v if v > 0 else 0.0 + return 0.0 + + rr_low = rr_mid = rr_high = 0.0 + if el > 0 and eh > el: + rr_low = _rr(el) # 下沿买入:最乐观 + rr_mid = _rr((el + eh) / 2.0) + rr_high = _rr(eh) # 上沿买入:最保守 + else: + # 区间缺失 → 中值兜底现价 + try: + pr = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if pr and (pr[0] or 0) > 0: + rr_mid = _rr(float(pr[0])) + except Exception: + pass + conn.execute( + "UPDATE holding_strategies SET rr_ratio=?, rr_low=?, rr_high=? WHERE code=? AND status='active'", + (rr_mid, rr_low, rr_high, code)) + conn.commit() + compute_rec_score(conn, code) # RR 变→评分同步刷新 + return rr_mid + except Exception as e: + print(f" [RR] {code} 重算失败: {e}", flush=True) + return 0.0 + + +def compute_rec_score(conn, code: str) -> int: + """五维复合推荐评分 0-100。RR高≠值得买,趋势+行业+信号综合判断。 + 维度:RR(0-35) + 信号(0-25) + 趋势(0-20) + 行业(0-10) + 区间(0-10)""" + try: + row = conn.execute( + "SELECT rr_ratio, timing_signal, tech_snapshot, sector_context, entry_low, entry_high " + "FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if not row: + return 0 + rr, sig, tech, sector, el, eh = row + rr = rr or 0; el = el or 0; eh = eh or 0 + + # ── 1. RR (0-35) ── + if rr >= 3.0: s_rr = 35 + elif rr >= 2.5: s_rr = 28 + elif rr >= 2.0: s_rr = 20 + elif rr >= 1.5: s_rr = 10 + else: s_rr = 0 + + # ── 2. 信号强度 (0-25) ── + sig_map = {"买入": 25, "可买入": 20, "可加仓": 15} + s_sig = sig_map.get(sig, 0) + + # ── 3. 技术趋势 (0-20) ── + tech_str = str(tech or '') + # 形态判定 + if '/bullish' in tech_str or '看涨' in tech_str: + s_trend = 15 + elif '/bearish' in tech_str or '看跌' in tech_str: + s_trend = 8 + else: + s_trend = 12 + # MA 排列加成 + import re as _re_ma + ma_vals = {} + for m in _re_ma.finditer(r'MA(\d+)=([\d.]+)', tech_str): + ma_vals[int(m.group(1))] = float(m.group(2)) + if all(k in ma_vals for k in [5,10,20,60]): + if ma_vals[5] > ma_vals[10] > ma_vals[20] > ma_vals[60]: + s_trend += 5 # 多头排列 + elif ma_vals[5] < ma_vals[10] < ma_vals[20] < ma_vals[60]: + s_trend -= 3 # 空头排列 + s_trend = max(0, min(20, s_trend)) + + # ── 4. 行业强弱 (0-10) ── + sec_str = str(sector or '') + if '领涨' in sec_str: + s_sec = 9 + elif '偏强' in sec_str or '上涨' in sec_str: + s_sec = 7 + elif '偏弱' in sec_str or '下跌' in sec_str: + s_sec = 3 + else: + s_sec = 5 + + # ── 5. 买入区间质量 (0-10) ── + s_zone = 0 + if el > 0 and eh > el: + zone_pct = (eh - el) / el * 100 + if zone_pct >= 5: s_zone = 10 + elif zone_pct >= 3: s_zone = 7 + elif zone_pct >= 2: s_zone = 4 + else: s_zone = 2 + + total = s_rr + s_sig + s_trend + s_sec + s_zone + conn.execute( + "UPDATE holding_strategies SET rec_score=? WHERE code=? AND status='active'", + (total, code)) + conn.commit() + # 高评分自动打推荐 tag(补 LLM 未打 tag 的缺口) + if total >= 50 and rr >= 2.0 and sig in ("买入", "可买入", "可加仓"): + _pos_v = conn.execute( + "SELECT position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if _pos_v and _pos_v[0] and '%' in str(_pos_v[0]): + conn.execute( + "UPDATE holding_strategies SET tag='current_recommend' WHERE code=? AND status='active' AND (tag IS NULL OR tag='')", + (code,)) + conn.commit() + return total + except Exception as e: + print(f" [SCORE] {code} 评分失败: {e}", flush=True) + return 0 + + +def sync_recommend_tag(conn, code: str, timing_signal: str): + """裸 SQL 调用方(batch_reassess / per_stock_reassess)的推荐 tag 同步。 + 动作级信号 → current_recommend;信号降级 → 清除 current_recommend; + active_manual(人工标记)永不动。与 XMPP 动作级告警同源(红线#12)。""" + try: + recompute_rr(conn, code) # 先入先算:保证 tag/入队/盯盘排序拿到新鲜 RR + _row = conn.execute( + "SELECT tag, timing_signal FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + _old_tag = (_row[0] or '') if _row else '' + _old_sig = (_row[1] or '') if _row else '' + # 卖出/止盈 仅对持仓股算动作信号(没持仓卖什么) + _ACTION_BUY = ("买入", "可买入", "可加仓") + _ACTION_SELL = ("卖出", "止盈") + if timing_signal in _ACTION_SELL: + _h = conn.execute("SELECT shares FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + if not (_h and (_h[0] or 0) > 0): + timing_signal = "" # 非持仓的卖出/止盈不算动作信号 + # ── 仓位自动补全(2026-07-27 老爸:不明确就让它明确,不是丢弃)── + # LLM 经常输出"减仓或观望/中等仓位"等模糊表述,系统按公式自动计算。 + # 基础仓位 by RR(<1.5→不推荐,1.5~3→8%,3~5→12%,5+→15%) × 成长系数0.85(兜底) + # → 最终范围5-20% + if timing_signal in _ACTION_BUY: + import re as _re2 + _pos_r = conn.execute( + "SELECT rr_ratio, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _pos_r and not _re2.search(r'\d+(?:\.\d+)?\s*%', _pos_r[1] or ''): + _rr_pos = float(_pos_r[0] or 0) + if _rr_pos < 1.5: + _pct = 0 # RR不推荐 + elif _rr_pos < 3: + _pct = 8 + elif _rr_pos < 5: + _pct = 12 + else: + _pct = 15 + # 系数兜底:成长股0.85(最保守),大盘系数1.0(中性) + _pct = round(_pct * 0.85, 0) + _pct = max(5, min(20, _pct)) + _pos_auto = f"{int(_pct)}%(系统按RR{_rr_pos:.1f}自动计算,见原建议仓位)" + # ── 股数换算(2026-07-27 老爸:方便快速操作)── + # 2026-07-27 修正:按总资产算仓位,不用现金(现金波动剧烈) + try: + _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + _ta = conn.execute("SELECT total_assets FROM portfolio_summary WHERE id=1").fetchone() + if _lp and _lp[0] and _ta and _ta[0]: + _price = float(_lp[0]) + _total = float(_ta[0]) + _shares_raw = _total * _pct / 100.0 / _price + if _shares_raw >= 100: + _shares = int(_shares_raw / 100) * 100 # A股整手 + else: + _shares = int(_shares_raw) + if _shares > 0: + _pos_auto = f"{int(_pct)}% ≈ {_shares}股(系统按RR{_rr_pos:.1f}自动计算)" + except Exception: + pass + conn.execute( + "UPDATE holding_strategies SET position_advice=? WHERE code=? AND status='active'", + (_pos_auto, code)) + conn.commit() + print(f" [AUTO-POS] {code} 仓位'{_pos_r[1]}'→'{_pos_auto}'", flush=True) + if timing_signal in _ACTION_BUY or timing_signal in _ACTION_SELL: + # ── 买入RR质量门禁(2026-07-27 老爸:RR<2.0的买入不值得推荐,tag都不能打,防盯盘垃圾)── + # 此前tag先打、enqueue再查RR,结果RR<2.0的tag已落盯盘,与XMPP不一致。 + # 已持仓股不再重复推荐(2026-07-28 老爸:已买了的票该在持仓不在推荐) + _should_tag = True + _owned = conn.execute( + "SELECT shares FROM holdings WHERE code=? AND is_active=1 AND shares>0", + (code,)).fetchone() + if _owned and _owned[0] and _owned[0] > 0: + _should_tag = False # 已持仓,不再重复推荐 + if _old_tag == 'current_recommend': + conn.execute("UPDATE holding_strategies SET tag='' WHERE code=? AND status='active'", (code,)) + conn.commit() + elif timing_signal in _ACTION_BUY: + _rr_chk = conn.execute( + "SELECT rr_ratio FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _rr_chk and (_rr_chk[0] or 0) < 2.0: + print(f" [TAG SYNC] {code} RR={_rr_chk[0]:.2f}<2.0,不打推荐tag", flush=True) + _should_tag = False + if _should_tag: + conn.execute( + "UPDATE holding_strategies SET tag='current_recommend' " + "WHERE code=? AND status='active' AND (tag IS NULL OR tag != 'active_manual')", + (code,)) + conn.commit() + else: + # RR不达标 → 清除旧tag(如果打了) + if _old_tag == 'current_recommend': + conn.execute("UPDATE holding_strategies SET tag='' WHERE code=? AND status='active'", (code,)) + conn.commit() + # 入队条件(2026-07-27 老爸:tag新鲜转成、信号转入动作级、或卖出信号 — 三个场景均需入队) + _old_action = _old_sig in _ACTION_BUY or _old_sig in _ACTION_SELL + _new_action = timing_signal in _ACTION_BUY or timing_signal in _ACTION_SELL + if (_should_tag and _old_tag != 'current_recommend') or (not _old_action and _new_action): + enqueue_recommend(conn, code) # 新推荐 → 摘要队列(batch 结束统一发) + elif _old_tag == 'current_recommend': + # 空信号或非动作信号 → 清除自动推荐(active_manual 不动) + conn.execute( + "UPDATE holding_strategies SET tag='' " + "WHERE code=? AND status='active' AND tag='current_recommend'", + (code,)) + conn.commit() + # ── 策略版本追踪(2026-07-27 老爸:每次tag变更都记录到评估表)── + track_strategy_version(conn, code) + except Exception as e: + print(f" [TAG SYNC] {code} 失败: {e}", flush=True) + + +def enqueue_recommend(conn, code: str): + """新推荐入摘要队列(防逐只轰炸)。batch_reassess 跑完后 flush_rec_digest 统一发一条。 + 校验(2026-07-24 老爸"阿猫阿狗"事件后加严): + 1. tag=current_recommend 且信号为动作级 + 2. RR(中值)>=2.0(1.5边缘的平庸推荐一律拦下) + 3. position_advice 必须含明确仓位%("减仓或观望/不新建仓"不算推荐) + 4. 买入区必须有效(区—~—/0~0 不入) + 5. 买入信号时现价不得在区上沿 5% 以上(追高不买)""" + try: + import json as _j, re as _re + from datetime import datetime as _dt + row = conn.execute( + "SELECT name, timing_signal, tag, entry_low, entry_high, stop_loss, take_profit, " + "rr_ratio, rr_low, rr_high, position_advice, full_analysis, rec_score FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not row: + return + name, sig, tag, el, eh, sl, tp, rr, rr_lo, rr_hi, pos, fa, score = row + if tag != 'current_recommend' or sig not in ("买入", "可买入", "可加仓", "卖出", "止盈"): + print(f" [REC] {code} 非有效推荐(tag={tag},sig={sig}),不入队", flush=True) + return False + # ── 买入类质量闸(卖出/止盈不受 RR/仓位限制——那是风控动作)── + if sig in ("买入", "可买入", "可加仓"): + if (rr or 0) < 2.0: + print(f" [REC] {code} RR={rr}<2.0 平庸推荐,不入队", flush=True) + return False + if not _re.search(r'\d+(?:\.\d+)?\s*%', pos or ''): + print(f" [REC] {code} 仓位非明确%({pos}),不入队", flush=True) + return False + if not (el and eh and el > 0 and eh > el): + print(f" [REC] {code} 买入区缺失/无效({el}~{eh}),不入队", flush=True) + return False + try: + _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if _lp and _lp[0] and _lp[0] > eh * 1.05: + print(f" [REC] {code} 现价{_lp[0]}超区上沿{eh}5%,追高不入队", flush=True) + return False + except Exception: + pass + # 提取【最终新策略】段作为推荐依据摘要 + fa_text = fa or "" + strat = "" + for marker in ("【最终新策略】", "【综合结论】"): + idx = fa_text.find(marker) + if idx >= 0: + strat = fa_text[idx:idx + 450] + break + qf = '/home/hmo/MoFin/gateway/logs/rec_digest_queue.jsonl' + import os as _os + _os.makedirs(_os.path.dirname(qf), exist_ok=True) + with open(qf, 'a', encoding='utf-8') as f: + f.write(_j.dumps({"code": code, "name": name, "signal": sig, + "entry_low": el, "entry_high": eh, "stop_loss": sl, + "take_profit": tp, "rr": rr, "rr_low": rr_lo, "rr_high": rr_hi, + "position": pos, "score": score or 0, + "strategy_excerpt": strat, + "full_analysis": fa_text[:2500], + "ts": _dt.now().isoformat()}, ensure_ascii=False) + "\n") + print(f" [REC] {code} 已入推荐摘要队列", flush=True) + return True + except Exception as e: + print(f" [REC] {code} 入队失败: {e}", flush=True) + return False + + +def track_strategy_version(conn, code: str): + """版本化策略追踪:每次策略变更自动记录新版本。 + 跟踪所有策略状态(不限于 tag='current_recommend'),tag 清除时也记录。""" + try: + row = conn.execute( + "SELECT name, timing_signal, rec_score, rr_ratio, entry_low, entry_high, " + "stop_loss, take_profit, position_advice, tag FROM holding_strategies " + "WHERE code=? AND status='active'", (code,)).fetchone() + if not row: + return + name, sig, score, rr, el, eh, sl, tp, pos, tag = row + # 空壳策略(无信号/无买入区)不追踪 + if not sig or (not el and not eh): + return + + lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + price = lp[0] if lp and lp[0] else 0 + mid = round((el + eh) / 2, 2) if el > 0 and eh > el else 0 + + # 查上一个版本 + prev = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit, rr_ratio, rec_score, " + "status FROM strategy_tracking WHERE code=? ORDER BY id DESC LIMIT 1", + (code,)).fetchone() + + # 计算版本号 + # 计算版本号 + last_ver = conn.execute( + "SELECT MAX(version_seq) FROM strategy_tracking WHERE code=?", (code,)).fetchone() + ver = (last_ver[0] or 0) + 1 if last_ver else 1 + + if prev and prev[6] == 'active': # 上一版本还在进行中 + if (abs((prev[0] or 0) - (el or 0)) < 0.01 and + abs((prev[1] or 0) - (eh or 0)) < 0.01 and + abs((prev[2] or 0) - (sl or 0)) < 0.01 and + abs((prev[3] or 0) - (tp or 0)) < 0.01): + # 参数没变,只更新评分和RR + conn.execute( + "UPDATE strategy_tracking SET rec_score=?, rr_ratio=?, price_at_track=?, " + "timing_signal=?, position_advice=? WHERE id=(" + "SELECT id FROM strategy_tracking WHERE code=? ORDER BY id DESC LIMIT 1)", + (score, rr, price, sig, pos, code)) + conn.commit() + return + + # 有变更 → 追加新版本 + change = "" + if prev: + parts = [] + if abs((prev[0] or 0) - (el or 0)) > 0.5: parts.append(f"区{prev[0]}→{el}") + if abs((prev[2] or 0) - (sl or 0)) > 0.5: parts.append(f"损{prev[2]}→{sl}") + if abs((prev[3] or 0) - (tp or 0)) > 0.5: parts.append(f"盈{prev[3]}→{tp}") + if abs((prev[5] or 0) - (score or 0)) >= 5: parts.append(f"评分{prev[5]}→{score}") + change = "; ".join(parts) if parts else "" + + conn.execute(""" + INSERT INTO strategy_tracking + (code, name, version_seq, tracked_at, timing_signal, rec_score, rr_ratio, + entry_low, entry_high, entry_mid, stop_loss, take_profit, + position_advice, price_at_track, change_summary) + VALUES (?,?,?,datetime('now','localtime'),?,?,?,?,?,?,?,?,?,?,?) + """, (code, name, ver, sig, score, rr, el, eh, mid, sl, tp, pos, price, change)) + conn.commit() + if change: + print(f" [TRACK] {code} v{ver}: {change}", flush=True) + except Exception as e: + print(f" [TRACK] {code} 版本记录失败: {e}", flush=True) + + +def check_strategy_outcomes(conn): + """检查所有 active 追踪版本是否触发 SL/TP,自动关闭并记录理论盈亏""" + active = conn.execute(""" + SELECT id, code, entry_mid, stop_loss, take_profit, rr_ratio + FROM strategy_tracking WHERE status='active' + """).fetchall() + + updated = 0 + for r in active: + tid, code, mid, sl, tp, rr = r + lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if not lp or not lp[0]: + continue + price = float(lp[0]) + mid = mid or price # fallback + + closed = False + if tp and tp > 0 and price >= tp: + pnl_pct = round((tp - mid) / mid * 100, 1) if mid > 0 else 0 + conn.execute(""" + UPDATE strategy_tracking SET status='hit_tp', closed_at=datetime('now','localtime'), + close_price=?, close_reason='止盈触发', theoretical_pnl=? + WHERE id=? + """, (price, pnl_pct, tid)) + print(f" [TRACK] {code} v{tid} 止盈! {price}≥{tp} +{pnl_pct}%", flush=True) + closed = True + elif sl and sl > 0 and price <= sl: + pnl_pct = round((sl - mid) / mid * 100, 1) if mid > 0 else -5 + conn.execute(""" + UPDATE strategy_tracking SET status='hit_sl', closed_at=datetime('now','localtime'), + close_price=?, close_reason='止损触发', theoretical_pnl=? + WHERE id=? + """, (price, pnl_pct, tid)) + print(f" [TRACK] {code} v{tid} 止损! {price}≤{sl} {pnl_pct}%", flush=True) + closed = True + + if closed: + updated += 1 + + if updated: + conn.commit() + # ── 统计验证(2026-07-28 老爸:胜率/夏普/最大回撤)── + try: + closed = conn.execute(""" + SELECT theoretical_pnl FROM strategy_tracking + WHERE status IN ("hit_tp", "hit_sl", "manual_close") AND theoretical_pnl IS NOT NULL + """).fetchall() + if closed: + wins = [p[0] for p in closed if p[0] > 0] + losses = [abs(p[0]) for p in closed if p[0] < 0] + win_rate = len(wins) / len(closed) if closed else 0.55 + avg_win = sum(wins) / len(wins) if wins else 0 + avg_loss = sum(losses) / len(losses) if losses else 0 + sharpe = (avg_win * win_rate - avg_loss * (1 - win_rate)) / (avg_loss if avg_loss > 0 else 1) if avg_loss > 0 else 0 + max_dd = max([abs(p[0]) for p in closed if p[0] < 0], default=0) + print(f" [STATS] 胜率{win_rate:.0%} 夏普{sharpe:.2f} 最大回撤{max_dd:.1f}%", flush=True) + except Exception as _se: + print(f" [STATS] 统计失败: {_se}", flush=True) + return updated + + +def flush_rec_digest(max_items=5): + """把队列里的新推荐聚成一条 XMPP 摘要(RR 降序,最多 max_items 只)。 + 头部 1-2 只附带策略依据摘要+按现金的操盘建议。""" + import json as _j, os as _os, sqlite3 as _sq + qf = '/home/hmo/MoFin/gateway/logs/rec_digest_queue.jsonl' + if not _os.path.exists(qf): + return 0 + try: + with open(qf, encoding='utf-8') as f: + items = [_j.loads(l) for l in f if l.strip()] + except Exception: + return 0 + if not items: + return 0 + _os.remove(qf) + + # ── 快照回库校验(2026-07-24 老爸:推荐和XMPP同步)── + # 队列是打标瞬间的快照;flush 前回库读实时 信号/RR/tag, + # 信号降级为弱信号或RR跌破2.0的条目直接丢弃——XMPP说的必须和盯盘一致。 + import sqlite3 as _sq0 + from datetime import datetime as _ddt + _now = _ddt.now() + _h, _m, _w = _now.hour, _now.minute, _now.weekday() + _market_open = _w < 5 and ((_h == 9 and _m >= 30) or (10 <= _h < 15)) + _vconn = _sq0.connect("/home/hmo/MoFin/data/mofin.db") + _WEAK = ("信号不充分", "关注", "弱势持有", "观望", "持有", "") + _live = [] + for it in items: + r = _vconn.execute( + "SELECT timing_signal, rr_ratio, tag, reassessed_at FROM holding_strategies WHERE code=? AND status='active'", + (it['code'],)).fetchone() + if not r: + print(f" [REC] {it['code']} 已不在库,丢弃", flush=True) + continue + cur_sig, cur_rr, cur_tag, cur_ra = r[0] or "", r[1] or 0, r[2] or "", r[3] or "" + if cur_tag != 'current_recommend': + print(f" [REC] {it['code']} tag已撤销({cur_tag}),丢弃", flush=True) + continue + # ── 数据时效校验(2026-07-27 老爸:盘前分析盘中推送=过期数据误导)── + # 分析时间在开盘前且现在已开盘 → 触发盘中重评(用实时数据分析,不推旧分析) + if _market_open and cur_ra: + try: + _ra_dt = _ddt.fromisoformat(str(cur_ra)[:19]) + # 分析在 08:00-09:29 之间做的 = 盘前分析 → 盘中触发重评 + if (_ra_dt.hour >= 8 and (_ra_dt.hour < 9 or (_ra_dt.hour == 9 and _ra_dt.minute < 30))) \ + and (_ddt.now() - _ra_dt).total_seconds() > 120: + try: + import subprocess as _sp + _re = _sp.run( + ["python3", "/home/hmo/MoFin/deploy/profile-scripts/per_stock_reassess.py", it['code']], + capture_output=True, text=True, timeout=90) + if _re.returncode == 0: + print(f" [REC] {it['code']} 盘中重评完成,用实时数据更新策略", flush=True) + # 重新读库获取更新后数据 + r = _vconn.execute( + "SELECT timing_signal, rr_ratio, tag FROM holding_strategies WHERE code=? AND status='active'", + (it['code'],)).fetchone() + if r: + cur_sig, cur_rr, cur_tag = r[0] or "", r[1] or 0, r[2] or "" + if cur_tag != 'current_recommend': + print(f" [REC] {it['code']} 重评后tag撤销,丢弃", flush=True) + continue + else: + print(f" [REC] {it['code']} 盘中重评失败(rc={_re.returncode}),标记警告", flush=True) + it['_stale_warn'] = "⚠️ 盘前分析(已开盘未能及时重评,请结合实时盘面判断)" + except subprocess.TimeoutExpired: + print(f" [REC] {it['code']} 盘中重评超时,标记警告", flush=True) + it['_stale_warn'] = "⚠️ 盘前分析(已开盘未能及时重评,请结合实时盘面判断)" + except Exception: + pass + if it.get('signal') in ("买入", "可买入", "可加仓"): + if cur_sig in _WEAK: + print(f" [REC] {it['code']} 信号降级为'{cur_sig}',丢弃", flush=True) + continue + if cur_rr < 2.0: + print(f" [REC] {it['code']} 实时RR={cur_rr}<2.0,丢弃", flush=True) + continue + it['signal'] = cur_sig # 用实时信号发 + it['rr'] = cur_rr + _live.append(it) + _vconn.close() + items = _live + if not items: + print(" [REC] 快照校验后无有效推荐,不发digest", flush=True) + return 0 + + _SELL_SIGS = ("卖出", "止盈") + # 卖出/止盈是释放现金的操作,不占买入预算,单独一组排最前 + sells = [x for x in items if x.get('signal') in _SELL_SIGS] + buys_all = [x for x in items if x.get('signal') not in _SELL_SIGS] + buys_all.sort(key=lambda x: (x.get('score') or 0, x.get('rr') or 0), reverse=True) + items = sells + buys_all + top = items[:max_items] + + # ── 现金预算(决定操盘建议 + 换仓策略):只对买入项计算,卖出不占预算 ── + cash_note = "" + rotation_note = "" + buys = [] + queued = [] + try: + conn = _sq.connect("/home/hmo/MoFin/data/mofin.db") + conn.row_factory = _sq.Row + r = conn.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone() + if r and r[1]: + cash, total = r[0] or 0, r[1] + budget_pct = cash / total * 100 + cum = 0.0 + buys = [] + queued = [] + for it in buys_all: # 只遍历买入项;sells 永远可执行 + import re as _re + m = _re.search(r'(\d+(?:\.\d+)?)\s*%', it.get('position') or '') + pct = float(m.group(1)) if m else 8.0 + if (it.get('rr') or 0) >= 2.0 and cum + pct <= budget_pct + 1e-9: + buys.append((it, pct)) + cum += pct + else: + queued.append((it, pct)) + cash_note = (f"现金{cash/10000:.1f}万({budget_pct:.1f}%)|按预算本次可执行: " + + ("、".join(f"{b[0].get('name') or b[0]['code']}≈{b[1]:.0f}%" for b in buys) if buys else "无") + + (f"(合计≈{cum:.0f}%)" if buys else "")) + # ── 换仓策略:有排队推荐时,找可减的弱持仓来腾挪 ── + if queued: + weak = conn.execute(""" + SELECT hs.code, hs.name, hs.timing_signal, h.position_pct, h.cost, lp.price, lp.change_pct + FROM holding_strategies hs + JOIN holdings h ON hs.code = h.code AND h.is_active = 1 + LEFT JOIN live_prices lp ON hs.code = lp.code + WHERE hs.status='active' AND h.shares > 0 + AND hs.timing_signal IN ('弱势持有','观望','持有') + ORDER BY CASE hs.timing_signal WHEN '弱势持有' THEN 0 WHEN '观望' THEN 1 ELSE 2 END, + h.position_pct DESC + """).fetchall() + if weak: + need_pct = queued[0][1] + plan = [] + freed = 0.0 + for w in weak: + if freed >= need_pct: + break + plan.append(w) + freed += w["position_pct"] or 0 + q0 = queued[0][0] + _names = "+".join(str(w['name']) for w in plan) + _sigs = ",".join(sorted({w['timing_signal'] for w in plan})) + rotation_note = ("🔄 换仓建议:现金不足买 " + str(q0.get('name') or q0['code']) + + f"(RR={q0.get('rr') or 0})→ 可减 {_names}" + + f"({_sigs},腾出≈{freed:.0f}%仓位)换入") + conn.close() + except Exception as _re: + print(f" [REC] 换仓计算异常: {_re}", flush=True) + + lines = [f"📈 新增推荐 {len(items)} 只(按RR排序):"] + # 与盯盘推荐区一致的 可执行/排队 徽章(2026-07-24 老爸:推荐和XMPP同步) + _exec_codes = {b[0]['code'] for b in buys} | {s['code'] for s in sells} + for i, it in enumerate(top): + _rr_mid = it.get('rr') or 0 + _rr_lo, _rr_hi = it.get('rr_low') or 0, it.get('rr_high') or 0 + if _rr_lo and _rr_hi and _rr_lo != _rr_hi: + _lo_val = min(_rr_lo, _rr_hi) + _hi_val = max(_rr_lo, _rr_hi) + _rr_txt = f"RR={_rr_mid}({_lo_val}~{_hi_val})" + else: + _rr_txt = f"RR={_rr_mid}" + _el = it.get('entry_low') or 0 + _eh = it.get('entry_high') or 0 + _mid = f"{(_el+_eh)/2:.2f}" if _el > 0 and _eh > _el else "—" + _badge = "💰可执行" if it['code'] in _exec_codes else "⏳排队" + _score = it.get('score') or 0 + _score_txt = f" [{_score}分]" if _score else "" + lines.append(f"• {_badge}{_score_txt} {it.get('name') or it['code']}({it['code']}) {it['signal']}" + f" 区{_el or '—'}→{_mid}←{_eh or '—'}" + f" 损{it.get('stop_loss') or '—'} 盈{it.get('take_profit') or '—'}" + f" {_rr_txt} 仓位{it.get('position') or '—'}") + if it.get('_stale_warn'): + lines.append(f" ⚠️ {it['_stale_warn']}") + # 所有推荐都附完整策略依据 + if it.get('strategy_excerpt'): + lines.append(f" 依据: {it['strategy_excerpt']}") + elif it.get('full_analysis'): + lines.append(f" 依据: {it['full_analysis']}") + if len(items) > max_items: + lines.append(f"…另有 {len(items) - max_items} 只详见盯盘推荐操作区") + if cash_note: + lines.append("💰 " + cash_note) + if rotation_note: + lines.append(rotation_note) + try: + import sys as _s, os as _o2 + _s.path.insert(0, '/home/hmo/MoFin/deploy/profile-scripts') + from alert_helper import notify, ACTION + return notify("推荐操作", "\n".join(lines), ACTION) + except Exception as e: + print(f" [REC] 摘要推送失败: {e}", flush=True) + return False + + +def push_recommend_alert(conn, code: str): + """推荐操作 XMPP 推送(tag 转为 current_recommend 时调用,全路径统一)。 + 质量门禁:实时价>0、区间有效(下沿<上沿<下沿x3)、现价不超上沿5%、 + 损<下沿且在(0.5x~1.0x)现价内、盈>上沿>损。不过不推。""" + try: + row = conn.execute( + "SELECT name, timing_signal, entry_low, entry_high, stop_loss, take_profit, " + "rr_ratio, position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not row: + return False + name, sig, el, eh, sl, tp, rr, pos = row + lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + price = lp[0] if lp and lp[0] else 0 + el, eh, sl, tp = el or 0, eh or 0, sl or 0, tp or 0 + # ── 门禁 ── + if price <= 0: + print(f" [ALERT] {code} 无实时价,不推", flush=True); return False + if not (el > 0 and eh > el and eh < el * 3): + print(f" [ALERT] {code} 区间无效({el}~{eh}),不推", flush=True); return False + if price > eh * 1.05: + print(f" [ALERT] {code} 现价{price}高超上沿{eh}5%,不推", flush=True); return False + if not (sl > 0 and sl < el and price * 0.5 <= sl <= price): + print(f" [ALERT] {code} 止损{sl}不合理,不推", flush=True); return False + if not (tp > eh and tp > sl): + print(f" [ALERT] {code} 止盈{tp}不合理,不推", flush=True); return False + import sys as _s, os as _o + _s.path.insert(0, _o.path.dirname(_o.path.abspath(__file__))) + from alert_helper import notify, ACTION + _mid_xmpp = f"{(el+eh)/2:.2f}" if el > 0 and eh > el else "—" + msg = (f"📈 {name or code}({code}) 价{price}→12维{sig}!" + f"区间{el}→{_mid_xmpp}←{eh} 损{sl} 盈{tp} RR={rr or 0} 仓位{pos or '-'}") + return notify("买入信号", msg, ACTION) + except Exception as e: + print(f" [ALERT] {code} 推送异常: {e}", flush=True) + return False + + +def snapshot_strategy_history(conn, code: str, source_trigger: str = "write_holding_strategy"): + """在修改前快照当前策略到 strategy_history 表。永不抛异常。""" + try: + row = conn.execute( + "SELECT code, name, decision_type, strategy_type, full_analysis, " + "action, timing_signal, entry_low, entry_high, stop_loss, take_profit, " + "position_advice, rr_ratio, version, reassessed_at " + "FROM holding_strategies WHERE code=? AND status='active'", + (code,) + ).fetchone() + if not row: + return + now = datetime.now().isoformat() + conn.execute(""" + INSERT INTO strategy_history + (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) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + row[0], row[1], row[2], row[3], + row[4], row[5], row[6], + row[7], row[8], row[9], row[10], + row[11], row[12], row[13], + source_trigger, row[14], now + )) + conn.commit() + # 每只股票只保留最近20条历史 + conn.execute(""" + DELETE FROM strategy_history WHERE code=? AND id NOT IN ( + SELECT id FROM strategy_history WHERE code=? ORDER BY snapshotted_at DESC LIMIT 20 + ) + """, (code, code)) + conn.commit() + except Exception as e: + print(f" [SNAPSHOT] {code} 快照失败: {e}", flush=True) + + +def write_holding_strategy(conn, code: str, name: str, data: dict, + source_trigger: str = "write_holding_strategy") -> tuple[bool, str]: + """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" + try: + # ── 覆写前快照旧行 ── + snapshot_strategy_history(conn, code, source_trigger) + + + currency = data.get('currency', 'CNY') + # Serialize JSON fields + import json as _json + trigger_j = _json.dumps(data.get('trigger', {}), ensure_ascii=False) if isinstance(data.get('trigger'), dict) else str(data.get('trigger', '{}')) + changelog_j = _json.dumps(data.get('changelog', []), ensure_ascii=False) if isinstance(data.get('changelog'), list) else str(data.get('changelog', '[]')) + quality_issues_j = _json.dumps(data.get('quality_issues', {}), ensure_ascii=False) if isinstance(data.get('quality_issues'), dict) else data.get('quality_issues_json', '') + signal_factors_j = _json.dumps(data.get('signal_factors', []), ensure_ascii=False) if isinstance(data.get('signal_factors'), list) else data.get('signal_factors_json', '') + + # ── 推荐操作 tag 同步语义(与 XMPP 动作级信号同源,红线#12)── + # 动作级信号 → tag=current_recommend(进盯盘"推荐操作"区) + # 信号降级 → 清除 current_recommend(区域同步消失) + # active_manual(人工标记)永远不被自动流程覆盖或清除 + _ACTION_SIGNALS = ("买入", "可买入", "可加仓", "卖出", "止盈") + _RISK_SIGNALS = ("卖出", "止盈") + _existing_fa = data.get('full_analysis', '') + _existing_ra = data.get('reassessed_at', '') + _tag_absent = 'tag' not in data + _new_sig = data.get('timing_signal', '') or '' + _explicit_tag = data.get('tag', None) + _old_tag = '' + _old_sig = '' + _old_ra = '' + _old_action = '' + if True: + try: + _old = conn.execute("SELECT full_analysis, reassessed_at, tag, timing_signal, action FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone() + if _old: + if not _existing_fa: + if _old[0]: _existing_fa = _old[0] + if _old[1]: _existing_ra = _old[1] + _old_tag = _old[2] or '' + _old_sig = _old[3] or '' + _old_ra = _old[1] or '' + _old_action = _old[4] or '' + except: + pass + # ── 信号权威层级(2026-07-22):新鲜(<20h)12维动作级信号, + # 技术路径(regenerate_all/price_monitor)无权降级为 关注/信号不充分/持有。 + # 只有 LLM 路径(batch_12d/per_stock_12d)可以覆盖。 + # 2026-07-27 老爸补:卖出/止盈的保护漏了(技术路径把卖出→持有导致盯盘显示矛盾)── + _TECHNICAL_PATHS = ('write_holding_strategy',) + if source_trigger in _TECHNICAL_PATHS \ + and _old_sig in ("买入", "可买入", "可加仓", "卖出", "止盈") \ + and _new_sig not in _ACTION_SIGNALS and _old_ra: + try: + from datetime import datetime as _ddt, timedelta as _dtd + _ra_dt = _ddt.fromisoformat(str(_old_ra)[:19]) + if (_ddt.now() - _ra_dt) < _dtd(hours=20): + print(f" [AUTHORITY] {code} 保留新鲜12维信号'{_old_sig}'({_old_ra[:16]})," + f"拒绝技术路径降级为'{_new_sig}'", flush=True) + _new_sig = _old_sig + data['timing_signal'] = _old_sig + except Exception: + pass + # ── 策略参数权威保护(2026-07-27 老爸:技术路径每2分钟覆写12维的Zone/SL/TP/Position→RR波动→盯盘和XMPP不一致)── + # 新鲜(<20h)12维分析的技术参数+仓位不允许被技术路径覆写。 + # 2026-07-27 坑:_old_ra=None 时权威保护永不触发(很多股票的reassessed_at为空), + # 导致系统自动计算的仓位被反复踩回"中等仓位"。加入兜底:仓位含"%(系统按"即永保。 + if source_trigger in _TECHNICAL_PATHS: + # 兜底:系统自动计算的仓位永久保护(不含 %(系统按 的不保护,即只有 LLM 仓和系统仓被保护) + _old_pos = conn.execute( + "SELECT position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + _old_pos_val = (_old_pos[0] or '') if _old_pos else '' + if _old_pos_val and '系统按' in str(_old_pos_val): + data['position_advice'] = _old_pos_val + # 被保护仓位触发时顺便保护参数(无论 _old_ra 是否空) + _op = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if _op and float(_op[0] or 0) > 0: + data['entry_low'] = float(_op[0]) + data['entry_high'] = float(_op[1]) + data['stop_loss'] = float(_op[2]) + data['take_profit'] = float(_op[3]) + print(f" [AUTHORITY-POS] {code} 保护系统仓位'{_old_pos_val[:30]}'", flush=True) + elif _old_ra: + try: + from datetime import datetime as _ddt3, timedelta as _dtd3 + _ra_dt3 = _ddt3.fromisoformat(str(_old_ra)[:19]) + if (_ddt3.now() - _ra_dt3) < _dtd3(hours=20): + _old_params = conn.execute( + "SELECT entry_low, entry_high, stop_loss, take_profit, position_advice FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if _old_params: + _keys = ['entry_low','entry_high','stop_loss','take_profit','position_advice'] + _vals = [v if v else '' for v in _old_params] + for i, k in enumerate(_keys): + if i < 4 and float(_vals[i] or 0) > 0: + data[k] = float(_vals[i]) + elif i == 4 and str(_vals[i]).strip(): + data[k] = str(_vals[i]) + print(f" [AUTHORITY-PARAM] {code} 保留12维参数(区{_vals[0]}~{_vals[1]} 损{_vals[2]} 盈{_vals[3]} pos={_vals[4]})", flush=True) + except Exception: + pass + # ── action 权限保护(与信号同一权威层级,2026-07-22)── + # 技术路径不得覆盖新鲜(<20h)12维 action。 + # 根治:技术路径写的"盈亏比不足1:1.5不建议买入"旧 action 与12维买入分析同框矛盾。 + if source_trigger not in ('batch_12d', 'per_stock_12d') and _old_action and _old_ra: + try: + from datetime import datetime as _ddt2, timedelta as _dtd2 + if (_ddt2.now() - _ddt2.fromisoformat(str(_old_ra)[:19])) < _dtd2(hours=20): + data['action'] = _old_action + except Exception: + pass + if _old_tag == 'active_manual': + _existing_tag = 'active_manual' # 人工标记不可动 + elif _explicit_tag is not None: + _existing_tag = _explicit_tag # 显式传入优先(含''清除) + elif _new_sig in _ACTION_SIGNALS and source_trigger in ('batch_12d', 'per_stock_12d'): + _existing_tag = 'current_recommend' # 仅 LLM 路径可创建推荐(防技术路径抖动) + elif _new_sig and _old_tag == 'current_recommend' and source_trigger in ('batch_12d', 'per_stock_12d'): + _existing_tag = '' # 仅 LLM 路径可撤销推荐 + else: + _existing_tag = _old_tag # 技术路径一律不动 tag + + # ── 类型守卫:shares 必须是数值,防止字符串写入导致下游崩溃 ── + _shares = data.get('shares', 0) + if not isinstance(_shares, (int, float)): + print(f" [TYPE GUARD] {code} shares类型异常({type(_shares).__name__}={_shares!r}),重置为0", flush=True) + _shares = 0 + + # ── action 权限保护已在上方信号权威块中统一处理 ── + + # ── UPSERT(2026-07-23 老爸:新增计算列不该用DELETE+INSERT,该用UPDATE)── + # 只写本函数拥有的列;rr_low/rr_high(recompute_rr拥有)、superseded_at(data_governance + # 拥有)、created_at(创建时间)不在写集内 → 天然保留,未来新增计算列自动免疫。 + # 同时消除 DELETE→INSERT 之间崩溃=行丢失的原子性窗口,以及 created_at 被重置的副作用。 + conn.execute(""" + INSERT INTO holding_strategies + (code, name, version, price, cost, shares, stop_loss, take_profit, + entry_low, entry_high, currency, strategy_type, action, + timing_signal, rr_ratio, tech_snapshot, stock_category, + sector_context, status, trigger_json, changelog_json, + source, reason, updated_at, + avg_price, decision_timestamp, note, quality_check, + quality_checked_at, quality_issues_json, position_advice, + signal_factors_json, time_horizon, decision_type, + full_analysis, reassessed_at, tag) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, + datetime('now','localtime'), + ?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, version=excluded.version, price=excluded.price, + cost=excluded.cost, shares=excluded.shares, + stop_loss=excluded.stop_loss, take_profit=excluded.take_profit, + entry_low=excluded.entry_low, entry_high=excluded.entry_high, + currency=excluded.currency, strategy_type=excluded.strategy_type, + action=excluded.action, timing_signal=excluded.timing_signal, + rr_ratio=excluded.rr_ratio, tech_snapshot=excluded.tech_snapshot, + stock_category=excluded.stock_category, sector_context=excluded.sector_context, + status=excluded.status, trigger_json=excluded.trigger_json, + changelog_json=excluded.changelog_json, source=excluded.source, + reason=excluded.reason, updated_at=excluded.updated_at, + avg_price=excluded.avg_price, decision_timestamp=excluded.decision_timestamp, + note=excluded.note, quality_check=excluded.quality_check, + quality_checked_at=excluded.quality_checked_at, + quality_issues_json=excluded.quality_issues_json, + position_advice=excluded.position_advice, + signal_factors_json=excluded.signal_factors_json, + time_horizon=excluded.time_horizon, decision_type=excluded.decision_type, + full_analysis=excluded.full_analysis, reassessed_at=excluded.reassessed_at, + tag=excluded.tag + """, ( + code, name, + data.get('version', 1), data.get('price'), data.get('cost'), + _shares, data.get('stop_loss'), data.get('take_profit'), + data.get('entry_low'), data.get('entry_high'), currency, + data.get('strategy_type', 'holding'), data.get('action'), + data.get('timing_signal'), data.get('rr_ratio'), + data.get('tech_snapshot'), data.get('stock_category'), + data.get('sector_context'), data.get('status', 'active'), + trigger_j, changelog_j, + data.get('source'), data.get('reason'), + # new columns + data.get('avg_price', 0), + data.get('timestamp') or data.get('created_at', ''), + data.get('note', ''), + data.get('quality_check', ''), + data.get('quality_checked_at', ''), + quality_issues_j, + data.get('position_advice', ''), + signal_factors_j, + data.get('time_horizon', ''), + data.get('type', data.get('strategy_type', 'holding')), + # 保留full_analysis和reassessed_at(合并逻辑在上方完成) + _existing_fa, + _existing_ra, + _existing_tag, + )) + conn.commit() + # ── 策略版本追踪(每次策略写入后自动记录,2026-07-27)── + if _existing_tag == 'current_recommend': + track_strategy_version(conn, code) + # ── 推荐转场:LLM路径新转为 current_recommend → 记入摘要队列(不逐只推送)── + if _existing_tag == 'current_recommend' and _old_tag != 'current_recommend' \ + and source_trigger in ('batch_12d', 'per_stock_12d'): + enqueue_recommend(conn, code) + return True, f"策略 {code} 已写入" + except sqlite3.IntegrityError as e: + return False, f"币种约束: {e}" + except Exception as e: + return False, str(e) + + +def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]: + """批量写入持仓(替代 portfolio.json holdings[])""" + try: + conn.execute("BEGIN IMMEDIATE") + for h in holdings: + currency = str(h.get('currency', 'CNY')).upper() + if currency not in ('CNY', 'HKD'): + return False, f"非法币种: {currency}(必须 CNY 或 HKD)" + conn.execute(""" + INSERT INTO holdings (code, name, shares, cost, price, market_value, + change_pct, currency, position_pct, added_at, is_active) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, shares=excluded.shares, cost=excluded.cost, + price=excluded.price, market_value=excluded.market_value, + change_pct=excluded.change_pct, currency=excluded.currency, + position_pct=excluded.position_pct + """, ( + h.get('code'), h.get('name'), h.get('shares', 0), + h.get('cost'), h.get('price'), + h.get('market_value'), h.get('change_pct'), + h.get('currency', 'CNY'), h.get('position_pct'), + )) + conn.commit() + # ── 同步 holding_strategies(2026-07-27 老爸:导入持仓后盯盘应即时出现)── + for h in holdings: + code = h.get('code') + name = h.get('name', '') + shares = h.get('shares') or 0 + if not code or shares <= 0: + continue + existing = conn.execute( + "SELECT id, decision_type FROM holding_strategies WHERE code=? AND status='active'", + (code,)).fetchone() + if not existing: + # 全新持仓:创建基础策略条目 + conn.execute(""" + INSERT INTO holding_strategies (code, name, decision_type, strategy_type, + status, timing_signal, created_at) + VALUES (?, ?, '持仓策略', 'holding', 'active', '关注', datetime('now','localtime')) + """, (code, name)) + elif existing[1] != '持仓策略': + # 已有条目但类型不对(自选转持仓) + conn.execute( + "UPDATE holding_strategies SET decision_type='持仓策略' WHERE code=? AND status='active'", + (code,)) + conn.commit() + return True, f"已写入 {len(holdings)} 条持仓" + except sqlite3.IntegrityError as e: + conn.rollback() + return False, f"币种约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突(重试耗尽): {e}" +def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]: + """写入持仓汇总(替代 portfolio.json 顶层)""" + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute(""" + INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, + cash, frozen_cash, position_pct, total_pnl, currency, updated_at) + VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(id) DO UPDATE SET + total_assets=excluded.total_assets, total_mv=excluded.total_mv, + stock_value=excluded.stock_value, cash=excluded.cash, + frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, + total_pnl=excluded.total_pnl, currency=excluded.currency, + updated_at=datetime('now','localtime') + """, ( + data.get('total_assets'), data.get('total_mv'), data.get('stock_value'), + data.get('cash'), data.get('frozen_cash', 0), data.get('position_pct'), + data.get('total_pnl'), data.get('currency', 'CNY'), + )) + conn.commit() + return True, "汇总已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突: {e}" + + +def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]: + """写入自选股(写入 watchlist_stocks 表)""" + try: + conn.execute(""" + INSERT INTO watchlist_stocks (code, name, price, entry_low, entry_high, + stop_loss, currency, source, source_detail, notes, added_by, added_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, price=excluded.price, entry_low=excluded.entry_low, + entry_high=excluded.entry_high, stop_loss=excluded.stop_loss, + currency=excluded.currency, source=excluded.source, + source_detail=excluded.source_detail, notes=excluded.notes, + added_by=excluded.added_by + """, ( + stock.get('code'), stock.get('name'), stock.get('price'), + stock.get('entry_low'), stock.get('entry_high'), stock.get('stop_loss'), + stock.get('currency', 'CNY'), stock.get('source'), stock.get('source_detail'), + stock.get('notes'), stock.get('added_by'), + )) + conn.commit() + return True, f"自选 {stock.get('code')} 已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + + +def write_cash_log(conn, data: dict) -> tuple[bool, str]: + """记录现金变更(替代手动改 portfolio.json cash 字段)""" + try: + conn.execute(""" + INSERT INTO cash_log (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note) + VALUES (?,?,?,?,?,?,?) + """, ( + data.get('cash_before'), data.get('cash_after'), + data.get('frozen_before'), data.get('frozen_after'), + data.get('change_amount'), data.get('source', 'manual'), + data.get('note', ''), + )) + conn.commit() + return True, "现金变更已记录" + except Exception as e: + return False, str(e) + + +def query_cash_log(conn, limit: int = 20) -> list[dict]: + rows = conn.execute( + "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +# ═══ live_prices / mtf_cache / capital_flow_cache 写函数 ═══ + +def write_live_prices(conn, prices: dict): + """写入实时价格快照(替代 live_prices.json)""" + import json + for code, info in prices.items(): + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) VALUES (?,?,?,datetime('now','localtime'))", + (code, info.get('price'), info.get('change_pct')) + ) + +def read_live_prices(conn) -> dict: + rows = conn.execute("SELECT code, price, change_pct FROM live_prices").fetchall() + return {r['code']: {'price': r['price'], 'change_pct': r['change_pct']} for r in rows} + + +def write_mtf_cache(conn, code: str, data: dict): + """写入多周期缓存(替代 multi_tf_cache.json 单条)""" + import json + conn.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + +def read_mtf_cache(conn, code: str) -> dict: + import json + r = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() + return json.loads(r['cache_json']) if r else {} + + +def write_capital_flow_cache(conn, data: dict): + """写入资金流缓存(替代 capital_flow_cache.json)""" + import json + conn.execute("DELETE FROM capital_flow_cache") + conn.execute( + "INSERT INTO capital_flow_cache (cache_json, updated_at) VALUES (?,datetime('now','localtime'))", + (json.dumps(data, ensure_ascii=False),) + ) + +def read_capital_flow_cache(conn) -> dict: + import json + r = conn.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + return json.loads(r['cache_json']) if r else {} diff --git a/server.py b/server.py index 198dfc93..2ad2dbed 100644 --- a/server.py +++ b/server.py @@ -1,1739 +1,1762 @@ -#!/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 - - -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/") -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=&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/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], - "updated_at": summary.get("updated_at", ""), - }) - except Exception: - return jsonify({"error": "数据库查询失败"}), 500 - - -@app.route("/api/report/") -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/") -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/", 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 - - # 尝试小果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 # 行情获取失败不影响主流程 - - # 更新对应数据文件 - 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/") -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) - - -if __name__ == "__main__": - port = int(os.environ.get("PORT", 8899)) - print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}") +#!/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 + + +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/") +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=&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/backtest") +def get_research_backtest(): + import sqlite3 + from datetime import datetime, timedelta + period = request.args.get("period", "1y") + capital = float(request.args.get("capital", 1000000)) + end_date = datetime.now().strftime("%Y-%m-%d") + if period == "6m": + start_date = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d') + elif period == "1y": + start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d') + else: + start_date = (datetime.now() - timedelta(days=730)).strftime('%Y-%m-%d') + try: + from backtest_framework import run_strategy_research + result = run_strategy_research(start_date, end_date, capital) + from flask import jsonify + return jsonify(result) + except Exception as e: + from flask import jsonify + return jsonify({'error': str(e)}), 500 + +@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], + "updated_at": summary.get("updated_at", ""), + }) + except Exception: + return jsonify({"error": "数据库查询失败"}), 500 + + +@app.route("/api/report/") +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/") +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/", 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 + + # 尝试小果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 # 行情获取失败不影响主流程 + + # 更新对应数据文件 + 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/") +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) + + +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) \ No newline at end of file diff --git a/static/index.html b/static/index.html index ea89632e..affd517c 100644 --- a/static/index.html +++ b/static/index.html @@ -70,6 +70,7 @@ body { background: #0f0f13; color: #e2e8f0; } + 📸 上传 @@ -82,6 +83,7 @@ body { background: #0f0f13; color: #e2e8f0; } +