股票与 ETF / ETF 申购与赎回

股票与 ETF

ETF 申购与赎回

ETF 一级申购赎回的策略写法:purchase 与 redeem 的参数和篮子换算,现金替代选择,一级订单对象与交收阶段,settlement_data 清算字典,撤单与申赎跟踪,查询申赎清单和额度,以及如何串联二级买卖与一级申赎。

本页讲 ETF 的一级市场申购与赎回:用一篮子成分券(加现金差额)换 ETF 份额,或反过来。二级市场买卖 ETF 仍用 order_sharesorder_values(见下单、撤单与回报),那不是申购赎回。

适用范围:沪深上市、证券资料已明确分类的境内非 QDII 股票 ETF

说明

运行一级策略前,回测任务需要有对应日期的申赎清单(PCF)、成分券、额度和实际确认交收资料。只有 PCF 和行情,不足以完成实际现金差额、替代退补和到账处理;这时申请会一直等待,不要在策略里自行补造清算金额。

一、申购与赎回

python
purchase(
    account_id, symbol, amount, cash_substitution=False
)

redeem(
    account_id, symbol, quantity, cash_substitution=False
)
参数 类型、默认值 用法
account_id 字符串,必填 与股票二级交易相同的证券账户编号
symbol 字符串,必填 ETF 完整代码,不是成分券代码
purchase.amount 数值,必填 正的申购名义金额,元,用于换算整篮子的 ETF 份额
redeem.quantity 整数,必填 正的 ETF 份额,必须是当日最小申赎单位的整数倍
cash_substitution 布尔值或字典,默认 False 现金替代选择,见下一节;用关键字传入

两个方法返回单个一级订单对象,不是列表。受理和业务拒绝都可能返回对象,分别读 statusreason。赎回传份额,不能沿用二级卖出的负数量写法。后续关联申请用返回对象的 order_id

篮子换算。 申购份额按「名义金额 ÷ 参考净值 ÷ 最小申赎份额」向下取整为篮子数,再乘最小申赎份额;不足一篮会被拒绝。参考净值依次取当日 PCF 的 unit_nav、当日额度表的 nav,或 creation_unit / 最小申赎单位。它不是 ETF 的二级市场价格,也不能自行换成其他日期的净值。

举例:最小单位 100 万份、参考净值 1.20 元,约 120 万元名义金额对应一篮。但名义金额不是最终扣款:申购实际交付的是清单上的证券,加上现金差额、现金替代资金和费用,而不是简单扣掉 120 万元。

二、现金替代

取值 效果
False 不主动选择可选现金替代;必须替代和退补替代仍按清单要求执行
True 对该方向允许主动替代的成分券全部选择现金;禁止替代的部分仍按实物处理
{成分券代码: True/False} 逐券指定;没写出的可选成分券按 False 处理

字典里的代码必须出现在当日清单中,值必须是布尔值。不能显式关闭必须替代或退补替代,不能对禁止替代的成分券设 True;赎回方向的可选替代还要满足该产品的赎回条件。这只是「选不选替代」,不是金额配置,不能在字典里填自定的替代价格。

清单中的替代标志:

标志 含义 策略如何选择
1 允许现金替代 申购可选实物或现金;赎回还需有明确的赎回替代条件
2 必须现金替代 按规定金额处理,不选择实物
3 禁止现金替代 准备实物成分券,不能设为 True
4 退补现金替代 先预估、再按实际金额交收,不能把预估金额当作最终金额

只替代指定成分券时,用证券代码列表作策略参数,在初始化时生成字典(字典本身不放进 STRATEGY_PARAMS):

python
STRATEGY_PARAMS = {
    'cash_symbols': ['600000.SH'],
}


def init_cash_choices(context):
    context.cash_choices = {symbol: True for symbol in STRATEGY_PARAMS['cash_symbols']}

然后在申购信号中传 purchase(context.account, etf_symbol, amount, cash_substitution=context.cash_choices)600000.SH 只是写法示意,使用前核对它在当日清单中、且允许相应方向的替代。全周期回测时成分和替代标志可能变化,完整示例在代码不在清单中或变为禁止替代时会明确报错,而不是悄悄换一种交付方式。

三、一级订单对象

purchaseredeem 的返回值,以及三个一级回调收到的 order / result,都用同一组属性:

属性 类型、单位 含义
order_id 字符串 一级申请编号
account 字符串 账户编号
symbol 字符串 ETF 完整代码
side 整数 0 申购、1 赎回
units 整数,份 申请的 ETF 份额,不是篮子数;拒单时可能为零
submit_date 字符串 申请日,YYYYMMDD
confirm_date 字符串 预计确认日,不是完成凭据
confirmed_date 字符串 实际确认日,未确认时为空字符串
settled_date 字符串 全部交收完成日,未完成时为空字符串
status 整数 1 处理中、2 完成、3 已撤、-1 拒绝
stage 字符串 当前阶段,见下表
reason 字符串 拒绝或等待原因
fee 数值,元 本单一级手续费
estimated_cash 数值,元 预估现金差额及现金替代金额,不含手续费
actual_cash 数值,元 实际现金差额及实际替代金额,不含手续费
cash_substitution 布尔值或字典 本单的现金替代选择
frozen_cash 数值,元 本单当前冻结的现金
frozen_components 字典 当前冻结的证券代码及数量,股/份
settlement_data 字典或 None 当前可见的实际清算资料,见下文

