完整示例 / 股票与 ETF 示例

完整示例

股票与 ETF 示例

六份可直接运行的股票与 ETF 回测策略:单只股票往返、沪深北股票混合、月度动量调仓、股票与 ETF 混合买卖、分钟定时买入、多只 ETF 组合,附运行参数与观察要点。

下面每份代码都包含初始化、决策逻辑和所需回调,复制整段即可作为一份独立策略运行,不依赖其他页面的片段。账户取自任务配置,不需要填账号;改标的、数量等参数只需改文件开头的 STRATEGY_PARAMS。示例用于演示 API 用法,不承诺收益。

示例 演示内容 建议运行设置
单只股票往返 买入一次、跨日按可卖数量卖出,订单与 T+1 日线或分钟;至少两个有行情的交易日
沪深北股票混合 一个账户交易三个市场,北交所非整百申报 所选区间内三只股票都有有效行情
月度动量调仓 历史因子查询、月度选股、先减仓后买入 固定股票池;区间跨多个月
股票与 ETF 混合买卖 同一账户交替建仓和卖出,待处理订单管理 日线或分钟;10 万元;至少四个交易日
分钟定时买入 时间间隔判断、单日次数上限、防重复下单 分钟 1M
多只 ETF 组合 按预算逐只建仓、持有、卖出一轮 10 万元;覆盖建仓、持有与卖出

一、单只股票往返

目标股票没有初始持仓时,只尝试买入一次;实际买入后,从下一交易日起按可卖数量卖出,每天最多尝试一次。拒单或没有返回订单时不会重复买入;部分成交后剩余的持仓,会在后续交易日继续尝试卖出。默认 100 股,运行区间至少包含两个有行情的交易日。

观察点:买入成交回报、次日的可卖数量、卖出回报。示例中的价格预算只是为了避免明显超额报单,实际成交还受费用、滑点、成交量和交易规则限制。

python
from panda_backtest.api.api import order_shares


def initialize(context):
    context.symbol = "600000.SH"
    context.buy_quantity = 100
    context.account = context.run_info.stock_account
    context.validate_stock_symbol(context.symbol)
    if type(context.buy_quantity) is not int or context.buy_quantity < 100 or context.buy_quantity % 100:
        raise ValueError("本例买入量必须为正的100股整数倍")
    if context.stock_account_dict[context.account].positions[context.symbol].quantity > 0:
        raise ValueError("本例要求目标股票没有初始持仓")
    context.buy_attempted = False
    context.buy_date = ""
    context.sell_attempt_date = ""
    context.pending = set()
    context.finished = set()
    context.seen_trades = set()


def remember_order(context, order):
    if order.account != context.account or order.order_book_id != context.symbol:
        return
    if order.status in (-1, 2, 3, 5, 7):
        context.finished.add(order.order_id)
        context.pending.discard(order.order_id)
    elif order.order_id not in context.finished:
        context.pending.add(order.order_id)


def handle_data(context, data):
    if context.pending:
        return
    bar = data[context.symbol]
    if bar is None:
        return
    price = bar.open if context.run_info.matching_type == 1 else bar.close
    if not (0 < price < float("inf")):
        return
    account = context.stock_account_dict[context.account]
    today = str(context.trade_date)
    if not context.buy_attempted:
        if account.cash < price * context.buy_quantity * 1.02 + 10:
            return
        context.buy_attempted = True
        orders = order_shares(context.account, context.symbol, int(context.buy_quantity))
    elif context.buy_date and today > context.buy_date:
        if context.sell_attempt_date == today:
            return
        quantity = int(account.positions[context.symbol].sellable)
        if quantity <= 0:
            return
        context.sell_attempt_date = today
        orders = order_shares(context.account, context.symbol, -quantity)
    else:
        return
    for order in orders or []:
        remember_order(context, order)


def on_stock_order_rtn(context, order):
    remember_order(context, order)
    if order.account == context.account and order.order_book_id == context.symbol:
        print("委托", order.order_id, order.status, order.message)


def on_stock_trade_rtn(context, trade):
    if trade.account_id != context.account or trade.contract_code != context.symbol:
        return
    if trade.trade_id in context.seen_trades:
        return
    context.seen_trades.add(trade.trade_id)
    if trade.business == 0 and not context.buy_date:
        context.buy_date = str(context.trade_date)
    print("成交", trade.contract_code, trade.business, trade.volume, trade.price)


def stock_order_cancel(context, order):
    remember_order(context, order)
    if order.account == context.account and order.order_book_id == context.symbol:
        print("委托结束", order.order_id, order.message)


def after_trading(context):
    account = context.stock_account_dict[context.account]
    position = account.positions[context.symbol]
    print("盘后", context.trade_date, "可用现金", account.cash,
          "总资产", account.total_value, "持仓", position.quantity,
          "可卖", position.sellable)

