merge: retire price_events.json

This commit is contained in:
知微
2026-07-20 18:10:40 +08:00
11 changed files with 2306 additions and 2063 deletions
+231 -231
View File
@@ -1,231 +1,231 @@
#!/usr/bin/env python3
"""
mo_config.py — MoFin 统一配置管理(单例模式)
替代 MoFin 中散落在各文件的硬编码路径和常量。
⚠️ 铁律:所有 MoFin 模块必须从此处获取路径和配置,严禁硬编码。
之前:DATA_DIR = "/home/hmo/web-dashboard/data" (散落在 10+ 文件中)
现在:from mo_config import config; config.data_dir
用法:
from mo_config import config
from mo_data import read_portfolio; data = read_portfolio()
"""
import os
import json
from pathlib import Path
from dataclasses import dataclass, field
from typing import List
@dataclass
class MoConfig:
"""MoFin 全局配置单例"""
# ── 路径 ──────────────────────────────────────────────────────
# 项目根目录
project_dir: Path = field(default_factory=lambda: Path(__file__).parent.resolve())
# 数据目录(mofin.db 等,所有数据只从 DB 读写)
data_dir: Path = field(default_factory=lambda: Path(
os.environ.get("MOFIN_DATA_DIR", "/home/hmo/web-dashboard/data")
))
# SQLite 数据库路径
db_path: Path = field(default=None)
# 缓存目录
cache_dir: Path = field(default_factory=lambda: Path.home() / ".cache" / "mofin")
# Hermes 状态目录
hermes_dir: Path = field(default_factory=lambda: Path.home() / ".hermes")
# ── 关键数据文件路径(已废弃,仅保留为检查逻辑。新代码勿用) ──────
@property
def portfolio_path(self) -> Path:
"""⚠️ 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:
"""⚠️ 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:
"""⚠️ 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:
"""⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。"""
return self.data_dir / "live_prices.json"
@property
def evaluation_input_path(self) -> Path:
return self.data_dir / "evaluation_input.json"
@property
def multi_tf_cache_path(self) -> Path:
"""⚠️ DEPRECATED: 多周期缓存已迁移到 mofin_db.mtf_cache 表。"""
return self.data_dir / "multi_tf_cache.json"
@property
def price_history_path(self) -> Path:
return self.data_dir / "price_history.json"
# ── DB 路径(懒加载) ────────────────────────────────────────
def _get_db_path(self) -> Path:
if self.db_path is None:
self.db_path = self.data_dir / "mofin.db"
return self.db_path
# ── 汇率 ──────────────────────────────────────────────────────
hk_rate_fallback: float = 0.87 # 港币→人民币 fallback 汇率
# ── 小果 LLM 端点(用机器名,/etc/hosts 自动解析 LAN/EasyTier)─
# node122 = 192.168.1.122 (LAN) / 10.144.144.2 (EasyTier)
xiaoguo_host: str = "node122"
xiaoguo_port: int = 18003
@property
def xiaoguo_url(self) -> str:
return f"http://{self.xiaoguo_host}:{self.xiaoguo_port}"
@property
def xiaoguo_api_url(self) -> str:
return f"{self.xiaoguo_url}/v1/chat/completions"
port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8899")))
tdx_relay_url: str = field(
default_factory=lambda: os.environ.get("TDX_RELAY_URL", "http://localhost:8080")
)
xmpp_agent_host: str = field(
default_factory=lambda: os.environ.get("XMPP_AGENT_HOST", "localhost")
)
xmpp_agent_port: int = field(
default_factory=lambda: int(os.environ.get("XMPP_AGENT_PORT", "5801"))
)
# ── DSA 集成 ──────────────────────────────────────────────────
dsa_enabled: bool = field(
default_factory=lambda: os.environ.get("DSA_ENABLED", "false").lower() == "true"
)
dsa_base_dir: Path = field(default_factory=lambda: Path(
os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "daily-stock-analysis",
"ZhuLinsen-daily_stock_analysis-a448886"
))
))
# ── 数据新鲜度 ────────────────────────────────────────────────
market_hours_max_stale_min: int = 5 # 盘中最大过期时间(分钟)
off_hours_max_stale_min: int = 120 # 盘后最大过期时间(分钟)
# ── 验证 ──────────────────────────────────────────────────────
def validate(self) -> List[str]:
"""验证配置,返回问题列表"""
issues = []
if not self.data_dir.exists():
issues.append(f"数据目录不存在: {self.data_dir}")
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
def ensure_dirs(self):
"""确保必要的目录存在"""
self.data_dir.mkdir(parents=True, exist_ok=True)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.hermes_dir.mkdir(parents=True, exist_ok=True)
# ── 输出 ──────────────────────────────────────────────────────
def summary(self) -> str:
"""打印配置摘要"""
lines = [
"=== MoFin 配置 ===",
f"项目目录: {self.project_dir}",
f"数据目录: {self.data_dir} (存在: {self.data_dir.exists()})",
f"DB路径: {self._get_db_path()} (存在: {self._get_db_path().exists()})",
f"端口: {self.port}",
f"TDX Relay: {self.tdx_relay_url}",
f"DSA 集成: {'启用' if self.dsa_enabled else '关闭'}",
f"港币汇率 fallback: {self.hk_rate_fallback}",
]
issues = self.validate()
if issues:
lines.append(f"\n⚠️ 配置问题 ({len(issues)}):")
for i in issues:
lines.append(f" - {i}")
return "\n".join(lines)
# ── 单例 ────────────────────────────────────────────────────────────
_config_instance: MoConfig | None = None
def get_config() -> MoConfig:
"""获取全局配置单例"""
global _config_instance
if _config_instance is None:
_config_instance = MoConfig()
return _config_instance
# 便捷别名
config = property(lambda self: get_config())
# ── 模块级便捷访问 ──────────────────────────────────────────────────
def data_dir() -> Path:
return get_config().data_dir
def ensure_dirs():
get_config().ensure_dirs()
# ── 向后兼容:导出已废弃的路由常量 ──────────────────────────────────
# PORTFOLIO_PATH / DECISIONS_PATH / WATCHLIST_PATH 均已废弃(数据在 DB)。
def _lazy(attr):
"""懒加载属性,首次访问时从 config 获取"""
return getattr(get_config(), attr)
# 为兼容旧代码导出以下变量
PORTFOLIO_PATH = None # 改用 config.portfolio_path
DECISIONS_PATH = None # 改用 config.decisions_path
WATCHLIST_PATH = None # 改用 config.watchlist_path
# ── 自检 ────────────────────────────────────────────────────────────
if __name__ == "__main__":
cfg = get_config()
print(cfg.summary())
#!/usr/bin/env python3
"""
mo_config.py — MoFin 统一配置管理(单例模式)
替代 MoFin 中散落在各文件的硬编码路径和常量。
⚠️ 铁律:所有 MoFin 模块必须从此处获取路径和配置,严禁硬编码。
之前:DATA_DIR = "/home/hmo/web-dashboard/data" (散落在 10+ 文件中)
现在:from mo_config import config; config.data_dir
用法:
from mo_config import config
from mo_data import read_portfolio; data = read_portfolio()
"""
import os
import json
from pathlib import Path
from dataclasses import dataclass, field
from typing import List
@dataclass
class MoConfig:
"""MoFin 全局配置单例"""
# ── 路径 ──────────────────────────────────────────────────────
# 项目根目录
project_dir: Path = field(default_factory=lambda: Path(__file__).parent.resolve())
# 数据目录(mofin.db 等,所有数据只从 DB 读写)
data_dir: Path = field(default_factory=lambda: Path(
os.environ.get("MOFIN_DATA_DIR", "/home/hmo/web-dashboard/data")
))
# SQLite 数据库路径
db_path: Path = field(default=None)
# 缓存目录
cache_dir: Path = field(default_factory=lambda: Path.home() / ".cache" / "mofin")
# Hermes 状态目录
hermes_dir: Path = field(default_factory=lambda: Path.home() / ".hermes")
# ── 关键数据文件路径(已废弃,仅保留为检查逻辑。新代码勿用) ──────
@property
def portfolio_path(self) -> Path:
"""⚠️ 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:
"""⚠️ 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:
"""⚠️ 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:
"""⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。"""
return self.data_dir / "live_prices.json"
@property
def evaluation_input_path(self) -> Path:
return self.data_dir / "evaluation_input.json"
@property
def multi_tf_cache_path(self) -> Path:
"""⚠️ DEPRECATED: 多周期缓存已迁移到 mofin_db.mtf_cache 表。"""
return self.data_dir / "multi_tf_cache.json"
@property
def price_history_path(self) -> Path:
return self.data_dir / "price_history.json"
# ── DB 路径(懒加载) ────────────────────────────────────────
def _get_db_path(self) -> Path:
if self.db_path is None:
self.db_path = self.data_dir / "mofin.db"
return self.db_path
# ── 汇率 ──────────────────────────────────────────────────────
hk_rate_fallback: float = 0.87 # 港币→人民币 fallback 汇率
# ── 小果 LLM 端点(用机器名,/etc/hosts 自动解析 LAN/EasyTier)─
# node122 = 192.168.1.122 (LAN) / 10.144.144.2 (EasyTier)
xiaoguo_host: str = "node122"
xiaoguo_port: int = 18003
@property
def xiaoguo_url(self) -> str:
return f"http://{self.xiaoguo_host}:{self.xiaoguo_port}"
@property
def xiaoguo_api_url(self) -> str:
return f"{self.xiaoguo_url}/v1/chat/completions"
port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8899")))
tdx_relay_url: str = field(
default_factory=lambda: os.environ.get("TDX_RELAY_URL", "http://localhost:8080")
)
xmpp_agent_host: str = field(
default_factory=lambda: os.environ.get("XMPP_AGENT_HOST", "localhost")
)
xmpp_agent_port: int = field(
default_factory=lambda: int(os.environ.get("XMPP_AGENT_PORT", "5801"))
)
# ── DSA 集成 ──────────────────────────────────────────────────
dsa_enabled: bool = field(
default_factory=lambda: os.environ.get("DSA_ENABLED", "false").lower() == "true"
)
dsa_base_dir: Path = field(default_factory=lambda: Path(
os.path.normpath(os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "daily-stock-analysis",
"ZhuLinsen-daily_stock_analysis-a448886"
))
))
# ── 数据新鲜度 ────────────────────────────────────────────────
market_hours_max_stale_min: int = 5 # 盘中最大过期时间(分钟)
off_hours_max_stale_min: int = 120 # 盘后最大过期时间(分钟)
# ── 验证 ──────────────────────────────────────────────────────
def validate(self) -> List[str]:
"""验证配置,返回问题列表"""
issues = []
if not self.data_dir.exists():
issues.append(f"数据目录不存在: {self.data_dir}")
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
def ensure_dirs(self):
"""确保必要的目录存在"""
self.data_dir.mkdir(parents=True, exist_ok=True)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.hermes_dir.mkdir(parents=True, exist_ok=True)
# ── 输出 ──────────────────────────────────────────────────────
def summary(self) -> str:
"""打印配置摘要"""
lines = [
"=== MoFin 配置 ===",
f"项目目录: {self.project_dir}",
f"数据目录: {self.data_dir} (存在: {self.data_dir.exists()})",
f"DB路径: {self._get_db_path()} (存在: {self._get_db_path().exists()})",
f"端口: {self.port}",
f"TDX Relay: {self.tdx_relay_url}",
f"DSA 集成: {'启用' if self.dsa_enabled else '关闭'}",
f"港币汇率 fallback: {self.hk_rate_fallback}",
]
issues = self.validate()
if issues:
lines.append(f"\n⚠️ 配置问题 ({len(issues)}):")
for i in issues:
lines.append(f" - {i}")
return "\n".join(lines)
# ── 单例 ────────────────────────────────────────────────────────────
_config_instance: MoConfig | None = None
def get_config() -> MoConfig:
"""获取全局配置单例"""
global _config_instance
if _config_instance is None:
_config_instance = MoConfig()
return _config_instance
# 便捷别名
config = property(lambda self: get_config())
# ── 模块级便捷访问 ──────────────────────────────────────────────────
def data_dir() -> Path:
return get_config().data_dir
def ensure_dirs():
get_config().ensure_dirs()
# ── 向后兼容:导出已废弃的路由常量 ──────────────────────────────────
# PORTFOLIO_PATH / DECISIONS_PATH / WATCHLIST_PATH 均已废弃(数据在 DB)。
def _lazy(attr):
"""懒加载属性,首次访问时从 config 获取"""
return getattr(get_config(), attr)
# 为兼容旧代码导出以下变量
PORTFOLIO_PATH = None # 改用 config.portfolio_path
DECISIONS_PATH = None # 改用 config.decisions_path
WATCHLIST_PATH = None # 改用 config.watchlist_path
# ── 自检 ────────────────────────────────────────────────────────────
if __name__ == "__main__":
cfg = get_config()
print(cfg.summary())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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,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 获取"""
+248 -70
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,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触发跑一轮"""
+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 表。"""
+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'))