From 277defc6ace65370d4a02b567d641e71f12fe102 Mon Sep 17 00:00:00 2001 From: zengbin93 Date: Fri, 12 Jul 2024 15:16:37 +0800 Subject: [PATCH] =?UTF-8?q?V0.9.55=20=E6=9B=B4=E6=96=B0=E4=B8=80=E6=89=B9?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 0.9.55 start coding * 飞书API (#202) Co-authored-by: jun <> * 0.9.55 新增飞书多维表格API接口 * 增加gm 可实盘模板 (#205) * 0.9.55 update * 0.9.55 新增 fernet 加密解密方法 * 0.9.55 update --------- Co-authored-by: JunTurbo <70478893+JunTurbo@users.noreply.github.com> Co-authored-by: xie lee <129350069+leee2023@users.noreply.github.com> --- .github/workflows/pythonpackage.yml | 2 +- czsc/__init__.py | 9 +- czsc/connectors/cooperation.py | 3 +- czsc/connectors/qmt_connector.py | 238 ++++-- czsc/connectors/tq_connector.py | 25 +- czsc/fsa/base.py | 37 +- czsc/fsa/bi_table.py | 699 +++++++++++++++++- czsc/utils/__init__.py | 18 + czsc/utils/fernet.py | 58 ++ czsc/utils/st_components.py | 25 +- examples/gm_0701.py | 505 +++++++++++++ examples/test_offline/test_feishu_bi_table.py | 107 +++ requirements.txt | 3 +- test/test_utils.py | 10 + 14 files changed, 1629 insertions(+), 110 deletions(-) create mode 100644 czsc/utils/fernet.py create mode 100644 examples/gm_0701.py create mode 100644 examples/test_offline/test_feishu_bi_table.py diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 26aa810be..f8487d104 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -5,7 +5,7 @@ name: Python package on: push: - branches: [ master, V0.9.54 ] + branches: [ master, V0.9.55 ] pull_request: branches: [ master ] diff --git a/czsc/__init__.py b/czsc/__init__.py index 066b7917f..ea996b816 100644 --- a/czsc/__init__.py +++ b/czsc/__init__.py @@ -45,6 +45,7 @@ ExitsOptimize, ) from czsc.utils import ( + mac_address, overlap, format_standard_kline, @@ -105,6 +106,10 @@ optuna_study, optuna_good_params, + + generate_fernet_key, + fernet_encrypt, + fernet_decrypt, ) # 交易日历工具 @@ -187,10 +192,10 @@ ) -__version__ = "0.9.54" +__version__ = "0.9.55" __author__ = "zengbin93" __email__ = "zeng_bin8888@163.com" -__date__ = "20240616" +__date__ = "20240706" def welcome(): diff --git a/czsc/connectors/cooperation.py b/czsc/connectors/cooperation.py index bc2d6b990..f5f0f0075 100644 --- a/czsc/connectors/cooperation.py +++ b/czsc/connectors/cooperation.py @@ -212,8 +212,6 @@ def stocks_daily_klines(sdt="20170101", edt="20240101", **kwargs): for year in years: ttl = 3600 * 6 if year == str(datetime.now().year) else -1 kline = dc.pro_bar(trade_year=year, adj=adj, v=2, ttl=ttl) - kline["price"] = kline["open"].shift(-1) - kline["price"] = kline["price"].fillna(kline["close"]) res.append(kline) dfk = pd.concat(res, ignore_index=True) @@ -223,6 +221,7 @@ def stocks_daily_klines(sdt="20170101", edt="20240101", **kwargs): dfk = dfk[~dfk["code"].str.endswith(".BJ")].reset_index(drop=True) dfk = dfk.rename(columns={"code": "symbol"}) + dfk["price"] = dfk["close"] nxb = kwargs.get("nxb", [1, 2, 5, 10, 20, 30, 60]) if nxb: dfk = czsc.update_nxb(dfk, nseq=nxb) diff --git a/czsc/connectors/qmt_connector.py b/czsc/connectors/qmt_connector.py index 482cba81e..f07f5ce55 100644 --- a/czsc/connectors/qmt_connector.py +++ b/czsc/connectors/qmt_connector.py @@ -13,12 +13,14 @@ import time import random import czsc +import loguru import pyautogui import subprocess import pandas as pd from typing import List from tqdm import tqdm -from loguru import logger + +# from loguru import logger from datetime import datetime, timedelta from czsc.objects import Freq, RawBar from czsc.fsa.im import IM @@ -32,8 +34,9 @@ dt_fmt = "%Y-%m-%d %H:%M:%S" -def find_exe_window(title): +def find_exe_window(title, **kwargs): """windows系统:根据 title 查找 window""" + logger = kwargs.get("logger", loguru.logger) windows = pyautogui.getWindowsWithTitle(title) if len(windows) > 1: logger.warning(f"找到多个 {title} 窗口,数量:{len(windows)};请检查是否有多个程序实例") @@ -45,7 +48,7 @@ def find_exe_window(title): return windows[0] -def close_exe_window(title): +def close_exe_window(title, **kwargs): """windows系统:关闭 exe 应用程序 :param title: 程序标题,支持模糊匹配,不需要完全匹配 @@ -66,6 +69,8 @@ def close_exe_window(title): 1. 将鼠标悬停在任务栏上的应用程序图标上。 2. 通常,任务栏会显示该应用程序的标题。 """ + logger = kwargs.get("logger", loguru.logger) + window = find_exe_window(title) if window: window.activate() @@ -75,6 +80,51 @@ def close_exe_window(title): logger.error(f"没有找到 {title} 程序") +def wait_qmt_ready(timeout=60, **kwargs): + """等待 QMT 窗口就绪""" + logger = kwargs.get("logger", loguru.logger) + + start = time.time() + while time.time() - start < timeout: + x = xtdata.get_full_tick(code_list=["000001.SZ"]) + if x and x["000001.SZ"]["lastPrice"] > 0: + return True + + logger.warning("等待 QMT 窗口就绪") + time.sleep(1) + return False + + +def initialize_qmt(**kwargs): + """初始化 QMT 交易端 + + :param kwargs: + mini_qmt_dir: str, mini qmt 目录 + account_id: str, 账户id + callback_params: dict, TraderCallback 回调类的参数 + :return: xtt, acc + xtt - XtQuantTrader + acc - StockAccount + """ + logger = kwargs.get("logger", loguru.logger) + + import random + + mini_qmt_dir = kwargs.get("mini_qmt_dir") + account_id = kwargs.get("account_id") + + session = random.randint(10000, 20000) + callback = TraderCallback(logger=logger, **kwargs.get("callback_params", {})) + xtt = XtQuantTrader(mini_qmt_dir, session=session, callback=callback) + acc = StockAccount(account_id, "STOCK") + xtt.start() + xtt.connect() + assert xtt.connected, "交易服务器连接失败" + _res = xtt.subscribe(acc) + assert _res == 0, "账号订阅失败" + return xtt, acc + + def start_qmt_exe(acc, pwd, qmt_exe, title, max_retry=6, **kwargs): """windows系统:启动 QMT,并登录 @@ -84,6 +134,8 @@ def start_qmt_exe(acc, pwd, qmt_exe, title, max_retry=6, **kwargs): :param title: QMT 窗口标题,如 国金证券QMT交易端 :param max_retry: 最大重试次数 """ + logger = kwargs.get("logger", loguru.logger) + wait_seconds = kwargs.get("wait_seconds", 6) i = 0 while not find_exe_window(acc): @@ -182,6 +234,8 @@ def get_kline(symbol, period, start_time, end_time, count=-1, dividend_type="fro 3 000001.SZ 4 000001.SZ """ + logger = kwargs.get("logger", loguru.logger) + 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") @@ -227,6 +281,8 @@ def get_raw_bars(symbol, freq, sdt, edt, fq="前复权", **kwargs) -> List[RawBa :param kwargs: :return: """ + logger = kwargs.get("logger", loguru.logger) + freq = Freq(freq) if freq == Freq.F1: period = "1m" @@ -328,6 +384,7 @@ class TraderCallback(XtQuantTraderCallback): def __init__(self, **kwargs): super(TraderCallback, self).__init__() self.kwargs = kwargs + self.logger = kwargs.get("logger", loguru.logger) 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"]) @@ -341,9 +398,9 @@ def __init__(self, **kwargs): file_log = kwargs.get("file_log", None) if file_log: - logger.add(file_log, rotation="1 day", encoding="utf-8", enqueue=True) + self.logger.add(file_log, rotation="1 day", encoding="utf-8", enqueue=True) self.file_log = file_log - logger.info(f"TraderCallback init: {kwargs}") + self.logger.info(f"TraderCallback init: {kwargs}") def push_message(self, msg: str, msg_type="text"): """批量推送消息""" @@ -357,13 +414,13 @@ def push_message(self, msg: str, msg_type="text"): elif msg_type == "file": self.im.send_file(msg, member) else: - logger.error(f"不支持的消息类型:{msg_type}") + self.logger.error(f"不支持的消息类型:{msg_type}") except Exception as e: - logger.error(f"推送消息失败:{e}") + self.logger.error(f"推送消息失败:{e}") def on_disconnected(self): """连接断开""" - logger.info("connection lost") + self.logger.info("connection lost") if is_trade_time(): self.push_message("连接断开") @@ -398,7 +455,7 @@ def on_stock_order(self, order): f"价格:{order.price}\n" f"状态:{order_status_map[order.order_status]}" ) - logger.info(f"on order callback: {msg}") + self.logger.info(f"on order callback: {msg}") if self.feishu_push_mode == "detail" and is_trade_time(): self.push_message(msg, msg_type="text") @@ -414,7 +471,7 @@ def on_stock_asset(self, asset): f"可用资金:{asset.cash} \n" f"总资产:{asset.total_asset}" ) - logger.info(f"on asset callback: {msg}") + self.logger.info(f"on asset callback: {msg}") if self.feishu_push_mode == "detail" and is_trade_time(): self.push_message(msg, msg_type="text") @@ -432,7 +489,7 @@ def on_stock_trade(self, trade): f"成交量:{int(trade.traded_volume)}\n" f"成交价:{round(trade.traded_price, 2)}" ) - logger.info(f"on trade callback: {msg}") + self.logger.info(f"on trade callback: {msg}") if self.feishu_push_mode == "detail" and is_trade_time(): self.push_message(msg, msg_type="text") @@ -448,7 +505,7 @@ def on_stock_position(self, position): f"标的:{position.stock_code}\n" f"成交量:{position.volume}" ) - logger.info(f"on position callback: {msg}") + self.logger.info(f"on position callback: {msg}") if self.feishu_push_mode == "detail" and is_trade_time(): self.push_message(msg, msg_type="text") @@ -465,7 +522,7 @@ def on_order_error(self, order_error): f"错误编码:{order_error.error_id}\n" f"失败原因:{order_error.error_msg}" ) - logger.info(f"on order_error callback: {msg}") + self.logger.info(f"on order_error callback: {msg}") if is_trade_time(): self.push_message(msg, msg_type="text") @@ -482,7 +539,7 @@ def on_cancel_error(self, cancel_error): f"错误编码:{cancel_error.error_id}\n" f"失败原因:{cancel_error.error_msg}" ) - logger.info(f"on_cancel_error: {msg}") + self.logger.info(f"on_cancel_error: {msg}") if is_trade_time(): self.push_message(msg, msg_type="text") @@ -500,7 +557,7 @@ def on_order_stock_async_response(self, response): ) if is_trade_time(): self.push_message(msg, msg_type="text") - logger.info(f"on_order_stock_async_response: {msg}") + self.logger.info(f"on_order_stock_async_response: {msg}") def on_account_status(self, status): """账户状态变化推送 @@ -521,7 +578,7 @@ def on_account_status(self, status): f"账户状态:{status_map[status.status]}\n" ) - logger.info( + self.logger.info( f"账户ID: {status.account_id} " f"账号类型:{'证券账户' if status.account_type == 2 else '其他'} " f"账户状态:{status_map[status.status]}" @@ -562,7 +619,7 @@ def query_today_trades(xtt: XtQuantTrader, acc: StockAccount): return res -def cancel_timeout_orders(xtt: XtQuantTrader, acc: StockAccount, minutes=30): +def cancel_timeout_orders(xtt: XtQuantTrader, acc: StockAccount, minutes=30, **kwargs): """撤销超时的委托单 http://docs.thinktrader.net/pages/ee0e9b/#%E8%82%A1%E7%A5%A8%E5%90%8C%E6%AD%A5%E6%92%A4%E5%8D%95 @@ -572,20 +629,61 @@ def cancel_timeout_orders(xtt: XtQuantTrader, acc: StockAccount, minutes=30): :param minutes: 超时时间,单位分钟 :return: """ + logger = kwargs.get("logger", loguru.logger) orders = xtt.query_stock_orders(acc, cancelable_only=True) for o in orders: if datetime.fromtimestamp(o.order_time) < datetime.now() - timedelta(minutes=minutes): xtt.cancel_order_stock(acc, o.order_id) + logger.info(f"撤销超时委托单:{o.order_id}; {o.stock_code}; {o.order_volume}; {o.order_type}") -def is_order_exist(xtt: XtQuantTrader, acc: StockAccount, symbol, order_type, volume=None): - """判断是否存在相同的委托单 - - http://docs.thinktrader.net/pages/ee0e9b/#%E8%82%A1%E7%A5%A8%E5%90%8C%E6%AD%A5%E6%92%A4%E5%8D%95 - http://docs.thinktrader.net/pages/ee0e9b/#%E5%A7%94%E6%89%98%E6%9F%A5%E8%AF%A2 - http://docs.thinktrader.net/pages/198696/#%E5%A7%94%E6%89%98xtorder +def query_stock_orders(xtt, acc, **kwargs): + """查询股票委托单 + :param xtt: XtQuantTrader, QMT 交易接口 + :param acc: StockAccount, 账户 + :param kwargs: + cancelable_only: bool, 是否只查询可撤单的委托单 + start_time: str, 开始时间,格式:09:00:00 """ + cancelable_only = kwargs.get("cancelable_only", False) + orders = xtt.query_stock_orders(acc, cancelable_only) + + start_time = kwargs.get("start_time", "09:00:00") + rows = [] + for order in orders: + row = { + "account_id": order.account_id, + "stock_code": order.stock_code, + "order_id": order.order_id, + "order_sysid": order.order_sysid, + "order_time": order.order_time, + "order_type": order.order_type, + "order_volume": order.order_volume, + "price_type": order.price_type, + "price": order.price, + "traded_volume": order.traded_volume, + "traded_price": order.traded_price, + "order_status": order.order_status, + "status_msg": order.status_msg, + "strategy_name": order.strategy_name, + "order_remark": order.order_remark, + "direction": order.direction, + "offset_flag": order.offset_flag, + } + rows.append(row) + + dfr = pd.DataFrame(rows) + dfr["order_time"] = pd.to_datetime(dfr["order_time"], unit="s") + pd.Timedelta(hours=8) + dfr["order_date"] = dfr["order_time"].dt.strftime("%Y-%m-%d") + dfr["order_time"] = dfr["order_time"].dt.strftime("%H:%M:%S") + if start_time: + dfr = dfr[dfr["order_time"] >= start_time].copy().reset_index(drop=True) + return dfr + + +def is_order_exist(xtt: XtQuantTrader, acc: StockAccount, symbol, order_type, volume=None): + """判断是否存在相同方向的委托单""" orders = xtt.query_stock_orders(acc, cancelable_only=False) for o in orders: if o.stock_code == symbol and o.order_type == order_type: @@ -594,37 +692,38 @@ def is_order_exist(xtt: XtQuantTrader, acc: StockAccount, symbol, order_type, vo return False -def is_allow_open(xtt: XtQuantTrader, acc: StockAccount, symbol, price, **kwargs): +def is_allow_open(xtt: XtQuantTrader, acc: StockAccount, symbol, **kwargs): """判断是否允许开仓 - http://docs.thinktrader.net/pages/198696/#%E8%B5%84%E4%BA%A7xtasset + http://dict.thinktrader.net/nativeApi/xttrader.html?id=H018C2#%E8%B5%84%E4%BA%A7xtasset + :param xtt: XtQuantTrader, QMT 交易接口 + :param acc: StockAccount, 账户 :param symbol: 股票代码 - :param price: 股票现价 + :param kwargs: + + forbidden_symbols: list, 禁止交易的标的 + multiple_orders: bool, 是否允许多次下单 + :return: True 允许开仓,False 不允许开仓 """ - symbol_max_pos = kwargs.get("max_pos", 0) # 最大持仓数量 - - # 如果 symbol_max_pos 为 0,不允许开仓 - if symbol_max_pos <= 0: - return False + logger = kwargs.get("logger", loguru.logger) # 如果 symbol 在禁止交易的列表中,不允许开仓 if symbol in kwargs.get("forbidden_symbols", []): return False # 如果 未成交的开仓委托单 存在,不允许开仓 - if is_order_exist(xtt, acc, symbol, order_type=23): - logger.warning(f"存在未成交的开仓委托单,symbol={symbol}") - return False - - # 如果已经有持仓,不允许开仓 - if query_stock_positions(xtt, acc).get(symbol, None): - return False + multiple_orders = kwargs.get("multiple_orders", False) + if not multiple_orders: + if is_order_exist(xtt, acc, symbol, order_type=23): + logger.warning(f"存在未成交的开仓委托单,symbol={symbol}") + return False # 如果资金不足,不允许开仓 assets = xtt.query_stock_asset(acc) - if assets.cash < price * 120: + symbol_price = xtdata.get_full_tick([symbol])[symbol]["lastPrice"] + if assets.cash < symbol_price * 120: logger.warning(f"资金不足,无法开仓,symbol={symbol}") return False @@ -634,9 +733,18 @@ def is_allow_open(xtt: XtQuantTrader, acc: StockAccount, symbol, price, **kwargs def is_allow_exit(xtt: XtQuantTrader, acc: StockAccount, symbol, **kwargs): """判断是否允许平仓 + :param xtt: XtQuantTrader, QMT 交易接口 + :param acc: StockAccount, 账户 :param symbol: 股票代码 + :param kwargs: + + forbidden_symbols: list, 禁止交易的标的 + multiple_orders: bool, 是否允许多次下单 + :return: True 允许开仓,False 不允许开仓 """ + logger = kwargs.get("logger", loguru.logger) + # symbol 在禁止交易的列表中,不允许平仓 if symbol in kwargs.get("forbidden_symbols", []): return False @@ -644,12 +752,15 @@ def is_allow_exit(xtt: XtQuantTrader, acc: StockAccount, symbol, **kwargs): # 没有持仓 或 可用数量为 0,不允许平仓 pos = query_stock_positions(xtt, acc).get(symbol, None) if not pos or pos.can_use_volume <= 0: + logger.warning(f"没有持仓或可用数量为0,无法平仓,symbol={symbol}") return False - # 未成交的平仓委托单 存在,不允许继续平仓 - if is_order_exist(xtt, acc, symbol, order_type=24): - logger.warning(f"存在未成交的平仓委托单,symbol={symbol}") - return False + multiple_orders = kwargs.get("multiple_orders", False) + if not multiple_orders: + # 未成交的平仓委托单 存在,不允许继续平仓 + if is_order_exist(xtt, acc, symbol, order_type=24): + logger.warning(f"存在未成交的平仓委托单,symbol={symbol}") + return False return True @@ -673,6 +784,8 @@ def send_stock_order(xtt: XtQuantTrader, acc: StockAccount, **kwargs): :return: 返回下单请求序号, 成功委托后的下单请求序号为大于0的正整数, 如果为-1表示委托失败 """ + logger = kwargs.get("logger", loguru.logger) + stock_code = kwargs.get("stock_code") order_type = kwargs.get("order_type") order_volume = kwargs.get("order_volume") # 委托数量, 股票以'股'为单位, 债券以'张'为单位 @@ -706,9 +819,14 @@ def order_stock_target(xtt: XtQuantTrader, acc: StockAccount, symbol, target, ** xtconstant.LATEST_PRICE 5 最新价 xtconstant.FIX_PRICE 11 限价 - price: float, 报价价格, 如果price_type为限价, 那price为指定的价格, 否则填0 + - logger: loguru.logger, 日志记录器 + - forbidden_symbols: list, 禁止交易的标的 + - multiple_orders: bool, 是否允许多次下单 :return: """ + logger = kwargs.get("logger", loguru.logger) + # 查询持仓 pos = query_stock_positions(xtt, acc).get(symbol, None) current = pos.volume if pos else 0 @@ -721,7 +839,7 @@ def order_stock_target(xtt: XtQuantTrader, acc: StockAccount, symbol, target, ** price = kwargs.get("price", 0) # 如果目标小于当前,平仓 - if target < current: + if target < current and is_allow_exit(xtt, acc, symbol, **kwargs): delta = min(current - target, pos.can_use_volume if pos else current) logger.info(f"{symbol}平仓,目标仓位:{target},当前仓位:{current},平仓数量:{delta}") if delta != 0: @@ -731,7 +849,7 @@ def order_stock_target(xtt: XtQuantTrader, acc: StockAccount, symbol, target, ** return # 如果目标大于当前,开仓 - if target > current: + if target > current and is_allow_open(xtt, acc, symbol, **kwargs): delta = target - current logger.info(f"{symbol}开仓,目标仓位:{target},当前仓位:{current},开仓数量:{delta}") if delta != 0: @@ -761,6 +879,8 @@ def __init__(self, mini_qmt_dir, account_id, **kwargs): :param kwargs: """ + self.logger = kwargs.get("logger", loguru.logger) + self.cache_path = kwargs["cache_path"] # 交易缓存路径 os.makedirs(self.cache_path, exist_ok=True) self.symbols = kwargs.get("symbols", []) # 交易标的列表 @@ -804,7 +924,7 @@ def __create_traders(self, **kwargs): ) 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}") + self.logger.info(f"{symbol} 需要更新的K线数量:{len(news)} | 最新的K线时间是 {news[-1].dt}") for bar in news: trader.on_bar(bar) @@ -820,9 +940,9 @@ def __create_traders(self, **kwargs): traders[symbol] = trader 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}") + self.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}") + self.logger.exception(f"创建交易对象失败,symbol={symbol}, e={e}") return traders @@ -890,7 +1010,7 @@ def is_allow_open(self, symbol, price): # 如果 未成交的开仓委托单 存在,不允许开仓 if self.is_order_exist(symbol, order_type=23): - logger.warning(f"存在未成交的开仓委托单,symbol={symbol}") + self.logger.warning(f"存在未成交的开仓委托单,symbol={symbol}") return False # 如果 symbol_max_pos 为 0,不允许开仓 @@ -904,7 +1024,7 @@ def is_allow_open(self, symbol, price): # 如果资金不足,不允许开仓 assets = self.get_assets() if assets.cash < price * 120: - logger.warning(f"资金不足,无法开仓,symbol={symbol}") + self.logger.warning(f"资金不足,无法开仓,symbol={symbol}") return False return True @@ -926,7 +1046,7 @@ def is_allow_exit(self, symbol): # 未成交的平仓委托单 存在,不允许平仓 if self.is_order_exist(symbol, order_type=24): - logger.warning(f"存在未成交的平仓委托单,symbol={symbol}") + self.logger.warning(f"存在未成交的平仓委托单,symbol={symbol}") return False # 持仓可用数量为 0,不允许平仓 @@ -998,7 +1118,7 @@ def update_traders(self): 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}") + self.logger.info(f"{symbol} 需要更新的K线数量:{len(news)} | 最新的K线时间是 {news[-1].dt}") for bar in news: trader.on_bar(bar) @@ -1018,11 +1138,11 @@ def update_traders(self): self.send_stock_order(stock_code=symbol, order_type=24, order_volume=order_volume) else: - logger.info(f"{symbol} 没有需要更新的K线,最新的K线时间是 {trader.end_dt}") + self.logger.info(f"{symbol} 没有需要更新的K线,最新的K线时间是 {trader.end_dt}") if trader.get_ensemble_pos("mean") > 0: pos_info = {x.name: x.pos for x in trader.positions if x.pos != 0} - logger.info( + self.logger.info( f"{trader.end_dt} {symbol} trader pos:{pos_info} | ensemble_pos: {trader.get_ensemble_pos('mean')}" ) @@ -1031,7 +1151,7 @@ def update_traders(self): except Exception as e: self.callback.push_message(f"{symbol} 更新交易策略失败,原因是 {e}") - logger.error(f"{symbol} 更新交易策略失败,原因是 {e}") + self.logger.error(f"{symbol} 更新交易策略失败,原因是 {e}") def update_offline_traders(self): """更新全部品种策略""" @@ -1044,7 +1164,7 @@ def update_offline_traders(self): file_trader = os.path.join(self.cache_path, f"{symbol}.ct") if not os.path.exists(file_trader): - logger.error(f"{symbol} 交易对象不存在,无法更新") + self.logger.error(f"{symbol} 交易对象不存在,无法更新") continue try: @@ -1052,7 +1172,7 @@ def update_offline_traders(self): trader: CzscTrader = czsc.dill_load(file_trader) 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}") + self.logger.info(f"{symbol} 需要更新的K线数量:{len(news)} | 最新的K线时间是 {news[-1].dt}") for bar in news: trader.on_bar(bar) @@ -1074,9 +1194,9 @@ def update_offline_traders(self): traders[symbol] = trader 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}") + self.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}") + self.logger.exception(f"创建交易对象失败,symbol={symbol}, e={e}") self.traders = traders diff --git a/czsc/connectors/tq_connector.py b/czsc/connectors/tq_connector.py index fe2f744e1..91883b10d 100644 --- a/czsc/connectors/tq_connector.py +++ b/czsc/connectors/tq_connector.py @@ -9,10 +9,9 @@ 2. [使用 tqsdk 查看期货实时行情](https://s0cqcxuy3p.feishu.cn/wiki/SH3mwOU6piPqnGkRRiocQrhAnrh) """ import czsc +import loguru import pandas as pd -from loguru import logger -from typing import List, Union, Optional -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta from czsc import Freq, RawBar from tqsdk import TqApi, TqAuth, TqSim, TqBacktest, TargetPosTask, BacktestFinished, TqAccount, TqKq # noqa @@ -129,10 +128,12 @@ def create_symbol_trader(api: TqApi, symbol, **kwargs): "KQ.m@SHFE.au", "KQ.m@SHFE.sn", "KQ.m@SHFE.al", + "KQ.m@SHFE.ao", "KQ.m@SHFE.zn", "KQ.m@SHFE.cu", "KQ.m@SHFE.pb", - "KQ.m@SHFE.wr", + # "KQ.m@SHFE.wr", + "KQ.m@SHFE.br", # https://www.jiaoyixingqiu.com/shouxufei/jiaoyisuo/CZCE "KQ.m@CZCE.SA", "KQ.m@CZCE.FG", @@ -146,8 +147,11 @@ def create_symbol_trader(api: TqApi, symbol, **kwargs): "KQ.m@CZCE.PF", "KQ.m@CZCE.AP", "KQ.m@CZCE.SF", + "KQ.m@CZCE.PX", + "KQ.m@CZCE.CJ", "KQ.m@CZCE.PK", "KQ.m@CZCE.SM", + "KQ.m@CZCE.CY", "KQ.m@CZCE.RS", # https://www.jiaoyixingqiu.com/shouxufei/jiaoyisuo/DCE "KQ.m@DCE.m", @@ -170,25 +174,31 @@ def create_symbol_trader(api: TqApi, symbol, **kwargs): "KQ.m@DCE.lh", "KQ.m@DCE.rr", "KQ.m@DCE.fb", + "KQ.m@DCE.bb", # https://www.jiaoyixingqiu.com/shouxufei/jiaoyisuo/GFEX "KQ.m@GFEX.si", + "KQ.m@GFEX.lc", # https://www.jiaoyixingqiu.com/shouxufei/jiaoyisuo/INE "KQ.m@INE.lu", "KQ.m@INE.sc", "KQ.m@INE.nr", "KQ.m@INE.bc", + "KQ.m@INE.ec", # https://www.jiaoyixingqiu.com/shouxufei/jiaoyisuo/CFFEX "KQ.m@CFFEX.T", "KQ.m@CFFEX.TF", + "KQ.m@CFFEX.TS", + "KQ.m@CFFEX.TL", "KQ.m@CFFEX.IF", "KQ.m@CFFEX.IC", "KQ.m@CFFEX.IH", "KQ.m@CFFEX.IM", - "KQ.m@CFFEX.TS", ] future_name_map = { + "AO": "氧化铝", + "PX": "对二甲苯", "EC": "欧线集运", "LC": "碳酸锂", "PG": "LPG", @@ -254,6 +264,7 @@ def create_symbol_trader(api: TqApi, symbol, **kwargs): "AU": "黄金", "PB": "沪铅", "RU": "橡胶", + "BR": "合成橡胶", "HC": "热轧卷板", "BU": "沥青", "SP": "纸浆", @@ -346,8 +357,9 @@ def get_daily_backup(api: TqApi, **kwargs): return backup -def is_trade_time(quote): +def is_trade_time(quote, **kwargs): """判断当前是否是交易时间""" + logger = kwargs.get("logger", loguru.logger) trade_time = pd.Timestamp.now().strftime("%H:%M:%S") times = quote["trading_time"]["day"] + quote["trading_time"]["night"] @@ -375,6 +387,7 @@ def adjust_portfolio(api: TqApi, portfolio, account=None, **kwargs): :param kwargs: dict, 其他参数 """ + logger = kwargs.get("logger", loguru.logger) timeout = kwargs.get("timeout", 600) start_time = datetime.now() diff --git a/czsc/fsa/base.py b/czsc/fsa/base.py index 2aede0705..80b69f007 100644 --- a/czsc/fsa/base.py +++ b/czsc/fsa/base.py @@ -22,7 +22,7 @@ logger.disable(__name__) -@retry(stop=stop_after_attempt(3), wait=wait_random(min=1, max=5)) +@retry(stop=stop_after_attempt(2), wait=wait_random(min=1, max=3)) def request(method, url, headers, payload=None) -> dict: """飞书API标准请求 @@ -40,7 +40,7 @@ def request(method, url, headers, payload=None) -> dict: logger.info(f"payload: {payload}") resp = {} - if response.text[0] == '{': + if response.text[0] == "{": resp = response.json() logger.info(f"response: {resp}") else: @@ -62,32 +62,32 @@ def __init__(self, app_id, app_secret): self.app_id = app_id self.app_secret = app_secret self.host = "https://open.feishu.cn" - self.headers = {'Content-Type': 'application/json'} + self.headers = {"Content-Type": "application/json"} self.cache = dict() - def get_access_token(self, key='app_access_token'): - assert key in ['app_access_token', 'tenant_access_token'] - cache_key = 'access_token_data' + def get_access_token(self, key="app_access_token"): + assert key in ["app_access_token", "tenant_access_token"] + cache_key = "access_token_data" data = self.cache.get(cache_key, {}) - if not data or time.time() - data['update_time'] > data['expire'] * 0.8: + if not data or time.time() - data["update_time"] > data["expire"] * 0.8: url = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal" - data = request('POST', url, self.headers, {"app_id": self.app_id, "app_secret": self.app_secret}) - data['update_time'] = time.time() + data = request("POST", url, self.headers, {"app_id": self.app_id, "app_secret": self.app_secret}) + data["update_time"] = time.time() self.cache[cache_key] = data return data[key] def get_headers(self): headers = dict(self.headers) - headers['Authorization'] = f"Bearer {self.get_access_token()}" + headers["Authorization"] = f"Bearer {self.get_access_token()}" return headers def get_root_folder_token(self): """获取飞书云空间根目录 token""" url = f"{self.host}/open-apis/drive/explorer/v2/root_folder/meta" resp = request("GET", url, self.get_headers()) - return resp['data']['token'] + return resp["data"]["token"] def remove(self, token, kind): """删除用户在云空间内的文件或者文件夹。文件或者文件夹被删除后,会进入用户回收站里。 @@ -153,12 +153,17 @@ def upload_file(self, file_path, parent_node): """ file_size = os.path.getsize(file_path) url = "https://open.feishu.cn/open-apis/drive/v1/files/upload_all" - form = {'file_name': os.path.basename(file_path), 'parent_type': 'explorer', - 'parent_node': parent_node, 'size': str(file_size), 'file': (open(file_path, 'rb'))} + form = { + "file_name": os.path.basename(file_path), + "parent_type": "explorer", + "parent_node": parent_node, + "size": str(file_size), + "file": (open(file_path, "rb")), + } multi_form = MultipartEncoder(form) - headers = {'Authorization': f'Bearer {self.get_access_token()}', 'Content-Type': multi_form.content_type} + headers = {"Authorization": f"Bearer {self.get_access_token()}", "Content-Type": multi_form.content_type} response = requests.request("POST", url, headers=headers, data=multi_form) - return response.json()['data']['file_token'] + return response.json()["data"]["file_token"] def download_file(self, file_token, file_path): """使用该接口可以下载在云空间目录下的文件(不含飞书文档/表格/思维导图等在线文档) @@ -171,6 +176,6 @@ def download_file(self, file_token, file_path): """ url = f"{self.host}/open-apis/drive/v1/files/{file_token}/download" res = requests.request("GET", url, headers=self.get_headers()) - with open(file_path, 'w') as f: + with open(file_path, "w") as f: f.write(res.text) return res diff --git a/czsc/fsa/bi_table.py b/czsc/fsa/bi_table.py index 80d5d89b1..819b93567 100644 --- a/czsc/fsa/bi_table.py +++ b/czsc/fsa/bi_table.py @@ -5,6 +5,7 @@ create_dt: 2023/06/16 19:45 describe: 飞书多维表格接口 """ +import os import pandas as pd from czsc.fsa.base import FeishuApiBase, request @@ -14,30 +15,35 @@ class BiTable(FeishuApiBase): 多维表格概述: https://open.feishu.cn/document/server-docs/docs/bitable-v1/bitable-overview """ - def __init__(self, app_id, app_secret): - super().__init__(app_id, app_secret) + def __init__(self, app_id=None, app_secret=None, app_token=None): + """ - def list_tables(self, app_token): - """列出数据表 + :param app_id: 飞书应用的唯一标识 + :param app_secret: 飞书应用的密钥 + :param app_token: 一个多维表格的唯一标识。示例值:"bascnKMKGS5oD3lmCHq9euO8cGh" + """ + 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) + self.app_token = app_token - https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/list + def one_record(self, table_id, record_id): + """根据 record_id 的值检索现有记录 - :param app_token: 一个多维表格的唯一标识。示例值:"bascnKMKGS5oD3lmCHq9euO8cGh" - :return: 返回数据 + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/get """ - url = f"{self.host}/open-apis/bitable/v1/apps/{app_token}/tables" + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/{record_id}" return request("GET", url, self.get_headers()) - def list_records(self, app_token, table_id, **kwargs): + def list_records(self, table_id, **kwargs): """列出数据表中的记录 https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/list - :param app_token: 一个多维表格的唯一标识。示例值:"bascnKMKGS5oD3lmCHq9euO8cGh" :param table_id: 数据表id :return: 返回数据 """ - url = f"{self.host}/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records" if kwargs.get("page_size") is None: kwargs["page_size"] = 500 if kwargs.get("page_token") is None: @@ -45,22 +51,685 @@ def list_records(self, app_token, table_id, **kwargs): url = url + "?" + "&".join([f"{k}={v}" for k, v in kwargs.items()]) return request("GET", url, self.get_headers()) + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # 数据表相关api + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def create_table(self, name=None, default_view_name=None, fields=None): + """新增一个仅包含索引列的空数据表,也可以指定一部分初始字段。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/create + :param name:非必填 数据表名称 请注意: + 名称中的首尾空格将会被去除。 + 示例值:"table1" + 数据校验规则: + 长度范围:1 字符 ~ 100 字符 + :param default_view_name:非必填 默认表格视图的名称,不填则默认为 表格。 + :param fields: 非必填 数据表的初始字段。数组类型 + 结构: + field_name: 必填 字段名 + type:必填 字段类型 + ui_type:字段在界面上的展示类型 + property:字段属性 + description: 字段的描述 + :return: 返回数据 + """ + params = {} + if name is not None: + params["name"] = name + if default_view_name is not None: + params["default_view_name"] = default_view_name + if default_view_name is not None: + params["fields"] = fields + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables" + return request("POST", url, self.get_headers(), payload={"table": params}) + + def batch_create_table(self, names=None): + """新增多个数据表。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/batch_create + :param names:非必填 数据表名称 [] + name :数据表名称 + 请注意: + 名称中的首尾空格将会被去除。 + 示例值:"table1" + 数据校验规则: + 长度范围:1 字符 ~ 100 字符 + :return: 返回数据 + """ + params = [] + if names is not None: + for name in names: + params.append({"name": name}) + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/batch_create" + return request("POST", url, self.get_headers(), payload={"tables": params}) + + def delete_table(self, table_id): + """删除一个数据表,最后一张数据表不允许被删除。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/delete + :param table_id:多维表格数据表的唯一标识符 + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}" + return request("DELETE", url, self.get_headers()) + + def batch_delete_table(self, table_ids=None): + """删除一个数据表,最后一张数据表不允许被删除。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/batch_delete + :param table_ids: 待删除的数据表的id [table_id 参数说明],当前一次操作最多支持50个数据表 + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/batch_delete" + return request("POST", url, self.get_headers(), payload={"table_ids": table_ids}) + + def patch_table(self, table_id, name): + """更新数据表的基本信息,包括数据表的名称等 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/patch + :param table_id: 多维表格数据表的唯一标识符 + :param name: 数据表的新名称。请注意: + 名称中的首尾空格将会被去除。 + 如果名称为空或和旧名称相同,接口仍然会返回成功,但是名称不会被更改。 + 示例值:"数据表的新名称" + 数据校验规则: + 长度范围:1 字符 ~ 100 字符 + 正则校验:^[^\[\]\:\\\/\?\*]+$ + + :return: 返回数据 + """ + params = {} + if name is not None: + params["name"] = name + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}" + return request("PATCH", url, self.get_headers(), payload=params) + + def list_tables(self, page_token=None, page_size=20): + """获取多维表格下的所有数据表。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table/list + :param page_token: 分页标记,第一次请求不填,表示从头开始遍历;分页查询结果还有更多项时会同时返回新的 page_token, + 下次遍历可采用该 page_token 获取查询结果 + :param page_size: 分页大小示例值:10 默认值:20 数据校验规则:最大值:100 + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables?page_size={page_size}" + url = url if page_token is None else url + f"&page_token={page_token}" + return request("GET", url, self.get_headers()) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # 记录相关api + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def table_record_get( + self, + table_id, + record_id, + text_field_as_array=None, + user_id_type=None, + display_formula_ref=None, + with_shared_url=None, + automatic_fields=None, + ): + """获取多维表格下的所有数据表。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/get + :param table_id: table id + :param record_id: 单条记录的 id + :param text_field_as_array: 非必需 多行文本字段数据是否以数组形式返回。true 表示以数组形式返回。默认为 false + :param user_id_type: 非必需 用户 ID 类型 + :param display_formula_ref: 控制公式、查找引用是否显示完整原样的返回结果。默认为 false + :param with_shared_url: 非必需 控制是否返回该记录的链接,即 record_url 参数。默认为 false,即不返回 + :param automatic_fields: 非必需 控制是否返回自动计算的字段,例如 created_by/created_time/last_modified_by/last_modified_time,true 表示返回。默认为 false + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/{record_id}?1=1" + url = url if text_field_as_array is None else url + f"&text_field_as_array={text_field_as_array}" + url = url if user_id_type is None else url + f"&user_id_type={user_id_type}" + url = url if display_formula_ref is None else url + f"&display_formula_ref={display_formula_ref}" + url = url if with_shared_url is None else url + f"&with_shared_url={with_shared_url}" + url = url if automatic_fields is None else url + f"&automatic_fields={automatic_fields}" + return request("GET", url, self.get_headers()) + + def table_record_search( + self, + table_id, + user_id_type=None, + page_token=None, + page_size=20, + view_id=None, + field_names=None, + sort=None, + filter=None, + automatic_fields=None, + ): + """查询数据表中的现有记录,单次最多查询 500 行记录,支持分页获取 + + https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/bitable-v1/app-table-record/search + :param table_id: table id + :param user_id_type: 非必需 用户 ID 类型 + :param page_token: 非必需 分页标记,第一次请求不填,表示从头开始遍历;分页查询结果还有更多项时会同时返回新的 page_token,下次遍历可采用该 page_token 获取查询结果 + :param page_size: 非必需 分页大小。最大值为 500 + + :param view_id: 非必需 视图的唯一标识符,获取指定视图下的记录view_id 参数说明 注意:当 filter 参数 或 sort 参数不为空时,请求视为对数据表中的全部数据做条件过滤,指定的view_id 会被忽略。数据校验规则:长度范围:0 字符 ~ 50 字符 + :param field_names: 非必需 字段名称,用于指定本次查询返回记录中包含的字段 + :param sort: 非必需 sort[] 排序条件 + field_name: 非必需 字段名称 示例值:"多行文本" 数据校验规则:长度范围:0 字符 ~ 1000 字符 + desc:非必需 是否倒序排序 示例值:true 默认值:false + :param filter: 非必需 筛选条件 + :param automatic_fields: 非必需 控制是否返回自动计算的字段, true 表示返回 + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/search?page_size={page_size}" + url = url if user_id_type is None else url + f"&user_id_type={user_id_type}" + url = url if page_token is None else url + f"&page_token={page_token}" + + params = {} + if view_id is not None: + params["view_id"] = view_id + if field_names is not None: + params["field_names"] = field_names + if sort is not None: + params["sort"] = sort + if filter is not None: + params["filter"] = filter + if automatic_fields is not None: + params["automatic_fields"] = automatic_fields + return request("POST", url, self.get_headers(), payload=params) + + def table_record_create(self, table_id, fields, user_id_type=None, client_token=None): + """数据表中新增一条记录 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/create + :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" + 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}" + return request("POST", url, self.get_headers(), payload={"fields": fields}) + + def table_record_update(self, table_id, record_id, fields, user_id_type=None): + """更新数据表中的一条记录 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/update + :param table_id: table id + :param record_id: 一条记录的唯一标识 id + + :param user_id_type: 非必需 用户 ID 类型 + + :param fields: 必需 + 数据表的字段,即数据表的列。当前接口支持的字段类型为:多行文本、单选、条码、多选、日期、人员、附件、复选框、超链接、数字、单向关联、双向关联、电话号码、地理位置。详情参考 + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/{record_id}/?1=1" + url = url if user_id_type is None else url + f"&user_id_type={user_id_type}" + return request("PUT", url, self.get_headers(), payload={"fields": fields}) + + 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): + """在数据表中新增多条记录,单次调用最多新增 500 条记录。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/batch_create + :param table_id: table id + + :param user_id_type: 非必需 用户 ID 类型 + :param client_token: 非必需 格式为标准的 uuidv4,操作的唯一标识,用于幂等的进行更新操作。此值为空表示将发起一次新的请求,此值非空表示幂等的进行更新操作。 + + :param fields:[] 数据表的字段,即数据表的列当前接口支持的字段类型 示例值:{"多行文本":"HelloWorld"} + :return: 返回数据 + """ + 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}" + return request("POST", url, self.get_headers(), payload={"records": records}) + + def table_record_batch_update(self, table_id, records, user_id_type=None): + """更新数据表中的多条记录,单次调用最多更新 500 条记录。 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/batch_update + :param table_id: table id + + :param user_id_type: 非必需 用户 ID 类型 + + :param records:[] 记录 + [{ + "record_id": "reclAqylTN", + "fields": { + "索引": "索引列多行文本类型" + } + }] + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/batch_update?1=1" + url = url if user_id_type is None else url + f"&user_id_type={user_id_type}" + return request("POST", url, self.get_headers(), payload={"records": records}) + + def table_record_batch_delete(self, table_id, record_ids): + """删除数据表中现有的多条记录,单次调用中最多删除 500 条记录 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-record/batch_delete + :param table_id: table id + + :param record_ids:string[] 删除的多条记录id列表示例值:["recwNXzPQv"] + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/records/batch_delete" + return request("POST", url, self.get_headers(), payload={"records": record_ids}) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # 视图相关api + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def table_view_patch(self, table_id, view_id, infos): + """增量修改视图信息 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-view/patch + :param table_id: table id + :param view_id: 视图 ID + + :param infos: 修改信息 + view_name: 视图名称 + property: 非必需 视图属性 + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/views/{view_id}" + return request("PATCH", url, self.get_headers(), payload=infos) + + def table_view_get(self, table_id, view_id): + """根据 view_id 检索现有视图 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-view/get + :param table_id: table id + :param view_id: 视图 ID + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/views/{view_id}" + return request("GET", url, self.get_headers()) + + def table_view_list(self, table_id, page_size=20, user_id_type=None, page_token=None): + """根据 app_token 和 table_id,获取数据表的所有视图 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-view/list + :param table_id: table id + + :param user_id_type: 非必需 用户 ID 类型 示例值:"open_id" + :param page_token:非必需 分页标记,第一次请求不填,表示从头开始遍历;分页查询结果还有更多项时会同时返回新的 page_token,下次遍历可采用该 page_token 获取查询结果 + :param page_size:非必需 分页大小 示例值:10 默认值:20 数据校验规则:最大值:100 + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/views?page_size={page_size}" + url = url if user_id_type is None else url + f"&user_id_type={user_id_type}" + url = url if page_token is None else url + f"&page_token={page_token}" + return request("GET", url, self.get_headers()) + + def table_view_create(self, table_id, view_name, view_type): + """在数据表中新增一个视图 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-view/create + :param table_id: table id + + :param view_name: 视图名字 + :param view_type: 视图类型 示例值:"grid" + 可选值有: + grid:表格视图 + kanban:看板视图 + gallery:画册视图 + gantt:甘特视图 + form:表单视图 + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/views" + return request("POST", url, self.get_headers(), payload={"view_name": view_name, "view_type": view_type}) + + def table_view_delete(self, table_id, view_id): + """在数据表中新增一个视图 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-view/create + :param table_id: table id + + :param view_id: 视图id + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/views/{view_id}" + return request("DELETE", url, self.get_headers()) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # 字段相关api + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def table_field_list(self, table_id, page_size=20, view_id=None, text_field_as_array=None, page_token=None): + """根据 app_token 和 table_id,获取数据表的所有字段 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-field/list + :param table_id: table id + + :param view_id: 视图 ID + :param text_field_as_array: 控制字段描述(多行文本格式)数据的返回格式, true 表示以数组富文本形式返回 + :param page_token: 分页标记,第一次请求不填,表示从头开始遍历;分页查询结果还有更多项时会同时返回新的 page_token,下次遍历可采用该 page_token 获取查询结果 + :param page_size: 分页大小 示例值:10 默认值:20 数据校验规则:最大值:100 + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields?page_size={page_size}" + url = url if view_id is None else url + f"&view_id={view_id}" + url = url if text_field_as_array is None else url + f"&text_field_as_array={text_field_as_array}" + url = url if page_token is None else url + f"&page_token={page_token}" + return request("GET", url, self.get_headers()) + + def table_field_create( + self, table_id, field_name, type, property=None, description=None, ui_type=None, client_token=None + ): + """在数据表中新增一个字段 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-field/create + :param table_id: table id + + :param client_token: 格式为标准的 uuidv4,操作的唯一标识,用于幂等的进行更新操作。此值为空表示将发起一次新的请求,此值非空表示幂等的进行更新操作。 + + :param field_name: 多维表格字段名 + :param type: 多维表格字段类型 + 可选值有:1:多行文本 2:数字 3:单选 4:多选5:日期7:复选框11:人员13:电话号码15:超链接17:附件18:关联20:公式21:双向关联22:地理位置23:群组1001:创建时间1002:最后更新时间1003:创建人1004:修改人1005:自动编号 + :param property: 字段属性 + :param description: 字段的描述 + :param ui_type: 字段在界面上的展示类型,例如进度字段是数字的一种展示形态 + 示例值:"Progress" + 可选值有:Text:多行文本 Email:邮箱地址 Barcode:条码 Number:数字 Progress:进度 Currency:货币 Rating:评分 SingleSelect:单选 MultiSelect:多选 DateTime:日期 Checkbox:复选框 User:人员 GroupChat:群组 Phone:电话号码 Url:超链接 Attachment:附件 SingleLink:单向关联 Formula:公式 DuplexLink:双向关联 Location:地理位置 CreatedTime:创建时间 ModifiedTime:最后更新时间 CreatedUser:创建人 ModifiedUser:修改人 AutoNumber:自动编号 + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields?1=1" + url = url if client_token is None else url + f"&client_token={client_token}" + + params = {} + if field_name is not None: + params["field_name"] = field_name + if type is not None: + params["type"] = type + if property is not None: + params["property"] = property + if description is not None: + params["description"] = description + if ui_type is not None: + params["ui_type"] = ui_type + + return request("POST", url, self.get_headers(), payload=params) + + def table_field_update(self, table_id, field_id, field_name, type, property=None, description=None, ui_type=None): + """数据表中更新一个字段 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-field/update + :param table_id: table id + + :param field_id: field id + + :param field_name: 多维表格字段名 + :param type: 多维表格字段类型 + 可选值有:1:多行文本 2:数字 3:单选 4:多选5:日期7:复选框11:人员13:电话号码15:超链接17:附件18:关联20:公式21:双向关联22:地理位置23:群组1001:创建时间1002:最后更新时间1003:创建人1004:修改人1005:自动编号 + :param property: 字段属性 + :param description: 字段的描述 + :param ui_type: 字段在界面上的展示类型,例如进度字段是数字的一种展示形态 + 示例值:"Progress" + 可选值有:Text:多行文本 Email:邮箱地址 Barcode:条码 Number:数字 Progress:进度 Currency:货币 Rating:评分 SingleSelect:单选 MultiSelect:多选 DateTime:日期 Checkbox:复选框 User:人员 GroupChat:群组 Phone:电话号码 Url:超链接 Attachment:附件 SingleLink:单向关联 Formula:公式 DuplexLink:双向关联 Location:地理位置 CreatedTime:创建时间 ModifiedTime:最后更新时间 CreatedUser:创建人 ModifiedUser:修改人 AutoNumber:自动编号 + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields/{field_id}" + + params = {} + if field_name is not None: + params["field_name"] = field_name + if type is not None: + params["type"] = type + if property is not None: + params["property"] = property + if description is not None: + params["description"] = description + if ui_type is not None: + params["ui_type"] = ui_type + + return request("PUT", url, self.get_headers(), payload=params) + + def table_field_delete(self, table_id, field_id): + """数据表中删除一个字段 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app-table-field/delete + :param table_id: table id + + :param field_id: field id + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/fields/{field_id}" + return request("DELETE", url, self.get_headers()) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # 表单相关api + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def table_form_patch_2( + self, table_id, form_id, name=None, description=None, shared=None, shared_limit=None, submit_limit_once=None + ): + """更新表单中的元数据项 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/form/patch-2 + :param table_id: table id + + :param field_id: field id + + :param name: 非必需 表单名称 + :param description: 非必需 表单描述 + :param shared: 非必需 是否开启共享 + :param shared_limit: 非必需 分享范围限制 示例值:"tenant_editable" 可选值有:off:仅邀请的人可填写 tenant_editable:组织内获得链接的人可填写 anyone_editable:互联网上获得链接的人可填写 + :param submit_limit_once: 非必需 填写次数限制一次 示例值:true + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/forms/{form_id}" + params = {} + if name is not None: + params["name"] = name + if description is not None: + params["description"] = description + if shared is not None: + params["shared"] = shared + if shared_limit is not None: + params["shared_limit"] = shared_limit + if submit_limit_once is not None: + params["submit_limit_once"] = submit_limit_once + return request("PATCH", url, self.get_headers(), payload=params) + + def table_form_get(self, table_id, form_id): + """列出表单的所有问题项 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/form/list + :param table_id: table id + + :param field_id: field id + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/forms/{form_id}" + return request("GET", url, self.get_headers()) + + def table_form_patch( + self, table_id, form_id, field_id, pre_field_id=None, title=None, description=None, required=None, visible=None + ): + """更新表单中的问题项 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/form/patch + :param table_id: table id + + :param field_id: field id + + :param pre_field_id: 非必需 上一个表单问题 ID,用于支持调整表单问题的顺序,通过前一个表单问题的 field_id 来确定位置;如果 pre_field_id 为空字符串,则说明要排到首个表单问题 + :param title: 非必需 表单问题 + :param description: 非必需 问题描述 + :param required: 非必需 是否必填 + :param visible: 非必需 是否可见,当值为 false 时,不允许更新其他字段 + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/forms/{form_id}/fields/{field_id}" + params = {} + if pre_field_id is not None: + params["pre_field_id"] = pre_field_id + if description is not None: + params["description"] = description + if title is not None: + params["title"] = title + if required is not None: + params["required"] = required + if visible is not None: + params["visible"] = visible + return request("PATCH", url, self.get_headers(), payload=params) + + def table_form_list(self, table_id, form_id): + """列出表单的所有问题项 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/form/list + :param table_id: table id + + :param field_id: field id + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps/{self.app_token}/tables/{table_id}/forms/{form_id}/fields" + return request("GET", url, self.get_headers()) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # 多维表格相关api + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def table_copy(self, app_token=None, name=None, folder_token=None, without_content=None, time_zone=None): + """复制一个多维表格,可以指定复制到某个有权限的文件夹下 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app/copy + + :param app_token: app_token 不传复制当前表格 + :param name: 多维表格 App 名字 + :param folder_token: 多维表格 App 归属文件夹 + :param without_content: 是否复制多维表格内容,取值:true:不复制 false:复制 + :param time_zone: 文档时区 示例值:"Asia/Shanghai" + :return: 返回数据 + """ + if app_token is None: + app_token = self.app_token + url = f"{self.host}/open-apis/bitable/v1/apps/{app_token}/copy" + params = {} + if name is not None: + params["name"] = name + if folder_token is not None: + params["folder_token"] = folder_token + if without_content is not None: + params["without_content"] = without_content + if time_zone is not None: + params["time_zone"] = time_zone + return request("POST", url, self.get_headers(), payload=params) + + def table_create(self, name=None, folder_token=None, time_zone=None): + """复制一个多维表格,可以指定复制到某个有权限的文件夹下 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app/create + :param name: 多维表格 App 名字 + :param folder_token: 多维表格 App 归属文件夹 + :param time_zone: 文档时区 示例值:"Asia/Shanghai" + + :return: 返回数据 + """ + url = f"{self.host}/open-apis/bitable/v1/apps" + params = {} + if name is not None: + params["name"] = name + if folder_token is not None: + params["folder_token"] = folder_token + if time_zone is not None: + params["time_zone"] = time_zone + return request("POST", url, self.get_headers(), payload=params) + + def table_get(self, app_token=None): + """复制一个多维表格,可以指定复制到某个有权限的文件夹下 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app/get + + :param app_token: 不传获取当前表格 + :return: 返回数据 + """ + if app_token is None: + app_token = self.app_token + url = f"{self.host}/open-apis/bitable/v1/apps/{app_token}" + return request("GET", url, self.get_headers()) + + def table_update(self, app_token=None, name=None, is_advanced=None): + """通过 app_token 更新多维表格元数据 + + https://open.feishu.cn/document/server-docs/docs/bitable-v1/app/update + :param app_token: 不传修改当前表格 + :param name: 新的多维表格名字 + :param is_advanced: 多维表格是否开启高级权限 + + :return: 返回数据 + """ + if app_token is None: + app_token = self.app_token + url = f"{self.host}/open-apis/bitable/v1/apps/{app_token}" + + params = {} + if name is not None: + params["name"] = name + if is_advanced is not None: + params["is_advanced"] = is_advanced + return request("PUT", url, self.get_headers(), payload=params) + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # 以下是便捷使用的封装,非官方API接口 # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - def read_table(self, app_token, table_id, **kwargs): + + @property + def tables(self): + """获取所有表格 + + :return: + """ + res = self.list_tables() + return res["data"]["items"] + + def read_table(self, table_id, **kwargs): """读取多维表格中指定表格的数据 - :param app_token: 一个多维表格的唯一标识。示例值:"bascnKMKGS5oD3lmCHq9euO8cGh" :param table_id: 表格id :return: """ rows = [] - res = self.list_records(app_token, table_id, **kwargs)["data"] + res = self.list_records(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"] + res = self.list_records(table_id, page_token=res["page_token"], **kwargs)["data"] rows.extend(res["items"]) assert len(rows) == total, "数据读取异常" diff --git a/czsc/utils/__init__.py b/czsc/utils/__init__.py index 8e10b517a..c991f5b0c 100644 --- a/czsc/utils/__init__.py +++ b/czsc/utils/__init__.py @@ -35,6 +35,7 @@ from .oss import AliyunOSS from .optuna import optuna_study, optuna_good_params from .events import overlap +from .fernet import generate_fernet_key, fernet_encrypt, fernet_decrypt sorted_freqs = [ @@ -181,3 +182,20 @@ def print_df_sample(df, n=5): from tabulate import tabulate print(tabulate(df.head(n).values, headers=df.columns, tablefmt="rst")) + + +def mac_address(): + """获取本机 MAC 地址 + + MAC地址(英语:Media Access Control Address),直译为媒体访问控制地址,也称为局域网地址(LAN Address), + 以太网地址(Ethernet Address)或物理地址(Physical Address),它是一个用来确认网络设备位置的地址。在OSI模 + 型中,第三层网络层负责IP地址,第二层数据链接层则负责MAC地址。MAC地址用于在网络中唯一标示一个网卡,一台设备若有一 + 或多个网卡,则每个网卡都需要并会有一个唯一的MAC地址。 + + :return: 本机 MAC 地址 + """ + import uuid + + x = uuid.UUID(int=uuid.getnode()).hex[-12:].upper() + x = "-".join([x[i : i + 2] for i in range(0, 11, 2)]) + return x diff --git a/czsc/utils/fernet.py b/czsc/utils/fernet.py new file mode 100644 index 000000000..0ddadcfdd --- /dev/null +++ b/czsc/utils/fernet.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" +author: zengbin93 +email: zeng_bin8888@163.com +create_dt: 2024/07/11 12:39 +describe: Fernet 加密解密 +""" +import os +from typing import Union + +from cryptography.fernet import Fernet + + +def generate_fernet_key(): + """生成 Fernet key + + 等价于:base64.urlsafe_b64encode(os.urandom(32)) + """ + key = Fernet.generate_key() + return key.decode() + + +def fernet_encrypt(data: Union[dict, str], key: str = None) -> str: + """加密文本/字典 + + :param data: 需要加密的文本、字典 + :param key: Fernet key must be 32 url-safe base64-encoded bytes. + 推荐使用 generate_fernet_key() 生成 + :return: 加密后的文本 + """ + key = key or os.getenv("FERNET_KEY") + cipher_suite = Fernet(key.encode()) + encrypted_text = cipher_suite.encrypt(str(data).encode()).decode() + return encrypted_text + + +def fernet_decrypt(data: str, key: str = None, is_dict=False) -> str: + """解密文本 + + :param data: 需要解密的文本 + :param key: Fernet key must be 32 url-safe base64-encoded bytes. + 推荐使用 generate_fernet_key() 生成 + :param is_dict: 是否解密字典数据 + :return: 解密后的文本 + """ + key = key or os.getenv("FERNET_KEY") + cipher_suite = Fernet(key.encode()) + decrypted_text = cipher_suite.decrypt(data.encode()).decode() + return eval(decrypted_text) if is_dict else decrypted_text + + +def test(): + key = generate_fernet_key() + # key = 'HYtUW7y0HOMQySGmOiDHztUGaHC-WnBVh-yqn11Tszw=' + text = {"account": "admin", "password": "123456"} + encrypted = fernet_encrypt(text, key) + decrypted = fernet_decrypt(encrypted, key, is_dict=True) + assert text == decrypted, f"{text} != {decrypted}" diff --git a/czsc/utils/st_components.py b/czsc/utils/st_components.py index ea876124a..3301f0785 100644 --- a/czsc/utils/st_components.py +++ b/czsc/utils/st_components.py @@ -1041,7 +1041,6 @@ def show_event_return(df, factor, **kwargs): :param kwargs: dict, 其他参数 - sub_title: str, 子标题 - - max_overlap: int, 事件最大重叠次数 - max_unique: int, 因子独立值最大数量 """ @@ -1059,21 +1058,28 @@ def show_event_return(df, factor, **kwargs): c1, c2, c3, c4 = st.columns([1, 1, 1, 1]) agg_method = c1.selectbox( "聚合方法", - ["平均收益率", "收益中位数", "最小收益率", "盈亏比", "交易胜率"], + [ + "平均收益率", + "收益中位数", + "盈亏比", + "交易胜率", + "前20%平均收益率", + "后20%平均收益率", + ], index=0, key=f"agg_method_{factor}", ) 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=5, min_value=1, max_value=20) + max_overlap = c4.number_input("最大重叠次数", value=3, min_value=1, max_value=20) df[factor] = df[factor].astype(str) df = czsc.overlap(df, factor, new_col="overlap", max_overlap=max_overlap) df = df[(df["dt"] >= sdt) & (df["dt"] <= edt)].copy() - st.write( - f"时间范围:{df['dt'].min().strftime('%Y%m%d')} ~ {df['dt'].max().strftime('%Y%m%d')};聚合方法:{agg_method}" - ) + sdt = df["dt"].min().strftime("%Y-%m-%d") + edt = df["dt"].max().strftime("%Y-%m-%d") + st.write(f"时间范围:{sdt} ~ {edt};聚合方法:{agg_method}") nb_cols = [x for x in df.columns.to_list() if x.startswith("n") and x.endswith("b")] if agg_method == "平均收益率": @@ -1082,8 +1088,11 @@ def show_event_return(df, factor, **kwargs): if agg_method == "收益中位数": agg_method = lambda x: np.median(x) - if agg_method == "最小收益率": - agg_method = lambda x: np.min(x) + if agg_method == "前20%平均收益率": + agg_method = lambda x: np.mean(sorted(x)[int(len(x) * 0.8) :]) + + if agg_method == "后20%平均收益率": + agg_method = lambda x: np.mean(sorted(x)[: int(len(x) * 0.2)]) if agg_method == "盈亏比": agg_method = lambda x: np.mean([y for y in x if y > 0]) / abs(np.mean([y for y in x if y < 0])) diff --git a/examples/gm_0701.py b/examples/gm_0701.py new file mode 100644 index 000000000..5bc451c7c --- /dev/null +++ b/examples/gm_0701.py @@ -0,0 +1,505 @@ +# -*- coding: utf-8 -*- + +# 自行创建 .env 文件,并使用 dotenv 加载环境变量 +import dotenv +import pytz + +dotenv.load_dotenv() +from czsc.connectors.gm_connector import * +import czsc +from datetime import datetime +import json + +import os + +underlyer_symbols = [ + 'CZCE.AP', + 'CZCE.CF', + 'CZCE.CJ', + 'CZCE.CY', + 'CZCE.ER', + 'CZCE.FG', + 'CZCE.GN', + 'CZCE.JR', + 'CZCE.LR', + 'CZCE.MA', + 'CZCE.ME', + 'CZCE.OI', + 'CZCE.PF', + 'CZCE.PK', + 'CZCE.PM', + 'CZCE.PX', + 'CZCE.RI', + 'CZCE.RM', + 'CZCE.RO', + 'CZCE.RS', + 'CZCE.SA', + 'CZCE.SF', + 'CZCE.SH', + 'CZCE.SM', + 'CZCE.SR', + 'CZCE.TA', + 'CZCE.TC', + 'CZCE.UR', + 'CZCE.WH', + 'CZCE.WS', + 'CZCE.WT', + 'CZCE.ZC', + 'DCE.A', + 'DCE.B', + 'DCE.BB', + 'DCE.C', + 'DCE.CS', + 'DCE.EB', + 'DCE.EG', + 'DCE.FB', + 'DCE.I', + 'DCE.J', + 'DCE.JD', + 'DCE.JM', + 'DCE.L', + 'DCE.LH', + 'DCE.M', + 'DCE.P', + 'DCE.PG', + 'DCE.PP', + 'DCE.RR', + 'DCE.V', + 'DCE.Y', + 'GFEX.LC', + 'GFEX.SI', + 'INE.BC', + 'INE.LU', + 'INE.NR', + 'INE.SC', + 'SHFE.AG', + 'SHFE.AL', + 'SHFE.AO', + 'SHFE.AU', + 'SHFE.BR', + 'SHFE.BU', + 'SHFE.CU', + 'SHFE.FU', + 'SHFE.HC', + 'SHFE.NI', + 'SHFE.PB', + 'SHFE.RB', + 'SHFE.RU', + 'SHFE.SN', + 'SHFE.SP', + 'SHFE.SS', + 'SHFE.WR', + 'SHFE.ZN', + +] + + +def init(context): + """ + 1. 初始化gm_log 日志记录器 + 2. 读取accountID + 3. 初始化策略配置,包括了trader,symbol_max_pos等信息 + 4. 初始化定时任务,主要备份和报告账户信息 + + """ + context.account_id = os.environ.get("gm_accountID") + context.strategyname = os.path.basename(__file__) + # # 创建文件目录和日志目录 + init_context_universal(context, context.strategyname) + # 初始化trader,symbol_max_pos等信息 + init_config(context) + # 初始化定时任务 + init_context_schedule(context) + + +def init_context_universal(context, name): + """通用 context 初始化:1、创建文件目录和日志记录 + + :param context: + :param name: 交易策略名称,建议使用英文 + """ + path_gm_logs = os.environ.get('path_gm_logs', None) + if context.mode == MODE_BACKTEST: + data_path = os.path.join(path_gm_logs, f"backtest/{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}") + else: + data_path = os.path.join(path_gm_logs, f"realtime/{name}") + os.makedirs(data_path, exist_ok=True) + + context.name = name + context.data_path = data_path + context.stocks = get_symbol_names() + + logger.add(os.path.join(data_path, "gm_trader.log"), rotation="500MB", + encoding="utf-8", enqueue=True, retention="1 days") + logger.info("运行配置:") + logger.info(f"data_path = {data_path}") + + if context.mode == MODE_BACKTEST: + logger.info("backtest_start_time = " + str(context.backtest_start_time)) + logger.info("backtest_end_time = " + str(context.backtest_end_time)) + + +def init_config(context): + """初始化策略配置""" + now_str = context.now.strftime('%Y-%m-%d') + # 主力合约 + if context.now.hour > 18: + date = get_next_n_trading_dates(exchange='SHSE', date=now_str, n=1)[0] + # logger.info(date) + else: + date = context.now.strftime('%Y-%m-%d') + # logger.info(date) + + # 货值等权配置品种最大手数 + pos_multiplier1 = { + "CFFEX.IF": 1, + "SHFE.RB": 1, + 'SHFE.RU': 1, + 'SHFE.SN': 1, + 'SHFE.SP': 1, + 'SHFE.SS': 1, + 'SHFE.WR': 1, + 'SHFE.ZN': 1, + 'SHFE.AG': 1, + 'SHFE.AL': 1, + 'SHFE.AO': 1, + 'SHFE.AU': 1, + 'SHFE.BR': 1, + 'SHFE.BU': 1, + 'SHFE.CU': 1, + 'SHFE.FU': 1, + 'SHFE.HC': 1, + 'SHFE.NI': 1, + 'SHFE.PB': 1, + } + + pos_multiplier2 = { + "CFFEX.IC": 1, + } + # 以 json 文件配置的策略,每个json文件对应一个持仓策略配置 + files_position1 = [ + r"C:\趋势研究\完全分类\15分钟趋势突破海龟1策略多60#10#60分钟完全分类16#10.json", + r"C:\趋势研究\完全分类\15分钟趋势突破海龟1策略多60#10#日线完全分类16#10.json", + + ] + + files_position2 = [ + r"C:\趋势研究\完全分类\15分钟趋势突破海龟1策略多60#10#日线完全分类9#60.json", + r"C:\趋势研究\完全分类\15分钟趋势突破海龟1策略多60#10#日线完全分类9#120.json", + ] + # feishu_key = "d1a3d99f-5d74-41f2-b8b1-2517750d2ea6" + context.feishu_key = "05ecf70e-f65a-4bd8-985a-5a232b903225" + + context.symbol_metas = {} + for symbol, _ in pos_multiplier1.items(): + # 取主力合约 + csymbol = \ + fut_get_continuous_contracts(symbol, start_date=date, end_date=date)[0]['symbol'] + # print(csymbol) + meta = create_symbol_trader(context, csymbol, files_position=files_position1, sdt="2024-07-01") + context.symbol_metas[csymbol] = meta + context.symbol_metas[csymbol]['max_pos'] = pos_multiplier1.get(symbol) + + # context.symbol_metas[csymbol]['tactic'] = czsc.CzscJsonStrategy(symbol=symbol, files_position=files_position1) + + for symbol, _ in pos_multiplier2.items(): + # 取主力合约 + csymbol = \ + fut_get_continuous_contracts(symbol, start_date=date, end_date=date)[0]['symbol'] + # print(csymbol) + meta = create_symbol_trader(context, csymbol, files_position=files_position2, sdt="2024-07-01") + context.symbol_metas[csymbol] = meta + context.symbol_metas[csymbol]['max_pos'] = pos_multiplier2.get(symbol) + + # logger.info(context.symbol_metas) + logger.info(f"策略初始化完成:{list(context.symbol_metas.keys())}") + + # 订阅行情 + subscribe(list(context.symbol_metas.keys()), '900s', count=100, + fields='symbol,eob,open,close,high,low,volume,amount', + unsubscribe_previous=True, format='df') + + # 有持仓时,检查持仓的合约是否为主力合约,非主力合约则卖出 + context.accountID = os.environ.get("gm_accountID") + Account_positions = get_position(context.accountID) + if Account_positions: + for posi in Account_positions: + if posi['symbol'] not in context.symbol_metas.keys(): + print('{}:持仓合约由{}替换为主力合约'.format(context.now, posi['symbol'])) + new_price = current(symbols=posi['symbol'])[0]['price'] + order_target_volume(symbol=posi['symbol'], + volume=0, + position_side=posi['side'], + order_type=OrderType_Limit, + price=new_price) + + +def on_bar(context, bars): + # pass + + context.unfinished_orders = get_unfinished_orders() + cancel_timeout_orders(context, max_m=30) + # 更新trader + for bar in bars: + symbol = bar['symbol'] + logger.info(f"{symbol} - {bar} - {bar['eob']}") + trader: CzscTrader = context.symbol_metas[symbol]['trader'] + + base_freq = trader.base_freq + bars = context.data(symbol=symbol, frequency=freq_cn2gm[base_freq], count=10, + fields='symbol,eob,open,close,high,low,volume,amount') + + bars = format_kline(bars, freq=trader.bg.freq_map[base_freq]) + bars_new = [x for x in bars if x.dt > trader.end_dt and x.vol > 0] + logger.info(f"{symbol} - {bars_new[-1]}") + # + if not bars_new: + logger.warning(f"{symbol} 没有新的K线") + continue + # + if bars_new: + for bar_ in bars_new: + trader.update(bar_) + + if trader.pos_changed: + # 消息推送,必须放在 is_changing 判断之后,这样可以保证消息的准确,同时不推送大量重复消息 + send_trader_change(trader, feishu_key=context.feishu_key, ensemble_method="mean", + symbol_max_pos=context.symbol_metas[symbol]['max_pos'], ensemble_desc="取均值") + + sync_position(context, trader) + + +def create_symbol_trader(context, symbol, **kwargs): + """创建一个品种的 CzscTrader, 回测与实盘场景同样适用 + + :param symbol: 合约代码 + """ + adj_type = kwargs.get("adj_type", ADJUST_PREV) + files_position = kwargs.get("files_position") + tactic = czsc.CzscJsonStrategy(symbol=symbol, files_position=files_position) + frequency = int(tactic.base_freq.strip("分钟")) * 60 + kline = history_n(symbol, f'{frequency}s', count=1000, end_time=None, + fields="symbol,eob,open,close,high,low,volume,amount", + skip_suspended=True, fill_missing=None, adjust=adj_type, adjust_end_time='', df=True) + raw_bars = format_kline(kline, freq=tactic.base_freq) + # print(raw_bars[-1]) + # print(raw_bars[-2]) + if kwargs.get("sdt"): + sdt = pd.to_datetime(kwargs.get("sdt")).date() + else: + sdt = (pd.Timestamp.now() - pd.Timedelta(days=1)).date() + + os.makedirs(os.path.join(context.data_path, f'traders'), exist_ok=True) + + try: + file_trader = os.path.join(context.data_path, f'traders/{symbol}.ct') + + if os.path.exists(file_trader) and context.mode != MODE_BACKTEST: + trader: CzscTrader = dill.load(open(file_trader, 'rb')) + logger.info(f"{symbol} Loaded Trader from {file_trader}") + + else: + trader = tactic.init_trader(raw_bars, sdt=sdt) + dill.dump(trader, open(file_trader, 'wb')) + + meta = { + "symbol": symbol, + "kline": kline, + "trader": trader, + "base_freq": tactic.base_freq, + } + return meta + except Exception as e1: + logger.exception(f"{e1}:{symbol} - 初始化失败,当前时间:{context.now}") + + +def format_kline(df, freq=Freq.F1): + """对分钟K线进行格式化""" + freq = Freq(freq) + rows = df.to_dict("records") + local_tz = pytz.timezone('Asia/Shanghai') + raw_bars = [] + for i, row in enumerate(rows): + # 首先将'eob'转换到本地时区 + local_dt = row['eob'].astimezone(local_tz) + # 然后加一分钟(根据需要调整) + adjusted_local_dt = local_dt + timedelta(minutes=1) + # 最后转换为去除时区信息的datetime对象 + utc_naive = adjusted_local_dt.replace(tzinfo=None) + + bar = RawBar( + symbol=row["symbol"], + id=i, + freq=freq, + dt=utc_naive, + open=row["open"], + close=row["close"], + high=row["high"], + low=row["low"], + vol=row["volume"], + amount=row["volume"] * row["close"], + ) + raw_bars.append(bar) + return raw_bars + + +def sync_position(context, trader: CzscTrader): + """同步多头仓位到交易账户""" + if not trader.positions: + return + + symbol = trader.symbol + # name = context.stocks.get(symbol, "无名标的") + ensemble_pos = trader.get_ensemble_pos(method='mean') + max_sym_pos = context.symbol_metas[symbol]['max_pos'] # 最大标的仓位 + # target_volume = int(ensemble_pos * max_sym_pos) + if context.mode == MODE_BACKTEST: + account = context.account() + else: + account = context.account(account_id=context.accountID) + cash = get_cash(account_id=context.accountID) + logger.info(f"账户可用资金为:{cash.available}") + + price = trader.latest_price + sym_positions = get_position(account_id=context.accountID) + sym_pos_long = [x for x in sym_positions if x.side == PositionSide_Long] + sym_pos_short = [x for x in sym_positions if x.side == PositionSide_Short] + if ensemble_pos == 0 and not sym_positions: + # 如果多头仓位为0且掘金账户没有对应持仓,直接退出 + return + + if ensemble_pos == 0 and sym_pos_long and sym_pos_long[0].volume > 0: + # 如果多头仓位为0且掘金账户依然还有持仓,清掉仓位 + order_target_volume(symbol=symbol, volume=0, position_side=PositionSide_Long, + order_type=OrderType_Limit, price=price, account=context.accountID) + return + + if ensemble_pos == 0 and sym_pos_short and sym_pos_short[0].volume > 0: + # 如果多头仓位为0且掘金账户依然还有持仓,清掉仓位 + order_target_volume(symbol=symbol, volume=0, position_side=PositionSide_Short, + order_type=OrderType_Limit, price=price, account=context.accountID) + return + + # 没有仓位变化,直接退出 + if not trader.pos_changed: + return + + # 仓位指向空头,直接退出 + + if cash.available < cash.nav * 0.6: + logger.info(f"{context.now} {symbol} 可用资金不足,达到风控阈值,不再开仓") + return + + if is_order_exist(context, symbol, PositionSide_Long): + logger.info(f"{context.now} {symbol} 同方向订单已存在") + return + + if ensemble_pos > 0: + volume = max(int(max_sym_pos * ensemble_pos), 1) + order_target_volume(symbol=symbol, volume=volume, position_side=PositionSide_Long, + order_type=OrderType_Limit, price=price, account=context.accountID) + elif ensemble_pos < 0: + volume = min(int(max_sym_pos * ensemble_pos), -1) + order_target_volume(symbol=symbol, volume=abs(volume), position_side=PositionSide_Short, + order_type=OrderType_Limit, price=price, account=context.accountID) + + +def save_traders(context): + """实盘:保存交易员快照""" + if context.now.isoweekday() > 5: + print(f"save_traders: {context.now} 不是交易时间") + return + + for symbol in context.symbol_metas.keys(): + trader: CzscTrader = context.symbol_metas[symbol]['trader'] + if context.mode != MODE_BACKTEST: + file_trader = os.path.join(context.data_path, f'traders/{symbol}.ct') + dill.dump(trader, open(file_trader, 'wb')) + + +def init_context_schedule(context): + """通用 context 初始化:设置定时任务""" + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='09:31:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='10:01:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='10:31:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='11:01:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='11:31:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='13:01:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='13:31:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='14:01:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='14:31:00') + # schedule(schedule_func=report_account_status, date_rule='1d', time_rule='15:01:00') + + # 以下是 实盘/仿真 模式下的定时任务 + if context.mode != MODE_BACKTEST: + schedule(schedule_func=save_traders, date_rule='1d', time_rule='11:40:00') + schedule(schedule_func=save_traders, date_rule='1d', time_rule='15:23:00') + # schedule(schedule_func=realtime_check_index_status, date_rule='1d', time_rule='17:30:00') + # schedule(schedule_func=process_out_of_symbols, date_rule='1d', time_rule='09:40:00') + + +def send_trader_change(trader: czsc.CzscTrader, **kwargs): + """发送持仓变化 + + :param trader: czsc.CzscTrader, 交易员对象 + """ + if not trader.pos_changed: + logger.info(f"{trader.symbol} 持仓未变化") + return + + feishu_key = kwargs.get("feishu_key") + ensemble_method = kwargs.get("ensemble_method", "mean") + ensemble_desc = kwargs.get("ensemble_method", ensemble_method) + symbol_max_pos = kwargs.get("symbol_max_pos", 0) + + rows = [] + for pos in trader.positions: + if not pos.operates: + continue + + last_op = pos.operates[-2] if len(pos.operates) > 1 else None + curr_op = pos.operates[-1] + + if last_op: + last_op = f"{last_op['dt']} | {last_op['op']} | 价格:{last_op['price']} | {last_op['op_desc']}" + else: + last_op = "" + + curr_op = f"{curr_op['dt']} | {curr_op['op']} | 价格:{curr_op['price']} | {curr_op['op_desc']}" + + row = { + "pos": f"{pos.pos:.1%}", + "pos_name": pos.name, + "last_op": last_op, + "curr_op": curr_op, + } + rows.append(row) + + dfr = pd.DataFrame(rows) + + # 总的目标仓位说明 + ensemble_pos = trader.get_ensemble_pos(ensemble_method) + target_vol = int(ensemble_pos * symbol_max_pos) + + target = f"目标持仓:int({ensemble_pos} * {symbol_max_pos}) = {target_vol} 手;集成方法:{ensemble_desc}" + + # 使用 CzscTrader仓位变动通知卡片发送消息 + card = { + "type": "template", + "data": { + # 卡片模板 ID 需要根据实际情况修改,可以在飞书后台查看 + "template_id": "AAq3L1dkwNCX3", + "template_variable": {"symbol": trader.symbol, + "target": target, + "dfr": dfr.to_dict(orient="records")}, + }, + } + card_str = json.dumps(card) + czsc.fsa.push_card(card_str, feishu_key) + + +if __name__ == '__main__': + run(filename=os.path.basename(__file__), + token=os.environ['gm_token'], + mode=MODE_LIVE, + strategy_id=os.environ['gm_strategyID']) diff --git a/examples/test_offline/test_feishu_bi_table.py b/examples/test_offline/test_feishu_bi_table.py new file mode 100644 index 000000000..018b99a05 --- /dev/null +++ b/examples/test_offline/test_feishu_bi_table.py @@ -0,0 +1,107 @@ +import os +import sys +import dotenv + +os.chdir(r"A:\ZB\git_repo\waditu\czsc\examples\test_offline") +sys.path.insert(0, r"A:\ZB\git_repo\waditu\czsc") +dotenv.load_dotenv(dotenv.find_dotenv(raise_error_if_not_found=True), override=True) +from czsc.fsa import BiTable + +bi_table = BiTable(app_token="ZkWzbY7xjaicgkslpdUc1tQLnk8") + +print( + bi_table.create_table( + name="test2", + default_view_name="视图1", + fields=[{"field_name": "text", "type": 1}, {"field_name": "text1", "type": 2}], + ) +) +print(bi_table.batch_create_table(["tb1", "tb2", "tb3"])) +print(bi_table.delete_table("tblNW6s4ldB3dhCG")) +print(bi_table.batch_delete_table(["tbly1sheIukE622s", "tblNK7XcwQDLdomG"])) +print(bi_table.patch_table("tblzRoIFq3URau2V", "name_patch")) +print(bi_table.list_tables(page_token="tbltFoOtwXCuhtj9")) + +print(bi_table.table_record_get(table_id="tblfSD2jLnUMi4sE", record_id="recwyiPrHM")) +print(bi_table.table_record_search(table_id="tblfSD2jLnUMi4sE")) +print(bi_table.table_record_create(table_id="tblfSD2jLnUMi4sE", fields={"文本": "多行文本内容", "日期": 1674206443000})) + +print( + bi_table.table_record_update( + table_id="tblfSD2jLnUMi4sE", + record_id="recuhm4cMqw7fn", + fields={ + "文本": "多行文本内容修改", + }, + ) +) +print(bi_table.table_record_delete(table_id="tblfSD2jLnUMi4sE", record_id="recwyiPrHM")) + +print( + bi_table.table_record_batch_create( + table_id="tblfSD2jLnUMi4sE", + fields=[{"文本": "多行文本内容1"}, {"文本": "多行文本内容2"}, {"文本": "多行文本内容3", "日期": 1674206443000}], + ) +) + +print( + bi_table.table_record_batch_update( + table_id="tblfSD2jLnUMi4sE", + records=[ + {"record_id": "recuhm9s2axNVM", "fields": {"文本": "批量修改内容1"}}, + {"record_id": "recuhm9s2aZ3At", "fields": {"文本": "批量修改内容2"}}, + ], + ) +) + +print(bi_table.table_record_batch_delete(table_id="tblfSD2jLnUMi4sE", record_ids=["recuhm9s2axNVM", "recuhm9s2aZ3At"])) + +print(bi_table.table_view_list(table_id="tblfSD2jLnUMi4sE")) +print(bi_table.table_field_list(table_id="tblfSD2jLnUMi4sE", view_id="vewo15a8k6")) + +print(bi_table.table_view_patch(table_id="tblfSD2jLnUMi4sE", view_id="vewo15a8k6", infos={"view_name": "修改的视图名"})) + +print(bi_table.table_view_get(table_id="tblfSD2jLnUMi4sE", view_id="vewo15a8k6")) + +print(bi_table.table_view_create(table_id="tblfSD2jLnUMi4sE", view_name="测试添加视图", view_type="grid")) +print(bi_table.table_view_delete(table_id="tblfSD2jLnUMi4sE", view_id="vew4hoglsH")) + +print( + bi_table.table_field_create( + table_id="tblfSD2jLnUMi4sE", field_name="测试添加2", type=1, description={"text": "测试的"} + ) +) +print( + bi_table.table_field_update( + table_id="tblfSD2jLnUMi4sE", + field_id="fldEbAErbT", + field_name="测试添加2修改", + type=11, + description={"text": "测试的 并修改"}, + property={"multiple": True}, + ) +) + +print(bi_table.table_field_delete(table_id="tblfSD2jLnUMi4sE", field_id="fldEbAErbT")) + +print(bi_table.table_form_list(table_id="tblfSD2jLnUMi4sE", form_id="vewafwFMhM")) +print( + bi_table.table_form_patch( + table_id="tblfSD2jLnUMi4sE", form_id="vewafwFMhM", name="测试修改的名字", description="测试修改的备注" + ) +) +print(bi_table.table_form_get(table_id="tblfSD2jLnUMi4sE", form_id="vewafwFMhM")) +print( + bi_table.table_form_patch( + table_id="tblfSD2jLnUMi4sE", + form_id="vewafwFMhM", + field_id="fld7mfWuZ2", + title="修改的title", + description="api修改的", + ) +) + +print(bi_table.table_copy(name="测试添加的")) +print(bi_table.table_create(name="测试添加的")) +print(bi_table.table_get(app_token="YuSZbIPLlaPenUsOzbfcL25Sn4g")) +print(bi_table.table_update(app_token="YuSZbIPLlaPenUsOzbfcL25Sn4g", name="修改成的新名字")) diff --git a/requirements.txt b/requirements.txt index f9e604cf1..2206bd379 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,4 +24,5 @@ streamlit redis oss2 statsmodels -optuna \ No newline at end of file +optuna +cryptography \ No newline at end of file diff --git a/test/test_utils.py b/test/test_utils.py index 4fc357699..3e444b110 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -19,6 +19,16 @@ def test_x_round(): assert utils.x_round(1.000342, 5) == 1.00034 +def test_fernet(): + from czsc.utils.fernet import generate_fernet_key, fernet_encrypt, fernet_decrypt + + key = generate_fernet_key() + text = {"account": "admin", "password": "123456"} + encrypted = fernet_encrypt(text, key) + decrypted = fernet_decrypt(encrypted, key, is_dict=True) + assert text == decrypted, f"{text} != {decrypted}" + + def test_subtract_fee(): from czsc.utils.stats import subtract_fee