Files
MoFin/price_monitor.py
T
知微 02d1c93923 fix(price_monitor): DB写锁死锁根治 - 统一BEGIN IMMEDIATE + 5次重试指数退避 + emergency WAL checkpoint
- 统一一个BEGIN IMMEDIATE事务包裹所有写操作,替代独立写函数调用
- 5次重试 + 指数退避 (1s,2s,4s,8s,16s),原3次+固定2s
- 重试耗尽后自动 emergency WAL checkpoint(TRUNCATE)释放死锁
- try/except确保连接始终释放,修复conn泄漏
- 三副本同步:profile/scripts + MoFin/scripts + MoFin/root
2026-07-14 10:30:15 +08:00

555 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""price_monitor.py — 高频价格监控脚本(批量版)
规则:进入区间报一次,离开区间报一次,中间不重复。
每次运行时一次性刷新所有持仓+自选股的实时价。
"""
import json
import urllib.request
import os
import sys
import time
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"
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"
# 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
# 策略重评依赖(技术面驱动,非机械百分比)
sys.path.insert(0, "/home/hmo/web-dashboard")
try:
from strategy_lifecycle import reassess_strategy
HAS_REASSESS = True
except ImportError:
HAS_REASSESS = False
UA = "Mozilla/5.0"
# ── 批量拉取价格 ──────────────────────────────────────────────────────────
def fetch_all_prices(codes):
"""腾讯批量行情API:一次请求拉取所有股票(A股+港股)
A股:sh600110 / sz000001
港股:hk00700
返回 {code: (price, change, change_pct)}
"""
if not codes:
return {}
# 构建批量查询串
symbols = []
code_map = {} # symbol -> original_code
for code in codes:
code_s = str(code).strip()
if len(code_s) == 6:
# A股:沪市以5/6/9开头,深市以0/3开头
if code_s.startswith(('5', '6', '9')):
sym = f"sh{code_s}"
else:
sym = f"sz{code_s}"
else:
sym = f"hk{code_s}"
symbols.append(sym)
code_map[sym] = code_s
url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as r:
text = r.read().decode("gbk")
except Exception as e:
print(f"⚠️ 批量拉取失败: {e}", file=sys.stderr)
return {}
results = {}
for line in text.strip().split("\n"):
line = line.strip()
if not line or "=" not in line:
continue
try:
# 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..."
raw_value = line.split("=", 1)[1].strip().strip('"').strip(";")
fields = raw_value.split("~")
if len(fields) < 6:
continue
sym = line.split("=", 1)[0].strip().lstrip("v_")
orig_code = code_map.get(sym)
if not orig_code:
continue
price = float(fields[3]) if fields[3] else 0
prev_close = float(fields[4]) if fields[4] else 0
change = price - prev_close if prev_close > 0 else 0
change_pct = fields[32] if len(fields) > 32 and fields[32] else "0"
results[orig_code] = (price, change, change_pct)
except (ValueError, IndexError):
continue
return results
def refresh_data_prices():
"""一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON"""
all_codes = set()
# 从DB读所有需要拉取价格的代码
try:
conn = get_conn()
for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"):
all_codes.add(r['code'])
for r in conn.execute("SELECT code FROM watchlist_stocks"):
all_codes.add(r['code'])
conn.close()
except Exception as e:
print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr)
return 0
if not all_codes:
return 0
# 一次性批量拉取
prices = fetch_all_prices(list(all_codes))
updated = len(prices)
# === 弹性同步实时价到 mofin.db ===
# 防死锁策略(经2026-07-14 WAL死锁复盘改进):
# ① 启动时 checkpoint WAL(清理残留事务)
# ② 统一 BEGIN IMMEDIATE 包裹整个写操作
# ③ 5次重试 + 指数退避: 1s → 2s → 4s → 8s → 16s(共~31s
# ④ get_conn() 的 busy_timeout=30000 保证等待上限
# ⑤ 每个写操作检查返回值,任一失败立即 rollback + 重试
# ⑥ try/finally 确保连接始终释放
if HAS_DB and prices:
# 先checkpoint一次,清理上次被kill残留的WAL
try:
c = get_conn()
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
c.close()
except Exception:
pass
max_tries = 5
conn = None
for db_attempt in range(max_tries):
try:
conn = get_conn()
# BEGIN IMMEDIATE 立即获取写锁——失败则等 busy_timeout(30s)
conn.execute("BEGIN IMMEDIATE")
# ── 构建 holdings 更新数据 ──
db_holdings = []
for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"):
h = dict(r)
code = str(h.get('code', ''))
if code in prices:
price_val, _, change_pct = prices[code]
if price_val > 0:
h['price'] = round(price_val, 2)
h['change_pct'] = float(change_pct) if change_pct else 0
db_holdings.append(h)
# ── 写 holdings 表 ──
for h in db_holdings:
currency = str(h.get('currency', 'CNY')).upper()
if currency not in ('CNY', 'HKD'):
raise ValueError(f"非法币种: {currency}")
conn.execute("""
INSERT INTO holdings (code, name, shares, cost, price, market_value,
change_pct, currency, position_pct, added_at, is_active)
VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1)
ON CONFLICT(code) DO UPDATE SET
name=excluded.name, shares=excluded.shares, cost=excluded.cost,
price=excluded.price, market_value=excluded.market_value,
change_pct=excluded.change_pct, currency=excluded.currency,
position_pct=excluded.position_pct
""", (
h.get('code'), h.get('name'), h.get('shares', 0),
h.get('cost'), h.get('price'),
h.get('market_value'), h.get('change_pct'),
h.get('currency', 'CNY'), h.get('position_pct'),
))
# ── 写 portfolio_summary ──
mv = calc_total_mv(db_holdings)
# 从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()
db_cash = existing['cash'] if existing else 0.0
db_frozen = existing['frozen_cash'] if existing else 0.0
assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen})
position_pct = round(mv / assets * 100, 2) if assets > 0 else 0
conn.execute("""
INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value,
cash, frozen_cash, position_pct, total_pnl, currency, updated_at)
VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime'))
ON CONFLICT(id) DO UPDATE SET
total_assets=excluded.total_assets, total_mv=excluded.total_mv,
stock_value=excluded.stock_value, cash=excluded.cash,
frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct,
total_pnl=excluded.total_pnl, currency=excluded.currency,
updated_at=datetime('now','localtime')
""", (
assets, mv, mv, db_cash, db_frozen,
position_pct, 0, 'CNY',
))
# ── 写 live_prices ──
for h in db_holdings:
code = h.get('code', '')
if code:
p = h.get('price', 0)
cp = h.get('change_pct', 0)
conn.execute(
"INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) "
"VALUES (?,?,?,datetime('now','localtime'))",
(code, p, cp)
)
conn.commit()
conn.close()
conn = None
if db_attempt > 0:
print(f"DB同步成功(第{db_attempt+1}次重试)")
break # success
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
if conn:
try: conn.rollback()
except Exception: pass
try: conn.close()
except Exception: pass
conn = None
err_str = str(e)
if "locked" in err_str or "cannot commit" in err_str or "busy" in err_str:
if db_attempt < max_tries - 1:
wait = 2 ** db_attempt # 1, 2, 4, 8, 16
print(f"⏳ DB锁(尝试{db_attempt+1}/{max_tries}): {e}{wait}s后重试", file=sys.stderr)
time.sleep(wait)
else:
print(f"❌ DB锁(重试{max_tries}次耗尽): {e}", file=sys.stderr)
else:
print(f"❌ DB错误: {e}", file=sys.stderr)
break
except Exception as e:
if conn:
try: conn.rollback()
except Exception: pass
try: conn.close()
except Exception: pass
conn = None
print(f"⚠️ DB同步异常: {e}", file=sys.stderr)
break
else:
print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr)
try:
c = sqlite3.connect(str(DB_PATH), timeout=1)
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
c.close()
print(" ↪ 紧急WAL checkpoint完成", file=sys.stderr)
except Exception as we:
print(f" ↪ WAL checkpoint也失败: {we}", file=sys.stderr)
return updated
# ── 区间偏离检测 ──────────────────────────────────────────────────────────
def load_state():
try:
with open(STATE_PATH) as f:
return json.load(f)
except:
return {}
def save_state(state):
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
with open(STATE_PATH, 'w') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
def load_breaches():
try:
with open(BREACH_PATH) as f:
return json.load(f)
except:
return {}
def save_breaches(data):
os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True)
with open(BREACH_PATH, 'w') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def 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()
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)
def get_trigger_zones(trigger):
"""返回该trigger所有可监控的区间列表,跳过已执行的batch"""
zones = []
for key, label in [
("entry_zone", "加仓区间"),
("batch1_price", "试仓区间"),
("batch2_price", "加仓区间"),
("take_profit_zone", "止盈区间"),
("watch_low", "关注区间"),
("watch_high", "减仓区间"),
("watch_break", "止损区间")
]:
status_key = key.replace("_price", "_status")
if status_key in trigger and trigger[status_key] == "executed":
continue
val = trigger.get(key, "")
if val and "~" in val:
try:
parts = val.split("~")
lo, hi = float(parts[0]), float(parts[1])
zones.append((key, label, lo, hi))
except:
pass
sl = trigger.get("stop_loss", "")
if sl:
try:
sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl)
zones.append(("stop_loss", "止损", 0, sl_price))
except:
pass
return zones
def run_once(round_label=""):
"""执行一轮完整的监控流程"""
label = f" [{round_label}]" if round_label else ""
start = time.time()
# === 第一步:一次性刷新所有价格 ===
refreshed = refresh_data_prices()
# === 第二步:检查触发条件(纯DB,不读JSON) ===
try:
dec = read_decisions()
except Exception as e:
print(f"❌{label} 无法从DB读取决策数据: {e}", file=sys.stderr)
return
active = [d for d in dec.get("decisions", []) if d.get("status") == "active"]
state = load_state()
outputs = []
state_updated = False
# 收集所有需要检查的代码
check_codes = set()
for d in active:
trig = d.get("trigger", {})
if trig:
check_codes.add(d["code"])
# 批量拉取这些股票的价格
prices = fetch_all_prices(list(check_codes))
for d in active:
code = d["code"]
trig = d.get("trigger", {})
if not trig:
continue
zones = get_trigger_zones(trig)
if not zones:
continue
price_info = prices.get(code)
if not price_info:
continue
price, _ = price_info
if price == 0:
continue
name = d.get("name", code)
if code not in state:
state[code] = {}
for key, label, lo, hi in zones:
in_zone = lo <= price <= hi
prev_in_zone = state[code].get(key, None)
if in_zone and prev_in_zone != True:
if key == "stop_loss":
outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}")
record_event(code, name, "stop_loss", price, str(hi))
else:
extra = ""
if "_price" in key:
batch_shares = trig.get(key.replace("_price", "_shares"), "")
action = trig.get(key.replace("_price", "_action"), "")
if batch_shares:
extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股"
elif key in ("take_profit_zone",):
act = trig.get("take_profit_action", "")
if act:
extra = f"{act}"
outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}")
record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label)
state[code][key] = True
state_updated = True
elif not in_zone and prev_in_zone == True:
if key != "stop_loss":
outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}")
state[code][key] = False
state_updated = True
# === 第三步:买入区偏离检测 + 自动重评 ===
reassesed_codes = []
for d in active:
code = d["code"]
name = d.get("name", code)
price_info = prices.get(code)
if not price_info:
continue
price, _ = price_info
if price == 0:
continue
# 从 DB holding_strategies 读取买入区
entry_low = d.get("entry_low", 0)
entry_high = d.get("entry_high", 0)
if not entry_low or not entry_high:
continue
in_buy_zone = entry_low <= price <= entry_high
prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None)
# 状态变化时才触发
if in_buy_zone and prev_in_buy_zone == False:
# 重新进入买入区 → 重评确认区间是否仍然有效
outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评")
do_reassess = True
elif not in_buy_zone and prev_in_buy_zone == True:
# 离开买入区 → 立即重评,更新止损/止盈/区间
outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评")
do_reassess = True
else:
do_reassess = False
if do_reassess and HAS_REASSESS:
try:
cost = d.get("cost", 0) or 0
shares = d.get("shares", 0) or 0
profit_pct = (price - cost) / cost * 100 if cost else 0
is_deep_loss = profit_pct < -20
sentiment = "neutral"
if d.get("tech_snapshot"):
if "bearish" in d["tech_snapshot"]:
sentiment = "bearish"
elif "bullish" in d["tech_snapshot"]:
sentiment = "bullish"
# 调用技术面驱动重评(非机械百分比)
result = reassess_strategy(
code, name, price, cost, shares,
current_action=d.get("action", ""),
volume_signal="中性", sentiment=sentiment,
)
outputs.append(f" 📊 新策略: 损{result['stop_loss']}{result['take_profit']}{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}")
reassesed_codes.append(code)
except Exception as e:
outputs.append(f" ⚠️ 重评失败: {e}")
# 更新买入区状态
if "__buy_zone" not in state.get(code, {}):
if code not in state:
state[code] = {}
state[code]["__buy_zone"] = in_buy_zone
state_updated = True
# 如果有重评过的股票,更新 decisions.json
if reassesed_codes and HAS_REASSESS:
try:
# 重新 regenerate_all 只针对受影响的股票效率太低
# 直接全量重评(regenerate_all 内部会批量拉价格、做技术分析)
from strategy_lifecycle import regenerate_all
r = regenerate_all(stdout=False)
outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功")
outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}")
except Exception as e:
outputs.append(f" ⚠️ 全量重评失败: {e}")
# === 第四步:输出 ===
now_str = datetime.now().strftime("%H:%M:%S")
elapsed = time.time() - start
if outputs:
print(f"\n🔔 {now_str}{label}")
for o in outputs:
print(o)
print(f"\n<structured_data>{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}</structured_data>")
else:
# 无触发时 SILENT(中继不推送)
print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s")
if state_updated:
save_state(state)
# 输出耗时
print(f"⏱{label} {elapsed:.1f}s", flush=True)
def main():
"""每cron触发跑一轮"""
run_once()
if __name__ == "__main__":
main()