Files
MoFin/deploy/profile-scripts/s2_scanner.py
T
xxm 2bca726418 fix: market_watch/mr_scanner/s2_scanner 统一加装自愈式单例守卫
- 与price_monitor同模式: 新鲜实例在跑则退出, 卡死超时则SIGKILL接管
- market_watch 600s / 扫描器 1500s 阈值
- 落实2026-08-05重型任务资源约束铁律第4条
2026-08-05 15:23:00 +08:00

245 lines
9.7 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 _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)=?",
(_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()