← 返回首页

均值回归策略测试

做T好难啊,还是网格策略简单点。这个策略还需要晚上修改。
1、先做大小网格策略,大网格负责行情,小网格负责做T。

CODE
# -*- coding: gbk -*-
"""
QMT版VWAP均值回归参数扫描回测

策略逻辑:
1. 使用QMT本地1分钟行情计算每日累计VWAP。
2. 收盘价低于VWAP指定阈值时产生做多信号。
3. 收盘价高于VWAP指定阈值时产生做空信号。
4. 价格回归VWAP时平仓,14:50后强制平仓。
5. 信号在当前分钟收盘后确认,并在下一分钟开盘成交,避免未来函数。
6. 每个方向每天最多开仓一次,同一时刻最多持有一个方向。
7. 扫描全部偏离阈值,输出收益、胜率、盈亏比、回撤和夏普比率。

重要说明:
- 本程序是QMT策略中的独立参数扫描器,不使用QMT内置撮合。
- 运行前必须通过QMT“数据管理”补充对应标的的1分钟历史行情。
- 普通ETF无法直接做空,SHORT结果仅代表理论双向回测结果。
"""

import os
import warnings
from datetime import datetime

import numpy as np
import pandas as pd

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

warnings.filterwarnings('ignore')
matplotlib.rcParams['font.sans-serif'] = ['SimHei']
matplotlib.rcParams['axes.unicode_minus'] = False


# ================================================================
# 第一部分:回测配置
# ================================================================

CONFIG = {
    'stock_code': '159915.SZ',
    'start_date': '2021-09-01',
    'end_date': '2026-09-01',
    'initial_capital': 100000,
    'position_ratio': 0.5,
    'fee_rate': 0.00015,
    'slippage': 0.001,
    'min_trade_volume': 100,
    'force_exit_time': '14:50:00',
    'entry_start_time': '09:30:00',
    'lookback_years': 5,
    'output_dir': r'C:\Users\Administrator\Desktop\py\数据输出'
}


# 参数扫描范围
THRESHOLDS = []

# 0.3%至1.5%,步长0.1%
THRESHOLDS.extend([round(i / 10.0, 2) for i in range(3, 16)])

# 1.75%至3.0%,步长0.25%
THRESHOLDS.extend([round(i / 100.0, 2) for i in range(175, 325, 25)])

# 3.5%和4.0%
THRESHOLDS.extend([3.5, 4.0])

THRESHOLDS = sorted(set(THRESHOLDS))


# ================================================================
# 第二部分:通用辅助函数
# ================================================================

def safe_float(value, default_value=0.0):
    """将数据安全转换为浮点数。"""
    try:
        result = float(value)
        if np.isfinite(result):
            return result
    except Exception:
        pass
    return default_value


def parse_datetime_index(index_values):
    """
    将QMT行情DataFrame的索引转换为DatetimeIndex。

    兼容:
    - YYYYMMDD
    - YYYYMMDDHHMMSS
    - 毫秒时间戳
    - pandas时间对象
    """
    raw_series = pd.Series(list(index_values))

    if len(raw_series) == 0:
        return pd.DatetimeIndex([])

    numeric_series = pd.to_numeric(raw_series, errors='coerce')
    numeric_ratio = numeric_series.notnull().sum() / float(len(raw_series))

    if numeric_ratio > 0.95:
        valid_numeric = numeric_series.dropna()
        if len(valid_numeric) > 0 and valid_numeric.abs().median() > 100000000000:
            return pd.DatetimeIndex(
                pd.to_datetime(numeric_series, unit='ms', errors='coerce')
            )

    string_series = raw_series.astype(str).str.strip()
    string_series = string_series.str.replace(r'\.0$', '', regex=True)

    result = pd.Series(pd.NaT, index=string_series.index)

    mask_8 = string_series.str.len() == 8
    mask_14 = string_series.str.len() == 14

    if mask_8.any():
        result.loc[mask_8] = pd.to_datetime(
            string_series.loc[mask_8],
            format='%Y%m%d',
            errors='coerce'
        )

    if mask_14.any():
        result.loc[mask_14] = pd.to_datetime(
            string_series.loc[mask_14],
            format='%Y%m%d%H%M%S',
            errors='coerce'
        )

    other_mask = ~(mask_8 | mask_14)
    if other_mask.any():
        result.loc[other_mask] = pd.to_datetime(
            string_series.loc[other_mask],
            errors='coerce'
        )

    return pd.DatetimeIndex(result)


