diff --git a/price_monitor.py b/price_monitor.py deleted file mode 100644 index f28e7896..00000000 --- a/price_monitor.py +++ /dev/null @@ -1,732 +0,0 @@ -#!/usr/bin/env python3 -"""price_monitor.py — 高频价格监控脚本(批量版) -规则:进入区间报一次,离开区间报一次,中间不重复。 -每次运行时一次性刷新所有持仓+自选股的实时价。 -""" -import urllib.request -import os, sys, time, json -import sqlite3 -from datetime import datetime - -from mo_data import read_decisions - -BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" -STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json" - -# DB 模块(同步实时价到 mofin.db) -sys.path.insert(0, "/home/hmo/MoFin") -try: - from mofin_db import get_conn, DB_PATH - from mo_models import calc_total_mv, calc_total_assets - HAS_DB = True -except ImportError: - HAS_DB = False - -# 策略重评依赖(技术面驱动,非机械百分比) -sys.path.insert(0, "/home/hmo/web-dashboard") -try: - from strategy_lifecycle import reassess_strategy, reassess_with_context - HAS_REASSESS = True -except ImportError: - HAS_REASSESS = False - -UA = "Mozilla/5.0" - -# ── XMPP推送 ────────────────────────────────────────────────────────── -XMPP_USER = "hmo@yoin.fun" -XMPP_BRIDGE = "http://127.0.0.1:5805/" - -def push_to_xmpp(text): - """通过知微 HTTP bridge 推送到Dad私信""" - if not text.strip(): - return - try: - payload = json.dumps({ - "to": XMPP_USER, - "body": text.strip(), - "type": "chat", - }).encode("utf-8") - req = urllib.request.Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req, timeout=5) - except Exception as e: - print(f"[XMPP推送失败] {e}", file=sys.stderr) - -# ── 批量拉取价格 ────────────────────────────────────────────────────────── - -def fetch_all_prices(codes): - """腾讯批量行情API:一次请求拉取所有股票(A股+港股) - A股:sh600110 / sz000001 - 港股:hk00700 - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - # 构建批量查询串 - symbols = [] - code_map = {} # symbol -> original_code - for code in codes: - code_s = str(code).strip() - if len(code_s) == 6: - # A股:沪市以5/6/9开头,深市以0/3开头 - if code_s.startswith(('5', '6', '9')): - sym = f"sh{code_s}" - else: - sym = f"sz{code_s}" - else: - sym = f"hk{code_s}" - symbols.append(sym) - code_map[sym] = code_s - - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - try: - # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..." - raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw_value.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig_code = code_map.get(sym) - if not orig_code: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig_code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -def refresh_data_prices(): - """一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON)""" - all_codes = set() - - # 从DB读所有需要拉取价格的代码 - try: - conn = get_conn() - for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): - all_codes.add(r['code']) - for r in conn.execute("SELECT code FROM watchlist_stocks"): - all_codes.add(r['code']) - for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): - all_codes.add(r['code']) - conn.close() - except Exception as e: - print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr) - return 0 - - if not all_codes: - return 0 - - # 一次性批量拉取 - prices = fetch_all_prices(list(all_codes)) - updated = len(prices) - - # === 弹性同步实时价到 mofin.db === - # 防死锁策略(经2026-07-14 WAL死锁复盘改进): - # ① 启动时 checkpoint WAL(清理残留事务) - # ② 统一 BEGIN IMMEDIATE 包裹整个写操作 - # ③ 5次重试 + 指数退避: 1s → 2s → 4s → 8s → 16s(共~31s) - # ④ get_conn() 的 busy_timeout=30000 保证等待上限 - # ⑤ 每个写操作检查返回值,任一失败立即 rollback + 重试 - # ⑥ try/finally 确保连接始终释放 - if HAS_DB and prices: - # 先checkpoint一次,清理上次被kill残留的WAL - try: - c = get_conn() - c.execute("PRAGMA wal_checkpoint(TRUNCATE)") - c.close() - except Exception: - pass - - max_tries = 5 - conn = None - for db_attempt in range(max_tries): - try: - conn = get_conn() - # BEGIN IMMEDIATE 立即获取写锁——失败则等 busy_timeout(30s) - conn.execute("BEGIN IMMEDIATE") - - # ── 构建 holdings 更新数据 ── - db_holdings = [] - for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"): - h = dict(r) - code = str(h.get('code', '')) - if code in prices: - price_val, _, change_pct = prices[code] - if price_val > 0: - h['price'] = round(price_val, 2) - h['change_pct'] = float(change_pct) if change_pct else 0 - db_holdings.append(h) - - # ── 写 holdings 表 ── - for h in db_holdings: - currency = str(h.get('currency', 'CNY')).upper() - if currency not in ('CNY', 'HKD'): - raise ValueError(f"非法币种: {currency}") - 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'), - )) - - # ── 写 portfolio_summary ── - mv = calc_total_mv(db_holdings) - existing = conn.execute( - 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' - ).fetchone() - db_cash = existing['cash'] if existing else 0.0 - db_frozen = existing['frozen_cash'] if existing else 0.0 - assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen}) - position_pct = round(mv / assets * 100, 2) if assets > 0 else 0 - 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') - """, ( - assets, mv, mv, db_cash, db_frozen, - position_pct, 0, 'CNY', - )) - - # ── 写 live_prices ── - for h in db_holdings: - code = h.get('code', '') - if code: - p = h.get('price', 0) - cp = h.get('change_pct', 0) - conn.execute( - "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " - "VALUES (?,?,?,datetime('now','localtime'))", - (code, p, cp) - ) - # 补充策略股/自选股的价格(不在holdings中的) - for code, pdata in prices.items(): - if code not in {h.get('code') for h in db_holdings}: - price_val = pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price', 0) - cp_val = pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct', 0) - conn.execute( - "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " - "VALUES (?,?,?,datetime('now','localtime'))", - (code, price_val, cp_val) - ) - - conn.commit() - conn.close() - conn = None - if db_attempt > 0: - print(f"DB同步成功(第{db_attempt+1}次重试)") - break # success - - except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: - if conn: - try: conn.rollback() - except Exception: pass - try: conn.close() - except Exception: pass - conn = None - err_str = str(e) - if "locked" in err_str or "cannot commit" in err_str or "busy" in err_str: - if db_attempt < max_tries - 1: - wait = 2 ** db_attempt # 1, 2, 4, 8, 16 - print(f"⏳ DB锁(尝试{db_attempt+1}/{max_tries}): {e} → {wait}s后重试", file=sys.stderr) - time.sleep(wait) - else: - print(f"❌ DB锁(重试{max_tries}次耗尽): {e}", file=sys.stderr) - else: - print(f"❌ DB错误: {e}", file=sys.stderr) - break - except Exception as e: - if conn: - try: conn.rollback() - except Exception: pass - try: conn.close() - except Exception: pass - conn = None - print(f"⚠️ DB同步异常: {e}", file=sys.stderr) - break - else: - # for-else: loop exhausted without break - print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr) - # 尝试紧急 WAL checkpoint(释放死锁) - try: - c = sqlite3.connect(str(DB_PATH), timeout=1) - c.execute("PRAGMA wal_checkpoint(TRUNCATE)") - c.close() - print(" ↪ 紧急WAL checkpoint完成", file=sys.stderr) - except Exception as we: - print(f" ↪ WAL checkpoint也失败: {we}", file=sys.stderr) - - return updated - - -# ── 区间偏离检测 ────────────────────────────────────────────────────────── - -def load_state(): - try: - with open(STATE_PATH) as f: - return json.load(f) - except: - return {} - -def save_state(state): - os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) - with open(STATE_PATH, 'w') as f: - json.dump(state, f, ensure_ascii=False, indent=2) - -def load_breaches(): - try: - with open(BREACH_PATH) as f: - return json.load(f) - except: - return {} - -def save_breaches(data): - os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True) - with open(BREACH_PATH, 'w') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def record_event(code, name, event_type, price, trigger_value, event_label=""): - """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。 - - price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股) - 先注册再写事件,否则 FK 失败事件丢失。 - """ - now = datetime.now().isoformat() - - if HAS_DB: - try: - from mofin_db import get_conn, write_price_event - _c = get_conn() - _exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A")) - _c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)", - (str(code), name or str(code), _exch, _typ, now)) - _c.commit() - write_price_event(_c, code=code, name=name, event_type=event_type, - price=round(price, 2), trigger_value=str(trigger_value), - event_label=event_label) - _c.close() - except Exception as e: - print(f"[price_events DB写入失败] {e}", file=sys.stderr) - - -def get_trigger_zones(trigger): - """返回该trigger所有可监控的区间列表,跳过已执行的batch""" - zones = [] - for key, label in [ - ("entry_zone", "加仓区间"), - ("batch1_price", "试仓区间"), - ("batch2_price", "加仓区间"), - ("take_profit_zone", "止盈区间"), - ("watch_low", "关注区间"), - ("watch_high", "减仓区间"), - ("watch_break", "止损区间") - ]: - status_key = key.replace("_price", "_status") - if status_key in trigger and trigger[status_key] == "executed": - continue - val = trigger.get(key, "") - if val and "~" in val: - try: - parts = val.split("~") - lo, hi = float(parts[0]), float(parts[1]) - zones.append((key, label, lo, hi)) - except: - pass - sl = trigger.get("stop_loss", "") - if sl: - try: - sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl) - zones.append(("stop_loss", "止损", 0, sl_price)) - except: - pass - return zones - - -def _cleanup_lock(): - """清理进程锁文件""" - try: - os.remove("/tmp/price_monitor.lock") - except Exception: - pass - -def _handle_sigterm(signum, frame): - """收到SIGTERM时清理锁文件后退出""" - _cleanup_lock() - sys.exit(0) - -def run_once(round_label=""): - """执行一轮完整的监控流程""" - import os, signal # 必须在开头import,否则os变量会被后面的局部import绑定覆盖 - signal.signal(signal.SIGTERM, _handle_sigterm) - os.nice(10) # 降低优先级,避免与DB其他写操作抢占 - # ── 进程锁:同一时间只跑一个实例 ── - _lk = "/tmp/price_monitor.lock" - _pid = None - try: - with open(_lk) as _f: - _pid = int(_f.read().strip()) - os.kill(_pid, 0) - print(f"[LOCK] 已有实例(PID {_pid})在运行,跳过本轮", file=sys.stderr, flush=True) - return - except (FileNotFoundError, ProcessLookupError, ValueError): - pass - with open(_lk, "w") as _f: - _f.write(str(os.getpid())) - - label = f" [{round_label}]" if round_label else "" - start = time.time() - TIME_BUDGET = 90 # 预留30s给输出和清理,90s内必须完成核心逻辑 - - # === 第一步:一次性刷新所有价格 === - refreshed = refresh_data_prices() - - # === 第二步:检查触发条件 === - try: - dec = read_decisions() - except: - print(f"❌{label} 无法读取decisions(DB)", file=sys.stderr) - return - - active = [d for d in dec.get("decisions", []) if d.get("status") == "active"] - state = load_state() - outputs = [] - state_updated = False - # 时间冷却:同股同区间30分钟内不重复推 - _push_cooldown = {} - _cooldown_file = "/home/hmo/.hermes/.price_push_cooldown.json" - try: - import os - if os.path.exists(_cooldown_file): - with open(_cooldown_file) as _f: - _push_cooldown = json.load(_f) - except Exception: - _push_cooldown = {} - - def _can_push(code, zone_key): - now = time.time() - key = f"{code}_{zone_key}" - last = _push_cooldown.get(key, 0) - if now - last < 1800: # 30分钟 - return False - _push_cooldown[key] = now - # 持久化写入 - try: - with open(_cooldown_file, "w") as _f: - json.dump(_push_cooldown, _f) - except Exception: - pass - return True - - # 收集所有需要检查的代码 - check_codes = set() - for d in active: - trig = d.get("trigger", {}) - if trig: - check_codes.add(d["code"]) - - # 批量拉取这些股票的价格 - prices = fetch_all_prices(list(check_codes)) - - for d in active: - code = d["code"] - trig = d.get("trigger", {}) - if not trig: - continue - - zones = get_trigger_zones(trig) - if not zones: - continue - - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - name = d.get("name", code) - if code not in state: - state[code] = {} - - # 时间预算检查:如果超时,跳过重评只做状态记录 - _budget_low = (time.time() - start) > TIME_BUDGET - - for key, label, lo, hi in zones: - in_zone = lo <= price <= hi - prev_in_zone = state[code].get(key, None) - - if in_zone and prev_in_zone != True: - if key == "stop_loss": - outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") - record_event(code, name, "stop_loss", price, str(hi)) - # 止损触发 → 立即重评并推送给Dad(时间不够则直接推原始告警) - if _budget_low: - outputs.append(f" 📨 止损触发(超时跳过重评)→已推送Dad") - if _can_push(code, "stop_loss"): - push_to_xmpp(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") - else: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - current_action = d.get("action", "") - result = reassess_with_context(code, name, price, cost, shares, current_action) - if result: - timing_signal = result.get("timing_signal", "") - action = result.get("action", "") - if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"): - buy_lo = d.get("entry_low", 0) - buy_hi = d.get("entry_high", 0) - rr = result.get("rr_ratio", 0) - if _can_push(code, "stop_loss"): - msg = f"🔔 {name}({code}) 价{price}→触发操作区间{max(buy_lo,0):.2f}~{buy_hi:.2f},已触发重评|RR={rr}" - push_to_xmpp(msg) - outputs.append(f" 📨 止损重评→已推送Dad: {action}") - except Exception as e: - outputs.append(f" ⚠️ 止损重评失败: {e}") - else: - extra = "" - if "_price" in key: - batch_shares = trig.get(key.replace("_price", "_shares"), "") - action = trig.get(key.replace("_price", "_action"), "") - if batch_shares: - extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股" - elif key in ("take_profit_zone",): - act = trig.get("take_profit_action", "") - if act: - extra = f"({act})" - outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}") - record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label) - # 进入区间 → 立即重评并推送给Dad(时间不够则跳过重评直接推原始告警) - if _budget_low: - if _can_push(code, key): - push_to_xmpp(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}") - outputs.append(f" 📨 区间触发(超时跳过重评)→已推送Dad") - else: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - current_action = d.get("action", "") - result = reassess_with_context(code, name, price, cost, shares, current_action) - if result: - timing_signal = result.get("timing_signal", "") - action = result.get("action", "") - # 格式化区间描述(止盈区lo=0时美化显示) - if key == "take_profit_zone" and lo == 0: - zone_desc = f"止盈监控(目标{hi:.0f})" - else: - zone_desc = f"操作区间{lo}~{hi}" - if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"): - rr = result.get("rr_ratio", 0) - if _can_push(code, key): - msg = f"🔔 {name}({code}) 价{price}→触发{zone_desc},已触发重评|RR={rr}" - push_to_xmpp(msg) - outputs.append(f" 📨 区间触发重评→已推送Dad: {action}") - else: - reason = f"重评结果:{timing_signal},不构成操作建议" - outputs.append(f" 📋 本地日志(不推): {reason}") - except Exception as e: - outputs.append(f" ⚠️ 区间重评失败: {e}") - state[code][key] = True - state_updated = True - - elif not in_zone and prev_in_zone == True: - if key != "stop_loss": - outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}") - state[code][key] = False - state_updated = True - - # === 第三步:买入区偏离检测 + 自动重评 === - reassesed_codes = [] - # 先做急跌检测(仅持仓,自选股不推送暴跌告警) - holdings_codes = {d["code"] for d in active if (d.get("shares") or 0) > 0} - for d in active: - code = d["code"] - # 非持仓跳过 - if code not in holdings_codes: - continue - name = d.get("name", code) - price_info = prices.get(code) - if not price_info: - continue - price, _, change_pct = price_info - if price == 0: - continue - # 单日跌幅>7%告警(不依赖zone边界,盘中急跌即触发) - try: - cp = float(change_pct) if change_pct else 0 - except: - cp = 0 - if cp <= -7: - prev_alert = state.get(code, {}).get("__sharp_decline_triggered", False) - if not prev_alert: - stop_loss = d.get("stop_loss", 0) - sl_note = f" 止损{stop_loss}" if stop_loss else "" - msg = f"🔻 {name}({code}) {price} 暴跌{cp:.1f}%!{sl_note}" - push_to_xmpp(msg) - outputs.append(msg) - state.setdefault(code, {})["__sharp_decline_triggered"] = True - state_updated = True - # 立即持久化,防止后续超时导致状态丢失而重复推送 - save_state(state) - elif cp > -5: - # 反弹后清除告警标记,下次再跌还能报 - state.setdefault(code, {}).pop("__sharp_decline_triggered", None) - - for d in active: - code = d["code"] - name = d.get("name", code) - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - # 从 decisions (DB holding_strategies) 中读取 analysis 的买入区 - entry_low = d.get("entry_low", 0) - entry_high = d.get("entry_high", 0) - if not entry_low or not entry_high: - continue - - in_buy_zone = entry_low <= price <= entry_high - prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) - - # 状态变化时才触发 - if in_buy_zone and prev_in_buy_zone == False: - # 重新进入买入区 → 重评确认区间是否仍然有效 - outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") - do_reassess = True - elif not in_buy_zone and prev_in_buy_zone == True: - # 离开买入区 → 立即重评,更新止损/止盈/区间 - outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") - do_reassess = True - else: - do_reassess = False - - if do_reassess and HAS_REASSESS: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_deep_loss = profit_pct < -20 - sentiment = "neutral" - if d.get("tech_snapshot"): - if "bearish" in d["tech_snapshot"]: - sentiment = "bearish" - elif "bullish" in d["tech_snapshot"]: - sentiment = "bullish" - - # 调用技术面驱动重评(非机械百分比) - result = reassess_strategy( - code, name, price, cost, shares, - current_action=d.get("action", ""), - volume_signal="中性", sentiment=sentiment, - ) - outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") - reassesed_codes.append(code) - except Exception as e: - outputs.append(f" ⚠️ 重评失败: {e}") - - # 更新买入区状态 - if "__buy_zone" not in state.get(code, {}): - if code not in state: - state[code] = {} - state[code]["__buy_zone"] = in_buy_zone - state_updated = True - - # 如果有重评过的股票,更新 DB holding_strategies(此前写入 decisions.json,已废弃) - if reassesed_codes and HAS_REASSESS: - # ── 5分钟冷却:regenerate_all 开销太大,不每2分钟跑一次 ── - _regen_marker = "/tmp/price_monitor_regen_at" - _skip_regen = False - try: - if os.path.exists(_regen_marker): - with open(_regen_marker) as _f: - _last_regen = float(_f.read().strip()) - if time.time() - _last_regen < 300: - _skip_regen = True - except: - pass - - if _skip_regen: - outputs.append(f" ⏭ 跳过全量重评(距上次<5min),下次再跑") - else: - try: - from strategy_lifecycle import regenerate_all - r = regenerate_all(stdout=False) - outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") - outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") - try: - with open(_regen_marker, "w") as _f: - _f.write(str(time.time())) - except: - pass - except Exception as e: - outputs.append(f" ⚠️ 全量重评失败: {e}") - - # === 第四步:输出 === - now_str = datetime.now().strftime("%H:%M:%S") - elapsed = time.time() - start - - if outputs: - print(f"\n🔔 {now_str}{label}") - for o in outputs: - print(o) - print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}") - else: - # 无触发时 SILENT(中继不推送) - print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s") - - if state_updated: - save_state(state) - - # 输出耗时 - print(f"⏱{label} {elapsed:.1f}s", flush=True) - - # 清理进程锁 - try: - os.remove("/tmp/price_monitor.lock") - except Exception: - pass - - -def main(): - """每cron触发跑一轮""" - run_once() - - -if __name__ == "__main__": - main() diff --git a/scripts/batch_reassess.py b/scripts/batch_reassess.py deleted file mode 100644 index 450eee54..00000000 --- a/scripts/batch_reassess.py +++ /dev/null @@ -1,554 +0,0 @@ -#!/usr/bin/env python3 -"""batch_reassess.py — 批量补全12维(九维矩阵)LLM分析(逐只处理,间隔防限流) - -用法: - python3 batch_reassess.py # 所有缺分析/过期的 active 策略 - python3 batch_reassess.py --type holding # 只处理持仓策略 - python3 batch_reassess.py --type watchlist # 只处理自选策略 - python3 batch_reassess.py --type holding --today # 持仓每日刷新(今早未评过的强制重评) - python3 batch_reassess.py --code XXXXXX # 单只 - -流程:收集最新数据 → 调LLM(gateway)写12维分析+策略 → 保存到DB -""" -import sys, json, subprocess, sqlite3, re, time, os -from datetime import datetime - -# ── 共享 LLM 客户端 + DB 工具(profile-scripts 硬链到同目录)── -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, "/home/hmo/MoFin") -from llm_client import call_llm, REASSESS_MODEL, FALLBACK_MODEL, gateway_alive, ocg_alive -from mofin_db import snapshot_strategy_history, sync_recommend_tag - -DB = "/home/hmo/MoFin/data/mofin.db" -COOLDOWN_HOURS = 1 -STALE_HOURS = 20 # 分析超过20小时视为过期,需要重评 - -def has_llm_analysis(code): - """检查是否为LLM生成的12维分析(>500字)""" - conn = sqlite3.connect(DB) - r = conn.execute("SELECT LENGTH(full_analysis) FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - conn.close() - return r and r[0] and r[0] > 500 - -def in_cooldown(code): - """冷却期检查""" - conn = sqlite3.connect(DB) - r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - conn.close() - if not r or not r[0]: - return False - try: - last = datetime.fromisoformat(r[0]) - diff = (datetime.now() - last).total_seconds() / 3600 - return diff < COOLDOWN_HOURS - except: - return False - -def analysis_stale(code, force_today=False): - """分析是否过期(>STALE_HOURS 或 force_today 时今早4点前未重评)""" - conn = sqlite3.connect(DB) - r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - conn.close() - if not r or not r[0]: - return True - try: - last = datetime.fromisoformat(r[0]) - if force_today: - today4am = datetime.now().replace(hour=4, minute=0, second=0, microsecond=0) - return last < today4am - return (datetime.now() - last).total_seconds() / 3600 > STALE_HOURS - except: - return True - -def get_portfolio(): - """从 portfolio_summary 读实时现金/总资产(不再硬编码)""" - try: - conn = sqlite3.connect(DB) - r = conn.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone() - conn.close() - if r and r[1]: - return int(r[0] or 0), int(r[1]) - except Exception: - pass - return 0, 0 - -def collect_data(code): - """收集最新数据(含完整策略原文)""" - data = {"code": code} - - # 从DB读策略(含 full_analysis / changelog_json / position_advice) - conn = sqlite3.connect(DB) - r = conn.execute("SELECT name, entry_low, entry_high, stop_loss, take_profit, timing_signal, action, rr_ratio, tech_snapshot, sector_context, stock_category, full_analysis, changelog_json, reassessed_at, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if r: - data["name"] = r[0] - data["entry_low"] = r[1] or 0 - data["entry_high"] = r[2] or 0 - data["stop_loss"] = r[3] or 0 - data["take_profit"] = r[4] or 0 - data["timing_signal"] = r[5] or "" - data["action"] = r[6] or "" - data["rr_ratio"] = r[7] or 0 - data["tech_snapshot"] = r[8] or "" - data["sector_context"] = r[9] or "" - data["stock_category"] = r[10] or "" - data["full_analysis"] = r[11] or "" - data["changelog_json"] = r[12] or "" - data["reassessed_at"] = r[13] or "" - data["position_advice"] = r[14] or "" - conn.close() - - # 从腾讯API拉最新价和基本面 - # 代码前缀:5位=港股(hk),6/9开头=沪(sh),其他=深(sz) - _c = str(code) - if len(_c) == 5: - prefix = "hk" - elif _c.startswith(("6", "9")): - prefix = "sh" - else: - prefix = "sz" - try: - r = subprocess.run(["curl", "-s", f"http://qt.gtimg.cn/q={prefix}{code}"], capture_output=True, timeout=10) - parts = r.stdout.decode("gbk", errors="ignore").split("~") - data["price"] = float(parts[3]) if len(parts) > 3 and parts[3] else 0 - data["pe"] = parts[39] if len(parts) > 39 and parts[39] else "" - data["mcap"] = parts[44] if len(parts) > 44 and parts[44] else "" - data["change_pct"] = parts[32] if len(parts) > 32 and parts[32] else "0" - except: - data["price"] = 0 - - # 大盘 - try: - conn = sqlite3.connect(DB) - mr = conn.execute("SELECT structure FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() - if mr and mr[0]: - s = json.loads(mr[0]) - data["macro"] = s.get("description", "大盘震荡") - conn.close() - except: - data["macro"] = "大盘震荡" - - return data - -def build_prompt(data): - """构建LLM prompt,先审阅原策略再结合实时数据输出修改判断+九维矩阵分析""" - cash, total = get_portfolio() - if not total: - cash, total = 241330, 929727 # 兜底(DB读不到时) - - # 拉取资金流数据 - _flow_note = "暂无资金流数据" - try: - import sqlite3 as _sq, json as _j - _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") - _fr = _db.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() - if _fr and _fr[0]: - _fc = _j.loads(_fr[0]) - _stocks = _fc.get("stocks", {}) - _s = _stocks.get(data['code'], {}) - if _s and _s.get("analysis"): - _a = _s["analysis"] - _net = _a.get("net_flow", 0) - _main = _a.get("main_force", 0) - _retail = _a.get("retail_flow", 0) - _trend = _a.get("trend", "中性") - _flow_note = f"净流入{_net:.0f}万 主力{_main:.0f}万 散户{_retail:.0f}万 趋势{_trend}" - _db.close() - except: - pass - - # 拉取近期消息面 - _news_note = "暂无近期消息" - try: - import sqlite3 as _sq - _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") - _nr = _db.execute( - "SELECT summary, overall_sentiment, created_at FROM signal_news " - "WHERE (code=? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " - "ORDER BY id DESC LIMIT 3", - (data['code'], f'%{data.get("name","")[:4]}%') - ).fetchall() - if _nr: - _news_note = " | ".join([f"{r[2][:10]} {r[1]} {r[0][:40]}" for r in _nr]) - _db.close() - except: - pass - - # ── 构建【原策略全文】section ── - _params_parts = [] - if data.get('action'): _params_parts.append(f"当前策略: {data['action']}") - if data.get('timing_signal'): _params_parts.append(f"信号: {data['timing_signal']}") - if data.get('entry_low') or data.get('entry_high'): - _params_parts.append(f"买入区间: {data.get('entry_low',0)}~{data.get('entry_high',0)}") - if data.get('stop_loss'): _params_parts.append(f"止损: {data['stop_loss']}") - if data.get('take_profit'): _params_parts.append(f"止盈: {data['take_profit']}") - if data.get('position_advice'): _params_parts.append(f"仓位: {data['position_advice']}") - _params_str = " | ".join(_params_parts) if _params_parts else "无策略参数" - - # 最近3条变更记录 - _changelog_str = "无变更记录" - try: - _cl_raw = data.get('changelog_json', '') - if _cl_raw: - _cl = json.loads(_cl_raw) if isinstance(_cl_raw, str) else _cl_raw - if isinstance(_cl, list) and _cl: - _recent = _cl[-3:] if len(_cl) > 3 else _cl - _cl_lines = [] - for i, c in enumerate(_recent): - _act = c.get('action', c.get('reason', '')) if isinstance(c, dict) else str(c) - _ts = c.get('timestamp', '') if isinstance(c, dict) else '' - _cl_lines.append(f" {i+1}. {_ts[:16]} {_act[:80]}") - if _cl_lines: - _changelog_str = "\n".join(_cl_lines) - except: - pass - - # 完整分析原文(不截断) - _full_analysis = data.get('full_analysis', '') or '' - _fa_display = _full_analysis if _full_analysis else '(首次分析,无历史)' - - _orig_strategy_section = f"""当前策略参数: {_params_str} - -变更记录(最近3条): -{_changelog_str} - -完整分析原文: -{_fa_display}""" - - return f"""你是一个资深A股分析师。请先审阅以下【原策略全文】,判断是否需要修改策略,然后做出完整的九维矩阵分析。 - -【原策略全文】 -{_orig_strategy_section} - -── 以上是已有的策略,以下是当前实时数据,请结合两者做出判断 ── - -⚠️ 重要:以下9个维度不是独立分析的,你必须交叉对比后给出综合结论。 -例如:如果消息面利好但资金流在流出,说明利好可能是出货;如果基本面强但技术面破位,说明估值可能还没到底。 - -当前数据(以下数据均来自实时API,每条标注时间窗口,禁止使用模型内部训练数据): -大盘:{data.get('macro','震荡')}(当日实时) -最新价:{data.get('price',0)} 涨跌:{data.get('change_pct','0')}%(当日实时) -PE={data.get('pe','?')}(最新财报) 市值={data.get('mcap','?')}亿 -行业:{data.get('sector_context','?')}(当日实时) -技术面:{data.get('tech_snapshot','')[:300]}(MA=5/10/20/60日 支撑阻力=近20日 量价=当日+近5日趋势) -资金流:{_flow_note}(近5日累计) -消息面:{_news_note}(最近3条,自动标注抓取时间) -当前信号:{data.get('timing_signal','?')} 分类:{data.get('stock_category','?')} - -我的总资产={total}元,可用现金={cash}元。 - -请严格按以下格式输出(注意节标题不可省略): - -【维持或修改】明确二选一判断:维持原策略 / 需要修改策略 -【修改点及理由】 -如果维持原策略 → 写"无需修改" -如果需要修改 → 逐条列出(每条格式:"- 修改点名称:理由说明") -【最终新策略】 -用自然语言输出完整的最终策略全文(200-400字),自包含核心交易逻辑、买入区间价格、止损价、止盈价、仓位比例、风险提示。 -⚠️ 本段不要使用【综合结论】【买入区间】等标签——用自然语言描述即可。 - -【交叉分析】用2-3句话说明哪些维度出现矛盾/共振,最关键的信号是什么 -① 大盘×基本面 [一句话,说明矛盾关系] -② 大盘×消息面 [一句话] -③ 大盘×技术面 [一句话] -④ 大盘×资金面 [一句话] -⑤ 行业×基本面 [一句话] -⑥ 行业×消息面 [一句话] -⑦ 行业×技术面 [一句话] -⑧ 行业×资金面 [一句话] -⑨ 个股×基本面 [一句话] -⑩ 个股×消息面 [一句话] -⑪ 个股×技术面 [一句话] -⑫ 个股×资金面 [一句话] - -【综合结论】(买入/关注/观望/卖出) -【操作建议】具体操作建议 -【买入区间】最低价~最高价 -【建议止损】数字 -【建议止盈】数字 - -【建议仓位】⚠️不可省略。综合结论非"买入"时写"不新建仓";为"买入"时按以下公式: -基础仓位按RR确定:RR<1.5→不推荐,RR1.5~3→8%,RR3~5→12%,RR5+→15% -大盘偏弱×0.8,大盘偏强×1.15 -蓝筹/白马×1.2,成长×0.85,题材/短线×0.6 -最终仓位范围:5%~20% -同时考虑:现金{cash}元足够买多少手。 -输出格式:"X%(理由:一句话说明为什么这个仓位)" - -⚠️ 输出纪律(必须遵守): -1. 直接以【维持或修改】开头,禁止任何寒暄、开场白、分隔线 -2. 禁止输出 或任何 XML/JSON/代码块 -3. 所有【】节标题一个都不能少""" -def parse_response(text): - """从LLM回复中提取策略参数""" - result = {"signal": "", "entry_low": 0, "entry_high": 0, "stop_loss": 0, "take_profit": 0, "position": ""} - - # 信号 - sl = [l for l in text.split("\n") if "综合结论" in l] - if sl: - for kw in ["买入","关注","观望","卖出"]: - if kw in sl[0]: - result["signal"] = kw - break - - # 买入区间 - zl = [l for l in text.split("\n") if "买入区间" in l] - if zl: - nums = re.findall(r'[\d.]+', zl[0]) - if len(nums) >= 2: - result["entry_low"] = float(nums[0]) - result["entry_high"] = float(nums[1]) - - # 止损 - for l in text.split("\n"): - if "建议止损" in l: - nums = re.findall(r'[\d.]+', l) - if nums: result["stop_loss"] = float(nums[0]) - - # 止盈 - for l in text.split("\n"): - if "建议止盈" in l: - nums = re.findall(r'[\d.]+', l) - if nums: result["take_profit"] = float(nums[0]) - - # 仓位:只有买入信号才需要,提取百分比数字 - result["position"] = "" - if result["signal"] == "买入": - for l in text.split("\n"): - if "建议仓位" in l: - nums = re.findall(r'[\d.]+', l) - for n in nums: - f = float(n) - if 1 <= f <= 30: # 合理的仓位范围 - result["position"] = f"{f:.0f}%" - break - break - - return result - -def save_result(code, full_text, parsed): - """保存LLM结果到DB(先快照再UPDATE)。空分析拒绝写入。""" - if not (full_text or "").strip(): - print(f" \u274c 拒绝写入空分析(LLM输出为空,保护已有数据)") - return - conn = sqlite3.connect(DB) - now = datetime.now().isoformat() - - # ── 修改前快照 ── - snapshot_strategy_history(conn, code, 'batch_12d') - - updates = ["full_analysis=?", "reassessed_at=?"] - params = [full_text, now] - - if parsed["signal"]: - updates.append("timing_signal=?") - params.append(parsed["signal"]) - # 区间写入门禁:上下沿都必须为正且 下沿<上沿<下沿x3,否则视为解析错误整体跳过 - # (防 214.68~2.52 类解析污染,与 GATE_ZONE_SANITY 同级防护) - _el, _eh = parsed["entry_low"], parsed["entry_high"] - if _el > 0 and _eh > _el and _eh < _el * 3: - updates.append("entry_low=?") - params.append(_el) - updates.append("entry_high=?") - params.append(_eh) - elif _el > 0 or _eh > 0: - print(f" ⚠️ 买入区解析异常({_el}~{_eh}),跳过区间写入(保留原值)", flush=True) - # 止损/止盈一致性门禁:损>0 时必须在区间下沿之下(0.5x~1.0x),盈>0 时必须在区间上沿之上 - _sl, _tp = parsed["stop_loss"], parsed["take_profit"] - if _sl > 0 and (not _el or _sl < _el) and (not _tp or _sl < _tp): - updates.append("stop_loss=?") - params.append(_sl) - elif _sl > 0: - print(f" ⚠️ 止损{_sl}与区间/止盈不一致,跳过写入(保留原值)", flush=True) - if _tp > 0 and (not _eh or _tp > _eh) and (not _sl or _tp > _sl): - updates.append("take_profit=?") - params.append(_tp) - elif _tp > 0: - print(f" ⚠️ 止盈{_tp}与区间/止损不一致,跳过写入(保留原值)", flush=True) - if parsed["position"]: - updates.append("position_advice=?") - params.append(parsed["position"]) - - params.append(code) - sql = f"UPDATE holding_strategies SET {', '.join(updates)} WHERE code=? AND status='active'" - conn.execute(sql, params) - conn.commit() - - # ── 推荐操作 tag 同步(与 XMPP 动作级信号同源)── - sync_recommend_tag(conn, code, parsed.get("signal", "")) - - # 买入信号→推XMPP通知(在conn close前执行)——推送质量门禁: - # 价格必须>0(live_prices实时价)、区间有效(下沿<上沿<下沿x3)、现价不超过上沿5%、 - # 损<下沿、盈>上沿、损在(0.5x~1.0x)现价内。任何一项不过 → 不推,只记日志。 - if parsed.get("signal") == "买入": - try: - _nr = conn.execute("SELECT name FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - _lp = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() - _name = _nr[0] if _nr else code - _p = _lp[0] if _lp and _lp[0] else 0 - _el = parsed.get("entry_low", 0) - _eh = parsed.get("entry_high", 0) - _sl = parsed.get("stop_loss", 0) - _tp = parsed.get("take_profit", 0) - _pos = parsed.get("position", "") - _ok, _why = _validate_buy_alert(_p, _el, _eh, _sl, _tp) - if _ok: - _msg = f"📈 {_name}({code}) 价{_p}→12维分析生成买入信号!区间{_el}~{_eh} 损{_sl} 盈{_tp} 仓位{_pos}" - from alert_helper import notify as _notify, ACTION as _ACT - _notify("买入信号", _msg, _ACT) - print(f" \U0001f4e8 XMPP推送成功: {_msg[:60]}") - else: - print(f" ⚠️ 买入信号未过推送门禁({_why}),仅记日志不推送", flush=True) - except Exception as _e: - print(f" \u26a0\ufe0f XMPP推送失败: {_e}") - - conn.close() - - -def _validate_buy_alert(price, el, eh, sl, tp): - """买入信号推送门禁(垃圾信号不发)。 - 返回 (ok, reason)""" - if not price or price <= 0: - return False, f"无实时价格({price})" - if not (el > 0 and eh > el and eh < el * 3): - return False, f"区间无效({el}~{eh})" - if price > eh * 1.05: - return False, f"现价{price}高于区间上沿{eh}超5%(追高信号不推)" - if not (sl > 0 and sl < el and price * 0.5 <= sl <= price): - return False, f"止损{sl}不合理(需0.5x~1.0x现价且<下沿{el})" - if not (tp > eh and tp > sl): - return False, f"止盈{tp}需>上沿{eh}且>止损{sl}" - return True, "" - -def process_stock(code, force_today=False): - """处理单只股票""" - print(f"\n{'='*50}") - print(f"处理: {code}") - print(f"{'='*50}") - - if in_cooldown(code): - print(f" \u23ed 冷却期内,跳过") - return False - - # 有分析且未过期 \u2192 跳过(除非 force_today 且今早未评) - if has_llm_analysis(code) and not analysis_stale(code, force_today): - print(f" \u23ed 已有12维分析且未过期,跳过") - return False - - print(f" 收集数据...", flush=True) - data = collect_data(code) - if not data.get("price"): - print(f" \u26a0\ufe0f 无价格数据,跳过") - return False - - print(f" 调LLM生成九维分析...", flush=True) - prompt = build_prompt(data) - - # ── 使用共享 LLM 客户端(替代 curl subprocess)── - result = call_llm(prompt, model=REASSESS_MODEL, max_tokens=4096) - - if not result["ok"] or not (result.get("content") or "").strip(): - print(f" \u274c LLM调用失败或空输出: {result.get('error') or 'empty content'}") - return False - - full_text = result["content"] - print(f" \u2705 LLM返回({len(full_text)}字, {result['elapsed']:.1f}s, 尝试{result['attempts']}次)", flush=True) - - parsed = parse_response(full_text) - - # ── 截断保护:输出过短且无信号 = 低质输出,升级 pro 重试一次 ── - if not parsed.get("signal") and len(full_text) < 1500: - print(f" ⚠️ 输出截断({len(full_text)}字)且无信号,升级 {FALLBACK_MODEL} 重试...", flush=True) - result2 = call_llm(prompt, model=FALLBACK_MODEL, max_tokens=4096) - if result2["ok"] and len((result2.get("content") or "").strip()) > len(full_text): - full_text = result2["content"] - parsed = parse_response(full_text) - print(f" \u2705 升级后({len(full_text)}字)", flush=True) - - print(f" 信号={parsed['signal']} 区间={parsed['entry_low']}~{parsed['entry_high']} 损={parsed['stop_loss']} 盈={parsed['take_profit']} 仓位={parsed['position']}") - - save_result(code, full_text, parsed) - print(f" \u2705 已保存到DB") - return True - -def main(): - # ── 双通道预检:OCG直连 + hermes gateway 兜底,全挂才退出 ── - _ocg_ok = ocg_alive() - _gw_ok = gateway_alive() - if not _ocg_ok and not _gw_ok: - print("[FATAL] OCG上游与hermes gateway均不可用,退出") - sys.exit(1) - if not _ocg_ok: - print("[WARN] OCG直连不可用,将使用gateway兜底(agent运行时,较慢)") - if not _gw_ok: - print("[WARN] hermes gateway不可用,仅使用OCG直连") - - codes = [] - force_today = "--today" in sys.argv - dtype = None - if "--type" in sys.argv: - idx = sys.argv.index("--type") - dtype = sys.argv[idx + 1] # holding | watchlist | all - if "--code" in sys.argv: - idx = sys.argv.index("--code") - codes = [sys.argv[idx+1]] - else: - # 按类型筛选 active 策略 - type_map = {"holding": "持仓策略", "watchlist": "自选策略"} - conn = sqlite3.connect(DB) - if dtype in type_map: - rows = conn.execute( - "SELECT code FROM holding_strategies WHERE status='active' AND decision_type=? ORDER BY code", - (type_map[dtype],)).fetchall() - else: - rows = conn.execute( - "SELECT code FROM holding_strategies WHERE status='active' ORDER BY decision_type, code").fetchall() - conn.close() - codes = [r[0] for r in rows] - - print(f"待处理: {len(codes)}只 (type={dtype or 'all'}, force_today={force_today})") - - ok = 0 - fail = 0 - skip = 0 - failed_codes = [] - for i, code in enumerate(codes): - if has_llm_analysis(code) and not analysis_stale(code, force_today): - print(f" [{i+1}/{len(codes)}] \u23ed {code} 已有12维分析且未过期") - skip += 1 - continue - - print(f" [{i+1}/{len(codes)}] ", end="", flush=True) - if process_stock(code, force_today): - ok += 1 - else: - fail += 1 - failed_codes.append(code) - - # 间隔8秒(pro model较重但gateway可承受;retry逻辑吸收瞬断) - if i < len(codes) - 1: - print(f" 等待8秒...", flush=True) - time.sleep(8) - - # ── 失败二轮:主跑结束后休息 60s 让上游恢复,失败股整体重试一次 ── - # (凌晨上游空输出高发,二轮可救回大半;仍失败的留给下一轮调度) - if failed_codes: - print(f"\n{'='*50}") - print(f"失败二轮: {len(failed_codes)}只,休息60s后重试...") - time.sleep(60) - retry_ok = 0 - for code in failed_codes: - print(f" [retry] {code} ", end="", flush=True) - if process_stock(code, force_today): - retry_ok += 1 - ok += 1 - fail -= 1 - print(f" 等待8秒...", flush=True) - time.sleep(8) - print(f"失败二轮: {retry_ok}/{len(failed_codes)} 救回") - - print(f"\n{'='*50}") - print(f"完成: {ok}成功, {fail}失败, {skip}跳过") - print(f"{'='*50}") - -if __name__ == "__main__": - main() diff --git a/scripts/per_stock_reassess.py b/scripts/per_stock_reassess.py deleted file mode 100644 index b84fd8fc..00000000 --- a/scripts/per_stock_reassess.py +++ /dev/null @@ -1,822 +0,0 @@ -#!/usr/bin/env python3 -""" -per_stock_reassess.py — 按个股触发重评 - -对每只传进来的 code 执行 reassess_with_context(),然后写入 -DB holding_strategies 表(纯DB模式,已移除JSON依赖)。 -""" -import sys, json, os, re -from datetime import datetime - -COOLDOWN_HOURS_TRADING = 1 # 交易时段冷却(1小时) -COOLDOWN_HOURS_NONTRADING = 24 # 非交易时段冷却 - -def _in_cooldown(code): - """检查个股是否在重评冷却期内""" - try: - import sqlite3 - conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") - r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active' ORDER BY id DESC LIMIT 1", (code,)).fetchone() - conn.close() - if not r or not r[0]: - return False # 从未重评,立即执行 - last = datetime.fromisoformat(r[0]) - now = datetime.now() - # 交易时段 vs 非交易时段 - if 9 <= now.hour < 15: - hours = COOLDOWN_HOURS_TRADING - else: - hours = COOLDOWN_HOURS_NONTRADING - diff = (now - last).total_seconds() / 3600 - return diff < hours - except: - return False - -sys.path.insert(0, "/home/hmo/web-dashboard") -sys.path.insert(0, "/home/hmo/MoFin") -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # profile-scripts 硬链目录 -from strategy_lifecycle import reassess_with_context as reassess_strategy -from mo_data import read_decisions, read_portfolio -from llm_client import call_llm, REASSESS_MODEL -from mofin_db import snapshot_strategy_history - - -def _build_full_analysis(code, entry, result): - """从重评结果构建完整九维分析文本""" - if not result: - return "" - lines = [] - name = entry.get("name", code) - price = result.get("price") or entry.get("price", 0) - - tech = result.get("tech_snapshot") or entry.get("tech_snapshot", "") - sector = result.get("sector_context") or entry.get("sector_context", "") - signal = result.get("timing_signal") or entry.get("timing_signal", "") - category = result.get("stock_category") or entry.get("stock_category", "") - - el = result.get("entry_low") or entry.get("entry_low", 0) - eh = result.get("entry_high") or entry.get("entry_high", 0) - sl = result.get("stop_loss") or entry.get("stop_loss", 0) - tp = result.get("take_profit") or entry.get("take_profit", 0) - rr = result.get("rr_ratio") or entry.get("rr_ratio", 0) - act = result.get("action", "") - - # ── 从DB拉取大盘、基本面、资金流 ── - macro_desc = "" - pe_val = pb_val = "" - try: - import sqlite3 as _sq, json as _j - _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") - # 大盘(从structure列读取) - _m = _db.execute("SELECT structure, sector_mood FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() - if _m and _m[0]: - _st = _j.loads(_m[0]) - _ix = _st.get("indices", {}) - _desc = _st.get("description", "") - if _ix: - _parts = [] - for _name in ["上证指数", "深证成指", "创业板指", "科创50", "恒生指数"]: - if _name in _ix: - _d = _ix[_name] - if isinstance(_d, dict): - _p = _d.get("price", 0) - _c = _d.get("change_pct", 0) - _parts.append(f"{_name}({_p:.0f},{_c:+.1f}%)") - elif isinstance(_d, (int, float)): - _parts.append(f"{_name}({_d})") - macro_desc = " ".join(_parts) - elif _desc: - macro_desc = _desc - _mood = str(_m[1] or "") - if _mood and not macro_desc: - macro_desc = f"情绪={_mood}" - elif _mood: - macro_desc += f" 情绪={_mood}" - if not macro_desc: - # fallback: 直接用腾讯API拉大盘 - try: - _r2 = __import__('subprocess').run(["curl", "-s", "http://qt.gtimg.cn/q=sh000001,sz399001,sz399006,sh000688"], - capture_output=True, timeout=10) - _txt = _r2.stdout.decode("gbk", errors="ignore") - _parts = [] - for _line in _txt.strip().split("\n"): - if "~" not in _line: continue - _p = _line.split("~") - if len(_p) < 4: continue - _name2 = _p[1] - _price2 = _p[3] - _chg2 = _p[32] if len(_p) > 32 else "0" - _parts.append(f"{_name2}({_price2},{_chg2}%)") - if _parts: - macro_desc = "腾讯实时 " + " ".join(_parts[:3]) - except: - pass - # 基本面+实时价:直接从腾讯API拉(盘后也有收盘价) - try: - _pfx = "sh" if str(code).startswith(("6", "9")) else "sz" - _r3 = __import__('subprocess').run(["curl", "-s", f"http://qt.gtimg.cn/q={_pfx}{code}"], - capture_output=True, timeout=10) - _txt3 = _r3.stdout.decode("gbk", errors="ignore") - _p3 = _txt3.split("~") - if len(_p3) > 45: - _pe = _p3[39] if _p3[39] else "" - _pb = _p3[40] if len(_p3) > 40 and _p3[40] else "" - _mcap = _p3[44] if len(_p3) > 44 and _p3[44] else "" - _price_now = float(_p3[3]) if _p3[3] else 0 - _chg_now = float(_p3[32]) if len(_p3) > 32 and _p3[32] else 0 - if _price_now > 0: - price = _price_now # 覆盖策略中的price=0 - if _pe: pe_val = f"PE={_pe}" - if _pb: pb_val = f"PB={_pb}" - if _mcap: - mcap_val = f"市值{float(_mcap)/10000:.1f}亿" if float(_mcap) > 10000 else f"市值{_mcap}万" - pe_val += f" {mcap_val}" if pe_val else mcap_val - except: - pass - _db.close() - except Exception as _e: - pass - - # ── 从tech_snapshot提取MA和支撑阻力 ── - import re - ma5 = ma10 = ma20 = ma60 = "?" - ma_match = re.search(r'MA5=([\d.]+).*?MA10=([\d.]+).*?MA20=([\d.]+).*?MA60=([\d.]+)', tech) - if ma_match: - ma5, ma10, ma20, ma60 = ma_match.groups() - - lines.append(f"【{name}({code} 九维全析)】") - lines.append("") - if macro_desc: - lines.append(f"① 大盘环境(当日实时):{macro_desc}") - else: - lines.append(f"① 大盘环境(当日实时):数据待刷新") - if pe_val or pb_val: - lines.append(f"② 个股基本面(最新财报):{pe_val} {pb_val}") - else: - lines.append(f"② 个股基本面(最新财报):数据待补充") - lines.append(f"③ 技术面(MA5/10/20/60日 支撑阻力近20日):MA5={ma5} MA10={ma10} MA20={ma20} MA60={ma60}") - if el and eh and price > 0: - pos = "在买入区内" if el <= price <= eh else (f"低于买入区{(1-price/el)*100:.0f}%" if price < el else f"高于买入区{(price/eh-1)*100:.0f}%") - lines.append(f"④ 价格位置:{price} {pos} 区间{el}~{eh}") - else: - lines.append(f"④ 价格位置:数据待刷新") - if sl and tp and rr: - lines.append(f"⑤ 风报比:止损{sl} 止盈{tp} RR={rr:.1f}") - # 支撑阻力 - sr_m = re.search(r'强撑:([\d.]+).*?弱撑:([\d.]+).*?弱压:([\d.]+).*?强压:([\d.]+)', tech) - if sr_m: - lines.append(f"⑥ 支撑阻力:强撑{sr_m.group(1)}→弱撑{sr_m.group(2)}→弱压{sr_m.group(3)}→强压{sr_m.group(4)}") - if sector: - lines.append(f"⑦ 行业背景:{sector}") - else: - # 从stock_sectors表补行业 - try: - _s2 = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _sr = _s2.execute("SELECT sector_name FROM stock_sectors WHERE code=? LIMIT 1", (code,)).fetchone() - if _sr and _sr[0]: - lines.append(f"⑦ 行业背景:{_sr[0]}") - _s2.close() - except: - pass - - # 消息面:从signal_news读最新信号 - news_lines = [] - try: - _n_db = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _nr = _n_db.execute( - "SELECT summary, overall_sentiment, created_at FROM signal_news " - "WHERE (sector LIKE ? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " - "ORDER BY id DESC LIMIT 2", - (f'%{code}%', f'%{name[:4]}%') - ).fetchall() - for _ns in _nr: - _sent = _ns[1] - _icon = '📈' if '利好' in str(_sent) else '📉' - news_lines.append(f"{_icon} {_ns[0][:60]} ({str(_ns[2])[:10]})") - _n_db.close() - except: - pass - if category: - lines.append(f"⑧ 分类评级:{category}") - lines.append(f"⑨ 策略信号:{signal}") - if news_lines: - lines.append("") - lines.extend(news_lines) - if act: - lines.append(f"\n策略详情:{act[:200]}") - - return "\n".join(lines) - - -def main(): - codes = [a for a in sys.argv[1:] if not a.startswith("-")] - if not codes: - print("[FULL] 无指定编码,跑全量 regenerate_all()") - from strategy_lifecycle import regenerate_all - regenerate_all(stdout=False) - print("[FULL] 全量重评完成") - return - - # 读现有 decisions - raw = read_decisions() - decisions_map = {d["code"]: d for d in raw.get("decisions", []) if d.get("code")} - - ok = 0 - errors = 0 - skipped = 0 - for code in codes: - # 冷却期检查 - if _in_cooldown(code): - print(f" ⏭ {code}: 冷却期内跳过") - skipped += 1 - continue - entry = decisions_map.get(code) - if not entry: - # 不在 decisions 中的自选股 → 从 holding_strategies 构建entry - import sqlite3 - _db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') - _db.row_factory = sqlite3.Row - _wl = _db.execute("SELECT * FROM holding_strategies WHERE code=? AND status='active' AND decision_type='自选策略'", (code,)).fetchone() - _db.close() - if _wl: - entry = { - "code": code, - "name": _wl["name"], - "price": _wl["price"] or 0, - "cost": 0, - "shares": 0, - "entry_low": _wl["entry_low"] or 0, - "entry_high": _wl["entry_high"] or 0, - "stop_loss": _wl["stop_loss"] or 0, - "take_profit": 0, - "action": "", - "type": "自选策略", - "is_watchlist": True, - "analysis": json.loads(_wl["analysis_json"]) if _wl["analysis_json"] else {} - } - print(f"[WL] {code} {_wl['name']}: 从自选表构建entry") - if not entry: - print(f"[SKIP] {code}: 不在 decisions 或 watchlist_stocks 中") - errors += 1 - continue - - try: - # Always fetch live price for accurate reassessment - price = 0 - try: - # 价格从 DB 读取(price_monitor 每2分钟更新,唯一价格入口) - code_raw = entry.get("code", "") - price = 0 - import sqlite3 - db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - db.row_factory = sqlite3.Row - row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code_raw,)).fetchone() - if not row: - row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code_raw,)).fetchone() - if not row: - row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code_raw,)).fetchone() - if row: - price = row['price'] or 0 - db.close() - if price > 0: - print(f" 实时价: {price} (来自DB)") - else: - # fallback to DB portfolio data - _pf_data = read_portfolio() - for _h in _pf_data.get("holdings", []): - if _h["code"] == code_raw: - price = float(_h.get("price", 0)) - break - if price <= 0: - price = entry.get("current_price") or entry.get("price") or 0 - except Exception as e: - print(f" 价格获取失败: {e}", file=sys.stderr) - price = entry.get("current_price") or entry.get("price") or 0 - - # Price diff debounce: skip reassessment if price changed < 1% since last update - last_price = entry.get("last_reassessed_price") or 0 - if last_price > 0 and price > 0: - diff_pct = abs(price - last_price) / last_price * 100 - if diff_pct < 1.0: - print(f" 价差仅{diff_pct:.2f}% (<1%),跳过重评(上次价={last_price},现价={price})") - skipped += 1 - continue - # 打印参数调试 - if entry is None: - print(f" DEBUG: code={code} ENTRY=NONE 跳过") - print(f" [SKIP] {code} 策略数据不存在") - skipped += 1 - continue - entry_action = str(entry.get('action') or '') - print(f" DEBUG: code={code} name={entry.get('name','')} price={price} cost={entry.get('cost')} shares={entry.get('shares')} action={entry_action[:30]} is_wl={entry.get('type','') in ('自选策略','watchlist')}", flush=True) - result = reassess_strategy( - code=code, - name=entry.get("name", ""), - price=price or 0, - cost=entry.get("cost") or 0, - shares=entry.get("shares") or 0, - current_action=entry.get("action", ""), - is_watchlist=entry.get("type", "") in ("自选策略", "watchlist"), - ) - if result and result.get("action"): - # 持仓股止损不下移(移动止损规则):已有仓位的止损只上不下 - is_held = (entry.get("cost") or 0) > 0 and (entry.get("shares") or 0) > 0 and \ - entry.get("type", "") not in ("自选策略", "watchlist") - old_stop = entry.get("stop_loss") or 0 - new_stop = result.get("stop_loss") or 0 - if is_held and old_stop > 0 and new_stop > 0 and new_stop < old_stop: - print(f" 移动止损保护: {new_stop}→保持{old_stop} (持仓止损不下移)") - result["stop_loss"] = old_stop - # 同时更新 action 字符串中的止损值 - act = result.get("action", "") - if act: - act = re.sub(r'止损[\d.]+', f'止损{old_stop}', act) - result["action"] = act - - # ── 写入 DB holding_strategies 表(替代 decisions.json)── - try: - from mofin_db import get_conn, write_holding_strategy - _conn = get_conn() - _db_entry = { - "code": code, - "name": entry.get("name", ""), - "price": price, - "cost": entry.get("cost", 0), - "shares": entry.get("shares", 0), - "stop_loss": result.get("stop_loss", entry.get("stop_loss")), - "take_profit": result.get("take_profit", entry.get("take_profit")), - "entry_low": result.get("entry_low", entry.get("entry_low")), - "entry_high": result.get("entry_high", entry.get("entry_high")), - "currency": "HKD" if (len(str(code)) == 5 and str(code)[0] in '01') else "CNY", - "strategy_type": "自选策略" if entry.get("type", "") in ("自选策略", "watchlist") else "持仓策略", - "action": result.get("action", ""), - "timing_signal": result.get("timing_signal", entry.get("timing_signal", "")), - "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), - "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot", "")), - "stock_category": result.get("stock_category", entry.get("stock_category", "")), - "sector_context": result.get("sector_context", entry.get("sector_context", "")), - "status": result.get("status", "active"), - "source": entry.get("source", "auto"), - "reason": result.get("action_note", ""), - "version": entry.get("version", 1), - "full_analysis": _build_full_analysis(code, entry, result) if result else "", - } - write_holding_strategy(_conn, code, entry.get("name", ""), _db_entry) - _conn.commit() - _conn.close() - # 验证写入 - _fa_check = _db_entry.get("full_analysis", "") - print(f" DEBUG: full_analysis长度={len(_fa_check)} 内容=[{_fa_check[:100]}]") - # 直接用SQL写入full_analysis - try: - _fa_conn = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _fa_conn.execute("UPDATE holding_strategies SET full_analysis=? WHERE code=? AND status='active'", (_fa_check, code)) - _fa_conn.commit() - _fa_conn.close() - print(f" ✅ full_analysis直接SQL写入成功") - except Exception as _fa_e: - print(f" ⚠️ 直接SQL写入失败: {_fa_e}") - _v = __import__('sqlite3').connect(str(__import__('pathlib').Path("/home/hmo/MoFin/data/mofin.db"))) - _fa = _v.execute("SELECT full_analysis FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if _fa and _fa[0]: print(f" ✅ full_analysis已写入({len(_fa[0])}字)") - else: print(f" ⚠️ full_analysis为空") - _v.close() - # LLM生成完整九维分析 - _macro_desc = "" - _pe_val = "" - _pb_val = "" - try: - _mdb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _mr = _mdb.execute("SELECT structure FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() - if _mr and _mr[0]: - _st = __import__('json').loads(_mr[0]) - _macro_desc = _st.get("description", "") - _mood = _mr[1] if len(_mr) > 1 else "" - if _mood: _macro_desc += f" 情绪={_mood}" - # 基本面从腾讯API - _p = "sh" if str(code).startswith(("6","9")) else "sz" - _cr = __import__('subprocess').run(["curl","-s",f"http://qt.gtimg.cn/q={_p}{code}"], capture_output=True, timeout=10) - _ct = _cr.stdout.decode("gbk", errors="ignore").split("~") - if len(_ct) > 39 and _ct[39]: _pe_val = f"PE={_ct[39]}" - if len(_ct) > 44 and _ct[44]: _pb_val = f"PB≈{float(_ct[44])/10000:.1f}亿" - _mdb.close() - except: - pass - - # 拉取资金流数据 - _flow_note = "暂无资金流数据" - try: - _fdb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _fr = _fdb.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() - if _fr and _fr[0]: - _fc = __import__('json').loads(_fr[0]) - _s = _fc.get("stocks", {}).get(code, {}) - if _s and _s.get("analysis"): - _a = _s["analysis"] - _flow_note = f"净流入{_a.get('net_flow',0):.0f}万 主力{_a.get('main_force',0):.0f}万 趋势{_a.get('trend','中性')}" - _fdb.close() - except: - pass - - # 拉取近期消息面 - _news_note = "暂无近期消息" - try: - _ndb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _nr2 = _ndb.execute( - "SELECT summary, overall_sentiment, created_at FROM signal_news " - "WHERE (code=? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " - "ORDER BY id DESC LIMIT 3", - (code, f'%{entry.get("name","")[:4]}%') - ).fetchall() - if _nr2: - _news_note = " | ".join([f"{r[2][:10]} {r[1]} {r[0][:40]}" for r in _nr2]) - _ndb.close() - except: - pass - - # ── 拉取已有策略全文 + 最近变更 ── - _existing_full_analysis = "" - _existing_changelog_text = "无变更记录" - try: - _edb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - _er = _edb.execute( - "SELECT full_analysis, changelog_json FROM holding_strategies " - "WHERE code=? AND status='active'", (code,) - ).fetchone() - if _er: - _existing_full_analysis = _er[0] or "" - _cl_raw = _er[1] or "" - if _cl_raw: - _cl = __import__('json').loads(_cl_raw) if isinstance(_cl_raw, str) else _cl_raw - if isinstance(_cl, list) and _cl: - _recent = _cl[-3:] - _existing_changelog_text = "\n".join( - [f" [{c.get('timestamp','?')}] {c.get('action','?')}: {c.get('reason','')}"[:120] - for c in reversed(_recent)] - ) - _edb.close() - except: - pass - - _prompt = f"""你是一个资深股票分析师。请对股票{code}评估现有策略是否仍然有效,并输出完整的新策略。 - -╔══════════════════════════════════════════════╗ -║ 📋 第一步:审阅原策略 ║ -╚══════════════════════════════════════════════╝ - -【原策略全文】(上次完整分析): -{_existing_full_analysis or '暂无完整策略分析'} - -【当前策略参数】: - 价格={price} 信号={result.get("timing_signal") or entry.get("timing_signal","")} - 买入区间={entry.get("entry_low",0)}~{entry.get("entry_high",0)} - 止损={entry.get("stop_loss",0)} 止盈={entry.get("take_profit",0)} - RR={result.get("rr_ratio", entry.get("rr_ratio", 0))} - 策略={result.get("action") or entry.get("action","")} - 行业={(result.get("sector_context") or entry.get("sector_context",""))[:50]}(当日实时) - 技术={(result.get("tech_snapshot") or entry.get("tech_snapshot",""))[:200]}(MA=5/10/20/60日 支撑阻力=近20日 量价=当日+近5日趋势) - -【最近变更记录】: -{_existing_changelog_text} - -╔══════════════════════════════════════════════╗ -║ 📊 第二步:12维矩阵交叉分析 ║ -╚══════════════════════════════════════════════╝ - -⚠️ 重要:12个维度必须交叉对比,找出矛盾/共振点,给出综合判断。 - -当前实时数据(每条标注时间窗口,禁止使用模型训练数据): -大盘={_macro_desc or "震荡"}(当日实时) | PE/市值={_pe_val} {_pb_val}(最新财报) -资金流={_flow_note}(近5日累计) -消息面={_news_note}(最近3条,自动标注抓取时间) - -╔══════════════════════════════════════════════╗ -║ 📝 第三步:决策输出 ║ -╚══════════════════════════════════════════════╝ - -请严格按以下顺序输出: - -【维持或修改】判断当前策略是否仍然有效,回答「维持」或「修改」。 - -【修改点及理由】(如果维持,写「无需修改」;如果修改,逐条列出): - - 修改什么参数/方向 - - 理由(引用具体维度矛盾或共振) - -【最终新策略】(完整策略全文,self-contained,可直接存入DB) - -【交叉分析】哪些维度矛盾/共振,关键信号 -① 大盘×基本面 ② 大盘×消息面 ③ 大盘×技术面 ④ 大盘×资金面 -⑤ 行业×基本面 ⑥ 行业×消息面 ⑦ 行业×技术面 ⑧ 行业×资金面 -⑨ 个股×基本面 ⑩ 个股×消息面 ⑪ 个股×技术面 ⑫ 个股×资金面 - -最后必须输出: -【综合结论】(买入/关注/观望/卖出) -【操作建议】 -【建议止损】 -【建议止盈】 -【建议仓位】⚠️不可省略,非"买入"时写"不新建仓" - -⚠️ 输出纪律(必须遵守): -1. 直接以【维持或修改】开头,禁止任何寒暄、开场白、分隔线 -2. 禁止输出 或任何 XML/JSON/代码块 -3. 所有【】节标题一个都不能少""" - _full_analysis_text = None - try: - _llm_result = call_llm(_prompt, max_tokens=4096, timeout=150, retries=1, backoff=20) - if _llm_result["ok"]: - _full_analysis_text = _llm_result["content"] - print(f" ✅ LLM12维分析完成({len(_full_analysis_text)}字, {_llm_result['elapsed']:.1f}s)", flush=True) - else: - print(f" ❌ LLM12维分析失败({_llm_result['attempts']}次): {_llm_result['error'][:200]}", flush=True) - except Exception as _e: - print(f" ❌ LLM12维分析异常: {_e}", flush=True) - - # ── 保存到DB(覆写前先快照)── - _fa_conn = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") - if _full_analysis_text: - # 快照旧策略(使用共享函数) - try: - snapshot_strategy_history(_fa_conn, code, "per_stock_12d") - except Exception as _se: - print(f" ⚠️ 快照失败: {_se}", flush=True) - - _fa_conn.execute( - "UPDATE holding_strategies SET full_analysis=?, reassessed_at=? WHERE code=? AND status='active'", - (_full_analysis_text, __import__('datetime').datetime.now().isoformat(), code)) - _fa_conn.commit() - _fa_conn.close() - if _full_analysis_text: - print(f" ✅ 完整12维分析已保存({len(_full_analysis_text)}字)") - else: - print(f" ⚠️ 12维分析未完成,跳过保存") - print(f" [DB] holding_strategies 已更新: {code}") - # 从LLM输出提取信号 - if _full_analysis_text and '【综合结论】' in _full_analysis_text: - try: - _sig_line = [l for l in _full_analysis_text.split('\n') if '综合结论' in l] - if _sig_line: - _sig = '买入' if '买入' in _sig_line[0] else '关注' if '关注' in _sig_line[0] else '观望' if '观望' in _sig_line[0] else '卖出' if '卖出' in _sig_line[0] else '' - if _sig: - _ts_conn = __import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db') - _ts_conn.execute( - "UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", (_sig, code)) - _ts_conn.commit() - # 推荐操作 tag 同步(与 XMPP 动作级信号同源) - from mofin_db import sync_recommend_tag - sync_recommend_tag(_ts_conn, code, _sig) - _ts_conn.close() - print(f" ✅ LLM信号={_sig} 已写入") - # 买入信号→推XMPP - if _sig == "买入": - try: - _nr2 = __import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db').execute( - "SELECT name, price, entry_low, entry_high, stop_loss, take_profit, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() - if _nr2: - _xm = f"📈 {_nr2[0] or code}({code}) 价{_nr2[1]}→12维买入信号!区间{_nr2[2]}~{_nr2[3]} 损{_nr2[4]} 盈{_nr2[5]} 仓位{_nr2[6] or '-'}" - from alert_helper import notify as _notify2, ACTION as _ACT2 - _notify2("买入信号", _xm, _ACT2) - print(f" 📨 XMPP推送买入信号") - except: pass - except: pass - # 冷却期已更新(reassessed_at写入) - except Exception as _dbe: - print(f" [DB FAIL] holding_strategies 写入失败: {_dbe}", file=sys.stderr) - - # 更新 decisions_map 中对应的条目 - updated = entry.copy() - # 币种标记:HK股保留HKD原始值,A股为CNY - is_hk = len(str(code)) == 5 and str(code)[0] in '01' - updated.update({ - "action": result["action"], - "stop_loss": result.get("stop_loss", entry.get("stop_loss")), - "entry_low": result.get("entry_low", entry.get("entry_low")), - "entry_high": result.get("entry_high", entry.get("entry_high")), - "take_profit": result.get("take_profit"), - "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot")), - "timing_signal": result.get("timing_signal", entry.get("timing_signal")), - "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), - "status": result.get("status", "updated"), - "price": price, - "currency": "HKD" if is_hk else "CNY", - }) - # Save last reassessed price for debounce tracking - updated["last_reassessed_price"] = price - decisions_map[code] = updated - # ——— 初始化多分支策略树 ——— - try: - sys.path.insert(0, '/home/hmo/MoFin') - from strategy_tree import init_default_branches - branches = init_default_branches( - code, - entry.get('name', ''), - result.get('entry_low', 0), - result.get('entry_high', 0), - result.get('stop_loss', 0), - result.get('take_profit', 0), - ) - st = updated.setdefault('strategy_tree', {}) - st['branches'] = branches - except Exception: - pass - print(f"[OK] {code} {entry.get('name','')}: {result['action'][:80]}") - ok += 1 - else: - print(f"[SYNCED] {code}: 无变更") - ok += 1 - except Exception as e: - print(f"[ERROR] {code}: {e}", file=sys.stderr) - import traceback - traceback.print_exc(file=sys.stderr) - errors += 1 - - # 同步自选股更新回 watchlist_stocks 表(持仓策略已通过 write_holding_strategy 写入 DB) - try: - from datetime import datetime as _dt - import sqlite3 - _db2 = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') - for _code in codes: - _entry = decisions_map.get(_code) - if _entry and _entry.get("is_watchlist"): - _db2.execute(""" - UPDATE watchlist_stocks - SET entry_low=?, entry_high=?, stop_loss=?, price=?, - analysis_json=json(?) - WHERE code=? AND is_active=1 - """, ( - _entry.get("entry_low", 0), - _entry.get("entry_high", 0), - _entry.get("stop_loss", 0), - _entry.get("price", 0), - json.dumps({ - "action": _entry.get("action",""), - "take_profit": _entry.get("take_profit", 0), - "stop_loss": _entry.get("stop_loss", 0), - "tech_snapshot": _entry.get("tech_snapshot", ""), - "rr": _entry.get("rr_ratio", 0), - "reassessed_at": _dt.now().strftime("%Y-%m-%d") - }, ensure_ascii=False), - _code - )) - _db2.commit() - _db2.close() - if any(e.get("is_watchlist") for e in [decisions_map.get(c) for c in codes] if e): - print("[SYNC] 自选股策略已同步回 watchlist_stocks 表") - except Exception as e: - print(f"[SYNC FAIL] watchlist_stocks 同步失败: {e}", file=sys.stderr) - - print(f"[DONE] {ok}成功 {skipped}跳过 {errors}失败") - - # ── 第二步:扫描自选股(watchlist),价格偏离买入区>20%触发重评 ── - scan_watchlist_stocks() - - -# ════════════════════════════════════════════════════════════════════ -# 自选股扫描 -# ════════════════════════════════════════════════════════════════════ - -def scan_watchlist_stocks(): - """扫描自选股表 (watchlist_stocks),对价格偏离买入区 >20% 的股票自动重评。 - - 偏离公式: max(|price - entry_low|, |price - entry_high|) / entry_low * 100 > 20 - - 通过 technical_analysis.full_analysis() 获取最新支撑/阻力位, - 更新 entry_low / entry_high / stop_loss / price / analysis_json。 - 每轮最多处理 3 只,超过时标记剩余数量待下次扫描。 - """ - import sqlite3, json - from datetime import datetime - from technical_analysis import full_analysis - from mo_models import is_hk_stock - - DB = '/home/hmo/web-dashboard/data/mofin.db' - db = sqlite3.connect(DB) - db.row_factory = sqlite3.Row - - rows = db.execute( - "SELECT * FROM watchlist_stocks WHERE is_active=1" - ).fetchall() - - if not rows: - print("[WL-SCAN] 自选股表为空,跳过") - db.close() - return - - # ── 筛选偏离 >20% 的股票 ── - candidates = [] # (code, name, price, entry_low, entry_high, stop_loss, deviation, analysis_json) - for r in rows: - code = r["code"] - name = r["name"] - 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 - analysis_json = r["analysis_json"] - - if entry_low <= 0 or price <= 0: - continue - - dev_low = abs(price - entry_low) - dev_high = abs(price - entry_high) - deviation = max(dev_low, dev_high) / entry_low * 100 - - if deviation > 20: - candidates.append((code, name, price, entry_low, entry_high, stop_loss, deviation, analysis_json)) - - total_needed = len(candidates) - print(f"[WL-SCAN] 自选股共{len(rows)}只,偏离>20%需重评: {total_needed}只") - - MAX_PER_RUN = 3 - to_process = candidates[:MAX_PER_RUN] - remaining = max(0, total_needed - MAX_PER_RUN) - - if remaining > 0: - print(f"[WL-SCAN] 本轮限{MAX_PER_RUN}只,剩余{remaining}只待下次扫描") - - if not to_process: - print("[WL-SCAN] 无需重评") - db.close() - return - - ok = 0 - errors = 0 - for code, name, price, old_low, old_high, old_stop, deviation, old_analysis_json in to_process: - print(f"[WL-REASSESS] {code} {name}: 偏离{deviation:.1f}%,触发重评") - try: - ta = full_analysis(code) - if not ta or "error" in ta: - print(f" [WARN] TA失败: {ta}") - errors += 1 - continue - - sr = ta.get("support_resistance", {}) - if "error" in sr: - print(f" [WARN] 支撑/阻力计算失败: {sr}") - errors += 1 - continue - - new_price = ta.get("quote", {}).get("price", price) - new_entry_low = round(sr.get("weak_support", old_low), 2) - new_entry_high = round(sr.get("weak_resist", old_high), 2) - new_stop_loss = round(sr.get("strong_support", old_stop), 2) - new_take_profit = round(sr.get("strong_resist", 0), 2) - - # ── 更新 analysis_json + changelog ── - old_analysis = json.loads(old_analysis_json) if old_analysis_json else {} - changelog = old_analysis.get("changelog", []) - changelog.append({ - "action": "auto_reassess_watchlist", - "reason": f"价格偏离买入区{deviation:.1f}%", - "old_entry_low": old_low, - "old_entry_high": old_high, - "new_entry_low": new_entry_low, - "new_entry_high": new_entry_high, - "old_stop_loss": old_stop, - "new_stop_loss": new_stop_loss, - "take_profit": new_take_profit, - "price": new_price, - "deviation_pct": round(deviation, 1), - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), - }) - - new_analysis = { - **old_analysis, - "take_profit": new_take_profit, - "tech_snapshot": { - "support_resistance": sr, - "candlestick": ta.get("candlestick", {}), - "volume": ta.get("volume", {}), - "analyzed_at": ta.get("analyzed_at", ""), - }, - "reassessed_at": datetime.now().strftime("%Y-%m-%d"), - "changelog": changelog, - } - - currency = "HKD" if is_hk_stock(code) else "CNY" - - db.execute(""" - UPDATE watchlist_stocks - SET entry_low=?, entry_high=?, stop_loss=?, price=?, - currency=?, analysis_json=? - WHERE code=? AND is_active=1 - """, ( - new_entry_low, new_entry_high, new_stop_loss, - new_price, currency, json.dumps(new_analysis, ensure_ascii=False), - code, - )) - db.commit() - print(f" [OK] {code} {name}: 买入区{old_low}-{old_high} -> {new_entry_low}-{new_entry_high}, " - f"止损{new_stop_loss}, 止盈{new_take_profit}") - ok += 1 - except Exception as e: - import traceback - print(f" [ERROR] {code}: {e}", file=sys.stderr) - traceback.print_exc(file=sys.stderr) - errors += 1 - - db.close() - remaining_msg = f" (剩余{remaining}只)" if remaining else "" - print(f"[WL-SCAN] DONE: {ok}成功 {errors}失败{remaining_msg}") - - -if __name__ == "__main__": - main() diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py deleted file mode 100644 index 25f5cd92..00000000 --- a/scripts/price_monitor.py +++ /dev/null @@ -1,781 +0,0 @@ -#!/usr/bin/env python3 -"""price_monitor.py — 高频价格监控脚本(批量版) -规则:进入区间报一次,离开区间报一次,中间不重复。 -每次运行时一次性刷新所有持仓+自选股的实时价。 -""" -import urllib.request -import os, sys, time, json -import sqlite3 -from datetime import datetime - -from mo_data import read_decisions - -BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" -STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json" - -# DB 模块(同步实时价到 mofin.db) -sys.path.insert(0, "/home/hmo/MoFin") -try: - from mofin_db import get_conn, DB_PATH - from mo_models import calc_total_mv, calc_total_assets - HAS_DB = True -except ImportError: - HAS_DB = False - -# 策略重评依赖(技术面驱动,非机械百分比) -sys.path.insert(0, "/home/hmo/web-dashboard") -try: - from strategy_lifecycle import reassess_strategy, reassess_with_context - HAS_REASSESS = True -except ImportError: - HAS_REASSESS = False - -UA = "Mozilla/5.0" - -# ── XMPP推送 ────────────────────────────────────────────────────────── -XMPP_USER = "hmo@yoin.fun" -XMPP_BRIDGE = "http://127.0.0.1:5805/" - -def push_to_xmpp(text): - """原始直推(已废弃直用)——保留给极少数必须原样的场景。 - 新代码请用 _push_action/_push_digest。""" - if not text.strip(): - return - try: - payload = json.dumps({ - "to": XMPP_USER, - "body": text.strip(), - "type": "chat", - }).encode("utf-8") - req = urllib.request.Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) - urllib.request.urlopen(req, timeout=5) - except Exception as e: - print(f"[XMPP推送失败] {e}", file=sys.stderr) - - -# ── 分级推送(2026-07-21 信噪比纪律,红线#12)── -# ACTION: 破止损/重评确认的操作信号 — 直通不限速 -# INFO: 未确认的进区提示 — 聚合成摘要,30min 限 1 条 -def _push_action(category, text): - try: - from alert_helper import notify, ACTION - notify(category, text, ACTION) - except Exception as e: - print(f"[ACTION推送失败] {e}", file=sys.stderr) - - -def _push_digest(category, text): - try: - from alert_helper import notify, INFO - notify(category, text, INFO) - except Exception as e: - print(f"[INFO推送失败] {e}", file=sys.stderr) - -# ── 批量拉取价格 ────────────────────────────────────────────────────────── - -def fetch_all_prices(codes): - """腾讯批量行情API:一次请求拉取所有股票(A股+港股) - A股:sh600110 / sz000001 - 港股:hk00700 - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - # 构建批量查询串 - symbols = [] - code_map = {} # symbol -> original_code - for code in codes: - code_s = str(code).strip() - if len(code_s) == 6: - # A股:沪市以5/6/9开头,深市以0/3开头 - if code_s.startswith(('5', '6', '9')): - sym = f"sh{code_s}" - else: - sym = f"sz{code_s}" - else: - sym = f"hk{code_s}" - symbols.append(sym) - code_map[sym] = code_s - - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - try: - # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..." - raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw_value.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig_code = code_map.get(sym) - if not orig_code: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig_code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -def refresh_data_prices(): - """一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON)""" - all_codes = set() - - # 从DB读所有需要拉取价格的代码 - try: - conn = get_conn() - for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): - all_codes.add(r['code']) - for r in conn.execute("SELECT code FROM watchlist_stocks"): - all_codes.add(r['code']) - for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): - all_codes.add(r['code']) - conn.close() - except Exception as e: - print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr) - return 0 - - if not all_codes: - return 0 - - # 一次性批量拉取 - prices = fetch_all_prices(list(all_codes)) - updated = len(prices) - - # === 弹性同步实时价到 mofin.db === - # 防死锁策略(经2026-07-14 WAL死锁复盘改进): - # ① 启动时 checkpoint WAL(清理残留事务) - # ② 统一 BEGIN IMMEDIATE 包裹整个写操作 - # ③ 5次重试 + 指数退避: 1s → 2s → 4s → 8s → 16s(共~31s) - # ④ get_conn() 的 busy_timeout=30000 保证等待上限 - # ⑤ 每个写操作检查返回值,任一失败立即 rollback + 重试 - # ⑥ try/finally 确保连接始终释放 - if HAS_DB and prices: - # 先checkpoint一次,清理上次被kill残留的WAL - try: - c = get_conn() - c.execute("PRAGMA wal_checkpoint(TRUNCATE)") - c.close() - except Exception: - pass - - max_tries = 5 - conn = None - for db_attempt in range(max_tries): - try: - conn = get_conn() - # BEGIN IMMEDIATE 立即获取写锁——失败则等 busy_timeout(30s) - conn.execute("BEGIN IMMEDIATE") - - # ── 构建 holdings 更新数据 ── - db_holdings = [] - for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"): - h = dict(r) - code = str(h.get('code', '')) - if code in prices: - price_val, _, change_pct = prices[code] - if price_val > 0: - h['price'] = round(price_val, 2) - h['change_pct'] = float(change_pct) if change_pct else 0 - db_holdings.append(h) - - # ── 写 holdings 表 ── - for h in db_holdings: - currency = str(h.get('currency', 'CNY')).upper() - if currency not in ('CNY', 'HKD'): - raise ValueError(f"非法币种: {currency}") - 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'), - )) - - # ── 写 portfolio_summary ── - mv = calc_total_mv(db_holdings) - existing = conn.execute( - 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' - ).fetchone() - db_cash = existing['cash'] if existing else 0.0 - db_frozen = existing['frozen_cash'] if existing else 0.0 - assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen}) - position_pct = round(mv / assets * 100, 2) if assets > 0 else 0 - 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') - """, ( - assets, mv, mv, db_cash, db_frozen, - position_pct, 0, 'CNY', - )) - - # ── 写 live_prices ── - for h in db_holdings: - code = h.get('code', '') - if code: - p = h.get('price', 0) - cp = h.get('change_pct', 0) - conn.execute( - "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " - "VALUES (?,?,?,datetime('now','localtime'))", - (code, p, cp) - ) - # 补充策略股/自选股的价格(不在holdings中的) - for code, pdata in prices.items(): - if code not in {h.get('code') for h in db_holdings}: - price_val = pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price', 0) - cp_val = pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct', 0) - conn.execute( - "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " - "VALUES (?,?,?,datetime('now','localtime'))", - (code, price_val, cp_val) - ) - - conn.commit() - conn.close() - conn = None - if db_attempt > 0: - print(f"DB同步成功(第{db_attempt+1}次重试)") - break # success - - except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: - if conn: - try: conn.rollback() - except Exception: pass - try: conn.close() - except Exception: pass - conn = None - err_str = str(e) - if "locked" in err_str or "cannot commit" in err_str or "busy" in err_str: - if db_attempt < max_tries - 1: - wait = 2 ** db_attempt # 1, 2, 4, 8, 16 - print(f"⏳ DB锁(尝试{db_attempt+1}/{max_tries}): {e} → {wait}s后重试", file=sys.stderr) - time.sleep(wait) - else: - print(f"❌ DB锁(重试{max_tries}次耗尽): {e}", file=sys.stderr) - else: - print(f"❌ DB错误: {e}", file=sys.stderr) - break - except Exception as e: - if conn: - try: conn.rollback() - except Exception: pass - try: conn.close() - except Exception: pass - conn = None - print(f"⚠️ DB同步异常: {e}", file=sys.stderr) - break - else: - # for-else: loop exhausted without break - print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr) - # 尝试紧急 WAL checkpoint(释放死锁) - try: - c = sqlite3.connect(str(DB_PATH), timeout=1) - c.execute("PRAGMA wal_checkpoint(TRUNCATE)") - c.close() - print(" ↪ 紧急WAL checkpoint完成", file=sys.stderr) - except Exception as we: - print(f" ↪ WAL checkpoint也失败: {we}", file=sys.stderr) - - return updated - - -# ── 区间偏离检测 ────────────────────────────────────────────────────────── - -def load_state(): - try: - with open(STATE_PATH) as f: - return json.load(f) - except: - return {} - -def save_state(state): - os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) - with open(STATE_PATH, 'w') as f: - json.dump(state, f, ensure_ascii=False, indent=2) - -def load_breaches(): - try: - with open(BREACH_PATH) as f: - return json.load(f) - except: - return {} - -def save_breaches(data): - os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True) - with open(BREACH_PATH, 'w') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def record_event(code, name, event_type, price, trigger_value, event_label=""): - """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。 - - price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股) - 先注册再写事件,否则 FK 失败事件丢失。 - """ - now = datetime.now().isoformat() - - if HAS_DB: - try: - from mofin_db import get_conn, write_price_event - _c = get_conn() - _exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A")) - _c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)", - (str(code), name or str(code), _exch, _typ, now)) - _c.commit() - write_price_event(_c, code=code, name=name, event_type=event_type, - price=round(price, 2), trigger_value=str(trigger_value), - event_label=event_label) - _c.close() - except Exception as e: - print(f"[price_events DB写入失败] {e}", file=sys.stderr) - - -def get_trigger_zones(trigger): - """返回该trigger所有可监控的区间列表,跳过已执行的batch""" - zones = [] - for key, label in [ - ("entry_zone", "加仓区间"), - ("batch1_price", "试仓区间"), - ("batch2_price", "加仓区间"), - ("take_profit_zone", "止盈区间"), - ("watch_low", "关注区间"), - ("watch_high", "减仓区间"), - ("watch_break", "止损区间") - ]: - status_key = key.replace("_price", "_status") - if status_key in trigger and trigger[status_key] == "executed": - continue - val = trigger.get(key, "") - if val and "~" in val: - try: - parts = val.split("~") - lo, hi = float(parts[0]), float(parts[1]) - zones.append((key, label, lo, hi)) - except: - pass - sl = trigger.get("stop_loss", "") - if sl: - try: - sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl) - zones.append(("stop_loss", "止损", 0, sl_price)) - except: - pass - return zones - - -def _cleanup_lock(): - """清理进程锁文件""" - try: - os.remove("/tmp/price_monitor.lock") - except Exception: - pass - -def _handle_sigterm(signum, frame): - """收到SIGTERM时清理锁文件后退出""" - _cleanup_lock() - sys.exit(0) - -def _handle_sigalrm(signum, frame): - """收到SIGALRM强制超时时清理锁文件后退出""" - _cleanup_lock() - print(f"[TIMEOUT] 本轮执行超时({signum}s),已清理锁文件", file=sys.stderr, flush=True) - sys.exit(0) - -def run_once(round_label=""): - """执行一轮完整的监控流程""" - import os, signal # 必须在开头import,否则os变量会被后面的局部import绑定覆盖 - signal.signal(signal.SIGTERM, _handle_sigterm) - signal.signal(signal.SIGALRM, _handle_sigalrm) - os.nice(10) # 降低优先级,避免与DB其他写操作抢占 - # ── 进程锁:同一时间只跑一个实例 ── - _lk = "/tmp/price_monitor.lock" - _pid = None - try: - with open(_lk) as _f: - _pid = int(_f.read().strip()) - os.kill(_pid, 0) - print(f"[LOCK] 已有实例(PID {_pid})在运行,跳过本轮", file=sys.stderr, flush=True) - return - except (FileNotFoundError, ProcessLookupError, ValueError): - pass - with open(_lk, "w") as _f: - _f.write(str(os.getpid())) - signal.alarm(120) # 硬上限120s,超时自动清理锁退出 - - label = f" [{round_label}]" if round_label else "" - start = time.time() - TIME_BUDGET = 90 # 预留30s给输出和清理,90s内必须完成核心逻辑 - - # === 第一步:一次性刷新所有价格 === - refreshed = refresh_data_prices() - - # === 第二步:检查触发条件 === - try: - dec = read_decisions() - except: - print(f"❌{label} 无法读取decisions(DB)", file=sys.stderr) - return - - active = [d for d in dec.get("decisions", []) if d.get("status") == "active"] - state = load_state() - outputs = [] - state_updated = False - # 时间冷却:同股同区间30分钟内不重复推 - _push_cooldown = {} - _cooldown_file = "/home/hmo/.hermes/.price_push_cooldown.json" - try: - import os - if os.path.exists(_cooldown_file): - with open(_cooldown_file) as _f: - _push_cooldown = json.load(_f) - except Exception: - _push_cooldown = {} - - def _can_push(code, zone_key): - now = time.time() - key = f"{code}_{zone_key}" - last = _push_cooldown.get(key, 0) - if now - last < 1800: # 30分钟 - return False - _push_cooldown[key] = now - # 持久化写入 - try: - with open(_cooldown_file, "w") as _f: - json.dump(_push_cooldown, _f) - except Exception: - pass - return True - - # 收集所有需要检查的代码 - check_codes = set() - for d in active: - trig = d.get("trigger", {}) - if trig: - check_codes.add(d["code"]) - - # 批量拉取这些股票的价格 - prices = fetch_all_prices(list(check_codes)) - - # 本轮进区事件收集(聚合成一条摘要推送,替代逐条轰炸) - _zone_entries = [] - - for d in active: - code = d["code"] - trig = d.get("trigger", {}) - if not trig: - continue - - zones = get_trigger_zones(trig) - if not zones: - continue - - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - name = d.get("name", code) - if code not in state: - state[code] = {} - - # 时间预算检查:如果超时,跳过重评只做状态记录 - _budget_low = (time.time() - start) > TIME_BUDGET - - for key, label, lo, hi in zones: - in_zone = lo <= price <= hi - prev_in_zone = state[code].get(key, None) - - if in_zone and prev_in_zone != True: - if key == "stop_loss": - outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") - record_event(code, name, "stop_loss", price, str(hi)) - # 止损触发 → 立即重评并推送给Dad(时间不够则直接推原始告警) - if _budget_low: - outputs.append(f" 📨 止损触发(超时跳过重评)→已推送Dad") - if _can_push(code, "stop_loss"): - _push_action("止损告警", f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") - else: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - current_action = d.get("action", "") - result = reassess_with_context(code, name, price, cost, shares, current_action) - if result: - timing_signal = result.get("timing_signal", "") - action = result.get("action", "") - if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"): - buy_lo = d.get("entry_low", 0) - buy_hi = d.get("entry_high", 0) - rr = result.get("rr_ratio", 0) - if _can_push(code, "stop_loss"): - msg = f"🔔 {name}({code}) 价{price}→触发操作区间{max(buy_lo,0):.2f}~{buy_hi:.2f},已触发重评|RR={rr}" - _push_action("操作信号", msg) - outputs.append(f" 📨 止损重评→已推送Dad: {action}") - except Exception as e: - outputs.append(f" ⚠️ 止损重评失败: {e}") - else: - extra = "" - if "_price" in key: - batch_shares = trig.get(key.replace("_price", "_shares"), "") - action = trig.get(key.replace("_price", "_action"), "") - if batch_shares: - extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股" - elif key in ("take_profit_zone",): - act = trig.get("take_profit_action", "") - if act: - extra = f"({act})" - outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}") - record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label) - # 进入区间 → 立即重评并推送给Dad(时间不够则记入摘要,不逐条轰炸) - if _budget_low: - if _can_push(code, key): - _zone_entries.append(f"{name}({code}) {price}→{label}{lo}~{hi}") - outputs.append(f" 📨 区间触发(超时)→记入摘要") - else: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - current_action = d.get("action", "") - result = reassess_with_context(code, name, price, cost, shares, current_action) - if result: - timing_signal = result.get("timing_signal", "") - action = result.get("action", "") - # 格式化区间描述(止盈区lo=0时美化显示) - if key == "take_profit_zone" and lo == 0: - zone_desc = f"止盈监控(目标{hi:.0f})" - else: - zone_desc = f"操作区间{lo}~{hi}" - if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"): - rr = result.get("rr_ratio", 0) - if _can_push(code, key): - msg = f"🔔 {name}({code}) 价{price}→触发{zone_desc},已触发重评|RR={rr}" - _push_action("操作信号", msg) - outputs.append(f" 📨 区间触发重评→已推送Dad: {action}") - else: - reason = f"重评结果:{timing_signal},不构成操作建议" - outputs.append(f" 📋 本地日志(不推): {reason}") - except Exception as e: - outputs.append(f" ⚠️ 区间重评失败: {e}") - state[code][key] = True - state_updated = True - - elif not in_zone and prev_in_zone == True: - if key != "stop_loss": - outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}") - state[code][key] = False - state_updated = True - - # === 第二步收尾:进区事件聚合成一条摘要推送(INFO级,30min限1条+截断)=== - if _zone_entries: - _digest = f"📋 {len(_zone_entries)}只进入操作区:\n" + "\n".join(f"• {e}" for e in _zone_entries) - _push_digest("盘中触发", _digest) - outputs.append(f"📨 进区摘要({len(_zone_entries)}只)→已按INFO策略推送") - - # === 第三步:买入区偏离检测 + 自动重评 === - reassesed_codes = [] - # 先做急跌检测(仅持仓,自选股不推送暴跌告警) - holdings_codes = set() - for d in active: - shares = d.get("shares", 0) - if isinstance(shares, (int, float)): - if shares > 0: - holdings_codes.add(d["code"]) - else: - # 非数值shares(如被错误写入的字符串),兜底处理 - holdings_codes.add(d["code"]) - print(f" [WARN] {d.get('code')} shares为非数值({shares!r}),视为持仓处理", flush=True) - for d in active: - code = d["code"] - # 非持仓跳过 - if code not in holdings_codes: - continue - name = d.get("name", code) - price_info = prices.get(code) - if not price_info: - continue - price, _, change_pct = price_info - if price == 0: - continue - # 单日跌幅>7%告警(不依赖zone边界,盘中急跌即触发) - try: - cp = float(change_pct) if change_pct else 0 - except: - cp = 0 - if cp <= -7: - prev_alert = state.get(code, {}).get("__sharp_decline_triggered", False) - if not prev_alert: - stop_loss = d.get("stop_loss", 0) - sl_note = f" 止损{stop_loss}" if stop_loss else "" - msg = f"🔻 {name}({code}) {price} 暴跌{cp:.1f}%!{sl_note}" - _push_action("急跌告警", msg) - outputs.append(msg) - state.setdefault(code, {})["__sharp_decline_triggered"] = True - state_updated = True - # 立即持久化,防止后续超时导致状态丢失而重复推送 - save_state(state) - elif cp > -5: - # 反弹后清除告警标记,下次再跌还能报 - state.setdefault(code, {}).pop("__sharp_decline_triggered", None) - - for d in active: - code = d["code"] - name = d.get("name", code) - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - # 从 decisions (DB holding_strategies) 中读取 analysis 的买入区 - entry_low = d.get("entry_low", 0) - entry_high = d.get("entry_high", 0) - if not entry_low or not entry_high: - continue - - in_buy_zone = entry_low <= price <= entry_high - prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) - - # 状态变化时才触发 - if in_buy_zone and prev_in_buy_zone == False: - # 重新进入买入区 → 重评确认区间是否仍然有效 - outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") - do_reassess = True - elif not in_buy_zone and prev_in_buy_zone == True: - # 离开买入区 → 立即重评,更新止损/止盈/区间 - outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") - do_reassess = True - else: - do_reassess = False - - if do_reassess and HAS_REASSESS: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_deep_loss = profit_pct < -20 - sentiment = "neutral" - if d.get("tech_snapshot"): - if "bearish" in d["tech_snapshot"]: - sentiment = "bearish" - elif "bullish" in d["tech_snapshot"]: - sentiment = "bullish" - - # 调用技术面驱动重评(非机械百分比) - result = reassess_strategy( - code, name, price, cost, shares, - current_action=d.get("action", ""), - volume_signal="中性", sentiment=sentiment, - ) - outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") - reassesed_codes.append(code) - except Exception as e: - outputs.append(f" ⚠️ 重评失败: {e}") - - # 更新买入区状态 - if "__buy_zone" not in state.get(code, {}): - if code not in state: - state[code] = {} - state[code]["__buy_zone"] = in_buy_zone - state_updated = True - - # 如果有重评过的股票,更新 DB holding_strategies(此前写入 decisions.json,已废弃) - if reassesed_codes and HAS_REASSESS: - # ── 5分钟冷却:regenerate_all 开销太大,不每2分钟跑一次 ── - _regen_marker = "/tmp/price_monitor_regen_at" - _skip_regen = False - try: - if os.path.exists(_regen_marker): - with open(_regen_marker) as _f: - _last_regen = float(_f.read().strip()) - if time.time() - _last_regen < 300: - _skip_regen = True - except: - pass - - if _skip_regen: - outputs.append(f" ⏭ 跳过全量重评(距上次<5min),下次再跑") - else: - try: - from strategy_lifecycle import regenerate_all - r = regenerate_all(stdout=False) - outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") - outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") - try: - with open(_regen_marker, "w") as _f: - _f.write(str(time.time())) - except: - pass - except Exception as e: - outputs.append(f" ⚠️ 全量重评失败: {e}") - - # === 第四步:输出 === - now_str = datetime.now().strftime("%H:%M:%S") - elapsed = time.time() - start - - if outputs: - print(f"\n🔔 {now_str}{label}") - for o in outputs: - print(o) - print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}") - else: - # 无触发时 SILENT(中继不推送) - print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s") - - if state_updated: - save_state(state) - - # 输出耗时 - print(f"⏱{label} {elapsed:.1f}s", flush=True) - - # 取消超时定时器(正常完成) - signal.alarm(0) - - # 清理进程锁 - try: - os.remove("/tmp/price_monitor.lock") - except Exception: - pass - - -def main(): - """每cron触发跑一轮""" - run_once() - - -if __name__ == "__main__": - main() diff --git a/scripts/price_monitor.py.bk b/scripts/price_monitor.py.bk deleted file mode 100644 index 57403e5b..00000000 --- a/scripts/price_monitor.py.bk +++ /dev/null @@ -1,827 +0,0 @@ -#!/usr/bin/env python3 -"""price_monitor.py — 高频价格监控脚本(批量版) -规则:进入区间报一次,离开区间报一次,中间不重复。 -每次运行时一次性刷新所有持仓+自选股的实时价。 -""" -import json -import urllib.request -import os -import sys -import time -from datetime import datetime - -# ── MoFin unified model ────────────────────────────────────────────── -sys.path.insert(0, "/home/hmo/MoFin") -from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct -from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock -from mo_data import read_portfolio, read_decisions, read_watchlist - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" -BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" -STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json") -EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" - -# 策略重评依赖(技术面驱动,非机械百分比) -sys.path.insert(0, "/home/hmo/web-dashboard") -try: - from strategy_lifecycle import reassess_strategy - HAS_REASSESS = True -except ImportError: - HAS_REASSESS = False - -try: - HK_RATE = get_hk_rate() -except Exception: - HK_RATE = 0.87 # ultimate fallback - -# 分支系统与情景检测 -try: - sys.path.insert(0, '/home/hmo/MoFin') - from strategy_tree import detect_scenario, evaluate_branches - HAS_TREE = True -except Exception: - HAS_TREE = False - def detect_scenario(): return {} - def evaluate_branches(*a, **kw): return [] - -# 情景缓存(每次run_once刷新) -_SCENARIO_CACHE = {} -_BRANCH_CACHE = {} # code -> branches list - -UA = "Mozilla/5.0" - -# ── 批量拉取价格 ────────────────────────────────────────────────────────── - -def fetch_all_prices(codes): - """腾讯批量行情API:仅用于A股(沪市/深市) - A股:sh600110 / sz000001 - 港股已迁移至 fetch_hk_eastmoney()(东方财富实时行情) - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - # 只处理A股(6位代码),港股走东方财富 - a_codes = [c for c in codes if len(str(c).strip()) == 6] - if not a_codes: - return {} - - symbols = [] - code_map = {} - for code in a_codes: - code_s = str(code).strip() - if code_s.startswith(('5', '6', '9')): - sym = f"sh{code_s}" - else: - sym = f"sz{code_s}" - symbols.append(sym) - code_map[sym] = code_s - - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 腾讯A股拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - try: - raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw_value.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig_code = code_map.get(sym) - if not orig_code: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig_code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -# ── 港股实时行情(新浪财经批量版,实时,无延迟)───────────────────────────── - -def fetch_hk_sina_batch(codes): - """新浪财经港股批量实时行情 — 一次HTTP请求获取全部港股。 - - 新浪港股API(hq.sinajs.cn)支持批量查询,返回实时数据。 - 对比东财逐股查询(0.2s间隔×17只=3.4s),新浪1次请求搞定。 - - API: https://hq.sinajs.cn/list=hk00700,hk09988 - 格式: hq_str_hk00700="TENCENT,腾讯控股,当前价,昨收,开盘,最高,最低,涨跌额,涨跌幅,..." - - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] - if not hk_codes: - return {} - - symbols = [f"hk{c}" for c in hk_codes] - url = f"https://hq.sinajs.cn/list={','.join(symbols)}" - - try: - # 新浪要求有 Referer,且需绕过系统代理(某些环境下东财/新浪走代理会断连) - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = urllib.request.Request(url, headers={ - "User-Agent": "Mozilla/5.0", - "Referer": "https://finance.sina.com.cn", - }) - with opener.open(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 新浪港股批量拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if "=" not in line: - continue - try: - code = line.split("=", 1)[0].replace("hq_str_hk", "").replace("var ", "").strip() - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split(",") - if len(fields) < 9: - continue - price = float(fields[2]) if fields[2] else 0 - prev_close = float(fields[3]) if fields[3] else 0 - change_amt = float(fields[7]) if fields[7] else 0 - change_pct = fields[8] if fields[8] else "0" - # 新浪 field[2] 可能非实时最新价,用 prev_close + change 计算更准确 - if prev_close > 0 and abs(change_amt) > 0: - price = round(prev_close + change_amt, 2) - change = round(change_amt, 2) - if price > 0: - results[code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -# ── 港股备用通道(东方财富逐股 + 腾讯15min延迟)─────────────────────────── - -def fetch_hk_eastmoney_fallback(codes): - """东方财富港股实时行情(备用通道),逐股查询、间隔1秒避免限流。 - - FTP 说明:港股限流严重,不适合主通道,降级为备用。 - 建议用上面的 fetch_hk_sina_batch() 做主通道。 - - 返回 {code: (price, change, change_pct)} - Fallback: 仍失败时回退到腾讯 qt.gtimg.cn(15分钟延迟) - """ - if not codes: - return {} - - hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] - if not hk_codes: - return {} - - results = {} - - # 东方财富逐股查询,1秒间隔避免限流 - for code in hk_codes: - try: - url = (f"https://push2.eastmoney.com/api/qt/stock/get" - f"?secid=116.{code}" - f"&fields=f43,f170,f60,f57,f58" - f"&fltt=2") - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = urllib.request.Request(url, headers={ - "User-Agent": UA, - "Referer": "https://quote.eastmoney.com/", - }) - with opener.open(req, timeout=5) as r: - resp = json.loads(r.read().decode("utf-8")) - - if resp.get("rc") != 0: - continue - item = resp.get("data", {}) - if not item: - continue - price = float(item.get("f43", 0)) if item.get("f43") else 0 - prev_close = float(item.get("f60", 0)) if item.get("f60") else 0 - change = round(price - prev_close, 2) if prev_close > 0 else 0 - change_pct = str(item.get("f170", "0")) - if price > 0: - results[code] = (price, change, change_pct) - time.sleep(1.0) # 1秒间隔,大幅降低限流概率 - except Exception as e: - print(f" [东财备用 {code}] {e}", file=sys.stderr) - continue - - # Fallback: 腾讯 qt.gtimg.cn(15分钟延迟) - missing = [c for c in hk_codes if c not in results] - if missing: - try: - fallback = _fetch_hk_tencent_fallback(missing) - results.update(fallback) - except Exception: - pass - - return results - - -def _fetch_hk_tencent_fallback(codes): - """腾讯港股行情(15分钟延迟,仅作 fallback)""" - symbols = [f"hk{c}" for c in codes] - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - - code_map = {f"hk{c}": c for c in codes} - results = {} - for line in text.strip().split("\n"): - if "=" not in line: - continue - try: - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig = code_map.get(sym) - if not orig: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig] = (price, change, change_pct) - except (ValueError, IndexError): - continue - return results - - -def refresh_data_prices(): - """一次性刷新portfolio.json和watchlist.json的所有实时价""" - all_codes = set() - - # 收集所有需要拉取的代码 - try: - pf = read_portfolio() - for s in pf.get('holdings', []): - all_codes.add(s['code']) - except Exception: - pf = {"holdings": []} - - try: - wl = read_watchlist() - for s in wl.get('stocks', []): - all_codes.add(s['code']) - except Exception: - wl = {"stocks": []} - - if not all_codes: - return 0 - - # 分批拉取:A股走腾讯(实时) + 港股走新浪批量(实时,无限流) - all_list = list(all_codes) - prices = fetch_all_prices(all_list) # A股(腾讯,实时) - hk_prices = fetch_hk_sina_batch(all_list) # 港股(新浪批量,实时) - # 新浪未覆盖的走备用通道(东财逐股→腾讯15min延迟) - # 港股市场09:30开盘,之前走备用通道会空耗1秒/只且无实时数据 - hk_codes_missing = [c for c in all_list if len(str(c).strip()) <= 5 and c not in hk_prices] - if hk_codes_missing: - # 09:30前港股未开盘,跳过慢速降级通道 - now_h = datetime.now().hour - now_m = datetime.now().minute - if now_h > 9 or (now_h == 9 and now_m >= 30): - fallback = fetch_hk_eastmoney_fallback(hk_codes_missing) - hk_prices.update(fallback) - prices.update(hk_prices) - updated = 0 - - # 保存全量实时价快照(供报告管道消费,确保分析用最新数据) - try: - live = {"updated_at": datetime.now().isoformat(), "prices": {}} - for code in all_codes: - if code in prices: - p, c, chg = prices[code] - live["prices"][code] = {"price": p, "change_pct": chg} - json.dump(live, open("/home/hmo/web-dashboard/data/live_prices.json", "w"), indent=2) - except Exception: - pass - - # 更新portfolio(只在价格变化时写入,避免触发文件变更通知) - changed = False - for s in pf.get('holdings', []): - if s['code'] in prices: - price, _, change_pct = prices[s['code']] - if price > 0: - # 港股:API返回HKD,需转RMB - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) - old = s.get('price') - if old is None: - old = 0 - if abs(old - price) > 0.001: - s['price'] = round(price, 2) - s['change_pct'] = float(change_pct) if change_pct else 0 - updated += 1 - changed = True - if changed: - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - pf['total_mv'] = calc_total_mv(pf.get('holdings', [])) - pf['total_assets'] = calc_total_assets(pf) - pf['position_pct'] = calc_position_pct(pf) - # DB 写入(替代 json.dump,强制币种约束) - try: - conn = get_conn() - write_holdings_batch(conn, pf['holdings']) - write_portfolio_summary(conn, pf) - conn.close() - except Exception as e: - print(f" [DB写入失败] {e}", flush=True) - # 保留 JSON 副本作为冷备 - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - elif pf.get('updated_at'): - try: - last_ts = datetime.strptime(pf['updated_at'], '%Y-%m-%d %H:%M') - if (datetime.now() - last_ts).total_seconds() > 600: - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - except: - pass - - # 更新watchlist(只在价格变化时写入) - changed = False - for s in wl.get('stocks', []): - if s['code'] in prices: - price, _, change_pct = prices[s['code']] - if price > 0: - # 港股:API返回HKD,需转RMB - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) - old = s.get('price') - if old is None: - old = 0 - if abs(old - price) > 0.001: - s['price'] = round(price, 2) - s['change_pct'] = float(change_pct) if change_pct else 0 - updated += 1 - changed = True - if changed: - wl['updated_at'] = datetime.now().isoformat() - # DB 写入(替代 json.dump) - try: - conn = get_conn() - for s in wl.get('stocks', []): - s['currency'] = 'CNY' # 自选股价格统一CNY - write_watchlist_stock(conn, s) - conn.close() - except Exception as e: - print(f" [DB watchlist写入失败] {e}", flush=True) - # 保留 JSON 冷备 - json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) - - # --- 汇总值重算(使用 mo_models 唯一公式)--- - try: - live_market_value = calc_total_mv(pf.get('holdings', [])) - old_mv = pf.get('total_mv', 0) - - if abs(old_mv - live_market_value) > 0.01: - pf['total_mv'] = round(live_market_value, 2) - - pf['total_assets'] = calc_total_assets(pf) - if pf['total_assets'] > 0: - pf['position_pct'] = calc_position_pct(pf) - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - # DB 写入 - try: - conn = get_conn() - write_portfolio_summary(conn, pf) - conn.close() - except Exception as e: - print(f" [DB汇总写入失败] {e}", flush=True) - # JSON 冷备 - json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - except Exception as e: - print(f" [汇总重算失败] {e}", flush=True) - # --- 结束汇总重算 --- - - return updated - - -# ── 分支系统辅助函数 ────────────────────────────────────────────────────── - -def _branch_alert_suffix(code, price, shares=0, cost=0): - """返回分支信息后缀:「 | 情景→动作」""" - if not HAS_TREE or not _SCENARIO_CACHE.get('id'): - return "" - try: - sc_id = _SCENARIO_CACHE['id'] - results = evaluate_branches(code, sc_id, price, shares, cost) - for r in results: - if r.get('applicable'): - _record_branch_trigger(code, r.get('branch_id',''), price) - branch_action = r.get('action_type', r.get('action', 'hold')) - return f" | {sc_id}→{branch_action}" - except Exception: - pass - return "" - - -def _record_branch_trigger(code, branch_id, price): - """记录分支触发事件(自成长:trigger_count+1)""" - try: - raw = read_decisions() - for d in raw.get('decisions', []): - if d.get('code') == code and d.get('strategy_tree',{}).get('branches'): - for b in d['strategy_tree']['branches']: - if b['id'] == branch_id: - b.setdefault('trigger_count', 0) - b['trigger_count'] += 1 - b['last_trigger_price'] = round(price, 2) - b['last_triggered'] = datetime.now().isoformat() - break - json.dump(raw, open(DECISIONS_PATH, 'w'), ensure_ascii=False, indent=2) - except Exception: - pass - - -# ── 区间偏离检测 ────────────────────────────────────────────────────────── - -def load_state(): - try: - with open(STATE_PATH) as f: - return json.load(f) - except: - return {} - -def save_state(state): - os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) - with open(STATE_PATH, 'w') as f: - json.dump(state, f, ensure_ascii=False, indent=2) - -def load_breaches(): - try: - with open(BREACH_PATH) as f: - return json.load(f) - except: - return {} - -def save_breaches(data): - os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True) - with open(BREACH_PATH, 'w') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def load_events(): - try: - with open(EVENTS_PATH) as f: - return json.load(f) - except: - return {"events": []} - - -def save_events(events): - os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True) - with open(EVENTS_PATH, 'w') as f: - json.dump(events, f, ensure_ascii=False, indent=2) - - -def record_event(code, name, event_type, price, trigger_value, event_label=""): - """记录一次价格触发事件到 price_events.json + SQLite""" - events = load_events() - now = datetime.now().isoformat() - events["events"].append({ - "code": code, - "name": name, - "event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone - "price": round(price, 2), - "trigger_value": trigger_value, - "event_label": event_label, - "timestamp": now, - "date": datetime.now().strftime("%Y-%m-%d"), - }) - # 保留最近10000条 - events["events"] = events["events"][-10000:] - save_events(events) - - # ── SQLite 双写 ── - try: - from mofin_db import get_conn, init_all_tables, write_price_event - conn = get_conn() - init_all_tables(conn) - write_price_event(conn, code, name, event_type, price, trigger_value, event_label) - conn.close() - except Exception: - pass # SQLite 写入失败不影响主流程 - - -def get_trigger_zones(d): - """返回该decision所有可监控的区间列表,从顶层字段读取""" - zones = [] - is_holding = d.get('shares', 0) > 0 - # 买入区间(自选和持仓都监控) - el = d.get("entry_low", 0) - eh = d.get("entry_high", 0) - if el and eh and float(el) > 0 and float(eh) > 0: - try: - zones.append(("entry_zone", "买入区间", float(el), float(eh))) - except: - pass - # 止损+止盈(只有持仓才监控,自选无意义) - if is_holding: - sl = d.get("stop_loss", 0) - if sl and float(sl) > 0: - try: - zones.append(("stop_loss", "止损", 0, float(sl))) - except: - pass - tp = d.get("take_profit", 0) - if tp and float(tp) > 0: - try: - zones.append(("take_profit_zone", "止盈区间", 0, float(tp))) - except: - pass - return zones - - -def run_once(round_label=""): - """执行一轮完整的监控流程""" - global _SCENARIO_CACHE, _BRANCH_CACHE - label = f" [{round_label}]" if round_label else "" - start = time.time() - - # 刷新情景与分支缓存(每轮更新) - _SCENARIO_CACHE = detect_scenario() if HAS_TREE else {} - _BRANCH_CACHE = {} - try: - raw = read_decisions() - for d in raw.get('decisions', []): - tree = d.get('strategy_tree', {}) - if tree and tree.get('branches'): - _BRANCH_CACHE[d['code']] = tree['branches'] - except Exception: - pass - - # === 第一步:一次性刷新所有价格 === - refreshed = refresh_data_prices() - - # === 第二步:检查触发条件 === - try: - with open(DECISIONS_PATH) as f: - dec = json.load(f) - except: - print(f"❌{label} 无法读取decisions.json", file=sys.stderr) - return - - active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")] - state = load_state() - outputs = [] - state_updated = False - reassesed_codes = [] # 止损触发和离/进买入区都记入此列表 - - # 收集所有需要检查的代码 - check_codes = set() - for d in active: - if get_trigger_zones(d): - check_codes.add(d["code"]) - - # 批量拉取这些股票的价格 - prices = fetch_all_prices(list(check_codes)) - - for d in active: - code = d["code"] - - zones = get_trigger_zones(d) - if not zones: - continue - - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - name = d.get("name", code) - if code not in state: - state[code] = {} - - for key, label, lo, hi in zones: - in_zone = lo <= price <= hi - prev_in_zone = state[code].get(key, None) - - if in_zone and prev_in_zone != True: - if key == "stop_loss": - branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) - outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!{branch_sfx}") - record_event(code, name, "stop_loss", price, str(hi)) - # --- 止损触发 → 立即重评 + 操作建议 --- - if HAS_REASSESS: - try: - cost = d.get('cost', 0) or 0 - shares = d.get('shares', 0) or 0 - profit_pct = (price - cost) / cost * 100 if cost else 0 - sentiment = "neutral" - if d.get("tech_snapshot"): - if "bearish" in d["tech_snapshot"]: - sentiment = "bearish" - elif "bullish" in d["tech_snapshot"]: - sentiment = "bullish" - result = reassess_strategy( - code, name, price, cost, shares, - current_action=d.get("action", ""), - volume_signal="中性", sentiment=sentiment, - ) - # 生成操作建议 - new_sl = result.get('stop_loss', 0) - if price < hi: # 跌破止损 → 建议卖出 - advice = "建议止损卖出" - elif new_sl > 0 and price > new_sl: - advice = f"建议观察, 设新止损{new_sl:.2f}" - else: - advice = "建议持有观察" - outputs.append(f" 📊 {advice} | 新损{result['stop_loss']} 盈{result['take_profit']} RR={result['rr_ratio']}") - reassesed_codes.append(code) - except Exception as e: - outputs.append(f" ⚠️ 止损重评失败: {e}") - else: - extra = "" - if "_price" in key: - batch_shares = d.get(key.replace("_price", "_shares"), "") - action = d.get(key.replace("_price", "_action"), "") - if batch_shares: - extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股" - elif key in ("take_profit_zone",): - act = d.get("take_profit_action", "") - if act: - extra = f"({act})" - branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) - outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}{branch_sfx}") - record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label) - state[code][key] = True - state_updated = True - - elif not in_zone and prev_in_zone == True: - if key != "stop_loss": - outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}") - state[code][key] = False - state_updated = True - - # === 第三步:买入区偏离检测 + 自动重评 === - for d in active: - code = d["code"] - name = d.get("name", code) - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - # 从 decisions.json 中读取 analysis 的买入区 - entry_low = d.get("entry_low", 0) - entry_high = d.get("entry_high", 0) - if not entry_low or not entry_high: - continue - - in_buy_zone = entry_low <= price <= entry_high - prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) - - # 状态变化时才触发:True→False离区 或 False→True进区 - # [2026-07-01 fix] prev_in_buy_zone is None(新加自选首次检测) - # 也要触发——否则新自选全程不走重评,timing_signal卡在初始值 - if in_buy_zone and (prev_in_buy_zone == False or prev_in_buy_zone is None): - # 进入买入区 → 触发技术面重评,更新止损/止盈/信号 - outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") - do_reassess = True - elif not in_buy_zone and prev_in_buy_zone == True: - # 离开买入区 → 立即重评,更新止损/止盈/区间 - outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") - do_reassess = True - else: - do_reassess = False - - if do_reassess and HAS_REASSESS: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_deep_loss = profit_pct < -20 - sentiment = "neutral" - if d.get("tech_snapshot"): - if "bearish" in d["tech_snapshot"]: - sentiment = "bearish" - elif "bullish" in d["tech_snapshot"]: - sentiment = "bullish" - - # 调用技术面驱动重评(非机械百分比) - result = reassess_strategy( - code, name, price, cost, shares, - current_action=d.get("action", ""), - volume_signal="中性", sentiment=sentiment, - ) - outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") - reassesed_codes.append(code) - except Exception as e: - outputs.append(f" ⚠️ 重评失败: {e}") - - # 更新买入区状态 - if "__buy_zone" not in state.get(code, {}): - if code not in state: - state[code] = {} - state[code]["__buy_zone"] = in_buy_zone - state_updated = True - - # 如果有重评过的股票,更新 decisions.json - if reassesed_codes and HAS_REASSESS: - try: - # 重新 regenerate_all 只针对受影响的股票效率太低 - # 直接全量重评(regenerate_all 内部会批量拉价格、做技术分析) - from strategy_lifecycle import regenerate_all - r = regenerate_all(stdout=False) - outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") - outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") - except Exception as e: - outputs.append(f" ⚠️ 全量重评失败: {e}") - - # === 3.5 资金流异常检测(2026-06-27 新增)=== - try: - cf = json.load(open("/home/hmo/web-dashboard/data/capital_flow_cache.json")) - # 检查所有 active decision 中的资金流异常 - for d in active: - code = d["code"] - stock_cf = cf.get("stocks", {}).get(code, {}) - analysis = stock_cf.get("analysis", {}) - alerts = analysis.get("alerts", []) - if alerts: - name = d.get("name", code) - for a in alerts: - outputs.append(f" 💰 {name}({code}) {a}") - except Exception: - pass - - # === 第四步:情景变化检测 + 输出 → 直接推XMPP === - now_str = datetime.now().strftime("%H:%M:%S") - elapsed = time.time() - start - - # 情景变化检测(跨轮对比) - if HAS_TREE and _SCENARIO_CACHE.get('id'): - prev_scenario = state.get('_system', {}).get('last_scenario', '') - curr_scenario = _SCENARIO_CACHE['id'] - if prev_scenario and curr_scenario != prev_scenario: - combo = _SCENARIO_CACHE.get('combo_action', '') - outputs.insert(0, f"🌀 情景切换: {prev_scenario}→{curr_scenario} | {combo}") - if outputs: - state.setdefault('_system', {})['last_scenario'] = curr_scenario - state_updated = True - elif not prev_scenario: - state.setdefault('_system', {})['last_scenario'] = curr_scenario - state_updated = True - - if outputs: - # 简短一行一个触发 - for o in outputs: - print(o) - # 推送XMPP(只推关键事件:止损跌破+情景切换+资金流异动,不推买入区进出/重评等操作细节) - critical = [o for o in outputs if o.startswith(("⚠️", "🌀", "💰"))] - if critical: - try: - body = "\n".join([f"{now_str}"] + critical) - payload = json.dumps({ - "to": "hmo@yoin.fun", "body": body, "type": "chat", - }).encode("utf-8") - req = urllib.request.Request( - "http://127.0.0.1:5805/", data=payload, - headers={"Content-Type": "application/json"}, - ) - urllib.request.urlopen(req, timeout=5) - except Exception: - pass - # else: SILENT — 无触发,无输出,不推 - - if state_updated: - save_state(state) - - -def main(): - """每cron触发跑一轮""" - run_once() - - -if __name__ == "__main__": - main() diff --git a/scripts/server.py b/scripts/server.py deleted file mode 100644 index 8eb6f143..00000000 --- a/scripts/server.py +++ /dev/null @@ -1,1071 +0,0 @@ -#!/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 - -from flask import Flask, jsonify, send_from_directory, request - -# 提示词管理模块 -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="") - -DATA_DIR = Path(__file__).parent.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/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(): - """自选列表""" - try: - from mofin_db import get_conn, query_watchlist - conn = get_conn() - stocks = query_watchlist(conn) - conn.close() - if stocks: - return jsonify({"stocks": stocks}) - except Exception: - pass - return jsonify({"error": "数据库查询失败"}), 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/reports") -def api_reports(): - """历史报告列表""" - reports_dir = DATA_DIR / "reports" - reports = [] - if reports_dir.exists(): - for f in sorted(reports_dir.iterdir(), reverse=True)[:100]: - if f.suffix == ".json": - 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", ""), - }) - return jsonify(reports) - - -@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): - """个股详情 + 操作建议历史""" - stock_data = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) - 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/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_stocks 表(DB) - 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(), - }) - - -# 注册提示词管理路由 -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/scripts/verify_reassess_pipeline.py b/scripts/verify_reassess_pipeline.py deleted file mode 100644 index 0e99fd4d..00000000 --- a/scripts/verify_reassess_pipeline.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -"""verify_reassess_pipeline.py — 重评推送管道审计 + 全局cron失败监控 - -检查: -1. price_monitor 每2分正常跑 -2. zone breach检测正常 -3. holding_strategies有数据 -4. XMPP bridge在线 -5. reassess模块可导入 -6. 【新增】所有关键cron job状态(是否有failed) - -输出:正常时 [SILENT],有异常时推XMPP -""" -import json, os, sys, subprocess, sqlite3 -from pathlib import Path -from datetime import datetime, timedelta -from urllib.request import Request, urlopen - -BASE = Path(__file__).parent.parent -sys.path.insert(0, str(BASE)) -sys.path.insert(0, "/home/hmo/MoFin") - -XMPP_BRIDGE = "http://127.0.0.1:5805/" -XMPP_USER = "hmo@yoin.fun" - -def xmpp_push(text): - try: - payload = json.dumps({"to": XMPP_USER, "body": text, "type": "chat"}).encode() - req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) - urlopen(req, timeout=5) - except Exception as e: - print(f"[XMPP推送失败] {e}", file=sys.stderr) - -def scan_cron_failures(): - """扫描两个cron jobs.json看是否有failed状态的关键job""" - failures = [] - jobs_files = [ - "/home/hmo/.hermes/cron/jobs.json", - "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json", - ] - for jf in jobs_files: - try: - data = json.load(open(jf)) - for job in data.get("jobs", []): - jid = job.get("id", "?") - name = job.get("name", "") or jid[:12] - status = job.get("last_status", "") - enabled = job.get("enabled", True) - if not enabled: - continue - # 关键job:价格监控、重评、盘前中监控 - key_job = any(kw in name.lower() for kw in [ - "price_monitor", "monitor", "盘前中", "reassess", - "重评", "自选买入", "stale_push", "管道审计", - "宏观风险", "策略时效" - ]) - if not key_job: - continue - if status == "failed": - last_run = job.get("last_run_at", "?") - failures.append(f" ❌ {name} ({jid[:8]}) last_run={last_run}") - except Exception: - pass - return failures - -def check_cron_jobs(): - """另法:直接查cron数据库""" - issues = [] - for db_path in [ - BASE / "cron" / "cron.db", - Path("/home/hmo/.hermes/cron/cron.db"), - ]: - if not db_path.exists(): - continue - try: - c = sqlite3.connect(str(db_path)) - for row in c.execute(""" - SELECT id, name, last_status, last_run_at, enabled - FROM cron_jobs WHERE enabled=1 - ORDER BY last_run_at DESC - """).fetchall(): - jid, name, status, last_run, enabled = row - if status == "failed": - issues.append(f" ❌ {name}({jid[:8]}) last_run={last_run}") - c.close() - except Exception: - pass - return issues - -def run(): - ok = True - alerts = [] - checks = [] - - # 1. price_monitor 最近运行时间 - try: - conn = None - last_err = None - # malformed 可能是 I/O 风暴下的瞬态 WAL 损坏(2026-07-21 事件): - # checkpoint 后自愈。重试一次再告警,避免误报轰炸 - for _attempt in range(2): - try: - conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") - conn.execute("SELECT 1 FROM live_prices LIMIT 1").fetchone() - break - except Exception as e: - last_err = e - import time as _t - _t.sleep(3) - if conn is None: - raise last_err - lp = conn.execute("SELECT MAX(updated_at) FROM live_prices").fetchone()[0] - if lp: - lp_dt = datetime.fromisoformat(lp) if isinstance(lp, str) else lp - if hasattr(lp_dt, 'tzinfo') and lp_dt.tzinfo is None: - if isinstance(lp, str) and '+' not in lp: - lp_dt = lp_dt.replace(tzinfo=None) - mins_ago = (datetime.now() - lp_dt).total_seconds() / 60 - status = "ok" if mins_ago < 10 else "warn" - if mins_ago > 15: - status = "fail" - ok = False - alerts.append(f"price_monitor {mins_ago:.0f}分未更新") - checks.append({"check":"price_monitor","status":status,"detail":f"最后更新{mins_ago:.0f}分前"}) - else: - checks.append({"check":"price_monitor","status":"warn","detail":"live_prices无数据"}) - except Exception as e: - checks.append({"check":"price_monitor","status":"fail","detail":str(e)}) - ok = False - alerts.append(f"price_monitor异常: {e}") - - # 2. 策略评估活动(reassess_with_context写strategy_evaluations,不是holding_strategies) - try: - today_se = conn.execute("SELECT COUNT(*) FROM strategy_evaluations WHERE date(created_at)=date('now')").fetchone()[0] - total_se = conn.execute("SELECT COUNT(*) FROM strategy_evaluations").fetchone()[0] - # 也尝试查holding_strategies(如果存在并有数据) - hs_exists = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='holding_strategies'").fetchone()[0] - hs = 0 - if hs_exists: - hs = conn.execute("SELECT COUNT(*) FROM holding_strategies").fetchone()[0] - detail = f"今日{today_se}次评估, 累计{total_se}条" - if hs > 0: - detail += f", holding_strategies{hs}条" - checks.append({"check":"strategy_activity","status":"ok","detail":detail}) - except Exception as e: - checks.append({"check":"strategies","status":"fail","detail":str(e)}) - ok = False - - # 3. XMPP bridge 是否在线(TCP端口检测,不发消息到Dad) - try: - import socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(3) - result = sock.connect_ex(("127.0.0.1", 5805)) - sock.close() - bridge_ok = (result == 0) - if not bridge_ok: - ok = False - alerts.append("XMPP bridge(5805)端口无响应") - checks.append({"check":"xmpp_bridge","status":"ok" if bridge_ok else "fail","detail":"在线" if bridge_ok else "端口无响应"}) - except Exception as e: - checks.append({"check":"xmpp_bridge","status":"fail","detail":str(e)}) - ok = False - alerts.append(f"XMPP bridge不可达: {e}") - - # 4. reassess模块可导入 - try: - from strategy_lifecycle import reassess_with_context - checks.append({"check":"reassess_module","status":"ok","detail":"可导入"}) - except Exception as e: - checks.append({"check":"reassess_module","status":"fail","detail":str(e)}) - ok = False - alerts.append(f"reassess模块导入失败: {e}") - - # 5. cron job失败检测 - cron_issues = scan_cron_failures() + check_cron_jobs() - if cron_issues: - ok = False - alerts.append(f"{len(cron_issues)}个cron job失败") - for issue in cron_issues[:5]: - alerts.append(issue) - checks.append({"check":"cron_jobs","status":"fail","detail":"; ".join(cron_issues[:3])}) - else: - checks.append({"check":"cron_jobs","status":"ok","detail":"所有关键job正常"}) - - conn.close() - - # 输出 - result = { - "pipeline": "ok" if ok else "degraded", - "checked_at": datetime.now().isoformat(), - "checks": checks, - "alerts": alerts - } - - if ok: - print("[SILENT]") - else: - msg = "🔴 重评管道异常:\n" + "\n".join(alerts) - print(json.dumps(result, ensure_ascii=False, indent=2)) - # 有异常时主动推XMPP(取代静默) - xmpp_push(msg) - print(f"\n已推送XMPP: {len(alerts)}条告警", file=sys.stderr) - -if __name__ == "__main__": - run()