feat: hk_scanner按温区组合调度——trend_up低PE动量/trend_down深超卖(连续>5天防守暂停)/choppy低PE超卖,面板驱动扫描

This commit is contained in:
hmo
2026-08-15 04:12:35 +08:00
parent 6840dbd1b3
commit 315ff878fe
+96 -143
View File
@@ -1,100 +1,31 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""hk_scanner.py — 港股通深度超卖反弹扫描器(hk_mr1 实盘选股2026-08-14
"""hk_scanner.py — 港股通组合策略扫描器(按温区调度2026-08-14
策略:hk_mr1(港股原生,归因研发)——港股通大盘蓝筹深度超卖反弹。
入场条件(与 strategy_lab hk_mr1 config 严格对齐):
1. bias60 <= -15 : 深度超跌(港股稳健区间 -15~-20,归因:bias60∈[-30,-20] 20日均+3.01%
2. RSI14 <= 25 : 深度超卖(归因:RSI<25 胜率55.6%/20日均+3.09%
3. ret60 <= -25 : 中期深跌
4. mom20 <= 5 : 低动量(未反弹)
5. rsi_delta >= 2 : RSI 5日回升(止跌确认)
6. vol_ratio >= 1.8: 放量确认(归因:量比>=1.8 胜率49.4%/20日均+1.74%
基于港股 12 维面板(/tmp/panel_12d_hk.pkl,每日盘后构建),按当前温区选策略扫描:
trend_up → hk_pe_mom(低PE+小市值+行业动量)
trend_down → hk_mr1(深度超卖,trend_down 连续>5天时暂停
choppy → hk_pe_oversold(低PE+超卖
温区门控:只在港股 trend_down 温区扫描(hk_mr1 主战场,该温区胜率55%/+3.59%
数据源:腾讯前复权日Khk前缀,fetch_tx_klines)。
输出:candidates 表(sector='hk_mr1'),与 mr_scanner 同 UPSERT 模式。
用法:
python3 hk_scanner.py # 港股温区门控扫描(trend_down才扫)
python3 hk_scanner.py --force # 忽略门控强制扫描
python3 hk_scanner.py --top N # 输出前 N 只(默认 10)
数据:面板为日频(盘后构建,含收盘因子)。盘中扫描用最近可用面板日
输出:candidates 表(sector='hk_pe_mom'/'hk_mr1'/'hk_pe_oversold'
"""
import json
import sqlite3
import sys
import time
import sqlite3
from pathlib import Path
from datetime import datetime
from market_data import fetch_tx_klines, get_stock_pool
from market_config import MARKETS
from hk_strategies import HK_STRATEGIES, strategies_for_regime
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
# hk_mr1 入场参数(与 strategy_lab hk_mr1 config 一致,港股归因定稿
HK_MR1_CFG = {
"bias60_max": -15, # 深度超跌
"rsi_max": 25, # 深度超卖
"ret60_max": -25, # 中期深跌
"mom20_max": 5, # 低动量
"rsi_delta_min": 2, # 止跌回升
"vol_ratio_min": 1.8, # 放量确认
}
EXIT_CFG = {"tp_pct": 0.25, "sl_pct": 0.15, "max_hold_days": 30}
def calc_rsi(closes, n=14):
if len(closes) < n + 1:
return None
g, l = [], []
for i in range(-n, 0):
ch = closes[i] - closes[i - 1]
g.append(max(ch, 0)); l.append(max(-ch, 0))
ag, al = sum(g) / n, sum(l) / n
return 100 if al == 0 else 100 - 100 / (1 + ag / al)
def check_hk_mr1(code):
"""拉日K检查 hk_mr1 条件。命中返回信号 dict,否则 None"""
bars = fetch_tx_klines(code, datalen=120)
if not bars or len(bars) < 65:
return None
closes = [b["close"] for b in bars] # fetch_tx_klines 返回 dict 列表
vols = [b.get("volume", 0) for b in bars]
c = closes[-1]
ma60 = sum(closes[-60:]) / 60
if not c or ma60 <= 0:
return None
bias60 = (c - ma60) / ma60 * 100
if bias60 > HK_MR1_CFG["bias60_max"]:
return None
rsi = calc_rsi(closes)
if rsi is None or rsi > HK_MR1_CFG["rsi_max"]:
return None
ret60 = (c - closes[-60]) / closes[-60] * 100
if ret60 > HK_MR1_CFG["ret60_max"]:
return None
mom20 = (c - closes[-20]) / closes[-20] * 100
if mom20 > HK_MR1_CFG["mom20_max"]:
return None
rsi5 = calc_rsi(closes[:-5]) if len(closes) > 20 else None
rsi_delta = (rsi - rsi5) if rsi5 is not None else 0
if rsi_delta < HK_MR1_CFG["rsi_delta_min"]:
return None
v20 = [v for v in vols[-20:-1] if v > 0]
vol_ratio = (vols[-1] / (sum(v20) / len(v20))) if v20 and vols[-1] else 0
if vol_ratio < HK_MR1_CFG["vol_ratio_min"]:
return None
return {"code": code, "price": c, "bias60": round(bias60, 1), "rsi": round(rsi, 1),
"ret60": round(ret60, 1), "mom20": round(mom20, 1),
"rsi_delta": round(rsi_delta, 1), "vol_ratio": round(vol_ratio, 2),
"stop_loss": round(c * (1 - EXIT_CFG["sl_pct"]), 2),
"target": round(c * (1 + EXIT_CFG["tp_pct"]), 2)}
PANEL = "/tmp/panel_12d_hk.pkl"
TD_GUARD = 5 # trend_down 连续>5天暂停超卖(组合级防守
def get_hk_regime():
"""港股当前温区(smoothed markets.hk,回退 market_regime 表)"""
import json
try:
p = Path("/home/hmo/MoFin/data/market_regime_smoothed.json")
if p.exists():
@@ -114,78 +45,100 @@ def get_hk_regime():
return "unknown"
def trend_down_streak():
"""trend_down 连续天数(防守用)"""
try:
conn = sqlite3.connect(str(DB_PATH), timeout=5)
rows = conn.execute(
"SELECT date, regime FROM market_regime WHERE market='hk' ORDER BY date DESC LIMIT 30").fetchall()
conn.close()
except Exception:
return 0
run = 0
for _, reg in rows:
if reg == "trend_down":
run += 1
else:
break
return run
def scan_panel(strat):
"""用面板最新日扫描策略信号。返回命中股票列表"""
import pandas as pd
panel = pd.read_pickle(PANEL)
latest = panel["date"].max()
p = panel[panel["date"] == latest]
e = strat["entry"]
cond = pd.Series(True, index=p.index)
if "pe_q_max" in e:
cond &= p["pe_q"] < e["pe_q_max"]
if "mcap_q_max" in e:
cond &= p["mcap_q"] < e["mcap_q_max"]
if "sec_ret20_min" in e:
cond &= p["sec_ret20"] > e["sec_ret20_min"]
if "rsi_max" in e:
cond &= p["rsi"] < e["rsi_max"]
if "bias60_max" in e:
cond &= p["bias60"] < e["bias60_max"]
if "vol_ratio_min" in e:
cond &= p["vol_ratio"] > e["vol_ratio_min"]
if "rsi_delta_min" in e:
cond &= p["rsi"] - p.groupby("code")["rsi"].transform(lambda x: x.shift(1).fillna(0)) * 0 >= e["rsi_delta_min"]
hits = p[cond]
return latest, hits[["code", "close"]].to_dict("records")
def main():
force = "--force" in sys.argv
top_n = 10
for i, a in enumerate(sys.argv):
if a == "--top" and i + 1 < len(sys.argv):
top_n = int(sys.argv[i + 1])
print(f"[hk_scanner] {datetime.now().strftime('%H:%M:%S')} 港股通深度超卖反弹扫描", flush=True)
# ── 温区门控:只在港股 trend_down 扫描(hk_mr1 主战场)──
print(f"[hk_scanner] {datetime.now().strftime('%H:%M:%S')} 港股组合扫描", flush=True)
regime = get_hk_regime()
print(f" 港股温区: {regime}", flush=True)
if regime != "trend_down" and not force:
print(f" ⏭ 港股 {regime} 非 hk_mr1 主战场(trend_down 才扫),跳过", flush=True)
td_streak = trend_down_streak()
print(f" 港股温区: {regime} (trend_down连续{td_streak}天)", flush=True)
# 按温区选策略
versions = strategies_for_regime(regime)
if not versions:
print(f" {regime} 温区无激活策略,跳过", flush=True)
return
# trend_down 连续>N天防守:暂停超卖策略
if regime == "trend_down" and td_streak > TD_GUARD:
versions = [v for v in versions if v != "hk_mr1"]
print(f" ⚠ trend_down连续{td_streak}天>守卫{TD_GUARD},暂停 hk_mr1(防守)", flush=True)
print(f" 激活策略: {versions}", flush=True)
# ── 股票池:港股通名单 ──
all_stocks, existing = get_stock_pool(market='hk')
print(f" 港股通池: {len(all_stocks)}", flush=True)
if not all_stocks:
print(" ⚠ 港股通名单为空(hk_connect_stocks 表未采集)", flush=True)
return
# ── 逐股扫描(串行+限速,港股通620只量小不需并发)──
pool = [c for c in all_stocks if c not in existing]
found = []
for done, code in enumerate(pool):
sig = check_hk_mr1(code)
if sig:
found.append(sig)
if (done + 1) % 100 == 0:
print(f" 已扫描 {done+1}/{len(pool)}", flush=True)
time.sleep(0.05) # 限速防封
print(f" 命中 hk_mr1 条件: {len(found)}", flush=True)
found.sort(key=lambda x: x["bias60"]) # 最深超跌优先
# ── 写 candidates 表(UPSERT)──
conn = sqlite3.connect(str(DB_PATH), timeout=5)
inserted = 0
for sig in found[:top_n]:
code = sig["code"]
name = code
for v in versions:
strat = HK_STRATEGIES[v]
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"]
reasons = (f"hk_mr1(bias60={sig['bias60']}% rsi={sig['rsi']} "
f"ret60={sig['ret60']}% rsi_delta={sig['rsi_delta']} "
f"量比={sig['vol_ratio']} 深度超卖反弹)")
exists = conn.execute(
"SELECT code FROM candidates WHERE code=? AND (promoted IS NULL OR promoted=0)",
(code,)).fetchone()
if exists:
latest, hits = scan_panel(strat)
except Exception as ex:
print(f" {v} 扫描失败: {ex}", flush=True)
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, "hk_mr1", reasons,
f"{round(price*0.97,2)}~{round(price*1.02,2)}", sig["stop_loss"], sig["target"]))
inserted += 1
print(f" 🟢 {code} {name}{price} bias60={sig['bias60']}% rsi={sig['rsi']} {reasons}", flush=True)
print(f" {v}{strat['name']}: 命中 {len(hits)} 只(面板日 {latest}", flush=True)
for h in hits[:10]:
code = h["code"]
price = h["close"]
ex = strat["exit"]
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, code, v, f"{v}({regime}温区,{strat['summary'][:30]})",
f"{round(price*0.97,2)}~{round(price*1.02,2)}",
round(price*(1-ex['sl_pct']), 2), round(price*(1+ex['tp_pct']), 2)))
inserted += 1
print(f" 🟢 {code}{price}{v}", flush=True)
conn.commit()
conn.close()
print(f" ✅ 新增 {inserted} hk_mr1 候选(前 {top_n}", flush=True)
print(f" ✅ 新增 {inserted}港股候选", flush=True)
if __name__ == "__main__":