def prepare_output_directory():
    """创建输出目录;原目录不可用时退回QMT当前工作目录。"""
    output_dir = CONFIG['output_dir']

    try:
        if not os.path.exists(output_dir):
            os.makedirs(output_dir)
        return output_dir
    except Exception as error:
        fallback_dir = os.path.join(os.getcwd(), 'vwap_backtest_output')
        if not os.path.exists(fallback_dir):
            os.makedirs(fallback_dir)

        print('指定输出目录创建失败:{}'.format(error))
        print('改用输出目录:{}'.format(fallback_dir))
        return fallback_dir


# ================================================================
# 第三部分:QMT行情读取
# ================================================================

def load_qmt_minute_data(ContextInfo):
    """
    使用QMT get_market_data_ex读取本地1分钟行情。

    subscribe=False表示只读取已经补充到本地的数据,不发起实时订阅。
    """
    stock_code = CONFIG['stock_code']
    start_time = CONFIG['start_date'].replace('-', '') + '000000'
    end_time = CONFIG['end_date'].replace('-', '') + '235959'

    print('正在读取QMT本地1分钟行情...')
    print('标的:{}'.format(stock_code))
    print('请求区间:{} 至 {}'.format(
        CONFIG['start_date'],
        CONFIG['end_date']
    ))

    try:
        market_data = ContextInfo.get_market_data_ex(
            fields=['open', 'high', 'low', 'close', 'volume', 'amount'],
            stock_code=[stock_code],
            period='1m',
            start_time=start_time,
            end_time=end_time,
            count=-1,
            dividend_type='none',
            fill_data=True,
            subscribe=False
        )
    except Exception as error:
        print('QMT行情读取失败:{}'.format(error))
        print('请先在QMT数据管理中补充该标的的1分钟历史行情。')
        return None

    if not isinstance(market_data, dict):
        print('QMT行情接口返回值不是字典。')
        return None

    if stock_code not in market_data:
        print('行情结果中不存在标的:{}'.format(stock_code))
        print('接口返回代码:{}'.format(list(market_data.keys())))
        return None

    raw_df = market_data.get(stock_code)

    if raw_df is None or not isinstance(raw_df, pd.DataFrame) or raw_df.empty:
        print('未取得有效的1分钟行情。')
        return None

    df = raw_df.copy()
    df['datetime'] = parse_datetime_index(df.index)
    df = df.reset_index(drop=True)

    required_columns = ['open', 'high', 'low', 'close', 'volume']
    missing_columns = [
        column for column in required_columns if column not in df.columns
    ]

    if missing_columns:
        print('行情缺少字段:{}'.format(', '.join(missing_columns)))
        return None

    numeric_columns = ['open', 'high', 'low', 'close', 'volume', 'amount']
    for column in numeric_columns:
        if column in df.columns:
            df[column] = pd.to_numeric(df[column], errors='coerce')

    df = df.dropna(
        subset=['datetime', 'open', 'high', 'low', 'close', 'volume']
    )

    df = df[
        (df['open'] > 0) &
        (df['high'] > 0) &
        (df['low'] > 0) &
        (df['close'] > 0) &
        (df['volume'] >= 0)
    ]

    df = df.sort_values('datetime')
    df = df.drop_duplicates(subset=['datetime'], keep='last')
    df = df.reset_index(drop=True)

    start_datetime = pd.to_datetime(CONFIG['start_date'])
    end_datetime = pd.to_datetime(CONFIG['end_date']) + pd.Timedelta(days=1)

    df = df[
        (df['datetime'] >= start_datetime) &
        (df['datetime'] < end_datetime)
    ].copy()

    # 只保留A股连续竞价时间段
    time_text = df['datetime'].dt.strftime('%H:%M:%S')
    morning_mask = (
        (time_text >= '09:30:00') &
        (time_text <= '11:30:00')
    )
    afternoon_mask = (
        (time_text >= '13:00:00') &
        (time_text <= '15:00:00')
    )
    df = df[morning_mask | afternoon_mask].copy()
    df = df.reset_index(drop=True)

    if df.empty:
        print('日期和交易时段过滤后没有有效行情。')
        return None

    actual_end = df['datetime'].max()
    configured_end = pd.to_datetime(CONFIG['end_date'])

    if actual_end.normalize() < configured_end.normalize():
        print(
            '提示:配置结束日期晚于本地最新行情,实际回测截止到 {}。'.format(
                actual_end.strftime('%Y-%m-%d %H:%M:%S')
            )
        )

    print('行情读取完成。')
    print('实际区间:{} 至 {}'.format(
        df['datetime'].min().strftime('%Y-%m-%d %H:%M:%S'),
        df['datetime'].max().strftime('%Y-%m-%d %H:%M:%S')
    ))
    print('分钟记录数:{:,}'.format(len(df)))

    return df


# ================================================================
# 第四部分:VWAP计算
# ================================================================

