diff --git a/__pycache__/price_monitor.cpython-312.pyc b/__pycache__/price_monitor.cpython-312.pyc index 7b9cf5a..de3fcc6 100644 Binary files a/__pycache__/price_monitor.cpython-312.pyc and b/__pycache__/price_monitor.cpython-312.pyc differ diff --git a/__pycache__/strategy_lifecycle.cpython-312.pyc b/__pycache__/strategy_lifecycle.cpython-312.pyc index b559aa0..a5a1bcf 100644 Binary files a/__pycache__/strategy_lifecycle.cpython-312.pyc and b/__pycache__/strategy_lifecycle.cpython-312.pyc differ diff --git a/scripts/300308_monitor.py b/scripts/300308_monitor.py new file mode 100644 index 0000000..d63171a --- /dev/null +++ b/scripts/300308_monitor.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""300308 午后紧盯监控 (no_agent) +每2分钟运行一次。在条件满足时输出买入信号,否则静默。 +支持策略动态调整——输出状态变化。 +""" + +import json, os, sys, urllib.request +from pathlib import Path +from datetime import datetime + +STATE_PATH = "/home/hmo/.hermes/300308_monitor_state.json" + +BUY_ZONE_LOW = 1307.22 +BUY_ZONE_HIGH = 1358.94 +STOP_LOSS = 1293.88 +TAKE_PROFIT = 1456.53 + +# 三档触发条件(按优先级) +LEVEL_STRONG = {"label": "★ 强信号", "price_min": 1330, "price_breach": 1325, "desc": "前3根5分K站稳1330+量放"} +LEVEL_MID = {"label": "◎ 中信号", "price_min": 1315, "price_breach": 1307, "desc": "回踩1315企稳+买盘放量"} +LEVEL_WEAK = {"label": "○ 弱信号", "price_min": 1307, "price_breach": 1298, "desc": "在买入区下沿附近企稳"} + + +def get_price(): + url = "https://qt.gtimg.cn/q=sz300308" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = urllib.request.urlopen(req, timeout=10) + data = resp.read().decode("gbk") + parts = data.split("~") + price = float(parts[3]) + high = float(parts[33]) + low = float(parts[34]) + change_pct = float(parts[32]) + volume = int(parts[6]) + buy_vol = int(parts[7]) if parts[7] else 0 + sell_vol = int(parts[8]) if parts[8] else 0 + return { + "price": price, + "high": high, + "low": low, + "change_pct": change_pct, + "volume": volume, + "buy_vol": buy_vol, + "sell_vol": sell_vol, + } + + +def load_state(): + try: + with open(STATE_PATH) as f: + return json.load(f) + except: + return {"phase": "waiting", "triggered_levels": [], "last_report": None, "afternoon_low": 99999} + + +def save_state(s): + os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(s, f, ensure_ascii=False, indent=2) + + +def main(): + now = datetime.now() + # 只在13:00-15:00运行 + if now.hour < 13 or now.hour >= 15: + # After 15:00, send final summary if not already sent + state = load_state() + if state.get("phase") not in ("done", "final"): + # Market closed, output final status + state["phase"] = "final" + save_state(state) + print("【300308紧盯结束】15:00收盘。") + # Final check + price = get_price() + if price["price"] >= STOP_LOSS: + print(f"收盘价{price['price']:.2f},未触及止损{STOP_LOSS},策略有效。") + else: + print(f"⚠️ 收盘价{price['price']:.2f}已跌破止损{STOP_LOSS}!") + return + + state = load_state() + + # If already in done phase (signal already sent), stay silent + if state.get("phase") == "done": + return + + price_data = get_price() + p = price_data["price"] + vol = price_data["volume"] + buy_vol = price_data["buy_vol"] + sell_vol = price_data["sell_vol"] + + # Track afternoon low + if p < state["afternoon_low"]: + state["afternoon_low"] = p + + # Get volume since last check to calculate relative activity + state.setdefault("last_vol", 0) + vol_delta = vol - state["last_vol"] + state["last_vol"] = vol + vol_active = vol_delta > 0 # new trades happened + + # Determine buy/sell pressure (active buying as proportion) + total_trade = buy_vol + sell_vol + buy_ratio = buy_vol / total_trade if total_trade > 0 else 0.5 + + # Minutes into afternoon session + afternoon_minutes = (now.hour - 13) * 60 + now.minute + candle_num = afternoon_minutes // 5 + 1 # Which 5-min candle we're in + + # ---------------------------------------------------------------- + # Level checking - try from strong to weak + # ---------------------------------------------------------------- + triggered = state.get("triggered_levels", []) + + # Check levels from weak to strong + for level_key, level in [("weak", LEVEL_WEAK), ("mid", LEVEL_MID), ("strong", LEVEL_STRONG)]: + if level_key in triggered: + continue # already triggered, skip + + if p >= level["price_min"] and state["afternoon_low"] >= level["price_breach"]: + # Basic price conditions met — check volume + # Volume should show active buying (buy_vol > sell_vol * 0.8 = not too one-sided) + volume_ok = buy_ratio >= 0.45 # at least 45% buying + if volume_ok: + triggered.append(level_key) + state["triggered_levels"] = triggered + + if level_key == "strong": + msg = ( + f"【300308买入信号】★ 强信号触发\n" + f"现价{p:.2f},已在1330上方站稳,午后最低{state['afternoon_low']:.2f}未破1325\n" + f"主动买比{buy_ratio:.0%},买盘积极\n" + f"距止损{(p-STOP_LOSS)/STOP_LOSS*100:.1f}%,距止盈{(TAKE_PROFIT-p)/p*100:.1f}%\n" + f"操作:买入1手(100股),止损{STOP_LOSS},止盈{TAKE_PROFIT}" + ) + state["phase"] = "done" + save_state(state) + print(msg) + return + + if level_key == "mid": + msg = ( + f"【300308买入信号】◎ 中信号触发\n" + f"现价{p:.2f},回踩1315企稳,午后最低{state['afternoon_low']:.2f}\n" + f"主动买比{buy_ratio:.0%},买盘回温\n" + f"距止损{(p-STOP_LOSS)/STOP_LOSS*100:.1f}%,距止盈{(TAKE_PROFIT-p)/p*100:.1f}%\n" + f"操作:可买入1手(100股),止损{STOP_LOSS}(-{(p-STOP_LOSS)/p*100:.1f}%),止盈{TAKE_PROFIT}" + ) + state["phase"] = "done" + save_state(state) + print(msg) + return + + # ---------------------------------------------------------------- + # Check for failure conditions + # ---------------------------------------------------------------- + if state["afternoon_low"] < 1300: # Breaking towards stop loss + if not state.get("warned_low"): + state["warned_low"] = True + save_state(state) + print(f"【300308风险警告】午后最低{state['afternoon_low']:.2f},逼近止损{STOP_LOSS}(-4.4%)。" + f"现价{p:.2f}。建议取消今日买入。") + return + + if state["afternoon_low"] < STOP_LOSS: + state["phase"] = "done" # stop monitoring + save_state(state) + print(f"【300308止损触发】午后最低{state['afternoon_low']:.2f}<止损{STOP_LOSS}。" + f"策略已失效,取消今日买入。等明日重新评估。") + return + + # ---------------------------------------------------------------- + # Periodically report status (every ~10 mins) + # ---------------------------------------------------------------- + last_report = state.get("last_report_min", -999) + report_interval = 10 # minutes + this_minute = now.hour * 60 + now.minute + + if this_minute - last_report >= report_interval: + state["last_report_min"] = this_minute + # Check what condition we're closest to + if p >= LEVEL_STRONG["price_min"]: + closest = "强信号(1330)" + elif p >= LEVEL_MID["price_min"]: + closest = f"中信号(1315),差{LEVEL_STRONG['price_min']-p:.1f}到强信号" + else: + closest = f"弱信号(1307),差{LEVEL_MID['price_min']-p:.1f}到中信号" + + msg = ( + f"【300308午后监控】第{candle_num}根5分K线\n" + f"现价{p:.2f} | 午后最低{state['afternoon_low']:.2f}\n" + f"买比{buy_ratio:.0%} | 距目标{closest}\n" + f"仍有效,继续监控。" + ) + save_state(state) + print(msg) + return + + save_state(state) + # Silent - nothing to report + + +if __name__ == "__main__": + main() diff --git a/scripts/monitor_300308.py b/scripts/monitor_300308.py new file mode 100644 index 0000000..76b418d --- /dev/null +++ b/scripts/monitor_300308.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""monitor_300308.py — 紧盯中际旭创(300308)站稳1330条件 + +每2分钟检查一次,条件满足时发XMPP信号,然后停用自身。 + +站稳1330条件(三条件同时满足): +1. 现价 >= 1332(1330留2元余量) +2. 连续2次检查都 >= 1332(排除毛刺) +3. 13:00之后(下午开盘后) + +发一次信号后就停,不再重复。 +""" + +import json, os, subprocess, sys, urllib.request +from datetime import datetime + +CODE = "300308" +NAME = "中际旭创" +PRICE_THRESHOLD = 1332.0 +BUY_QTY = 100 # 1手 +STOP_LOSS = 1293.88 +TAKE_PROFIT = 1456.53 + +STATE_FILE = os.path.expanduser("~/.hermes/monitor_300308_state.json") +HERMES_SEND = ["hermes", "send", "--to", "xmpp:hmo@yoin.fun"] + + +def load_state(): + try: + with open(STATE_FILE) as f: + return json.load(f) + except: + return {"alerted": False, "consecutive_ok": 0, "last_price": 0} + + +def save_state(s): + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + with open(STATE_FILE, "w") as f: + json.dump(s, f, indent=2) + + +def fetch_price(): + """从腾讯API获取实时价""" + url = f"http://qt.gtimg.cn/q=sz{CODE}" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + try: + resp = urllib.request.urlopen(req, timeout=10) + text = resp.read().decode("gbk") + fields = text.split("~") + price = float(fields[3]) if fields[3] else 0 + change_pct = fields[32] if len(fields) > 32 else "0" + return price, change_pct + except Exception as e: + return 0, "0" + + +def send_alert(price, change_pct): + msg = ( + f"【信号】{NAME}({CODE}) 站稳1330,可买入!\n" + f"现价{price} ({change_pct}%)\n" + f"信号:下午三条件满足(现价>=1332+连续确认+午后时段)\n" + f"操作:买入1手(100股)约{price*100:.0f}元\n" + f"止损{STOP_LOSS}({(price-STOP_LOSS)/price*100:.1f}%) 止盈{TAKE_PROFIT}({(TAKE_PROFIT-price)/price*100:.1f}%)\n" + f"RR={(TAKE_PROFIT-price)/(price-STOP_LOSS):.1f}\n" + f"现金150,625元,1手占{price*100/150625*100:.1f}%" + ) + try: + subprocess.run(HERMES_SEND + [msg], capture_output=True, timeout=15) + return True + except: + return False + + +def main(): + now = datetime.now() + state = load_state() + + # 已发过信号 → 静默 + if state.get("alerted"): + print("[SILENT] 已发过买入信号") + return + + # 非交易时段 → 静默(但允许14:30前) + if now.weekday() >= 5 or now.hour < 13 or now.hour >= 14: + print(f"[SILENT] 非监控时段(13:00-14:00)") + return + + # 取价 + price, change_pct = fetch_price() + if price == 0: + print(f"[SILENT] 取价失败") + return + + # 条件1:现价 >= 1332 + if price >= PRICE_THRESHOLD: + state["consecutive_ok"] = state.get("consecutive_ok", 0) + 1 + else: + state["consecutive_ok"] = 0 + print(f"[SILENT] 现价{price}低于阈值{PRICE_THRESHOLD}") + save_state(state) + return + + # 条件2:连续2次检查都满足(约4分钟确认) + if state["consecutive_ok"] < 2: + print(f"[SILENT] 连续确认中 {state['consecutive_ok']}/2") + save_state(state) + return + + # 条件3:13:00之后(已隐含在上面的时段检查中) + + # 全部满足 → 发信号 + success = send_alert(price, change_pct) + if success: + state["alerted"] = True + state["alerted_at"] = now.isoformat() + state["alert_price"] = price + save_state(state) + print(f"✅ 信号已推送: {NAME} {price} 可买入") + else: + print(f"⚠️ 信号生成失败,下次重试") + + +if __name__ == "__main__": + main() diff --git a/scripts/refresh_mtf_cache.py b/scripts/refresh_mtf_cache.py new file mode 100755 index 0000000..e6e76bf --- /dev/null +++ b/scripts/refresh_mtf_cache.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""多周期缓存刷新脚本 — 在开盘前预填充K线数据 + +为所有持仓+自选股预先拉取日/周/月K线,写入 multi_tf_cache.json, +这样收盘后全量重评(regenerate_all)运行时K线数据已有缓存,无需逐个拉取。 + +运行时间:每天9:00(开盘前),no_agent模式。 +无输出 = 成功(避免每天收到无意义消息)。 +""" + +import sys +import os +import json +from datetime import datetime + +# 确保能找到 web-dashboard 模块 +sys.path.insert(0, "/home/hmo/web-dashboard") + +# 控制台UTC日志 +def log(msg): + ts = datetime.utcnow().strftime("%H:%M:%S") + print(f"[{ts}] {msg}", file=sys.stderr) + +def main(): + from strategy_lifecycle import safe_json_load, PORTFOLIO_PATH, WATCHLIST_PATH + + # 收集所有股票代码 + codes = [] + for item in safe_json_load(PORTFOLIO_PATH, {}).get("holdings", []): + code = item.get("code", "") + if code: + codes.append(("portfolio", code)) + seen = set(c[1] for c in codes) + for item in safe_json_load(WATCHLIST_PATH, {}).get("stocks", []): + code = item.get("code", "") + if code and code not in seen: + codes.append(("watchlist", code)) + seen.add(code) + + # 加入指数代码(用于多周期趋势研判) + INDEXES = { + "sh000001": "上证指数", "sz399001": "深证成指", + "sz399006": "创业板指", "sh000688": "科创50", + "hkHSI": "恒生指数", "hkHSTECH": "恒生科技", + } + for idx_code in INDEXES: + if idx_code not in seen: + codes.append(("index", idx_code)) + seen.add(idx_code) + + log(f"Pre-populating multi-timeframe cache for {len(codes)} stocks...") + + # 检查当前缓存,只更新需要更新的 + mtf_cache_path = "/home/hmo/web-dashboard/data/multi_tf_cache.json" + try: + with open(mtf_cache_path) as f: + existing = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + existing = {} + + import time + from multi_timeframe import full_multi_tf_analysis + + cached = 0 + fetched = 0 + errors = 0 + + for source, code in codes: + cached_entry = existing.get(code, {}) + updated_at = cached_entry.get("updated_at", 0) + now = time.time() + + # 检查缓存是否新鲜:日K 1小时内,周/月K 1天内 + has_daily = bool(cached_entry.get("daily")) + has_weekly = bool(cached_entry.get("weekly")) + has_monthly = bool(cached_entry.get("monthly")) + cache_fresh = (updated_at > 0 and (now - updated_at) < 3600) + + if has_daily and has_weekly and has_monthly and cache_fresh: + cached += 1 + continue + + try: + r = full_multi_tf_analysis(code) + if any(k in r for k in ["daily", "weekly", "monthly"]): + fetched += 1 + log(f" OK {code} ({source})") + else: + errors += 1 + log(f" EMPTY {code} ({source})") + except Exception as e: + errors += 1 + log(f" FAIL {code} ({source}): {e}") + + log(f"Done: {cached} cached, {fetched} fetched, {errors} errors") + +if __name__ == "__main__": + main() diff --git a/scripts/strategy-staleness-check.py b/scripts/strategy-staleness-check.py new file mode 100644 index 0000000..f9c31f6 --- /dev/null +++ b/scripts/strategy-staleness-check.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +策略时效性检查 - Strategy Staleness Checker v1 +============================================== +扫描所有活跃策略,检查三个维度: +1. 时间老化:超过14/21天未更新预警 +2. 价格偏离:当前价偏离买入区中心 >30% 预警 +3. 买入区失效:买入区完全脱离当前价格区间 + +输出两份:JSON报告(给系统) + 人类可读摘要(stdout for cron) +""" + +import json, sys, os, re, urllib.request +from datetime import datetime + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" +OUTPUT_PATH = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" + +# Fallback: if new path not found, use old path +FALLBACK_PATH = "/home/hmo/data/decisions.json" + +WARN_DAYS = 14 # 超过14天未更新→警告 +CRITICAL_DAYS = 21 # 超过21天→严重警告 +DIVERGENCE_WARN = 30 # 偏离买入区>30%→警告 +DIVERGENCE_CRIT = 50 # 偏离>50%→严重 + +def get_price(code): + """从腾讯API获取当前价""" + try: + market = "sh" if code.startswith("6") else "sz" if code.startswith("0") or code.startswith("3") else "" + if code.startswith(("00", "30")) or code.startswith("68"): + market = "sh" if code.startswith("6") else "sz" + elif code.startswith(("01", "02", "03")): + market = "sz" + url = f"http://qt.gtimg.cn/q={market}{code}" + req = urllib.request.Request(url, headers={"User-Agent": "curl/7.81"}) + with urllib.request.urlopen(req, timeout=5) as resp: + raw = resp.read().decode("gbk") + parts = raw.split("~") + if len(parts) > 3: + price = float(parts[3]) if parts[3] else 0 + chg = float(parts[32]) if parts[32] else 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(): + # Try new path first, fall back to old format + path = DECISIONS_PATH if os.path.exists(DECISIONS_PATH) else FALLBACK_PATH + is_new_format = (path == DECISIONS_PATH) + + with open(path) as f: + data = json.load(f) + + # 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: + with open(PORTFOLIO_PATH) as pf: + pdata = json.load(pf) + 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}元),买入建议受限") + + # 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)