现金的正负号要结合方向读:申购时正数表示支付、负数表示收取;赎回时正数表示收取、负数表示补款。含手续费的净支付额,申购为 actual_cash + fee,赎回为 fee - actual_cash,结果为负表示净收款。

stage 含义
ACCEPTED 已受理
CONFIRMED 已确认,尚有待交收项目
CASH_DELIVERED 现金已交收,证券仍有待交收项目
SECURITIES_DELIVERED 证券已交收,现金仍有待交收项目
SETTLED 全部交收完成
REJECTED 拒绝
CANCELLED 已撤销
注意

阶段不一定逐个出现。只有 status == FILLEDstage == 'SETTLED' 才算完成。不能仅凭确认日已到、actual_cash 非零或收到确认回调就判断完成;实际现金为零也可能是合法结果。

四、settlement_data 清算字典

result.settlement_data 读取。它可能为 None;有值时用字典的 .get() 取键,不用属性写法。

类型、单位 含义
cash_component 数值或数字字符串,元/篮子 每个最小申赎单位的实际现金差额,计算前先转 float
substitution_amounts 字典,元/篮子 成分券代码到每篮子实际现金替代金额;金额可能是数字字符串
available_dateavailable_time 字符串 该版资料的公开日期与时间
confirm_dateconfirm_time 字符串 实际确认日期与时间
payment_datepayment_time 字符串 现金交收日期与时间
securities_datesecurities_time 字符串 证券交收日期与时间
sellable_date 字符串,可选 证券可卖日
redeemable_date 字符串,可选 份额可赎回日
deliverable_date 字符串,可选 证券可交付日

日期为 YYYYMMDD、时间为 HHMMSS,字段可能缺失。这里是每篮子的清算金额,与订单上全单actual_cash 不是同一口径。

到账后重新查询账户和持仓。证券到账不等于已经可卖,以 position.sellable 为准;在途申请继续保留,不要把预估现金当成实际收款。

五、撤销一级申请

cancel_etf_cr_order(account_id, order_id) 返回布尔值,只对账户匹配、仍处于可撤阶段、且产品允许撤单的申请尝试撤单。

返回 True 后,结合 etf_cr_order_cancel 回报更新记录;返回 False 时原申请可能仍在处理,不能据此认为申请失败或资产已解冻。

六、申赎跟踪

下面的片段把一级申请的提交、回报和去重串起来。在 initialize 设置账户后调用 init_primary_tracking(context);在自己的申赎信号满足时调用提交函数,传入已核对清单的金额或份额。它不替策略选择篮子。

python
from math import isfinite
from panda_backtest.api.api import *
def init_primary_tracking(context):
    context.primary_pending = {}
    context.primary_terminal = set()
    context.primary_attempts = set()
    context.primary_confirmed = set()


def _track_primary(context, order):
    key = (order.account, 'PRIMARY', str(order.order_id))
    if order.status in {3, -1} or (order.status == 2 and order.stage == 'SETTLED'):
        context.primary_pending.pop(key, None)
        context.primary_terminal.add(key)
    elif key not in context.primary_terminal:
        context.primary_pending[key] = {
            'symbol': order.symbol, 'units': order.units,
            'status': order.status, 'confirm_date': order.confirm_date,
        }


def submit_primary_creation(context, symbol, nominal_amount):
    # 每只ETF每日只提交一次同方向申请,避免重复占用资金与证券。
    intent = (str(context.trade_date), symbol, 'PURCHASE')
    if intent in context.primary_attempts:
        return None
    if not isfinite(nominal_amount) or nominal_amount <= 0:
        raise ValueError('申购名义金额必须为有限正数')
    context.primary_attempts.add(intent)
    order = purchase(context.account, symbol, nominal_amount,
                     cash_substitution=False)
    if order is None:
        raise ValueError('ETF申购未返回约定的一级委托对象')
    _track_primary(context, order)
    return order


def submit_primary_redemption(context, symbol, quantity):
    intent = (str(context.trade_date), symbol, 'REDEEM')
    if intent in context.primary_attempts:
        return None
    if (not isinstance(quantity, int) or isinstance(quantity, bool)
            or quantity <= 0):
        raise ValueError('赎回份额必须为正整数,并满足当日最小申赎单位')
    context.primary_attempts.add(intent)
    order = redeem(context.account, symbol, quantity, cash_substitution=False)
    if order is None:
        raise ValueError('ETF赎回未返回约定的一级委托对象')
    _track_primary(context, order)
    return order