def add_vwap_columns(df):
    """
    计算每日累计VWAP。

    使用典型价格:
        typical_price = (high + low + close) / 3

    使用成交量加权:
        VWAP = 累计(typical_price * volume) / 累计volume
    """
    result = df.copy()

    result['trade_date'] = result['datetime'].dt.strftime('%Y-%m-%d')
    result['trade_time'] = result['datetime'].dt.strftime('%H:%M:%S')
    result['typical_price'] = (
        result['high'] +
        result['low'] +
        result['close']
    ) / 3.0

    result['typical_volume'] = (
        result['typical_price'] * result['volume']
    )

    result['cum_volume'] = result.groupby(
        'trade_date'
    )['volume'].cumsum()

    result['cum_typical_volume'] = result.groupby(
        'trade_date'
    )['typical_volume'].cumsum()

    result['vwap'] = (
        result['cum_typical_volume'] /
        result['cum_volume'].replace(0, np.nan)
    )

    result['deviation_pct'] = (
        (result['close'] / result['vwap']) - 1.0
    ) * 100.0

    result = result.dropna(subset=['vwap', 'deviation_pct'])
    result = result[result['vwap'] > 0]
    result = result.reset_index(drop=True)

    return result


# ================================================================
# 第五部分:单阈值事件驱动回测
# ================================================================

def calculate_trade_result(position, exit_bar, exit_reason, equity):
    """根据持仓及退出K线计算单笔净盈亏。"""
    direction = position['direction']
    shares = position['shares']
    fee_rate = CONFIG['fee_rate']
    slippage = CONFIG['slippage']

    raw_exit_price = safe_float(exit_bar['open'])

    if raw_exit_price <= 0:
        raw_exit_price = safe_float(exit_bar['close'])

    if direction == 'LONG':
        exit_price = max(raw_exit_price - slippage, 0.0001)
        gross_pnl = (
            exit_price - position['entry_price']
        ) * shares
    else:
        exit_price = raw_exit_price + slippage
        gross_pnl = (
            position['entry_price'] - exit_price
        ) * shares

    exit_fee = exit_price * shares * fee_rate
    net_pnl = gross_pnl - position['entry_fee'] - exit_fee
    entry_notional = position['entry_price'] * shares

    if entry_notional > 0:
        pnl_pct = net_pnl / entry_notional * 100.0
    else:
        pnl_pct = 0.0

    new_equity = equity + net_pnl

    trade = {
        'signal_time': position['signal_time'],
        'entry_time': position['entry_time'],
        'exit_time': exit_bar['datetime'],
        'direction': direction,
        'signal_close': position['signal_close'],
        'signal_vwap': position['signal_vwap'],
        'entry_price': position['entry_price'],
        'exit_price': exit_price,
        'shares': shares,
        'entry_notional': entry_notional,
        'entry_fee': position['entry_fee'],
        'exit_fee': exit_fee,
        'gross_pnl': gross_pnl,
        'net_pnl': net_pnl,
        'pnl_pct': pnl_pct,
        'exit_reason': exit_reason,
        'threshold': position['threshold'],
        'equity_after_trade': new_equity
    }

    return trade, new_equity


def open_position_from_signal(signal, entry_bar, equity, threshold):
    """使用下一分钟开盘价执行开仓信号。"""
    direction = signal['direction']
    raw_entry_price = safe_float(entry_bar['open'])
    slippage = CONFIG['slippage']

    if raw_entry_price <= 0:
        return None

    if direction == 'LONG':
        entry_price = raw_entry_price + slippage
    else:
        entry_price = max(raw_entry_price - slippage, 0.0001)

    capital_used = equity * CONFIG['position_ratio']
    lot_size = int(CONFIG['min_trade_volume'])

    shares = int(
        capital_used / entry_price / lot_size
    ) * lot_size

    if shares < lot_size:
        return None

    entry_notional = entry_price * shares
    entry_fee = entry_notional * CONFIG['fee_rate']

    return {
        'direction': direction,
        'signal_time': signal['signal_time'],
        'entry_time': entry_bar['datetime'],
        'signal_close': signal['signal_close'],
        'signal_vwap': signal['signal_vwap'],
        'entry_price': entry_price,
        'entry_fee': entry_fee,
        'shares': shares,
        'threshold': threshold
    }


