279 lines
11 KiB
Python
279 lines
11 KiB
Python
#!/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"]
|
||
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):
|
||
"""资金模拟 + 窗口收益(净值曲线)"""
|
||
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)
|
||
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)
|
||
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()
|