def on_etf_cr_order_rtn(context, order):
    if order.account == context.account:
        _track_primary(context, order)
        print(f"[一级委托] {order.order_id} "
              f"状态={order.status} 原因={order.reason}")


def on_etf_cr_confirm(context, result):
    if result.account != context.account:
        return
    _track_primary(context, result)
    if result.status == FILLED and result.stage == "SETTLED":
        key = (result.account, 'PRIMARY', str(result.order_id))
        if key in context.primary_confirmed:
            return
        context.primary_confirmed.add(key)
        print(f"[一级完成] {result.order_id} 份额={result.units} "
              f"现金={result.actual_cash} 费用={result.fee}")
    else:
        print(f"[一级状态] {result.order_id} "
              f"状态={result.status} 原因={result.reason}")
    # 不在这里递归下单,也不手工加减账户资产。


def etf_cr_order_cancel(context, order):
    if order.account == context.account:
        _track_primary(context, order)

提交函数限制同一交易日、同一 ETF、同一方向只尝试一次。跨日仍有申请在途时,先检查 context.primary_pending 再决定是否新增;它和二级的 pending_orders 分开保存。

七、查询申赎清单和额度

以下三个方法返回 DataFrame,参数相同:

python
panda_data.get_fund_etf_cr(start_date="", end_date="", symbol=None, exchange=None, fields=None)
panda_data.get_fund_etf_constituents(start_date="", end_date="", symbol=None, exchange=None, fields=None)
panda_data.get_fund_etf_cr_limits(start_date="", end_date="", symbol=None, exchange=None, fields=None)

start_dateend_date 必填,YYYYMMDDsymbol 为 ETF 代码或代码列表;exchange 为市场筛选,如 'SH'fields 为所需列名。每次查询区间不超过一年,全周期数据在初始化时分段取得,交易时只读当前交易日对应的记录。

三张表都用 symboldateexchange 标识 ETF、清单日期和市场,不能把一只 ETF 的清单和另一只的额度混用。

清单(PCF)常用列

单位 用途
unit 份/篮子 最小申赎单位
unit_nav 元/份 该清单给出的参考净值
creation_unit 元/篮子 最小申赎单位的参考资产净值
estimated_cash_component 元/篮子 预估现金差额,用于准备资金
cash_component 元/篮子 数据中的现金差额;按业务归属日和公开时点理解,不能直接当成本次申请已到账
cash_substitution_rate % 现金替代比例上限,50 表示 50%
purchase_allowed_flag 标志 当日是否开放申购
redemption_allowed_flag 标志 当日是否开放赎回

成分券常用列

单位 用途
stock_symbol 完整代码 实物或现金替代对应的证券
quantity 股/篮子 每个最小申赎单位要求的成分券数量,不是整笔申请的数量
cash_substitution_flag 标志 现金替代
cash_premium_rate % 现金替代溢价比例
cash_discount_rate 按数据口径 现金替代折价比例;未经核对不要与溢价字段按相同缩放计算
fixed_substitution_amount 元/篮子 固定替代金额
redemption_cash_amount 元/篮子 赎回替代金额

额度常用列

单位 用途
min_cr_unit 份/篮子 当日最小申赎单位,须与 PCF 一致
nav 元/份 参考净值
purchase_limitredemption_limit 当日申购、赎回上限
net_purchase_limitnet_redemption_limit 当日净申购、净赎回上限
account_purchase_limitaccount_redemption_limit 单账户申购、赎回上限
account_net_purchaseaccount_net_redemption 单账户净申购、净赎回上限

额度占用包括已受理、尚未结束的申请。即使金额、份额和持仓都符合要求,也可能因额度不足被拒,读 reason 后再安排后续交易。额度为空时,不要擅自当成一个很大的可用额度。

八、串联二级买卖与一级申赎

准备成分券后申购:读取当日清单 → 按篮数计算实物数量 → 补齐证券并留出现金 → 检查是否具备交付条件 → 提交申购 → 等待确认及交收 → 读取到账的 ETF 持仓。

买入 ETF 后赎回:按最小单位在二级市场准备 ETF 份额(最小申赎单位 × 篮数)→ 等待具备赎回条件 → 重新核对申请日的清单和额度 → 提交赎回 → 等待成分券、现金分别到账 → 按可卖数量决定后续交易。赎回拿到的是清单规定的实物证券与现金,不是按 ETF 市价一笔卖出;要继续卖出成分券,确认到账后逐只读 sellable,用普通卖出方法处理。

几点提醒:

  • 申购当日的清单可能与前一天不同,昨天买齐的篮子不代表今天也齐。
  • 实物成分券的买入取整可能留下余股,赎回后也会持有成分券。它们仍属于本账户,不要因为一次申赎完成就统一清零。
  • 到账后估算收益,用重新读取的成本、市值和净资产。不要把申购扣券看成普通卖出,也不要把赎回到账的成分券按零成本另行入账。

两条路径的完整代码是同一份策略,用 start_mode 切换,见 ETF 申赎与两融示例