From 315ff878feee1113b6f9cd2b0cb9cf9bb90b7b70 Mon Sep 17 00:00:00 2001 From: hmo Date: Sat, 15 Aug 2026 04:12:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20hk=5Fscanner=E6=8C=89=E6=B8=A9=E5=8C=BA?= =?UTF-8?q?=E7=BB=84=E5=90=88=E8=B0=83=E5=BA=A6=E2=80=94=E2=80=94trend=5Fu?= =?UTF-8?q?p=E4=BD=8EPE=E5=8A=A8=E9=87=8F/trend=5Fdown=E6=B7=B1=E8=B6=85?= =?UTF-8?q?=E5=8D=96(=E8=BF=9E=E7=BB=AD>5=E5=A4=A9=E9=98=B2=E5=AE=88?= =?UTF-8?q?=E6=9A=82=E5=81=9C)/choppy=E4=BD=8EPE=E8=B6=85=E5=8D=96,?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E9=A9=B1=E5=8A=A8=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/profile-scripts/hk_scanner.py | 239 +++++++++++---------------- 1 file changed, 96 insertions(+), 143 deletions(-) diff --git a/deploy/profile-scripts/hk_scanner.py b/deploy/profile-scripts/hk_scanner.py index 1177679c..66782e25 100644 --- a/deploy/profile-scripts/hk_scanner.py +++ b/deploy/profile-scripts/hk_scanner.py @@ -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%)。 -数据源:腾讯前复权日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) +数据:面板为日频(盘后构建,含收盘因子)。盘中扫描用最近可用面板日。 +输出: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__":