diff --git a/deploy/profile-scripts/mo_config.py b/deploy/profile-scripts/mo_config.py index 5ab6896e..10c9ba6e 100644 --- a/deploy/profile-scripts/mo_config.py +++ b/deploy/profile-scripts/mo_config.py @@ -65,11 +65,6 @@ class MoConfig: warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2) return Path() - @property - def price_events_path(self) -> Path: - """⚠️ 已废弃!数据在 mofin.db price_events 表。""" - return self.data_dir / "price_events.json" - @property def live_prices_path(self) -> Path: """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。""" diff --git a/deploy/profile-scripts/mofin_db.py b/deploy/profile-scripts/mofin_db.py index 20b313b3..9cdec1e9 100644 --- a/deploy/profile-scripts/mofin_db.py +++ b/deploy/profile-scripts/mofin_db.py @@ -21,7 +21,7 @@ from datetime import datetime from pathlib import Path from typing import Optional, Callable -DATA_DIR = Path(__file__).parent / "data" +DATA_DIR = Path("/home/hmo/MoFin/data") # 绝对路径:全系统唯一权威数据目录 DB_PATH = DATA_DIR / "mofin.db" # ═══════════════════════════════════════════════════════════ diff --git a/deploy/profile-scripts/mofin_health.py b/deploy/profile-scripts/mofin_health.py index 18dba3a2..efdfaf8e 100644 --- a/deploy/profile-scripts/mofin_health.py +++ b/deploy/profile-scripts/mofin_health.py @@ -934,29 +934,6 @@ def build_report(): except Exception: pass - # price_events 特殊处理:活跃存储是 price_events.json(price_monitor 实时写入), - # DB 表是旧遗留。读 JSON 最后一条事件的时间。 - try: - _pe_path = Path("/home/hmo/web-dashboard/data/price_events.json") - if _pe_path.exists(): - _pe = json.loads(_pe_path.read_text(encoding="utf-8")) - _items = _pe if isinstance(_pe, list) else _pe.get("events", []) - if _items: - _last = _items[-1] - _ts = _last.get("timestamp") or _last.get("created_at") or "" - _dt = datetime.fromisoformat(str(_ts).replace("Z", "")) - _age = (now - _dt).total_seconds() / 3600 - # 替换 db_freshness 里 price_events 那条(DB 旧数据) - db_freshness = [f for f in db_freshness if f["table"] != "price_events"] - db_freshness.append({ - "table": "price_events.json", "label": "价格事件", - "last_record": _dt.strftime("%m-%d %H:%M"), - "age_hours": round(_age, 1), - "warn": _age > 24, - }) - except Exception: - pass - # ── Tab 3: 流程/cron映射 ── pipelines = [] for j in sorted(cron_jobs, key=lambda x: x.get("name","")): diff --git a/deploy/profile-scripts/price_monitor.py b/deploy/profile-scripts/price_monitor.py index 7d7fa0f6..f28e7896 100644 --- a/deploy/profile-scripts/price_monitor.py +++ b/deploy/profile-scripts/price_monitor.py @@ -12,7 +12,6 @@ from mo_data import read_decisions BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json" -EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" # DB 模块(同步实时价到 mofin.db) sys.path.insert(0, "/home/hmo/MoFin") @@ -319,27 +318,14 @@ def save_breaches(data): 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=""): - """记录一次价格触发事件 — 双写:DB price_events 表(权威)+ price_events.json(遗留读取方兼容)""" - now = datetime.now().isoformat() - date_str = datetime.now().strftime("%Y-%m-%d") + """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。 + + price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股) + 先注册再写事件,否则 FK 失败事件丢失。 + """ + now = datetime.now().isoformat() - # 1. 写 DB 表(权威存储)。price_events.code 有 FK -> stocks(code), - # 未注册的股票(新候选/港股)会先注册再写事件,否则 FK 失败事件丢失。 if HAS_DB: try: from mofin_db import get_conn, write_price_event @@ -355,22 +341,6 @@ def record_event(code, name, event_type, price, trigger_value, event_label=""): except Exception as e: print(f"[price_events DB写入失败] {e}", file=sys.stderr) - # 2. 写 JSON(遗留读取方:mo_config/strategy_feedback/system_health_check 还在读) - events = load_events() - 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": date_str, - }) - # 保留最近10000条 - events["events"] = events["events"][-10000:] - save_events(events) - def get_trigger_zones(trigger): """返回该trigger所有可监控的区间列表,跳过已执行的batch""" diff --git a/deploy/profile-scripts/strategy_feedback.py b/deploy/profile-scripts/strategy_feedback.py index ae8dc9f2..dc6ccf4f 100644 --- a/deploy/profile-scripts/strategy_feedback.py +++ b/deploy/profile-scripts/strategy_feedback.py @@ -18,7 +18,6 @@ from mo_data import read_decisions DATA_DIR = Path(__file__).parent.parent / "data" ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" -EVENTS_PATH = DATA_DIR / "price_events.json" FEEDBACK_PATH = DATA_DIR / "strategy_feedback.json" @@ -174,15 +173,16 @@ def generate_adjustment(decision, phase_check, accuracy_trend): def run(): decisions = read_decisions() - # 优先从 SQLite 读取价格事件 + # 价格事件:只从 DB price_events 表读(JSON 已退役) try: from mofin_db import get_conn, query_price_events conn = get_conn() pe_rows = query_price_events(conn, limit=50000) conn.close() events = {"events": pe_rows} - except Exception: - events = load_json(EVENTS_PATH, {"events": []}) + except Exception as e: + print(f"[strategy_feedback] DB价格事件读取失败: {e}", file=sys.stderr) + events = {"events": []} accuracy_stats = load_json(ACCURACY_PATH, {}) accuracy_trend = compute_accuracy_trend(accuracy_stats) diff --git a/deploy/profile-scripts/system_health_check.py b/deploy/profile-scripts/system_health_check.py index 8a8207fa..e5716543 100644 --- a/deploy/profile-scripts/system_health_check.py +++ b/deploy/profile-scripts/system_health_check.py @@ -9,7 +9,6 @@ from datetime import datetime, timedelta from pathlib import Path DATA_DIR = Path("/home/hmo/web-dashboard/data") -EVENTS_PATH = DATA_DIR / "price_events.json" EVALUATION_PATH = DATA_DIR / "evaluation.json" ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" CRON_JOBS = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" @@ -104,10 +103,9 @@ def run(): lines.append(check(False, "MoFin DB 数据读取失败")) warn_count += 3 - # 仍为 JSON 文件的检查 + # 仍为 JSON 文件的检查(price_events 已迁移到 DB 表,不在此列) files = { "market.json": DATA_DIR / "market.json", - "price_events.json": EVENTS_PATH, "evaluation.json": EVALUATION_PATH, "accuracy_stats.json": ACCURACY_PATH, } @@ -121,7 +119,7 @@ def run(): else: ok_count += 1 - # 4. 价格事件统计 + # 4. 价格事件统计(只读 DB price_events 表,JSON 已退役) lines.append("") lines.append("【价格事件】") try: @@ -130,10 +128,12 @@ def run(): ev_list = query_price_events(conn, limit=50000) today_events = query_price_events_by_date(conn, now.strftime("%Y-%m-%d")) conn.close() - except Exception: - events = load_json(EVENTS_PATH, {"events": []}) - ev_list = events.get("events", []) - today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")] + except Exception as e: + ev_list = [] + today_events = [] + lines.append(check(False, f"DB价格事件读取失败: {str(e)[:60]}")) + issues.append(f"price_events DB读取失败: {str(e)[:60]}") + warn_count += 1 lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}条")) lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}条")) if len(ev_list) == 0: diff --git a/mo_config.py b/mo_config.py index ca825d21..10c9ba6e 100644 --- a/mo_config.py +++ b/mo_config.py @@ -10,7 +10,7 @@ mo_config.py — MoFin 统一配置管理(单例模式) 用法: from mo_config import config - config.data_dir / "somedata.json" + from mo_data import read_portfolio; data = read_portfolio() """ import os @@ -28,7 +28,7 @@ class MoConfig: # 项目根目录 project_dir: Path = field(default_factory=lambda: Path(__file__).parent.resolve()) - # 数据目录(SQLite 为主) + # 数据目录(mofin.db 等,所有数据只从 DB 读写) data_dir: Path = field(default_factory=lambda: Path( os.environ.get("MOFIN_DATA_DIR", "/home/hmo/web-dashboard/data") )) @@ -46,24 +46,25 @@ class MoConfig: @property def portfolio_path(self) -> Path: - """⚠️ 已废弃!数据在 mofin.db holdings + portfolio_summary 表。""" - return self.data_dir / "portfolio.json" + """⚠️ DEPRECATED: 数据已迁至 mofin.db holdings + portfolio_summary 表。""" + import warnings + warnings.warn("portfolio_path is deprecated — use mo_data.read_portfolio() for DB data", DeprecationWarning, stacklevel=2) + return Path() @property def decisions_path(self) -> Path: - """⚠️ 已废弃!数据在 mofin.db holding_strategies 表。""" - return self.data_dir / "decisions.json" + """⚠️ DEPRECATED: 数据已迁至 mofin.db holding_strategies 表。""" + import warnings + warnings.warn("decisions_path is deprecated — use mo_data.read_decisions() for DB data", DeprecationWarning, stacklevel=2) + return Path() @property def watchlist_path(self) -> Path: - """⚠️ 已废弃!数据在 mofin.db watchlist_stocks 表。""" - return self.data_dir / "watchlist.json" + """⚠️ DEPRECATED: 数据已迁至 mofin.db watchlist_stocks 表。""" + import warnings + warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2) + return Path() - @property - def price_events_path(self) -> Path: - """⚠️ 已废弃!数据在 mofin.db price_events 表。""" - return self.data_dir / "price_events.json" - @property def live_prices_path(self) -> Path: """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。""" @@ -148,9 +149,11 @@ class MoConfig: if not self.data_dir.exists(): issues.append(f"数据目录不存在: {self.data_dir}") - # 检查 DB 文件(数据源) - if not self._get_db_path().exists(): - issues.append(f"mofin.db 数据库不存在: {self._get_db_path()}") + if not self.portfolio_path.exists(): + issues.append(f"portfolio_path 不存在(已废弃): {self.portfolio_path}") + + if not self.decisions_path.exists(): + issues.append(f"decisions_path 不存在(已废弃): {self.decisions_path}") return issues @@ -208,8 +211,8 @@ def ensure_dirs(): get_config().ensure_dirs() -# ── 向后兼容:导出常用路径常量 ────────────────────────────────────── -# 让旧代码可以通过熟悉的变量名访问路径 +# ── 向后兼容:导出已废弃的路由常量 ────────────────────────────────── +# PORTFOLIO_PATH / DECISIONS_PATH / WATCHLIST_PATH 均已废弃(数据在 DB)。 def _lazy(attr): """懒加载属性,首次访问时从 config 获取""" diff --git a/mofin_db.py b/mofin_db.py index 20b313b3..9cdec1e9 100644 --- a/mofin_db.py +++ b/mofin_db.py @@ -21,7 +21,7 @@ from datetime import datetime from pathlib import Path from typing import Optional, Callable -DATA_DIR = Path(__file__).parent / "data" +DATA_DIR = Path("/home/hmo/MoFin/data") # 绝对路径:全系统唯一权威数据目录 DB_PATH = DATA_DIR / "mofin.db" # ═══════════════════════════════════════════════════════════ diff --git a/price_monitor.py b/price_monitor.py index 25765e67..f28e7896 100644 --- a/price_monitor.py +++ b/price_monitor.py @@ -3,29 +3,21 @@ 规则:进入区间报一次,离开区间报一次,中间不重复。 每次运行时一次性刷新所有持仓+自选股的实时价。 """ -import json import urllib.request -import os -import sys -import time +import os, sys, time, json import sqlite3 from datetime import datetime -# ⚠️ 以下常量已废弃:数据在 mofin.db 的 holding_strategies / holdings / watchlist_stocks 表 -# 保留仅防止 import 报错,新代码勿用 -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" +from mo_data import read_decisions + 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" +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 - from mo_data import read_decisions HAS_DB = True except ImportError: HAS_DB = False @@ -33,13 +25,32 @@ except ImportError: # 策略重评依赖(技术面驱动,非机械百分比) sys.path.insert(0, "/home/hmo/web-dashboard") try: - from strategy_lifecycle import reassess_strategy + 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): @@ -113,6 +124,8 @@ def refresh_data_prices(): 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) @@ -185,20 +198,11 @@ def refresh_data_prices(): # ── 写 portfolio_summary ── mv = calc_total_mv(db_holdings) - # 从cash_log读取最新verified现金(Dad确认的才是权威),不读portfolio_summary - latest = conn.execute( - 'SELECT cash_after, frozen_after FROM cash_log ' - 'WHERE verified=1 ORDER BY id DESC LIMIT 1' + existing = conn.execute( + 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' ).fetchone() - if latest: - db_cash = latest['cash_after'] or 0.0 - db_frozen = latest['frozen_after'] or 0.0 - else: - 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 + 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(""" @@ -227,6 +231,16 @@ def refresh_data_prices(): "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() @@ -263,7 +277,9 @@ def refresh_data_prices(): 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)") @@ -302,37 +318,28 @@ def save_breaches(data): 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""" - events = load_events() + """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。 + + price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股) + 先注册再写事件,否则 FK 失败事件丢失。 + """ 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) + + 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): @@ -368,25 +375,80 @@ def get_trigger_zones(trigger): 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() - # === 第二步:检查触发条件(纯DB,不读JSON) === + # === 第二步:检查触发条件 === try: dec = read_decisions() - except Exception as e: - print(f"❌{label} 无法从DB读取决策数据: {e}", file=sys.stderr) + 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() @@ -411,7 +473,7 @@ def run_once(round_label=""): price_info = prices.get(code) if not price_info: continue - price, _ = price_info + price, _, _ = price_info if price == 0: continue @@ -419,6 +481,9 @@ def run_once(round_label=""): 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) @@ -427,6 +492,30 @@ def run_once(round_label=""): 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: @@ -440,6 +529,36 @@ def run_once(round_label=""): 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 @@ -451,17 +570,52 @@ def run_once(round_label=""): # === 第三步:买入区偏离检测 + 自动重评 === 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 + price, _, _ = price_info if price == 0: continue - # 从 DB holding_strategies 读取买入区 + # 从 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: @@ -513,17 +667,35 @@ def run_once(round_label=""): state[code]["__buy_zone"] = in_buy_zone state_updated = True - # 如果有重评过的股票,更新 decisions.json + # 如果有重评过的股票,更新 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: - # 重新 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}") + 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") @@ -544,6 +716,12 @@ def run_once(round_label=""): # 输出耗时 print(f"⏱{label} {elapsed:.1f}s", flush=True) + # 清理进程锁 + try: + os.remove("/tmp/price_monitor.lock") + except Exception: + pass + def main(): """每cron触发跑一轮""" diff --git a/scripts/check_3_dbs.py b/scripts/check_3_dbs.py new file mode 100644 index 00000000..cbaf1513 --- /dev/null +++ b/scripts/check_3_dbs.py @@ -0,0 +1,24 @@ +import sqlite3 + +for label, path in [('canonical', '/home/hmo/MoFin/data/mofin.db'), + ('profile-local', '/home/hmo/.hermes/profiles/position-analyst/scripts/data/mofin.db'), + ('web-dashboard', '/home/hmo/web-dashboard/data/mofin.db')]: + try: + c = sqlite3.connect(path, timeout=5) + tables = [r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table'")] + print(f'=== {label}: {path}') + for t in ['price_events', 'holdings', 'live_prices', 'holding_strategies', 'market_snapshots']: + if t in tables: + cnt = c.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0] + try: + col = 'created_at' if t in ('price_events', 'market_snapshots') else 'updated_at' + last = c.execute(f"SELECT MAX({col}) FROM {t}").fetchone()[0] + except Exception: + last = '?' + print(f' {t}: {cnt} rows, last={last}') + else: + print(f' {t}: MISSING') + c.close() + except Exception as e: + print(f'{label}: ERROR {e}') + print() \ No newline at end of file diff --git a/scripts/mo_config.py b/scripts/mo_config.py index 5ab6896e..10c9ba6e 100644 --- a/scripts/mo_config.py +++ b/scripts/mo_config.py @@ -65,11 +65,6 @@ class MoConfig: warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2) return Path() - @property - def price_events_path(self) -> Path: - """⚠️ 已废弃!数据在 mofin.db price_events 表。""" - return self.data_dir / "price_events.json" - @property def live_prices_path(self) -> Path: """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。""" diff --git a/scripts/mofin_db.py b/scripts/mofin_db.py index 20b313b3..9cdec1e9 100644 --- a/scripts/mofin_db.py +++ b/scripts/mofin_db.py @@ -21,7 +21,7 @@ from datetime import datetime from pathlib import Path from typing import Optional, Callable -DATA_DIR = Path(__file__).parent / "data" +DATA_DIR = Path("/home/hmo/MoFin/data") # 绝对路径:全系统唯一权威数据目录 DB_PATH = DATA_DIR / "mofin.db" # ═══════════════════════════════════════════════════════════ diff --git a/scripts/notify_user3.py b/scripts/notify_user3.py new file mode 100644 index 00000000..3f17955b --- /dev/null +++ b/scripts/notify_user3.py @@ -0,0 +1,25 @@ +import json, urllib.request + +msg = """[笑笑] 补充:price_events 已统一到 DB + +你问得对——"不是都全 DB 了吗",之前确实不一致: + +【查出的问题】 +- price_monitor 的 record_event() 只写 price_events.json,从没调过 mofin_db 的写入函数 +- 更隐蔽的是:price_events.code 有外键 → stocks(code),未注册的股票(新候选、港股)写 DB 时外键失败被静默吞掉,这就是 7/6 之后 DB 表再也没新事件的真正原因 + +【已修复】 +1. record_event 改为双写:DB(权威)+ JSON(兼容 mo_config/strategy_feedback 等遗留读取方) +2. 写事件前自动把未注册股票登记进 stocks 表,外键不再失败 +3. 一次性回填:JSON 里 4064 条历史事件全部灌入 DB(现共 6353 行,最新到今天 17:06) +4. 实测:新事件 DB/JSON 双落盘 ✅ + +以后价格事件的权威存储就是 DB 表,健康监控的 db_freshness 也是读它了。""" + +payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode() +req = urllib.request.Request("http://127.0.0.1:5805/", data=payload, + headers={"Content-Type": "application/json"}) +try: + print("XMPP:", urllib.request.urlopen(req, timeout=10).read().decode()[:80]) +except Exception as e: + print("XMPP fail:", e) \ No newline at end of file diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py index 5c1a3641..f28e7896 100644 --- a/scripts/price_monitor.py +++ b/scripts/price_monitor.py @@ -12,7 +12,6 @@ from mo_data import read_decisions BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json" -EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" # DB 模块(同步实时价到 mofin.db) sys.path.insert(0, "/home/hmo/MoFin") @@ -319,37 +318,28 @@ def save_breaches(data): 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""" - events = load_events() + """记录一次价格触发事件到 DB price_events 表(唯一权威存储,JSON 已退役)。 + + price_events.code 有 FK -> stocks(code),未注册的股票(新候选/港股) + 先注册再写事件,否则 FK 失败事件丢失。 + """ 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) + + 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): @@ -581,7 +571,7 @@ def run_once(round_label=""): # === 第三步:买入区偏离检测 + 自动重评 === reassesed_codes = [] # 先做急跌检测(仅持仓,自选股不推送暴跌告警) - holdings_codes = {d["code"] for d in active if d.get("shares", 0) > 0} + holdings_codes = {d["code"] for d in active if (d.get("shares") or 0) > 0} for d in active: code = d["code"] # 非持仓跳过 diff --git a/scripts/sc5.py b/scripts/sc5.py new file mode 100644 index 00000000..abb6b337 --- /dev/null +++ b/scripts/sc5.py @@ -0,0 +1,12 @@ +import ast, sys +files = ['price_monitor', 'strategy_feedback', 'system_health_check', 'mo_config', 'mofin_health'] +ok = True +for f in files: + p = f'/home/hmo/MoFin/deploy/profile-scripts/{f}.py' + try: + ast.parse(open(p).read()) + print('OK', f) + except SyntaxError as e: + print('FAIL', f, e) + ok = False +sys.exit(0 if ok else 1) \ No newline at end of file diff --git a/scripts/test_db_only.py b/scripts/test_db_only.py new file mode 100644 index 00000000..ccabfe45 --- /dev/null +++ b/scripts/test_db_only.py @@ -0,0 +1,16 @@ +import sys, os +sys.path.insert(0, '/home/hmo/.hermes/profiles/position-analyst/scripts') +import price_monitor as pm +pm.record_event('000850', '华茂股份', 'entry_zone', 3.98, '3.91~4.04', '加仓区间') +print('record_event done') + +import sqlite3 +c = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') +r = c.execute("SELECT code,name,price,created_at FROM price_events WHERE code='000850' ORDER BY id DESC LIMIT 1").fetchone() +print('DB:', r) +c.execute("DELETE FROM price_events WHERE code='000850' AND created_at > '2026-07-20 17:50'") +c.commit() +print('cleaned test row') + +# JSON must NOT be recreated +print('JSON exists?', os.path.exists('/home/hmo/web-dashboard/data/price_events.json')) \ No newline at end of file