diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 244050925..867fd745a 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -5,7 +5,7 @@ name: Python package on: push: - branches: [ master, V0.9.27 ] + branches: [ master, V0.9.28 ] pull_request: branches: [ master ] diff --git a/README.md b/README.md index 20a1b02e6..d695a5074 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ >**假如没有了分型、笔、线段,缠论还是缠论吗?如果你的答案是“是”,这个项目是为你准备的。本项目旨在提供一个符合缠中说禅思维方式的程序化交易工具。** -* 已经开始用czsc库进行量化研究的朋友,欢迎加入飞书群,快点击 https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=a51qf1f4-8aee-47a5-8dca-e01d076b4baf 加入吧! +* 已经开始用czsc库进行量化研究的朋友,欢迎[加入飞书群](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=a51qf1f4-8aee-47a5-8dca-e01d076b4baf),快点击加入吧! * [B站视频教程合集(持续更新...)](https://space.bilibili.com/243682308/channel/series) +* [CZSC策略圈介绍](https://s0cqcxuy3p.feishu.cn/wiki/D12bwh4SriW1Lgk23HUchFKFnpe) ## 项目贡献 @@ -91,15 +92,3 @@ pip install czsc -U -i https://pypi.python.org/simple * 链接:https://pan.baidu.com/s/1RXkP3188F0qu8Yk6CjbxRQ * 提取码:vhue - - -## [知识星球【CZSC小圈子】](https://t.zsxq.com/04B2jmUN7)费用:100元 - -**知识星球【CZSC小圈子】的定位是什么?** - - 为仔细研读过禅师原文并且愿意使用 CZSC 库进行量化投研的朋友提供一个交流平台。 - - 寻找一群有能力的朋友共同进行量化策略研究。 - - 促成策略逻辑互补的实盘组合构建。 - - 对于刚接触缠论和量化交易的新朋友,给出一些力所能及的帮助。 -> 详情点击:https://s0cqcxuy3p.feishu.cn/wiki/wikcnwXSk9mWnki1b6URPhLA2Hc -> -> **加入知识星球后**,请加微信 `zengbin93`,备注【CZSC小圈子】 diff --git a/czsc/__init__.py b/czsc/__init__.py index 186f8a94b..138172b09 100644 --- a/czsc/__init__.py +++ b/czsc/__init__.py @@ -12,37 +12,76 @@ from czsc import aphorism from czsc.analyze import CZSC from czsc.objects import Freq, Operate, Direction, Signal, Factor, Event, RawBar, NewBar, Position - -from czsc.traders import CzscTrader, CzscSignals, generate_czsc_signals, check_signals_acc, get_unique_signals -from czsc.traders import PairsPerformance, combine_holds_and_pairs, combine_dates_and_pairs, stock_holds_performance -from czsc.traders import DummyBacktest, SignalsParser, get_signals_by_conf, get_signals_config, get_signals_freqs -from czsc.traders import WeightBacktest, get_ensemble_weight -from czsc.sensors import holds_concepts_effect, CTAResearch, EventMatchSensor from czsc.strategies import CzscStrategyBase, CzscJsonStrategy - -from czsc.utils import KlineChart, BarGenerator, resample_bars, dill_dump, dill_load, read_json, save_json -from czsc.utils import get_sub_elements, get_py_namespace, freqs_sorted, x_round, import_by_name, create_grid_params -from czsc.utils import cal_trade_price, cross_sectional_ic, update_bbars, update_tbars, update_nbars -from czsc.utils import CrossSectionalPerformance -from czsc.utils.signal_analyzer import SignalAnalyzer, SignalPerformance -from czsc.utils.stats import daily_performance, net_value_stats, subtract_fee -from czsc.utils.cache import home_path, get_dir_size, empty_cache_path +from czsc.sensors import holds_concepts_effect, CTAResearch, EventMatchSensor +from czsc.traders import ( + CzscTrader, + CzscSignals, + generate_czsc_signals, + check_signals_acc, + get_unique_signals, + PairsPerformance, + combine_holds_and_pairs, + combine_dates_and_pairs, + stock_holds_performance, + DummyBacktest, + SignalsParser, + get_signals_by_conf, + get_signals_config, + get_signals_freqs, + WeightBacktest, + get_ensemble_weight, +) +from czsc.utils import ( + KlineChart, + WordWriter, + BarGenerator, + freq_end_time, + resample_bars, + dill_dump, + dill_load, + read_json, + save_json, + get_sub_elements, + get_py_namespace, + freqs_sorted, + x_round, + import_by_name, + create_grid_params, + cal_trade_price, + update_bbars, + update_tbars, + update_nbars, + CrossSectionalPerformance, + cross_sectional_ranker, + cross_sectional_ic, + SignalAnalyzer, + SignalPerformance, + daily_performance, + net_value_stats, + subtract_fee, + home_path, + get_dir_size, + empty_cache_path, +) -__version__ = "0.9.27" +__version__ = "0.9.28" __author__ = "zengbin93" __email__ = "zeng_bin8888@163.com" -__date__ = "20230812" +__date__ = "20230820" def welcome(): print(f"欢迎使用CZSC!当前版本标识为 {__version__}@{__date__}\n") aphorism.print_one() - print(f"CZSC环境变量:" - f"czsc_min_bi_len = {envs.get_min_bi_len()}; " - f"czsc_max_bi_num = {envs.get_max_bi_num()}; " - f"czsc_bi_change_th = {envs.get_bi_change_th()}") + print( + f"CZSC环境变量:" + f"czsc_min_bi_len = {envs.get_min_bi_len()}; " + f"czsc_max_bi_num = {envs.get_max_bi_num()}; " + f"czsc_bi_change_th = {envs.get_bi_change_th()}" + ) if envs.get_welcome(): diff --git a/czsc/analyze.py b/czsc/analyze.py index 049bf7609..161f724e4 100644 --- a/czsc/analyze.py +++ b/czsc/analyze.py @@ -7,9 +7,8 @@ """ import os import webbrowser -import numpy as np from loguru import logger -from typing import List, Callable +from typing import List from collections import OrderedDict from czsc.enum import Mark, Direction from czsc.objects import BI, FX, RawBar, NewBar diff --git a/czsc/data/ts.py b/czsc/data/ts.py index 5bf33d904..e950691c8 100644 --- a/czsc/data/ts.py +++ b/czsc/data/ts.py @@ -93,10 +93,10 @@ def format_kline(kline: pd.DataFrame, freq: Freq) -> List[RawBar]: for i, record in enumerate(records): if freq == Freq.D: - vol = int(record['vol']*100) - amount = int(record.get('amount', 0)*1000) + vol = int(record['vol'] * 100) if record['vol'] > 0 else 0 + amount = int(record.get('amount', 0) * 1000) else: - vol = int(record['vol']) + vol = int(record['vol']) if record['vol'] > 0 else 0 amount = int(record.get('amount', 0)) # 将每一根K线转换成 RawBar 对象 diff --git a/czsc/sensors/event.py b/czsc/sensors/event.py index 693db06f7..3fbd4f9ee 100644 --- a/czsc/sensors/event.py +++ b/czsc/sensors/event.py @@ -6,7 +6,7 @@ describe: Event 相关的传感器 """ import os -import shutil +import shutil import pandas as pd from copy import deepcopy from loguru import logger @@ -83,7 +83,7 @@ def __init__(self, events: List[Union[Dict[str, Any], Event]], symbols: List[str df = df.set_index("dt") _res.append(df) df = pd.concat(_res, axis=1, ignore_index=False).reset_index() - file_csc = os.path.join(self.results_path, f"cross_section_counts.csv") + file_csc = os.path.join(self.results_path, "cross_section_counts.csv") df.to_csv(file_csc, index=False) logger.info(f"截面匹配次数计算完成,结果保存至:{file_csc}") diff --git a/czsc/signals/__init__.py b/czsc/signals/__init__.py index 41f10bcd5..a99e74cf7 100644 --- a/czsc/signals/__init__.py +++ b/czsc/signals/__init__.py @@ -44,6 +44,7 @@ cxt_ubi_end_V230816, cxt_bi_end_V230815, cxt_bi_stop_V230815, + cxt_bi_trend_V230824, ) @@ -232,6 +233,8 @@ obvm_line_V230610, obv_up_dw_line_V230719, cvolp_up_dw_line_V230612, + kcatr_up_dw_line_V230823, + ntmdk_V230824, ) diff --git a/czsc/signals/ang.py b/czsc/signals/ang.py index 85993e823..19668d259 100644 --- a/czsc/signals/ang.py +++ b/czsc/signals/ang.py @@ -794,3 +794,96 @@ def cvolp_up_dw_line_V230612(c: CZSC, **kwargs) -> OrderedDict: v1 = "看空" return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def ntmdk_V230824(c: CZSC, **kwargs) -> OrderedDict: + """NTMDK多空指标,贡献者:琅盎 + + 参数模板:"{freq}_D{di}M{m}_NTMDK多空V230824" + + **信号逻辑:** + + 此信号函数的逻辑非常简单,流传于股市中有一句话:日日新高日日持股, + 那么此信号函数利用的是收盘价和M日前的收盘价进行比较,如果差值为正 + 即多头成立,反之空头成立。 + + **信号列表:** + + - Signal('日线_D1M10_NTMDK多空V230824_看空_任意_任意_0') + - Signal('日线_D1M10_NTMDK多空V230824_看多_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: 参数字典 + + - :param di: 信号计算截止倒数第i根K线 + - :param m: m天前的价格 + + :return: 信号识别结果 + """ + di = int(kwargs.get("di", 1)) + m = int(kwargs.get("m", 10)) + freq = c.freq.value + k1, k2, k3 = f"{freq}_D{di}M{m}_NTMDK多空V230824".split('_') + v1 = "其他" + if len(c.bars_raw) < di + m + 10: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + bars = get_sub_elements(c.bars_raw, di=di, n=m) + v1 = "看多" if bars[-1].close > bars[0].close else "看空" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def kcatr_up_dw_line_V230823(c: CZSC, **kwargs) -> OrderedDict: + """用ATR波幅构造上下轨,收盘价突破判断多空 贡献者:琅盎 + + 参数模板:"{freq}_D{di}N{n}M{m}T{th}_KCATR多空V230823" + + **信号逻辑:** + + 与布林带类似,都是用价格的移动平均构造中轨,不同的是表示波幅 + 的方法,这里用 atr 来作为波幅构造上下轨。价格突破上轨, + 可看成新的上升趋势,买入;价格突破下轨, + + **信号列表:** + + - Signal('日线_D1N30M16T2_KCATR多空V230823_看多_任意_任意_0') + - Signal('日线_D1N30M16T2_KCATR多空V230823_看空_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: 参数字典 + + - :param di: 信号计算截止倒数第i根K线 + - :param n: 获取K线的根数进行ATR计算,默认为30 + - :param m: 获取K线的根数进行均价计算,默认为16 + - :param th: 突破ATR的倍数,默认为2 + + :return: 信号识别结果 + """ + di = int(kwargs.get("di", 1)) + n = int(kwargs.get("n", 30)) + m = int(kwargs.get("m", 16)) + th = int(kwargs.get("th", 2)) # 突破ATR的倍数 + + freq = c.freq.value + k1, k2, k3 = f"{freq}_D{di}N{n}M{m}T{th}_KCATR多空V230823".split('_') + v1 = "其他" + if len(c.bars_raw) < di + max(m, n) + 10: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + n_bars = get_sub_elements(c.bars_raw, di=di, n=n) + m_bars = get_sub_elements(c.bars_raw, di=di, n=m) + atr = np.mean([ + max( + abs(n_bars[i].high - n_bars[i].low), + abs(n_bars[i].high - n_bars[i - 1].close), + abs(n_bars[i].low - n_bars[i - 1].close), + ) + for i in range(1, len(n_bars)) + ]) + middle = np.mean([x.close for x in m_bars]) + + if m_bars[-1].close > middle + atr * th: + v1 = "看多" + elif m_bars[-1].close < middle - atr * th: + v1 = "看空" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) diff --git a/czsc/signals/cxt.py b/czsc/signals/cxt.py index aa6d52211..5ee8a56fd 100644 --- a/czsc/signals/cxt.py +++ b/czsc/signals/cxt.py @@ -2044,3 +2044,55 @@ def cxt_bi_stop_V230815(c: CZSC, **kwargs) -> OrderedDict: v1 = '向上' v2 = "阈值内" if last_bar.close < bi.low * (1 + th / 10000) else "阈值外" return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1, v2=v2) + + +def cxt_bi_trend_V230824(c: CZSC, **kwargs) -> OrderedDict: + """判断N笔形态,贡献者:chenglei + + 参数模板:"{freq}_D{di}N{n}TH{th}_形态V230824" + + **信号逻辑:** + + 1. 通过对最近N笔的中心点的均值和-n笔的中心点的位置关系来判断当前N比是上涨形态还是下跌,横盘震荡形态 + 2. 给定阈值 th,判断上涨下跌横盘按照 所有笔中心点/第-n笔中心点 与 正负th区间的相对位置来判断。 + 3. 当在区间上时为上涨,区间内为横盘,区间下为下跌 + + **信号列表:** + + - Signal('日线_D1N4TH5_形态V230824_横盘_任意_任意_0') + - Signal('日线_D1N4TH5_形态V230824_向上_任意_任意_0') + - Signal('日线_D1N4TH5_形态V230824_向下_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: + + - di: 倒数第几笔 + - n :检查范围 + - th: 振幅阈值,2 表示 2%,即 2% 以内的振幅都认为是震荡 + + :return: 信号识别结果 + """ + di = int(kwargs.get('di', 1)) + n = int(kwargs.get('n', 4)) + th = int(kwargs.get('th', 2)) # 振幅阈值,2 表示 2%,即 2% 以内的振幅都认为是震荡 + freq = c.freq.value + k1, k2, k3 = f"{freq}_D{di}N{n}TH{th}_形态V230824".split('_') + v1 = '其他' + if len(c.bi_list) < di + n + 2: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + _bis = get_sub_elements(c.bi_list, di=di, n=n) + assert len(_bis) == n, f"获取第 {di} 笔到第 {di+n} 笔失败" + + all_means = [(bi.low + bi.high) / 2 for bi in _bis] + average_of_means = sum(all_means) / n + ratio = all_means[0] / average_of_means + + if ratio * 100 > 100 + th: + v1 = "向下" + elif ratio * 100 < 100 - th: + v1 = "向上" + else: + v1 = "横盘" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + diff --git a/czsc/signals/tas.py b/czsc/signals/tas.py index 6738e0412..f553c7fb1 100644 --- a/czsc/signals/tas.py +++ b/czsc/signals/tas.py @@ -3322,7 +3322,7 @@ def tas_macd_bc_V230804(c: CZSC, **kwargs) -> OrderedDict: def tas_macd_bc_ubi_V230804(c: CZSC, **kwargs) -> OrderedDict: """未完成笔MACD黄白线辅助背驰判断 - 参数模板:"{freq}_MACD背驰_BS辅助V230804" + 参数模板:"{freq}_MACD背驰_UBI观察V230804" **信号逻辑:** diff --git a/czsc/traders/base.py b/czsc/traders/base.py index dbd787eaf..362103859 100644 --- a/czsc/traders/base.py +++ b/czsc/traders/base.py @@ -172,7 +172,7 @@ def get_signals_by_conf(cat: CzscSignals, conf): sig_func = import_by_name(param.pop('name')) freq = param.pop('freq', None) if freq in cat.kas: # 如果指定了 freq,那么就使用 CZSC 对象作为输入 - s.update(sig_func(cat.kas[freq], **param)) + s.update(sig_func(cat.kas[freq], **param)) # type: ignore else: # 否则使用 CAT 作为输入 s.update(sig_func(cat, **param)) return s @@ -197,13 +197,13 @@ def generate_czsc_signals(bars: List[RawBar], signals_config: List[dict], """ freqs = get_signals_freqs(signals_config) freqs = [freq for freq in freqs if freq != bars[0].freq.value] - sdt = pd.to_datetime(sdt) # type: ignore - bars_left = [x for x in bars if x.dt < sdt] # type: ignore + sdt = pd.to_datetime(sdt) # type: ignore + bars_left = [x for x in bars if x.dt < sdt] # type: ignore if len(bars_left) <= init_n: bars_left = bars[:init_n] bars_right = bars[init_n:] else: - bars_right = [x for x in bars if x.dt >= sdt] # type: ignore + bars_right = [x for x in bars if x.dt >= sdt] # type: ignore if len(bars_right) == 0: logger.warning("右侧K线为空,无法进行信号生成", category=RuntimeWarning) @@ -302,7 +302,7 @@ def get_unique_signals(bars: List[RawBar], signals_config: List[dict], **kwargs) class CzscTrader(CzscSignals): """缠中说禅技术分析理论之多级别联立交易决策类(支持多策略独立执行)""" - def __init__(self, bg: BarGenerator = None, positions: List[Position] = None, + def __init__(self, bg: Optional[BarGenerator] = None, positions: Optional[List[Position]] = None, ensemble_method: Union[AnyStr, Callable] = "mean", **kwargs): """ @@ -483,7 +483,7 @@ def get_ensemble_weight(self, method: Optional[Union[AnyStr, Callable]] = None): {'多头策略A': 1, '多头策略B': 1, '空头策略A': -1} :param kwargs: :return: pd.DataFrame - columns = ['dt', 'symbol', 'weight', 'price'] + columns = ['dt', 'symbol', 'weight', 'price'] """ from czsc.traders.weight_backtest import get_ensemble_weight method = self.__ensemble_method if not method else method diff --git a/czsc/traders/weight_backtest.py b/czsc/traders/weight_backtest.py index a99ee04b0..cfcbca974 100644 --- a/czsc/traders/weight_backtest.py +++ b/czsc/traders/weight_backtest.py @@ -27,7 +27,7 @@ def get_ensemble_weight(trader: CzscTrader, method: Union[AnyStr, Callable] = 'm {'多头策略A': 1, '多头策略B': 1, '空头策略A': -1} :param kwargs: :return: pd.DataFrame - columns = ['dt', 'symbol', 'weight', 'price'] + columns = ['dt', 'symbol', 'weight', 'price'] """ logger.info(f"trader positions: {[p.name for p in trader.positions]}") @@ -40,7 +40,7 @@ def get_ensemble_weight(trader: CzscTrader, method: Union[AnyStr, Callable] = 'm assert dfp['dt'].equals(p_pos['dt']) dfp = dfp.merge(p_pos[['dt', 'pos']], on='dt', how='left') dfp.rename(columns={'pos': p.name}, inplace=True) - + pos_cols = [c for c in dfp.columns if c not in ['dt', 'weight', 'price']] if callable(method): dfp['weight'] = dfp[pos_cols].apply(lambda x: method(x.to_dict()), axis=1) @@ -56,7 +56,7 @@ def get_ensemble_weight(trader: CzscTrader, method: Union[AnyStr, Callable] = 'm dfp['weight'] = dfp[pos_cols].apply(lambda x: np.sign(np.sum(x)), axis=1) else: raise ValueError(f"method {method} not supported") - + dfp['symbol'] = trader.symbol logger.info(f"trader weight decribe: {dfp['weight'].describe().round(4).to_dict()}") return dfp[['dt', 'symbol', 'weight', 'price']].copy() @@ -67,14 +67,14 @@ class WeightBacktest: def __init__(self, dfw, digits=2, **kwargs) -> None: """持仓权重回测 - + :param dfw: pd.DataFrame, columns = ['dt', 'symbol', 'weight', 'price'], 持仓权重数据,其中 dt 为K线结束时间, symbol 为合约代码, weight 为K线结束时间对应的持仓权重, price 为结束时间对应的交易价格,可以是当前K线的收盘价,或者下一根K线的开盘价,或者未来N根K线的TWAP、VWAP等 - + 数据样例如下: =================== ======== ======== ======= dt symbol weight price @@ -85,13 +85,13 @@ def __init__(self, dfw, digits=2, **kwargs) -> None: 2019-01-02 09:04:00 DLi9001 0.25 960.72 2019-01-02 09:05:00 DLi9001 0.25 961.695 =================== ======== ======== ======= - + :param digits: int, 权重列保留小数位数 :param kwargs: - fee_rate: float,单边交易成本,包括手续费与冲击成本, 默认为 0.0002 - res_path: str,回测结果保存路径,默认为 "weight_backtest" - + """ self.kwargs = kwargs self.dfw = dfw.copy() @@ -100,7 +100,7 @@ def __init__(self, dfw, digits=2, **kwargs) -> None: self.dfw['weight'] = self.dfw['weight'].round(digits) self.symbols = list(self.dfw['symbol'].unique().tolist()) self.res_path = Path(kwargs.get('res_path', "weight_backtest")) - self.res_path.mkdir(exist_ok=True, parents=True) + self.res_path.mkdir(exist_ok=True, parents=True) logger.add(self.res_path.joinpath("weight_backtest.log"), rotation="1 week") logger.info(f"持仓权重回测参数:digits={digits}, fee_rate={self.fee_rate},res_path={self.res_path},kwargs={kwargs}") @@ -116,7 +116,7 @@ def get_symbol_daily(self, symbol): edge 为每日收益率, return 为每日收益率减去交易成本后的真实收益, cost 为交易成本 - + 数据样例如下: ========== ======== ============ ============ ======= @@ -181,13 +181,13 @@ def __add_operate(dt, bar_id, volume, price, operate): # 多头转换成空头对应的操作 __add_operate(row2['dt'], row2['bar_id'], row1['volume'], row2['price'], operate='平多') __add_operate(row2['dt'], row2['bar_id'], row2['volume'], row2['price'], operate='开空') - + elif row1['volume'] <= 0 and row2['volume'] >= 0: # 空头转换成多头对应的操作 __add_operate(row2['dt'], row2['bar_id'], row1['volume'], row2['price'], operate='平空') __add_operate(row2['dt'], row2['bar_id'], row2['volume'], row2['price'], operate='开多') - pairs, opens =[], [] + pairs, opens = [], [] for op in operates: if op['operate'] in ['开多', '开空']: opens.append(op) @@ -202,9 +202,9 @@ def __add_operate(dt, bar_id, volume, price, operate): p_ret = round((open_op['price'] - op['price']) / open_op['price'] * 10000, 2) p_dir = '空头' pair = {"标的代码": symbol, "交易方向": p_dir, - "开仓时间": open_op['dt'], "平仓时间": op['dt'], + "开仓时间": open_op['dt'], "平仓时间": op['dt'], "开仓价格": open_op['price'], "平仓价格": op['price'], - "持仓K线数": op['bar_id'] - open_op['bar_id'] + 1, + "持仓K线数": op['bar_id'] - open_op['bar_id'] + 1, "事件序列": f"{open_op['operate']} -> {op['operate']}", "持仓天数": (op['dt'] - open_op['dt']).days, "盈亏比例": p_ret} diff --git a/czsc/utils/__init__.py b/czsc/utils/__init__.py index 3567dca23..2c4e1e7ef 100644 --- a/czsc/utils/__init__.py +++ b/czsc/utils/__init__.py @@ -16,8 +16,10 @@ from .sig import same_dir_counts, fast_slow_cross, count_last_same, create_single_signal from .plotly_plot import KlineChart from .trade import cal_trade_price, update_nbars, update_bbars, update_tbars -from .cross import CrossSectionalPerformance +from .cross import CrossSectionalPerformance, cross_sectional_ranker from .stats import daily_performance, net_value_stats, subtract_fee +from .signal_analyzer import SignalAnalyzer, SignalPerformance +from .cache import home_path, get_dir_size, empty_cache_path sorted_freqs = ['Tick', '1分钟', '5分钟', '15分钟', '30分钟', '60分钟', '日线', '周线', '月线', '季线', '年线'] diff --git a/czsc/utils/cache.py b/czsc/utils/cache.py index 09407b0ff..c1e6862f8 100644 --- a/czsc/utils/cache.py +++ b/czsc/utils/cache.py @@ -28,5 +28,3 @@ def empty_cache_path(): shutil.rmtree(home_path) os.makedirs(home_path, exist_ok=False) print(f"已清空缓存文件夹:{home_path}") - - diff --git a/czsc/utils/corr.py b/czsc/utils/corr.py index 39e6387d6..8c53212ac 100644 --- a/czsc/utils/corr.py +++ b/czsc/utils/corr.py @@ -113,6 +113,8 @@ def cross_sectional_ic(df, x_col='open', y_col='n1b', method='spearman', **kwarg df = pd.DataFrame(s, columns=['ic']).reset_index(inplace=False) res = { + "x_col": x_col, + "y_col": y_col, "method": method, "IC均值": 0, "IC标准差": 0, @@ -133,5 +135,3 @@ def cross_sectional_ic(df, x_col='open', y_col='n1b', method='spearman', **kwarg res['IC胜率'] = round(len(df[df['ic'] > 0]) / len(df), 4) res['IC绝对值>2%占比'] = round(len(df[df['ic'].abs() > 0.02]) / len(df), 4) return df, res - - diff --git a/czsc/utils/cross.py b/czsc/utils/cross.py index d8a395a5f..52428a790 100644 --- a/czsc/utils/cross.py +++ b/czsc/utils/cross.py @@ -9,7 +9,6 @@ import time import numpy as np import pandas as pd -from tqdm import tqdm from loguru import logger from czsc.utils import WordWriter from czsc.utils.stats import net_value_stats @@ -174,3 +173,56 @@ def report(self, file_docx): writer.add_df_table(ymr.reset_index(drop=False), style='Medium List 1 Accent 2', font_size=8) writer.save() logger.info(f"报告生成成功:{file_docx}") + + +def cross_sectional_ranker(df, x_cols, y_col, **kwargs): + """截面打分排序 + + :param df: 因子数据,必须包含日期、品种、因子值、预测列,且按日期升序排列,样例数据如下: + :param x_cols: 因子列名 + :param y_col: 预测列名 + :param kwargs: 其他参数 + + - model_params: dict, 模型参数,默认{'n_estimators': 40, 'learning_rate': 0.01},可调整,参考lightgbm文档 + - n_splits: int, 时间拆分次数,默认5,即5段时间 + - rank_ascending: bool, 打分排序是否升序,默认False-降序 + - copy: bool, 是否拷贝df,True-拷贝,False-不拷贝 + + :return: df, 包含预测分数和排序列 + """ + from sklearn.model_selection import TimeSeriesSplit + try: + from lightgbm import LGBMRanker + except: + logger.warning("lightgbm not installed, please install it first! (pip install lightgbm -U)") + return df + + assert "symbol" in df.columns, "df must have column 'symbol'" + assert "dt" in df.columns, f"df must have column 'dt'" + + if kwargs.get('copy', True): + df = df.copy() + df['dt'] = pd.to_datetime(df['dt']) + df = df.sort_values(['dt', y_col], ascending=[True, False]) + + model_params = kwargs.get('model_params', {'n_estimators': 40, 'learning_rate': 0.01}) + model = LGBMRanker(**model_params) + + dfd = pd.DataFrame({'dt': sorted(df['dt'].unique())}).values + tss = TimeSeriesSplit(n_splits=kwargs.get('n_splits', 5)) + + for train_index, test_index in tss.split(dfd): + train_dts = dfd[train_index][:, 0] + test_dts= dfd[test_index][:, 0] + + # 拆分训练集和测试集 + train, test = df[df['dt'].isin(train_dts)], df[df['dt'].isin(test_dts)] + X_train, X_test, y_train = train[x_cols], test[x_cols], train[y_col] + query_train = train.groupby('dt')['symbol'].count().values + + # 训练模型 & 预测 + model.fit(X_train, y_train, group=query_train) + df.loc[X_test.index, 'score'] = model.predict(X_test) + + df['rank'] = df.groupby('dt')['score'].rank(ascending=kwargs.get('rank_ascending', False)) + return df diff --git a/czsc/utils/stats.py b/czsc/utils/stats.py index 4acd3b494..1f65baec2 100644 --- a/czsc/utils/stats.py +++ b/czsc/utils/stats.py @@ -3,7 +3,7 @@ author: zengbin93 email: zeng_bin8888@163.com create_dt: 2023/4/19 23:27 -describe: +describe: 绩效表现统计 """ import numpy as np import pandas as pd @@ -19,7 +19,7 @@ def subtract_fee(df, fee=1): if 'n1b' not in df.columns: assert 'price' in df.columns, '当n1b列不存在时,price 列必须存在' df['n1b'] = (df['price'].shift(-1) / df['price'] - 1) * 10000 - + df['date'] = df['dt'].dt.date df['edge_pre_fee'] = df['pos'] * df['n1b'] df['edge_post_fee'] = df['pos'] * df['n1b'] @@ -41,25 +41,30 @@ def daily_performance(daily_returns): [0.01, 0.02, -0.01, 0.03, 0.02, -0.02, 0.01, -0.01, 0.02, 0.01] :return: dict """ - if isinstance(daily_returns, list): - daily_returns = np.array(daily_returns) - + daily_returns = np.array(daily_returns, dtype=np.float64) + if len(daily_returns) == 0 or np.std(daily_returns) == 0 or all(x == 0 for x in daily_returns): - return {"年化": 0, "夏普": 0, "最大回撤": 0, "卡玛": 0, "日胜率": 0} - + return {"年化": 0, "夏普": 0, "最大回撤": 0, "卡玛": 0, "日胜率": 0, "年化波动率": 0, "非零覆盖": 0} + annual_returns = np.sum(daily_returns) / len(daily_returns) * 252 sharpe_ratio = np.mean(daily_returns) / np.std(daily_returns) * np.sqrt(252) cum_returns = np.cumsum(daily_returns) max_drawdown = np.max(np.maximum.accumulate(cum_returns) - cum_returns) kama = annual_returns / max_drawdown if max_drawdown != 0 else 10 win_pct = len(daily_returns[daily_returns > 0]) / len(daily_returns) - return { + annual_volatility = np.std(daily_returns) * np.sqrt(252) + none_zero_cover = len(daily_returns[daily_returns != 0]) / len(daily_returns) + + sta = { "年化": round(annual_returns, 4), "夏普": round(sharpe_ratio, 2), "最大回撤": round(max_drawdown, 4), "卡玛": round(kama, 2), "日胜率": round(win_pct, 4), + "年化波动率": round(annual_volatility, 4), + "非零覆盖": round(none_zero_cover, 4), } + return sta def net_value_stats(nv: pd.DataFrame, exclude_zero: bool = False, sub_cost=True) -> dict: @@ -183,7 +188,7 @@ def evaluate_pairs(pairs: pd.DataFrame, trade_dir: str = "多空") -> dict: if len(pairs) == 0: return p - + if trade_dir in ["多头", "空头"]: pairs = pairs[pairs["交易方向"] == trade_dir] else: diff --git a/czsc/utils/ta.py b/czsc/utils/ta.py index 6d912ac79..958d8a931 100644 --- a/czsc/utils/ta.py +++ b/czsc/utils/ta.py @@ -22,7 +22,7 @@ def SMA(close: np.array, timeperiod=5): res = [] for i in range(len(close)): if i < timeperiod: - seq = close[0: i+1] + seq = close[0: i + 1] else: seq = close[i - timeperiod + 1: i + 1] res.append(seq.mean()) @@ -44,7 +44,7 @@ def EMA(close: np.array, timeperiod=5): if i < 1: res.append(close[i]) else: - ema = (2 * close[i] + res[i-1] * (timeperiod-1)) / (timeperiod+1) + ema = (2 * close[i] + res[i - 1] * (timeperiod - 1)) / (timeperiod + 1) res.append(ema) return np.array(res, dtype=np.double).round(4) @@ -85,8 +85,8 @@ def KDJ(close: np.array, high: np.array, low: np.array): lv = [] for i in range(len(close)): if i < n: - h_ = high[0: i+1] - l_ = low[0: i+1] + h_ = high[0: i + 1] + l_ = low[0: i + 1] else: h_ = high[i - n + 1: i + 1] l_ = low[i - n + 1: i + 1] @@ -105,8 +105,8 @@ def KDJ(close: np.array, high: np.array, low: np.array): k_ = rsv[i] d_ = k_ else: - k_ = (2 / 3) * k[i-1] + (1 / 3) * rsv[i] - d_ = (2 / 3) * d[i-1] + (1 / 3) * k_ + k_ = (2 / 3) * k[i - 1] + (1 / 3) * rsv[i] + d_ = (2 / 3) * d[i - 1] + (1 / 3) * k_ k.append(k_) d.append(d_) diff --git a/examples/signals_dev/cxt_bi_end_V230815.py b/examples/signals_dev/merged/cxt_bi_end_V230815.py similarity index 100% rename from examples/signals_dev/cxt_bi_end_V230815.py rename to examples/signals_dev/merged/cxt_bi_end_V230815.py diff --git a/examples/signals_dev/cxt_bi_stop_V230815.py b/examples/signals_dev/merged/cxt_bi_stop_V230815.py similarity index 100% rename from examples/signals_dev/cxt_bi_stop_V230815.py rename to examples/signals_dev/merged/cxt_bi_stop_V230815.py diff --git a/examples/signals_dev/merged/cxt_bi_trend_V230824.py b/examples/signals_dev/merged/cxt_bi_trend_V230824.py new file mode 100644 index 000000000..4ac38179e --- /dev/null +++ b/examples/signals_dev/merged/cxt_bi_trend_V230824.py @@ -0,0 +1,77 @@ +import sys + +sys.path.insert(0, '.') +sys.path.insert(0, '..') + +import talib as ta +import numpy as np +from czsc import CZSC, Direction +from collections import OrderedDict +from czsc.utils import create_single_signal, get_sub_elements + + +def cxt_bi_trend_V230824(c: CZSC, **kwargs) -> OrderedDict: + """判断N笔形态 + + 参数模板:"{freq}_D{di}N{n}TH{th}_形态V230824" + + **信号逻辑:** + + 1. 通过对最近N笔的中心点的均值和-n笔的中心点的位置关系来判断当前N比是上涨形态还是下跌,横盘震荡形态 + 2. 给定阈值 th,判断上涨下跌横盘按照 所有笔中心点/第-n笔中心点 与 正负th区间的相对位置来判断。 + 3. 当在区间上时为上涨,区间内为横盘,区间下为下跌 + + **信号列表:** + + - Signal('日线_D1N4TH5_形态V230824_横盘_任意_任意_0') + - Signal('日线_D1N4TH5_形态V230824_向上_任意_任意_0') + - Signal('日线_D1N4TH5_形态V230824_向下_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: + + - di: 倒数第几笔 + - n :检查范围 + - th: 振幅阈值,2 表示 2%,即 2% 以内的振幅都认为是震荡 + + :return: 信号识别结果 + """ + di = int(kwargs.get('di', 1)) + n = int(kwargs.get('n', 4)) + th = int(kwargs.get('th', 2)) # 振幅阈值,2 表示 2%,即 2% 以内的振幅都认为是震荡 + freq = c.freq.value + k1, k2, k3 = f"{freq}_D{di}N{n}TH{th}_形态V230824".split('_') + v1 = '其他' + if len(c.bi_list) < di + n + 2: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + _bis = get_sub_elements(c.bi_list, di=di, n=n) + assert len(_bis) == n, f"获取第 {di} 笔到第 {di+n} 笔失败" + + all_means = [(bi.low + bi.high) / 2 for bi in _bis] + average_of_means = sum(all_means) / n + ratio = all_means[0] / average_of_means + + if ratio * 100 > 100 + th: + v1 = "向下" + elif ratio * 100 < 100 - th: + v1 = "向上" + else: + v1 = "横盘" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def check(): + from czsc.connectors import research + from czsc.traders.base import check_signals_acc + + symbols = research.get_symbols('中证500成分股') + symbol = symbols[0] + # for symbol in symbols[:10]: + bars = research.get_raw_bars(symbol, '15分钟', '20181101', '20210101', fq='前复权') + signals_config = [{'name': cxt_bi_trend_V230824, 'freq': '日线', 'di': 1, 'n': 6, 'th': 5}] + check_signals_acc(bars, signals_config=signals_config, height='780px') # type: ignore + + +if __name__ == '__main__': + check() diff --git a/examples/signals_dev/cxt_ubi_end_V230816.py b/examples/signals_dev/merged/cxt_ubi_end_V230816.py similarity index 100% rename from examples/signals_dev/cxt_ubi_end_V230816.py rename to examples/signals_dev/merged/cxt_ubi_end_V230816.py diff --git a/examples/signals_dev/merged/kcatr_up_dw_line_V230823.py b/examples/signals_dev/merged/kcatr_up_dw_line_V230823.py new file mode 100644 index 000000000..87f6b82d4 --- /dev/null +++ b/examples/signals_dev/merged/kcatr_up_dw_line_V230823.py @@ -0,0 +1,74 @@ +from collections import OrderedDict +import numpy as np +from czsc.connectors import research +from czsc import CZSC, check_signals_acc, get_sub_elements +from czsc.utils import create_single_signal +from czsc.signals.tas import update_atr_cache + + +def kcatr_up_dw_line_V230823(c: CZSC, **kwargs) -> OrderedDict: + """用atr波幅构造上下轨,收盘价突破判断多空 贡献者:琅盎 + + 参数模板:"{freq}_D{di}N{n}M{m}T{th}_KCATR多空V230823" + + **信号逻辑:** + + 与布林带类似,都是用价格的移动平均构造中轨,不同的是表示波幅 + 的方法,这里用 atr 来作为波幅构造上下轨。价格突破上轨, + 可看成新的上升趋势,买入;价格突破下轨, + + **信号列表:** + + - Signal('日线_D1N30M16T2_KCATR多空V230823_看多_任意_任意_0') + - Signal('日线_D1N30M16T2_KCATR多空V230823_看空_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: 参数字典 + + - :param di: 信号计算截止倒数第i根K线 + - :param n: 获取K线的根数进行ATR计算,默认为30 + - :param m: 获取K线的根数进行均价计算,默认为16 + - :param th: 突破ATR的倍数,默认为2 + + :return: 信号识别结果 + """ + di = int(kwargs.get("di", 1)) + n = int(kwargs.get("n", 30)) + m = int(kwargs.get("m", 16)) + th = int(kwargs.get("th", 2)) # 突破ATR的倍数 + + freq = c.freq.value + k1, k2, k3 = f"{freq}_D{di}N{n}M{m}T{th}_KCATR多空V230823".split('_') + v1 = "其他" + if len(c.bars_raw) < di + max(m, n) + 10: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + n_bars = get_sub_elements(c.bars_raw, di=di, n=n) + m_bars = get_sub_elements(c.bars_raw, di=di, n=m) + atr = np.mean([ + max( + abs(n_bars[i].high - n_bars[i].low), + abs(n_bars[i].high - n_bars[i - 1].close), + abs(n_bars[i - 1].close - n_bars[i - 1].low), + ) + for i in range(1, len(n_bars)) + ]) + middle = np.mean([x.close for x in m_bars]) + + if m_bars[-1].close > middle + atr * th: + v1 = "看多" + elif m_bars[-1].close < middle - atr * th: + v1 = "看空" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def main(): + symbols = research.get_symbols('A股主要指数') + bars = research.get_raw_bars(symbols[0], '15分钟', '20171101', '20210101', fq='前复权') + + signals_config = [{'name': kcatr_up_dw_line_V230823, 'freq': '日线', 'di': 1}] + check_signals_acc(bars, signals_config=signals_config) + + +if __name__ == '__main__': + main() diff --git a/examples/signals_dev/merged/lverage_up_dw_line_V230824.py b/examples/signals_dev/merged/lverage_up_dw_line_V230824.py new file mode 100644 index 000000000..fda686745 --- /dev/null +++ b/examples/signals_dev/merged/lverage_up_dw_line_V230824.py @@ -0,0 +1,83 @@ +from collections import OrderedDict +import pandas as pd +import tushare as ts +import numpy as np +from tqdm import tqdm +from czsc.connectors import research +from czsc import CZSC, check_signals_acc, get_sub_elements +from czsc.utils import create_single_signal + + +def lverage_up_dw_line_V230824(c: CZSC, **kwargs) -> OrderedDict: + """.....,贡献者:琅盎 + + 参数模板:"{freq}_D{di}N{n}_V230604dc" + + 信号逻辑:** + + + + 信号列表: + + - Signal('日线_D1N10_V230604dc_看空_任意_任意_0') + - Signal('日线_D1N10_V230604dc_看多_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: 参数字典 + - :param di: 信号计算截止倒数第i根K线 + - :param n: 获取K线的根数,默认为105 + - :param start_date: 获取tushare备用行情的开始日期--> 调用函数时需要和主数据开始日期保持一致 + - :param end_date: 获取tushare备用行情的结束日期--> 调用函数时需要和主数据结束日期保持一致 + + :return: 信号识别结果 + """ + di = int(kwargs.get("di", 1)) + n = int(kwargs.get("n", 10)) + freq = c.freq.value + k1, k2, k3 = f"{freq}_D{di}N{n}_V230604dc".split('_') + assert freq == "日线", "该信号只能在日线上使用" + + # 从外部获取数据进行缓存 + cache_key = "lverage_up_dw_line_V230824_volume_ratio" + if cache_key not in c.cache.keys(): + pro = ts.pro_api() + _df = pro.daily_basic(ts_code=c.symbol, start_date='20100101', end_date='20240101', fields='trade_date,volume_ratio') + c.cache[cache_key] = _df.set_index('trade_date')['volume_ratio'].to_dict() + map_volume_ratio = c.cache[cache_key] + + v1 = "其他" + if len(c.bars_raw) < di + 10: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + _bars = get_sub_elements(c.bars_raw, di=di, n=n) # 取n根K线 + max_high = max([x.high for x in _bars]) + min_low = min([x.low for x in _bars]) + ratio = [map_volume_ratio.get(x.dt.strftime("%Y%m%d"), 1) for x in _bars] + max_ratio = max(ratio) + min_ratio = min(ratio) + + dc = (max_high + min_low) / 2 + ra = (max_ratio + min_ratio) / 2 # type: ignore + + if _bars[-1].close > dc and ratio[-1] < ra: + v1 = "看多" + if _bars[-1].close < dc and ratio[-1] > ra: + v1 = "看空" + + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def check(): + from czsc.connectors import research + from czsc.traders.base import check_signals_acc + + symbols = research.get_symbols('中证500成分股') + symbol = symbols[0] + # for symbol in symbols[:10]: + bars = research.get_raw_bars(symbol, '日线', '20101101', '20230101', fq='前复权') + signals_config = [{'name': lverage_up_dw_line_V230824, 'freq': '日线', 'di': 1, 'n': 10}] + check_signals_acc(bars, signals_config=signals_config, height='780px') # type: ignore + + +if __name__ == '__main__': + check() diff --git a/examples/signals_dev/merged/ntmdk_V230824.py b/examples/signals_dev/merged/ntmdk_V230824.py new file mode 100644 index 000000000..09efd9d21 --- /dev/null +++ b/examples/signals_dev/merged/ntmdk_V230824.py @@ -0,0 +1,56 @@ +from collections import OrderedDict +import numpy as np +from czsc.connectors import research +from czsc import CZSC, check_signals_acc, get_sub_elements +from czsc.utils import create_single_signal + + +def ntmdk_V230824(c: CZSC, **kwargs) -> OrderedDict: + """NTMDK多空指标,贡献者:琅盎 + + 参数模板:"freq}_D{di}M{m}_NTMDK多空V230824" + + **信号逻辑:** + + 此信号函数的逻辑非常简单,流传于股市中有一句话:日日新高日日持股, + 那么此信号函数利用的是收盘价和M日前的收盘价进行比较,如果差值为正 + 即多头成立,反之空头成立。 + + **信号列表:** + + - Signal('日线_D1M10_NTMDK多空V230824_看空_任意_任意_0') + - Signal('日线_D1M10_NTMDK多空V230824_看多_任意_任意_0') + + :param c: CZSC对象 + :param kwargs: 参数字典 + + - :param di: 信号计算截止倒数第i根K线 + - :param m: m天前的价格 + + :return: 信号识别结果 + """ + di = int(kwargs.get("di", 1)) + m = int(kwargs.get("m", 10)) + freq = c.freq.value + k1, k2, k3 = f"{freq}_D{di}M{m}_NTMDK多空V230824".split('_') + v1 = "其他" + if len(c.bars_raw) < di + m + 10: + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + bars = get_sub_elements(c.bars_raw, di=di, n=m) + v1 = "看多" if bars[-1].close > bars[0].close else "看空" + return create_single_signal(k1=k1, k2=k2, k3=k3, v1=v1) + + +def main(): + symbols = research.get_symbols('A股主要指数') + bars = research.get_raw_bars(symbols[0], '15分钟', '20171101', '20210101', fq='前复权') + + signals_config = [ + {'name': ntmdk_V230824, 'freq': '日线', 'di': 1}, + ] + check_signals_acc(bars, signals_config=signals_config) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/examples/signals_dev/pos_status_V230808.py b/examples/signals_dev/merged/pos_status_V230808.py similarity index 100% rename from examples/signals_dev/pos_status_V230808.py rename to examples/signals_dev/merged/pos_status_V230808.py diff --git a/examples/signals_dev/signal_match.py b/examples/signals_dev/signal_match.py index 5daa08588..0d595f4db 100644 --- a/examples/signals_dev/signal_match.py +++ b/examples/signals_dev/signal_match.py @@ -52,5 +52,5 @@ bars = read_1min() conf = get_signals_config(signals_seq) freqs = get_signals_freqs(signals_seq) - sigs = generate_czsc_signals(bars, signals_config=conf, sdt='20180101', df=True) + sigs = generate_czsc_signals(bars, signals_config=conf, sdt='20190101', df=True) print(sigs.shape) diff --git a/requirements.txt b/requirements.txt index e6e070334..da8b4e2dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ tenacity>=8.1.0 requests-toolbelt>=0.10.1 plotly>=5.11.0 parse>=1.19.0 +lightgbm>=4.0.0 \ No newline at end of file diff --git a/test/test_utils.py b/test/test_utils.py index a739a2622..72d3a40ce 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -56,4 +56,34 @@ def test_subtract_fee(): # 执行函数并捕获异常 with pytest.raises(AssertionError): - subtract_fee(df, fee=1) \ No newline at end of file + subtract_fee(df, fee=1) + + +def test_ranker(): + import numpy as np + import pandas as pd + from czsc.utils.cross import cross_sectional_ranker + + np.random.seed(42) + dates = pd.date_range('2021-01-01', '2023-01-05') + symbols = ['AAPL', 'GOOG', 'TSLA', 'MSFT'] + data = {'date': [], 'symbol': [], 'return': [], 'factor1': [], 'factor2': []} + for date in dates: + returns = np.random.randn(len(symbols)) + ranks = np.argsort(returns) + 1 + for ticker, rank in zip(symbols, ranks): + data['date'].append(date) + data['symbol'].append(ticker) + data['return'].append(rank) # 'return' 现在代表了每天的收益率排名 + data['factor1'].append(np.random.randn()) + data['factor2'].append(np.random.randn()) + df = pd.DataFrame(data) + df['dt'] = df['date'] + + x_cols = ['factor1', 'factor2'] + y_col = 'return' + + dfp = cross_sectional_ranker(df, x_cols, y_col) + assert dfp['rank'].max() == len(symbols) + assert dfp['rank'].min() == 1 + assert dfp['rank'].mean() == 2.5