#!/usr/bin/env python3 # -*- coding: utf-8 -*- """hk_backtest.py — 港股策略回测+资金模拟(独立于A股strategy_lab) 功能: 1. 读港股12维面板 + 港股策略库(hk_strategies.py) 2. 生成信号 → trades(止盈/止损/最大持有出场) 3. portfolio_sim 资金模拟(复用 strategy_lab.portfolio_sim 纯函数,不改A股框架) 4. 输出组合指标:平均年化/近1年/近6月/近3月(Ralph Loop验收标准) 用法: python3 hk_backtest.py # 全部策略 + 组合 python3 hk_backtest.py --version hk_pe_mom # 单策略 """ import sys import argparse import collections import sqlite3 from datetime import datetime, timedelta import pandas as pd sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts") sys.path.insert(0, "/home/hmo/MoFin") # strategy_lab.portfolio_sim(纯函数复用) from hk_strategies import HK_STRATEGIES, get_hk_strategy PANEL = "/tmp/panel_12d_hk.pkl" DB = "/home/hmo/MoFin/data/mofin.db" COST = 0.0015 # 港股往返费率近似(佣金+印花税) # 港股温区映射(组合按温区调度用) _REGIME_CACHE = None def load_regime_map(): global _REGIME_CACHE if _REGIME_CACHE is None: conn = sqlite3.connect(DB) _REGIME_CACHE = dict(conn.execute( "SELECT date, regime FROM market_regime WHERE market='hk'").fetchall()) conn.close() return _REGIME_CACHE def load_panel(): p = pd.read_pickle(PANEL) return p.sort_values(["code", "date"]).reset_index(drop=True) def gen_trades_defensive(panel, strat, strike=3, cooldown_days=15): """带三振出局防守的信号→trades:连续 strike 笔亏损 → 暂停 cooldown_days(自动避持续下跌被埋)""" e = strat["entry"] cond = pd.Series(True, index=panel.index) if "pe_q_max" in e: cond &= panel["pe_q"] < e["pe_q_max"] if "mcap_q_max" in e: cond &= panel["mcap_q"] < e["mcap_q_max"] if "sec_ret20_min" in e: cond &= panel["sec_ret20"] > e["sec_ret20_min"] if "rsi_max" in e: cond &= panel["rsi"] < e["rsi_max"] if "bias60_max" in e: cond &= panel["bias60"] < e["bias60_max"] if "rsi_delta_min" in e: cond &= panel["rsi"] - panel.groupby("code")["rsi"].shift(5) >= e["rsi_delta_min"] if "vol_ratio_min" in e: cond &= panel["vol_ratio"] > e["vol_ratio_min"] if "mkt_ret20_max" in e: cond &= panel["mkt_ret20"] < e["mkt_ret20_max"] if "mkt_rsi_max" in e: cond &= panel["mkt_rsi"] < e["mkt_rsi_max"] if "mkt_adx_max" in e: cond &= panel["mkt_adx"] < e["mkt_adx_max"] if "mkt_rsi_min" in e: cond &= panel["mkt_rsi"] > e["mkt_rsi_min"] if "ret1_min" in e: cond &= panel["ret1"] > e["ret1_min"] if "ret5_min" in e: cond &= panel["ret5"] > e["ret5_min"] if "ret20_max" in e: cond &= panel["ret20"] < e["ret20_max"] if "dist_lo20_min" in e: cond &= panel["dist_lo20"] > e["dist_lo20_min"] if "bias20_max" in e: cond &= panel["bias20"] < e["bias20_max"] sig = panel[cond].copy() sig = sig.dropna(subset=["close"]) ex = strat["exit"] tp, sl, maxh = ex["tp_pct"], ex["sl_pct"], ex["max_hold_days"] bycode = {c: df for c, df in panel.groupby("code")} trades = [] last_loss_dates = {} # code -> last loss date(个股级三振) consecutive = {} # code -> 连续亏损数 for _, s in sig.iterrows(): code = s["code"] # 三振出局:个股连续亏损 strike 次 → 暂停 cooldown_days if consecutive.get(code, 0) >= strike: if (pd.Timestamp(s["date"]) - pd.Timestamp(last_loss_dates.get(code, "1900-01-01"))).days < cooldown_days: continue consecutive[code] = 0 # 冷却期后恢复 df = bycode.get(code) if df is None: continue idx = df.index[df["date"] == s["date"]] if len(idx) == 0: continue pos = df.index.get_loc(idx[0]) fut = df.iloc[pos + 1: pos + maxh + 2] if len(fut) < 2: continue ep = s["close"] if ep <= 0: continue exit_p, reason, hold = None, None, 0 for k, fb in enumerate(fut.itertuples()): if fb.close <= ep * (1 - sl): exit_p, reason, hold = ep * (1 - sl), "stop", k + 1 break if fb.close >= ep * (1 + tp): exit_p, reason, hold = ep * (1 + tp), "target", k + 1 break if exit_p is None: exit_p, reason, hold = fut.iloc[-1]["close"], "time", maxh pnl = (exit_p - ep) / ep * 100 exit_date = fut.iloc[min(hold - 1, len(fut) - 1)]["date"] if hold > 0 else s["date"] # 更新三振状态 if pnl < 0: consecutive[code] = consecutive.get(code, 0) + 1 last_loss_dates[code] = exit_date else: consecutive[code] = 0 trades.append({ "code": code, "name": code, "entry_date": s["date"], "exit_date": exit_date, "entry_price": round(ep, 2), "exit_price": round(exit_p, 2), "profit_pct": round(pnl, 2), "exit_reason": reason, "hold_days": hold, "score": 0, "score_comp": {}, "kelly": 0, "stop_loss": round(ep * (1 - sl), 2), "target": round(ep * (1 + tp), 2), "dna": False, "factors": {}, }) return trades def gen_trades(panel, strat): """兼容入口:无防守的信号生成""" return gen_trades_defensive(panel, strat, strike=999, cooldown_days=0) def portfolio_nav(trades, capital=1000000, slots=8): """8槽资金管理净值曲线:每日结算到期→入场(仓位满跳过)→持仓按成本估值。 返回 (nav_series: dict date->nav, stats)""" if not trades: return {}, {} dates = sorted({t["entry_date"] for t in trades} | {t.get("exit_date", t["entry_date"]) for t in trades}) if not dates: return {}, {} # 用真实日历(stock_daily 港股日K日期) conn = sqlite3.connect(DB) cal = [r[0] for r in conn.execute( "SELECT DISTINCT date FROM stock_daily WHERE date>=? AND date<=? AND length(code)=5 ORDER BY date", (dates[0], dates[-1])).fetchall()] conn.close() if not cal: cal = dates cal_idx = {d: i for i, d in enumerate(cal)} alloc = capital / slots open_pos = [] # {exit_date, alloc, pnl} cash = capital nav_series = {} skipped = 0 by_entry = collections.defaultdict(list) for t in trades: by_entry[t["entry_date"]].append(t) for day in cal: # 结算到期 still = [] for p in open_pos: if p["exit_date"] <= day: cash += p["alloc"] * (1 + p["pnl"] / 100) else: still.append(p) open_pos = still # 入场 for t in by_entry.get(day, []): if len(open_pos) >= slots or cash < alloc: skipped += 1 continue open_pos.append({"exit_date": t.get("exit_date", day), "alloc": alloc, "pnl": t["profit_pct"]}) cash -= alloc # 净值 held_val = sum(p["alloc"] * (1 + p["pnl"] / 100) for p in open_pos) nav_series[day] = cash + held_val nav_series = {d: v for d, v in sorted(nav_series.items())} return nav_series, {"skipped": skipped} def window_returns(nav_series): """从净值曲线算窗口收益(近1年/6月/3月,对照最后日期)""" if not nav_series: return {"year1": None, "month6": None, "month3": None} items = sorted(nav_series.items()) last_d, last_v = items[-1] last_dt = datetime.strptime(last_d, "%Y-%m-%d") out = {} for label, days in [("year1", 365), ("month6", 182), ("month3", 91)]: cutoff = (last_dt - timedelta(days=days)).strftime("%Y-%m-%d") # 取 cutoff 后最近的净值点 base = None for d, v in items: if d >= cutoff: base = v break out[label] = (last_v / base - 1) * 100 if base else None return out def portfolio_metrics(trades, capital=1000000, slots=8): """资金模拟 + 完整 summary_stats(复用 strategy_lab.calc_summary,与A股同构)""" import strategy_lab as lab nav, stats = portfolio_nav(trades, capital, slots) win = window_returns(nav) cagr = None if nav: items = sorted(nav.items()) d0, v0 = items[0] d1, v1 = items[-1] yrs = max((datetime.strptime(d1, "%Y-%m-%d") - datetime.strptime(d0, "%Y-%m-%d")).days / 365.0, 0.5) cagr = (((v1 / v0) ** (1 / yrs)) - 1) * 100 if v0 > 0 else None # 完整 summary_stats(复用 A 股 calc_summary——字段结构与A股完全一致) cs = lab.calc_summary(trades, capital) pf = lab.portfolio_sim(trades, capital, max_positions=slots) or {} pff = lab.portfolio_sim_full(trades, capital) or {} summary = dict(cs) summary["sizing_slots"] = slots summary["portfolio"] = pf summary["portfolio_full"] = pff return { "summary_stats": summary, "cagr": cagr, "year1": win.get("year1"), "month6": win.get("month6"), "month3": win.get("month3"), "trades": len(trades), "skipped": stats.get("skipped", 0), "win_rate": cs.get("win_rate"), } def main(): ap = argparse.ArgumentParser() ap.add_argument("--version", default=None) ap.add_argument("--slots", type=int, default=8) ap.add_argument("--strike", type=int, default=2) ap.add_argument("--cooldown", type=int, default=40) ap.add_argument("--td-guard", type=int, default=5, help="trend_down连续超过N天暂停超卖策略(防守)") args = ap.parse_args() panel = load_panel() print(f"港股面板: {len(panel)} 行\n", flush=True) # trend_down 连续天数(组合级防守) rm = load_regime_map() dates = sorted(rm.keys()) td_run = {} run = 0 for d in dates: run = run + 1 if rm[d] == "trend_down" else 0 td_run[d] = run versions = [args.version] if args.version else list(HK_STRATEGIES.keys()) all_trades = [] for v in versions: strat = get_hk_strategy(v) if not strat: print(f"未知策略: {v}") continue print(f"=== {v} ({strat['name']}) ===", flush=True) trades = gen_trades_defensive(panel, strat, strike=args.strike, cooldown_days=args.cooldown) if not trades: print(" 无交易\n", flush=True) continue # 温区调度 + 组合级防守(trend_down 连续>N天暂停超卖,避免持续下跌被埋) reg = strat.get("regime", "all") if reg != "all" and args.version is None: trades = [t for t in trades if rm.get(t["entry_date"]) == reg] if reg == "trend_down" and args.td_guard > 0: trades = [t for t in trades if td_run.get(t["entry_date"], 0) <= args.td_guard] print(f" 温区调度({reg})+防守: 保留 {len(trades)} 笔", flush=True) m = portfolio_metrics(trades, slots=args.slots) ss = m["summary_stats"] print(f" 交易{ss['total_trades']} 胜率{ss['win_rate']}% 组合年化{m['cagr']}% " f"近1年{m['year1']:+.1f}% 近6月{m['month6']:+.1f}% 近3月{m['month3']:+.1f}%\n", flush=True) all_trades += trades if all_trades and not args.version: print("=== 港股策略组合(全温区)===", flush=True) m = portfolio_metrics(all_trades, slots=args.slots) ss = m["summary_stats"] print(f" 组合: 交易{ss['total_trades']} 胜率{ss['win_rate']}% 组合年化{m['cagr']}% " f"近1年{m['year1']:+.1f}% 近6月{m['month6']:+.1f}% 近3月{m['month3']:+.1f}%", flush=True) ok = (m['cagr'] or 0) > 10 and (m['year1'] or 0) > 10 and (m['month6'] or 0) > 5 and (m['month3'] or 0) > 0 print(f" 验收: {'✅ 达标' if ok else '❌ 未达标'}(年化>10/近1年>10/近6月>5/近3月>0)", flush=True) if __name__ == "__main__": main()