def run_backtest_for_threshold(df, threshold):
    """
    对单个偏离阈值执行事件驱动回测。

    为避免未来函数:
    - 当前分钟收盘确认信号。
    - 下一分钟开盘成交。
    - VWAP回归信号同样在下一分钟开盘平仓。
    """
    initial_capital = float(CONFIG['initial_capital'])
    equity = initial_capital

    entry_start_time = CONFIG['entry_start_time']
    force_exit_time = CONFIG['force_exit_time']

    trades = []
    daily_equity_records = []

    position = None
    pending_entry = None
    pending_exit_reason = None

    grouped_data = df.groupby('trade_date', sort=True)

    for trade_date, day_df in grouped_data:
        day_df = day_df.sort_values('datetime').reset_index(drop=True)

        if day_df.empty:
            continue

        position = None
        pending_entry = None
        pending_exit_reason = None

        used_directions = set()

        for row_index in range(len(day_df)):
            bar = day_df.iloc[row_index]
            current_time = bar['trade_time']

            # ---------- 1. 执行上一分钟产生的平仓信号 ----------
            if position is not None and pending_exit_reason is not None:
                trade, equity = calculate_trade_result(
                    position,
                    bar,
                    pending_exit_reason,
                    equity
                )
                trades.append(trade)

                position = None
                pending_exit_reason = None

            # ---------- 2. 执行上一分钟产生的开仓信号 ----------
            if (
                position is None and
                pending_entry is not None and
                current_time < force_exit_time
            ):
                new_position = open_position_from_signal(
                    pending_entry,
                    bar,
                    equity,
                    threshold
                )

                if new_position is not None:
                    position = new_position
                    used_directions.add(position['direction'])

                pending_entry = None

            # ---------- 3. 到达强制平仓时间 ----------
            if current_time >= force_exit_time:
                pending_entry = None

                if position is not None:
                    trade, equity = calculate_trade_result(
                        position,
                        bar,
                        '尾盘强制平仓',
                        equity
                    )
                    trades.append(trade)
                    position = None

                continue

            # ---------- 4. 根据当前分钟收盘价产生平仓信号 ----------
            if position is not None:
                close_price = safe_float(bar['close'])
                vwap_price = safe_float(bar['vwap'])

                if position['direction'] == 'LONG':
                    if close_price >= vwap_price:
                        pending_exit_reason = '回归VWAP平仓'
                else:
                    if close_price <= vwap_price:
                        pending_exit_reason = '回归VWAP平仓'

            # ---------- 5. 空仓时根据当前分钟收盘价产生开仓信号 ----------
            if (
                position is None and
                pending_exit_reason is None and
                pending_entry is None and
                current_time >= entry_start_time
            ):
                close_price = safe_float(bar['close'])
                vwap_price = safe_float(bar['vwap'])

                long_trigger_price = vwap_price * (
                    1.0 - threshold / 100.0
                )
                short_trigger_price = vwap_price * (
                    1.0 + threshold / 100.0
                )

                if (
                    close_price <= long_trigger_price and
                    'LONG' not in used_directions
                ):
                    pending_entry = {
                        'direction': 'LONG',
                        'signal_time': bar['datetime'],
                        'signal_close': close_price,
                        'signal_vwap': vwap_price
                    }

                elif (
                    close_price >= short_trigger_price and
                    'SHORT' not in used_directions
                ):
                    pending_entry = {
                        'direction': 'SHORT',
                        'signal_time': bar['datetime'],
                        'signal_close': close_price,
                        'signal_vwap': vwap_price
                    }

        # 当天最后仍有持仓时,使用最后一根K线收盘价强制退出
        if position is not None:
            last_bar = day_df.iloc[-1].copy()
            last_bar['open'] = last_bar['close']

            trade, equity = calculate_trade_result(
                position,
                last_bar,
                '当日最后K线强制平仓',
                equity
            )
            trades.append(trade)
            position = None

        daily_equity_records.append({
            'trade_date': trade_date,
            'equity': equity
        })

    return build_backtest_statistics(
        threshold,
        trades,
        daily_equity_records
    )


# ================================================================
# 第六部分:绩效统计
# ================================================================

