From 0046c331e9eeda9cee0bd1ad2e22bd7698d280b7 Mon Sep 17 00:00:00 2001 From: zengbin93 Date: Sun, 4 Aug 2024 10:31:03 +0800 Subject: [PATCH] =?UTF-8?q?V0.9.57=20=E6=9B=B4=E6=96=B0=E4=B8=80=E6=89=B9?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20(#210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 0.9.57 start coding * 0.9.57 新增最大夏普计算 * 0.9.57 新增最大夏普计算 * 0.9.57 新增最大夏普计算 * 0.9.57 新增 show_strategies_recent * 0.9.57 新增 get_stk_strategy * v0.9.57 (#209) 1.上传策略时, 将策略名写入SET(Weights:META:ALL) 2.增加获取所有策略了名的方法 get_strategy_names * 0.9.57 修复 table_record_batch_create 接口 * 0.9.57 优化飞书API接口 * 0.9.57 新增 remove_beta_effects * 0.9.57 新增 cross_sectional_strategy * 0.9.57 新增 show_factor_value 和 show_code_editor * 0.9.57 update * 0.9.57 update * 0.9.57 update * 0.9.57 update --------- Co-authored-by: 不归 --- .github/workflows/pythonpackage.yml | 2 +- README.md | 22 +-- czsc/__init__.py | 17 +- czsc/connectors/cooperation.py | 24 +++ czsc/eda.py | 92 ++++++++++ czsc/fsa/base.py | 4 +- czsc/fsa/bi_table.py | 46 +++-- czsc/fsa/im.py | 4 +- czsc/fsa/spreed_sheets.py | 39 ++-- czsc/signals/pos.py | 4 +- czsc/traders/rwc.py | 22 +++ czsc/traders/weight_backtest.py | 3 +- czsc/utils/corr.py | 15 ++ czsc/utils/portfolio.py | 56 ++++++ czsc/utils/st_components.py | 166 +++++++++++++++++- czsc/utils/stats.py | 2 +- .../weight_backtest.py" | 13 ++ ...77\347\224\250\346\241\210\344\276\213.py" | 8 +- 18 files changed, 476 insertions(+), 63 deletions(-) create mode 100644 czsc/utils/portfolio.py create mode 100644 "examples/Streamlit\347\273\204\344\273\266\345\272\223\344\275\277\347\224\250\346\241\210\344\276\213/weight_backtest.py" diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index d2a668d33..0d20a5b60 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -5,7 +5,7 @@ name: Python package on: push: - branches: [ master, V0.9.56 ] + branches: [ master, V0.9.57 ] pull_request: branches: [ master ] diff --git a/README.md b/README.md index 5c653e8a2..d1a6c63a2 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,6 @@ >源于[缠中说缠博客](http://blog.sina.com.cn/chzhshch),原始博客中的内容不太完整,且没有评论,以下是网友整理的原文备份 * 备份网址1:http://www.fxgan.com ->**假如没有了分型、笔、线段,缠论还是缠论吗?如果你的答案是“是”,这个项目是为你准备的。本项目旨在提供一个符合缠中说禅思维方式的程序化交易工具。** - * 已经开始用czsc库进行量化研究的朋友,欢迎[加入飞书群](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=0bak668e-7617-452c-b935-94d2c209e6cf),快点击加入吧! * [B站视频教程合集(持续更新...)](https://space.bilibili.com/243682308/channel/series) @@ -25,13 +23,19 @@ > 有意愿的朋友请联系我,微信号:**zengbin93**,备注:**桌面应用开发**。 > 我们将为你提供一个更好的量化交易学习和交流平台。 +## 缠论精华 + +>学了本ID的理论,去再看其他的理论,就可以更清楚地看到其缺陷与毛病,因此,广泛地去看不同的理论,不仅不影响本ID理论的学习,更能明白本ID理论之所以与其他理论不同的根本之处。 + +>为什么要去了解其他理论,就是这些理论操作者的行为模式,将构成以后我们猎杀的对象,他们操作模式的缺陷,就是以后猎杀他们的最好武器,这就如同学独孤九剑,必须学会发现所有派别招数的缺陷,这也是本ID理论学习中一个极为关键的步骤。 + + ## 知识星球 * [CZSC小圈子(缠论、量化、专享案例)](https://s0cqcxuy3p.feishu.cn/wiki/wikcnwXSk9mWnki1b6URPhLA2Hc) * 链接:https://wx.zsxq.com/dweb2/index/group/88851448582512 * 加入:https://t.zsxq.com/0aMSAqcgO -* 费用:100元 > **知识星球【CZSC小圈子】的定位是什么?** > - 为仔细研读过禅师原文并且愿意使用 CZSC 库进行量化投研的朋友提供一个深入交流的平台。 @@ -47,7 +51,7 @@ * 定义并实现 `信号-因子-事件-交易` 量化交易逻辑体系,因子是信号的线性组合,事件是因子的同类合并,详见 `czsc/objects.py` * 定义并实现了若干信号函数,详见 `czsc/signals` * 缠论多级别联立决策分析交易,详见 `CzscTrader` -* 基于 Tushare 数据的择时、选股策略回测研究流程 +* [Streamlit 量化研究组件库](https://s0cqcxuy3p.feishu.cn/wiki/AATuw5vN7iN9XbkVPuwcE186n9f) ## 安装使用 @@ -69,16 +73,6 @@ pip install git+https://github.com/waditu/czsc.git@V0.9.46 -U pip install czsc -U -i https://pypi.python.org/simple ``` - -## 信号开源计划 - ->学了本ID的理论,去再看其他的理论,就可以更清楚地看到其缺陷与毛病,因此,广泛地去看不同的理论,不仅不影响本ID理论的学习,更能明白本ID理论之所以与其他理论不同的根本之处。 - ->为什么要去了解其他理论,就是这些理论操作者的行为模式,将构成以后我们猎杀的对象,他们操作模式的缺陷,就是以后猎杀他们的最好武器,这就如同学独孤九剑,必须学会发现所有派别招数的缺陷,这也是本ID理论学习中一个极为关键的步骤。 - -信号开源计划旨在为缠论学习者提供一批其他理论对应的信号计算函数,供各位以量化的方式研究其他理论的缺陷和价值。这个计划的工作量极大,需要各位的参与。有意愿加入的朋友,请点击查看详情:**[CZSC信号开源计划介绍](https://s0cqcxuy3p.feishu.cn/wiki/wikcnx7707hlakYMi4HmxdAIHJg)** - - ## 使用前必看 * 目前的开发还在高频次的迭代中,对于已经在使用某个版本的用户,请谨慎更新,版本兼容性实在是太差,主要是因为当前还有太多考虑不完善的地方,我为此感到抱歉; diff --git a/czsc/__init__.py b/czsc/__init__.py index ca3caa06e..fc6a164b8 100644 --- a/czsc/__init__.py +++ b/czsc/__init__.py @@ -152,6 +152,9 @@ show_symbols_corr, show_feature_returns, show_czsc_trader, + show_strategies_recent, + show_factor_value, + show_code_editor, ) from czsc.utils.bi_info import ( @@ -192,10 +195,20 @@ ) -__version__ = "0.9.56" +from czsc.utils.portfolio import ( + max_sharp, +) + +from czsc.eda import ( + remove_beta_effects, vwap, twap, + cross_sectional_strategy, +) + + +__version__ = "0.9.57" __author__ = "zengbin93" __email__ = "zeng_bin8888@163.com" -__date__ = "20240714" +__date__ = "20240726" def welcome(): diff --git a/czsc/connectors/cooperation.py b/czsc/connectors/cooperation.py index 6ad83d234..75329d9dc 100644 --- a/czsc/connectors/cooperation.py +++ b/czsc/connectors/cooperation.py @@ -291,3 +291,27 @@ def upload_strategy(df, meta, token=None, **kwargs): logger.info(f"上传策略接口返回: {response.json()}") return response.json() + + +def get_stk_strategy(name="STK_001", **kwargs): + """获取 STK 系列子策略的持仓权重数据 + + :param name: str + 子策略名称 + :param kwargs: dict + sdt: str, optional + 开始日期,默认为 "20170101" + edt: str, optional + 结束日期,默认为当前日期 + """ + dfw = dc.post_request(api_name=name, v=2, hist=1, ttl=kwargs.get("ttl", 3600 * 6)) + dfw["dt"] = pd.to_datetime(dfw["dt"]) + sdt = kwargs.get("sdt", "20170101") + edt = pd.Timestamp.now().strftime("%Y%m%d") + edt = kwargs.get("edt", edt) + dfw = dfw[(dfw["dt"] >= pd.to_datetime(sdt)) & (dfw["dt"] <= pd.to_datetime(edt))].copy().reset_index(drop=True) + + dfb = stocks_daily_klines(sdt=sdt, edt=edt, nxb=(1, 2)) + dfw = pd.merge(dfw, dfb, on=["dt", "symbol"], how="left") + dfh = dfw[["dt", "symbol", "weight", "n1b"]].copy() + return dfh diff --git a/czsc/eda.py b/czsc/eda.py index c1488b4b3..bd62e54bc 100644 --- a/czsc/eda.py +++ b/czsc/eda.py @@ -5,7 +5,10 @@ create_dt: 2023/2/7 13:17 describe: 用于探索性分析的函数 """ +import loguru +import pandas as pd import numpy as np +from sklearn.linear_model import Ridge, LinearRegression, Lasso def vwap(price: np.array, volume: np.array, **kwargs) -> float: @@ -25,3 +28,92 @@ def twap(price: np.array, **kwargs) -> float: :return: 平均价 """ return np.average(price) + + +def remove_beta_effects(df, **kwargs): + """去除 beta 对因子的影响 + + :param df: DataFrame, 数据, 必须包含 dt、symbol、factor 和 betas 列 + :param kwargs: + + - factor: str, 因子列名 + - betas: list, beta 列名列表 + - linear_model: str, 线性模型,可选 ridge、linear 或 lasso + + :return: DataFrame + """ + + linear_model = kwargs.get("linear_model", "ridge") + linear = { + "ridge": Ridge(), + "linear": LinearRegression(), + "lasso": Lasso(), + } + assert linear_model in linear.keys(), "linear_model 参数必须为 ridge、linear 或 lasso" + Model = linear[linear_model] + + factor = kwargs.get("factor") + betas = kwargs.get("betas") + logger = kwargs.get("logger", loguru.logger) + + assert factor is not None and betas is not None, "factor 和 betas 参数必须指定" + assert isinstance(betas, list), "betas 参数必须为列表" + assert factor in df.columns, f"数据中不包含因子 {factor}" + assert all([x in df.columns for x in betas]), f"数据中不包含全部 beta {betas}" + + logger.info(f"去除 beta 对因子 {factor} 的影响, 使用 {linear_model} 模型, betas: {betas}") + + rows = [] + for dt, dfg in df.groupby("dt"): + dfg = dfg.copy().dropna(subset=[factor] + betas) + if dfg.empty: + continue + + x = dfg[betas].values + y = dfg[factor].values + model = Model().fit(x, y) + dfg[factor] = y - model.predict(x) + rows.append(dfg) + + dfr = pd.concat(rows, ignore_index=True) + return dfr + + +def cross_sectional_strategy(df, factor, **kwargs): + """根据截面因子值构建多空组合 + + :param df: pd.DataFrame, 包含因子列的数据, 必须包含 dt, symbol, factor 列 + :param factor: str, 因子列名称 + :param kwargs: + + - factor_direction: str, 因子方向,positive 或 negative + - long_num: int, 多头持仓数量 + - short_num: int, 空头持仓数量 + - logger: loguru.logger, 日志记录器 + + :return: pd.DataFrame, 包含 weight 列的数据 + """ + factor_direction = kwargs.get("factor_direction", "positive") + long_num = kwargs.get("long_num", 5) + short_num = kwargs.get("short_num", 5) + logger = kwargs.get("logger", loguru.logger) + + assert factor in df.columns, f"{factor} 不在 df 中" + assert factor_direction in ["positive", "negative"], f"factor_direction 参数错误" + + df = df.copy() + if factor_direction == "negative": + df[factor] = -df[factor] + + df['weight'] = 0 + for dt, dfg in df.groupby("dt"): + if len(dfg) < long_num + short_num: + logger.warning(f"{dt} 截面数据量过小,跳过;仅有 {len(dfg)} 条数据,需要 {long_num + short_num} 条数据") + continue + + dfa = dfg.sort_values(factor, ascending=False).head(long_num) + dfb = dfg.sort_values(factor, ascending=True).head(short_num) + df.loc[dfa.index, "weight"] = 1 / long_num + df.loc[dfb.index, "weight"] = -1 / short_num + + return df diff --git a/czsc/fsa/base.py b/czsc/fsa/base.py index 80b69f007..d1dc1dda5 100644 --- a/czsc/fsa/base.py +++ b/czsc/fsa/base.py @@ -14,6 +14,7 @@ """ import os import time +import loguru import requests from loguru import logger from tenacity import retry, stop_after_attempt, wait_random @@ -58,12 +59,13 @@ def request(method, url, headers, payload=None) -> dict: class FeishuApiBase: - def __init__(self, app_id, app_secret): + def __init__(self, app_id, app_secret, **kwargs): self.app_id = app_id self.app_secret = app_secret self.host = "https://open.feishu.cn" self.headers = {"Content-Type": "application/json"} self.cache = dict() + self.logger = kwargs.get("logger", loguru.logger) def get_access_token(self, key="app_access_token"): assert key in ["app_access_token", "tenant_access_token"] diff --git a/czsc/fsa/bi_table.py b/czsc/fsa/bi_table.py index 819b93567..8a16e506c 100644 --- a/czsc/fsa/bi_table.py +++ b/czsc/fsa/bi_table.py @@ -6,6 +6,7 @@ describe: 飞书多维表格接口 """ import os +import loguru import pandas as pd from czsc.fsa.base import FeishuApiBase, request @@ -15,7 +16,7 @@ class BiTable(FeishuApiBase): 多维表格概述: https://open.feishu.cn/document/server-docs/docs/bitable-v1/bitable-overview """ - def __init__(self, app_id=None, app_secret=None, app_token=None): + def __init__(self, app_id=None, app_secret=None, app_token=None, **kwargs): """ :param app_id: 飞书应用的唯一标识 @@ -24,8 +25,9 @@ def __init__(self, app_id=None, app_secret=None, app_token=None): """ app_id = app_id or os.getenv("FEISHU_APP_ID") app_secret = app_secret or os.getenv("FEISHU_APP_SECRET") - super().__init__(app_id, app_secret) + super().__init__(app_id, app_secret, **kwargs) self.app_token = app_token + # self.logger = kwargs.get("logger", loguru.logger) def one_record(self, table_id, record_id): """根据 record_id 的值检索现有记录 @@ -245,14 +247,12 @@ def table_record_create(self, table_id, fields, user_id_type=None, client_token= """数据表中新增一条记录 https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create - :param table_id: table id + :param table_id: table id :param user_id_type: 非必需 用户 ID 类型 :param client_token: 非必需 格式为标准的 uuidv4,操作的唯一标识,用于幂等的进行更新操作。此值为空表示将发起一次新的请求,此值非空表示幂等的进行更新操作。 - :param fields: 必需 数据表的字段,即数据表的列。当前接口支持的字段类型为:多行文本、单选、条码、多选、日期、人员、附件、复选框、超链接、数字、单向关联、双向关联、电话号码、地理位置。详情参考 - :return: 返回数据 """ url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records?1=1" @@ -284,27 +284,25 @@ def table_record_delete(self, table_id, record_id): https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/delete :param table_id: table id :param record_id: 一条记录的唯一标识 id - :return: 返回数据 """ url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/{record_id}" return request("DELETE", url, self.get_headers()) - def table_record_batch_create(self, table_id, fields, user_id_type=None, client_token=None): + def table_record_batch_create(self, table_id, records, user_id_type=None, client_token=None): """在数据表中新增多条记录,单次调用最多新增 500 条记录。 https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/batch_create - :param table_id: table id + :param table_id: table id :param user_id_type: 非必需 用户 ID 类型 :param client_token: 非必需 格式为标准的 uuidv4,操作的唯一标识,用于幂等的进行更新操作。此值为空表示将发起一次新的请求,此值非空表示幂等的进行更新操作。 - - :param fields:[] 数据表的字段,即数据表的列当前接口支持的字段类型 示例值:{"多行文本":"HelloWorld"} + :param records:[] 数据表的字段,即数据表的列当前接口支持的字段类型 示例值:{"多行文本":"HelloWorld"} :return: 返回数据 """ - records = [] - for field in fields: - records.append({"fields": field}) + # records = [] + # for field in fields: + # records.append({"fields": field}) url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/batch_create?1=1" url = url if user_id_type is None else url + f"&user_id_type={user_id_type}" url = url if client_token is None else url + f"&client_token={client_token}" @@ -734,3 +732,25 @@ def read_table(self, table_id, **kwargs): assert len(rows) == total, "数据读取异常" return pd.DataFrame([x["fields"] for x in rows]) + + def empty_table(self, table_id, **kwargs): + """清空多维表格中指定表格的数据,保留表头 + + :param table_id: 表格id + :return: + """ + res = self.list_records(table_id, **kwargs)["data"] + self.logger.info(f"{table_id} 表格中共有 {res['total']} 条数据") + records = res["items"] + if records: + record_ids = [x["record_id"] for x in records] + self.table_record_batch_delete(table_id, record_ids) + self.logger.info(f"{table_id} 删除 {len(record_ids)} 条数据") + + while res["has_more"]: + res = self.list_records(table_id, page_token=res["page_token"], **kwargs)["data"] + records = res["items"] + if records: + record_ids = [x["record_id"] for x in records] + self.table_record_batch_delete(table_id, record_ids) + self.logger.info(f"{table_id} 删除 {len(record_ids)} 条数据") diff --git a/czsc/fsa/im.py b/czsc/fsa/im.py index eafe7ff31..3c6cdcb50 100644 --- a/czsc/fsa/im.py +++ b/czsc/fsa/im.py @@ -14,8 +14,8 @@ class IM(FeishuApiBase): """即时消息发送""" - def __init__(self, app_id, app_secret): - super().__init__(app_id, app_secret) + def __init__(self, app_id, app_secret, **kwargs): + super().__init__(app_id, app_secret, **kwargs) def get_user_id(self, payload, user_id_type="open_id"): """获取用户ID diff --git a/czsc/fsa/spreed_sheets.py b/czsc/fsa/spreed_sheets.py index d053b4ddb..71215f054 100644 --- a/czsc/fsa/spreed_sheets.py +++ b/czsc/fsa/spreed_sheets.py @@ -16,8 +16,8 @@ class SpreadSheets(FeishuApiBase): 电子表格概述: https://open.feishu.cn/document/ukTMukTMukTM/uATMzUjLwEzM14CMxMTN/overview """ - def __init__(self, app_id, app_secret): - super().__init__(app_id, app_secret) + def __init__(self, app_id, app_secret, **kwargs): + super().__init__(app_id, app_secret, **kwargs) def create(self, folder_token, title): """创建电子表格 @@ -65,7 +65,7 @@ def check(self, token): } """ url = f"{self.host}/open-apis/sheets/v3/spreadsheets/{token}" - return request('GET', url, self.get_headers()) + return request("GET", url, self.get_headers()) def get_sheets(self, token): """获取工作表 @@ -110,7 +110,7 @@ def get_sheet_meta(self, token, sheet_id): 'msg': ''} """ url = f"{self.host}/open-apis/sheets/v3/spreadsheets/{token}/sheets/{sheet_id}" - return request('GET', url, self.get_headers()) + return request("GET", url, self.get_headers()) def update_values(self, token, data): """向多个范围写入数据 @@ -170,7 +170,7 @@ def delete_values(self, token, sheet_id): :return: """ url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{token}/dimension_range" - row_count = self.get_sheet_meta(token, sheet_id)['data']['sheet']['grid_properties']['row_count'] - 1 + row_count = self.get_sheet_meta(token, sheet_id)["data"]["sheet"]["grid_properties"]["row_count"] - 1 while row_count > 1: data = { "dimension": { @@ -180,10 +180,10 @@ def delete_values(self, token, sheet_id): "endIndex": min(4001, row_count), } } - request('DELETE', url, self.get_headers(), data) - row_count = self.get_sheet_meta(token, sheet_id)['data']['sheet']['grid_properties']['row_count'] - 1 + request("DELETE", url, self.get_headers(), data) + row_count = self.get_sheet_meta(token, sheet_id)["data"]["sheet"]["grid_properties"]["row_count"] - 1 - col_count = self.get_sheet_meta(token, sheet_id)['data']['sheet']['grid_properties']['column_count'] - 1 + col_count = self.get_sheet_meta(token, sheet_id)["data"]["sheet"]["grid_properties"]["column_count"] - 1 if col_count > 1: data = { "dimension": { @@ -193,7 +193,7 @@ def delete_values(self, token, sheet_id): "endIndex": min(4001, col_count), } } - request('DELETE', url, self.get_headers(), data) + request("DELETE", url, self.get_headers(), data) def dimension_range(self, token, data): """增加行列 @@ -212,7 +212,7 @@ def dimension_range(self, token, data): :return: """ url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{token}/dimension_range" - return request('POST', url, self.get_headers(), data) + return request("POST", url, self.get_headers(), data) def update_sheets(self, token, operates): """增加工作表,复制工作表、删除工作表 @@ -286,23 +286,23 @@ def append(self, token, sheet_id, df: pd.DataFrame, batch_size=2000, overwrite=T self.delete_values(token, sheet_id) cols = df.columns.tolist() col_range = f"{sheet_id}!A1:{string.ascii_uppercase[len(cols) - 1]}1" - self.update_values(token, {'valueRanges': [{"range": col_range, "values": [cols]}]}) + self.update_values(token, {"valueRanges": [{"range": col_range, "values": [cols]}]}) # 读取表格列名,确保 df 列名与表格列名一致 sheet_cols = self.get_sheet_cols(token, sheet_id) df = df[sheet_cols] meta = self.get_sheet_meta(token, sheet_id) - start_index = meta['data']['sheet']['grid_properties']['row_count'] - col_count = meta['data']['sheet']['grid_properties']['column_count'] + start_index = meta["data"]["sheet"]["grid_properties"]["row_count"] + col_count = meta["data"]["sheet"]["grid_properties"]["column_count"] assert df.shape[1] == col_count, f"df 列数 {df.shape[1]} 与表格列数 {col_count} 不一致" for i in range(0, len(df), batch_size): - dfi = df.iloc[i: i + batch_size] + dfi = df.iloc[i : i + batch_size] si = i + start_index + 1 ei = si + batch_size vol_range = f"{sheet_id}!A{si}:{string.ascii_uppercase[col_count - 1]}{ei}" - self.update_values(token, {'valueRanges': [{"range": vol_range, "values": dfi.values.tolist()}]}) + self.update_values(token, {"valueRanges": [{"range": vol_range, "values": dfi.values.tolist()}]}) def get_sheet_cols(self, token, sheet_id, n=1): """读取表格列名 @@ -313,9 +313,9 @@ def get_sheet_cols(self, token, sheet_id, n=1): :return: 列名列表 """ meta = self.get_sheet_meta(token, sheet_id) - col_count = meta['data']['sheet']['grid_properties']['column_count'] + col_count = meta["data"]["sheet"]["grid_properties"]["column_count"] res = self.read_sheet(token, f"{sheet_id}!A{n}:{string.ascii_uppercase[col_count - 1]}{n}") - values = res['data']['valueRange']['values'] + values = res["data"]["valueRange"]["values"] cols = values.pop(0) return cols @@ -327,7 +327,7 @@ def read_table(self, token, sheet_id): :return: """ res = self.read_sheet(token, sheet_id) - values = res['data']['valueRange']['values'] + values = res["data"]["valueRange"]["values"] cols = values.pop(0) return pd.DataFrame(values, columns=cols) @@ -347,7 +347,7 @@ def set_condition_formats(self, token, data): url = f"{self.host}/open-apis/sheets/v2/spreadsheets/{token}/condition_formats/batch_create" return request("POST", url, self.get_headers(), data) - def set_styles_batch(self, token,data): + def set_styles_batch(self, token, data): """批量设置表格普通样式 https://open.feishu.cn/document/server-docs/docs/sheets-v3/data-operation/batch-set-cell-style """ @@ -403,4 +403,3 @@ def single_delete_values(self): 删除电子表格的所有数据 """ super().delete_values(self.token, self.sheet_id) - diff --git a/czsc/signals/pos.py b/czsc/signals/pos.py index 5d0b954ac..6d7922575 100644 --- a/czsc/signals/pos.py +++ b/czsc/signals/pos.py @@ -959,8 +959,8 @@ def pos_stop_V240717(cat: CzscTrader, **kwargs) -> OrderedDict: **信号列表:** - - Signal('SMA5多头_15分钟N3T20_止损V240614_多头止损_任意_任意_0') - - Signal('SMA5空头_15分钟N3T20_止损V240614_空头止损_任意_任意_0') + - Signal('SMA5多头_15分钟N3T20_止损V240717_多头止损_任意_任意_0') + - Signal('SMA5空头_15分钟N3T20_止损V240717_空头止损_任意_任意_0') :param cat: CzscTrader对象 :param kwargs: 参数字典 diff --git a/czsc/traders/rwc.py b/czsc/traders/rwc.py index 4247256ce..daf5cda21 100644 --- a/czsc/traders/rwc.py +++ b/czsc/traders/rwc.py @@ -101,6 +101,7 @@ def set_metadata(self, base_freq, description, author, outsample_sdt, **kwargs): "kwargs": json.dumps(kwargs), } self.r.hset(key, mapping=meta) + self.r.sadd(f"{self.key_prefix}:StrategyNames", self.strategy_name) def update_last(self, **kwargs): """设置策略最近一次更新时间,以及更新参数【可选】""" @@ -111,6 +112,7 @@ def update_last(self, **kwargs): "kwargs": json.dumps(kwargs), } self.r.hset(key, mapping=last) + logger.info(f"更新 {key} 的 last 时间") @property @@ -592,3 +594,23 @@ def get_heartbeat_time(strategy_name=None, redis_url=None, connection_pool=None, logger.warning(f"{sn} 没有心跳时间") r.close() return res + + +def get_strategy_names(redis_url=None, connection_pool=None, key_prefix="Weights"): + """获取所有策略名的列表. + + :param redis_url: str, redis连接字符串, 默认为None, 即从环境变量 RWC_REDIS_URL 中读取 + :param connection_pool: redis.ConnectionPool, redis连接池 + :param key_prefix: str, redis中key的前缀,默认为 Weights + + :return: list, 所有策略名 + """ + + if connection_pool: + r = redis.Redis(connection_pool=connection_pool) + else: + redis_url = redis_url if redis_url else os.getenv("RWC_REDIS_URL") + r = redis.Redis.from_url(redis_url, decode_responses=True) + + rs = r.smembers(f"{key_prefix}:StrategyNames") + return list(rs) diff --git a/czsc/traders/weight_backtest.py b/czsc/traders/weight_backtest.py index b14e3b00f..436cbffc0 100644 --- a/czsc/traders/weight_backtest.py +++ b/czsc/traders/weight_backtest.py @@ -283,9 +283,8 @@ def __init__(self, dfw, digits=2, **kwargs) -> None: self.fee_rate = kwargs.get("fee_rate", 0.0002) self.dfw["weight"] = self.dfw["weight"].astype("float").round(digits) self.symbols = list(self.dfw["symbol"].unique().tolist()) - default_n_jobs = min(cpu_count() // 2, len(self.symbols)) self._dailys = None - self.results = self.backtest(n_jobs=kwargs.get("n_jobs", default_n_jobs)) + self.results = self.backtest(n_jobs=kwargs.get("n_jobs", 1)) @property def stats(self): diff --git a/czsc/utils/corr.py b/czsc/utils/corr.py index e4d6d1212..3ddab4127 100644 --- a/czsc/utils/corr.py +++ b/czsc/utils/corr.py @@ -123,6 +123,10 @@ def cross_sectional_ic(df, x_col="open", y_col="n1b", method="spearman", **kwarg "IC绝对值>2%占比": 0, "累计IC回归R2": 0, "累计IC回归斜率": 0, + "月胜率": 0, + "月均值": 0, + "年胜率": 0, + "年均值": 0, } if df.empty: return df, res @@ -143,4 +147,15 @@ def cross_sectional_ic(df, x_col="open", y_col="n1b", method="spearman", **kwarg lr_ = single_linear(y=df["ic"].cumsum().to_list()) res.update({"累计IC回归R2": lr_["r2"], "累计IC回归斜率": lr_["slope"]}) + + monthly_ic = df.groupby(df["dt"].dt.strftime("%Y年%m月"))["ic"].mean().to_dict() + monthly_win_rate = len([1 for x in monthly_ic.values() if np.sign(x) == np.sign(res["IC均值"])]) / len(monthly_ic) + res["月胜率"] = round(monthly_win_rate, 4) + res["月均值"] = round(np.mean(list(monthly_ic.values())), 4) + + yearly_ic = df.groupby(df["dt"].dt.strftime("%Y年"))["ic"].mean().to_dict() + yearly_win_rate = len([1 for x in yearly_ic.values() if np.sign(x) == np.sign(res["IC均值"])]) / len(yearly_ic) + res["年胜率"] = round(yearly_win_rate, 4) + res["年均值"] = round(np.mean(list(yearly_ic.values())), 4) + return df, res diff --git a/czsc/utils/portfolio.py b/czsc/utils/portfolio.py new file mode 100644 index 000000000..83dad7727 --- /dev/null +++ b/czsc/utils/portfolio.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +""" +author: zengbin93 +email: zeng_bin8888@163.com +create_dt: 2024/7/26 15:46 +describe: 资产组合研究相关的工具函数 +""" +import loguru + + +def max_sharp(df, weight_bounds=(0, 1), **kwargs): + """最大夏普比例组合 + + 依赖 PyPortfolioOpt 库,需要安装 pip install PyPortfolioOpt + + :param df: pd.DataFrame, 包含多个资产的日收益率数据,index 为日期 + :param weight_bounds: tuple, 权重范围, 默认 (0, 1), 参考 EfficientFrontier 的 weight_bounds 参数 + 如果需要设置不同的权重范围,可以传入 dict,如 {"asset1": (0, 0.5), "asset2": (0.1, 0.5)} + :param kwargs: 其他参数 + + - logger: loguru.logger, 默认 None, 日志记录器 + - rounding: int, 默认 4, 权重四舍五入的小数位数 + + :return: dict, 资产权重 + """ + from pypfopt import EfficientFrontier + from pypfopt import expected_returns + from pypfopt import risk_models + from czsc.utils.stats import daily_performance + + logger = kwargs.get("logger", loguru.logger) + df = df.copy() + if "dt" in df.columns: + df = df.set_index("dt") + + mu = expected_returns.mean_historical_return(df, returns_data=True) + S = risk_models.risk_matrix(df, returns_data=True, method="sample_cov") + + if isinstance(weight_bounds, dict): + weight_bounds = [ + [weight_bounds.get(asset, (0, 1))[0] for asset in df.columns], + [weight_bounds.get(asset, (0, 1))[1] for asset in df.columns], + ] + + # Optimize for maximal Sharpe ratio + ef = EfficientFrontier(mu, S, weight_bounds=weight_bounds) + _ = ef.max_sharpe() + weights = ef.clean_weights(cutoff=1e-4, rounding=kwargs.get("rounding", 4)) + + logger.info(f"资产权重:{weights}") + + portfolio = df.apply(lambda x: x * weights[x.name], axis=0) + stats = daily_performance(portfolio.sum(axis=1).to_list()) + logger.info(f"组合表现:{stats}") + + return weights diff --git a/czsc/utils/st_components.py b/czsc/utils/st_components.py index 3301f0785..b3491c75d 100644 --- a/czsc/utils/st_components.py +++ b/czsc/utils/st_components.py @@ -408,6 +408,7 @@ def show_weight_backtest(dfw, **kwargs): - show_splited_daily: bool,是否展示分段日收益表现,默认为 False - show_yearly_stats: bool,是否展示年度绩效指标,默认为 False - show_monthly_return: bool,是否展示月度累计收益,默认为 False + - n_jobs: int, 并行计算的进程数,默认为 1 """ fee = kwargs.get("fee", 2) @@ -417,7 +418,7 @@ def show_weight_backtest(dfw, **kwargs): st.dataframe(dfw[dfw.isnull().sum(axis=1) > 0], use_container_width=True) st.stop() - wb = czsc.WeightBacktest(dfw, fee_rate=fee / 10000, digits=digits) + wb = czsc.WeightBacktest(dfw, fee_rate=fee / 10000, digits=digits, n_jobs=kwargs.get("n_jobs", 1)) stat = wb.results["绩效评价"] st.divider() @@ -1483,3 +1484,166 @@ def show_czsc_trader(trader: czsc.CzscTrader, max_k_num=300, **kwargs): st.divider() st.write(pos.name) st.json(pos.dump(with_data=False)) + + +def show_strategies_recent(df, **kwargs): + """展示最近 N 天的策略表现 + + :param df: pd.DataFrame, columns=['dt', 'strategy', 'returns'], 样例如下: + + =================== ========== ============ + dt strategy returns + =================== ========== ============ + 2021-01-04 00:00:00 STK001 -0.00240078 + 2021-01-05 00:00:00 STK001 -0.00107012 + 2021-01-06 00:00:00 STK001 0.00122168 + 2021-01-07 00:00:00 STK001 0.0020896 + 2021-01-08 00:00:00 STK001 0.000510725 + =================== ========== ============ + + :param kwargs: dict + + - nseq: tuple, optional, 默认为 (1, 3, 5, 10, 20, 30, 60, 90, 120, 180, 240, 360),展示的天数序列 + """ + nseq = kwargs.get("nseq", (1, 3, 5, 10, 20, 30, 60, 90, 120, 180, 240, 360)) + dfr = df.copy() + dfr = pd.pivot_table(dfr, index="dt", columns="strategy", values="returns", aggfunc="sum").fillna(0) + rows = [] + for n in nseq: + for k, v in dfr.iloc[-n:].sum(axis=0).to_dict().items(): + rows.append({"天数": f"近{n}天", "策略": k, "收益": v}) + + n_rets = pd.DataFrame(rows).pivot_table(index="策略", columns="天数", values="收益") + n_rets = n_rets[[f"近{x}天" for x in nseq]] + + st.dataframe( + n_rets.style.background_gradient(cmap="RdYlGn_r").format("{:.2%}", na_rep="-"), + use_container_width=True, + hide_index=False, + ) + + # 计算每个时间段的盈利策略数量 + win_count = n_rets.applymap(lambda x: 1 if x > 0 else 0).sum(axis=0) + win_rate = n_rets.applymap(lambda x: 1 if x > 0 else 0).sum(axis=0) / n_rets.shape[0] + dfs = pd.DataFrame({"盈利策略数量": win_count, "盈利策略比例": win_rate}).T + dfs = dfs.style.background_gradient(cmap="RdYlGn_r", axis=1).format("{:.4f}", na_rep="-") + st.dataframe(dfs, use_container_width=True) + st.caption(f"统计截止日期:{dfr.index[-1].strftime('%Y-%m-%d')};策略数量:{dfr.shape[1]}") + + +def show_factor_value(df, factor, **kwargs): + """展示因子值可视化 + + :param df: pd.DataFrame, columns=['dt', ‘open’, ‘close’, ‘high’, ‘low’, ‘vol’, factor] + :param factor: str, 因子名称 + :param kwargs: dict, 其他参数 + + - height: int, 可视化高度,默认为 600 + - row_heights: list, 默认为 [0.6, 0.1, 0.3] + - title: str, 默认为 f"{factor} 可视化" + + """ + if factor not in df.columns: + st.warning(f"因子 {factor} 不存在,请检查") + return + + height = kwargs.get("height", 600) + row_heights = kwargs.get("row_heights", [0.6, 0.1, 0.3]) + title = kwargs.get("title", f"{factor} 可视化") + + chart = czsc.KlineChart(n_rows=3, height=height, row_heights=row_heights, title=title) + chart.add_kline(df) + chart.add_sma(df, visible=True, line_width=1) + chart.add_vol(df, row=2) + chart.add_scatter_indicator(df["dt"], df[factor], name=factor, row=3, line_width=1.5) + + plotly_config = { + "scrollZoom": True, + "displayModeBar": True, + "displaylogo": False, + "modeBarButtonsToRemove": [ + "toggleSpikelines", + "select2d", + "zoomIn2d", + "zoomOut2d", + "lasso2d", + "autoScale2d", + "hoverClosestCartesian", + "hoverCompareCartesian", + ], + } + st.plotly_chart(chart.fig, use_container_width=True, config=plotly_config) + + +def show_code_editor(default: str = None, **kwargs): + """用户自定义 Python 代码编辑器 + + :param default: str, 默认代码 + :param kwargs: dict, 额外参数 + + - language: str, 编辑器语言,默认为 "python" + - use_expander: bool, 是否使用折叠面板,默认为 True + - expander_title: str, 折叠面板标题,默认为 "PYTHON代码编辑" + - exec: bool, 是否执行代码,默认为 True + - theme: str, 编辑器主题,默认为 "gruvbox" + - keybinding: str, 编辑器快捷键,默认为 "vscode" + + :return: tuple + - code: str, 编辑器中的代码 + - namespace: dict, 执行代码后的命名空间 + """ + if default is None: + default = """ +# 代码示例 +import czsc +import pandas as pd +import numpy as np +""" + try: + from streamlit_ace import st_ace, THEMES, KEYBINDINGS, LANGUAGES + except ImportError: + st.error("请先安装 streamlit-ace 库,执行命令:pip install streamlit-ace") + return + + default_language = kwargs.get("language", "python") + default_theme = kwargs.get("theme", "gruvbox") + default_keybinding = kwargs.get("keybinding", "vscode") + + def __editor(): + c1, c2 = st.columns([10, 1]) + with c2: + language = c2.selectbox("语言", LANGUAGES, index=LANGUAGES.index(default_language)) + height = c2.number_input("编辑器高度", value=550, min_value=100, max_value=2000, step=50) + font_size = c2.number_input("字体大小", value=16, min_value=8, max_value=32) + theme = c2.selectbox("主题", THEMES, index=THEMES.index(default_theme)) + keybinding = c2.selectbox("快捷键", KEYBINDINGS, index=KEYBINDINGS.index(default_keybinding)) + wrap = c2.checkbox("自动换行", value=True) + show_gutter = c2.checkbox("显示行号", value=True) + readonly = c2.checkbox("只读模式", value=False) + + with c1: + _code = st_ace( + language=language, + value=default, + height=height, + font_size=font_size, + theme=theme, + show_gutter=show_gutter, + keybinding=keybinding, + markers=None, + tab_size=4, + wrap=wrap, + show_print_margin=False, + readonly=readonly, + key="python_editor", + ) + return _code + + use_expander = kwargs.get("use_expander", True) + expander_title = kwargs.get("expander_title", "代码编辑器") + if not use_expander: + code = __editor() + else: + with st.expander(expander_title, expanded=True): + code = __editor() + return code diff --git a/czsc/utils/stats.py b/czsc/utils/stats.py index 61f5d593e..5943407ea 100644 --- a/czsc/utils/stats.py +++ b/czsc/utils/stats.py @@ -69,7 +69,7 @@ def daily_performance(daily_returns, **kwargs): 函数计算逻辑: 1. 首先,将传入的日收益率数据转换为NumPy数组,并指定数据类型为float64。 - 2. 然后,进行一系列判断:如果日收益率数据为空或标准差为零或全部为零,则返回一个字典,其中所有指标的值都为零。 + 2. 然后,进行一系列判断:如果日收益率数据为空或标准差为零或全部为零,则返回字典,其中所有指标的值都为零。 3. 如果日收益率数据满足要求,则进行具体的指标计算: - 年化收益率 = 日收益率列表的和 / 日收益率列表的长度 * 252 diff --git "a/examples/Streamlit\347\273\204\344\273\266\345\272\223\344\275\277\347\224\250\346\241\210\344\276\213/weight_backtest.py" "b/examples/Streamlit\347\273\204\344\273\266\345\272\223\344\275\277\347\224\250\346\241\210\344\276\213/weight_backtest.py" new file mode 100644 index 000000000..49f3b98df --- /dev/null +++ "b/examples/Streamlit\347\273\204\344\273\266\345\272\223\344\275\277\347\224\250\346\241\210\344\276\213/weight_backtest.py" @@ -0,0 +1,13 @@ +import czsc +import pandas as pd +import streamlit as st + +st.set_page_config(layout="wide") + +dfw = pd.read_feather(r"C:\Users\zengb\Downloads\ST组件样例数据\时序持仓权重样例数据.feather") + +if __name__ == "__main__": + st.subheader("时序持仓策略回测样例", anchor="时序持仓策略回测样例", divider="rainbow") + czsc.show_weight_backtest( + dfw, fee=2, digits=2, show_drawdowns=True, show_monthly_return=True, show_yearly_stats=True + ) diff --git "a/examples/streamlit\346\227\245\346\224\266\347\233\212\345\210\206\346\236\220\347\273\204\344\273\266\344\275\277\347\224\250\346\241\210\344\276\213.py" "b/examples/streamlit\346\227\245\346\224\266\347\233\212\345\210\206\346\236\220\347\273\204\344\273\266\344\275\277\347\224\250\346\241\210\344\276\213.py" index 598717d8a..b215d96af 100644 --- "a/examples/streamlit\346\227\245\346\224\266\347\233\212\345\210\206\346\236\220\347\273\204\344\273\266\344\275\277\347\224\250\346\241\210\344\276\213.py" +++ "b/examples/streamlit\346\227\245\346\224\266\347\233\212\345\210\206\346\236\220\347\273\204\344\273\266\344\275\277\347\224\250\346\241\210\344\276\213.py" @@ -1,4 +1,5 @@ import sys + sys.path.insert(0, r"A:\ZB\git_repo\waditu\czsc") import czsc import streamlit as st @@ -7,10 +8,9 @@ st.set_page_config(layout="wide") -df = pd.DataFrame({ - "dt": pd.date_range("20210101", periods=1000), - "ret": np.random.randn(1000) -}) +df = pd.DataFrame( + {"dt": pd.date_range("20210101", periods=1000), "ret": [np.random.uniform(-0.1, 0.1) for _ in range(1000)]} +) czsc.show_daily_return(df)