Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@
CHANGELOG
==================

6.4.0
==================

**[新增功能]**

- 新增 ``base.market`` 配置项,允许设置回测市场,accounts 币种将跟随市场变动
- 支持为 ETF 默认类型、债券 ETF 和货币 ETF 分别配置佣金费率及最低佣金;旧 bundle 需更新以包含 ``fund_type`` 字段

**[问题修复]**

- 修复股票代码转换后现金与持仓成本可能不一致的问题,确保转换后的持仓与资金连续性
- 改进已退市或尚未上市标的的仓位查询,以及合约匹配歧义时的错误处理

**[兼容性与扩展]**

- Bundle 更新流程加载 ``hdf5plugin``,增强压缩 HDF5 数据的兼容性。
- 分析报告将港股交易纳入股票换手率计算。

**[For Mod 开发者] 接口变更指引**

- ``Position`` 及内置持仓类的构造函数首个参数由 ``order_book_id`` 改为 ``instrument``;自定义 ``Position`` 或覆写 ``Account._init_position`` 的 Mod 应改为接收并传递对应的 ``Instrument`` 对象。位置参数传入字符串暂时兼容,但 ``order_book_id=`` 关键字调用不再支持。
- 新增 ``EVENT.INIT_PORTFOLIO`` 事件,Mod 可在 Broker、事件源及 DataProxy 初始化完成后注册自定义 Portfolio。

6.3.0
==================

Expand Down
4 changes: 4 additions & 0 deletions docs/source/api/base_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,10 @@ Instrument - 交易标的

【int】股票:一手对应多少股,中国A股一手是100股。期货:一律为1。

.. py:attribute:: fund_type(基金专用)

【str or None】基金类型。ETF 常见取值包括 ``Bond``、``BondIndex``、``ShortBond``、``Money``、``Stock``、``Hybrid``、``StockIndex``、``Related``、``QDII`` 和 ``Other``。ETF 数据不包含该字段时抛出 ``KeyError``;其他品种缺少该字段时返回 ``None``。

.. py:attribute:: sector_code(股票专用)

【str】板块缩写代码,全球通用标准定义
Expand Down
39 changes: 38 additions & 1 deletion docs/source/development/mod.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,43 @@ Mod开发环境搭建
def tear_down(self, success, exception=None):
print(">>> HelloWorldMod.tear_down")

自定义 Portfolio 初始化
------------------------

当 Mod 需要创建或替换默认的 :code:`Portfolio` 时,应在 :code:`start_up` 中监听 :code:`EVENT.INIT_PORTFOLIO`,而不是直接创建 Portfolio。

:code:`EVENT.INIT_PORTFOLIO` 会在数据源、DataProxy、Broker 和事件源初始化完成后触发,此时默认 Portfolio 尚未创建。在监听函数中通过 :code:`env.set_portfolio` 注册自定义 Portfolio 后,框架将不再创建默认 Portfolio。自定义实现需要与 RQAlpha 所使用的 Portfolio 接口保持兼容。

.. code-block:: python3

from rqalpha.core.events import EVENT
from rqalpha.interface import AbstractMod
from rqalpha.portfolio import Portfolio


class CustomPortfolio(Portfolio):
pass


class CustomPortfolioMod(AbstractMod):
def start_up(self, env, mod_config):
self._env = env
env.event_bus.add_listener(
EVENT.INIT_PORTFOLIO, self._init_portfolio
)

def _init_portfolio(self, event):
config = self._env.config
self._env.set_portfolio(CustomPortfolio(
config.base.accounts,
config.base.init_positions,
config.mod.sys_accounts.financing_rate,
self._env,
))

def tear_down(self, code, exception=None):
pass

我们第一个 Mod 就写好了,接下来我们需要写一个 :code:`setup.py` 以便我们以PyPI的形式发布以及安装。

PyPI方式安装Mod
Expand Down Expand Up @@ -272,4 +309,4 @@ RQAlpha 支持安装、卸载、启用、停止第三方Mod。
如您不熟悉PyPI发布的流程,请参考官方文档:https://packaging.python.org/distributing/


