Files
MoFin/deploy/profile-scripts/b_td1_v3_scanner.py
T

190 lines
7.2 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
# -*- coding: utf-8 -*-
"""b_td1_v3_scanner.py — B组超跌·原池优选 实盘扫描器(2026-08-17 择优激活落地)
由果及因(老莫指正):b_td1 原池(5000信号)信号太密集,用 score 每日top5截断 → 信号572/成交316/比1.8
实盘对齐回测:
池(b_td1 原池):dist_lo20 > 5(距20日低点>5%)——news3/mcap_q/pe_q 为回测外部因子,
实盘用可获取近似:mcap_q<0.3(市值分位,从 stock_daily 市值算)pe_q<0.3 暂缺则放宽
score(池内超跌评分):bias60深度 + rsi + sec_ret20 + ret5
每日 top5score 降序)
出场建议:tp15% / sl8% / max35日(原版模拟验证参数)
数据源:腾讯前复权日K(与 mr_scanner 同源,零偏差)+ 市值分位从 stock_daily
输出:candidates 表(sector='b_td1_v3'
用法:
python3 b_td1_v3_scanner.py # 完整扫描
python3 b_td1_v3_scanner.py --force # 忽略门控
python3 b_td1_v3_scanner.py --top N # 输出前 N 只(默认 5)
"""
import sys, json, sqlite3
from pathlib import Path
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent))
from indicators import calc_ma, calc_rsi
from market_data import fetch_tx_klines, get_stock_pool
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
TOP_N = 5
EXIT_CFG = {"tp_pct": 0.15, "sl_pct": 0.08, "max_hold_days": 35}
def load_regime():
"""当前温区(平滑优先)"""
try:
from regime_gate import get_current_regime
rg = get_current_regime()
if rg and rg.get("regime") != "unknown":
return rg.get("regime")
except Exception:
pass
try:
conn = sqlite3.connect(str(DB_PATH), timeout=5)
r = conn.execute("SELECT regime FROM market_regime WHERE market='a' ORDER BY date DESC LIMIT 1").fetchone()
conn.close()
return r[0] if r else "unknown"
except Exception:
return "unknown"
def mcap_quantile(code):
"""从 stock_daily 算市值分位(mcap_q 近似)"""
try:
conn = sqlite3.connect(str(DB_PATH), timeout=5)
row = conn.execute(
"SELECT amount, close FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 1",
(code,)).fetchone()
if not row or not row[0]:
conn.close()
return 0.3 # 缺省给中值(放宽)
# 全市场今日成交额分位
rows = conn.execute(
"SELECT amount FROM stock_daily WHERE date=(SELECT MAX(date) FROM stock_daily) AND amount IS NOT NULL"
).fetchall()
conn.close()
amounts = sorted([r[0] for r in rows if r[0]])
if not amounts:
return 0.3
import bisect
pos = bisect.bisect_left(amounts, row[0])
return round(pos / max(len(amounts), 1), 2)
except Exception:
return 0.3
def score_of(bias60, rsi, sec_ret20, ret5):
"""池内超跌评分(与回测 b_td1_v3_gen 一致)"""
sc = 0
if bias60 is not None:
sc += 40 if bias60 < -30 else 32 if bias60 < -20 else 20 if bias60 < -10 else 8
if rsi is not None:
sc += 30 if rsi < 30 else 24 if rsi < 40 else 14 if rsi < 50 else 6
if sec_ret20 is not None:
sc += 20 if sec_ret20 < -20 else 14 if sec_ret20 < -10 else 8 if sec_ret20 < 0 else 3
if ret5 is not None:
sc += 10 if ret5 < -25 else 7 if ret5 < -15 else 4 if ret5 < -8 else 1
return sc
def sec_ret20_approx(code):
"""行业20日涨幅近似:用该股所在板块指数或简化为大盘对照"""
# 实盘简化:返回 None(score 该分项给0),避免复杂行业数据依赖
return None
def check_b_td1(klines, code):
"""b_td1_v3 筛选:池条件 + score"""
if not klines or len(klines) < 60:
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
bias60 = (close - m60) / m60 * 100
# 池条件:dist_lo20 > 5(距20日低点>5%
lo20 = min(lows[max(0, i - 19):i + 1])
dist_lo20 = (close - lo20) / lo20 * 100 if lo20 > 0 else 0
if dist_lo20 <= 5:
return None
rsi = calc_rsi(closes)
rsi_v = rsi[i] if i < len(rsi) else None
prev5 = closes[i - 5] if i >= 5 else 0
ret5 = (close - prev5) / prev5 * 100 if prev5 > 0 else 0
# 市值分位
mcap_q = mcap_quantile(code)
if mcap_q >= 0.3:
return None # 池条件:小市值
# score
sec20 = sec_ret20_approx(code)
sc = score_of(bias60, rsi_v, sec20, ret5)
return {
"price": close, "bias60": round(bias60, 2), "rsi": round(rsi_v, 2) if rsi_v else None,
"ret5": round(ret5, 2), "dist_lo20": round(dist_lo20, 2), "mcap_q": mcap_q,
"score": sc, "target": round(close * (1 + EXIT_CFG["tp_pct"]), 2),
"stop_loss": round(close * (1 - EXIT_CFG["sl_pct"]), 2),
"date": klines[i]["date"],
}
def main():
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--force", action="store_true")
ap.add_argument("--top", type=int, default=TOP_N)
args = ap.parse_args()
regime = load_regime()
print(f"[b_td1_v3] {datetime.now().strftime('%H:%M')} 扫描开始 温区={regime}", flush=True)
# 温区门控:trend_down/choppy 才扫(超跌池主战场)
if not args.force and regime not in ("trend_down", "choppy"):
print(f" 温区 {regime} 非超跌池主战场,跳过", flush=True)
return
all_stocks, existing = get_stock_pool()
print(f" 股票池 {len(all_stocks)} 只", flush=True)
hits = []
for code, name in all_stocks:
try:
klines = fetch_tx_klines(code, datalen=120)
sig = check_b_td1(klines, code)
if sig:
hits.append((code, name, sig))
except Exception as e:
pass
# score 降序 top-N
hits.sort(key=lambda x: -x[2]["score"])
hits = hits[: args.top]
print(f" 命中 {len(hits)} 只(score降序前{args.top}", flush=True)
conn = sqlite3.connect(str(DB_PATH), timeout=10)
inserted = 0
for code, name, sig in hits:
reasons = (f"dist_lo20={sig['dist_lo20']}% bias60={sig['bias60']}% "
f"rsi={sig['rsi']} ret5={sig['ret5']}% mcap_q={sig['mcap_q']} score={sig['score']}")
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, "b_td1_v3", reasons,
f"{sig['price']*0.98:.2f}~{sig['price']:.2f}", sig["stop_loss"], sig["target"]))
inserted += 1
print(f" 🟢 {code} {name}{sig['price']} score={sig['score']} {reasons}", flush=True)
conn.commit()
conn.close()
print(f" ✅ 新增 {inserted} 只 b_td1_v3 候选", flush=True)
if __name__ == "__main__":
main()