#!/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(panel, strat): """按策略入场条件生成信号 → trades""" 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: # 面板无rsi_delta,用rsi与前5日差值近似(面板已有rsi) 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"] sig = panel[cond].copy() sig = sig.dropna(subset=["close"]) print(f" {strat['version']} 信号: {len(sig)}", flush=True) 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 = [] for _, s in sig.iterrows(): df = bycode.get(s["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 # 退出日期(用该股票日历往后推 hold 个交易日) exit_date = fut.iloc[min(hold - 1, len(fut) - 1)]["date"] if hold > 0 else s["date"] trades.append({ "code": s["code"], "name": s["code"], "entry_date": s["date"], "exit_date": exit_date, "entry_price": round(ep, 2), "exit_price": round(exit_p, 2), "profit_pct": round((exit_p - ep) / ep * 100, 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 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): """资金模拟 + 窗口收益(净值曲线)""" 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 return { "cagr": cagr, "year1": win.get("year1"), "month6": win.get("month6"), "month3": win.get("month3"), "trades": len(trades), "skipped": stats.get("skipped", 0), "win_rate": sum(1 for t in trades if t["profit_pct"] > 0) / len(trades) * 100 if trades else 0, } def main(): ap = argparse.ArgumentParser() ap.add_argument("--version", default=None) ap.add_argument("--slots", type=int, default=8) args = ap.parse_args() panel = load_panel() print(f"港股面板: {len(panel)} 行\n", flush=True) versions = [args.version] if args.version else list(HK_STRATEGIES.keys()) all_trades = [] rm = load_regime_map() # 港股温区映射(组合温区调度) 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(panel, strat) if not trades: print(" 无交易\n", flush=True) continue # 温区调度:只保留策略适用温区的交易(组合正确性关键) 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] print(f" 温区调度({reg}): 保留 {len(trades)} 笔", flush=True) m = portfolio_metrics(trades, slots=args.slots) print(f" 交易{m['trades']} 胜率{m['win_rate']:.0f}% 组合年化{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) print(f" 组合: 交易{m['trades']} 胜率{m['win_rate']:.0f}% 组合年化{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()