Files
MoFin/deploy/profile-scripts/s2_scanner.py
T
xxm 6973d3599b 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%
2026-08-05 13:03:13 +08:00

212 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()