Files
MoFin/deploy/profile-scripts/strategy-staleness-check.py
T

275 lines
12 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
"""
策略时效性检查 - Strategy Staleness Checker v1
==============================================
扫描所有活跃策略,检查三个维度:
1. 时间老化:超过14/21天未更新预警
2. 价格偏离:当前价偏离买入区中心 >30% 预警
3. 买入区失效:买入区完全脱离当前价格区间
输出两份:JSON报告(给系统) + 人类可读摘要(stdout for cron)
"""
import json, sys, os, re, sqlite3
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from datetime import datetime
from mo_data import read_decisions, read_portfolio
# ── 消息通道统一路由(broadcast/xmpp by delivery) ──
try:
from messenger import install_stdio_hook as _msh
_msh()
except Exception:
pass
DB_PATH = '/home/hmo/web-dashboard/data/mofin.db'
OUTPUT_PATH = "/home/hmo/web-dashboard/data/strategy_staleness_report.json"
WARN_DAYS = 14 # 超过14天未更新→警告
CRITICAL_DAYS = 21 # 超过21天→严重警告
DIVERGENCE_WARN = 30 # 偏离买入区>30%→警告
DIVERGENCE_CRIT = 50 # 偏离>50%→严重
def get_price(code):
"""从 live_prices 表获取当前价(2026-08-26 分层铁律:消费层不直连腾讯API"""
try:
c = str(code).strip().split("_")[0]
if c.lower().startswith("hk"):
c = c[2:]
conn = sqlite3.connect(DB_PATH, timeout=5)
row = conn.execute(
"SELECT price, change_pct FROM live_prices WHERE code=?", (c,)
).fetchone()
conn.close()
if row and row[0]:
price = float(row[0])
chg = float(row[1] or 0)
return (price, chg) if price > 0 else (None, None)
except: pass
return None, None
def parse_buy_zone(current):
"""从策略current字段提取买入区间最低和最高"""
if not current:
return None, None
m = re.search(r'买入.*?(\d+\.?\d*)\s*[~\-]\s*(\d+\.?\d*)', current)
if m:
return float(m.group(1)), float(m.group(2))
return None, None
def main():
data = read_decisions()
# Filter: exclude closed strategies
all_entries = data.get("decisions", [])
active = [e for e in all_entries if e.get("status", "") != "closed"]
flagged = []
now = datetime.now()
for entry in active:
code = entry.get("code", "")
name = entry.get("name", "") or code
entry_type = entry.get("type", "")
is_watchlist = "自选" in entry_type
# --- Age tracking: prefer created_at, fallback to timestamp/updated_at ---
ts = entry.get("created_at") or entry.get("timestamp") or entry.get("updated_at") or ""
age = 0
if ts:
try:
if "T" in ts:
dt = datetime.fromisoformat(ts)
else:
dt = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S")
age = (now - dt).days
except:
try:
# Try alternative format
dt = datetime.strptime(str(ts)[:19], "%Y-%m-%d %H:%M:%S")
age = (now - dt).days
except:
pass
# --- Get price: prefer local data, API fallback ---
price = entry.get("price", None)
if not price or price == 0:
price, _ = get_price(code)
else:
price = float(price) if price else None
# --- Get entry zone from structured fields ---
entry_low = entry.get("entry_low", None)
entry_high = entry.get("entry_high", None)
if (not entry_low) or (not entry_high):
# Fall back to parsing from action/current string
txt = entry.get("action", "") or entry.get("current", "")
entry_low, entry_high = parse_buy_zone(txt)
# --- Build display info ---
action_text = entry.get("action", "") or entry.get("current", "")
last_update = entry.get("updated_at", "") or entry.get("timestamp", "")
flags = []
# Time-based flagging (different thresholds for 持仓 vs 自选)
time_threshold = WARN_DAYS if not is_watchlist else WARN_DAYS * 2 # 自选给2倍时间
critical_threshold = CRITICAL_DAYS if not is_watchlist else CRITICAL_DAYS * 2
if age >= critical_threshold and not is_watchlist:
flags.append(f"严重过期: {age}天未更新")
elif age >= time_threshold and is_watchlist:
flags.append(f"策略已{age}天未更新(自选)")
elif age >= time_threshold:
flags.append(f"策略已{age}天未更新")
if price and entry_low and entry_high:
# -- STRATEGY_STALE check for watchlist stocks in buy zone --
if is_watchlist and entry_low <= price <= entry_high:
timing_signal = entry.get("timing_signal", "") or ""
rr_ratio = entry.get("rr_ratio", 0) or 0
sl = entry.get("stop_loss", 0) or 0
tp = entry.get("take_profit", 0) or 0
# 规则1: timing_signal 含"弱势持有"/"等企稳" → STRATEGY_STALE
if any(kw in timing_signal for kw in ["弱势持有", "等企稳"]):
flags.append("[STRATEGY_STALE] 信号不良(timing_signal含"+str([kw for kw in ["弱势持有", "等企稳"] if kw in timing_signal])+")")
# 规则2: RR<1.5 或无止盈 → STRATEGY_STALE
if tp == 0 or (rr_ratio > 0 and rr_ratio < 1.5):
flags.append("[STRATEGY_STALE] 盈亏比不足(RR="+str(rr_ratio)+")或无止盈")
if is_watchlist:
# 自选股:检查价格是否进入了买入区
if entry_low <= price <= entry_high:
flags.append(f"现价{price:.2f}在买入区{entry_low:.0f}~{entry_high:.0f}(是否可买需结合timing_signal判断)")
elif price > entry_high * 1.3:
flags.append(f"现价{price:.2f}远高于买入区{entry_low:.0f}~{entry_high:.0f},需重评")
elif price < entry_low * 0.8:
flags.append(f"现价{price:.2f}远低于买入区{entry_low:.0f}~{entry_high:.0f},买入区需下移")
else:
# 持仓股:检查价格偏离买入区中心
zone_center = (entry_low + entry_high) / 2
if zone_center > 0:
divergence = abs(price - zone_center) / zone_center * 100
if divergence >= DIVERGENCE_CRIT:
flags.append(f"价格距买入区中心{divergence:.0f}% 已严重偏离")
elif divergence >= DIVERGENCE_WARN:
flags.append(f"价格距买入区中心{divergence:.0f}% 需关注")
# Check if price entirely out of zone
if price > entry_high * 1.5:
flags.append(f"现价{price:.0f}远高于买入区{entry_low:.0f}~{entry_high:.0f}")
elif price < entry_low * 0.5:
flags.append(f"现价{price:.0f}远低于买入区{entry_low:.0f}~{entry_high:.0f}")
# Add type tag for clarity
type_tag = "自选" if is_watchlist else "持仓"
if flags:
flagged.append({
"code": code,
"name": name,
"price": round(price, 2) if price else None,
"flags": flags,
"age_days": age,
"last_update": last_update,
"entry_zone": f"{entry_low:.0f}~{entry_high:.0f}" if entry_low else "无买入区",
"current": action_text,
"updated_by": entry.get("source", "auto"),
"updated_reason": "自动生成",
"is_watchlist": is_watchlist,
})
# --- Portfolio-level analysis: count weak + deep_loss categories ---
holdings_count = len([e for e in active if "自选" not in e.get("type", "")])
weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套") and "自选" not in e.get("type", "")])
all_weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套")])
weak_pct = round(weak_count / holdings_count * 100, 1) if holdings_count > 0 else 0
all_weak_pct = round(all_weak_count / len(active) * 100, 1) if len(active) > 0 else 0
# Read portfolio for position%
position_pct = 0
cash = 0
try:
pdata = read_portfolio()
position_pct = pdata.get("position_pct", 0)
cash = pdata.get("cash", 0)
except: pass
portfolio_flags = []
if weak_pct >= 40:
portfolio_flags.append(f"[PORTFOLIO_WEAK] 组合中弱势+深套分类持仓占比{weak_pct}%>40%,建议系统性减仓")
elif weak_pct >= 30:
portfolio_flags.append(f"[PORTFOLIO_WEAK_MILD] 组合弱势占比{weak_pct}%,需关注")
if position_pct > 80:
portfolio_flags.append(f"[PORTFOLIO_FULL] 总仓位{position_pct}%(现金{cash:.0f}元),买入建议受限")
# 写 TODO — 过期策略自动建修复任务
stale_items = [s for s in flagged if any("[STRATEGY_STALE]" in f for f in s.get("flags", []))]
if stale_items:
conn = sqlite3.connect(DB_PATH)
for s in stale_items:
code = s["code"]
existing = conn.execute("SELECT id FROM todos WHERE title LIKE ? AND status IN ('pending','in_progress')", (f"%{code}%",)).fetchone()
if existing:
continue
conn.execute(
"INSERT INTO todos (title, description, status, priority, source, fix_action) VALUES (?,?,?,?,?,?)",
(f"[STRATEGY_STALE] {s['name']}({code}) 需重评",
f"策略过期: {'; '.join(s['flags'])} | 上次更新{s['age_days']}天前 | 区间{s['entry_zone']}",
"pending", "high", "staleness_check",
f"cd /home/hmo/MoFin && python3 deploy/profile-scripts/per_stock_reassess.py {code}",
)
)
conn.commit()
conn.close()
print(f" TODO: 为 {len(stale_items)} 个过期策略创建修复任务")
# Write report
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
report = {
"checked_at": now.strftime("%Y-%m-%dT%H:%M:%S"),
"total_active": len(active),
"flagged_count": len(flagged),
"flagged": flagged,
"portfolio": {
"position_pct": position_pct,
"cash": round(cash, 2),
"weak_position_pct": weak_pct,
"all_weak_pct": all_weak_pct,
"signals": portfolio_flags
},
"summary": f"扫描{len(active)}个策略,{len(flagged)}个需关注"
}
with open(OUTPUT_PATH, 'w') as f:
json.dump(report, f, indent=2, ensure_ascii=False)
# Human-readable output
now_str = now.strftime("%m/%d %H:%M")
print(f"【策略重估检查】{now_str}")
print(f"活跃策略{len(active)}个 | 需关注{len(flagged)}个")
# Portfolio-level signals first (if any)
for pf in portfolio_flags:
print(pf)
if flagged:
print("")
for s in flagged:
price_str = f"¥{s['price']}" if s['price'] else "N/A"
ttag = "[自选]" if s.get('is_watchlist') else ""
print(f"{ttag}[{s['code']}] {s['name']} {price_str}")
print(f" 上次更新{s['age_days']}天前 | 区间{s['entry_zone']}")
for f in s['flags']:
print(f" ⚠ {f}")
print(f" 策略: {s['current']}")
print("")
print(f"—END—{len(flagged)}个需关注 | 弱势持仓占比{weak_pct}% | 仓位{position_pct}%")
return len(flagged)
if __name__ == "__main__":
try:
stale = main()
sys.exit(0) # Always exit 0 — flagged items are in JSON report + stdout; exit code 1 creates misleading "Script Error" alerts
except Exception as e:
print(f"ERROR: {e}")
sys.exit(2)