二、沪深北股票混合

一个股票账户同时交易沪、深、北交所:默认沪、深各 100 股,北交所 235 股(演示北交所可按 1 股递增申报);各标的首次实际买入后,经过 2 个交易日尝试卖出。

要求配置的三只股票没有初始持仓,且只处理这三只。北交所代码需要在所选回测期间有有效行情,换标的时同时检查上市日期和数据覆盖。例子允许部分成交,按实际持仓和可卖数量完成后续卖出,不假定 235 股一定一次成交。

python
from panda_backtest.api.api import order_shares


def initialize(context):
    context.symbols = ["600000.SH", "000001.SZ", "920001.BJ"]
    context.sh_quantity = 100
    context.sz_quantity = 100
    context.bj_quantity = 235
    context.holding_days = 2
    context.account = context.run_info.stock_account
    context.quantities = dict(zip(context.symbols,
                                 [context.sh_quantity, context.sz_quantity, context.bj_quantity]))
    for symbol in context.symbols:
        context.validate_stock_symbol(symbol)
        quantity = context.quantities[symbol]
        if type(quantity) is not int or quantity < 100:
            raise ValueError("本例每只股票的买入量必须为不少于100的整数")
        if not symbol.endswith(".BJ") and quantity % 100:
            raise ValueError("本例沪深股票使用100股整数倍")
        if context.stock_account_dict[context.account].positions[symbol].quantity > 0:
            raise ValueError("本例要求配置的股票没有初始持仓")
    if type(context.holding_days) is not int or context.holding_days < 1:
        raise ValueError("持有交易日数必须是正整数")
    context.day_index = 0
    context.last_date = ""
    context.buy_attempts = set()
    context.first_fill_day = {}
    context.sell_attempts = set()
    context.pending = {}
    context.finished = set()
    context.seen_trades = set()


def before_trading(context):
    today = str(context.trade_date)
    if context.last_date != today:
        context.day_index += 1
        context.last_date = today


def remember_order(context, order):
    if order.account != context.account or order.order_book_id not in context.symbols:
        return
    if order.status in (-1, 2, 3, 5, 7):
        context.finished.add(order.order_id)
        context.pending.pop(order.order_id, None)
    elif order.order_id not in context.finished:
        context.pending[order.order_id] = order.order_book_id


def handle_data(context, data):
    account = context.stock_account_dict[context.account]
    today = str(context.trade_date)
    for symbol in context.symbols:
        if symbol in context.pending.values():
            continue
        bar = data[symbol]
        if bar is None:
            continue
        price = bar.open if context.run_info.matching_type == 1 else bar.close
        if not (0 < price < float("inf")):
            continue
        if symbol not in context.buy_attempts:
            quantity = int(context.quantities[symbol])
            if account.cash < price * quantity * 1.02 + 10:
                continue
            context.buy_attempts.add(symbol)
        elif symbol in context.first_fill_day:
            if context.day_index - context.first_fill_day[symbol] < context.holding_days:
                continue
            if (today, symbol) in context.sell_attempts:
                continue
            quantity = -int(account.positions[symbol].sellable)
            if quantity == 0:
                continue
            context.sell_attempts.add((today, symbol))
        else:
            continue
        orders = order_shares(context.account, symbol, quantity)
        for order in orders or []:
            remember_order(context, order)


