From 3681728cba61405748908e8b86825c5e497b540d Mon Sep 17 00:00:00 2001 From: zengbin93 Date: Sat, 29 Jun 2024 12:16:20 +0800 Subject: [PATCH] =?UTF-8?q?V0.9.54=20=E6=9B=B4=E6=96=B0=E4=B8=80=E6=89=B9?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20(#201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 0.9.54 start coding * 0.9.54 tushare api 缓存案例 * 0.9.54 新增 bar_decision_V240616 信号 * 0.9.54 新增 bar_td9_V240616 信号 * 0.9.54 新增 bar_td9_V240616 信号 * 0.9.54 新增 bar_td9_V240616 信号 * 0.9.54 新增 cci_decision_V240620 信号 * 0.9.54 新增 show_czsc_trader * 0.9.54 新增信号 * 0.9.54 新增信号 * 0.9.54 update * 0.9.54 增加超额分析 * 0.9.54 QMT 增加自动登录函数 * 0.9.54 update --- .github/workflows/pythonpackage.yml | 2 +- czsc/__init__.py | 5 +- czsc/connectors/qmt_connector.py | 669 ++++++++++++------ czsc/connectors/tq_connector.py | 2 + czsc/fsa/bi_table.py | 20 +- czsc/signals/__init__.py | 4 + czsc/signals/bar.py | 150 ++++ czsc/signals/tas.py | 51 +- czsc/signals/xls.py | 71 +- czsc/traders/weight_backtest.py | 96 ++- czsc/utils/data_client.py | 27 +- czsc/utils/st_components.py | 121 +++- examples/signals_dev/fenlei.py | 2 +- .../{ => merged}/bar_classify_V240606.py | 0 .../{ => merged}/bar_classify_V240607.py | 0 .../{ => merged}/bar_decision_V240608.py | 0 .../signals_dev/merged/bar_td9_V240616.py | 93 +++ .../merged/cci_decision_V240620.py | 64 ++ .../{ => merged}/cxt_bs_V240526.py | 0 .../{ => merged}/cxt_bs_V240527.py | 0 .../{ => merged}/cxt_decision_V240526.py | 0 .../{ => merged}/cxt_decision_V240612.py | 0 .../{ => merged}/cxt_decision_V240613.py | 0 .../{ => merged}/cxt_decision_V240614.py | 0 .../{ => merged}/cxt_overlap_V240526.py | 0 .../{ => merged}/cxt_overlap_V240612.py | 0 examples/signals_dev/signal_match.py | 2 +- examples/test_offline/test_weight_backtest.py | 15 +- examples/tushare_data_client.py | 24 + 29 files changed, 1125 insertions(+), 293 deletions(-) rename examples/signals_dev/{ => merged}/bar_classify_V240606.py (100%) rename examples/signals_dev/{ => merged}/bar_classify_V240607.py (100%) rename examples/signals_dev/{ => merged}/bar_decision_V240608.py (100%) create mode 100644 examples/signals_dev/merged/bar_td9_V240616.py create mode 100644 examples/signals_dev/merged/cci_decision_V240620.py rename examples/signals_dev/{ => merged}/cxt_bs_V240526.py (100%) rename examples/signals_dev/{ => merged}/cxt_bs_V240527.py (100%) rename examples/signals_dev/{ => merged}/cxt_decision_V240526.py (100%) rename examples/signals_dev/{ => merged}/cxt_decision_V240612.py (100%) rename examples/signals_dev/{ => merged}/cxt_decision_V240613.py (100%) rename examples/signals_dev/{ => merged}/cxt_decision_V240614.py (100%) rename examples/signals_dev/{ => merged}/cxt_overlap_V240526.py (100%) rename examples/signals_dev/{ => merged}/cxt_overlap_V240612.py (100%) create mode 100644 examples/tushare_data_client.py diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6adeb5342..26aa810be 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -5,7 +5,7 @@ name: Python package on: push: - branches: [ master, V0.9.53 ] + branches: [ master, V0.9.54 ] pull_request: branches: [ master ] diff --git a/czsc/__init__.py b/czsc/__init__.py index 9e0bd53f0..066b7917f 100644 --- a/czsc/__init__.py +++ b/czsc/__init__.py @@ -146,6 +146,7 @@ show_holds_backtest, show_symbols_corr, show_feature_returns, + show_czsc_trader, ) from czsc.utils.bi_info import ( @@ -186,10 +187,10 @@ ) -__version__ = "0.9.53" +__version__ = "0.9.54" __author__ = "zengbin93" __email__ = "zeng_bin8888@163.com" -__date__ = "20240607" +__date__ = "20240616" def welcome(): diff --git a/czsc/connectors/qmt_connector.py b/czsc/connectors/qmt_connector.py index 582398cc6..482cba81e 100644 --- a/czsc/connectors/qmt_connector.py +++ b/czsc/connectors/qmt_connector.py @@ -4,11 +4,17 @@ email: zeng_bin8888@163.com create_dt: 2022/12/31 16:03 describe: QMT 量化交易平台接口 + +需要额外安装:pyautogui,xtquant + +xtquant 的下载地址:http://dict.thinktrader.net/nativeApi/download_xtquant.html?id=7zqjlm """ import os import time import random import czsc +import pyautogui +import subprocess import pandas as pd from typing import List from tqdm import tqdm @@ -26,6 +32,90 @@ dt_fmt = "%Y-%m-%d %H:%M:%S" +def find_exe_window(title): + """windows系统:根据 title 查找 window""" + windows = pyautogui.getWindowsWithTitle(title) + if len(windows) > 1: + logger.warning(f"找到多个 {title} 窗口,数量:{len(windows)};请检查是否有多个程序实例") + return None + + if len(windows) == 0: + return None + + return windows[0] + + +def close_exe_window(title): + """windows系统:关闭 exe 应用程序 + + :param title: 程序标题,支持模糊匹配,不需要完全匹配 + + 查看应用的标题(Title)通常指的是获取正在运行的应用程序窗口的标题栏上的文字。 + 在Windows操作系统中,你可以通过几种不同的方法来查看应用的标题: + + 1. 使用任务管理器 + 1. 按下 Ctrl + Shift + Esc 打开任务管理器。 + 2. 在“进程”标签页中,找到你想要查看标题的应用程序。 + 3. 在“应用程序”列表中,找到对应的应用程序,其标题通常显示在“名称”列中。 + + 2. 使用Alt+Tab快捷键 + 1. 按下 Alt + Tab 快捷键,可以快速切换到正在运行的应用程序。 + 2. 在弹出的窗口中,你可以看到每个应用程序的缩略图和标题。 + + 3. 使用任务栏 + 1. 将鼠标悬停在任务栏上的应用程序图标上。 + 2. 通常,任务栏会显示该应用程序的标题。 + """ + window = find_exe_window(title) + if window: + window.activate() + window.close() + pyautogui.press("enter") + else: + logger.error(f"没有找到 {title} 程序") + + +def start_qmt_exe(acc, pwd, qmt_exe, title, max_retry=6, **kwargs): + """windows系统:启动 QMT,并登录 + + :param acc: QMT 账号 + :param pwd: QMT 密码 + :param qmt_exe: QMT 应用路径,如 D:\\国金证券QMT交易端\\bin.x64\\XtItClient.exe + :param title: QMT 窗口标题,如 国金证券QMT交易端 + :param max_retry: 最大重试次数 + """ + wait_seconds = kwargs.get("wait_seconds", 6) + i = 0 + while not find_exe_window(acc): + if i > max_retry: + logger.warning(f"QMT连续{i}次尝试依旧无法启动,请人工检查!") + break + + close_exe_window(title) + + i += 1 + try: + subprocess.Popen(qmt_exe) + time.sleep(wait_seconds) + + qmt_windows = find_exe_window(title) + if not qmt_windows: + continue + + qmt_windows.activate() + pyautogui.typewrite(acc) + pyautogui.press("tab") + pyautogui.typewrite(pwd) + pyautogui.press("enter") + qmt_windows.activate() + time.sleep(wait_seconds) + + except Exception as e: + logger.exception(f"启动QMT失败:{e}") + + logger.info(f"启动 QMT 成功,账号:{acc}") + + def format_stock_kline(kline: pd.DataFrame, freq: Freq) -> List[RawBar]: """QMT A股市场K线数据转换 @@ -46,22 +136,29 @@ def format_stock_kline(kline: pd.DataFrame, freq: Freq) -> List[RawBar]: :return: 转换好的K线数据 """ bars = [] - dt_key = 'time' + dt_key = "time" kline = kline.sort_values(dt_key, ascending=True, ignore_index=True) - records = kline.to_dict('records') + records = kline.to_dict("records") for i, record in enumerate(records): # 将每一根K线转换成 RawBar 对象 - bar = RawBar(symbol=record['symbol'], dt=pd.to_datetime(record[dt_key]), id=i, freq=freq, - open=record['open'], close=record['close'], high=record['high'], low=record['low'], - vol=record['volume'] * 100 if record['volume'] else 0, # 成交量,单位:股 - amount=record['amount'] if record['amount'] > 0 else 0, # 成交额,单位:元 - ) + bar = RawBar( + symbol=record["symbol"], + dt=pd.to_datetime(record[dt_key]), + id=i, + freq=freq, + open=record["open"], + close=record["close"], + high=record["high"], + low=record["low"], + vol=record["volume"] * 100 if record["volume"] else 0, # 成交量,单位:股 + amount=record["amount"] if record["amount"] > 0 else 0, # 成交额,单位:元 + ) bars.append(bar) return bars -def get_kline(symbol, period, start_time, end_time, count=-1, dividend_type='front_ratio', **kwargs): +def get_kline(symbol, period, start_time, end_time, count=-1, dividend_type="front_ratio", **kwargs): """获取 QMT K线数据,实盘、回测通用 :param symbol: 股票代码 例如:300001.SZ @@ -85,24 +182,31 @@ def get_kline(symbol, period, start_time, end_time, count=-1, dividend_type='fro 3 000001.SZ 4 000001.SZ """ - start_time = pd.to_datetime(start_time).strftime('%Y%m%d%H%M%S') - if '1d' == period: - end_time = pd.to_datetime(end_time).replace(hour=15, minute=0).strftime('%Y%m%d%H%M%S') + start_time = pd.to_datetime(start_time).strftime("%Y%m%d%H%M%S") + if "1d" == period: + end_time = pd.to_datetime(end_time).replace(hour=15, minute=0).strftime("%Y%m%d%H%M%S") else: - end_time = pd.to_datetime(end_time).strftime('%Y%m%d%H%M%S') + end_time = pd.to_datetime(end_time).strftime("%Y%m%d%H%M%S") if kwargs.get("download_hist", True): xtdata.download_history_data(symbol, period=period, start_time=start_time, end_time=end_time) - field_list = ['time', 'open', 'high', 'low', 'close', 'volume', 'amount'] - data = xtdata.get_market_data(field_list, stock_list=[symbol], period=period, count=count, - dividend_type=dividend_type, start_time=start_time, - end_time=end_time, fill_data=kwargs.get("fill_data", False)) + field_list = ["time", "open", "high", "low", "close", "volume", "amount"] + data = xtdata.get_market_data( + field_list, + stock_list=[symbol], + period=period, + count=count, + dividend_type=dividend_type, + start_time=start_time, + end_time=end_time, + fill_data=kwargs.get("fill_data", False), + ) df = pd.DataFrame({key: value.values[0] for key, value in data.items()}) - df['time'] = pd.to_datetime(df['time'], unit='ms') + pd.to_timedelta('8H') + df["time"] = pd.to_datetime(df["time"], unit="ms") + pd.to_timedelta("8H") df.reset_index(inplace=True, drop=True) - df['symbol'] = symbol + df["symbol"] = symbol df = df.dropna() if kwargs.get("df", True): @@ -112,7 +216,7 @@ def get_kline(symbol, period, start_time, end_time, count=-1, dividend_type='fro return format_stock_kline(df, freq=freq_map[period]) -def get_raw_bars(symbol, freq, sdt, edt, fq='前复权', **kwargs) -> List[RawBar]: +def get_raw_bars(symbol, freq, sdt, edt, fq="前复权", **kwargs) -> List[RawBar]: """获取 CZSC 库定义的标准 RawBar 对象列表 :param symbol: 标的代码 @@ -125,27 +229,28 @@ def get_raw_bars(symbol, freq, sdt, edt, fq='前复权', **kwargs) -> List[RawBa """ freq = Freq(freq) if freq == Freq.F1: - period = '1m' + period = "1m" elif freq in [Freq.F5, Freq.F15, Freq.F30, Freq.F60]: - period = '5m' + period = "5m" else: - period = '1d' + period = "1d" - if fq == '前复权': - dividend_type = 'front_ratio' - elif fq == '后复权': - dividend_type = 'back_ratio' + if fq == "前复权": + dividend_type = "front_ratio" + elif fq == "后复权": + dividend_type = "back_ratio" else: - assert fq == '不复权' - dividend_type = 'none' + assert fq == "不复权" + dividend_type = "none" - kline = get_kline(symbol, period, sdt, edt, dividend_type=dividend_type, - download_hist=kwargs.get("download_hist", True), df=True) + kline = get_kline( + symbol, period, sdt, edt, dividend_type=dividend_type, download_hist=kwargs.get("download_hist", True), df=True + ) if kline.empty: return [] - kline['dt'] = pd.to_datetime(kline['time']) - kline['vol'] = kline['volume'] + kline["dt"] = pd.to_datetime(kline["time"]) + kline["vol"] = kline["volume"] bars = resample_bars(kline, freq, raw_bars=True) return bars @@ -156,21 +261,48 @@ def get_symbols(step): :param step: 投研阶段 :return: 标的列表 """ - stocks = xtdata.get_stock_list_in_sector('沪深A股') + stocks = xtdata.get_stock_list_in_sector("沪深A股") stocks_map = { - "index": ['000905.SH', '000016.SH', '000300.SH', '000001.SH', '000852.SH', - '399001.SZ', '399006.SZ', '399376.SZ', '399377.SZ', '399317.SZ', '399303.SZ'], + "index": [ + "000905.SH", + "000016.SH", + "000300.SH", + "000001.SH", + "000852.SH", + "399001.SZ", + "399006.SZ", + "399376.SZ", + "399377.SZ", + "399317.SZ", + "399303.SZ", + ], "stock": stocks, - "check": ['000001.SZ'], + "check": ["000001.SZ"], "train": stocks[:200], "valid": stocks[200:600], - "etfs": ['512880.SH', '518880.SH', '515880.SH', '513050.SH', '512690.SH', - '512660.SH', '512400.SH', '512010.SH', '512000.SH', '510900.SH', - '510300.SH', '510500.SH', '510050.SH', '159992.SZ', '159985.SZ', - '159981.SZ', '159949.SZ', '159915.SZ'], + "etfs": [ + "512880.SH", + "518880.SH", + "515880.SH", + "513050.SH", + "512690.SH", + "512660.SH", + "512400.SH", + "512010.SH", + "512000.SH", + "510900.SH", + "510300.SH", + "510500.SH", + "510050.SH", + "159992.SZ", + "159985.SZ", + "159981.SZ", + "159949.SZ", + "159915.SZ", + ], } - if step.upper() == 'ALL': - return stocks_map['index'] + stocks_map['stock'] + stocks_map['etfs'] + if step.upper() == "ALL": + return stocks_map["index"] + stocks_map["stock"] + stocks_map["etfs"] return stocks_map[step] @@ -186,8 +318,8 @@ def is_trade_time(dt: datetime = datetime.now()): def is_trade_day(dt: datetime = datetime.now()): """判断指定日期是否是交易日""" - date = dt.strftime('%Y%m%d') - return True if xtdata.get_trading_dates('SH', date, date) else False + date = dt.strftime("%Y%m%d") + return True if xtdata.get_trading_dates("SH", date, date) else False class TraderCallback(XtQuantTraderCallback): @@ -197,32 +329,32 @@ def __init__(self, **kwargs): super(TraderCallback, self).__init__() self.kwargs = kwargs - if kwargs.get('feishu_app_id', None) and kwargs.get('feishu_app_secret', None): - self.im = IM(app_id=kwargs['feishu_app_id'], app_secret=kwargs['feishu_app_secret']) - self.members = kwargs['feishu_members'] + if kwargs.get("feishu_app_id", None) and kwargs.get("feishu_app_secret", None): + self.im = IM(app_id=kwargs["feishu_app_id"], app_secret=kwargs["feishu_app_secret"]) + self.members = kwargs["feishu_members"] else: self.im = None self.members = None # 推送模式:detail-详细模式,summary-汇总模式 - self.feishu_push_mode = kwargs.get('feishu_push_mode', 'detail') + self.feishu_push_mode = kwargs.get("feishu_push_mode", "detail") - file_log = kwargs.get('file_log', None) + file_log = kwargs.get("file_log", None) if file_log: - logger.add(file_log, rotation='1 day', encoding='utf-8', enqueue=True) + logger.add(file_log, rotation="1 day", encoding="utf-8", enqueue=True) self.file_log = file_log logger.info(f"TraderCallback init: {kwargs}") - def push_message(self, msg: str, msg_type='text'): + def push_message(self, msg: str, msg_type="text"): """批量推送消息""" if self.im and self.members: for member in self.members: try: - if msg_type == 'text': + if msg_type == "text": self.im.send_text(msg, member) - elif msg_type == 'image': + elif msg_type == "image": self.im.send_image(msg, member) - elif msg_type == 'file': + elif msg_type == "file": self.im.send_file(msg, member) else: logger.error(f"不支持的消息类型:{msg_type}") @@ -242,112 +374,132 @@ def on_stock_order(self, order): http://docs.thinktrader.net/pages/198696/#%E5%A7%94%E6%89%98xtorder http://docs.thinktrader.net/pages/198696/#%E5%A7%94%E6%89%98%E7%8A%B6%E6%80%81-order-status """ - order_status_map = {xtconstant.ORDER_UNREPORTED: '未报', xtconstant.ORDER_WAIT_REPORTING: '待报', - xtconstant.ORDER_REPORTED: '已报', xtconstant.ORDER_REPORTED_CANCEL: '已报待撤', - xtconstant.ORDER_PARTSUCC_CANCEL: '部成待撤', xtconstant.ORDER_PART_CANCEL: '部撤', - xtconstant.ORDER_CANCELED: '已撤', xtconstant.ORDER_PART_SUCC: '部成', - xtconstant.ORDER_SUCCEEDED: '已成', xtconstant.ORDER_JUNK: '废单', - xtconstant.ORDER_UNKNOWN: '未知', - } - - msg = f"委托回报通知:\n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户:{order.account_id}\n" \ - f"标的:{order.stock_code}\n" \ - f"方向:{'做多' if order.order_type == 23 else '平多'}\n" \ - f"数量:{int(order.order_volume)}\n" \ - f"价格:{order.price}\n" \ - f"状态:{order_status_map[order.order_status]}" + order_status_map = { + xtconstant.ORDER_UNREPORTED: "未报", + xtconstant.ORDER_WAIT_REPORTING: "待报", + xtconstant.ORDER_REPORTED: "已报", + xtconstant.ORDER_REPORTED_CANCEL: "已报待撤", + xtconstant.ORDER_PARTSUCC_CANCEL: "部成待撤", + xtconstant.ORDER_PART_CANCEL: "部撤", + xtconstant.ORDER_CANCELED: "已撤", + xtconstant.ORDER_PART_SUCC: "部成", + xtconstant.ORDER_SUCCEEDED: "已成", + xtconstant.ORDER_JUNK: "废单", + xtconstant.ORDER_UNKNOWN: "未知", + } + + msg = ( + f"委托回报通知:\n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户:{order.account_id}\n" + f"标的:{order.stock_code}\n" + f"方向:{'做多' if order.order_type == 23 else '平多'}\n" + f"数量:{int(order.order_volume)}\n" + f"价格:{order.price}\n" + f"状态:{order_status_map[order.order_status]}" + ) logger.info(f"on order callback: {msg}") - if self.feishu_push_mode == 'detail' and is_trade_time(): - self.push_message(msg, msg_type='text') + if self.feishu_push_mode == "detail" and is_trade_time(): + self.push_message(msg, msg_type="text") def on_stock_asset(self, asset): """资金变动推送 :param asset: XtAsset对象 """ - msg = f"资金变动通知: \n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户: {asset.account_id} \n" \ - f"可用资金:{asset.cash} \n" \ - f"总资产:{asset.total_asset}" + msg = ( + f"资金变动通知: \n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户: {asset.account_id} \n" + f"可用资金:{asset.cash} \n" + f"总资产:{asset.total_asset}" + ) logger.info(f"on asset callback: {msg}") - if self.feishu_push_mode == 'detail' and is_trade_time(): - self.push_message(msg, msg_type='text') + if self.feishu_push_mode == "detail" and is_trade_time(): + self.push_message(msg, msg_type="text") def on_stock_trade(self, trade): """成交变动推送 :param trade: XtTrade对象 """ - msg = f"成交变动通知:\n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户:{trade.account_id}\n" \ - f"标的:{trade.stock_code}\n" \ - f"方向:{'开多' if trade.order_type == 23 else '平多'}\n" \ - f"成交量:{int(trade.traded_volume)}\n" \ - f"成交价:{round(trade.traded_price, 2)}" + msg = ( + f"成交变动通知:\n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户:{trade.account_id}\n" + f"标的:{trade.stock_code}\n" + f"方向:{'开多' if trade.order_type == 23 else '平多'}\n" + f"成交量:{int(trade.traded_volume)}\n" + f"成交价:{round(trade.traded_price, 2)}" + ) logger.info(f"on trade callback: {msg}") - if self.feishu_push_mode == 'detail' and is_trade_time(): - self.push_message(msg, msg_type='text') + if self.feishu_push_mode == "detail" and is_trade_time(): + self.push_message(msg, msg_type="text") def on_stock_position(self, position): """持仓变动推送 :param position: XtPosition对象 """ - msg = f"持仓变动通知: \n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户:{position.account_id}\n" \ - f"标的:{position.stock_code}\n" \ - f"成交量:{position.volume}" + msg = ( + f"持仓变动通知: \n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户:{position.account_id}\n" + f"标的:{position.stock_code}\n" + f"成交量:{position.volume}" + ) logger.info(f"on position callback: {msg}") - if self.feishu_push_mode == 'detail' and is_trade_time(): - self.push_message(msg, msg_type='text') + if self.feishu_push_mode == "detail" and is_trade_time(): + self.push_message(msg, msg_type="text") def on_order_error(self, order_error): """委托失败推送 :param order_error:XtOrderError 对象 """ - msg = f"委托失败通知: \n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户:{order_error.account_id}\n" \ - f"订单编号:{order_error.order_id}\n" \ - f"错误编码:{order_error.error_id}\n" \ - f"失败原因:{order_error.error_msg}" + msg = ( + f"委托失败通知: \n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户:{order_error.account_id}\n" + f"订单编号:{order_error.order_id}\n" + f"错误编码:{order_error.error_id}\n" + f"失败原因:{order_error.error_msg}" + ) logger.info(f"on order_error callback: {msg}") if is_trade_time(): - self.push_message(msg, msg_type='text') + self.push_message(msg, msg_type="text") def on_cancel_error(self, cancel_error): """撤单失败推送 :param cancel_error: XtCancelError 对象 """ - msg = f"撤单失败通知: \n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户:{cancel_error.account_id}\n" \ - f"订单编号:{cancel_error.order_id}\n" \ - f"错误编码:{cancel_error.error_id}\n" \ - f"失败原因:{cancel_error.error_msg}" + msg = ( + f"撤单失败通知: \n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户:{cancel_error.account_id}\n" + f"订单编号:{cancel_error.order_id}\n" + f"错误编码:{cancel_error.error_id}\n" + f"失败原因:{cancel_error.error_msg}" + ) logger.info(f"on_cancel_error: {msg}") if is_trade_time(): - self.push_message(msg, msg_type='text') + self.push_message(msg, msg_type="text") def on_order_stock_async_response(self, response): """异步下单回报推送 :param response: XtOrderResponse 对象 """ - msg = f"异步下单回报推送: \n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户:{response.account_id}\n" \ - f"订单编号:{response.order_id}\n" \ - f"策略名称:{response.strategy_name}" + msg = ( + f"异步下单回报推送: \n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户:{response.account_id}\n" + f"订单编号:{response.order_id}\n" + f"策略名称:{response.strategy_name}" + ) if is_trade_time(): - self.push_message(msg, msg_type='text') + self.push_message(msg, msg_type="text") logger.info(f"on_order_stock_async_response: {msg}") def on_account_status(self, status): @@ -355,22 +507,28 @@ def on_account_status(self, status): :param status: XtAccountStatus 对象 """ - status_map = {xtconstant.ACCOUNT_STATUS_OK: '正常', - xtconstant.ACCOUNT_STATUS_WAITING_LOGIN: '连接中', - xtconstant.ACCOUNT_STATUSING: '登陆中', - xtconstant.ACCOUNT_STATUS_FAIL: '失败'} - msg = f"账户状态变化推送:\n{'*' * 31}\n" \ - f"时间:{datetime.now().strftime(dt_fmt)}\n" \ - f"账户ID:{status.account_id}\n" \ - f"账号类型:{'证券账户' if status.account_type == 2 else '其他'}\n" \ - f"账户状态:{status_map[status.status]}\n" - - logger.info(f"账户ID: {status.account_id} " - f"账号类型:{'证券账户' if status.account_type == 2 else '其他'} " - f"账户状态:{status_map[status.status]}") + status_map = { + xtconstant.ACCOUNT_STATUS_OK: "正常", + xtconstant.ACCOUNT_STATUS_WAITING_LOGIN: "连接中", + xtconstant.ACCOUNT_STATUSING: "登陆中", + xtconstant.ACCOUNT_STATUS_FAIL: "失败", + } + msg = ( + f"账户状态变化推送:\n{'*' * 31}\n" + f"时间:{datetime.now().strftime(dt_fmt)}\n" + f"账户ID:{status.account_id}\n" + f"账号类型:{'证券账户' if status.account_type == 2 else '其他'}\n" + f"账户状态:{status_map[status.status]}\n" + ) + + logger.info( + f"账户ID: {status.account_id} " + f"账号类型:{'证券账户' if status.account_type == 2 else '其他'} " + f"账户状态:{status_map[status.status]}" + ) if is_trade_time(): - self.push_message(msg, msg_type='text') + self.push_message(msg, msg_type="text") def query_stock_positions(xtt: XtQuantTrader, acc: StockAccount): @@ -390,9 +548,17 @@ def query_today_trades(xtt: XtQuantTrader, acc: StockAccount): http://docs.thinktrader.net/pages/198696/#%E6%88%90%E4%BA%A4xttrade """ trades = xtt.query_stock_trades(acc) - res = [{'品种': x.stock_code, '均价': x.traded_price, "方向": "买入" if x.order_type == 23 else "卖出", - '数量': x.traded_volume, '金额': x.traded_amount, - '时间': time.strftime("%H:%M:%S", time.localtime(x.traded_time))} for x in trades] + res = [ + { + "品种": x.stock_code, + "均价": x.traded_price, + "方向": "买入" if x.order_type == 23 else "卖出", + "数量": x.traded_volume, + "金额": x.traded_amount, + "时间": time.strftime("%H:%M:%S", time.localtime(x.traded_time)), + } + for x in trades + ] return res @@ -437,14 +603,14 @@ def is_allow_open(xtt: XtQuantTrader, acc: StockAccount, symbol, price, **kwargs :param price: 股票现价 :return: True 允许开仓,False 不允许开仓 """ - symbol_max_pos = kwargs.get('max_pos', 0) # 最大持仓数量 + symbol_max_pos = kwargs.get("max_pos", 0) # 最大持仓数量 # 如果 symbol_max_pos 为 0,不允许开仓 if symbol_max_pos <= 0: return False # 如果 symbol 在禁止交易的列表中,不允许开仓 - if symbol in kwargs.get('forbidden_symbols', []): + if symbol in kwargs.get("forbidden_symbols", []): return False # 如果 未成交的开仓委托单 存在,不允许开仓 @@ -472,7 +638,7 @@ def is_allow_exit(xtt: XtQuantTrader, acc: StockAccount, symbol, **kwargs): :return: True 允许开仓,False 不允许开仓 """ # symbol 在禁止交易的列表中,不允许平仓 - if symbol in kwargs.get('forbidden_symbols', []): + if symbol in kwargs.get("forbidden_symbols", []): return False # 没有持仓 或 可用数量为 0,不允许平仓 @@ -507,21 +673,23 @@ def send_stock_order(xtt: XtQuantTrader, acc: StockAccount, **kwargs): :return: 返回下单请求序号, 成功委托后的下单请求序号为大于0的正整数, 如果为-1表示委托失败 """ - stock_code = kwargs.get('stock_code') - order_type = kwargs.get('order_type') - order_volume = kwargs.get('order_volume') # 委托数量, 股票以'股'为单位, 债券以'张'为单位 - price_type = kwargs.get('price_type', 5) - price = kwargs.get('price', 0) - strategy_name = kwargs.get('strategy_name', "程序下单") - order_remark = kwargs.get('order_remark', "程序下单") + stock_code = kwargs.get("stock_code") + order_type = kwargs.get("order_type") + order_volume = kwargs.get("order_volume") # 委托数量, 股票以'股'为单位, 债券以'张'为单位 + price_type = kwargs.get("price_type", 5) + price = kwargs.get("price", 0) + strategy_name = kwargs.get("strategy_name", "程序下单") + order_remark = kwargs.get("order_remark", "程序下单") if not xtt.connected: xtt.start() xtt.connect() - order_volume = max(order_volume // 100 * 100, 0) # 股票市场只允许做多 100 的整数倍 + order_volume = max(order_volume // 100 * 100, 0) # 股票市场只允许做多 100 的整数倍 assert xtt.connected, "交易服务器连接断开" - _id = xtt.order_stock(acc, stock_code, order_type, int(order_volume), price_type, price, strategy_name, order_remark) + _id = xtt.order_stock( + acc, stock_code, order_type, int(order_volume), price_type, price, strategy_name, order_remark + ) return _id @@ -549,16 +717,17 @@ def order_stock_target(xtt: XtQuantTrader, acc: StockAccount, symbol, target, ** if current == target: return - price_type = kwargs.get('price_type', 5) - price = kwargs.get('price', 0) + price_type = kwargs.get("price_type", 5) + price = kwargs.get("price", 0) # 如果目标小于当前,平仓 if target < current: delta = min(current - target, pos.can_use_volume if pos else current) logger.info(f"{symbol}平仓,目标仓位:{target},当前仓位:{current},平仓数量:{delta}") if delta != 0: - send_stock_order(xtt, acc, stock_code=symbol, order_type=24, - order_volume=delta, price_type=price_type, price=price) + send_stock_order( + xtt, acc, stock_code=symbol, order_type=24, order_volume=delta, price_type=price_type, price=price + ) return # 如果目标大于当前,开仓 @@ -566,8 +735,9 @@ def order_stock_target(xtt: XtQuantTrader, acc: StockAccount, symbol, target, ** delta = target - current logger.info(f"{symbol}开仓,目标仓位:{target},当前仓位:{current},开仓数量:{delta}") if delta != 0: - send_stock_order(xtt, acc, stock_code=symbol, order_type=23, - order_volume=delta, price_type=price_type, price=price) + send_stock_order( + xtt, acc, stock_code=symbol, order_type=23, order_volume=delta, price_type=price_type, price=price + ) return @@ -591,24 +761,24 @@ def __init__(self, mini_qmt_dir, account_id, **kwargs): :param kwargs: """ - self.cache_path = kwargs['cache_path'] # 交易缓存路径 + self.cache_path = kwargs["cache_path"] # 交易缓存路径 os.makedirs(self.cache_path, exist_ok=True) - self.symbols = kwargs.get('symbols', []) # 交易标的列表 - self.strategy = kwargs.get('strategy', []) # 交易策略 + self.symbols = kwargs.get("symbols", []) # 交易标的列表 + self.strategy = kwargs.get("strategy", []) # 交易策略 assert issubclass(self.strategy, czsc.CzscStrategyBase), "交易策略必须是CzscStrategyBase的子类" - self.symbol_max_pos = kwargs.get('symbol_max_pos', 0.5) # 每个标的最大持仓比例 - self.trade_sdt = kwargs.get('trade_sdt', '20220601') # 交易跟踪开始日期 + self.symbol_max_pos = kwargs.get("symbol_max_pos", 0.5) # 每个标的最大持仓比例 + self.trade_sdt = kwargs.get("trade_sdt", "20220601") # 交易跟踪开始日期 self.mini_qmt_dir = mini_qmt_dir self.account_id = account_id - self.base_freq = self.strategy(symbol='symbol').sorted_freqs[0] - self.delta_days = int(kwargs.get('delta_days', 1)) # 定时执行获取的K线天数 - self.forbidden_symbols = kwargs.get('forbidden_symbols', []) # 禁止交易的品种列表 + self.base_freq = self.strategy(symbol="symbol").sorted_freqs[0] + self.delta_days = int(kwargs.get("delta_days", 1)) # 定时执行获取的K线天数 + self.forbidden_symbols = kwargs.get("forbidden_symbols", []) # 禁止交易的品种列表 self.session = random.randint(10000, 20000) - self.callback = TraderCallback(**kwargs.get('callback_params', {})) + self.callback = TraderCallback(**kwargs.get("callback_params", {})) self.xtt = XtQuantTrader(mini_qmt_dir, session=self.session, callback=self.callback) - self.acc = StockAccount(account_id, 'STOCK') + self.acc = StockAccount(account_id, "STOCK") self.xtt.start() self.xtt.connect() assert self.xtt.connected, "交易服务器连接失败" @@ -629,8 +799,9 @@ def __create_traders(self, **kwargs): # 从缓存文件中恢复交易对象,并更新K线数据 trader: CzscTrader = czsc.dill_load(file_trader) kline_sdt = pd.to_datetime(trader.end_dt) - timedelta(days=self.delta_days) - bars = get_raw_bars(symbol, self.base_freq, kline_sdt, datetime.now(), fq="前复权", - download_hist=True) + bars = get_raw_bars( + symbol, self.base_freq, kline_sdt, datetime.now(), fq="前复权", download_hist=True + ) news = [x for x in bars if x.dt > trader.end_dt] if news: logger.info(f"{symbol} 需要更新的K线数量:{len(news)} | 最新的K线时间是 {news[-1].dt}") @@ -639,11 +810,11 @@ def __create_traders(self, **kwargs): else: # 从头创建交易对象 - bars = get_raw_bars(symbol, self.base_freq, '20180101', datetime.now(), fq="前复权") + bars = get_raw_bars(symbol, self.base_freq, "20180101", datetime.now(), fq="前复权") trader: CzscTrader = self.strategy(symbol=symbol).init_trader(bars, sdt=self.trade_sdt) czsc.dill_dump(trader, file_trader) - mean_pos = trader.get_ensemble_pos('mean') + mean_pos = trader.get_ensemble_pos("mean") if mean_pos == 0: continue @@ -651,7 +822,7 @@ def __create_traders(self, **kwargs): pos_info = {x.name: x.pos for x in trader.positions if x.pos != 0} logger.info(f"最新时间:{trader.end_dt};{symbol} trader pos:{pos_info} | mean_pos: {mean_pos}") except Exception as e: - logger.exception(f'创建交易对象失败,symbol={symbol}, e={e}') + logger.exception(f"创建交易对象失败,symbol={symbol}, e={e}") return traders @@ -673,10 +844,17 @@ def query_today_trades(self): """查询当日成交""" # http://docs.thinktrader.net/pages/198696/#%E6%88%90%E4%BA%A4xttrade trades = self.xtt.query_stock_trades(self.acc) - res = [{'品种': x.stock_code, '均价': x.traded_price, "方向": "买入" if x.order_type == 23 else "卖出", - '数量': x.traded_volume, '金额': x.traded_amount, - '时间': time.strftime("%H:%M:%S", time.localtime(x.traded_time))} - for x in trades] + res = [ + { + "品种": x.stock_code, + "均价": x.traded_price, + "方向": "买入" if x.order_type == 23 else "卖出", + "数量": x.traded_volume, + "金额": x.traded_amount, + "时间": time.strftime("%H:%M:%S", time.localtime(x.traded_time)), + } + for x in trades + ] return res def cancel_timeout_orders(self, minutes=30): @@ -787,13 +965,13 @@ def send_stock_order(self, **kwargs): :return: 返回下单请求序号, 成功委托后的下单请求序号为大于0的正整数, 如果为-1表示委托失败 """ - stock_code = kwargs.get('stock_code') - order_type = kwargs.get('order_type') - order_volume = kwargs.get('order_volume') # 委托数量, 股票以'股'为单位, 债券以'张'为单位 - price_type = kwargs.get('price_type', xtconstant.LATEST_PRICE) - price = kwargs.get('price', 0) - strategy_name = kwargs.get('strategy_name', "程序下单") - order_remark = kwargs.get('order_remark', "程序下单") + stock_code = kwargs.get("stock_code") + order_type = kwargs.get("order_type") + order_volume = kwargs.get("order_volume") # 委托数量, 股票以'股'为单位, 债券以'张'为单位 + price_type = kwargs.get("price_type", xtconstant.LATEST_PRICE) + price = kwargs.get("price", 0) + strategy_name = kwargs.get("strategy_name", "程序下单") + order_remark = kwargs.get("order_remark", "程序下单") if not self.xtt.connected: self.xtt.connect() @@ -803,8 +981,9 @@ def send_stock_order(self, **kwargs): order_volume = order_volume // 100 * 100 assert self.xtt.connected, "交易服务器连接断开" - _id = self.xtt.order_stock(self.acc, stock_code, order_type, int(order_volume), - price_type, price, strategy_name, order_remark) + _id = self.xtt.order_stock( + self.acc, stock_code, order_type, int(order_volume), price_type, price, strategy_name, order_remark + ) return _id def update_traders(self): @@ -824,24 +1003,28 @@ def update_traders(self): trader.on_bar(bar) # 根据策略的交易信号,下单【股票只有多头】,只有当信号变化时才下单 - if trader.get_ensemble_pos(method='vote') == 1 and trader.pos_changed \ - and self.is_allow_open(symbol, price=news[-1].close): + if ( + trader.get_ensemble_pos(method="vote") == 1 + and trader.pos_changed + and self.is_allow_open(symbol, price=news[-1].close) + ): assets = self.get_assets() order_volume = min(self.symbol_max_pos * assets.total_asset, assets.cash) // news[-1].close self.send_stock_order(stock_code=symbol, order_type=23, order_volume=order_volume) # 平多头 - if trader.get_ensemble_pos(method='vote') == 0 and self.is_allow_exit(symbol): + if trader.get_ensemble_pos(method="vote") == 0 and self.is_allow_exit(symbol): order_volume = holds[symbol].can_use_volume self.send_stock_order(stock_code=symbol, order_type=24, order_volume=order_volume) else: logger.info(f"{symbol} 没有需要更新的K线,最新的K线时间是 {trader.end_dt}") - if trader.get_ensemble_pos('mean') > 0: + if trader.get_ensemble_pos("mean") > 0: pos_info = {x.name: x.pos for x in trader.positions if x.pos != 0} logger.info( - f"{trader.end_dt} {symbol} trader pos:{pos_info} | ensemble_pos: {trader.get_ensemble_pos('mean')}") + f"{trader.end_dt} {symbol} trader pos:{pos_info} | ensemble_pos: {trader.get_ensemble_pos('mean')}" + ) # 更新交易对象 self.traders[symbol] = trader @@ -874,15 +1057,18 @@ def update_offline_traders(self): trader.on_bar(bar) # 根据策略的交易信号,下单【股票只有多头】,只有当信号变化时才下单 - if trader.get_ensemble_pos(method='vote') == 1 and trader.pos_changed \ - and self.is_allow_open(symbol, price=news[-1].close): + if ( + trader.get_ensemble_pos(method="vote") == 1 + and trader.pos_changed + and self.is_allow_open(symbol, price=news[-1].close) + ): assets = self.get_assets() order_volume = min(self.symbol_max_pos * assets.total_asset, assets.cash) // news[-1].close self.send_stock_order(stock_code=symbol, order_type=23, order_volume=order_volume) czsc.dill_dump(trader, file_trader) - mean_pos = trader.get_ensemble_pos('mean') + mean_pos = trader.get_ensemble_pos("mean") if mean_pos == 0: continue @@ -890,7 +1076,7 @@ def update_offline_traders(self): pos_info = {x.name: x.pos for x in trader.positions if x.pos != 0} logger.info(f"最新时间:{trader.end_dt};{symbol} trader pos:{pos_info} | mean_pos: {mean_pos}") except Exception as e: - logger.exception(f'创建交易对象失败,symbol={symbol}, e={e}') + logger.exception(f"创建交易对象失败,symbol={symbol}, e={e}") self.traders = traders @@ -902,22 +1088,33 @@ def report(self): writer.add_title("QMT 交易报告") assets = self.get_assets() - writer.add_heading('一、账户状态', level=1) - writer.add_paragraph(f"交易品种数量:{len(self.traders)}\n" - f"传入品种数量:{len(self.symbols)}\n" - f"交易账户:{self.account_id}\n" - f"账户资产:{assets.total_asset}\n" - f"可用资金:{assets.cash}\n" - f"持仓市值:{assets.market_value}\n" - f"持仓情况:", first_line_indent=0) + writer.add_heading("一、账户状态", level=1) + writer.add_paragraph( + f"交易品种数量:{len(self.traders)}\n" + f"传入品种数量:{len(self.symbols)}\n" + f"交易账户:{self.account_id}\n" + f"账户资产:{assets.total_asset}\n" + f"可用资金:{assets.cash}\n" + f"持仓市值:{assets.market_value}\n" + f"持仓情况:", + first_line_indent=0, + ) sp = self.query_stock_positions() if sp: _res_sp = [] for k, v in sp.items(): is_auto = "程序" if k in self.traders.keys() else "人工" - _res_sp.append({'品种': k, '持仓股数': v.volume, '可用股数': v.can_use_volume, - '成本': v.open_price, '市值': int(v.market_value), '操盘手': is_auto}) + _res_sp.append( + { + "品种": k, + "持仓股数": v.volume, + "可用股数": v.can_use_volume, + "成本": v.open_price, + "市值": int(v.market_value), + "操盘手": is_auto, + } + ) writer.add_df_table(pd.DataFrame(_res_sp)) else: writer.add_paragraph("当前没有持仓", first_line_indent=0) @@ -930,36 +1127,58 @@ def report(self): else: writer.add_paragraph("当日没有成交", first_line_indent=0) - writer.add_heading('二、策略状态', level=1) + writer.add_heading("二、策略状态", level=1) _res = [] for symbol, trader in self.traders.items(): - if trader.get_ensemble_pos('mean') > 0: + if trader.get_ensemble_pos("mean") > 0: _pos_str = "\n\n".join([f"{x.name}:{x.pos}" for x in trader.positions if x.pos != 0]) _ops = [x.operates[-1] for x in trader.positions if x.pos != 0] _ops_str = "\n\n".join([f"时间:{x['dt']}_价格:{x['price']}_描述:{x['op_desc']}" for x in _ops]) - _res.append({'symbol': symbol, 'pos': round(trader.get_ensemble_pos('mean'), 3), - 'positions': _pos_str, 'operates': _ops_str}) + _res.append( + { + "symbol": symbol, + "pos": round(trader.get_ensemble_pos("mean"), 3), + "positions": _pos_str, + "operates": _ops_str, + } + ) if _res: - writer.add_df_table(pd.DataFrame(_res).sort_values(by='pos', ascending=False)) + writer.add_df_table(pd.DataFrame(_res).sort_values(by="pos", ascending=False)) else: writer.add_paragraph("当前所有品种都是空仓") file_docx = f"QMT{self.account_id}_交易报告_{datetime.now().strftime('%Y%m%d_%H%M')}.docx" writer.save(file_docx) - self.callback.push_message(file_docx, msg_type='file') + self.callback.push_message(file_docx, msg_type="file") os.remove(file_docx) - def run(self, mode='30m', order_timeout=120): + def run(self, mode="30m", order_timeout=120): """运行策略""" self.report() - if mode.lower() == '15m': - _times = ["09:45", "10:00", "10:15", "10:30", "10:45", "11:00", "11:15", "11:30", - "13:15", "13:30", "13:45", "14:00", "14:15", "14:30", "14:45", "15:00"] - elif mode.lower() == '30m': + if mode.lower() == "15m": + _times = [ + "09:45", + "10:00", + "10:15", + "10:30", + "10:45", + "11:00", + "11:15", + "11:30", + "13:15", + "13:30", + "13:45", + "14:00", + "14:15", + "14:30", + "14:45", + "15:00", + ] + elif mode.lower() == "30m": _times = ["09:45", "10:00", "10:30", "11:00", "11:30", "13:30", "14:00", "14:30", "15:00"] - elif mode.lower() == '60m': + elif mode.lower() == "60m": _times = ["10:30", "11:30", "13:45", "14:30"] else: raise ValueError("mode 只能是 15m, 30m, 60m") @@ -981,7 +1200,7 @@ def run(self, mode='30m', order_timeout=120): self.xtt.start() if now_dt in ["11:35", "14:05", "15:05"]: - self.callback.push_message(f"{self.account_id} 开始更新离线交易员对象", msg_type='text') + self.callback.push_message(f"{self.account_id} 开始更新离线交易员对象", msg_type="text") self.update_offline_traders() self.report() @@ -990,32 +1209,36 @@ def run(self, mode='30m', order_timeout=120): # 以下是测试代码 # ====================================================================================================================== + def test_get_kline(): # 获取所有板块 slt = xtdata.get_sector_list() - stocks = xtdata.get_stock_list_in_sector('沪深A股') + stocks = xtdata.get_stock_list_in_sector("沪深A股") - df = get_kline(symbol='000001.SZ', period='1m', count=1000, dividend_type='front', - start_time='20200427', end_time='20221231') + df = get_kline( + symbol="000001.SZ", period="1m", count=1000, dividend_type="front", start_time="20200427", end_time="20221231" + ) assert not df.empty - df = get_kline(symbol='000001.SZ', period='5m', count=1000, dividend_type='front', - start_time='20200427', end_time='20221231') + df = get_kline( + symbol="000001.SZ", period="5m", count=1000, dividend_type="front", start_time="20200427", end_time="20221231" + ) assert not df.empty - df = get_kline(symbol='000001.SZ', period='1d', count=1000, dividend_type='front', - start_time='20200427', end_time='20221231') + df = get_kline( + symbol="000001.SZ", period="1d", count=1000, dividend_type="front", start_time="20200427", end_time="20221231" + ) assert not df.empty def test_get_symbols(): - symbols = get_symbols('index') + symbols = get_symbols("index") assert len(symbols) > 0 - symbols = get_symbols('stock') + symbols = get_symbols("stock") assert len(symbols) > 0 - symbols = get_symbols('check') + symbols = get_symbols("check") assert len(symbols) > 0 - symbols = get_symbols('train') + symbols = get_symbols("train") assert len(symbols) > 0 - symbols = get_symbols('valid') + symbols = get_symbols("valid") assert len(symbols) > 0 - symbols = get_symbols('etfs') + symbols = get_symbols("etfs") assert len(symbols) > 0 diff --git a/czsc/connectors/tq_connector.py b/czsc/connectors/tq_connector.py index b6b754631..fe2f744e1 100644 --- a/czsc/connectors/tq_connector.py +++ b/czsc/connectors/tq_connector.py @@ -189,6 +189,8 @@ def create_symbol_trader(api: TqApi, symbol, **kwargs): future_name_map = { + "EC": "欧线集运", + "LC": "碳酸锂", "PG": "LPG", "EB": "苯乙烯", "CS": "玉米淀粉", diff --git a/czsc/fsa/bi_table.py b/czsc/fsa/bi_table.py index ff792f7dc..80d5d89b1 100644 --- a/czsc/fsa/bi_table.py +++ b/czsc/fsa/bi_table.py @@ -22,7 +22,7 @@ def list_tables(self, app_token): https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/list - :param app_token: 应用token + :param app_token: 一个多维表格的唯一标识。示例值:"bascnKMKGS5oD3lmCHq9euO8cGh" :return: 返回数据 """ url = f"{self.host}/open-apis/bitable/v1/apps/{app_token}/tables" @@ -33,7 +33,7 @@ def list_records(self, app_token, table_id, **kwargs): https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/list - :param app_token: 应用token + :param app_token: 一个多维表格的唯一标识。示例值:"bascnKMKGS5oD3lmCHq9euO8cGh" :param table_id: 数据表id :return: 返回数据 """ @@ -51,17 +51,17 @@ def list_records(self, app_token, table_id, **kwargs): def read_table(self, app_token, table_id, **kwargs): """读取多维表格中指定表格的数据 - :param app_token: 多维表格应用token + :param app_token: 一个多维表格的唯一标识。示例值:"bascnKMKGS5oD3lmCHq9euO8cGh" :param table_id: 表格id :return: """ rows = [] - res = self.list_records(app_token, table_id, **kwargs)['data'] - total = res['total'] - rows.extend(res['items']) - while res['has_more']: - res = self.list_records(app_token, table_id, page_token=res['page_token'], **kwargs)['data'] - rows.extend(res['items']) + res = self.list_records(app_token, table_id, **kwargs)["data"] + total = res["total"] + rows.extend(res["items"]) + while res["has_more"]: + res = self.list_records(app_token, table_id, page_token=res["page_token"], **kwargs)["data"] + rows.extend(res["items"]) assert len(rows) == total, "数据读取异常" - return pd.DataFrame([x['fields'] for x in rows]) + return pd.DataFrame([x["fields"] for x in rows]) diff --git a/czsc/signals/__init__.py b/czsc/signals/__init__.py index 737d4733a..c01814e67 100644 --- a/czsc/signals/__init__.py +++ b/czsc/signals/__init__.py @@ -119,6 +119,8 @@ bar_classify_V240606, bar_classify_V240607, bar_decision_V240608, + bar_decision_V240616, + bar_td9_V240616, ) from czsc.signals.jcc import ( @@ -213,6 +215,7 @@ tas_dma_bs_V240608, tas_dif_zero_V240612, tas_dif_zero_V240614, + cci_decision_V240620, ) from czsc.signals.pos import ( @@ -287,4 +290,5 @@ xl_bar_trend_V240331, xl_bar_basis_V240411, xl_bar_basis_V240412, + xl_bar_trend_V240623, ) diff --git a/czsc/signals/bar.py b/czsc/signals/bar.py index a3c407fc5..692a8d699 100644 --- a/czsc/signals/bar.py +++ b/czsc/signals/bar.py @@ -2116,3 +2116,153 @@ def bar_decision_V240608(c: CZSC, **kwargs) -> OrderedDict: if vol_match and n_diff < 0: v1 = "看多" return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def bar_decision_V240616(c: CZSC, **kwargs) -> OrderedDict: + """新高/低后的弱/强势信号 + + 参数模板:"{freq}_W{w}N{n}强弱_决策区域V240616" + + **信号逻辑:** + + 1. 看多:最近 N 根K线出现新高,且后续出现低位收盘的弱势信号 + 2. 看空:最近 N 根K线出现新低,且后续出现高位收盘的强势信号 + + https://s0cqcxuy3p.feishu.cn/wiki/MT47wiaalilwnnkAGo5c04Sxnfd + + **信号列表:** + + - Signal('60分钟_W100N5强弱_决策区域V240616_看多_任意_任意_0') + - Signal('60分钟_W100N5强弱_决策区域V240616_看空_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: + + - w: int, default 300, 窗口大小 + - n: int, default 20, 最近N根K线 + + :return: 信号识别结果 + """ + w = int(kwargs.get("w", 100)) + n = int(kwargs.get("n", 5)) + assert w > n > 2, "参数 w 必须大于 n,且 n 必须大于 0" + + freq = c.freq.value + k1, k2, k3 = f"{freq}_W{w}N{n}强弱_决策区域V240616".split("_") + v1 = "其他" + + # 更新K线收盘位置信息 + cache_key = "bar_decision_V240616_close_position" + for bar in c.bars_raw: + if not hasattr(bar.cache, cache_key): + hl = bar.high - bar.low + t1 = bar.low + hl * (2 / 3) + t2 = bar.low + hl * (1 / 3) + if bar.close > t1: + bar.cache[cache_key] = "高位收盘" + elif t2 < bar.close < t1: + bar.cache[cache_key] = "中位收盘" + else: + bar.cache[cache_key] = "低位收盘" + + if len(c.bars_raw) < w + n + 10: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + w_bars = get_sub_elements(c.bars_raw, di=n, n=w) + w_bars_high = max([x.high for x in w_bars]) + w_bars_low = min([x.low for x in w_bars]) + + # K线平均长度 + hl_mean = np.mean([x.high - x.low for x in w_bars]) + n_bars = [x for x in get_sub_elements(c.bars_raw, di=1, n=n) if x.high - x.low > hl_mean] + + # 寻找 n_bars 中的新高K线,及其后面的K线序列 + for i, bar in enumerate(n_bars): + right_bars = n_bars[i + 1 :] + if not right_bars: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + # 当前K线创新高,但是收盘价在HL中点以下 + if bar.high >= w_bars_high and bar.cache[cache_key] != "高位收盘": + for rb in right_bars: + if rb.cache[cache_key] == "低位收盘" or rb.close < rb.low: + v1 = "看空" + break + + # 当前K线创新低,但是收盘价在HL中点以上 + if bar.low <= w_bars_low and bar.cache[cache_key] != "低位收盘": + for rb in right_bars: + if rb.cache[cache_key] == "高位收盘" or rb.close > rb.high: + v1 = "看多" + break + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def bar_td9_V240616(c: CZSC, **kwargs) -> OrderedDict: + """神奇九转计数 + + 参数模板:"{freq}_神奇九转N{n}_BS辅助V240616" + + **信号逻辑:** + + 1. 当前收盘价大于前4根K线的收盘价,+1,否则-1 + 2. 如果最后一根K线为1,且连续值计数大于等于N,卖点;如果最后一根K线为-1,且连续值计数小于等于-N,买点 + + **信号列表:** + + - Signal('60分钟_神奇九转N9_BS辅助V240616_买点_9转_任意_0') + - Signal('60分钟_神奇九转N9_BS辅助V240616_卖点_9转_任意_0') + + :param c: CZSC对象 + :param kwargs: + + - n: int, default 9, 连续转折次数 + + :return: 信号识别结果 + """ + n = int(kwargs.get("n", 9)) + + freq = c.freq.value + k1, k2, k3 = f"{freq}_神奇九转N{n}_BS辅助V240616".split("_") + v1 = "其他" + + # 更新缓存 + cache_key = "bar_td9_V240616" + for i, bar in enumerate(c.bars_raw): + if i < 4 or hasattr(bar.cache, cache_key): + continue + + if bar.close > c.bars_raw[i - 4].close: + bar.cache[cache_key] = 1 + elif bar.close < c.bars_raw[i - 4].close: + bar.cache[cache_key] = -1 + else: + bar.cache[cache_key] = 0 + + if len(c.bars_raw) < 30 + n: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + v2 = "任意" + bars = get_sub_elements(c.bars_raw, di=1, n=n * 2) + if bars[-1].cache[cache_key] == 1: + count = 0 + for bar in bars[::-1]: + if bar.cache[cache_key] != 1: + break + count += 1 + if count >= n: + v1 = "卖点" + v2 = f"{count}转" + + elif bars[-1].cache[cache_key] == -1: + count = 0 + for bar in bars[::-1]: + if bar.cache[cache_key] != -1: + break + count += 1 + if count >= n: + v1 = "买点" + v2 = f"{count}转" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) diff --git a/czsc/signals/tas.py b/czsc/signals/tas.py index 5b5f46d82..9d9d15b23 100644 --- a/czsc/signals/tas.py +++ b/czsc/signals/tas.py @@ -1991,6 +1991,49 @@ def tas_cci_base_V230402(c: CZSC, **kwargs) -> OrderedDict: return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) +def cci_decision_V240620(c: CZSC, **kwargs) -> OrderedDict: + """根据CCI指标逆势用法,判断买卖决策区域 + + 参数模板:"{freq}_N{n}CCI_决策区域V240620" + + **信号逻辑:** + + 取最近N根K线,如果最小的CCI值小于 -100,开多;如果最大的CCI值大于 100,开空。 + + **信号列表:** + + - Signal('15分钟_N4CCI_决策区域V240620_开多_2次_任意_0') + - Signal('15分钟_N4CCI_决策区域V240620_开多_1次_任意_0') + - Signal('15分钟_N4CCI_决策区域V240620_开空_1次_任意_0') + - Signal('15分钟_N4CCI_决策区域V240620_开空_2次_任意_0') + + :param c: CZSC对象 + :param kwargs: 无 + :return: 信号识别结果 + """ + n = int(kwargs.get("n", 2)) + + freq = c.freq.value + k1, k2, k3 = f"{freq}_N{n}CCI_决策区域V240620".split("_") + v1 = "其他" + cache_key = update_cci_cache(c, timeperiod=14) + if len(c.bars_raw) < 100: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + cci_seq = [x.cache[cache_key] for x in c.bars_raw[-n:]] + short_cci = [x for x in cci_seq if x > 100] + long_cci = [x for x in cci_seq if x < -100] + + v2 = "任意" + if min(cci_seq) < -100: + v1 = "开多" + v2 = f"{len(long_cci)}次" + if max(cci_seq) > 100: + v1 = "开空" + v2 = f"{len(short_cci)}次" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + def tas_kdj_evc_V230401(c: CZSC, **kwargs) -> OrderedDict: """KDJ极值计数信号, evc 是 extreme value counts 的首字母缩写 @@ -3074,7 +3117,7 @@ def cat_macd_V230518(cat: CzscSignals, **kwargs) -> OrderedDict: # 找出 c1 的最近一次金叉 macd_gold_bars = [] for bar1, bar2 in zip(c1_bars, c1_bars[1:]): - if bar1.cache[cache_key]["macd"] < 0 and bar2.cache[cache_key]["macd"] > 0: + if bar1.cache[cache_key]["macd"] < 0 < bar2.cache[cache_key]["macd"]: macd_gold_bars.append(bar2) assert macd_gold_bars, "没有找到金叉" macd_gold_bar = macd_gold_bars[-1] @@ -3084,7 +3127,7 @@ def cat_macd_V230518(cat: CzscSignals, **kwargs) -> OrderedDict: if len(c2_bars) > 3: c2_gold_bars = [] for bar1, bar2 in zip(c2_bars, c2_bars[1:]): - if bar1.cache[cache_key]["macd"] < 0 and bar2.cache[cache_key]["macd"] > 0: + if bar1.cache[cache_key]["macd"] < 0 < bar2.cache[cache_key]["macd"]: c2_gold_bars.append(bar2) if len(c2_gold_bars) == 1: @@ -3094,7 +3137,7 @@ def cat_macd_V230518(cat: CzscSignals, **kwargs) -> OrderedDict: # 找出 c1 的最近一次死叉 macd_dead_bars = [] for bar1, bar2 in zip(c1_bars, c1_bars[1:]): - if bar1.cache[cache_key]["macd"] > 0 and bar2.cache[cache_key]["macd"] < 0: + if bar1.cache[cache_key]["macd"] > 0 > bar2.cache[cache_key]["macd"]: macd_dead_bars.append(bar2) assert macd_dead_bars, "没有找到死叉" macd_dead_bar = macd_dead_bars[-1] @@ -3104,7 +3147,7 @@ def cat_macd_V230518(cat: CzscSignals, **kwargs) -> OrderedDict: if len(c2_bars) > 3: c2_dead_bars = [] for bar1, bar2 in zip(c2_bars, c2_bars[1:]): - if bar1.cache[cache_key]["macd"] > 0 and bar2.cache[cache_key]["macd"] < 0: + if bar1.cache[cache_key]["macd"] > 0 > bar2.cache[cache_key]["macd"]: c2_dead_bars.append(bar2) if len(c2_dead_bars) == 1: diff --git a/czsc/signals/xls.py b/czsc/signals/xls.py index cb58af2e2..2113ea173 100644 --- a/czsc/signals/xls.py +++ b/czsc/signals/xls.py @@ -89,18 +89,10 @@ def xl_bar_trend_V240329(c: CZSC, **kwargs) -> OrderedDict: return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) bar1, bar2 = get_sub_elements(c.bars_raw, di=1, n=2) - if ( - check_szx(bar2, n) - and bar1.close < bar1.open - and (bar1.open - bar1.close) / (bar1.high - bar1.low) * 10 >= m - ): + if check_szx(bar2, n) and bar1.close < bar1.open and (bar1.open - bar1.close) / (bar1.high - bar1.low) * 10 >= m: v1 = "底部十字孕线" - if ( - check_szx(bar2, n) - and bar1.close > bar1.open - and (bar1.close - bar1.open) / (bar1.high - bar1.low) * 10 >= m - ): + if check_szx(bar2, n) and bar1.close > bar1.open and (bar1.close - bar1.open) / (bar1.high - bar1.low) * 10 >= m: v1 = "顶部十字孕线" return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) @@ -311,17 +303,56 @@ def xl_bar_basis_V240411(c: CZSC, **kwargs) -> OrderedDict: bar1, bar2 = get_sub_elements(c.bars_raw, di=1, n=2) - if ( - (bar1.open > bar1.close) - and (bar2.close > bar1.high) - and (bar2.open <= bar1.low) - ): + if (bar1.open > bar1.close) and (bar2.close > bar1.high) and (bar2.open <= bar1.low): v1 = "看涨吞没" - elif ( - (bar1.open < bar1.close) - and (bar2.open >= bar1.high) - and (bar2.close < bar1.low) - ): + elif (bar1.open < bar1.close) and (bar2.open >= bar1.high) and (bar2.close < bar1.low): v1 = "看跌吞没" return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def xl_bar_trend_V240623(c: CZSC, **kwargs) -> OrderedDict: + """突破信号; 贡献者:谢磊 + + 参数模板:"{freq}_N{n}通道_突破信号V240623" + + **信号逻辑:** + + 1, 突破前N日最高价,入场,做多 + 2. 跌破前N日最低价,入场,做空 + + **信号列表:** + + - Signal('30分钟_N20通道_突破信号V240623_做多_连续2次上涨_任意_0') + - Signal('30分钟_N20通道_突破信号V240623_做空_连续2次下跌_任意_0') + + :param c: CZSC对象 + :param kwargs: + + - n: int, 默认20,突破前N日的最高价或最低价 + + :return: 信号识别结果 + """ + n = int(kwargs.get("n", 20)) + freq = c.freq.value + k1, k2, k3 = f"{freq}_N{n}通道_突破信号V240623".split("_") + v1 = "其他" + v2 = "任意" + + bars2 = get_sub_elements(c.bars_raw, di=1, n=n + 1) + hh = max([x.high for x in bars2[0:-2]]) + ll = min([x.low for x in bars2[0:-2]]) + _high = bars2[-2].high + _low = bars2[-2].low + + if _high >= hh: + v1 = "做多" + if bars2[-1].high > _high: + v2 = "连续2次上涨" + + elif _low <= ll: + v1 = "做空" + if bars2[-1].low < _low: + v2 = "连续2次下跌" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) diff --git a/czsc/traders/weight_backtest.py b/czsc/traders/weight_backtest.py index 826dccca1..b14e3b00f 100644 --- a/czsc/traders/weight_backtest.py +++ b/czsc/traders/weight_backtest.py @@ -15,6 +15,8 @@ from typing import Union, AnyStr, Callable from multiprocessing import cpu_count from concurrent.futures import ProcessPoolExecutor + +import czsc from czsc.traders.base import CzscTrader from czsc.utils.io import save_json from czsc.utils.stats import daily_performance, evaluate_pairs @@ -226,9 +228,13 @@ class WeightBacktest: """持仓权重回测 飞书文档:https://s0cqcxuy3p.feishu.cn/wiki/Pf1fw1woQi4iJikbKJmcYToznxb + + 更新日志: + + - V240627: 增加dailys属性,品种每日的交易信息 """ - version = "V231126" + version = "V240627" def __init__(self, dfw, digits=2, **kwargs) -> None: """持仓权重回测 @@ -278,6 +284,7 @@ def __init__(self, dfw, digits=2, **kwargs) -> None: 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)) @property @@ -290,6 +297,55 @@ def daily_return(self) -> pd.DataFrame: """品种等权费后日收益率""" return self.results.get("品种等权日收益", pd.DataFrame()) + @property + def dailys(self) -> pd.DataFrame: + """品种每日的交易信息 + + columns = ['date', 'symbol', 'edge', 'return', 'cost', 'n1b', 'turnover'] + + 其中: + date 交易日, + symbol 合约代码, + n1b 品种每日收益率, + edge 策略每日收益率, + return 策略每日收益率减去交易成本后的真实收益, + cost 交易成本 + turnover 当日的单边换手率 + """ + return self._dailys.copy() if self._dailys is not None else pd.DataFrame() + + @property + def alpha(self) -> pd.DataFrame: + """策略超额收益 + + columns = ['date', '策略', '基准', '超额'] + """ + if self._dailys is None: + return pd.DataFrame() + df1 = self._dailys.groupby("date").agg({"return": "mean", "n1b": "mean"}) + df1["alpha"] = df1["return"] - df1["n1b"] + df1.rename(columns={"return": "策略", "n1b": "基准", "alpha": "超额"}, inplace=True) + df1 = df1.reset_index() + return df1 + + @property + def alpha_stats(self): + """策略超额收益统计""" + df = self.alpha.copy() + stats = czsc.daily_performance(df["超额"].to_list()) + stats["开始日期"] = df["date"].min().strftime("%Y-%m-%d") + stats["结束日期"] = df["date"].max().strftime("%Y-%m-%d") + return stats + + @property + def bench_stats(self): + """基准收益统计""" + df = self.alpha.copy() + stats = czsc.daily_performance(df["基准"].to_list()) + stats["开始日期"] = df["date"].min().strftime("%Y-%m-%d") + stats["结束日期"] = df["date"].max().strftime("%Y-%m-%d") + return stats + def get_symbol_daily(self, symbol): """获取某个合约的每日收益率 @@ -301,19 +357,21 @@ def get_symbol_daily(self, symbol): 4. 计算每条数据扣除手续费后的收益(edge_post_fee):收益减去手续费。 5. 根据日期进行分组,并对每组进行求和操作,得到每日的总收益、总扣除手续费后的收益和总手续费。 6. 重置索引,并将交易标的符号添加到DataFrame中。 - 7. 重命名列名,将'edge_post_fee'列改为'return',将'dt'列改为'date'。 + 7. 重命名列名,将'edge_post_fee'列改为 return,将'dt'列改为 date。 8. 选择需要的列,并返回包含日期、交易标的、收益、扣除手续费后的收益和手续费的DataFrame。 :param symbol: str,合约代码 :return: pd.DataFrame,品种每日收益率, - columns = ['date', 'symbol', 'edge', 'return', 'cost'] + columns = ['date', 'symbol', 'edge', 'return', 'cost', 'n1b'] 其中 - date 为交易日, - symbol 为合约代码, - edge 为每日收益率, - return 为每日收益率减去交易成本后的真实收益, - cost 为交易成本 + date 交易日, + symbol 合约代码, + n1b 品种每日收益率, + edge 策略每日收益率, + return 策略每日收益率减去交易成本后的真实收益, + cost 交易成本 + turnover 当日的单边换手率 数据样例如下: @@ -328,13 +386,19 @@ def get_symbol_daily(self, symbol): ========== ======== ============ ============ ======= """ dfs = self.dfw[self.dfw["symbol"] == symbol].copy() - dfs["edge"] = dfs["weight"] * (dfs["price"].shift(-1) / dfs["price"] - 1) - dfs["cost"] = abs(dfs["weight"].shift(1) - dfs["weight"]) * self.fee_rate + dfs["n1b"] = dfs["price"].shift(-1) / dfs["price"] - 1 + dfs["edge"] = dfs["weight"] * dfs["n1b"] + dfs["turnover"] = abs(dfs["weight"].shift(1) - dfs["weight"]) + dfs["cost"] = dfs["turnover"] * self.fee_rate dfs["edge_post_fee"] = dfs["edge"] - dfs["cost"] - daily = dfs.groupby(dfs["dt"].dt.date).agg({"edge": "sum", "edge_post_fee": "sum", "cost": "sum"}).reset_index() + daily = ( + dfs.groupby(dfs["dt"].dt.date) + .agg({"edge": "sum", "edge_post_fee": "sum", "cost": "sum", "n1b": "sum", "turnover": "sum"}) + .reset_index() + ) daily["symbol"] = symbol daily.rename(columns={"edge_post_fee": "return", "dt": "date"}, inplace=True) - daily = daily[["date", "symbol", "edge", "return", "cost"]] + daily = daily[["date", "symbol", "n1b", "edge", "return", "cost", "turnover"]].copy() return daily def get_symbol_pairs(self, symbol): @@ -485,6 +549,8 @@ def backtest(self, n_jobs=1): ): res[symbol] = res_symbol + self._dailys = pd.concat([v["daily"] for k, v in res.items() if k in symbols], ignore_index=True) + dret = pd.concat([v["daily"] for k, v in res.items() if k in symbols], ignore_index=True) dret = pd.pivot_table(dret, index="date", columns="symbol", values="return").fillna(0) dret["total"] = dret[list(res.keys())].mean(axis=1) @@ -528,6 +594,12 @@ def report(self, res_path): fig.write_html(res_path.joinpath("daily_return.html")) logger.info(f"费后日收益率资金曲线已保存到 {res_path.joinpath('daily_return.html')}") + # 绘制alpha曲线 + alpha = self.alpha.copy() + alpha[["策略", "基准", "超额"]] = alpha[["策略", "基准", "超额"]].cumsum() + fig = px.line(alpha, x="date", y=["策略", "基准", "超额"], title="策略超额收益") + fig.write_html(res_path.joinpath("alpha.html")) + # 所有开平交易记录的表现 stats = res["绩效评价"].copy() logger.info(f"绩效评价:{stats}") diff --git a/czsc/utils/data_client.py b/czsc/utils/data_client.py index 778e42351..3d1138a34 100644 --- a/czsc/utils/data_client.py +++ b/czsc/utils/data_client.py @@ -15,20 +15,20 @@ def set_url_token(token, url): :param token: 凭证码 :param url: 数据接口地址 """ - hash_key = hashlib.md5(str(url).encode('utf-8')).hexdigest() + hash_key = hashlib.md5(str(url).encode("utf-8")).hexdigest() file_token = Path("~").expanduser() / f"{hash_key}.txt" - with open(file_token, 'w', encoding='utf-8') as f: + with open(file_token, "w", encoding="utf-8") as f: f.write(token) logger.info(f"{url} 数据访问凭证码已保存到 {file_token}") def get_url_token(url): """获取指定 URL 数据接口的凭证码""" - hash_key = hashlib.md5(str(url).encode('utf-8')).hexdigest() + hash_key = hashlib.md5(str(url).encode("utf-8")).hexdigest() file_token = Path("~").expanduser() / f"{hash_key}.txt" if file_token.exists(): logger.info(f"从 {file_token} 读取 {url} 的访问凭证码") - return open(file_token, 'r', encoding='utf-8').read() + return open(file_token, "r", encoding="utf-8").read() logger.warning(f"请设置 {url} 的访问凭证码,如果没有请联系管理员申请") token = input(f"请输入 {url} 的访问凭证码(token):") @@ -41,7 +41,7 @@ def get_url_token(url): class DataClient: __version__ = "V231109" - def __init__(self, token=None, url='http://api.tushare.pro', timeout=300, **kwargs): + def __init__(self, token=None, url="http://api.tushare.pro", timeout=300, **kwargs): """数据接口客户端,支持缓存,默认缓存路径为 ~/.quant_data_cache;兼容Tushare数据接口 :param token: str API接口TOKEN,用于用户认证 @@ -58,12 +58,14 @@ def __init__(self, token=None, url='http://api.tushare.pro', timeout=300, **kwar self.__token = token or get_url_token(url) self.__http_url = url self.__timeout = timeout - self.__url_hash = hashlib.md5(str(url).encode('utf-8')).hexdigest()[:8] + self.__url_hash = hashlib.md5(str(url).encode("utf-8")).hexdigest()[:8] assert self.__token, "请设置czsc_token凭证码,如果没有请联系管理员申请" self.cache_path = Path(kwargs.get("cache_path", os.path.expanduser("~/.quant_data_cache"))) self.cache_path.mkdir(exist_ok=True, parents=True) - logger.info(f"数据URL: {url} 数据缓存路径:{self.cache_path} 占用磁盘空间:{get_dir_size(self.cache_path) / 1024 / 1024:.2f} MB") + logger.info( + f"数据URL: {url} 数据缓存路径:{self.cache_path} 占用磁盘空间:{get_dir_size(self.cache_path) / 1024 / 1024:.2f} MB" + ) if kwargs.get("clear_cache", False): self.clear_cache() @@ -71,8 +73,9 @@ def clear_cache(self): """清空缓存""" shutil.rmtree(self.cache_path) logger.info(f"{self.cache_path} 路径下的数据缓存已清空") + self.cache_path.mkdir(exist_ok=True, parents=True) - def post_request(self, api_name, fields='', **kwargs): + def post_request(self, api_name, fields="", **kwargs): """执行API数据查询 :param api_name: str, 查询接口名称 @@ -84,11 +87,11 @@ def post_request(self, api_name, fields='', **kwargs): :return: pd.DataFrame """ stime = time() - if api_name in ['__getstate__', '__setstate__']: + if api_name in ["__getstate__", "__setstate__"]: return pd.DataFrame() ttl = int(kwargs.pop("ttl", -1)) - req_params = {'api_name': api_name, 'token': self.__token, 'params': kwargs, 'fields': fields} + req_params = {"api_name": api_name, "token": self.__token, "params": kwargs, "fields": fields} path = self.cache_path / f"{self.__url_hash}_{api_name}" path.mkdir(exist_ok=True, parents=True) file_cache = path / f"{hashlib.md5(str(req_params).encode('utf-8')).hexdigest()}.pkl" @@ -100,10 +103,10 @@ def post_request(self, api_name, fields='', **kwargs): res = requests.post(self.__http_url, json=req_params, timeout=self.__timeout) if res: result = res.json() - if result['code'] != 0: + if result["code"] != 0: raise Exception(f"API: {api_name} - {kwargs} 数据获取失败: {result}") - df = pd.DataFrame(result['data']['items'], columns=result['data']['fields']) + df = pd.DataFrame(result["data"]["items"], columns=result["data"]["fields"]) df.to_pickle(file_cache) else: df = pd.DataFrame() diff --git a/czsc/utils/st_components.py b/czsc/utils/st_components.py index 22906bb26..ea876124a 100644 --- a/czsc/utils/st_components.py +++ b/czsc/utils/st_components.py @@ -1042,15 +1042,15 @@ def show_event_return(df, factor, **kwargs): - sub_title: str, 子标题 - max_overlap: int, 事件最大重叠次数 + - max_unique: int, 因子独立值最大数量 """ sub_title = kwargs.get("sub_title", "事件收益率特征") - default_max_overlap = kwargs.get("max_overlap", 2) - + max_unique = kwargs.get("max_unique", 20) if sub_title: st.subheader(sub_title, divider="rainbow") - if df[factor].nunique() > 20: + if df[factor].nunique() > max_unique: st.warning(f"因子分布过于离散,无法进行分析,请检查!!!因子独立值数量:{df[factor].nunique()}") return @@ -1065,7 +1065,7 @@ def show_event_return(df, factor, **kwargs): ) sdt = pd.to_datetime(c2.date_input("开始时间", value=df["dt"].min())) edt = pd.to_datetime(c3.date_input("结束时间", value=df["dt"].max())) - max_overlap = c4.number_input("最大重叠次数", value=default_max_overlap, min_value=1, max_value=20) + max_overlap = c4.number_input("最大重叠次数", value=5, min_value=1, max_value=20) df[factor] = df[factor].astype(str) df = czsc.overlap(df, factor, new_col="overlap", max_overlap=max_overlap) @@ -1361,3 +1361,116 @@ def show_symbols_corr(df, factor, target="n1b", method="pearson", **kwargs): fig_title = kwargs.get("fig_title", f"{factor} 在品种上的相关性分布") fig = px.bar(dfr, x="symbol", y="corr", title=fig_title, orientation="v") st.plotly_chart(fig, use_container_width=True) + + +def show_czsc_trader(trader: czsc.CzscTrader, max_k_num=300, **kwargs): + """显示缠中说禅交易员详情 + + :param trader: CzscTrader 对象 + :param max_k_num: 最大显示 K 线数量 + :param kwargs: 其他参数 + """ + from czsc.utils.ta import MACD + + sub_title = kwargs.get("sub_title", "缠中说禅交易员详情") + if sub_title: + st.subheader(sub_title, divider="rainbow") + + if not trader.freqs or not trader.kas or not trader.positions: + st.error("当前 trader 没有回测数据") + return + + freqs = czsc.freqs_sorted(trader.freqs) + st.write(f"交易品种: {trader.symbol}") + tabs = st.tabs(freqs + ["策略详情"]) + + for freq, tab in zip(freqs, tabs[:-1]): + + c = trader.kas[freq] + sdt = c.bars_raw[-max_k_num].dt if len(c.bars_raw) > max_k_num else c.bars_raw[0].dt + df = pd.DataFrame(c.bars_raw) + df["DIFF"], df["DEA"], df["MACD"] = MACD(df["close"], fastperiod=12, slowperiod=26, signalperiod=9) + + df = df[df["dt"] >= sdt].copy() + kline = czsc.KlineChart(n_rows=3, row_heights=(0.5, 0.3, 0.2), title="", width="100%", height=800) + kline.add_kline(df, name="") + + if len(c.bi_list) > 0: + bi = pd.DataFrame( + [{"dt": x.fx_a.dt, "bi": x.fx_a.fx} for x in c.bi_list] + + [{"dt": c.bi_list[-1].fx_b.dt, "bi": c.bi_list[-1].fx_b.fx}] + ) + fx = pd.DataFrame([{"dt": x.dt, "fx": x.fx} for x in c.fx_list]) + fx = fx[fx["dt"] >= sdt] + bi = bi[bi["dt"] >= sdt] + kline.add_scatter_indicator( + fx["dt"], + fx["fx"], + name="分型", + row=1, + line_width=1.2, + visible=True, + mode="lines", + line_dash="dot", + marker_color="white", + ) + kline.add_scatter_indicator(bi["dt"], bi["bi"], name="笔", row=1, line_width=1.5) + + kline.add_sma(df, ma_seq=(5, 20, 60), row=1, visible=False, line_width=1) + kline.add_vol(df, row=2, line_width=1) + kline.add_macd(df, row=3, line_width=1) + + # 在基础周期上绘制交易信号 + if freq == trader.base_freq: + for pos in trader.positions: + bs_df = pd.DataFrame([x for x in pos.operates if x["dt"] >= sdt]) + if bs_df.empty: + continue + + open_ops = [czsc.Operate.LO, czsc.Operate.SO] + bs_df["tag"] = bs_df["op"].apply(lambda x: "triangle-up" if x in open_ops else "triangle-down") + bs_df["color"] = bs_df["op"].apply(lambda x: "red" if x in open_ops else "white") + + kline.add_scatter_indicator( + bs_df["dt"], + bs_df["price"], + name=pos.name, + text=bs_df["op_desc"], + row=1, + mode="markers", + marker_size=15, + marker_symbol=bs_df["tag"], + marker_color=bs_df["color"], + visible=False, + hover_template="价格: %{y:.2f}
时间: %{x}
操作: %{text}", + ) + + with tab: + config = { + "scrollZoom": True, + "displayModeBar": True, + "displaylogo": False, + "modeBarButtonsToRemove": [ + "toggleSpikelines", + "select2d", + "zoomIn2d", + "zoomOut2d", + "lasso2d", + "autoScale2d", + "hoverClosestCartesian", + "hoverCompareCartesian", + ], + } + st.plotly_chart(kline.fig, use_container_width=True, config=config) + + with tabs[-1]: + with st.expander("查看最新信号", expanded=False): + if len(trader.s): + s = {k: v for k, v in trader.s.items() if len(k.split("_")) == 3} + st.write(s) + else: + st.warning("当前没有信号配置信息") + for pos in trader.positions: + st.divider() + st.write(pos.name) + st.json(pos.dump(with_data=False)) diff --git a/examples/signals_dev/fenlei.py b/examples/signals_dev/fenlei.py index d6787cceb..c8f54fe50 100644 --- a/examples/signals_dev/fenlei.py +++ b/examples/signals_dev/fenlei.py @@ -6,5 +6,5 @@ bars = research.get_raw_bars("000001.SH", "15分钟", "20101101", "20210101", fq="前复权") -signals_config = [{"name": "czsc.signals.cxt_second_bs_V240524", "freq": "60分钟", "w": 9}] +signals_config = [{"name": "czsc.signals.bar_decision_V240616", "freq": "60分钟"}] czsc.check_signals_acc(bars, signals_config=signals_config, height="780px", delta_days=5) # type: ignore diff --git a/examples/signals_dev/bar_classify_V240606.py b/examples/signals_dev/merged/bar_classify_V240606.py similarity index 100% rename from examples/signals_dev/bar_classify_V240606.py rename to examples/signals_dev/merged/bar_classify_V240606.py diff --git a/examples/signals_dev/bar_classify_V240607.py b/examples/signals_dev/merged/bar_classify_V240607.py similarity index 100% rename from examples/signals_dev/bar_classify_V240607.py rename to examples/signals_dev/merged/bar_classify_V240607.py diff --git a/examples/signals_dev/bar_decision_V240608.py b/examples/signals_dev/merged/bar_decision_V240608.py similarity index 100% rename from examples/signals_dev/bar_decision_V240608.py rename to examples/signals_dev/merged/bar_decision_V240608.py diff --git a/examples/signals_dev/merged/bar_td9_V240616.py b/examples/signals_dev/merged/bar_td9_V240616.py new file mode 100644 index 000000000..480770965 --- /dev/null +++ b/examples/signals_dev/merged/bar_td9_V240616.py @@ -0,0 +1,93 @@ +from collections import OrderedDict + +import numpy as np + +from copy import deepcopy +from czsc.analyze import CZSC +from czsc.utils import create_single_signal, get_sub_elements + + +def bar_td9_V240616(c: CZSC, **kwargs) -> OrderedDict: + """神奇九转计数 + + 参数模板:"{freq}_神奇九转N{n}_BS辅助V240616" + + **信号逻辑:** + + 1. 当前收盘价大于前4根K线的收盘价,+1,否则-1 + 2. 如果最后一根K线为1,且连续值计数大于等于N,卖点;如果最后一根K线为-1,且连续值计数小于等于-N,买点 + + **信号列表:** + + - Signal('60分钟_神奇九转N9_BS辅助V240616_买点_9转_任意_0') + - Signal('60分钟_神奇九转N9_BS辅助V240616_卖点_9转_任意_0') + + :param c: CZSC对象 + :param kwargs: + + - n: int, default 9, 连续转折次数 + + :return: 信号识别结果 + """ + n = int(kwargs.get("n", 9)) + + freq = c.freq.value + k1, k2, k3 = f"{freq}_神奇九转N{n}_BS辅助V240616".split("_") + v1 = "其他" + + # 更新缓存 + cache_key = "bar_td9_V240616" + for i, bar in enumerate(c.bars_raw): + if i < 4 or hasattr(bar.cache, cache_key): + continue + + if bar.close > c.bars_raw[i - 4].close: + bar.cache[cache_key] = 1 + elif bar.close < c.bars_raw[i - 4].close: + bar.cache[cache_key] = -1 + else: + bar.cache[cache_key] = 0 + + if len(c.bars_raw) < 30 + n: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + v2 = "任意" + bars = get_sub_elements(c.bars_raw, di=1, n=n * 2) + if bars[-1].cache[cache_key] == 1: + count = 0 + for bar in bars[::-1]: + if bar.cache[cache_key] != 1: + break + count += 1 + if count >= n: + v1 = "卖点" + v2 = f"{count}转" + + elif bars[-1].cache[cache_key] == -1: + count = 0 + for bar in bars[::-1]: + if bar.cache[cache_key] != -1: + break + count += 1 + if count >= n: + v1 = "买点" + v2 = f"{count}转" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +def check(): + from czsc.connectors import research + from czsc.traders.base import check_signals_acc + + symbols = research.get_symbols("A股主要指数") + bars = research.get_raw_bars(symbols[0], "15分钟", "20181101", "20210101", fq="前复权") + + signals_config = [ + {"name": bar_td9_V240616, "freq": "60分钟"}, + ] + check_signals_acc(bars, signals_config=signals_config, height="780px", delta_days=5) # type: ignore + + +if __name__ == "__main__": + check() diff --git a/examples/signals_dev/merged/cci_decision_V240620.py b/examples/signals_dev/merged/cci_decision_V240620.py new file mode 100644 index 000000000..f3f772f2d --- /dev/null +++ b/examples/signals_dev/merged/cci_decision_V240620.py @@ -0,0 +1,64 @@ +import numpy as np +from collections import OrderedDict +from czsc.analyze import CZSC, BI, Direction +from czsc.signals.tas import update_cci_cache +from czsc.utils import create_single_signal, get_sub_elements +from loguru import logger + + +def cci_decision_V240620(c: CZSC, **kwargs) -> OrderedDict: + """根据CCI指标逆势用法,判断买卖决策区域 + + 参数模板:"{freq}_N{n}CCI_决策区域V240620" + + **信号逻辑:** + + 取最近N根K线,如果最小的CCI值小于 -100,开多;如果最大的CCI值大于 100,开空。 + + **信号列表:** + + - Signal('15分钟_N4CCI_决策区域V240620_开多_2次_任意_0') + - Signal('15分钟_N4CCI_决策区域V240620_开多_1次_任意_0') + - Signal('15分钟_N4CCI_决策区域V240620_开空_1次_任意_0') + - Signal('15分钟_N4CCI_决策区域V240620_开空_2次_任意_0') + + :param c: CZSC对象 + :param kwargs: 无 + :return: 信号识别结果 + """ + n = int(kwargs.get("n", 2)) + + freq = c.freq.value + k1, k2, k3 = f"{freq}_N{n}CCI_决策区域V240620".split("_") + v1 = "其他" + cache_key = update_cci_cache(c, timeperiod=14) + if len(c.bars_raw) < 100: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + cci_seq = [x.cache[cache_key] for x in c.bars_raw[-n:]] + short_cci = [x for x in cci_seq if x > 100] + long_cci = [x for x in cci_seq if x < -100] + + v2 = "任意" + if min(cci_seq) < -100: + v1 = "开多" + v2 = f"{len(long_cci)}次" + if max(cci_seq) > 100: + v1 = "开空" + v2 = f"{len(short_cci)}次" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +def check(): + from czsc.connectors import research + from czsc.traders.base import check_signals_acc + + symbols = research.get_symbols("A股主要指数") + bars = research.get_raw_bars(symbols[0], "15分钟", "20181101", "20210101", fq="前复权") + + signals_config = [{"name": cci_decision_V240620, "freq": "15分钟", "n": 4}] + check_signals_acc(bars, signals_config=signals_config, height="780px", delta_days=5) # type: ignore + + +if __name__ == "__main__": + check() diff --git a/examples/signals_dev/cxt_bs_V240526.py b/examples/signals_dev/merged/cxt_bs_V240526.py similarity index 100% rename from examples/signals_dev/cxt_bs_V240526.py rename to examples/signals_dev/merged/cxt_bs_V240526.py diff --git a/examples/signals_dev/cxt_bs_V240527.py b/examples/signals_dev/merged/cxt_bs_V240527.py similarity index 100% rename from examples/signals_dev/cxt_bs_V240527.py rename to examples/signals_dev/merged/cxt_bs_V240527.py diff --git a/examples/signals_dev/cxt_decision_V240526.py b/examples/signals_dev/merged/cxt_decision_V240526.py similarity index 100% rename from examples/signals_dev/cxt_decision_V240526.py rename to examples/signals_dev/merged/cxt_decision_V240526.py diff --git a/examples/signals_dev/cxt_decision_V240612.py b/examples/signals_dev/merged/cxt_decision_V240612.py similarity index 100% rename from examples/signals_dev/cxt_decision_V240612.py rename to examples/signals_dev/merged/cxt_decision_V240612.py diff --git a/examples/signals_dev/cxt_decision_V240613.py b/examples/signals_dev/merged/cxt_decision_V240613.py similarity index 100% rename from examples/signals_dev/cxt_decision_V240613.py rename to examples/signals_dev/merged/cxt_decision_V240613.py diff --git a/examples/signals_dev/cxt_decision_V240614.py b/examples/signals_dev/merged/cxt_decision_V240614.py similarity index 100% rename from examples/signals_dev/cxt_decision_V240614.py rename to examples/signals_dev/merged/cxt_decision_V240614.py diff --git a/examples/signals_dev/cxt_overlap_V240526.py b/examples/signals_dev/merged/cxt_overlap_V240526.py similarity index 100% rename from examples/signals_dev/cxt_overlap_V240526.py rename to examples/signals_dev/merged/cxt_overlap_V240526.py diff --git a/examples/signals_dev/cxt_overlap_V240612.py b/examples/signals_dev/merged/cxt_overlap_V240612.py similarity index 100% rename from examples/signals_dev/cxt_overlap_V240612.py rename to examples/signals_dev/merged/cxt_overlap_V240612.py diff --git a/examples/signals_dev/signal_match.py b/examples/signals_dev/signal_match.py index cbf6a5f45..ddb48eb03 100644 --- a/examples/signals_dev/signal_match.py +++ b/examples/signals_dev/signal_match.py @@ -45,7 +45,7 @@ conf = sp.parse(signals_seq) parsed_name = {x["name"] for x in conf} print(f"total signal functions: {len(sp.sig_name_map)}; parsed: {len(parsed_name)}") - # total signal functions: 218; parsed: 218 + # total signal functions: 241; parsed: 241 # 测试信号配置生成信号 from czsc import generate_czsc_signals, get_signals_freqs, get_signals_config diff --git a/examples/test_offline/test_weight_backtest.py b/examples/test_offline/test_weight_backtest.py index 1657ebbd3..a7be4e278 100644 --- a/examples/test_offline/test_weight_backtest.py +++ b/examples/test_offline/test_weight_backtest.py @@ -1,10 +1,11 @@ import sys + sys.path.insert(0, ".") sys.path.insert(0, "..") import czsc import pandas as pd -assert czsc.WeightBacktest.version == "V231126" +assert czsc.WeightBacktest.version == "V240627" def run_by_weights(): @@ -12,11 +13,19 @@ def run_by_weights(): dfw = pd.read_feather(r"C:\Users\zengb\Downloads\weight_example.feather") wb = czsc.WeightBacktest(dfw, digits=1, fee_rate=0.0002, n_jobs=1) # wb = czsc.WeightBacktest(dfw, digits=1, fee_rate=0.0002) + dailys = wb.dailys + print(wb.stats) + print(wb.alpha_stats) + print(wb.bench_stats) + + # 计算等权组合的超额 + df1 = dailys.groupby("date").agg({"return": "mean", "n1b": "mean"}) + df1["alpha"] = df1["return"] - df1["n1b"] # ------------------------------------------------------------------------------------ # 查看绩效评价 # ------------------------------------------------------------------------------------ - print(wb.results['绩效评价']) + print(wb.results["绩效评价"]) # {'开始日期': '20170103', # '结束日期': '20230731', # '年化': 0.093, # 品种等权之后的年化收益率 @@ -41,5 +50,5 @@ def run_by_weights(): wb.report(res_path=r"C:\Users\zengb\Desktop\231005\weight_example") -if __name__ == '__main__': +if __name__ == "__main__": run_by_weights() diff --git a/examples/tushare_data_client.py b/examples/tushare_data_client.py new file mode 100644 index 000000000..c37a3645a --- /dev/null +++ b/examples/tushare_data_client.py @@ -0,0 +1,24 @@ +# https://s0cqcxuy3p.feishu.cn/wiki/OpxqwUjdaifQq9kigCUcIWeonsg +import czsc + +# 首次使用需要设置 Tushare token,用于获取数据 +# czsc.set_url_token(token="your tushare token", url="https://api.tushare.pro") + +# 也可以在初始化 DataClient 时设置 token;不推荐直接在代码中写入 token +# dc = czsc.DataClient(url="https://api.tushare.pro", cache_path="~/czsc", token="your tushare token", timeout=300) + +# 设置过 token 后,可以直接初始化 DataClient,不需要再次设置 token +# cache_path 用于设置缓存路径,后面的缓存文件会保存在该路径下 +pro = czsc.DataClient(url="https://api.tushare.pro", cache_path=r"D:\.tushare_cache", timeout=300) + +# 创建 pro 对象后,可以直接使用 Tushare 数据接口,与 Tushare 官方接口一致 +# 首次调用会自动下载数据并缓存,后续调用,如果参数没有变化,会直接从缓存读取 +df1 = pro.stock_basic(exchange="", list_status="L", fields="ts_code,symbol,name,area,industry,list_date") + +# 再次执行同样参数的查询 +df2 = pro.stock_basic(exchange="", list_status="L", fields="ts_code,symbol,name,area,industry,list_date") + +# 如果需要刷新数据,可以设置 ttl 参数,单位秒;ttl=-1 表示不过期;ttl=0 表示每次都重新下载 +df3 = pro.stock_basic(exchange="", list_status="L", fields="ts_code,symbol,name,area,industry,list_date", ttl=0) + +# df = pro.daily(trade_date="20240614")