xiaowei-system/scripts/stock_ma20_backtest.py

186 lines
7.3 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
"""
贵州茅台 MA20 突破策略回测
策略:金叉买入,死叉卖出
"""
import sys, json, urllib.request
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
OUTPUT = Path.home() / ".hermes" / "stock_backtest"
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/446db983-e392-4d2c-bfb8-f9060e5df3ad"
def get_data(code, count=500):
mc = f"sh{code}" if code.startswith("6") else f"sz{code}"
end = datetime.now().strftime("%Y-%m-%d")
start = (datetime.now() - timedelta(days=count*1.5)).strftime("%Y-%m-%d")
url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
f"?_var=kline_dayqfq&param={mc},day,{start},{end},500,qfq")
try:
text = urllib.request.urlopen(url, timeout=10).read().decode("utf-8")
data = json.loads(text.replace("kline_dayqfq=", "", 1))
qfq = (data.get("data", {}).get(mc, {}).get("qfqday") or
data.get("data", {}).get(mc, {}).get("day") or [])
rows = []
for item in qfq:
if len(item) < 6: continue
rows.append({"date": item[0], "open": float(item[1]),
"close": float(item[2]), "high": float(item[3]),
"low": float(item[4]), "volume": float(item[5])})
df = pd.DataFrame(rows)
df["date"] = pd.to_datetime(df["date"])
return df.sort_values("date").reset_index(drop=True)
except Exception as e:
print(f"数据获取失败: {e}")
return pd.DataFrame()
def send_feishu(msg):
payload = json.dumps({"msg_type": "text", "content": {"text": msg}}).encode()
req = urllib.request.Request(FEISHU_WEBHOOK, data=payload,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10): pass
except Exception:
pass
def backtest_ma20(code, name, initial_cash=100000):
df = get_data(code, 500)
if df.empty:
print(f"获取数据失败")
return
df["ma20"] = df["close"].rolling(window=20).mean()
df["prev_close"] = df["close"].shift(1)
df["prev_ma20"] = df["ma20"].shift(1)
# 金叉/死叉信号
df["golden_cross"] = (df["prev_close"] < df["prev_ma20"]) & (df["close"] > df["ma20"])
df["death_cross"] = (df["prev_close"] > df["prev_ma20"]) & (df["close"] < df["ma20"])
# 策略回测
cash = initial_cash
position = 0
trades = []
equity = []
in_position = False
buy_price = 0
for i, row in df.iterrows():
if pd.isna(row["ma20"]):
equity.append({"date": row["date"], "value": cash})
continue
date_str = row["date"].strftime("%Y-%m-%d")
price = row["close"]
# 买入(金叉)
if row["golden_cross"] and not in_position:
shares = int(cash / price)
cost = shares * price
if shares > 0:
cash -= cost
position = shares
buy_price = price
in_position = True
trades.append({"type": "BUY", "date": date_str, "price": price, "shares": shares, "reason": "金叉"})
# 卖出(死叉)
elif row["death_cross"] and in_position:
proceeds = position * price
cash += proceeds
profit_pct = (price - buy_price) / buy_price * 100
trades.append({"type": "SELL", "date": date_str, "price": price, "shares": position, "profit_pct": profit_pct, "reason": "死叉"})
position = 0
in_position = False
buy_price = 0
# 当日价值
value = cash + position * price
equity.append({"date": date_str, "value": value, "price": price})
# 最终资产
final_price = df.iloc[-1]["close"]
final_value = cash + position * final_price
# 买入持有对比
buy_price_hold = df.iloc[19]["close"] # 第一个有效 MA20 时的价格
shares_hold = int(initial_cash / buy_price_hold)
hold_value = shares_hold * final_price
hold_return = (hold_value - initial_cash) / initial_cash * 100
strat_return = (final_value - initial_cash) / initial_cash * 100
alpha = strat_return - hold_return
# 最大回撤
equity_curve = [e["value"] for e in equity]
peak = equity_curve[0]
max_dd = 0
for v in equity_curve:
if v > peak: peak = v
dd = (peak - v) / peak * 100
if dd > max_dd: max_dd = dd
# 胜率
sell_trades = [t for t in trades if t["type"] == "SELL"]
win_trades = [t for t in sell_trades if t.get("profit_pct", 0) > 0]
win_rate = len(win_trades) / len(sell_trades) * 100 if sell_trades else 0
print(f"========================================")
print(f"{name}({code}) MA20突破策略回测")
print(f"数据区间: {df['date'].min().date()} ~ {df['date'].max().date()}")
print(f"========================================")
print(f" 初始资金: {initial_cash:,.0f}")
print(f" 最终资产: {final_value:,.0f}")
print(f" 策略收益: {strat_return:+.2f}%")
print(f" 买入持有: {hold_return:+.2f}%")
print(f" 超额收益(α):{alpha:+.2f}%")
print(f" 最大回撤: {max_dd:.2f}%")
print(f" 交易次数: {len(sell_trades)}")
print(f" 胜率: {win_rate:.1f}%")
print()
print(f" 金叉次数: {df['golden_cross'].sum()}")
print(f" 死叉次数: {df['death_cross'].sum()}")
print(f" 当前持仓: {'' if in_position else ''}")
if in_position:
print(f" 持仓成本: {buy_price:.2f}")
print(f" 当前盈亏: {(final_price - buy_price) / buy_price * 100:+.2f}%")
print()
print(f" 最近5笔交易:")
for t in trades[-5:]:
if t["type"] == "BUY":
print(f" [{t['date']}] BUY {t['shares']}股@{t['price']:.2f} ({t['reason']})")
else:
print(f" [{t['date']}] SELL {t['shares']}股@{t['price']:.2f} {t['profit_pct']:+.2f}% ({t['reason']})")
# 保存结果
result = {
"code": code, "name": name, "strategy": "MA20",
"period": f"{df['date'].min().date()} ~ {df['date'].max().date()}",
"initial_cash": initial_cash, "final_value": final_value,
"strategy_return": strat_return, "buyhold_return": hold_return,
"alpha": alpha, "max_drawdown": max_dd,
"total_trades": len(sell_trades), "win_rate": win_rate,
"trades": str(trades[-10:]),
"in_position": in_position, "buy_price": buy_price if in_position else None,
"current_price": float(final_price)
}
OUTPUT.mkdir(exist_ok=True)
result_file = OUTPUT / f"ma20_result_{code}.json"
with open(result_file, "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
# 飞书推送
msg = (f"📊 {name}({code}) MA20策略回测\n"
f"区间: {df['date'].min().date()} ~ {df['date'].max().date()}\n"
f"策略收益: {strat_return:+.2f}% | 买入持有: {hold_return:+.2f}% | α={alpha:+.2f}%\n"
f"最大回撤: {max_dd:.2f}% | 交易次数: {len(sell_trades)} | 胜率: {win_rate:.1f}%\n"
f"当前: {'持仓中 ' + str(round((final_price-buy_price)/buy_price*100,2)) + '%' if in_position else '空仓'}")
send_feishu(msg)
if __name__ == "__main__":
if len(sys.argv) >= 3:
backtest_ma20(sys.argv[1], sys.argv[2], 100000)
else:
backtest_ma20("600519", "贵州茅台", 100000)