def build_backtest_statistics(
    threshold,
    trades,
    daily_equity_records
):
    """根据成交记录和每日权益计算绩效指标。"""
    initial_capital = float(CONFIG['initial_capital'])

    if len(trades) == 0:
        return {
            'threshold': threshold,
            'total_trades': 0,
            'winning_trades': 0,
            'losing_trades': 0,
            'win_rate': 0.0,
            'total_pnl': 0.0,
            'total_return_pct': 0.0,
            'avg_pnl': 0.0,
            'avg_pnl_pct': 0.0,
            'profit_factor': 0.0,
            'max_drawdown_pct': 0.0,
            'sharpe_ratio': 0.0,
            'final_equity': initial_capital,
            'long_trades': 0,
            'short_trades': 0,
            'trades': pd.DataFrame(),
            'daily_equity': pd.DataFrame(daily_equity_records)
        }

    trades_df = pd.DataFrame(trades)
    trades_df = trades_df.sort_values(
        ['entry_time', 'exit_time']
    ).reset_index(drop=True)

    total_trades = len(trades_df)
    winning_trades = int((trades_df['net_pnl'] > 0).sum())
    losing_trades = total_trades - winning_trades

    win_rate = (
        winning_trades / float(total_trades) * 100.0
        if total_trades > 0 else 0.0
    )

    total_pnl = trades_df['net_pnl'].sum()
    final_equity = initial_capital + total_pnl
    total_return_pct = (
        final_equity / initial_capital - 1.0
    ) * 100.0

    avg_pnl = trades_df['net_pnl'].mean()
    avg_pnl_pct = trades_df['pnl_pct'].mean()

    gross_profit = trades_df.loc[
        trades_df['net_pnl'] > 0,
        'net_pnl'
    ].sum()

    gross_loss = abs(
        trades_df.loc[
            trades_df['net_pnl'] <= 0,
            'net_pnl'
        ].sum()
    )

    if gross_loss > 0:
        profit_factor = gross_profit / gross_loss
    elif gross_profit > 0:
        profit_factor = np.inf
    else:
        profit_factor = 0.0

    long_trades = int((trades_df['direction'] == 'LONG').sum())
    short_trades = int((trades_df['direction'] == 'SHORT').sum())

    daily_equity_df = pd.DataFrame(daily_equity_records)

    if not daily_equity_df.empty:
        daily_equity_df = daily_equity_df.drop_duplicates(
            subset=['trade_date'],
            keep='last'
        )
        daily_equity_df = daily_equity_df.sort_values(
            'trade_date'
        ).reset_index(drop=True)

        initial_row = pd.DataFrame([{
            'trade_date': 'INITIAL',
            'equity': initial_capital
        }])

        equity_curve = pd.concat(
            [initial_row, daily_equity_df],
            ignore_index=True
        )

        running_high = equity_curve['equity'].cummax()
        drawdown_series = (
            equity_curve['equity'] / running_high - 1.0
        ) * 100.0
        max_drawdown_pct = abs(drawdown_series.min())

        daily_returns = equity_curve['equity'].pct_change().dropna()

        if (
            len(daily_returns) > 1 and
            daily_returns.std(ddof=1) > 0
        ):
            sharpe_ratio = (
                daily_returns.mean() /
                daily_returns.std(ddof=1) *
                np.sqrt(252.0)
            )
        else:
            sharpe_ratio = 0.0

        daily_equity_df['daily_return'] = (
            equity_curve['equity'].pct_change().iloc[1:].values
        )
    else:
        max_drawdown_pct = 0.0
        sharpe_ratio = 0.0

    return {
        'threshold': threshold,
        'total_trades': total_trades,
        'winning_trades': winning_trades,
        'losing_trades': losing_trades,
        'win_rate': win_rate,
        'total_pnl': total_pnl,
        'total_return_pct': total_return_pct,
        'avg_pnl': avg_pnl,
        'avg_pnl_pct': avg_pnl_pct,
        'profit_factor': profit_factor,
        'max_drawdown_pct': max_drawdown_pct,
        'sharpe_ratio': sharpe_ratio,
        'final_equity': final_equity,
        'long_trades': long_trades,
        'short_trades': short_trades,
        'trades': trades_df,
        'daily_equity': daily_equity_df
    }


# ================================================================
# 第七部分:参数扫描
# ================================================================

def run_parameter_scan(df):
    """依次扫描全部VWAP偏离阈值。"""
    results = []

    print('')
    print('=' * 80)
    print('开始扫描VWAP偏离阈值')
    print('阈值数量:{}'.format(len(THRESHOLDS)))
    print('阈值列表:{}'.format(THRESHOLDS))
    print('=' * 80)

    for index, threshold in enumerate(THRESHOLDS):
        print(
            '正在回测阈值 {:.2f}%({}/{})'.format(
                threshold,
                index + 1,
                len(THRESHOLDS)
            )
        )

        result = run_backtest_for_threshold(df, threshold)
        results.append(result)

        print(
            '  交易次数:{},胜率:{:.2f}%,收益:{:.2f}%,'
            '回撤:{:.2f}%'.format(
                result['total_trades'],
                result['win_rate'],
                result['total_return_pct'],
                result['max_drawdown_pct']
            )
        )

    return results


# ================================================================
# 第八部分:报告与图表
# ================================================================

def normalize_score(series, higher_is_better=True):
    """将指标归一化至0至1区间。"""
    clean_series = series.replace([np.inf, -np.inf], np.nan).fillna(0.0)

    minimum = clean_series.min()
    maximum = clean_series.max()
    value_range = maximum - minimum

    if value_range <= 0:
        normalized = pd.Series(
            np.ones(len(clean_series)),
            index=clean_series.index
        )
    else:
        normalized = (clean_series - minimum) / value_range

    if not higher_is_better:
        normalized = 1.0 - normalized

    return normalized