def on_stock_order_rtn(context, order):
    remember_order(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print("委托", order.order_book_id, order.order_id, order.status, order.message)


def on_stock_trade_rtn(context, trade):
    if trade.account_id != context.account or trade.contract_code not in context.symbols:
        return
    if trade.trade_id in context.seen_trades:
        return
    context.seen_trades.add(trade.trade_id)
    if trade.business == 0 and trade.contract_code not in context.first_fill_day:
        context.first_fill_day[trade.contract_code] = context.day_index
    print("成交", trade.contract_code, trade.business, trade.volume, trade.price)


def stock_order_cancel(context, order):
    remember_order(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print("委托结束", order.order_book_id, order.message)


def after_trading(context):
    account = context.stock_account_dict[context.account]
    holdings = {symbol: int(account.positions[symbol].quantity) for symbol in context.symbols}
    print("盘后", context.trade_date, "现金", account.cash,
          "总资产", account.total_value, "持仓", holdings)

三、月度动量调仓

固定 4 只沪深股票,取前 20 个交易日的收盘价动量,选前 2 只,目标总仓位 80%。

  • 何时评估:回测开始所在月先评估一次,之后每月第一个触发盘前回调的交易日重新评估。
  • 避免未来信息:只查询上一交易日及更早的数据;缺少 21 个完整收盘价时跳过该标的,候选不足则保留持仓。
  • 调仓:当天先减仓,再按当时的现金决定买入。每只股票每天最多报一笔,不会因为分钟回调重复而不断追单;未成交或数量不足时可能达不到 80%,下次月度决策再重新计算。固定股票池以外的持仓不参与调仓,回测结束日也不会自动清仓。

动量用的是普通收盘价,跨除权除息时会受价格跳变影响;用于投资研究时要选择一致的复权口径。

python
import panda_data
from panda_backtest.api.api import order_shares


def initialize(context):
    context.symbols = ["600000.SH", "600036.SH", "000001.SZ", "000002.SZ"]
    context.lookback = 20
    context.top_n = 2
    context.target_weight = 0.80
    context.account = context.run_info.stock_account
    if type(context.lookback) is not int or context.lookback < 1:
        raise ValueError("回看交易日数必须是正整数")
    if type(context.top_n) is not int or not (1 <= context.top_n <= len(context.symbols)):
        raise ValueError("选股数量必须在1和股票池数量之间")
    if not (0 < context.target_weight <= 1):
        raise ValueError("目标总权重必须在0到1之间")
    for symbol in context.symbols:
        context.validate_stock_symbol(symbol)
    context.last_month = ""
    context.selected = []
    context.rebalance_date = ""
    context.attempts = set()
    context.pending = {}
    context.finished = set()
    context.seen_trades = set()


def before_trading(context):
    today = str(context.trade_date)
    month = today[:6]
    context.rebalance_date = ""
    if month == context.last_month:
        return
    context.last_month = month
    end_date = panda_data.get_prev_trade_date(date=today, exchange="SH", n=1)
    start_date = panda_data.get_prev_trade_date(date=today, exchange="SH", n=context.lookback + 1)
    if not end_date or not start_date or end_date == "None" or start_date == "None":
        print("交易日历为空,本月保持持仓")
        return
    frame = panda_data.get_factor(symbol=context.symbols, start_date=start_date,
                                  end_date=end_date, type="stock", factors=["close"])
    if frame is None or frame.empty:
        print("因子数据为空,本月保持持仓")
        return
    if not {"symbol", "date", "close"}.issubset(frame.columns):
        raise ValueError("因子返回缺少 symbol、date 或 close 列")
    frame = frame.copy()
    frame["date"] = frame["date"].astype(str)
    frame = frame[(frame["date"] >= start_date) & (frame["date"] <= end_date)]
    frame = frame.dropna(subset=["symbol", "date", "close"])
    frame = frame[(frame["close"] > 0) & (frame["close"] < float("inf"))]
    scores = []
    for symbol in context.symbols:
        rows = frame[frame["symbol"] == symbol].sort_values("date").drop_duplicates("date")
        if len(rows) < context.lookback + 1:
            continue
        if str(rows.iloc[-1]["date"]) != end_date:
            continue
        momentum = float(rows.iloc[-1]["close"]) / float(rows.iloc[-context.lookback - 1]["close"]) - 1.0
        scores.append((momentum, symbol))
    if len(scores) < context.top_n:
        print("完整历史不足,本月保持持仓")
        return
    scores.sort(key=lambda item: (-item[0], item[1]))
    context.selected = [symbol for score, symbol in scores[:context.top_n]]
    context.rebalance_date = today
    print("本月目标", context.selected, "信号截止", end_date)


def remember_order(context, order):
    if order.account != context.account or order.order_book_id not in context.symbols:
        return
    if order.status in (-1, 2, 3, 5, 7):
        context.finished.add(order.order_id)
        context.pending.pop(order.order_id, None)
    elif order.order_id not in context.finished:
        context.pending[order.order_id] = order.order_book_id


def submit(context, symbol, quantity):
    context.attempts.add((str(context.trade_date), symbol))
    orders = order_shares(context.account, symbol, int(quantity))
    for order in orders or []:
        remember_order(context, order)


def handle_data(context, data):
    today = str(context.trade_date)
    if context.rebalance_date != today or context.pending:
        return
    account = context.stock_account_dict[context.account]
    prices = {}
    for symbol in context.symbols:
        bar = data[symbol]
        if bar is None:
            return
        price = bar.open if context.run_info.matching_type == 1 else bar.close
        if not (0 < price < float("inf")):
            return
        prices[symbol] = price
    per_stock = account.total_value * context.target_weight / len(context.selected)
    targets = {symbol: int(per_stock / prices[symbol] / 100) * 100
               if symbol in context.selected else 0 for symbol in context.symbols}
    # 先减仓;只管理固定股票池中的股票,不出售账户里的其他证券。
    for symbol in context.symbols:
        if (today, symbol) in context.attempts:
            continue
        position = account.positions[symbol]
        excess = int(position.quantity) - targets[symbol]
        if excess > 0:
            quantity = min(excess, int(position.sellable))
            if quantity >= 100:
                quantity = quantity // 100 * 100
            if quantity > 0:
                submit(context, symbol, -quantity)
    if context.pending:
        return
    # 买入前重新读取可用现金,保留费用余量,不假设卖单已经成交。
    for symbol in context.selected:
        if (today, symbol) in context.attempts:
            continue
        position = account.positions[symbol]
        missing = max(0, targets[symbol] - int(position.quantity))
        affordable = int(max(0, account.cash - 10) / (prices[symbol] * 1.02) / 100) * 100
        quantity = min(missing // 100 * 100, affordable)
        if quantity >= 100:
            submit(context, symbol, quantity)


def on_stock_order_rtn(context, order):
    remember_order(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print("委托", order.order_book_id, order.status, order.message)


def on_stock_trade_rtn(context, trade):
    if trade.account_id != context.account or trade.contract_code not in context.symbols:
        return
    if trade.trade_id in context.seen_trades:
        return
    context.seen_trades.add(trade.trade_id)
    print("成交", trade.contract_code, trade.business, trade.volume, trade.price)


def stock_order_cancel(context, order):
    remember_order(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print("委托结束", order.order_book_id, order.message)


def after_trading(context):
    account = context.stock_account_dict[context.account]
    print("盘后", context.trade_date, "现金", account.cash, "总资产", account.total_value)

四、股票与 ETF 混合买卖

当天第一次行情回调时,如果管理范围内没有持仓,就分别买入股票和 ETF;有持仓则当天只卖出可卖数量。每只证券每天最多提交一次,不操作证券列表之外的持仓;价格暂缺或现金不足时等待后续行情。可选日线 1d 或分钟 1M,建议初始资金 10 万元、区间至少四个交易日。

金额预算中的 2% 和 cash_buffer 只是现金预留,不是手续费设置。这份代码只做普通现金买卖;要加入一级申赎或两融信号,使用 ETF 申购与赎回融资融券中的方法和回报处理,并统一安排同一账户里的现金、证券和待处理订单。

python
"""股票与ETF在统一账户内交替建仓和卖出。

建议:初始资金100000元,日线1d或分钟1M,至少覆盖4个交易日。
无管理范围内持仓的交易日买入;有持仓的交易日只卖出可卖份额。
本策略使用二级买卖及回报,不发起一级申赎或两融业务。
"""

from math import isfinite

from panda_backtest.api.api import *


STRATEGY_PARAMS = {
    'stock_symbols': ['600000.SH', '000001.SZ'],
    'etf_symbols': ['510300.SH', '159919.SZ'],
    'stock_buy_quantity': 100,
    'etf_buy_quantity': 1000,
    'cash_buffer': 100.0,
}


def initialize(context):
    context.account = context.run_info.stock_account
    stock_symbols = list(STRATEGY_PARAMS['stock_symbols'])
    etf_symbols = list(STRATEGY_PARAMS['etf_symbols'])
    context.symbols = stock_symbols + etf_symbols
    if not stock_symbols or not etf_symbols:
        raise ValueError('混合策略请分别配置至少一只股票和一只ETF')
    if len(set(context.symbols)) != len(context.symbols):
        raise ValueError('股票与ETF证券列表不能包含重复代码')
    # 平台参数只保存标量或字符串列表,数量映射在运行时建立。
    context.buy_quantities = {}
    for symbol in stock_symbols:
        context.buy_quantities[symbol] = STRATEGY_PARAMS['stock_buy_quantity']
    for symbol in etf_symbols:
        context.buy_quantities[symbol] = STRATEGY_PARAMS['etf_buy_quantity']
    context.cash_buffer = float(STRATEGY_PARAMS['cash_buffer'])
    context.current_trade_date = None
    context.day_action = None
    context.attempted_symbols = set()
    context.wait_messages = set()
    context.pending_orders = {}
    context.finished_orders = set()
    context.seen_trades = set()
    context.buy_trade_count = 0
    context.sell_trade_count = 0
    context.actual_trade_fees = 0.0

    _get_account(context)
    if context.run_info.frequency not in ('1d', '1M'):
        raise ValueError('本策略请选择日线1d或分钟1M')
    for symbol in context.symbols:
        quantity = context.buy_quantities[symbol]
        if not isinstance(quantity, int) or quantity <= 0 or quantity % 100 != 0:
            raise ValueError(f'{symbol} 本示例买入数量必须为正的100整数倍')
    if not isfinite(context.cash_buffer) or context.cash_buffer < 0:
        raise ValueError('现金预留必须是有限的非负数')
    print(f'[初始化] 统一账户={context.account},证券={context.symbols}')


def _get_account(context):
    account = context.stock_account_dict.get(context.account)
    if account is None:
        raise ValueError(f'股票与ETF统一账户不存在:{context.account}')
    return account


def _reference_price(context, data, symbol):
    try:
        bar = data[symbol]
    except KeyError:
        return None
    if bar is None:
        return None
    value = bar.open if context.run_info.matching_type == 1 else bar.close
    try:
        price = float(value)
    except (TypeError, ValueError):
        return None
    return price if isfinite(price) and price > 0 else None


def _wait_once(context, symbol, reason):
    key = (symbol, reason)
    if key not in context.wait_messages:
        context.wait_messages.add(key)
        print(f'[{context.trade_date} {context.hms}] [等待] {symbol}:{reason}')


def _track_order(context, order):
    if order.account != context.account or order.order_book_id not in context.symbols:
        return
    key = (order.account, order.order_id)
    if order.status in (-1, 2, 3, 5, 7):
        context.finished_orders.add(key)
        context.pending_orders.pop(key, None)
    elif key not in context.finished_orders:
        context.pending_orders[key] = order.order_book_id


def handle_data(context, data):
    account = _get_account(context)
    today = str(context.trade_date)

    if context.current_trade_date != today:
        context.current_trade_date = today
        context.attempted_symbols = set()
        context.wait_messages = set()
        context.day_action = '买入'
        for symbol in context.symbols:
            position = account.positions.get(symbol)
            if position is not None and position.quantity > 0:
                context.day_action = '卖出'
                break
        print(
            f'[{today}] [交易日] 账户={context.account},'
            f'本日只做{context.day_action},可用现金={account.cash:.2f}'
        )

    for symbol in context.symbols:
        if symbol in context.attempted_symbols:
            continue
        if symbol in context.pending_orders.values():
            _wait_once(context, symbol, '已有未结束委托')
            continue

        account = _get_account(context)
        if context.day_action == '卖出':
            position = account.positions.get(symbol)
            if position is None or position.quantity <= 0:
                continue
            quantity = -int(position.sellable)
            if quantity >= 0:
                _wait_once(context, symbol, '持仓尚不可卖或已被冻结')
                continue
        else:
            quantity = context.buy_quantities[symbol]

        price = _reference_price(context, data, symbol)
        if price is None:
            _wait_once(context, symbol, '当前没有有效参考价格')
            continue

        if quantity > 0:
            # 只作保守预算预留,精确费用及资金校验仍以委托结果为准。
            budget = quantity * price * 1.02 + context.cash_buffer
            if account.cash < budget:
                _wait_once(context, symbol, '可用现金不足以覆盖本笔参考预算')
                continue

        # 在提交前标记,兼容订单和成交回调先于下单函数返回。
        context.attempted_symbols.add(symbol)
        print(
            f'[{today} {context.hms}] [申请] 账户={context.account},'
            f'{context.day_action} {symbol},数量={abs(quantity)},'
            f'参考价={price:.3f}'
        )
        orders = order_shares(
            context.account,
            symbol,
            quantity,
            style=MarketOrderStyle(),
            remark=f'混合交易_{context.day_action}_{today}',
        )
        if not orders:
            print(f'[{today}] [提示] {symbol} 没有返回委托,不能视为成交')
            continue
        for order in orders:
            _track_order(context, order)
            # 即时撮合后通常已经结束;仅对仍活动的本次委托申请撤余。
            key = (order.account, order.order_id)
            if order.status in (1, 4) and key in context.pending_orders:
                print(f'[{today}] [申请撤余] 委托={order.order_id}')
                cancel_order(context.account, order.order_id)


def on_stock_order_rtn(context, order):
    _track_order(context, order)
    if order.account != context.account or order.order_book_id not in context.symbols:
        return
    print(
        f'[{context.trade_date} {context.hms}] [订单回报] '
        f'账户={order.account},委托={order.order_id},证券={order.order_book_id},'
        f'状态={order.status},委托量={order.quantity},'
        f'已成={order.filled_quantity},未成={order.unfilled_quantity},'
        f'说明={order.message}'
    )


def on_stock_trade_rtn(context, trade):
    if trade.account_id != context.account or trade.contract_code not in context.symbols:
        return
    key = (trade.account_id, trade.trade_date, trade.order_id, trade.trade_id)
    if key in context.seen_trades:
        return
    context.seen_trades.add(key)
    if trade.business == 0:
        context.buy_trade_count += 1
    else:
        context.sell_trade_count += 1
    context.actual_trade_fees += float(trade.cost)
    direction = '买入' if trade.business == 0 else '卖出'
    print(
        f'[{trade.trade_date} {context.hms}] [实际成交] '
        f'账户={trade.account_id},委托={trade.order_id},成交={trade.trade_id},'
        f'{direction} {trade.contract_code},数量={trade.volume},'
        f'成交价={trade.price:.3f},费用={trade.cost:.2f}'
    )


def stock_order_cancel(context, order):
    _track_order(context, order)
    if order.account != context.account or order.order_book_id not in context.symbols:
        return
    print(
        f'[{context.trade_date} {context.hms}] [撤单或拒单回报] '
        f'委托={order.order_id},证券={order.order_book_id},'
        f'状态={order.status},已成={order.filled_quantity},'
        f'未成={order.unfilled_quantity},说明={order.message}'
    )


def after_trading(context):
    account = _get_account(context)
    print(
        f'[{context.trade_date}] [日终] 账户={context.account},'
        f'可用现金={account.cash:.2f},冻结现金={account.frozen_cash:.2f},'
        f'持仓市值={account.market_value:.2f},账户净资产={account.total_value:.2f},'
        f'累计买入成交笔数={context.buy_trade_count},'
        f'累计卖出成交笔数={context.sell_trade_count},'
        f'累计回报成交费用={context.actual_trade_fees:.2f}'
    )
    for symbol in context.symbols:
        position = account.positions.get(symbol)
        if position is not None and position.quantity > 0:
            print(
                f'[{context.trade_date}] [持仓] {symbol},'
                f'数量={position.quantity},可卖={position.sellable},'
                f'成本价={position.avg_price:.3f},市值={position.market_value:.2f}'
            )

五、分钟定时买入

选择分钟 1M 回测。策略从每天第一次行情回调开始计时,每隔 300 秒轮流买入列表中的股票和 ETF,每笔 100 股/份,单日最多提交 4 笔。它只买入、不自动清仓,适合学习时间判断和控制重复下单。

interval_seconds 是回测时间间隔,跨午休也按时间差计算;没有行情、资金不足或存在未结束订单时等待。max_orders_per_day 限制的是提交次数,不保证成交笔数。

python
"""分钟策略:每隔5分钟买一笔,依次轮到股票与ETF,单日最多4笔。

使用1M任务。从当日第一根有行情的回调开始计时,每日重置。
本例只买入,不自动卖出;足额预留资金。跨午休按回测时间计算间隔。
"""

from math import isfinite
from panda_backtest.api.api import *


STRATEGY_PARAMS = {
    'symbols': ['600000.SH', '510300.SH', '159919.SZ'],
    'quantity': 100,
    'interval_seconds': 300,
    'max_orders_per_day': 4,
}


def initialize(context):
    if context.run_info.frequency != '1M':
        raise ValueError('本策略使用分钟回测1M')
    context.account = context.run_info.stock_account
    context.symbols = list(STRATEGY_PARAMS['symbols'])
    context.quantity = STRATEGY_PARAMS['quantity']
    context.interval = STRATEGY_PARAMS['interval_seconds']
    context.maximum = STRATEGY_PARAMS['max_orders_per_day']
    if not context.symbols or len(context.symbols) != len(set(context.symbols)):
        raise ValueError('证券列表为空或存在重复')
    if type(context.quantity) is not int or context.quantity <= 0 or context.quantity % 100:
        raise ValueError('本示例买入数量使用正的100整数倍')
    if type(context.interval) is not int or context.interval <= 0:
        raise ValueError('交易间隔必须为正整数秒')
    if type(context.maximum) is not int or context.maximum <= 0:
        raise ValueError('单日最多委托数必须为正整数')
    context.date = None
    context.last_attempt = None
    context.attempts = 0
    context.index = 0
    context.seen_trades = set()
    context.pending = {}
    context.finished = set()


def _track(context, order):
    if order.account != context.account or order.order_book_id not in context.symbols:
        return
    key = (order.account, order.order_id)
    if order.status in (-1, 2, 3, 5, 7):
        context.finished.add(key)
        context.pending.pop(key, None)
    elif key not in context.finished:
        context.pending[key] = order.order_book_id


def handle_data(context, data):
    today = str(context.trade_date)
    now = context.trade_time
    if context.date != today:
        context.date = today
        context.last_attempt = now
        context.attempts = 0
        return
    if context.attempts >= context.maximum or context.pending:
        return
    if (now - context.last_attempt).total_seconds() < context.interval:
        return
    symbol = context.symbols[context.index % len(context.symbols)]
    try:
        bar = data[symbol]
    except KeyError:
        return
    if bar is None:
        return
    value = bar.open if context.run_info.matching_type == 1 else bar.close
    if value is None:
        return
    price = float(value)
    if not isfinite(price) or price <= 0:
        return
    account = context.stock_account_dict.get(context.account)
    if account is None:
        raise ValueError('证券账户不存在')
    if account.cash < context.quantity * price * 1.02 + 100:
        return
    context.last_attempt = now
    context.attempts += 1
    context.index += 1
    orders = order_shares(context.account, symbol, context.quantity,
                          style=MarketOrderStyle(), remark='分钟定时买入')
    for order in orders or []:
        _track(context, order)
        if order.status in (1, 4, 6) and (order.account, order.order_id) in context.pending:
            cancel_order(context.account, order.order_id)


def on_stock_order_rtn(context, order):
    _track(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print(f'[订单] {order.order_book_id} 状态={order.status} 说明={order.message}')


def on_stock_trade_rtn(context, trade):
    if trade.account_id != context.account or trade.contract_code not in context.symbols:
        return
    key = (trade.account_id, trade.trade_date, trade.order_id, trade.trade_id)
    if key not in context.seen_trades:
        context.seen_trades.add(key)
        print(f'[成交] {trade.contract_code} 数量={trade.volume} 价格={trade.price} 费用={trade.cost}')


def stock_order_cancel(context, order):
    _track(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print(f'[撤单或拒单] {order.order_book_id} 状态={order.status} 说明={order.message}')

六、多只 ETF 组合

用相同的初始预算配置多只 ETF:逐只买入,全部建仓后持有指定的交易日数,再卖出本策略管理的 ETF。一轮完成后不再开仓,不卖管理范围以外的证券。逐笔订单各自独立成交,组合买入不是保证全部同时成功的原子操作。

参数 默认值 用法
etf_symbols 三只示例 ETF 完整代码列表,可直接替换
per_etf_budget 20000.0 每只 ETF 的初始目标预算,元
hold_trading_days 3 全部建仓后持有的交易日数
cash_buffer 500.0 账户保留现金,元

初始份额按参考价换算并向下取整为 100 份;部分成交后按真实持仓补足可申报的差额。目标数量确定后不会逐分钟重算,因此不是持续维持等权。默认参数可用 10 万元初始资金。

python
"""多只 ETF 的二级市场组合买卖,运行一轮后停止下单。

每只 ETF 按 per_etf_budget 建仓,数量向下取整到 100 份。
全部完成建仓后持有 hold_trading_days 个交易日,再卖出这些 ETF。
从配置证券空仓启动;日线 1d、分钟 1M 均可。示例用于回测功能验证。
"""

from math import isfinite

from panda_backtest.api.api import *


STRATEGY_PARAMS = {
    'etf_symbols': ['510300.SH', '510500.SH', '159919.SZ'],
    'per_etf_budget': 20000.0,
    'hold_trading_days': 3,
    'cash_buffer': 500.0,
}


def initialize(context):
    context.account = context.run_info.stock_account
    context.symbols = list(STRATEGY_PARAMS['etf_symbols'])
    context.per_etf_budget = float(STRATEGY_PARAMS['per_etf_budget'])
    context.hold_days = STRATEGY_PARAMS['hold_trading_days']
    context.cash_buffer = float(STRATEGY_PARAMS['cash_buffer'])
    context.phase = '建仓'
    context.current_date = None
    context.day_index = 0
    context.hold_start = None
    context.targets = {}
    context.attempted = set()
    context.wait_messages = set()
    context.pending = {}
    context.finished = set()
    context.seen_trades = set()

    if not context.symbols or len(set(context.symbols)) != len(context.symbols):
        raise ValueError('ETF列表不能为空或包含重复代码')
    if not isfinite(context.per_etf_budget) or context.per_etf_budget <= 0:
        raise ValueError('每只ETF的预算必须是有限正数')
    if type(context.hold_days) is not int or context.hold_days < 1:
        raise ValueError('持有交易日数必须是正整数')
    if not isfinite(context.cash_buffer) or context.cash_buffer < 0:
        raise ValueError('现金预留必须是有限非负数')
    account = _account(context)
    for symbol in context.symbols:
        position = account.positions.get(symbol)
        if position is not None and position.quantity > 0:
            raise ValueError(f'示例需要从空仓启动:{symbol} 已有持仓')
    print(f'[初始化] 账户={context.account},ETF组合={context.symbols}')


def _account(context):
    account = context.stock_account_dict.get(context.account)
    if account is None:
        raise ValueError(f'证券账户不存在:{context.account}')
    return account


def _price(context, data, symbol):
    try:
        bar = data[symbol]
    except KeyError:
        return None
    if bar is None:
        return None
    value = bar.open if context.run_info.matching_type == 1 else bar.close
    try:
        price = float(value)
    except (TypeError, ValueError):
        return None
    return price if isfinite(price) and price > 0 else None


def _wait(context, reason):
    if reason not in context.wait_messages:
        context.wait_messages.add(reason)
        print(f'[{context.trade_date} {context.hms}] [等待] {reason}')


def _track(context, order):
    if order.account != context.account or order.order_book_id not in context.symbols:
        return
    key = (order.account, order.order_id)
    if order.status in (-1, 2, 3, 5, 7):
        context.finished.add(key)
        context.pending.pop(key, None)
    elif key not in context.finished:
        context.pending[key] = order.order_book_id


def _send(context, symbol, quantity):
    # 提交前登记,兼容回调先于下单函数返回的情况。
    context.attempted.add(symbol)
    orders = order_shares(
        context.account, symbol, quantity, style=MarketOrderStyle(),
        remark=f'ETF组合_{context.phase}_{context.trade_date}',
    )
    if not orders:
        print(f'[{context.trade_date}] {symbol} 未返回委托')
        return
    for order in orders:
        _track(context, order)
        key = (order.account, order.order_id)
        if order.status in (1, 4) and key in context.pending:
            cancel_order(context.account, order.order_id)


def handle_data(context, data):
    today = str(context.trade_date)
    if today != context.current_date:
        context.current_date = today
        context.day_index += 1
        context.attempted = set()
        context.wait_messages = set()

    if context.phase == '完成':
        return
    if context.phase == '持有':
        if context.day_index - context.hold_start < context.hold_days:
            return
        context.phase = '卖出'

    for symbol in context.symbols:
        if symbol in context.attempted or symbol in context.pending.values():
            continue
        price = _price(context, data, symbol)
        if price is None:
            _wait(context, f'{symbol} 当前无有效价格')
            continue
        account = _account(context)
        position = account.positions.get(symbol)
        held = 0 if position is None else int(position.quantity)

        if context.phase == '建仓':
            if symbol not in context.targets:
                target = int(context.per_etf_budget / price / 100) * 100
                if target < 100:
                    raise ValueError(f'{symbol} 单只预算不足100份,请调整预算')
                context.targets[symbol] = target
            # 部分成交留下不足100份的差额时不追加一整手。
            quantity = max(0, (context.targets[symbol] - held) // 100 * 100)
            if quantity == 0:
                continue
            if account.cash < quantity * price * 1.02 + context.cash_buffer:
                _wait(context, f'{symbol} 可用资金不足,暂不补仓')
                continue
        else:
            if held == 0:
                continue
            quantity = -int(position.sellable)
            if quantity >= 0:
                _wait(context, f'{symbol} 持仓暂不可卖')
                continue
        _send(context, symbol, quantity)

    if context.pending:
        return
    account = _account(context)
    if context.phase == '建仓' and len(context.targets) == len(context.symbols):
        for symbol in context.symbols:
            position = account.positions.get(symbol)
            held = 0 if position is None else int(position.quantity)
            if context.targets[symbol] - held >= 100:
                return
        context.phase = '持有'
        context.hold_start = context.day_index
        print(f'[{today}] 组合建仓完成,开始计算持有交易日')
    elif context.phase == '卖出':
        for symbol in context.symbols:
            position = account.positions.get(symbol)
            if position is not None and position.quantity > 0:
                return
        context.phase = '完成'
        print(f'[{today}] 本轮ETF组合买卖完成,不再开仓')


def on_stock_order_rtn(context, order):
    _track(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print(
            f'[订单回报] {order.order_book_id} 委托={order.order_id},'
            f'状态={order.status},已成={order.filled_quantity},'
            f'未成={order.unfilled_quantity},说明={order.message}'
        )


def on_stock_trade_rtn(context, trade):
    if trade.account_id != context.account or trade.contract_code not in context.symbols:
        return
    key = (trade.account_id, trade.trade_date, trade.order_id, trade.trade_id)
    if key in context.seen_trades:
        return
    context.seen_trades.add(key)
    direction = '买入' if trade.business == 0 else '卖出'
    print(
        f'[成交回报] {direction} {trade.contract_code},委托={trade.order_id},'
        f'数量={trade.volume},价格={trade.price},费用={trade.cost}'
    )


def stock_order_cancel(context, order):
    _track(context, order)
    if order.account == context.account and order.order_book_id in context.symbols:
        print(
            f'[撤单或拒单回报] {order.order_book_id} 委托={order.order_id},'
            f'状态={order.status},说明={order.message}'
        )


def after_trading(context):
    account = _account(context)
    print(
        f'[{context.trade_date}] 阶段={context.phase},现金={account.cash:.2f},'
        f'冻结现金={account.frozen_cash:.2f},净资产={account.total_value:.2f}'
    )
    for symbol in context.symbols:
        position = account.positions.get(symbol)
        if position is not None and position.quantity > 0:
            print(f'[持仓] {symbol} 数量={position.quantity},可卖={position.sellable}')