#!/usr/bin/env python3 # -*- coding: utf-8 -*- """anomaly_monitor.py — 持仓股异动监控(2026-08-19 老莫:中科电气放量拉升无通知的教训) 检测持仓股/自选股的异动(价格急变/量能异常/突破),异动时 ACTION 级推送(不被信息淹没)。 异动判定(确定性代码,不依赖 LLM): 1. 单日涨跌幅 ≥ 阈值(默认 ±5%)→ 急涨/急跌 2. 量能异常:当日成交量 ≥ 5日均量 × 3 → 放量 3. 价格突破:创 20 日新高(或跌破 20 日新低) 4. 深套股反弹:浮亏 ≤ -20% 的持仓单日涨幅 ≥ 4%(深套异动,老莫关注) 推送:ACTION 级(alert_helper 直通),只对持仓股 + 自选股。 用法:cron 每 5 分钟跑(交易日盘中) python3 anomaly_monitor.py """ import sqlite3, os, sys from datetime import datetime from pathlib import Path # ── 消息通道统一路由(broadcast/xmpp by delivery) ── try: from messenger import install_stdio_hook as _msh _msh() except Exception: pass sys.path.insert(0, str(Path(__file__).resolve().parent)) DB = "/home/hmo/MoFin/data/mofin.db" THRESHOLD_CHG = 5.0 # 单日涨跌幅阈值 % THRESHOLD_VOL = 3.0 # 量能倍数(vs 5日均量) THRESHOLD_20D = True # 20日新高/新低检测 THRESHOLD_DEEP_REBOUND = 4.0 # 深套股反弹阈值 % STATE_FILE = "/home/hmo/MoFin/data/anomaly_state.json" def get_conn(): conn = sqlite3.connect(DB, timeout=30) conn.execute("PRAGMA busy_timeout=30000") return conn def load_state(): try: import json with open(STATE_FILE, encoding="utf-8") as f: return json.load(f) except Exception: return {"notified": {}} # {code_date: ts} def save_state(st): import json try: # 只保留最近 500 条 if len(st.get("notified", {})) > 500: st["notified"] = dict(list(st["notified"].items())[-500:]) with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(st, f) except Exception: pass def push_anomaly(body): """ACTION 级推送(直通,不限速)""" try: from alert_helper import notify, ACTION # 2026-08-24 老莫:异动无操作建议且过于频繁→只进broadcast不推XMPP from messenger import send as _msend _msend(title="持仓异动", content=body, source="anomaly_monitor.py", channel="broadcast") return True except Exception as e: print(f" ⚠️ 异动推送失败: {e}", flush=True) return False def get_holdings(): """持仓 + 自选股列表""" conn = get_conn() # 持仓(holdings 表) holds = conn.execute("SELECT code, name, shares, cost, price FROM holdings WHERE is_active=1 AND shares>0").fetchall() # 自选(holding_strategies 自选策略) wl = conn.execute("SELECT code, name FROM holding_strategies WHERE status='active' AND decision_type='自选策略'").fetchall() conn.close() codes = set(h[0] for h in holds) | set(w[0] for w in wl) names = {h[0]: h[1] or h[0] for h in holds} names.update({w[0]: w[1] or w[0] for w in wl}) costs = {h[0]: h[3] for h in holds} # 2026-08-24 修复列错位:h[2]=shares h[3]=cost(518880成本2400 bug) return codes, names, costs def check_anomalies(): conn = get_conn() codes, names, costs = get_holdings() st = load_state() notified = st.get("notified", {}) now = datetime.now().strftime("%Y-%m-%d %H:%M") today = now[:10] anomalies = [] for code in sorted(codes): # 最新日线 + 昨收 + 前5日均量 row = conn.execute(""" SELECT date, close, volume FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 6 """, (code,)).fetchall() if len(row) < 2: continue latest = row[0] # (date, close, volume) prev = row[1] if not latest[1] or not prev[1] or prev[1] <= 0: continue # 当日涨跌幅 chg = (latest[1] - prev[1]) / prev[1] * 100 # 5日均量(排除当日) vols = [r[2] for r in row[1:6] if r[2]] avg_vol = sum(vols) / len(vols) if vols else 0 vol_ratio = latest[2] / avg_vol if avg_vol > 0 else 0 # 20日新高/新低 high20 = conn.execute(""" SELECT MAX(high), MIN(low) FROM ( SELECT high, low FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 20 ) """, (code,)).fetchone() is_new_high = bool(high20 and high20[0] and latest[1] >= high20[0]) is_new_low = bool(high20 and high20[1] and latest[1] <= high20[1]) # 深套反弹(成本 vs 现价) cost = costs.get(code) or 0 pnl = (latest[1] - cost) / cost * 100 if cost > 0 else 0 is_deep = pnl <= -20 # 判定异动 reasons = [] if abs(chg) >= THRESHOLD_CHG: reasons.append(f"{'急涨' if chg > 0 else '急跌'}{abs(chg):.1f}%") if vol_ratio >= THRESHOLD_VOL: reasons.append(f"放量{vol_ratio:.1f}倍") if THRESHOLD_20D and is_new_high: reasons.append("创20日新高") if THRESHOLD_20D and is_new_low: reasons.append("破20日新低") if is_deep and chg >= THRESHOLD_DEEP_REBOUND: reasons.append(f"深套反弹{chg:.1f}%") if reasons and latest[0] == today: # 去重:同 code 同原因类型当天只推一次 key = f"{code}_{'|'.join(sorted(reasons))}_{today}" if key not in notified or (datetime.now().timestamp() - notified[key] > 3600): name = names.get(code, code) cost_s = f" 成本{cost:.2f} 浮盈{pnl:+.1f}%" if cost > 0 else "" body = (f"⚡ {name}({code}) 异动\n" f"现价 {latest[1]:.2f} ({chg:+.1f}%) | 量能{vol_ratio:.1f}倍\n" f"触发: {'、'.join(reasons)}{cost_s}") anomalies.append(body) notified[key] = datetime.now().timestamp() print(f" ⚡ {code}: {'、'.join(reasons)}", flush=True) save_state({"notified": notified}) conn.close() if anomalies: full = f"🔔 持仓/自选异动 {len(anomalies)} 条\n" + "\n\n".join(anomalies) push_anomaly(full) print(f"[异动] 推送 {len(anomalies)} 条", flush=True) else: print(f"[异动] {now} 无异动", flush=True) return len(anomalies) if __name__ == "__main__": # 单例守卫(防 cron 重复) import fcntl lock_f = open("/tmp/anomaly_monitor.lock", "w") try: fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB) except Exception: print("已有实例在运行,跳过", flush=True) sys.exit(0) try: raise SystemExit(check_anomalies()) finally: try: fcntl.flock(lock_f, fcntl.LOCK_UN) except Exception: pass