339 lines
14 KiB
Python
339 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""mr_scanner.py — v_weak 弱市策略实盘扫描器(2026-08-05 从 v_mr 精选版切换)
|
||
|
||
v_weak 是六步法+12维框架定稿的弱市策略(docs/v_mr_strategy.md §19/§21),
|
||
全市场 10y 验证:234笔 / wr 62.4% / avg +7.17% / 8槽组合 ret 178.3% / cagr 12.8% / dd 16.4%。
|
||
v_mr 已被证伪(2018 依赖 + 去2018 掷硬币,§12),v_weak 是其替代。
|
||
|
||
入场条件(v_weak 六条件,与回测严格对齐):
|
||
市场门控(main):大盘 MA20 下方(trend_down/choppy)+ 大盘 ADX ∈ [25,30) 甜区
|
||
个股:
|
||
1. bias60 ∈ [-35, -20) : 深超跌但不过深(过深易续跌)
|
||
2. RSI14 ≤ 25 : 极度超卖
|
||
3. r5f ≤ -3% : 5日急跌(下跌动能确认)
|
||
4. dist_lo20 < 5% : 距20日低点 < 5%(下方有支撑承接,§17)
|
||
5. 信号日收阳 : 入场重评确认=资金进场(§19)
|
||
6. 缩量 vol5/vol20<1.0 : 入场重评确认=抛压衰竭(§19)
|
||
出场建议:tp=+30% / sl=-12% / max_hold=40 交易日(RR 2.5:1)
|
||
排序:bias60 升序(最深超跌优先,§15 自然序=隐式质量排序)
|
||
候选写入 sector='v_mr'(管道槽位名不变,下游兼容)
|
||
|
||
市场门控(2026-08-02 新增):
|
||
- 只读 market_regime 表,regime 为 trend_down 或 choppy 时启用扫描
|
||
(v_mr 主战场:下跌趋势 + 震荡市;趋势市让位 v_next4)
|
||
- trend_up 时跳过(v_next4 追涨主战场,不扫超跌)
|
||
|
||
数据源:腾讯前复权日K(qfq),datalen=120(覆盖 MA60 + 60日回看 + RSI 收敛)
|
||
指标算法:与 backtest_framework.py 完全一致(内联,零偏差)
|
||
输出:candidates 表(sector='v_mr'),与 accumulation_scanner 同 UPSERT 模式
|
||
|
||
用法:
|
||
python3 mr_scanner.py # 完整扫描(regime 门控)
|
||
python3 mr_scanner.py --force # 忽略 regime 门控强制扫描
|
||
python3 mr_scanner.py --top N # 输出前 N 只(默认 10)
|
||
"""
|
||
import sys, json, urllib.request, re, time, sqlite3
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
|
||
# 2026-08-11 重构:工具函数抽取到公共模块(indicators/market_data)
|
||
# 原 calc_ma/calc_rsi/calc_atr/calc_obv → indicators.py
|
||
# 原 fetch_tx_klines/get_stock_pool → market_data.py
|
||
# 保留本文件的 import 兼容(从公共模块导入同名函数)
|
||
from indicators import calc_ma, calc_rsi, calc_atr, calc_obv
|
||
from market_data import fetch_tx_klines, get_stock_pool
|
||
|
||
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
|
||
UA = "Mozilla/5.0"
|
||
|
||
# ── v_weak 参数(2026-08-03 六步法+12维框架定稿,见 docs/v_mr_strategy.md §19/§21)──
|
||
# 弱市(大盘MA20下) + 大盘ADX∈[25,30]甜区(门控在main) + 个股六条件
|
||
WEAK_CFG = {
|
||
"bias60_min": -35, # bias60 ∈ [-35, -20):深超跌但不过深(过深易续跌)
|
||
"bias60_max": -20,
|
||
"rsi_max": 25, # RSI ≤ 25:极度超卖
|
||
"r5f_max": -3, # 5日急跌 ≤ -3%(下跌动能确认)
|
||
"dist_lo20_max": 5, # 距20日低点 < 5%(下方有支撑承接)
|
||
"vol_shrink_max": 1.0, # 缩量 vol5/vol20 < 1.0(抛压衰竭,入场重评确认)
|
||
"require_yang": True, # 信号日收阳(资金进场,入场重评确认)
|
||
}
|
||
EXIT_CFG = {"tp_pct": 0.30, "sl_pct": 0.12, "max_hold_days": 40}
|
||
|
||
TOP_N = 10
|
||
|
||
|
||
def load_regime():
|
||
"""读取当前温区。优先平滑温区(regime_gate K=5),回退原始 market_regime。
|
||
adx 仍需从原始表补(甜区门控用)。"""
|
||
# 平滑温区优先
|
||
try:
|
||
from regime_gate import get_current_regime
|
||
_rg = get_current_regime()
|
||
if _rg and _rg.get("regime") != "unknown":
|
||
_base = {"date": _rg.get("date", ""), "regime": _rg.get("regime"), "smoothed": True}
|
||
# 补 adx/above_ma20(甜区门控需要)
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
||
row = conn.execute(
|
||
"SELECT date, above_ma20, adx, regime FROM market_regime "
|
||
"ORDER BY date DESC LIMIT 1").fetchone()
|
||
conn.close()
|
||
if row:
|
||
_base["above_ma20"] = bool(row[1])
|
||
_base["adx"] = row[2]
|
||
except Exception:
|
||
pass
|
||
return _base
|
||
except Exception:
|
||
pass
|
||
# 回退原始
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
||
row = conn.execute(
|
||
"SELECT date, above_ma20, adx, regime FROM market_regime "
|
||
"ORDER BY date DESC LIMIT 1").fetchone()
|
||
conn.close()
|
||
if row:
|
||
return {"date": row[0], "above_ma20": bool(row[1]), "adx": row[2], "regime": row[3]}
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
# ── v_mr 筛选(精选版:基线6条件 + 正面4因子 + 剔除4负面)──
|
||
|
||
def check_vmr(klines, mkt_adx=None):
|
||
"""对单只股票做 v_weak 入场筛选(2026-08-05 从 v_mr 精选版切换)。
|
||
六条件:bias60∈[-35,-20) + RSI≤25 + 5日急跌≤-3% + 距20日低点<5% + 收阳 + 缩量。
|
||
klines 为升序日K。mkt_adx 保留参数兼容(甜区门控在 main)。"""
|
||
if not klines or len(klines) < 70:
|
||
return None
|
||
closes = [k["close"] for k in klines]
|
||
highs = [k["high"] for k in klines]
|
||
lows = [k["low"] for k in klines]
|
||
vols = [k["volume"] for k in klines]
|
||
i = len(klines) - 1 # 最新一日
|
||
close = closes[i]
|
||
if close <= 0:
|
||
return None
|
||
|
||
ma60 = calc_ma(closes, 60)
|
||
ma20 = calc_ma(closes, 20)
|
||
rsi_all = calc_rsi(closes)
|
||
rsi = rsi_all[i] if i < len(rsi_all) else None
|
||
m60 = ma60[i]
|
||
if not m60 or m60 <= 0 or rsi is None:
|
||
return None
|
||
|
||
# 1. bias60 ∈ [-35, -20):深超跌但不过深
|
||
bias60 = (close - m60) / m60 * 100
|
||
if not (WEAK_CFG["bias60_min"] <= bias60 < WEAK_CFG["bias60_max"]):
|
||
return None
|
||
|
||
# 2. RSI ≤ 25:极度超卖
|
||
if rsi > WEAK_CFG["rsi_max"]:
|
||
return None
|
||
|
||
# 3. 5日急跌 ≤ -3%(下跌动能确认)
|
||
prev5 = closes[i - 5] if i >= 5 else 0
|
||
r5f = (close - prev5) / prev5 * 100 if prev5 > 0 else 0
|
||
if r5f > WEAK_CFG["r5f_max"]:
|
||
return None
|
||
|
||
# 4. 距20日低点 < 5%(下方有支撑承接,§17 老莫洞察)
|
||
lo20 = min(lows[max(0, i - 19):i + 1])
|
||
dist_lo20 = (close - lo20) / lo20 * 100 if lo20 > 0 else 999
|
||
if dist_lo20 >= WEAK_CFG["dist_lo20_max"]:
|
||
return None
|
||
|
||
# 5. 信号日收阳(入场重评确认:资金进场,§19)
|
||
if WEAK_CFG["require_yang"] and close <= klines[i]["open"]:
|
||
return None
|
||
|
||
# 6. 缩量 vol5/vol20 < 1.0(入场重评确认:抛压衰竭,§19)
|
||
vol5 = sum(vols[max(0, i - 4):i + 1]) / 5 if i >= 4 else 0
|
||
vol20 = sum(vols[max(0, i - 19):i + 1]) / 20 if i >= 19 else 0
|
||
vol_shrink = vol5 / vol20 if vol20 > 0 else 99
|
||
if vol_shrink >= WEAK_CFG["vol_shrink_max"]:
|
||
return None
|
||
|
||
# 命中 → 出场建议(精选版 exit_cfg)
|
||
tp_pct = EXIT_CFG["tp_pct"]
|
||
sl_pct = EXIT_CFG["sl_pct"]
|
||
target = round(close * (1 + tp_pct), 2)
|
||
stop = round(close * (1 - sl_pct), 2)
|
||
|
||
return {
|
||
"price": close,
|
||
"bias60": round(bias60, 2),
|
||
"rsi": round(rsi, 2),
|
||
"r5f": round(r5f, 2),
|
||
"dist_lo20": round(dist_lo20, 2),
|
||
"vol_shrink": round(vol_shrink, 3),
|
||
"mkt_adx": mkt_adx,
|
||
"target": target,
|
||
"stop_loss": stop,
|
||
"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, "mr_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"[MR] {datetime.now().strftime('%H:%M')} v_mr 实盘扫描开始", flush=True)
|
||
|
||
# ── regime 门控 + 大盘 ADX(负面因子用 + 甜区门控 2026-08-03 12维发现)──
|
||
regime = load_regime()
|
||
mkt_adx = None
|
||
if regime:
|
||
rg = regime["regime"]
|
||
mkt_adx = regime["adx"]
|
||
print(f" 市场阶段: {regime['date']} → {rg} (adx={regime['adx']})", flush=True)
|
||
if rg not in ("trend_down", "choppy") and not force:
|
||
print(f" ⏭ {rg} 非 v_mr 主战场(trend_down/choppy 才扫),跳过", flush=True)
|
||
return
|
||
if rg not in ("trend_down", "choppy"):
|
||
print(f" ⚠ --force 强制扫描(当前 {rg})", flush=True)
|
||
# 12维框架验证(2026-08-03): 弱市深超跌edge的前提是大盘ADX∈[25,30](甜区)
|
||
# ADX<20 avg+1.63% / 20-25 -1.24% / 25-30 +4.89% / 30-40 -0.04% / >=40 -0.10%
|
||
# 非甜区时 v_mr 信号质量显著下降,默认跳过(--force 可强制)
|
||
if mkt_adx is not None and not (25 <= mkt_adx < 30) and not force:
|
||
print(f" ⏭ 大盘ADX={mkt_adx:.1f} 非甜区[25,30)(12维验证:强跌/弱跌市超跌信号质量差),跳过", flush=True)
|
||
return
|
||
if mkt_adx is not None and not (25 <= mkt_adx < 30):
|
||
print(f" ⚠ --force 强制扫描(当前ADX={mkt_adx:.1f} 非甜区)", flush=True)
|
||
else:
|
||
print(" ⚠ market_regime 不可用,默认执行扫描(v_mr 全市场可用)", flush=True)
|
||
|
||
# ── 幂等检查:当天已扫过 v_mr 则跳过(避免 30 分钟调度重复全扫描)──
|
||
import sqlite3 as _sq
|
||
_conn = _sq.connect(str(DB_PATH), timeout=5)
|
||
try:
|
||
_today = datetime.now().strftime("%Y-%m-%d")
|
||
# 幂等只认扫描器自己写的候选(reason以v_weak/v_mr精选开头)
|
||
# —— watchlist_auto_exit 回归池也写 sector='v_mr',不能算数(2026-08-05 bug修复)
|
||
_n = _conn.execute(
|
||
"SELECT COUNT(*) FROM candidates WHERE sector='v_mr' AND substr(created_at,1,10)=?"
|
||
" AND (reason LIKE 'v_weak%' OR reason LIKE 'v_mr精选%')",
|
||
(_today,)).fetchone()[0]
|
||
except Exception:
|
||
_n = 0
|
||
_conn.close()
|
||
if _n > 0 and not force:
|
||
print(f" 已有 {_n} 条今日 v_mr 候选,跳过重复扫描(--force 可强制)", flush=True)
|
||
return
|
||
|
||
# ── 股票池 ──
|
||
all_stocks, existing = get_stock_pool()
|
||
print(f" 股票池: {len(all_stocks)}只A股, 已有策略: {len(existing)}只", flush=True)
|
||
if not all_stocks:
|
||
print(" ⚠ stocks 表为空", flush=True)
|
||
return
|
||
|
||
# 并发拉日K(ThreadPool 8 并发)
|
||
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_sina_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_vmr(klines, mkt_adx=mkt_adx)
|
||
if sig:
|
||
found.append((code, sig))
|
||
if done % 400 == 0:
|
||
print(f" 已扫描 {done}/{len(pool)}", flush=True)
|
||
|
||
print(f" 命中 v_weak 条件: {len(found)} 只", flush=True)
|
||
|
||
# 排序:同分按偏度,精选因子已强过滤,按 rsi_delta 降序(止跌确认最强优先)
|
||
found.sort(key=lambda x: x[1]["bias60"]) # 最深超跌优先(§15 自然序)
|
||
|
||
# ── 写 candidates 表(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)
|
||
sl = sig["stop_loss"]
|
||
tp = sig["target"]
|
||
reasons = (f"v_weak(bias60={sig['bias60']}% rsi={sig['rsi']} "
|
||
f"r5f={sig['r5f']}% dist_lo20={sig['dist_lo20']}% "
|
||
f"vol={sig['vol_shrink']} 收阳+缩量确认)")
|
||
# 检查是否已在 candidates 且未 promoted
|
||
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, "v_mr", reasons,
|
||
f"{entry_low}~{entry_high}", sl, tp))
|
||
inserted += 1
|
||
print(f" 🟢 {code} {name} 价{price} bias60={sig['bias60']}% "
|
||
f"rsi={sig['rsi']} r5f={sig['r5f']}% {reasons}", flush=True)
|
||
conn.commit()
|
||
conn.close()
|
||
print(f" ✅ 新增 {inserted} 只 v_weak 候选(前 {top_n})", flush=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|