#!/usr/bin/env python3 """promote_candidates.py — 自动提拔候选股入自选(修复版 2026-08-10) 修复:每次运行限处理 N 个新候选(按评分降序优中选优), 加单例守卫防重叠,缩短重评子进程超时,加总时长护栏。 """ import sys, json, sqlite3, time from pathlib import Path from datetime import datetime DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") # 2026-08-10 修复参数:控制单次运行时长,防 hermes 600s 超时杀进程 MAX_NEW_PER_RUN = 10 # 每次最多提拔 10 个新候选(评分降序优中选优) REASSESS_TIMEOUT = 120 # 重评子进程超时(原480s→120s,重评是辅助不阻塞主流程) TOTAL_TIME_BUDGET = 500 # 总时长护栏(hermes child_timeout=600,留100s余量) START_TIME = time.time() def over_budget(): return (time.time() - START_TIME) > TOTAL_TIME_BUDGET def main(): # ── 单例守卫(2026-05-31 铁律:常驻/定时脚本必须防重复)── import fcntl lock_path = "/tmp/promote_candidates.lock" lock_f = open(lock_path, "w") try: fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: print("[PROMOTE] 已有实例在运行,退出") return conn = sqlite3.connect(str(DB_PATH), timeout=30) conn.execute("PRAGMA busy_timeout=30000") conn.row_factory = sqlite3.Row # 读未提拔候选(按评分降序) rows = conn.execute(""" SELECT c.code, c.name, c.score_final, c.entry_range, c.stop_loss, c.target, c.sector FROM candidates c WHERE (c.promoted IS NULL OR c.promoted = 0) AND (c.dropped IS NULL OR c.dropped = 0) AND c.score_final >= 7 ORDER BY c.score_final DESC """).fetchall() if not rows: print("[PROMOTE] 无待提拔候选") conn.close() return print(f"[PROMOTE] 待处理 {len(rows)} 个候选(本次最多 {MAX_NEW_PER_RUN} 个新提拔)", flush=True) promoted = 0 processed = 0 for r in rows: if over_budget(): print(f"[PROMOTE] 已达时长护栏 {TOTAL_TIME_BUDGET}s,停止本批(已处理{processed})", flush=True) break # 已有"在自选"的候选(上次已插但被标记或重复)快速跳过,不计入 new 配额 code = str(r[0]) # 2026-08-11:读取候选 sector(用于 p_oversold RR 例外) cand_sector = str(r[6] or "").strip() if len(r) > 6 else "" exists = conn.execute( "SELECT id FROM holding_strategies WHERE code=? AND status='active'", (code,) ).fetchone() if exists: conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,)) processed += 1 continue # 已达本批新提拔配额 → 停止(剩余的留到下批,按评分降序保证优中选优) if promoted >= MAX_NEW_PER_RUN: print(f"[PROMOTE] 本批已达 {MAX_NEW_PER_RUN} 个新提拔配额,停止(剩余留待下批)", flush=True) break name = r[1] or code score = r[2] or 0 entry_range = r[3] or "" sl = r[4] or 0 tp = r[5] or 0 # 解析 entry_range el, eh = 0, 0 if "~" in entry_range: parts = entry_range.split("~") try: el = float(parts[0]) eh = float(parts[1]) except: pass # 验证实时价格 _price = 0.0 try: import subprocess, json as _jj _r = subprocess.run(["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/stock_quote.py", code], capture_output=True, text=True, timeout=10) _q = _jj.loads(_r.stdout) _price = float(_q.get("price", 0)) if _price <= 0: print(f" ⏭ {code} {name} 无实时价格,跳过") processed += 1 continue except Exception as _e: print(f" ⏭ {code} {name} 价格获取失败({_e}),跳过") processed += 1 continue # 优中选优闸:ST排除 + 技术位锚定 + RR>=2.0 if "ST" in (name or "").upper(): print(f" ⏭ {code} {name} ST股,不入自选") processed += 1 continue try: import sys as _s if '/home/hmo/MoFin/deploy/profile-scripts' not in _s.path: _s.path.insert(0, '/home/hmo/MoFin/deploy/profile-scripts') import technical_analysis as _ta _ta_r = _ta.full_analysis(code) _sr = (_ta_r or {}).get("support_resistance", {}) or {} _ws, _ss = _sr.get("weak_support"), _sr.get("strong_support") _wr, _sr2 = _sr.get("weak_resist"), _sr.get("strong_resist") if _ws and _wr and _price > 0: el = round(_ws * 0.995, 2) eh = round(min(_wr, _price * 1.05), 2) sl = round((_ss or _ws) * 0.985, 2) tp = round(_sr2 or _wr * 1.15, 2) except Exception as _te: print(f" ⚠️ {code} 技术位锚定失败({_te}),用扫描器参数", flush=True) if el > 0 and eh > el and sl > 0 and tp > 0: _mid = (el + eh) / 2 _rr = (tp - _mid) / (_mid - sl) if (_mid - sl) > 0 else 0 # 2026-08-11:p_oversold(预测超跌反弹)候选跳过 RR>=2.0 门槛—— # 超跌反弹候选 RR 天然 <2(支撑近/压力远),用专属评估替代(部署计划 §8.3 方案C) if _rr < 2.0 and cand_sector != "p_oversold": print(f" ⏭ {code} {name} RR={_rr:.2f}<2.0,不入自选") processed += 1 continue else: print(f" ⏭ {code} {name} 锚定后参数无效(区{el}~{eh} 损{sl} 盈{tp}),跳过") processed += 1 continue # 构建策略 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") timing_signal = "关注" price_est = (el + eh) / 2 if el > 0 and eh > 0 else 0 reason_text = [] if el > 0: reason_text.append(f"买{el}~{eh}") if sl > 0: reason_text.append(f"损{sl}") if tp > 0: reason_text.append(f"盈{tp}") if sl > 0 and tp > 0 and price_est > 0: rr = (tp - price_est) / (price_est - sl) if (price_est - sl) > 0 else 0 reason_text.append(f"RR{rr:.1f}") reason_text.append(f"评分{score}") action = " | ".join(reason_text) if reason_text else f"市场扫描发现(评分{score})" # 容量闸:自选上限60只 MAX_WATCH = 60 wl_count = conn.execute( "SELECT COUNT(*) FROM holding_strategies WHERE status='active' AND decision_type='自选策略'").fetchone()[0] if wl_count >= MAX_WATCH: weakest = conn.execute(""" SELECT code, name, COALESCE(rr_ratio,0) as rr FROM holding_strategies WHERE status='active' AND decision_type='自选策略' ORDER BY COALESCE(rr_ratio,0) ASC, updated_at ASC LIMIT 1""").fetchone() if weakest and (weakest[2] or 0) < 2.0: conn.execute( "INSERT INTO watchlist_log (code, name, event, reason, old_signal, new_signal, price) " "VALUES (?,?,?,?,?,?,?)", (weakest[0], weakest[1] or "", "exit", f"容量{MAX_WATCH}淘汰为新标的{code}腾位", "", "已删除", 0)) conn.execute( "DELETE FROM holding_strategies WHERE code=? AND status='active' AND decision_type='自选策略'", (weakest[0],)) print(f" 🔴 容量淘汰: {weakest[0]} {weakest[1]} (RR={weakest[2]})", flush=True) else: print(f" ⏭ 自选已满{MAX_WATCH}且现有标的均RR>=2.0,{code}暂缓提拔", flush=True) processed += 1 continue cur = conn.execute(""" INSERT OR IGNORE INTO holding_strategies (code, name, price, entry_low, entry_high, stop_loss, take_profit, timing_signal, action, decision_type, strategy_type, status, rr_ratio, stock_category, created_at, updated_at, sector_context, quality_check) VALUES (?,?,?,?,?,?,?,?,?,'自选策略','scan', 'active',0,'关注',?,?,'', 'pending') """, (code, name, 0, el, eh, sl, tp, timing_signal, action, now, now)) newly_added = cur.rowcount > 0 conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,)) if newly_added: promoted += 1 processed += 1 print(f" ✅ {code} {name} 评分{score} → 已加入自选({timing_signal})", flush=True) else: print(f" ⏭ {code} {name} 已在自选策略中,标记promoted", flush=True) processed += 1 # 触发全量重评(仅新插入) if newly_added: try: import subprocess as _sp r = _sp.run(["python3", "/home/hmo/MoFin/deploy/profile-scripts/per_stock_reassess.py", code], capture_output=True, text=True, timeout=REASSESS_TIMEOUT) if r.returncode == 0: print(f" 重评完成", flush=True) else: print(f" 重评失败: {r.stderr.strip()[:100]}", flush=True) except Exception as e: print(f" 重评异常: {e}", flush=True) conn.commit() print(f"\n[PROMOTE] 本次处理{processed}个,提拔{promoted}只(剩余留待下批)", flush=True) # 推XMPP if promoted > 0: try: import urllib.request body = f"🤖 候选自动提拔: {promoted}只新入自选(评分降序优中选优,每批≤{MAX_NEW_PER_RUN})" req = urllib.request.Request("http://127.0.0.1:5805/", data=json.dumps({"to": "hmo@yoin.fun", "body": body, "type": "chat"}).encode(), headers={"Content-Type": "application/json"}) urllib.request.urlopen(req, timeout=5) print("[PROMOTE] XMPP 通知已发送", flush=True) except Exception as e: print(f"[PROMOTE] XMPP 通知失败: {e}", flush=True) conn.close() if __name__ == "__main__": main()