def build_report_dataframe(results):
    """将参数扫描结果整理为汇总DataFrame。"""
    report_rows = []

    for result in results:
        profit_factor = result['profit_factor']

        if np.isinf(profit_factor):
            report_profit_factor = 999.0
        else:
            report_profit_factor = profit_factor

        report_rows.append({
            '阈值(%)': result['threshold'],
            '交易次数': result['total_trades'],
            '多头交易': result['long_trades'],
            '空头交易': result['short_trades'],
            '盈利次数': result['winning_trades'],
            '亏损次数': result['losing_trades'],
            '胜率(%)': result['win_rate'],
            '总盈亏(元)': result['total_pnl'],
            '总收益(%)': result['total_return_pct'],
            '期末权益(元)': result['final_equity'],
            '平均每笔盈亏(元)': result['avg_pnl'],
            '平均每笔收益(%)': result['avg_pnl_pct'],
            '盈亏比': report_profit_factor,
            '最大回撤(%)': result['max_drawdown_pct'],
            '夏普比率': result['sharpe_ratio']
        })

    report_df = pd.DataFrame(report_rows)

    numeric_columns = [
        '胜率(%)',
        '总盈亏(元)',
        '总收益(%)',
        '期末权益(元)',
        '平均每笔盈亏(元)',
        '平均每笔收益(%)',
        '盈亏比',
        '最大回撤(%)',
        '夏普比率'
    ]

    for column in numeric_columns:
        report_df[column] = pd.to_numeric(
            report_df[column],
            errors='coerce'
        )

    return report_df


def select_best_result(report_df):
    """
    按原策略的综合权重选择最优参数:
    - 胜率:40%
    - 总收益:40%
    - 最大回撤:20%
    """
    score_df = report_df.copy()

    score_df['胜率得分'] = normalize_score(
        score_df['胜率(%)'],
        higher_is_better=True
    )
    score_df['收益得分'] = normalize_score(
        score_df['总收益(%)'],
        higher_is_better=True
    )
    score_df['回撤得分'] = normalize_score(
        score_df['最大回撤(%)'],
        higher_is_better=False
    )

    score_df['综合得分'] = (
        score_df['胜率得分'] * 0.4 +
        score_df['收益得分'] * 0.4 +
        score_df['回撤得分'] * 0.2
    )

    best_index = score_df['综合得分'].idxmax()
    return best_index, score_df


def save_parameter_chart(report_df, output_dir):
    """绘制并保存参数敏感性图表。"""
    figure, axes = plt.subplots(2, 2, figsize=(14, 10))
    figure.suptitle(
        'VWAP均值回归策略参数敏感性分析',
        fontsize=16
    )

    # 阈值与胜率
    axes[0, 0].plot(
        report_df['阈值(%)'],
        report_df['胜率(%)'],
        marker='o',
        color='blue',
        linewidth=2
    )
    axes[0, 0].set_title('阈值与胜率')
    axes[0, 0].set_xlabel('偏离阈值(%)')
    axes[0, 0].set_ylabel('胜率(%)')
    axes[0, 0].grid(True, alpha=0.3)

    # 阈值与收益
    axes[0, 1].plot(
        report_df['阈值(%)'],
        report_df['总收益(%)'],
        marker='s',
        color='green',
        linewidth=2
    )
    axes[0, 1].set_title('阈值与总收益')
    axes[0, 1].set_xlabel('偏离阈值(%)')
    axes[0, 1].set_ylabel('总收益(%)')
    axes[0, 1].grid(True, alpha=0.3)

    # 阈值与交易次数
    axes[1, 0].plot(
        report_df['阈值(%)'],
        report_df['交易次数'],
        marker='^',
        color='orange',
        linewidth=2
    )
    axes[1, 0].set_title('阈值与交易次数')
    axes[1, 0].set_xlabel('偏离阈值(%)')
    axes[1, 0].set_ylabel('交易次数')
    axes[1, 0].grid(True, alpha=0.3)

    # 阈值与最大回撤
    axes[1, 1].plot(
        report_df['阈值(%)'],
        report_df['最大回撤(%)'],
        marker='D',
        color='red',
        linewidth=2
    )
    axes[1, 1].set_title('阈值与最大回撤')
    axes[1, 1].set_xlabel('偏离阈值(%)')
    axes[1, 1].set_ylabel('最大回撤(%)')
    axes[1, 1].grid(True, alpha=0.3)

    plt.tight_layout(rect=[0, 0, 1, 0.96])

    chart_path = os.path.join(
        output_dir,
        'vwap_parameter_scan_chart.png'
    )
    figure.savefig(chart_path, dpi=150, bbox_inches='tight')
    plt.close(figure)

    return chart_path


