- 与price_monitor同模式: 新鲜实例在跑则退出, 卡死超时则SIGKILL接管 - market_watch 600s / 扫描器 1500s 阈值 - 落实2026-08-05重型任务资源约束铁律第4条
245 lines
9.7 KiB
Python
245 lines
9.7 KiB
Python
#!/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"])
|
||
|
||
# ── 写 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()
|