refactor: retire price_events.json completely — DB is the only store

User directive: no JSON, retire it fully, fix all related code.

Changes:
- price_monitor.py: record_event writes DB only; removed EVENTS_PATH/
  load_events/save_events entirely
- strategy_feedback.py: price events read from DB only (removed JSON fallback)
- system_health_check.py: removed price_events.json from file-check list,
  DB-only event stats (was showing 0/0 due to wrong-DB resolution)
- mo_config.py: removed dead price_events_path property (no callers)
- mofin_health.py: price_events freshness reads DB table (authoritative now)
- mofin_db.py: DATA_DIR/DB_PATH now ABSOLUTE (/home/hmo/MoFin/data) —
  was relative __file__.parent, so each hardlinked copy of mofin_db.py
  resolved to a DIFFERENT database (canonical vs web-dashboard vs
  profile-local third DB with 0 rows of everything except market_snapshots).
  This fragmentation was the real cause of health checks reading empty tables.
- Unified all 4 mofin_db copies (root/scripts/deploy/profile) via hardlink
- price_events.json archived to trashbox (fully backfilled: 6353 rows in DB)

Verified:
- record_event lands in DB only, JSON not recreated
- system_health_check: 历史事件 6353 / 今日事件 2965 (was 0/0)
- strategy_feedback + price_monitor full runs clean
This commit is contained in:
hmo
2026-07-20 18:10:05 +08:00
parent efdfaf956a
commit d5b8bec897
16 changed files with 388 additions and 203 deletions
-5
View File
@@ -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 表。"""
+1 -1
View File
@@ -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"
# ═══════════════════════════════════════════════════════════
-23
View File
@@ -934,29 +934,6 @@ def build_report():
except Exception:
pass
# price_events 特殊处理:活跃存储是 price_events.jsonprice_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","")):
+6 -36
View File
@@ -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"""
+4 -4
View File
@@ -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)
@@ -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:
+21 -18
View File
@@ -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,23 +46,24 @@ 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"
@property
def price_events_path(self) -> Path:
"""⚠️ 已废弃!数据在 mofin.db price_events 表。"""
return self.data_dir / "price_events.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 live_prices_path(self) -> Path:
@@ -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 获取"""
+1 -1
View File
@@ -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"
# ═══════════════════════════════════════════════════════════
+238 -60
View File
@@ -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,15 +198,6 @@ 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'
).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()
@@ -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,15 +667,33 @@ 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:
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:
# 重新 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)}")
try:
with open(_regen_marker, "w") as _f:
_f.write(str(time.time()))
except:
pass
except Exception as e:
outputs.append(f" ⚠️ 全量重评失败: {e}")
@@ -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触发跑一轮"""
+24
View File
@@ -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()
-5
View File
@@ -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 表。"""
+1 -1
View File
@@ -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"
# ═══════════════════════════════════════════════════════════
+25
View File
@@ -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)
+21 -31
View File
@@ -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"]
# 非持仓跳过
+12
View File
@@ -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)
+16
View File
@@ -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'))