股票与 ETF
ETF 申购与赎回
ETF 一级申购赎回的策略写法:purchase 与 redeem 的参数和篮子换算,现金替代选择,一级订单对象与交收阶段,settlement_data 清算字典,撤单与申赎跟踪,查询申赎清单和额度,以及如何串联二级买卖与一级申赎。
本页讲 ETF 的一级市场申购与赎回:用一篮子成分券(加现金差额)换 ETF 份额,或反过来。二级市场买卖 ETF 仍用 order_shares、order_values(见下单、撤单与回报),那不是申购赎回。
适用范围:沪深上市、证券资料已明确分类的境内非 QDII 股票 ETF。
运行一级策略前,回测任务需要有对应日期的申赎清单(PCF)、成分券、额度和实际确认交收资料。只有 PCF 和行情,不足以完成实际现金差额、替代退补和到账处理;这时申请会一直等待,不要在策略里自行补造清算金额。
一、申购与赎回
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 |
现金替代选择,见下一节;用关键字传入 |
两个方法返回单个一级订单对象,不是列表。受理和业务拒绝都可能返回对象,分别读 status、reason。赎回传正份额,不能沿用二级卖出的负数量写法。后续关联申请用返回对象的 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):
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 只是写法示意,使用前核对它在当日清单中、且允许相应方向的替代。全周期回测时成分和替代标志可能变化,完整示例在代码不在清单中或变为禁止替代时会明确报错,而不是悄悄换一种交付方式。
三、一级订单对象
purchase、redeem 的返回值,以及三个一级回调收到的 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 == FILLED 且 stage == 'SETTLED' 才算完成。不能仅凭确认日已到、actual_cash 非零或收到确认回调就判断完成;实际现金为零也可能是合法结果。
四、settlement_data 清算字典
用 result.settlement_data 读取。它可能为 None;有值时用字典的 .get() 取键,不用属性写法。
| 键 | 类型、单位 | 含义 |
|---|---|---|
cash_component |
数值或数字字符串,元/篮子 | 每个最小申赎单位的实际现金差额,计算前先转 float |
substitution_amounts |
字典,元/篮子 | 成分券代码到每篮子实际现金替代金额;金额可能是数字字符串 |
available_date、available_time |
字符串 | 该版资料的公开日期与时间 |
confirm_date、confirm_time |
字符串 | 实际确认日期与时间 |
payment_date、payment_time |
字符串 | 现金交收日期与时间 |
securities_date、securities_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);在自己的申赎信号满足时调用提交函数,传入已核对清单的金额或份额。它不替策略选择篮子。
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,参数相同:
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_date、end_date 必填,YYYYMMDD;symbol 为 ETF 代码或代码列表;exchange 为市场筛选,如 'SH';fields 为所需列名。每次查询区间不超过一年,全周期数据在初始化时分段取得,交易时只读当前交易日对应的记录。
三张表都用 symbol、date、exchange 标识 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_limit、redemption_limit |
份 | 当日申购、赎回上限 |
net_purchase_limit、net_redemption_limit |
份 | 当日净申购、净赎回上限 |
account_purchase_limit、account_redemption_limit |
份 | 单账户申购、赎回上限 |
account_net_purchase、account_net_redemption |
份 | 单账户净申购、净赎回上限 |
额度占用包括已受理、尚未结束的申请。即使金额、份额和持仓都符合要求,也可能因额度不足被拒,读 reason 后再安排后续交易。额度为空时,不要擅自当成一个很大的可用额度。
八、串联二级买卖与一级申赎
准备成分券后申购:读取当日清单 → 按篮数计算实物数量 → 补齐证券并留出现金 → 检查是否具备交付条件 → 提交申购 → 等待确认及交收 → 读取到账的 ETF 持仓。
买入 ETF 后赎回:按最小单位在二级市场准备 ETF 份额(最小申赎单位 × 篮数)→ 等待具备赎回条件 → 重新核对申请日的清单和额度 → 提交赎回 → 等待成分券、现金分别到账 → 按可卖数量决定后续交易。赎回拿到的是清单规定的实物证券与现金,不是按 ETF 市价一笔卖出;要继续卖出成分券,确认到账后逐只读 sellable,用普通卖出方法处理。
几点提醒:
- 申购当日的清单可能与前一天不同,昨天买齐的篮子不代表今天也齐。
- 实物成分券的买入取整可能留下余股,赎回后也会持有成分券。它们仍属于本账户,不要因为一次申赎完成就统一清零。
- 到账后估算收益,用重新读取的成本、市值和净资产。不要把申购扣券看成普通卖出,也不要把赎回到账的成分券按零成本另行入账。
两条路径的完整代码是同一份策略,用 start_mode 切换,见 ETF 申赎与两融示例。
