research: s2_panic_v2 由果及因研究——恐慌日alpha特征挖掘+每日top-N截断+score择优验证
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""由果及因(四):全部恐慌日的 alpha 特征挖掘
|
||||||
|
方法:合并所有 mkt_rsi<25 的恐慌日信号(全市场),按特征分桶看 fwd_ret20/60 差异
|
||||||
|
目标:找到能区分"反弹强弱"的特征 → 作为信号 score 依据
|
||||||
|
关键:所有恐慌日合并(不只2025-04-09),保证样本量和统计意义
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/home/hmo/MoFin")
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
panel = pd.read_pickle("/tmp/panel_12d.pkl")
|
||||||
|
panel = panel.sort_values(["code", "date"]).reset_index(drop=True)
|
||||||
|
for w in [10, 20, 60]:
|
||||||
|
panel[f"fwd_ret{w}"] = panel.groupby("code")["close"].transform(lambda x, ww=w: x.shift(-ww) / x - 1) * 100
|
||||||
|
|
||||||
|
# 全部恐慌日(mkt_rsi<25)
|
||||||
|
panic = panel[panel["mkt_rsi"] < 25].dropna(subset=["fwd_ret20"]).copy()
|
||||||
|
print(f"恐慌日总样本: {len(panic)} (涉及 {panic['date'].nunique()} 天, {panic['code'].nunique()} 只)")
|
||||||
|
print(f"恐慌日日期: {sorted(panic['date'].unique())}")
|
||||||
|
base = panic["fwd_ret20"].mean()
|
||||||
|
base_wr = (panic["fwd_ret20"] > 0).mean() * 100
|
||||||
|
print(f"恐慌日基线: 20d均值 {base:.2f}% 胜率 {base_wr:.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 特征分桶(重点看哪些特征桶间差异大)
|
||||||
|
features = {
|
||||||
|
"ret5(5日跌幅)": ("ret5", [-40, -25, -18, -12, -8, -4, 0]),
|
||||||
|
"bias60(60日偏离)": ("bias60", [-45, -30, -20, -10, 0, 10, 30]),
|
||||||
|
"bias20(20日偏离)": ("bias20", [-30, -20, -10, 0, 10, 25]),
|
||||||
|
"dist_lo20(离低点)": ("dist_lo20", [0, 5, 10, 15, 25, 50]),
|
||||||
|
"rsi(个股)": ("rsi", [0, 15, 25, 35, 45, 60]),
|
||||||
|
"ret20(20日跌)": ("ret20", [-40, -25, -15, -5, 0, 10]),
|
||||||
|
"mcap_q(市值分位)": ("mcap_q", [0, 0.2, 0.4, 0.6, 0.8, 1]),
|
||||||
|
"pe_q(估值)": ("pe_q", [0, 0.2, 0.4, 0.6, 0.8, 1]),
|
||||||
|
"pb_q(市净率)": ("pb_q", [0, 0.2, 0.4, 0.6, 0.8, 1]),
|
||||||
|
"news3(新闻)": ("news3", [0, 1, 2, 4, 8]),
|
||||||
|
"vol_ratio(量比)": ("vol_ratio", [0, 0.8, 1.2, 1.8, 3, 6]),
|
||||||
|
"flow5(5日资金)": ("flow5", [-50, -20, 0, 20, 50]),
|
||||||
|
"mkt_adx(大盘ADX)": ("mkt_adx", [0, 20, 30, 40, 60]),
|
||||||
|
"mkt_ret20(大盘20d)": ("mkt_ret20", [-30, -20, -10, 0, 10]),
|
||||||
|
"sec_ret20(行业20d)": ("sec_ret20", [-30, -20, -10, 0, 10]),
|
||||||
|
"hi20_new(20日新高)": ("hi20_new", [0, 1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
print("=" * 78)
|
||||||
|
print(f"{'特征':<16} {'桶':<14} {'n':>6} {'20d均值':>8} {'胜率':>7} {'超额':>7}")
|
||||||
|
print("=" * 78)
|
||||||
|
for label, (feat, edges) in features.items():
|
||||||
|
if feat not in panic.columns:
|
||||||
|
continue
|
||||||
|
s = panic.dropna(subset=[feat])
|
||||||
|
if len(s) < 200:
|
||||||
|
continue
|
||||||
|
results = []
|
||||||
|
for i in range(len(edges) - 1):
|
||||||
|
lo, hi = edges[i], edges[i + 1]
|
||||||
|
m = s[(s[feat] >= lo) & (s[feat] < hi)]
|
||||||
|
if len(m) < 50:
|
||||||
|
continue
|
||||||
|
wr = (m["fwd_ret20"] > 0).mean() * 100
|
||||||
|
results.append((f"{lo}~{hi}", len(m), m["fwd_ret20"].mean(), wr))
|
||||||
|
m = s[s[feat] >= edges[-1]]
|
||||||
|
if len(m) >= 50:
|
||||||
|
wr = (m["fwd_ret20"] > 0).mean() * 100
|
||||||
|
results.append((f">={edges[-1]}", len(m), m["fwd_ret20"].mean(), wr))
|
||||||
|
# 打印桶间差异(max-min 均值差)
|
||||||
|
if len(results) >= 2:
|
||||||
|
means = [r[2] for r in results]
|
||||||
|
spread = max(means) - min(means)
|
||||||
|
mark = " ★" if spread > 15 else (" ◈" if spread > 8 else "")
|
||||||
|
print(f"{label:<16} (桶间差 {spread:.1f}pp){mark}")
|
||||||
|
for name, n, mean, wr in results:
|
||||||
|
bar = "█" * int(abs(mean) / 2)
|
||||||
|
print(f"{'':<16} {name:<14} {n:>6} {mean:>7.2f}% {wr:>6.1f}% {mean-base:>+6.2f} {bar}")
|
||||||
|
print()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""s2_panic_v2 修正:每日 top-N 截断(每恐慌日只选 score 最高的 N 个)
|
||||||
|
这是关键:恐慌日信号 1200/天 是结构问题,必须每日截断让信号/成交比可控
|
||||||
|
"""
|
||||||
|
import sys, json
|
||||||
|
sys.path.insert(0, "/home/hmo/MoFin")
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def alpha_score(mcap_q, rsi, sec_ret20, news3):
|
||||||
|
sc = 0
|
||||||
|
if mcap_q is not None and not np.isnan(mcap_q):
|
||||||
|
sc += 40 if mcap_q < 0.2 else 32 if mcap_q < 0.4 else 24 if mcap_q < 0.6 else 16 if mcap_q < 0.8 else 8
|
||||||
|
if rsi is not None and not np.isnan(rsi):
|
||||||
|
sc += 30 if rsi >= 45 else 22 if rsi >= 35 else 12 if rsi >= 25 else 6
|
||||||
|
if sec_ret20 is not None and not np.isnan(sec_ret20):
|
||||||
|
sc += 20 if sec_ret20 >= 0 else 16 if sec_ret20 >= -10 else 8 if sec_ret20 >= -20 else 3
|
||||||
|
if news3 is not None and not np.isnan(news3):
|
||||||
|
sc += 10 if news3 >= 2 else 7 if news3 >= 1 else 2
|
||||||
|
return sc
|
||||||
|
|
||||||
|
def gen_trades(start="2016-01-01", end="2026-07-01", top_n=8):
|
||||||
|
panel = pd.read_pickle("/tmp/panel_12d.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_max60"] = g["close"].transform(lambda x: fwd_max(x, 60))
|
||||||
|
panel["fwd_min60"] = g["close"].transform(lambda x: fwd_min(x, 60))
|
||||||
|
|
||||||
|
panic = panel[(panel["mkt_rsi"] < 25) & (panel["date"] >= start) & (panel["date"] <= end)].copy()
|
||||||
|
sig = panic[(panic["mcap_q"] < 0.4) & (panic["rsi"] >= 35) & (panic["sec_ret20"] >= -10)].copy()
|
||||||
|
sig["score"] = sig.apply(lambda r: alpha_score(r["mcap_q"], r["rsi"], r["sec_ret20"], r["news3"]), axis=1)
|
||||||
|
|
||||||
|
# 每日 top-N 截断(score 降序)
|
||||||
|
sig = sig.sort_values(["date", "score"], ascending=[True, False]).groupby("date").head(top_n)
|
||||||
|
print(f"每日top{top_n}截断后: {len(sig)} 信号 (原6932)")
|
||||||
|
|
||||||
|
trades = []
|
||||||
|
for _, s in sig.iterrows():
|
||||||
|
ep = s["close"]
|
||||||
|
if ep <= 0:
|
||||||
|
continue
|
||||||
|
fmax = s["fwd_max60"]; fmin = s["fwd_min60"]
|
||||||
|
hit_tp = fmax >= ep * 1.30
|
||||||
|
hit_sl = fmin <= ep * 0.88
|
||||||
|
if hit_tp: pnl, reason = 30.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"], "entry_date": s["date"], "entry_price": round(ep, 2),
|
||||||
|
"profit_pct": round(pnl, 2), "exit_reason": reason,
|
||||||
|
"hold_days": 60, "score": int(s["score"]), "name": str(s["code"]), "boost": 1.0})
|
||||||
|
return trades
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from strategy_lab import portfolio_sim
|
||||||
|
import copy, random
|
||||||
|
for top_n in [5, 8, 10]:
|
||||||
|
trades = gen_trades(top_n=top_n)
|
||||||
|
print(f"\n=== top{top_n} ===")
|
||||||
|
sim0 = portfolio_sim(trades, 1000000, max_positions=10)
|
||||||
|
n_sig = len(trades)
|
||||||
|
n_pos = sim0.get("positions_taken")
|
||||||
|
print(f"信号{n_sig} 成交{n_pos} 比{n_sig/max(n_pos,1):.1f} 总收益{sim0.get('total_return_pct')}% 年化{sim0.get('cagr_pct')}% 回撤{sim0.get('portfolio_max_dd_pct')}%")
|
||||||
|
# 稳健性
|
||||||
|
rets = []
|
||||||
|
for seed in range(5):
|
||||||
|
t2 = copy.deepcopy(trades); rng = random.Random(seed); rng.shuffle(t2)
|
||||||
|
rets.append(portfolio_sim(t2, 1000000, max_positions=10).get("total_return_pct"))
|
||||||
|
print(f" 洗牌5次: 收益{rets} 差{max(rets)-min(rets):.1f}pp")
|
||||||
|
# 信号/成交分布
|
||||||
|
from collections import Counter
|
||||||
|
dc = Counter(t["entry_date"] for t in trades)
|
||||||
|
print(f" 每日信号分布: {dict(sorted(dc.items()))}")
|
||||||
|
if top_n == 8:
|
||||||
|
with open("/home/hmo/MoFin/data/s2_panic_v2_trades.json", "w") as f:
|
||||||
|
json.dump(trades, f, ensure_ascii=False)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""验证:top-N(score择优) vs 随机抽样 的收益差异"""
|
||||||
|
import sys, random
|
||||||
|
sys.path.insert(0, "/home/hmo/MoFin")
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def alpha_score(mcap_q, rsi, sec_ret20, news3):
|
||||||
|
sc = 0
|
||||||
|
if mcap_q is not None and not np.isnan(mcap_q):
|
||||||
|
sc += 40 if mcap_q < 0.2 else 32 if mcap_q < 0.4 else 24 if mcap_q < 0.6 else 16 if mcap_q < 0.8 else 8
|
||||||
|
if rsi is not None and not np.isnan(rsi):
|
||||||
|
sc += 30 if rsi >= 45 else 22 if rsi >= 35 else 12 if rsi >= 25 else 6
|
||||||
|
if sec_ret20 is not None and not np.isnan(sec_ret20):
|
||||||
|
sc += 20 if sec_ret20 >= 0 else 16 if sec_ret20 >= -10 else 8 if sec_ret20 >= -20 else 3
|
||||||
|
if news3 is not None and not np.isnan(news3):
|
||||||
|
sc += 10 if news3 >= 2 else 7 if news3 >= 1 else 2
|
||||||
|
return sc
|
||||||
|
|
||||||
|
panel = pd.read_pickle("/tmp/panel_12d.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_max60"] = g["close"].transform(lambda x: fwd_max(x, 60))
|
||||||
|
panel["fwd_min60"] = g["close"].transform(lambda x: fwd_min(x, 60))
|
||||||
|
|
||||||
|
panic = panel[panel["mkt_rsi"] < 25].copy()
|
||||||
|
sig = panic[(panic["mcap_q"] < 0.4) & (panic["rsi"] >= 35) & (panic["sec_ret20"] >= -10)].copy()
|
||||||
|
sig["score"] = sig.apply(lambda r: alpha_score(r["mcap_q"], r["rsi"], r["sec_ret20"], r["news3"]), axis=1)
|
||||||
|
|
||||||
|
def pnl_of(s):
|
||||||
|
ep = s["close"]
|
||||||
|
fmax, fmin = s["fwd_max60"], s["fwd_min60"]
|
||||||
|
if fmax >= ep * 1.30: return 30.0
|
||||||
|
if fmin <= ep * 0.88: return -12.0
|
||||||
|
return (fmax / ep - 1) * 100 if not pd.isna(fmax) else 0
|
||||||
|
|
||||||
|
days = {dt: grp for dt, grp in sig.groupby("date")}
|
||||||
|
|
||||||
|
# top5 择优
|
||||||
|
top5 = sig.sort_values(["date", "score"], ascending=[True, False]).groupby("date").head(5)
|
||||||
|
tp = top5.apply(pnl_of, axis=1)
|
||||||
|
print(f"top5(score择优): n={len(top5)} 平均pnl={tp.mean():.2f}% 胜率={(tp>0).mean()*100:.1f}%")
|
||||||
|
|
||||||
|
# 随机抽5
|
||||||
|
rng = random.Random(42)
|
||||||
|
allr = []
|
||||||
|
for trial in range(20):
|
||||||
|
chosen = []
|
||||||
|
for dt, grp in days.items():
|
||||||
|
n = min(5, len(grp))
|
||||||
|
chosen.extend(grp.sample(n=n, random_state=1000*trial+7).index.tolist())
|
||||||
|
rp = sig.loc[chosen].apply(pnl_of, axis=1)
|
||||||
|
allr.append(rp.mean())
|
||||||
|
print(f"随机抽5×20次: 平均={np.mean(allr):.2f}% 范围{min(allr):.2f}~{max(allr):.2f}%")
|
||||||
|
|
||||||
|
# top10
|
||||||
|
top10 = sig.sort_values(["date", "score"], ascending=[True, False]).groupby("date").head(10)
|
||||||
|
tp10 = top10.apply(pnl_of, axis=1)
|
||||||
|
print(f"top10(score择优): n={len(top10)} 平均pnl={tp10.mean():.2f}% 胜率={(tp10>0).mean()*100:.1f}%")
|
||||||
|
allr10 = []
|
||||||
|
for trial in range(20):
|
||||||
|
chosen = []
|
||||||
|
for dt, grp in days.items():
|
||||||
|
n = min(10, len(grp))
|
||||||
|
chosen.extend(grp.sample(n=n, random_state=2000*trial+13).index.tolist())
|
||||||
|
rp = sig.loc[chosen].apply(pnl_of, axis=1)
|
||||||
|
allr10.append(rp.mean())
|
||||||
|
print(f"随机抽10×20次: 平均={np.mean(allr10):.2f}% 范围{min(allr10):.2f}~{max(allr10):.2f}%")
|
||||||
|
|
||||||
|
# bottom5(最低分对照组)
|
||||||
|
bot5 = sig.sort_values(["date", "score"], ascending=[True, True]).groupby("date").head(5)
|
||||||
|
bp = bot5.apply(pnl_of, axis=1)
|
||||||
|
print(f"bottom5(最低分): n={len(bot5)} 平均pnl={bp.mean():.2f}% 胜率={(bp>0).mean()*100:.1f}%")
|
||||||
Reference in New Issue
Block a user