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

193 lines
7.6 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 -*-
"""hk_scanner.py — 港股通深度超卖反弹扫描器(hk_mr1 实盘选股,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%
温区门控:只在港股 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)
"""
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
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[2] for b in bars] # close 在第3列(date,open,close,...
vols = [b[5] if len(b) > 5 else 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)}
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():
d = json.loads(p.read_text(encoding="utf-8"))
mk = (d.get("markets") or {}).get("hk") or {}
if mk.get("current_regime"):
return mk["current_regime"]
except Exception:
pass
try:
conn = sqlite3.connect(str(DB_PATH), timeout=5)
row = conn.execute(
"SELECT regime FROM market_regime WHERE market='hk' ORDER BY date DESC LIMIT 1").fetchone()
conn.close()
return row[0] if row else "unknown"
except Exception:
return "unknown"
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 主战场)──
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)
return
# ── 股票池:港股通名单 ──
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
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:
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)
conn.commit()
conn.close()
print(f" ✅ 新增 {inserted} 只 hk_mr1 候选(前 {top_n}", flush=True)
if __name__ == "__main__":
main()