def save_best_equity_chart(best_result, output_dir):
    """保存最优阈值对应的每日权益曲线。"""
    equity_df = best_result['daily_equity']

    if equity_df is None or equity_df.empty:
        return None

    chart_df = equity_df.copy()
    chart_df['trade_date'] = pd.to_datetime(
        chart_df['trade_date'],
        errors='coerce'
    )
    chart_df = chart_df.dropna(subset=['trade_date'])

    if chart_df.empty:
        return None

    figure, axis = plt.subplots(figsize=(14, 6))

    axis.plot(
        chart_df['trade_date'],
        chart_df['equity'],
        color='blue',
        linewidth=1.5
    )
    axis.axhline(
        CONFIG['initial_capital'],
        color='gray',
        linestyle='--',
        linewidth=1
    )
    axis.set_title(
        '最优阈值 {:.2f}% 每日权益曲线'.format(
            best_result['threshold']
        )
    )
    axis.set_xlabel('日期')
    axis.set_ylabel('账户权益(元)')
    axis.grid(True, alpha=0.3)

    plt.tight_layout()

    equity_chart_path = os.path.join(
        output_dir,
        'vwap_best_equity_curve.png'
    )
    figure.savefig(
        equity_chart_path,
        dpi=150,
        bbox_inches='tight'
    )
    plt.close(figure)

    return equity_chart_path


def generate_report(results, df):
    """输出、保存参数扫描结果和最优参数。"""
    output_dir = prepare_output_directory()
    report_df = build_report_dataframe(results)

    if report_df.empty:
        print('没有可输出的回测结果。')
        return None

    display_df = report_df.copy()

    decimal_columns = [
        '胜率(%)',
        '总盈亏(元)',
        '总收益(%)',
        '期末权益(元)',
        '平均每笔盈亏(元)',
        '平均每笔收益(%)',
        '盈亏比',
        '最大回撤(%)',
        '夏普比率'
    ]

    for column in decimal_columns:
        display_df[column] = display_df[column].round(3)

    print('')
    print('=' * 120)
    print('VWAP均值回归策略参数扫描汇总')
    print('=' * 120)
    print(display_df.to_string(index=False))
    print('=' * 120)

    report_path = os.path.join(
        output_dir,
        'vwap_parameter_scan_results.csv'
    )
    report_df.to_csv(
        report_path,
        index=False,
        encoding='gbk'
    )

    best_index, score_df = select_best_result(report_df)
    best_row = score_df.loc[best_index]

    best_threshold = safe_float(best_row['阈值(%)'])
    best_result = None

    for result in results:
        if abs(result['threshold'] - best_threshold) < 0.000001:
            best_result = result
            break

    if best_result is None:
        best_result = results[int(best_index)]

    # 保存最优参数的逐笔成交记录
    trades_path = os.path.join(
        output_dir,
        'vwap_best_threshold_trades.csv'
    )

    if (
        best_result['trades'] is not None and
        not best_result['trades'].empty
    ):
        best_result['trades'].to_csv(
            trades_path,
            index=False,
            encoding='gbk'
        )

    chart_path = save_parameter_chart(
        report_df,
        output_dir
    )
    equity_chart_path = save_best_equity_chart(
        best_result,
        output_dir
    )

    config_path = os.path.join(
        output_dir,
        'vwap_best_threshold.txt'
    )

    with open(config_path, 'w', encoding='gbk') as output_file:
        output_file.write('VWAP均值回归策略最优参数\n')
        output_file.write(
            '生成时间:{}\n'.format(
                datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            )
        )
        output_file.write(
            '标的:{}\n'.format(CONFIG['stock_code'])
        )
        output_file.write(
            '配置回测区间:{} 至 {}\n'.format(
                CONFIG['start_date'],
                CONFIG['end_date']
            )
        )
        output_file.write(
            '实际数据区间:{} 至 {}\n'.format(
                df['datetime'].min().strftime('%Y-%m-%d %H:%M:%S'),
                df['datetime'].max().strftime('%Y-%m-%d %H:%M:%S')
            )
        )
        output_file.write(
            '最优阈值:{:.2f}%\n'.format(best_threshold)
        )
        output_file.write(
            '交易次数:{}\n'.format(
                int(best_row['交易次数'])
            )
        )
        output_file.write(
            '胜率:{:.2f}%\n'.format(
                best_row['胜率(%)']
            )
        )
        output_file.write(
            '总收益:{:.2f}%\n'.format(
                best_row['总收益(%)']
            )
        )
        output_file.write(
            '总盈亏:{:.2f}元\n'.format(
                best_row['总盈亏(元)']
            )
        )
        output_file.write(
            '期末权益:{:.2f}元\n'.format(
                best_row['期末权益(元)']
            )
        )
        output_file.write(
            '最大回撤:{:.2f}%\n'.format(
                best_row['最大回撤(%)']
            )
        )
        output_file.write(
            '盈亏比:{:.3f}\n'.format(
                best_row['盈亏比']
            )
        )
        output_file.write(
            '夏普比率:{:.3f}\n'.format(
                best_row['夏普比率']
            )
        )
        output_file.write(
            '综合得分:{:.4f}\n'.format(
                best_row['综合得分']
            )
        )

    print('')
    print('=' * 80)
    print('综合最优参数')
    print('=' * 80)
    print('最优阈值:{:.2f}%'.format(best_threshold))
    print('交易次数:{}'.format(int(best_row['交易次数'])))
    print('多头交易:{}'.format(int(best_row['多头交易'])))
    print('空头交易:{}'.format(int(best_row['空头交易'])))
    print('胜率:{:.2f}%'.format(best_row['胜率(%)']))
    print('总收益:{:.2f}%'.format(best_row['总收益(%)']))
    print('总盈亏:{:.2f}元'.format(best_row['总盈亏(元)']))
    print('期末权益:{:.2f}元'.format(best_row['期末权益(元)']))
    print('最大回撤:{:.2f}%'.format(best_row['最大回撤(%)']))
    print('盈亏比:{:.3f}'.format(best_row['盈亏比']))
    print('夏普比率:{:.3f}'.format(best_row['夏普比率']))
    print('综合得分:{:.4f}'.format(best_row['综合得分']))
    print('=' * 80)

    print('参数汇总:{}'.format(report_path))
    print('逐笔成交:{}'.format(trades_path))
    print('参数图表:{}'.format(chart_path))

    if equity_chart_path is not None:
        print('权益曲线:{}'.format(equity_chart_path))

    print('最优参数:{}'.format(config_path))

    return report_df


