178 lines
6.9 KiB
Python
178 lines
6.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""B组挖掘 v4:相对分位果 + 三因子组合扫描
|
|
果 = 该温区下 fwd_ret60 前 20% 分位(相对,避免绝对阈值稀疏)
|
|
"""
|
|
import json
|
|
import sqlite3
|
|
import numpy as np
|
|
import pandas as pd
|
|
from datetime import datetime
|
|
from itertools import combinations
|
|
|
|
DATA_DIR = "/home/hmo/MoFin/data"
|
|
OUT_JSON = f"{DATA_DIR}/b_group_candidates.json"
|
|
|
|
|
|
def load_regime_map(market="a"):
|
|
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=10)
|
|
rows = conn.execute("SELECT date, regime FROM market_regime WHERE market=?", (market,)).fetchall()
|
|
conn.close()
|
|
return {d: r for d, r in rows}
|
|
|
|
|
|
def load_panel(market):
|
|
path = "/tmp/panel_12d_hk.pkl" if market == "hk" else "/tmp/panel_12d.pkl"
|
|
p = pd.read_pickle(path)
|
|
p = p.sort_values(["code", "date"]).reset_index(drop=True)
|
|
p["fwd_ret60"] = p.groupby("code")["close"].transform(lambda x: x.shift(-60) / x - 1) * 100
|
|
return p
|
|
|
|
|
|
def scan3(market, regime, panel, min_n=500):
|
|
"""三因子组合扫描:相对分位果"""
|
|
rm = load_regime_map(market)
|
|
p = panel.copy()
|
|
p["_regime"] = p["date"].map(rm)
|
|
sub = p[p["_regime"] == regime].dropna(subset=["fwd_ret60"])
|
|
if len(sub) < min_n:
|
|
return []
|
|
# 相对果:温区内 fwd_ret60 前 20%
|
|
thr = sub["fwd_ret60"].quantile(0.80)
|
|
sub["is_good"] = (sub["fwd_ret60"] >= thr).astype(int)
|
|
br = 20.0 # 相对分位定义,基线恒 20%
|
|
print(f"[{market}/{regime}] 样本{len(sub)} 果阈值60日+{thr:.0f}%")
|
|
|
|
# 因子池(方向:小市值/低估值/超跌/放量/企稳/低动量)
|
|
factor_defs = {
|
|
"mcap_q": ("<", 0.5), "pe_q": ("<", 0.5), "pb_q": ("<", 0.5),
|
|
"bias60": ("<", -5), "rsi": ("<", 50), "dist_lo20": (">", 3),
|
|
"vol_ratio": (">", 1.0), "mkt_ret20": ("<", 0), "ret20": ("<", 0),
|
|
"sec_ret20": ("<", 0), "flow5": (">", 0), "news3": (">=", 1),
|
|
"ret5": (">", -3), "mkt_rsi": ("<", 50),
|
|
}
|
|
# 单条件
|
|
single = []
|
|
for feat, (op, val) in factor_defs.items():
|
|
if feat not in sub.columns:
|
|
continue
|
|
cond = sub[feat] < val if op == "<" else sub[feat] > val
|
|
m = sub[cond]
|
|
if len(m) < 200:
|
|
continue
|
|
rate = m["is_good"].mean() * 100
|
|
if rate > 23: # 相对基线20% +3pp
|
|
single.append((feat, round(rate, 1), len(m), round(rate - 20, 1)))
|
|
single.sort(key=lambda x: -x[3])
|
|
print(" 单条件:", single[:4])
|
|
|
|
# 三因子组合(从单条件超额>2pp 里取 6 个,C(6,3)=20 组合)
|
|
pool = [s[0] for s in single if s[3] > 2][:6]
|
|
results = []
|
|
for combo in combinations(pool, 3):
|
|
cond = pd.Series(True, index=sub.index)
|
|
for feat in combo:
|
|
op, val = factor_defs[feat]
|
|
cond &= (sub[feat] < val) if op == "<" else (sub[feat] > val)
|
|
m = sub[cond]
|
|
if len(m) < 200:
|
|
continue
|
|
rate = m["is_good"].mean() * 100
|
|
avg = m["fwd_ret60"].mean()
|
|
results.append(({f: factor_defs[f] for f in combo}, len(m), round(rate, 1),
|
|
round(avg, 1), round(rate - 20, 1)))
|
|
results.sort(key=lambda x: -x[4])
|
|
return results[:5]
|
|
|
|
|
|
def _simulate_verify(market, regime, panel, cond, tp=10, sl=5, maxh=20):
|
|
"""模拟验证:候选条件在目标温区的模拟交易胜率/收益
|
|
返回 (trades, win_rate, avg_pnl) 或 None
|
|
"""
|
|
rm = load_regime_map(market)
|
|
sub = panel.copy()
|
|
sub["_regime"] = sub["date"].map(rm)
|
|
sub = sub[(sub["_regime"] == regime) & cond].copy()
|
|
if len(sub) < 200:
|
|
return None
|
|
sub = sub.sort_values(["code", "date"])
|
|
trades = []
|
|
for code, g in sub.groupby("code"):
|
|
g = g.sort_values("date")
|
|
idxs = list(g.index)
|
|
for k, i in enumerate(idxs):
|
|
fut = g.iloc[k+1:k+maxh+1]
|
|
if len(fut) < 2:
|
|
continue
|
|
ep = g.loc[i, "close"]
|
|
if ep <= 0:
|
|
continue
|
|
res = None
|
|
for _, fb in fut.iterrows():
|
|
if fb["close"] <= ep * (1 - sl / 100):
|
|
res = -sl
|
|
break
|
|
if fb["close"] >= ep * (1 + tp / 100):
|
|
res = tp
|
|
break
|
|
if res is None:
|
|
res = (fut.iloc[-1]["close"] / ep - 1) * 100
|
|
trades.append(res)
|
|
if not trades:
|
|
return None
|
|
wins = [x for x in trades if x > 0]
|
|
return len(trades), len(wins) / len(trades) * 100, sum(trades) / len(trades)
|
|
|
|
|
|
def to_entry(cond_dict):
|
|
entry = {}
|
|
for feat, (op, val) in cond_dict.items():
|
|
key = feat + ("_min" if (op == ">" or op == ">=") else "_max")
|
|
entry[key] = float(val)
|
|
return entry
|
|
|
|
|
|
def mine(market="a", regimes=None):
|
|
regimes = regimes or ["trend_up", "choppy", "trend_down"]
|
|
panel = load_panel(market)
|
|
out = {"market": market, "mined_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "candidates": []}
|
|
for rg in regimes:
|
|
combos = scan3(market, rg, panel)
|
|
for cond, n, rate, avg, extra in combos[:3]:
|
|
# ── 模拟验证门槛(2026-08-16 教训:好果率≠能赚钱,须模拟胜率≥50%且收益>0)──
|
|
# 构造条件 Series
|
|
c = pd.Series(True, index=panel.index)
|
|
for feat, (op, val) in cond.items():
|
|
if feat not in panel.columns:
|
|
c = None
|
|
break
|
|
c &= (panel[feat] < val) if op == "<" else (panel[feat] > val)
|
|
verified = None
|
|
if c is not None:
|
|
verified = _simulate_verify(market, rg, panel, c)
|
|
if verified:
|
|
tn, twr, tavg = verified
|
|
if twr < 50 or tavg <= 0:
|
|
print(f" [{rg}] {list(cond.keys())} 模拟未达标(胜率{twr:.0f}%/均{tavg:.2f}%) 剔除", flush=True)
|
|
continue
|
|
cand = {
|
|
"regime": rg, "market": market, "group": "B", "status": "verified",
|
|
"entry": to_entry(cond), "trades_est": n, "good_rate": rate,
|
|
"avg60": avg, "excess_pp": extra,
|
|
"sim_trades": tn, "sim_win_rate": round(twr, 1), "sim_avg_pnl": round(tavg, 2),
|
|
"hypothesis": f"[{rg}] 由果及因三因子: {list(cond.keys())} → 好果率{rate}% 模拟胜率{twr:.0f}%/均{tavg:.2f}%",
|
|
}
|
|
out["candidates"].append(cand)
|
|
print(f" [{rg}] {list(cond.keys())} ✅模拟达标 胜率{twr:.0f}% 均{tavg:.2f}%", flush=True)
|
|
else:
|
|
print(f" [{rg}] {list(cond.keys())} 样本不足或条件无效 剔除", flush=True)
|
|
return out
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
market = sys.argv[1] if len(sys.argv) > 1 else "a"
|
|
res = mine(market)
|
|
with open(OUT_JSON, "w", encoding="utf-8") as f:
|
|
json.dump(res, f, ensure_ascii=False, indent=1)
|
|
print(f"写入 {OUT_JSON}: {len(res['candidates'])} 个候选")
|