diff --git a/deploy/profile-scripts/hk_scanner.py b/deploy/profile-scripts/hk_scanner.py new file mode 100644 index 00000000..3abb081e --- /dev/null +++ b/deploy/profile-scripts/hk_scanner.py @@ -0,0 +1,192 @@ +#!/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%)。 +数据源:腾讯前复权日K(hk前缀,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()