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:
@@ -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 表。"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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","")):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
@@ -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 获取"""
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
+248
-70
@@ -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触发跑一轮"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
# 非持仓跳过
|
||||
|
||||
@@ -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)
|
||||
@@ -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'))
|
||||
Reference in New Issue
Block a user