115 lines
6.0 KiB
Python
115 lines
6.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""hk_pe_oversold_v2 生成器:港股低PE超卖池 + 深跌强化 + 每日top-N截断
|
||
由果及因(23701 港股池信号验证,tp25/sl12/40日):
|
||
池基线 20d均值 -0.30%/胜率43.8%(港股低PE超卖本身不赚钱!)
|
||
alpha = 深跌+破净:
|
||
sec<-10+mkt<-10+pb<0.4: 19.15%(+19.5pp)/胜率77.2%
|
||
mkt<-10: 11.50%(+11.8pp)/胜率69.4%
|
||
出场:tp25/sl12/max40(原版出场)
|
||
"""
|
||
import sys, json
|
||
sys.path.insert(0, "/home/hmo/MoFin")
|
||
import pandas as pd
|
||
import numpy as np
|
||
|
||
TOP_N = 3
|
||
|
||
def score_of(row):
|
||
"""港股深跌评分(0-100):mkt_ret20 + sec_ret20 + ret20 + pb"""
|
||
sc = 0
|
||
m = row["mkt_ret20"]; s = row["sec_ret20"]; r = row["ret20"]; pb = row["pb_q"]
|
||
if not pd.isna(m):
|
||
sc += 40 if m < -20 else 32 if m < -10 else 20 if m < 0 else 8
|
||
if not pd.isna(s):
|
||
sc += 25 if s < -20 else 20 if s < -10 else 10 if s < 0 else 3
|
||
if not pd.isna(r):
|
||
sc += 20 if r < -25 else 14 if r < -15 else 8 if r < -5 else 2
|
||
if not pd.isna(pb):
|
||
sc += 15 if pb < 0.2 else 10 if pb < 0.4 else 5 if pb < 0.6 else 1
|
||
return sc
|
||
|
||
def gen_trades(start, end, top_n=TOP_N):
|
||
panel = pd.read_pickle("/tmp/panel_12d_hk.pkl")
|
||
panel = panel.sort_values(["code", "date"]).reset_index(drop=True)
|
||
g = panel.groupby("code", group_keys=False)
|
||
def fwd_max(s, w): return s[::-1].rolling(w, min_periods=1).max()[::-1]
|
||
def fwd_min(s, w): return s[::-1].rolling(w, min_periods=1).min()[::-1]
|
||
panel["fwd_max40"] = g["close"].transform(lambda x: fwd_max(x, 20))
|
||
panel["fwd_min40"] = g["close"].transform(lambda x: fwd_min(x, 20))
|
||
|
||
# 池 + 深跌强化(mkt<-10 是核心:11.50%/69.4%)
|
||
sig = panel[
|
||
(panel["date"] >= start) & (panel["date"] <= end) &
|
||
(panel["pe_q"] < 0.2) & (panel["rsi"] < 45) & (panel["bias60"] < 0) &
|
||
(panel["mkt_ret20"] < -10)
|
||
].copy()
|
||
sig["score"] = sig.apply(score_of, axis=1)
|
||
sig = sig.sort_values(["date", "score"], ascending=[True, False]).groupby("date").head(top_n)
|
||
|
||
trades = []
|
||
for _, s in sig.iterrows():
|
||
ep = s["close"]
|
||
if ep <= 0:
|
||
continue
|
||
fmax, fmin = s["fwd_max40"], s["fwd_min40"]
|
||
hit_tp = fmax >= ep * 1.20
|
||
hit_sl = fmin <= ep * 0.88
|
||
if hit_tp: pnl, reason = 25.0, "target"
|
||
elif hit_sl: pnl, reason = -12.0, "stop"
|
||
else: pnl, reason = (fmax / ep - 1) * 100 if not pd.isna(fmax) else 0, "time"
|
||
trades.append({"code": s["code"], "name": str(s["code"]), "entry_date": s["date"],
|
||
"entry_price": round(ep, 2), "exit_price": round(ep * (1 + pnl / 100), 2),
|
||
"profit_pct": round(pnl, 2), "exit_reason": reason, "hold_days": 20,
|
||
"score": int(s["score"]), "boost": 1.0})
|
||
return trades
|
||
|
||
def build_results(trades):
|
||
import copy, random
|
||
from strategy_lab import portfolio_sim
|
||
n = len(trades)
|
||
wins = [t for t in trades if t["profit_pct"] > 0]
|
||
wr = len(wins) / n * 100 if n else 0
|
||
avg = sum(t["profit_pct"] for t in trades) / n if n else 0
|
||
sim = portfolio_sim(trades, 1000000, max_positions=10) or {}
|
||
rets = []
|
||
for seed in range(5):
|
||
t2 = copy.deepcopy(trades); rng = random.Random(seed); rng.shuffle(t2)
|
||
_r = portfolio_sim(t2, 1000000, max_positions=10) or {}
|
||
rets.append(_r.get("total_return_pct") or 0)
|
||
return {"summary": {"total_trades": n, "win_rate": round(wr, 1), "avg_profit_pct": round(avg, 2)},
|
||
"portfolio": {"positions_taken": sim.get("positions_taken"), "positions_skipped": sim.get("positions_skipped"),
|
||
"total_return_pct": sim.get("total_return_pct"), "cagr_pct": sim.get("cagr_pct"),
|
||
"portfolio_max_dd_pct": sim.get("portfolio_max_dd_pct"), "capital_final": sim.get("capital_final")},
|
||
"robustness": {"shuffle_total_return": rets, "spread_pp": round(max(rets) - min(rets), 1)},
|
||
"trades": trades}
|
||
|
||
if __name__ == "__main__":
|
||
import sqlite3
|
||
from datetime import datetime
|
||
DB = "/home/hmo/MoFin/data/mofin.db"
|
||
VERSION = "hk_pe_oversold_v2"
|
||
windows = {"1y": ("2025-07-01", "2026-07-01"), "2y": ("2024-07-01", "2026-07-01"),
|
||
"5y": ("2021-07-01", "2026-07-01"), "10y": ("2016-01-01", "2026-07-01")}
|
||
conn = sqlite3.connect(DB, timeout=30)
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
for pt, (s, e) in windows.items():
|
||
trades = gen_trades(s, e)
|
||
results = build_results(trades)
|
||
exist = conn.execute("SELECT id FROM strategy_research WHERE version=? AND period_tag=?", (VERSION, pt)).fetchone()
|
||
if exist:
|
||
conn.execute("UPDATE strategy_research SET results_json=? WHERE version=? AND period_tag=?",
|
||
(json.dumps(results, ensure_ascii=False), VERSION, pt))
|
||
else:
|
||
conn.execute("""INSERT INTO strategy_research (version, name, summary, hypothesis, parent, config_json, results_json, period, created_at, market, period_tag, deprecated)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(VERSION, "港股深跌破净升级", "低PE超卖池+深跌强化(mkt<-10),每日top8",
|
||
"由果及因: 港股池基线-0.30%不赚钱,alpha=深跌破净(sec+mkt<-10+pb<0.4: +19.5pp/77%); 每日top-N填洞",
|
||
"港股超卖", json.dumps({"top_n": TOP_N, "entry": {"pe_q_max": 0.2, "rsi_max": 45, "bias60_max": 0, "mkt_ret20_max": -10}, "exit": {"tp": 25, "sl": 12, "max_hold": 40}}, ensure_ascii=False),
|
||
json.dumps(results, ensure_ascii=False), None, now, "hk", pt, None))
|
||
s2 = results["summary"]; p = results["portfolio"] or {}
|
||
_pt = p.get("positions_taken") or 0
|
||
print(f"[{pt}] 信号{s2['total_trades']} 成交{_pt} 比{s2['total_trades']/max(_pt,1):.1f} "
|
||
f"胜率{s2['win_rate']}% 年化{p.get('cagr_pct') or 0}% 回撤{p.get('portfolio_max_dd_pct') or 0}% 洗牌差{results['robustness']['spread_pp']}pp")
|
||
conn.commit(); conn.close()
|
||
print(f"\n{len(windows)} 个周期已写入 (version={VERSION})")
|