# ================================================================
# 第九部分:QMT策略入口
# ================================================================

def init(ContextInfo):
    """QMT策略初始化函数,只执行一次。"""
    ContextInfo.set_universe([CONFIG['stock_code']])

    # 防止实时行情中最后一根K线的多个tick重复执行扫描
    ContextInfo.scan_completed = False
    ContextInfo.scan_running = False

    print('=' * 80)
    print('QMT版VWAP均值回归参数扫描回测')
    print('=' * 80)
    print('标的:{}'.format(CONFIG['stock_code']))
    print('配置区间:{} 至 {}'.format(
        CONFIG['start_date'],
        CONFIG['end_date']
    ))
    print('初始资金:{:.2f}元'.format(
        CONFIG['initial_capital']
    ))
    print('仓位比例:{:.2f}%'.format(
        CONFIG['position_ratio'] * 100.0
    ))
    print('手续费率:{:.6f}'.format(
        CONFIG['fee_rate']
    ))
    print('单边滑点:{:.4f}元'.format(
        CONFIG['slippage']
    ))
    print('阈值数量:{}'.format(len(THRESHOLDS)))
    print('=' * 80)
    print('请确认已经在QMT数据管理中补充1分钟历史行情。')


def handlebar(ContextInfo):
    """
    QMT行情事件函数。

    策略只在主图最后一根K线执行一次完整扫描,避免每根历史K线重复读取
    全量分钟数据。
    """
    if not ContextInfo.is_last_bar():
        return

    if getattr(ContextInfo, 'scan_completed', False):
        return

    if getattr(ContextInfo, 'scan_running', False):
        return

    ContextInfo.scan_running = True

    try:
        minute_df = load_qmt_minute_data(ContextInfo)

        if minute_df is None or minute_df.empty:
            print('回测终止:没有取得有效行情。')
            return

        print('')
        print('正在计算每日累计VWAP...')
        minute_df = add_vwap_columns(minute_df)

        if minute_df.empty:
            print('回测终止:VWAP计算后没有有效数据。')
            return

        print('VWAP计算完成。')
        print('有效交易日数:{}'.format(
            minute_df['trade_date'].nunique()
        ))
        print('有效分钟数:{:,}'.format(len(minute_df)))

        results = run_parameter_scan(minute_df)
        report_df = generate_report(results, minute_df)

        if report_df is not None:
            ContextInfo.vwap_scan_report = report_df
            ContextInfo.scan_completed = True
            print('')
            print('VWAP参数扫描回测全部完成。')

    except Exception as error:
        print('VWAP参数扫描发生异常:{}'.format(error))
        import traceback
        print(traceback.format_exc())

    finally:
        ContextInfo.scan_running = False


def stop(ContextInfo):
    """QMT策略停止回调。"""
    print('VWAP参数扫描策略已停止。')

💬 评论 (0)

今日还可评论 3 条

还没有评论,来抢沙发吧。