Files
stockbuddy/stock_backtest_v2.py
Stock Buddy bd7d85817a init: stock buddy v5 完整回测系统
三版本 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)
2026-03-22 12:57:47 +08:00

291 lines
12 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.
"""
港股 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()