404 lines
17 KiB
Python
404 lines
17 KiB
Python
import yfinance as yf
|
||
import pandas as pd
|
||
import numpy as np
|
||
import matplotlib.pyplot as plt
|
||
import seaborn as sns
|
||
import core.logger as logging
|
||
from config import OKX_MONITOR_CONFIG, MYSQL_CONFIG, WINDOW_SIZE
|
||
from core.db.db_market_data import DBMarketData
|
||
from core.db.db_huge_volume_data import DBHugeVolumeData
|
||
from core.utils import timestamp_to_datetime, transform_date_time_to_timestamp
|
||
|
||
# seaborn支持中文
|
||
plt.rcParams["font.family"] = ["SimHei"]
|
||
|
||
logger = logging.logger
|
||
|
||
|
||
class ORBStrategy:
|
||
def __init__(
|
||
self,
|
||
initial_capital=25000,
|
||
max_leverage=4,
|
||
risk_per_trade=0.01,
|
||
commission_per_share=0.0005,
|
||
):
|
||
"""
|
||
初始化ORB策略参数
|
||
:param initial_capital: 初始账户资金(美元)
|
||
:param max_leverage: 最大杠杆倍数(默认4倍,符合FINRA规定)
|
||
:param risk_per_trade: 单次交易风险比例(默认1%)
|
||
:param commission_per_share: 每股交易佣金(美元,默认0.0005)
|
||
"""
|
||
logger.info(f"初始化ORB策略参数:初始账户资金={initial_capital},最大杠杆倍数={max_leverage},单次交易风险比例={risk_per_trade},每股交易佣金={commission_per_share}")
|
||
self.initial_capital = initial_capital
|
||
self.max_leverage = max_leverage
|
||
self.risk_per_trade = risk_per_trade
|
||
self.commission_per_share = commission_per_share
|
||
self.data = None # 存储K线数据
|
||
self.trades = [] # 存储交易记录
|
||
self.equity_curve = None # 存储账户净值曲线
|
||
mysql_user = MYSQL_CONFIG.get("user", "xch")
|
||
mysql_password = MYSQL_CONFIG.get("password", "")
|
||
if not mysql_password:
|
||
raise ValueError("MySQL password is not set")
|
||
mysql_host = MYSQL_CONFIG.get("host", "localhost")
|
||
mysql_port = MYSQL_CONFIG.get("port", 3306)
|
||
mysql_database = MYSQL_CONFIG.get("database", "okx")
|
||
|
||
self.db_url = f"mysql+pymysql://{mysql_user}:{mysql_password}@{mysql_host}:{mysql_port}/{mysql_database}"
|
||
self.db_market_data = DBMarketData(self.db_url)
|
||
|
||
def fetch_intraday_data(self, symbol, start_date, end_date, interval="5m"):
|
||
"""
|
||
获取日内5分钟K线数据(需yfinance支持,部分数据可能有延迟)
|
||
:param ticker: 股票代码(如QQQ、TQQQ)
|
||
:param start_date: 起始日期(格式:YYYY-MM-DD)
|
||
:param end_date: 结束日期(格式:YYYY-MM-DD)
|
||
:param interval: K线周期(默认5分钟)
|
||
"""
|
||
logger.info(f"开始获取{symbol}数据:{start_date}至{end_date},间隔{interval}")
|
||
# data = yf.download(
|
||
# symbol, start=start_date, end=end_date, interval=interval, progress=False
|
||
# )
|
||
data = self.db_market_data.query_market_data_by_symbol_bar(
|
||
symbol, interval, start=start_date, end=end_date
|
||
)
|
||
data = pd.DataFrame(data)
|
||
data.sort_values(by="date_time", inplace=True)
|
||
# 保留核心列:开盘价、最高价、最低价、收盘价、成交量
|
||
data["Open"] = data["open"]
|
||
data["High"] = data["high"]
|
||
data["Low"] = data["low"]
|
||
data["Close"] = data["close"]
|
||
data["Volume"] = data["volume"]
|
||
# 将data["date_time"]从字符串类型转换为日期
|
||
data["date_time"] = pd.to_datetime(data["date_time"])
|
||
# data["Date"]为日期,不包括时分秒,即date_time如果是2025-01-01 10:00:00,则Date为2025-01-01
|
||
data["Date"] = data["date_time"].dt.date
|
||
# 将Date转换为datetime64[ns]类型以确保类型一致
|
||
data["Date"] = pd.to_datetime(data["Date"])
|
||
|
||
self.data = data[["Date", "date_time", "Open", "High", "Low", "Close", "Volume"]].copy()
|
||
logger.info(f"成功获取{symbol}数据:{len(self.data)}根{interval}K线")
|
||
|
||
def calculate_shares(self, account_value, entry_price, stop_price):
|
||
"""
|
||
根据ORB公式计算交易股数
|
||
:param account_value: 当前账户价值(美元)
|
||
:param entry_price: 交易entry价格(第二根5分钟K线开盘价)
|
||
:param stop_price: 止损价格(多头=第一根K线最低价,空头=第一根K线最高价)
|
||
:return: 整数股数(Shares)
|
||
"""
|
||
logger.info(f"开始计算交易股数:账户价值={account_value},entry价格={entry_price},止损价格={stop_price}")
|
||
# 计算单交易风险金额($R)
|
||
risk_per_trade_dollar = abs(entry_price - stop_price) # 风险金额取绝对值
|
||
if risk_per_trade_dollar <= 0:
|
||
return 0 # 无风险时不交易
|
||
|
||
# 公式1:基于风险预算的最大股数(风险控制优先)
|
||
shares_risk = (account_value * self.risk_per_trade) / risk_per_trade_dollar
|
||
# 公式2:基于杠杆限制的最大股数(杠杆约束)
|
||
shares_leverage = (self.max_leverage * account_value) / entry_price
|
||
# 取两者最小值(满足风险和杠杆双重约束)
|
||
max_shares = min(shares_risk, shares_leverage)
|
||
# 扣除佣金影响(简化计算:假设佣金从可用资金中扣除)
|
||
commission_cost = max_shares * self.commission_per_share
|
||
if (account_value - commission_cost) < 0:
|
||
return 0 # 扣除佣金后资金不足,不交易
|
||
|
||
return int(max_shares) # 股数取整
|
||
|
||
def generate_orb_signals(self):
|
||
"""
|
||
生成ORB策略信号(每日仅1次交易机会)
|
||
- 第一根5分钟K线:确定开盘区间(High1, Low1)
|
||
- 第二根5分钟K线:根据第一根K线方向生成多空信号
|
||
"""
|
||
logger.info("开始生成ORB策略信号")
|
||
if self.data is None:
|
||
raise ValueError("请先调用fetch_intraday_data获取数据")
|
||
|
||
signals = []
|
||
# 按日期分组处理每日数据
|
||
for date, daily_data in self.data.groupby("Date"):
|
||
daily_data = daily_data.sort_index() # 按时间排序
|
||
if len(daily_data) < 2:
|
||
continue # 当日K线不足2根,跳过
|
||
|
||
# 第一根5分钟K线(开盘区间)
|
||
first_candle = daily_data.iloc[0]
|
||
high1 = first_candle["High"]
|
||
low1 = first_candle["Low"]
|
||
open1 = first_candle["Open"]
|
||
close1 = first_candle["Close"]
|
||
|
||
# 第二根5分钟K线(entry信号)
|
||
second_candle = daily_data.iloc[1]
|
||
entry_price = second_candle["Open"] # entry价格=第二根K线开盘价
|
||
entry_time = second_candle.date_time # entry时间
|
||
|
||
# 生成信号:第一根K线方向决定多空(排除十字星:open1 == close1)
|
||
if open1 < close1:
|
||
# 第一根K线收涨→多头信号
|
||
signal = "Long"
|
||
stop_price = low1 # 多头止损=第一根K线最低价
|
||
elif open1 > close1:
|
||
# 第一根K线收跌→空头信号
|
||
signal = "Short"
|
||
stop_price = high1 # 空头止损=第一根K线最高价
|
||
else:
|
||
# 十字星→无信号
|
||
signal = "None"
|
||
stop_price = None
|
||
|
||
signals.append(
|
||
{
|
||
"Date": date,
|
||
"EntryTime": entry_time,
|
||
"Signal": signal,
|
||
"EntryPrice": entry_price,
|
||
"StopPrice": stop_price,
|
||
"High1": high1,
|
||
"Low1": low1,
|
||
}
|
||
)
|
||
|
||
# 将信号合并到原始数据
|
||
signals_df = pd.DataFrame(signals)
|
||
# 确保Date列类型一致,将Date转换为datetime64[ns]类型
|
||
signals_df['Date'] = pd.to_datetime(signals_df['Date'])
|
||
# 使用merge而不是join来合并数据,根据signals_df的EntryTime与self.data的date_time进行匹配
|
||
# TODO: 这里需要优化
|
||
self.data = self.data.merge(signals_df, left_on="date_time", right_on="EntryTime", how="left")
|
||
# 将Date_x和Date_y合并为Date
|
||
self.data["Date"] = self.data["Date_x"].combine_first(self.data["Date_y"])
|
||
# 删除Date_x和Date_y
|
||
self.data.drop(columns=["Date_x", "Date_y"], inplace=True)
|
||
logger.info(
|
||
f"生成信号完成:共{len(signals_df)}个交易日,其中多头{sum(signals_df['Signal']=='Long')}次,空头{sum(signals_df['Signal']=='Short')}次"
|
||
)
|
||
|
||
def backtest(self, profit_target_multiple=10):
|
||
"""
|
||
回测ORB策略
|
||
:param profit_target_multiple: 盈利目标倍数(默认10倍$R,即10R)
|
||
"""
|
||
logger.info(f"开始回测ORB策略:盈利目标倍数={profit_target_multiple}")
|
||
if "Signal" not in self.data.columns:
|
||
raise ValueError("请先调用generate_orb_signals生成策略信号")
|
||
|
||
account_value = self.initial_capital # 初始账户价值
|
||
current_position = None # 当前持仓(None=空仓,Long/Short=持仓)
|
||
equity_history = [account_value] # 净值历史
|
||
trade_id = 0 # 交易ID
|
||
|
||
# 按时间遍历数据(每日仅处理第二根K线后的信号)
|
||
for date, daily_data in self.data.groupby("Date"):
|
||
daily_data = daily_data.sort_index()
|
||
if len(daily_data) < 2:
|
||
continue
|
||
|
||
# 获取当日信号(第二根K线的信号)
|
||
signal_row = (
|
||
daily_data[~pd.isna(daily_data["Signal"])].iloc[0]
|
||
if sum(~pd.isna(daily_data["Signal"])) > 0
|
||
else None
|
||
)
|
||
if signal_row is None:
|
||
# 无信号→当日不交易,净值保持不变
|
||
equity_history.append(account_value)
|
||
continue
|
||
|
||
# 提取信号参数
|
||
signal = signal_row["Signal"]
|
||
if pd.isna(signal):
|
||
continue
|
||
entry_price = signal_row["EntryPrice"]
|
||
stop_price = signal_row["StopPrice"]
|
||
high1 = signal_row["High1"]
|
||
low1 = signal_row["Low1"]
|
||
risk_assumed = abs(entry_price - stop_price) # 计算$R
|
||
profit_target = (
|
||
entry_price + (risk_assumed * profit_target_multiple)
|
||
if signal == "Long"
|
||
else entry_price - (risk_assumed * profit_target_multiple)
|
||
)
|
||
|
||
# 计算交易股数
|
||
shares = self.calculate_shares(account_value, entry_price, stop_price)
|
||
if shares == 0:
|
||
# 股数为0→不交易
|
||
equity_history.append(account_value)
|
||
continue
|
||
|
||
# 计算佣金(买入/卖出各收一次)
|
||
total_commission = shares * self.commission_per_share * 2 # 往返佣金
|
||
|
||
# 模拟日内持仓:寻找止损/止盈触发点,或当日收盘平仓
|
||
daily_prices = daily_data[
|
||
daily_data.date_time > signal_row.date_time
|
||
] # 从entry时间开始遍历
|
||
exit_price = None
|
||
exit_time = None
|
||
exit_reason = None
|
||
|
||
for idx, (time, row) in enumerate(daily_prices.iterrows()):
|
||
high = row["High"]
|
||
low = row["Low"]
|
||
close = row["Close"]
|
||
|
||
# 检查止损/止盈条件
|
||
if signal == "Long":
|
||
# 多头:跌破止损→止损;突破止盈→止盈
|
||
if low <= stop_price:
|
||
exit_price = stop_price
|
||
exit_reason = "Stop Loss"
|
||
exit_time = time
|
||
break
|
||
elif high >= profit_target:
|
||
exit_price = profit_target
|
||
exit_reason = "Profit Target (10R)"
|
||
exit_time = time
|
||
break
|
||
elif signal == "Short":
|
||
# 空头:突破止损→止损;跌破止盈→止盈
|
||
if high >= stop_price:
|
||
exit_price = stop_price
|
||
exit_reason = "Stop Loss"
|
||
exit_time = time
|
||
break
|
||
elif low <= profit_target:
|
||
exit_price = profit_target
|
||
exit_reason = "Profit Target (10R)"
|
||
exit_time = time
|
||
break
|
||
|
||
# 若未触发止损/止盈,当日收盘平仓
|
||
if exit_price is None:
|
||
exit_price = daily_prices.iloc[-1]["Close"]
|
||
exit_reason = "End of Day (EoD)"
|
||
exit_time = daily_prices.iloc[-1].date_time
|
||
|
||
# 计算盈亏
|
||
if signal == "Long":
|
||
profit_loss = (exit_price - entry_price) * shares - total_commission
|
||
else: # Short
|
||
profit_loss = (entry_price - exit_price) * shares - total_commission
|
||
|
||
# 更新账户价值
|
||
account_value += profit_loss
|
||
account_value = max(account_value, 0) # 账户价值不能为负
|
||
|
||
# 记录交易
|
||
self.trades.append(
|
||
{
|
||
"TradeID": trade_id,
|
||
"Date": date,
|
||
"Signal": signal,
|
||
"EntryTime": signal_row.date_time,
|
||
"EntryPrice": entry_price,
|
||
"ExitTime": exit_time,
|
||
"ExitPrice": exit_price,
|
||
"Shares": shares,
|
||
"RiskAssumed": risk_assumed,
|
||
"ProfitLoss": profit_loss,
|
||
"ExitReason": exit_reason,
|
||
"AccountValueAfter": account_value,
|
||
}
|
||
)
|
||
|
||
# 记录净值
|
||
equity_history.append(account_value)
|
||
trade_id += 1
|
||
|
||
# 生成净值曲线
|
||
self.equity_curve = pd.Series(
|
||
equity_history,
|
||
index=pd.date_range(
|
||
start=self.data.index[0].date(), periods=len(equity_history), freq="D"
|
||
),
|
||
)
|
||
|
||
# 输出回测结果
|
||
trades_df = pd.DataFrame(self.trades)
|
||
total_return = (
|
||
(account_value - self.initial_capital) / self.initial_capital * 100
|
||
)
|
||
win_rate = (
|
||
(trades_df["ProfitLoss"] > 0).sum() / len(trades_df) * 100
|
||
if len(trades_df) > 0
|
||
else 0
|
||
)
|
||
|
||
logger.info("\n" + "=" * 50)
|
||
logger.info("ORB策略回测结果")
|
||
logger.info("=" * 50)
|
||
logger.info(f"初始资金:${self.initial_capital:,.2f}")
|
||
logger.info(f"最终资金:${account_value:,.2f}")
|
||
logger.info(f"总收益率:{total_return:.2f}%")
|
||
logger.info(f"总交易次数:{len(trades_df)}")
|
||
logger.info(f"胜率:{win_rate:.2f}%")
|
||
if len(trades_df) > 0:
|
||
logger.info(f"平均每笔盈亏:${trades_df['ProfitLoss'].mean():.2f}")
|
||
logger.info(f"最大单笔盈利:${trades_df['ProfitLoss'].max():.2f}")
|
||
logger.info(f"最大单笔亏损:${trades_df['ProfitLoss'].min():.2f}")
|
||
|
||
def plot_equity_curve(self):
|
||
"""
|
||
绘制账户净值曲线
|
||
"""
|
||
logger.info("开始绘制账户净值曲线")
|
||
if self.equity_curve is None:
|
||
raise ValueError("请先调用backtest进行回测")
|
||
|
||
# seaborn风格设置
|
||
sns.set_theme(style="whitegrid")
|
||
# plt.rcParams['font.family'] = "SimHei"
|
||
plt.rcParams["font.sans-serif"] = ["SimHei"] # 也可直接用字体名
|
||
plt.rcParams["font.size"] = 11 # 设置字体大小
|
||
plt.rcParams["axes.unicode_minus"] = False # 解决负号显示问题
|
||
|
||
plt.figure(figsize=(12, 6))
|
||
plt.plot(
|
||
self.equity_curve.index,
|
||
self.equity_curve.values,
|
||
label="ORB策略净值",
|
||
color="blue",
|
||
)
|
||
plt.axhline(
|
||
y=self.initial_capital, color="red", linestyle="--", label="初始资金"
|
||
)
|
||
plt.title("ORB策略账户净值曲线", fontsize=14)
|
||
plt.xlabel("日期", fontsize=12)
|
||
plt.ylabel("账户价值(美元)", fontsize=12)
|
||
plt.legend()
|
||
plt.grid(True, alpha=0.3)
|
||
plt.show()
|
||
|
||
|
||
# ------------------- 策略示例:回测QQQ的ORB策略(2016-2023) -------------------
|
||
if __name__ == "__main__":
|
||
|
||
# 初始化ORB策略
|
||
orb_strategy = ORBStrategy(
|
||
initial_capital=25000,
|
||
max_leverage=4,
|
||
risk_per_trade=0.01,
|
||
commission_per_share=0.0005,
|
||
)
|
||
|
||
# 1. 获取QQQ的5分钟日内数据(2024-2025,注意:yfinance免费版可能限制历史日内数据,建议用专业数据源)
|
||
orb_strategy.fetch_intraday_data(
|
||
symbol="ETH-USDT", start_date="2025-05-15", end_date="2025-08-20", interval="5m"
|
||
)
|
||
|
||
# 2. 生成ORB策略信号
|
||
orb_strategy.generate_orb_signals()
|
||
|
||
# 3. 回测策略(盈利目标10R)
|
||
orb_strategy.backtest(profit_target_multiple=10)
|
||
|
||
# 4. 绘制净值曲线
|
||
orb_strategy.plot_equity_curve()
|