Files
MoFin/deploy/profile-scripts/hk_backtest.py
T

266 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
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_defensive(panel, strat, strike=3, cooldown_days=15)
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()