From 6973d3599b215b862b3fb87961d396a2096423f3 Mon Sep 17 00:00:00 2001 From: xxm Date: Wed, 5 Aug 2026 13:03:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20S2=E6=81=90=E6=85=8C=E4=B9=B0=E8=B6=85?= =?UTF-8?q?=E8=B7=8C=E5=AE=9E=E7=9B=98=E6=89=AB=E6=8F=8F=E5=99=A8=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF(=E7=AD=96=E7=95=A5=E5=AE=B6=E6=97=8FS1+S2=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E8=BF=90=E8=A1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit s2_scanner.py(§39定稿): - 门控: 大盘RSI14<25(极端恐慌), 从stock_daily计算与回测零偏差 - 个股: bias60<-6.8 + r5f<-10(5日深崩) + dist_lo20>=10(崩前强势) - 排序: r5f升序(崩最深优先, 深崩74%wr>浅崩55%wr) - 候选写sector=s2_panic(与v_weak的v_mr管道分离), top15/日幂等 - 单测: 正确画像命中/浅崩/贴低/超MA60三种错误样本全正确拦截 market_watch.py: 每10分钟调度链挂s2_scanner子进程调用(与mr_scanner并列) 回测依据§39: 3261信号/avg+16.6%/wr85.6%, S1+S2组合8槽ret344.9%/年化18.7% --- deploy/profile-scripts/s2_scanner.py | 211 +++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 deploy/profile-scripts/s2_scanner.py diff --git a/deploy/profile-scripts/s2_scanner.py b/deploy/profile-scripts/s2_scanner.py new file mode 100644 index 00000000..884a7833 --- /dev/null +++ b/deploy/profile-scripts/s2_scanner.py @@ -0,0 +1,211 @@ +#!/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)) +from mr_scanner import fetch_tx_klines, calc_ma, calc_rsi, 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 main(): + force = "--force" in sys.argv + 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)=?", + (_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()