feat: S2恐慌买超跌实盘扫描器上线(策略家族S1+S2同时运行)

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%
This commit is contained in:
xxm
2026-08-05 13:03:13 +08:00
parent ffabc88a54
commit 6973d3599b
+211
View File
@@ -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"])
# ── 写 candidatessector='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()