diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9448ef392..f6148ecfa 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 ================== diff --git a/docs/source/api/base_api.rst b/docs/source/api/base_api.rst index b22ff3c91..8082cfefc 100644 --- a/docs/source/api/base_api.rst +++ b/docs/source/api/base_api.rst @@ -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】板块缩写代码,全球通用标准定义 diff --git a/docs/source/development/mod.rst b/docs/source/development/mod.rst index 065522f0e..857ce0f9c 100644 --- a/docs/source/development/mod.rst +++ b/docs/source/development/mod.rst @@ -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 @@ -272,4 +309,4 @@ RQAlpha 支持安装、卸载、启用、停止第三方Mod。 如您不熟悉PyPI发布的流程,请参考官方文档:https://packaging.python.org/distributing/ -如果您希望更多人使用您的Mod,您也可以联系我们,我们审核通过后,会在 RQAlpha 项目介绍和文档中增加您的Mod的介绍和推荐。 \ No newline at end of file +如果您希望更多人使用您的Mod,您也可以联系我们,我们审核通过后,会在 RQAlpha 项目介绍和文档中增加您的Mod的介绍和推荐。 diff --git a/rqalpha/const.py b/rqalpha/const.py index f28fd0aee..30431a3b6 100644 --- a/rqalpha/const.py +++ b/rqalpha/const.py @@ -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" # 资本收益税(含增值税、附加税等) diff --git a/rqalpha/mod/rqalpha_mod_sys_accounts/api/order_target_portfolio.py b/rqalpha/mod/rqalpha_mod_sys_accounts/api/order_target_portfolio.py index f444196c7..f97b6ccb2 100644 --- a/rqalpha/mod/rqalpha_mod_sys_accounts/api/order_target_portfolio.py +++ b/rqalpha/mod/rqalpha_mod_sys_accounts/api/order_target_portfolio.py @@ -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( @@ -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 @@ -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 diff --git a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/README.rst b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/README.rst index e39998f79..6953dfb30 100644 --- a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/README.rst +++ b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/README.rst @@ -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, } - - diff --git a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/__init__.py b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/__init__.py index 652580c64..e4047d8f8 100644 --- a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/__init__.py +++ b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/__init__.py @@ -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, # 是否使用回测当时时间点对应的真实印花税率 diff --git a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/deciders.py b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/deciders.py index 90082b181..75f00d6f1 100644 --- a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/deciders.py +++ b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/deciders.py @@ -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 @@ -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) @@ -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 @@ -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: @@ -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 diff --git a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/mod.py b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/mod.py index c2660bf76..145ba47a8 100644 --- a/rqalpha/mod/rqalpha_mod_sys_transaction_cost/mod.py +++ b/rqalpha/mod/rqalpha_mod_sys_transaction_cost/mod.py @@ -15,18 +15,99 @@ # 在此前提下,对本软件的使用同样需要遵守 Apache 2.0 许可,Apache 2.0 许可与本许可冲突之处,以本许可为准。 # 详细的授权流程,请联系 public@ricequant.com 获取。 +from math import isfinite +from numbers import Real +from typing import AbstractSet, Any, Dict, FrozenSet, Mapping, Optional, Tuple + +from rqalpha.environment import Environment from rqalpha.interface import AbstractMod from rqalpha.const import INSTRUMENT_TYPE from rqalpha.utils.exception import patch_user_exc -from rqalpha.utils import INST_TYPE_IN_STOCK_ACCOUNT +from rqalpha.utils import INST_TYPE_IN_STOCK_ACCOUNT, RqAttrDict from rqalpha.utils.i18n import gettext as _ from rqalpha.utils.logger import user_log -from .deciders import StockTransactionCostDecider, FuturesTransactionCostDecider +from .deciders import ( + CommissionProfile, + ETFTransactionCostDecider, + FuturesTransactionCostDecider, + StockTransactionCostDecider, +) + + +_PROFILE_FIELDS: FrozenSet[str] = frozenset({"commission_rate", "min_commission"}) +_ETF_SUBTYPES: FrozenSet[str] = frozenset({"bond", "money"}) + + +def _to_mapping(value: Any, path: str) -> Mapping[str, Any]: + if isinstance(value, RqAttrDict): + value = value.convert_to_dict() + if not isinstance(value, Mapping): + raise ValueError("{} must be a mapping".format(path)) + return value + + +def _validate_keys( + value: Mapping[str, Any], allowed_keys: AbstractSet[str], path: str +) -> None: + unknown_keys = set(value) - set(allowed_keys) + if unknown_keys: + raise ValueError("unknown {} config field(s): {}".format(path, ", ".join(sorted(unknown_keys)))) + + +def _validate_profile(profile: Any, path: str) -> Dict[str, Optional[float]]: + profile = _to_mapping(profile, path) + _validate_keys(profile, _PROFILE_FIELDS, path) + result: Dict[str, Optional[float]] = {} + for field in _PROFILE_FIELDS: + value = profile.get(field) + if value is None: + result[field] = None + continue + if isinstance(value, bool) or not isinstance(value, Real) or not isfinite(value) or value < 0: + raise ValueError("{}.{} must be a finite non-negative number or None".format(path, field)) + result[field] = float(value) + return result + + +def _overlay_profile( + base: CommissionProfile, override: Mapping[str, Optional[float]], path: str +) -> CommissionProfile: + commission_rate = override.get("commission_rate") + min_commission = override.get("min_commission") + profile = CommissionProfile( + commission_rate=base.commission_rate if commission_rate is None else commission_rate, + min_commission=base.min_commission if min_commission is None else min_commission, + ) + if profile.commission_rate == 0 and profile.min_commission > 0: + raise ValueError("{}.min_commission must be 0 when commission_rate is 0".format(path)) + return profile + + +def _resolve_etf_commission( + etf_commission: Any, stock_profile: CommissionProfile +) -> Tuple[CommissionProfile, Dict[str, CommissionProfile]]: + etf_commission = _to_mapping(etf_commission, "etf_commission") + _validate_keys(etf_commission, {"default", "subtypes"}, "etf_commission") + + default_config = _validate_profile(etf_commission.get("default", {}), "etf_commission.default") + default_profile = _overlay_profile(stock_profile, default_config, "etf_commission.default") + + subtype_configs = _to_mapping(etf_commission.get("subtypes", {}), "etf_commission.subtypes") + _validate_keys(subtype_configs, _ETF_SUBTYPES, "etf_commission.subtypes") + subtype_profiles: Dict[str, CommissionProfile] = {} + for subtype in _ETF_SUBTYPES: + config = _validate_profile( + subtype_configs.get(subtype, {}), "etf_commission.subtypes.{}".format(subtype) + ) + subtype_profiles[subtype] = _overlay_profile( + default_profile, config, "etf_commission.subtypes.{}".format(subtype) + ) + return default_profile, subtype_profiles class TransactionCostMod(AbstractMod): - def start_up(self, env, mod_config): + def start_up(self, env: Environment, mod_config: RqAttrDict) -> None: stock_commission_multiplier = mod_config.stock_commission_multiplier futures_commission_multiplier = mod_config.futures_commission_multiplier @@ -42,17 +123,30 @@ def start_up(self, env, mod_config): else: stock_min_commission = mod_config.stock_min_commission + stock_profile = CommissionProfile( + commission_rate=0.0008 * stock_commission_multiplier, + min_commission=stock_min_commission, + ) + default_etf_profile, etf_subtype_profiles = _resolve_etf_commission( + getattr(mod_config, "etf_commission", {}), stock_profile + ) + for instrument_type in INST_TYPE_IN_STOCK_ACCOUNT: - if instrument_type == INSTRUMENT_TYPE.PUBLIC_FUND: + if instrument_type in {INSTRUMENT_TYPE.PUBLIC_FUND, INSTRUMENT_TYPE.ETF}: continue env.set_transaction_cost_decider(instrument_type, StockTransactionCostDecider( stock_commission_multiplier, stock_min_commission, mod_config.tax_multiplier, mod_config.pit_tax, env.event_bus )) + env.set_transaction_cost_decider(INSTRUMENT_TYPE.ETF, ETFTransactionCostDecider( + default_etf_profile, + etf_subtype_profiles, + )) + env.set_transaction_cost_decider(INSTRUMENT_TYPE.FUTURE, FuturesTransactionCostDecider( futures_commission_multiplier )) - def tear_down(self, code, exception=None): + def tear_down(self, code: int, exception: Optional[Exception] = None) -> None: pass diff --git a/rqalpha/model/instrument.py b/rqalpha/model/instrument.py index 72e6423b1..c147c6448 100644 --- a/rqalpha/model/instrument.py +++ b/rqalpha/model/instrument.py @@ -116,6 +116,15 @@ def type(self) -> INSTRUMENT_TYPE: """ return INSTRUMENT_TYPE[self._dict["type"]] # type: ignore + @cached_property + def fund_type(self) -> Optional[str]: + """ + [str] ETF 基金类型。 + """ + if self.type == INSTRUMENT_TYPE.ETF: + return self._dict["fund_type"] + return self._dict.get("fund_type") + @cached_property def exchange(self) -> EXCHANGE: """ diff --git a/tests/integration_tests/test_api/mod/sys_transaction_cost/test_etf_commission_backtest.py b/tests/integration_tests/test_api/mod/sys_transaction_cost/test_etf_commission_backtest.py new file mode 100644 index 000000000..4ef577d6d --- /dev/null +++ b/tests/integration_tests/test_api/mod/sys_transaction_cost/test_etf_commission_backtest.py @@ -0,0 +1,70 @@ +import pytest + +from rqalpha import run_func +from rqalpha.apis import order_shares, order_target_portfolio + + +def make_config(): + return { + "base": { + "start_date": "2022-01-04", + "end_date": "2022-01-04", + "frequency": "1d", + "accounts": {"stock": 1_000_000}, + "capital_gain_tax_rate": 0, + }, + "mod": { + "sys_analyser": {"enabled": False}, + "sys_transaction_cost": { + "etf_commission": { + "default": {"commission_rate": None, "min_commission": None}, + "subtypes": { + "bond": {"commission_rate": 0.0002, "min_commission": 0}, + "money": {"commission_rate": 0, "min_commission": 0}, + }, + }, + }, + }, + } + + +def assert_etf_transaction_costs(bond_order, money_order): + assert bond_order.transaction_cost == pytest.approx( + bond_order.avg_price * bond_order.filled_quantity * 0.0002 + ) + assert money_order.transaction_cost == 0 + + +def test_etf_commission_profiles_apply_to_backtest_trades(): + + def init(context): + context.ordered = False + + def handle_bar(context, _bar_dict): + if context.ordered: + return + context.ordered = True + bond_order = order_shares("511010.XSHG", 1000) + money_order = order_shares("511880.XSHG", 1000) + + assert_etf_transaction_costs(bond_order, money_order) + + run_func(config=make_config(), init=init, handle_bar=handle_bar) + + +def test_etf_commission_profiles_apply_to_order_target_portfolio(): + def handle_bar(context, _bar_dict): + if getattr(context, "ordered", False): + return + context.ordered = True + orders = order_target_portfolio({ + "511010.XSHG": 0.1, + "511880.XSHG": 0.1, + }) + orders_by_id = {order.order_book_id: order for order in orders} + + assert_etf_transaction_costs( + orders_by_id["511010.XSHG"], orders_by_id["511880.XSHG"] + ) + + run_func(config=make_config(), handle_bar=handle_bar) diff --git a/tests/unittest/test_const.py b/tests/unittest/test_const.py new file mode 100644 index 000000000..fd561e3ae --- /dev/null +++ b/tests/unittest/test_const.py @@ -0,0 +1,31 @@ +import pytest + +from rqalpha.const import MARKET + + +@pytest.mark.parametrize( + "value, expected", + [ + ("CN", MARKET.CN), + ("cn", MARKET.CN), + ("Cn", MARKET.CN), + ("HK", MARKET.HK), + ("hk", MARKET.HK), + ("Hk", MARKET.HK), + ], +) +def test_market_accepts_case_insensitive_values(value, expected): + assert MARKET(value) is expected + + +@pytest.mark.parametrize( + "key, expected", + [ + ("CN", MARKET.CN), + ("cn", MARKET.CN), + ("HK", MARKET.HK), + ("hk", MARKET.HK), + ], +) +def test_market_accepts_case_insensitive_keys(key, expected): + assert MARKET[key] is expected diff --git a/tests/unittest/test_data/test_instrument_mixin.py b/tests/unittest/test_data/test_instrument_mixin.py index 6013cae7a..0618fc904 100644 --- a/tests/unittest/test_data/test_instrument_mixin.py +++ b/tests/unittest/test_data/test_instrument_mixin.py @@ -84,4 +84,3 @@ def test_get_active_instrument_raises_for_multiple_candidates(self): self.instruments_mixin.get_active_instrument(order_book_id, dt) self.data_source.get_instruments.assert_called_once_with(id_or_syms=[order_book_id]) - diff --git a/tests/unittest/test_mod/test_sys_transaction_cost/test_etf_commission.py b/tests/unittest/test_mod/test_sys_transaction_cost/test_etf_commission.py new file mode 100644 index 000000000..ba27d93b3 --- /dev/null +++ b/tests/unittest/test_mod/test_sys_transaction_cost/test_etf_commission.py @@ -0,0 +1,234 @@ +from unittest.mock import Mock, call, patch + +import pytest +from pandas import Series + +from rqalpha.const import INSTRUMENT_TYPE, MARKET, POSITION_EFFECT, SIDE +from rqalpha.interface import TransactionCost, TransactionCostArgs +from rqalpha.model.instrument import Instrument +from rqalpha.mod.rqalpha_mod_sys_accounts.api.order_target_portfolio import OrderTargetPortfolio +from rqalpha.mod.rqalpha_mod_sys_transaction_cost.deciders import ( + AbstractStockTransactionCostDecider, + CommissionProfile, + ETFTransactionCostDecider, + StockTransactionCostDecider, +) +from rqalpha.mod.rqalpha_mod_sys_transaction_cost.mod import TransactionCostMod +from rqalpha.utils import RqAttrDict + + +def make_instrument( + instrument_type=INSTRUMENT_TYPE.ETF, + fund_type="Stock", + order_book_id="510300.XSHG", +): + data = { + "order_book_id": order_book_id, + "symbol": order_book_id, + "type": instrument_type.name, + "exchange": "XSHG", + } + if fund_type is not None: + data["fund_type"] = fund_type + return Instrument(data) + + +def make_args(instrument, *, quantity=1000, price=10, side=SIDE.BUY, order_id=None): + return TransactionCostArgs( + instrument=instrument, + price=price, + quantity=quantity, + side=side, + position_effect=POSITION_EFFECT.OPEN if side == SIDE.BUY else POSITION_EFFECT.CLOSE, + order_id=order_id, + ) + + +def make_decider(default_profile=None): + with patch( + "rqalpha.mod.rqalpha_mod_sys_transaction_cost.deciders.Environment.get_instance" + ): + return ETFTransactionCostDecider( + default_profile=default_profile or CommissionProfile(0.0008, 5), + subtype_profiles={ + "bond": CommissionProfile(0.0002, 1), + "money": CommissionProfile(0, 0), + }, + ) + + +def test_etf_profile_selection_tax_and_missing_metadata(): + decider = make_decider() + expected_commissions = { + "Bond": 2, + "BondIndex": 2, + "ShortBond": 2, + "Money": 0, + "Stock": 8, + "UnknownFutureType": 8, + } + + for fund_type, expected in expected_commissions.items(): + cost = decider.calc(make_args(make_instrument(fund_type=fund_type))) + assert cost.commission == expected + assert cost.tax == 0 + + assert decider.calc(make_args(make_instrument(), side=SIDE.SELL)).tax == 0 + with pytest.raises(KeyError, match="fund_type"): + decider.calc(make_args(make_instrument(fund_type=None))) + + +def test_commission_state_handles_etf_profiles_and_preserves_stock_behavior(): + bond_decider = make_decider() + bond = make_instrument(fund_type="Bond") + assert [ + bond_decider.calc(make_args(bond, quantity=quantity, order_id=1)).commission + for quantity in (100, 2500) + ] == [1, 4.2] + assert bond_decider.calc(make_args(bond, quantity=100, order_id=2)).commission == 1 + + with patch( + "rqalpha.mod.rqalpha_mod_sys_transaction_cost.deciders.Environment.get_instance" + ): + stock_decider = StockTransactionCostDecider(0, 5, 1, False, Mock()) + stock = make_instrument(INSTRUMENT_TYPE.CS, fund_type=None, order_book_id="000001.XSHE") + assert [ + stock_decider.calc(make_args(stock, quantity=100, order_id=1)).commission + for _ in range(3) + ] == [5, 5, 5] + + +def test_batch_estimate_uses_each_etf_profile(): + instruments = { + "510300.XSHG": make_instrument(order_book_id="510300.XSHG"), + "511010.XSHG": make_instrument(INSTRUMENT_TYPE.ETF, "BondIndex", "511010.XSHG"), + "511880.XSHG": make_instrument(INSTRUMENT_TYPE.ETF, "Money", "511880.XSHG"), + } + quantities = Series({order_book_id: -1000 for order_book_id in instruments}) + prices = Series({order_book_id: 10 for order_book_id in instruments}) + decider = make_decider() + decider.env = Mock() + decider.env.data_proxy.get_active_instruments.return_value = instruments + + costs = decider.batch_estimate(quantities, prices) + + assert costs.to_dict() == { + "510300.XSHG": 8, + "511010.XSHG": 2, + "511880.XSHG": 0, + } + + +class FixedBatchDecider(AbstractStockTransactionCostDecider): + def __init__(self, cost): + self.cost = cost + + def calc(self, args): + return TransactionCost.zero() + + def batch_estimate(self, delta_quantities, prices): + return Series(self.cost, index=delta_quantities.index, dtype=float) + + +def test_smart_portfolio_uses_etf_decider_only_for_etfs(): + env = Mock() + deciders = { + (INSTRUMENT_TYPE.CS, MARKET.CN): FixedBatchDecider(1), + (INSTRUMENT_TYPE.ETF, MARKET.CN): FixedBatchDecider(2), + } + env.get_transaction_cost_decider.side_effect = lambda instrument_type, market: deciders[ + instrument_type, market + ] + portfolio = object.__new__(OrderTargetPortfolio) + portfolio._env = env + portfolio._market = Series({ + order_book_id: MARKET.CN + for order_book_id in ("stock", "etf", "lof", "convertible") + }) + portfolio._instrument_types = Series({ + "stock": INSTRUMENT_TYPE.CS, + "etf": INSTRUMENT_TYPE.ETF, + "lof": INSTRUMENT_TYPE.LOF, + "convertible": INSTRUMENT_TYPE.CONVERTIBLE, + }) + portfolio._exchange_rates = {} + + costs = portfolio._estimate_transaction_costs( + Series({"stock": -1000, "etf": -1000, "lof": -1000, "convertible": -1000}), + Series({"stock": 10, "etf": 10, "lof": 10, "convertible": 10}), + ) + + assert costs == 5 + assert env.get_transaction_cost_decider.call_args_list == [ + call(INSTRUMENT_TYPE.CS, MARKET.CN), + call(INSTRUMENT_TYPE.ETF, MARKET.CN), + ] + + +def make_mod_config(etf_commission): + return RqAttrDict({ + "stock_commission_multiplier": 2, + "stock_min_commission": 3, + "cn_stock_min_commission": None, + "futures_commission_multiplier": 1, + "tax_multiplier": 1, + "pit_tax": False, + "etf_commission": etf_commission, + }) + + +def start_mod(etf_commission, *, omit_etf_config=False): + config = make_mod_config(etf_commission) + if omit_etf_config: + del config.etf_commission + env = Mock() + env.event_bus = Mock() + deciders = {} + env.set_transaction_cost_decider.side_effect = lambda instrument_type, decider: deciders.__setitem__( + instrument_type, decider + ) + with patch( + "rqalpha.mod.rqalpha_mod_sys_transaction_cost.deciders.Environment.get_instance", + return_value=env, + ): + TransactionCostMod().start_up(env, config) + return deciders + + +def test_etf_config_resolves_inheritance_per_field(): + inherited = start_mod({}, omit_etf_config=True)[INSTRUMENT_TYPE.ETF] + assert inherited.calc(make_args(make_instrument())).commission == 16 + + configured = start_mod({ + "default": {"commission_rate": 0.0005, "min_commission": None}, + "subtypes": { + "bond": {"commission_rate": None, "min_commission": 0}, + "money": {"commission_rate": 0, "min_commission": 0}, + }, + })[INSTRUMENT_TYPE.ETF] + assert { + fund_type: configured.calc( + make_args(make_instrument(fund_type=fund_type), quantity=100) + ).commission + for fund_type in ("Stock", "Bond", "Money") + } == {"Stock": 3, "Bond": 0.5, "Money": 0} + + with pytest.raises(KeyError, match="fund_type"): + configured.calc(make_args(make_instrument(fund_type=None))) + + +def test_invalid_etf_configs_are_rejected(): + cases = [ + ({"default": {"commission_rate": -0.1}, "subtypes": {}}, "commission_rate"), + ({"default": {"commission_rate": float("nan")}, "subtypes": {}}, "commission_rate"), + ({"default": {"commission_rate": 0, "min_commission": 1}, "subtypes": {}}, "min_commission"), + ({ + "default": {"commission_rate": 0.0005, "min_commission": 1}, + "subtypes": {"money": {"commission_rate": 0}}, + }, "min_commission"), + ({"default": {"typo": 1}, "subtypes": {}}, "typo"), + ({"default": {}, "subtypes": {"gold": {}}}, "gold"), + ] + for config, error in cases: + with pytest.raises(ValueError, match=error): + start_mod(config)