如果您希望更多人使用您的Mod,您也可以联系我们,我们审核通过后,会在 RQAlpha 项目介绍和文档中增加您的Mod的介绍和推荐。
如果您希望更多人使用您的Mod,您也可以联系我们,我们审核通过后,会在 RQAlpha 项目介绍和文档中增加您的Mod的介绍和推荐。
15 changes: 14 additions & 1 deletion rqalpha/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,23 @@ class TRADING_CALENDAR_TYPE(CustomEnum):
# backward compatible
TRADING_CALENDAR_TYPE.EXCHANGE = TRADING_CALENDAR_TYPE.CN_STOCK

class MARKET(CustomEnum):
class MarketEnumMeta(CustomEnumMeta):
def __getitem__(cls, item):
if isinstance(item, str):
item = item.upper()
return super(MarketEnumMeta, cls).__getitem__(item)


class MARKET(CustomEnum, metaclass=MarketEnumMeta):
CN = "CN"
HK = "HK"

@classmethod
def _missing_(cls, value):
if isinstance(value, str):
return cls._value2member_map_.get(value.upper())
return None


class TAX_TYPE(CustomEnum):
CAPITAL_GAINS = "capital_gains" # 资本收益税(含增值税、附加税等)
Expand Down
28 changes: 22 additions & 6 deletions rqalpha/mod/rqalpha_mod_sys_accounts/api/order_target_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ def __init__(
if i.type not in SUPPORTED_INSTRUMENT_TYPES:
raise RQApiNotSupportedError(_('instrument type {} is not supported').format(i.type))

self._instrument_types = Series(
{i.order_book_id: i.type for i in instruments.values()}, dtype='object'
)
self._market = Series({i.order_book_id: i.market for i in instruments.values()}, dtype='object')
self._tick_sizes = Series({i: env.data_proxy.get_tick_size(i) for i in index}, dtype=float)
self._min_qty = Series(
Expand Down Expand Up @@ -262,12 +265,15 @@ def _format_denials(denials: Dict[DenialReason, Series]) -> Dict[str, str]:
return denial_reason_details

@lru_cache(maxsize=8)
def _trans_cost_decider(self, market: MARKET) -> AbstractStockTransactionCostDecider:
decider = self._env.get_transaction_cost_decider(INSTRUMENT_TYPE.CS, market)
def _trans_cost_decider(
self, instrument_type: INSTRUMENT_TYPE, market: MARKET
) -> AbstractStockTransactionCostDecider:
decider = self._env.get_transaction_cost_decider(instrument_type, market)
if not isinstance(decider, AbstractStockTransactionCostDecider):
raise RuntimeError(
'transaction cost decider for market {} is not a subclass of AbstractStockTransactionCostDecider'.format(
market
"transaction cost decider for instrument type {} and market {} is not a subclass of "
"AbstractStockTransactionCostDecider".format(
instrument_type, market
)
)
return decider
Expand All @@ -277,8 +283,18 @@ def _estimate_transaction_costs(self, diff: Series, prices: Series) -> float:
delta_mv = diff * prices
costs = 0.0
for market, group in self._market.groupby(by=self._market):
# 税费等成本
costs += self._trans_cost_decider(market).batch_estimate(diff[group.index], prices[group.index]).sum() # type: ignore
instrument_types = self._instrument_types[group.index]
etf_index = instrument_types[instrument_types == INSTRUMENT_TYPE.ETF].index
non_etf_index = instrument_types[instrument_types != INSTRUMENT_TYPE.ETF].index
for instrument_type, cost_index in (
(INSTRUMENT_TYPE.CS, non_etf_index),
(INSTRUMENT_TYPE.ETF, etf_index),
):
if cost_index.empty:
continue
decider = self._trans_cost_decider(instrument_type, market)
cost = decider.batch_estimate(diff[cost_index], prices[cost_index])
costs += cost.sum()
if market != MARKET.CN:
# 汇率成本
exchange_rate = self._exchange_rates[market] # type: ignore
Expand Down
28 changes: 24 additions & 4 deletions rqalpha/mod/rqalpha_mod_sys_transaction_cost/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,34 @@ RQAlpha 交易税费 Mod,实现了不同市场不同交易标的的税费计
.. code-block:: python

{
# 股票最小手续费,单位元
"cn_stock_min_commission": 5,
# 股票最小手续费,单位元;cn_stock_min_commission 是兼容旧配置的废弃字段
"stock_min_commission": 5,
"cn_stock_min_commission": None,
# 佣金倍率,即在默认的手续费率基础上按该倍数进行调整,股票的默认佣金为万八,期货默认佣金因合约而异
"commission_multiplier": None,
"stock_commission_multiplier": 1,
"futures_commission_multiplier": 1,
# ETF 最终佣金费率和最低佣金。None 表示逐字段继承上层配置,0 是有效的显式值
# 最终 commission_rate 为 0 时,min_commission 也必须为 0
# 优先级:bond/money subtype > default > 股票有效配置
# Bond/BondIndex/ShortBond 使用 bond,Money 使用 money,其余 ETF 类型使用 default
# ETF 数据必须包含 fund_type;使用旧 bundle 时需先更新 bundle
"etf_commission": {
"default": {
"commission_rate": None,
"min_commission": None,
},
"subtypes": {
"bond": {
"commission_rate": None,
"min_commission": None,
},
"money": {
"commission_rate": None,
"min_commission": None,
},
},
},
# 印花倍率,即在默认的印花税基础上按该倍数进行调整,股票默认印花税为千分之一,单边收取
"tax_multiplier": 1,
}


17 changes: 17 additions & 0 deletions rqalpha/mod/rqalpha_mod_sys_transaction_cost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@
"stock_commission_multiplier": 1,
# 期货佣金倍率,即在默认的手续费率基础上按该倍数进行调整,期货默认佣金因合约而异
"futures_commission_multiplier": 1,
# ETF 佣金配置。None 表示逐字段继承股票的有效费率配置,0 表示显式零费率/零最低佣金
"etf_commission": {
"default": {
"commission_rate": None,
"min_commission": None,
},
"subtypes": {
"bond": {
"commission_rate": None,
"min_commission": None,
},
"money": {
"commission_rate": None,
"min_commission": None,
},
},
},
# 印花倍率,即在默认的印花税基础上按该倍数进行调整,股票默认印花税为千分之一,单边收取
"tax_multiplier": 1,
# 是否使用回测当时时间点对应的真实印花税率
Expand Down
102 changes: 83 additions & 19 deletions rqalpha/mod/rqalpha_mod_sys_transaction_cost/deciders.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from collections import defaultdict
from datetime import datetime
from typing import Dict, FrozenSet, Mapping, MutableMapping, NamedTuple, Optional

from pandas import Series
from numpy import maximum
Expand All @@ -22,6 +23,7 @@
from rqalpha.environment import Environment
from rqalpha.const import SIDE, HEDGE_TYPE, COMMISSION_TYPE, POSITION_EFFECT, INSTRUMENT_TYPE
from rqalpha.core.events import EVENT
from rqalpha.model.instrument import Instrument


STOCK_PIT_TAX_CHANGE_DATE = datetime(2023, 8, 28)
Expand All @@ -32,7 +34,41 @@ def batch_estimate(self, delta_quantities: Series, prices: Series) -> Series:
raise NotImplementedError


class StockTransactionCostDecider(AbstractStockTransactionCostDecider):
class CommissionProfile(NamedTuple):
commission_rate: float
min_commission: float


class CommissionMixin:
commission_map: MutableMapping[int, float]

def _calculate_commission(
self,
cost_commission: float,
min_commission: float,
order_id: Optional[int],
) -> float:
if order_id is None:
return max(cost_commission, min_commission)

commission = self.commission_map[order_id]
if cost_commission > commission:
if commission == min_commission:
self.commission_map[order_id] = 0
return cost_commission
else:
self.commission_map[order_id] = 0
return cost_commission - commission
else:
if commission == min_commission:
self.commission_map[order_id] -= cost_commission
return commission
else:
self.commission_map[order_id] -= cost_commission
return 0


class StockTransactionCostDecider(CommissionMixin, AbstractStockTransactionCostDecider):
def __init__(self, commission_multiplier, min_commission, tax_multiplier, pit_tax, event_bus):
self.commission_rate = 0.0008
self.commission_multiplier = commission_multiplier
Expand Down Expand Up @@ -65,24 +101,7 @@ def _calc_commission(self, args: TransactionCostArgs) -> float:
4.2 如果commission 不等于 min_commission, 说明不是第一笔trade, 之前的trade中min_commission已经收过了,所以返回0.
"""
cost_commission = args.price * args.quantity * self.commission_rate * self.commission_multiplier
order_id = args.order_id
if order_id is None:
return max(cost_commission, self.min_commission)
commission = self.commission_map[order_id]
if cost_commission > commission:
if commission == self.min_commission:
self.commission_map[order_id] = 0
return cost_commission
else:
self.commission_map[order_id] = 0
return cost_commission - commission
else:
if commission == self.min_commission:
self.commission_map[order_id] -= cost_commission
return commission
else:
self.commission_map[order_id] -= cost_commission
return 0
return self._calculate_commission(cost_commission, self.min_commission, args.order_id)

def _calc_tax(self, args: TransactionCostArgs) -> float:
if args.side == SIDE.BUY or args.instrument.type != INSTRUMENT_TYPE.CS:
Expand All @@ -99,6 +118,51 @@ def batch_estimate(self, delta_quantities: Series, prices: Series) -> Series:
return commission + tax


class ETFTransactionCostDecider(CommissionMixin, AbstractStockTransactionCostDecider):
_BOND_FUND_TYPES: FrozenSet[str] = frozenset({"Bond", "BondIndex", "ShortBond"})

def __init__(
self,
default_profile: CommissionProfile,
subtype_profiles: Mapping[str, CommissionProfile],
) -> None:
self.default_profile: CommissionProfile = default_profile
self.subtype_profiles: Dict[str, CommissionProfile] = dict(subtype_profiles)
self.commission_map: Dict[int, float] = {}
self.env: Environment = Environment.get_instance()

def _get_profile(self, instrument: Instrument) -> CommissionProfile:
fund_type = instrument.fund_type
if fund_type in self._BOND_FUND_TYPES:
return self.subtype_profiles["bond"]
if fund_type == "Money":
return self.subtype_profiles["money"]
return self.default_profile

def _calc_commission(self, args: TransactionCostArgs) -> float:
profile = self._get_profile(args.instrument)
cost_commission = args.price * args.quantity * profile.commission_rate
if args.order_id is not None:
self.commission_map.setdefault(args.order_id, profile.min_commission)
return self._calculate_commission(cost_commission, profile.min_commission, args.order_id)

def calc(self, args: TransactionCostArgs) -> TransactionCost:
return TransactionCost(commission=self._calc_commission(args), tax=0, other_fees=0)

def batch_estimate(self, delta_quantities: Series, prices: Series) -> Series:
instruments = self.env.data_proxy.get_active_instruments(
delta_quantities.index, self.env.trading_dt
)
costs: Dict[str, float] = {}
for order_book_id in delta_quantities.index:
profile = self._get_profile(instruments[order_book_id])
costs[order_book_id] = max(
abs(delta_quantities[order_book_id]) * prices[order_book_id] * profile.commission_rate,
profile.min_commission,
)
return Series(costs, dtype=float)


class FuturesTransactionCostDecider(AbstractTransactionCostDecider):
def __init__(self, commission_multiplier):
self.commission_multiplier = commission_multiplier
Expand Down
Loading
Loading