#!/usr/bin/env python3 """s2_scanner.py — S2 恐慌买超跌策略实盘扫描器(2026-08-05,策略家族成员) 策略家族架构(docs/v_mr_strategy.md §39):S1(v_weak 弱市甜区) + S2(恐慌买超跌) 同时运行、票自己对号入座、零重叠。S2 吃 v_weak 的真空带——大盘极端恐慌 (ADX 冲过甜区上沿、v_weak 按规则休眠的日子)正是 S2 大开张的日子。 入场条件(与 §39 回测严格对齐): 市场门控:大盘 RSI14 < 25(极端恐慌日) 个股: 1. bias60 < -6.8% : 收盘价在 MA60 下方超 6.8%(超跌) 2. r5f < -10% : 5日深崩(恐慌日里崩得越深越好,74%wr vs 浅崩55%) 3. dist_lo20 >= 10% : 离20日低点≥10%(崩前是强势股,恐慌陪葬品) 画像:大盘极端恐慌日,强势票被错杀。 出场建议(候选字段):结构出场=峰值回撤8%(数据归纳动态卖点),止损-12%兜底, 目标参考+30%。下游 watchlist 的 12维重评会接管实际出场决策(3+12 实盘流程)。 排序:r5f 升序(5日崩最深优先,§39 负面因子分析:深崩74%wr>浅崩55%wr) 候选写入 sector='s2_panic'(与 v_weak 的 sector='v_mr' 管道分离) 回测依据(§39):3261信号 / avg+16.6% / wr 85.6% 2020新冠+14%/88%、2022双底+21%/85%、2025恐慌+16%/87% 三次恐慌集群全盈利。 用法: python3 s2_scanner.py # 完整扫描(大盘RSI<25门控) python3 s2_scanner.py --force # 忽略门控强制扫描 python3 s2_scanner.py --top N # 输出前 N 只(默认 15) """ import sys, json, sqlite3 from pathlib import Path from datetime import datetime sys.path.insert(0, str(Path(__file__).parent)) # 2026-08-11 重构:工具函数从 mr_scanner 抽到公共模块,s2 直接引用公共模块 from indicators import calc_ma, calc_rsi from market_data import fetch_tx_klines, get_stock_pool DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") # ── S2 参数(§39 定稿)── S2_CFG = { "mkt_rsi_max": 25, # 大盘RSI14 < 25(极端恐慌) "bias60_max": -6.8, # bias60 < -6.8%(超跌) "r5f_max": -10, # r5f < -10%(5日深崩) "dist_lo20_min": 10, # 距20日低点 ≥ 10%(崩前强势) } EXIT_NOTE = {"tp_ref": 0.30, "sl_backstop": 0.12, "struct_dd": 0.08, "max_hold": 60} TOP_N = 15 def load_mkt_rsi(): """从 stock_daily 计算大盘 RSI14(与回测零偏差——回测用的就是 stock_daily)""" try: conn = sqlite3.connect(str(DB_PATH), timeout=5) rows = conn.execute( "SELECT date, close FROM stock_daily WHERE code='sh000001'" " ORDER BY date DESC LIMIT 40").fetchall() conn.close() if len(rows) < 20: return None, None rows = list(reversed(rows)) closes = [r[1] for r in rows] rsi = calc_rsi(closes) return rows[-1][0], rsi[-1] except Exception: return None, None def check_s2(klines): """S2 恐慌买超跌筛选。命中返回信号 dict,否则 None。""" if not klines or len(klines) < 70: return None closes = [k["close"] for k in klines] lows = [k["low"] for k in klines] i = len(klines) - 1 close = closes[i] if close <= 0: return None ma60 = calc_ma(closes, 60) m60 = ma60[i] if not m60 or m60 <= 0: return None # 1. bias60 < -6.8% bias60 = (close - m60) / m60 * 100 if bias60 >= S2_CFG["bias60_max"]: return None # 2. r5f < -10%(5日深崩) prev5 = closes[i - 5] if i >= 5 else 0 r5f = (close - prev5) / prev5 * 100 if prev5 > 0 else 0 if r5f >= S2_CFG["r5f_max"]: return None # 3. dist_lo20 >= 10%(崩前强势:离20日低点≥10%) lo20 = min(lows[max(0, i - 19):i + 1]) dist_lo20 = (close - lo20) / lo20 * 100 if lo20 > 0 else 0 if dist_lo20 < S2_CFG["dist_lo20_min"]: return None return { "price": close, "bias60": round(bias60, 2), "r5f": round(r5f, 2), "dist_lo20": round(dist_lo20, 2), "target": round(close * (1 + EXIT_NOTE["tp_ref"]), 2), "stop_loss": round(close * (1 - EXIT_NOTE["sl_backstop"]), 2), "date": klines[i]["date"], } def _singleton_guard(max_age_sec, script_tag): """自愈式单例守卫(2026-08-05 进程堆积事故后统一加装)""" import subprocess as _sp, os as _os, sys as _sys my_pid = _os.getpid() try: out = _sp.run(["ps", "-C", "python3", "-o", "pid,etimes,cmd"], capture_output=True, text=True, timeout=10).stdout for line in out.splitlines(): if script_tag not in line: continue parts = line.split(None, 2) if len(parts) < 3: continue try: pid = int(parts[0]); age = int(parts[1]) except ValueError: continue if pid == my_pid: continue if age > max_age_sec: try: _os.kill(pid, 9) print(f"[guard] SIGKILL卡死实例 pid={pid} age={age}s", flush=True) except ProcessLookupError: pass else: print(f"[guard] 已有新鲜实例 pid={pid} age={age}s 在跑, 本实例退出", flush=True) _sys.exit(0) except Exception as _e: print(f"[guard] 守卫异常(放行): {_e}", flush=True) def main(): force = "--force" in sys.argv _singleton_guard(1500, "s2_scanner.py") top_n = TOP_N if "--top" in sys.argv: try: top_n = int(sys.argv[sys.argv.index("--top") + 1]) except (ValueError, IndexError): pass print(f"[S2] {datetime.now().strftime('%H:%M')} S2 恐慌买超跌扫描开始", flush=True) # ── 大盘 RSI 门控(唯一市场开关:RSI14<25 极端恐慌)── mkt_date, mkt_rsi = load_mkt_rsi() if mkt_rsi is not None: print(f" 大盘RSI14({mkt_date}): {mkt_rsi:.1f}", flush=True) if mkt_rsi >= S2_CFG["mkt_rsi_max"] and not force: print(f" ⏭ RSI={mkt_rsi:.1f} ≥ {S2_CFG['mkt_rsi_max']},非恐慌日,S2 休眠", flush=True) return if mkt_rsi >= S2_CFG["mkt_rsi_max"]: print(f" ⚠ --force 强制扫描(RSI={mkt_rsi:.1f} 非恐慌)", flush=True) else: print(" ⚠ 大盘RSI不可用,默认执行扫描", flush=True) # ── 幂等:当天已有 s2_panic 候选则跳过 ── conn = sqlite3.connect(str(DB_PATH), timeout=5) try: _today = datetime.now().strftime("%Y-%m-%d") _n = conn.execute( "SELECT COUNT(*) FROM candidates WHERE sector='s2_panic' AND substr(created_at,1,10)=?" " AND reason LIKE 'S2恐慌买%'", (_today,)).fetchone()[0] except Exception: _n = 0 conn.close() if _n > 0 and not force: print(f" 已有 {_n} 条今日 s2_panic 候选,跳过(--force 可强制)", flush=True) return # ── 股票池(与 v_weak 同口径)── all_stocks, existing = get_stock_pool() print(f" 股票池: {len(all_stocks)}只A股", flush=True) if not all_stocks: print(" ⚠ 股票池为空", flush=True) return from concurrent.futures import ThreadPoolExecutor, as_completed pool = [c for c in all_stocks if c not in existing] found = [] done = 0 with ThreadPoolExecutor(max_workers=8) as ex: fut_map = {ex.submit(fetch_tx_klines, c): c for c in pool} for fut in as_completed(fut_map): code = fut_map[fut] done += 1 klines = fut.result() if klines: sig = check_s2(klines) if sig: found.append((code, sig)) if done % 400 == 0: print(f" 已扫描 {done}/{len(pool)}", flush=True) print(f" 命中 S2 条件: {len(found)} 只", flush=True) # 排序:r5f 升序(5日崩最深优先,§39:深崩74%wr > 浅崩55%wr) found.sort(key=lambda x: x[1]["r5f"]) # ── 写 candidates(sector='s2_panic',UPSERT)── conn = sqlite3.connect(str(DB_PATH), timeout=5) inserted = 0 for code, sig in found[:top_n]: name = code try: r = conn.execute("SELECT name FROM stocks WHERE code=?", (code,)).fetchone() if r and r[0]: name = r[0] except Exception: pass price = sig["price"] entry_low = round(price * 0.97, 2) entry_high = round(price * 1.02, 2) reasons = (f"S2恐慌买(bias60={sig['bias60']}% r5f={sig['r5f']}% " f"dist_lo20={sig['dist_lo20']}% | 结构出场:峰值回撤8%, " f"止损-12%兜底, 目标参考+30%)") exists = conn.execute( "SELECT code FROM candidates WHERE code=? AND (promoted IS NULL OR promoted=0)", (code,)).fetchone() if exists: continue conn.execute( "INSERT INTO candidates (code, name, sector, reason, " "entry_range, stop_loss, target, created_at) " "VALUES (?,?,?,?,?,?,?,datetime('now','localtime')) " "ON CONFLICT(code) DO UPDATE SET " "name=excluded.name, sector=excluded.sector, reason=excluded.reason, " "entry_range=excluded.entry_range, stop_loss=excluded.stop_loss, target=excluded.target", (code, name, "s2_panic", reasons, f"{entry_low}~{entry_high}", sig["stop_loss"], sig["target"])) inserted += 1 print(f" 🟢 {code} {name} 价{price} bias60={sig['bias60']}% r5f={sig['r5f']}% dist_lo20={sig['dist_lo20']}%", flush=True) conn.commit() conn.close() print(f" ✅ 新增 {inserted} 只 s2_panic 候选(前 {top_n},r5f最深优先)", flush=True) if __name__ == "__main__": main()