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()