三版本 A/B/C 止损策略对比回测 - A: 固定止损 12% - B: ATR x2.5 动态止损 - C: 混合自适应(低波动固定8%/中波动ATR×2.5/高波动ATR×2.0) 含仓位分级、成交量确认、CSV缓存机制 已验证三只港股持仓:01833 / 09886 / 09982 待补全:data/1833.csv 和 data/9886.csv(在外网运行 download_data.py)
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""
|
||
港股 AI 综合评分系统 v2 - 回测框架
|
||
三维度加权评分:技术面(50%) + 基本面(30%) + 舆情(20%)
|
||
"""
|
||
|
||
import yfinance as yf
|
||
import pandas as pd
|
||
import numpy as np
|
||
import time
|
||
import warnings
|
||
warnings.filterwarnings('ignore')
|
||
|
||
# ── 参数 ──────────────────────────────────────────────────────────────
|
||
STOCKS = {
|
||
"平安好医生": "1833.HK",
|
||
"叮当健康": "9886.HK",
|
||
"中原建业": "9982.HK",
|
||
}
|
||
PERIOD = "2y"
|
||
INITIAL_CAPITAL = 10000 # HKD
|
||
|
||
# 三维度权重
|
||
W_TECH = 0.50
|
||
W_FUNDAMENTAL = 0.30
|
||
W_SENTIMENT = 0.20
|
||
|
||
# ── 基本面快照(手动录入,按季度/年更新)────────────────────────────
|
||
# 格式:每条记录 {"from": "YYYY-MM-DD", "score": float, "note": str}
|
||
# 分数区间 -10 ~ +10
|
||
FUNDAMENTAL_TIMELINE = {
|
||
"平安好医生": [
|
||
{"from": "2024-01-01", "score": -3.0, "note": "持续亏损,估值偏高"},
|
||
{"from": "2024-08-01", "score": -1.0, "note": "2024中报净利润转正,估值仍高"},
|
||
{"from": "2025-01-01", "score": 0.0, "note": "盈利改善,医险协同深化"},
|
||
{"from": "2025-08-01", "score": 1.0, "note": "营收+13.6%,经调整净利润+45.7%"},
|
||
],
|
||
"叮当健康": [
|
||
{"from": "2024-01-01", "score": -3.0, "note": "连续亏损"},
|
||
{"from": "2024-06-01", "score": -2.0, "note": "亏损收窄中"},
|
||
{"from": "2025-01-01", "score": -1.0, "note": "亏损继续收窄,毛利率提升"},
|
||
{"from": "2025-09-01", "score": 1.0, "note": "2025全年调整后盈利1070万,拐点初现"},
|
||
],
|
||
"中原建业": [
|
||
{"from": "2024-01-01", "score": -3.0, "note": "地产下行,代建收入减少"},
|
||
{"from": "2024-06-01", "score": -4.0, "note": "停牌风险,执行董事辞任"},
|
||
{"from": "2025-01-01", "score": -4.0, "note": "盈警:净利润同比-28~32%"},
|
||
{"from": "2025-10-01", "score": -5.0, "note": "地产持续低迷,无机构覆盖"},
|
||
],
|
||
}
|
||
|
||
# ── 舆情快照(手动录入)────────────────────────────────────────────
|
||
SENTIMENT_TIMELINE = {
|
||
"平安好医生": [
|
||
{"from": "2024-01-01", "score": -1.0, "note": "行业承压"},
|
||
{"from": "2024-10-01", "score": 1.0, "note": "互联网医疗政策边际改善"},
|
||
{"from": "2025-01-01", "score": 2.0, "note": "大摩买入评级,目标价19.65"},
|
||
{"from": "2026-01-01", "score": 3.0, "note": "主力资金持续净流入,连锁药房扩展"},
|
||
],
|
||
"叮当健康": [
|
||
{"from": "2024-01-01", "score": -2.0, "note": "市场悲观,连亏"},
|
||
{"from": "2024-08-01", "score": -1.0, "note": "关注度低"},
|
||
{"from": "2025-04-01", "score": 1.0, "note": "互联网首诊试点,4月新规利好"},
|
||
{"from": "2025-10-01", "score": 2.0, "note": "雪球社区关注回升,创新药布局"},
|
||
],
|
||
"中原建业": [
|
||
{"from": "2024-01-01", "score": -2.0, "note": "地产悲观情绪"},
|
||
{"from": "2024-06-01", "score": -3.0, "note": "管理层动荡,停牌"},
|
||
{"from": "2025-01-01", "score": -3.0, "note": "无投行覆盖,成交极低"},
|
||
{"from": "2025-10-01", "score": -4.0, "note": "发盈警,市场信心极低"},
|
||
],
|
||
}
|
||
|
||
# ── 工具函数 ──────────────────────────────────────────────────────────
|
||
|
||
def get_snapshot_score(timeline, date):
|
||
"""根据日期获取对应时间段的快照分数"""
|
||
score = timeline[0]["score"]
|
||
for entry in timeline:
|
||
if str(date.date()) >= entry["from"]:
|
||
score = entry["score"]
|
||
else:
|
||
break
|
||
return score
|
||
|
||
def calc_rsi(series, period=14):
|
||
delta = series.diff()
|
||
gain = delta.clip(lower=0)
|
||
loss = -delta.clip(upper=0)
|
||
avg_gain = gain.ewm(com=period - 1, min_periods=period).mean()
|
||
avg_loss = loss.ewm(com=period - 1, min_periods=period).mean()
|
||
rs = avg_gain / avg_loss
|
||
return 100 - (100 / (1 + rs))
|
||
|
||
def calc_macd(series, fast=12, slow=26, signal=9):
|
||
ema_fast = series.ewm(span=fast, adjust=False).mean()
|
||
ema_slow = series.ewm(span=slow, adjust=False).mean()
|
||
macd = ema_fast - ema_slow
|
||
signal_line = macd.ewm(span=signal, adjust=False).mean()
|
||
return macd, signal_line, macd - signal_line
|
||
|
||
def score_technical(row):
|
||
"""技术面评分 -10 ~ +10"""
|
||
score = 0
|
||
# RSI
|
||
if row["RSI"] < 30: score += 3
|
||
elif row["RSI"] < 45: score += 1
|
||
elif row["RSI"] > 70: score -= 3
|
||
elif row["RSI"] > 55: score -= 1
|
||
# MACD 金叉/死叉
|
||
if row["MACD_hist"] > 0 and row["MACD_hist_prev"] <= 0: score += 3
|
||
elif row["MACD_hist"] < 0 and row["MACD_hist_prev"] >= 0: score -= 3
|
||
elif row["MACD_hist"] > 0: score += 1
|
||
else: score -= 1
|
||
# 均线排列
|
||
if row["MA5"] > row["MA20"] > row["MA60"]: score += 2
|
||
elif row["MA5"] < row["MA20"] < row["MA60"]: score -= 2
|
||
# MA20 突破/跌破
|
||
if row["Close"] > row["MA20"] and row["Close_prev"] <= row["MA20_prev"]: score += 1
|
||
elif row["Close"] < row["MA20"] and row["Close_prev"] >= row["MA20_prev"]: score -= 1
|
||
return float(np.clip(score, -10, 10))
|
||
|
||
# ── 回测主函数 ────────────────────────────────────────────────────────
|
||
|
||
def backtest(name, ticker):
|
||
print(f"\n{'='*65}")
|
||
print(f" 回测: {name} ({ticker})")
|
||
print(f"{'='*65}")
|
||
|
||
df = yf.download(ticker, period=PERIOD, auto_adjust=True, progress=False)
|
||
if df.empty or len(df) < 60:
|
||
print(" ⚠️ 数据不足,跳过")
|
||
return None
|
||
|
||
if isinstance(df.columns, pd.MultiIndex):
|
||
df.columns = df.columns.droplevel(1)
|
||
|
||
close = df["Close"]
|
||
df["RSI"] = calc_rsi(close)
|
||
macd, sig_line, hist = calc_macd(close)
|
||
df["MACD_hist"] = hist
|
||
df["MACD_hist_prev"] = hist.shift(1)
|
||
for p in [5, 20, 60]:
|
||
df[f"MA{p}"] = close.rolling(p).mean()
|
||
df["MA20_prev"] = df["MA20"].shift(1)
|
||
df["Close_prev"] = close.shift(1)
|
||
df = df.dropna()
|
||
|
||
# 加入三维度评分
|
||
tech_scores = []
|
||
fund_scores = []
|
||
sent_scores = []
|
||
total_scores = []
|
||
|
||
for date, row in df.iterrows():
|
||
t = score_technical(row)
|
||
f = get_snapshot_score(FUNDAMENTAL_TIMELINE[name], date)
|
||
s = get_snapshot_score(SENTIMENT_TIMELINE[name], date)
|
||
combined = W_TECH * t + W_FUNDAMENTAL * f + W_SENTIMENT * s
|
||
tech_scores.append(t)
|
||
fund_scores.append(f)
|
||
sent_scores.append(s)
|
||
total_scores.append(combined)
|
||
|
||
df["Tech"] = tech_scores
|
||
df["Fund"] = fund_scores
|
||
df["Sent"] = sent_scores
|
||
df["Score"] = total_scores
|
||
|
||
# 买卖阈值:综合评分 >= 1.5 买入;<= -1.5 卖出
|
||
BUY_THRESH = 1.5
|
||
SELL_THRESH = -1.5
|
||
df["Signal"] = 0
|
||
df.loc[df["Score"] >= BUY_THRESH, "Signal"] = 1
|
||
df.loc[df["Score"] <= SELL_THRESH, "Signal"] = -1
|
||
|
||
# ── 模拟交易 ──
|
||
capital = float(INITIAL_CAPITAL)
|
||
position = 0
|
||
entry_price = 0.0
|
||
trades = []
|
||
|
||
for date, row in df.iterrows():
|
||
price = float(row["Close"])
|
||
sig = int(row["Signal"])
|
||
|
||
if sig == 1 and position == 0 and capital > price:
|
||
shares = int(capital / price)
|
||
position = shares
|
||
entry_price = price
|
||
capital -= shares * price
|
||
trades.append({
|
||
"日期": date.date(), "操作": "买入",
|
||
"价格": round(price, 4), "股数": shares,
|
||
"综合分": round(float(row["Score"]), 2),
|
||
"技术": round(float(row["Tech"]), 1),
|
||
"基本面": round(float(row["Fund"]), 1),
|
||
"舆情": round(float(row["Sent"]), 1),
|
||
})
|
||
|
||
elif sig == -1 and position > 0:
|
||
revenue = position * price
|
||
pnl = revenue - position * entry_price
|
||
pnl_pct = pnl / (position * entry_price) * 100
|
||
capital += revenue
|
||
trades.append({
|
||
"日期": date.date(), "操作": "卖出",
|
||
"价格": round(price, 4), "股数": position,
|
||
"综合分": round(float(row["Score"]), 2),
|
||
"技术": round(float(row["Tech"]), 1),
|
||
"基本面": round(float(row["Fund"]), 1),
|
||
"舆情": round(float(row["Sent"]), 1),
|
||
"盈亏HKD": round(pnl, 2),
|
||
"盈亏%": f"{pnl_pct:+.1f}%",
|
||
})
|
||
position = 0
|
||
entry_price = 0.0
|
||
|
||
last_price = float(df["Close"].iloc[-1])
|
||
if position > 0:
|
||
unrealized = position * (last_price - entry_price)
|
||
capital_total = capital + position * last_price
|
||
trades.append({
|
||
"日期": "持仓中", "操作": "未平仓",
|
||
"价格": round(last_price, 4), "股数": position,
|
||
"未实现盈亏HKD": round(unrealized, 2),
|
||
})
|
||
else:
|
||
capital_total = capital
|
||
|
||
strategy_return = (capital_total - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100
|
||
buy_hold_return = (last_price / float(df["Close"].iloc[0]) - 1) * 100
|
||
|
||
# ── 输出 ──
|
||
print(f"\n 📊 交易记录:")
|
||
tdf = pd.DataFrame(trades)
|
||
if not tdf.empty:
|
||
print(tdf.to_string(index=False))
|
||
else:
|
||
print(" 无交易信号")
|
||
|
||
# 评分分布统计
|
||
print(f"\n 📉 评分分布(综合):")
|
||
bins = [-10, -3, -1.5, 0, 1.5, 3, 10]
|
||
labels = ["强卖[-10,-3]","卖[-3,-1.5]","中性[-1.5,0]","中性[0,1.5]","买[1.5,3]","强买[3,10]"]
|
||
score_ser = df["Score"]
|
||
for label, cnt in zip(labels, np.histogram(score_ser, bins=bins)[0]):
|
||
bar = "█" * int(cnt / max(1, len(df)) * 40)
|
||
print(f" {label:>18}: {bar} ({cnt}天)")
|
||
|
||
print(f"\n 📈 回测结果汇总:")
|
||
print(f" 初始资金: HKD {INITIAL_CAPITAL:>10,.0f}")
|
||
print(f" 最终资金: HKD {capital_total:>10,.2f}")
|
||
print(f" 策略总收益: {strategy_return:>+10.1f}%")
|
||
print(f" 买入持有收益: {buy_hold_return:>+10.1f}% (同期)")
|
||
print(f" 超额收益(α): {strategy_return - buy_hold_return:>+10.1f}%")
|
||
print(f" 触发交易次数: {len([t for t in trades if t.get('操作') in ['买入','卖出']]):>10}")
|
||
|
||
return {
|
||
"name": name,
|
||
"strategy": strategy_return,
|
||
"buy_hold": buy_hold_return,
|
||
"alpha": strategy_return - buy_hold_return,
|
||
"trades": len([t for t in trades if t.get("操作") in ["买入", "卖出"]]),
|
||
}
|
||
|
||
|
||
# ── 主入口 ────────────────────────────────────────────────────────────
|
||
if __name__ == "__main__":
|
||
print("\n🔬 港股 AI 综合评分系统 v2 — 历史回测")
|
||
print(f" 权重: 技术面 {W_TECH*100:.0f}% | 基本面 {W_FUNDAMENTAL*100:.0f}% | 舆情 {W_SENTIMENT*100:.0f}%")
|
||
print(f" 买入阈值: ≥1.5 | 卖出阈值: ≤-1.5 | 数据周期: {PERIOD}\n")
|
||
|
||
results = []
|
||
for i, (name, ticker) in enumerate(STOCKS.items()):
|
||
if i > 0:
|
||
time.sleep(3) # 避免 yfinance 限速
|
||
r = backtest(name, ticker)
|
||
if r:
|
||
results.append(r)
|
||
|
||
if results:
|
||
print(f"\n{'='*65}")
|
||
print(" 📋 三股票综合汇总")
|
||
print(f"{'='*65}")
|
||
print(f" {'股票':<12} {'策略收益':>10} {'买持收益':>10} {'超额收益α':>10} {'交易次数':>8}")
|
||
print(f" {'-'*54}")
|
||
for r in results:
|
||
flag = "✅" if r["alpha"] > 0 else "❌"
|
||
print(f" {r['name']:<12} {r['strategy']:>+9.1f}% {r['buy_hold']:>+9.1f}% {r['alpha']:>+9.1f}% {r['trades']:>6} {flag}")
|
||
print()
|