From d3a1e03a113127bf65464c0b53e2fdd213d8dd2e Mon Sep 17 00:00:00 2001 From: bxdd Date: Sat, 20 Mar 2021 00:11:19 +0800 Subject: [PATCH 001/187] add sample & base class --- qlib/data/data.py | 40 ++-- qlib/strategy/__init__.py | 9 + qlib/strategy/cost_control.py | 73 ++++++++ qlib/strategy/order_generator.py | 171 +++++++++++++++++ qlib/strategy/strategy.py | 304 +++++++++++++++++++++++++++++++ qlib/utils/__init__.py | 120 ++++++++++++ 6 files changed, 705 insertions(+), 12 deletions(-) create mode 100644 qlib/strategy/__init__.py create mode 100644 qlib/strategy/cost_control.py create mode 100644 qlib/strategy/order_generator.py create mode 100644 qlib/strategy/strategy.py diff --git a/qlib/data/data.py b/qlib/data/data.py index 000bd1196..68e1a69d2 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -6,6 +6,7 @@ from __future__ import division from __future__ import print_function import os +import re import abc import time import queue @@ -24,7 +25,7 @@ from ..log import get_module_logger from ..utils import parse_field, read_bin, hash_args, normalize_cache_fields, code_to_fname from .base import Feature from .cache import DiskDatasetCache, DiskExpressionCache -from ..utils import Wrapper, init_instance_by_config, register_wrapper, get_module_by_module_path +from ..utils import Wrapper, init_instance_by_config, register_wrapper, get_module_by_module_path, sample_calendar class CalendarProvider(abc.ABC): @@ -55,7 +56,7 @@ class CalendarProvider(abc.ABC): """ raise NotImplementedError("Subclass of CalendarProvider must implement `calendar` method") - def locate_index(self, start_time, end_time, freq, future): + def locate_index(self, start_time, end_time, freq, freq_sam=None, future=False): """Locate the start time index and end time index in a calendar under certain frequency. Parameters @@ -82,7 +83,7 @@ class CalendarProvider(abc.ABC): """ start_time = pd.Timestamp(start_time) end_time = pd.Timestamp(end_time) - calendar, calendar_index = self._get_calendar(freq=freq, future=future) + calendar, calendar_index = self._get_calendar(freq=freq, freq_sam=freq_sam, future=future) if start_time not in calendar_index: try: start_time = calendar[bisect.bisect_left(calendar, start_time)] @@ -96,7 +97,7 @@ class CalendarProvider(abc.ABC): end_index = calendar_index[end_time] return start_time, end_time, start_index, end_index - def _get_calendar(self, freq, future): + def _get_calendar(self, freq, freq_sam=None, future=False): """Load calendar using memcache. Parameters @@ -113,14 +114,21 @@ class CalendarProvider(abc.ABC): dict dict composed by timestamp as key and index as value for fast search. """ - flag = f"{freq}_future_{future}" + flag = f"{freq}_future_{future}_sam_{freq_sam}" if flag in H["c"]: _calendar, _calendar_index = H["c"][flag] else: + flag_raw = f"{freq}_future_{future}_sam_{None}" _calendar = np.array(self.load_calendar(freq, future)) _calendar_index = {x: i for i, x in enumerate(_calendar)} # for fast search - H["c"][flag] = _calendar, _calendar_index - return _calendar, _calendar_index + H["c"][flag_raw] = _calendar, _calendar_index + if freq_sam is None: + return _calendar, _calendar_index + else: + _calendar_sam = sample_calendar(_calendar, freq, freq_sam) + _calendar_sam_index = {x: i for i, x in enumerate(_calendar_sam)} + H["c"][flag] = _calendar_sam, _calendar_sam_index + return _calendar_sam, _calendar_sam_index def _uri(self, start_time, end_time, freq, future=False): """Get the uri of calendar generation task.""" @@ -530,12 +538,13 @@ class LocalCalendarProvider(CalendarProvider): with open(fname) as f: return [pd.Timestamp(x.strip()) for x in f] - def calendar(self, start_time=None, end_time=None, freq="day", future=False): - _calendar, _calendar_index = self._get_calendar(freq, future) + def calendar(self, start_time=None, end_time=None, freq="day", future=False, freq_sam=None): + _calendar, _ = self._get_calendar(freq=freq, future=future) if start_time == "None": start_time = None if end_time == "None": end_time = None + # strip if start_time: start_time = pd.Timestamp(start_time) @@ -549,8 +558,15 @@ class LocalCalendarProvider(CalendarProvider): return np.array([]) else: end_time = _calendar[-1] - _, _, si, ei = self.locate_index(start_time, end_time, freq, future) - return _calendar[si : ei + 1] + st, et, si, ei = self.locate_index(start_time, end_time, freq=freq, future=future) + _calendar = _calendar[si : ei + 1] + if freq_sam is None: + return _calendar + else: + _calendar_sam, _ = self._get_calendar(freq=freq, freq_sam=freq_sam, future=future) + st, et, si, ei = self.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam, future=future) + if bisect.bisect(_calendar, st, 0, len(_calendar)): + return np.hstack() class LocalInstrumentProvider(InstrumentProvider): @@ -658,7 +674,7 @@ class LocalExpressionProvider(ExpressionProvider): expression = self.get_expression_instance(field) start_time = pd.Timestamp(start_time) end_time = pd.Timestamp(end_time) - _, _, start_index, end_index = Cal.locate_index(start_time, end_time, freq, future=False) + _, _, start_index, end_index = Cal.locate_index(start_time, end_time, freq=freq, future=False) lft_etd, rght_etd = expression.get_extended_window_size() series = expression.load(instrument, max(0, start_index - lft_etd), end_index + rght_etd, freq) # Ensure that each column type is consistent diff --git a/qlib/strategy/__init__.py b/qlib/strategy/__init__.py new file mode 100644 index 000000000..6c2e4ceed --- /dev/null +++ b/qlib/strategy/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +from .strategy import ( + TopkDropoutStrategy, + BaseStrategy, + WeightStrategyBase, +) diff --git a/qlib/strategy/cost_control.py b/qlib/strategy/cost_control.py new file mode 100644 index 000000000..dd90437b0 --- /dev/null +++ b/qlib/strategy/cost_control.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +from .strategy import StrategyWrapper, WeightStrategyBase +import copy + + +class SoftTopkStrategy(WeightStrategyBase): + def __init__(self, topk, max_sold_weight=1.0, risk_degree=0.95, buy_method="first_fill"): + """Parameter + topk : int + top-N stocks to buy + risk_degree : float + position percentage of total value + buy_method : + rank_fill: assign the weight stocks that rank high first(1/topk max) + average_fill: assign the weight to the stocks rank high averagely. + """ + super().__init__() + self.topk = topk + self.max_sold_weight = max_sold_weight + self.risk_degree = risk_degree + self.buy_method = buy_method + + def get_risk_degree(self, date): + """get_risk_degree + Return the proportion of your total value you will used in investment. + Dynamically risk_degree will result in Market timing + """ + # It will use 95% amoutn of your total value by default + return self.risk_degree + + def generate_target_weight_position(self, score, current, trade_date): + """Parameter: + score : pred score for this trade date, pd.Series, index is stock_id, contain 'score' column + current : current position, use Position() class + trade_date : trade date + generate target position from score for this date and the current position + The cache is not considered in the position + """ + # TODO: + # If the current stock list is more than topk(eg. The weights are modified + # by risk control), the weight will not be handled correctly. + buy_signal_stocks = set(score.sort_values(ascending=False).iloc[: self.topk].index) + cur_stock_weight = current.get_stock_weight_dict(only_stock=True) + + if len(cur_stock_weight) == 0: + final_stock_weight = {code: 1 / self.topk for code in buy_signal_stocks} + else: + final_stock_weight = copy.deepcopy(cur_stock_weight) + sold_stock_weight = 0.0 + for stock_id in final_stock_weight: + if stock_id not in buy_signal_stocks: + sw = min(self.max_sold_weight, final_stock_weight[stock_id]) + sold_stock_weight += sw + final_stock_weight[stock_id] -= sw + if self.buy_method == "first_fill": + for stock_id in buy_signal_stocks: + add_weight = min( + max(1 / self.topk - final_stock_weight.get(stock_id, 0), 0.0), + sold_stock_weight, + ) + final_stock_weight[stock_id] = final_stock_weight.get(stock_id, 0.0) + add_weight + sold_stock_weight -= add_weight + elif self.buy_method == "average_fill": + for stock_id in buy_signal_stocks: + final_stock_weight[stock_id] = final_stock_weight.get(stock_id, 0.0) + sold_stock_weight / len( + buy_signal_stocks + ) + else: + raise ValueError("Buy method not found") + return final_stock_weight diff --git a/qlib/strategy/order_generator.py b/qlib/strategy/order_generator.py new file mode 100644 index 000000000..494981ecc --- /dev/null +++ b/qlib/strategy/order_generator.py @@ -0,0 +1,171 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +This order generator is for strategies based on WeightStrategyBase +""" +from ..backtest.position import Position +from ..backtest.exchange import Exchange +import pandas as pd +import copy + + +class OrderGenerator: + def generate_order_list_from_target_weight_position( + self, + current: Position, + trade_exchange: Exchange, + target_weight_position: dict, + risk_degree: float, + pred_date: pd.Timestamp, + trade_date: pd.Timestamp, + ) -> list: + """generate_order_list_from_target_weight_position + + :param current: The current position + :type current: Position + :param trade_exchange: + :type trade_exchange: Exchange + :param target_weight_position: {stock_id : weight} + :type target_weight_position: dict + :param risk_degree: + :type risk_degree: float + :param pred_date: the date the score is predicted + :type pred_date: pd.Timestamp + :param trade_date: the date the stock is traded + :type trade_date: pd.Timestamp + + :rtype: list + """ + raise NotImplementedError() + + +class OrderGenWInteract(OrderGenerator): + """Order Generator With Interact""" + + def generate_order_list_from_target_weight_position( + self, + current: Position, + trade_exchange: Exchange, + target_weight_position: dict, + risk_degree: float, + pred_date: pd.Timestamp, + trade_date: pd.Timestamp, + ) -> list: + """generate_order_list_from_target_weight_position + + No adjustment for for the nontradable share. + All the tadable value is assigned to the tadable stock according to the weight. + if interact == True, will use the price at trade date to generate order list + else, will only use the price before the trade date to generate order list + + :param current: + :type current: Position + :param trade_exchange: + :type trade_exchange: Exchange + :param target_weight_position: + :type target_weight_position: dict + :param risk_degree: + :type risk_degree: float + :param pred_date: + :type pred_date: pd.Timestamp + :param trade_date: + :type trade_date: pd.Timestamp + + :rtype: list + """ + # calculate current_tradable_value + current_amount_dict = current.get_stock_amount_dict() + current_total_value = trade_exchange.calculate_amount_position_value( + amount_dict=current_amount_dict, trade_date=trade_date, only_tradable=False + ) + current_tradable_value = trade_exchange.calculate_amount_position_value( + amount_dict=current_amount_dict, trade_date=trade_date, only_tradable=True + ) + # add cash + current_tradable_value += current.get_cash() + + reserved_cash = (1.0 - risk_degree) * (current_total_value + current.get_cash()) + current_tradable_value -= reserved_cash + + if current_tradable_value < 0: + # if you sell all the tradable stock can not meet the reserved + # value. Then just sell all the stocks + target_amount_dict = copy.deepcopy(current_amount_dict.copy()) + for stock_id in list(target_amount_dict.keys()): + if trade_exchange.is_stock_tradable(stock_id, trade_date): + del target_amount_dict[stock_id] + else: + # consider cost rate + current_tradable_value /= 1 + max(trade_exchange.close_cost, trade_exchange.open_cost) + + # strategy 1 : generate amount_position by weight_position + # Use API in Exchange() + target_amount_dict = trade_exchange.generate_amount_position_from_weight_position( + weight_position=target_weight_position, + cash=current_tradable_value, + trade_date=trade_date, + ) + order_list = trade_exchange.generate_order_for_target_amount_position( + target_position=target_amount_dict, + current_position=current_amount_dict, + trade_date=trade_date, + ) + return order_list + + +class OrderGenWOInteract(OrderGenerator): + """Order Generator Without Interact""" + + def generate_order_list_from_target_weight_position( + self, + current: Position, + trade_exchange: Exchange, + target_weight_position: dict, + risk_degree: float, + pred_date: pd.Timestamp, + trade_date: pd.Timestamp, + ) -> list: + """generate_order_list_from_target_weight_position + + generate order list directly not using the information (e.g. whether can be traded, the accurate trade price) at trade date. + In target weight position, generating order list need to know the price of objective stock in trade date, but we cannot get that + value when do not interact with exchange, so we check the %close price at pred_date or price recorded in current position. + + :param current: + :type current: Position + :param trade_exchange: + :type trade_exchange: Exchange + :param target_weight_position: + :type target_weight_position: dict + :param risk_degree: + :type risk_degree: float + :param pred_date: + :type pred_date: pd.Timestamp + :param trade_date: + :type trade_date: pd.Timestamp + + :rtype: list + """ + risk_total_value = risk_degree * current.calculate_value() + + current_stock = current.get_stock_list() + amount_dict = {} + for stock_id in target_weight_position: + # Current rule will ignore the stock that not hold and cannot be traded at predict date + if trade_exchange.is_stock_tradable(stock_id=stock_id, trade_date=pred_date): + amount_dict[stock_id] = ( + risk_total_value * target_weight_position[stock_id] / trade_exchange.get_close(stock_id, pred_date) + ) + elif stock_id in current_stock: + amount_dict[stock_id] = ( + risk_total_value * target_weight_position[stock_id] / current.get_stock_price(stock_id) + ) + else: + continue + order_list = trade_exchange.generate_order_for_target_amount_position( + target_position=amount_dict, + current_position=current.get_stock_amount_dict(), + trade_date=trade_date, + ) + return order_list diff --git a/qlib/strategy/strategy.py b/qlib/strategy/strategy.py new file mode 100644 index 000000000..0476f7d72 --- /dev/null +++ b/qlib/strategy/strategy.py @@ -0,0 +1,304 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import copy +import numpy as np +import pandas as pd + +from ..data.dataset import DatasetH +from ..backtest.order import Order +from .order_generator import OrderGenWInteract + +""" +1. BaseStrategy 的粒度一定是数据粒度的整数倍 +- 关于calendar的合并咋整 +- adjust_dates这个东西啥用 +- label和freq和strategy的bar分离,这个如何决策呢 +""" +class BaseStrategy: + def __init__(self, bar, start_time, end_time): + self.bar = bar + self.start_time = start_time + self.end_time = end_time + self.current_time = start_time + + def generate_action(self, current): + pass + + +class RuleStrategy(BaseStrategy): + pass + +class DLStrategy(BaseStrategy): + def __init__(self, bar, model, dataset:DatasetH, start_time=None, end_time=None): + super(DLStrategy, self).__init__(bar, start_time, end_time) + self.model = model + self.dataset = dataset + self.pred_score_all = self.model.predict(dataset) + self.pred_score = None + _pred_dates = pred.index.get_level_values(level="datetime") + self.start_time = _pred_dates.min() if start_time is None else start_time + self.end_time = _pred_dates.max() if end_time is None else end_time + self.pred_date = [pd.Timestamp(self.start_time), *D.calendar(start_time=_pred_dates.min(), end_time=_pred_dates.max(), freq=bar), self.end_time] + self.current_index = -1 + self.pred_length = len(self.pred_date) + + def _update_pred_score(self): + """update pred score + """ + pass + +class AdjustTimer: + """AdjustTimer + Responsible for timing of position adjusting + + This is designed as multiple inheritance mechanism due to: + - the is_adjust may need access to the internel state of a strategy. + + - it can be reguard as a enhancement to the existing strategy. + """ + + # adjust position in each trade date + def is_adjust(self, trade_date): + """is_adjust + Return if the strategy can adjust positions on `trade_date` + Will normally be used in strategy do trading with trade frequency + """ + return True + + +class ListAdjustTimer(AdjustTimer): + def __init__(self, adjust_dates=None): + """__init__ + + :param adjust_dates: an iterable object, it will return a timelist for trading dates + """ + if adjust_dates is None: + # None indicates that all dates is OK for adjusting + self.adjust_dates = None + else: + self.adjust_dates = {pd.Timestamp(dt) for dt in adjust_dates} + + def is_adjust(self, trade_date): + if self.adjust_dates is None: + return True + return pd.Timestamp(trade_date) in self.adjust_dates + +class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): + def __init__( + self, + bar, + model, + dataset, + trade_exchange, + topk, + n_drop, + start_time=None, + end_time=None, + method_sell="bottom", + method_buy="top", + risk_degree=0.95, + thresh=1, + hold_thresh=1, + only_tradable=False, + **kwargs, + ): + """ + Parameters + ----------- + topk : int + the number of stocks in the portfolio. + n_drop : int + number of stocks to be replaced in each trading date. + method_sell : str + dropout method_sell, random/bottom. + method_buy : str + dropout method_buy, random/top. + risk_degree : float + position percentage of total value. + thresh : int + minimun holding days since last buy singal of the stock. + hold_thresh : int + minimum holding days + before sell stock , will check current.get_stock_count(order.stock_id) >= self.thresh. + only_tradable : bool + will the strategy only consider the tradable stock when buying and selling. + if only_tradable: + strategy will make buy sell decision without checking the tradable state of the stock. + else: + strategy will make decision with the tradable state of the stock info and avoid buy and sell them. + """ + super(TopkDropoutStrategy, self).__init__(bar, model, dataset, start_time, end_time) + ListAdjustTimer.__init__(self, kwargs.get("adjust_dates", None)) + self.trade_exchange = trade_exchange + self.topk = topk + self.n_drop = n_drop + self.method_sell = method_sell + self.method_buy = method_buy + self.risk_degree = risk_degree + self.thresh = thresh + # self.stock_count['code'] will be the days the stock has been hold + # since last buy signal. This is designed for thresh + self.stock_count = {} + + self.hold_thresh = hold_thresh + self.only_tradable = only_tradable + + def get_risk_degree(self, date): + """get_risk_degree + Return the proportion of your total value you will used in investment. + Dynamically risk_degree will result in Market timing. + """ + # It will use 95% amoutn of your total value by default + return self.risk_degree + + def generate_action(self, current): + + self.current_index += 1 + + if not self.is_adjust(trade_date): + return [] + + if self.only_tradable: + # If The strategy only consider tradable stock when make decision + # It needs following actions to filter stocks + def get_first_n(l, n, reverse=False): + cur_n = 0 + res = [] + for si in reversed(l) if reverse else l: + if self.trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date): + res.append(si) + cur_n += 1 + if cur_n >= n: + break + return res[::-1] if reverse else res + + def get_last_n(l, n): + return get_first_n(l, n, reverse=True) + + def filter_stock(l): + return [si for si in l if self.trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date)] + + else: + # Otherwise, the stock will make decision with out the stock tradable info + def get_first_n(l, n): + return list(l)[:n] + + def get_last_n(l, n): + return list(l)[-n:] + + def filter_stock(l): + return l + + current_temp = copy.deepcopy(current) + # generate order list for this adjust date + sell_order_list = [] + buy_order_list = [] + # load score + cash = current_temp.get_cash() + current_stock_list = current_temp.get_stock_list() + # last position (sorted by score) + last = self.pred_score.reindex(current_stock_list).sort_values(ascending=False).index + # The new stocks today want to buy **at most** + if self.method_buy == "top": + today = get_first_n( + self.pred_score[~self.pred_score.index.isin(last)].sort_values(ascending=False).index, + self.n_drop + self.topk - len(last), + ) + elif self.method_buy == "random": + topk_candi = get_first_n(self.pred_score.sort_values(ascending=False).index, self.topk) + candi = list(filter(lambda x: x not in last, topk_candi)) + n = self.n_drop + self.topk - len(last) + try: + today = np.random.choice(candi, n, replace=False) + except ValueError: + today = candi + else: + raise NotImplementedError(f"This type of input is not supported") + # combine(new stocks + last stocks), we will drop stocks from this list + # In case of dropping higher score stock and buying lower score stock. + comb = self.pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index + + # Get the stock list we really want to sell (After filtering the case that we sell high and buy low) + if self.method_sell == "bottom": + sell = last[last.isin(get_last_n(comb, self.n_drop))] + elif self.method_sell == "random": + candi = filter_stock(last) + try: + sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else []) + except ValueError: # No enough candidates + sell = candi + else: + raise NotImplementedError(f"This type of input is not supported") + + # Get the stock list we really want to buy + buy = today[: len(sell) + self.topk - len(last)] + + # buy singal: if a stock falls into topk, it appear in the buy_sinal + buy_signal = self.pred_score.sort_values(ascending=False).iloc[: self.topk].index + + for code in current_stock_list: + if not self.trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): + continue + if code in sell: + # check hold limit + if self.stock_count[code] < self.thresh or current_temp.get_stock_count(code) < self.hold_thresh: + # can not sell this code + # no buy signal, but the stock is kept + self.stock_count[code] += 1 + continue + # sell order + sell_amount = current_temp.get_stock_amount(code=code) + sell_order = Order( + stock_id=code, + amount=sell_amount, + trade_date=trade_date, + direction=Order.SELL, # 0 for sell, 1 for buy + factor=self.trade_exchange.get_factor(code, trade_date), + ) + # is order executable + if self.trade_exchange.check_order(sell_order): + sell_order_list.append(sell_order) + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(sell_order, position=current_temp) + # update cash + cash += trade_val - trade_cost + # sold + del self.stock_count[code] + else: + # no buy signal, but the stock is kept + self.stock_count[code] += 1 + elif code in buy_signal: + # NOTE: This is different from the original version + # get new buy signal + # Only the stock fall in to topk will produce buy signal + self.stock_count[code] = 1 + else: + self.stock_count[code] += 1 + # buy new stock + # note the current has been changed + current_stock_list = current_temp.get_stock_list() + value = cash * self.risk_degree / len(buy) if len(buy) > 0 else 0 + + # open_cost should be considered in the real trading environment, while the backtest in evaluate.py does not + # consider it as the aim of demo is to accomplish same strategy as evaluate.py, so comment out this line + # value = value / (1+self.trade_exchange.open_cost) # set open_cost limit + for code in buy: + # check is stock suspended + if not self.trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): + continue + # buy order + buy_price = self.trade_exchange.get_deal_price(stock_id=code, trade_date=trade_date) + buy_amount = value / buy_price + factor = self.trade_exchange.quote[(code, trade_date)]["$factor"] + buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor) + buy_order = Order( + stock_id=code, + amount=buy_amount, + trade_date=trade_date, + direction=Order.BUY, # 1 for buy + factor=factor, + ) + buy_order_list.append(buy_order) + self.stock_count[code] = 1 + return sell_order_list + buy_order_list diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index 1ee6f07a1..28982bc3a 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -799,3 +799,123 @@ def fname_to_code(fname: str): if fname.startswith(prefix): fname = fname.lstrip(prefix) return fname + +########################## Sample ############################ +def sample_calendar_bac(calendar_raw, freq_raw, freq_sam): + """ + freq_raw : "min" or "day" + """ + freq_raw = "1" + freq_raw if re.match("^[0-9]", freq_raw) is None else freq_raw + freq_sam = "1" + freq_sam if re.match("^[0-9]", freq_sam) is None else freq_sam + + if freq_sam.endswith(("minute", "min")): + def cal_next_sam_minute(x, sam_minutes): + hour = x.hour + minute = x.minute + if 9 <= hour <= 11: + minute_index = (11 - hour)*60 + 30 - minute + 120 + elif 13 <= hour <= 15: + minute_index = (15 - hour)*60 - minute + else: + raise ValueError("calendar hour must be in [9, 11] or [13, 15]") + + minute_index = minute_index // sam_minutes * sam_minutes + + if 0 <= minute_index < 120: + return 15 - (minute_index + 59) // 60, (120 - minute_index) % 60 + elif 120 <= minute_index < 240: + return 11 - (minute_index - 120 + 29) // 60, (240 - minute_index + 30) % 60 + else: + raise ValueError("calendar minute_index error") + + sam_minutes = int(freq_sam[:-3]) if freq_sam.endswith("min") else int(freq_sam[:-6]) + + if not freq_raw.endswith(("minute", "min")): + raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") + else: + raw_minutes = int(freq_raw[:-3]) if freq_raw.endswith("min") else int(freq_raw[:-6]) + if raw_minutes > sam_minutes: + raise ValueError("raw freq must be higher than sample freq") + + _calendar_minute = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_minutes), 59), calendar_raw))) + return _calendar_minute + else: + + _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 23, 59, 59), calendar_raw))) + if freq_sam.endswith(("day", "d")): + sam_days = int(freq_sam[:-1]) if freq_sam.endswith("d") else int(freq_sam[:-3]) + return _calendar_day[(len(_calendar_day) + sam_days - 1)%sam_days::sam_days] + + elif freq_sam.endswith(("week", "w")): + sam_weeks = int(freq_sam[:-1]) if freq_sam.endswith("w") else int(freq_sam[:-4]) + _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) + _calendar_week = _calendar_day[np.ediff1d(_day_in_week[::-1], to_begin=1)[::-1] > 0] + return _calendar_week[(len(_calendar_week) + sam_weeks - 1)%sam_weeks::sam_weeks] + + elif freq_sam.endswith(("month", "m")): + sam_months = int(freq_sam[:-1]) if freq_sam.endswith("m") else int(freq_sam[:-5]) + _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) + _calendar_month = _calendar_day[np.ediff1d(_day_in_month[::-1], to_begin=1)[::-1] > 0] + return _calendar_month[(len(_calendar_month) + sam_months - 1)%sam_months::sam_months] + else: + raise ValueError("sample freq must be xmin, xd, xw, xm") + +def sample_calendar(calendar_raw, freq_raw, freq_sam): + """ + freq_raw : "min" or "day" + """ + freq_raw = "1" + freq_raw if re.match("^[0-9]", freq_raw) is None else freq_raw + freq_sam = "1" + freq_sam if re.match("^[0-9]", freq_sam) is None else freq_sam + + if freq_sam.endswith(("minute", "min")): + def cal_next_sam_minute(x, sam_minutes): + hour = x.hour + minute = x.minute + if 9 <= hour <= 11: + minute_index = (hour - 9)*60 + minute - 30 + elif 13 <= hour <= 15: + minute_index = (hour - 13)*60 + minute + 120 + else: + raise ValueError("calendar hour must be in [9, 11] or [13, 15]") + + minute_index = minute_index // sam_minutes * sam_minutes + + if 0 <= minute_index < 120: + return 9 + (minute_index + 30) // 60, (minute_index + 30) % 60 + elif 120 <= minute_index < 240: + return 13 + (minute_index - 120) // 60, (minute_index - 120) % 60 + else: + raise ValueError("calendar minute_index error") + sam_minutes = int(freq_sam[:-3]) if freq_sam.endswith("min") else int(freq_sam[:-6]) + if not freq_raw.endswith(("minute", "min")): + raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") + else: + raw_minutes = int(freq_raw[:-3]) if freq_raw.endswith("min") else int(freq_raw[:-6]) + if raw_minutes > sam_minutes: + raise ValueError("raw freq must be higher than sample freq") + _calendar_minute = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_minutes), 0), calendar_raw))) + return _calendar_minute + else: + _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 0, 0, 0), calendar_raw))) + if freq_sam.endswith(("day", "d")): + sam_days = int(freq_sam[:-1]) if freq_sam.endswith("d") else int(freq_sam[:-3]) + return _calendar_day[::sam_days] + + elif freq_sam.endswith(("week", "w")): + sam_weeks = int(freq_sam[:-1]) if freq_sam.endswith("w") else int(freq_sam[:-4]) + _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) + _calendar_week = _calendar_day[np.ediff1d(_day_in_week, to_begin=-1) < 0] + return _calendar_week[::sam_weeks] + + elif freq_sam.endswith(("month", "m")): + sam_months = int(freq_sam[:-1]) if freq_sam.endswith("m") else int(freq_sam[:-5]) + _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) + _calendar_month = _calendar_day[np.ediff1d(_day_in_month, to_begin=-1) < 0] + return _calendar_month[::sam_months] + else: + raise ValueError("sample freq must be xmin, xd, xw, xm") + +def sample_feature(feature_raw, freq, start_time, end_time, method="last"): + datetime_raw = feature_raw.index.get_level_values("datetime") + feature_sample = feature_raw[list(map(lambda x: start_time < x <= end_time, datetime_raw))] + return getattr(feature_sample.groupby(level="instrument"), method)() \ No newline at end of file From 971d6a2847e58150a69a8b1bb3ed10cb8c4f86b8 Mon Sep 17 00:00:00 2001 From: bxdd Date: Wed, 21 Apr 2021 16:42:16 +0800 Subject: [PATCH 002/187] update strategy --- qlib/backtest/__init__.py | 324 +++++++++++++ qlib/backtest/account.py | 170 +++++++ qlib/backtest/backtest.py | 143 ++++++ qlib/backtest/exchange.py | 429 ++++++++++++++++++ qlib/backtest/import numpy as np | 90 ++++ qlib/backtest/order.py | 30 ++ qlib/backtest/position.py | 213 +++++++++ qlib/backtest/profit_attribution.py | 324 +++++++++++++ qlib/backtest/report.py | 106 +++++ qlib/contrib/strategy/__init__.py | 11 +- qlib/contrib/strategy/cost_control.py | 6 +- .../strategy/dl_strategy.py} | 198 ++++---- qlib/contrib/strategy/order_generator.py | 39 +- qlib/contrib/strategy/rule_strategy.py | 161 +++++++ qlib/contrib/strategy/strategy.py | 413 ----------------- qlib/data/data.py | 26 +- qlib/env/__init__.py | 0 qlib/env/env.py | 169 +++++++ qlib/env/env_wrapper.py | 33 ++ qlib/env/interpreter.py | 15 + qlib/strategy/__init__.py | 7 - qlib/strategy/base.py | 67 +++ qlib/strategy/cost_control.py | 73 --- qlib/strategy/order_generator.py | 171 ------- qlib/utils/__init__.py | 34 +- 25 files changed, 2442 insertions(+), 810 deletions(-) create mode 100644 qlib/backtest/__init__.py create mode 100644 qlib/backtest/account.py create mode 100644 qlib/backtest/backtest.py create mode 100644 qlib/backtest/exchange.py create mode 100644 qlib/backtest/import numpy as np create mode 100644 qlib/backtest/order.py create mode 100644 qlib/backtest/position.py create mode 100644 qlib/backtest/profit_attribution.py create mode 100644 qlib/backtest/report.py rename qlib/{strategy/strategy.py => contrib/strategy/dl_strategy.py} (64%) create mode 100644 qlib/contrib/strategy/rule_strategy.py delete mode 100644 qlib/contrib/strategy/strategy.py create mode 100644 qlib/env/__init__.py create mode 100644 qlib/env/env.py create mode 100644 qlib/env/env_wrapper.py create mode 100644 qlib/env/interpreter.py create mode 100644 qlib/strategy/base.py delete mode 100644 qlib/strategy/cost_control.py delete mode 100644 qlib/strategy/order_generator.py diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py new file mode 100644 index 000000000..aa24ffb0c --- /dev/null +++ b/qlib/backtest/__init__.py @@ -0,0 +1,324 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from .order import Order +from .account import Account +from .position import Position +from .exchange import Exchange +from .report import Report +from .backtest import backtest as backtest_func, get_date_range + +import numpy as np +import inspect +from ...utils import init_instance_by_config +from ...log import get_module_logger +from ...config import C + +logger = get_module_logger("backtest caller") + + +def get_strategy( + strategy=None, + topk=50, + margin=0.5, + n_drop=5, + risk_degree=0.95, + str_type="dropout", + adjust_dates=None, +): + """get_strategy + + There will be 3 ways to return a stratgy. Please follow the code. + + + Parameters + ---------- + + strategy : Strategy() + strategy used in backtest. + topk : int (Default value: 50) + top-N stocks to buy. + margin : int or float(Default value: 0.5) + - if isinstance(margin, int): + + sell_limit = margin + + - else: + + sell_limit = pred_in_a_day.count() * margin + + buffer margin, in single score_mode, continue holding stock if it is in nlargest(sell_limit). + sell_limit should be no less than topk. + n_drop : int + number of stocks to be replaced in each trading date. + risk_degree: float + 0-1, 0.95 for example, use 95% money to trade. + str_type: 'amount', 'weight' or 'dropout' + strategy type: TopkAmountStrategy ,TopkWeightStrategy or TopkDropoutStrategy. + + Returns + ------- + :class: Strategy + an initialized strategy object + """ + + # There will be 3 ways to return a strategy. + if strategy is None: + # 1) create strategy with param `strategy` + str_cls_dict = { + "amount": "TopkAmountStrategy", + "weight": "TopkWeightStrategy", + "dropout": "TopkDropoutStrategy", + } + logger.info("Create new strategy ") + from .. import strategy as strategy_pool + + str_cls = getattr(strategy_pool, str_cls_dict.get(str_type)) + strategy = str_cls( + topk=topk, + buffer_margin=margin, + n_drop=n_drop, + risk_degree=risk_degree, + adjust_dates=adjust_dates, + ) + elif isinstance(strategy, (dict, str)): + # 2) create strategy with init_instance_by_config + logger.info("Create new strategy ") + strategy = init_instance_by_config(strategy) + + from ..strategy.strategy import BaseStrategy + + # else: nothing happens. 3) Use the strategy directly + if not isinstance(strategy, BaseStrategy): + raise TypeError("Strategy not supported") + return strategy + + +def get_exchange( + pred, + exchange=None, + subscribe_fields=[], + open_cost=0.0015, + close_cost=0.0025, + min_cost=5.0, + trade_unit=None, + limit_threshold=None, + deal_price=None, + extract_codes=False, + shift=1, +): + """get_exchange + + Parameters + ---------- + + # exchange related arguments + exchange: Exchange(). + subscribe_fields: list + subscribe fields. + open_cost : float + open transaction cost. + close_cost : float + close transaction cost. + min_cost : float + min transaction cost. + trade_unit : int + 100 for China A. + deal_price: str + dealing price type: 'close', 'open', 'vwap'. + limit_threshold : float + limit move 0.1 (10%) for example, long and short with same limit. + extract_codes: bool + will we pass the codes extracted from the pred to the exchange. + NOTE: This will be faster with offline qlib. + + Returns + ------- + :class: Exchange + an initialized Exchange object + """ + + if trade_unit is None: + trade_unit = C.trade_unit + if limit_threshold is None: + limit_threshold = C.limit_threshold + if deal_price is None: + deal_price = C.deal_price + if exchange is None: + logger.info("Create new exchange") + # handle exception for deal_price + if deal_price[0] != "$": + deal_price = "$" + deal_price + if extract_codes: + codes = sorted(pred.index.get_level_values("instrument").unique()) + else: + codes = "all" # TODO: We must ensure that 'all.txt' includes all the stocks + + dates = sorted(pred.index.get_level_values("datetime").unique()) + dates = np.append(dates, get_date_range(dates[-1], left_shift=1, right_shift=shift)) + + exchange = Exchange( + trade_dates=dates, + codes=codes, + deal_price=deal_price, + subscribe_fields=subscribe_fields, + limit_threshold=limit_threshold, + open_cost=open_cost, + close_cost=close_cost, + min_cost=min_cost, + trade_unit=trade_unit, + ) + return exchange + + +def get_executor( + executor=None, + trade_exchange=None, + verbose=True, +): + """get_executor + + There will be 3 ways to return a executor. Please follow the code. + + Parameters + ---------- + + executor : BaseExecutor + executor used in backtest. + trade_exchange : Exchange + exchange used in executor + verbose : bool + whether to print log. + + Returns + ------- + :class: BaseExecutor + an initialized BaseExecutor object + """ + + # There will be 3 ways to return a executor. + if executor is None: + # 1) create executor with param `executor` + logger.info("Create new executor ") + from ..online.executor import SimulatorExecutor + + executor = SimulatorExecutor(trade_exchange=trade_exchange, verbose=verbose) + elif isinstance(executor, (dict, str)): + # 2) create executor with config + logger.info("Create new executor ") + executor = init_instance_by_config(executor) + + from ..online.executor import BaseExecutor + + # 3) Use the executor directly + if not isinstance(executor, BaseExecutor): + raise TypeError("Executor not supported") + return executor + + +# This is the API for compatibility for legacy code +def backtest(pred, account=1e9, shift=1, benchmark="SH000905", verbose=True, return_order=False, **kwargs): + """This function will help you set a reasonable Exchange and provide default value for strategy + Parameters + ---------- + + - **backtest workflow related or commmon arguments** + + pred : pandas.DataFrame + predict should has index and one `score` column. + account : float + init account value. + shift : int + whether to shift prediction by one day. + benchmark : str + benchmark code, default is SH000905 CSI 500. + verbose : bool + whether to print log. + return_order : bool + whether to return order list + + - **strategy related arguments** + + strategy : Strategy() + strategy used in backtest. + topk : int (Default value: 50) + top-N stocks to buy. + margin : int or float(Default value: 0.5) + - if isinstance(margin, int): + + sell_limit = margin + + - else: + + sell_limit = pred_in_a_day.count() * margin + + buffer margin, in single score_mode, continue holding stock if it is in nlargest(sell_limit). + sell_limit should be no less than topk. + n_drop : int + number of stocks to be replaced in each trading date. + risk_degree: float + 0-1, 0.95 for example, use 95% money to trade. + str_type: 'amount', 'weight' or 'dropout' + strategy type: TopkAmountStrategy ,TopkWeightStrategy or TopkDropoutStrategy. + + - **exchange related arguments** + + exchange: Exchange() + pass the exchange for speeding up. + subscribe_fields: list + subscribe fields. + open_cost : float + open transaction cost. The default value is 0.002(0.2%). + close_cost : float + close transaction cost. The default value is 0.002(0.2%). + min_cost : float + min transaction cost. + trade_unit : int + 100 for China A. + deal_price: str + dealing price type: 'close', 'open', 'vwap'. + limit_threshold : float + limit move 0.1 (10%) for example, long and short with same limit. + extract_codes: bool + will we pass the codes extracted from the pred to the exchange. + + .. note:: This will be faster with offline qlib. + + - **executor related arguments** + + executor : BaseExecutor() + executor used in backtest. + verbose : bool + whether to print log. + + """ + # check strategy: + spec = inspect.getfullargspec(get_strategy) + str_args = {k: v for k, v in kwargs.items() if k in spec.args} + strategy = get_strategy(**str_args) + + # init exchange: + spec = inspect.getfullargspec(get_exchange) + ex_args = {k: v for k, v in kwargs.items() if k in spec.args} + trade_exchange = get_exchange(pred, **ex_args) + + # init executor: + executor = get_executor(executor=kwargs.get("executor"), trade_exchange=trade_exchange, verbose=verbose) + + # run backtest + report_dict = backtest_func( + pred=pred, + strategy=strategy, + executor=executor, + trade_exchange=trade_exchange, + shift=shift, + verbose=verbose, + account=account, + benchmark=benchmark, + return_order=return_order, + ) + # for compatibility of the old API. return the dict positions + + positions = report_dict.get("positions") + report_dict.update({"positions": {k: p.position for k, p in positions.items()}}) + return report_dict diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py new file mode 100644 index 000000000..ce4b631ac --- /dev/null +++ b/qlib/backtest/account.py @@ -0,0 +1,170 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import copy + +from .position import Position +from .report import Report +from .order import Order + + +""" +rtn & earning in the Account + rtn: + from order's view + 1.change if any order is executed, sell order or buy order + 2.change at the end of today, (today_clse - stock_price) * amount + earning + from value of current position + earning will be updated at the end of trade date + earning = today_value - pre_value + **is consider cost** + while earning is the difference of two position value, so it considers cost, it is the true return rate + in the specific accomplishment for rtn, it does not consider cost, in other words, rtn - cost = earning +""" + + +class Account: + def __init__(self, init_cash, last_trade_date=None): + self.init_vars(init_cash, last_trade_date) + + def init_vars(self, init_cash, last_trade_date=None): + # init cash + self.init_cash = init_cash + self.current = Position(cash=init_cash) + self.positions = {} + self.rtn = 0 + self.ct = 0 + self.to = 0 + self.val = 0 + self.report = Report() + self.earning = 0 + self.last_trade_date = last_trade_date + + def get_positions(self): + return self.positions + + def get_cash(self): + return self.current.position["cash"] + + def update_state_from_order(self, order, trade_val, cost, trade_price): + # update turnover + self.to += trade_val + # update cost + self.ct += cost + # update return + # update self.rtn from order + trade_amount = trade_val / trade_price + if order.direction == Order.SELL: # 0 for sell + # when sell stock, get profit from price change + profit = trade_val - self.current.get_stock_price(order.stock_id) * trade_amount + self.rtn += profit # note here do not consider cost + elif order.direction == Order.BUY: # 1 for buy + # when buy stock, we get return for the rtn computing method + # profit in buy order is to make self.rtn is consistent with self.earning at the end of date + profit = self.current.get_stock_price(order.stock_id) * trade_amount - trade_val + self.rtn += profit + + def update_order(self, order, trade_val, cost, trade_price): + # if stock is sold out, no stock price information in Position, then we should update account first, then update current position + # if stock is bought, there is no stock in current position, update current, then update account + # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation + trade_amount = trade_val / trade_price + if order.direction == Order.SELL: + # sell stock + self.update_state_from_order(order, trade_val, cost, trade_price) + # update current position + # for may sell all of stock_id + self.current.update_order(order, trade_val, cost, trade_price) + else: + # buy stock + # deal order, then update state + self.current.update_order(order, trade_val, cost, trade_price) + self.update_state_from_order(order, trade_val, cost, trade_price) + + def update_bar_end(self, start_time, end_time, trader): + """ + start_time: pd.TimeStamp + end_time: pd.TimeStamp + quote: pd.DataFrame (code, date), collumns + when the end of trade date + - update rtn + - update price for each asset + - update value for this account + - update earning (2nd view of return ) + - update holding day, count of stock + - update position hitory + - update report + :return: None + """ + # update price for stock in the position and the profit from changed_price + stock_list = self.current.get_stock_list() + profit = 0 + for code in stock_list: + # if suspend, no new price to be updated, profit is 0 + if trader.check_stock_suspended(code, today): + continue + today_close = trader.get_close(code, today) + profit += (today_close - self.current.position[code]["price"]) * self.current.position[code]["amount"] + self.current.update_stock_price(stock_id=code, price=today_close) + self.rtn += profit + # update holding day count + self.current.add_count_all() + # update value + self.val = self.current.calculate_value() + # update earning (2nd view of return) + # account_value - last_account_value + # for the first trade date, account_value - init_cash + # self.report.is_empty() to judge is_first_trade_date + # get last_account_value, today_account_value, today_stock_value + if self.report.is_empty(): + last_account_value = self.init_cash + else: + last_account_value = self.report.get_latest_account_value() + today_account_value = self.current.calculate_value() + today_stock_value = self.current.calculate_stock_value() + self.earning = today_account_value - last_account_value + # update report for today + # judge whether the the trading is begin. + # and don't add init account state into report, due to we don't have excess return in those days. + self.report.update_report_record( + trade_date=today, + account_value=today_account_value, + cash=self.current.position["cash"], + return_rate=(self.earning + self.ct) / last_account_value, + # here use earning to calculate return, position's view, earning consider cost, true return + # in order to make same definition with original backtest in evaluate.py + turnover_rate=self.to / last_account_value, + cost_rate=self.ct / last_account_value, + stock_value=today_stock_value, + ) + # set today_account_value to position + self.current.position["today_account_value"] = today_account_value + self.current.update_weight_all() + # update positions + # note use deepcopy + self.positions[today] = copy.deepcopy(self.current) + + # finish today's updation + # reset the daily variables + self.rtn = 0 + self.ct = 0 + self.to = 0 + self.last_trade_date = today + + def load_account(self, account_path): + report = Report() + position = Position() + last_trade_date = position.load_position(account_path / "position.xlsx") + report.load_report(account_path / "report.csv") + + # assign values + self.init_vars(position.init_cash) + self.current = position + self.report = report + self.last_trade_date = last_trade_date if last_trade_date else None + + def save_account(self, account_path): + self.current.save_position(account_path / "position.xlsx", self.last_trade_date) + self.report.save_report(account_path / "report.csv") diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py new file mode 100644 index 000000000..b87d6afe3 --- /dev/null +++ b/qlib/backtest/backtest.py @@ -0,0 +1,143 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import numpy as np +import pandas as pd +from ...utils import get_date_by_shift, get_date_range +from ...data import D +from .account import Account +from ...config import C +from ...log import get_module_logger +from ...data.dataset.utils import get_level_index + +LOG = get_module_logger("backtest") + + +def backtest(pred, strategy, executor, trade_exchange, shift, verbose, account, benchmark, return_order): + """Parameters + ---------- + pred : pandas.DataFrame + predict should has index and one `score` column + Qlib want to support multi-singal strategy in the future. So pd.Series is not used. + strategy : Strategy() + strategy part for backtest + trade_exchange : Exchange() + exchage for backtest + shift : int + whether to shift prediction by one day + verbose : bool + whether to print log + account : float + init account value + benchmark : str/list/pd.Series + `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. + example: + print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) + 2017-01-04 0.011693 + 2017-01-05 0.000721 + 2017-01-06 -0.004322 + 2017-01-09 0.006874 + 2017-01-10 -0.003350 + + `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. + `benchmark` is str, will use the daily change as the 'bench'. + benchmark code, default is SH000905 CSI500 + """ + # Convert format if the input format is not expected + if get_level_index(pred, level="datetime") == 1: + pred = pred.swaplevel().sort_index() + if isinstance(pred, pd.Series): + pred = pred.to_frame("score") + + trade_account = Account(init_cash=account) + _pred_dates = pred.index.get_level_values(level="datetime") + predict_dates = D.calendar(start_time=_pred_dates.min(), end_time=_pred_dates.max()) + if isinstance(benchmark, pd.Series): + bench = benchmark + else: + _codes = benchmark if isinstance(benchmark, list) else [benchmark] + _temp_result = D.features( + _codes, + ["$close/Ref($close,1)-1"], + predict_dates[0], + get_date_by_shift(predict_dates[-1], shift=shift), + disk_cache=1, + ) + if len(_temp_result) == 0: + raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") + bench = _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean() + + trade_dates = np.append(predict_dates[shift:], get_date_range(predict_dates[-1], left_shift=1, right_shift=shift)) + if return_order: + multi_order_list = [] + # trading apart + for pred_date, trade_date in zip(predict_dates, trade_dates): + # for loop predict date and trading date + # print + if verbose: + LOG.info("[I {:%Y-%m-%d}]: trade begin.".format(trade_date)) + + # 1. Load the score_series at pred_date + try: + score = pred.loc(axis=0)[pred_date, :] # (trade_date, stock_id) multi_index, score in pdate + score_series = score.reset_index(level="datetime", drop=True)[ + "score" + ] # pd.Series(index:stock_id, data: score) + except KeyError: + LOG.warning("No score found on predict date[{:%Y-%m-%d}]".format(trade_date)) + score_series = None + + if score_series is not None and score_series.count() > 0: # in case of the scores are all None + # 2. Update your strategy (and model) + strategy.update(score_series, pred_date, trade_date) + + # 3. Generate order list + order_list = strategy.generate_order_list( + score_series=score_series, + current=trade_account.current, + trade_exchange=trade_exchange, + pred_date=pred_date, + trade_date=trade_date, + ) + else: + order_list = [] + if return_order: + multi_order_list.append((trade_account, order_list, trade_date)) + # 4. Get result after executing order list + # NOTE: The following operation will modify order.amount. + # NOTE: If it is buy and the cash is insufficient, the tradable amount will be recalculated + trade_info = executor.execute(trade_account, order_list, trade_date) + + # 5. Update account information according to transaction + update_account(trade_account, trade_info, trade_exchange, trade_date) + + # generate backtest report + report_df = trade_account.report.generate_report_dataframe() + report_df["bench"] = bench + positions = trade_account.get_positions() + + report_dict = {"report_df": report_df, "positions": positions} + if return_order: + report_dict.update({"order_list": multi_order_list}) + return report_dict + + +def update_account(trade_account, trade_info, trade_exchange, trade_date): + """Update the account and strategy + Parameters + ---------- + trade_account : Account() + trade_info : list of [Order(), float, float, float] + (order, trade_val, trade_cost, trade_price), trade_info with out factor + trade_exchange : Exchange() + used to get the $close_price at trade_date to update account + trade_date : pd.Timestamp + """ + # update account + for [order, trade_val, trade_cost, trade_price] in trade_info: + if order.deal_amount == 0: + continue + trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) + # at the end of trade date, update the account based the $close_price of stocks. + trade_account.update_daily_end(today=trade_date, trader=trade_exchange) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py new file mode 100644 index 000000000..985cf92e8 --- /dev/null +++ b/qlib/backtest/exchange.py @@ -0,0 +1,429 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import random +import logging + +import numpy as np +import pandas as pd + +from ..data import D +from ..utils import sample_feature +from .order import Order +from ..config import C, REG_CN +from ..log import get_module_logger + + +class Exchange: + def __init__( + self, + start_time=None, + end_time=None, + codes="all", + deal_price=None, + subscribe_fields=[], + limit_threshold=None, + open_cost=0.0015, + close_cost=0.0025, + trade_unit=None, + min_cost=5, + extra_quote=None, + ): + """__init__ + + :param start_time: start time for backtest + :param end_time: end time for backtest + :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) + :param deal_price: str, 'close', 'open', 'vwap' + :param subscribe_fields: list, subscribe fields + :param limit_threshold: float, 0.1 for example, default None + :param open_cost: cost rate for open, default 0.0015 + :param close_cost: cost rate for close, default 0.0025 + :param trade_unit: trade unit, 100 for China A market + :param min_cost: min cost, default 5 + :param extra_quote: pandas, dataframe consists of + columns: like ['$vwap', '$close', '$factor', 'limit']. + The limit indicates that the etf is tradable on a specific day. + Necessary fields: + $close is for calculating the total value at end of each day. + Optional fields: + $vwap is only necessary when we use the $vwap price as the deal price + $factor is for rounding to the trading unit + limit will be set to False by default(False indicates we can buy this + target on this day). + index: MultipleIndex(instrument, pd.Datetime) + """ + self.start_time = start_time + self.end_time = end_time + if trade_unit is None: + trade_unit = C.trade_unit + if limit_threshold is None: + limit_threshold = C.limit_threshold + if deal_price is None: + deal_price = C.deal_price + + self.logger = get_module_logger("online operator", level=logging.INFO) + + self.trade_unit = trade_unit + + # TODO: the quote, trade_dates, codes are not necessray. + # It is just for performance consideration. + if limit_threshold is None: + if C.region == REG_CN: + self.logger.warning(f"limit_threshold not set. The stocks hit the limit may be bought/sold") + elif abs(limit_threshold) > 0.1: + if C.region == REG_CN: + self.logger.warning(f"limit_threshold may not be set to a reasonable value") + + if deal_price[0] != "$": + self.deal_price = "$" + deal_price + else: + self.deal_price = deal_price + if isinstance(codes, str): + codes = D.instruments(codes) + self.codes = codes + # Necessary fields + # $close is for calculating the total value at end of each day. + # $factor is for rounding to the trading unit + # $change is for calculating the limit of the stock + + necessary_fields = {self.deal_price, "$close", "$change", "$factor"} + subscribe_fields = list(necessary_fields | set(subscribe_fields)) + all_fields = list(necessary_fields | set(subscribe_fields)) + self.all_fields = all_fields + self.open_cost = open_cost + self.close_cost = close_cost + self.min_cost = min_cost + self.limit_threshold = limit_threshold + + + self.extra_quote = extra_quote + self.set_quote(codes, start_time, end_time) + + def set_quote(self, codes, start_time, end_time): + if len(codes) == 0: + codes = D.instruments() + self.quote = D.features(codes, self.all_fields, start_time, end_time, disk_cache=True).dropna(subset=["$close"]) + self.quote.columns = self.all_fields + + if self.quote[self.deal_price].isna().any(): + self.logger.warning("{} field data contains nan.".format(self.deal_price)) + + if self.quote["$factor"].isna().any(): + # The 'factor.day.bin' file not exists, and `factor` field contains `nan` + # Use adjusted price + self.trade_w_adj_price = True + self.logger.warning("factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.") + else: + # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` + # Use normal price + self.trade_w_adj_price = False + # update limit + # check limit_threshold + if self.limit_threshold is None: + self.quote["limit"] = False + else: + # set limit + self._update_limit(buy_limit=self.limit_threshold, sell_limit=self.limit_threshold) + + quote_df = self.quote + if self.extra_quote is not None: + # process extra_quote + if "$close" not in self.extra_quote: + raise ValueError("$close is necessray in extra_quote") + if self.deal_price not in self.extra_quote.columns: + self.extra_quote[self.deal_price] = self.extra_quote["$close"] + self.logger.warning("No deal_price set for extra_quote. Use $close as deal_price.") + if "$factor" not in self.extra_quote.columns: + self.extra_quote["$factor"] = 1.0 + self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") + if "limit" not in self.extra_quote.columns: + self.extra_quote["limit"] = False + self.logger.warning("No limit set for extra_quote. All stock will be tradable.") + assert set(self.extra_quote.columns) == set(quote_df.columns) - {"$change"} + quote_df = pd.concat([quote_df, self.extra_quote], sort=False, axis=0) + + # update quote: pd.DataFrame to dict, for search use + self.quote = quote_df + + def _update_limit(self, buy_limit, sell_limit): + self.quote["limit"] = ~self.quote["$change"].between(-sell_limit, buy_limit, inclusive=False) + + def check_stock_limit(self, stock_id, start_time, end_time): + """Parameter + stock_id + trade_date + is limtited + """ + return sample_feature(self.quote, stock_id, start_time, end_time, fields="limit", method="any").iloc[0, 0] + + + def check_stock_suspended(self, stock_id, start_time, end_time): + # is suspended + return sample_feature(self.quote, stock_id, start_time, end_time).empty is False + + + def is_stock_tradable(self, stock_id, start_time, end_time): + # check if stock can be traded + # same as check in check_order + if self.check_stock_suspended(stock_id, start_time, end_time) or self.check_stock_limit(stock_id, start_time, end_time): + return False + else: + return True + + def check_order(self, order): + # check limit and suspended + if self.check_stock_suspended(order.stock_id, order.start_time, order.end_time) or self.check_stock_limit( + order.stock_id, order.start_time, order.end_time + ): + return False + else: + return True + + def deal_order(self, order, trade_account=None, position=None): + """ + Deal order when the actual transaction + + :param order: Deal the order. + :param trade_account: Trade account to be updated after dealing the order. + :param position: position to be updated after dealing the order. + :return: trade_val, trade_cost, trade_price + """ + # need to check order first + # TODO: check the order unit limit in the exchange!!!! + # The order limit is related to the adj factor and the cur_amount. + # factor = self.quote[(order.stock_id, order.trade_date)]['$factor'] + # cur_amount = trade_account.current.get_stock_amount(order.stock_id) + if self.check_order(order) is False: + raise AttributeError("need to check order first") + if trade_account is not None and position is not None: + raise ValueError("trade_account and position can only choose one") + + trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) + trade_val, trade_cost = self._calc_trade_info_by_order( + order, trade_account.current if trade_account else position + ) + # update account + if trade_val > 0: + # If the order can only be deal 0 trade_val. Nothing to be updated + # Otherwise, it will result some stock with 0 amount in the position + if trade_account: + trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) + elif position: + position.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) + + return trade_val, trade_cost, trade_price + + def get_quote_info(self, stock_id, start_time, end_time): + return sample_feature(self.quote, stock_id, start_time, end_time) + + def get_close(self, stock_id, start_time, end_time): + return sample_feature(self.quote, stock_id, start_time, end_time, fields="$close", method="last") + + def get_deal_price(self, stock_id, start_time, end_time): + deal_price = sample_feature(self.quote, stock_id, start_time, end_time, fields=self.deal_price, method="last") + deal_price = self.quote[(stock_id, trade_date)][self.deal_price] + if np.isclose(deal_price, 0.0) or np.isnan(deal_price): + self.logger.warning(f"(stock_id:{stock_id}, trade_date:{trade_date}, {self.deal_price}): {deal_price}!!!") + self.logger.warning(f"setting deal_price to close price") + deal_price = self.get_close(stock_id, start_time, end_time) + return deal_price + + def get_factor(self, stock_id, start_time, end_time): + return sample_feature(self.quote, stock_id, start_time, end_time, fields="$factor", method="last") + + def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): + """ + The generate the target position according to the weight and the cash. + NOTE: All the cash will assigned to the tadable stock. + + Parameter: + weight_position : dict {stock_id : weight}; allocate cash by weight_position + among then, weight must be in this range: 0 < weight < 1 + cash : cash + trade_date : trade date + """ + + # calculate the total weight of tradable value + tradable_weight = 0.0 + for stock_id in weight_position: + if self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): + # weight_position must be greater than 0 and less than 1 + if weight_position[stock_id] < 0 or weight_position[stock_id] > 1: + raise ValueError( + "weight_position is {}, " + "weight_position is not in the range of (0, 1).".format(weight_position[stock_id]) + ) + tradable_weight += weight_position[stock_id] + + if tradable_weight - 1.0 >= 1e-5: + raise ValueError("tradable_weight is {}, can not greater than 1.".format(tradable_weight)) + + amount_dict = {} + for stock_id in weight_position: + if weight_position[stock_id] > 0.0 and self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): + amount_dict[stock_id] = ( + cash + * weight_position[stock_id] + / tradable_weight + // self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) + ) + return amount_dict + + def get_real_deal_amount(self, current_amount, target_amount, factor): + """ + Calculate the real adjust deal amount when considering the trading unit + + :param current_amount: + :param target_amount: + :param factor: + :return real_deal_amount; Positive deal_amount indicates buying more stock. + """ + if current_amount == target_amount: + return 0 + elif current_amount < target_amount: + deal_amount = target_amount - current_amount + deal_amount = self.round_amount_by_trade_unit(deal_amount, factor) + return deal_amount + else: + if target_amount == 0: + return -current_amount + else: + deal_amount = current_amount - target_amount + deal_amount = self.round_amount_by_trade_unit(deal_amount, factor) + return -deal_amount + + def generate_order_for_target_amount_position(self, target_position, current_position, start_time, end_time): + """Parameter: + target_position : dict { stock_id : amount } + current_postion : dict { stock_id : amount} + trade_unit : trade_unit + down sample : for amount 321 and trade_unit 100, deal_amount is 300 + deal order on trade_date + """ + # split buy and sell for further use + buy_order_list = [] + sell_order_list = [] + # three parts: kept stock_id, dropped stock_id, new stock_id + # handle kept stock_id + + # because the order of the set is not fixed, the trading order of the stock is different, so that the backtest results of the same parameter are different; + # so here we sort stock_id, and then randomly shuffle the order of stock_id + # because the same random seed is used, the final stock_id order is fixed + sorted_ids = sorted(set(list(current_position.keys()) + list(target_position.keys()))) + random.seed(0) + random.shuffle(sorted_ids) + for stock_id in sorted_ids: + + # Do not generate order for the nontradable stocks + if not self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): + continue + + target_amount = target_position.get(stock_id, 0) + current_amount = current_position.get(stock_id, 0) + factor = self.get_factor(stock_id, start_time=start_time, end_time=end_time) + + deal_amount = self.get_real_deal_amount(current_amount, target_amount, factor) + if deal_amount == 0: + continue + elif deal_amount > 0: + # buy stock + buy_order_list.append( + Order( + stock_id=stock_id, + amount=deal_amount, + direction=Order.BUY, + start_time=start_time, + end_time=end_time, + factor=factor, + ) + ) + else: + # sell stock + sell_order_list.append( + Order( + stock_id=stock_id, + amount=abs(deal_amount), + direction=Order.SELL, + start_time=start_time, + end_time=end_time, + factor=factor, + ) + ) + # return order_list : buy + sell + return sell_order_list + buy_order_list + + def calculate_amount_position_value(self, amount_dict, start_time, end_time, only_tradable=False): + """Parameter + position : Position() + amount_dict : {stock_id : amount} + """ + value = 0 + for stock_id in amount_dict: + if ( + self.check_stock_suspended(stock_id=stock_id, start_time=start_time, end_time=end_time) is False + and self.check_stock_limit(stock_id=stock_id, start_time=start_time, end_time=end_time) is False + ): + value += self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) * amount_dict[stock_id] + return value + + def round_amount_by_trade_unit(self, deal_amount, factor): + """Parameter + deal_amount : float, adjusted amount + factor : float, adjusted factor + return : float, real amount + """ + if not self.trade_w_adj_price: + # the minimal amount is 1. Add 0.1 for solving precision problem. + return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor + return deal_amount + + def _calc_trade_info_by_order(self, order, position): + """ + Calculation of trade info + + :param order: + :param position: Position + :return: trade_val, trade_cost + """ + + trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) + if order.direction == Order.SELL: + # sell + if position is not None: + if np.isclose(order.amount, position.get_stock_amount(order.stock_id)): + # when selling last stock. The amount don't need rounding + order.deal_amount = order.amount + else: + order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + else: + # TODO: We don't know current position. + # We choose to sell all + order.deal_amount = order.amount + + trade_val = order.deal_amount * trade_price + trade_cost = max(trade_val * self.close_cost, self.min_cost) + elif order.direction == Order.BUY: + # buy + if position is not None: + cash = position.get_cash() + trade_val = order.amount * trade_price + if cash < trade_val * (1 + self.open_cost): + # The money is not enough + order.deal_amount = self.round_amount_by_trade_unit( + cash / (1 + self.open_cost) / trade_price, order.factor + ) + else: + # THe money is enough + order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + else: + # Unknown amount of money. Just round the amount + order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + + trade_val = order.deal_amount * trade_price + trade_cost = trade_val * self.open_cost + else: + raise NotImplementedError("order type {} error".format(order.type)) + + return trade_val, trade_cost diff --git a/qlib/backtest/import numpy as np b/qlib/backtest/import numpy as np new file mode 100644 index 000000000..f558d3649 --- /dev/null +++ b/qlib/backtest/import numpy as np @@ -0,0 +1,90 @@ +class HighFreqOrderNorm(Processor): + def __init__(self, fit_start_time, fit_end_time, feature_save_dir, price_dim=5, order_price_dim=2, volume_dim=1, order_volume_dim=8, day_length=240): + self.fit_start_time = fit_start_time + self.fit_end_time = fit_end_time + self.price_dim = price_dim + self.volume_dim = volume_dim + self.order_price_dim = order_price_dim + self.order_volume_dim = order_volume_dim + self.feature_save_dir = feature_save_dir + self.day_length = day_length + self.names = dict() + column_dim = self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim + fields = [("price", self.price_dim), ("order_price", self.order_price_dim), ("volume", self.volume_dim), ("order_volume", self.order_volume_dim)] + last_dim = 0 + for field, field_dim in fields: + self.names[field] = list(range(last_dim, last_dim + field_dim)) + list((range(column_dim + last_dim, column_dim + last_dim + field_dim))) + last_dim += field_dim + + @profile + def fit(self, df_features): + # fetch_df = fetch_df_by_index(df_features, slice(self.fit_start_time, self.fit_end_time), level="datetime") + + + print("end") + if not os.path.exists(self.feature_save_dir): + os.makedirs(self.feature_save_dir) + for name, name_val in self.names.items(): + print(name) + df_values = df_features.iloc(axis=1)[name_val].values + if name == "volume" or name == "order_volume": + df_values = np.log1p(df_values) + self.feature_med = np.nanmedian(df_values) + np.save(self.feature_save_dir + name + "_med.npy", self.feature_med) + df_values = df_values - self.feature_med + self.feature_std = np.nanmedian(np.absolute(df_values)) * 1.4826 + 1e-12 + np.save(self.feature_save_dir + name + "_std.npy", self.feature_std) + df_values = df_values / self.feature_std + np.save(self.feature_save_dir + name + "_vmax.npy", np.nanmax(df_values)) + np.save(self.feature_save_dir + name + "_vmin.npy", np.nanmin(df_values)) + + + def __call__(self, df_features): + df_features.set_index("date", append=True, drop=True, inplace=True) + df_values = df_features.values + df_values_dict = dict() + for name, name_val in self.names.items(): + self.feature_med = np.load(self.feature_save_dir + name + "_med.npy") + self.feature_std = np.load(self.feature_save_dir + name + "_std.npy") + self.feature_vmax = np.load(self.feature_save_dir + name + "_vmax.npy") + self.feature_vmin = np.load(self.feature_save_dir + name + "_vmin.npy") + + df_values = df_features.iloc(axis=1)[name_val].values + if name == "volume" or name == "order_volume": + df_values[:] = np.log1p(df_values) + df_values[:] -= self.feature_med + df_values[:] /= self.feature_std + slice0 = df_values > 3.0 + slice1 = df_values > 3.5 + slice2 = df_values < -3.0 + slice3 = df_values < -3.5 + + df_values[slice0] = ( + 3.0 + (df_values[slice0] - 3.0) / (self.feature_vmax - 3) * 0.5 + ) + df_values[slice1] = 3.5 + df_values[slice2] = ( + -3.0 - (df_values[slice2] + 3.0) / (self.feature_vmin + 3) * 0.5 + ) + df_values[slice3] = -3.5 + df_values_dict[name] = df_values + + idx = df_features.index.droplevel("datetime").drop_duplicates() + idx.set_names(["instrument", "datetime"], inplace=True) + + # Reshape is specifically for adapting to RL high-freq executor + feat = df_values[:, list(range(self.price_dim)) + list(range(self.price_dim * 2, self.price_dim * 2 + self.order_price_dim)) + + list(range((self.price_dim + self.order_price_dim) * 2, (self.price_dim + self.order_price_dim) * 2 + self.volume_dim)) + + list(range((self.price_dim + self.order_price_dim + self.volume_dim) * 2, (self.price_dim + self.order_price_dim + self.volume_dim) * 2 + self.order_volume_dim)) + ].reshape(-1, (self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim) * self.day_length) + + feat_1 = df_values[:, list(np.arange(self.price_dim) + self.price_dim) + list(np.arange(self.price_dim * 2, self.price_dim * 2 + self.order_price_dim) + self.order_price_dim) + + list(np.arange((self.price_dim + self.order_price_dim) * 2, (self.price_dim + self.order_price_dim) * 2 + self.volume_dim) + self.volume_dim) + + list(np.arange((self.price_dim + self.order_price_dim + self.volume_dim) * 2, (self.price_dim + self.order_price_dim + self.volume_dim) * 2 + self.order_volume_dim) + self.order_volume_dim) + ].reshape(-1, (self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim) * self.day_length) + df_new_features = pd.DataFrame( + data=np.concatenate((feat, feat_1), axis=1), + index=idx, + columns=range(2 * (self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim) * self.day_length), + ).sort_index() + return df_new_features \ No newline at end of file diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py new file mode 100644 index 000000000..0d637d9db --- /dev/null +++ b/qlib/backtest/order.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +class Order: + + SELL = 0 + BUY = 1 + + def __init__(self, stock_id, amount, start_time, end_time, direction, factor): + """Parameter + direction : Order.SELL for sell; Order.BUY for buy + stock_id : str + amount : float + trade_date : pd.Timestamp + factor : float + presents the weight factor assigned in Exchange() + """ + # check direction + if direction not in {Order.SELL, Order.BUY}: + raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") + self.stock_id = stock_id + # amount of generated orders + self.amount = amount + # amount of successfully completed orders + self.deal_amount = 0 + self.start_time = start_time + self.end_time = end_time + self.direction = direction + self.factor = factor diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py new file mode 100644 index 000000000..c63651164 --- /dev/null +++ b/qlib/backtest/position.py @@ -0,0 +1,213 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import pandas as pd +import copy +import pathlib +from .order import Order + +""" +Position module +""" + +""" +current state of position +a typical example is :{ + : { + 'count': , + 'amount': , + 'price': , + 'weight': , + }, +} + +""" + + +class Position: + """Position""" + + def __init__(self, cash=0, position_dict={}, today_account_value=0): + # NOTE: The position dict must be copied!!! + # Otherwise the initial value + self.init_cash = cash + self.position = position_dict.copy() + self.position["cash"] = cash + self.position["today_account_value"] = today_account_value + + def init_stock(self, stock_id, amount, price=None): + self.position[stock_id] = {} + self.position[stock_id]["count"] = 0 # update count in the end of this date + self.position[stock_id]["amount"] = amount + self.position[stock_id]["price"] = price + self.position[stock_id]["weight"] = 0 # update the weight in the end of the trade date + + def buy_stock(self, stock_id, trade_val, cost, trade_price): + trade_amount = trade_val / trade_price + if stock_id not in self.position: + self.init_stock(stock_id=stock_id, amount=trade_amount, price=trade_price) + else: + # exist, add amount + self.position[stock_id]["amount"] += trade_amount + + self.position["cash"] -= trade_val + cost + + def sell_stock(self, stock_id, trade_val, cost, trade_price): + trade_amount = trade_val / trade_price + if stock_id not in self.position: + raise KeyError("{} not in current position".format(stock_id)) + else: + # decrease the amount of stock + self.position[stock_id]["amount"] -= trade_amount + # check if to delete + if self.position[stock_id]["amount"] < -1e-5: + raise ValueError( + "only have {} {}, require {}".format(self.position[stock_id]["amount"], stock_id, trade_amount) + ) + elif abs(self.position[stock_id]["amount"]) <= 1e-5: + self.del_stock(stock_id) + + self.position["cash"] += trade_val - cost + + def del_stock(self, stock_id): + del self.position[stock_id] + + def update_order(self, order, trade_val, cost, trade_price): + # handle order, order is a order class, defined in exchange.py + if order.direction == Order.BUY: + # BUY + self.buy_stock(order.stock_id, trade_val, cost, trade_price) + elif order.direction == Order.SELL: + # SELL + self.sell_stock(order.stock_id, trade_val, cost, trade_price) + else: + raise NotImplementedError("do not support order direction {}".format(order.direction)) + + def update_stock_price(self, stock_id, price): + self.position[stock_id]["price"] = price + + def update_stock_count(self, stock_id, count): + self.position[stock_id]["count"] = count + + def update_stock_weight(self, stock_id, weight): + self.position[stock_id]["weight"] = weight + + def update_cash(self, cash): + self.position["cash"] = cash + + def calculate_stock_value(self): + stock_list = self.get_stock_list() + value = 0 + for stock_id in stock_list: + value += self.position[stock_id]["amount"] * self.position[stock_id]["price"] + return value + + def calculate_value(self): + value = self.calculate_stock_value() + value += self.position["cash"] + return value + + def get_stock_list(self): + stock_list = list(set(self.position.keys()) - {"cash", "today_account_value"}) + return stock_list + + def get_stock_price(self, code): + return self.position[code]["price"] + + def get_stock_amount(self, code): + return self.position[code]["amount"] + + def get_stock_count(self, code): + return self.position[code]["count"] + + def get_stock_weight(self, code): + return self.position[code]["weight"] + + def get_cash(self): + return self.position["cash"] + + def get_stock_amount_dict(self): + """generate stock amount dict {stock_id : amount of stock} """ + d = {} + stock_list = self.get_stock_list() + for stock_code in stock_list: + d[stock_code] = self.get_stock_amount(code=stock_code) + return d + + def get_stock_weight_dict(self, only_stock=False): + """get_stock_weight_dict + generate stock weight fict {stock_id : value weight of stock in the position} + it is meaningful in the beginning or the end of each trade date + + :param only_stock: If only_stock=True, the weight of each stock in total stock will be returned + If only_stock=False, the weight of each stock in total assets(stock + cash) will be returned + """ + if only_stock: + position_value = self.calculate_stock_value() + else: + position_value = self.calculate_value() + d = {} + stock_list = self.get_stock_list() + for stock_code in stock_list: + d[stock_code] = self.position[stock_code]["amount"] * self.position[stock_code]["price"] / position_value + return d + + def add_count_all(self): + stock_list = self.get_stock_list() + for code in stock_list: + self.position[code]["count"] += 1 + + def update_weight_all(self): + weight_dict = self.get_stock_weight_dict() + for stock_code, weight in weight_dict.items(): + self.update_stock_weight(stock_code, weight) + + def save_position(self, path, last_trade_date): + path = pathlib.Path(path) + p = copy.deepcopy(self.position) + cash = pd.Series(dtype=np.float) + cash["init_cash"] = self.init_cash + cash["cash"] = p["cash"] + cash["today_account_value"] = p["today_account_value"] + cash["last_trade_date"] = str(last_trade_date.date()) if last_trade_date else None + del p["cash"] + del p["today_account_value"] + positions = pd.DataFrame.from_dict(p, orient="index") + with pd.ExcelWriter(path) as writer: + positions.to_excel(writer, sheet_name="position") + cash.to_excel(writer, sheet_name="info") + + def load_position(self, path): + """load position information from a file + should have format below + sheet "position" + columns: ['stock', 'count', 'amount', 'price', 'weight'] + 'count': , + 'amount': , + 'price': , + 'weight': , + + sheet "cash" + index: ['init_cash', 'cash', 'today_account_value'] + 'init_cash': , + 'cash': , + 'today_account_value': + """ + path = pathlib.Path(path) + positions = pd.read_excel(open(path, "rb"), sheet_name="position", index_col=0) + cash_record = pd.read_excel(open(path, "rb"), sheet_name="info", index_col=0) + positions = positions.to_dict(orient="index") + init_cash = cash_record.loc["init_cash"].values[0] + cash = cash_record.loc["cash"].values[0] + today_account_value = cash_record.loc["today_account_value"].values[0] + last_trade_date = cash_record.loc["last_trade_date"].values[0] + + # assign values + self.position = {} + self.init_cash = init_cash + self.position = positions + self.position["cash"] = cash + self.position["today_account_value"] = today_account_value + + return None if pd.isna(last_trade_date) else pd.Timestamp(last_trade_date) diff --git a/qlib/backtest/profit_attribution.py b/qlib/backtest/profit_attribution.py new file mode 100644 index 000000000..20c6f638f --- /dev/null +++ b/qlib/backtest/profit_attribution.py @@ -0,0 +1,324 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import numpy as np +import pandas as pd +from .position import Position +from ...data import D +from ...config import C +import datetime +from pathlib import Path + + +def get_benchmark_weight( + bench, + start_date=None, + end_date=None, + path=None, +): + """get_benchmark_weight + + get the stock weight distribution of the benchmark + + :param bench: + :param start_date: + :param end_date: + :param path: + + :return: The weight distribution of the the benchmark described by a pandas dataframe + Every row corresponds to a trading day. + Every column corresponds to a stock. + Every cell represents the strategy. + + """ + if not path: + path = Path(C.get_data_path()).expanduser() / "raw" / "AIndexMembers" / "weights.csv" + # TODO: the storage of weights should be implemented in a more elegent way + # TODO: The benchmark is not consistant with the filename in instruments. + bench_weight_df = pd.read_csv(path, usecols=["code", "date", "index", "weight"]) + bench_weight_df = bench_weight_df[bench_weight_df["index"] == bench] + bench_weight_df["date"] = pd.to_datetime(bench_weight_df["date"]) + if start_date is not None: + bench_weight_df = bench_weight_df[bench_weight_df.date >= start_date] + if end_date is not None: + bench_weight_df = bench_weight_df[bench_weight_df.date <= end_date] + bench_stock_weight = bench_weight_df.pivot_table(index="date", columns="code", values="weight") / 100.0 + return bench_stock_weight + + +def get_stock_weight_df(positions): + """get_stock_weight_df + :param positions: Given a positions from backtest result. + :return: A weight distribution for the position + """ + stock_weight = [] + index = [] + for date in sorted(positions.keys()): + pos = positions[date] + if isinstance(pos, dict): + pos = Position(position_dict=pos) + index.append(date) + stock_weight.append(pos.get_stock_weight_dict(only_stock=True)) + return pd.DataFrame(stock_weight, index=index) + + +def decompose_portofolio_weight(stock_weight_df, stock_group_df): + """decompose_portofolio_weight + + ''' + :param stock_weight_df: a pandas dataframe to describe the portofolio by weight. + every row corresponds to a day + every column corresponds to a stock. + Here is an example below. + code SH600004 SH600006 SH600017 SH600022 SH600026 SH600037 \ + date + 2016-01-05 0.001543 0.001570 0.002732 0.001320 0.003000 NaN + 2016-01-06 0.001538 0.001569 0.002770 0.001417 0.002945 NaN + .... + :param stock_group_df: a pandas dataframe to describe the stock group. + every row corresponds to a day + every column corresponds to a stock. + the value in the cell repreponds the group id. + Here is a example by for stock_group_df for industry. The value is the industry code + instrument SH600000 SH600004 SH600005 SH600006 SH600007 SH600008 \ + datetime + 2016-01-05 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 + 2016-01-06 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 + ... + :return: Two dict will be returned. The group_weight and the stock_weight_in_group. + The key is the group. The value is a Series or Dataframe to describe the weight of group or weight of stock + """ + all_group = np.unique(stock_group_df.values.flatten()) + all_group = all_group[~np.isnan(all_group)] + + group_weight = {} + stock_weight_in_group = {} + for group_key in all_group: + group_mask = stock_group_df == group_key + group_weight[group_key] = stock_weight_df[group_mask].sum(axis=1) + stock_weight_in_group[group_key] = stock_weight_df[group_mask].divide(group_weight[group_key], axis=0) + return group_weight, stock_weight_in_group + + +def decompose_portofolio(stock_weight_df, stock_group_df, stock_ret_df): + """ + :param stock_weight_df: a pandas dataframe to describe the portofolio by weight. + every row corresponds to a day + every column corresponds to a stock. + Here is an example below. + code SH600004 SH600006 SH600017 SH600022 SH600026 SH600037 \ + date + 2016-01-05 0.001543 0.001570 0.002732 0.001320 0.003000 NaN + 2016-01-06 0.001538 0.001569 0.002770 0.001417 0.002945 NaN + 2016-01-07 0.001555 0.001546 0.002772 0.001393 0.002904 NaN + 2016-01-08 0.001564 0.001527 0.002791 0.001506 0.002948 NaN + 2016-01-11 0.001597 0.001476 0.002738 0.001493 0.003043 NaN + .... + + :param stock_group_df: a pandas dataframe to describe the stock group. + every row corresponds to a day + every column corresponds to a stock. + the value in the cell repreponds the group id. + Here is a example by for stock_group_df for industry. The value is the industry code + instrument SH600000 SH600004 SH600005 SH600006 SH600007 SH600008 \ + datetime + 2016-01-05 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 + 2016-01-06 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 + 2016-01-07 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 + 2016-01-08 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 + 2016-01-11 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 + ... + + :param stock_ret_df: a pandas dataframe to describe the stock return. + every row corresponds to a day + every column corresponds to a stock. + the value in the cell repreponds the return of the group. + Here is a example by for stock_ret_df. + instrument SH600000 SH600004 SH600005 SH600006 SH600007 SH600008 \ + datetime + 2016-01-05 0.007795 0.022070 0.099099 0.024707 0.009473 0.016216 + 2016-01-06 -0.032597 -0.075205 -0.098361 -0.098985 -0.099707 -0.098936 + 2016-01-07 -0.001142 0.022544 0.100000 0.004225 0.000651 0.047226 + 2016-01-08 -0.025157 -0.047244 -0.038567 -0.098177 -0.099609 -0.074408 + 2016-01-11 0.023460 0.004959 -0.034384 0.018663 0.014461 0.010962 + ... + + :return: It will decompose the portofolio to the group weight and group return. + """ + all_group = np.unique(stock_group_df.values.flatten()) + all_group = all_group[~np.isnan(all_group)] + + group_weight, stock_weight_in_group = decompose_portofolio_weight(stock_weight_df, stock_group_df) + + group_ret = {} + for group_key in stock_weight_in_group: + stock_weight_in_group_start_date = min(stock_weight_in_group[group_key].index) + stock_weight_in_group_end_date = max(stock_weight_in_group[group_key].index) + + temp_stock_ret_df = stock_ret_df[ + (stock_ret_df.index >= stock_weight_in_group_start_date) + & (stock_ret_df.index <= stock_weight_in_group_end_date) + ] + + group_ret[group_key] = (temp_stock_ret_df * stock_weight_in_group[group_key]).sum(axis=1) + # If no weight is assigned, then the return of group will be np.nan + group_ret[group_key][group_weight[group_key] == 0.0] = np.nan + + group_weight_df = pd.DataFrame(group_weight) + group_ret_df = pd.DataFrame(group_ret) + return group_weight_df, group_ret_df + + +def get_daily_bin_group(bench_values, stock_values, group_n): + """get_daily_bin_group + Group the values of the stocks of benchmark into several bins in a day. + Put the stocks into these bins. + + :param bench_values: A series contains the value of stocks in benchmark. + The index is the stock code. + :param stock_values: A series contains the value of stocks of your portofolio + The index is the stock code. + :param group_n: Bins will be produced + + :return: A series with the same size and index as the stock_value. + The value in the series is the group id of the bins. + The No.1 bin contains the biggest values. + """ + stock_group = stock_values.copy() + + # get the bin split points based on the daily proportion of benchmark + split_points = np.percentile(bench_values[~bench_values.isna()], np.linspace(0, 100, group_n + 1)) + # Modify the biggest uppper bound and smallest lowerbound + split_points[0], split_points[-1] = -np.inf, np.inf + for i, (lb, up) in enumerate(zip(split_points, split_points[1:])): + stock_group.loc[stock_values[(stock_values >= lb) & (stock_values < up)].index] = group_n - i + return stock_group + + +def get_stock_group(stock_group_field_df, bench_stock_weight_df, group_method, group_n=None): + if group_method == "category": + # use the value of the benchmark as the category + return stock_group_field_df + elif group_method == "bins": + assert group_n is not None + # place the values into `group_n` fields. + # Each bin corresponds to a category. + new_stock_group_df = stock_group_field_df.copy().loc[ + bench_stock_weight_df.index.min() : bench_stock_weight_df.index.max() + ] + for idx, row in (~bench_stock_weight_df.isna()).iterrows(): + bench_values = stock_group_field_df.loc[idx, row[row].index] + new_stock_group_df.loc[idx] = get_daily_bin_group( + bench_values, stock_group_field_df.loc[idx], group_n=group_n + ) + return new_stock_group_df + + +def brinson_pa( + positions, + bench="SH000905", + group_field="industry", + group_method="category", + group_n=None, + deal_price="vwap", +): + """brinson profit attribution + + :param positions: The position produced by the backtest class + :param bench: The benchmark for comparing. TODO: if no benchmark is set, the equal-weighted is used. + :param group_field: The field used to set the group for assets allocation. + `industry` and `market_value` is often used. + :param group_method: 'category' or 'bins'. The method used to set the group for asstes allocation + `bin` will split the value into `group_n` bins and each bins represents a group + :param group_n: . Only used when group_method == 'bins'. + + :return: + A dataframe with three columns: RAA(excess Return of Assets Allocation), RSS(excess Return of Stock Selectino), RTotal(Total excess Return) + Every row corresponds to a trading day, the value corresponds to the next return for this trading day + The middle info of brinson profit attribution + """ + # group_method will decide how to group the group_field. + dates = sorted(positions.keys()) + + start_date, end_date = min(dates), max(dates) + + bench_stock_weight = get_benchmark_weight(bench, start_date, end_date) + + # The attributes for allocation will not + if not group_field.startswith("$"): + group_field = "$" + group_field + if not deal_price.startswith("$"): + deal_price = "$" + deal_price + + # FIXME: In current version. Some attributes(such as market_value) of some + # suspend stock is NAN. So we have to get more date to forward fill the NAN + shift_start_date = start_date - datetime.timedelta(days=250) + instruments = D.list_instruments( + D.instruments(market="all"), + start_time=shift_start_date, + end_time=end_date, + as_list=True, + ) + stock_df = D.features( + instruments, + [group_field, deal_price], + start_time=shift_start_date, + end_time=end_date, + freq="day", + ) + stock_df.columns = [group_field, "deal_price"] + + stock_group_field = stock_df[group_field].unstack().T + # FIXME: some attributes of some suspend stock is NAN. + stock_group_field = stock_group_field.fillna(method="ffill") + stock_group_field = stock_group_field.loc[start_date:end_date] + + stock_group = get_stock_group(stock_group_field, bench_stock_weight, group_method, group_n) + + deal_price_df = stock_df["deal_price"].unstack().T + deal_price_df = deal_price_df.fillna(method="ffill") + + # NOTE: + # The return will be slightly different from the of the return in the report. + # Here the position are adjusted at the end of the trading day with close + stock_ret = (deal_price_df - deal_price_df.shift(1)) / deal_price_df.shift(1) + stock_ret = stock_ret.shift(-1).loc[start_date:end_date] + + port_stock_weight_df = get_stock_weight_df(positions) + + # decomposing the portofolio + port_group_weight_df, port_group_ret_df = decompose_portofolio(port_stock_weight_df, stock_group, stock_ret) + bench_group_weight_df, bench_group_ret_df = decompose_portofolio(bench_stock_weight, stock_group, stock_ret) + + # if the group return of the portofolio is NaN, replace it with the market + # value + mod_port_group_ret_df = port_group_ret_df.copy() + mod_port_group_ret_df[mod_port_group_ret_df.isna()] = bench_group_ret_df + + Q1 = (bench_group_weight_df * bench_group_ret_df).sum(axis=1) + Q2 = (port_group_weight_df * bench_group_ret_df).sum(axis=1) + Q3 = (bench_group_weight_df * mod_port_group_ret_df).sum(axis=1) + Q4 = (port_group_weight_df * mod_port_group_ret_df).sum(axis=1) + + return ( + pd.DataFrame( + { + "RAA": Q2 - Q1, # The excess profit from the assets allocation + "RSS": Q3 - Q1, # The excess profit from the stocks selection + # The excess profit from the interaction of assets allocation and stocks selection + "RIN": Q4 - Q3 - Q2 + Q1, + "RTotal": Q4 - Q1, # The totoal excess profit + } + ), + { + "port_group_ret": port_group_ret_df, + "port_group_weight": port_group_weight_df, + "bench_group_ret": bench_group_ret_df, + "bench_group_weight": bench_group_weight_df, + "stock_group": stock_group, + "bench_stock_weight": bench_stock_weight, + "port_stock_weight": port_stock_weight_df, + "stock_ret": stock_ret, + }, + ) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py new file mode 100644 index 000000000..beb9759d0 --- /dev/null +++ b/qlib/backtest/report.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +from collections import OrderedDict +import pandas as pd +import pathlib + + +class Report: + # daily report of the account + # contain those followings: returns, costs turnovers, accounts, cash, bench, value + # update report + def __init__(self): + self.init_vars() + + def init_vars(self): + self.accounts = OrderedDict() # account postion value for each trade date + self.returns = OrderedDict() # daily return rate for each trade date + self.turnovers = OrderedDict() # turnover for each trade date + self.costs = OrderedDict() # trade cost for each trade date + self.values = OrderedDict() # value for each trade date + self.cashes = OrderedDict() + self.latest_report_date = None # pd.TimeStamp + + def is_empty(self): + return len(self.accounts) == 0 + + def get_latest_date(self): + return self.latest_report_date + + def get_latest_account_value(self): + return self.accounts[self.latest_report_date] + + def update_report_record( + self, + trade_date=None, + account_value=None, + cash=None, + return_rate=None, + turnover_rate=None, + cost_rate=None, + stock_value=None, + ): + # check data + if None in [ + trade_date, + account_value, + cash, + return_rate, + turnover_rate, + cost_rate, + stock_value, + ]: + raise ValueError( + "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value]" + ) + # update report data + self.accounts[trade_date] = account_value + self.returns[trade_date] = return_rate + self.turnovers[trade_date] = turnover_rate + self.costs[trade_date] = cost_rate + self.values[trade_date] = stock_value + self.cashes[trade_date] = cash + # update latest_report_date + self.latest_report_date = trade_date + # finish daily report update + + def generate_report_dataframe(self): + report = pd.DataFrame() + report["account"] = pd.Series(self.accounts) + report["return"] = pd.Series(self.returns) + report["turnover"] = pd.Series(self.turnovers) + report["cost"] = pd.Series(self.costs) + report["value"] = pd.Series(self.values) + report["cash"] = pd.Series(self.cashes) + report.index.name = "date" + return report + + def save_report(self, path): + r = self.generate_report_dataframe() + r.to_csv(path) + + def load_report(self, path): + """load report from a file + should have format like + columns = ['account', 'return', 'turnover', 'cost', 'value', 'cash'] + :param + path: str/ pathlib.Path() + """ + path = pathlib.Path(path) + r = pd.read_csv(open(path, "rb"), index_col=0) + r.index = pd.DatetimeIndex(r.index) + + index = r.index + self.init_vars() + for date in index: + self.update_report_record( + trade_date=date, + account_value=r.loc[date]["account"], + cash=r.loc[date]["cash"], + return_rate=r.loc[date]["return"], + turnover_rate=r.loc[date]["turnover"], + cost_rate=r.loc[date]["cost"], + stock_value=r.loc[date]["value"], + ) diff --git a/qlib/contrib/strategy/__init__.py b/qlib/contrib/strategy/__init__.py index 6c2e4ceed..f0ec0a5d0 100644 --- a/qlib/contrib/strategy/__init__.py +++ b/qlib/contrib/strategy/__init__.py @@ -2,8 +2,17 @@ # Licensed under the MIT License. -from .strategy import ( +from .dl_strategy import ( TopkDropoutStrategy, BaseStrategy, WeightStrategyBase, ) + +from .rule_strategy import( + TWAPStrategy, + SBBEMAStrategy +) + +from .cost_control import ( + SoftTopkStrategy +) \ No newline at end of file diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index dd90437b0..001630a95 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. -from .strategy import StrategyWrapper, WeightStrategyBase +from .strategy import WeightStrategyBase import copy @@ -23,7 +23,7 @@ class SoftTopkStrategy(WeightStrategyBase): self.risk_degree = risk_degree self.buy_method = buy_method - def get_risk_degree(self, date): + def get_risk_degree(self, trade_index): """get_risk_degree Return the proportion of your total value you will used in investment. Dynamically risk_degree will result in Market timing @@ -31,7 +31,7 @@ class SoftTopkStrategy(WeightStrategyBase): # It will use 95% amoutn of your total value by default return self.risk_degree - def generate_target_weight_position(self, score, current, trade_date): + def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time): """Parameter: score : pred score for this trade date, pd.Series, index is stock_id, contain 'score' column current : current position, use Position() class diff --git a/qlib/strategy/strategy.py b/qlib/contrib/strategy/dl_strategy.py similarity index 64% rename from qlib/strategy/strategy.py rename to qlib/contrib/strategy/dl_strategy.py index 0476f7d72..f3a227c85 100644 --- a/qlib/strategy/strategy.py +++ b/qlib/contrib/strategy/dl_strategy.py @@ -1,94 +1,18 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - import copy +import warnings import numpy as np import pandas as pd -from ..data.dataset import DatasetH -from ..backtest.order import Order +from ...utils import sample_feature +from ...strategy.base import DLStrategy +from ...backtest.order import Order from .order_generator import OrderGenWInteract -""" -1. BaseStrategy 的粒度一定是数据粒度的整数倍 -- 关于calendar的合并咋整 -- adjust_dates这个东西啥用 -- label和freq和strategy的bar分离,这个如何决策呢 -""" -class BaseStrategy: - def __init__(self, bar, start_time, end_time): - self.bar = bar - self.start_time = start_time - self.end_time = end_time - self.current_time = start_time - - def generate_action(self, current): - pass - -class RuleStrategy(BaseStrategy): - pass - -class DLStrategy(BaseStrategy): - def __init__(self, bar, model, dataset:DatasetH, start_time=None, end_time=None): - super(DLStrategy, self).__init__(bar, start_time, end_time) - self.model = model - self.dataset = dataset - self.pred_score_all = self.model.predict(dataset) - self.pred_score = None - _pred_dates = pred.index.get_level_values(level="datetime") - self.start_time = _pred_dates.min() if start_time is None else start_time - self.end_time = _pred_dates.max() if end_time is None else end_time - self.pred_date = [pd.Timestamp(self.start_time), *D.calendar(start_time=_pred_dates.min(), end_time=_pred_dates.max(), freq=bar), self.end_time] - self.current_index = -1 - self.pred_length = len(self.pred_date) - - def _update_pred_score(self): - """update pred score - """ - pass - -class AdjustTimer: - """AdjustTimer - Responsible for timing of position adjusting - - This is designed as multiple inheritance mechanism due to: - - the is_adjust may need access to the internel state of a strategy. - - - it can be reguard as a enhancement to the existing strategy. - """ - - # adjust position in each trade date - def is_adjust(self, trade_date): - """is_adjust - Return if the strategy can adjust positions on `trade_date` - Will normally be used in strategy do trading with trade frequency - """ - return True - - -class ListAdjustTimer(AdjustTimer): - def __init__(self, adjust_dates=None): - """__init__ - - :param adjust_dates: an iterable object, it will return a timelist for trading dates - """ - if adjust_dates is None: - # None indicates that all dates is OK for adjusting - self.adjust_dates = None - else: - self.adjust_dates = {pd.Timestamp(dt) for dt in adjust_dates} - - def is_adjust(self, trade_date): - if self.adjust_dates is None: - return True - return pd.Timestamp(trade_date) in self.adjust_dates - -class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): +class TopkDropoutStrategy(DLStrategy): def __init__( self, - bar, + step_bar, model, dataset, trade_exchange, @@ -129,8 +53,7 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): else: strategy will make decision with the tradable state of the stock info and avoid buy and sell them. """ - super(TopkDropoutStrategy, self).__init__(bar, model, dataset, start_time, end_time) - ListAdjustTimer.__init__(self, kwargs.get("adjust_dates", None)) + super(TopkDropoutStrategy, self).__init__(step_bar, model, dataset, start_time, end_time) self.trade_exchange = trade_exchange self.topk = topk self.n_drop = n_drop @@ -145,7 +68,7 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): self.hold_thresh = hold_thresh self.only_tradable = only_tradable - def get_risk_degree(self, date): + def get_risk_degree(self, trade_index): """get_risk_degree Return the proportion of your total value you will used in investment. Dynamically risk_degree will result in Market timing. @@ -153,13 +76,13 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): # It will use 95% amoutn of your total value by default return self.risk_degree - def generate_action(self, current): - - self.current_index += 1 - - if not self.is_adjust(trade_date): - return [] - + def generate_order_list(self, trade_account, trade_start_time, trade_end_time, **kwargs): + super(TopkDropoutStrategy, self).generate_order_list() + if self.trade_index == 1: + pred_start_time, pred_end_time = None, trade_start_time - pd.Timedelta(seconds=1) + else: + pred_start_time, pred_end_time = self.trade_dates[self.trade_index - 2], trade_start_time - pd.Timedelta(seconds=1) + pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if self.only_tradable: # If The strategy only consider tradable stock when make decision # It needs following actions to filter stocks @@ -167,7 +90,7 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): cur_n = 0 res = [] for si in reversed(l) if reverse else l: - if self.trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date): + if self.trade_exchange.is_stock_tradable(stock_id=si, start_time=trade_start_time, end_time=trade_end_time): res.append(si) cur_n += 1 if cur_n >= n: @@ -178,7 +101,7 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): return get_first_n(l, n, reverse=True) def filter_stock(l): - return [si for si in l if self.trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date)] + return [si for si in l if self.trade_exchange.is_stock_tradable(stock_id=si, start_time=trade_start_time, end_time=trade_end_time)] else: # Otherwise, the stock will make decision with out the stock tradable info @@ -191,7 +114,7 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): def filter_stock(l): return l - current_temp = copy.deepcopy(current) + current_temp = copy.deepcopy(trade_account.current) # generate order list for this adjust date sell_order_list = [] buy_order_list = [] @@ -199,15 +122,15 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): cash = current_temp.get_cash() current_stock_list = current_temp.get_stock_list() # last position (sorted by score) - last = self.pred_score.reindex(current_stock_list).sort_values(ascending=False).index + last = pred_score.reindex(current_stock_list).sort_values(ascending=False).index # The new stocks today want to buy **at most** if self.method_buy == "top": today = get_first_n( - self.pred_score[~self.pred_score.index.isin(last)].sort_values(ascending=False).index, + pred_score[~pred_score.index.isin(last)].sort_values(ascending=False).index, self.n_drop + self.topk - len(last), ) elif self.method_buy == "random": - topk_candi = get_first_n(self.pred_score.sort_values(ascending=False).index, self.topk) + topk_candi = get_first_n(pred_score.sort_values(ascending=False).index, self.topk) candi = list(filter(lambda x: x not in last, topk_candi)) n = self.n_drop + self.topk - len(last) try: @@ -218,7 +141,7 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): raise NotImplementedError(f"This type of input is not supported") # combine(new stocks + last stocks), we will drop stocks from this list # In case of dropping higher score stock and buying lower score stock. - comb = self.pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index + comb = pred_score.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index # Get the stock list we really want to sell (After filtering the case that we sell high and buy low) if self.method_sell == "bottom": @@ -236,10 +159,10 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): buy = today[: len(sell) + self.topk - len(last)] # buy singal: if a stock falls into topk, it appear in the buy_sinal - buy_signal = self.pred_score.sort_values(ascending=False).iloc[: self.topk].index + buy_signal = pred_score.sort_values(ascending=False).iloc[: self.topk].index for code in current_stock_list: - if not self.trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): + if not self.trade_exchange.is_stock_tradable(stock_id=code, start_time=trade_start_time, end_time=trade_end_time): continue if code in sell: # check hold limit @@ -253,9 +176,10 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): sell_order = Order( stock_id=code, amount=sell_amount, - trade_date=trade_date, + start_time=trade_start_time, + end_time=trade_end_time, direction=Order.SELL, # 0 for sell, 1 for buy - factor=self.trade_exchange.get_factor(code, trade_date), + factor=self.trade_exchange.get_factor(code, trade_start_time, trade_end_time), ) # is order executable if self.trade_exchange.check_order(sell_order): @@ -290,15 +214,79 @@ class TopkDropoutStrategy(DLStrategy, ListAdjustTimer): # buy order buy_price = self.trade_exchange.get_deal_price(stock_id=code, trade_date=trade_date) buy_amount = value / buy_price - factor = self.trade_exchange.quote[(code, trade_date)]["$factor"] + factor = self.trade_exchange.get_factor(stock_id=code, start_time=trade_start_time, end_time=trade_end_time) buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor) buy_order = Order( stock_id=code, amount=buy_amount, - trade_date=trade_date, + start_time=trade_start_time, + end_time=trade_end_time, direction=Order.BUY, # 1 for buy factor=factor, ) buy_order_list.append(buy_order) self.stock_count[code] = 1 return sell_order_list + buy_order_list + +class WeightStrategyBase(DLStrategy): + def __init__(self, trade_exchange, order_generator_cls_or_obj=OrderGenWInteract, **kwargs): + super().__init__(**kwargs) + self.trade_exchange = trade_exchange + if isinstance(order_generator_cls_or_obj, type): + self.order_generator = order_generator_cls_or_obj() + else: + self.order_generator = order_generator_cls_or_obj + + + def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time): + """ + Generate target position from score for this date and the current position.The cash is not considered in the position + Parameters + ----------- + score : pd.Series + pred score for this trade date, index is stock_id, contain 'score' column. + current : Position() + current position. + trade_exchange : Exchange() + trade_date : pd.Timestamp + trade date. + """ + raise NotImplementedError() + + def generate_order_list(self, trade_account, trade_start_time, trade_end_time, **kwargs): + """ + Parameters + ----------- + score_series : pd.Seires + stock_id , score. + current : Position() + current of account. + trade_exchange : Exchange() + exchange. + trade_date : pd.Timestamp + date. + """ + # generate_order_list + # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list + super(WeightStrategyBase, self).generate_order_list() + if self.trade_index == 1: + pred_start_time, pred_end_time = None, trade_start_time - pd.Timedelta(seconds=1) + else: + pred_start_time, pred_end_time = self.trade_dates[self.trade_index - 2], trade_start_time - pd.Timedelta(seconds=1) + + pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") + current_temp = copy.deepcopy(trade_account.current) + target_weight_position = self.generate_target_weight_position( + score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time + ) + order_list = self.order_generator.generate_order_list_from_target_weight_position( + current=current_temp, + trade_exchange=self.trade_exchange, + risk_degree=self.get_risk_degree(self.trade_index), + target_weight_position=target_weight_position, + pred_start_time=pred_start_time, + pred_end_time=pred_end_time, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + ) + return order_list diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index 494981ecc..cdbd30c1f 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -4,8 +4,8 @@ """ This order generator is for strategies based on WeightStrategyBase """ -from ..backtest.position import Position -from ..backtest.exchange import Exchange +from ...backtest.position import Position +from ...backtest.exchange import Exchange import pandas as pd import copy @@ -17,8 +17,10 @@ class OrderGenerator: trade_exchange: Exchange, target_weight_position: dict, risk_degree: float, - pred_date: pd.Timestamp, - trade_date: pd.Timestamp, + pred_start_time: pd.Timestamp, + pred_end_time: pd.Timestamp, + trade_start_time: pd.Timestamp, + trade_end_time: pd.Timestamp, ) -> list: """generate_order_list_from_target_weight_position @@ -49,8 +51,10 @@ class OrderGenWInteract(OrderGenerator): trade_exchange: Exchange, target_weight_position: dict, risk_degree: float, - pred_date: pd.Timestamp, - trade_date: pd.Timestamp, + pred_start_time: pd.Timestamp, + pred_end_time: pd.Timestamp, + trade_start_time: pd.Timestamp, + trade_end_time: pd.Timestamp, ) -> list: """generate_order_list_from_target_weight_position @@ -77,10 +81,10 @@ class OrderGenWInteract(OrderGenerator): # calculate current_tradable_value current_amount_dict = current.get_stock_amount_dict() current_total_value = trade_exchange.calculate_amount_position_value( - amount_dict=current_amount_dict, trade_date=trade_date, only_tradable=False + amount_dict=current_amount_dict, trade_start_time=trade_start_time, trade_end_time=trade_end_time, only_tradable=False ) current_tradable_value = trade_exchange.calculate_amount_position_value( - amount_dict=current_amount_dict, trade_date=trade_date, only_tradable=True + amount_dict=current_amount_dict, trade_start_time=trade_start_time, trade_end_time=trade_end_time, only_tradable=True ) # add cash current_tradable_value += current.get_cash() @@ -93,7 +97,7 @@ class OrderGenWInteract(OrderGenerator): # value. Then just sell all the stocks target_amount_dict = copy.deepcopy(current_amount_dict.copy()) for stock_id in list(target_amount_dict.keys()): - if trade_exchange.is_stock_tradable(stock_id, trade_date): + if trade_exchange.is_stock_tradable(stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time): del target_amount_dict[stock_id] else: # consider cost rate @@ -104,12 +108,14 @@ class OrderGenWInteract(OrderGenerator): target_amount_dict = trade_exchange.generate_amount_position_from_weight_position( weight_position=target_weight_position, cash=current_tradable_value, - trade_date=trade_date, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, ) order_list = trade_exchange.generate_order_for_target_amount_position( target_position=target_amount_dict, current_position=current_amount_dict, - trade_date=trade_date, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, ) return order_list @@ -123,8 +129,10 @@ class OrderGenWOInteract(OrderGenerator): trade_exchange: Exchange, target_weight_position: dict, risk_degree: float, - pred_date: pd.Timestamp, - trade_date: pd.Timestamp, + pred_start_time: pd.Timestamp, + pred_end_time: pd.Timestamp, + trade_start_time: pd.Timestamp, + trade_end_time: pd.Timestamp, ) -> list: """generate_order_list_from_target_weight_position @@ -153,7 +161,7 @@ class OrderGenWOInteract(OrderGenerator): amount_dict = {} for stock_id in target_weight_position: # Current rule will ignore the stock that not hold and cannot be traded at predict date - if trade_exchange.is_stock_tradable(stock_id=stock_id, trade_date=pred_date): + if trade_exchange.is_stock_tradable(stock_id=stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time): amount_dict[stock_id] = ( risk_total_value * target_weight_position[stock_id] / trade_exchange.get_close(stock_id, pred_date) ) @@ -166,6 +174,7 @@ class OrderGenWOInteract(OrderGenerator): order_list = trade_exchange.generate_order_for_target_amount_position( target_position=amount_dict, current_position=current.get_stock_amount_dict(), - trade_date=trade_date, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, ) return order_list diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py new file mode 100644 index 000000000..af43be246 --- /dev/null +++ b/qlib/contrib/strategy/rule_strategy.py @@ -0,0 +1,161 @@ +import copy +import warnings +import numpy as np +import pandas as pd + +from ...utils import sample_feature +from ...strategy.base import RuleStrategy, TradingEnhancement +from ...backtest.order import Order + + +class TWAPStrategy(RuleStrategy, TradingEnhancement): + def __init__( + self, + step_bar, + start_time, + end_time, + **kwargs, + ): + self.step_bar = step_bar + self.reset(start_time=start_time, end_time=end_time, **kwargs) + self.trade_amount = {} + for order in self.trade_order_list: + self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len + + def reset(self, start_time=None, end_time=None, trade_order_list=None, **kwargs): + super(SignalStrategy, self).reset(start_time=start_time, end_time=end_time, **kwargs) + TradingEnhancement.reset(trade_order_list=trade_order_list) + + def generate_order_list(self, **kwargs): + super(TopkDropoutStrategy, self).generate_order_list() + trade_start_time = self.trade_dates[self.trade_index - 1] + trade_end_time = self.trade_dates[self.trade_index] + order_list = [] + for order in self.trade_order_list: + _order = Order( + stock_id=order.stock_id, + amount=self.trade_amount[(order.stock_id, order.direction)], + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) + return order_list + +class SBBStrategy(RuleStrategy, TradingEnhancement): + """ + (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy. + """ + TREND_MID = 0 + TREND_SHORT = 1 + TREND_LONG = 2 + + def __init__( + self, + step_bar, + start_time, + end_time, + **kwargs, + ): + self.step_bar = step_bar + self.reset(start_time=start_time, end_time=end_time, **kwargs) + self.trade_amount = {} + self.trade_delay = {} + for order in self.trade_order_list: + self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len + self.trade_trend[(order.stock_id, order.direction)] = TREND_MID + + def reset(self, start_time=None, end_time=None, trade_order_list=None, **kwargs): + super(SignalStrategy, self).reset(start_time=start_time, end_time=end_time, **kwargs) + TradingEnhancement.reset(trade_order_list=trade_order_list) + + def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): + raise NotImplementedError("pred_price_trend method is not implemented!") + + def generate_order_list(self, trade_start_time, trade_end_time, **kwargs): + super(TopkDropoutStrategy, self).generate_order_list() + if self.trade_index == 1: + pred_start_time, pred_end_time = None, trade_start_time - pd.Timedelta(seconds=1) + else: + pred_start_time, pred_end_time = self.trade_dates[self.trade_index - 2], trade_start_time - pd.Timedelta(seconds=1) + order_list = [] + for order in self.trade_order_list: + if self.trade_index % 2 == 1: + _pred_trend = self._pred_price_trend(order.stock_id) + else: + _pred_trend = self.trade_trend[(order.stock_id, order.direction)] + if _pred_trend == TREND_MID: + _order = Order( + stock_id=order.stock_id, + amount=self.trade_amount[(order.stock_id, order.direction)], + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) + else: + if self.trade_index % 2 == 1: + if _pred_trend == self.TREND_SHORT and order.direction == order.SELL or _pred_trend == self.TREND_LONG and order.direction == order.BUY: + _order = Order( + stock_id=order.stock_id, + amount=2*self.trade_amount[(order.stock_id, order.direction)], + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) + else: + if _pred_trend == self.TREND_SHORT and order.direction == order.BUY or _pred_trend == self.TREND_LONG and order.direction == order.SELL: + _order = Order( + stock_id=order.stock_id, + amount=2*self.trade_amount[(order.stock_id, order.direction)], + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) + if self.trade_index % 2 == 1 + self.trade_trend[(order.stock_id, order.direction)] = _pred_trend + + return order_list + + +class SBBEMAStrategy(SBBStrategy): + """ + (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA). + """ + def __init__( + self, + step_bar, + start_time, + end_time, + instruments="csi300", + freq="day", + **kwargs, + ): + self.step_bar = step_bar + if instruments is None: + warnings.warn("`instruments` is not set, will load all stocks") + self.instruments = "all" + if isinstance(instruments, str): + self.instruments = D.instruments(instruments, filter_pipe=self.filter_pipe) + self.freq = freq + self.reset(start_time=start_time, end_time=end_time) + + def _reset_trade_date(self, start_time=None, end_time=None): + super(SignalStrategy, self)._reset_trade_date(start_time=start_time, end_time=end_time) + fields = [("EMA...", "signal")] + self.signal = D.features(instruments, fields, start_time=self.start_time, end_time=self.end_time, freq=self.freq) + + def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): + _sample_signal = sample_feature(self.signal, stock_id, start_time=pred_start_time, end_time=pred_end_time, fields="signal", method="last") + if _sample_signal.empty: + return SBBStrategy.TREND_MID + elif _sample_signal.iloc[0, 0] > 0: + return SBBStrategy.TREND_LONG + else: + return SBBStrategy.TREND_SHORT \ No newline at end of file diff --git a/qlib/contrib/strategy/strategy.py b/qlib/contrib/strategy/strategy.py deleted file mode 100644 index 550ff649d..000000000 --- a/qlib/contrib/strategy/strategy.py +++ /dev/null @@ -1,413 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import copy -import numpy as np -import pandas as pd - -from ..backtest.order import Order -from .order_generator import OrderGenWInteract - - -# TODO: The base strategies will be moved out of contrib to core code -class BaseStrategy: - def __init__(self): - pass - - def get_risk_degree(self, date): - """get_risk_degree - Return the proportion of your total value you will used in investment. - Dynamically risk_degree will result in Market timing - """ - # It will use 95% amount of your total value by default - return 0.95 - - def generate_order_list(self, score_series, current, trade_exchange, pred_date, trade_date): - """ - DO NOT directly change the state of current - - Parameters - ----------- - score_series : pd.Series - stock_id , score. - current : Position() - current state of position. - DO NOT directly change the state of current. - trade_exchange : Exchange() - trade exchange. - pred_date : pd.Timestamp - predict date. - trade_date : pd.Timestamp - trade date. - """ - pass - - def update(self, score_series, pred_date, trade_date): - """User can use this method to update strategy state each trade date. - Parameters - ----------- - score_series : pd.Series - stock_id , score. - pred_date : pd.Timestamp - oredict date. - trade_date : pd.Timestamp - trade date. - """ - pass - - def init(self, **kwargs): - """Some strategy need to be initial after been implemented, - User can use this method to init his strategy with parameters needed. - """ - pass - - def get_init_args_from_model(self, model, init_date): - """ - This method only be used in 'online' module, it will generate the *args to initial the strategy. - :param - mode : model used in 'online' module. - """ - return {} - - -class StrategyWrapper: - """ - StrategyWrapper is a wrapper of another strategy. - By overriding some methods to make some changes on the basic strategy - Cost control and risk control will base on this class. - """ - - def __init__(self, inner_strategy): - """__init__ - - :param inner_strategy: set the inner strategy. - """ - self.inner_strategy = inner_strategy - - def __getattr__(self, name): - """__getattr__ - - :param name: If no implementation in this method. Call the method in the innter_strategy by default. - """ - return getattr(self.inner_strategy, name) - - -class AdjustTimer: - """AdjustTimer - Responsible for timing of position adjusting - - This is designed as multiple inheritance mechanism due to: - - the is_adjust may need access to the internel state of a strategy. - - - it can be reguard as a enhancement to the existing strategy. - """ - - # adjust position in each trade date - def is_adjust(self, trade_date): - """is_adjust - Return if the strategy can adjust positions on `trade_date` - Will normally be used in strategy do trading with trade frequency - """ - return True - - -class ListAdjustTimer(AdjustTimer): - def __init__(self, adjust_dates=None): - """__init__ - - :param adjust_dates: an iterable object, it will return a timelist for trading dates - """ - if adjust_dates is None: - # None indicates that all dates is OK for adjusting - self.adjust_dates = None - else: - self.adjust_dates = {pd.Timestamp(dt) for dt in adjust_dates} - - def is_adjust(self, trade_date): - if self.adjust_dates is None: - return True - return pd.Timestamp(trade_date) in self.adjust_dates - - -class WeightStrategyBase(BaseStrategy, AdjustTimer): - def __init__(self, order_generator_cls_or_obj=OrderGenWInteract, *args, **kwargs): - super().__init__(*args, **kwargs) - if isinstance(order_generator_cls_or_obj, type): - self.order_generator = order_generator_cls_or_obj() - else: - self.order_generator = order_generator_cls_or_obj - - def generate_target_weight_position(self, score, current, trade_date): - """ - Generate target position from score for this date and the current position.The cash is not considered in the position - - Parameters - ----------- - score : pd.Series - pred score for this trade date, index is stock_id, contain 'score' column. - current : Position() - current position. - trade_exchange : Exchange() - trade_date : pd.Timestamp - trade date. - """ - raise NotImplementedError() - - def generate_order_list(self, score_series, current, trade_exchange, pred_date, trade_date): - """ - Parameters - ----------- - score_series : pd.Seires - stock_id , score. - current : Position() - current of account. - trade_exchange : Exchange() - exchange. - trade_date : pd.Timestamp - date. - """ - # judge if to adjust - if not self.is_adjust(trade_date): - return [] - # generate_order_list - # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list - current_temp = copy.deepcopy(current) - target_weight_position = self.generate_target_weight_position( - score=score_series, current=current_temp, trade_date=trade_date - ) - - order_list = self.order_generator.generate_order_list_from_target_weight_position( - current=current_temp, - trade_exchange=trade_exchange, - risk_degree=self.get_risk_degree(trade_date), - target_weight_position=target_weight_position, - pred_date=pred_date, - trade_date=trade_date, - ) - return order_list - - -class TopkDropoutStrategy(BaseStrategy, ListAdjustTimer): - def __init__( - self, - topk, - n_drop, - method_sell="bottom", - method_buy="top", - risk_degree=0.95, - thresh=1, - hold_thresh=1, - only_tradable=False, - **kwargs, - ): - """ - Parameters - ----------- - topk : int - the number of stocks in the portfolio. - n_drop : int - number of stocks to be replaced in each trading date. - method_sell : str - dropout method_sell, random/bottom. - method_buy : str - dropout method_buy, random/top. - risk_degree : float - position percentage of total value. - thresh : int - minimun holding days since last buy singal of the stock. - hold_thresh : int - minimum holding days - before sell stock , will check current.get_stock_count(order.stock_id) >= self.thresh. - only_tradable : bool - will the strategy only consider the tradable stock when buying and selling. - if only_tradable: - strategy will make buy sell decision without checking the tradable state of the stock. - else: - strategy will make decision with the tradable state of the stock info and avoid buy and sell them. - """ - super(TopkDropoutStrategy, self).__init__() - ListAdjustTimer.__init__(self, kwargs.get("adjust_dates", None)) - self.topk = topk - self.n_drop = n_drop - self.method_sell = method_sell - self.method_buy = method_buy - self.risk_degree = risk_degree - self.thresh = thresh - # self.stock_count['code'] will be the days the stock has been hold - # since last buy signal. This is designed for thresh - self.stock_count = {} - - self.hold_thresh = hold_thresh - self.only_tradable = only_tradable - - def get_risk_degree(self, date): - """get_risk_degree - Return the proportion of your total value you will used in investment. - Dynamically risk_degree will result in Market timing. - """ - # It will use 95% amoutn of your total value by default - return self.risk_degree - - def generate_order_list(self, score_series, current, trade_exchange, pred_date, trade_date): - """ - Gnererate order list according to score_series at trade_date, will not change current. - - Parameters - ----------- - score_series : pd.Series - stock_id , score. - current : Position() - current of account. - trade_exchange : Exchange() - exchange. - pred_date : pd.Timestamp - predict date. - trade_date : pd.Timestamp - trade date. - """ - if not self.is_adjust(trade_date): - return [] - - if self.only_tradable: - # If The strategy only consider tradable stock when make decision - # It needs following actions to filter stocks - def get_first_n(l, n, reverse=False): - cur_n = 0 - res = [] - for si in reversed(l) if reverse else l: - if trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date): - res.append(si) - cur_n += 1 - if cur_n >= n: - break - return res[::-1] if reverse else res - - def get_last_n(l, n): - return get_first_n(l, n, reverse=True) - - def filter_stock(l): - return [si for si in l if trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date)] - - else: - # Otherwise, the stock will make decision with out the stock tradable info - def get_first_n(l, n): - return list(l)[:n] - - def get_last_n(l, n): - return list(l)[-n:] - - def filter_stock(l): - return l - - current_temp = copy.deepcopy(current) - # generate order list for this adjust date - sell_order_list = [] - buy_order_list = [] - # load score - cash = current_temp.get_cash() - current_stock_list = current_temp.get_stock_list() - # last position (sorted by score) - last = score_series.reindex(current_stock_list).sort_values(ascending=False).index - # The new stocks today want to buy **at most** - if self.method_buy == "top": - today = get_first_n( - score_series[~score_series.index.isin(last)].sort_values(ascending=False).index, - self.n_drop + self.topk - len(last), - ) - elif self.method_buy == "random": - topk_candi = get_first_n(score_series.sort_values(ascending=False).index, self.topk) - candi = list(filter(lambda x: x not in last, topk_candi)) - n = self.n_drop + self.topk - len(last) - try: - today = np.random.choice(candi, n, replace=False) - except ValueError: - today = candi - else: - raise NotImplementedError(f"This type of input is not supported") - # combine(new stocks + last stocks), we will drop stocks from this list - # In case of dropping higher score stock and buying lower score stock. - comb = score_series.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index - - # Get the stock list we really want to sell (After filtering the case that we sell high and buy low) - if self.method_sell == "bottom": - sell = last[last.isin(get_last_n(comb, self.n_drop))] - elif self.method_sell == "random": - candi = filter_stock(last) - try: - sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else []) - except ValueError: # No enough candidates - sell = candi - else: - raise NotImplementedError(f"This type of input is not supported") - - # Get the stock list we really want to buy - buy = today[: len(sell) + self.topk - len(last)] - - # buy singal: if a stock falls into topk, it appear in the buy_sinal - buy_signal = score_series.sort_values(ascending=False).iloc[: self.topk].index - - for code in current_stock_list: - if not trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): - continue - if code in sell: - # check hold limit - if self.stock_count[code] < self.thresh or current_temp.get_stock_count(code) < self.hold_thresh: - # can not sell this code - # no buy signal, but the stock is kept - self.stock_count[code] += 1 - continue - # sell order - sell_amount = current_temp.get_stock_amount(code=code) - sell_order = Order( - stock_id=code, - amount=sell_amount, - trade_date=trade_date, - direction=Order.SELL, # 0 for sell, 1 for buy - factor=trade_exchange.get_factor(code, trade_date), - ) - # is order executable - if trade_exchange.check_order(sell_order): - sell_order_list.append(sell_order) - trade_val, trade_cost, trade_price = trade_exchange.deal_order(sell_order, position=current_temp) - # update cash - cash += trade_val - trade_cost - # sold - del self.stock_count[code] - else: - # no buy signal, but the stock is kept - self.stock_count[code] += 1 - elif code in buy_signal: - # NOTE: This is different from the original version - # get new buy signal - # Only the stock fall in to topk will produce buy signal - self.stock_count[code] = 1 - else: - self.stock_count[code] += 1 - # buy new stock - # note the current has been changed - current_stock_list = current_temp.get_stock_list() - value = cash * self.risk_degree / len(buy) if len(buy) > 0 else 0 - - # open_cost should be considered in the real trading environment, while the backtest in evaluate.py does not - # consider it as the aim of demo is to accomplish same strategy as evaluate.py, so comment out this line - # value = value / (1+trade_exchange.open_cost) # set open_cost limit - for code in buy: - # check is stock suspended - if not trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): - continue - # buy order - buy_price = trade_exchange.get_deal_price(stock_id=code, trade_date=trade_date) - buy_amount = value / buy_price - factor = trade_exchange.quote[(code, trade_date)]["$factor"] - buy_amount = trade_exchange.round_amount_by_trade_unit(buy_amount, factor) - buy_order = Order( - stock_id=code, - amount=buy_amount, - trade_date=trade_date, - direction=Order.BUY, # 1 for buy - factor=factor, - ) - buy_order_list.append(buy_order) - self.stock_count[code] = 1 - return sell_order_list + buy_order_list diff --git a/qlib/data/data.py b/qlib/data/data.py index 68e1a69d2..f978f520c 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -119,9 +119,12 @@ class CalendarProvider(abc.ABC): _calendar, _calendar_index = H["c"][flag] else: flag_raw = f"{freq}_future_{future}_sam_{None}" - _calendar = np.array(self.load_calendar(freq, future)) - _calendar_index = {x: i for i, x in enumerate(_calendar)} # for fast search - H["c"][flag_raw] = _calendar, _calendar_index + if flag_raw in H["c"]: + _calendar, _calendar_index = H["c"][flag_raw] + else: + _calendar = np.array(self.load_calendar(freq, future)) + _calendar_index = {x: i for i, x in enumerate(_calendar)} # for fast search + H["c"][flag_raw] = _calendar, _calendar_index if freq_sam is None: return _calendar, _calendar_index else: @@ -540,11 +543,6 @@ class LocalCalendarProvider(CalendarProvider): def calendar(self, start_time=None, end_time=None, freq="day", future=False, freq_sam=None): _calendar, _ = self._get_calendar(freq=freq, future=future) - if start_time == "None": - start_time = None - if end_time == "None": - end_time = None - # strip if start_time: start_time = pd.Timestamp(start_time) @@ -558,16 +556,8 @@ class LocalCalendarProvider(CalendarProvider): return np.array([]) else: end_time = _calendar[-1] - st, et, si, ei = self.locate_index(start_time, end_time, freq=freq, future=future) - _calendar = _calendar[si : ei + 1] - if freq_sam is None: - return _calendar - else: - _calendar_sam, _ = self._get_calendar(freq=freq, freq_sam=freq_sam, future=future) - st, et, si, ei = self.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam, future=future) - if bisect.bisect(_calendar, st, 0, len(_calendar)): - return np.hstack() - + st, et, si, ei = self.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam, future=future) + return _calendar[si : ei + 1] class LocalInstrumentProvider(InstrumentProvider): """Local instrument data provider class diff --git a/qlib/env/__init__.py b/qlib/env/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/qlib/env/env.py b/qlib/env/env.py new file mode 100644 index 000000000..852d4379b --- /dev/null +++ b/qlib/env/env.py @@ -0,0 +1,169 @@ + + +import re +import json +import copy +import pathlib +import pandas as pd +from loguru import Logger +from ...data import D +from ...utils import get_date_in_file_name +from ...utils import get_pre_trading_date +from ..backtest.order import Order +from ..utils import init_instance_by_config + +class BaseEnv: + """ + # Strategy framework document + + class Env(BaseEnv): + """ + + def __init__( + self, + step_bar, + trade_account, + start_time=None, + end_time=None, + track=False, + verbose=False, + **kwargs + ): + self.step_bar = step_bar + self.reset(start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, **kwargs) + + def _reset_trade_date(self, start_time=None, end_time=None): + if start_time: + self.start_time = start_time + if end_time: + self.end_time = end_time + if not self.start_time or not self.end_time: + raise ValueError("value of `start_time` or `end_time` is None") + _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) + self.trade_dates = np.hstack(pd.Timestamp(self.start_time), _calendar, self.end_time) + self.trade_len = len(self.trade_dates) + self.trade_index = 0 + + def reset(self, start_time=None, end_time=None, **kwargs): + if start_time or end_time: + self._reset_trade_date(start_time=start_time, end_time=end_time) + self.track = kwargs.get("track", False) + self.upper_action = kwargs.get("upper_action", None) + self.trade_account = init_instance_by_config(kwargs.get("trade_account")) + return self.trade_account + + def execute(self, action): + self.trade_index = self.trade_index + 1 + return + ( + self.trade_account, + { + "start_time": self.start_time, + "end_time": self.end_time, + "trade_len": self.trade_len, + "trade_index": self.trade_index - 1, + } + ) + + def finished(self): + return self.trade_index >= self.trade_len + + + +class SplitEnv(BaseEnv): + def __init__( + self, + step_bar, + start_time, + end_time, + trade_account, + sub_env, + sub_strategy, + track=False, + verbose=False, + **kwargs + ): + self.sub_env = sub_env + self.sub_strategy = sub_strategy + super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track) + + def execute(self, action): + if self.finished(): + raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") + #if self.track: + # yield action + #episode_reward = 0 + self.sub_strategy.reset(uppper_action=action) + sub_account = self.sub_env.reset(uppper_action=action, start_time=self.trade_dates[self.trade_index - 1], end_time=self.trade_dates[self.trade_index]) + while not self.sub_env.finished(): + sub_order = self.sub_strategy.generate_order(sub_obs) + sub_account, sub_info = self.sub_env.execute(sub_action) + #episode_reward += sub_reward + _account, _info = super(SimulatorEnv, self).execute(action) + return _account, _info + + + +class SimulatorEnv(BaseEnv): + + def __init__( + self, + step_bar, + start_time, + end_time, + trade_account, + trade_exchange, + track=False, + verbose=False, + **kwargs + ): + self.trade_exchange = trade_exchange + super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, verbose=verbose) + + def execute(self, action:dict): + """ + Return: obs, done, info + """ + if self.finished(): + raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") + + trade_info = [] + order_list = action + + for order in order_list: + if self.trade_exchange.check_order(order) is True: + # execute the order + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(order, trade_account=self.trade_account) + trade_info.append((order, trade_val, trade_cost, trade_price)) + if self.verbose: + if order.direction == Order.SELL: # sell + print( + "[I ({:%Y-%m-%d})-({:%Y-%m-%d})]: sell {}, price {:.2f}, amount {}, value {:.2f}.".format( + self.trade_dates[self.trade_index], + self.trade_dates[self.trade_index + 1], + order.stock_id, + trade_price, + order.deal_amount, + trade_val, + ) + ) + else: + print( + "[I ({:%Y-%m-%d})-{:%Y-%m-%d}]: buy {}, price {:.2f}, amount {}, value {:.2f}.".format( + self.trade_dates[self.trade_index], + self.trade_dates[self.trade_index + 1], + order.stock_id, + trade_price, + order.deal_amount, + trade_val, + ) + ) + + else: + if self.verbose: + print("[W ({:%Y-%m-%d})-({:%Y-%m-%d})]: {} wrong.".format(self.trade_dates[self.trade_index], self.trade_dates[self.trade_index + 1], order.stock_id)) + # do nothing + pass + self.trade_account.update_daily_end(today=trade_dates, trader=self.trade_exchange) + _account, _info = super(SimulatorEnv, self).execute(action) + return _account, {**_info, "trade_info", trade_info} \ No newline at end of file diff --git a/qlib/env/env_wrapper.py b/qlib/env/env_wrapper.py new file mode 100644 index 000000000..f08c99a2c --- /dev/null +++ b/qlib/env/env_wrapper.py @@ -0,0 +1,33 @@ + + +class BaseEnvWrapper: + + """ + # Base Env Wrapper for Reforcement Learning Framework + + class EnvWrapper(BaseEnvWrapper): + """ + def __init__(self, sub_env, action_interpreter, state_interpreter): + self.sub_env = sub_env + self.action_interpreter = action_interpreter + self.state_interpreter = state_interpreter + + def reset(self, **kwargs): + self.upper_state = kwargs.get("upper_state", None) + self.sub_env.reset() + + def step(self, action): + sub_action = self.action_interpreter.interpret(action) + sub_state = self.sub_env.step(sub_action) + state = self.state_interpreter.interpret(sub_state) + return state + reurn self. + if self.track: + yield action + yield from + + def finished(self): + return self.sub_env.finished() + + + \ No newline at end of file diff --git a/qlib/env/interpreter.py b/qlib/env/interpreter.py new file mode 100644 index 000000000..94d6f9ec2 --- /dev/null +++ b/qlib/env/interpreter.py @@ -0,0 +1,15 @@ + +class BaseInterpreter: + @staticmethod + def interpret(**kwargs): + raise NotImplementedError("interpret is not implemented!") + +class ActionInterpreter: + @staticmethod + def interpret(action, **kwargs): + return action + +class StateInterpreter: + @staticmethod + def interpret(state, **kwargs): + return state \ No newline at end of file diff --git a/qlib/strategy/__init__.py b/qlib/strategy/__init__.py index 6c2e4ceed..59e481eb9 100644 --- a/qlib/strategy/__init__.py +++ b/qlib/strategy/__init__.py @@ -1,9 +1,2 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - - -from .strategy import ( - TopkDropoutStrategy, - BaseStrategy, - WeightStrategyBase, -) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py new file mode 100644 index 000000000..692959f21 --- /dev/null +++ b/qlib/strategy/base.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import copy +import warnings +import numpy as np +import pandas as pd + + +from ..utils import sample_feature, get_sample_freq_calendar +from ..data.dataset import DatasetH +from ..backtest.order import Order +from .order_generator import OrderGenWInteract +from ..data.data import D +""" +1. BaseStrategy 的粒度一定是数据粒度的整数倍 +- 关于calendar的合并咋整 +- adjust_dates这个东西啥用 +- label和freq和strategy的bar分离,这个如何决策呢 +""" +class BaseStrategy: + def __init__(self, step_bar, start_time, end_time, **kwargs): + self.step_bar = step_bar + self.reset(start_time=start_time, end_time=end_time, **kwargs) + + def _reset_trade_date(self, start_time=None, end_time=None): + if start_time: + self.start_time = start_time + if end_time: + self.end_time = end_time + if not self.start_time or not self.end_time: + raise ValueError("value of `start_time` or `end_time` is None") + _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) + self.trade_dates = np.hstack(pd.Timestamp(self.start_time), _calendar, self.end_time) + self.trade_len = len(self.trade_dates) + self.trade_index = 0 + + def reset(self, start_time=None, end_time=None, **kwargs): + if start_time or end_time: + self._reset_trade_date(start_time=start_time, end_time=end_time) + + def generate_order_list(self, **kwargs): + self.trade_index = self.trade_index + 1 + + +class RuleStrategy(BaseStrategy): + pass + +class DLStrategy(BaseStrategy): + def __init__(self, step_bar, start_time, end_time, model, dataset:DatasetH): + self.model = model + self.dataset = dataset + self.pred_scores = self.model.predict(dataset) + #pred_score_dates = self.pred_scores.index.get_level_values(level="datetime") + super(DLStrategy, self).__init__(step_bar, start_time, end_time) + + def _update_model(self): + """update pred score + """ + pass + +class TradingEnhancement: + def reset(self, trade_order_list): + if trade_order_list: + self.trade_order_list = trade_order_list + diff --git a/qlib/strategy/cost_control.py b/qlib/strategy/cost_control.py deleted file mode 100644 index dd90437b0..000000000 --- a/qlib/strategy/cost_control.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -from .strategy import StrategyWrapper, WeightStrategyBase -import copy - - -class SoftTopkStrategy(WeightStrategyBase): - def __init__(self, topk, max_sold_weight=1.0, risk_degree=0.95, buy_method="first_fill"): - """Parameter - topk : int - top-N stocks to buy - risk_degree : float - position percentage of total value - buy_method : - rank_fill: assign the weight stocks that rank high first(1/topk max) - average_fill: assign the weight to the stocks rank high averagely. - """ - super().__init__() - self.topk = topk - self.max_sold_weight = max_sold_weight - self.risk_degree = risk_degree - self.buy_method = buy_method - - def get_risk_degree(self, date): - """get_risk_degree - Return the proportion of your total value you will used in investment. - Dynamically risk_degree will result in Market timing - """ - # It will use 95% amoutn of your total value by default - return self.risk_degree - - def generate_target_weight_position(self, score, current, trade_date): - """Parameter: - score : pred score for this trade date, pd.Series, index is stock_id, contain 'score' column - current : current position, use Position() class - trade_date : trade date - generate target position from score for this date and the current position - The cache is not considered in the position - """ - # TODO: - # If the current stock list is more than topk(eg. The weights are modified - # by risk control), the weight will not be handled correctly. - buy_signal_stocks = set(score.sort_values(ascending=False).iloc[: self.topk].index) - cur_stock_weight = current.get_stock_weight_dict(only_stock=True) - - if len(cur_stock_weight) == 0: - final_stock_weight = {code: 1 / self.topk for code in buy_signal_stocks} - else: - final_stock_weight = copy.deepcopy(cur_stock_weight) - sold_stock_weight = 0.0 - for stock_id in final_stock_weight: - if stock_id not in buy_signal_stocks: - sw = min(self.max_sold_weight, final_stock_weight[stock_id]) - sold_stock_weight += sw - final_stock_weight[stock_id] -= sw - if self.buy_method == "first_fill": - for stock_id in buy_signal_stocks: - add_weight = min( - max(1 / self.topk - final_stock_weight.get(stock_id, 0), 0.0), - sold_stock_weight, - ) - final_stock_weight[stock_id] = final_stock_weight.get(stock_id, 0.0) + add_weight - sold_stock_weight -= add_weight - elif self.buy_method == "average_fill": - for stock_id in buy_signal_stocks: - final_stock_weight[stock_id] = final_stock_weight.get(stock_id, 0.0) + sold_stock_weight / len( - buy_signal_stocks - ) - else: - raise ValueError("Buy method not found") - return final_stock_weight diff --git a/qlib/strategy/order_generator.py b/qlib/strategy/order_generator.py deleted file mode 100644 index 494981ecc..000000000 --- a/qlib/strategy/order_generator.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -""" -This order generator is for strategies based on WeightStrategyBase -""" -from ..backtest.position import Position -from ..backtest.exchange import Exchange -import pandas as pd -import copy - - -class OrderGenerator: - def generate_order_list_from_target_weight_position( - self, - current: Position, - trade_exchange: Exchange, - target_weight_position: dict, - risk_degree: float, - pred_date: pd.Timestamp, - trade_date: pd.Timestamp, - ) -> list: - """generate_order_list_from_target_weight_position - - :param current: The current position - :type current: Position - :param trade_exchange: - :type trade_exchange: Exchange - :param target_weight_position: {stock_id : weight} - :type target_weight_position: dict - :param risk_degree: - :type risk_degree: float - :param pred_date: the date the score is predicted - :type pred_date: pd.Timestamp - :param trade_date: the date the stock is traded - :type trade_date: pd.Timestamp - - :rtype: list - """ - raise NotImplementedError() - - -class OrderGenWInteract(OrderGenerator): - """Order Generator With Interact""" - - def generate_order_list_from_target_weight_position( - self, - current: Position, - trade_exchange: Exchange, - target_weight_position: dict, - risk_degree: float, - pred_date: pd.Timestamp, - trade_date: pd.Timestamp, - ) -> list: - """generate_order_list_from_target_weight_position - - No adjustment for for the nontradable share. - All the tadable value is assigned to the tadable stock according to the weight. - if interact == True, will use the price at trade date to generate order list - else, will only use the price before the trade date to generate order list - - :param current: - :type current: Position - :param trade_exchange: - :type trade_exchange: Exchange - :param target_weight_position: - :type target_weight_position: dict - :param risk_degree: - :type risk_degree: float - :param pred_date: - :type pred_date: pd.Timestamp - :param trade_date: - :type trade_date: pd.Timestamp - - :rtype: list - """ - # calculate current_tradable_value - current_amount_dict = current.get_stock_amount_dict() - current_total_value = trade_exchange.calculate_amount_position_value( - amount_dict=current_amount_dict, trade_date=trade_date, only_tradable=False - ) - current_tradable_value = trade_exchange.calculate_amount_position_value( - amount_dict=current_amount_dict, trade_date=trade_date, only_tradable=True - ) - # add cash - current_tradable_value += current.get_cash() - - reserved_cash = (1.0 - risk_degree) * (current_total_value + current.get_cash()) - current_tradable_value -= reserved_cash - - if current_tradable_value < 0: - # if you sell all the tradable stock can not meet the reserved - # value. Then just sell all the stocks - target_amount_dict = copy.deepcopy(current_amount_dict.copy()) - for stock_id in list(target_amount_dict.keys()): - if trade_exchange.is_stock_tradable(stock_id, trade_date): - del target_amount_dict[stock_id] - else: - # consider cost rate - current_tradable_value /= 1 + max(trade_exchange.close_cost, trade_exchange.open_cost) - - # strategy 1 : generate amount_position by weight_position - # Use API in Exchange() - target_amount_dict = trade_exchange.generate_amount_position_from_weight_position( - weight_position=target_weight_position, - cash=current_tradable_value, - trade_date=trade_date, - ) - order_list = trade_exchange.generate_order_for_target_amount_position( - target_position=target_amount_dict, - current_position=current_amount_dict, - trade_date=trade_date, - ) - return order_list - - -class OrderGenWOInteract(OrderGenerator): - """Order Generator Without Interact""" - - def generate_order_list_from_target_weight_position( - self, - current: Position, - trade_exchange: Exchange, - target_weight_position: dict, - risk_degree: float, - pred_date: pd.Timestamp, - trade_date: pd.Timestamp, - ) -> list: - """generate_order_list_from_target_weight_position - - generate order list directly not using the information (e.g. whether can be traded, the accurate trade price) at trade date. - In target weight position, generating order list need to know the price of objective stock in trade date, but we cannot get that - value when do not interact with exchange, so we check the %close price at pred_date or price recorded in current position. - - :param current: - :type current: Position - :param trade_exchange: - :type trade_exchange: Exchange - :param target_weight_position: - :type target_weight_position: dict - :param risk_degree: - :type risk_degree: float - :param pred_date: - :type pred_date: pd.Timestamp - :param trade_date: - :type trade_date: pd.Timestamp - - :rtype: list - """ - risk_total_value = risk_degree * current.calculate_value() - - current_stock = current.get_stock_list() - amount_dict = {} - for stock_id in target_weight_position: - # Current rule will ignore the stock that not hold and cannot be traded at predict date - if trade_exchange.is_stock_tradable(stock_id=stock_id, trade_date=pred_date): - amount_dict[stock_id] = ( - risk_total_value * target_weight_position[stock_id] / trade_exchange.get_close(stock_id, pred_date) - ) - elif stock_id in current_stock: - amount_dict[stock_id] = ( - risk_total_value * target_weight_position[stock_id] / current.get_stock_price(stock_id) - ) - else: - continue - order_list = trade_exchange.generate_order_for_target_amount_position( - target_position=amount_dict, - current_position=current.get_stock_amount_dict(), - trade_date=trade_date, - ) - return order_list diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index 28982bc3a..1c0ef87a4 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -915,7 +915,33 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): else: raise ValueError("sample freq must be xmin, xd, xw, xm") -def sample_feature(feature_raw, freq, start_time, end_time, method="last"): - datetime_raw = feature_raw.index.get_level_values("datetime") - feature_sample = feature_raw[list(map(lambda x: start_time < x <= end_time, datetime_raw))] - return getattr(feature_sample.groupby(level="instrument"), method)() \ No newline at end of file +def get_sample_freq_calendar(start_time, end_time, freq): + try: + _calendar = D.calendar(start_time=start_time, end_time=end_time, freq=freq) + except ValueError: + if freq.endswith(("m", "month", "w", "week", "d", "day")): + try: + _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq) + except ValueError: + _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="day", freq_sam=freq) + elif freq.endswith(("min", "minute")): + _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq) + else: + raise ValueError(f"freq {freq} is not supported") + return _calendar + +def sample_feature(feature, instruments=None, start_time=None, end_time=None, fields=None, method=None, method_kwargs={}): + if instruments and type(instruments) is not list: + instruments = [instruments] + if fields and type(fields) is not list: + fields = [fields] + selector_inst = slice(None) if instruments is None else instruments + selector_datetime = slice(start_time, end_time) + if fields is not None and type(fields) is not list: + fields = [fields] + selector_fields = slice(None) if fields is None else fields + feature = feature.loc[(selector_inst, selector_datetime), selector_fields] + if method: + return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) + else: + return feature \ No newline at end of file From 8979d786a9977b25706ce31c152519958926e491 Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 22 Apr 2021 02:04:40 +0800 Subject: [PATCH 003/187] update report & account --- qlib/backtest/account.py | 47 +++---- qlib/backtest/env.py | 169 ++++++++++++++++++++++++++ qlib/backtest/position.py | 12 +- qlib/backtest/report.py | 42 +++---- qlib/contrib/backtest_new/backtest.py | 0 qlib/strategy/base.py | 2 +- qlib/utils/__init__.py | 12 +- 7 files changed, 231 insertions(+), 53 deletions(-) create mode 100644 qlib/backtest/env.py create mode 100644 qlib/contrib/backtest_new/backtest.py diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index ce4b631ac..038bbcf60 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -26,10 +26,10 @@ rtn & earning in the Account class Account: - def __init__(self, init_cash, last_trade_date=None): - self.init_vars(init_cash, last_trade_date) + def __init__(self, init_cash, last_trade_time=None): + self.init_vars(init_cash, last_trade_time) - def init_vars(self, init_cash, last_trade_date=None): + def init_vars(self, init_cash, last_trade_time=None): # init cash self.init_cash = init_cash self.current = Position(cash=init_cash) @@ -40,7 +40,7 @@ class Account: self.val = 0 self.report = Report() self.earning = 0 - self.last_trade_date = last_trade_date + self.last_trade_time = last_trade_time def get_positions(self): return self.positions @@ -83,7 +83,7 @@ class Account: self.current.update_order(order, trade_val, cost, trade_price) self.update_state_from_order(order, trade_val, cost, trade_price) - def update_bar_end(self, start_time, end_time, trader): + def update_bar_end(self, trade_start_time, trade_end_time, trade_exchange): """ start_time: pd.TimeStamp end_time: pd.TimeStamp @@ -103,11 +103,11 @@ class Account: profit = 0 for code in stock_list: # if suspend, no new price to be updated, profit is 0 - if trader.check_stock_suspended(code, today): + if trade_exchange.check_stock_suspended(code, trade_start_time, trade_end_time): continue - today_close = trader.get_close(code, today) - profit += (today_close - self.current.position[code]["price"]) * self.current.position[code]["amount"] - self.current.update_stock_price(stock_id=code, price=today_close) + bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) + profit += (bar_close - self.current.position[code]["price"]) * self.current.position[code]["amount"] + self.current.update_stock_price(stock_id=code, price=bar_close) self.rtn += profit # update holding day count self.current.add_count_all() @@ -117,54 +117,55 @@ class Account: # account_value - last_account_value # for the first trade date, account_value - init_cash # self.report.is_empty() to judge is_first_trade_date - # get last_account_value, today_account_value, today_stock_value + # get last_account_value, now_account_value, now_stock_value if self.report.is_empty(): last_account_value = self.init_cash else: last_account_value = self.report.get_latest_account_value() - today_account_value = self.current.calculate_value() - today_stock_value = self.current.calculate_stock_value() - self.earning = today_account_value - last_account_value + now_account_value = self.current.calculate_value() + now_stock_value = self.current.calculate_stock_value() + self.earning = now_account_value - last_account_value # update report for today # judge whether the the trading is begin. # and don't add init account state into report, due to we don't have excess return in those days. self.report.update_report_record( - trade_date=today, - account_value=today_account_value, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + account_value=now_account_value, cash=self.current.position["cash"], return_rate=(self.earning + self.ct) / last_account_value, # here use earning to calculate return, position's view, earning consider cost, true return # in order to make same definition with original backtest in evaluate.py turnover_rate=self.to / last_account_value, cost_rate=self.ct / last_account_value, - stock_value=today_stock_value, + stock_value=now_stock_value, ) - # set today_account_value to position - self.current.position["today_account_value"] = today_account_value + # set now_account_value to position + self.current.position["now_account_value"] = now_account_value self.current.update_weight_all() # update positions # note use deepcopy - self.positions[today] = copy.deepcopy(self.current) + self.positions[trade_start_time] = copy.deepcopy(self.current) # finish today's updation # reset the daily variables self.rtn = 0 self.ct = 0 self.to = 0 - self.last_trade_date = today + self.last_trade_time = (trade_start_time, trade_end_time) def load_account(self, account_path): report = Report() position = Position() - last_trade_date = position.load_position(account_path / "position.xlsx") + last_trade_time = position.load_position(account_path / "position.xlsx") report.load_report(account_path / "report.csv") # assign values self.init_vars(position.init_cash) self.current = position self.report = report - self.last_trade_date = last_trade_date if last_trade_date else None + self.last_trade_time = last_trade_time def save_account(self, account_path): - self.current.save_position(account_path / "position.xlsx", self.last_trade_date) + self.current.save_position(account_path / "position.xlsx", self.last_trade_time) self.report.save_report(account_path / "report.csv") diff --git a/qlib/backtest/env.py b/qlib/backtest/env.py new file mode 100644 index 000000000..32ed91ef0 --- /dev/null +++ b/qlib/backtest/env.py @@ -0,0 +1,169 @@ + + +import re +import json +import copy +import pathlib +import pandas as pd +from loguru import Logger +from ...data import D +from ...utils import get_date_in_file_name +from ...utils import get_pre_trading_date +from ..backtest.order import Order +from ..utils import init_instance_by_config + +class BaseEnv: + """ + # Strategy framework document + + class Env(BaseEnv): + """ + + def __init__( + self, + step_bar, + trade_account, + start_time=None, + end_time=None, + track=False, + verbose=False, + **kwargs + ): + self.step_bar = step_bar + self.reset(start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, **kwargs) + + def _reset_trade_date(self, start_time=None, end_time=None): + if start_time: + self.start_time = start_time + if end_time: + self.end_time = end_time + if not self.start_time or not self.end_time: + raise ValueError("value of `start_time` or `end_time` is None") + _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) + self.trade_dates = np.hstack(_calendar, pd.Timestamp(self.end_time)) + self.trade_len = len(self.trade_dates) + self.trade_index = 0 + + def reset(self, start_time=None, end_time=None, **kwargs): + if start_time or end_time: + self._reset_trade_date(start_time=start_time, end_time=end_time) + self.track = kwargs.get("track", False) + self.upper_action = kwargs.get("upper_action", None) + self.trade_account = init_instance_by_config(kwargs.get("trade_account")) + return self.trade_account + + def execute(self, **kwargs): + self.trade_index = self.trade_index + 1 + return + ( + self.trade_account, + { + "start_time": self.start_time, + "end_time": self.end_time, + "trade_len": self.trade_len, + "trade_index": self.trade_index - 1, + } + ) + + def finished(self): + return self.trade_index >= self.trade_len - 1 + + + +class SplitEnv(BaseEnv): + def __init__( + self, + step_bar, + start_time, + end_time, + trade_account, + sub_env, + sub_strategy, + track=False, + verbose=False, + **kwargs + ): + self.sub_env = sub_env + self.sub_strategy = sub_strategy + super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track) + + def execute(self, order_list, **kwargs): + if self.finished(): + raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") + #if self.track: + # yield action + #episode_reward = 0 + trade_start_time = self.trade_dates[self.trade_index] + trade_end_time = self.trade_dates[self.trade_index + 1] + self.sub_strategy.reset(trade_order_list=order_list) + sub_account = self.sub_env.reset(trade_order_list=order_list, start_time=self.trade_dates[self.trade_index - 1], end_time=self.trade_dates[self.trade_index]) + while not self.sub_env.finished(): + sub_order_list = self.sub_strategy.generate_order(sub_account) + sub_account, sub_info = self.sub_env.execute(sub_order_list) + #episode_reward += sub_reward + _account, _info = super(SimulatorEnv, self).execute(**kwargs) + return _account, _info + + + +class SimulatorEnv(BaseEnv): + + def __init__( + self, + step_bar, + start_time, + end_time, + trade_account, + trade_exchange, + track=False, + verbose=False, + **kwargs + ): + self.trade_exchange = trade_exchange + super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, verbose=verbose) + + def execute(self, order_list, **kwargs): + """ + Return: obs, done, info + """ + if self.finished(): + raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") + + trade_start_time = self.trade_dates[self.trade_index] + trade_end_time = self.trade_dates[self.trade_index + 1] + trade_info = [] + for order in order_list: + if self.trade_exchange.check_order(order) is True: + # execute the order + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(order, trade_account=self.trade_account) + trade_info.append((order, trade_val, trade_cost, trade_price)) + if self.verbose: + if order.direction == Order.SELL: # sell + print( + "[I {:%Y-%m-%d}]: sell {}, price {:.2f}, amount {}, value {:.2f}.".format( + trade_start_time, + order.stock_id, + trade_price, + order.deal_amount, + trade_val, + ) + ) + else: + print( + "[I {:%Y-%m-%d}]: buy {}, price {:.2f}, amount {}, value {:.2f}.".format( + trade_start_time, + order.stock_id, + trade_price, + order.deal_amount, + trade_val, + ) + ) + + else: + if self.verbose: + print("[W {:%Y-%m-%d}]: {} wrong.".format(trade_start_time, order.stock_id)) + # do nothing + pass + self.trade_account.update_bar_end(trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange) + _account, _info = super(SimulatorEnv, self).execute(**kwargs) + return _account, {**_info, "trade_info", trade_info} \ No newline at end of file diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index c63651164..9945a7e8f 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -163,14 +163,15 @@ class Position: for stock_code, weight in weight_dict.items(): self.update_stock_weight(stock_code, weight) - def save_position(self, path, last_trade_date): + def save_position(self, path, last_trade_time): path = pathlib.Path(path) p = copy.deepcopy(self.position) cash = pd.Series(dtype=np.float) cash["init_cash"] = self.init_cash cash["cash"] = p["cash"] cash["today_account_value"] = p["today_account_value"] - cash["last_trade_date"] = str(last_trade_date.date()) if last_trade_date else None + cash["last_trade_start_time"] = str(last_trade_time[0]) if last_trade_time else None + cash["last_trade_end_time"] = str(last_trade_time[1]) if last_trade_time else None del p["cash"] del p["today_account_value"] positions = pd.DataFrame.from_dict(p, orient="index") @@ -201,7 +202,8 @@ class Position: init_cash = cash_record.loc["init_cash"].values[0] cash = cash_record.loc["cash"].values[0] today_account_value = cash_record.loc["today_account_value"].values[0] - last_trade_date = cash_record.loc["last_trade_date"].values[0] + last_trade_start_time = cash_record.loc["last_trade_start_time"].values[0] + last_trade_end_time = cash_record.loc["last_trade_end_time"].values[0] # assign values self.position = {} @@ -210,4 +212,6 @@ class Position: self.position["cash"] = cash self.position["today_account_value"] = today_account_value - return None if pd.isna(last_trade_date) else pd.Timestamp(last_trade_date) + last_trade_start_time = None is pd.isna(last_trade_start_time) else pd.Timestamp(last_trade_start_time) + last_trade_end_time = None is pd.isna(last_trade_end_time) else pd.Timestamp(last_trade_end_time) + return last_trade_start_time, last_trade_end_time diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index beb9759d0..9a57156f2 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -21,20 +21,20 @@ class Report: self.costs = OrderedDict() # trade cost for each trade date self.values = OrderedDict() # value for each trade date self.cashes = OrderedDict() - self.latest_report_date = None # pd.TimeStamp + self.latest_report_time = None # pd.TimeStamp def is_empty(self): return len(self.accounts) == 0 def get_latest_date(self): - return self.latest_report_date + return self.latest_report_time def get_latest_account_value(self): - return self.accounts[self.latest_report_date] + return self.accounts[self.latest_report_time] def update_report_record( self, - trade_date=None, + trade_time=None, account_value=None, cash=None, return_rate=None, @@ -44,7 +44,7 @@ class Report: ): # check data if None in [ - trade_date, + trade_time, account_value, cash, return_rate, @@ -56,14 +56,14 @@ class Report: "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value]" ) # update report data - self.accounts[trade_date] = account_value - self.returns[trade_date] = return_rate - self.turnovers[trade_date] = turnover_rate - self.costs[trade_date] = cost_rate - self.values[trade_date] = stock_value - self.cashes[trade_date] = cash + self.accounts[trade_time] = account_value + self.returns[trade_time] = return_rate + self.turnovers[trade_time] = turnover_rate + self.costs[trade_time] = cost_rate + self.values[trade_time] = stock_value + self.cashes[trade_time] = cash # update latest_report_date - self.latest_report_date = trade_date + self.latest_report_time = trade_time # finish daily report update def generate_report_dataframe(self): @@ -74,7 +74,7 @@ class Report: report["cost"] = pd.Series(self.costs) report["value"] = pd.Series(self.values) report["cash"] = pd.Series(self.cashes) - report.index.name = "date" + report.index.name = "trade_time" return report def save_report(self, path): @@ -94,13 +94,13 @@ class Report: index = r.index self.init_vars() - for date in index: + for trade_time in index: self.update_report_record( - trade_date=date, - account_value=r.loc[date]["account"], - cash=r.loc[date]["cash"], - return_rate=r.loc[date]["return"], - turnover_rate=r.loc[date]["turnover"], - cost_rate=r.loc[date]["cost"], - stock_value=r.loc[date]["value"], + trade_time=trade_time, + account_value=r.loc[trade_time]["account"], + cash=r.loc[trade_time]["cash"], + return_rate=r.loc[trade_time]["return"], + turnover_rate=r.loc[trade_time]["turnover"], + cost_rate=r.loc[trade_time]["cost"], + stock_value=r.loc[trade_time]["value"], ) diff --git a/qlib/contrib/backtest_new/backtest.py b/qlib/contrib/backtest_new/backtest.py new file mode 100644 index 000000000..e69de29bb diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 692959f21..03b9d88c0 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -32,7 +32,7 @@ class BaseStrategy: if not self.start_time or not self.end_time: raise ValueError("value of `start_time` or `end_time` is None") _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) - self.trade_dates = np.hstack(pd.Timestamp(self.start_time), _calendar, self.end_time) + self.trade_dates = np.hstack(_calendar, pd.Timestamp(self.end_time)) self.trade_len = len(self.trade_dates) self.trade_index = 0 diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index 1c0ef87a4..2cd2f5d13 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -866,14 +866,15 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): """ freq_raw = "1" + freq_raw if re.match("^[0-9]", freq_raw) is None else freq_raw freq_sam = "1" + freq_sam if re.match("^[0-9]", freq_sam) is None else freq_sam - + if not len(calendar_raw): + return calendar_raw if freq_sam.endswith(("minute", "min")): def cal_next_sam_minute(x, sam_minutes): hour = x.hour minute = x.minute - if 9 <= hour <= 11: + if (hour == 9 and minute >= 30) or (9 < hour < 11) or (hour == 11 and minute < 30): minute_index = (hour - 9)*60 + minute - 30 - elif 13 <= hour <= 15: + elif 13 <= hour < 15: minute_index = (hour - 13)*60 + minute + 120 else: raise ValueError("calendar hour must be in [9, 11] or [13, 15]") @@ -894,6 +895,8 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): if raw_minutes > sam_minutes: raise ValueError("raw freq must be higher than sample freq") _calendar_minute = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_minutes), 0), calendar_raw))) + if calendar_raw[0] > _calendar_minute[0]: + _calendar_minute[0] = calendar_raw[0] return _calendar_minute else: _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 0, 0, 0), calendar_raw))) @@ -944,4 +947,5 @@ def sample_feature(feature, instruments=None, start_time=None, end_time=None, fi if method: return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) else: - return feature \ No newline at end of file + return feature + From 39deb7d27fd49b5b7074282d50c1c7de0ee0e0bf Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 22 Apr 2021 22:28:01 +0800 Subject: [PATCH 004/187] update env & strategy, add workflow --- examples/highfreq/backtest/workflow.py | 135 ++++++++ examples/highfreq/{ => data}/README.md | 0 .../highfreq/{ => data}/highfreq_handler.py | 0 examples/highfreq/{ => data}/highfreq_ops.py | 0 .../highfreq/{ => data}/highfreq_processor.py | 0 examples/highfreq/{ => data}/workflow.py | 0 qlib/backtest/__init__.py | 288 ++++-------------- qlib/backtest/account.py | 3 +- qlib/backtest/env.py | 119 ++++---- qlib/contrib/strategy/dl_strategy.py | 26 +- qlib/contrib/strategy/rule_strategy.py | 59 ++-- qlib/strategy/base.py | 52 +++- 12 files changed, 319 insertions(+), 363 deletions(-) create mode 100644 examples/highfreq/backtest/workflow.py rename examples/highfreq/{ => data}/README.md (100%) rename examples/highfreq/{ => data}/highfreq_handler.py (100%) rename examples/highfreq/{ => data}/highfreq_ops.py (100%) rename examples/highfreq/{ => data}/highfreq_processor.py (100%) rename examples/highfreq/{ => data}/workflow.py (100%) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py new file mode 100644 index 000000000..cddc78b92 --- /dev/null +++ b/examples/highfreq/backtest/workflow.py @@ -0,0 +1,135 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import sys +from pathlib import Path + +import qlib +import pandas as pd +from qlib.config import REG_CN +from qlib.contrib.model.gbdt import LGBModel +from qlib.contrib.data.handler import Alpha158 +from qlib.contrib.strategy.strategy import TopkDropoutStrategy +from qlib.contrib.evaluate import ( + backtest as normal_backtest, + risk_analysis, +) +from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict +from qlib.workflow import R +from qlib.workflow.record_temp import SignalRecord, PortAnaRecord +from qlib.tests.data import GetData + +if __name__ == "__main__": + + # use default data + provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir + if not exists_qlib_data(provider_uri): + print(f"Qlib data is not found in {provider_uri}") + GetData().qlib_data(target_dir=provider_uri, region=REG_CN) + + qlib.init(provider_uri=provider_uri, region=REG_CN) + + market = "csi300" + benchmark = "SH000300" + + ################################### + # train model + ################################### + + data_handler_config = { + "start_time": "2008-01-01", + "end_time": "2020-08-01", + "fit_start_time": "2008-01-01", + "fit_end_time": "2014-12-31", + "instruments": market, + } + + task = { + "model": { + "class": "LGBModel", + "module_path": "qlib.contrib.model.gbdt", + "kwargs": { + "loss": "mse", + "colsample_bytree": 0.8879, + "learning_rate": 0.0421, + "subsample": 0.8789, + "lambda_l1": 205.6999, + "lambda_l2": 580.9768, + "max_depth": 8, + "num_leaves": 210, + "num_threads": 20, + }, + }, + "dataset": { + "class": "DatasetH", + "module_path": "qlib.data.dataset", + "kwargs": { + "handler": { + "class": "Alpha158", + "module_path": "qlib.contrib.data.handler", + "kwargs": data_handler_config, + }, + "segments": { + "train": ("2008-01-01", "2014-12-31"), + "valid": ("2015-01-01", "2016-12-31"), + "test": ("2017-01-01", "2020-08-01"), + }, + }, + }, + } + # model initialization + model = init_instance_by_config(task["model"]) + dataset = init_instance_by_config(task["dataset"]) + model.fit(dataset) + + trade_start_time = "2017-01-01" + trade_end_time = "2020-08-01" + trade_exchange = get_exchange(start_time=trade_start_time, end_time=trade_end_time) + + backtest_config={ + "strategy": { + "class": "TopkDropoutStrategy", + "module_path": "qlib.contrib.strategy.dl_strategy", + "kwargs": { + "step_bar": "day", + "model": model, + "dataset": dataset, + "trade_exchange": trade_exchange, + "topk": 50, + "n_drop": 5, + }, + }, + "env":{ + "class": "SplitEnv", + "module_path": "qlib.backtest.env", + "kwargs": { + "step_bar": "day", + "sub_env": { + "class": "SimulatorEnv", + "module_path": "qlib.backtest.env", + "kwargs": { + "step_bar": "1min", + "trade_exchange": trade_exchange, + } + }, + "sub_strategy": { + "class": "SBBStrategyEMA", + "module_path": "qlib.contrib.strategy.rule_strategy", + "kwargs": { + "step_bar": "1min", + } + } + } + } + } + + + # prediction + recorder = R.get_recorder() + sr = SignalRecord(model, dataset, recorder) + sr.generate() + + # backtest. If users want to use backtest based on their own prediction, + # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. + par = PortAnaRecord(recorder, port_analysis_config) + par.generate() diff --git a/examples/highfreq/README.md b/examples/highfreq/data/README.md similarity index 100% rename from examples/highfreq/README.md rename to examples/highfreq/data/README.md diff --git a/examples/highfreq/highfreq_handler.py b/examples/highfreq/data/highfreq_handler.py similarity index 100% rename from examples/highfreq/highfreq_handler.py rename to examples/highfreq/data/highfreq_handler.py diff --git a/examples/highfreq/highfreq_ops.py b/examples/highfreq/data/highfreq_ops.py similarity index 100% rename from examples/highfreq/highfreq_ops.py rename to examples/highfreq/data/highfreq_ops.py diff --git a/examples/highfreq/highfreq_processor.py b/examples/highfreq/data/highfreq_processor.py similarity index 100% rename from examples/highfreq/highfreq_processor.py rename to examples/highfreq/data/highfreq_processor.py diff --git a/examples/highfreq/workflow.py b/examples/highfreq/data/workflow.py similarity index 100% rename from examples/highfreq/workflow.py rename to examples/highfreq/data/workflow.py diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index aa24ffb0c..0afe03ea4 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -8,95 +8,37 @@ from .exchange import Exchange from .report import Report from .backtest import backtest as backtest_func, get_date_range +import copy import numpy as np import inspect -from ...utils import init_instance_by_config -from ...log import get_module_logger -from ...config import C +from ..utils import init_instance_by_config +from ..log import get_module_logger +from ..config import C logger = get_module_logger("backtest caller") -def get_strategy( - strategy=None, - topk=50, - margin=0.5, - n_drop=5, - risk_degree=0.95, - str_type="dropout", - adjust_dates=None, -): - """get_strategy - - There will be 3 ways to return a stratgy. Please follow the code. - - - Parameters - ---------- - - strategy : Strategy() - strategy used in backtest. - topk : int (Default value: 50) - top-N stocks to buy. - margin : int or float(Default value: 0.5) - - if isinstance(margin, int): - - sell_limit = margin - - - else: - - sell_limit = pred_in_a_day.count() * margin - - buffer margin, in single score_mode, continue holding stock if it is in nlargest(sell_limit). - sell_limit should be no less than topk. - n_drop : int - number of stocks to be replaced in each trading date. - risk_degree: float - 0-1, 0.95 for example, use 95% money to trade. - str_type: 'amount', 'weight' or 'dropout' - strategy type: TopkAmountStrategy ,TopkWeightStrategy or TopkDropoutStrategy. - - Returns - ------- - :class: Strategy - an initialized strategy object - """ - - # There will be 3 ways to return a strategy. - if strategy is None: - # 1) create strategy with param `strategy` - str_cls_dict = { - "amount": "TopkAmountStrategy", - "weight": "TopkWeightStrategy", - "dropout": "TopkDropoutStrategy", - } - logger.info("Create new strategy ") - from .. import strategy as strategy_pool - - str_cls = getattr(strategy_pool, str_cls_dict.get(str_type)) - strategy = str_cls( - topk=topk, - buffer_margin=margin, - n_drop=n_drop, - risk_degree=risk_degree, - adjust_dates=adjust_dates, - ) - elif isinstance(strategy, (dict, str)): - # 2) create strategy with init_instance_by_config - logger.info("Create new strategy ") - strategy = init_instance_by_config(strategy) - - from ..strategy.strategy import BaseStrategy - - # else: nothing happens. 3) Use the strategy directly - if not isinstance(strategy, BaseStrategy): - raise TypeError("Strategy not supported") - return strategy +def init_env_instance_by_config(env): + if isinstance(env, dict): + env_config = copy.copy(env) + if "kwargs" in env_config: + env_kwargs = copy.copy(env_config["kwargs"]): + if "sub_env" in env_kwargs: + env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) + if "sub_strategy" in env_kwargs: + env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) + env_config["kwargs"] = env_kwargs + return init_instance_by_config(env_config) + else: + return env def get_exchange( pred, exchange=None, + start_time=None, + end_time=None, + codes = "all", subscribe_fields=[], open_cost=0.0015, close_cost=0.0025, @@ -104,7 +46,6 @@ def get_exchange( trade_unit=None, limit_threshold=None, deal_price=None, - extract_codes=False, shift=1, ): """get_exchange @@ -128,9 +69,6 @@ def get_exchange( dealing price type: 'close', 'open', 'vwap'. limit_threshold : float limit move 0.1 (10%) for example, long and short with same limit. - extract_codes: bool - will we pass the codes extracted from the pred to the exchange. - NOTE: This will be faster with offline qlib. Returns ------- @@ -149,176 +87,52 @@ def get_exchange( # handle exception for deal_price if deal_price[0] != "$": deal_price = "$" + deal_price - if extract_codes: - codes = sorted(pred.index.get_level_values("instrument").unique()) - else: - codes = "all" # TODO: We must ensure that 'all.txt' includes all the stocks - - dates = sorted(pred.index.get_level_values("datetime").unique()) - dates = np.append(dates, get_date_range(dates[-1], left_shift=1, right_shift=shift)) exchange = Exchange( - trade_dates=dates, + start_time=start_time, + end_time=end_time, codes=codes, deal_price=deal_price, subscribe_fields=subscribe_fields, limit_threshold=limit_threshold, open_cost=open_cost, close_cost=close_cost, - min_cost=min_cost, trade_unit=trade_unit, + min_cost=min_cost, ) - return exchange + else: + return init_instance_by_config(exchange, accept_types=Exchange) +def backtest(start_time, end_time, strategy, env, account=1e9, benchmark, **kwargs): + trade_strategy = init_instance_by_config(strategy) + trade_env = init_env_instance_by_config(env) + trade_account = Account(init_cash=account) -def get_executor( - executor=None, - trade_exchange=None, - verbose=True, -): - """get_executor - - There will be 3 ways to return a executor. Please follow the code. - - Parameters - ---------- - - executor : BaseExecutor - executor used in backtest. - trade_exchange : Exchange - exchange used in executor - verbose : bool - whether to print log. - - Returns - ------- - :class: BaseExecutor - an initialized BaseExecutor object - """ - - # There will be 3 ways to return a executor. - if executor is None: - # 1) create executor with param `executor` - logger.info("Create new executor ") - from ..online.executor import SimulatorExecutor - - executor = SimulatorExecutor(trade_exchange=trade_exchange, verbose=verbose) - elif isinstance(executor, (dict, str)): - # 2) create executor with config - logger.info("Create new executor ") - executor = init_instance_by_config(executor) - - from ..online.executor import BaseExecutor - - # 3) Use the executor directly - if not isinstance(executor, BaseExecutor): - raise TypeError("Executor not supported") - return executor - - -# This is the API for compatibility for legacy code -def backtest(pred, account=1e9, shift=1, benchmark="SH000905", verbose=True, return_order=False, **kwargs): - """This function will help you set a reasonable Exchange and provide default value for strategy - Parameters - ---------- - - - **backtest workflow related or commmon arguments** - - pred : pandas.DataFrame - predict should has index and one `score` column. - account : float - init account value. - shift : int - whether to shift prediction by one day. - benchmark : str - benchmark code, default is SH000905 CSI 500. - verbose : bool - whether to print log. - return_order : bool - whether to return order list - - - **strategy related arguments** - - strategy : Strategy() - strategy used in backtest. - topk : int (Default value: 50) - top-N stocks to buy. - margin : int or float(Default value: 0.5) - - if isinstance(margin, int): - - sell_limit = margin - - - else: - - sell_limit = pred_in_a_day.count() * margin - - buffer margin, in single score_mode, continue holding stock if it is in nlargest(sell_limit). - sell_limit should be no less than topk. - n_drop : int - number of stocks to be replaced in each trading date. - risk_degree: float - 0-1, 0.95 for example, use 95% money to trade. - str_type: 'amount', 'weight' or 'dropout' - strategy type: TopkAmountStrategy ,TopkWeightStrategy or TopkDropoutStrategy. - - - **exchange related arguments** - - exchange: Exchange() - pass the exchange for speeding up. - subscribe_fields: list - subscribe fields. - open_cost : float - open transaction cost. The default value is 0.002(0.2%). - close_cost : float - close transaction cost. The default value is 0.002(0.2%). - min_cost : float - min transaction cost. - trade_unit : int - 100 for China A. - deal_price: str - dealing price type: 'close', 'open', 'vwap'. - limit_threshold : float - limit move 0.1 (10%) for example, long and short with same limit. - extract_codes: bool - will we pass the codes extracted from the pred to the exchange. - - .. note:: This will be faster with offline qlib. - - - **executor related arguments** - - executor : BaseExecutor() - executor used in backtest. - verbose : bool - whether to print log. - - """ - # check strategy: - spec = inspect.getfullargspec(get_strategy) - str_args = {k: v for k, v in kwargs.items() if k in spec.args} - strategy = get_strategy(**str_args) - - # init exchange: spec = inspect.getfullargspec(get_exchange) ex_args = {k: v for k, v in kwargs.items() if k in spec.args} trade_exchange = get_exchange(pred, **ex_args) - # init executor: - executor = get_executor(executor=kwargs.get("executor"), trade_exchange=trade_exchange, verbose=verbose) + temp_env = trade_env + while True: + if hasattr(temp_env, "trade_exchange"): + temp_env.reset(trade_exchange=trade_exchange) + if hasattr(temp_env, "sub_env"): + temp_env = temp_env.sub_env + else: + break + + trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) + trade_strategy.reset(start_time=start_time, end_time=end_time) + trade_state = self.sub_env.get_first_state() + + + while not trade_env.finished(): + _order_list = self.sub_strategy.generate_order(**trade_state) + trade_state, trade_info = self.sub_env.execute(sub_order_list) + + report_df = trade_account.report.generate_report_dataframe() + positions = trade_account.get_positions() - # run backtest - report_dict = backtest_func( - pred=pred, - strategy=strategy, - executor=executor, - trade_exchange=trade_exchange, - shift=shift, - verbose=verbose, - account=account, - benchmark=benchmark, - return_order=return_order, - ) - # for compatibility of the old API. return the dict positions + report_dict = {"report_df": report_df, "positions": positions} - positions = report_dict.get("positions") - report_dict.update({"positions": {k: p.position for k, p in positions.items()}}) - return report_dict + return diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 038bbcf60..c44d26d7b 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -129,8 +129,7 @@ class Account: # judge whether the the trading is begin. # and don't add init account state into report, due to we don't have excess return in those days. self.report.update_report_record( - trade_start_time=trade_start_time, - trade_end_time=trade_end_time, + trade_time=trade_start_time, account_value=now_account_value, cash=self.current.position["cash"], return_rate=(self.earning + self.ct) / last_account_value, diff --git a/qlib/backtest/env.py b/qlib/backtest/env.py index 32ed91ef0..a4f1eb95e 100644 --- a/qlib/backtest/env.py +++ b/qlib/backtest/env.py @@ -3,6 +3,7 @@ import re import json import copy +import warnings import pathlib import pandas as pd from loguru import Logger @@ -22,70 +23,76 @@ class BaseEnv: def __init__( self, step_bar, - trade_account, start_time=None, end_time=None, - track=False, + trade_account=None, verbose=False, **kwargs ): self.step_bar = step_bar - self.reset(start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, **kwargs) + self.verbose = verbose + self.reset(start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs) - def _reset_trade_date(self, start_time=None, end_time=None): + def _reset_trade_calendar(self, start_time, end_time): if start_time: self.start_time = start_time if end_time: self.end_time = end_time - if not self.start_time or not self.end_time: - raise ValueError("value of `start_time` or `end_time` is None") - _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) - self.trade_dates = np.hstack(_calendar, pd.Timestamp(self.end_time)) - self.trade_len = len(self.trade_dates) - self.trade_index = 0 + if self.start_time and self.end_time: + _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) + self.trade_calendar = np.hstack(_calendar, pd.Timestamp(self.end_time)) + self.trade_len = len(self.trade_calendar) + self.trade_index = 0 + else: + raise ValueError("failed to reset trade calendar, params `start_time` or `end_time` is None.") - def reset(self, start_time=None, end_time=None, **kwargs): + def _get_position(self): + return self.trade_account.current + + def _get_trade_time(self): + if 0 < self.trade_index < self.trade_len - 1: + trade_start_time = self.trade_calendar[self.trade_index - 1] + trade_end_time = self.trade_calendar[self.trade_index] - pd.Timestamp(second=1) + return trade_start_time, trade_end_time + elif self.trade_index == self.trade_len - 1: + trade_start_time = self.trade_calendar[self.trade_index - 1] + trade_end_time = self.trade_calendar[self.trade_index] + return trade_start_time, trade_end_time + else: + raise RuntimeError("trade_index out of range") + + def reset(self, start_time=None, end_time=None, trade_account=None, **kwargs): if start_time or end_time: - self._reset_trade_date(start_time=start_time, end_time=end_time) - self.track = kwargs.get("track", False) - self.upper_action = kwargs.get("upper_action", None) - self.trade_account = init_instance_by_config(kwargs.get("trade_account")) - return self.trade_account + self._reset_trade_calendar(start_time=start_time, end_time=end_time) + self.trade_account = trade_account - def execute(self, **kwargs): + def get_first_state(self): + init_state = {"current": self._get_position()} + return init_state + + + def execute(self, order_list, **kwargs): self.trade_index = self.trade_index + 1 - return - ( - self.trade_account, - { - "start_time": self.start_time, - "end_time": self.end_time, - "trade_len": self.trade_len, - "trade_index": self.trade_index - 1, - } - ) def finished(self): return self.trade_index >= self.trade_len - 1 - class SplitEnv(BaseEnv): def __init__( self, step_bar, - start_time, - end_time, - trade_account, sub_env, sub_strategy, - track=False, + start_time=None, + end_time=None, + trade_account=None, verbose=False, **kwargs ): self.sub_env = sub_env self.sub_strategy = sub_strategy - super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track) + super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, verbose=verbose) def execute(self, order_list, **kwargs): if self.finished(): @@ -93,16 +100,18 @@ class SplitEnv(BaseEnv): #if self.track: # yield action #episode_reward = 0 - trade_start_time = self.trade_dates[self.trade_index] - trade_end_time = self.trade_dates[self.trade_index + 1] - self.sub_strategy.reset(trade_order_list=order_list) - sub_account = self.sub_env.reset(trade_order_list=order_list, start_time=self.trade_dates[self.trade_index - 1], end_time=self.trade_dates[self.trade_index]) + super(SimulatorEnv, self).execute(**kwargs) + trade_start_time, trade_end_time = self._get_trade_time() + self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time, trade_account=self.trade_account) + self.sub_strategy.reset(start_time=trade_start_time, end_time=trade_end_time, trade_order_list=order_list) + trade_state = self.sub_env.get_first_state() while not self.sub_env.finished(): - sub_order_list = self.sub_strategy.generate_order(sub_account) - sub_account, sub_info = self.sub_env.execute(sub_order_list) + _order_list = self.sub_strategy.generate_order(**trade_state) + trade_state, trade_info = self.sub_env.execute(order_list=_order_list) #episode_reward += sub_reward - _account, _info = super(SimulatorEnv, self).execute(**kwargs) - return _account, _info + _obs = {"current": self._get_position()} + _info = {} + return _obs, _info @@ -111,16 +120,18 @@ class SimulatorEnv(BaseEnv): def __init__( self, step_bar, - start_time, - end_time, - trade_account, - trade_exchange, - track=False, + start_time=None, + end_time=None, + trade_account=None, + trade_exchange=None, verbose=False, - **kwargs + **kwargs, ): - self.trade_exchange = trade_exchange - super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, verbose=verbose) + super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, trade_exchange=trade_exchange, verbose=verbose) + + def reset(trade_exchange=None, **kwargs): + super(SimulatorEnv, self).reset(**kwargs) + self.trade_exchange=trade_exchange def execute(self, order_list, **kwargs): """ @@ -128,9 +139,8 @@ class SimulatorEnv(BaseEnv): """ if self.finished(): raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") - - trade_start_time = self.trade_dates[self.trade_index] - trade_end_time = self.trade_dates[self.trade_index + 1] + super(SimulatorEnv, self).execute(**kwargs) + ttrade_start_time, trade_end_time = self._get_trade_time() trade_info = [] for order in order_list: if self.trade_exchange.check_order(order) is True: @@ -165,5 +175,6 @@ class SimulatorEnv(BaseEnv): # do nothing pass self.trade_account.update_bar_end(trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange) - _account, _info = super(SimulatorEnv, self).execute(**kwargs) - return _account, {**_info, "trade_info", trade_info} \ No newline at end of file + _obs = {"current": self._get_position()} + _info = {"trade_info": trade_info} + return _obs, _info \ No newline at end of file diff --git a/qlib/contrib/strategy/dl_strategy.py b/qlib/contrib/strategy/dl_strategy.py index f3a227c85..737fd7a58 100644 --- a/qlib/contrib/strategy/dl_strategy.py +++ b/qlib/contrib/strategy/dl_strategy.py @@ -64,9 +64,9 @@ class TopkDropoutStrategy(DLStrategy): # self.stock_count['code'] will be the days the stock has been hold # since last buy signal. This is designed for thresh self.stock_count = {} - self.hold_thresh = hold_thresh self.only_tradable = only_tradable + def get_risk_degree(self, trade_index): """get_risk_degree @@ -76,12 +76,10 @@ class TopkDropoutStrategy(DLStrategy): # It will use 95% amoutn of your total value by default return self.risk_degree - def generate_order_list(self, trade_account, trade_start_time, trade_end_time, **kwargs): + def generate_order_list(self, current, **kwargs): super(TopkDropoutStrategy, self).generate_order_list() - if self.trade_index == 1: - pred_start_time, pred_end_time = None, trade_start_time - pd.Timedelta(seconds=1) - else: - pred_start_time, pred_end_time = self.trade_dates[self.trade_index - 2], trade_start_time - pd.Timedelta(seconds=1) + trade_start_time, trade_end_time = self._get_trade_time() + pred_start_time, pred_end_time = self._get_last_trade_time() pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if self.only_tradable: # If The strategy only consider tradable stock when make decision @@ -114,7 +112,7 @@ class TopkDropoutStrategy(DLStrategy): def filter_stock(l): return l - current_temp = copy.deepcopy(trade_account.current) + current_temp = copy.deepcopy(current) # generate order list for this adjust date sell_order_list = [] buy_order_list = [] @@ -229,14 +227,15 @@ class TopkDropoutStrategy(DLStrategy): return sell_order_list + buy_order_list class WeightStrategyBase(DLStrategy): - def __init__(self, trade_exchange, order_generator_cls_or_obj=OrderGenWInteract, **kwargs): - super().__init__(**kwargs) + def __init__(self, trade_exchange, order_generator_cls_or_obj=OrderGenWInteract, start_time=None, end_time=None, **kwargs): + super(WeightStrategyBase, self).__init__(step_bar, start_time, end_time) self.trade_exchange = trade_exchange if isinstance(order_generator_cls_or_obj, type): self.order_generator = order_generator_cls_or_obj() else: self.order_generator = order_generator_cls_or_obj + def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time): """ @@ -253,7 +252,7 @@ class WeightStrategyBase(DLStrategy): """ raise NotImplementedError() - def generate_order_list(self, trade_account, trade_start_time, trade_end_time, **kwargs): + def generate_order_list(self, current, **kwargs): """ Parameters ----------- @@ -269,11 +268,8 @@ class WeightStrategyBase(DLStrategy): # generate_order_list # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list super(WeightStrategyBase, self).generate_order_list() - if self.trade_index == 1: - pred_start_time, pred_end_time = None, trade_start_time - pd.Timedelta(seconds=1) - else: - pred_start_time, pred_end_time = self.trade_dates[self.trade_index - 2], trade_start_time - pd.Timedelta(seconds=1) - + trade_start_time, trade_end_time = self._get_trade_time() + pred_start_time, pred_end_time = self._get_pred_time() pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") current_temp = copy.deepcopy(trade_account.current) target_weight_position = self.generate_target_weight_position( diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index af43be246..31968dafa 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -9,27 +9,18 @@ from ...backtest.order import Order class TWAPStrategy(RuleStrategy, TradingEnhancement): - def __init__( - self, - step_bar, - start_time, - end_time, - **kwargs, - ): - self.step_bar = step_bar - self.reset(start_time=start_time, end_time=end_time, **kwargs) + + def reset(self, trade_order_list=None, **kwargs): + super(TWAPStrategy, self).reset(**kwargs) + TradingEnhancement.reset(trade_order_list=trade_order_list) self.trade_amount = {} for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len - - def reset(self, start_time=None, end_time=None, trade_order_list=None, **kwargs): - super(SignalStrategy, self).reset(start_time=start_time, end_time=end_time, **kwargs) - TradingEnhancement.reset(trade_order_list=trade_order_list) + def generate_order_list(self, **kwargs): super(TopkDropoutStrategy, self).generate_order_list() - trade_start_time = self.trade_dates[self.trade_index - 1] - trade_end_time = self.trade_dates[self.trade_index] + trade_start_time, trade_end_time = self._get_trade_time() order_list = [] for order in self.trade_order_list: _order = Order( @@ -43,7 +34,7 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): order_list.append(_order) return order_list -class SBBStrategy(RuleStrategy, TradingEnhancement): +class SBBStrategyBase(RuleStrategy, TradingEnhancement): """ (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy. """ @@ -51,34 +42,22 @@ class SBBStrategy(RuleStrategy, TradingEnhancement): TREND_SHORT = 1 TREND_LONG = 2 - def __init__( - self, - step_bar, - start_time, - end_time, - **kwargs, - ): - self.step_bar = step_bar - self.reset(start_time=start_time, end_time=end_time, **kwargs) + def reset(self, trade_order_list=None, **kwargs): + TradingEnhancement.reset(trade_order_list=trade_order_list) self.trade_amount = {} self.trade_delay = {} for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len self.trade_trend[(order.stock_id, order.direction)] = TREND_MID - - def reset(self, start_time=None, end_time=None, trade_order_list=None, **kwargs): - super(SignalStrategy, self).reset(start_time=start_time, end_time=end_time, **kwargs) - TradingEnhancement.reset(trade_order_list=trade_order_list) + super(SBBStrategyBase, self).reset(**kwargs) def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") - def generate_order_list(self, trade_start_time, trade_end_time, **kwargs): - super(TopkDropoutStrategy, self).generate_order_list() - if self.trade_index == 1: - pred_start_time, pred_end_time = None, trade_start_time - pd.Timedelta(seconds=1) - else: - pred_start_time, pred_end_time = self.trade_dates[self.trade_index - 2], trade_start_time - pd.Timedelta(seconds=1) + def generate_order_list(self, **kwargs): + super(SBBStrategyBase, self).generate_order_list() + trade_start_time, trade_end_time = self._get_trade_time() + pred_start_time, pred_end_time = self._get_last_trade_time() order_list = [] for order in self.trade_order_list: if self.trade_index % 2 == 1: @@ -124,7 +103,7 @@ class SBBStrategy(RuleStrategy, TradingEnhancement): return order_list -class SBBEMAStrategy(SBBStrategy): +class SBBStrategyEMA(SBBStrategyBase): """ (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA). """ @@ -137,17 +116,17 @@ class SBBEMAStrategy(SBBStrategy): freq="day", **kwargs, ): - self.step_bar = step_bar + super(SBBStrategyEMA, self).__init__(step_bar, start_time, end_time, **kwargs) if instruments is None: warnings.warn("`instruments` is not set, will load all stocks") self.instruments = "all" if isinstance(instruments, str): self.instruments = D.instruments(instruments, filter_pipe=self.filter_pipe) self.freq = freq - self.reset(start_time=start_time, end_time=end_time) + - def _reset_trade_date(self, start_time=None, end_time=None): - super(SignalStrategy, self)._reset_trade_date(start_time=start_time, end_time=end_time) + def _reset_trade_calendar(self, start_time=None, end_time=None, _calendar=None): + super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time, _calendar=_calendar) fields = [("EMA...", "signal")] self.signal = D.features(instruments, fields, start_time=self.start_time, end_time=self.end_time, freq=self.freq) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 03b9d88c0..9f9be45cb 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -20,26 +20,49 @@ from ..data.data import D - label和freq和strategy的bar分离,这个如何决策呢 """ class BaseStrategy: - def __init__(self, step_bar, start_time, end_time, **kwargs): + def __init__(self, step_bar, start_time=None, end_time=None, **kwargs): self.step_bar = step_bar self.reset(start_time=start_time, end_time=end_time, **kwargs) - def _reset_trade_date(self, start_time=None, end_time=None): + def _reset_trade_calendar(self, start_time, end_time, _calendar=None): if start_time: self.start_time = start_time if end_time: self.end_time = end_time - if not self.start_time or not self.end_time: - raise ValueError("value of `start_time` or `end_time` is None") - _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) - self.trade_dates = np.hstack(_calendar, pd.Timestamp(self.end_time)) - self.trade_len = len(self.trade_dates) - self.trade_index = 0 - - def reset(self, start_time=None, end_time=None, **kwargs): - if start_time or end_time: - self._reset_trade_date(start_time=start_time, end_time=end_time) + if self.start_time and self.end_time: + if not _calendar: + _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) + self.trade_calendar = np.hstack(_calendar, pd.Timestamp(self.end_time)) + else: + self.trade_calendar = _calendar + self.trade_len = len(self.trade_calendar) + self.trade_index = 0 + else: + raise ValueError("failed to reset trade calendar, params `start_time` or `end_time` is None.") + def reset(self, start_time=None, end_time=None, _calendar=None): + if start_time or end_time : + self._reset_trade_calendar(start_time=start_time, end_time=end_time, calendar=calendar) + + def _get_trade_time(self): + if 0 < self.trade_index < self.trade_len - 1: + trade_start_time = self.trade_calendar[self.trade_index - 1] + trade_end_time = self.trade_calendar[self.trade_index] - pd.Timestamp(second=1) + return trade_start_time, trade_end_time + elif self.trade_index == self.trade_len - 1: + trade_start_time = self.trade_calendar[self.trade_index - 1] + trade_end_time = self.trade_calendar[self.trade_index] + return trade_start_time, trade_end_time + else: + raise RuntimeError("trade_index out of range") + + def _get_last_trade_time(self, shift=1): + if self.trade_index - shift < 0: + return None, None + elif self.trade_index - shift == 0: + return None, self.trade_index[self.trade_index - shift] + else: + return self.trade_index[self.trade_index - shift - 1], self.trade_index[self.trade_index - shift] def generate_order_list(self, **kwargs): self.trade_index = self.trade_index + 1 @@ -48,7 +71,7 @@ class RuleStrategy(BaseStrategy): pass class DLStrategy(BaseStrategy): - def __init__(self, step_bar, start_time, end_time, model, dataset:DatasetH): + def __init__(self, step_bar, model, dataset:DatasetH, start_time=None, end_time=None): self.model = model self.dataset = dataset self.pred_scores = self.model.predict(dataset) @@ -62,6 +85,5 @@ class DLStrategy(BaseStrategy): class TradingEnhancement: def reset(self, trade_order_list): - if trade_order_list: - self.trade_order_list = trade_order_list + self.trade_order_list = trade_order_list From b14efa11291895b1e5e0424b504818d6eda09730 Mon Sep 17 00:00:00 2001 From: bxdd Date: Sat, 24 Apr 2021 02:29:42 +0800 Subject: [PATCH 005/187] update trade calendar & backtest workflow --- examples/highfreq/backtest/workflow.py | 15 +-- qlib/backtest/__init__.py | 74 ++++++------- qlib/backtest/backtest.py | 137 ++----------------------- qlib/backtest/env.py | 80 +++++++++------ qlib/backtest/init.py | 132 ++++++++++++++++++++++++ qlib/contrib/strategy/dl_strategy.py | 13 ++- qlib/contrib/strategy/rule_strategy.py | 7 +- qlib/data/data.py | 4 +- qlib/strategy/base.py | 38 ++----- qlib/utils/__init__.py | 17 +-- 10 files changed, 263 insertions(+), 254 deletions(-) create mode 100644 qlib/backtest/init.py diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index cddc78b92..df01e31de 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -10,10 +10,7 @@ from qlib.config import REG_CN from qlib.contrib.model.gbdt import LGBModel from qlib.contrib.data.handler import Alpha158 from qlib.contrib.strategy.strategy import TopkDropoutStrategy -from qlib.contrib.evaluate import ( - backtest as normal_backtest, - risk_analysis, -) +from qlib.backtest import backtest from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict from qlib.workflow import R from qlib.workflow.record_temp import SignalRecord, PortAnaRecord @@ -124,12 +121,4 @@ if __name__ == "__main__": } - # prediction - recorder = R.get_recorder() - sr = SignalRecord(model, dataset, recorder) - sr.generate() - - # backtest. If users want to use backtest based on their own prediction, - # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. - par = PortAnaRecord(recorder, port_analysis_config) - par.generate() + backtest(**backtest_config, ) \ No newline at end of file diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 0afe03ea4..70bc03363 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -2,11 +2,10 @@ # Licensed under the MIT License. from .order import Order -from .account import Account from .position import Position from .exchange import Exchange from .report import Report -from .backtest import backtest as backtest_func, get_date_range +from .backtest import backtest as backtest_func import copy import numpy as np @@ -18,21 +17,6 @@ from ..config import C logger = get_module_logger("backtest caller") -def init_env_instance_by_config(env): - if isinstance(env, dict): - env_config = copy.copy(env) - if "kwargs" in env_config: - env_kwargs = copy.copy(env_config["kwargs"]): - if "sub_env" in env_kwargs: - env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) - if "sub_strategy" in env_kwargs: - env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) - env_config["kwargs"] = env_kwargs - return init_instance_by_config(env_config) - else: - return env - - def get_exchange( pred, exchange=None, @@ -103,36 +87,44 @@ def get_exchange( else: return init_instance_by_config(exchange, accept_types=Exchange) -def backtest(start_time, end_time, strategy, env, account=1e9, benchmark, **kwargs): +def init_env_instance_by_config(env): + if isinstance(env, dict): + env_config = copy.copy(env) + if "kwargs" in env_config: + env_kwargs = copy.copy(env_config["kwargs"]): + if "sub_env" in env_kwargs: + env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) + if "sub_strategy" in env_kwargs: + env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) + env_config["kwargs"] = env_kwargs + return init_instance_by_config(env_config) + else: + return env + +def setup_exchange(root_instance, trade_exchange=None, force=False): + if "trade_exchange" in inspect.getfullargspec(root_instance.__class__).args: + if force: + root_instance.reset(trade_exchange=trade_exchange) + else: + if not hasattr(root_instance, "trade_exchange") or root_instance.trade_exchange is None: + root_instance.reset(trade_exchange=trade_exchange) + if hasattr(root_instance, "sub_env"): + setup_exchange(root_instance.sub_env, trade_exchange) + if hasattr(root_instance, "sub_strategy"): + setup_exchange(root_instance.sub_strategy, trade_exchange) + + +def backtest(start_time, end_time, strategy, env, benchmark, account=1e9, **kwargs): trade_strategy = init_instance_by_config(strategy) trade_env = init_env_instance_by_config(env) - trade_account = Account(init_cash=account) spec = inspect.getfullargspec(get_exchange) ex_args = {k: v for k, v in kwargs.items() if k in spec.args} trade_exchange = get_exchange(pred, **ex_args) - temp_env = trade_env - while True: - if hasattr(temp_env, "trade_exchange"): - temp_env.reset(trade_exchange=trade_exchange) - if hasattr(temp_env, "sub_env"): - temp_env = temp_env.sub_env - else: - break - - trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) - trade_strategy.reset(start_time=start_time, end_time=end_time) - trade_state = self.sub_env.get_first_state() - - - while not trade_env.finished(): - _order_list = self.sub_strategy.generate_order(**trade_state) - trade_state, trade_info = self.sub_env.execute(sub_order_list) - - report_df = trade_account.report.generate_report_dataframe() - positions = trade_account.get_positions() + setup_exchange(trade_env, trade_exchange) + setup_exchange(trade_strategy, trade_exchange) - report_dict = {"report_df": report_df, "positions": positions} + report_dict = backtest_func(start_time, end_time, trade_strategy, trade_env, benchmark, account) - return + return report_dict diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index b87d6afe3..cd9539725 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -4,140 +4,23 @@ import numpy as np import pandas as pd -from ...utils import get_date_by_shift, get_date_range -from ...data import D + from .account import Account -from ...config import C -from ...log import get_module_logger -from ...data.dataset.utils import get_level_index -LOG = get_module_logger("backtest") - - -def backtest(pred, strategy, executor, trade_exchange, shift, verbose, account, benchmark, return_order): - """Parameters - ---------- - pred : pandas.DataFrame - predict should has index and one `score` column - Qlib want to support multi-singal strategy in the future. So pd.Series is not used. - strategy : Strategy() - strategy part for backtest - trade_exchange : Exchange() - exchage for backtest - shift : int - whether to shift prediction by one day - verbose : bool - whether to print log - account : float - init account value - benchmark : str/list/pd.Series - `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. - example: - print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) - 2017-01-04 0.011693 - 2017-01-05 0.000721 - 2017-01-06 -0.004322 - 2017-01-09 0.006874 - 2017-01-10 -0.003350 - - `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. - `benchmark` is str, will use the daily change as the 'bench'. - benchmark code, default is SH000905 CSI500 - """ - # Convert format if the input format is not expected - if get_level_index(pred, level="datetime") == 1: - pred = pred.swaplevel().sort_index() - if isinstance(pred, pd.Series): - pred = pred.to_frame("score") +def backtest(trade_strategy, trade_env, benchmark, account): trade_account = Account(init_cash=account) - _pred_dates = pred.index.get_level_values(level="datetime") - predict_dates = D.calendar(start_time=_pred_dates.min(), end_time=_pred_dates.max()) - if isinstance(benchmark, pd.Series): - bench = benchmark - else: - _codes = benchmark if isinstance(benchmark, list) else [benchmark] - _temp_result = D.features( - _codes, - ["$close/Ref($close,1)-1"], - predict_dates[0], - get_date_by_shift(predict_dates[-1], shift=shift), - disk_cache=1, - ) - if len(_temp_result) == 0: - raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") - bench = _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean() + trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) + trade_strategy.reset(start_time=start_time, end_time=end_time) - trade_dates = np.append(predict_dates[shift:], get_date_range(predict_dates[-1], left_shift=1, right_shift=shift)) - if return_order: - multi_order_list = [] - # trading apart - for pred_date, trade_date in zip(predict_dates, trade_dates): - # for loop predict date and trading date - # print - if verbose: - LOG.info("[I {:%Y-%m-%d}]: trade begin.".format(trade_date)) - - # 1. Load the score_series at pred_date - try: - score = pred.loc(axis=0)[pred_date, :] # (trade_date, stock_id) multi_index, score in pdate - score_series = score.reset_index(level="datetime", drop=True)[ - "score" - ] # pd.Series(index:stock_id, data: score) - except KeyError: - LOG.warning("No score found on predict date[{:%Y-%m-%d}]".format(trade_date)) - score_series = None - - if score_series is not None and score_series.count() > 0: # in case of the scores are all None - # 2. Update your strategy (and model) - strategy.update(score_series, pred_date, trade_date) - - # 3. Generate order list - order_list = strategy.generate_order_list( - score_series=score_series, - current=trade_account.current, - trade_exchange=trade_exchange, - pred_date=pred_date, - trade_date=trade_date, - ) - else: - order_list = [] - if return_order: - multi_order_list.append((trade_account, order_list, trade_date)) - # 4. Get result after executing order list - # NOTE: The following operation will modify order.amount. - # NOTE: If it is buy and the cash is insufficient, the tradable amount will be recalculated - trade_info = executor.execute(trade_account, order_list, trade_date) - - # 5. Update account information according to transaction - update_account(trade_account, trade_info, trade_exchange, trade_date) - - # generate backtest report + trade_state = self.sub_env.get_init_state() + while not trade_env.finished(): + _order_list = self.sub_strategy.generate_order(**trade_state) + trade_state, trade_info = self.sub_env.execute(sub_order_list) + report_df = trade_account.report.generate_report_dataframe() - report_df["bench"] = bench positions = trade_account.get_positions() - report_dict = {"report_df": report_df, "positions": positions} - if return_order: - report_dict.update({"order_list": multi_order_list}) + return report_dict - -def update_account(trade_account, trade_info, trade_exchange, trade_date): - """Update the account and strategy - Parameters - ---------- - trade_account : Account() - trade_info : list of [Order(), float, float, float] - (order, trade_val, trade_cost, trade_price), trade_info with out factor - trade_exchange : Exchange() - used to get the $close_price at trade_date to update account - trade_date : pd.Timestamp - """ - # update account - for [order, trade_val, trade_cost, trade_price] in trade_info: - if order.deal_amount == 0: - continue - trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) - # at the end of trade date, update the account based the $close_price of stocks. - trade_account.update_daily_end(today=trade_date, trader=trade_exchange) diff --git a/qlib/backtest/env.py b/qlib/backtest/env.py index a4f1eb95e..571f33b7e 100644 --- a/qlib/backtest/env.py +++ b/qlib/backtest/env.py @@ -7,13 +7,54 @@ import warnings import pathlib import pandas as pd from loguru import Logger -from ...data import D +from ...data import D, Cal from ...utils import get_date_in_file_name from ...utils import get_pre_trading_date from ..backtest.order import Order from ..utils import init_instance_by_config +class TradeCalendarBase: -class BaseEnv: + def _reset_trade_calendar(self, start_time, end_time): + if start_time: + self.start_time = pd.Timestamp(start_time) + if end_time: + self.end_time = pd.Timestamp(end_time) + if self.start_time and self.end_time: + _calendar, freq, freq_sam = get_sample_freq_calendar(freq=step_bar) + self.calendar = _calendar + _start_time, _end_time, _start_index, _end_index = Cal.locate_index(self.start_time, self.end_time, freq=freq, freq_sam=freq_sam) + _trade_calendar = self.calendar[_start_index, _end_index + 1] + if _start_time != self.start_time: + self.trade_calendar = np.hstack((self.start_time, _trade_calendar, self.end_time)) + self.start_index = _start_index - 1 + else: + self.trade_calendar = np.hstack((_trade_calendar, self.end_time)) + self.start_index = _start_index + self.end_index = _end_index + self.trade_index = 0 + self.trade_len = len(self.trade_calendar) + else: + raise ValueError("failed to reset trade calendar, params `start_time` or `end_time` is None.") + + def _get_trade_time(self, trade_index=1, shift=0): + trade_index = trade_index - shift + if 0 < trade_index < self.trade_len - 1: + trade_start_time = self.trade_calendar[trade_index - 1] + trade_end_time = self.trade_calendar[trade_index] - pd.Timestamp(second=1) + return trade_start_time, trade_end_time + elif trade_index == self.trade_len - 1: + trade_start_time = self.trade_calendar[trade_index - 1] + trade_end_time = self.trade_calendar[trade_index] + return trade_start_time, trade_end_time + else: + raise RuntimeError("trade_index out of range") + + def _get_calendar_time(self, trade_index=1, shift=1): + trade_index = trade_index - shift + calendar_index = self.start_index + trade_index + return self.calendar[calendar_index - 1], self.calendar[calendar_index] + +class BaseEnv(TradeCalendarBase): """ # Strategy framework document @@ -33,38 +74,19 @@ class BaseEnv: self.verbose = verbose self.reset(start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs) - def _reset_trade_calendar(self, start_time, end_time): - if start_time: - self.start_time = start_time - if end_time: - self.end_time = end_time - if self.start_time and self.end_time: - _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) - self.trade_calendar = np.hstack(_calendar, pd.Timestamp(self.end_time)) - self.trade_len = len(self.trade_calendar) - self.trade_index = 0 - else: - raise ValueError("failed to reset trade calendar, params `start_time` or `end_time` is None.") - def _get_position(self): return self.trade_account.current - def _get_trade_time(self): - if 0 < self.trade_index < self.trade_len - 1: - trade_start_time = self.trade_calendar[self.trade_index - 1] - trade_end_time = self.trade_calendar[self.trade_index] - pd.Timestamp(second=1) - return trade_start_time, trade_end_time - elif self.trade_index == self.trade_len - 1: - trade_start_time = self.trade_calendar[self.trade_index - 1] - trade_end_time = self.trade_calendar[self.trade_index] - return trade_start_time, trade_end_time - else: - raise RuntimeError("trade_index out of range") + def reset(self, start_time=None, end_time=None, trade_account=None, **kwargs): if start_time or end_time: self._reset_trade_calendar(start_time=start_time, end_time=end_time) self.trade_account = trade_account + + for k, v in kwargs: + if hasattr(self, k): + setattr(self, k, v) def get_first_state(self): init_state = {"current": self._get_position()} @@ -101,10 +123,10 @@ class SplitEnv(BaseEnv): # yield action #episode_reward = 0 super(SimulatorEnv, self).execute(**kwargs) - trade_start_time, trade_end_time = self._get_trade_time() + trade_start_time, trade_end_time = self._get_trade_time(trade_index=self.trade_index) self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time, trade_account=self.trade_account) self.sub_strategy.reset(start_time=trade_start_time, end_time=trade_end_time, trade_order_list=order_list) - trade_state = self.sub_env.get_first_state() + trade_state = self.sub_env.get_init_state() while not self.sub_env.finished(): _order_list = self.sub_strategy.generate_order(**trade_state) trade_state, trade_info = self.sub_env.execute(order_list=_order_list) @@ -140,7 +162,7 @@ class SimulatorEnv(BaseEnv): if self.finished(): raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") super(SimulatorEnv, self).execute(**kwargs) - ttrade_start_time, trade_end_time = self._get_trade_time() + ttrade_start_time, trade_end_time = self._get_trade_time(trade_index=self.trade_index) trade_info = [] for order in order_list: if self.trade_exchange.check_order(order) is True: diff --git a/qlib/backtest/init.py b/qlib/backtest/init.py new file mode 100644 index 000000000..06dd437db --- /dev/null +++ b/qlib/backtest/init.py @@ -0,0 +1,132 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from .order import Order +from .account import Account +from .position import Position +from .exchange import Exchange +from .report import Report +from .backtest import backtest as backtest_func, get_date_range + +import copy +import numpy as np +import inspect +from ..utils import init_instance_by_config +from ..log import get_module_logger +from ..config import C + +logger = get_module_logger("backtest caller") + + +def init_env_instance_by_config(env): + if isinstance(env, dict): + env_config = copy.copy(env) + if "kwargs" in env_config: + env_kwargs = copy.copy(env_config["kwargs"]): + if "sub_env" in env_kwargs: + env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) + if "sub_strategy" in env_kwargs: + env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) + env_config["kwargs"] = env_kwargs + return init_instance_by_config(env_config) + else: + return env + + +def get_exchange( + exchange=None, + start_time=None, + end_time=None, + codes = "all", + subscribe_fields=[], + open_cost=0.0015, + close_cost=0.0025, + min_cost=5.0, + trade_unit=None, + limit_threshold=None, + deal_price=None, + shift=1, +): + """get_exchange + + Parameters + ---------- + + # exchange related arguments + exchange: Exchange(). + subscribe_fields: list + subscribe fields. + open_cost : float + open transaction cost. + close_cost : float + close transaction cost. + min_cost : float + min transaction cost. + trade_unit : int + 100 for China A. + deal_price: str + dealing price type: 'close', 'open', 'vwap'. + limit_threshold : float + limit move 0.1 (10%) for example, long and short with same limit. + + Returns + ------- + :class: Exchange + an initialized Exchange object + """ + + if trade_unit is None: + trade_unit = C.trade_unit + if limit_threshold is None: + limit_threshold = C.limit_threshold + if deal_price is None: + deal_price = C.deal_price + if exchange is None: + logger.info("Create new exchange") + # handle exception for deal_price + if deal_price[0] != "$": + deal_price = "$" + deal_price + + exchange = Exchange( + start_time=start_time, + end_time=end_time, + codes=codes, + deal_price=deal_price, + subscribe_fields=subscribe_fields, + limit_threshold=limit_threshold, + open_cost=open_cost, + close_cost=close_cost, + trade_unit=trade_unit, + min_cost=min_cost, + ) + else: + return init_instance_by_config(exchange, accept_types=Exchange) + +def backtest(start_time, end_time, strategy, env, account=1e9, **kwargs): + trade_strategy = init_instance_by_config(strategy) + trade_env = init_env_instance_by_config(env) + trade_account = Account(init_cash=account) + + spec = inspect.getfullargspec(get_exchange) + ex_args = {k: v for k, v in kwargs.items() if k in spec.args} + trade_exchange = get_exchange(pred, **ex_args) + +# temp_env = trade_env +# while True: +# if hasattr(temp_env, "trade_exchange"): +# temp_env.reset(trade_exchange=trade_exchange) +# if hasattr(temp_env, "sub_env"): +# temp_env = temp_env.sub_env +# else: +# break + + trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) + trade_state, _reset_info = self.sub_env.get_first_state() + trade_strategy.reset(**_reset_info) + + + while not trade_env.finished(): + _order_list = self.sub_strategy.generate_order(**trade_state) + trade_state, trade_info = self.sub_env.execute(sub_order_list) + + return diff --git a/qlib/contrib/strategy/dl_strategy.py b/qlib/contrib/strategy/dl_strategy.py index 737fd7a58..5f702fe0b 100644 --- a/qlib/contrib/strategy/dl_strategy.py +++ b/qlib/contrib/strategy/dl_strategy.py @@ -15,11 +15,11 @@ class TopkDropoutStrategy(DLStrategy): step_bar, model, dataset, - trade_exchange, topk, n_drop, start_time=None, end_time=None, + trade_exchange=None, method_sell="bottom", method_buy="top", risk_degree=0.95, @@ -54,7 +54,6 @@ class TopkDropoutStrategy(DLStrategy): strategy will make decision with the tradable state of the stock info and avoid buy and sell them. """ super(TopkDropoutStrategy, self).__init__(step_bar, model, dataset, start_time, end_time) - self.trade_exchange = trade_exchange self.topk = topk self.n_drop = n_drop self.method_sell = method_sell @@ -68,6 +67,10 @@ class TopkDropoutStrategy(DLStrategy): self.only_tradable = only_tradable + def reset(trade_exchange=None, **kwargs): + super(TopkDropoutStrategy, self).reset(**kwargs) + self.trade_exchange = trade_exchange + def get_risk_degree(self, trade_index): """get_risk_degree Return the proportion of your total value you will used in investment. @@ -78,8 +81,8 @@ class TopkDropoutStrategy(DLStrategy): def generate_order_list(self, current, **kwargs): super(TopkDropoutStrategy, self).generate_order_list() - trade_start_time, trade_end_time = self._get_trade_time() - pred_start_time, pred_end_time = self._get_last_trade_time() + trade_start_time, trade_end_time = self._get_trade_time(self.trade_index) + pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if self.only_tradable: # If The strategy only consider tradable stock when make decision @@ -268,7 +271,7 @@ class WeightStrategyBase(DLStrategy): # generate_order_list # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list super(WeightStrategyBase, self).generate_order_list() - trade_start_time, trade_end_time = self._get_trade_time() + trade_start_time, trade_end_time = self._get_trade_time(self.trade_index) pred_start_time, pred_end_time = self._get_pred_time() pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") current_temp = copy.deepcopy(trade_account.current) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 31968dafa..dd2e17c54 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -57,7 +57,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): def generate_order_list(self, **kwargs): super(SBBStrategyBase, self).generate_order_list() trade_start_time, trade_end_time = self._get_trade_time() - pred_start_time, pred_end_time = self._get_last_trade_time() + pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) order_list = [] for order in self.trade_order_list: if self.trade_index % 2 == 1: @@ -127,8 +127,9 @@ class SBBStrategyEMA(SBBStrategyBase): def _reset_trade_calendar(self, start_time=None, end_time=None, _calendar=None): super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time, _calendar=_calendar) - fields = [("EMA...", "signal")] - self.signal = D.features(instruments, fields, start_time=self.start_time, end_time=self.end_time, freq=self.freq) + fields = [("EMA($close, 10) - EMA($close, 20)", "signal")] + signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) + self.signal = D.features(instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq) def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): _sample_signal = sample_feature(self.signal, stock_id, start_time=pred_start_time, end_time=pred_end_time, fields="signal", method="last") diff --git a/qlib/data/data.py b/qlib/data/data.py index f978f520c..98427637a 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -114,11 +114,11 @@ class CalendarProvider(abc.ABC): dict dict composed by timestamp as key and index as value for fast search. """ - flag = f"{freq}_future_{future}_sam_{freq_sam}" + flag = f"{freq}_sam_{freq_sam}_future_{future}" if flag in H["c"]: _calendar, _calendar_index = H["c"][flag] else: - flag_raw = f"{freq}_future_{future}_sam_{None}" + flag_raw = f"{freq}_sam_{None}_future_{future}" if flag_raw in H["c"]: _calendar, _calendar_index = H["c"][flag_raw] else: diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 9f9be45cb..cad093af2 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -8,41 +8,30 @@ import numpy as np import pandas as pd -from ..utils import sample_feature, get_sample_freq_calendar +from ..utils import get_sample_freq_calendar from ..data.dataset import DatasetH from ..backtest.order import Order -from .order_generator import OrderGenWInteract -from ..data.data import D +from ..backtest.env import TradeCalendarBase + """ 1. BaseStrategy 的粒度一定是数据粒度的整数倍 - 关于calendar的合并咋整 - adjust_dates这个东西啥用 - label和freq和strategy的bar分离,这个如何决策呢 """ -class BaseStrategy: +class BaseStrategy(TradeCalendarBase): def __init__(self, step_bar, start_time=None, end_time=None, **kwargs): self.step_bar = step_bar self.reset(start_time=start_time, end_time=end_time, **kwargs) - def _reset_trade_calendar(self, start_time, end_time, _calendar=None): - if start_time: - self.start_time = start_time - if end_time: - self.end_time = end_time - if self.start_time and self.end_time: - if not _calendar: - _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) - self.trade_calendar = np.hstack(_calendar, pd.Timestamp(self.end_time)) - else: - self.trade_calendar = _calendar - self.trade_len = len(self.trade_calendar) - self.trade_index = 0 - else: - raise ValueError("failed to reset trade calendar, params `start_time` or `end_time` is None.") - - def reset(self, start_time=None, end_time=None, _calendar=None): + def reset(self, start_time=None, end_time=None, _calendar=None, **kwargs): if start_time or end_time : self._reset_trade_calendar(start_time=start_time, end_time=end_time, calendar=calendar) + + for k, v in kwargs: + if hasattr(self, k): + setattr(self, k, v) + def _get_trade_time(self): if 0 < self.trade_index < self.trade_len - 1: @@ -56,13 +45,6 @@ class BaseStrategy: else: raise RuntimeError("trade_index out of range") - def _get_last_trade_time(self, shift=1): - if self.trade_index - shift < 0: - return None, None - elif self.trade_index - shift == 0: - return None, self.trade_index[self.trade_index - shift] - else: - return self.trade_index[self.trade_index - shift - 1], self.trade_index[self.trade_index - shift] def generate_order_list(self, **kwargs): self.trade_index = self.trade_index + 1 diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index 2cd2f5d13..028e60cc6 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -918,20 +918,25 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): else: raise ValueError("sample freq must be xmin, xd, xw, xm") -def get_sample_freq_calendar(start_time, end_time, freq): +def get_sample_freq_calendar(start_time=None, end_time=None, freq, **kwargs): try: - _calendar = D.calendar(start_time=start_time, end_time=end_time, freq=freq) + _calendar = D.calendar(start_time=start_time, end_time=end_time, freq=freq, **kwargs) + freq, freq_sam = freq, None except ValueError: + freq_sam = freq if freq.endswith(("m", "month", "w", "week", "d", "day")): try: - _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq) + _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq, **kwargs) + freq = "min" except ValueError: - _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="day", freq_sam=freq) + _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="day", freq_sam=freq, **kwargs) + freq = "day" elif freq.endswith(("min", "minute")): - _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq) + _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq, **kwargs) + freq = "min" else: raise ValueError(f"freq {freq} is not supported") - return _calendar + return _calendar, freq, freq_sam def sample_feature(feature, instruments=None, start_time=None, end_time=None, fields=None, method=None, method_kwargs={}): if instruments and type(instruments) is not list: From af0053eb17ee932d9cd9d3e4625c35258a0c0dc9 Mon Sep 17 00:00:00 2001 From: bxdd Date: Sat, 24 Apr 2021 22:37:36 +0800 Subject: [PATCH 006/187] fix bug --- examples/highfreq/backtest/workflow.py | 35 +- qlib/backtest/__init__.py | 130 ------- qlib/backtest/account.py | 170 --------- qlib/backtest/backtest.py | 26 -- qlib/backtest/exchange.py | 429 ----------------------- qlib/backtest/import numpy as np | 90 ----- qlib/backtest/init.py | 132 ------- qlib/backtest/order.py | 30 -- qlib/backtest/position.py | 217 ------------ qlib/backtest/profit_attribution.py | 324 ----------------- qlib/backtest/report.py | 106 ------ qlib/contrib/backtest/__init__.py | 284 +++------------ qlib/contrib/backtest/account.py | 49 +-- qlib/contrib/backtest/backtest.py | 138 +------- qlib/{ => contrib}/backtest/env.py | 41 +-- qlib/contrib/backtest/exchange.py | 100 +++--- qlib/contrib/backtest/interpreter.py | 15 + qlib/contrib/backtest/order.py | 5 +- qlib/contrib/backtest/position.py | 32 +- qlib/contrib/backtest/report.py | 42 +-- qlib/contrib/backtest_new/backtest.py | 0 qlib/contrib/strategy/__init__.py | 4 +- qlib/contrib/strategy/cost_control.py | 2 +- qlib/contrib/strategy/dl_strategy.py | 23 +- qlib/contrib/strategy/order_generator.py | 4 +- qlib/contrib/strategy/rule_strategy.py | 56 +-- qlib/data/data.py | 8 +- qlib/strategy/base.py | 41 +-- qlib/utils/__init__.py | 28 +- 29 files changed, 314 insertions(+), 2247 deletions(-) delete mode 100644 qlib/backtest/__init__.py delete mode 100644 qlib/backtest/account.py delete mode 100644 qlib/backtest/backtest.py delete mode 100644 qlib/backtest/exchange.py delete mode 100644 qlib/backtest/import numpy as np delete mode 100644 qlib/backtest/init.py delete mode 100644 qlib/backtest/order.py delete mode 100644 qlib/backtest/position.py delete mode 100644 qlib/backtest/profit_attribution.py delete mode 100644 qlib/backtest/report.py rename qlib/{ => contrib}/backtest/env.py (89%) create mode 100644 qlib/contrib/backtest/interpreter.py delete mode 100644 qlib/contrib/backtest_new/backtest.py diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index df01e31de..3e0e1524b 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -7,13 +7,9 @@ from pathlib import Path import qlib import pandas as pd from qlib.config import REG_CN -from qlib.contrib.model.gbdt import LGBModel -from qlib.contrib.data.handler import Alpha158 -from qlib.contrib.strategy.strategy import TopkDropoutStrategy -from qlib.backtest import backtest +from qlib.contrib.strategy import TopkDropoutStrategy +from qlib.contrib.backtest import backtest from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict -from qlib.workflow import R -from qlib.workflow.record_temp import SignalRecord, PortAnaRecord from qlib.tests.data import GetData if __name__ == "__main__": @@ -67,9 +63,9 @@ if __name__ == "__main__": "kwargs": data_handler_config, }, "segments": { - "train": ("2008-01-01", "2014-12-31"), + "train": ("2012-01-01", "2014-12-31"), "valid": ("2015-01-01", "2016-12-31"), - "test": ("2017-01-01", "2020-08-01"), + "test": ("2017-01-01", "2018-01-31"), }, }, }, @@ -79,41 +75,40 @@ if __name__ == "__main__": dataset = init_instance_by_config(task["dataset"]) model.fit(dataset) - trade_start_time = "2017-01-01" - trade_end_time = "2020-08-01" - trade_exchange = get_exchange(start_time=trade_start_time, end_time=trade_end_time) + trade_start_time = "2017-01-31" + trade_end_time = "2018-01-31" backtest_config={ "strategy": { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.dl_strategy", "kwargs": { - "step_bar": "day", + "step_bar": "week", "model": model, "dataset": dataset, - "trade_exchange": trade_exchange, "topk": 50, "n_drop": 5, }, }, "env":{ "class": "SplitEnv", - "module_path": "qlib.backtest.env", + "module_path": "qlib.contrib.backtest.env", "kwargs": { - "step_bar": "day", + "step_bar": "week", "sub_env": { "class": "SimulatorEnv", - "module_path": "qlib.backtest.env", + "module_path": "qlib.contrib.backtest.env", "kwargs": { - "step_bar": "1min", - "trade_exchange": trade_exchange, + "step_bar": "day", } }, "sub_strategy": { "class": "SBBStrategyEMA", "module_path": "qlib.contrib.strategy.rule_strategy", "kwargs": { - "step_bar": "1min", + "step_bar": "day", + "freq": "day", + "instruments": "csi300", } } } @@ -121,4 +116,4 @@ if __name__ == "__main__": } - backtest(**backtest_config, ) \ No newline at end of file + report_dict = backtest(start_time=trade_start_time, end_time=trade_end_time, **backtest_config, account=1e8, deal_price="$close", verbose=False) \ No newline at end of file diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py deleted file mode 100644 index 70bc03363..000000000 --- a/qlib/backtest/__init__.py +++ /dev/null @@ -1,130 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from .order import Order -from .position import Position -from .exchange import Exchange -from .report import Report -from .backtest import backtest as backtest_func - -import copy -import numpy as np -import inspect -from ..utils import init_instance_by_config -from ..log import get_module_logger -from ..config import C - -logger = get_module_logger("backtest caller") - - -def get_exchange( - pred, - exchange=None, - start_time=None, - end_time=None, - codes = "all", - subscribe_fields=[], - open_cost=0.0015, - close_cost=0.0025, - min_cost=5.0, - trade_unit=None, - limit_threshold=None, - deal_price=None, - shift=1, -): - """get_exchange - - Parameters - ---------- - - # exchange related arguments - exchange: Exchange(). - subscribe_fields: list - subscribe fields. - open_cost : float - open transaction cost. - close_cost : float - close transaction cost. - min_cost : float - min transaction cost. - trade_unit : int - 100 for China A. - deal_price: str - dealing price type: 'close', 'open', 'vwap'. - limit_threshold : float - limit move 0.1 (10%) for example, long and short with same limit. - - Returns - ------- - :class: Exchange - an initialized Exchange object - """ - - if trade_unit is None: - trade_unit = C.trade_unit - if limit_threshold is None: - limit_threshold = C.limit_threshold - if deal_price is None: - deal_price = C.deal_price - if exchange is None: - logger.info("Create new exchange") - # handle exception for deal_price - if deal_price[0] != "$": - deal_price = "$" + deal_price - - exchange = Exchange( - start_time=start_time, - end_time=end_time, - codes=codes, - deal_price=deal_price, - subscribe_fields=subscribe_fields, - limit_threshold=limit_threshold, - open_cost=open_cost, - close_cost=close_cost, - trade_unit=trade_unit, - min_cost=min_cost, - ) - else: - return init_instance_by_config(exchange, accept_types=Exchange) - -def init_env_instance_by_config(env): - if isinstance(env, dict): - env_config = copy.copy(env) - if "kwargs" in env_config: - env_kwargs = copy.copy(env_config["kwargs"]): - if "sub_env" in env_kwargs: - env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) - if "sub_strategy" in env_kwargs: - env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) - env_config["kwargs"] = env_kwargs - return init_instance_by_config(env_config) - else: - return env - -def setup_exchange(root_instance, trade_exchange=None, force=False): - if "trade_exchange" in inspect.getfullargspec(root_instance.__class__).args: - if force: - root_instance.reset(trade_exchange=trade_exchange) - else: - if not hasattr(root_instance, "trade_exchange") or root_instance.trade_exchange is None: - root_instance.reset(trade_exchange=trade_exchange) - if hasattr(root_instance, "sub_env"): - setup_exchange(root_instance.sub_env, trade_exchange) - if hasattr(root_instance, "sub_strategy"): - setup_exchange(root_instance.sub_strategy, trade_exchange) - - -def backtest(start_time, end_time, strategy, env, benchmark, account=1e9, **kwargs): - trade_strategy = init_instance_by_config(strategy) - trade_env = init_env_instance_by_config(env) - - spec = inspect.getfullargspec(get_exchange) - ex_args = {k: v for k, v in kwargs.items() if k in spec.args} - trade_exchange = get_exchange(pred, **ex_args) - - setup_exchange(trade_env, trade_exchange) - setup_exchange(trade_strategy, trade_exchange) - - report_dict = backtest_func(start_time, end_time, trade_strategy, trade_env, benchmark, account) - - return report_dict diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py deleted file mode 100644 index c44d26d7b..000000000 --- a/qlib/backtest/account.py +++ /dev/null @@ -1,170 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import copy - -from .position import Position -from .report import Report -from .order import Order - - -""" -rtn & earning in the Account - rtn: - from order's view - 1.change if any order is executed, sell order or buy order - 2.change at the end of today, (today_clse - stock_price) * amount - earning - from value of current position - earning will be updated at the end of trade date - earning = today_value - pre_value - **is consider cost** - while earning is the difference of two position value, so it considers cost, it is the true return rate - in the specific accomplishment for rtn, it does not consider cost, in other words, rtn - cost = earning -""" - - -class Account: - def __init__(self, init_cash, last_trade_time=None): - self.init_vars(init_cash, last_trade_time) - - def init_vars(self, init_cash, last_trade_time=None): - # init cash - self.init_cash = init_cash - self.current = Position(cash=init_cash) - self.positions = {} - self.rtn = 0 - self.ct = 0 - self.to = 0 - self.val = 0 - self.report = Report() - self.earning = 0 - self.last_trade_time = last_trade_time - - def get_positions(self): - return self.positions - - def get_cash(self): - return self.current.position["cash"] - - def update_state_from_order(self, order, trade_val, cost, trade_price): - # update turnover - self.to += trade_val - # update cost - self.ct += cost - # update return - # update self.rtn from order - trade_amount = trade_val / trade_price - if order.direction == Order.SELL: # 0 for sell - # when sell stock, get profit from price change - profit = trade_val - self.current.get_stock_price(order.stock_id) * trade_amount - self.rtn += profit # note here do not consider cost - elif order.direction == Order.BUY: # 1 for buy - # when buy stock, we get return for the rtn computing method - # profit in buy order is to make self.rtn is consistent with self.earning at the end of date - profit = self.current.get_stock_price(order.stock_id) * trade_amount - trade_val - self.rtn += profit - - def update_order(self, order, trade_val, cost, trade_price): - # if stock is sold out, no stock price information in Position, then we should update account first, then update current position - # if stock is bought, there is no stock in current position, update current, then update account - # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation - trade_amount = trade_val / trade_price - if order.direction == Order.SELL: - # sell stock - self.update_state_from_order(order, trade_val, cost, trade_price) - # update current position - # for may sell all of stock_id - self.current.update_order(order, trade_val, cost, trade_price) - else: - # buy stock - # deal order, then update state - self.current.update_order(order, trade_val, cost, trade_price) - self.update_state_from_order(order, trade_val, cost, trade_price) - - def update_bar_end(self, trade_start_time, trade_end_time, trade_exchange): - """ - start_time: pd.TimeStamp - end_time: pd.TimeStamp - quote: pd.DataFrame (code, date), collumns - when the end of trade date - - update rtn - - update price for each asset - - update value for this account - - update earning (2nd view of return ) - - update holding day, count of stock - - update position hitory - - update report - :return: None - """ - # update price for stock in the position and the profit from changed_price - stock_list = self.current.get_stock_list() - profit = 0 - for code in stock_list: - # if suspend, no new price to be updated, profit is 0 - if trade_exchange.check_stock_suspended(code, trade_start_time, trade_end_time): - continue - bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) - profit += (bar_close - self.current.position[code]["price"]) * self.current.position[code]["amount"] - self.current.update_stock_price(stock_id=code, price=bar_close) - self.rtn += profit - # update holding day count - self.current.add_count_all() - # update value - self.val = self.current.calculate_value() - # update earning (2nd view of return) - # account_value - last_account_value - # for the first trade date, account_value - init_cash - # self.report.is_empty() to judge is_first_trade_date - # get last_account_value, now_account_value, now_stock_value - if self.report.is_empty(): - last_account_value = self.init_cash - else: - last_account_value = self.report.get_latest_account_value() - now_account_value = self.current.calculate_value() - now_stock_value = self.current.calculate_stock_value() - self.earning = now_account_value - last_account_value - # update report for today - # judge whether the the trading is begin. - # and don't add init account state into report, due to we don't have excess return in those days. - self.report.update_report_record( - trade_time=trade_start_time, - account_value=now_account_value, - cash=self.current.position["cash"], - return_rate=(self.earning + self.ct) / last_account_value, - # here use earning to calculate return, position's view, earning consider cost, true return - # in order to make same definition with original backtest in evaluate.py - turnover_rate=self.to / last_account_value, - cost_rate=self.ct / last_account_value, - stock_value=now_stock_value, - ) - # set now_account_value to position - self.current.position["now_account_value"] = now_account_value - self.current.update_weight_all() - # update positions - # note use deepcopy - self.positions[trade_start_time] = copy.deepcopy(self.current) - - # finish today's updation - # reset the daily variables - self.rtn = 0 - self.ct = 0 - self.to = 0 - self.last_trade_time = (trade_start_time, trade_end_time) - - def load_account(self, account_path): - report = Report() - position = Position() - last_trade_time = position.load_position(account_path / "position.xlsx") - report.load_report(account_path / "report.csv") - - # assign values - self.init_vars(position.init_cash) - self.current = position - self.report = report - self.last_trade_time = last_trade_time - - def save_account(self, account_path): - self.current.save_position(account_path / "position.xlsx", self.last_trade_time) - self.report.save_report(account_path / "report.csv") diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py deleted file mode 100644 index cd9539725..000000000 --- a/qlib/backtest/backtest.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import numpy as np -import pandas as pd - -from .account import Account - -def backtest(trade_strategy, trade_env, benchmark, account): - - trade_account = Account(init_cash=account) - trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) - trade_strategy.reset(start_time=start_time, end_time=end_time) - - trade_state = self.sub_env.get_init_state() - while not trade_env.finished(): - _order_list = self.sub_strategy.generate_order(**trade_state) - trade_state, trade_info = self.sub_env.execute(sub_order_list) - - report_df = trade_account.report.generate_report_dataframe() - positions = trade_account.get_positions() - report_dict = {"report_df": report_df, "positions": positions} - - return report_dict - diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py deleted file mode 100644 index 985cf92e8..000000000 --- a/qlib/backtest/exchange.py +++ /dev/null @@ -1,429 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import random -import logging - -import numpy as np -import pandas as pd - -from ..data import D -from ..utils import sample_feature -from .order import Order -from ..config import C, REG_CN -from ..log import get_module_logger - - -class Exchange: - def __init__( - self, - start_time=None, - end_time=None, - codes="all", - deal_price=None, - subscribe_fields=[], - limit_threshold=None, - open_cost=0.0015, - close_cost=0.0025, - trade_unit=None, - min_cost=5, - extra_quote=None, - ): - """__init__ - - :param start_time: start time for backtest - :param end_time: end time for backtest - :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) - :param deal_price: str, 'close', 'open', 'vwap' - :param subscribe_fields: list, subscribe fields - :param limit_threshold: float, 0.1 for example, default None - :param open_cost: cost rate for open, default 0.0015 - :param close_cost: cost rate for close, default 0.0025 - :param trade_unit: trade unit, 100 for China A market - :param min_cost: min cost, default 5 - :param extra_quote: pandas, dataframe consists of - columns: like ['$vwap', '$close', '$factor', 'limit']. - The limit indicates that the etf is tradable on a specific day. - Necessary fields: - $close is for calculating the total value at end of each day. - Optional fields: - $vwap is only necessary when we use the $vwap price as the deal price - $factor is for rounding to the trading unit - limit will be set to False by default(False indicates we can buy this - target on this day). - index: MultipleIndex(instrument, pd.Datetime) - """ - self.start_time = start_time - self.end_time = end_time - if trade_unit is None: - trade_unit = C.trade_unit - if limit_threshold is None: - limit_threshold = C.limit_threshold - if deal_price is None: - deal_price = C.deal_price - - self.logger = get_module_logger("online operator", level=logging.INFO) - - self.trade_unit = trade_unit - - # TODO: the quote, trade_dates, codes are not necessray. - # It is just for performance consideration. - if limit_threshold is None: - if C.region == REG_CN: - self.logger.warning(f"limit_threshold not set. The stocks hit the limit may be bought/sold") - elif abs(limit_threshold) > 0.1: - if C.region == REG_CN: - self.logger.warning(f"limit_threshold may not be set to a reasonable value") - - if deal_price[0] != "$": - self.deal_price = "$" + deal_price - else: - self.deal_price = deal_price - if isinstance(codes, str): - codes = D.instruments(codes) - self.codes = codes - # Necessary fields - # $close is for calculating the total value at end of each day. - # $factor is for rounding to the trading unit - # $change is for calculating the limit of the stock - - necessary_fields = {self.deal_price, "$close", "$change", "$factor"} - subscribe_fields = list(necessary_fields | set(subscribe_fields)) - all_fields = list(necessary_fields | set(subscribe_fields)) - self.all_fields = all_fields - self.open_cost = open_cost - self.close_cost = close_cost - self.min_cost = min_cost - self.limit_threshold = limit_threshold - - - self.extra_quote = extra_quote - self.set_quote(codes, start_time, end_time) - - def set_quote(self, codes, start_time, end_time): - if len(codes) == 0: - codes = D.instruments() - self.quote = D.features(codes, self.all_fields, start_time, end_time, disk_cache=True).dropna(subset=["$close"]) - self.quote.columns = self.all_fields - - if self.quote[self.deal_price].isna().any(): - self.logger.warning("{} field data contains nan.".format(self.deal_price)) - - if self.quote["$factor"].isna().any(): - # The 'factor.day.bin' file not exists, and `factor` field contains `nan` - # Use adjusted price - self.trade_w_adj_price = True - self.logger.warning("factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.") - else: - # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` - # Use normal price - self.trade_w_adj_price = False - # update limit - # check limit_threshold - if self.limit_threshold is None: - self.quote["limit"] = False - else: - # set limit - self._update_limit(buy_limit=self.limit_threshold, sell_limit=self.limit_threshold) - - quote_df = self.quote - if self.extra_quote is not None: - # process extra_quote - if "$close" not in self.extra_quote: - raise ValueError("$close is necessray in extra_quote") - if self.deal_price not in self.extra_quote.columns: - self.extra_quote[self.deal_price] = self.extra_quote["$close"] - self.logger.warning("No deal_price set for extra_quote. Use $close as deal_price.") - if "$factor" not in self.extra_quote.columns: - self.extra_quote["$factor"] = 1.0 - self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") - if "limit" not in self.extra_quote.columns: - self.extra_quote["limit"] = False - self.logger.warning("No limit set for extra_quote. All stock will be tradable.") - assert set(self.extra_quote.columns) == set(quote_df.columns) - {"$change"} - quote_df = pd.concat([quote_df, self.extra_quote], sort=False, axis=0) - - # update quote: pd.DataFrame to dict, for search use - self.quote = quote_df - - def _update_limit(self, buy_limit, sell_limit): - self.quote["limit"] = ~self.quote["$change"].between(-sell_limit, buy_limit, inclusive=False) - - def check_stock_limit(self, stock_id, start_time, end_time): - """Parameter - stock_id - trade_date - is limtited - """ - return sample_feature(self.quote, stock_id, start_time, end_time, fields="limit", method="any").iloc[0, 0] - - - def check_stock_suspended(self, stock_id, start_time, end_time): - # is suspended - return sample_feature(self.quote, stock_id, start_time, end_time).empty is False - - - def is_stock_tradable(self, stock_id, start_time, end_time): - # check if stock can be traded - # same as check in check_order - if self.check_stock_suspended(stock_id, start_time, end_time) or self.check_stock_limit(stock_id, start_time, end_time): - return False - else: - return True - - def check_order(self, order): - # check limit and suspended - if self.check_stock_suspended(order.stock_id, order.start_time, order.end_time) or self.check_stock_limit( - order.stock_id, order.start_time, order.end_time - ): - return False - else: - return True - - def deal_order(self, order, trade_account=None, position=None): - """ - Deal order when the actual transaction - - :param order: Deal the order. - :param trade_account: Trade account to be updated after dealing the order. - :param position: position to be updated after dealing the order. - :return: trade_val, trade_cost, trade_price - """ - # need to check order first - # TODO: check the order unit limit in the exchange!!!! - # The order limit is related to the adj factor and the cur_amount. - # factor = self.quote[(order.stock_id, order.trade_date)]['$factor'] - # cur_amount = trade_account.current.get_stock_amount(order.stock_id) - if self.check_order(order) is False: - raise AttributeError("need to check order first") - if trade_account is not None and position is not None: - raise ValueError("trade_account and position can only choose one") - - trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) - trade_val, trade_cost = self._calc_trade_info_by_order( - order, trade_account.current if trade_account else position - ) - # update account - if trade_val > 0: - # If the order can only be deal 0 trade_val. Nothing to be updated - # Otherwise, it will result some stock with 0 amount in the position - if trade_account: - trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) - elif position: - position.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) - - return trade_val, trade_cost, trade_price - - def get_quote_info(self, stock_id, start_time, end_time): - return sample_feature(self.quote, stock_id, start_time, end_time) - - def get_close(self, stock_id, start_time, end_time): - return sample_feature(self.quote, stock_id, start_time, end_time, fields="$close", method="last") - - def get_deal_price(self, stock_id, start_time, end_time): - deal_price = sample_feature(self.quote, stock_id, start_time, end_time, fields=self.deal_price, method="last") - deal_price = self.quote[(stock_id, trade_date)][self.deal_price] - if np.isclose(deal_price, 0.0) or np.isnan(deal_price): - self.logger.warning(f"(stock_id:{stock_id}, trade_date:{trade_date}, {self.deal_price}): {deal_price}!!!") - self.logger.warning(f"setting deal_price to close price") - deal_price = self.get_close(stock_id, start_time, end_time) - return deal_price - - def get_factor(self, stock_id, start_time, end_time): - return sample_feature(self.quote, stock_id, start_time, end_time, fields="$factor", method="last") - - def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): - """ - The generate the target position according to the weight and the cash. - NOTE: All the cash will assigned to the tadable stock. - - Parameter: - weight_position : dict {stock_id : weight}; allocate cash by weight_position - among then, weight must be in this range: 0 < weight < 1 - cash : cash - trade_date : trade date - """ - - # calculate the total weight of tradable value - tradable_weight = 0.0 - for stock_id in weight_position: - if self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): - # weight_position must be greater than 0 and less than 1 - if weight_position[stock_id] < 0 or weight_position[stock_id] > 1: - raise ValueError( - "weight_position is {}, " - "weight_position is not in the range of (0, 1).".format(weight_position[stock_id]) - ) - tradable_weight += weight_position[stock_id] - - if tradable_weight - 1.0 >= 1e-5: - raise ValueError("tradable_weight is {}, can not greater than 1.".format(tradable_weight)) - - amount_dict = {} - for stock_id in weight_position: - if weight_position[stock_id] > 0.0 and self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): - amount_dict[stock_id] = ( - cash - * weight_position[stock_id] - / tradable_weight - // self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) - ) - return amount_dict - - def get_real_deal_amount(self, current_amount, target_amount, factor): - """ - Calculate the real adjust deal amount when considering the trading unit - - :param current_amount: - :param target_amount: - :param factor: - :return real_deal_amount; Positive deal_amount indicates buying more stock. - """ - if current_amount == target_amount: - return 0 - elif current_amount < target_amount: - deal_amount = target_amount - current_amount - deal_amount = self.round_amount_by_trade_unit(deal_amount, factor) - return deal_amount - else: - if target_amount == 0: - return -current_amount - else: - deal_amount = current_amount - target_amount - deal_amount = self.round_amount_by_trade_unit(deal_amount, factor) - return -deal_amount - - def generate_order_for_target_amount_position(self, target_position, current_position, start_time, end_time): - """Parameter: - target_position : dict { stock_id : amount } - current_postion : dict { stock_id : amount} - trade_unit : trade_unit - down sample : for amount 321 and trade_unit 100, deal_amount is 300 - deal order on trade_date - """ - # split buy and sell for further use - buy_order_list = [] - sell_order_list = [] - # three parts: kept stock_id, dropped stock_id, new stock_id - # handle kept stock_id - - # because the order of the set is not fixed, the trading order of the stock is different, so that the backtest results of the same parameter are different; - # so here we sort stock_id, and then randomly shuffle the order of stock_id - # because the same random seed is used, the final stock_id order is fixed - sorted_ids = sorted(set(list(current_position.keys()) + list(target_position.keys()))) - random.seed(0) - random.shuffle(sorted_ids) - for stock_id in sorted_ids: - - # Do not generate order for the nontradable stocks - if not self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): - continue - - target_amount = target_position.get(stock_id, 0) - current_amount = current_position.get(stock_id, 0) - factor = self.get_factor(stock_id, start_time=start_time, end_time=end_time) - - deal_amount = self.get_real_deal_amount(current_amount, target_amount, factor) - if deal_amount == 0: - continue - elif deal_amount > 0: - # buy stock - buy_order_list.append( - Order( - stock_id=stock_id, - amount=deal_amount, - direction=Order.BUY, - start_time=start_time, - end_time=end_time, - factor=factor, - ) - ) - else: - # sell stock - sell_order_list.append( - Order( - stock_id=stock_id, - amount=abs(deal_amount), - direction=Order.SELL, - start_time=start_time, - end_time=end_time, - factor=factor, - ) - ) - # return order_list : buy + sell - return sell_order_list + buy_order_list - - def calculate_amount_position_value(self, amount_dict, start_time, end_time, only_tradable=False): - """Parameter - position : Position() - amount_dict : {stock_id : amount} - """ - value = 0 - for stock_id in amount_dict: - if ( - self.check_stock_suspended(stock_id=stock_id, start_time=start_time, end_time=end_time) is False - and self.check_stock_limit(stock_id=stock_id, start_time=start_time, end_time=end_time) is False - ): - value += self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) * amount_dict[stock_id] - return value - - def round_amount_by_trade_unit(self, deal_amount, factor): - """Parameter - deal_amount : float, adjusted amount - factor : float, adjusted factor - return : float, real amount - """ - if not self.trade_w_adj_price: - # the minimal amount is 1. Add 0.1 for solving precision problem. - return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor - return deal_amount - - def _calc_trade_info_by_order(self, order, position): - """ - Calculation of trade info - - :param order: - :param position: Position - :return: trade_val, trade_cost - """ - - trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) - if order.direction == Order.SELL: - # sell - if position is not None: - if np.isclose(order.amount, position.get_stock_amount(order.stock_id)): - # when selling last stock. The amount don't need rounding - order.deal_amount = order.amount - else: - order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - else: - # TODO: We don't know current position. - # We choose to sell all - order.deal_amount = order.amount - - trade_val = order.deal_amount * trade_price - trade_cost = max(trade_val * self.close_cost, self.min_cost) - elif order.direction == Order.BUY: - # buy - if position is not None: - cash = position.get_cash() - trade_val = order.amount * trade_price - if cash < trade_val * (1 + self.open_cost): - # The money is not enough - order.deal_amount = self.round_amount_by_trade_unit( - cash / (1 + self.open_cost) / trade_price, order.factor - ) - else: - # THe money is enough - order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - else: - # Unknown amount of money. Just round the amount - order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - - trade_val = order.deal_amount * trade_price - trade_cost = trade_val * self.open_cost - else: - raise NotImplementedError("order type {} error".format(order.type)) - - return trade_val, trade_cost diff --git a/qlib/backtest/import numpy as np b/qlib/backtest/import numpy as np deleted file mode 100644 index f558d3649..000000000 --- a/qlib/backtest/import numpy as np +++ /dev/null @@ -1,90 +0,0 @@ -class HighFreqOrderNorm(Processor): - def __init__(self, fit_start_time, fit_end_time, feature_save_dir, price_dim=5, order_price_dim=2, volume_dim=1, order_volume_dim=8, day_length=240): - self.fit_start_time = fit_start_time - self.fit_end_time = fit_end_time - self.price_dim = price_dim - self.volume_dim = volume_dim - self.order_price_dim = order_price_dim - self.order_volume_dim = order_volume_dim - self.feature_save_dir = feature_save_dir - self.day_length = day_length - self.names = dict() - column_dim = self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim - fields = [("price", self.price_dim), ("order_price", self.order_price_dim), ("volume", self.volume_dim), ("order_volume", self.order_volume_dim)] - last_dim = 0 - for field, field_dim in fields: - self.names[field] = list(range(last_dim, last_dim + field_dim)) + list((range(column_dim + last_dim, column_dim + last_dim + field_dim))) - last_dim += field_dim - - @profile - def fit(self, df_features): - # fetch_df = fetch_df_by_index(df_features, slice(self.fit_start_time, self.fit_end_time), level="datetime") - - - print("end") - if not os.path.exists(self.feature_save_dir): - os.makedirs(self.feature_save_dir) - for name, name_val in self.names.items(): - print(name) - df_values = df_features.iloc(axis=1)[name_val].values - if name == "volume" or name == "order_volume": - df_values = np.log1p(df_values) - self.feature_med = np.nanmedian(df_values) - np.save(self.feature_save_dir + name + "_med.npy", self.feature_med) - df_values = df_values - self.feature_med - self.feature_std = np.nanmedian(np.absolute(df_values)) * 1.4826 + 1e-12 - np.save(self.feature_save_dir + name + "_std.npy", self.feature_std) - df_values = df_values / self.feature_std - np.save(self.feature_save_dir + name + "_vmax.npy", np.nanmax(df_values)) - np.save(self.feature_save_dir + name + "_vmin.npy", np.nanmin(df_values)) - - - def __call__(self, df_features): - df_features.set_index("date", append=True, drop=True, inplace=True) - df_values = df_features.values - df_values_dict = dict() - for name, name_val in self.names.items(): - self.feature_med = np.load(self.feature_save_dir + name + "_med.npy") - self.feature_std = np.load(self.feature_save_dir + name + "_std.npy") - self.feature_vmax = np.load(self.feature_save_dir + name + "_vmax.npy") - self.feature_vmin = np.load(self.feature_save_dir + name + "_vmin.npy") - - df_values = df_features.iloc(axis=1)[name_val].values - if name == "volume" or name == "order_volume": - df_values[:] = np.log1p(df_values) - df_values[:] -= self.feature_med - df_values[:] /= self.feature_std - slice0 = df_values > 3.0 - slice1 = df_values > 3.5 - slice2 = df_values < -3.0 - slice3 = df_values < -3.5 - - df_values[slice0] = ( - 3.0 + (df_values[slice0] - 3.0) / (self.feature_vmax - 3) * 0.5 - ) - df_values[slice1] = 3.5 - df_values[slice2] = ( - -3.0 - (df_values[slice2] + 3.0) / (self.feature_vmin + 3) * 0.5 - ) - df_values[slice3] = -3.5 - df_values_dict[name] = df_values - - idx = df_features.index.droplevel("datetime").drop_duplicates() - idx.set_names(["instrument", "datetime"], inplace=True) - - # Reshape is specifically for adapting to RL high-freq executor - feat = df_values[:, list(range(self.price_dim)) + list(range(self.price_dim * 2, self.price_dim * 2 + self.order_price_dim)) - + list(range((self.price_dim + self.order_price_dim) * 2, (self.price_dim + self.order_price_dim) * 2 + self.volume_dim)) - + list(range((self.price_dim + self.order_price_dim + self.volume_dim) * 2, (self.price_dim + self.order_price_dim + self.volume_dim) * 2 + self.order_volume_dim)) - ].reshape(-1, (self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim) * self.day_length) - - feat_1 = df_values[:, list(np.arange(self.price_dim) + self.price_dim) + list(np.arange(self.price_dim * 2, self.price_dim * 2 + self.order_price_dim) + self.order_price_dim) - + list(np.arange((self.price_dim + self.order_price_dim) * 2, (self.price_dim + self.order_price_dim) * 2 + self.volume_dim) + self.volume_dim) - + list(np.arange((self.price_dim + self.order_price_dim + self.volume_dim) * 2, (self.price_dim + self.order_price_dim + self.volume_dim) * 2 + self.order_volume_dim) + self.order_volume_dim) - ].reshape(-1, (self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim) * self.day_length) - df_new_features = pd.DataFrame( - data=np.concatenate((feat, feat_1), axis=1), - index=idx, - columns=range(2 * (self.price_dim + self.order_price_dim + self.volume_dim + self.order_volume_dim) * self.day_length), - ).sort_index() - return df_new_features \ No newline at end of file diff --git a/qlib/backtest/init.py b/qlib/backtest/init.py deleted file mode 100644 index 06dd437db..000000000 --- a/qlib/backtest/init.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from .order import Order -from .account import Account -from .position import Position -from .exchange import Exchange -from .report import Report -from .backtest import backtest as backtest_func, get_date_range - -import copy -import numpy as np -import inspect -from ..utils import init_instance_by_config -from ..log import get_module_logger -from ..config import C - -logger = get_module_logger("backtest caller") - - -def init_env_instance_by_config(env): - if isinstance(env, dict): - env_config = copy.copy(env) - if "kwargs" in env_config: - env_kwargs = copy.copy(env_config["kwargs"]): - if "sub_env" in env_kwargs: - env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) - if "sub_strategy" in env_kwargs: - env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) - env_config["kwargs"] = env_kwargs - return init_instance_by_config(env_config) - else: - return env - - -def get_exchange( - exchange=None, - start_time=None, - end_time=None, - codes = "all", - subscribe_fields=[], - open_cost=0.0015, - close_cost=0.0025, - min_cost=5.0, - trade_unit=None, - limit_threshold=None, - deal_price=None, - shift=1, -): - """get_exchange - - Parameters - ---------- - - # exchange related arguments - exchange: Exchange(). - subscribe_fields: list - subscribe fields. - open_cost : float - open transaction cost. - close_cost : float - close transaction cost. - min_cost : float - min transaction cost. - trade_unit : int - 100 for China A. - deal_price: str - dealing price type: 'close', 'open', 'vwap'. - limit_threshold : float - limit move 0.1 (10%) for example, long and short with same limit. - - Returns - ------- - :class: Exchange - an initialized Exchange object - """ - - if trade_unit is None: - trade_unit = C.trade_unit - if limit_threshold is None: - limit_threshold = C.limit_threshold - if deal_price is None: - deal_price = C.deal_price - if exchange is None: - logger.info("Create new exchange") - # handle exception for deal_price - if deal_price[0] != "$": - deal_price = "$" + deal_price - - exchange = Exchange( - start_time=start_time, - end_time=end_time, - codes=codes, - deal_price=deal_price, - subscribe_fields=subscribe_fields, - limit_threshold=limit_threshold, - open_cost=open_cost, - close_cost=close_cost, - trade_unit=trade_unit, - min_cost=min_cost, - ) - else: - return init_instance_by_config(exchange, accept_types=Exchange) - -def backtest(start_time, end_time, strategy, env, account=1e9, **kwargs): - trade_strategy = init_instance_by_config(strategy) - trade_env = init_env_instance_by_config(env) - trade_account = Account(init_cash=account) - - spec = inspect.getfullargspec(get_exchange) - ex_args = {k: v for k, v in kwargs.items() if k in spec.args} - trade_exchange = get_exchange(pred, **ex_args) - -# temp_env = trade_env -# while True: -# if hasattr(temp_env, "trade_exchange"): -# temp_env.reset(trade_exchange=trade_exchange) -# if hasattr(temp_env, "sub_env"): -# temp_env = temp_env.sub_env -# else: -# break - - trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) - trade_state, _reset_info = self.sub_env.get_first_state() - trade_strategy.reset(**_reset_info) - - - while not trade_env.finished(): - _order_list = self.sub_strategy.generate_order(**trade_state) - trade_state, trade_info = self.sub_env.execute(sub_order_list) - - return diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py deleted file mode 100644 index 0d637d9db..000000000 --- a/qlib/backtest/order.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -class Order: - - SELL = 0 - BUY = 1 - - def __init__(self, stock_id, amount, start_time, end_time, direction, factor): - """Parameter - direction : Order.SELL for sell; Order.BUY for buy - stock_id : str - amount : float - trade_date : pd.Timestamp - factor : float - presents the weight factor assigned in Exchange() - """ - # check direction - if direction not in {Order.SELL, Order.BUY}: - raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") - self.stock_id = stock_id - # amount of generated orders - self.amount = amount - # amount of successfully completed orders - self.deal_amount = 0 - self.start_time = start_time - self.end_time = end_time - self.direction = direction - self.factor = factor diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py deleted file mode 100644 index 9945a7e8f..000000000 --- a/qlib/backtest/position.py +++ /dev/null @@ -1,217 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import pandas as pd -import copy -import pathlib -from .order import Order - -""" -Position module -""" - -""" -current state of position -a typical example is :{ - : { - 'count': , - 'amount': , - 'price': , - 'weight': , - }, -} - -""" - - -class Position: - """Position""" - - def __init__(self, cash=0, position_dict={}, today_account_value=0): - # NOTE: The position dict must be copied!!! - # Otherwise the initial value - self.init_cash = cash - self.position = position_dict.copy() - self.position["cash"] = cash - self.position["today_account_value"] = today_account_value - - def init_stock(self, stock_id, amount, price=None): - self.position[stock_id] = {} - self.position[stock_id]["count"] = 0 # update count in the end of this date - self.position[stock_id]["amount"] = amount - self.position[stock_id]["price"] = price - self.position[stock_id]["weight"] = 0 # update the weight in the end of the trade date - - def buy_stock(self, stock_id, trade_val, cost, trade_price): - trade_amount = trade_val / trade_price - if stock_id not in self.position: - self.init_stock(stock_id=stock_id, amount=trade_amount, price=trade_price) - else: - # exist, add amount - self.position[stock_id]["amount"] += trade_amount - - self.position["cash"] -= trade_val + cost - - def sell_stock(self, stock_id, trade_val, cost, trade_price): - trade_amount = trade_val / trade_price - if stock_id not in self.position: - raise KeyError("{} not in current position".format(stock_id)) - else: - # decrease the amount of stock - self.position[stock_id]["amount"] -= trade_amount - # check if to delete - if self.position[stock_id]["amount"] < -1e-5: - raise ValueError( - "only have {} {}, require {}".format(self.position[stock_id]["amount"], stock_id, trade_amount) - ) - elif abs(self.position[stock_id]["amount"]) <= 1e-5: - self.del_stock(stock_id) - - self.position["cash"] += trade_val - cost - - def del_stock(self, stock_id): - del self.position[stock_id] - - def update_order(self, order, trade_val, cost, trade_price): - # handle order, order is a order class, defined in exchange.py - if order.direction == Order.BUY: - # BUY - self.buy_stock(order.stock_id, trade_val, cost, trade_price) - elif order.direction == Order.SELL: - # SELL - self.sell_stock(order.stock_id, trade_val, cost, trade_price) - else: - raise NotImplementedError("do not support order direction {}".format(order.direction)) - - def update_stock_price(self, stock_id, price): - self.position[stock_id]["price"] = price - - def update_stock_count(self, stock_id, count): - self.position[stock_id]["count"] = count - - def update_stock_weight(self, stock_id, weight): - self.position[stock_id]["weight"] = weight - - def update_cash(self, cash): - self.position["cash"] = cash - - def calculate_stock_value(self): - stock_list = self.get_stock_list() - value = 0 - for stock_id in stock_list: - value += self.position[stock_id]["amount"] * self.position[stock_id]["price"] - return value - - def calculate_value(self): - value = self.calculate_stock_value() - value += self.position["cash"] - return value - - def get_stock_list(self): - stock_list = list(set(self.position.keys()) - {"cash", "today_account_value"}) - return stock_list - - def get_stock_price(self, code): - return self.position[code]["price"] - - def get_stock_amount(self, code): - return self.position[code]["amount"] - - def get_stock_count(self, code): - return self.position[code]["count"] - - def get_stock_weight(self, code): - return self.position[code]["weight"] - - def get_cash(self): - return self.position["cash"] - - def get_stock_amount_dict(self): - """generate stock amount dict {stock_id : amount of stock} """ - d = {} - stock_list = self.get_stock_list() - for stock_code in stock_list: - d[stock_code] = self.get_stock_amount(code=stock_code) - return d - - def get_stock_weight_dict(self, only_stock=False): - """get_stock_weight_dict - generate stock weight fict {stock_id : value weight of stock in the position} - it is meaningful in the beginning or the end of each trade date - - :param only_stock: If only_stock=True, the weight of each stock in total stock will be returned - If only_stock=False, the weight of each stock in total assets(stock + cash) will be returned - """ - if only_stock: - position_value = self.calculate_stock_value() - else: - position_value = self.calculate_value() - d = {} - stock_list = self.get_stock_list() - for stock_code in stock_list: - d[stock_code] = self.position[stock_code]["amount"] * self.position[stock_code]["price"] / position_value - return d - - def add_count_all(self): - stock_list = self.get_stock_list() - for code in stock_list: - self.position[code]["count"] += 1 - - def update_weight_all(self): - weight_dict = self.get_stock_weight_dict() - for stock_code, weight in weight_dict.items(): - self.update_stock_weight(stock_code, weight) - - def save_position(self, path, last_trade_time): - path = pathlib.Path(path) - p = copy.deepcopy(self.position) - cash = pd.Series(dtype=np.float) - cash["init_cash"] = self.init_cash - cash["cash"] = p["cash"] - cash["today_account_value"] = p["today_account_value"] - cash["last_trade_start_time"] = str(last_trade_time[0]) if last_trade_time else None - cash["last_trade_end_time"] = str(last_trade_time[1]) if last_trade_time else None - del p["cash"] - del p["today_account_value"] - positions = pd.DataFrame.from_dict(p, orient="index") - with pd.ExcelWriter(path) as writer: - positions.to_excel(writer, sheet_name="position") - cash.to_excel(writer, sheet_name="info") - - def load_position(self, path): - """load position information from a file - should have format below - sheet "position" - columns: ['stock', 'count', 'amount', 'price', 'weight'] - 'count': , - 'amount': , - 'price': , - 'weight': , - - sheet "cash" - index: ['init_cash', 'cash', 'today_account_value'] - 'init_cash': , - 'cash': , - 'today_account_value': - """ - path = pathlib.Path(path) - positions = pd.read_excel(open(path, "rb"), sheet_name="position", index_col=0) - cash_record = pd.read_excel(open(path, "rb"), sheet_name="info", index_col=0) - positions = positions.to_dict(orient="index") - init_cash = cash_record.loc["init_cash"].values[0] - cash = cash_record.loc["cash"].values[0] - today_account_value = cash_record.loc["today_account_value"].values[0] - last_trade_start_time = cash_record.loc["last_trade_start_time"].values[0] - last_trade_end_time = cash_record.loc["last_trade_end_time"].values[0] - - # assign values - self.position = {} - self.init_cash = init_cash - self.position = positions - self.position["cash"] = cash - self.position["today_account_value"] = today_account_value - - last_trade_start_time = None is pd.isna(last_trade_start_time) else pd.Timestamp(last_trade_start_time) - last_trade_end_time = None is pd.isna(last_trade_end_time) else pd.Timestamp(last_trade_end_time) - return last_trade_start_time, last_trade_end_time diff --git a/qlib/backtest/profit_attribution.py b/qlib/backtest/profit_attribution.py deleted file mode 100644 index 20c6f638f..000000000 --- a/qlib/backtest/profit_attribution.py +++ /dev/null @@ -1,324 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import numpy as np -import pandas as pd -from .position import Position -from ...data import D -from ...config import C -import datetime -from pathlib import Path - - -def get_benchmark_weight( - bench, - start_date=None, - end_date=None, - path=None, -): - """get_benchmark_weight - - get the stock weight distribution of the benchmark - - :param bench: - :param start_date: - :param end_date: - :param path: - - :return: The weight distribution of the the benchmark described by a pandas dataframe - Every row corresponds to a trading day. - Every column corresponds to a stock. - Every cell represents the strategy. - - """ - if not path: - path = Path(C.get_data_path()).expanduser() / "raw" / "AIndexMembers" / "weights.csv" - # TODO: the storage of weights should be implemented in a more elegent way - # TODO: The benchmark is not consistant with the filename in instruments. - bench_weight_df = pd.read_csv(path, usecols=["code", "date", "index", "weight"]) - bench_weight_df = bench_weight_df[bench_weight_df["index"] == bench] - bench_weight_df["date"] = pd.to_datetime(bench_weight_df["date"]) - if start_date is not None: - bench_weight_df = bench_weight_df[bench_weight_df.date >= start_date] - if end_date is not None: - bench_weight_df = bench_weight_df[bench_weight_df.date <= end_date] - bench_stock_weight = bench_weight_df.pivot_table(index="date", columns="code", values="weight") / 100.0 - return bench_stock_weight - - -def get_stock_weight_df(positions): - """get_stock_weight_df - :param positions: Given a positions from backtest result. - :return: A weight distribution for the position - """ - stock_weight = [] - index = [] - for date in sorted(positions.keys()): - pos = positions[date] - if isinstance(pos, dict): - pos = Position(position_dict=pos) - index.append(date) - stock_weight.append(pos.get_stock_weight_dict(only_stock=True)) - return pd.DataFrame(stock_weight, index=index) - - -def decompose_portofolio_weight(stock_weight_df, stock_group_df): - """decompose_portofolio_weight - - ''' - :param stock_weight_df: a pandas dataframe to describe the portofolio by weight. - every row corresponds to a day - every column corresponds to a stock. - Here is an example below. - code SH600004 SH600006 SH600017 SH600022 SH600026 SH600037 \ - date - 2016-01-05 0.001543 0.001570 0.002732 0.001320 0.003000 NaN - 2016-01-06 0.001538 0.001569 0.002770 0.001417 0.002945 NaN - .... - :param stock_group_df: a pandas dataframe to describe the stock group. - every row corresponds to a day - every column corresponds to a stock. - the value in the cell repreponds the group id. - Here is a example by for stock_group_df for industry. The value is the industry code - instrument SH600000 SH600004 SH600005 SH600006 SH600007 SH600008 \ - datetime - 2016-01-05 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 - 2016-01-06 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 - ... - :return: Two dict will be returned. The group_weight and the stock_weight_in_group. - The key is the group. The value is a Series or Dataframe to describe the weight of group or weight of stock - """ - all_group = np.unique(stock_group_df.values.flatten()) - all_group = all_group[~np.isnan(all_group)] - - group_weight = {} - stock_weight_in_group = {} - for group_key in all_group: - group_mask = stock_group_df == group_key - group_weight[group_key] = stock_weight_df[group_mask].sum(axis=1) - stock_weight_in_group[group_key] = stock_weight_df[group_mask].divide(group_weight[group_key], axis=0) - return group_weight, stock_weight_in_group - - -def decompose_portofolio(stock_weight_df, stock_group_df, stock_ret_df): - """ - :param stock_weight_df: a pandas dataframe to describe the portofolio by weight. - every row corresponds to a day - every column corresponds to a stock. - Here is an example below. - code SH600004 SH600006 SH600017 SH600022 SH600026 SH600037 \ - date - 2016-01-05 0.001543 0.001570 0.002732 0.001320 0.003000 NaN - 2016-01-06 0.001538 0.001569 0.002770 0.001417 0.002945 NaN - 2016-01-07 0.001555 0.001546 0.002772 0.001393 0.002904 NaN - 2016-01-08 0.001564 0.001527 0.002791 0.001506 0.002948 NaN - 2016-01-11 0.001597 0.001476 0.002738 0.001493 0.003043 NaN - .... - - :param stock_group_df: a pandas dataframe to describe the stock group. - every row corresponds to a day - every column corresponds to a stock. - the value in the cell repreponds the group id. - Here is a example by for stock_group_df for industry. The value is the industry code - instrument SH600000 SH600004 SH600005 SH600006 SH600007 SH600008 \ - datetime - 2016-01-05 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 - 2016-01-06 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 - 2016-01-07 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 - 2016-01-08 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 - 2016-01-11 801780.0 801170.0 801040.0 801880.0 801180.0 801160.0 - ... - - :param stock_ret_df: a pandas dataframe to describe the stock return. - every row corresponds to a day - every column corresponds to a stock. - the value in the cell repreponds the return of the group. - Here is a example by for stock_ret_df. - instrument SH600000 SH600004 SH600005 SH600006 SH600007 SH600008 \ - datetime - 2016-01-05 0.007795 0.022070 0.099099 0.024707 0.009473 0.016216 - 2016-01-06 -0.032597 -0.075205 -0.098361 -0.098985 -0.099707 -0.098936 - 2016-01-07 -0.001142 0.022544 0.100000 0.004225 0.000651 0.047226 - 2016-01-08 -0.025157 -0.047244 -0.038567 -0.098177 -0.099609 -0.074408 - 2016-01-11 0.023460 0.004959 -0.034384 0.018663 0.014461 0.010962 - ... - - :return: It will decompose the portofolio to the group weight and group return. - """ - all_group = np.unique(stock_group_df.values.flatten()) - all_group = all_group[~np.isnan(all_group)] - - group_weight, stock_weight_in_group = decompose_portofolio_weight(stock_weight_df, stock_group_df) - - group_ret = {} - for group_key in stock_weight_in_group: - stock_weight_in_group_start_date = min(stock_weight_in_group[group_key].index) - stock_weight_in_group_end_date = max(stock_weight_in_group[group_key].index) - - temp_stock_ret_df = stock_ret_df[ - (stock_ret_df.index >= stock_weight_in_group_start_date) - & (stock_ret_df.index <= stock_weight_in_group_end_date) - ] - - group_ret[group_key] = (temp_stock_ret_df * stock_weight_in_group[group_key]).sum(axis=1) - # If no weight is assigned, then the return of group will be np.nan - group_ret[group_key][group_weight[group_key] == 0.0] = np.nan - - group_weight_df = pd.DataFrame(group_weight) - group_ret_df = pd.DataFrame(group_ret) - return group_weight_df, group_ret_df - - -def get_daily_bin_group(bench_values, stock_values, group_n): - """get_daily_bin_group - Group the values of the stocks of benchmark into several bins in a day. - Put the stocks into these bins. - - :param bench_values: A series contains the value of stocks in benchmark. - The index is the stock code. - :param stock_values: A series contains the value of stocks of your portofolio - The index is the stock code. - :param group_n: Bins will be produced - - :return: A series with the same size and index as the stock_value. - The value in the series is the group id of the bins. - The No.1 bin contains the biggest values. - """ - stock_group = stock_values.copy() - - # get the bin split points based on the daily proportion of benchmark - split_points = np.percentile(bench_values[~bench_values.isna()], np.linspace(0, 100, group_n + 1)) - # Modify the biggest uppper bound and smallest lowerbound - split_points[0], split_points[-1] = -np.inf, np.inf - for i, (lb, up) in enumerate(zip(split_points, split_points[1:])): - stock_group.loc[stock_values[(stock_values >= lb) & (stock_values < up)].index] = group_n - i - return stock_group - - -def get_stock_group(stock_group_field_df, bench_stock_weight_df, group_method, group_n=None): - if group_method == "category": - # use the value of the benchmark as the category - return stock_group_field_df - elif group_method == "bins": - assert group_n is not None - # place the values into `group_n` fields. - # Each bin corresponds to a category. - new_stock_group_df = stock_group_field_df.copy().loc[ - bench_stock_weight_df.index.min() : bench_stock_weight_df.index.max() - ] - for idx, row in (~bench_stock_weight_df.isna()).iterrows(): - bench_values = stock_group_field_df.loc[idx, row[row].index] - new_stock_group_df.loc[idx] = get_daily_bin_group( - bench_values, stock_group_field_df.loc[idx], group_n=group_n - ) - return new_stock_group_df - - -def brinson_pa( - positions, - bench="SH000905", - group_field="industry", - group_method="category", - group_n=None, - deal_price="vwap", -): - """brinson profit attribution - - :param positions: The position produced by the backtest class - :param bench: The benchmark for comparing. TODO: if no benchmark is set, the equal-weighted is used. - :param group_field: The field used to set the group for assets allocation. - `industry` and `market_value` is often used. - :param group_method: 'category' or 'bins'. The method used to set the group for asstes allocation - `bin` will split the value into `group_n` bins and each bins represents a group - :param group_n: . Only used when group_method == 'bins'. - - :return: - A dataframe with three columns: RAA(excess Return of Assets Allocation), RSS(excess Return of Stock Selectino), RTotal(Total excess Return) - Every row corresponds to a trading day, the value corresponds to the next return for this trading day - The middle info of brinson profit attribution - """ - # group_method will decide how to group the group_field. - dates = sorted(positions.keys()) - - start_date, end_date = min(dates), max(dates) - - bench_stock_weight = get_benchmark_weight(bench, start_date, end_date) - - # The attributes for allocation will not - if not group_field.startswith("$"): - group_field = "$" + group_field - if not deal_price.startswith("$"): - deal_price = "$" + deal_price - - # FIXME: In current version. Some attributes(such as market_value) of some - # suspend stock is NAN. So we have to get more date to forward fill the NAN - shift_start_date = start_date - datetime.timedelta(days=250) - instruments = D.list_instruments( - D.instruments(market="all"), - start_time=shift_start_date, - end_time=end_date, - as_list=True, - ) - stock_df = D.features( - instruments, - [group_field, deal_price], - start_time=shift_start_date, - end_time=end_date, - freq="day", - ) - stock_df.columns = [group_field, "deal_price"] - - stock_group_field = stock_df[group_field].unstack().T - # FIXME: some attributes of some suspend stock is NAN. - stock_group_field = stock_group_field.fillna(method="ffill") - stock_group_field = stock_group_field.loc[start_date:end_date] - - stock_group = get_stock_group(stock_group_field, bench_stock_weight, group_method, group_n) - - deal_price_df = stock_df["deal_price"].unstack().T - deal_price_df = deal_price_df.fillna(method="ffill") - - # NOTE: - # The return will be slightly different from the of the return in the report. - # Here the position are adjusted at the end of the trading day with close - stock_ret = (deal_price_df - deal_price_df.shift(1)) / deal_price_df.shift(1) - stock_ret = stock_ret.shift(-1).loc[start_date:end_date] - - port_stock_weight_df = get_stock_weight_df(positions) - - # decomposing the portofolio - port_group_weight_df, port_group_ret_df = decompose_portofolio(port_stock_weight_df, stock_group, stock_ret) - bench_group_weight_df, bench_group_ret_df = decompose_portofolio(bench_stock_weight, stock_group, stock_ret) - - # if the group return of the portofolio is NaN, replace it with the market - # value - mod_port_group_ret_df = port_group_ret_df.copy() - mod_port_group_ret_df[mod_port_group_ret_df.isna()] = bench_group_ret_df - - Q1 = (bench_group_weight_df * bench_group_ret_df).sum(axis=1) - Q2 = (port_group_weight_df * bench_group_ret_df).sum(axis=1) - Q3 = (bench_group_weight_df * mod_port_group_ret_df).sum(axis=1) - Q4 = (port_group_weight_df * mod_port_group_ret_df).sum(axis=1) - - return ( - pd.DataFrame( - { - "RAA": Q2 - Q1, # The excess profit from the assets allocation - "RSS": Q3 - Q1, # The excess profit from the stocks selection - # The excess profit from the interaction of assets allocation and stocks selection - "RIN": Q4 - Q3 - Q2 + Q1, - "RTotal": Q4 - Q1, # The totoal excess profit - } - ), - { - "port_group_ret": port_group_ret_df, - "port_group_weight": port_group_weight_df, - "bench_group_ret": bench_group_ret_df, - "bench_group_weight": bench_group_weight_df, - "stock_group": stock_group, - "bench_stock_weight": bench_stock_weight, - "port_stock_weight": port_stock_weight_df, - "stock_ret": stock_ret, - }, - ) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py deleted file mode 100644 index 9a57156f2..000000000 --- a/qlib/backtest/report.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -from collections import OrderedDict -import pandas as pd -import pathlib - - -class Report: - # daily report of the account - # contain those followings: returns, costs turnovers, accounts, cash, bench, value - # update report - def __init__(self): - self.init_vars() - - def init_vars(self): - self.accounts = OrderedDict() # account postion value for each trade date - self.returns = OrderedDict() # daily return rate for each trade date - self.turnovers = OrderedDict() # turnover for each trade date - self.costs = OrderedDict() # trade cost for each trade date - self.values = OrderedDict() # value for each trade date - self.cashes = OrderedDict() - self.latest_report_time = None # pd.TimeStamp - - def is_empty(self): - return len(self.accounts) == 0 - - def get_latest_date(self): - return self.latest_report_time - - def get_latest_account_value(self): - return self.accounts[self.latest_report_time] - - def update_report_record( - self, - trade_time=None, - account_value=None, - cash=None, - return_rate=None, - turnover_rate=None, - cost_rate=None, - stock_value=None, - ): - # check data - if None in [ - trade_time, - account_value, - cash, - return_rate, - turnover_rate, - cost_rate, - stock_value, - ]: - raise ValueError( - "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value]" - ) - # update report data - self.accounts[trade_time] = account_value - self.returns[trade_time] = return_rate - self.turnovers[trade_time] = turnover_rate - self.costs[trade_time] = cost_rate - self.values[trade_time] = stock_value - self.cashes[trade_time] = cash - # update latest_report_date - self.latest_report_time = trade_time - # finish daily report update - - def generate_report_dataframe(self): - report = pd.DataFrame() - report["account"] = pd.Series(self.accounts) - report["return"] = pd.Series(self.returns) - report["turnover"] = pd.Series(self.turnovers) - report["cost"] = pd.Series(self.costs) - report["value"] = pd.Series(self.values) - report["cash"] = pd.Series(self.cashes) - report.index.name = "trade_time" - return report - - def save_report(self, path): - r = self.generate_report_dataframe() - r.to_csv(path) - - def load_report(self, path): - """load report from a file - should have format like - columns = ['account', 'return', 'turnover', 'cost', 'value', 'cash'] - :param - path: str/ pathlib.Path() - """ - path = pathlib.Path(path) - r = pd.read_csv(open(path, "rb"), index_col=0) - r.index = pd.DatetimeIndex(r.index) - - index = r.index - self.init_vars() - for trade_time in index: - self.update_report_record( - trade_time=trade_time, - account_value=r.loc[trade_time]["account"], - cash=r.loc[trade_time]["cash"], - return_rate=r.loc[trade_time]["return"], - turnover_rate=r.loc[trade_time]["turnover"], - cost_rate=r.loc[trade_time]["cost"], - stock_value=r.loc[trade_time]["value"], - ) diff --git a/qlib/contrib/backtest/__init__.py b/qlib/contrib/backtest/__init__.py index aa24ffb0c..8796d0057 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/contrib/backtest/__init__.py @@ -2,12 +2,12 @@ # Licensed under the MIT License. from .order import Order -from .account import Account from .position import Position from .exchange import Exchange from .report import Report -from .backtest import backtest as backtest_func, get_date_range +from .backtest import backtest as backtest_func +import copy import numpy as np import inspect from ...utils import init_instance_by_config @@ -17,86 +17,11 @@ from ...config import C logger = get_module_logger("backtest caller") -def get_strategy( - strategy=None, - topk=50, - margin=0.5, - n_drop=5, - risk_degree=0.95, - str_type="dropout", - adjust_dates=None, -): - """get_strategy - - There will be 3 ways to return a stratgy. Please follow the code. - - - Parameters - ---------- - - strategy : Strategy() - strategy used in backtest. - topk : int (Default value: 50) - top-N stocks to buy. - margin : int or float(Default value: 0.5) - - if isinstance(margin, int): - - sell_limit = margin - - - else: - - sell_limit = pred_in_a_day.count() * margin - - buffer margin, in single score_mode, continue holding stock if it is in nlargest(sell_limit). - sell_limit should be no less than topk. - n_drop : int - number of stocks to be replaced in each trading date. - risk_degree: float - 0-1, 0.95 for example, use 95% money to trade. - str_type: 'amount', 'weight' or 'dropout' - strategy type: TopkAmountStrategy ,TopkWeightStrategy or TopkDropoutStrategy. - - Returns - ------- - :class: Strategy - an initialized strategy object - """ - - # There will be 3 ways to return a strategy. - if strategy is None: - # 1) create strategy with param `strategy` - str_cls_dict = { - "amount": "TopkAmountStrategy", - "weight": "TopkWeightStrategy", - "dropout": "TopkDropoutStrategy", - } - logger.info("Create new strategy ") - from .. import strategy as strategy_pool - - str_cls = getattr(strategy_pool, str_cls_dict.get(str_type)) - strategy = str_cls( - topk=topk, - buffer_margin=margin, - n_drop=n_drop, - risk_degree=risk_degree, - adjust_dates=adjust_dates, - ) - elif isinstance(strategy, (dict, str)): - # 2) create strategy with init_instance_by_config - logger.info("Create new strategy ") - strategy = init_instance_by_config(strategy) - - from ..strategy.strategy import BaseStrategy - - # else: nothing happens. 3) Use the strategy directly - if not isinstance(strategy, BaseStrategy): - raise TypeError("Strategy not supported") - return strategy - - def get_exchange( - pred, exchange=None, + start_time=None, + end_time=None, + codes = "all", subscribe_fields=[], open_cost=0.0015, close_cost=0.0025, @@ -104,7 +29,6 @@ def get_exchange( trade_unit=None, limit_threshold=None, deal_price=None, - extract_codes=False, shift=1, ): """get_exchange @@ -128,9 +52,6 @@ def get_exchange( dealing price type: 'close', 'open', 'vwap'. limit_threshold : float limit move 0.1 (10%) for example, long and short with same limit. - extract_codes: bool - will we pass the codes extracted from the pred to the exchange. - NOTE: This will be faster with offline qlib. Returns ------- @@ -149,176 +70,61 @@ def get_exchange( # handle exception for deal_price if deal_price[0] != "$": deal_price = "$" + deal_price - if extract_codes: - codes = sorted(pred.index.get_level_values("instrument").unique()) - else: - codes = "all" # TODO: We must ensure that 'all.txt' includes all the stocks - - dates = sorted(pred.index.get_level_values("datetime").unique()) - dates = np.append(dates, get_date_range(dates[-1], left_shift=1, right_shift=shift)) exchange = Exchange( - trade_dates=dates, + start_time=start_time, + end_time=end_time, codes=codes, deal_price=deal_price, subscribe_fields=subscribe_fields, limit_threshold=limit_threshold, open_cost=open_cost, close_cost=close_cost, - min_cost=min_cost, trade_unit=trade_unit, + min_cost=min_cost, ) - return exchange + return exchange + else: + return init_instance_by_config(exchange, accept_types=Exchange) +def init_env_instance_by_config(env): + if isinstance(env, dict): + env_config = copy.copy(env) + if "kwargs" in env_config: + env_kwargs = copy.copy(env_config["kwargs"]) + if "sub_env" in env_kwargs: + env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) + if "sub_strategy" in env_kwargs: + env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) + env_config["kwargs"] = env_kwargs + return init_instance_by_config(env_config) + else: + return env -def get_executor( - executor=None, - trade_exchange=None, - verbose=True, -): - """get_executor +def setup_exchange(root_instance, trade_exchange=None, force=False): + if "trade_exchange" in inspect.getfullargspec(root_instance.__class__).args: + if force: + root_instance.reset(trade_exchange=trade_exchange) + else: + if not hasattr(root_instance, "trade_exchange") or root_instance.trade_exchange is None: + root_instance.reset(trade_exchange=trade_exchange) + if hasattr(root_instance, "sub_env"): + setup_exchange(root_instance.sub_env, trade_exchange) + if hasattr(root_instance, "sub_strategy"): + setup_exchange(root_instance.sub_strategy, trade_exchange) + + +def backtest(start_time, end_time, strategy, env, benchmark=None, account=1e9, **kwargs): + trade_strategy = init_instance_by_config(strategy) + trade_env = init_env_instance_by_config(env) - There will be 3 ways to return a executor. Please follow the code. - - Parameters - ---------- - - executor : BaseExecutor - executor used in backtest. - trade_exchange : Exchange - exchange used in executor - verbose : bool - whether to print log. - - Returns - ------- - :class: BaseExecutor - an initialized BaseExecutor object - """ - - # There will be 3 ways to return a executor. - if executor is None: - # 1) create executor with param `executor` - logger.info("Create new executor ") - from ..online.executor import SimulatorExecutor - - executor = SimulatorExecutor(trade_exchange=trade_exchange, verbose=verbose) - elif isinstance(executor, (dict, str)): - # 2) create executor with config - logger.info("Create new executor ") - executor = init_instance_by_config(executor) - - from ..online.executor import BaseExecutor - - # 3) Use the executor directly - if not isinstance(executor, BaseExecutor): - raise TypeError("Executor not supported") - return executor - - -# This is the API for compatibility for legacy code -def backtest(pred, account=1e9, shift=1, benchmark="SH000905", verbose=True, return_order=False, **kwargs): - """This function will help you set a reasonable Exchange and provide default value for strategy - Parameters - ---------- - - - **backtest workflow related or commmon arguments** - - pred : pandas.DataFrame - predict should has index and one `score` column. - account : float - init account value. - shift : int - whether to shift prediction by one day. - benchmark : str - benchmark code, default is SH000905 CSI 500. - verbose : bool - whether to print log. - return_order : bool - whether to return order list - - - **strategy related arguments** - - strategy : Strategy() - strategy used in backtest. - topk : int (Default value: 50) - top-N stocks to buy. - margin : int or float(Default value: 0.5) - - if isinstance(margin, int): - - sell_limit = margin - - - else: - - sell_limit = pred_in_a_day.count() * margin - - buffer margin, in single score_mode, continue holding stock if it is in nlargest(sell_limit). - sell_limit should be no less than topk. - n_drop : int - number of stocks to be replaced in each trading date. - risk_degree: float - 0-1, 0.95 for example, use 95% money to trade. - str_type: 'amount', 'weight' or 'dropout' - strategy type: TopkAmountStrategy ,TopkWeightStrategy or TopkDropoutStrategy. - - - **exchange related arguments** - - exchange: Exchange() - pass the exchange for speeding up. - subscribe_fields: list - subscribe fields. - open_cost : float - open transaction cost. The default value is 0.002(0.2%). - close_cost : float - close transaction cost. The default value is 0.002(0.2%). - min_cost : float - min transaction cost. - trade_unit : int - 100 for China A. - deal_price: str - dealing price type: 'close', 'open', 'vwap'. - limit_threshold : float - limit move 0.1 (10%) for example, long and short with same limit. - extract_codes: bool - will we pass the codes extracted from the pred to the exchange. - - .. note:: This will be faster with offline qlib. - - - **executor related arguments** - - executor : BaseExecutor() - executor used in backtest. - verbose : bool - whether to print log. - - """ - # check strategy: - spec = inspect.getfullargspec(get_strategy) - str_args = {k: v for k, v in kwargs.items() if k in spec.args} - strategy = get_strategy(**str_args) - - # init exchange: spec = inspect.getfullargspec(get_exchange) - ex_args = {k: v for k, v in kwargs.items() if k in spec.args} - trade_exchange = get_exchange(pred, **ex_args) + exchange_args = {k: v for k, v in kwargs.items() if k in spec.args} + trade_exchange = get_exchange(**exchange_args) - # init executor: - executor = get_executor(executor=kwargs.get("executor"), trade_exchange=trade_exchange, verbose=verbose) + setup_exchange(trade_env, trade_exchange) + setup_exchange(trade_strategy, trade_exchange) - # run backtest - report_dict = backtest_func( - pred=pred, - strategy=strategy, - executor=executor, - trade_exchange=trade_exchange, - shift=shift, - verbose=verbose, - account=account, - benchmark=benchmark, - return_order=return_order, - ) - # for compatibility of the old API. return the dict positions + report_dict = backtest_func(start_time, end_time, trade_strategy, trade_env, benchmark, account) - positions = report_dict.get("positions") - report_dict.update({"positions": {k: p.position for k, p in positions.items()}}) return report_dict diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index a614f08b6..c44d26d7b 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -26,10 +26,10 @@ rtn & earning in the Account class Account: - def __init__(self, init_cash, last_trade_date=None): - self.init_vars(init_cash, last_trade_date) + def __init__(self, init_cash, last_trade_time=None): + self.init_vars(init_cash, last_trade_time) - def init_vars(self, init_cash, last_trade_date=None): + def init_vars(self, init_cash, last_trade_time=None): # init cash self.init_cash = init_cash self.current = Position(cash=init_cash) @@ -40,7 +40,7 @@ class Account: self.val = 0 self.report = Report() self.earning = 0 - self.last_trade_date = last_trade_date + self.last_trade_time = last_trade_time def get_positions(self): return self.positions @@ -83,9 +83,10 @@ class Account: self.current.update_order(order, trade_val, cost, trade_price) self.update_state_from_order(order, trade_val, cost, trade_price) - def update_daily_end(self, today, trader): + def update_bar_end(self, trade_start_time, trade_end_time, trade_exchange): """ - today: pd.TimeStamp + start_time: pd.TimeStamp + end_time: pd.TimeStamp quote: pd.DataFrame (code, date), collumns when the end of trade date - update rtn @@ -102,11 +103,11 @@ class Account: profit = 0 for code in stock_list: # if suspend, no new price to be updated, profit is 0 - if trader.check_stock_suspended(code, today): + if trade_exchange.check_stock_suspended(code, trade_start_time, trade_end_time): continue - today_close = trader.get_close(code, today) - profit += (today_close - self.current.position[code]["price"]) * self.current.position[code]["amount"] - self.current.update_stock_price(stock_id=code, price=today_close) + bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) + profit += (bar_close - self.current.position[code]["price"]) * self.current.position[code]["amount"] + self.current.update_stock_price(stock_id=code, price=bar_close) self.rtn += profit # update holding day count self.current.add_count_all() @@ -116,54 +117,54 @@ class Account: # account_value - last_account_value # for the first trade date, account_value - init_cash # self.report.is_empty() to judge is_first_trade_date - # get last_account_value, today_account_value, today_stock_value + # get last_account_value, now_account_value, now_stock_value if self.report.is_empty(): last_account_value = self.init_cash else: last_account_value = self.report.get_latest_account_value() - today_account_value = self.current.calculate_value() - today_stock_value = self.current.calculate_stock_value() - self.earning = today_account_value - last_account_value + now_account_value = self.current.calculate_value() + now_stock_value = self.current.calculate_stock_value() + self.earning = now_account_value - last_account_value # update report for today # judge whether the the trading is begin. # and don't add init account state into report, due to we don't have excess return in those days. self.report.update_report_record( - trade_date=today, - account_value=today_account_value, + trade_time=trade_start_time, + account_value=now_account_value, cash=self.current.position["cash"], return_rate=(self.earning + self.ct) / last_account_value, # here use earning to calculate return, position's view, earning consider cost, true return # in order to make same definition with original backtest in evaluate.py turnover_rate=self.to / last_account_value, cost_rate=self.ct / last_account_value, - stock_value=today_stock_value, + stock_value=now_stock_value, ) - # set today_account_value to position - self.current.position["today_account_value"] = today_account_value + # set now_account_value to position + self.current.position["now_account_value"] = now_account_value self.current.update_weight_all() # update positions # note use deepcopy - self.positions[today] = copy.deepcopy(self.current) + self.positions[trade_start_time] = copy.deepcopy(self.current) # finish today's updation # reset the daily variables self.rtn = 0 self.ct = 0 self.to = 0 - self.last_trade_date = today + self.last_trade_time = (trade_start_time, trade_end_time) def load_account(self, account_path): report = Report() position = Position() - last_trade_date = position.load_position(account_path / "position.xlsx") + last_trade_time = position.load_position(account_path / "position.xlsx") report.load_report(account_path / "report.csv") # assign values self.init_vars(position.init_cash) self.current = position self.report = report - self.last_trade_date = last_trade_date if last_trade_date else None + self.last_trade_time = last_trade_time def save_account(self, account_path): - self.current.save_position(account_path / "position.xlsx", self.last_trade_date) + self.current.save_position(account_path / "position.xlsx", self.last_trade_time) self.report.save_report(account_path / "report.csv") diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index b87d6afe3..8e157a361 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -4,140 +4,24 @@ import numpy as np import pandas as pd -from ...utils import get_date_by_shift, get_date_range -from ...data import D + from .account import Account -from ...config import C -from ...log import get_module_logger -from ...data.dataset.utils import get_level_index -LOG = get_module_logger("backtest") - - -def backtest(pred, strategy, executor, trade_exchange, shift, verbose, account, benchmark, return_order): - """Parameters - ---------- - pred : pandas.DataFrame - predict should has index and one `score` column - Qlib want to support multi-singal strategy in the future. So pd.Series is not used. - strategy : Strategy() - strategy part for backtest - trade_exchange : Exchange() - exchage for backtest - shift : int - whether to shift prediction by one day - verbose : bool - whether to print log - account : float - init account value - benchmark : str/list/pd.Series - `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. - example: - print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) - 2017-01-04 0.011693 - 2017-01-05 0.000721 - 2017-01-06 -0.004322 - 2017-01-09 0.006874 - 2017-01-10 -0.003350 - - `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. - `benchmark` is str, will use the daily change as the 'bench'. - benchmark code, default is SH000905 CSI500 - """ - # Convert format if the input format is not expected - if get_level_index(pred, level="datetime") == 1: - pred = pred.swaplevel().sort_index() - if isinstance(pred, pd.Series): - pred = pred.to_frame("score") +def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account): trade_account = Account(init_cash=account) - _pred_dates = pred.index.get_level_values(level="datetime") - predict_dates = D.calendar(start_time=_pred_dates.min(), end_time=_pred_dates.max()) - if isinstance(benchmark, pd.Series): - bench = benchmark - else: - _codes = benchmark if isinstance(benchmark, list) else [benchmark] - _temp_result = D.features( - _codes, - ["$close/Ref($close,1)-1"], - predict_dates[0], - get_date_by_shift(predict_dates[-1], shift=shift), - disk_cache=1, - ) - if len(_temp_result) == 0: - raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") - bench = _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean() + trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) + trade_strategy.reset(start_time=start_time, end_time=end_time) - trade_dates = np.append(predict_dates[shift:], get_date_range(predict_dates[-1], left_shift=1, right_shift=shift)) - if return_order: - multi_order_list = [] - # trading apart - for pred_date, trade_date in zip(predict_dates, trade_dates): - # for loop predict date and trading date - # print - if verbose: - LOG.info("[I {:%Y-%m-%d}]: trade begin.".format(trade_date)) - - # 1. Load the score_series at pred_date - try: - score = pred.loc(axis=0)[pred_date, :] # (trade_date, stock_id) multi_index, score in pdate - score_series = score.reset_index(level="datetime", drop=True)[ - "score" - ] # pd.Series(index:stock_id, data: score) - except KeyError: - LOG.warning("No score found on predict date[{:%Y-%m-%d}]".format(trade_date)) - score_series = None - - if score_series is not None and score_series.count() > 0: # in case of the scores are all None - # 2. Update your strategy (and model) - strategy.update(score_series, pred_date, trade_date) - - # 3. Generate order list - order_list = strategy.generate_order_list( - score_series=score_series, - current=trade_account.current, - trade_exchange=trade_exchange, - pred_date=pred_date, - trade_date=trade_date, - ) - else: - order_list = [] - if return_order: - multi_order_list.append((trade_account, order_list, trade_date)) - # 4. Get result after executing order list - # NOTE: The following operation will modify order.amount. - # NOTE: If it is buy and the cash is insufficient, the tradable amount will be recalculated - trade_info = executor.execute(trade_account, order_list, trade_date) - - # 5. Update account information according to transaction - update_account(trade_account, trade_info, trade_exchange, trade_date) - - # generate backtest report + trade_state = trade_env.get_init_state() + while not trade_env.finished(): + _order_list = trade_strategy.generate_order_list(**trade_state) + print("_order_list", _order_list) + trade_state, trade_info = trade_env.execute(_order_list) + report_df = trade_account.report.generate_report_dataframe() - report_df["bench"] = bench positions = trade_account.get_positions() - report_dict = {"report_df": report_df, "positions": positions} - if return_order: - report_dict.update({"order_list": multi_order_list}) + return report_dict - -def update_account(trade_account, trade_info, trade_exchange, trade_date): - """Update the account and strategy - Parameters - ---------- - trade_account : Account() - trade_info : list of [Order(), float, float, float] - (order, trade_val, trade_cost, trade_price), trade_info with out factor - trade_exchange : Exchange() - used to get the $close_price at trade_date to update account - trade_date : pd.Timestamp - """ - # update account - for [order, trade_val, trade_cost, trade_price] in trade_info: - if order.deal_amount == 0: - continue - trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) - # at the end of trade date, update the account based the $close_price of stocks. - trade_account.update_daily_end(today=trade_date, trader=trade_exchange) diff --git a/qlib/backtest/env.py b/qlib/contrib/backtest/env.py similarity index 89% rename from qlib/backtest/env.py rename to qlib/contrib/backtest/env.py index 571f33b7e..85a6c1ec3 100644 --- a/qlib/backtest/env.py +++ b/qlib/contrib/backtest/env.py @@ -5,13 +5,13 @@ import json import copy import warnings import pathlib +import numpy as np import pandas as pd -from loguru import Logger -from ...data import D, Cal -from ...utils import get_date_in_file_name -from ...utils import get_pre_trading_date -from ..backtest.order import Order -from ..utils import init_instance_by_config +from ...data.data import Cal +from ...utils import get_sample_freq_calendar +from .order import Order + + class TradeCalendarBase: def _reset_trade_calendar(self, start_time, end_time): @@ -20,10 +20,10 @@ class TradeCalendarBase: if end_time: self.end_time = pd.Timestamp(end_time) if self.start_time and self.end_time: - _calendar, freq, freq_sam = get_sample_freq_calendar(freq=step_bar) + _calendar, freq, freq_sam = get_sample_freq_calendar(freq=self.step_bar) self.calendar = _calendar _start_time, _end_time, _start_index, _end_index = Cal.locate_index(self.start_time, self.end_time, freq=freq, freq_sam=freq_sam) - _trade_calendar = self.calendar[_start_index, _end_index + 1] + _trade_calendar = self.calendar[_start_index: _end_index + 1] if _start_time != self.start_time: self.trade_calendar = np.hstack((self.start_time, _trade_calendar, self.end_time)) self.start_index = _start_index - 1 @@ -40,7 +40,7 @@ class TradeCalendarBase: trade_index = trade_index - shift if 0 < trade_index < self.trade_len - 1: trade_start_time = self.trade_calendar[trade_index - 1] - trade_end_time = self.trade_calendar[trade_index] - pd.Timestamp(second=1) + trade_end_time = self.trade_calendar[trade_index] - pd.Timedelta(seconds=1) return trade_start_time, trade_end_time elif trade_index == self.trade_len - 1: trade_start_time = self.trade_calendar[trade_index - 1] @@ -68,7 +68,7 @@ class BaseEnv(TradeCalendarBase): end_time=None, trade_account=None, verbose=False, - **kwargs + **kwargs, ): self.step_bar = step_bar self.verbose = verbose @@ -76,24 +76,24 @@ class BaseEnv(TradeCalendarBase): def _get_position(self): return self.trade_account.current - def reset(self, start_time=None, end_time=None, trade_account=None, **kwargs): if start_time or end_time: self._reset_trade_calendar(start_time=start_time, end_time=end_time) - self.trade_account = trade_account + if trade_account: + self.trade_account = trade_account for k, v in kwargs: if hasattr(self, k): setattr(self, k, v) - def get_first_state(self): + def get_init_state(self): init_state = {"current": self._get_position()} return init_state - def execute(self, order_list, **kwargs): + def execute(self, order_list=None, **kwargs): self.trade_index = self.trade_index + 1 def finished(self): @@ -122,13 +122,13 @@ class SplitEnv(BaseEnv): #if self.track: # yield action #episode_reward = 0 - super(SimulatorEnv, self).execute(**kwargs) + super(SplitEnv, self).execute(**kwargs) trade_start_time, trade_end_time = self._get_trade_time(trade_index=self.trade_index) self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time, trade_account=self.trade_account) self.sub_strategy.reset(start_time=trade_start_time, end_time=trade_end_time, trade_order_list=order_list) trade_state = self.sub_env.get_init_state() while not self.sub_env.finished(): - _order_list = self.sub_strategy.generate_order(**trade_state) + _order_list = self.sub_strategy.generate_order_list(**trade_state) trade_state, trade_info = self.sub_env.execute(order_list=_order_list) #episode_reward += sub_reward _obs = {"current": self._get_position()} @@ -149,11 +149,12 @@ class SimulatorEnv(BaseEnv): verbose=False, **kwargs, ): - super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, trade_exchange=trade_exchange, verbose=verbose) + super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, trade_exchange=trade_exchange, verbose=verbose, **kwargs) - def reset(trade_exchange=None, **kwargs): + def reset(self, trade_exchange=None, **kwargs): super(SimulatorEnv, self).reset(**kwargs) - self.trade_exchange=trade_exchange + if trade_exchange: + self.trade_exchange=trade_exchange def execute(self, order_list, **kwargs): """ @@ -162,7 +163,7 @@ class SimulatorEnv(BaseEnv): if self.finished(): raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") super(SimulatorEnv, self).execute(**kwargs) - ttrade_start_time, trade_end_time = self._get_trade_time(trade_index=self.trade_index) + trade_start_time, trade_end_time = self._get_trade_time(trade_index=self.trade_index) trade_info = [] for order in order_list: if self.trade_exchange.check_order(order) is True: diff --git a/qlib/contrib/backtest/exchange.py b/qlib/contrib/backtest/exchange.py index 178950eeb..62f6c63bd 100644 --- a/qlib/contrib/backtest/exchange.py +++ b/qlib/contrib/backtest/exchange.py @@ -8,16 +8,19 @@ import logging import numpy as np import pandas as pd -from ...data import D -from .order import Order +from ...data.data import D from ...config import C, REG_CN +from ...utils import sample_feature from ...log import get_module_logger +from .order import Order + class Exchange: def __init__( self, - trade_dates=None, + start_time=None, + end_time=None, codes="all", deal_price=None, subscribe_fields=[], @@ -30,7 +33,8 @@ class Exchange: ): """__init__ - :param trade_dates: list of pd.Timestamp + :param start_time: start time for backtest + :param end_time: end time for backtest :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) :param deal_price: str, 'close', 'open', 'vwap' :param subscribe_fields: list, subscribe fields @@ -51,6 +55,8 @@ class Exchange: target on this day). index: MultipleIndex(instrument, pd.Datetime) """ + self.start_time = start_time + self.end_time = end_time if trade_unit is None: trade_unit = C.trade_unit if limit_threshold is None: @@ -91,21 +97,15 @@ class Exchange: self.close_cost = close_cost self.min_cost = min_cost self.limit_threshold = limit_threshold - # TODO: the quote, trade_dates, codes are not necessray. - # It is just for performance consideration. - if trade_dates is not None and len(trade_dates): - start_date, end_date = trade_dates[0], trade_dates[-1] - else: - self.logger.warning("trade_dates have not been assigned, all dates will be loaded") - start_date, end_date = None, None + self.extra_quote = extra_quote - self.set_quote(codes, start_date, end_date) + self.set_quote(codes, start_time, end_time) - def set_quote(self, codes, start_date, end_date): + def set_quote(self, codes, start_time, end_time): if len(codes) == 0: codes = D.instruments() - self.quote = D.features(codes, self.all_fields, start_date, end_date, disk_cache=True).dropna(subset=["$close"]) + self.quote = D.features(codes, self.all_fields, start_time, end_time, disk_cache=True).dropna(subset=["$close"]) self.quote.columns = self.all_fields if self.quote[self.deal_price].isna().any(): @@ -146,35 +146,37 @@ class Exchange: quote_df = pd.concat([quote_df, self.extra_quote], sort=False, axis=0) # update quote: pd.DataFrame to dict, for search use - self.quote = quote_df.to_dict("index") + self.quote = quote_df def _update_limit(self, buy_limit, sell_limit): self.quote["limit"] = ~self.quote["$change"].between(-sell_limit, buy_limit, inclusive=False) - def check_stock_limit(self, stock_id, trade_date): + def check_stock_limit(self, stock_id, start_time, end_time): """Parameter stock_id trade_date is limtited """ - return self.quote[(stock_id, trade_date)]["limit"] + return sample_feature(self.quote, stock_id, start_time, end_time, fields="limit", method="any").iloc[0] + - def check_stock_suspended(self, stock_id, trade_date): + def check_stock_suspended(self, stock_id, start_time, end_time): # is suspended - return (stock_id, trade_date) not in self.quote + return sample_feature(self.quote, stock_id, start_time, end_time).empty - def is_stock_tradable(self, stock_id, trade_date): + + def is_stock_tradable(self, stock_id, start_time, end_time): # check if stock can be traded # same as check in check_order - if self.check_stock_suspended(stock_id, trade_date) or self.check_stock_limit(stock_id, trade_date): + if self.check_stock_suspended(stock_id, start_time, end_time) or self.check_stock_limit(stock_id, start_time, end_time): return False else: return True def check_order(self, order): # check limit and suspended - if self.check_stock_suspended(order.stock_id, order.trade_date) or self.check_stock_limit( - order.stock_id, order.trade_date + if self.check_stock_suspended(order.stock_id, order.start_time, order.end_time) or self.check_stock_limit( + order.stock_id, order.start_time, order.end_time ): return False else: @@ -199,7 +201,7 @@ class Exchange: if trade_account is not None and position is not None: raise ValueError("trade_account and position can only choose one") - trade_price = self.get_deal_price(order.stock_id, order.trade_date) + trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) trade_val, trade_cost = self._calc_trade_info_by_order( order, trade_account.current if trade_account else position ) @@ -214,24 +216,24 @@ class Exchange: return trade_val, trade_cost, trade_price - def get_quote_info(self, stock_id, trade_date): - return self.quote[(stock_id, trade_date)] + def get_quote_info(self, stock_id, start_time, end_time): + return sample_feature(self.quote, stock_id, start_time, end_time) - def get_close(self, stock_id, trade_date): - return self.quote[(stock_id, trade_date)]["$close"] + def get_close(self, stock_id, start_time, end_time): + return sample_feature(self.quote, stock_id, start_time, end_time, fields="$close", method="last").iloc[0] - def get_deal_price(self, stock_id, trade_date): - deal_price = self.quote[(stock_id, trade_date)][self.deal_price] + def get_deal_price(self, stock_id, start_time, end_time): + deal_price = sample_feature(self.quote, stock_id, start_time, end_time, fields=self.deal_price, method="last").iloc[0] if np.isclose(deal_price, 0.0) or np.isnan(deal_price): - self.logger.warning(f"(stock_id:{stock_id}, trade_date:{trade_date}, {self.deal_price}): {deal_price}!!!") + self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {self.deal_price}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") - deal_price = self.get_close(stock_id, trade_date) + deal_price = self.get_close(stock_id, start_time, end_time) return deal_price - def get_factor(self, stock_id, trade_date): - return self.quote[(stock_id, trade_date)]["$factor"] + def get_factor(self, stock_id, start_time, end_time): + return sample_feature(self.quote, stock_id, start_time, end_time, fields="$factor", method="last").iloc[0] - def generate_amount_position_from_weight_position(self, weight_position, cash, trade_date): + def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): """ The generate the target position according to the weight and the cash. NOTE: All the cash will assigned to the tadable stock. @@ -246,7 +248,7 @@ class Exchange: # calculate the total weight of tradable value tradable_weight = 0.0 for stock_id in weight_position: - if self.is_stock_tradable(stock_id=stock_id, trade_date=trade_date): + if self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): # weight_position must be greater than 0 and less than 1 if weight_position[stock_id] < 0 or weight_position[stock_id] > 1: raise ValueError( @@ -260,12 +262,12 @@ class Exchange: amount_dict = {} for stock_id in weight_position: - if weight_position[stock_id] > 0.0 and self.is_stock_tradable(stock_id=stock_id, trade_date=trade_date): + if weight_position[stock_id] > 0.0 and self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): amount_dict[stock_id] = ( cash * weight_position[stock_id] / tradable_weight - // self.get_deal_price(stock_id=stock_id, trade_date=trade_date) + // self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) ) return amount_dict @@ -292,7 +294,7 @@ class Exchange: deal_amount = self.round_amount_by_trade_unit(deal_amount, factor) return -deal_amount - def generate_order_for_target_amount_position(self, target_position, current_position, trade_date): + def generate_order_for_target_amount_position(self, target_position, current_position, start_time, end_time): """Parameter: target_position : dict { stock_id : amount } current_postion : dict { stock_id : amount} @@ -315,12 +317,12 @@ class Exchange: for stock_id in sorted_ids: # Do not generate order for the nontradable stocks - if not self.is_stock_tradable(stock_id=stock_id, trade_date=trade_date): + if not self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): continue target_amount = target_position.get(stock_id, 0) current_amount = current_position.get(stock_id, 0) - factor = self.quote[(stock_id, trade_date)]["$factor"] + factor = self.get_factor(stock_id, start_time=start_time, end_time=end_time) deal_amount = self.get_real_deal_amount(current_amount, target_amount, factor) if deal_amount == 0: @@ -332,7 +334,8 @@ class Exchange: stock_id=stock_id, amount=deal_amount, direction=Order.BUY, - trade_date=trade_date, + start_time=start_time, + end_time=end_time, factor=factor, ) ) @@ -343,14 +346,15 @@ class Exchange: stock_id=stock_id, amount=abs(deal_amount), direction=Order.SELL, - trade_date=trade_date, + start_time=start_time, + end_time=end_time, factor=factor, ) ) # return order_list : buy + sell return sell_order_list + buy_order_list - def calculate_amount_position_value(self, amount_dict, trade_date, only_tradable=False): + def calculate_amount_position_value(self, amount_dict, start_time, end_time, only_tradable=False): """Parameter position : Position() amount_dict : {stock_id : amount} @@ -358,10 +362,10 @@ class Exchange: value = 0 for stock_id in amount_dict: if ( - self.check_stock_suspended(stock_id=stock_id, trade_date=trade_date) is False - and self.check_stock_limit(stock_id=stock_id, trade_date=trade_date) is False + self.check_stock_suspended(stock_id=stock_id, start_time=start_time, end_time=end_time) is False + and self.check_stock_limit(stock_id=stock_id, start_time=start_time, end_time=end_time) is False ): - value += self.get_deal_price(stock_id=stock_id, trade_date=trade_date) * amount_dict[stock_id] + value += self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) * amount_dict[stock_id] return value def round_amount_by_trade_unit(self, deal_amount, factor): @@ -384,7 +388,7 @@ class Exchange: :return: trade_val, trade_cost """ - trade_price = self.get_deal_price(order.stock_id, order.trade_date) + trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) if order.direction == Order.SELL: # sell if position is not None: diff --git a/qlib/contrib/backtest/interpreter.py b/qlib/contrib/backtest/interpreter.py new file mode 100644 index 000000000..94d6f9ec2 --- /dev/null +++ b/qlib/contrib/backtest/interpreter.py @@ -0,0 +1,15 @@ + +class BaseInterpreter: + @staticmethod + def interpret(**kwargs): + raise NotImplementedError("interpret is not implemented!") + +class ActionInterpreter: + @staticmethod + def interpret(action, **kwargs): + return action + +class StateInterpreter: + @staticmethod + def interpret(state, **kwargs): + return state \ No newline at end of file diff --git a/qlib/contrib/backtest/order.py b/qlib/contrib/backtest/order.py index 740773b2f..0d637d9db 100644 --- a/qlib/contrib/backtest/order.py +++ b/qlib/contrib/backtest/order.py @@ -7,7 +7,7 @@ class Order: SELL = 0 BUY = 1 - def __init__(self, stock_id, amount, trade_date, direction, factor): + def __init__(self, stock_id, amount, start_time, end_time, direction, factor): """Parameter direction : Order.SELL for sell; Order.BUY for buy stock_id : str @@ -24,6 +24,7 @@ class Order: self.amount = amount # amount of successfully completed orders self.deal_amount = 0 - self.trade_date = trade_date + self.start_time = start_time + self.end_time = end_time self.direction = direction self.factor = factor diff --git a/qlib/contrib/backtest/position.py b/qlib/contrib/backtest/position.py index 6c269d505..ac1a471f8 100644 --- a/qlib/contrib/backtest/position.py +++ b/qlib/contrib/backtest/position.py @@ -28,13 +28,13 @@ a typical example is :{ class Position: """Position""" - def __init__(self, cash=0, position_dict={}, today_account_value=0): + def __init__(self, cash=0, position_dict={}, now_account_value=0): # NOTE: The position dict must be copied!!! # Otherwise the initial value self.init_cash = cash self.position = position_dict.copy() self.position["cash"] = cash - self.position["today_account_value"] = today_account_value + self.position["now_account_value"] = now_account_value def init_stock(self, stock_id, amount, price=None): self.position[stock_id] = {} @@ -82,7 +82,7 @@ class Position: # SELL self.sell_stock(order.stock_id, trade_val, cost, trade_price) else: - raise NotImplementedError("do not suppotr order direction {}".format(order.direction)) + raise NotImplementedError("do not support order direction {}".format(order.direction)) def update_stock_price(self, stock_id, price): self.position[stock_id]["price"] = price @@ -109,7 +109,7 @@ class Position: return value def get_stock_list(self): - stock_list = list(set(self.position.keys()) - {"cash", "today_account_value"}) + stock_list = list(set(self.position.keys()) - {"cash", "now_account_value"}) return stock_list def get_stock_price(self, code): @@ -163,16 +163,17 @@ class Position: for stock_code, weight in weight_dict.items(): self.update_stock_weight(stock_code, weight) - def save_position(self, path, last_trade_date): + def save_position(self, path, last_trade_time): path = pathlib.Path(path) p = copy.deepcopy(self.position) cash = pd.Series(dtype=np.float) cash["init_cash"] = self.init_cash cash["cash"] = p["cash"] - cash["today_account_value"] = p["today_account_value"] - cash["last_trade_date"] = str(last_trade_date.date()) if last_trade_date else None + cash["now_account_value"] = p["now_account_value"] + cash["last_trade_start_time"] = str(last_trade_time[0]) if last_trade_time else None + cash["last_trade_end_time"] = str(last_trade_time[1]) if last_trade_time else None del p["cash"] - del p["today_account_value"] + del p["now_account_value"] positions = pd.DataFrame.from_dict(p, orient="index") with pd.ExcelWriter(path) as writer: positions.to_excel(writer, sheet_name="position") @@ -189,10 +190,10 @@ class Position: 'weight': , sheet "cash" - index: ['init_cash', 'cash', 'today_account_value'] + index: ['init_cash', 'cash', 'now_account_value'] 'init_cash': , 'cash': , - 'today_account_value': + 'now_account_value': """ path = pathlib.Path(path) positions = pd.read_excel(open(path, "rb"), sheet_name="position", index_col=0) @@ -200,14 +201,17 @@ class Position: positions = positions.to_dict(orient="index") init_cash = cash_record.loc["init_cash"].values[0] cash = cash_record.loc["cash"].values[0] - today_account_value = cash_record.loc["today_account_value"].values[0] - last_trade_date = cash_record.loc["last_trade_date"].values[0] + now_account_value = cash_record.loc["now_account_value"].values[0] + last_trade_start_time = cash_record.loc["last_trade_start_time"].values[0] + last_trade_end_time = cash_record.loc["last_trade_end_time"].values[0] # assign values self.position = {} self.init_cash = init_cash self.position = positions self.position["cash"] = cash - self.position["today_account_value"] = today_account_value + self.position["now_account_value"] = now_account_value - return None if pd.isna(last_trade_date) else pd.Timestamp(last_trade_date) + last_trade_start_time = None if pd.isna(last_trade_start_time) else pd.Timestamp(last_trade_start_time) + last_trade_end_time = None if pd.isna(last_trade_end_time) else pd.Timestamp(last_trade_end_time) + return last_trade_start_time, last_trade_end_time diff --git a/qlib/contrib/backtest/report.py b/qlib/contrib/backtest/report.py index beb9759d0..9a57156f2 100644 --- a/qlib/contrib/backtest/report.py +++ b/qlib/contrib/backtest/report.py @@ -21,20 +21,20 @@ class Report: self.costs = OrderedDict() # trade cost for each trade date self.values = OrderedDict() # value for each trade date self.cashes = OrderedDict() - self.latest_report_date = None # pd.TimeStamp + self.latest_report_time = None # pd.TimeStamp def is_empty(self): return len(self.accounts) == 0 def get_latest_date(self): - return self.latest_report_date + return self.latest_report_time def get_latest_account_value(self): - return self.accounts[self.latest_report_date] + return self.accounts[self.latest_report_time] def update_report_record( self, - trade_date=None, + trade_time=None, account_value=None, cash=None, return_rate=None, @@ -44,7 +44,7 @@ class Report: ): # check data if None in [ - trade_date, + trade_time, account_value, cash, return_rate, @@ -56,14 +56,14 @@ class Report: "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value]" ) # update report data - self.accounts[trade_date] = account_value - self.returns[trade_date] = return_rate - self.turnovers[trade_date] = turnover_rate - self.costs[trade_date] = cost_rate - self.values[trade_date] = stock_value - self.cashes[trade_date] = cash + self.accounts[trade_time] = account_value + self.returns[trade_time] = return_rate + self.turnovers[trade_time] = turnover_rate + self.costs[trade_time] = cost_rate + self.values[trade_time] = stock_value + self.cashes[trade_time] = cash # update latest_report_date - self.latest_report_date = trade_date + self.latest_report_time = trade_time # finish daily report update def generate_report_dataframe(self): @@ -74,7 +74,7 @@ class Report: report["cost"] = pd.Series(self.costs) report["value"] = pd.Series(self.values) report["cash"] = pd.Series(self.cashes) - report.index.name = "date" + report.index.name = "trade_time" return report def save_report(self, path): @@ -94,13 +94,13 @@ class Report: index = r.index self.init_vars() - for date in index: + for trade_time in index: self.update_report_record( - trade_date=date, - account_value=r.loc[date]["account"], - cash=r.loc[date]["cash"], - return_rate=r.loc[date]["return"], - turnover_rate=r.loc[date]["turnover"], - cost_rate=r.loc[date]["cost"], - stock_value=r.loc[date]["value"], + trade_time=trade_time, + account_value=r.loc[trade_time]["account"], + cash=r.loc[trade_time]["cash"], + return_rate=r.loc[trade_time]["return"], + turnover_rate=r.loc[trade_time]["turnover"], + cost_rate=r.loc[trade_time]["cost"], + stock_value=r.loc[trade_time]["value"], ) diff --git a/qlib/contrib/backtest_new/backtest.py b/qlib/contrib/backtest_new/backtest.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/qlib/contrib/strategy/__init__.py b/qlib/contrib/strategy/__init__.py index f0ec0a5d0..678b048c2 100644 --- a/qlib/contrib/strategy/__init__.py +++ b/qlib/contrib/strategy/__init__.py @@ -4,13 +4,13 @@ from .dl_strategy import ( TopkDropoutStrategy, - BaseStrategy, WeightStrategyBase, ) from .rule_strategy import( TWAPStrategy, - SBBEMAStrategy + SBBStrategyBase, + SBBStrategyEMA, ) from .cost_control import ( diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index 001630a95..962936f9f 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. -from .strategy import WeightStrategyBase +from .dl_strategy import WeightStrategyBase import copy diff --git a/qlib/contrib/strategy/dl_strategy.py b/qlib/contrib/strategy/dl_strategy.py index 5f702fe0b..4c7d16eea 100644 --- a/qlib/contrib/strategy/dl_strategy.py +++ b/qlib/contrib/strategy/dl_strategy.py @@ -4,12 +4,12 @@ import numpy as np import pandas as pd from ...utils import sample_feature -from ...strategy.base import DLStrategy -from ...backtest.order import Order +from ...strategy.base import ModelStrategy +from ..backtest.order import Order from .order_generator import OrderGenWInteract -class TopkDropoutStrategy(DLStrategy): +class TopkDropoutStrategy(ModelStrategy): def __init__( self, step_bar, @@ -53,7 +53,7 @@ class TopkDropoutStrategy(DLStrategy): else: strategy will make decision with the tradable state of the stock info and avoid buy and sell them. """ - super(TopkDropoutStrategy, self).__init__(step_bar, model, dataset, start_time, end_time) + super(TopkDropoutStrategy, self).__init__(step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange) self.topk = topk self.n_drop = n_drop self.method_sell = method_sell @@ -67,9 +67,10 @@ class TopkDropoutStrategy(DLStrategy): self.only_tradable = only_tradable - def reset(trade_exchange=None, **kwargs): + def reset(self, trade_exchange=None, **kwargs): super(TopkDropoutStrategy, self).reset(**kwargs) - self.trade_exchange = trade_exchange + if trade_exchange: + self.trade_exchange = trade_exchange def get_risk_degree(self, trade_index): """get_risk_degree @@ -189,7 +190,7 @@ class TopkDropoutStrategy(DLStrategy): # update cash cash += trade_val - trade_cost # sold - del self.stock_count[code] + self.stock_count[code] = 0 else: # no buy signal, but the stock is kept self.stock_count[code] += 1 @@ -210,10 +211,10 @@ class TopkDropoutStrategy(DLStrategy): # value = value / (1+self.trade_exchange.open_cost) # set open_cost limit for code in buy: # check is stock suspended - if not self.trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): + if not self.trade_exchange.is_stock_tradable(stock_id=code, start_time=trade_start_time, end_time=trade_end_time): continue # buy order - buy_price = self.trade_exchange.get_deal_price(stock_id=code, trade_date=trade_date) + buy_price = self.trade_exchange.get_deal_price(stock_id=code, start_time=trade_start_time, end_time=trade_end_time) buy_amount = value / buy_price factor = self.trade_exchange.get_factor(stock_id=code, start_time=trade_start_time, end_time=trade_end_time) buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor) @@ -229,8 +230,8 @@ class TopkDropoutStrategy(DLStrategy): self.stock_count[code] = 1 return sell_order_list + buy_order_list -class WeightStrategyBase(DLStrategy): - def __init__(self, trade_exchange, order_generator_cls_or_obj=OrderGenWInteract, start_time=None, end_time=None, **kwargs): +class WeightStrategyBase(ModelStrategy): + def __init__(self, step_bar, start_time=None, end_time=None, order_generator_cls_or_obj=OrderGenWInteract, trade_exchange=None, **kwargs): super(WeightStrategyBase, self).__init__(step_bar, start_time, end_time) self.trade_exchange = trade_exchange if isinstance(order_generator_cls_or_obj, type): diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index cdbd30c1f..d263f658d 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -4,8 +4,8 @@ """ This order generator is for strategies based on WeightStrategyBase """ -from ...backtest.position import Position -from ...backtest.exchange import Exchange +from ..backtest.position import Position +from ..backtest.exchange import Exchange import pandas as pd import copy diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index dd2e17c54..b51ec9aca 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -4,18 +4,20 @@ import numpy as np import pandas as pd from ...utils import sample_feature +from ...data.data import D from ...strategy.base import RuleStrategy, TradingEnhancement -from ...backtest.order import Order +from ..backtest.order import Order class TWAPStrategy(RuleStrategy, TradingEnhancement): def reset(self, trade_order_list=None, **kwargs): super(TWAPStrategy, self).reset(**kwargs) - TradingEnhancement.reset(trade_order_list=trade_order_list) - self.trade_amount = {} - for order in self.trade_order_list: - self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len + TradingEnhancement.reset(self, trade_order_list=trade_order_list) + if trade_order_list: + self.trade_amount = {} + for order in self.trade_order_list: + self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len def generate_order_list(self, **kwargs): @@ -43,13 +45,15 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): TREND_LONG = 2 def reset(self, trade_order_list=None, **kwargs): - TradingEnhancement.reset(trade_order_list=trade_order_list) - self.trade_amount = {} - self.trade_delay = {} - for order in self.trade_order_list: - self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len - self.trade_trend[(order.stock_id, order.direction)] = TREND_MID super(SBBStrategyBase, self).reset(**kwargs) + TradingEnhancement.reset(self, trade_order_list=trade_order_list) + if trade_order_list: + self.trade_amount = {} + self.trade_trend = {} + for order in self.trade_order_list: + self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len + self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID + def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") @@ -64,7 +68,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): _pred_trend = self._pred_price_trend(order.stock_id) else: _pred_trend = self.trade_trend[(order.stock_id, order.direction)] - if _pred_trend == TREND_MID: + if _pred_trend == self.TREND_MID: _order = Order( stock_id=order.stock_id, amount=self.trade_amount[(order.stock_id, order.direction)], @@ -97,7 +101,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): factor=order.factor, ) order_list.append(_order) - if self.trade_index % 2 == 1 + if self.trade_index % 2 == 1: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend return order_list @@ -110,8 +114,8 @@ class SBBStrategyEMA(SBBStrategyBase): def __init__( self, step_bar, - start_time, - end_time, + start_time=None, + end_time=None, instruments="csi300", freq="day", **kwargs, @@ -121,21 +125,23 @@ class SBBStrategyEMA(SBBStrategyBase): warnings.warn("`instruments` is not set, will load all stocks") self.instruments = "all" if isinstance(instruments, str): - self.instruments = D.instruments(instruments, filter_pipe=self.filter_pipe) + self.instruments = D.instruments(instruments) self.freq = freq - def _reset_trade_calendar(self, start_time=None, end_time=None, _calendar=None): - super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time, _calendar=_calendar) - fields = [("EMA($close, 10) - EMA($close, 20)", "signal")] - signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) - self.signal = D.features(instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq) + def _reset_trade_calendar(self, start_time=None, end_time=None): + super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time) + if self.start_time and self.end_time: + fields = ["EMA($close, 10)-EMA($close, 20)"] + signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) + self.signal = D.features(self.instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq) + self.signal.columns = ["signal"] def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): _sample_signal = sample_feature(self.signal, stock_id, start_time=pred_start_time, end_time=pred_end_time, fields="signal", method="last") if _sample_signal.empty: - return SBBStrategy.TREND_MID - elif _sample_signal.iloc[0, 0] > 0: - return SBBStrategy.TREND_LONG + return self.TREND_MID + elif _sample_signal.iloc[0] > 0: + return self.TREND_LONG else: - return SBBStrategy.TREND_SHORT \ No newline at end of file + return self.TREND_SHORT \ No newline at end of file diff --git a/qlib/data/data.py b/qlib/data/data.py index 98427637a..a8d5a42ab 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -117,6 +117,7 @@ class CalendarProvider(abc.ABC): flag = f"{freq}_sam_{freq_sam}_future_{future}" if flag in H["c"]: _calendar, _calendar_index = H["c"][flag] + return _calendar, _calendar_index else: flag_raw = f"{freq}_sam_{None}_future_{future}" if flag_raw in H["c"]: @@ -125,6 +126,7 @@ class CalendarProvider(abc.ABC): _calendar = np.array(self.load_calendar(freq, future)) _calendar_index = {x: i for i, x in enumerate(_calendar)} # for fast search H["c"][flag_raw] = _calendar, _calendar_index + if freq_sam is None: return _calendar, _calendar_index else: @@ -132,6 +134,7 @@ class CalendarProvider(abc.ABC): _calendar_sam_index = {x: i for i, x in enumerate(_calendar_sam)} H["c"][flag] = _calendar_sam, _calendar_sam_index return _calendar_sam, _calendar_sam_index + def _uri(self, start_time, end_time, freq, future=False): """Get the uri of calendar generation task.""" @@ -541,8 +544,8 @@ class LocalCalendarProvider(CalendarProvider): with open(fname) as f: return [pd.Timestamp(x.strip()) for x in f] - def calendar(self, start_time=None, end_time=None, freq="day", future=False, freq_sam=None): - _calendar, _ = self._get_calendar(freq=freq, future=future) + def calendar(self, start_time=None, end_time=None, freq="day", freq_sam=None, future=False): + _calendar, _ = self._get_calendar(freq=freq, freq_sam=freq_sam, future=future) # strip if start_time: start_time = pd.Timestamp(start_time) @@ -764,6 +767,7 @@ class ClientCalendarProvider(CalendarProvider): self.conn = conn def calendar(self, start_time=None, end_time=None, freq="day", future=False): + self.conn.send_request( request_type="calendar", request_content={ diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index cad093af2..193906dcd 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -10,8 +10,9 @@ import pandas as pd from ..utils import get_sample_freq_calendar from ..data.dataset import DatasetH -from ..backtest.order import Order -from ..backtest.env import TradeCalendarBase +from ..data.dataset.utils import get_level_index +from ..contrib.backtest.order import Order +from ..contrib.backtest.env import TradeCalendarBase """ 1. BaseStrategy 的粒度一定是数据粒度的整数倍 @@ -24,26 +25,14 @@ class BaseStrategy(TradeCalendarBase): self.step_bar = step_bar self.reset(start_time=start_time, end_time=end_time, **kwargs) - def reset(self, start_time=None, end_time=None, _calendar=None, **kwargs): + def reset(self, start_time=None, end_time=None, **kwargs): if start_time or end_time : - self._reset_trade_calendar(start_time=start_time, end_time=end_time, calendar=calendar) + self._reset_trade_calendar(start_time=start_time, end_time=end_time) for k, v in kwargs: if hasattr(self, k): setattr(self, k, v) - - def _get_trade_time(self): - if 0 < self.trade_index < self.trade_len - 1: - trade_start_time = self.trade_calendar[self.trade_index - 1] - trade_end_time = self.trade_calendar[self.trade_index] - pd.Timestamp(second=1) - return trade_start_time, trade_end_time - elif self.trade_index == self.trade_len - 1: - trade_start_time = self.trade_calendar[self.trade_index - 1] - trade_end_time = self.trade_calendar[self.trade_index] - return trade_start_time, trade_end_time - else: - raise RuntimeError("trade_index out of range") def generate_order_list(self, **kwargs): self.trade_index = self.trade_index + 1 @@ -52,20 +41,26 @@ class BaseStrategy(TradeCalendarBase): class RuleStrategy(BaseStrategy): pass -class DLStrategy(BaseStrategy): - def __init__(self, step_bar, model, dataset:DatasetH, start_time=None, end_time=None): +class ModelStrategy(BaseStrategy): + def __init__(self, step_bar, model, dataset:DatasetH, start_time=None, end_time=None, **kwargs): self.model = model self.dataset = dataset - self.pred_scores = self.model.predict(dataset) + self.pred_scores = self._convert_index_format(self.model.predict(dataset)) #pred_score_dates = self.pred_scores.index.get_level_values(level="datetime") - super(DLStrategy, self).__init__(step_bar, start_time, end_time) + super(ModelStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) - def _update_model(self): + def _convert_index_format(self, df): + if get_level_index(df, level="datetime") == 0: + df = df.swaplevel().sort_index() + return df + + def _update_model(self): """update pred score """ pass class TradingEnhancement: - def reset(self, trade_order_list): - self.trade_order_list = trade_order_list + def reset(self, trade_order_list=None): + if trade_order_list: + self.trade_order_list = trade_order_list diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index 028e60cc6..0f365956d 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -15,6 +15,7 @@ import bisect import shutil import difflib import hashlib +import warnings import datetime import requests import tempfile @@ -918,37 +919,40 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): else: raise ValueError("sample freq must be xmin, xd, xw, xm") -def get_sample_freq_calendar(start_time=None, end_time=None, freq, **kwargs): +def get_sample_freq_calendar(start_time=None, end_time=None, freq="day", **kwargs): + from ..data.data import Cal + try: - _calendar = D.calendar(start_time=start_time, end_time=end_time, freq=freq, **kwargs) + _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq=freq, **kwargs) freq, freq_sam = freq, None except ValueError: freq_sam = freq if freq.endswith(("m", "month", "w", "week", "d", "day")): try: - _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq, **kwargs) + _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, **kwargs) freq = "min" except ValueError: - _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="day", freq_sam=freq, **kwargs) + _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="day", freq_sam=freq, **kwargs) freq = "day" elif freq.endswith(("min", "minute")): - _calendar = D.calendar(start_time=self.start_time, end_time=self.end_time, freq="min", freq_sam=freq, **kwargs) + _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, **kwargs) freq = "min" else: raise ValueError(f"freq {freq} is not supported") return _calendar, freq, freq_sam def sample_feature(feature, instruments=None, start_time=None, end_time=None, fields=None, method=None, method_kwargs={}): - if instruments and type(instruments) is not list: + if instruments and not isinstance(instruments, list): instruments = [instruments] - if fields and type(fields) is not list: - fields = [fields] selector_inst = slice(None) if instruments is None else instruments selector_datetime = slice(start_time, end_time) - if fields is not None and type(fields) is not list: - fields = [fields] - selector_fields = slice(None) if fields is None else fields - feature = feature.loc[(selector_inst, selector_datetime), selector_fields] + if isinstance(feature, pd.Series): + feature = feature.loc[(selector_inst, selector_datetime)] + if fields: + warnings.warn(f"sample series feature, {fields} is ignored!") + elif isinstance(feature, pd.DataFrame): + selector_fields = slice(None) if fields is None else fields + feature = feature.loc[(selector_inst, selector_datetime), selector_fields] if method: return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) else: From 8920c1967f5898f2d6312ab21dbdb1b8938bb663 Mon Sep 17 00:00:00 2001 From: bxdd Date: Mon, 26 Apr 2021 20:54:10 +0800 Subject: [PATCH 007/187] del outdate file --- examples/highfreq/backtest/workflow.py | 1 + qlib/contrib/backtest/backtest.py | 1 - qlib/env/__init__.py | 0 qlib/env/env.py | 169 ------------------------- qlib/env/env_wrapper.py | 33 ----- qlib/env/interpreter.py | 15 --- 6 files changed, 1 insertion(+), 218 deletions(-) delete mode 100644 qlib/env/__init__.py delete mode 100644 qlib/env/env.py delete mode 100644 qlib/env/env_wrapper.py delete mode 100644 qlib/env/interpreter.py diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index 3e0e1524b..38c1eecc8 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -100,6 +100,7 @@ if __name__ == "__main__": "module_path": "qlib.contrib.backtest.env", "kwargs": { "step_bar": "day", + "verbose": True, } }, "sub_strategy": { diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index 8e157a361..2bc349be3 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -16,7 +16,6 @@ def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account trade_state = trade_env.get_init_state() while not trade_env.finished(): _order_list = trade_strategy.generate_order_list(**trade_state) - print("_order_list", _order_list) trade_state, trade_info = trade_env.execute(_order_list) report_df = trade_account.report.generate_report_dataframe() diff --git a/qlib/env/__init__.py b/qlib/env/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/qlib/env/env.py b/qlib/env/env.py deleted file mode 100644 index 852d4379b..000000000 --- a/qlib/env/env.py +++ /dev/null @@ -1,169 +0,0 @@ - - -import re -import json -import copy -import pathlib -import pandas as pd -from loguru import Logger -from ...data import D -from ...utils import get_date_in_file_name -from ...utils import get_pre_trading_date -from ..backtest.order import Order -from ..utils import init_instance_by_config - -class BaseEnv: - """ - # Strategy framework document - - class Env(BaseEnv): - """ - - def __init__( - self, - step_bar, - trade_account, - start_time=None, - end_time=None, - track=False, - verbose=False, - **kwargs - ): - self.step_bar = step_bar - self.reset(start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, **kwargs) - - def _reset_trade_date(self, start_time=None, end_time=None): - if start_time: - self.start_time = start_time - if end_time: - self.end_time = end_time - if not self.start_time or not self.end_time: - raise ValueError("value of `start_time` or `end_time` is None") - _calendar = get_sample_freq_calendar(start_time=start_time, end_time=end_time, freq=step_bar) - self.trade_dates = np.hstack(pd.Timestamp(self.start_time), _calendar, self.end_time) - self.trade_len = len(self.trade_dates) - self.trade_index = 0 - - def reset(self, start_time=None, end_time=None, **kwargs): - if start_time or end_time: - self._reset_trade_date(start_time=start_time, end_time=end_time) - self.track = kwargs.get("track", False) - self.upper_action = kwargs.get("upper_action", None) - self.trade_account = init_instance_by_config(kwargs.get("trade_account")) - return self.trade_account - - def execute(self, action): - self.trade_index = self.trade_index + 1 - return - ( - self.trade_account, - { - "start_time": self.start_time, - "end_time": self.end_time, - "trade_len": self.trade_len, - "trade_index": self.trade_index - 1, - } - ) - - def finished(self): - return self.trade_index >= self.trade_len - - - -class SplitEnv(BaseEnv): - def __init__( - self, - step_bar, - start_time, - end_time, - trade_account, - sub_env, - sub_strategy, - track=False, - verbose=False, - **kwargs - ): - self.sub_env = sub_env - self.sub_strategy = sub_strategy - super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track) - - def execute(self, action): - if self.finished(): - raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") - #if self.track: - # yield action - #episode_reward = 0 - self.sub_strategy.reset(uppper_action=action) - sub_account = self.sub_env.reset(uppper_action=action, start_time=self.trade_dates[self.trade_index - 1], end_time=self.trade_dates[self.trade_index]) - while not self.sub_env.finished(): - sub_order = self.sub_strategy.generate_order(sub_obs) - sub_account, sub_info = self.sub_env.execute(sub_action) - #episode_reward += sub_reward - _account, _info = super(SimulatorEnv, self).execute(action) - return _account, _info - - - -class SimulatorEnv(BaseEnv): - - def __init__( - self, - step_bar, - start_time, - end_time, - trade_account, - trade_exchange, - track=False, - verbose=False, - **kwargs - ): - self.trade_exchange = trade_exchange - super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, track=track, verbose=verbose) - - def execute(self, action:dict): - """ - Return: obs, done, info - """ - if self.finished(): - raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") - - trade_info = [] - order_list = action - - for order in order_list: - if self.trade_exchange.check_order(order) is True: - # execute the order - trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(order, trade_account=self.trade_account) - trade_info.append((order, trade_val, trade_cost, trade_price)) - if self.verbose: - if order.direction == Order.SELL: # sell - print( - "[I ({:%Y-%m-%d})-({:%Y-%m-%d})]: sell {}, price {:.2f}, amount {}, value {:.2f}.".format( - self.trade_dates[self.trade_index], - self.trade_dates[self.trade_index + 1], - order.stock_id, - trade_price, - order.deal_amount, - trade_val, - ) - ) - else: - print( - "[I ({:%Y-%m-%d})-{:%Y-%m-%d}]: buy {}, price {:.2f}, amount {}, value {:.2f}.".format( - self.trade_dates[self.trade_index], - self.trade_dates[self.trade_index + 1], - order.stock_id, - trade_price, - order.deal_amount, - trade_val, - ) - ) - - else: - if self.verbose: - print("[W ({:%Y-%m-%d})-({:%Y-%m-%d})]: {} wrong.".format(self.trade_dates[self.trade_index], self.trade_dates[self.trade_index + 1], order.stock_id)) - # do nothing - pass - self.trade_account.update_daily_end(today=trade_dates, trader=self.trade_exchange) - _account, _info = super(SimulatorEnv, self).execute(action) - return _account, {**_info, "trade_info", trade_info} \ No newline at end of file diff --git a/qlib/env/env_wrapper.py b/qlib/env/env_wrapper.py deleted file mode 100644 index f08c99a2c..000000000 --- a/qlib/env/env_wrapper.py +++ /dev/null @@ -1,33 +0,0 @@ - - -class BaseEnvWrapper: - - """ - # Base Env Wrapper for Reforcement Learning Framework - - class EnvWrapper(BaseEnvWrapper): - """ - def __init__(self, sub_env, action_interpreter, state_interpreter): - self.sub_env = sub_env - self.action_interpreter = action_interpreter - self.state_interpreter = state_interpreter - - def reset(self, **kwargs): - self.upper_state = kwargs.get("upper_state", None) - self.sub_env.reset() - - def step(self, action): - sub_action = self.action_interpreter.interpret(action) - sub_state = self.sub_env.step(sub_action) - state = self.state_interpreter.interpret(sub_state) - return state - reurn self. - if self.track: - yield action - yield from - - def finished(self): - return self.sub_env.finished() - - - \ No newline at end of file diff --git a/qlib/env/interpreter.py b/qlib/env/interpreter.py deleted file mode 100644 index 94d6f9ec2..000000000 --- a/qlib/env/interpreter.py +++ /dev/null @@ -1,15 +0,0 @@ - -class BaseInterpreter: - @staticmethod - def interpret(**kwargs): - raise NotImplementedError("interpret is not implemented!") - -class ActionInterpreter: - @staticmethod - def interpret(action, **kwargs): - return action - -class StateInterpreter: - @staticmethod - def interpret(state, **kwargs): - return state \ No newline at end of file From 86a6f565e826362006e71b458c8b7a6465fe685b Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 29 Apr 2021 02:15:34 +0800 Subject: [PATCH 008/187] trade_account support multi bar report --- examples/highfreq/backtest/workflow.py | 14 +- qlib/contrib/backtest/__init__.py | 2 + qlib/contrib/backtest/account.py | 95 +++++++++--- qlib/contrib/backtest/env.py | 135 +++++++++++------- qlib/contrib/backtest/exchange.py | 30 ++-- qlib/contrib/backtest/position.py | 34 ++--- qlib/contrib/backtest/report.py | 12 +- .../analysis_position/parse_position.py | 2 +- qlib/contrib/strategy/__init__.py | 2 +- qlib/contrib/strategy/cost_control.py | 2 +- .../{dl_strategy.py => model_strategy.py} | 16 ++- qlib/contrib/strategy/rule_strategy.py | 39 +++-- qlib/strategy/base.py | 22 +-- qlib/utils/__init__.py | 108 +++++++++----- qlib/workflow/record_temp.py | 58 ++++---- 15 files changed, 362 insertions(+), 209 deletions(-) rename qlib/contrib/strategy/{dl_strategy.py => model_strategy.py} (95%) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index 38c1eecc8..e5a832927 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -81,7 +81,7 @@ if __name__ == "__main__": backtest_config={ "strategy": { "class": "TopkDropoutStrategy", - "module_path": "qlib.contrib.strategy.dl_strategy", + "module_path": "qlib.contrib.strategy.model_strategy", "kwargs": { "step_bar": "week", "model": model, @@ -113,6 +113,18 @@ if __name__ == "__main__": } } } + }, + "backtest":{ + "start_time": trade_start_time, + "end_time": trade_end_time, + "verbose": False, + "limit_threshold": 0.095, + "account": 100000000, + "benchmark": benchmark, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, } } diff --git a/qlib/contrib/backtest/__init__.py b/qlib/contrib/backtest/__init__.py index 8796d0057..4a03bbe47 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/contrib/backtest/__init__.py @@ -19,6 +19,7 @@ logger = get_module_logger("backtest caller") def get_exchange( exchange=None, + freq="day", start_time=None, end_time=None, codes = "all", @@ -72,6 +73,7 @@ def get_exchange( deal_price = "$" + deal_price exchange = Exchange( + freq=freq, start_time=start_time, end_time=end_time, codes=codes, diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index c44d26d7b..981e3c07a 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -3,10 +3,13 @@ import copy +import pandas as pd from .position import Position from .report import Report from .order import Order +from ...utils import parse_freq, sample_feature + """ @@ -26,21 +29,86 @@ rtn & earning in the Account class Account: - def __init__(self, init_cash, last_trade_time=None): - self.init_vars(init_cash, last_trade_time) + def __init__(self, init_cash, benchmark=None, start_time=None, end_time=None, freq=None): + self.init_vars(init_cash, benchmark, start_time, end_time) - def init_vars(self, init_cash, last_trade_time=None): + def init_vars(self, init_cash, benchmark=None, start_time=None, end_time=None, freq=None): + """ + Parameters + ---------- + - benchmark: str/list/pd.Series + `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. + example: + print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) + 2017-01-04 0.011693 + 2017-01-05 0.000721 + 2017-01-06 -0.004322 + 2017-01-09 0.006874 + 2017-01-10 -0.003350 + `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. + `benchmark` is str, will use the daily change as the 'bench'. + benchmark code, default is SH000905 CSI500 + + """ # init cash self.init_cash = init_cash + self.benchmark = benchmark + self.start_time = start_time + self.end_time = end_time + self.freq = freq self.current = Position(cash=init_cash) self.positions = {} self.rtn = 0 self.ct = 0 self.to = 0 self.val = 0 - self.report = Report() self.earning = 0 - self.last_trade_time = last_trade_time + self.report = Report() + if freq and benchmark: + self.bench = self._cal_benchmark(benchmark, start_time, end_time, freq) + + def _cal_benchmark(self, benchmark, start_time=None, end_time=None, freq=None): + if isinstance(benchmark, pd.Series): + return benchmark + else: + if freq is None: + raise ValueError("benchmark freq can't be None!") + _codes = benchmark if isinstance(benchmark, list) else [benchmark] + fields = ["$close/Ref($close,1)-1"] + try: + _temp_result = D.features(_codes, fields, start_time, end_time, freq=freq, disk_cache=1) + except ValueError: + _, norm_freq = parse_freq(freq) + if norm_freq in ["month", "week", "day"]: + try: + _temp_result = D.features(_codes, fields, start_time, end_time, freq="day", disk_cache=1) + except ValueError: + _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) + elif norm_freq == "minute": + _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) + else: + raise ValueError(f"benchmark freq {freq} is not supported") + if len(_temp_result) == 0: + raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") + return _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean().fillna(0) + + def _sample_benchmark(self, bench, trade_start_time, trade_end_time): + def cal_change(x): + return x.prod() - 1 + return sample_feature(bench, trade_start_time, trade_end_time, method=cal_change) + + def reset(self, benchmark=None, freq=None,**kwargs): + if benchmark: + self.benchmark = benchmark + if freq: + self.freq = freq + if self.freq and self.benchmark and (freq or benchmark) + self.bench = self._cal_benchmark(self.benchmark, self.start_time, self.end_time, self.freq) + + for k, v in kwargs: + if hasattr(k): + setattr(k, v) + def get_positions(self): return self.positions @@ -83,7 +151,7 @@ class Account: self.current.update_order(order, trade_val, cost, trade_price) self.update_state_from_order(order, trade_val, cost, trade_price) - def update_bar_end(self, trade_start_time, trade_end_time, trade_exchange): + def update_report(self, trade_start_time, trade_end_time, trade_exchange): """ start_time: pd.TimeStamp end_time: pd.TimeStamp @@ -100,20 +168,17 @@ class Account: """ # update price for stock in the position and the profit from changed_price stock_list = self.current.get_stock_list() - profit = 0 for code in stock_list: # if suspend, no new price to be updated, profit is 0 if trade_exchange.check_stock_suspended(code, trade_start_time, trade_end_time): continue bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) - profit += (bar_close - self.current.position[code]["price"]) * self.current.position[code]["amount"] self.current.update_stock_price(stock_id=code, price=bar_close) - self.rtn += profit # update holding day count - self.current.add_count_all() + self.current.add_count_all(bar=self.freq) # update value self.val = self.current.calculate_value() - # update earning (2nd view of return) + # update earning # account_value - last_account_value # for the first trade date, account_value - init_cash # self.report.is_empty() to judge is_first_trade_date @@ -138,6 +203,7 @@ class Account: turnover_rate=self.to / last_account_value, cost_rate=self.ct / last_account_value, stock_value=now_stock_value, + bench_value=self._sample_benchmark(self.bench, trade_start_time, trade_end_time) ) # set now_account_value to position self.current.position["now_account_value"] = now_account_value @@ -148,23 +214,20 @@ class Account: # finish today's updation # reset the daily variables - self.rtn = 0 self.ct = 0 self.to = 0 - self.last_trade_time = (trade_start_time, trade_end_time) def load_account(self, account_path): report = Report() position = Position() - last_trade_time = position.load_position(account_path / "position.xlsx") report.load_report(account_path / "report.csv") + position.load_position(account_path / "position.xlsx") # assign values self.init_vars(position.init_cash) self.current = position self.report = report - self.last_trade_time = last_trade_time def save_account(self, account_path): - self.current.save_position(account_path / "position.xlsx", self.last_trade_time) + self.current.save_position(account_path / "position.xlsx") self.report.save_report(account_path / "report.csv") diff --git a/qlib/contrib/backtest/env.py b/qlib/contrib/backtest/env.py index 85a6c1ec3..9fa993e7b 100644 --- a/qlib/contrib/backtest/env.py +++ b/qlib/contrib/backtest/env.py @@ -9,12 +9,26 @@ import numpy as np import pandas as pd from ...data.data import Cal from ...utils import get_sample_freq_calendar +from .position import Position +from .report import Report from .order import Order -class TradeCalendarBase: + +class BaseTradeCalendar: + def __init__( + self, + step_bar, + start_time=None, + end_time=None, + **kwargs + ): + self.step_bar = step_bar + self.reset(start_time=start_time, end_time=end_time) def _reset_trade_calendar(self, start_time, end_time): + if not start_time and not end_time: + return if start_time: self.start_time = pd.Timestamp(start_time) if end_time: @@ -24,37 +38,33 @@ class TradeCalendarBase: self.calendar = _calendar _start_time, _end_time, _start_index, _end_index = Cal.locate_index(self.start_time, self.end_time, freq=freq, freq_sam=freq_sam) _trade_calendar = self.calendar[_start_index: _end_index + 1] - if _start_time != self.start_time: - self.trade_calendar = np.hstack((self.start_time, _trade_calendar, self.end_time)) - self.start_index = _start_index - 1 - else: - self.trade_calendar = np.hstack((_trade_calendar, self.end_time)) - self.start_index = _start_index + self.start_index = _start_index self.end_index = _end_index + self.trade_len = _end_index - _start_index + 1 self.trade_index = 0 - self.trade_len = len(self.trade_calendar) else: raise ValueError("failed to reset trade calendar, params `start_time` or `end_time` is None.") - def _get_trade_time(self, trade_index=1, shift=0): - trade_index = trade_index - shift - if 0 < trade_index < self.trade_len - 1: - trade_start_time = self.trade_calendar[trade_index - 1] - trade_end_time = self.trade_calendar[trade_index] - pd.Timedelta(seconds=1) - return trade_start_time, trade_end_time - elif trade_index == self.trade_len - 1: - trade_start_time = self.trade_calendar[trade_index - 1] - trade_end_time = self.trade_calendar[trade_index] - return trade_start_time, trade_end_time - else: - raise RuntimeError("trade_index out of range") + def reset(self, start_time=None, end_time=None, **kwargs): + if start_time or end_time: + self._reset_trade_calendar(start_time=start_time, end_time=end_time) + + for k, v in kwargs: + if hasattr(self, k): + setattr(self, k, v) - def _get_calendar_time(self, trade_index=1, shift=1): + def _get_calendar_time(self, trade_index=1, shift=0): trade_index = trade_index - shift calendar_index = self.start_index + trade_index return self.calendar[calendar_index - 1], self.calendar[calendar_index] -class BaseEnv(TradeCalendarBase): + def finished(self): + return self.trade_index >= self.trade_len + + def step(self): + self.trade_index = self.trade_index + 1 + +class BaseEnv(BaseTradeCalendar): """ # Strategy framework document @@ -67,38 +77,32 @@ class BaseEnv(TradeCalendarBase): start_time=None, end_time=None, trade_account=None, + update_report=False, verbose=False, **kwargs, ): - self.step_bar = step_bar + self.generate_report = update_report self.verbose = verbose - self.reset(start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs) - - def _get_position(self): - return self.trade_account.current + super(BaseEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs) - - def reset(self, start_time=None, end_time=None, trade_account=None, **kwargs): - if start_time or end_time: - self._reset_trade_calendar(start_time=start_time, end_time=end_time) + def reset(self, trade_account=None, **kwargs): + super(BaseEnv, self).reset(**kwargs) if trade_account: self.trade_account = trade_account - - for k, v in kwargs: - if hasattr(self, k): - setattr(self, k, v) + self.trade_account.reset(freq=self.step_bar, report=Report(), positions={}) def get_init_state(self): - init_state = {"current": self._get_position()} + init_state = {"current": self.trade_account.current} return init_state + def execute(self, **kwargs): + raise NotImplementedError("execute is not implemented!") - def execute(self, order_list=None, **kwargs): - self.trade_index = self.trade_index + 1 - - def finished(self): - return self.trade_index >= self.trade_len - 1 + def get_trade_account(self): + raise NotImplementedError("get_trade_account is not implemented!") + def get_report(self): + raise NotImplementedError("get_report is not implemented!") class SplitEnv(BaseEnv): def __init__( @@ -109,33 +113,44 @@ class SplitEnv(BaseEnv): start_time=None, end_time=None, trade_account=None, + update_report=False, verbose=False, **kwargs ): self.sub_env = sub_env self.sub_strategy = sub_strategy - super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, verbose=verbose) + super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, update_report=update_report, verbose=verbose, **kwargs) + def reset(self, trade_account=None, **kwargs): + super(SplitEnv, self).reset(trade_account=trade_account, **kwargs) + if trade_account: + self.sub_env.reset(trade_account=copy.copy(trade_account)) + def execute(self, order_list, **kwargs): if self.finished(): raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") #if self.track: # yield action #episode_reward = 0 - super(SplitEnv, self).execute(**kwargs) - trade_start_time, trade_end_time = self._get_trade_time(trade_index=self.trade_index) - self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time, trade_account=self.trade_account) + super(SplitEnv, self).step() + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) + self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time) self.sub_strategy.reset(start_time=trade_start_time, end_time=trade_end_time, trade_order_list=order_list) trade_state = self.sub_env.get_init_state() while not self.sub_env.finished(): _order_list = self.sub_strategy.generate_order_list(**trade_state) trade_state, trade_info = self.sub_env.execute(order_list=_order_list) - #episode_reward += sub_reward - _obs = {"current": self._get_position()} + + if self.generate_report: + self.trade_account.update_report(trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange) + _obs = {"current": self.trade_account.current} _info = {} return _obs, _info - + def get_report(self): + _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None + _positions = self.trade_account.get_positions() if self.generate_report else None + return [(_report,_positions), *sub_env.get_report()] class SimulatorEnv(BaseEnv): @@ -146,10 +161,11 @@ class SimulatorEnv(BaseEnv): end_time=None, trade_account=None, trade_exchange=None, + update_report=False, verbose=False, **kwargs, ): - super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, trade_exchange=trade_exchange, verbose=verbose, **kwargs) + super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, trade_exchange=trade_exchange, update_report=update_report, verbose=verbose, **kwargs) def reset(self, trade_exchange=None, **kwargs): super(SimulatorEnv, self).reset(**kwargs) @@ -162,8 +178,8 @@ class SimulatorEnv(BaseEnv): """ if self.finished(): raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") - super(SimulatorEnv, self).execute(**kwargs) - trade_start_time, trade_end_time = self._get_trade_time(trade_index=self.trade_index) + super(SimulatorEnv, self).step() + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) trade_info = [] for order in order_list: if self.trade_exchange.check_order(order) is True: @@ -197,7 +213,18 @@ class SimulatorEnv(BaseEnv): print("[W {:%Y-%m-%d}]: {} wrong.".format(trade_start_time, order.stock_id)) # do nothing pass - self.trade_account.update_bar_end(trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange) - _obs = {"current": self._get_position()} + if self.generate_report: + self.trade_account.update_report(trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange) + _obs = {"current": self.trade_account.current} _info = {"trade_info": trade_info} - return _obs, _info \ No newline at end of file + return _obs, _info + + def get_report(self): + _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None + _positions = self.trade_account.get_positions() if self.generate_report else None + return [ + { + "report": _report, + "positions": _positions + } + ] \ No newline at end of file diff --git a/qlib/contrib/backtest/exchange.py b/qlib/contrib/backtest/exchange.py index 62f6c63bd..399f9e151 100644 --- a/qlib/contrib/backtest/exchange.py +++ b/qlib/contrib/backtest/exchange.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd from ...data.data import D +from ...data.dataset.utils import get_level_index from ...config import C, REG_CN from ...utils import sample_feature from ...log import get_module_logger @@ -19,6 +20,7 @@ from .order import Order class Exchange: def __init__( self, + freq="day", start_time=None, end_time=None, codes="all", @@ -55,6 +57,7 @@ class Exchange: target on this day). index: MultipleIndex(instrument, pd.Datetime) """ + self.freq = freq self.start_time = start_time self.end_time = end_time if trade_unit is None: @@ -105,7 +108,7 @@ class Exchange: def set_quote(self, codes, start_time, end_time): if len(codes) == 0: codes = D.instruments() - self.quote = D.features(codes, self.all_fields, start_time, end_time, disk_cache=True).dropna(subset=["$close"]) + self.quote = D.features(codes, self.all_fields, start_time, end_time, freq=self.freq, disk_cache=True).dropna(subset=["$close"]) self.quote.columns = self.all_fields if self.quote[self.deal_price].isna().any(): @@ -146,7 +149,14 @@ class Exchange: quote_df = pd.concat([quote_df, self.extra_quote], sort=False, axis=0) # update quote: pd.DataFrame to dict, for search use - self.quote = quote_df + if get_level_index(quote_df, level="datetime") == 1: + quote_df = quote_df.swaplevel().sort_index() + + quote_dict = {} + for stock_id, stock_val in quote_df.groupby(level="instrument"): + quote_dict[stock_id] = stock_val + + self.quote = quote_dict def _update_limit(self, buy_limit, sell_limit): self.quote["limit"] = ~self.quote["$change"].between(-sell_limit, buy_limit, inclusive=False) @@ -157,13 +167,15 @@ class Exchange: trade_date is limtited """ - return sample_feature(self.quote, stock_id, start_time, end_time, fields="limit", method="any").iloc[0] + return sample_feature(self.quote[stock_id], start_time, end_time, fields="limit", method="all").iloc[0] def check_stock_suspended(self, stock_id, start_time, end_time): # is suspended - return sample_feature(self.quote, stock_id, start_time, end_time).empty - + if stock_id in self.quote: + return sample_feature(self.quote[stock_id], start_time, end_time, method=None) is None + else: + return True def is_stock_tradable(self, stock_id, start_time, end_time): # check if stock can be traded @@ -217,13 +229,13 @@ class Exchange: return trade_val, trade_cost, trade_price def get_quote_info(self, stock_id, start_time, end_time): - return sample_feature(self.quote, stock_id, start_time, end_time) + return sample_feature(self.quote[stock_id], start_time, end_time, method="last").iloc[0] def get_close(self, stock_id, start_time, end_time): - return sample_feature(self.quote, stock_id, start_time, end_time, fields="$close", method="last").iloc[0] + return sample_feature(self.quote[stock_id], start_time, end_time, fields="$close", method="last").iloc[0] def get_deal_price(self, stock_id, start_time, end_time): - deal_price = sample_feature(self.quote, stock_id, start_time, end_time, fields=self.deal_price, method="last").iloc[0] + deal_price = sample_feature(self.quote[stock_id], start_time, end_time, fields=self.deal_price, method="last").iloc[0] if np.isclose(deal_price, 0.0) or np.isnan(deal_price): self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {self.deal_price}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") @@ -231,7 +243,7 @@ class Exchange: return deal_price def get_factor(self, stock_id, start_time, end_time): - return sample_feature(self.quote, stock_id, start_time, end_time, fields="$factor", method="last").iloc[0] + return sample_feature(self.quote[stock_id], start_time, end_time, fields="$factor", method="last").iloc[0] def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): """ diff --git a/qlib/contrib/backtest/position.py b/qlib/contrib/backtest/position.py index ac1a471f8..6eb2c97b8 100644 --- a/qlib/contrib/backtest/position.py +++ b/qlib/contrib/backtest/position.py @@ -38,7 +38,6 @@ class Position: def init_stock(self, stock_id, amount, price=None): self.position[stock_id] = {} - self.position[stock_id]["count"] = 0 # update count in the end of this date self.position[stock_id]["amount"] = amount self.position[stock_id]["price"] = price self.position[stock_id]["weight"] = 0 # update the weight in the end of the trade date @@ -87,8 +86,8 @@ class Position: def update_stock_price(self, stock_id, price): self.position[stock_id]["price"] = price - def update_stock_count(self, stock_id, count): - self.position[stock_id]["count"] = count + def update_stock_count(self, stock_id, bar, count): + self.position[stock_id][f"count_{bar}"] = count def update_stock_weight(self, stock_id, weight): self.position[stock_id]["weight"] = weight @@ -118,8 +117,11 @@ class Position: def get_stock_amount(self, code): return self.position[code]["amount"] - def get_stock_count(self, code): - return self.position[code]["count"] + def get_stock_count(self, code, bar): + if f"count_{bar}" in self.position[code]: + return self.position[code][f"count_{bar}"] + else: + return 0 def get_stock_weight(self, code): return self.position[code]["weight"] @@ -153,25 +155,26 @@ class Position: d[stock_code] = self.position[stock_code]["amount"] * self.position[stock_code]["price"] / position_value return d - def add_count_all(self): + def add_count_all(self, bar): stock_list = self.get_stock_list() for code in stock_list: - self.position[code]["count"] += 1 + if f"count_{bar}" in self.position[code]: + self.position[code][f"count_{bar}"] += 1 + else: + self.position[code][f"count_{bar}"] = 1 def update_weight_all(self): weight_dict = self.get_stock_weight_dict() for stock_code, weight in weight_dict.items(): self.update_stock_weight(stock_code, weight) - def save_position(self, path, last_trade_time): + def save_position(self, path): path = pathlib.Path(path) p = copy.deepcopy(self.position) cash = pd.Series(dtype=np.float) cash["init_cash"] = self.init_cash cash["cash"] = p["cash"] cash["now_account_value"] = p["now_account_value"] - cash["last_trade_start_time"] = str(last_trade_time[0]) if last_trade_time else None - cash["last_trade_end_time"] = str(last_trade_time[1]) if last_trade_time else None del p["cash"] del p["now_account_value"] positions = pd.DataFrame.from_dict(p, orient="index") @@ -183,8 +186,8 @@ class Position: """load position information from a file should have format below sheet "position" - columns: ['stock', 'count', 'amount', 'price', 'weight'] - 'count': , + columns: ['stock', f'count_{bar}', 'amount', 'price', 'weight'] + f'count_{bar}': , 'amount': , 'price': , 'weight': , @@ -202,16 +205,9 @@ class Position: init_cash = cash_record.loc["init_cash"].values[0] cash = cash_record.loc["cash"].values[0] now_account_value = cash_record.loc["now_account_value"].values[0] - last_trade_start_time = cash_record.loc["last_trade_start_time"].values[0] - last_trade_end_time = cash_record.loc["last_trade_end_time"].values[0] - # assign values self.position = {} self.init_cash = init_cash self.position = positions self.position["cash"] = cash self.position["now_account_value"] = now_account_value - - last_trade_start_time = None if pd.isna(last_trade_start_time) else pd.Timestamp(last_trade_start_time) - last_trade_end_time = None if pd.isna(last_trade_end_time) else pd.Timestamp(last_trade_end_time) - return last_trade_start_time, last_trade_end_time diff --git a/qlib/contrib/backtest/report.py b/qlib/contrib/backtest/report.py index 9a57156f2..3bee440e0 100644 --- a/qlib/contrib/backtest/report.py +++ b/qlib/contrib/backtest/report.py @@ -21,6 +21,7 @@ class Report: self.costs = OrderedDict() # trade cost for each trade date self.values = OrderedDict() # value for each trade date self.cashes = OrderedDict() + self.benches = OrderedDict() self.latest_report_time = None # pd.TimeStamp def is_empty(self): @@ -41,6 +42,7 @@ class Report: turnover_rate=None, cost_rate=None, stock_value=None, + bench_value=None, ): # check data if None in [ @@ -51,9 +53,10 @@ class Report: turnover_rate, cost_rate, stock_value, + bench_value ]: raise ValueError( - "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value]" + "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value, bench_value]" ) # update report data self.accounts[trade_time] = account_value @@ -62,6 +65,7 @@ class Report: self.costs[trade_time] = cost_rate self.values[trade_time] = stock_value self.cashes[trade_time] = cash + self.benches[trade_time] = bench_value # update latest_report_date self.latest_report_time = trade_time # finish daily report update @@ -74,7 +78,8 @@ class Report: report["cost"] = pd.Series(self.costs) report["value"] = pd.Series(self.values) report["cash"] = pd.Series(self.cashes) - report.index.name = "trade_time" + report["bench"] = pd.Series(self.benches) + report.index.name = "datetime" return report def save_report(self, path): @@ -84,7 +89,7 @@ class Report: def load_report(self, path): """load report from a file should have format like - columns = ['account', 'return', 'turnover', 'cost', 'value', 'cash'] + columns = ['account', 'return', 'turnover', 'cost', 'value', 'cash', 'bench'] :param path: str/ pathlib.Path() """ @@ -103,4 +108,5 @@ class Report: turnover_rate=r.loc[trade_time]["turnover"], cost_rate=r.loc[trade_time]["cost"], stock_value=r.loc[trade_time]["value"], + bench_value=r.loc[trade_time]["bench"] ) diff --git a/qlib/contrib/report/analysis_position/parse_position.py b/qlib/contrib/report/analysis_position/parse_position.py index fe1d61137..c5d48ff8e 100644 --- a/qlib/contrib/report/analysis_position/parse_position.py +++ b/qlib/contrib/report/analysis_position/parse_position.py @@ -41,7 +41,7 @@ def parse_position(position: dict = None) -> pd.DataFrame: for _trading_date, _value in position.items(): # pd_date type: pd.Timestamp _cash = _value.pop("cash") - for _item in ["today_account_value"]: + for _item in ["now_account_value"]: if _item in _value: _value.pop(_item) diff --git a/qlib/contrib/strategy/__init__.py b/qlib/contrib/strategy/__init__.py index 678b048c2..b138edb23 100644 --- a/qlib/contrib/strategy/__init__.py +++ b/qlib/contrib/strategy/__init__.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. -from .dl_strategy import ( +from .model_strategy import ( TopkDropoutStrategy, WeightStrategyBase, ) diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index 962936f9f..111cc276a 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. -from .dl_strategy import WeightStrategyBase +from .model_strategy import WeightStrategyBase import copy diff --git a/qlib/contrib/strategy/dl_strategy.py b/qlib/contrib/strategy/model_strategy.py similarity index 95% rename from qlib/contrib/strategy/dl_strategy.py rename to qlib/contrib/strategy/model_strategy.py index 4c7d16eea..9aab96377 100644 --- a/qlib/contrib/strategy/dl_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -81,10 +81,12 @@ class TopkDropoutStrategy(ModelStrategy): return self.risk_degree def generate_order_list(self, current, **kwargs): - super(TopkDropoutStrategy, self).generate_order_list() - trade_start_time, trade_end_time = self._get_trade_time(self.trade_index) + super(TopkDropoutStrategy, self).step() + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") + if pred_score is None: + return [] if self.only_tradable: # If The strategy only consider tradable stock when make decision # It needs following actions to filter stocks @@ -168,7 +170,7 @@ class TopkDropoutStrategy(ModelStrategy): continue if code in sell: # check hold limit - if self.stock_count[code] < self.thresh or current_temp.get_stock_count(code) < self.hold_thresh: + if self.stock_count[code] < self.thresh or current_temp.get_stock_count(code, bar=self.step_bar) < self.hold_thresh: # can not sell this code # no buy signal, but the stock is kept self.stock_count[code] += 1 @@ -271,10 +273,12 @@ class WeightStrategyBase(ModelStrategy): """ # generate_order_list # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list - super(WeightStrategyBase, self).generate_order_list() - trade_start_time, trade_end_time = self._get_trade_time(self.trade_index) - pred_start_time, pred_end_time = self._get_pred_time() + super(WeightStrategyBase, self).step() + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) + pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") + if pred_score is None: + return [] current_temp = copy.deepcopy(trade_account.current) target_weight_position = self.generate_target_weight_position( score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index b51ec9aca..b432ccea2 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -5,6 +5,7 @@ import pandas as pd from ...utils import sample_feature from ...data.data import D +from ...data.dataset.utils import get_level_index from ...strategy.base import RuleStrategy, TradingEnhancement from ..backtest.order import Order @@ -21,8 +22,8 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): def generate_order_list(self, **kwargs): - super(TopkDropoutStrategy, self).generate_order_list() - trade_start_time, trade_end_time = self._get_trade_time() + super(TopkDropoutStrategy, self).step() + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) order_list = [] for order in self.trade_order_list: _order = Order( @@ -59,8 +60,8 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): raise NotImplementedError("pred_price_trend method is not implemented!") def generate_order_list(self, **kwargs): - super(SBBStrategyBase, self).generate_order_list() - trade_start_time, trade_end_time = self._get_trade_time() + super(SBBStrategyBase, self).step() + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) order_list = [] for order in self.trade_order_list: @@ -127,21 +128,33 @@ class SBBStrategyEMA(SBBStrategyBase): if isinstance(instruments, str): self.instruments = D.instruments(instruments) self.freq = freq - + + def _convert_index_format(self, df): + if get_level_index(df, level="datetime") == 1: + df = df.swaplevel().sort_index() + return df def _reset_trade_calendar(self, start_time=None, end_time=None): super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time) if self.start_time and self.end_time: fields = ["EMA($close, 10)-EMA($close, 20)"] signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) - self.signal = D.features(self.instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq) - self.signal.columns = ["signal"] - + signal_df = D.features(self.instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq) + signal_df = self._convert_index_format(signal_df) + signal_df.columns = ["signal"] + self.signal = {} + for stock_id, stock_val in signal_df.groupby(level="instrument"): + self.signal[stock_id] = stock_val + def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): - _sample_signal = sample_feature(self.signal, stock_id, start_time=pred_start_time, end_time=pred_end_time, fields="signal", method="last") - if _sample_signal.empty: + if stock_id not in self.signal: return self.TREND_MID - elif _sample_signal.iloc[0] > 0: - return self.TREND_LONG else: - return self.TREND_SHORT \ No newline at end of file + _sample_signal = sample_feature(self.signal[stock_id], pred_start_time, pred_end_time, fields="signal", method="last") + if _sample_signal is None or _sample_signal.iloc[0] == 0: + return self.TREND_MID + elif _sample_signal.iloc[0] > 0: + return self.TREND_LONG + else: + return self.TREND_SHORT + \ No newline at end of file diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 193906dcd..fb5b44334 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -12,7 +12,7 @@ from ..utils import get_sample_freq_calendar from ..data.dataset import DatasetH from ..data.dataset.utils import get_level_index from ..contrib.backtest.order import Order -from ..contrib.backtest.env import TradeCalendarBase +from ..contrib.backtest.env import BaseTradeCalendar """ 1. BaseStrategy 的粒度一定是数据粒度的整数倍 @@ -20,22 +20,10 @@ from ..contrib.backtest.env import TradeCalendarBase - adjust_dates这个东西啥用 - label和freq和strategy的bar分离,这个如何决策呢 """ -class BaseStrategy(TradeCalendarBase): - def __init__(self, step_bar, start_time=None, end_time=None, **kwargs): - self.step_bar = step_bar - self.reset(start_time=start_time, end_time=end_time, **kwargs) - - def reset(self, start_time=None, end_time=None, **kwargs): - if start_time or end_time : - self._reset_trade_calendar(start_time=start_time, end_time=end_time) - - for k, v in kwargs: - if hasattr(self, k): - setattr(self, k, v) - +class BaseStrategy(BaseTradeCalendar): def generate_order_list(self, **kwargs): - self.trade_index = self.trade_index + 1 + raise NotImplementedError("generator_order_list is not implemented!") class RuleStrategy(BaseStrategy): @@ -50,14 +38,14 @@ class ModelStrategy(BaseStrategy): super(ModelStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) def _convert_index_format(self, df): - if get_level_index(df, level="datetime") == 0: + if get_level_index(df, level="datetime") == 1: df = df.swaplevel().sort_index() return df def _update_model(self): """update pred score """ - pass + raise NotImplementedError("_update_model is not implemented!") class TradingEnhancement: def reset(self, trade_order_list=None): diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index 0f365956d..ea573d819 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -861,15 +861,38 @@ def sample_calendar_bac(calendar_raw, freq_raw, freq_sam): else: raise ValueError("sample freq must be xmin, xd, xw, xm") +def parse_freq(freq): + freq = freq.lower() + search_obj =re.search("^([0-9]*)([a-z]+)", freq) + if search_obj is None: + raise ValueError("freq format is not supported") + _count = int(search_obj.group(1) if search_obj.group(1) else "1") + _freq = search_obj.group(2) + _freq_format_dict = { + "month": "month", + "mon": "month", + "week": "week", + "w": "week", + "day": "day", + "d": "day", + "minute": "minute", + "min": "minute", + } + try: + _freq = _freq_format_dict.get(_freq) + except KeyError: + raise ValueError("freq format is not supported, the supported freq includes (x)month/m, (x)day/d, (x)minute/min") + return _count, _freq + def sample_calendar(calendar_raw, freq_raw, freq_sam): """ freq_raw : "min" or "day" """ - freq_raw = "1" + freq_raw if re.match("^[0-9]", freq_raw) is None else freq_raw - freq_sam = "1" + freq_sam if re.match("^[0-9]", freq_sam) is None else freq_sam + raw_count, freq_raw = parse_freq(freq_raw) + sam_count, freq_sam = parse_freq(freq_sam) if not len(calendar_raw): return calendar_raw - if freq_sam.endswith(("minute", "min")): + if freq_sam == "minute": def cal_next_sam_minute(x, sam_minutes): hour = x.hour minute = x.minute @@ -888,38 +911,36 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): return 13 + (minute_index - 120) // 60, (minute_index - 120) % 60 else: raise ValueError("calendar minute_index error") - sam_minutes = int(freq_sam[:-3]) if freq_sam.endswith("min") else int(freq_sam[:-6]) - if not freq_raw.endswith(("minute", "min")): + + if req_raw != "minute": raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") else: - raw_minutes = int(freq_raw[:-3]) if freq_raw.endswith("min") else int(freq_raw[:-6]) - if raw_minutes > sam_minutes: + if raw_count > sam_count: raise ValueError("raw freq must be higher than sample freq") - _calendar_minute = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_minutes), 0), calendar_raw))) + _calendar_minute = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_count), 0), calendar_raw))) if calendar_raw[0] > _calendar_minute[0]: _calendar_minute[0] = calendar_raw[0] return _calendar_minute else: _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 0, 0, 0), calendar_raw))) - if freq_sam.endswith(("day", "d")): - sam_days = int(freq_sam[:-1]) if freq_sam.endswith("d") else int(freq_sam[:-3]) - return _calendar_day[::sam_days] + if freq_sam == "day": + return _calendar_day[::sam_count] - elif freq_sam.endswith(("week", "w")): - sam_weeks = int(freq_sam[:-1]) if freq_sam.endswith("w") else int(freq_sam[:-4]) + elif freq_sam == "week": _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) _calendar_week = _calendar_day[np.ediff1d(_day_in_week, to_begin=-1) < 0] - return _calendar_week[::sam_weeks] + return _calendar_week[::sam_count] - elif freq_sam.endswith(("month", "m")): - sam_months = int(freq_sam[:-1]) if freq_sam.endswith("m") else int(freq_sam[:-5]) + elif freq_sam == "month": _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) _calendar_month = _calendar_day[np.ediff1d(_day_in_month, to_begin=-1) < 0] - return _calendar_month[::sam_months] + return _calendar_month[::sam_count] else: raise ValueError("sample freq must be xmin, xd, xw, xm") def get_sample_freq_calendar(start_time=None, end_time=None, freq="day", **kwargs): + _, norm_freq = parse_freq(freq) + from ..data.data import Cal try: @@ -927,34 +948,47 @@ def get_sample_freq_calendar(start_time=None, end_time=None, freq="day", **kwarg freq, freq_sam = freq, None except ValueError: freq_sam = freq - if freq.endswith(("m", "month", "w", "week", "d", "day")): + if norm_freq in ["month", "week", "day"]: try: - _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, **kwargs) - freq = "min" - except ValueError: _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="day", freq_sam=freq, **kwargs) freq = "day" - elif freq.endswith(("min", "minute")): + except ValueError: + raise + _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, **kwargs) + freq = "min" + elif norm_freq == "minute": _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, **kwargs) freq = "min" else: raise ValueError(f"freq {freq} is not supported") return _calendar, freq, freq_sam -def sample_feature(feature, instruments=None, start_time=None, end_time=None, fields=None, method=None, method_kwargs={}): - if instruments and not isinstance(instruments, list): - instruments = [instruments] - selector_inst = slice(None) if instruments is None else instruments +def sample_feature(feature, start_time=None, end_time=None, fields=None, method="last", method_kwargs={}): selector_datetime = slice(start_time, end_time) - if isinstance(feature, pd.Series): - feature = feature.loc[(selector_inst, selector_datetime)] - if fields: - warnings.warn(f"sample series feature, {fields} is ignored!") - elif isinstance(feature, pd.DataFrame): - selector_fields = slice(None) if fields is None else fields - feature = feature.loc[(selector_inst, selector_datetime), selector_fields] - if method: - return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) - else: - return feature + fields = fields if fields else slice(None) + from ..data.dataset.utils import get_level_index + + datetime_level = get_level_index(feature, level="datetime") == 0 + if isinstance(feature, pd.Series): + feature = feature.loc[selector_datetime] if datetime_level else feature.loc[(slice(None), selector_datetime)] + elif isinstance(feature, pd.DataFrame): + feature = feature.loc[selector_datetime, fields] if datetime_level else feature.loc[(slice(None), selector_datetime), fields] + if feature.empty: + return None + if isinstance(feature.index, pd.MultiIndex): + if callable(method): + method_func = method + return feature.groupby(level="instrument").apply(lambda x:method_func(x, **method_kwargs)) + elif isinstance(method, str): + return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) + else: + if callable(method): + method_func = method + return method_func(feature, **method_kwargs) + elif isinstance(method, str): + return getattr(feature, method)(**method_kwargs) + + return feature + + \ No newline at end of file diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 2c1b6fecc..51a9a305c 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -233,8 +233,8 @@ class PortAnaRecord(SignalRecord): super().__init__(recorder=recorder, **kwargs) self.strategy_config = config["strategy"] + self.env_config = config["env"] self.backtest_config = config["backtest"] - self.strategy = init_instance_by_config(self.strategy_config, accept_types=BaseStrategy) def generate(self, **kwargs): # check previously stored prediction results @@ -244,36 +244,32 @@ class PortAnaRecord(SignalRecord): super().generate() # custom strategy and get backtest - pred_score = super().load("pred.pkl") - report_dict = normal_backtest(pred_score, strategy=self.strategy, **self.backtest_config) - report_normal = report_dict.get("report_df") - positions_normal = report_dict.get("positions") - self.recorder.save_objects(**{"report_normal.pkl": report_normal}, artifact_path=PortAnaRecord.get_path()) - self.recorder.save_objects(**{"positions_normal.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path()) - order_normal = report_dict.get("order_list") - if order_normal: - self.recorder.save_objects(**{"order_normal.pkl": order_normal}, artifact_path=PortAnaRecord.get_path()) - - # analysis - analysis = dict() - analysis["excess_return_without_cost"] = risk_analysis(report_normal["return"] - report_normal["bench"]) - analysis["excess_return_with_cost"] = risk_analysis( - report_normal["return"] - report_normal["bench"] - report_normal["cost"] - ) - # save portfolio analysis results - analysis_df = pd.concat(analysis) # type: pd.DataFrame - # log metrics - self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) - # save results - self.recorder.save_objects(**{"port_analysis.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path()) - logger.info( - f"Portfolio analysis record 'port_analysis.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" - ) - # print out results - pprint("The following are analysis results of the excess return without cost.") - pprint(analysis["excess_return_without_cost"]) - pprint("The following are analysis results of the excess return with cost.") - pprint(analysis["excess_return_with_cost"]) + report_list = normal_backtest(env=self.env_config, strategy=self.strategy_config, **self.backtest_config) + for report_id, (report_normal, positions_normal) in enumerate(report_list): + if report_dict is None: + continue + + self.recorder.save_objects(**{f"report_normal_{report_id}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path()) + self.recorder.save_objects(**{f"positions_norma_{report_id}l.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path()) + # analysis + analysis = dict() + analysis["excess_return_without_cost"] = risk_analysis(report_normal["return"] - report_normal["bench"]) + analysis["excess_return_with_cost"] = risk_analysis( + report_normal["return"] - report_normal["bench"] - report_normal["cost"] + ) + analysis_df = pd.concat(analysis) # type: pd.DataFrame + # log metrics + self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) + # save results + self.recorder.save_objects(**{f"port_analysis.pkl_{report_id}": analysis_df}, artifact_path=PortAnaRecord.get_path()) + logger.info( + f"Portfolio analysis record 'port_analysis_{report_id}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" + ) + # print out results + pprint("The following are analysis results of the excess return without cost.") + pprint(analysis["excess_return_without_cost"]) + pprint("The following are analysis results of the excess return with cost.") + pprint(analysis["excess_return_with_cost"]) def list(self): return [ From 49cdaf8f5da528daeb2b0a967073c275388e95ad Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 29 Apr 2021 02:28:22 +0800 Subject: [PATCH 009/187] update port_ana_record --- qlib/contrib/backtest/account.py | 1 + qlib/contrib/backtest/backtest.py | 2 +- qlib/workflow/record_temp.py | 55 ++++++++++++++++++------------- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index 981e3c07a..8bf7dedb7 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -214,6 +214,7 @@ class Account: # finish today's updation # reset the daily variables + self.rtn = 0 self.ct = 0 self.to = 0 diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index 2bc349be3..a7e009a9a 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -9,7 +9,7 @@ from .account import Account def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account): - trade_account = Account(init_cash=account) + trade_account = Account(init_cash=account, benchmark=benchmark, start_time=start_time, end_time=end_time) trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) trade_strategy.reset(start_time=start_time, end_time=end_time) diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 51a9a305c..3d7188bcc 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import re +import warnings import pandas as pd from pathlib import Path from pprint import pprint @@ -223,18 +224,23 @@ class PortAnaRecord(SignalRecord): artifact_path = "portfolio_analysis" - def __init__(self, recorder, config, **kwargs): + def __init__(self, recorder, config, risk_analysis_dep, **kwargs): """ config["strategy"] : dict define the strategy class as well as the kwargs. + config["env"] : dict + define the env class as well as the kwargs. config["backtest"] : dict define the backtest kwargs. + risk_analysis_dep : int + risk analyze the dep'th env report """ super().__init__(recorder=recorder, **kwargs) self.strategy_config = config["strategy"] self.env_config = config["env"] self.backtest_config = config["backtest"] + self.risk_analysis_dep = risk_analysis_dep def generate(self, **kwargs): # check previously stored prediction results @@ -245,31 +251,34 @@ class PortAnaRecord(SignalRecord): # custom strategy and get backtest report_list = normal_backtest(env=self.env_config, strategy=self.strategy_config, **self.backtest_config) - for report_id, (report_normal, positions_normal) in enumerate(report_list): + for report_dep, (report_normal, positions_normal) in enumerate(report_list): if report_dict is None: + if self.risk_analysis_dep == report_dep: + warnings.warn(f"the report in dep {risk_analysis_dep} is None, please set the corresponding env with `generate_report==True`") continue - - self.recorder.save_objects(**{f"report_normal_{report_id}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path()) - self.recorder.save_objects(**{f"positions_norma_{report_id}l.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path()) + + self.recorder.save_objects(**{f"report_normal_{report_dep}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path()) + self.recorder.save_objects(**{f"positions_norma_{report_dep}l.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path()) # analysis - analysis = dict() - analysis["excess_return_without_cost"] = risk_analysis(report_normal["return"] - report_normal["bench"]) - analysis["excess_return_with_cost"] = risk_analysis( - report_normal["return"] - report_normal["bench"] - report_normal["cost"] - ) - analysis_df = pd.concat(analysis) # type: pd.DataFrame - # log metrics - self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) - # save results - self.recorder.save_objects(**{f"port_analysis.pkl_{report_id}": analysis_df}, artifact_path=PortAnaRecord.get_path()) - logger.info( - f"Portfolio analysis record 'port_analysis_{report_id}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" - ) - # print out results - pprint("The following are analysis results of the excess return without cost.") - pprint(analysis["excess_return_without_cost"]) - pprint("The following are analysis results of the excess return with cost.") - pprint(analysis["excess_return_with_cost"]) + self.risk_analysis_dep == report_dep: + analysis = dict() + analysis["excess_return_without_cost"] = risk_analysis(report_normal["return"] - report_normal["bench"]) + analysis["excess_return_with_cost"] = risk_analysis( + report_normal["return"] - report_normal["bench"] - report_normal["cost"] + ) + analysis_df = pd.concat(analysis) # type: pd.DataFrame + # log metrics + self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) + # save results + self.recorder.save_objects(**{f"port_analysis.pkl_{report_dep}": analysis_df}, artifact_path=PortAnaRecord.get_path()) + logger.info( + f"Portfolio analysis record 'port_analysis_{report_dep}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" + ) + # print out results + pprint("The following are analysis results of the excess return without cost.") + pprint(analysis["excess_return_without_cost"]) + pprint("The following are analysis results of the excess return with cost.") + pprint(analysis["excess_return_with_cost"]) def list(self): return [ From f404a031f3f9f54b21fd866339dd158459af417e Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 29 Apr 2021 02:29:29 +0800 Subject: [PATCH 010/187] black format --- examples/highfreq/backtest/workflow.py | 30 ++++--- qlib/contrib/backtest/__init__.py | 8 +- qlib/contrib/backtest/account.py | 11 ++- qlib/contrib/backtest/backtest.py | 4 +- qlib/contrib/backtest/env.py | 107 +++++++++++++---------- qlib/contrib/backtest/exchange.py | 28 ++++-- qlib/contrib/backtest/interpreter.py | 5 +- qlib/contrib/backtest/report.py | 13 +-- qlib/contrib/strategy/__init__.py | 6 +- qlib/contrib/strategy/model_strategy.py | 55 +++++++++--- qlib/contrib/strategy/order_generator.py | 22 +++-- qlib/contrib/strategy/rule_strategy.py | 55 +++++++----- qlib/data/data.py | 8 +- qlib/strategy/base.py | 13 +-- qlib/utils/__init__.py | 62 ++++++++----- qlib/workflow/record_temp.py | 20 +++-- 16 files changed, 275 insertions(+), 172 deletions(-) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index e5a832927..d031d40f2 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -28,7 +28,7 @@ if __name__ == "__main__": ################################### # train model ################################### - + data_handler_config = { "start_time": "2008-01-01", "end_time": "2020-08-01", @@ -70,7 +70,7 @@ if __name__ == "__main__": }, }, } - # model initialization + # model initialization model = init_instance_by_config(task["model"]) dataset = init_instance_by_config(task["dataset"]) model.fit(dataset) @@ -78,7 +78,7 @@ if __name__ == "__main__": trade_start_time = "2017-01-31" trade_end_time = "2018-01-31" - backtest_config={ + backtest_config = { "strategy": { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", @@ -90,7 +90,7 @@ if __name__ == "__main__": "n_drop": 5, }, }, - "env":{ + "env": { "class": "SplitEnv", "module_path": "qlib.contrib.backtest.env", "kwargs": { @@ -101,7 +101,7 @@ if __name__ == "__main__": "kwargs": { "step_bar": "day", "verbose": True, - } + }, }, "sub_strategy": { "class": "SBBStrategyEMA", @@ -110,11 +110,11 @@ if __name__ == "__main__": "step_bar": "day", "freq": "day", "instruments": "csi300", - } - } - } + }, + }, + }, }, - "backtest":{ + "backtest": { "start_time": trade_start_time, "end_time": trade_end_time, "verbose": False, @@ -125,8 +125,14 @@ if __name__ == "__main__": "open_cost": 0.0005, "close_cost": 0.0015, "min_cost": 5, - } + }, } - - report_dict = backtest(start_time=trade_start_time, end_time=trade_end_time, **backtest_config, account=1e8, deal_price="$close", verbose=False) \ No newline at end of file + report_dict = backtest( + start_time=trade_start_time, + end_time=trade_end_time, + **backtest_config, + account=1e8, + deal_price="$close", + verbose=False, + ) diff --git a/qlib/contrib/backtest/__init__.py b/qlib/contrib/backtest/__init__.py index 4a03bbe47..21d3913e5 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/contrib/backtest/__init__.py @@ -22,7 +22,7 @@ def get_exchange( freq="day", start_time=None, end_time=None, - codes = "all", + codes="all", subscribe_fields=[], open_cost=0.0015, close_cost=0.0025, @@ -89,6 +89,7 @@ def get_exchange( else: return init_instance_by_config(exchange, accept_types=Exchange) + def init_env_instance_by_config(env): if isinstance(env, dict): env_config = copy.copy(env) @@ -103,6 +104,7 @@ def init_env_instance_by_config(env): else: return env + def setup_exchange(root_instance, trade_exchange=None, force=False): if "trade_exchange" in inspect.getfullargspec(root_instance.__class__).args: if force: @@ -114,8 +116,8 @@ def setup_exchange(root_instance, trade_exchange=None, force=False): setup_exchange(root_instance.sub_env, trade_exchange) if hasattr(root_instance, "sub_strategy"): setup_exchange(root_instance.sub_strategy, trade_exchange) - - + + def backtest(start_time, end_time, strategy, env, benchmark=None, account=1e9, **kwargs): trade_strategy = init_instance_by_config(strategy) trade_env = init_env_instance_by_config(env) diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index 8bf7dedb7..ad88e274a 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -11,7 +11,6 @@ from .order import Order from ...utils import parse_freq, sample_feature - """ rtn & earning in the Account rtn: @@ -87,7 +86,7 @@ class Account: elif norm_freq == "minute": _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) else: - raise ValueError(f"benchmark freq {freq} is not supported") + raise ValueError(f"benchmark freq {freq} is not supported") if len(_temp_result) == 0: raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") return _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean().fillna(0) @@ -95,20 +94,20 @@ class Account: def _sample_benchmark(self, bench, trade_start_time, trade_end_time): def cal_change(x): return x.prod() - 1 + return sample_feature(bench, trade_start_time, trade_end_time, method=cal_change) - def reset(self, benchmark=None, freq=None,**kwargs): + def reset(self, benchmark=None, freq=None, **kwargs): if benchmark: self.benchmark = benchmark if freq: self.freq = freq - if self.freq and self.benchmark and (freq or benchmark) + if self.freq and self.benchmark and (freq or benchmark): self.bench = self._cal_benchmark(self.benchmark, self.start_time, self.end_time, self.freq) for k, v in kwargs: if hasattr(k): setattr(k, v) - def get_positions(self): return self.positions @@ -203,7 +202,7 @@ class Account: turnover_rate=self.to / last_account_value, cost_rate=self.ct / last_account_value, stock_value=now_stock_value, - bench_value=self._sample_benchmark(self.bench, trade_start_time, trade_end_time) + bench_value=self._sample_benchmark(self.bench, trade_start_time, trade_end_time), ) # set now_account_value to position self.current.position["now_account_value"] = now_account_value diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index a7e009a9a..d6fcb509d 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -7,6 +7,7 @@ import pandas as pd from .account import Account + def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account): trade_account = Account(init_cash=account, benchmark=benchmark, start_time=start_time, end_time=end_time) @@ -17,10 +18,9 @@ def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account while not trade_env.finished(): _order_list = trade_strategy.generate_order_list(**trade_state) trade_state, trade_info = trade_env.execute(_order_list) - + report_df = trade_account.report.generate_report_dataframe() positions = trade_account.get_positions() report_dict = {"report_df": report_df, "positions": positions} return report_dict - diff --git a/qlib/contrib/backtest/env.py b/qlib/contrib/backtest/env.py index 9fa993e7b..ade5caf24 100644 --- a/qlib/contrib/backtest/env.py +++ b/qlib/contrib/backtest/env.py @@ -1,5 +1,3 @@ - - import re import json import copy @@ -14,15 +12,8 @@ from .report import Report from .order import Order - class BaseTradeCalendar: - def __init__( - self, - step_bar, - start_time=None, - end_time=None, - **kwargs - ): + def __init__(self, step_bar, start_time=None, end_time=None, **kwargs): self.step_bar = step_bar self.reset(start_time=start_time, end_time=end_time) @@ -36,8 +27,10 @@ class BaseTradeCalendar: if self.start_time and self.end_time: _calendar, freq, freq_sam = get_sample_freq_calendar(freq=self.step_bar) self.calendar = _calendar - _start_time, _end_time, _start_index, _end_index = Cal.locate_index(self.start_time, self.end_time, freq=freq, freq_sam=freq_sam) - _trade_calendar = self.calendar[_start_index: _end_index + 1] + _start_time, _end_time, _start_index, _end_index = Cal.locate_index( + self.start_time, self.end_time, freq=freq, freq_sam=freq_sam + ) + _trade_calendar = self.calendar[_start_index : _end_index + 1] self.start_index = _start_index self.end_index = _end_index self.trade_len = _end_index - _start_index + 1 @@ -52,7 +45,7 @@ class BaseTradeCalendar: for k, v in kwargs: if hasattr(self, k): setattr(self, k, v) - + def _get_calendar_time(self, trade_index=1, shift=0): trade_index = trade_index - shift calendar_index = self.start_index + trade_index @@ -64,6 +57,7 @@ class BaseTradeCalendar: def step(self): self.trade_index = self.trade_index + 1 + class BaseEnv(BaseTradeCalendar): """ # Strategy framework document @@ -83,8 +77,10 @@ class BaseEnv(BaseTradeCalendar): ): self.generate_report = update_report self.verbose = verbose - super(BaseEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs) - + super(BaseEnv, self).__init__( + step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs + ) + def reset(self, trade_account=None, **kwargs): super(BaseEnv, self).reset(**kwargs) if trade_account: @@ -94,7 +90,7 @@ class BaseEnv(BaseTradeCalendar): def get_init_state(self): init_state = {"current": self.trade_account.current} return init_state - + def execute(self, **kwargs): raise NotImplementedError("execute is not implemented!") @@ -104,23 +100,32 @@ class BaseEnv(BaseTradeCalendar): def get_report(self): raise NotImplementedError("get_report is not implemented!") + class SplitEnv(BaseEnv): def __init__( - self, - step_bar, + self, + step_bar, sub_env, sub_strategy, - start_time=None, - end_time=None, + start_time=None, + end_time=None, trade_account=None, update_report=False, verbose=False, - **kwargs + **kwargs, ): self.sub_env = sub_env self.sub_strategy = sub_strategy - super(SplitEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, update_report=update_report, verbose=verbose, **kwargs) - + super(SplitEnv, self).__init__( + step_bar=step_bar, + start_time=start_time, + end_time=end_time, + trade_account=trade_account, + update_report=update_report, + verbose=verbose, + **kwargs, + ) + def reset(self, trade_account=None, **kwargs): super(SplitEnv, self).reset(trade_account=trade_account, **kwargs) if trade_account: @@ -129,9 +134,9 @@ class SplitEnv(BaseEnv): def execute(self, order_list, **kwargs): if self.finished(): raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") - #if self.track: + # if self.track: # yield action - #episode_reward = 0 + # episode_reward = 0 super(SplitEnv, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time) @@ -140,9 +145,11 @@ class SplitEnv(BaseEnv): while not self.sub_env.finished(): _order_list = self.sub_strategy.generate_order_list(**trade_state) trade_state, trade_info = self.sub_env.execute(order_list=_order_list) - + if self.generate_report: - self.trade_account.update_report(trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange) + self.trade_account.update_report( + trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange + ) _obs = {"current": self.trade_account.current} _info = {} return _obs, _info @@ -150,31 +157,40 @@ class SplitEnv(BaseEnv): def get_report(self): _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None _positions = self.trade_account.get_positions() if self.generate_report else None - return [(_report,_positions), *sub_env.get_report()] - -class SimulatorEnv(BaseEnv): + return [(_report, _positions), *sub_env.get_report()] + +class SimulatorEnv(BaseEnv): def __init__( - self, - step_bar, - start_time=None, - end_time=None, - trade_account=None, + self, + step_bar, + start_time=None, + end_time=None, + trade_account=None, trade_exchange=None, update_report=False, verbose=False, **kwargs, ): - super(SimulatorEnv, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, trade_exchange=trade_exchange, update_report=update_report, verbose=verbose, **kwargs) + super(SimulatorEnv, self).__init__( + step_bar=step_bar, + start_time=start_time, + end_time=end_time, + trade_account=trade_account, + trade_exchange=trade_exchange, + update_report=update_report, + verbose=verbose, + **kwargs, + ) def reset(self, trade_exchange=None, **kwargs): super(SimulatorEnv, self).reset(**kwargs) if trade_exchange: - self.trade_exchange=trade_exchange + self.trade_exchange = trade_exchange def execute(self, order_list, **kwargs): """ - Return: obs, done, info + Return: obs, done, info """ if self.finished(): raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") @@ -184,7 +200,9 @@ class SimulatorEnv(BaseEnv): for order in order_list: if self.trade_exchange.check_order(order) is True: # execute the order - trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(order, trade_account=self.trade_account) + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( + order, trade_account=self.trade_account + ) trade_info.append((order, trade_val, trade_cost, trade_price)) if self.verbose: if order.direction == Order.SELL: # sell @@ -214,7 +232,9 @@ class SimulatorEnv(BaseEnv): # do nothing pass if self.generate_report: - self.trade_account.update_report(trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange) + self.trade_account.update_report( + trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange + ) _obs = {"current": self.trade_account.current} _info = {"trade_info": trade_info} return _obs, _info @@ -222,9 +242,4 @@ class SimulatorEnv(BaseEnv): def get_report(self): _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None _positions = self.trade_account.get_positions() if self.generate_report else None - return [ - { - "report": _report, - "positions": _positions - } - ] \ No newline at end of file + return [{"report": _report, "positions": _positions}] diff --git a/qlib/contrib/backtest/exchange.py b/qlib/contrib/backtest/exchange.py index 399f9e151..a25b9b4a0 100644 --- a/qlib/contrib/backtest/exchange.py +++ b/qlib/contrib/backtest/exchange.py @@ -16,7 +16,6 @@ from ...log import get_module_logger from .order import Order - class Exchange: def __init__( self, @@ -101,14 +100,15 @@ class Exchange: self.min_cost = min_cost self.limit_threshold = limit_threshold - self.extra_quote = extra_quote self.set_quote(codes, start_time, end_time) def set_quote(self, codes, start_time, end_time): if len(codes) == 0: codes = D.instruments() - self.quote = D.features(codes, self.all_fields, start_time, end_time, freq=self.freq, disk_cache=True).dropna(subset=["$close"]) + self.quote = D.features(codes, self.all_fields, start_time, end_time, freq=self.freq, disk_cache=True).dropna( + subset=["$close"] + ) self.quote.columns = self.all_fields if self.quote[self.deal_price].isna().any(): @@ -168,7 +168,6 @@ class Exchange: is limtited """ return sample_feature(self.quote[stock_id], start_time, end_time, fields="limit", method="all").iloc[0] - def check_stock_suspended(self, stock_id, start_time, end_time): # is suspended @@ -180,7 +179,9 @@ class Exchange: def is_stock_tradable(self, stock_id, start_time, end_time): # check if stock can be traded # same as check in check_order - if self.check_stock_suspended(stock_id, start_time, end_time) or self.check_stock_limit(stock_id, start_time, end_time): + if self.check_stock_suspended(stock_id, start_time, end_time) or self.check_stock_limit( + stock_id, start_time, end_time + ): return False else: return True @@ -235,9 +236,13 @@ class Exchange: return sample_feature(self.quote[stock_id], start_time, end_time, fields="$close", method="last").iloc[0] def get_deal_price(self, stock_id, start_time, end_time): - deal_price = sample_feature(self.quote[stock_id], start_time, end_time, fields=self.deal_price, method="last").iloc[0] + deal_price = sample_feature( + self.quote[stock_id], start_time, end_time, fields=self.deal_price, method="last" + ).iloc[0] if np.isclose(deal_price, 0.0) or np.isnan(deal_price): - self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {self.deal_price}): {deal_price}!!!") + self.logger.warning( + f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {self.deal_price}): {deal_price}!!!" + ) self.logger.warning(f"setting deal_price to close price") deal_price = self.get_close(stock_id, start_time, end_time) return deal_price @@ -274,7 +279,9 @@ class Exchange: amount_dict = {} for stock_id in weight_position: - if weight_position[stock_id] > 0.0 and self.is_stock_tradable(stock_id=stock_id, start_time=start_time, end_time=end_time): + if weight_position[stock_id] > 0.0 and self.is_stock_tradable( + stock_id=stock_id, start_time=start_time, end_time=end_time + ): amount_dict[stock_id] = ( cash * weight_position[stock_id] @@ -377,7 +384,10 @@ class Exchange: self.check_stock_suspended(stock_id=stock_id, start_time=start_time, end_time=end_time) is False and self.check_stock_limit(stock_id=stock_id, start_time=start_time, end_time=end_time) is False ): - value += self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) * amount_dict[stock_id] + value += ( + self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) + * amount_dict[stock_id] + ) return value def round_amount_by_trade_unit(self, deal_amount, factor): diff --git a/qlib/contrib/backtest/interpreter.py b/qlib/contrib/backtest/interpreter.py index 94d6f9ec2..7f33c809d 100644 --- a/qlib/contrib/backtest/interpreter.py +++ b/qlib/contrib/backtest/interpreter.py @@ -1,15 +1,16 @@ - class BaseInterpreter: @staticmethod def interpret(**kwargs): raise NotImplementedError("interpret is not implemented!") + class ActionInterpreter: @staticmethod def interpret(action, **kwargs): return action + class StateInterpreter: @staticmethod def interpret(state, **kwargs): - return state \ No newline at end of file + return state diff --git a/qlib/contrib/backtest/report.py b/qlib/contrib/backtest/report.py index 3bee440e0..57e56c9a3 100644 --- a/qlib/contrib/backtest/report.py +++ b/qlib/contrib/backtest/report.py @@ -45,16 +45,7 @@ class Report: bench_value=None, ): # check data - if None in [ - trade_time, - account_value, - cash, - return_rate, - turnover_rate, - cost_rate, - stock_value, - bench_value - ]: + if None in [trade_time, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value, bench_value]: raise ValueError( "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value, bench_value]" ) @@ -108,5 +99,5 @@ class Report: turnover_rate=r.loc[trade_time]["turnover"], cost_rate=r.loc[trade_time]["cost"], stock_value=r.loc[trade_time]["value"], - bench_value=r.loc[trade_time]["bench"] + bench_value=r.loc[trade_time]["bench"], ) diff --git a/qlib/contrib/strategy/__init__.py b/qlib/contrib/strategy/__init__.py index b138edb23..e308c1a05 100644 --- a/qlib/contrib/strategy/__init__.py +++ b/qlib/contrib/strategy/__init__.py @@ -7,12 +7,10 @@ from .model_strategy import ( WeightStrategyBase, ) -from .rule_strategy import( +from .rule_strategy import ( TWAPStrategy, SBBStrategyBase, SBBStrategyEMA, ) -from .cost_control import ( - SoftTopkStrategy -) \ No newline at end of file +from .cost_control import SoftTopkStrategy diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 9aab96377..95280dc2f 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -53,7 +53,9 @@ class TopkDropoutStrategy(ModelStrategy): else: strategy will make decision with the tradable state of the stock info and avoid buy and sell them. """ - super(TopkDropoutStrategy, self).__init__(step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange) + super(TopkDropoutStrategy, self).__init__( + step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange + ) self.topk = topk self.n_drop = n_drop self.method_sell = method_sell @@ -65,8 +67,7 @@ class TopkDropoutStrategy(ModelStrategy): self.stock_count = {} self.hold_thresh = hold_thresh self.only_tradable = only_tradable - - + def reset(self, trade_exchange=None, **kwargs): super(TopkDropoutStrategy, self).reset(**kwargs) if trade_exchange: @@ -94,7 +95,9 @@ class TopkDropoutStrategy(ModelStrategy): cur_n = 0 res = [] for si in reversed(l) if reverse else l: - if self.trade_exchange.is_stock_tradable(stock_id=si, start_time=trade_start_time, end_time=trade_end_time): + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ): res.append(si) cur_n += 1 if cur_n >= n: @@ -105,7 +108,13 @@ class TopkDropoutStrategy(ModelStrategy): return get_first_n(l, n, reverse=True) def filter_stock(l): - return [si for si in l if self.trade_exchange.is_stock_tradable(stock_id=si, start_time=trade_start_time, end_time=trade_end_time)] + return [ + si + for si in l + if self.trade_exchange.is_stock_tradable( + stock_id=si, start_time=trade_start_time, end_time=trade_end_time + ) + ] else: # Otherwise, the stock will make decision with out the stock tradable info @@ -166,11 +175,16 @@ class TopkDropoutStrategy(ModelStrategy): buy_signal = pred_score.sort_values(ascending=False).iloc[: self.topk].index for code in current_stock_list: - if not self.trade_exchange.is_stock_tradable(stock_id=code, start_time=trade_start_time, end_time=trade_end_time): + if not self.trade_exchange.is_stock_tradable( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time + ): continue if code in sell: # check hold limit - if self.stock_count[code] < self.thresh or current_temp.get_stock_count(code, bar=self.step_bar) < self.hold_thresh: + if ( + self.stock_count[code] < self.thresh + or current_temp.get_stock_count(code, bar=self.step_bar) < self.hold_thresh + ): # can not sell this code # no buy signal, but the stock is kept self.stock_count[code] += 1 @@ -188,7 +202,9 @@ class TopkDropoutStrategy(ModelStrategy): # is order executable if self.trade_exchange.check_order(sell_order): sell_order_list.append(sell_order) - trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(sell_order, position=current_temp) + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( + sell_order, position=current_temp + ) # update cash cash += trade_val - trade_cost # sold @@ -213,10 +229,14 @@ class TopkDropoutStrategy(ModelStrategy): # value = value / (1+self.trade_exchange.open_cost) # set open_cost limit for code in buy: # check is stock suspended - if not self.trade_exchange.is_stock_tradable(stock_id=code, start_time=trade_start_time, end_time=trade_end_time): + if not self.trade_exchange.is_stock_tradable( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time + ): continue # buy order - buy_price = self.trade_exchange.get_deal_price(stock_id=code, start_time=trade_start_time, end_time=trade_end_time) + buy_price = self.trade_exchange.get_deal_price( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time + ) buy_amount = value / buy_price factor = self.trade_exchange.get_factor(stock_id=code, start_time=trade_start_time, end_time=trade_end_time) buy_amount = self.trade_exchange.round_amount_by_trade_unit(buy_amount, factor) @@ -231,17 +251,24 @@ class TopkDropoutStrategy(ModelStrategy): buy_order_list.append(buy_order) self.stock_count[code] = 1 return sell_order_list + buy_order_list - + + class WeightStrategyBase(ModelStrategy): - def __init__(self, step_bar, start_time=None, end_time=None, order_generator_cls_or_obj=OrderGenWInteract, trade_exchange=None, **kwargs): + def __init__( + self, + step_bar, + start_time=None, + end_time=None, + order_generator_cls_or_obj=OrderGenWInteract, + trade_exchange=None, + **kwargs, + ): super(WeightStrategyBase, self).__init__(step_bar, start_time, end_time) self.trade_exchange = trade_exchange if isinstance(order_generator_cls_or_obj, type): self.order_generator = order_generator_cls_or_obj() else: self.order_generator = order_generator_cls_or_obj - - def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time): """ diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index d263f658d..93bf7b2fe 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -81,10 +81,16 @@ class OrderGenWInteract(OrderGenerator): # calculate current_tradable_value current_amount_dict = current.get_stock_amount_dict() current_total_value = trade_exchange.calculate_amount_position_value( - amount_dict=current_amount_dict, trade_start_time=trade_start_time, trade_end_time=trade_end_time, only_tradable=False + amount_dict=current_amount_dict, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + only_tradable=False, ) current_tradable_value = trade_exchange.calculate_amount_position_value( - amount_dict=current_amount_dict, trade_start_time=trade_start_time, trade_end_time=trade_end_time, only_tradable=True + amount_dict=current_amount_dict, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + only_tradable=True, ) # add cash current_tradable_value += current.get_cash() @@ -97,7 +103,9 @@ class OrderGenWInteract(OrderGenerator): # value. Then just sell all the stocks target_amount_dict = copy.deepcopy(current_amount_dict.copy()) for stock_id in list(target_amount_dict.keys()): - if trade_exchange.is_stock_tradable(stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time): + if trade_exchange.is_stock_tradable( + stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time + ): del target_amount_dict[stock_id] else: # consider cost rate @@ -108,13 +116,13 @@ class OrderGenWInteract(OrderGenerator): target_amount_dict = trade_exchange.generate_amount_position_from_weight_position( weight_position=target_weight_position, cash=current_tradable_value, - trade_start_time=trade_start_time, + trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) order_list = trade_exchange.generate_order_for_target_amount_position( target_position=target_amount_dict, current_position=current_amount_dict, - trade_start_time=trade_start_time, + trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) return order_list @@ -161,7 +169,9 @@ class OrderGenWOInteract(OrderGenerator): amount_dict = {} for stock_id in target_weight_position: # Current rule will ignore the stock that not hold and cannot be traded at predict date - if trade_exchange.is_stock_tradable(stock_id=stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time): + if trade_exchange.is_stock_tradable( + stock_id=stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time + ): amount_dict[stock_id] = ( risk_total_value * target_weight_position[stock_id] / trade_exchange.get_close(stock_id, pred_date) ) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index b432ccea2..1acf55314 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -11,7 +11,6 @@ from ..backtest.order import Order class TWAPStrategy(RuleStrategy, TradingEnhancement): - def reset(self, trade_order_list=None, **kwargs): super(TWAPStrategy, self).reset(**kwargs) TradingEnhancement.reset(self, trade_order_list=trade_order_list) @@ -19,7 +18,6 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): self.trade_amount = {} for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len - def generate_order_list(self, **kwargs): super(TopkDropoutStrategy, self).step() @@ -37,10 +35,12 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): order_list.append(_order) return order_list + class SBBStrategyBase(RuleStrategy, TradingEnhancement): """ - (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy. + (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy. """ + TREND_MID = 0 TREND_SHORT = 1 TREND_LONG = 2 @@ -50,11 +50,10 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): TradingEnhancement.reset(self, trade_order_list=trade_order_list) if trade_order_list: self.trade_amount = {} - self.trade_trend = {} + self.trade_trend = {} for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID - def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") @@ -81,10 +80,15 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): order_list.append(_order) else: if self.trade_index % 2 == 1: - if _pred_trend == self.TREND_SHORT and order.direction == order.SELL or _pred_trend == self.TREND_LONG and order.direction == order.BUY: + if ( + _pred_trend == self.TREND_SHORT + and order.direction == order.SELL + or _pred_trend == self.TREND_LONG + and order.direction == order.BUY + ): _order = Order( stock_id=order.stock_id, - amount=2*self.trade_amount[(order.stock_id, order.direction)], + amount=2 * self.trade_amount[(order.stock_id, order.direction)], start_time=trade_start_time, end_time=trade_end_time, direction=order.direction, # 1 for buy @@ -92,31 +96,37 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): ) order_list.append(_order) else: - if _pred_trend == self.TREND_SHORT and order.direction == order.BUY or _pred_trend == self.TREND_LONG and order.direction == order.SELL: + if ( + _pred_trend == self.TREND_SHORT + and order.direction == order.BUY + or _pred_trend == self.TREND_LONG + and order.direction == order.SELL + ): _order = Order( stock_id=order.stock_id, - amount=2*self.trade_amount[(order.stock_id, order.direction)], + amount=2 * self.trade_amount[(order.stock_id, order.direction)], start_time=trade_start_time, end_time=trade_end_time, direction=order.direction, # 1 for buy factor=order.factor, - ) + ) order_list.append(_order) - if self.trade_index % 2 == 1: + if self.trade_index % 2 == 1: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend return order_list - + class SBBStrategyEMA(SBBStrategyBase): """ - (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA). + (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA). """ + def __init__( - self, - step_bar, - start_time=None, - end_time=None, + self, + step_bar, + start_time=None, + end_time=None, instruments="csi300", freq="day", **kwargs, @@ -139,22 +149,25 @@ class SBBStrategyEMA(SBBStrategyBase): if self.start_time and self.end_time: fields = ["EMA($close, 10)-EMA($close, 20)"] signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) - signal_df = D.features(self.instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq) + signal_df = D.features( + self.instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq + ) signal_df = self._convert_index_format(signal_df) signal_df.columns = ["signal"] self.signal = {} for stock_id, stock_val in signal_df.groupby(level="instrument"): self.signal[stock_id] = stock_val - + def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): if stock_id not in self.signal: return self.TREND_MID else: - _sample_signal = sample_feature(self.signal[stock_id], pred_start_time, pred_end_time, fields="signal", method="last") + _sample_signal = sample_feature( + self.signal[stock_id], pred_start_time, pred_end_time, fields="signal", method="last" + ) if _sample_signal is None or _sample_signal.iloc[0] == 0: return self.TREND_MID elif _sample_signal.iloc[0] > 0: return self.TREND_LONG else: return self.TREND_SHORT - \ No newline at end of file diff --git a/qlib/data/data.py b/qlib/data/data.py index a8d5a42ab..c34c02236 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -126,7 +126,7 @@ class CalendarProvider(abc.ABC): _calendar = np.array(self.load_calendar(freq, future)) _calendar_index = {x: i for i, x in enumerate(_calendar)} # for fast search H["c"][flag_raw] = _calendar, _calendar_index - + if freq_sam is None: return _calendar, _calendar_index else: @@ -134,7 +134,6 @@ class CalendarProvider(abc.ABC): _calendar_sam_index = {x: i for i, x in enumerate(_calendar_sam)} H["c"][flag] = _calendar_sam, _calendar_sam_index return _calendar_sam, _calendar_sam_index - def _uri(self, start_time, end_time, freq, future=False): """Get the uri of calendar generation task.""" @@ -560,7 +559,8 @@ class LocalCalendarProvider(CalendarProvider): else: end_time = _calendar[-1] st, et, si, ei = self.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam, future=future) - return _calendar[si : ei + 1] + return _calendar[si : ei + 1] + class LocalInstrumentProvider(InstrumentProvider): """Local instrument data provider class @@ -767,7 +767,7 @@ class ClientCalendarProvider(CalendarProvider): self.conn = conn def calendar(self, start_time=None, end_time=None, freq="day", future=False): - + self.conn.send_request( request_type="calendar", request_content={ diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index fb5b44334..e5840d66a 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -20,8 +20,9 @@ from ..contrib.backtest.env import BaseTradeCalendar - adjust_dates这个东西啥用 - label和freq和strategy的bar分离,这个如何决策呢 """ + + class BaseStrategy(BaseTradeCalendar): - def generate_order_list(self, **kwargs): raise NotImplementedError("generator_order_list is not implemented!") @@ -29,12 +30,13 @@ class BaseStrategy(BaseTradeCalendar): class RuleStrategy(BaseStrategy): pass + class ModelStrategy(BaseStrategy): - def __init__(self, step_bar, model, dataset:DatasetH, start_time=None, end_time=None, **kwargs): + def __init__(self, step_bar, model, dataset: DatasetH, start_time=None, end_time=None, **kwargs): self.model = model self.dataset = dataset self.pred_scores = self._convert_index_format(self.model.predict(dataset)) - #pred_score_dates = self.pred_scores.index.get_level_values(level="datetime") + # pred_score_dates = self.pred_scores.index.get_level_values(level="datetime") super(ModelStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) def _convert_index_format(self, df): @@ -43,12 +45,11 @@ class ModelStrategy(BaseStrategy): return df def _update_model(self): - """update pred score - """ + """update pred score""" raise NotImplementedError("_update_model is not implemented!") + class TradingEnhancement: def reset(self, trade_order_list=None): if trade_order_list: self.trade_order_list = trade_order_list - diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index ea573d819..a6bba1f38 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -801,6 +801,7 @@ def fname_to_code(fname: str): fname = fname.lstrip(prefix) return fname + ########################## Sample ############################ def sample_calendar_bac(calendar_raw, freq_raw, freq_sam): """ @@ -810,16 +811,17 @@ def sample_calendar_bac(calendar_raw, freq_raw, freq_sam): freq_sam = "1" + freq_sam if re.match("^[0-9]", freq_sam) is None else freq_sam if freq_sam.endswith(("minute", "min")): + def cal_next_sam_minute(x, sam_minutes): hour = x.hour minute = x.minute if 9 <= hour <= 11: - minute_index = (11 - hour)*60 + 30 - minute + 120 + minute_index = (11 - hour) * 60 + 30 - minute + 120 elif 13 <= hour <= 15: - minute_index = (15 - hour)*60 - minute + minute_index = (15 - hour) * 60 - minute else: raise ValueError("calendar hour must be in [9, 11] or [13, 15]") - + minute_index = minute_index // sam_minutes * sam_minutes if 0 <= minute_index < 120: @@ -838,32 +840,40 @@ def sample_calendar_bac(calendar_raw, freq_raw, freq_sam): if raw_minutes > sam_minutes: raise ValueError("raw freq must be higher than sample freq") - _calendar_minute = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_minutes), 59), calendar_raw))) + _calendar_minute = np.unique( + list( + map( + lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_minutes), 59), + calendar_raw, + ) + ) + ) return _calendar_minute else: _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 23, 59, 59), calendar_raw))) if freq_sam.endswith(("day", "d")): sam_days = int(freq_sam[:-1]) if freq_sam.endswith("d") else int(freq_sam[:-3]) - return _calendar_day[(len(_calendar_day) + sam_days - 1)%sam_days::sam_days] + return _calendar_day[(len(_calendar_day) + sam_days - 1) % sam_days :: sam_days] elif freq_sam.endswith(("week", "w")): sam_weeks = int(freq_sam[:-1]) if freq_sam.endswith("w") else int(freq_sam[:-4]) _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) _calendar_week = _calendar_day[np.ediff1d(_day_in_week[::-1], to_begin=1)[::-1] > 0] - return _calendar_week[(len(_calendar_week) + sam_weeks - 1)%sam_weeks::sam_weeks] + return _calendar_week[(len(_calendar_week) + sam_weeks - 1) % sam_weeks :: sam_weeks] elif freq_sam.endswith(("month", "m")): sam_months = int(freq_sam[:-1]) if freq_sam.endswith("m") else int(freq_sam[:-5]) _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) _calendar_month = _calendar_day[np.ediff1d(_day_in_month[::-1], to_begin=1)[::-1] > 0] - return _calendar_month[(len(_calendar_month) + sam_months - 1)%sam_months::sam_months] + return _calendar_month[(len(_calendar_month) + sam_months - 1) % sam_months :: sam_months] else: raise ValueError("sample freq must be xmin, xd, xw, xm") + def parse_freq(freq): freq = freq.lower() - search_obj =re.search("^([0-9]*)([a-z]+)", freq) + search_obj = re.search("^([0-9]*)([a-z]+)", freq) if search_obj is None: raise ValueError("freq format is not supported") _count = int(search_obj.group(1) if search_obj.group(1) else "1") @@ -881,9 +891,12 @@ def parse_freq(freq): try: _freq = _freq_format_dict.get(_freq) except KeyError: - raise ValueError("freq format is not supported, the supported freq includes (x)month/m, (x)day/d, (x)minute/min") + raise ValueError( + "freq format is not supported, the supported freq includes (x)month/m, (x)day/d, (x)minute/min" + ) return _count, _freq + def sample_calendar(calendar_raw, freq_raw, freq_sam): """ freq_raw : "min" or "day" @@ -893,16 +906,17 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): if not len(calendar_raw): return calendar_raw if freq_sam == "minute": + def cal_next_sam_minute(x, sam_minutes): hour = x.hour minute = x.minute if (hour == 9 and minute >= 30) or (9 < hour < 11) or (hour == 11 and minute < 30): - minute_index = (hour - 9)*60 + minute - 30 + minute_index = (hour - 9) * 60 + minute - 30 elif 13 <= hour < 15: - minute_index = (hour - 13)*60 + minute + 120 + minute_index = (hour - 13) * 60 + minute + 120 else: raise ValueError("calendar hour must be in [9, 11] or [13, 15]") - + minute_index = minute_index // sam_minutes * sam_minutes if 0 <= minute_index < 120: @@ -917,7 +931,11 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): else: if raw_count > sam_count: raise ValueError("raw freq must be higher than sample freq") - _calendar_minute = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_count), 0), calendar_raw))) + _calendar_minute = np.unique( + list( + map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_count), 0), calendar_raw) + ) + ) if calendar_raw[0] > _calendar_minute[0]: _calendar_minute[0] = calendar_raw[0] return _calendar_minute @@ -937,7 +955,8 @@ def sample_calendar(calendar_raw, freq_raw, freq_sam): return _calendar_month[::sam_count] else: raise ValueError("sample freq must be xmin, xd, xw, xm") - + + def get_sample_freq_calendar(start_time=None, end_time=None, freq="day", **kwargs): _, norm_freq = parse_freq(freq) @@ -963,23 +982,28 @@ def get_sample_freq_calendar(start_time=None, end_time=None, freq="day", **kwarg raise ValueError(f"freq {freq} is not supported") return _calendar, freq, freq_sam + def sample_feature(feature, start_time=None, end_time=None, fields=None, method="last", method_kwargs={}): selector_datetime = slice(start_time, end_time) fields = fields if fields else slice(None) from ..data.dataset.utils import get_level_index - + datetime_level = get_level_index(feature, level="datetime") == 0 if isinstance(feature, pd.Series): feature = feature.loc[selector_datetime] if datetime_level else feature.loc[(slice(None), selector_datetime)] elif isinstance(feature, pd.DataFrame): - feature = feature.loc[selector_datetime, fields] if datetime_level else feature.loc[(slice(None), selector_datetime), fields] + feature = ( + feature.loc[selector_datetime, fields] + if datetime_level + else feature.loc[(slice(None), selector_datetime), fields] + ) if feature.empty: return None if isinstance(feature.index, pd.MultiIndex): if callable(method): method_func = method - return feature.groupby(level="instrument").apply(lambda x:method_func(x, **method_kwargs)) + return feature.groupby(level="instrument").apply(lambda x: method_func(x, **method_kwargs)) elif isinstance(method, str): return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) else: @@ -988,7 +1012,5 @@ def sample_feature(feature, start_time=None, end_time=None, fields=None, method= return method_func(feature, **method_kwargs) elif isinstance(method, str): return getattr(feature, method)(**method_kwargs) - - return feature - \ No newline at end of file + return feature diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 3d7188bcc..b7935ae08 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -254,13 +254,19 @@ class PortAnaRecord(SignalRecord): for report_dep, (report_normal, positions_normal) in enumerate(report_list): if report_dict is None: if self.risk_analysis_dep == report_dep: - warnings.warn(f"the report in dep {risk_analysis_dep} is None, please set the corresponding env with `generate_report==True`") + warnings.warn( + f"the report in dep {risk_analysis_dep} is None, please set the corresponding env with `generate_report==True`" + ) continue - - self.recorder.save_objects(**{f"report_normal_{report_dep}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path()) - self.recorder.save_objects(**{f"positions_norma_{report_dep}l.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path()) + + self.recorder.save_objects( + **{f"report_normal_{report_dep}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path() + ) + self.recorder.save_objects( + **{f"positions_norma_{report_dep}l.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path() + ) # analysis - self.risk_analysis_dep == report_dep: + if self.risk_analysis_dep == report_dep: analysis = dict() analysis["excess_return_without_cost"] = risk_analysis(report_normal["return"] - report_normal["bench"]) analysis["excess_return_with_cost"] = risk_analysis( @@ -270,7 +276,9 @@ class PortAnaRecord(SignalRecord): # log metrics self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) # save results - self.recorder.save_objects(**{f"port_analysis.pkl_{report_dep}": analysis_df}, artifact_path=PortAnaRecord.get_path()) + self.recorder.save_objects( + **{f"port_analysis.pkl_{report_dep}": analysis_df}, artifact_path=PortAnaRecord.get_path() + ) logger.info( f"Portfolio analysis record 'port_analysis_{report_dep}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" ) From a109df3f467841eb32952ef924c19fc8373097bd Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 30 Apr 2021 01:06:05 +0800 Subject: [PATCH 011/187] fix bug in recorder --- examples/highfreq/backtest/workflow.py | 29 ++++++++++++------ qlib/contrib/backtest/__init__.py | 2 +- qlib/contrib/backtest/account.py | 19 +++++++----- qlib/contrib/backtest/backtest.py | 6 +--- qlib/contrib/backtest/env.py | 40 +++++++++++++------------ qlib/contrib/evaluate.py | 2 +- qlib/contrib/strategy/model_strategy.py | 35 ++-------------------- qlib/workflow/record_temp.py | 13 +++----- 8 files changed, 63 insertions(+), 83 deletions(-) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index d031d40f2..a4d163ce5 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -10,6 +10,8 @@ from qlib.config import REG_CN from qlib.contrib.strategy import TopkDropoutStrategy from qlib.contrib.backtest import backtest from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict +from qlib.workflow import R +from qlib.workflow.record_temp import PortAnaRecord from qlib.tests.data import GetData if __name__ == "__main__": @@ -78,7 +80,7 @@ if __name__ == "__main__": trade_start_time = "2017-01-31" trade_end_time = "2018-01-31" - backtest_config = { + port_analysis_config = { "strategy": { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", @@ -101,6 +103,7 @@ if __name__ == "__main__": "kwargs": { "step_bar": "day", "verbose": True, + "generate_report": True, }, }, "sub_strategy": { @@ -128,11 +131,19 @@ if __name__ == "__main__": }, } - report_dict = backtest( - start_time=trade_start_time, - end_time=trade_end_time, - **backtest_config, - account=1e8, - deal_price="$close", - verbose=False, - ) + #report_dict = backtest( + # start_time=trade_start_time, + # end_time=trade_end_time, + # **backtest_config, + # account=1e8, + # benchmark=benchmark, + # deal_price="$close", + # verbose=False, + #) + + with R.start(experiment_name="highfreq_backtest"): + # backtest. If users want to use backtest based on their own prediction, + # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. + recorder = R.get_recorder() + par = PortAnaRecord(recorder, port_analysis_config, 1) + par.generate() \ No newline at end of file diff --git a/qlib/contrib/backtest/__init__.py b/qlib/contrib/backtest/__init__.py index 21d3913e5..dacbdfefc 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/contrib/backtest/__init__.py @@ -118,7 +118,7 @@ def setup_exchange(root_instance, trade_exchange=None, force=False): setup_exchange(root_instance.sub_strategy, trade_exchange) -def backtest(start_time, end_time, strategy, env, benchmark=None, account=1e9, **kwargs): +def backtest(start_time, end_time, strategy, env, benchmark="SH000905", account=1e9, **kwargs): trade_strategy = init_instance_by_config(strategy) trade_env = init_env_instance_by_config(env) diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index ad88e274a..5a35ffc08 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -8,6 +8,7 @@ import pandas as pd from .position import Position from .report import Report from .order import Order +from ...data import D from ...utils import parse_freq, sample_feature @@ -95,7 +96,8 @@ class Account: def cal_change(x): return x.prod() - 1 - return sample_feature(bench, trade_start_time, trade_end_time, method=cal_change) + _ret = sample_feature(bench, trade_start_time, trade_end_time, method=cal_change) + return 0 if _ret is None else _ret def reset(self, benchmark=None, freq=None, **kwargs): if benchmark: @@ -105,9 +107,9 @@ class Account: if self.freq and self.benchmark and (freq or benchmark): self.bench = self._cal_benchmark(self.benchmark, self.start_time, self.end_time, self.freq) - for k, v in kwargs: - if hasattr(k): - setattr(k, v) + for k, v in kwargs.items(): + if hasattr(self, k): + setattr(self, k, v) def get_positions(self): return self.positions @@ -150,7 +152,7 @@ class Account: self.current.update_order(order, trade_val, cost, trade_price) self.update_state_from_order(order, trade_val, cost, trade_price) - def update_report(self, trade_start_time, trade_end_time, trade_exchange): + def update_bar_end(self, trade_start_time, trade_end_time, trade_exchange, update_report): """ start_time: pd.TimeStamp end_time: pd.TimeStamp @@ -166,6 +168,9 @@ class Account: :return: None """ # update price for stock in the position and the profit from changed_price + self.current.add_count_all(bar=self.freq) + if update_report is None: + return stock_list = self.current.get_stock_list() for code in stock_list: # if suspend, no new price to be updated, profit is 0 @@ -174,7 +179,7 @@ class Account: bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) self.current.update_stock_price(stock_id=code, price=bar_close) # update holding day count - self.current.add_count_all(bar=self.freq) + # update value self.val = self.current.calculate_value() # update earning @@ -212,7 +217,7 @@ class Account: self.positions[trade_start_time] = copy.deepcopy(self.current) # finish today's updation - # reset the daily variables + # reset the bar variables self.rtn = 0 self.ct = 0 self.to = 0 diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index d6fcb509d..d67d6782b 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -19,8 +19,4 @@ def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account _order_list = trade_strategy.generate_order_list(**trade_state) trade_state, trade_info = trade_env.execute(_order_list) - report_df = trade_account.report.generate_report_dataframe() - positions = trade_account.get_positions() - report_dict = {"report_df": report_df, "positions": positions} - - return report_dict + return trade_env.get_report() diff --git a/qlib/contrib/backtest/env.py b/qlib/contrib/backtest/env.py index ade5caf24..ea2618977 100644 --- a/qlib/contrib/backtest/env.py +++ b/qlib/contrib/backtest/env.py @@ -42,7 +42,7 @@ class BaseTradeCalendar: if start_time or end_time: self._reset_trade_calendar(start_time=start_time, end_time=end_time) - for k, v in kwargs: + for k, v in kwargs.items(): if hasattr(self, k): setattr(self, k, v) @@ -52,7 +52,7 @@ class BaseTradeCalendar: return self.calendar[calendar_index - 1], self.calendar[calendar_index] def finished(self): - return self.trade_index >= self.trade_len + return self.trade_index >= self.trade_len - 1 def step(self): self.trade_index = self.trade_index + 1 @@ -71,11 +71,11 @@ class BaseEnv(BaseTradeCalendar): start_time=None, end_time=None, trade_account=None, - update_report=False, + generate_report=False, verbose=False, **kwargs, ): - self.generate_report = update_report + self.generate_report = generate_report self.verbose = verbose super(BaseEnv, self).__init__( step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs @@ -110,7 +110,8 @@ class SplitEnv(BaseEnv): start_time=None, end_time=None, trade_account=None, - update_report=False, + trade_exchange=None, + generate_report=False, verbose=False, **kwargs, ): @@ -121,15 +122,18 @@ class SplitEnv(BaseEnv): start_time=start_time, end_time=end_time, trade_account=trade_account, - update_report=update_report, + trade_exchange=trade_exchange, + generate_report=generate_report, verbose=verbose, **kwargs, ) - def reset(self, trade_account=None, **kwargs): + def reset(self, trade_account=None, trade_exchange=None, **kwargs): super(SplitEnv, self).reset(trade_account=trade_account, **kwargs) if trade_account: self.sub_env.reset(trade_account=copy.copy(trade_account)) + if trade_exchange: + self.trade_exchange = trade_exchange def execute(self, order_list, **kwargs): if self.finished(): @@ -146,10 +150,9 @@ class SplitEnv(BaseEnv): _order_list = self.sub_strategy.generate_order_list(**trade_state) trade_state, trade_info = self.sub_env.execute(order_list=_order_list) - if self.generate_report: - self.trade_account.update_report( - trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange - ) + self.trade_account.update_bar_end( + trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange, update_report=self.generate_report + ) _obs = {"current": self.trade_account.current} _info = {} return _obs, _info @@ -157,7 +160,7 @@ class SplitEnv(BaseEnv): def get_report(self): _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None _positions = self.trade_account.get_positions() if self.generate_report else None - return [(_report, _positions), *sub_env.get_report()] + return [(_report, _positions), *self.sub_env.get_report()] class SimulatorEnv(BaseEnv): @@ -168,7 +171,7 @@ class SimulatorEnv(BaseEnv): end_time=None, trade_account=None, trade_exchange=None, - update_report=False, + generate_report=False, verbose=False, **kwargs, ): @@ -178,7 +181,7 @@ class SimulatorEnv(BaseEnv): end_time=end_time, trade_account=trade_account, trade_exchange=trade_exchange, - update_report=update_report, + generate_report=generate_report, verbose=verbose, **kwargs, ) @@ -231,10 +234,9 @@ class SimulatorEnv(BaseEnv): print("[W {:%Y-%m-%d}]: {} wrong.".format(trade_start_time, order.stock_id)) # do nothing pass - if self.generate_report: - self.trade_account.update_report( - trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange - ) + self.trade_account.update_bar_end( + trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange, update_report=self.generate_report + ) _obs = {"current": self.trade_account.current} _info = {"trade_info": trade_info} return _obs, _info @@ -242,4 +244,4 @@ class SimulatorEnv(BaseEnv): def get_report(self): _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None _positions = self.trade_account.get_positions() if self.generate_report else None - return [{"report": _report, "positions": _positions}] + return [(_report, _positions)] diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index 4aa5b5515..91cfc1d89 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -9,7 +9,7 @@ import pandas as pd import warnings from ..log import get_module_logger from .backtest import get_exchange, backtest as backtest_func -from .backtest.backtest import get_date_range +from ..utils import get_date_range from ..data import D from ..config import C diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 95280dc2f..0bd0b9e0c 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -23,7 +23,6 @@ class TopkDropoutStrategy(ModelStrategy): method_sell="bottom", method_buy="top", risk_degree=0.95, - thresh=1, hold_thresh=1, only_tradable=False, **kwargs, @@ -41,11 +40,9 @@ class TopkDropoutStrategy(ModelStrategy): dropout method_buy, random/top. risk_degree : float position percentage of total value. - thresh : int - minimun holding days since last buy singal of the stock. hold_thresh : int minimum holding days - before sell stock , will check current.get_stock_count(order.stock_id) >= self.thresh. + before sell stock , will check current.get_stock_count(order.stock_id) >= self.hold_thresh. only_tradable : bool will the strategy only consider the tradable stock when buying and selling. if only_tradable: @@ -61,10 +58,6 @@ class TopkDropoutStrategy(ModelStrategy): self.method_sell = method_sell self.method_buy = method_buy self.risk_degree = risk_degree - self.thresh = thresh - # self.stock_count['code'] will be the days the stock has been hold - # since last buy signal. This is designed for thresh - self.stock_count = {} self.hold_thresh = hold_thresh self.only_tradable = only_tradable @@ -170,10 +163,7 @@ class TopkDropoutStrategy(ModelStrategy): # Get the stock list we really want to buy buy = today[: len(sell) + self.topk - len(last)] - - # buy singal: if a stock falls into topk, it appear in the buy_sinal - buy_signal = pred_score.sort_values(ascending=False).iloc[: self.topk].index - + #print("flag", len(sell), len(buy), self.topk, len(last)) for code in current_stock_list: if not self.trade_exchange.is_stock_tradable( stock_id=code, start_time=trade_start_time, end_time=trade_end_time @@ -181,13 +171,7 @@ class TopkDropoutStrategy(ModelStrategy): continue if code in sell: # check hold limit - if ( - self.stock_count[code] < self.thresh - or current_temp.get_stock_count(code, bar=self.step_bar) < self.hold_thresh - ): - # can not sell this code - # no buy signal, but the stock is kept - self.stock_count[code] += 1 + if current_temp.get_stock_count(code, bar=self.step_bar) < self.hold_thresh: continue # sell order sell_amount = current_temp.get_stock_amount(code=code) @@ -207,18 +191,6 @@ class TopkDropoutStrategy(ModelStrategy): ) # update cash cash += trade_val - trade_cost - # sold - self.stock_count[code] = 0 - else: - # no buy signal, but the stock is kept - self.stock_count[code] += 1 - elif code in buy_signal: - # NOTE: This is different from the original version - # get new buy signal - # Only the stock fall in to topk will produce buy signal - self.stock_count[code] = 1 - else: - self.stock_count[code] += 1 # buy new stock # note the current has been changed current_stock_list = current_temp.get_stock_list() @@ -249,7 +221,6 @@ class TopkDropoutStrategy(ModelStrategy): factor=factor, ) buy_order_list.append(buy_order) - self.stock_count[code] = 1 return sell_order_list + buy_order_list diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index b7935ae08..546fb5a60 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -14,8 +14,9 @@ from ..data.dataset.handler import DataHandlerLP from ..utils import init_instance_by_config, get_module_by_module_path from ..log import get_module_logger from ..utils import flatten_dict +from ..strategy.base import BaseStrategy from ..contrib.eva.alpha import calc_ic, calc_long_short_return -from ..contrib.strategy.strategy import BaseStrategy + logger = get_module_logger("workflow", "INFO") @@ -212,7 +213,7 @@ class SigAnaRecord(SignalRecord): return paths -class PortAnaRecord(SignalRecord): +class PortAnaRecord(RecordTemp): """ This is the Portfolio Analysis Record class that generates the analysis results such as those of backtest. This class inherits the ``RecordTemp`` class. @@ -243,16 +244,10 @@ class PortAnaRecord(SignalRecord): self.risk_analysis_dep = risk_analysis_dep def generate(self, **kwargs): - # check previously stored prediction results - try: - self.check(parent=True) # "Make sure the parent process is completed and store the data properly." - except FileExistsError: - super().generate() - # custom strategy and get backtest report_list = normal_backtest(env=self.env_config, strategy=self.strategy_config, **self.backtest_config) for report_dep, (report_normal, positions_normal) in enumerate(report_list): - if report_dict is None: + if report_normal is None: if self.risk_analysis_dep == report_dep: warnings.warn( f"the report in dep {risk_analysis_dep} is None, please set the corresponding env with `generate_report==True`" From d297a493b870294d1f47e496b6664b64ed19705d Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 30 Apr 2021 22:56:21 +0800 Subject: [PATCH 012/187] fix bugs --- examples/highfreq/backtest/workflow.py | 18 +--- qlib/contrib/backtest/account.py | 2 +- qlib/contrib/backtest/env.py | 34 +++++-- qlib/contrib/strategy/model_strategy.py | 2 +- qlib/workflow/record_temp.py | 116 +++++++++++++++--------- 5 files changed, 102 insertions(+), 70 deletions(-) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index a4d163ce5..bbe00ed5c 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -7,8 +7,7 @@ from pathlib import Path import qlib import pandas as pd from qlib.config import REG_CN -from qlib.contrib.strategy import TopkDropoutStrategy -from qlib.contrib.backtest import backtest + from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict from qlib.workflow import R from qlib.workflow.record_temp import PortAnaRecord @@ -130,20 +129,9 @@ if __name__ == "__main__": "min_cost": 5, }, } - - #report_dict = backtest( - # start_time=trade_start_time, - # end_time=trade_end_time, - # **backtest_config, - # account=1e8, - # benchmark=benchmark, - # deal_price="$close", - # verbose=False, - #) - with R.start(experiment_name="highfreq_backtest"): # backtest. If users want to use backtest based on their own prediction, # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. recorder = R.get_recorder() - par = PortAnaRecord(recorder, port_analysis_config, 1) - par.generate() \ No newline at end of file + par = PortAnaRecord(recorder, port_analysis_config, "day") + par.generate() diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index 5a35ffc08..88a695f8f 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -179,7 +179,7 @@ class Account: bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) self.current.update_stock_price(stock_id=code, price=bar_close) # update holding day count - + # update value self.val = self.current.calculate_value() # update earning diff --git a/qlib/contrib/backtest/env.py b/qlib/contrib/backtest/env.py index ea2618977..f5c84169d 100644 --- a/qlib/contrib/backtest/env.py +++ b/qlib/contrib/backtest/env.py @@ -6,7 +6,7 @@ import pathlib import numpy as np import pandas as pd from ...data.data import Cal -from ...utils import get_sample_freq_calendar +from ...utils import get_sample_freq_calendar, parse_freq from .position import Position from .report import Report from .order import Order @@ -151,16 +151,25 @@ class SplitEnv(BaseEnv): trade_state, trade_info = self.sub_env.execute(order_list=_order_list) self.trade_account.update_bar_end( - trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange, update_report=self.generate_report + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + trade_exchange=self.trade_exchange, + update_report=self.generate_report, ) _obs = {"current": self.trade_account.current} _info = {} return _obs, _info def get_report(self): - _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None - _positions = self.trade_account.get_positions() if self.generate_report else None - return [(_report, _positions), *self.sub_env.get_report()] + sub_env_report_dict = self.sub_env.get_report() + if self.generate_report: + _report = self.trade_account.report.generate_report_dataframe() + _positions = self.trade_account.get_positions() + _count, _freq = parse_freq(self.step_bar) + sub_env_report_dict.update({f"{_count}{_freq}": (_report, _positions)}) + return sub_env_report_dict + else: + return sub_env_report_dict class SimulatorEnv(BaseEnv): @@ -235,13 +244,20 @@ class SimulatorEnv(BaseEnv): # do nothing pass self.trade_account.update_bar_end( - trade_start_time=trade_start_time, trade_end_time=trade_end_time, trade_exchange=self.trade_exchange, update_report=self.generate_report + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + trade_exchange=self.trade_exchange, + update_report=self.generate_report, ) _obs = {"current": self.trade_account.current} _info = {"trade_info": trade_info} return _obs, _info def get_report(self): - _report = self.trade_account.report.generate_report_dataframe() if self.generate_report else None - _positions = self.trade_account.get_positions() if self.generate_report else None - return [(_report, _positions)] + if self.generate_report: + _report = self.trade_account.report.generate_report_dataframe() + _positions = self.trade_account.get_positions() + _count, _freq = parse_freq(self.step_bar) + return {f"{_count}{_freq}": (_report, _positions)} + else: + return {} diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 0bd0b9e0c..4d471cf89 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -163,7 +163,7 @@ class TopkDropoutStrategy(ModelStrategy): # Get the stock list we really want to buy buy = today[: len(sell) + self.topk - len(last)] - #print("flag", len(sell), len(buy), self.topk, len(last)) + # print("flag", len(sell), len(buy), self.topk, len(last)) for code in current_stock_list: if not self.trade_exchange.is_stock_tradable( stock_id=code, start_time=trade_start_time, end_time=trade_end_time diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 546fb5a60..8ed1e724f 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -13,7 +13,7 @@ from ..data.dataset import DatasetH from ..data.dataset.handler import DataHandlerLP from ..utils import init_instance_by_config, get_module_by_module_path from ..log import get_module_logger -from ..utils import flatten_dict +from ..utils import flatten_dict, parse_freq from ..strategy.base import BaseStrategy from ..contrib.eva.alpha import calc_ic, calc_long_short_return @@ -225,7 +225,7 @@ class PortAnaRecord(RecordTemp): artifact_path = "portfolio_analysis" - def __init__(self, recorder, config, risk_analysis_dep, **kwargs): + def __init__(self, recorder, config, risk_analysis_freq, **kwargs): """ config["strategy"] : dict define the strategy class as well as the kwargs. @@ -233,59 +233,87 @@ class PortAnaRecord(RecordTemp): define the env class as well as the kwargs. config["backtest"] : dict define the backtest kwargs. - risk_analysis_dep : int - risk analyze the dep'th env report + risk_analysis_freq : int + risk analysis freq of report """ super().__init__(recorder=recorder, **kwargs) self.strategy_config = config["strategy"] self.env_config = config["env"] self.backtest_config = config["backtest"] - self.risk_analysis_dep = risk_analysis_dep + _count, _freq = parse_freq(risk_analysis_freq) + self.risk_analysis_freq = f"{_count}{_freq}" + self.report_freq = self._get_report_freq(self.env_config) + + def _get_report_freq(self, env_config): + ret_freq = [] + if env_config["kwargs"].get("generate_report", False): + _count, _freq = parse_freq(env_config["kwargs"]["step_bar"]) + ret_freq.append(f"{_count}{_freq}") + if "sub_env" in env_config["kwargs"]: + ret_freq.extend(self._get_report_freq(env_config["kwargs"]["sub_env"])) + return ret_freq + + def _cal_risk_analysis_scaler(self, freq): + _count, _freq = parse_freq(freq) + _freq_scaler = { + "minute": 240 * 250, + "day": 250, + "week": 50, + "month": 12, + } + return _count * _freq_scaler[_freq] def generate(self, **kwargs): # custom strategy and get backtest - report_list = normal_backtest(env=self.env_config, strategy=self.strategy_config, **self.backtest_config) - for report_dep, (report_normal, positions_normal) in enumerate(report_list): - if report_normal is None: - if self.risk_analysis_dep == report_dep: - warnings.warn( - f"the report in dep {risk_analysis_dep} is None, please set the corresponding env with `generate_report==True`" - ) - continue + report_dict = normal_backtest(env=self.env_config, strategy=self.strategy_config, **self.backtest_config) + for report_freq, (report_normal, positions_normal) in report_dict.items(): + self.recorder.save_objects( + **{f"report_normal_{report_freq}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path() + ) + self.recorder.save_objects( + **{f"positions_normal_{report_freq}.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path() + ) - self.recorder.save_objects( - **{f"report_normal_{report_dep}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path() + if self.risk_analysis_freq not in report_dict: + warnings.warn( + f"the freq {self.risk_analysis_freq} report is not found, please set the corresponding env with `generate_report==True`" ) - self.recorder.save_objects( - **{f"positions_norma_{report_dep}l.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path() + else: + report_normal, _ = report_dict.get(self.risk_analysis_freq) + analysis = dict() + risk_analysis_scaler = self._cal_risk_analysis_scaler(self.risk_analysis_freq) + analysis["excess_return_without_cost"] = risk_analysis( + report_normal["return"] - report_normal["bench"], risk_analysis_scaler ) - # analysis - if self.risk_analysis_dep == report_dep: - analysis = dict() - analysis["excess_return_without_cost"] = risk_analysis(report_normal["return"] - report_normal["bench"]) - analysis["excess_return_with_cost"] = risk_analysis( - report_normal["return"] - report_normal["bench"] - report_normal["cost"] - ) - analysis_df = pd.concat(analysis) # type: pd.DataFrame - # log metrics - self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) - # save results - self.recorder.save_objects( - **{f"port_analysis.pkl_{report_dep}": analysis_df}, artifact_path=PortAnaRecord.get_path() - ) - logger.info( - f"Portfolio analysis record 'port_analysis_{report_dep}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" - ) - # print out results - pprint("The following are analysis results of the excess return without cost.") - pprint(analysis["excess_return_without_cost"]) - pprint("The following are analysis results of the excess return with cost.") - pprint(analysis["excess_return_with_cost"]) + analysis["excess_return_with_cost"] = risk_analysis( + report_normal["return"] - report_normal["bench"] - report_normal["cost"], risk_analysis_scaler + ) + analysis_df = pd.concat(analysis) # type: pd.DataFrame + # log metrics + self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) + # save results + self.recorder.save_objects( + **{f"port_analysis_{report_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path() + ) + logger.info( + f"Portfolio analysis record 'port_analysis_{report_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" + ) + # print out results + pprint("The following are analysis results of the excess return without cost.") + pprint(analysis["excess_return_without_cost"]) + pprint("The following are analysis results of the excess return with cost.") + pprint(analysis["excess_return_with_cost"]) def list(self): - return [ - PortAnaRecord.get_path("report_normal.pkl"), - PortAnaRecord.get_path("positions_normal.pkl"), - PortAnaRecord.get_path("port_analysis.pkl"), - ] + list_path = [] + for _freq in self.report_freq: + list_path.extend( + [ + PortAnaRecord.get_path(f"report_normal_{_freq}.pkl"), + PortAnaRecord.get_path(f"positions_normal_{_freq}.pkl"), + ] + ) + if _freq == self.risk_analysis_freq: + list_path.append(PortAnaRecord.get_path(f"port_analysis_{_freq}.pkl")) + return list_path From ae339506b3ee43047f6978e4ce5c0462772fa789 Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 30 Apr 2021 23:35:28 +0800 Subject: [PATCH 013/187] del old strategy --- qlib/contrib/strategy/strategy.py | 413 ------------------------------ 1 file changed, 413 deletions(-) delete mode 100644 qlib/contrib/strategy/strategy.py diff --git a/qlib/contrib/strategy/strategy.py b/qlib/contrib/strategy/strategy.py deleted file mode 100644 index 4f8eb0ab1..000000000 --- a/qlib/contrib/strategy/strategy.py +++ /dev/null @@ -1,413 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import copy -import numpy as np -import pandas as pd - -from ..backtest.order import Order -from .order_generator import OrderGenWInteract - - -# TODO: The base strategies will be moved out of contrib to core code -class BaseStrategy: - def __init__(self): - pass - - def get_risk_degree(self, date): - """get_risk_degree - Return the proportion of your total value you will used in investment. - Dynamically risk_degree will result in Market timing - """ - # It will use 95% amount of your total value by default - return 0.95 - - def generate_order_list(self, score_series, current, trade_exchange, pred_date, trade_date): - """ - DO NOT directly change the state of current - - Parameters - ----------- - score_series : pd.Series - stock_id , score. - current : Position() - current state of position. - DO NOT directly change the state of current. - trade_exchange : Exchange() - trade exchange. - pred_date : pd.Timestamp - predict date. - trade_date : pd.Timestamp - trade date. - """ - pass - - def update(self, score_series, pred_date, trade_date): - """User can use this method to update strategy state each trade date. - Parameters - ----------- - score_series : pd.Series - stock_id , score. - pred_date : pd.Timestamp - oredict date. - trade_date : pd.Timestamp - trade date. - """ - pass - - def init(self, **kwargs): - """Some strategy need to be initial after been implemented, - User can use this method to init his strategy with parameters needed. - """ - pass - - def get_init_args_from_model(self, model, init_date): - """ - This method only be used in 'online' module, it will generate the *args to initial the strategy. - :param - mode : model used in 'online' module. - """ - return {} - - -class StrategyWrapper: - """ - StrategyWrapper is a wrapper of another strategy. - By overriding some methods to make some changes on the basic strategy - Cost control and risk control will base on this class. - """ - - def __init__(self, inner_strategy): - """__init__ - - :param inner_strategy: set the inner strategy. - """ - self.inner_strategy = inner_strategy - - def __getattr__(self, name): - """__getattr__ - - :param name: If no implementation in this method. Call the method in the innter_strategy by default. - """ - return getattr(self.inner_strategy, name) - - -class AdjustTimer: - """AdjustTimer - Responsible for timing of position adjusting - - This is designed as multiple inheritance mechanism due to: - - the is_adjust may need access to the internel state of a strategy. - - - it can be reguard as a enhancement to the existing strategy. - """ - - # adjust position in each trade date - def is_adjust(self, trade_date): - """is_adjust - Return if the strategy can adjust positions on `trade_date` - Will normally be used in strategy do trading with trade frequency - """ - return True - - -class ListAdjustTimer(AdjustTimer): - def __init__(self, adjust_dates=None): - """__init__ - - :param adjust_dates: an iterable object, it will return a timelist for trading dates - """ - if adjust_dates is None: - # None indicates that all dates is OK for adjusting - self.adjust_dates = None - else: - self.adjust_dates = {pd.Timestamp(dt) for dt in adjust_dates} - - def is_adjust(self, trade_date): - if self.adjust_dates is None: - return True - return pd.Timestamp(trade_date) in self.adjust_dates - - -class WeightStrategyBase(BaseStrategy, AdjustTimer): - def __init__(self, order_generator_cls_or_obj=OrderGenWInteract, *args, **kwargs): - super().__init__(*args, **kwargs) - if isinstance(order_generator_cls_or_obj, type): - self.order_generator = order_generator_cls_or_obj() - else: - self.order_generator = order_generator_cls_or_obj - - def generate_target_weight_position(self, score, current, trade_date): - """ - Generate target position from score for this date and the current position.The cash is not considered in the position - - Parameters - ----------- - score : pd.Series - pred score for this trade date, index is stock_id, contain 'score' column. - current : Position() - current position. - trade_exchange : Exchange() - trade_date : pd.Timestamp - trade date. - """ - raise NotImplementedError() - - def generate_order_list(self, score_series, current, trade_exchange, pred_date, trade_date): - """ - Parameters - ----------- - score_series : pd.Seires - stock_id , score. - current : Position() - current of account. - trade_exchange : Exchange() - exchange. - trade_date : pd.Timestamp - date. - """ - # judge if to adjust - if not self.is_adjust(trade_date): - return [] - # generate_order_list - # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list - current_temp = copy.deepcopy(current) - target_weight_position = self.generate_target_weight_position( - score=score_series, current=current_temp, trade_date=trade_date - ) - - order_list = self.order_generator.generate_order_list_from_target_weight_position( - current=current_temp, - trade_exchange=trade_exchange, - risk_degree=self.get_risk_degree(trade_date), - target_weight_position=target_weight_position, - pred_date=pred_date, - trade_date=trade_date, - ) - return order_list - - -class TopkDropoutStrategy(BaseStrategy, ListAdjustTimer): - def __init__( - self, - topk, - n_drop, - method_sell="bottom", - method_buy="top", - risk_degree=0.95, - thresh=1, - hold_thresh=1, - only_tradable=False, - **kwargs, - ): - """ - Parameters - ----------- - topk : int - the number of stocks in the portfolio. - n_drop : int - number of stocks to be replaced in each trading date. - method_sell : str - dropout method_sell, random/bottom. - method_buy : str - dropout method_buy, random/top. - risk_degree : float - position percentage of total value. - thresh : int - minimun holding days since last buy singal of the stock. - hold_thresh : int - minimum holding days - before sell stock , will check current.get_stock_count(order.stock_id) >= self.thresh. - only_tradable : bool - will the strategy only consider the tradable stock when buying and selling. - if only_tradable: - strategy will make buy sell decision without checking the tradable state of the stock. - else: - strategy will make decision with the tradable state of the stock info and avoid buy and sell them. - """ - super(TopkDropoutStrategy, self).__init__() - ListAdjustTimer.__init__(self, kwargs.get("adjust_dates", None)) - self.topk = topk - self.n_drop = n_drop - self.method_sell = method_sell - self.method_buy = method_buy - self.risk_degree = risk_degree - self.thresh = thresh - # self.stock_count['code'] will be the days the stock has been hold - # since last buy signal. This is designed for thresh - self.stock_count = {} - - self.hold_thresh = hold_thresh - self.only_tradable = only_tradable - - def get_risk_degree(self, date): - """get_risk_degree - Return the proportion of your total value you will used in investment. - Dynamically risk_degree will result in Market timing. - """ - # It will use 95% amoutn of your total value by default - return self.risk_degree - - def generate_order_list(self, score_series, current, trade_exchange, pred_date, trade_date): - """ - Generate order list according to score_series at trade_date, will not change current. - - Parameters - ----------- - score_series : pd.Series - stock_id , score. - current : Position() - current of account. - trade_exchange : Exchange() - exchange. - pred_date : pd.Timestamp - predict date. - trade_date : pd.Timestamp - trade date. - """ - if not self.is_adjust(trade_date): - return [] - - if self.only_tradable: - # If The strategy only consider tradable stock when make decision - # It needs following actions to filter stocks - def get_first_n(l, n, reverse=False): - cur_n = 0 - res = [] - for si in reversed(l) if reverse else l: - if trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date): - res.append(si) - cur_n += 1 - if cur_n >= n: - break - return res[::-1] if reverse else res - - def get_last_n(l, n): - return get_first_n(l, n, reverse=True) - - def filter_stock(l): - return [si for si in l if trade_exchange.is_stock_tradable(stock_id=si, trade_date=trade_date)] - - else: - # Otherwise, the stock will make decision with out the stock tradable info - def get_first_n(l, n): - return list(l)[:n] - - def get_last_n(l, n): - return list(l)[-n:] - - def filter_stock(l): - return l - - current_temp = copy.deepcopy(current) - # generate order list for this adjust date - sell_order_list = [] - buy_order_list = [] - # load score - cash = current_temp.get_cash() - current_stock_list = current_temp.get_stock_list() - # last position (sorted by score) - last = score_series.reindex(current_stock_list).sort_values(ascending=False).index - # The new stocks today want to buy **at most** - if self.method_buy == "top": - today = get_first_n( - score_series[~score_series.index.isin(last)].sort_values(ascending=False).index, - self.n_drop + self.topk - len(last), - ) - elif self.method_buy == "random": - topk_candi = get_first_n(score_series.sort_values(ascending=False).index, self.topk) - candi = list(filter(lambda x: x not in last, topk_candi)) - n = self.n_drop + self.topk - len(last) - try: - today = np.random.choice(candi, n, replace=False) - except ValueError: - today = candi - else: - raise NotImplementedError(f"This type of input is not supported") - # combine(new stocks + last stocks), we will drop stocks from this list - # In case of dropping higher score stock and buying lower score stock. - comb = score_series.reindex(last.union(pd.Index(today))).sort_values(ascending=False).index - - # Get the stock list we really want to sell (After filtering the case that we sell high and buy low) - if self.method_sell == "bottom": - sell = last[last.isin(get_last_n(comb, self.n_drop))] - elif self.method_sell == "random": - candi = filter_stock(last) - try: - sell = pd.Index(np.random.choice(candi, self.n_drop, replace=False) if len(last) else []) - except ValueError: # No enough candidates - sell = candi - else: - raise NotImplementedError(f"This type of input is not supported") - - # Get the stock list we really want to buy - buy = today[: len(sell) + self.topk - len(last)] - - # buy singal: if a stock falls into topk, it appear in the buy_sinal - buy_signal = score_series.sort_values(ascending=False).iloc[: self.topk].index - - for code in current_stock_list: - if not trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): - continue - if code in sell: - # check hold limit - if self.stock_count[code] < self.thresh or current_temp.get_stock_count(code) < self.hold_thresh: - # can not sell this code - # no buy signal, but the stock is kept - self.stock_count[code] += 1 - continue - # sell order - sell_amount = current_temp.get_stock_amount(code=code) - sell_order = Order( - stock_id=code, - amount=sell_amount, - trade_date=trade_date, - direction=Order.SELL, # 0 for sell, 1 for buy - factor=trade_exchange.get_factor(code, trade_date), - ) - # is order executable - if trade_exchange.check_order(sell_order): - sell_order_list.append(sell_order) - trade_val, trade_cost, trade_price = trade_exchange.deal_order(sell_order, position=current_temp) - # update cash - cash += trade_val - trade_cost - # sold - del self.stock_count[code] - else: - # no buy signal, but the stock is kept - self.stock_count[code] += 1 - elif code in buy_signal: - # NOTE: This is different from the original version - # get new buy signal - # Only the stock fall in to topk will produce buy signal - self.stock_count[code] = 1 - else: - self.stock_count[code] += 1 - # buy new stock - # note the current has been changed - current_stock_list = current_temp.get_stock_list() - value = cash * self.risk_degree / len(buy) if len(buy) > 0 else 0 - - # open_cost should be considered in the real trading environment, while the backtest in evaluate.py does not - # consider it as the aim of demo is to accomplish same strategy as evaluate.py, so comment out this line - # value = value / (1+trade_exchange.open_cost) # set open_cost limit - for code in buy: - # check is stock suspended - if not trade_exchange.is_stock_tradable(stock_id=code, trade_date=trade_date): - continue - # buy order - buy_price = trade_exchange.get_deal_price(stock_id=code, trade_date=trade_date) - buy_amount = value / buy_price - factor = trade_exchange.quote[(code, trade_date)]["$factor"] - buy_amount = trade_exchange.round_amount_by_trade_unit(buy_amount, factor) - buy_order = Order( - stock_id=code, - amount=buy_amount, - trade_date=trade_date, - direction=Order.BUY, # 1 for buy - factor=factor, - ) - buy_order_list.append(buy_order) - self.stock_count[code] = 1 - return sell_order_list + buy_order_list From 7540ecde11e4e1d0b7d6fb4f055ba74302eb2060 Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 6 May 2021 21:33:33 +0800 Subject: [PATCH 014/187] fix trade time bug --- examples/highfreq/backtest/workflow.py | 50 +++++++++++-------------- qlib/contrib/backtest/account.py | 2 +- qlib/contrib/backtest/env.py | 2 +- qlib/contrib/strategy/model_strategy.py | 2 +- qlib/contrib/strategy/rule_strategy.py | 36 ++++++++++++++++-- 5 files changed, 56 insertions(+), 36 deletions(-) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index bbe00ed5c..8e4f30c5f 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -10,7 +10,7 @@ from qlib.config import REG_CN from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict from qlib.workflow import R -from qlib.workflow.record_temp import PortAnaRecord +from qlib.workflow.record_temp import SignalRecord, PortAnaRecord from qlib.tests.data import GetData if __name__ == "__main__": @@ -64,9 +64,9 @@ if __name__ == "__main__": "kwargs": data_handler_config, }, "segments": { - "train": ("2012-01-01", "2014-12-31"), + "train": ("2008-01-01", "2014-12-31"), "valid": ("2015-01-01", "2016-12-31"), - "test": ("2017-01-01", "2018-01-31"), + "test": ("2017-01-01", "2020-08-01"), }, }, }, @@ -74,17 +74,16 @@ if __name__ == "__main__": # model initialization model = init_instance_by_config(task["model"]) dataset = init_instance_by_config(task["dataset"]) - model.fit(dataset) - trade_start_time = "2017-01-31" - trade_end_time = "2018-01-31" + trade_start_time = "2017-01-01" + trade_end_time = "2020-08-01" port_analysis_config = { "strategy": { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", "kwargs": { - "step_bar": "week", + "step_bar": "day", "model": model, "dataset": dataset, "topk": 50, @@ -92,28 +91,12 @@ if __name__ == "__main__": }, }, "env": { - "class": "SplitEnv", + "class": "SimulatorEnv", "module_path": "qlib.contrib.backtest.env", "kwargs": { - "step_bar": "week", - "sub_env": { - "class": "SimulatorEnv", - "module_path": "qlib.contrib.backtest.env", - "kwargs": { - "step_bar": "day", - "verbose": True, - "generate_report": True, - }, - }, - "sub_strategy": { - "class": "SBBStrategyEMA", - "module_path": "qlib.contrib.strategy.rule_strategy", - "kwargs": { - "step_bar": "day", - "freq": "day", - "instruments": "csi300", - }, - }, + "step_bar": "day", + "verbose": True, + "generate_report": True, }, }, "backtest": { @@ -129,9 +112,18 @@ if __name__ == "__main__": "min_cost": 5, }, } + with R.start(experiment_name="highfreq_backtest"): + R.log_params(**flatten_dict(task)) + model.fit(dataset) + R.save_objects(**{"params.pkl": model}) + + # prediction + recorder = R.get_recorder() + sr = SignalRecord(model, dataset, recorder) + sr.generate() + # backtest. If users want to use backtest based on their own prediction, # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. - recorder = R.get_recorder() par = PortAnaRecord(recorder, port_analysis_config, "day") - par.generate() + par.generate() \ No newline at end of file diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index 88a695f8f..39fecbd88 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -94,7 +94,7 @@ class Account: def _sample_benchmark(self, bench, trade_start_time, trade_end_time): def cal_change(x): - return x.prod() - 1 + return (x + 1).prod() - 1 _ret = sample_feature(bench, trade_start_time, trade_end_time, method=cal_change) return 0 if _ret is None else _ret diff --git a/qlib/contrib/backtest/env.py b/qlib/contrib/backtest/env.py index f5c84169d..eb922cefd 100644 --- a/qlib/contrib/backtest/env.py +++ b/qlib/contrib/backtest/env.py @@ -49,7 +49,7 @@ class BaseTradeCalendar: def _get_calendar_time(self, trade_index=1, shift=0): trade_index = trade_index - shift calendar_index = self.start_index + trade_index - return self.calendar[calendar_index - 1], self.calendar[calendar_index] + return self.calendar[calendar_index - 1], self.calendar[calendar_index] - pd.Timedelta(seconds=1) def finished(self): return self.trade_index >= self.trade_len - 1 diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 4d471cf89..6899a10a5 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -51,7 +51,7 @@ class TopkDropoutStrategy(ModelStrategy): strategy will make decision with the tradable state of the stock info and avoid buy and sell them. """ super(TopkDropoutStrategy, self).__init__( - step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange + step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange, **kwargs ) self.topk = topk self.n_drop = n_drop diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 1acf55314..f69dee10d 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -11,16 +11,30 @@ from ..backtest.order import Order class TWAPStrategy(RuleStrategy, TradingEnhancement): - def reset(self, trade_order_list=None, **kwargs): + def __init__( + self, + step_bar, + start_time=None, + end_time=None, + trade_exchange=None, + **kwargs, + ): + super(TWAPStrategy, self).__init__( + step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs + ) + + def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(TWAPStrategy, self).reset(**kwargs) TradingEnhancement.reset(self, trade_order_list=trade_order_list) if trade_order_list: self.trade_amount = {} for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len + if trade_exchange: + self.trade_exchange = trade_exchange def generate_order_list(self, **kwargs): - super(TopkDropoutStrategy, self).step() + super(TWAPStrategy, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) order_list = [] for order in self.trade_order_list: @@ -44,8 +58,19 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): TREND_MID = 0 TREND_SHORT = 1 TREND_LONG = 2 + def __init__( + self, + step_bar, + start_time=None, + end_time=None, + trade_exchange=None, + **kwargs, + ): + super(SBBStrategyBase, self).__init__( + step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs + ) - def reset(self, trade_order_list=None, **kwargs): + def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(SBBStrategyBase, self).reset(**kwargs) TradingEnhancement.reset(self, trade_order_list=trade_order_list) if trade_order_list: @@ -54,6 +79,8 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID + if trade_exchange: + self.trade_exchange = trade_exchange def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") @@ -127,11 +154,12 @@ class SBBStrategyEMA(SBBStrategyBase): step_bar, start_time=None, end_time=None, + trade_exchange=None, instruments="csi300", freq="day", **kwargs, ): - super(SBBStrategyEMA, self).__init__(step_bar, start_time, end_time, **kwargs) + super(SBBStrategyEMA, self).__init__(step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs) if instruments is None: warnings.warn("`instruments` is not set, will load all stocks") self.instruments = "all" From bc3eada02d77bca34f83fd23f7fbd7f80ab0c6c3 Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 6 May 2021 21:34:31 +0800 Subject: [PATCH 015/187] black format --- examples/highfreq/backtest/workflow.py | 4 ++-- qlib/contrib/strategy/rule_strategy.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index 8e4f30c5f..f229425c2 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -112,7 +112,7 @@ if __name__ == "__main__": "min_cost": 5, }, } - + with R.start(experiment_name="highfreq_backtest"): R.log_params(**flatten_dict(task)) model.fit(dataset) @@ -126,4 +126,4 @@ if __name__ == "__main__": # backtest. If users want to use backtest based on their own prediction, # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. par = PortAnaRecord(recorder, port_analysis_config, "day") - par.generate() \ No newline at end of file + par.generate() diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index f69dee10d..5f5329257 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -19,9 +19,7 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): trade_exchange=None, **kwargs, ): - super(TWAPStrategy, self).__init__( - step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs - ) + super(TWAPStrategy, self).__init__(step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs) def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(TWAPStrategy, self).reset(**kwargs) @@ -58,6 +56,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): TREND_MID = 0 TREND_SHORT = 1 TREND_LONG = 2 + def __init__( self, step_bar, @@ -66,9 +65,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): trade_exchange=None, **kwargs, ): - super(SBBStrategyBase, self).__init__( - step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs - ) + super(SBBStrategyBase, self).__init__(step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs) def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(SBBStrategyBase, self).reset(**kwargs) From f7d30960c13bbb1ba4a92d6e69eeeee493b54af8 Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 7 May 2021 00:10:44 +0800 Subject: [PATCH 016/187] update the internal bar strategy --- examples/highfreq/backtest/workflow.py | 26 +++- qlib/contrib/backtest/exchange.py | 6 + qlib/contrib/strategy/rule_strategy.py | 182 +++++++++++++++++-------- qlib/strategy/base.py | 2 +- 4 files changed, 150 insertions(+), 66 deletions(-) diff --git a/examples/highfreq/backtest/workflow.py b/examples/highfreq/backtest/workflow.py index f229425c2..786469d8b 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/highfreq/backtest/workflow.py @@ -83,7 +83,7 @@ if __name__ == "__main__": "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", "kwargs": { - "step_bar": "day", + "step_bar": "week", "model": model, "dataset": dataset, "topk": 50, @@ -91,12 +91,28 @@ if __name__ == "__main__": }, }, "env": { - "class": "SimulatorEnv", + "class": "SplitEnv", "module_path": "qlib.contrib.backtest.env", "kwargs": { - "step_bar": "day", - "verbose": True, - "generate_report": True, + "step_bar": "week", + "sub_env": { + "class": "SimulatorEnv", + "module_path": "qlib.contrib.backtest.env", + "kwargs": { + "step_bar": "day", + "verbose": True, + "generate_report": True, + }, + }, + "sub_strategy": { + "class": "SBBStrategyEMA", + "module_path": "qlib.contrib.strategy.rule_strategy", + "kwargs": { + "step_bar": "day", + "freq": "day", + "instruments": market, + }, + }, }, }, "backtest": { diff --git a/qlib/contrib/backtest/exchange.py b/qlib/contrib/backtest/exchange.py index a25b9b4a0..51f0dd68d 100644 --- a/qlib/contrib/backtest/exchange.py +++ b/qlib/contrib/backtest/exchange.py @@ -390,6 +390,12 @@ class Exchange: ) return value + def get_amount_of_trade_unit(self, factor): + if not self.trade_w_adj_price: + return self.trade_unit / factor + else: + return None + def round_amount_by_trade_unit(self, deal_amount, factor): """Parameter deal_amount : float, adjusted amount diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 5f5329257..45df94830 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -24,27 +24,45 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(TWAPStrategy, self).reset(**kwargs) TradingEnhancement.reset(self, trade_order_list=trade_order_list) + if trade_exchange: + self.trade_exchange = trade_exchange if trade_order_list: self.trade_amount = {} for order in self.trade_order_list: - self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len - if trade_exchange: - self.trade_exchange = trade_exchange + self.trade_amount[(order.stock_id, order.direction)] = order.amount def generate_order_list(self, **kwargs): super(TWAPStrategy, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) order_list = [] for order in self.trade_order_list: - _order = Order( - stock_id=order.stock_id, - amount=self.trade_amount[(order.stock_id, order.direction)], - start_time=trade_start_time, - end_time=trade_end_time, - direction=order.direction, # 1 for buy - factor=order.factor, - ) - order_list.append(_order) + if not self.trade_exchange.is_stock_tradable( + stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time + ): + continue + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) + _order_amount = None + if _amount_trade_unit is None: + _order_amount = self.trade_amount[(order.stock_id, order.direction)] / ( + self.trade_len - self.trade_index + ) + if self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + _order_amount = ( + (trade_unit_cnt + self.trade_len - self.trade_index - 1) + // (self.trade_len - self.trade_index) + * _amount_trade_unit + ) + if _order_amount: + _order = Order( + stock_id=order.stock_id, + amount=_order_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) return order_list @@ -70,20 +88,22 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(SBBStrategyBase, self).reset(**kwargs) TradingEnhancement.reset(self, trade_order_list=trade_order_list) - if trade_order_list: - self.trade_amount = {} - self.trade_trend = {} - for order in self.trade_order_list: - self.trade_amount[(order.stock_id, order.direction)] = order.amount // self.trade_len - self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID if trade_exchange: self.trade_exchange = trade_exchange + if trade_order_list is not None: + self.trade_trend = {} + self.trade_amount = {} + for order in self.trade_order_list: + self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID + self.trade_amount[(order.stock_id, order.direction)] = order.amount def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") def generate_order_list(self, **kwargs): super(SBBStrategyBase, self).step() + if not self.trade_order_list: + return [] trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) order_list = [] @@ -92,49 +112,91 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): _pred_trend = self._pred_price_trend(order.stock_id) else: _pred_trend = self.trade_trend[(order.stock_id, order.direction)] - if _pred_trend == self.TREND_MID: - _order = Order( - stock_id=order.stock_id, - amount=self.trade_amount[(order.stock_id, order.direction)], - start_time=trade_start_time, - end_time=trade_end_time, - direction=order.direction, # 1 for buy - factor=order.factor, - ) - order_list.append(_order) - else: + + if not self.trade_exchange.is_stock_tradable( + stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time + ): if self.trade_index % 2 == 1: - if ( - _pred_trend == self.TREND_SHORT - and order.direction == order.SELL - or _pred_trend == self.TREND_LONG - and order.direction == order.BUY - ): - _order = Order( - stock_id=order.stock_id, - amount=2 * self.trade_amount[(order.stock_id, order.direction)], - start_time=trade_start_time, - end_time=trade_end_time, - direction=order.direction, # 1 for buy - factor=order.factor, - ) - order_list.append(_order) - else: - if ( - _pred_trend == self.TREND_SHORT - and order.direction == order.BUY - or _pred_trend == self.TREND_LONG - and order.direction == order.SELL - ): - _order = Order( - stock_id=order.stock_id, - amount=2 * self.trade_amount[(order.stock_id, order.direction)], - start_time=trade_start_time, - end_time=trade_end_time, - direction=order.direction, # 1 for buy - factor=order.factor, - ) - order_list.append(_order) + self.trade_trend[(order.stock_id, order.direction)] = _pred_trend + continue + + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) + if _pred_trend == self.TREND_MID: + _order_amount = None + if _amount_trade_unit is None: + _order_amount = self.trade_amount[(order.stock_id, order.direction)] / ( + self.trade_len - self.trade_index + ) + if self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + _order_amount = ( + (trade_unit_cnt + self.trade_len - self.trade_index - 1) + // (self.trade_len - self.trade_index) + * _amount_trade_unit + ) + + if _order_amount: + self.trade_amount[(order.stock_id, order.direction)] -= _order_amount + _order = Order( + stock_id=order.stock_id, + amount=_order_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) + else: + _order_amount = None + if _amount_trade_unit is None: + _order_amount = ( + 2 + * self.trade_amount[(order.stock_id, order.direction)] + / (self.trade_len - self.trade_index + 1) + ) + if self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + _order_amount = ( + 2 + * (trade_unit_cnt + self.trade_len - self.trade_index) + // (self.trade_len - self.trade_index + 1) + * _amount_trade_unit + ) + if _order_amount: + _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) + self.trade_amount[(order.stock_id, order.direction)] -= _order_amount + if self.trade_index % 2 == 1: + if ( + _pred_trend == self.TREND_SHORT + and order.direction == order.SELL + or _pred_trend == self.TREND_LONG + and order.direction == order.BUY + ): + _order = Order( + stock_id=order.stock_id, + amount=_order_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) + else: + if ( + _pred_trend == self.TREND_SHORT + and order.direction == order.BUY + or _pred_trend == self.TREND_LONG + and order.direction == order.SELL + ): + _order = Order( + stock_id=order.stock_id, + amount=_order_amount, + start_time=trade_start_time, + end_time=trade_end_time, + direction=order.direction, # 1 for buy + factor=order.factor, + ) + order_list.append(_order) if self.trade_index % 2 == 1: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index e5840d66a..8a857eb00 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -51,5 +51,5 @@ class ModelStrategy(BaseStrategy): class TradingEnhancement: def reset(self, trade_order_list=None): - if trade_order_list: + if trade_order_list is not None: self.trade_order_list = trade_order_list From 621cb243c21f9ca5fce14912b7bceddd97ad55d9 Mon Sep 17 00:00:00 2001 From: bxdd Date: Wed, 12 May 2021 02:17:39 +0800 Subject: [PATCH 017/187] fix some comments and add docstring --- examples/highfreq/{data => }/README.md | 0 .../highfreq/{data => }/highfreq_handler.py | 0 examples/highfreq/{data => }/highfreq_ops.py | 0 .../highfreq/{data => }/highfreq_processor.py | 0 examples/highfreq/{data => }/workflow.py | 0 .../workflow.py | 23 +- qlib/contrib/backtest/__init__.py | 33 +- qlib/contrib/backtest/account.py | 5 +- qlib/contrib/backtest/backtest.py | 10 +- qlib/contrib/backtest/exchange.py | 2 +- qlib/contrib/backtest/{env.py => executor.py} | 172 ++++++---- qlib/contrib/backtest/interpreter.py | 16 - qlib/contrib/evaluate.py | 23 +- qlib/contrib/online/executor.py | 291 ----------------- qlib/contrib/strategy/model_strategy.py | 40 ++- qlib/contrib/strategy/rule_strategy.py | 42 ++- qlib/data/data.py | 13 +- qlib/data/dataset/utils.py | 24 ++ qlib/rl/__init__.py | 2 + qlib/rl/env.py | 104 ++++++ qlib/rl/interpreter.py | 20 ++ qlib/strategy/base.py | 155 +++++++-- qlib/utils/__init__.py | 214 ------------- qlib/utils/sample.py | 300 ++++++++++++++++++ qlib/workflow/record_temp.py | 18 +- 25 files changed, 795 insertions(+), 712 deletions(-) rename examples/highfreq/{data => }/README.md (100%) rename examples/highfreq/{data => }/highfreq_handler.py (100%) rename examples/highfreq/{data => }/highfreq_ops.py (100%) rename examples/highfreq/{data => }/highfreq_processor.py (100%) rename examples/highfreq/{data => }/workflow.py (100%) rename examples/{highfreq/backtest => multi_level_trading}/workflow.py (89%) rename qlib/contrib/backtest/{env.py => executor.py} (63%) delete mode 100644 qlib/contrib/backtest/interpreter.py delete mode 100644 qlib/contrib/online/executor.py create mode 100644 qlib/rl/__init__.py create mode 100644 qlib/rl/env.py create mode 100644 qlib/rl/interpreter.py create mode 100644 qlib/utils/sample.py diff --git a/examples/highfreq/data/README.md b/examples/highfreq/README.md similarity index 100% rename from examples/highfreq/data/README.md rename to examples/highfreq/README.md diff --git a/examples/highfreq/data/highfreq_handler.py b/examples/highfreq/highfreq_handler.py similarity index 100% rename from examples/highfreq/data/highfreq_handler.py rename to examples/highfreq/highfreq_handler.py diff --git a/examples/highfreq/data/highfreq_ops.py b/examples/highfreq/highfreq_ops.py similarity index 100% rename from examples/highfreq/data/highfreq_ops.py rename to examples/highfreq/highfreq_ops.py diff --git a/examples/highfreq/data/highfreq_processor.py b/examples/highfreq/highfreq_processor.py similarity index 100% rename from examples/highfreq/data/highfreq_processor.py rename to examples/highfreq/highfreq_processor.py diff --git a/examples/highfreq/data/workflow.py b/examples/highfreq/workflow.py similarity index 100% rename from examples/highfreq/data/workflow.py rename to examples/highfreq/workflow.py diff --git a/examples/highfreq/backtest/workflow.py b/examples/multi_level_trading/workflow.py similarity index 89% rename from examples/highfreq/backtest/workflow.py rename to examples/multi_level_trading/workflow.py index 786469d8b..9b0e6dc77 100644 --- a/examples/highfreq/backtest/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -91,13 +91,13 @@ if __name__ == "__main__": }, }, "env": { - "class": "SplitEnv", - "module_path": "qlib.contrib.backtest.env", + "class": "SplitExecutor", + "module_path": "qlib.contrib.backtest.executor", "kwargs": { "step_bar": "week", "sub_env": { - "class": "SimulatorEnv", - "module_path": "qlib.contrib.backtest.env", + "class": "SimulatorExecutor", + "module_path": "qlib.contrib.backtest.executor", "kwargs": { "step_bar": "day", "verbose": True, @@ -118,14 +118,17 @@ if __name__ == "__main__": "backtest": { "start_time": trade_start_time, "end_time": trade_end_time, - "verbose": False, - "limit_threshold": 0.095, "account": 100000000, "benchmark": benchmark, - "deal_price": "close", - "open_cost": 0.0005, - "close_cost": 0.0015, - "min_cost": 5, + "exchange_kwargs": { + "freq": "day", + "verbose": False, + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + }, }, } diff --git a/qlib/contrib/backtest/__init__.py b/qlib/contrib/backtest/__init__.py index dacbdfefc..c8114d852 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/contrib/backtest/__init__.py @@ -1,15 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from .order import Order -from .position import Position + from .exchange import Exchange -from .report import Report +from .executor import BaseExecutor from .backtest import backtest as backtest_func -import copy -import numpy as np import inspect +from ...strategy.base import BaseStrategy from ...utils import init_instance_by_config from ...log import get_module_logger from ...config import C @@ -90,21 +88,6 @@ def get_exchange( return init_instance_by_config(exchange, accept_types=Exchange) -def init_env_instance_by_config(env): - if isinstance(env, dict): - env_config = copy.copy(env) - if "kwargs" in env_config: - env_kwargs = copy.copy(env_config["kwargs"]) - if "sub_env" in env_kwargs: - env_kwargs["sub_env"] = init_env_instance_by_config(env_kwargs["sub_env"]) - if "sub_strategy" in env_kwargs: - env_kwargs["sub_strategy"] = init_instance_by_config(env_kwargs["sub_strategy"]) - env_config["kwargs"] = env_kwargs - return init_instance_by_config(env_config) - else: - return env - - def setup_exchange(root_instance, trade_exchange=None, force=False): if "trade_exchange" in inspect.getfullargspec(root_instance.__class__).args: if force: @@ -118,13 +101,11 @@ def setup_exchange(root_instance, trade_exchange=None, force=False): setup_exchange(root_instance.sub_strategy, trade_exchange) -def backtest(start_time, end_time, strategy, env, benchmark="SH000905", account=1e9, **kwargs): - trade_strategy = init_instance_by_config(strategy) - trade_env = init_env_instance_by_config(env) +def backtest(start_time, end_time, strategy, env, benchmark="SH000905", account=1e9, exchange_kwargs={}): + trade_strategy = init_instance_by_config(strategy, accept_types=BaseStrategy) + trade_env = init_instance_by_config(env, accept_types=BaseExecutor) - spec = inspect.getfullargspec(get_exchange) - exchange_args = {k: v for k, v in kwargs.items() if k in spec.args} - trade_exchange = get_exchange(**exchange_args) + trade_exchange = get_exchange(**exchange_kwargs) setup_exchange(trade_env, trade_exchange) setup_exchange(trade_strategy, trade_exchange) diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index 39fecbd88..7e37c1093 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -3,13 +3,14 @@ import copy +import warnings import pandas as pd from .position import Position from .report import Report from .order import Order from ...data import D -from ...utils import parse_freq, sample_feature +from ...utils.sample import parse_freq, sample_feature """ @@ -110,6 +111,8 @@ class Account: for k, v in kwargs.items(): if hasattr(self, k): setattr(self, k, v) + else: + warnings.warn(f"reser error, attribute {k} is not found!") def get_positions(self): return self.positions diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index d67d6782b..d5f92ebae 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -1,10 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - -import numpy as np -import pandas as pd - from .account import Account @@ -14,9 +10,9 @@ def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) trade_strategy.reset(start_time=start_time, end_time=end_time) - trade_state = trade_env.get_init_state() + _execute_state = trade_env.get_init_state() while not trade_env.finished(): - _order_list = trade_strategy.generate_order_list(**trade_state) - trade_state, trade_info = trade_env.execute(_order_list) + _order_list = trade_strategy.generate_order_list(_execute_state) + _execute_state = trade_env.execute(_order_list) return trade_env.get_report() diff --git a/qlib/contrib/backtest/exchange.py b/qlib/contrib/backtest/exchange.py index 51f0dd68d..86045fd7a 100644 --- a/qlib/contrib/backtest/exchange.py +++ b/qlib/contrib/backtest/exchange.py @@ -11,7 +11,7 @@ import pandas as pd from ...data.data import D from ...data.dataset.utils import get_level_index from ...config import C, REG_CN -from ...utils import sample_feature +from ...utils.sample import sample_feature from ...log import get_module_logger from .order import Order diff --git a/qlib/contrib/backtest/env.py b/qlib/contrib/backtest/executor.py similarity index 63% rename from qlib/contrib/backtest/env.py rename to qlib/contrib/backtest/executor.py index eb922cefd..935af7361 100644 --- a/qlib/contrib/backtest/env.py +++ b/qlib/contrib/backtest/executor.py @@ -1,19 +1,34 @@ -import re -import json import copy import warnings -import pathlib -import numpy as np import pandas as pd +from typing import Tuple, List, Union, Optional, Callable from ...data.data import Cal -from ...utils import get_sample_freq_calendar, parse_freq -from .position import Position +from ...strategy.base import BaseStrategy +from ...utils import init_instance_by_config +from ...utils.sample import get_sample_freq_calendar, parse_freq from .report import Report from .order import Order +from .account import Account +from .exchange import Exchange class BaseTradeCalendar: - def __init__(self, step_bar, start_time=None, end_time=None, **kwargs): + def __init__( + self, step_bar: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None + ): + """ + Parameters + ---------- + step_bar : str + frequency of each trading step bar + start_time : Union[str, pd.Timestamp], optional + start time of trading, by default None + If `start_time` is None, it must be reset before trading. + end_time : Union[str, pd.Timestamp], optional + end time of trading, by default None + If `end_time` is None, it must be reset before trading. + """ + self.step_bar = step_bar self.reset(start_time=start_time, end_time=end_time) @@ -27,10 +42,9 @@ class BaseTradeCalendar: if self.start_time and self.end_time: _calendar, freq, freq_sam = get_sample_freq_calendar(freq=self.step_bar) self.calendar = _calendar - _start_time, _end_time, _start_index, _end_index = Cal.locate_index( + _, _, _start_index, _end_index = Cal.locate_index( self.start_time, self.end_time, freq=freq, freq_sam=freq_sam ) - _trade_calendar = self.calendar[_start_index : _end_index + 1] self.start_index = _start_index self.end_index = _end_index self.trade_len = _end_index - _start_index + 1 @@ -45,6 +59,8 @@ class BaseTradeCalendar: for k, v in kwargs.items(): if hasattr(self, k): setattr(self, k, v) + else: + warnings.warn(f"reser error, attribute {k} is not found!") def _get_calendar_time(self, trade_index=1, shift=0): trade_index = trade_index - shift @@ -55,34 +71,43 @@ class BaseTradeCalendar: return self.trade_index >= self.trade_len - 1 def step(self): + if self.finished(): + raise RuntimeError(f"this env has completed its task, please reset it if you want to call it!") self.trade_index = self.trade_index + 1 -class BaseEnv(BaseTradeCalendar): - """ - # Strategy framework document - - class Env(BaseEnv): - """ +class BaseExecutor(BaseTradeCalendar): + """Base executor for trading""" def __init__( self, - step_bar, - start_time=None, - end_time=None, - trade_account=None, - generate_report=False, - verbose=False, + step_bar: str, + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + trade_account: Account = None, + generate_report: bool = False, + verbose: bool = False, **kwargs, ): - self.generate_report = generate_report - self.verbose = verbose - super(BaseEnv, self).__init__( + """ + Parameters + ---------- + trade_account : Account, optional + trade account for trading, by default None + If `trade_account` is None, it must be reset before trading + generate_report : bool, optional + whether to generate report, by default False + verbose : bool, optional + whether to print log, by default False + """ + super(BaseExecutor, self).__init__( step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs ) + self.generate_report = generate_report + self.verbose = verbose def reset(self, trade_account=None, **kwargs): - super(BaseEnv, self).reset(**kwargs) + super(BaseExecutor, self).reset(**kwargs) if trade_account: self.trade_account = trade_account self.trade_account.reset(freq=self.step_bar, report=Report(), positions={}) @@ -101,23 +126,31 @@ class BaseEnv(BaseTradeCalendar): raise NotImplementedError("get_report is not implemented!") -class SplitEnv(BaseEnv): +class SplitExecutor(BaseExecutor): def __init__( self, - step_bar, - sub_env, - sub_strategy, - start_time=None, - end_time=None, - trade_account=None, - trade_exchange=None, - generate_report=False, - verbose=False, + step_bar: str, + sub_env: Union[BaseExecutor, dict], + sub_strategy: Union[BaseStrategy, dict], + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + trade_account: Account = None, + trade_exchange: Exchange = None, + generate_report: bool = False, + verbose: bool = False, **kwargs, ): - self.sub_env = sub_env - self.sub_strategy = sub_strategy - super(SplitEnv, self).__init__( + """ + Parameters + ---------- + sub_env : BaseExecutor + trading env in each trading bar. + sub_strategy : BaseStrategy + trading strategy in each trading bar + trade_exchange : Exchange + exchange that provides market info + """ + super(SplitExecutor, self).__init__( step_bar=step_bar, start_time=start_time, end_time=end_time, @@ -127,28 +160,26 @@ class SplitEnv(BaseEnv): verbose=verbose, **kwargs, ) + self.sub_env = init_instance_by_config(sub_env, accept_types=BaseExecutor) + self.sub_strategy = init_instance_by_config(sub_strategy, accept_types=BaseStrategy) def reset(self, trade_account=None, trade_exchange=None, **kwargs): - super(SplitEnv, self).reset(trade_account=trade_account, **kwargs) + + super(SplitExecutor, self).reset(trade_account=trade_account, **kwargs) if trade_account: self.sub_env.reset(trade_account=copy.copy(trade_account)) if trade_exchange: self.trade_exchange = trade_exchange - def execute(self, order_list, **kwargs): - if self.finished(): - raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") - # if self.track: - # yield action - # episode_reward = 0 - super(SplitEnv, self).step() + def execute(self, order_list): + super(SplitExecutor, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time) self.sub_strategy.reset(start_time=trade_start_time, end_time=trade_end_time, trade_order_list=order_list) - trade_state = self.sub_env.get_init_state() + _execute_state = self.sub_env.get_init_state() while not self.sub_env.finished(): - _order_list = self.sub_strategy.generate_order_list(**trade_state) - trade_state, trade_info = self.sub_env.execute(order_list=_order_list) + _order_list = self.sub_strategy.generate_order_list(_execute_state) + _execute_state = self.sub_env.execute(order_list=_order_list) self.trade_account.update_bar_end( trade_start_time=trade_start_time, @@ -156,9 +187,8 @@ class SplitEnv(BaseEnv): trade_exchange=self.trade_exchange, update_report=self.generate_report, ) - _obs = {"current": self.trade_account.current} - _info = {} - return _obs, _info + _execute_state = {"current": self.trade_account.current} + return _execute_state def get_report(self): sub_env_report_dict = self.sub_env.get_report() @@ -167,12 +197,10 @@ class SplitEnv(BaseEnv): _positions = self.trade_account.get_positions() _count, _freq = parse_freq(self.step_bar) sub_env_report_dict.update({f"{_count}{_freq}": (_report, _positions)}) - return sub_env_report_dict - else: - return sub_env_report_dict + return sub_env_report_dict -class SimulatorEnv(BaseEnv): +class SimulatorExecutor(BaseExecutor): def __init__( self, step_bar, @@ -184,7 +212,13 @@ class SimulatorEnv(BaseEnv): verbose=False, **kwargs, ): - super(SimulatorEnv, self).__init__( + """ + Parameters + ---------- + trade_exchange : Exchange + exchange that provides market info + """ + super(SimulatorExecutor, self).__init__( step_bar=step_bar, start_time=start_time, end_time=end_time, @@ -196,17 +230,12 @@ class SimulatorEnv(BaseEnv): ) def reset(self, trade_exchange=None, **kwargs): - super(SimulatorEnv, self).reset(**kwargs) + super(SimulatorExecutor, self).reset(**kwargs) if trade_exchange: self.trade_exchange = trade_exchange - def execute(self, order_list, **kwargs): - """ - Return: obs, done, info - """ - if self.finished(): - raise StopIteration(f"this env has completed its task, please reset it if you want to call it!") - super(SimulatorEnv, self).step() + def execute(self, order_list): + super(SimulatorExecutor, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) trade_info = [] for order in order_list: @@ -219,21 +248,25 @@ class SimulatorEnv(BaseEnv): if self.verbose: if order.direction == Order.SELL: # sell print( - "[I {:%Y-%m-%d}]: sell {}, price {:.2f}, amount {}, value {:.2f}.".format( + "[I {:%Y-%m-%d}]: sell {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( trade_start_time, order.stock_id, trade_price, + order.amount, order.deal_amount, + order.factor, trade_val, ) ) else: print( - "[I {:%Y-%m-%d}]: buy {}, price {:.2f}, amount {}, value {:.2f}.".format( + "[I {:%Y-%m-%d}]: buy {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( trade_start_time, order.stock_id, trade_price, + order.amount, order.deal_amount, + order.factor, trade_val, ) ) @@ -249,9 +282,8 @@ class SimulatorEnv(BaseEnv): trade_exchange=self.trade_exchange, update_report=self.generate_report, ) - _obs = {"current": self.trade_account.current} - _info = {"trade_info": trade_info} - return _obs, _info + _execute_state = {"current": self.trade_account.current, "trade_info": trade_info} + return _execute_state def get_report(self): if self.generate_report: diff --git a/qlib/contrib/backtest/interpreter.py b/qlib/contrib/backtest/interpreter.py deleted file mode 100644 index 7f33c809d..000000000 --- a/qlib/contrib/backtest/interpreter.py +++ /dev/null @@ -1,16 +0,0 @@ -class BaseInterpreter: - @staticmethod - def interpret(**kwargs): - raise NotImplementedError("interpret is not implemented!") - - -class ActionInterpreter: - @staticmethod - def interpret(action, **kwargs): - return action - - -class StateInterpreter: - @staticmethod - def interpret(state, **kwargs): - return state diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index 91cfc1d89..10f80671e 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -10,6 +10,7 @@ import warnings from ..log import get_module_logger from .backtest import get_exchange, backtest as backtest_func from ..utils import get_date_range +from ..utils.sample import parse_freq from ..data import D from ..config import C @@ -19,7 +20,7 @@ from ..data.dataset.utils import get_level_index logger = get_module_logger("Evaluate") -def risk_analysis(r, N=252): +def risk_analysis(r, N: int = None, freq: str = None): """Risk Analysis Parameters @@ -27,8 +28,26 @@ def risk_analysis(r, N=252): r : pandas.Series daily return series. N: int - scaler for annualizing information_ratio (day: 250, week: 50, month: 12). + scaler for annualizing information_ratio (day: 250, week: 50, month: 12), at least one of `N` and `freq` should exist + freq: str + analysis frequency used for calculating the scaler, at least one of `N` and `freq` should exist """ + + def cal_risk_analysis_scaler(freq): + _count, _freq = parse_freq(freq) + _freq_scaler = { + "minute": 240 * 250, + "day": 250, + "week": 50, + "month": 12, + } + return _count * _freq_scaler[_freq] + + if N is None and freq is None: + raise ValueError("at least one of `N` and `freq` should exist") + if N is None: + N = cal_risk_analysis_scaler(freq) + mean = r.mean() std = r.std(ddof=1) annualized_return = mean * N diff --git a/qlib/contrib/online/executor.py b/qlib/contrib/online/executor.py deleted file mode 100644 index 2bd0937a0..000000000 --- a/qlib/contrib/online/executor.py +++ /dev/null @@ -1,291 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -import re -import json -import copy -import pathlib -import pandas as pd -from ...data import D -from ...utils import get_date_in_file_name -from ...utils import get_pre_trading_date -from ..backtest.order import Order - - -class BaseExecutor: - """ - # Strategy framework document - - class Executor(BaseExecutor): - """ - - def execute(self, trade_account, order_list, trade_date): - """ - return the executed result (trade_info) after trading at trade_date. - NOTICE: trade_account will not be modified after executing. - Parameter - --------- - trade_account : Account() - order_list : list - [Order()] - trade_date : pd.Timestamp - Return - --------- - trade_info : list - [Order(), float, float, float] - """ - raise NotImplementedError("get_execute_result for this model is not implemented.") - - def save_executed_file_from_trade_info(self, trade_info, user_path, trade_date): - """ - Save the trade_info to the .csv transaction file in disk - the columns of result file is - ['date', 'stock_id', 'direction', 'trade_val', 'trade_cost', 'trade_price', 'factor'] - Parameter - --------- - trade_info : list of [Order(), float, float, float] - (order, trade_val, trade_cost, trade_price), trade_info with out factor - user_path: str / pathlib.Path() - the sub folder to save user data - - transaction_path : string / pathlib.Path() - """ - YYYY, MM, DD = str(trade_date.date()).split("-") - folder_path = pathlib.Path(user_path) / "trade" / YYYY / MM - if not folder_path.exists(): - folder_path.mkdir(parents=True) - transaction_path = folder_path / "transaction_{}.csv".format(str(trade_date.date())) - columns = [ - "date", - "stock_id", - "direction", - "amount", - "trade_val", - "trade_cost", - "trade_price", - "factor", - ] - data = [] - for [order, trade_val, trade_cost, trade_price] in trade_info: - data.append( - [ - trade_date, - order.stock_id, - order.direction, - order.amount, - trade_val, - trade_cost, - trade_price, - order.factor, - ] - ) - df = pd.DataFrame(data, columns=columns) - df.to_csv(transaction_path, index=False) - - def load_trade_info_from_executed_file(self, user_path, trade_date): - YYYY, MM, DD = str(trade_date.date()).split("-") - file_path = pathlib.Path(user_path) / "trade" / YYYY / MM / "transaction_{}.csv".format(str(trade_date.date())) - if not file_path.exists(): - raise ValueError("File {} not exists!".format(file_path)) - - filedate = get_date_in_file_name(file_path) - transaction = pd.read_csv(file_path) - trade_info = [] - for i in range(len(transaction)): - date = transaction.loc[i]["date"] - if not date == filedate: - continue - # raise ValueError("date in transaction file {} not equal to it's file date{}".format(date, filedate)) - order = Order( - stock_id=transaction.loc[i]["stock_id"], - amount=transaction.loc[i]["amount"], - trade_date=transaction.loc[i]["date"], - direction=transaction.loc[i]["direction"], - factor=transaction.loc[i]["factor"], - ) - trade_val = transaction.loc[i]["trade_val"] - trade_cost = transaction.loc[i]["trade_cost"] - trade_price = transaction.loc[i]["trade_price"] - trade_info.append([order, trade_val, trade_cost, trade_price]) - return trade_info - - -class SimulatorExecutor(BaseExecutor): - def __init__(self, trade_exchange, verbose=False): - self.trade_exchange = trade_exchange - self.verbose = verbose - self.order_list = [] - - def execute(self, trade_account, order_list, trade_date): - """ - execute the order list, do the trading wil exchange at date. - Will not modify the trade_account. - Parameter - trade_account : Account() - order_list : list - list or orders - trade_date : pd.Timestamp - :return: - trade_info : list of [Order(), float, float, float] - (order, trade_val, trade_cost, trade_price), trade_info with out factor - """ - account = copy.deepcopy(trade_account) - trade_info = [] - - for order in order_list: - # check holding thresh is done in strategy - # if order.direction==0: # sell order - # # checking holding thresh limit for sell order - # if trade_account.current.get_stock_count(order.stock_id) < thresh: - # # can not sell this code - # continue - # is order executable - # check order - if self.trade_exchange.check_order(order) is True: - # execute the order - trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(order, trade_account=account) - trade_info.append([order, trade_val, trade_cost, trade_price]) - if self.verbose: - if order.direction == Order.SELL: # sell - print( - "[I {:%Y-%m-%d}]: sell {}, price {:.2f}, amount {}, value {:.2f}.".format( - trade_date, - order.stock_id, - trade_price, - order.deal_amount, - trade_val, - ) - ) - else: - print( - "[I {:%Y-%m-%d}]: buy {}, price {:.2f}, amount {}, value {:.2f}.".format( - trade_date, - order.stock_id, - trade_price, - order.deal_amount, - trade_val, - ) - ) - - else: - if self.verbose: - print("[W {:%Y-%m-%d}]: {} wrong.".format(trade_date, order.stock_id)) - # do nothing - pass - return trade_info - - -def save_score_series(score_series, user_path, trade_date): - """Save the score_series into a .csv file. - The columns of saved file is - [stock_id, score] - - Parameter - --------- - order_list: [Order()] - list of Order() - date: pd.Timestamp - the date to save the order list - user_path: str / pathlib.Path() - the sub folder to save user data - """ - user_path = pathlib.Path(user_path) - YYYY, MM, DD = str(trade_date.date()).split("-") - folder_path = user_path / "score" / YYYY / MM - if not folder_path.exists(): - folder_path.mkdir(parents=True) - file_path = folder_path / "score_{}.csv".format(str(trade_date.date())) - score_series.to_csv(file_path) - - -def load_score_series(user_path, trade_date): - """Save the score_series into a .csv file. - The columns of saved file is - [stock_id, score] - - Parameter - --------- - order_list: [Order()] - list of Order() - date: pd.Timestamp - the date to save the order list - user_path: str / pathlib.Path() - the sub folder to save user data - """ - user_path = pathlib.Path(user_path) - YYYY, MM, DD = str(trade_date.date()).split("-") - folder_path = user_path / "score" / YYYY / MM - if not folder_path.exists(): - folder_path.mkdir(parents=True) - file_path = folder_path / "score_{}.csv".format(str(trade_date.date())) - score_series = pd.read_csv(file_path, index_col=0, header=None, names=["instrument", "score"]) - return score_series - - -def save_order_list(order_list, user_path, trade_date): - """ - Save the order list into a json file. - Will calculate the real amount in order according to factors at date. - - The format in json file like - {"sell": {"stock_id": amount, ...} - ,"buy": {"stock_id": amount, ...}} - - :param - order_list: [Order()] - list of Order() - date: pd.Timestamp - the date to save the order list - user_path: str / pathlib.Path() - the sub folder to save user data - """ - user_path = pathlib.Path(user_path) - YYYY, MM, DD = str(trade_date.date()).split("-") - folder_path = user_path / "trade" / YYYY / MM - if not folder_path.exists(): - folder_path.mkdir(parents=True) - sell = {} - buy = {} - for order in order_list: - if order.direction == 0: # sell - sell[order.stock_id] = [order.amount, order.factor] - else: - buy[order.stock_id] = [order.amount, order.factor] - order_dict = {"sell": sell, "buy": buy} - file_path = folder_path / "orderlist_{}.json".format(str(trade_date.date())) - with file_path.open("w") as fp: - json.dump(order_dict, fp) - - -def load_order_list(user_path, trade_date): - user_path = pathlib.Path(user_path) - YYYY, MM, DD = str(trade_date.date()).split("-") - path = user_path / "trade" / YYYY / MM / "orderlist_{}.json".format(str(trade_date.date())) - if not path.exists(): - raise ValueError("File {} not exists!".format(path)) - # get orders - with path.open("r") as fp: - order_dict = json.load(fp) - order_list = [] - for stock_id in order_dict["sell"]: - amount, factor = order_dict["sell"][stock_id] - order = Order( - stock_id=stock_id, - amount=amount, - trade_date=pd.Timestamp(trade_date), - direction=Order.SELL, - factor=factor, - ) - order_list.append(order) - for stock_id in order_dict["buy"]: - amount, factor = order_dict["buy"][stock_id] - order = Order( - stock_id=stock_id, - amount=amount, - trade_date=pd.Timestamp(trade_date), - direction=Order.BUY, - factor=factor, - ) - order_list.append(order) - return order_list diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 6899a10a5..1fc1bf070 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -3,7 +3,7 @@ import warnings import numpy as np import pandas as pd -from ...utils import sample_feature +from ...utils.sample import sample_feature from ...strategy.base import ModelStrategy from ..backtest.order import Order from .order_generator import OrderGenWInteract @@ -66,7 +66,7 @@ class TopkDropoutStrategy(ModelStrategy): if trade_exchange: self.trade_exchange = trade_exchange - def get_risk_degree(self, trade_index): + def get_risk_degree(self, trade_index=None): """get_risk_degree Return the proportion of your total value you will used in investment. Dynamically risk_degree will result in Market timing. @@ -74,7 +74,7 @@ class TopkDropoutStrategy(ModelStrategy): # It will use 95% amoutn of your total value by default return self.risk_degree - def generate_order_list(self, current, **kwargs): + def generate_order_list(self, execute_state): super(TopkDropoutStrategy, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) @@ -120,6 +120,7 @@ class TopkDropoutStrategy(ModelStrategy): def filter_stock(l): return l + current = execute_state.get("current") current_temp = copy.deepcopy(current) # generate order list for this adjust date sell_order_list = [] @@ -163,6 +164,7 @@ class TopkDropoutStrategy(ModelStrategy): # Get the stock list we really want to buy buy = today[: len(sell) + self.topk - len(last)] + print("INTRANEL BAR", len(sell), len(sell) + self.topk - len(last), len(last)) # print("flag", len(sell), len(buy), self.topk, len(last)) for code in current_stock_list: if not self.trade_exchange.is_stock_tradable( @@ -175,13 +177,17 @@ class TopkDropoutStrategy(ModelStrategy): continue # sell order sell_amount = current_temp.get_stock_amount(code=code) + factor = self.trade_exchange.get_factor( + stock_id=code, start_time=trade_start_time, end_time=trade_end_time + ) + # sell_amount = self.trade_exchange.round_amount_by_trade_unit(sell_amount, factor) sell_order = Order( stock_id=code, amount=sell_amount, start_time=trade_start_time, end_time=trade_end_time, direction=Order.SELL, # 0 for sell, 1 for buy - factor=self.trade_exchange.get_factor(code, trade_start_time, trade_end_time), + factor=factor, ) # is order executable if self.trade_exchange.check_order(sell_order): @@ -228,19 +234,36 @@ class WeightStrategyBase(ModelStrategy): def __init__( self, step_bar, + model, + dataset, start_time=None, end_time=None, order_generator_cls_or_obj=OrderGenWInteract, trade_exchange=None, **kwargs, ): - super(WeightStrategyBase, self).__init__(step_bar, start_time, end_time) - self.trade_exchange = trade_exchange + super(WeightStrategyBase, self).__init__( + step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange, **kwargs + ) + if isinstance(order_generator_cls_or_obj, type): self.order_generator = order_generator_cls_or_obj() else: self.order_generator = order_generator_cls_or_obj + def reset(self, trade_exchange=None, **kwargs): + super(WeightStrategyBase, self).reset(**kwargs) + if trade_exchange: + self.trade_exchange = trade_exchange + + def get_risk_degree(self, trade_index=None): + """get_risk_degree + Return the proportion of your total value you will used in investment. + Dynamically risk_degree will result in Market timing. + """ + # It will use 95% amoutn of your total value by default + return 0.95 + def generate_target_weight_position(self, score, current, trade_start_time, trade_end_time): """ Generate target position from score for this date and the current position.The cash is not considered in the position @@ -256,7 +279,7 @@ class WeightStrategyBase(ModelStrategy): """ raise NotImplementedError() - def generate_order_list(self, current, **kwargs): + def generate_order_list(self, execute_state): """ Parameters ----------- @@ -277,7 +300,8 @@ class WeightStrategyBase(ModelStrategy): pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: return [] - current_temp = copy.deepcopy(trade_account.current) + current = execute_state.get("current") + current_temp = copy.deepcopy(current) target_weight_position = self.generate_target_weight_position( score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time ) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 45df94830..073f513c7 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -3,14 +3,15 @@ import warnings import numpy as np import pandas as pd -from ...utils import sample_feature + +from ...utils.sample import sample_feature from ...data.data import D -from ...data.dataset.utils import get_level_index -from ...strategy.base import RuleStrategy, TradingEnhancement +from ...data.dataset.utils import convert_index_format +from ...strategy.base import RuleStrategy, OrderEnhancement from ..backtest.order import Order -class TWAPStrategy(RuleStrategy, TradingEnhancement): +class TWAPStrategy(RuleStrategy, OrderEnhancement): def __init__( self, step_bar, @@ -23,7 +24,7 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(TWAPStrategy, self).reset(**kwargs) - TradingEnhancement.reset(self, trade_order_list=trade_order_list) + OrderEnhancement.reset(self, trade_order_list=trade_order_list) if trade_exchange: self.trade_exchange = trade_exchange if trade_order_list: @@ -31,7 +32,7 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount - def generate_order_list(self, **kwargs): + def generate_order_list(self, execute_state): super(TWAPStrategy, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) order_list = [] @@ -66,7 +67,7 @@ class TWAPStrategy(RuleStrategy, TradingEnhancement): return order_list -class SBBStrategyBase(RuleStrategy, TradingEnhancement): +class SBBStrategyBase(RuleStrategy, OrderEnhancement): """ (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy. """ @@ -87,7 +88,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): super(SBBStrategyBase, self).reset(**kwargs) - TradingEnhancement.reset(self, trade_order_list=trade_order_list) + OrderEnhancement.reset(self, trade_order_list=trade_order_list) if trade_exchange: self.trade_exchange = trade_exchange if trade_order_list is not None: @@ -100,7 +101,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") - def generate_order_list(self, **kwargs): + def generate_order_list(self, execute_state): super(SBBStrategyBase, self).step() if not self.trade_order_list: return [] @@ -109,7 +110,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): order_list = [] for order in self.trade_order_list: if self.trade_index % 2 == 1: - _pred_trend = self._pred_price_trend(order.stock_id) + _pred_trend = self._pred_price_trend(order.stock_id, pred_start_time, pred_end_time) else: _pred_trend = self.trade_trend[(order.stock_id, order.direction)] @@ -127,7 +128,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): _order_amount = self.trade_amount[(order.stock_id, order.direction)] / ( self.trade_len - self.trade_index ) - if self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) _order_amount = ( (trade_unit_cnt + self.trade_len - self.trade_index - 1) @@ -146,6 +147,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): factor=order.factor, ) order_list.append(_order) + # print("DEBUG AMOUNT", _order_amount, self.trade_amount[(order.stock_id, order.direction)], _amount_trade_unit) else: _order_amount = None if _amount_trade_unit is None: @@ -154,12 +156,12 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): * self.trade_amount[(order.stock_id, order.direction)] / (self.trade_len - self.trade_index + 1) ) - if self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) _order_amount = ( - 2 - * (trade_unit_cnt + self.trade_len - self.trade_index) + (trade_unit_cnt + self.trade_len - self.trade_index) // (self.trade_len - self.trade_index + 1) + * 2 * _amount_trade_unit ) if _order_amount: @@ -197,6 +199,7 @@ class SBBStrategyBase(RuleStrategy, TradingEnhancement): factor=order.factor, ) order_list.append(_order) + # print("DEBUG AMOUNT", _order_amount, self.trade_amount[(order.stock_id, order.direction)], _amount_trade_unit) if self.trade_index % 2 == 1: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend @@ -226,20 +229,15 @@ class SBBStrategyEMA(SBBStrategyBase): self.instruments = D.instruments(instruments) self.freq = freq - def _convert_index_format(self, df): - if get_level_index(df, level="datetime") == 1: - df = df.swaplevel().sort_index() - return df - - def _reset_trade_calendar(self, start_time=None, end_time=None): - super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time) + def reset(self, start_time=None, end_time=None, **kwargs): + super(SBBStrategyEMA, self).reset(start_time=start_time, end_time=end_time, **kwargs) if self.start_time and self.end_time: fields = ["EMA($close, 10)-EMA($close, 20)"] signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) signal_df = D.features( self.instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq ) - signal_df = self._convert_index_format(signal_df) + signal_df = convert_index_format(signal_df) signal_df.columns = ["signal"] self.signal = {} for stock_id, stock_val in signal_df.groupby(level="instrument"): diff --git a/qlib/data/data.py b/qlib/data/data.py index d44139c80..91a21da9f 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -25,7 +25,8 @@ from ..log import get_module_logger from ..utils import parse_field, read_bin, hash_args, normalize_cache_fields, code_to_fname from .base import Feature from .cache import DiskDatasetCache, DiskExpressionCache -from ..utils import Wrapper, init_instance_by_config, register_wrapper, get_module_by_module_path, sample_calendar +from ..utils import Wrapper, init_instance_by_config, register_wrapper, get_module_by_module_path +from ..utils.sample import sample_calendar class CalendarProvider(abc.ABC): @@ -35,7 +36,7 @@ class CalendarProvider(abc.ABC): """ @abc.abstractmethod - def calendar(self, start_time=None, end_time=None, freq="day", future=False): + def calendar(self, start_time=None, end_time=None, freq="day", freq_sam=None, future=False): """Get calendar of certain market in given time range. Parameters @@ -46,6 +47,8 @@ class CalendarProvider(abc.ABC): end of the time range. freq : str time frequency, available: year/quarter/month/week/day. + freq_sam : str + sample frequency used for sampling lower-frequency calendar, by default None(raw calendar). future : bool whether including future trading day. @@ -769,7 +772,7 @@ class ClientCalendarProvider(CalendarProvider): def set_conn(self, conn): self.conn = conn - def calendar(self, start_time=None, end_time=None, freq="day", future=False): + def calendar(self, start_time=None, end_time=None, freq="day", freq_sam=None, future=False): self.conn.send_request( request_type="calendar", @@ -937,8 +940,8 @@ class BaseProvider: To keep compatible with old qlib provider. """ - def calendar(self, start_time=None, end_time=None, freq="day", future=False): - return Cal.calendar(start_time, end_time, freq, future=future) + def calendar(self, start_time=None, end_time=None, freq="day", freq_sam=None, future=False): + return Cal.calendar(start_time, end_time, freq, freq_sam, future=future) def instruments(self, market="all", filter_pipe=None, start_time=None, end_time=None): if start_time is not None or end_time is not None: diff --git a/qlib/data/dataset/utils.py b/qlib/data/dataset/utils.py index feda19044..f7b07d563 100644 --- a/qlib/data/dataset/utils.py +++ b/qlib/data/dataset/utils.py @@ -70,3 +70,27 @@ def fetch_df_by_index( return df.loc[ pd.IndexSlice[idx_slc], ] + + +def convert_index_format(df: Union[pd.DataFrame, pd.Series], level: str = "datetime") -> Union[pd.DataFrame, pd.Series]: + """ + Convert the format of df.MultiIndex according to the following rules: + - If `level` is the first level of df.MultiIndex, do nothing + - If `level` is the second level of df.MultiIndex, swap the level of index. + + Parameters + ---------- + df : Union[pd.DataFrame, pd.Series] + raw DataFrame/Series + level : str, optional + the level that will be converted to the first one, by default "datetime" + + Returns + ------- + Union[pd.DataFrame, pd.Series] + converted DataFrame/Series + """ + + if get_level_index(df, level=level) == 1: + df = df.swaplevel().sort_index() + return df diff --git a/qlib/rl/__init__.py b/qlib/rl/__init__.py new file mode 100644 index 000000000..59e481eb9 --- /dev/null +++ b/qlib/rl/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/qlib/rl/env.py b/qlib/rl/env.py new file mode 100644 index 000000000..9424aafab --- /dev/null +++ b/qlib/rl/env.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from .interpreter import StateInterpreter, ActionInterpreter + +from ..contrib.backtest.executor import BaseExecutor + + +class BaseRLEnv: + def reset(self, **kwargs): + raise NotImplementedError("reset is not implemented!") + + def step(self, action): + """ + step method of rl env + Parameters + ---------- + action : + action from rl policy + + Returns + ------- + env state to rl policy + """ + raise NotImplementedError("step is not implemented!") + + +class QlibRLEnv: + """qlib-based RL env""" + + def __init__( + self, + executor: BaseExecutor, + ): + """ + Parameters + ---------- + executor : BaseExecutor + qlib multi-level/single-level executor, which can be regarded as gamecore in RL + """ + self.executor = executor + + def reset(self, **kwargs): + self.executor.reset(**kwargs) + + +class QlibIntRLEnv(QlibRLEnv): + """(Qlib)-based RL (Env) with (Interpreter)""" + + def __init__( + self, + executor: BaseExecutor, + state_interpreter: StateInterpreter, + action_interpreter: ActionInterpreter, + state_interpret_kwargs: dict = {}, + action_interpret_kwargs: dict = {}, + ): + """ + + Parameters + ---------- + state_interpreter : StateInterpreter + interpretor that interprets the qlib execute result into rl env state. + action_interpreter : ActionInterpreter + interpretor that interprets the rl agent action into qlib order list + state_interpret_kwargs : dict, optional + arguments may be used in `state_interpreter.interpret`, by default {} + such as the following arguments: + - trade exchange : Exchange + Exchange that can provide market info + action_interpret_kwargs: dict, optional + arguments may be used in `action_interpreter.interpret`, by default {} + such as the following arguments: + - trade_order_list : List[Order] + If the strategy is used to split order, it presents the trade order pool. + """ + super(QlibIntRLEnv, self).__init__(executor=executor) + self.state_interpreter = state_interpreter + self.action_interpreter = action_interpreter + self.state_interpret_kwargs = state_interpret_kwargs + self.action_interpret_kwargs = action_interpret_kwargs + + def step(self, action): + """ + step method of rl env, it run as following step: + - Use `action_interpreter.interpret` method to interpret the agent action into order list + - Execute the order list with qlib executor, and get the executed result + - Use `state_interpreter.interpret` method to interpret the executed result into env state + + Parameters + ---------- + action : + action from rl policy + + Returns + ------- + env state to rl rl policy + """ + _interpret_action = self.action_interpreter.interpret(action=action, **self.state_interpret_kwargs) + _execute_result = self.executor.execute(_interpret_action) + _interpret_state = self.state_interpreter.interpret( + execute_result=_execute_result, **self.action_interpret_kwargs + ) + return _interpret_state diff --git a/qlib/rl/interpreter.py b/qlib/rl/interpreter.py new file mode 100644 index 000000000..bad337f72 --- /dev/null +++ b/qlib/rl/interpreter.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +class BaseInterpreter: + @staticmethod + def interpret(**kwargs): + raise NotImplementedError("interpret is not implemented!") + + +class ActionInterpreter(BaseInterpreter): + @staticmethod + def interpret(action, **kwargs): + raise NotImplementedError("interpret is not implemented!") + + +class StateInterpreter(BaseInterpreter): + @staticmethod + def interpret(execute_result, **kwargs): + raise NotImplementedError("interpret is not implemented!") diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 8a857eb00..a5e7210bd 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,55 +1,160 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - -import copy -import warnings -import numpy as np import pandas as pd +from typing import Tuple, List, Union, Optional, Callable -from ..utils import get_sample_freq_calendar +from ..model.base import BaseModel from ..data.dataset import DatasetH -from ..data.dataset.utils import get_level_index +from ..data.dataset.utils import convert_index_format from ..contrib.backtest.order import Order -from ..contrib.backtest.env import BaseTradeCalendar - -""" -1. BaseStrategy 的粒度一定是数据粒度的整数倍 -- 关于calendar的合并咋整 -- adjust_dates这个东西啥用 -- label和freq和strategy的bar分离,这个如何决策呢 -""" +from ..contrib.backtest.executor import BaseTradeCalendar +from ..rl.interpreter import ActionInterpreter, StateInterpreter class BaseStrategy(BaseTradeCalendar): - def generate_order_list(self, **kwargs): + """Base strategy""" + + def generate_order_list(self, execute_state): + """Generate order list in each trading bar""" raise NotImplementedError("generator_order_list is not implemented!") class RuleStrategy(BaseStrategy): + """Trading strategy with rules""" + pass class ModelStrategy(BaseStrategy): - def __init__(self, step_bar, model, dataset: DatasetH, start_time=None, end_time=None, **kwargs): + """Trading Strategy by using Model to make predictions""" + + def __init__( + self, + step_bar: str, + model: BaseModel, + dataset: DatasetH, + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + **kwargs, + ): + """ + Parameters + ---------- + model : BaseModel + the model used in when making predictions + dataset : DatasetH + provide test data for model + kwargs : dict + arguments that will be passed into `reset` method + """ self.model = model self.dataset = dataset - self.pred_scores = self._convert_index_format(self.model.predict(dataset)) + self.pred_scores = convert_index_format(self.model.predict(dataset), level="datetime") # pred_score_dates = self.pred_scores.index.get_level_values(level="datetime") super(ModelStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) - def _convert_index_format(self, df): - if get_level_index(df, level="datetime") == 1: - df = df.swaplevel().sort_index() - return df - def _update_model(self): - """update pred score""" + """ + Update model in each bar when using online data as the following steps: + - update dataset with online data, the dataset should support online update + - make the latest prediction scores of the new bar + - update the pred score into the latest prediction + """ raise NotImplementedError("_update_model is not implemented!") -class TradingEnhancement: - def reset(self, trade_order_list=None): +class RLStrategy(BaseStrategy): + """RL-based Strategy""" + + def __init__( + self, + step_bar: str, + policy, + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + **kwargs, + ): + """ + Parameters + ---------- + policy : + RL policy for generate action + """ + super(RLStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) + self.policy = policy + + +class RLIntStrategy(RLStrategy): + """(RL)-based (Strategy) with (Int)erpreter""" + + def __init__( + self, + step_bar: str, + policy, + state_interpreter: StateInterpreter, + action_interpreter: ActionInterpreter, + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + state_interpret_kwargs: dict = {}, + action_interpret_kwargs: dict = {}, + **kwargs, + ): + """ + Parameters + ---------- + state_interpreter : StateInterpreter + interpretor that interprets the qlib execute result into rl env state. + action_interpreter : ActionInterpreter + interpretor that interprets the rl agent action into qlib order list + start_time : Union[str, pd.Timestamp], optional + start time of trading, by default None + end_time : Union[str, pd.Timestamp], optional + end time of trading, by default None + state_interpret_kwargs : dict, optional + arguments may be used in `state_interpreter.interpret`, by default {} + such as the following arguments: + - trade exchange : Exchange + Exchange that can provide market info + action_interpret_kwargs: dict, optional + arguments may be used in `action_interpreter.interpret`, by default {} + such as the following arguments: + - trade_order_list : List[Order] + If the strategy is used to split order, it presents the trade order pool. + """ + super(RLIntStrategy, self).__init__(step_bar, policy, start_time, end_time, **kwargs) + + self.policy = policy + self.action_interpreter = action_interpreter + self.state_interpreter = state_interpreter + self.state_interpret_kwargs = state_interpret_kwargs + self.action_interpret_kwargs = action_interpret_kwargs + + def generate_order_list(self, execute_state): + super(RLStrategy, self).step() + _interpret_state = self.state_interpretor.interpret( + execute_result=execute_state, **self.action_interpret_kwargs + ) + _policy_action = self.policy.step(_interpret_state) + _order_list = self.action_interpreter.interpret(action=_policy_action, **self.state_interpret_kwargs) + return _order_list + + +class OrderEnhancement: + """ + Order enhancement for strategy + - If the strategy is used to split orders, the enhancement should be inherited + - If the strategy is used for portfolio management, the enhancement can be ignored + """ + + def reset(self, trade_order_list: List[Order] = None): + """reset trade orders for split strategy + + Parameters + ---------- + trade_order_list for split strategy: List[Order], optional + trading orders , by default None + """ if trade_order_list is not None: self.trade_order_list = trade_order_list diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index a6bba1f38..15652dbaf 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -800,217 +800,3 @@ def fname_to_code(fname: str): if fname.startswith(prefix): fname = fname.lstrip(prefix) return fname - - -########################## Sample ############################ -def sample_calendar_bac(calendar_raw, freq_raw, freq_sam): - """ - freq_raw : "min" or "day" - """ - freq_raw = "1" + freq_raw if re.match("^[0-9]", freq_raw) is None else freq_raw - freq_sam = "1" + freq_sam if re.match("^[0-9]", freq_sam) is None else freq_sam - - if freq_sam.endswith(("minute", "min")): - - def cal_next_sam_minute(x, sam_minutes): - hour = x.hour - minute = x.minute - if 9 <= hour <= 11: - minute_index = (11 - hour) * 60 + 30 - minute + 120 - elif 13 <= hour <= 15: - minute_index = (15 - hour) * 60 - minute - else: - raise ValueError("calendar hour must be in [9, 11] or [13, 15]") - - minute_index = minute_index // sam_minutes * sam_minutes - - if 0 <= minute_index < 120: - return 15 - (minute_index + 59) // 60, (120 - minute_index) % 60 - elif 120 <= minute_index < 240: - return 11 - (minute_index - 120 + 29) // 60, (240 - minute_index + 30) % 60 - else: - raise ValueError("calendar minute_index error") - - sam_minutes = int(freq_sam[:-3]) if freq_sam.endswith("min") else int(freq_sam[:-6]) - - if not freq_raw.endswith(("minute", "min")): - raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") - else: - raw_minutes = int(freq_raw[:-3]) if freq_raw.endswith("min") else int(freq_raw[:-6]) - if raw_minutes > sam_minutes: - raise ValueError("raw freq must be higher than sample freq") - - _calendar_minute = np.unique( - list( - map( - lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_minutes), 59), - calendar_raw, - ) - ) - ) - return _calendar_minute - else: - - _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 23, 59, 59), calendar_raw))) - if freq_sam.endswith(("day", "d")): - sam_days = int(freq_sam[:-1]) if freq_sam.endswith("d") else int(freq_sam[:-3]) - return _calendar_day[(len(_calendar_day) + sam_days - 1) % sam_days :: sam_days] - - elif freq_sam.endswith(("week", "w")): - sam_weeks = int(freq_sam[:-1]) if freq_sam.endswith("w") else int(freq_sam[:-4]) - _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) - _calendar_week = _calendar_day[np.ediff1d(_day_in_week[::-1], to_begin=1)[::-1] > 0] - return _calendar_week[(len(_calendar_week) + sam_weeks - 1) % sam_weeks :: sam_weeks] - - elif freq_sam.endswith(("month", "m")): - sam_months = int(freq_sam[:-1]) if freq_sam.endswith("m") else int(freq_sam[:-5]) - _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) - _calendar_month = _calendar_day[np.ediff1d(_day_in_month[::-1], to_begin=1)[::-1] > 0] - return _calendar_month[(len(_calendar_month) + sam_months - 1) % sam_months :: sam_months] - else: - raise ValueError("sample freq must be xmin, xd, xw, xm") - - -def parse_freq(freq): - freq = freq.lower() - search_obj = re.search("^([0-9]*)([a-z]+)", freq) - if search_obj is None: - raise ValueError("freq format is not supported") - _count = int(search_obj.group(1) if search_obj.group(1) else "1") - _freq = search_obj.group(2) - _freq_format_dict = { - "month": "month", - "mon": "month", - "week": "week", - "w": "week", - "day": "day", - "d": "day", - "minute": "minute", - "min": "minute", - } - try: - _freq = _freq_format_dict.get(_freq) - except KeyError: - raise ValueError( - "freq format is not supported, the supported freq includes (x)month/m, (x)day/d, (x)minute/min" - ) - return _count, _freq - - -def sample_calendar(calendar_raw, freq_raw, freq_sam): - """ - freq_raw : "min" or "day" - """ - raw_count, freq_raw = parse_freq(freq_raw) - sam_count, freq_sam = parse_freq(freq_sam) - if not len(calendar_raw): - return calendar_raw - if freq_sam == "minute": - - def cal_next_sam_minute(x, sam_minutes): - hour = x.hour - minute = x.minute - if (hour == 9 and minute >= 30) or (9 < hour < 11) or (hour == 11 and minute < 30): - minute_index = (hour - 9) * 60 + minute - 30 - elif 13 <= hour < 15: - minute_index = (hour - 13) * 60 + minute + 120 - else: - raise ValueError("calendar hour must be in [9, 11] or [13, 15]") - - minute_index = minute_index // sam_minutes * sam_minutes - - if 0 <= minute_index < 120: - return 9 + (minute_index + 30) // 60, (minute_index + 30) % 60 - elif 120 <= minute_index < 240: - return 13 + (minute_index - 120) // 60, (minute_index - 120) % 60 - else: - raise ValueError("calendar minute_index error") - - if req_raw != "minute": - raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") - else: - if raw_count > sam_count: - raise ValueError("raw freq must be higher than sample freq") - _calendar_minute = np.unique( - list( - map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_count), 0), calendar_raw) - ) - ) - if calendar_raw[0] > _calendar_minute[0]: - _calendar_minute[0] = calendar_raw[0] - return _calendar_minute - else: - _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 0, 0, 0), calendar_raw))) - if freq_sam == "day": - return _calendar_day[::sam_count] - - elif freq_sam == "week": - _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) - _calendar_week = _calendar_day[np.ediff1d(_day_in_week, to_begin=-1) < 0] - return _calendar_week[::sam_count] - - elif freq_sam == "month": - _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) - _calendar_month = _calendar_day[np.ediff1d(_day_in_month, to_begin=-1) < 0] - return _calendar_month[::sam_count] - else: - raise ValueError("sample freq must be xmin, xd, xw, xm") - - -def get_sample_freq_calendar(start_time=None, end_time=None, freq="day", **kwargs): - _, norm_freq = parse_freq(freq) - - from ..data.data import Cal - - try: - _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq=freq, **kwargs) - freq, freq_sam = freq, None - except ValueError: - freq_sam = freq - if norm_freq in ["month", "week", "day"]: - try: - _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="day", freq_sam=freq, **kwargs) - freq = "day" - except ValueError: - raise - _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, **kwargs) - freq = "min" - elif norm_freq == "minute": - _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, **kwargs) - freq = "min" - else: - raise ValueError(f"freq {freq} is not supported") - return _calendar, freq, freq_sam - - -def sample_feature(feature, start_time=None, end_time=None, fields=None, method="last", method_kwargs={}): - selector_datetime = slice(start_time, end_time) - fields = fields if fields else slice(None) - - from ..data.dataset.utils import get_level_index - - datetime_level = get_level_index(feature, level="datetime") == 0 - if isinstance(feature, pd.Series): - feature = feature.loc[selector_datetime] if datetime_level else feature.loc[(slice(None), selector_datetime)] - elif isinstance(feature, pd.DataFrame): - feature = ( - feature.loc[selector_datetime, fields] - if datetime_level - else feature.loc[(slice(None), selector_datetime), fields] - ) - if feature.empty: - return None - if isinstance(feature.index, pd.MultiIndex): - if callable(method): - method_func = method - return feature.groupby(level="instrument").apply(lambda x: method_func(x, **method_kwargs)) - elif isinstance(method, str): - return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) - else: - if callable(method): - method_func = method - return method_func(feature, **method_kwargs) - elif isinstance(method, str): - return getattr(feature, method)(**method_kwargs) - - return feature diff --git a/qlib/utils/sample.py b/qlib/utils/sample.py new file mode 100644 index 000000000..9f67d4981 --- /dev/null +++ b/qlib/utils/sample.py @@ -0,0 +1,300 @@ +import re +import numpy as np +import pandas as pd +from typing import Tuple, List, Union, Optional, Callable + + +def parse_freq(freq: str) -> Tuple[int, str]: + """ + Parse freq into a unified format + + Parameters + ---------- + freq : str + Raw freq, supported freq should match the re '^([0-9]*)(month|mon|week|w|day|d|minute|min)$' + + Returns + ------- + freq: Tuple[int, str] + Unified freq, including freq count and unified freq unit. The freq unit should be '[month|week|day|minute]'. + Example: + + .. code-block:: + + print(parse_freq("day")) + (1, "day" ) + print(parse_freq("2mon")) + (2, "month") + print(parse_freq("10w")) + (10, "week") + + """ + freq = freq.lower() + match_obj = re.match("^([0-9]*)(month|mon|week|w|day|d|minute|min)$", freq) + if match_obj is None: + raise ValueError( + "freq format is not supported, the freq should be like (n)month/mon, (n)week/w, (n)day/d, (n)minute/min" + ) + _count = int(match_obj.group(1) if match_obj.group(1) else "1") + _freq = match_obj.group(2) + _freq_format_dict = { + "month": "month", + "mon": "month", + "week": "week", + "w": "week", + "day": "day", + "d": "day", + "minute": "minute", + "min": "minute", + } + return _count, _freq_format_dict[_freq] + + +def sample_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np.ndarray: + """ + Sample the calendar with frequency freq_raw into the calendar with frequency freq_sam + + Parameters + ---------- + calendar_raw : np.ndarray + The calendar with frequency freq_raw + freq_raw : str + Frequency of the raw calendar + freq_sam : str + Sample frequency + + Returns + ------- + np.ndarray + The calendar with frequency freq_sam + """ + raw_count, freq_raw = parse_freq(freq_raw) + sam_count, freq_sam = parse_freq(freq_sam) + if not len(calendar_raw): + return calendar_raw + if freq_sam == "minute": + + def cal_next_sam_minute(x, sam_minutes): + hour = x.hour + minute = x.minute + if (hour == 9 and minute >= 30) or (9 < hour < 11) or (hour == 11 and minute < 30): + minute_index = (hour - 9) * 60 + minute - 30 + elif 13 <= hour < 15: + minute_index = (hour - 13) * 60 + minute + 120 + else: + raise ValueError("calendar hour must be in [9, 11] or [13, 15]") + + minute_index = minute_index // sam_minutes * sam_minutes + + if 0 <= minute_index < 120: + return 9 + (minute_index + 30) // 60, (minute_index + 30) % 60 + elif 120 <= minute_index < 240: + return 13 + (minute_index - 120) // 60, (minute_index - 120) % 60 + else: + raise ValueError("calendar minute_index error") + + if freq_raw != "minute": + raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") + else: + if raw_count > sam_count: + raise ValueError("raw freq must be higher than sampling freq") + _calendar_minute = np.unique( + list( + map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_count), 0), calendar_raw) + ) + ) + if calendar_raw[0] > _calendar_minute[0]: + _calendar_minute[0] = calendar_raw[0] + return _calendar_minute + else: + _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 0, 0, 0), calendar_raw))) + if freq_sam == "day": + return _calendar_day[::sam_count] + + elif freq_sam == "week": + _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) + _calendar_week = _calendar_day[np.ediff1d(_day_in_week, to_begin=-1) < 0] + return _calendar_week[::sam_count] + + elif freq_sam == "month": + _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) + _calendar_month = _calendar_day[np.ediff1d(_day_in_month, to_begin=-1) < 0] + return _calendar_month[::sam_count] + else: + raise ValueError("sampling freq must be xmin, xd, xw, xm") + + +def get_sample_freq_calendar( + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + freq: str = "day", + future: bool = False, +) -> Tuple[np.ndarray, str, Optional[str]]: + """ + Get the calendar with frequency freq. + + - If the calendar with the raw frequency freq exists, return it directly + + - Else, sample from a higher frequency calendar automatically + + Parameters + ---------- + start_time : Union[str, pd.Timestamp], optional + start time of calendar, by default None + end_time : Union[str, pd.Timestamp], optional + end time of calendar, by default None + freq : str, optional + freq of calendar, by default "day" + future : bool, optional + whether including future trading day. + + Returns + ------- + Tuple[np.ndarray, str, Optional[str]] + + - the first value is the calendar + - the second value is the raw freq of calendar + - the third value is the sampling freq of calendar, it's None if the raw frequency freq exists. + + """ + + _, norm_freq = parse_freq(freq) + + from ..data.data import Cal + + try: + _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq=freq, future=future) + freq, freq_sam = freq, None + except ValueError: + freq_sam = freq + if norm_freq in ["month", "week", "day"]: + try: + _calendar = Cal.calendar( + start_time=start_time, end_time=end_time, freq="day", freq_sam=freq, future=future + ) + freq = "day" + except ValueError: + _calendar = Cal.calendar( + start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, future=future + ) + freq = "min" + elif norm_freq == "minute": + _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, future=future) + freq = "min" + else: + raise ValueError(f"freq {freq} is not supported") + return _calendar, freq, freq_sam + + +def sample_feature( + feature: Union[pd.DataFrame, pd.Series], + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + fields: Union[str, List[str]] = None, + method: Union[str, Callable] = "last", + method_kwargs: dict = {}, +): + """ + Sample value from pandas DataFrame or Series for each stock + + - If `feature` has MultiIndex[instrument, datetime], apply the `method` to each instruemnt data with datetime in [start_time, end_time] + Example: + + .. code-block:: + + print(feature) + $close $volume + instrument datetime + SH600000 2010-01-04 86.778313 16162960.0 + 2010-01-05 87.433578 28117442.0 + 2010-01-06 85.713585 23632884.0 + 2010-01-07 83.788803 20813402.0 + 2010-01-08 84.730675 16044853.0 + + SH600655 2010-01-04 2699.567383 158193.328125 + 2010-01-08 2612.359619 77501.406250 + 2010-01-11 2712.982422 160852.390625 + 2010-01-12 2788.688232 164587.937500 + 2010-01-13 2790.604004 145460.453125 + + print(sample_feature(feature, start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + $close $volume + instrument + SH600000 87.433578 28117442.0 + SH600655 2699.567383 158193.328125 + + - Else, the `feature` should have Index[datetime], just apply the `method` to `feature` directly + Example: + + .. code-block:: + print(feature) + $close $volume + datetime + 2010-01-04 86.778313 16162960.0 + 2010-01-05 87.433578 28117442.0 + 2010-01-06 85.713585 23632884.0 + 2010-01-07 83.788803 20813402.0 + 2010-01-08 84.730675 16044853.0 + + print(sample_feature(feature, start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + + $close 87.433578 + $volume 28117442.0 + + print(sample_feature(feature, start_time="2010-01-04", end_time="2010-01-05", fields="$close", method="last")) + + 87.433578 + + Parameters + ---------- + feature : Union[pd.DataFrame, pd.Series] + Raw feature to be sampled + start_time : Union[str, pd.Timestamp], optional + start sampling time, by default None + end_time : Union[str, pd.Timestamp], optional + end sampling time, by default None + fields : Union[str, List[str]], optional + column names, it's ignored when sample pd.Series data, by default None(all columns) + method : Union[str, Callable], optional + sample method, apply method function to each stock series data, by default "last" + - If type(method) is str, it should be an attribute of SeriesGroupBy or DataFrameGroupby, and run feature.groupby + - If `feature` has MultiIndex[instrument, datetime], method must be a member of pandas.groupby when it's type is str.or callable function. + method_kwargs : dict, optional + arguments of method, by default {} + + Returns + ------- + The Sampled DataFrame/Series/Value + """ + + selector_datetime = slice(start_time, end_time) + if fields is None: + fields = slice(None) + + from ..data.dataset.utils import get_level_index + + datetime_level = get_level_index(feature, level="datetime") == 0 + if isinstance(feature, pd.Series): + feature = feature.loc[selector_datetime] if datetime_level else feature.loc[(slice(None), selector_datetime)] + elif isinstance(feature, pd.DataFrame): + feature = ( + feature.loc[selector_datetime, fields] + if datetime_level + else feature.loc[(slice(None), selector_datetime), fields] + ) + if feature.empty: + return None + if isinstance(feature.index, pd.MultiIndex): + if callable(method): + method_func = method + return feature.groupby(level="instrument").apply(lambda x: method_func(x, **method_kwargs)) + elif isinstance(method, str): + return getattr(feature.groupby(level="instrument"), method)(**method_kwargs) + else: + if callable(method): + method_func = method + return method_func(feature, **method_kwargs) + elif isinstance(method, str): + return getattr(feature, method)(**method_kwargs) + + return feature diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index fa1dc2e25..8a8bde7ef 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -14,7 +14,8 @@ from ..data.dataset import DatasetH from ..data.dataset.handler import DataHandlerLP from ..utils import init_instance_by_config, get_module_by_module_path from ..log import get_module_logger -from ..utils import flatten_dict, parse_freq +from ..utils import flatten_dict +from ..utils.sample import parse_freq from ..strategy.base import BaseStrategy from ..contrib.eva.alpha import calc_ic, calc_long_short_return @@ -315,16 +316,6 @@ class PortAnaRecord(RecordTemp): ret_freq.extend(self._get_report_freq(env_config["kwargs"]["sub_env"])) return ret_freq - def _cal_risk_analysis_scaler(self, freq): - _count, _freq = parse_freq(freq) - _freq_scaler = { - "minute": 240 * 250, - "day": 250, - "week": 50, - "month": 12, - } - return _count * _freq_scaler[_freq] - def generate(self, **kwargs): # custom strategy and get backtest report_dict = normal_backtest(env=self.env_config, strategy=self.strategy_config, **self.backtest_config) @@ -343,12 +334,11 @@ class PortAnaRecord(RecordTemp): else: report_normal, _ = report_dict.get(self.risk_analysis_freq) analysis = dict() - risk_analysis_scaler = self._cal_risk_analysis_scaler(self.risk_analysis_freq) analysis["excess_return_without_cost"] = risk_analysis( - report_normal["return"] - report_normal["bench"], risk_analysis_scaler + report_normal["return"] - report_normal["bench"], self.risk_analysis_freq ) analysis["excess_return_with_cost"] = risk_analysis( - report_normal["return"] - report_normal["bench"] - report_normal["cost"], risk_analysis_scaler + report_normal["return"] - report_normal["bench"] - report_normal["cost"], self.risk_analysis_freq ) analysis_df = pd.concat(analysis) # type: pd.DataFrame # log metrics From 07eaada31e670e9322febb8bf7b269eb76fb020a Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 13 May 2021 00:33:57 +0800 Subject: [PATCH 018/187] fix comments --- examples/multi_level_trading/workflow.py | 1 - qlib/contrib/backtest/__init__.py | 41 +++--- qlib/contrib/backtest/account.py | 97 +++++++------ qlib/contrib/backtest/backtest.py | 7 +- qlib/contrib/backtest/executor.py | 176 +++++++++++++---------- qlib/contrib/backtest/faculty.py | 28 ++++ qlib/contrib/strategy/cost_control.py | 23 ++- qlib/contrib/strategy/model_strategy.py | 24 +--- qlib/contrib/strategy/order_generator.py | 4 +- qlib/contrib/strategy/rule_strategy.py | 30 ++-- qlib/rl/env.py | 2 + qlib/rl/interpreter.py | 30 ++++ qlib/strategy/base.py | 12 +- qlib/workflow/record_temp.py | 4 +- 14 files changed, 294 insertions(+), 185 deletions(-) create mode 100644 qlib/contrib/backtest/faculty.py diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index 9b0e6dc77..77689b3f7 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -122,7 +122,6 @@ if __name__ == "__main__": "benchmark": benchmark, "exchange_kwargs": { "freq": "day", - "verbose": False, "limit_threshold": 0.095, "deal_price": "close", "open_cost": 0.0005, diff --git a/qlib/contrib/backtest/__init__.py b/qlib/contrib/backtest/__init__.py index c8114d852..8cfbf9674 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/contrib/backtest/__init__.py @@ -1,16 +1,18 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - +from .account import Account from .exchange import Exchange from .executor import BaseExecutor from .backtest import backtest as backtest_func -import inspect + from ...strategy.base import BaseStrategy from ...utils import init_instance_by_config from ...log import get_module_logger from ...config import C +from .faculty import common_faculty + logger = get_module_logger("backtest caller") @@ -28,7 +30,6 @@ def get_exchange( trade_unit=None, limit_threshold=None, deal_price=None, - shift=1, ): """get_exchange @@ -88,28 +89,26 @@ def get_exchange( return init_instance_by_config(exchange, accept_types=Exchange) -def setup_exchange(root_instance, trade_exchange=None, force=False): - if "trade_exchange" in inspect.getfullargspec(root_instance.__class__).args: - if force: - root_instance.reset(trade_exchange=trade_exchange) - else: - if not hasattr(root_instance, "trade_exchange") or root_instance.trade_exchange is None: - root_instance.reset(trade_exchange=trade_exchange) - if hasattr(root_instance, "sub_env"): - setup_exchange(root_instance.sub_env, trade_exchange) - if hasattr(root_instance, "sub_strategy"): - setup_exchange(root_instance.sub_strategy, trade_exchange) +def backtest(start_time, end_time, strategy, env, benchmark="SH000300", account=1e9, exchange_kwargs={}): + trade_account = Account( + init_cash=account, + benchmark_config={ + "benchmark": benchmark, + "start_time": start_time, + "end_time": end_time, + }, + ) + trade_exchange = get_exchange(**exchange_kwargs) + + common_faculty.update( + trade_account=trade_account, + trade_exchange=trade_exchange, + ) -def backtest(start_time, end_time, strategy, env, benchmark="SH000905", account=1e9, exchange_kwargs={}): trade_strategy = init_instance_by_config(strategy, accept_types=BaseStrategy) trade_env = init_instance_by_config(env, accept_types=BaseExecutor) - trade_exchange = get_exchange(**exchange_kwargs) - - setup_exchange(trade_env, trade_exchange) - setup_exchange(trade_strategy, trade_exchange) - - report_dict = backtest_func(start_time, end_time, trade_strategy, trade_env, benchmark, account) + report_dict = backtest_func(start_time, end_time, trade_strategy, trade_env) return report_dict diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index 7e37c1093..5e2e03ea0 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -30,48 +30,53 @@ rtn & earning in the Account class Account: - def __init__(self, init_cash, benchmark=None, start_time=None, end_time=None, freq=None): - self.init_vars(init_cash, benchmark, start_time, end_time) + def __init__(self, init_cash, freq: str = "day", benchmark_config: dict = {}): + self.init_vars(init_cash, freq, benchmark_config) - def init_vars(self, init_cash, benchmark=None, start_time=None, end_time=None, freq=None): + def init_vars(self, init_cash, freq: str, benchmark_config: dict): """ Parameters ---------- - - benchmark: str/list/pd.Series - `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. - example: - print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) - 2017-01-04 0.011693 - 2017-01-05 0.000721 - 2017-01-06 -0.004322 - 2017-01-09 0.006874 - 2017-01-10 -0.003350 - `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. - `benchmark` is str, will use the daily change as the 'bench'. - benchmark code, default is SH000905 CSI500 + freq : str + frequency of trading bar, used for updating hold count of trading bar + benchmark_config : dict + config of benchmark, may including the following arguments: + - benchmark : Union[str, list, pd.Series] + - If `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. + example: + print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) + 2017-01-04 0.011693 + 2017-01-05 0.000721 + 2017-01-06 -0.004322 + 2017-01-09 0.006874 + 2017-01-10 -0.003350 + - If `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. + - If `benchmark` is str, will use the daily change as the 'bench'. + benchmark code, default is SH000300 CSI300 + - start_time : Union[str, pd.Timestamp], optional + - If `benchmark` is pd.Series, it will be ignored + - Else, it represent start time of benchmark, by default None + - end_time : Union[str, pd.Timestamp], optional + - If `benchmark` is pd.Series, it will be ignored + - Else, it represent end time of benchmark, by default None """ # init cash self.init_cash = init_cash - self.benchmark = benchmark - self.start_time = start_time - self.end_time = end_time self.freq = freq + self.benchmark_config = benchmark_config + self.bench = self._cal_benchmark(benchmark_config, freq) self.current = Position(cash=init_cash) - self.positions = {} - self.rtn = 0 - self.ct = 0 - self.to = 0 - self.val = 0 - self.earning = 0 - self.report = Report() - if freq and benchmark: - self.bench = self._cal_benchmark(benchmark, start_time, end_time, freq) + self._reset_report() - def _cal_benchmark(self, benchmark, start_time=None, end_time=None, freq=None): + def _cal_benchmark(self, benchmark_config, freq): + benchmark = benchmark_config.get("benchmark", "SH000300") if isinstance(benchmark, pd.Series): return benchmark else: + start_time = benchmark_config.get("start_time", None) + end_time = benchmark_config.get("end_time", None) + if freq is None: raise ValueError("benchmark freq can't be None!") _codes = benchmark if isinstance(benchmark, list) else [benchmark] @@ -100,19 +105,25 @@ class Account: _ret = sample_feature(bench, trade_start_time, trade_end_time, method=cal_change) return 0 if _ret is None else _ret - def reset(self, benchmark=None, freq=None, **kwargs): - if benchmark: - self.benchmark = benchmark - if freq: + def _reset_freq(self, freq): + """reset frequency""" + if freq != self.freq: self.freq = freq - if self.freq and self.benchmark and (freq or benchmark): - self.bench = self._cal_benchmark(self.benchmark, self.start_time, self.end_time, self.freq) + self.bench = self._cal_benchmark(self.benchmark_config, self.freq) - for k, v in kwargs.items(): - if hasattr(self, k): - setattr(self, k, v) - else: - warnings.warn(f"reser error, attribute {k} is not found!") + def _reset_report(self): + self.report = Report() + self.positions = {} + self.rtn = 0 + self.ct = 0 + self.to = 0 + self.val = 0 + self.earning = 0 + + def reset(self, freq=None, init_report: bool = False): + self._reset_freq(freq) + if init_report: + self._reset_report() def get_positions(self): return self.positions @@ -155,7 +166,10 @@ class Account: self.current.update_order(order, trade_val, cost, trade_price) self.update_state_from_order(order, trade_val, cost, trade_price) - def update_bar_end(self, trade_start_time, trade_end_time, trade_exchange, update_report): + def update_bar_count(self): + self.current.add_count_all(bar=self.freq) + + def update_bar_report(self, trade_start_time, trade_end_time, trade_exchange): """ start_time: pd.TimeStamp end_time: pd.TimeStamp @@ -171,9 +185,6 @@ class Account: :return: None """ # update price for stock in the position and the profit from changed_price - self.current.add_count_all(bar=self.freq) - if update_report is None: - return stock_list = self.current.get_stock_list() for code in stock_list: # if suspend, no new price to be updated, profit is 0 diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index d5f92ebae..73785c771 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -1,13 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from .account import Account +def backtest(start_time, end_time, trade_strategy, trade_env): -def backtest(start_time, end_time, trade_strategy, trade_env, benchmark, account): - - trade_account = Account(init_cash=account, benchmark=benchmark, start_time=start_time, end_time=end_time) - trade_env.reset(start_time=start_time, end_time=end_time, trade_account=trade_account) + trade_env.reset(start_time=start_time, end_time=end_time) trade_strategy.reset(start_time=start_time, end_time=end_time) _execute_state = trade_env.get_init_state() diff --git a/qlib/contrib/backtest/executor.py b/qlib/contrib/backtest/executor.py index 935af7361..65d9cfaea 100644 --- a/qlib/contrib/backtest/executor.py +++ b/qlib/contrib/backtest/executor.py @@ -1,18 +1,25 @@ import copy import warnings import pandas as pd -from typing import Tuple, List, Union, Optional, Callable +from typing import Union from ...data.data import Cal -from ...strategy.base import BaseStrategy + from ...utils import init_instance_by_config from ...utils.sample import get_sample_freq_calendar, parse_freq -from .report import Report + + from .order import Order from .account import Account from .exchange import Exchange +from .faculty import common_faculty class BaseTradeCalendar: + """ + Base class providing trading calendar + - BaseStrategy and BaseExecutor should inherited from this class + """ + def __init__( self, step_bar: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None ): @@ -30,16 +37,13 @@ class BaseTradeCalendar: """ self.step_bar = step_bar + self.start_time = pd.Timestamp(start_time) + self.end_time = pd.Timestamp(end_time) self.reset(start_time=start_time, end_time=end_time) def _reset_trade_calendar(self, start_time, end_time): - if not start_time and not end_time: - return - if start_time: - self.start_time = pd.Timestamp(start_time) - if end_time: - self.end_time = pd.Timestamp(end_time) - if self.start_time and self.end_time: + """reset trade calendar""" + if start_time and end_time: _calendar, freq, freq_sam = get_sample_freq_calendar(freq=self.step_bar) self.calendar = _calendar _, _, _start_index, _end_index = Cal.locate_index( @@ -50,17 +54,19 @@ class BaseTradeCalendar: self.trade_len = _end_index - _start_index + 1 self.trade_index = 0 else: - raise ValueError("failed to reset trade calendar, params `start_time` or `end_time` is None.") + raise ValueError("failed to reset trade calendar, param `start_time` or `end_time` is None.") - def reset(self, start_time=None, end_time=None, **kwargs): - if start_time or end_time: - self._reset_trade_calendar(start_time=start_time, end_time=end_time) + def reset(self, start_time=None, end_time=None): + """ + Reset start\end time of trading, and reset trading calendar + """ - for k, v in kwargs.items(): - if hasattr(self, k): - setattr(self, k, v) - else: - warnings.warn(f"reser error, attribute {k} is not found!") + if start_time: + self.start_time = pd.Timestamp(start_time) + if end_time: + self.end_time = pd.Timestamp(end_time) + if self.start_time and self.end_time and (start_time or end_time): + self._reset_trade_calendar(start_time=self.start_time, end_time=self.end_time) def _get_calendar_time(self, trade_index=1, shift=0): trade_index = trade_index - shift @@ -87,6 +93,7 @@ class BaseExecutor(BaseTradeCalendar): trade_account: Account = None, generate_report: bool = False, verbose: bool = False, + track_data: bool = False, **kwargs, ): """ @@ -94,23 +101,30 @@ class BaseExecutor(BaseTradeCalendar): ---------- trade_account : Account, optional trade account for trading, by default None - If `trade_account` is None, it must be reset before trading + - If `trade_account` is None, self.trade_account will be set with common_faculty generate_report : bool, optional whether to generate report, by default False verbose : bool, optional - whether to print log, by default False + whether to print trading info, by default False + track_data : bool, optional + whether to generate order_list, will be used when making data for multi-level training + - If `self.track_data` is true, when making data for training, the input `order_list` of `execute` will be generated by `get_data` + - Else, `order_list` will not be generated """ - super(BaseExecutor, self).__init__( - step_bar=step_bar, start_time=start_time, end_time=end_time, trade_account=trade_account, **kwargs - ) + super(BaseExecutor, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, **kwargs) + self.trade_account = copy.copy(common_faculty.trade_account if trade_account is None else trade_account) + self.trade_account.reset(freq=self.step_bar, init_report=True) self.generate_report = generate_report self.verbose = verbose + self.track_data = track_data - def reset(self, trade_account=None, **kwargs): + def reset(self, track_data: bool = None, **kwargs): + """ + Reset `track_data`, will be used when making data for multi-level training + """ super(BaseExecutor, self).reset(**kwargs) - if trade_account: - self.trade_account = trade_account - self.trade_account.reset(freq=self.step_bar, report=Report(), positions={}) + if track_data is not None: + self.track_data = track_data def get_init_state(self): init_state = {"current": self.trade_account.current} @@ -127,6 +141,8 @@ class BaseExecutor(BaseTradeCalendar): class SplitExecutor(BaseExecutor): + from ...strategy.base import BaseStrategy + def __init__( self, step_bar: str, @@ -138,6 +154,7 @@ class SplitExecutor(BaseExecutor): trade_exchange: Exchange = None, generate_report: bool = False, verbose: bool = False, + track_data: bool = False, **kwargs, ): """ @@ -155,40 +172,55 @@ class SplitExecutor(BaseExecutor): start_time=start_time, end_time=end_time, trade_account=trade_account, - trade_exchange=trade_exchange, generate_report=generate_report, verbose=verbose, + track_data=track_data, **kwargs, ) + if generate_report: + self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange self.sub_env = init_instance_by_config(sub_env, accept_types=BaseExecutor) - self.sub_strategy = init_instance_by_config(sub_strategy, accept_types=BaseStrategy) - def reset(self, trade_account=None, trade_exchange=None, **kwargs): + self.sub_strategy = init_instance_by_config(sub_strategy, accept_types=self.BaseStrategy) - super(SplitExecutor, self).reset(trade_account=trade_account, **kwargs) - if trade_account: - self.sub_env.reset(trade_account=copy.copy(trade_account)) - if trade_exchange: - self.trade_exchange = trade_exchange - - def execute(self, order_list): - super(SplitExecutor, self).step() + def _init_sub_trading(self, order_list): trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time) self.sub_strategy.reset(start_time=trade_start_time, end_time=trade_end_time, trade_order_list=order_list) - _execute_state = self.sub_env.get_init_state() - while not self.sub_env.finished(): - _order_list = self.sub_strategy.generate_order_list(_execute_state) - _execute_state = self.sub_env.execute(order_list=_order_list) + sub_execute_state = self.sub_env.get_init_state() + return sub_execute_state - self.trade_account.update_bar_end( - trade_start_time=trade_start_time, - trade_end_time=trade_end_time, - trade_exchange=self.trade_exchange, - update_report=self.generate_report, - ) - _execute_state = {"current": self.trade_account.current} - return _execute_state + def _update_trade_account(self): + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) + self.trade_account.update_bar_count() + if self.generate_report: + self.trade_account.update_bar_report( + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + trade_exchange=self.trade_exchange, + ) + + def execute(self, order_list): + super(SplitExecutor, self).step() + self._init_sub_trading(order_list) + sub_execute_state = self.sub_env.get_init_state() + while not self.sub_env.finished(): + _order_list = self.sub_strategy.generate_order_list(sub_execute_state) + sub_execute_state = self.sub_env.execute(order_list=_order_list) + self._update_trade_account() + return {"current": self.trade_account.current} + + def get_data(self, order_list): + if self.track_data: + yield order_list + super(SplitExecutor, self).step() + self._init_sub_trading(order_list) + sub_execute_state = self.sub_env.get_init_state() + while not self.sub_env.finished(): + _order_list = self.sub_strategy.generate_order_list(sub_execute_state) + sub_execute_state = yield from self.sub_env.get_data(order_list=_order_list) + self._update_trade_account() + return {"current": self.trade_account.current} def get_report(self): sub_env_report_dict = self.sub_env.get_report() @@ -203,13 +235,14 @@ class SplitExecutor(BaseExecutor): class SimulatorExecutor(BaseExecutor): def __init__( self, - step_bar, - start_time=None, - end_time=None, - trade_account=None, - trade_exchange=None, - generate_report=False, - verbose=False, + step_bar: str, + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + trade_account: Account = None, + trade_exchange: Exchange = None, + generate_report: bool = False, + verbose: bool = False, + track_data: bool = False, **kwargs, ): """ @@ -223,16 +256,12 @@ class SimulatorExecutor(BaseExecutor): start_time=start_time, end_time=end_time, trade_account=trade_account, - trade_exchange=trade_exchange, generate_report=generate_report, verbose=verbose, + track_data=track_data, **kwargs, ) - - def reset(self, trade_exchange=None, **kwargs): - super(SimulatorExecutor, self).reset(**kwargs) - if trade_exchange: - self.trade_exchange = trade_exchange + self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange def execute(self, order_list): super(SimulatorExecutor, self).step() @@ -276,14 +305,17 @@ class SimulatorExecutor(BaseExecutor): print("[W {:%Y-%m-%d}]: {} wrong.".format(trade_start_time, order.stock_id)) # do nothing pass - self.trade_account.update_bar_end( - trade_start_time=trade_start_time, - trade_end_time=trade_end_time, - trade_exchange=self.trade_exchange, - update_report=self.generate_report, - ) - _execute_state = {"current": self.trade_account.current, "trade_info": trade_info} - return _execute_state + + self.trade_account.update_bar_count() + + if self.generate_report: + self.trade_account.update_bar_report( + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, + trade_exchange=self.trade_exchange, + ) + + return {"current": self.trade_account.current, "trade_info": trade_info} def get_report(self): if self.generate_report: diff --git a/qlib/contrib/backtest/faculty.py b/qlib/contrib/backtest/faculty.py new file mode 100644 index 000000000..34ad14cbc --- /dev/null +++ b/qlib/contrib/backtest/faculty.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +class Faculty: + def __init__(self): + self.__dict__["_faculty"] = dict() + + def __getitem__(self, key): + return self.__dict__["_faculty"][key] + + def __getattr__(self, attr): + if attr in self.__dict__["_faculty"]: + return self.__dict__["_faculty"][attr] + + raise AttributeError(f"No such {attr} in self._faculty") + + def __setitem__(self, key, value): + self.__dict__["_faculty"][key] = value + + def __setattr__(self, attr, value): + self.__dict__["_faculty"][attr] = value + + def update(self, *args, **kwargs): + self.__dict__["_faculty"].update(*args, **kwargs) + + +common_faculty = Faculty() diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index 111cc276a..8b3e3db18 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -2,12 +2,27 @@ # Licensed under the MIT License. +from .order_generator import OrderGenWInteract from .model_strategy import WeightStrategyBase import copy class SoftTopkStrategy(WeightStrategyBase): - def __init__(self, topk, max_sold_weight=1.0, risk_degree=0.95, buy_method="first_fill"): + def __init__( + self, + step_bar, + model, + dataset, + topk, + start_time=None, + end_time=None, + order_generator_cls_or_obj=OrderGenWInteract, + trade_exchange=None, + max_sold_weight=1.0, + risk_degree=0.95, + buy_method="first_fill", + **kwargs, + ): """Parameter topk : int top-N stocks to buy @@ -17,13 +32,15 @@ class SoftTopkStrategy(WeightStrategyBase): rank_fill: assign the weight stocks that rank high first(1/topk max) average_fill: assign the weight to the stocks rank high averagely. """ - super().__init__() + super(SoftTopkStrategy, self).__init__( + step_bar, model, dataset, start_time, end_time, order_generator_cls_or_obj, trade_exchange + ) self.topk = topk self.max_sold_weight = max_sold_weight self.risk_degree = risk_degree self.buy_method = buy_method - def get_risk_degree(self, trade_index): + def get_risk_degree(self, trade_index=None): """get_risk_degree Return the proportion of your total value you will used in investment. Dynamically risk_degree will result in Market timing diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 1fc1bf070..b3bb33a88 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -6,6 +6,7 @@ import pandas as pd from ...utils.sample import sample_feature from ...strategy.base import ModelStrategy from ..backtest.order import Order +from ..backtest.faculty import common_faculty from .order_generator import OrderGenWInteract @@ -50,9 +51,8 @@ class TopkDropoutStrategy(ModelStrategy): else: strategy will make decision with the tradable state of the stock info and avoid buy and sell them. """ - super(TopkDropoutStrategy, self).__init__( - step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange, **kwargs - ) + super(TopkDropoutStrategy, self).__init__(step_bar, model, dataset, start_time, end_time, **kwargs) + self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange self.topk = topk self.n_drop = n_drop self.method_sell = method_sell @@ -61,11 +61,6 @@ class TopkDropoutStrategy(ModelStrategy): self.hold_thresh = hold_thresh self.only_tradable = only_tradable - def reset(self, trade_exchange=None, **kwargs): - super(TopkDropoutStrategy, self).reset(**kwargs) - if trade_exchange: - self.trade_exchange = trade_exchange - def get_risk_degree(self, trade_index=None): """get_risk_degree Return the proportion of your total value you will used in investment. @@ -164,7 +159,7 @@ class TopkDropoutStrategy(ModelStrategy): # Get the stock list we really want to buy buy = today[: len(sell) + self.topk - len(last)] - print("INTRANEL BAR", len(sell), len(sell) + self.topk - len(last), len(last)) + # print("INTRANEL BAR", len(sell), len(sell) + self.topk - len(last), len(last)) # print("flag", len(sell), len(buy), self.topk, len(last)) for code in current_stock_list: if not self.trade_exchange.is_stock_tradable( @@ -242,20 +237,13 @@ class WeightStrategyBase(ModelStrategy): trade_exchange=None, **kwargs, ): - super(WeightStrategyBase, self).__init__( - step_bar, model, dataset, start_time, end_time, trade_exchange=trade_exchange, **kwargs - ) - + super(WeightStrategyBase, self).__init__(step_bar, model, dataset, start_time, end_time, **kwargs) + self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange if isinstance(order_generator_cls_or_obj, type): self.order_generator = order_generator_cls_or_obj() else: self.order_generator = order_generator_cls_or_obj - def reset(self, trade_exchange=None, **kwargs): - super(WeightStrategyBase, self).reset(**kwargs) - if trade_exchange: - self.trade_exchange = trade_exchange - def get_risk_degree(self, trade_index=None): """get_risk_degree Return the proportion of your total value you will used in investment. diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index 93bf7b2fe..db2c1de0d 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -173,7 +173,9 @@ class OrderGenWOInteract(OrderGenerator): stock_id=stock_id, trade_start_time=trade_start_time, trade_end_time=trade_end_time ): amount_dict[stock_id] = ( - risk_total_value * target_weight_position[stock_id] / trade_exchange.get_close(stock_id, pred_date) + risk_total_value + * target_weight_position[stock_id] + / trade_exchange.get_close(stock_id, trade_start_time=pred_start_time, trade_end_time=pred_end_time) ) elif stock_id in current_stock: amount_dict[stock_id] = ( diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 073f513c7..240a61595 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -9,6 +9,7 @@ from ...data.data import D from ...data.dataset.utils import convert_index_format from ...strategy.base import RuleStrategy, OrderEnhancement from ..backtest.order import Order +from ..backtest.faculty import common_faculty class TWAPStrategy(RuleStrategy, OrderEnhancement): @@ -18,16 +19,17 @@ class TWAPStrategy(RuleStrategy, OrderEnhancement): start_time=None, end_time=None, trade_exchange=None, + trade_order_list=[], **kwargs, ): - super(TWAPStrategy, self).__init__(step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs) + super(TWAPStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) + self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange + self.trade_order_list = trade_order_list - def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): + def reset(self, trade_order_list: list = None, **kwargs): super(TWAPStrategy, self).reset(**kwargs) OrderEnhancement.reset(self, trade_order_list=trade_order_list) - if trade_exchange: - self.trade_exchange = trade_exchange - if trade_order_list: + if trade_order_list is not None: self.trade_amount = {} for order in self.trade_order_list: self.trade_amount[(order.stock_id, order.direction)] = order.amount @@ -82,15 +84,16 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): start_time=None, end_time=None, trade_exchange=None, + trade_order_list=[], **kwargs, ): - super(SBBStrategyBase, self).__init__(step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs) + super(SBBStrategyBase, self).__init__(step_bar, start_time, end_time, **kwargs) + self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange + self.trade_order_list = trade_order_list - def reset(self, trade_order_list=None, trade_exchange=None, **kwargs): + def reset(self, trade_order_list=None, **kwargs): super(SBBStrategyBase, self).reset(**kwargs) OrderEnhancement.reset(self, trade_order_list=trade_order_list) - if trade_exchange: - self.trade_exchange = trade_exchange if trade_order_list is not None: self.trade_trend = {} self.trade_amount = {} @@ -217,11 +220,12 @@ class SBBStrategyEMA(SBBStrategyBase): start_time=None, end_time=None, trade_exchange=None, + trade_order_list=[], instruments="csi300", freq="day", **kwargs, ): - super(SBBStrategyEMA, self).__init__(step_bar, start_time, end_time, trade_exchange=trade_exchange, **kwargs) + super(SBBStrategyEMA, self).__init__(step_bar, start_time, end_time, trade_exchange, trade_order_list, **kwargs) if instruments is None: warnings.warn("`instruments` is not set, will load all stocks") self.instruments = "all" @@ -229,9 +233,9 @@ class SBBStrategyEMA(SBBStrategyBase): self.instruments = D.instruments(instruments) self.freq = freq - def reset(self, start_time=None, end_time=None, **kwargs): - super(SBBStrategyEMA, self).reset(start_time=start_time, end_time=end_time, **kwargs) - if self.start_time and self.end_time: + def _reset_trade_calendar(self, start_time=None, end_time=None): + super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time) + if start_time and end_time: fields = ["EMA($close, 10)-EMA($close, 20)"] signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) signal_df = D.features( diff --git a/qlib/rl/env.py b/qlib/rl/env.py index 9424aafab..fae17918d 100644 --- a/qlib/rl/env.py +++ b/qlib/rl/env.py @@ -7,6 +7,8 @@ from ..contrib.backtest.executor import BaseExecutor class BaseRLEnv: + """Base environment for reinforcement learning""" + def reset(self, **kwargs): raise NotImplementedError("reset is not implemented!") diff --git a/qlib/rl/interpreter.py b/qlib/rl/interpreter.py index bad337f72..3c94aac09 100644 --- a/qlib/rl/interpreter.py +++ b/qlib/rl/interpreter.py @@ -3,18 +3,48 @@ class BaseInterpreter: + """Base Interpreter""" + @staticmethod def interpret(**kwargs): raise NotImplementedError("interpret is not implemented!") class ActionInterpreter(BaseInterpreter): + """Action Interpreter that interpret rl agent action into qlib orders""" + @staticmethod def interpret(action, **kwargs): + """interpret method + + Parameters + ---------- + action : + rl agent action + + Returns + ------- + qlib orders + + """ + raise NotImplementedError("interpret is not implemented!") class StateInterpreter(BaseInterpreter): + """State Interpreter that interpret execution result of qlib executor into rl env state""" + @staticmethod def interpret(execute_result, **kwargs): + """interpret method + + Parameters + ---------- + execute_result : + qlib execution result + + Returns + ---------- + rl env state + """ raise NotImplementedError("interpret is not implemented!") diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index a5e7210bd..5534998e9 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import pandas as pd -from typing import Tuple, List, Union, Optional, Callable +from typing import List, Union from ..model.base import BaseModel @@ -14,7 +14,7 @@ from ..rl.interpreter import ActionInterpreter, StateInterpreter class BaseStrategy(BaseTradeCalendar): - """Base strategy""" + """Base strategy for trading""" def generate_order_list(self, execute_state): """Generate order list in each trading bar""" @@ -22,13 +22,13 @@ class BaseStrategy(BaseTradeCalendar): class RuleStrategy(BaseStrategy): - """Trading strategy with rules""" + """Rule-based Trading strategy""" pass class ModelStrategy(BaseStrategy): - """Trading Strategy by using Model to make predictions""" + """Model-based trading strategy, use model to make predictions for trading""" def __init__( self, @@ -57,7 +57,7 @@ class ModelStrategy(BaseStrategy): def _update_model(self): """ - Update model in each bar when using online data as the following steps: + When using online data, pdate model in each bar as the following steps: - update dataset with online data, the dataset should support online update - make the latest prediction scores of the new bar - update the pred score into the latest prediction @@ -66,7 +66,7 @@ class ModelStrategy(BaseStrategy): class RLStrategy(BaseStrategy): - """RL-based Strategy""" + """RL-based strategy""" def __init__( self, diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 8a8bde7ef..6bb6341f0 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -335,10 +335,10 @@ class PortAnaRecord(RecordTemp): report_normal, _ = report_dict.get(self.risk_analysis_freq) analysis = dict() analysis["excess_return_without_cost"] = risk_analysis( - report_normal["return"] - report_normal["bench"], self.risk_analysis_freq + report_normal["return"] - report_normal["bench"], freq=self.risk_analysis_freq ) analysis["excess_return_with_cost"] = risk_analysis( - report_normal["return"] - report_normal["bench"] - report_normal["cost"], self.risk_analysis_freq + report_normal["return"] - report_normal["bench"] - report_normal["cost"], freq=self.risk_analysis_freq ) analysis_df = pd.concat(analysis) # type: pd.DataFrame # log metrics From c703dabcc762b03c6d2f22059bfb04d4538bbe3c Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 13 May 2021 00:46:17 +0800 Subject: [PATCH 019/187] fix rule_strategy reset method --- qlib/contrib/backtest/executor.py | 4 ++-- qlib/contrib/strategy/rule_strategy.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qlib/contrib/backtest/executor.py b/qlib/contrib/backtest/executor.py index 65d9cfaea..96be0778f 100644 --- a/qlib/contrib/backtest/executor.py +++ b/qlib/contrib/backtest/executor.py @@ -37,8 +37,8 @@ class BaseTradeCalendar: """ self.step_bar = step_bar - self.start_time = pd.Timestamp(start_time) - self.end_time = pd.Timestamp(end_time) + self.start_time = pd.Timestamp(start_time) if start_time else None + self.end_time = pd.Timestamp(end_time) if end_time else None self.reset(start_time=start_time, end_time=end_time) def _reset_trade_calendar(self, start_time, end_time): diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 240a61595..3b66b0f1e 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -233,9 +233,9 @@ class SBBStrategyEMA(SBBStrategyBase): self.instruments = D.instruments(instruments) self.freq = freq - def _reset_trade_calendar(self, start_time=None, end_time=None): - super(SBBStrategyEMA, self)._reset_trade_calendar(start_time=start_time, end_time=end_time) - if start_time and end_time: + def reset(self, start_time=None, end_time=None, **kwargs): + super(SBBStrategyEMA, self).reset(start_time=start_time, end_time=end_time, **kwargs) + if self.start_time and self.end_time and (start_time or end_time): fields = ["EMA($close, 10)-EMA($close, 20)"] signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) signal_df = D.features( From de2658a8dbb032769a2ed4d6c346c632d660cbde Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 13 May 2021 22:39:19 +0800 Subject: [PATCH 020/187] fix rule_strategy bug --- qlib/contrib/backtest/account.py | 1 - qlib/contrib/strategy/rule_strategy.py | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index 5e2e03ea0..df7614979 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -153,7 +153,6 @@ class Account: # if stock is sold out, no stock price information in Position, then we should update account first, then update current position # if stock is bought, there is no stock in current position, update current, then update account # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation - trade_amount = trade_val / trade_price if order.direction == Order.SELL: # sell stock self.update_state_from_order(order, trade_val, cost, trade_price) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 3b66b0f1e..222c56568 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -138,6 +138,9 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): // (self.trade_len - self.trade_index) * _amount_trade_unit ) + if order.direction == order.SELL: + if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and _order_amount is None: + _order_amount = self.trade_amount[(order.stock_id, order.direction)] if _order_amount: self.trade_amount[(order.stock_id, order.direction)] -= _order_amount @@ -167,6 +170,10 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): * 2 * _amount_trade_unit ) + if order.direction == order.SELL: + if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and _order_amount is None: + _order_amount = self.trade_amount[(order.stock_id, order.direction)] + if _order_amount: _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) self.trade_amount[(order.stock_id, order.direction)] -= _order_amount From ea60e608bac80bba63e83915b87a7fcd7a7ba022 Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 14 May 2021 01:51:43 +0800 Subject: [PATCH 021/187] update rule_startegy & add README, notebook for multi-level trading --- examples/multi_level_trading/README.md | 21 ++ examples/multi_level_trading/workflow.ipynb | 305 ++++++++++++++++++++ examples/multi_level_trading/workflow.py | 3 - qlib/contrib/backtest/executor.py | 12 +- qlib/contrib/strategy/rule_strategy.py | 29 +- 5 files changed, 358 insertions(+), 12 deletions(-) create mode 100644 examples/multi_level_trading/README.md create mode 100644 examples/multi_level_trading/workflow.ipynb diff --git a/examples/multi_level_trading/README.md b/examples/multi_level_trading/README.md new file mode 100644 index 000000000..f69afb13b --- /dev/null +++ b/examples/multi_level_trading/README.md @@ -0,0 +1,21 @@ +# Multi-level Trading + +This worflow is an example for multi-level trading. + +## Introduction + +Qlib supports backtesting of various strategies, including portfolio management strategies, order split strategies, model-based strategies (such as deep learning models), rule-based strategies, and RL-based strategies. + +And, Qlib also supports multi-level trading and backtesting. It means that users can use different strategies to trade at different frequencies. + +This example uses a DropoutTopkStrategy (a strategy based on the daily frequency Lightgbm model) in weekly frequency for portfolio generation. And, at the daily frequency level, this example uses SBBStrategyEMA (a rule-based strategy that uses EMA for decision-making) to split orders. + +## Usage + +Start backtesting by running the following command: +```bash + python workflow.py +``` + +Also, reports is shown in workflow.ipynb + diff --git a/examples/multi_level_trading/workflow.ipynb b/examples/multi_level_trading/workflow.ipynb new file mode 100644 index 000000000..a122a39fc --- /dev/null +++ b/examples/multi_level_trading/workflow.ipynb @@ -0,0 +1,305 @@ +{ + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.8" + }, + "orig_nbformat": 2, + "kernelspec": { + "name": "pythonjvsc74a57bd0fcc004278713aaede7c629a6a43738a929cb09abb52817d4f72eb70db44cd87b", + "display_name": "Python 3.8.8 ('qlib_backtest': conda)" + }, + "metadata": { + "interpreter": { + "hash": "fcc004278713aaede7c629a6a43738a929cb09abb52817d4f72eb70db44cd87b" + } + } + }, + "nbformat": 4, + "nbformat_minor": 2, + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright (c) Microsoft Corporation.\n", + "# Licensed under the MIT License." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys, site\n", + "from pathlib import Path\n", + "\n", + "################################# NOTE #################################\n", + "# Please be aware that if colab installs the latest numpy and pyqlib #\n", + "# in this cell, users should RESTART the runtime in order to run the #\n", + "# following cells successfully. #\n", + "########################################################################\n", + "\n", + "try:\n", + " import qlib\n", + "except ImportError:\n", + " # install qlib\n", + " ! pip install --upgrade numpy\n", + " ! pip install pyqlib\n", + " # reload\n", + " site.main()\n", + "\n", + "scripts_dir = Path.cwd().parent.joinpath(\"scripts\")\n", + "if not scripts_dir.joinpath(\"get_data.py\").exists():\n", + " # download get_data.py script\n", + " scripts_dir = Path(\"~/tmp/qlib_code/scripts\").expanduser().resolve()\n", + " scripts_dir.mkdir(parents=True, exist_ok=True)\n", + " import requests\n", + " with requests.get(\"https://raw.githubusercontent.com/microsoft/qlib/main/scripts/get_data.py\") as resp:\n", + " with open(scripts_dir.joinpath(\"get_data.py\"), \"wb\") as fp:\n", + " fp.write(resp.content)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "import pandas as pd\n", + "from qlib.config import REG_CN\n", + "from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict\n", + "from qlib.workflow import R\n", + "from qlib.workflow.record_temp import SignalRecord, PortAnaRecord\n", + "from qlib.tests.data import GetData" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# use default data\n", + "provider_uri = \"~/.qlib/qlib_data/cn_data\" # target_dir\n", + "if not exists_qlib_data(provider_uri):\n", + " print(f\"Qlib data is not found in {provider_uri}\")\n", + " GetData().qlib_data(target_dir=provider_uri, region=REG_CN)\n", + "\n", + "qlib.init(provider_uri=provider_uri, region=REG_CN)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "market = \"csi300\"\n", + "benchmark = \"SH000300\"\n", + "\n", + "###################################\n", + "# train model\n", + "###################################\n", + "\n", + "data_handler_config = {\n", + " \"start_time\": \"2008-01-01\",\n", + " \"end_time\": \"2020-08-01\",\n", + " \"fit_start_time\": \"2008-01-01\",\n", + " \"fit_end_time\": \"2014-12-31\",\n", + " \"instruments\": market,\n", + "}\n", + "\n", + "task = {\n", + " \"model\": {\n", + " \"class\": \"LGBModel\",\n", + " \"module_path\": \"qlib.contrib.model.gbdt\",\n", + " \"kwargs\": {\n", + " \"loss\": \"mse\",\n", + " \"colsample_bytree\": 0.8879,\n", + " \"learning_rate\": 0.0421,\n", + " \"subsample\": 0.8789,\n", + " \"lambda_l1\": 205.6999,\n", + " \"lambda_l2\": 580.9768,\n", + " \"max_depth\": 8,\n", + " \"num_leaves\": 210,\n", + " \"num_threads\": 20,\n", + " },\n", + " },\n", + " \"dataset\": {\n", + " \"class\": \"DatasetH\",\n", + " \"module_path\": \"qlib.data.dataset\",\n", + " \"kwargs\": {\n", + " \"handler\": {\n", + " \"class\": \"Alpha158\",\n", + " \"module_path\": \"qlib.contrib.data.handler\",\n", + " \"kwargs\": data_handler_config,\n", + " },\n", + " \"segments\": {\n", + " \"train\": (\"2008-01-01\", \"2014-12-31\"),\n", + " \"valid\": (\"2015-01-01\", \"2016-12-31\"),\n", + " \"test\": (\"2017-01-01\", \"2020-08-01\"),\n", + " },\n", + " },\n", + " },\n", + "}\n", + "# model initialization\n", + "model = init_instance_by_config(task[\"model\"])\n", + "dataset = init_instance_by_config(task[\"dataset\"])\n", + "\n", + "# start exp to train model\n", + "with R.start(experiment_name=\"train_model\"):\n", + " R.log_params(**flatten_dict(task))\n", + " model.fit(dataset)\n", + " R.save_objects(trained_model=model)\n", + " rid = R.get_recorder().id\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [ + "outputPrepend" + ] + }, + "outputs": [], + "source": [ + "trade_start_time = \"2017-01-01\"\n", + "trade_end_time = \"2020-08-01\"\n", + "\n", + "port_analysis_config = {\n", + " \"strategy\": {\n", + " \"class\": \"TopkDropoutStrategy\",\n", + " \"module_path\": \"qlib.contrib.strategy.model_strategy\",\n", + " \"kwargs\": {\n", + " \"step_bar\": \"week\",\n", + " \"model\": model,\n", + " \"dataset\": dataset,\n", + " \"topk\": 50,\n", + " \"n_drop\": 5,\n", + " },\n", + " },\n", + " \"env\": {\n", + " \"class\": \"SplitExecutor\",\n", + " \"module_path\": \"qlib.contrib.backtest.executor\",\n", + " \"kwargs\": {\n", + " \"step_bar\": \"week\",\n", + " \"generate_report\": True,\n", + " \"sub_env\": {\n", + " \"class\": \"SimulatorExecutor\",\n", + " \"module_path\": \"qlib.contrib.backtest.executor\",\n", + " \"kwargs\": {\n", + " \"step_bar\": \"day\",\n", + " \"verbose\": True,\n", + " \"generate_report\": True,\n", + " },\n", + " },\n", + " \"sub_strategy\": {\n", + " \"class\": \"SBBStrategyEMA\",\n", + " \"module_path\": \"qlib.contrib.strategy.rule_strategy\",\n", + " \"kwargs\": {\n", + " \"step_bar\": \"day\",\n", + " \"freq\": \"day\",\n", + " \"instruments\": market,\n", + " },\n", + " },\n", + " },\n", + " },\n", + " \"backtest\": {\n", + " \"start_time\": trade_start_time,\n", + " \"end_time\": trade_end_time,\n", + " \"account\": 100000000,\n", + " \"benchmark\": benchmark,\n", + " \"exchange_kwargs\": {\n", + " \"freq\": \"day\",\n", + " \"limit_threshold\": 0.095,\n", + " \"deal_price\": \"close\",\n", + " \"open_cost\": 0.0005,\n", + " \"close_cost\": 0.0015,\n", + " \"min_cost\": 5,\n", + " },\n", + " },\n", + "}\n", + "# backtest and analysis\n", + "with R.start(experiment_name=\"backtest_analysis\"):\n", + " # prediction\n", + " recorder = R.get_recorder()\n", + " ba_rid = recorder.id\n", + " sr = SignalRecord(model, dataset, recorder)\n", + " sr.generate()\n", + "\n", + " # backtest & analysis\n", + " par = PortAnaRecord(recorder, port_analysis_config, \"day\")\n", + " par.generate()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from qlib.contrib.report import analysis_model, analysis_position\n", + "from qlib.data import D\n", + "recorder = R.get_recorder(ba_rid, experiment_name=\"backtest_analysis\")\n", + "pred_df = recorder.load_object(\"pred.pkl\")\n", + "pred_df_dates = pred_df.index.get_level_values(level='datetime')\n", + "report_normal_df_1d = recorder.load_object(\"portfolio_analysis/report_normal_1day.pkl\")\n", + "positions_1d = recorder.load_object(\"portfolio_analysis/positions_normal_1day.pkl\")\n", + "analysis_df_1d = recorder.load_object(\"portfolio_analysis/port_analysis_1day.pkl\")\n", + "report_normal_df_1w = recorder.load_object(\"portfolio_analysis/report_normal_1week.pkl\")\n", + "positions_1w = recorder.load_object(\"portfolio_analysis/positions_normal_1week.pkl\")\n", + "analysis_df_1w = recorder.load_object(\"portfolio_analysis/port_analysis_1week.pkl\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "analysis_position.report_graph(report_normal_df_1d)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "analysis_position.report_graph(report_normal_df_1w)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "analysis_position.risk_analysis_graph(analysis_df_1d, report_normal_df_1d)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "analysis_position.risk_analysis_graph(analysis_df_1w, report_normal_df_1w)" + ] + } + ] +} \ No newline at end of file diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index 77689b3f7..8bfb4f3ec 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -1,11 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import sys -from pathlib import Path import qlib -import pandas as pd from qlib.config import REG_CN from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict diff --git a/qlib/contrib/backtest/executor.py b/qlib/contrib/backtest/executor.py index 96be0778f..ef0f205ce 100644 --- a/qlib/contrib/backtest/executor.py +++ b/qlib/contrib/backtest/executor.py @@ -127,8 +127,7 @@ class BaseExecutor(BaseTradeCalendar): self.track_data = track_data def get_init_state(self): - init_state = {"current": self.trade_account.current} - return init_state + raise NotImplementedError("get_init_state in not implemeted!") def execute(self, **kwargs): raise NotImplementedError("execute is not implemented!") @@ -180,9 +179,12 @@ class SplitExecutor(BaseExecutor): if generate_report: self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange self.sub_env = init_instance_by_config(sub_env, accept_types=BaseExecutor) - self.sub_strategy = init_instance_by_config(sub_strategy, accept_types=self.BaseStrategy) + def get_init_state(self): + init_state = {"current": self.trade_account.current} + return init_state + def _init_sub_trading(self, order_list): trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time) @@ -263,6 +265,10 @@ class SimulatorExecutor(BaseExecutor): ) self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange + def get_init_state(self): + init_state = {"current": self.trade_account.current, "trade_info": []} + return init_state + def execute(self, order_list): super(SimulatorExecutor, self).step() trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 222c56568..3a37d71d3 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -36,6 +36,10 @@ class TWAPStrategy(RuleStrategy, OrderEnhancement): def generate_order_list(self, execute_state): super(TWAPStrategy, self).step() + trade_info = execute_state.get("trade_info") + for order, _, _, _ in trade_info: + self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) order_list = [] for order in self.trade_order_list: @@ -56,7 +60,15 @@ class TWAPStrategy(RuleStrategy, OrderEnhancement): // (self.trade_len - self.trade_index) * _amount_trade_unit ) + + if order.direction == order.SELL: + if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( + _order_amount is None or self.trade_index == self.trade_len - 1 + ): + _order_amount = self.trade_amount[(order.stock_id, order.direction)] + if _order_amount: + _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) _order = Order( stock_id=order.stock_id, amount=_order_amount, @@ -106,8 +118,11 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): def generate_order_list(self, execute_state): super(SBBStrategyBase, self).step() - if not self.trade_order_list: - return [] + + trade_info = execute_state.get("trade_info") + for order, _, _, _ in trade_info: + self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount + trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) order_list = [] @@ -139,11 +154,12 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): * _amount_trade_unit ) if order.direction == order.SELL: - if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and _order_amount is None: + if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( + _order_amount is None or self.trade_index == self.trade_len - 1 + ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] if _order_amount: - self.trade_amount[(order.stock_id, order.direction)] -= _order_amount _order = Order( stock_id=order.stock_id, amount=_order_amount, @@ -171,12 +187,13 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): * _amount_trade_unit ) if order.direction == order.SELL: - if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and _order_amount is None: + if self.trade_amount[(order.stock_id, order.direction)] >= 1e-5 and ( + _order_amount is None or self.trade_index == self.trade_len - 1 + ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] if _order_amount: _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) - self.trade_amount[(order.stock_id, order.direction)] -= _order_amount if self.trade_index % 2 == 1: if ( _pred_trend == self.TREND_SHORT From eaa719df174327ebb5c4a9c4e948b339e0be29d9 Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 14 May 2021 15:50:27 +0800 Subject: [PATCH 022/187] optimize rule_strategy performance --- qlib/contrib/backtest/executor.py | 4 +- qlib/contrib/strategy/rule_strategy.py | 76 ++++++++++++++++++++------ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/qlib/contrib/backtest/executor.py b/qlib/contrib/backtest/executor.py index ef0f205ce..943b26f9c 100644 --- a/qlib/contrib/backtest/executor.py +++ b/qlib/contrib/backtest/executor.py @@ -74,11 +74,12 @@ class BaseTradeCalendar: return self.calendar[calendar_index - 1], self.calendar[calendar_index] - pd.Timedelta(seconds=1) def finished(self): - return self.trade_index >= self.trade_len - 1 + return self.trade_index >= self.trade_len def step(self): if self.finished(): raise RuntimeError(f"this env has completed its task, please reset it if you want to call it!") + # trade count += 1 self.trade_index = self.trade_index + 1 @@ -165,6 +166,7 @@ class SplitExecutor(BaseExecutor): trading strategy in each trading bar trade_exchange : Exchange exchange that provides market info + - If `trade_exchange` is None, self.trade_exchange will be set with common_faculty """ super(SplitExecutor, self).__init__( step_bar=step_bar, diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 3a37d71d3..0e0f2b907 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -2,7 +2,7 @@ import copy import warnings import numpy as np import pandas as pd - +from typing import Union from ...utils.sample import sample_feature from ...data.data import D @@ -13,6 +13,8 @@ from ..backtest.faculty import common_faculty class TWAPStrategy(RuleStrategy, OrderEnhancement): + """TWAP Strategy for trading""" + def __init__( self, step_bar, @@ -22,6 +24,15 @@ class TWAPStrategy(RuleStrategy, OrderEnhancement): trade_order_list=[], **kwargs, ): + """ + Parameters + ---------- + trade_exchange : Exchange, optional + exchange that provides market info, by default None + - If `trade_exchange` is None, self.trade_exchange will be set with common_faculty + trade_order_list : list, optional + order list to trade, which the strategy will trade in [start_time , end_time] , by default [] + """ super(TWAPStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange self.trade_order_list = trade_order_list @@ -51,19 +62,19 @@ class TWAPStrategy(RuleStrategy, OrderEnhancement): _order_amount = None if _amount_trade_unit is None: _order_amount = self.trade_amount[(order.stock_id, order.direction)] / ( - self.trade_len - self.trade_index + self.trade_len - self.trade_index + 1 ) if self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) _order_amount = ( - (trade_unit_cnt + self.trade_len - self.trade_index - 1) - // (self.trade_len - self.trade_index) + (trade_unit_cnt + self.trade_len - self.trade_index) + // (self.trade_len - self.trade_index + 1) * _amount_trade_unit ) if order.direction == order.SELL: if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount is None or self.trade_index == self.trade_len - 1 + _order_amount is None or self.trade_index == self.trade_len ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] @@ -99,6 +110,15 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): trade_order_list=[], **kwargs, ): + """ + Parameters + ---------- + trade_exchange : Exchange, optional + exchange that provides market info, by default None + - If `trade_exchange` is None, self.trade_exchange will be set with common_faculty + trade_order_list : list, optional + order list to trade, which the strategy will trade in [start_time , end_time] , by default [] + """ super(SBBStrategyBase, self).__init__(step_bar, start_time, end_time, **kwargs) self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange self.trade_order_list = trade_order_list @@ -144,18 +164,18 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): _order_amount = None if _amount_trade_unit is None: _order_amount = self.trade_amount[(order.stock_id, order.direction)] / ( - self.trade_len - self.trade_index + self.trade_len - self.trade_index + 1 ) elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) _order_amount = ( - (trade_unit_cnt + self.trade_len - self.trade_index - 1) - // (self.trade_len - self.trade_index) + (trade_unit_cnt + self.trade_len - self.trade_index) + // (self.trade_len - self.trade_index + 1) * _amount_trade_unit ) if order.direction == order.SELL: if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount is None or self.trade_index == self.trade_len - 1 + _order_amount is None or self.trade_index == self.trade_len ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] @@ -176,19 +196,19 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): _order_amount = ( 2 * self.trade_amount[(order.stock_id, order.direction)] - / (self.trade_len - self.trade_index + 1) + / (self.trade_len - self.trade_index + 2) ) elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) _order_amount = ( - (trade_unit_cnt + self.trade_len - self.trade_index) - // (self.trade_len - self.trade_index + 1) + (trade_unit_cnt + self.trade_len - self.trade_index + 1) + // (self.trade_len - self.trade_index + 2) * 2 * _amount_trade_unit ) if order.direction == order.SELL: if self.trade_amount[(order.stock_id, order.direction)] >= 1e-5 and ( - _order_amount is None or self.trade_index == self.trade_len - 1 + _order_amount is None or self.trade_index == self.trade_len ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] @@ -235,7 +255,7 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): class SBBStrategyEMA(SBBStrategyBase): """ - (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA). + (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA) signal. """ def __init__( @@ -249,6 +269,15 @@ class SBBStrategyEMA(SBBStrategyBase): freq="day", **kwargs, ): + """ + Parameters + ---------- + instruments : str, optional + instruments of EMA signal, by default "csi300" + freq : str, optional + freq of EMA signal, by default "day" + Note: `freq` may be different from `steb_bar` + """ super(SBBStrategyEMA, self).__init__(step_bar, start_time, end_time, trade_exchange, trade_order_list, **kwargs) if instruments is None: warnings.warn("`instruments` is not set, will load all stocks") @@ -257,13 +286,25 @@ class SBBStrategyEMA(SBBStrategyBase): self.instruments = D.instruments(instruments) self.freq = freq - def reset(self, start_time=None, end_time=None, **kwargs): + def reset(self, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, **kwargs): + """ + Reset EMA signal for trading + + Parameters + ---------- + start_time : Union[str, pd.Timestamp], optional + start time for trading, also used to calculate the start time of EMA signal, by default None + + end_time : Union[str, pd.Timestamp], optional + end time for trading, also used to calculate the end time of EMA signal, by default None + """ super(SBBStrategyEMA, self).reset(start_time=start_time, end_time=end_time, **kwargs) if self.start_time and self.end_time and (start_time or end_time): fields = ["EMA($close, 10)-EMA($close, 20)"] - signal_start_time, _ = self._get_calendar_time(trade_index=self.trade_index, shift=1) + signal_start_time, _ = self._get_calendar_time(trade_index=1, shift=1) + _, signal_end_time = self._get_calendar_time(trade_index=self.trade_len, shift=1) signal_df = D.features( - self.instruments, fields, start_time=signal_start_time, end_time=self.end_time, freq=self.freq + self.instruments, fields, start_time=signal_start_time, end_time=signal_end_time, freq=self.freq ) signal_df = convert_index_format(signal_df) signal_df.columns = ["signal"] @@ -272,6 +313,7 @@ class SBBStrategyEMA(SBBStrategyBase): self.signal[stock_id] = stock_val def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): + if stock_id not in self.signal: return self.TREND_MID else: From dda509da0b9fec0ad875f626835a3a0068135045 Mon Sep 17 00:00:00 2001 From: you-n-g Date: Wed, 19 May 2021 15:02:04 +0800 Subject: [PATCH 023/187] Update record_temp.py --- qlib/workflow/record_temp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 6bb6341f0..5463e9335 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -88,11 +88,11 @@ class RecordTemp: def list(self): """ - List the stored records. + List the supported artifacts. Return ------ - A list of all the stored records. + A list of all the supported artifacts. """ return [] From 26d75b71b021c6058c9f037f8a20938a1b35e6e9 Mon Sep 17 00:00:00 2001 From: you-n-g Date: Wed, 19 May 2021 15:06:47 +0800 Subject: [PATCH 024/187] Update sample.py --- qlib/utils/sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qlib/utils/sample.py b/qlib/utils/sample.py index 9f67d4981..5568fc244 100644 --- a/qlib/utils/sample.py +++ b/qlib/utils/sample.py @@ -35,7 +35,7 @@ def parse_freq(freq: str) -> Tuple[int, str]: raise ValueError( "freq format is not supported, the freq should be like (n)month/mon, (n)week/w, (n)day/d, (n)minute/min" ) - _count = int(match_obj.group(1) if match_obj.group(1) else "1") + _count = int(match_obj.group(1)) if match_obj.group(1) is None else 1 _freq = match_obj.group(2) _freq_format_dict = { "month": "month", From 0c6e50545541bdcc14bb62b2b631b7e35f74ba65 Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 25 May 2021 02:38:34 +0800 Subject: [PATCH 025/187] fix comments --- examples/multi_level_trading/README.md | 7 +- examples/multi_level_trading/workflow.ipynb | 305 -------------------- examples/multi_level_trading/workflow.py | 111 ++++--- qlib/config.py | 4 + qlib/contrib/backtest/__init__.py | 42 ++- qlib/contrib/backtest/account.py | 131 ++------- qlib/contrib/backtest/backtest.py | 31 +- qlib/contrib/backtest/exchange.py | 26 +- qlib/contrib/backtest/executor.py | 299 ++++++++++--------- qlib/contrib/backtest/faculty.py | 28 -- qlib/contrib/backtest/position.py | 2 +- qlib/contrib/backtest/report.py | 113 +++++++- qlib/contrib/backtest/utils.py | 67 +++++ qlib/contrib/evaluate.py | 11 +- qlib/contrib/online/operator.py | 6 +- qlib/contrib/strategy/cost_control.py | 8 +- qlib/contrib/strategy/model_strategy.py | 86 ++++-- qlib/contrib/strategy/rule_strategy.py | 260 +++++++++-------- qlib/data/data.py | 4 +- qlib/rl/env.py | 39 +-- qlib/rl/interpreter.py | 3 - qlib/strategy/base.py | 135 +++++---- qlib/utils/{sample.py => resam.py} | 91 +++--- qlib/workflow/record_temp.py | 24 +- 24 files changed, 855 insertions(+), 978 deletions(-) delete mode 100644 examples/multi_level_trading/workflow.ipynb delete mode 100644 qlib/contrib/backtest/faculty.py create mode 100644 qlib/contrib/backtest/utils.py rename qlib/utils/{sample.py => resam.py} (75%) diff --git a/examples/multi_level_trading/README.md b/examples/multi_level_trading/README.md index f69afb13b..6761b84ff 100644 --- a/examples/multi_level_trading/README.md +++ b/examples/multi_level_trading/README.md @@ -14,8 +14,11 @@ This example uses a DropoutTopkStrategy (a strategy based on the daily frequency Start backtesting by running the following command: ```bash - python workflow.py + python workflow.py backtest ``` -Also, reports is shown in workflow.ipynb +Start collecting data by running the following command: +```bash + python workflow.py collect_data +``` diff --git a/examples/multi_level_trading/workflow.ipynb b/examples/multi_level_trading/workflow.ipynb deleted file mode 100644 index a122a39fc..000000000 --- a/examples/multi_level_trading/workflow.ipynb +++ /dev/null @@ -1,305 +0,0 @@ -{ - "metadata": { - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.8" - }, - "orig_nbformat": 2, - "kernelspec": { - "name": "pythonjvsc74a57bd0fcc004278713aaede7c629a6a43738a929cb09abb52817d4f72eb70db44cd87b", - "display_name": "Python 3.8.8 ('qlib_backtest': conda)" - }, - "metadata": { - "interpreter": { - "hash": "fcc004278713aaede7c629a6a43738a929cb09abb52817d4f72eb70db44cd87b" - } - } - }, - "nbformat": 4, - "nbformat_minor": 2, - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Copyright (c) Microsoft Corporation.\n", - "# Licensed under the MIT License." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import sys, site\n", - "from pathlib import Path\n", - "\n", - "################################# NOTE #################################\n", - "# Please be aware that if colab installs the latest numpy and pyqlib #\n", - "# in this cell, users should RESTART the runtime in order to run the #\n", - "# following cells successfully. #\n", - "########################################################################\n", - "\n", - "try:\n", - " import qlib\n", - "except ImportError:\n", - " # install qlib\n", - " ! pip install --upgrade numpy\n", - " ! pip install pyqlib\n", - " # reload\n", - " site.main()\n", - "\n", - "scripts_dir = Path.cwd().parent.joinpath(\"scripts\")\n", - "if not scripts_dir.joinpath(\"get_data.py\").exists():\n", - " # download get_data.py script\n", - " scripts_dir = Path(\"~/tmp/qlib_code/scripts\").expanduser().resolve()\n", - " scripts_dir.mkdir(parents=True, exist_ok=True)\n", - " import requests\n", - " with requests.get(\"https://raw.githubusercontent.com/microsoft/qlib/main/scripts/get_data.py\") as resp:\n", - " with open(scripts_dir.joinpath(\"get_data.py\"), \"wb\") as fp:\n", - " fp.write(resp.content)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "\n", - "import pandas as pd\n", - "from qlib.config import REG_CN\n", - "from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict\n", - "from qlib.workflow import R\n", - "from qlib.workflow.record_temp import SignalRecord, PortAnaRecord\n", - "from qlib.tests.data import GetData" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# use default data\n", - "provider_uri = \"~/.qlib/qlib_data/cn_data\" # target_dir\n", - "if not exists_qlib_data(provider_uri):\n", - " print(f\"Qlib data is not found in {provider_uri}\")\n", - " GetData().qlib_data(target_dir=provider_uri, region=REG_CN)\n", - "\n", - "qlib.init(provider_uri=provider_uri, region=REG_CN)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "market = \"csi300\"\n", - "benchmark = \"SH000300\"\n", - "\n", - "###################################\n", - "# train model\n", - "###################################\n", - "\n", - "data_handler_config = {\n", - " \"start_time\": \"2008-01-01\",\n", - " \"end_time\": \"2020-08-01\",\n", - " \"fit_start_time\": \"2008-01-01\",\n", - " \"fit_end_time\": \"2014-12-31\",\n", - " \"instruments\": market,\n", - "}\n", - "\n", - "task = {\n", - " \"model\": {\n", - " \"class\": \"LGBModel\",\n", - " \"module_path\": \"qlib.contrib.model.gbdt\",\n", - " \"kwargs\": {\n", - " \"loss\": \"mse\",\n", - " \"colsample_bytree\": 0.8879,\n", - " \"learning_rate\": 0.0421,\n", - " \"subsample\": 0.8789,\n", - " \"lambda_l1\": 205.6999,\n", - " \"lambda_l2\": 580.9768,\n", - " \"max_depth\": 8,\n", - " \"num_leaves\": 210,\n", - " \"num_threads\": 20,\n", - " },\n", - " },\n", - " \"dataset\": {\n", - " \"class\": \"DatasetH\",\n", - " \"module_path\": \"qlib.data.dataset\",\n", - " \"kwargs\": {\n", - " \"handler\": {\n", - " \"class\": \"Alpha158\",\n", - " \"module_path\": \"qlib.contrib.data.handler\",\n", - " \"kwargs\": data_handler_config,\n", - " },\n", - " \"segments\": {\n", - " \"train\": (\"2008-01-01\", \"2014-12-31\"),\n", - " \"valid\": (\"2015-01-01\", \"2016-12-31\"),\n", - " \"test\": (\"2017-01-01\", \"2020-08-01\"),\n", - " },\n", - " },\n", - " },\n", - "}\n", - "# model initialization\n", - "model = init_instance_by_config(task[\"model\"])\n", - "dataset = init_instance_by_config(task[\"dataset\"])\n", - "\n", - "# start exp to train model\n", - "with R.start(experiment_name=\"train_model\"):\n", - " R.log_params(**flatten_dict(task))\n", - " model.fit(dataset)\n", - " R.save_objects(trained_model=model)\n", - " rid = R.get_recorder().id\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "tags": [ - "outputPrepend" - ] - }, - "outputs": [], - "source": [ - "trade_start_time = \"2017-01-01\"\n", - "trade_end_time = \"2020-08-01\"\n", - "\n", - "port_analysis_config = {\n", - " \"strategy\": {\n", - " \"class\": \"TopkDropoutStrategy\",\n", - " \"module_path\": \"qlib.contrib.strategy.model_strategy\",\n", - " \"kwargs\": {\n", - " \"step_bar\": \"week\",\n", - " \"model\": model,\n", - " \"dataset\": dataset,\n", - " \"topk\": 50,\n", - " \"n_drop\": 5,\n", - " },\n", - " },\n", - " \"env\": {\n", - " \"class\": \"SplitExecutor\",\n", - " \"module_path\": \"qlib.contrib.backtest.executor\",\n", - " \"kwargs\": {\n", - " \"step_bar\": \"week\",\n", - " \"generate_report\": True,\n", - " \"sub_env\": {\n", - " \"class\": \"SimulatorExecutor\",\n", - " \"module_path\": \"qlib.contrib.backtest.executor\",\n", - " \"kwargs\": {\n", - " \"step_bar\": \"day\",\n", - " \"verbose\": True,\n", - " \"generate_report\": True,\n", - " },\n", - " },\n", - " \"sub_strategy\": {\n", - " \"class\": \"SBBStrategyEMA\",\n", - " \"module_path\": \"qlib.contrib.strategy.rule_strategy\",\n", - " \"kwargs\": {\n", - " \"step_bar\": \"day\",\n", - " \"freq\": \"day\",\n", - " \"instruments\": market,\n", - " },\n", - " },\n", - " },\n", - " },\n", - " \"backtest\": {\n", - " \"start_time\": trade_start_time,\n", - " \"end_time\": trade_end_time,\n", - " \"account\": 100000000,\n", - " \"benchmark\": benchmark,\n", - " \"exchange_kwargs\": {\n", - " \"freq\": \"day\",\n", - " \"limit_threshold\": 0.095,\n", - " \"deal_price\": \"close\",\n", - " \"open_cost\": 0.0005,\n", - " \"close_cost\": 0.0015,\n", - " \"min_cost\": 5,\n", - " },\n", - " },\n", - "}\n", - "# backtest and analysis\n", - "with R.start(experiment_name=\"backtest_analysis\"):\n", - " # prediction\n", - " recorder = R.get_recorder()\n", - " ba_rid = recorder.id\n", - " sr = SignalRecord(model, dataset, recorder)\n", - " sr.generate()\n", - "\n", - " # backtest & analysis\n", - " par = PortAnaRecord(recorder, port_analysis_config, \"day\")\n", - " par.generate()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from qlib.contrib.report import analysis_model, analysis_position\n", - "from qlib.data import D\n", - "recorder = R.get_recorder(ba_rid, experiment_name=\"backtest_analysis\")\n", - "pred_df = recorder.load_object(\"pred.pkl\")\n", - "pred_df_dates = pred_df.index.get_level_values(level='datetime')\n", - "report_normal_df_1d = recorder.load_object(\"portfolio_analysis/report_normal_1day.pkl\")\n", - "positions_1d = recorder.load_object(\"portfolio_analysis/positions_normal_1day.pkl\")\n", - "analysis_df_1d = recorder.load_object(\"portfolio_analysis/port_analysis_1day.pkl\")\n", - "report_normal_df_1w = recorder.load_object(\"portfolio_analysis/report_normal_1week.pkl\")\n", - "positions_1w = recorder.load_object(\"portfolio_analysis/positions_normal_1week.pkl\")\n", - "analysis_df_1w = recorder.load_object(\"portfolio_analysis/port_analysis_1week.pkl\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "analysis_position.report_graph(report_normal_df_1d)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "analysis_position.report_graph(report_normal_df_1w)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "analysis_position.risk_analysis_graph(analysis_df_1d, report_normal_df_1d)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "analysis_position.risk_analysis_graph(analysis_df_1w, report_normal_df_1w)" - ] - } - ] -} \ No newline at end of file diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index 8bfb4f3ec..390044480 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -3,30 +3,21 @@ import qlib +import fire from qlib.config import REG_CN from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict from qlib.workflow import R from qlib.workflow.record_temp import SignalRecord, PortAnaRecord from qlib.tests.data import GetData +from qlib.contrib.backtest import collect_data -if __name__ == "__main__": - # use default data - provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir - if not exists_qlib_data(provider_uri): - print(f"Qlib data is not found in {provider_uri}") - GetData().qlib_data(target_dir=provider_uri, region=REG_CN) - - qlib.init(provider_uri=provider_uri, region=REG_CN) +class MultiLevelTradingWorkflow: market = "csi300" benchmark = "SH000300" - ################################### - # train model - ################################### - data_handler_config = { "start_time": "2008-01-01", "end_time": "2020-08-01", @@ -68,31 +59,17 @@ if __name__ == "__main__": }, }, } - # model initialization - model = init_instance_by_config(task["model"]) - dataset = init_instance_by_config(task["dataset"]) trade_start_time = "2017-01-01" trade_end_time = "2020-08-01" port_analysis_config = { - "strategy": { - "class": "TopkDropoutStrategy", - "module_path": "qlib.contrib.strategy.model_strategy", - "kwargs": { - "step_bar": "week", - "model": model, - "dataset": dataset, - "topk": 50, - "n_drop": 5, - }, - }, - "env": { + "executor": { "class": "SplitExecutor", "module_path": "qlib.contrib.backtest.executor", "kwargs": { "step_bar": "week", - "sub_env": { + "sub_executor": { "class": "SimulatorExecutor", "module_path": "qlib.contrib.backtest.executor", "kwargs": { @@ -105,11 +82,11 @@ if __name__ == "__main__": "class": "SBBStrategyEMA", "module_path": "qlib.contrib.strategy.rule_strategy", "kwargs": { - "step_bar": "day", "freq": "day", "instruments": market, }, }, + "track_data": True, }, }, "backtest": { @@ -128,17 +105,69 @@ if __name__ == "__main__": }, } - with R.start(experiment_name="highfreq_backtest"): - R.log_params(**flatten_dict(task)) - model.fit(dataset) - R.save_objects(**{"params.pkl": model}) + def _init_qlib(self): + """initialize qlib""" + # use yahoo_cn_1min data + provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir + if not exists_qlib_data(provider_uri): + print(f"Qlib data is not found in {provider_uri}") + GetData().qlib_data(target_dir=provider_uri, region=REG_CN) + qlib.init(provider_uri=provider_uri, region=REG_CN) - # prediction - recorder = R.get_recorder() - sr = SignalRecord(model, dataset, recorder) - sr.generate() + def _train_model(self, model, dataset): + with R.start(experiment_name="train"): + R.log_params(**flatten_dict(self.task)) + model.fit(dataset) + R.save_objects(**{"params.pkl": model}) - # backtest. If users want to use backtest based on their own prediction, - # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. - par = PortAnaRecord(recorder, port_analysis_config, "day") - par.generate() + # prediction + recorder = R.get_recorder() + sr = SignalRecord(model, dataset, recorder) + sr.generate() + + def backtest(self): + self._init_qlib() + model = init_instance_by_config(self.task["model"]) + dataset = init_instance_by_config(self.task["dataset"]) + self._train_model(model, dataset) + strategy_config = { + "class": "TopkDropoutStrategy", + "module_path": "qlib.contrib.strategy.model_strategy", + "kwargs": { + "model": model, + "dataset": dataset, + "topk": 50, + "n_drop": 5, + }, + } + self.port_analysis_config["strategy"] = strategy_config + with R.start(experiment_name="backtest"): + + recorder = R.get_recorder() + par = PortAnaRecord(recorder, self.port_analysis_config, "day") + par.generate() + + def collect_data(self): + self._init_qlib() + model = init_instance_by_config(self.task["model"]) + dataset = init_instance_by_config(self.task["dataset"]) + self._train_model(model, dataset) + executor_config = self.port_analysis_config["executor"] + backtest_config = self.port_analysis_config["backtest"] + strategy_config = { + "class": "TopkDropoutStrategy", + "module_path": "qlib.contrib.strategy.model_strategy", + "kwargs": { + "model": model, + "dataset": dataset, + "topk": 50, + "n_drop": 5, + }, + } + data_generator = collect_data(executor=executor_config, strategy=strategy_config, **backtest_config) + for trade_decision in data_generator: + print(trade_decision) + + +if __name__ == "__main__": + fire.Fire(MultiLevelTradingWorkflow) diff --git a/qlib/config.py b/qlib/config.py index 75ab0fa3e..3bcf79ddb 100644 --- a/qlib/config.py +++ b/qlib/config.py @@ -140,6 +140,10 @@ _default_config = { "default_exp_name": "Experiment", }, }, + # Shift minute for highfreq minite data, used in backtest + # if min_data_shift == 0, use default market time [9:30, 11:29, 1:30, 2:39] + # if min_data_shift != 0, use shifted market time [9:30, 11:29, 1:30, 2:39] - shift*minute + "min_data_shift": {0}, } MODE_CONF = { diff --git a/qlib/contrib/backtest/__init__.py b/qlib/contrib/backtest/__init__.py index 8cfbf9674..effab026b 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/contrib/backtest/__init__.py @@ -5,13 +5,12 @@ from .account import Account from .exchange import Exchange from .executor import BaseExecutor from .backtest import backtest as backtest_func - +from .backtest import collect_data as data_generator from ...strategy.base import BaseStrategy from ...utils import init_instance_by_config from ...log import get_module_logger from ...config import C -from .faculty import common_faculty logger = get_module_logger("backtest caller") @@ -89,8 +88,9 @@ def get_exchange( return init_instance_by_config(exchange, accept_types=Exchange) -def backtest(start_time, end_time, strategy, env, benchmark="SH000300", account=1e9, exchange_kwargs={}): - +def get_strategy_executor( + start_time, end_time, strategy, executor, benchmark="SH000300", account=1e9, exchange_kwargs={} +): trade_account = Account( init_cash=account, benchmark_config={ @@ -101,14 +101,32 @@ def backtest(start_time, end_time, strategy, env, benchmark="SH000300", account= ) trade_exchange = get_exchange(**exchange_kwargs) - common_faculty.update( - trade_account=trade_account, - trade_exchange=trade_exchange, + common_infra = { + "trade_account": trade_account, + "trade_exchange": trade_exchange, + } + + trade_strategy = init_instance_by_config(strategy, accept_types=BaseStrategy, common_infra=common_infra) + trade_executor = init_instance_by_config(executor, accept_types=BaseExecutor, common_infra=common_infra) + + return trade_strategy, trade_executor + + +def backtest(start_time, end_time, strategy, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): + + trade_strategy, trade_executor = get_strategy_executor( + start_time, end_time, strategy, executor, benchmark, account, exchange_kwargs ) - - trade_strategy = init_instance_by_config(strategy, accept_types=BaseStrategy) - trade_env = init_instance_by_config(env, accept_types=BaseExecutor) - - report_dict = backtest_func(start_time, end_time, trade_strategy, trade_env) + report_dict = backtest_func(start_time, end_time, trade_strategy, trade_executor) + + return report_dict + + +def collect_data(start_time, end_time, strategy, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): + + trade_strategy, trade_executor = get_strategy_executor( + start_time, end_time, strategy, executor, benchmark, account, exchange_kwargs + ) + report_dict = yield from data_generator(start_time, end_time, trade_strategy, trade_executor) return report_dict diff --git a/qlib/contrib/backtest/account.py b/qlib/contrib/backtest/account.py index df7614979..c7571bc98 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/contrib/backtest/account.py @@ -9,8 +9,6 @@ import pandas as pd from .position import Position from .report import Report from .order import Order -from ...data import D -from ...utils.sample import parse_freq, sample_feature """ @@ -34,85 +32,14 @@ class Account: self.init_vars(init_cash, freq, benchmark_config) def init_vars(self, init_cash, freq: str, benchmark_config: dict): - """ - Parameters - ---------- - freq : str - frequency of trading bar, used for updating hold count of trading bar - benchmark_config : dict - config of benchmark, may including the following arguments: - - benchmark : Union[str, list, pd.Series] - - If `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. - example: - print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) - 2017-01-04 0.011693 - 2017-01-05 0.000721 - 2017-01-06 -0.004322 - 2017-01-09 0.006874 - 2017-01-10 -0.003350 - - If `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. - - If `benchmark` is str, will use the daily change as the 'bench'. - benchmark code, default is SH000300 CSI300 - - start_time : Union[str, pd.Timestamp], optional - - If `benchmark` is pd.Series, it will be ignored - - Else, it represent start time of benchmark, by default None - - end_time : Union[str, pd.Timestamp], optional - - If `benchmark` is pd.Series, it will be ignored - - Else, it represent end time of benchmark, by default None - """ # init cash self.init_cash = init_cash - self.freq = freq - self.benchmark_config = benchmark_config - self.bench = self._cal_benchmark(benchmark_config, freq) self.current = Position(cash=init_cash) - self._reset_report() + self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) - def _cal_benchmark(self, benchmark_config, freq): - benchmark = benchmark_config.get("benchmark", "SH000300") - if isinstance(benchmark, pd.Series): - return benchmark - else: - start_time = benchmark_config.get("start_time", None) - end_time = benchmark_config.get("end_time", None) - - if freq is None: - raise ValueError("benchmark freq can't be None!") - _codes = benchmark if isinstance(benchmark, list) else [benchmark] - fields = ["$close/Ref($close,1)-1"] - try: - _temp_result = D.features(_codes, fields, start_time, end_time, freq=freq, disk_cache=1) - except ValueError: - _, norm_freq = parse_freq(freq) - if norm_freq in ["month", "week", "day"]: - try: - _temp_result = D.features(_codes, fields, start_time, end_time, freq="day", disk_cache=1) - except ValueError: - _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) - elif norm_freq == "minute": - _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) - else: - raise ValueError(f"benchmark freq {freq} is not supported") - if len(_temp_result) == 0: - raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") - return _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean().fillna(0) - - def _sample_benchmark(self, bench, trade_start_time, trade_end_time): - def cal_change(x): - return (x + 1).prod() - 1 - - _ret = sample_feature(bench, trade_start_time, trade_end_time, method=cal_change) - return 0 if _ret is None else _ret - - def _reset_freq(self, freq): - """reset frequency""" - if freq != self.freq: - self.freq = freq - self.bench = self._cal_benchmark(self.benchmark_config, self.freq) - - def _reset_report(self): - self.report = Report() + def reset_report(self, freq, benchmark_config): + self.report = Report(freq, benchmark_config) self.positions = {} self.rtn = 0 self.ct = 0 @@ -120,10 +47,25 @@ class Account: self.val = 0 self.earning = 0 - def reset(self, freq=None, init_report: bool = False): - self._reset_freq(freq) - if init_report: - self._reset_report() + def reset(self, freq=None, benchmark_config=None, init_report=False): + """reset freq and report of account + + Parameters + ---------- + freq : str, optional + frequency of account & report, by default None + benchmark_config : {}, optional + benchmark config of report, by default None + init_report : bool, optional + whether to initialize the report, by default False + """ + if freq is not None: + self.freq = freq + if benchmark_config is not None: + self.benchmark_config = benchmark_config + + if freq is not None or benchmark_config is not None or init_report: + self.reset_report(self.freq, self.benchmark_config) def get_positions(self): return self.positions @@ -131,7 +73,7 @@ class Account: def get_cash(self): return self.current.position["cash"] - def update_state_from_order(self, order, trade_val, cost, trade_price): + def _update_state_from_order(self, order, trade_val, cost, trade_price): # update turnover self.to += trade_val # update cost @@ -155,7 +97,7 @@ class Account: # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation if order.direction == Order.SELL: # sell stock - self.update_state_from_order(order, trade_val, cost, trade_price) + self._update_state_from_order(order, trade_val, cost, trade_price) # update current position # for may sell all of stock_id self.current.update_order(order, trade_val, cost, trade_price) @@ -163,15 +105,15 @@ class Account: # buy stock # deal order, then update state self.current.update_order(order, trade_val, cost, trade_price) - self.update_state_from_order(order, trade_val, cost, trade_price) + self._update_state_from_order(order, trade_val, cost, trade_price) def update_bar_count(self): self.current.add_count_all(bar=self.freq) def update_bar_report(self, trade_start_time, trade_end_time, trade_exchange): """ - start_time: pd.TimeStamp - end_time: pd.TimeStamp + trade_start_time: pd.TimeStamp + trade_end_time: pd.TimeStamp quote: pd.DataFrame (code, date), collumns when the end of trade date - update rtn @@ -211,7 +153,8 @@ class Account: # judge whether the the trading is begin. # and don't add init account state into report, due to we don't have excess return in those days. self.report.update_report_record( - trade_time=trade_start_time, + trade_start_time=trade_start_time, + trade_end_time=trade_end_time, account_value=now_account_value, cash=self.current.position["cash"], return_rate=(self.earning + self.ct) / last_account_value, @@ -220,7 +163,6 @@ class Account: turnover_rate=self.to / last_account_value, cost_rate=self.ct / last_account_value, stock_value=now_stock_value, - bench_value=self._sample_benchmark(self.bench, trade_start_time, trade_end_time), ) # set now_account_value to position self.current.position["now_account_value"] = now_account_value @@ -234,18 +176,3 @@ class Account: self.rtn = 0 self.ct = 0 self.to = 0 - - def load_account(self, account_path): - report = Report() - position = Position() - report.load_report(account_path / "report.csv") - position.load_position(account_path / "position.xlsx") - - # assign values - self.init_vars(position.init_cash) - self.current = position - self.report = report - - def save_account(self, account_path): - self.current.save_position(account_path / "position.xlsx") - self.report.save_report(account_path / "report.csv") diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index 73785c771..33c73de7a 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -2,14 +2,29 @@ # Licensed under the MIT License. -def backtest(start_time, end_time, trade_strategy, trade_env): +def backtest(start_time, end_time, trade_strategy, trade_executor): - trade_env.reset(start_time=start_time, end_time=end_time) - trade_strategy.reset(start_time=start_time, end_time=end_time) + trade_executor.reset(start_time=start_time, end_time=end_time) + level_infra = trade_executor.get_level_infra() + trade_strategy.reset(level_infra=level_infra) - _execute_state = trade_env.get_init_state() - while not trade_env.finished(): - _order_list = trade_strategy.generate_order_list(_execute_state) - _execute_state = trade_env.execute(_order_list) + sub_execute_state = trade_executor.get_init_state() + while not trade_executor.finished(): + sub_trade_decision = trade_strategy.generate_trade_decision(sub_execute_state) + sub_execute_state = trade_executor.execute(sub_trade_decision) - return trade_env.get_report() + return trade_executor.get_report() + + +def collect_data(start_time, end_time, trade_strategy, trade_executor): + + trade_executor.reset(start_time=start_time, end_time=end_time) + level_infra = trade_executor.get_level_infra() + trade_strategy.reset(level_infra=level_infra) + + sub_execute_state = trade_executor.get_init_state() + while not trade_executor.finished(): + sub_trade_decision = trade_strategy.generate_trade_decision(sub_execute_state) + sub_execute_state = yield from trade_executor.collect_data(sub_trade_decision) + + return trade_executor.get_report() diff --git a/qlib/contrib/backtest/exchange.py b/qlib/contrib/backtest/exchange.py index 86045fd7a..09b7f2a63 100644 --- a/qlib/contrib/backtest/exchange.py +++ b/qlib/contrib/backtest/exchange.py @@ -11,7 +11,7 @@ import pandas as pd from ...data.data import D from ...data.dataset.utils import get_level_index from ...config import C, REG_CN -from ...utils.sample import sample_feature +from ...utils.resam import resam_ts_data from ...log import get_module_logger from .order import Order @@ -34,8 +34,9 @@ class Exchange: ): """__init__ - :param start_time: start time for backtest - :param end_time: end time for backtest + :param freq: frequency of data + :param start_time: closed start time for backtest + :param end_time: closed end time for backtest :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) :param deal_price: str, 'close', 'open', 'vwap' :param subscribe_fields: list, subscribe fields @@ -91,7 +92,7 @@ class Exchange: # $factor is for rounding to the trading unit # $change is for calculating the limit of the stock - necessary_fields = {self.deal_price, "$close", "$change", "$factor"} + necessary_fields = {self.deal_price, "$close", "$change", "$factor", "$volume"} subscribe_fields = list(necessary_fields | set(subscribe_fields)) all_fields = list(necessary_fields | set(subscribe_fields)) self.all_fields = all_fields @@ -167,12 +168,12 @@ class Exchange: trade_date is limtited """ - return sample_feature(self.quote[stock_id], start_time, end_time, fields="limit", method="all").iloc[0] + return resam_ts_data(self.quote[stock_id]["limit"], start_time, end_time, method="all").iloc[0] def check_stock_suspended(self, stock_id, start_time, end_time): # is suspended if stock_id in self.quote: - return sample_feature(self.quote[stock_id], start_time, end_time, method=None) is None + return resam_ts_data(self.quote[stock_id], start_time, end_time, method=None) is None else: return True @@ -230,15 +231,16 @@ class Exchange: return trade_val, trade_cost, trade_price def get_quote_info(self, stock_id, start_time, end_time): - return sample_feature(self.quote[stock_id], start_time, end_time, method="last").iloc[0] + return resam_ts_data(self.quote[stock_id], start_time, end_time, method="last").iloc[0] def get_close(self, stock_id, start_time, end_time): - return sample_feature(self.quote[stock_id], start_time, end_time, fields="$close", method="last").iloc[0] + return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method="last").iloc[0] + + def get_volume(self, stock_id, start_time, end_time): + return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method="sum").iloc[0] def get_deal_price(self, stock_id, start_time, end_time): - deal_price = sample_feature( - self.quote[stock_id], start_time, end_time, fields=self.deal_price, method="last" - ).iloc[0] + deal_price = resam_ts_data(self.quote[stock_id][self.deal_price], start_time, end_time, method="last").iloc[0] if np.isclose(deal_price, 0.0) or np.isnan(deal_price): self.logger.warning( f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {self.deal_price}): {deal_price}!!!" @@ -248,7 +250,7 @@ class Exchange: return deal_price def get_factor(self, stock_id, start_time, end_time): - return sample_feature(self.quote[stock_id], start_time, end_time, fields="$factor", method="last").iloc[0] + return resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method="last").iloc[0] def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): """ diff --git a/qlib/contrib/backtest/executor.py b/qlib/contrib/backtest/executor.py index 943b26f9c..8a57d2986 100644 --- a/qlib/contrib/backtest/executor.py +++ b/qlib/contrib/backtest/executor.py @@ -2,88 +2,18 @@ import copy import warnings import pandas as pd from typing import Union -from ...data.data import Cal from ...utils import init_instance_by_config -from ...utils.sample import get_sample_freq_calendar, parse_freq +from ...utils.resam import parse_freq from .order import Order from .account import Account from .exchange import Exchange -from .faculty import common_faculty +from .utils import TradeCalendarManager -class BaseTradeCalendar: - """ - Base class providing trading calendar - - BaseStrategy and BaseExecutor should inherited from this class - """ - - def __init__( - self, step_bar: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None - ): - """ - Parameters - ---------- - step_bar : str - frequency of each trading step bar - start_time : Union[str, pd.Timestamp], optional - start time of trading, by default None - If `start_time` is None, it must be reset before trading. - end_time : Union[str, pd.Timestamp], optional - end time of trading, by default None - If `end_time` is None, it must be reset before trading. - """ - - self.step_bar = step_bar - self.start_time = pd.Timestamp(start_time) if start_time else None - self.end_time = pd.Timestamp(end_time) if end_time else None - self.reset(start_time=start_time, end_time=end_time) - - def _reset_trade_calendar(self, start_time, end_time): - """reset trade calendar""" - if start_time and end_time: - _calendar, freq, freq_sam = get_sample_freq_calendar(freq=self.step_bar) - self.calendar = _calendar - _, _, _start_index, _end_index = Cal.locate_index( - self.start_time, self.end_time, freq=freq, freq_sam=freq_sam - ) - self.start_index = _start_index - self.end_index = _end_index - self.trade_len = _end_index - _start_index + 1 - self.trade_index = 0 - else: - raise ValueError("failed to reset trade calendar, param `start_time` or `end_time` is None.") - - def reset(self, start_time=None, end_time=None): - """ - Reset start\end time of trading, and reset trading calendar - """ - - if start_time: - self.start_time = pd.Timestamp(start_time) - if end_time: - self.end_time = pd.Timestamp(end_time) - if self.start_time and self.end_time and (start_time or end_time): - self._reset_trade_calendar(start_time=self.start_time, end_time=self.end_time) - - def _get_calendar_time(self, trade_index=1, shift=0): - trade_index = trade_index - shift - calendar_index = self.start_index + trade_index - return self.calendar[calendar_index - 1], self.calendar[calendar_index] - pd.Timedelta(seconds=1) - - def finished(self): - return self.trade_index >= self.trade_len - - def step(self): - if self.finished(): - raise RuntimeError(f"this env has completed its task, please reset it if you want to call it!") - # trade count += 1 - self.trade_index = self.trade_index + 1 - - -class BaseExecutor(BaseTradeCalendar): +class BaseExecutor: """Base executor for trading""" def __init__( @@ -91,48 +21,97 @@ class BaseExecutor(BaseTradeCalendar): step_bar: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - trade_account: Account = None, generate_report: bool = False, verbose: bool = False, track_data: bool = False, + common_infra: dict = {}, **kwargs, ): """ Parameters ---------- - trade_account : Account, optional - trade account for trading, by default None - - If `trade_account` is None, self.trade_account will be set with common_faculty generate_report : bool, optional whether to generate report, by default False verbose : bool, optional whether to print trading info, by default False track_data : bool, optional - whether to generate order_list, will be used when making data for multi-level training - - If `self.track_data` is true, when making data for training, the input `order_list` of `execute` will be generated by `get_data` - - Else, `order_list` will not be generated + whether to generate trade_decision, will be used when making data for multi-level training + - If `self.track_data` is true, when making data for training, the input `trade_decision` of `execute` will be generated by `collect_data` + - Else, `trade_decision` will not be generated + common_infra : dict, optional: + common infrastructure for backtesting, may including: + - trade_account : Account, optional + trade account for trading + - trade_exchange : Exchange, optional + exchange that provides market info + """ - super(BaseExecutor, self).__init__(step_bar=step_bar, start_time=start_time, end_time=end_time, **kwargs) - self.trade_account = copy.copy(common_faculty.trade_account if trade_account is None else trade_account) - self.trade_account.reset(freq=self.step_bar, init_report=True) + self.step_bar = step_bar self.generate_report = generate_report self.verbose = verbose self.track_data = track_data + self.reset(start_time=start_time, end_time=end_time, track_data=track_data, common_infra=common_infra) - def reset(self, track_data: bool = None, **kwargs): + def reset_common_infra(self, common_infra): """ - Reset `track_data`, will be used when making data for multi-level training + reset infrastructure for trading + - reset trade_account """ - super(BaseExecutor, self).reset(**kwargs) + if not hasattr(self, "common_infra"): + self.common_infra = common_infra + else: + self.common_infra.update(common_infra) + + if "trade_account" in common_infra: + self.trade_account = copy.copy(common_infra.get("trade_account")) + self.trade_account.reset(freq=self.step_bar, init_report=True) + + def reset(self, track_data: bool = None, common_infra: dict = None, **kwargs): + """ + - reset `start_time` and `end_time`, used in trade calendar + - reset `track_data`, used when making data for multi-level training + - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc + """ + if track_data is not None: self.track_data = track_data + if common_infra is not None: + self.reset_common_infra(common_infra) + + if "start_time" in kwargs or "end_time" in kwargs: + start_time = kwargs.get("start_time") + end_time = kwargs.get("end_time") + self.trade_calendar = TradeCalendarManager(step_bar=self.step_bar, start_time=start_time, end_time=end_time) + + def get_level_infra(self): + return {"trade_calendar": self.trade_calendar} + + def finished(self): + return self.trade_calendar.finished() + + def execute(self, trade_decision): + """execute the trade decision and return the executed result + + Parameters + ---------- + trade_decision : object + + Returns + ---------- + executed state : List[Tuple[Order, float, float, float]] + - Each element in the list represents (order, trade value, trade cost, trade price) + """ + raise NotImplementedError("execute is not implemented!") + + def collect_data(self, trade_decision): + if self.track_data: + yield trade_decision + return self.execute(trade_decision) + def get_init_state(self): raise NotImplementedError("get_init_state in not implemeted!") - def execute(self, **kwargs): - raise NotImplementedError("execute is not implemented!") - def get_trade_account(self): raise NotImplementedError("get_trade_account is not implemented!") @@ -146,56 +125,75 @@ class SplitExecutor(BaseExecutor): def __init__( self, step_bar: str, - sub_env: Union[BaseExecutor, dict], + sub_executor: Union[BaseExecutor, dict], sub_strategy: Union[BaseStrategy, dict], start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - trade_account: Account = None, trade_exchange: Exchange = None, generate_report: bool = False, verbose: bool = False, track_data: bool = False, + common_infra: dict = {}, **kwargs, ): """ Parameters ---------- - sub_env : BaseExecutor + sub_executor : BaseExecutor trading env in each trading bar. sub_strategy : BaseStrategy trading strategy in each trading bar trade_exchange : Exchange - exchange that provides market info - - If `trade_exchange` is None, self.trade_exchange will be set with common_faculty + exchange that provides market info, used to generate report + - If generate_report is None, trade_exchange will be ignored + - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra """ + self.sub_executor = init_instance_by_config(sub_executor, common_infra=common_infra, accept_types=BaseExecutor) + self.sub_strategy = init_instance_by_config( + sub_strategy, common_infra=common_infra, accept_types=self.BaseStrategy + ) + super(SplitExecutor, self).__init__( step_bar=step_bar, start_time=start_time, end_time=end_time, - trade_account=trade_account, generate_report=generate_report, verbose=verbose, track_data=track_data, + common_infra=common_infra, **kwargs, ) - if generate_report: - self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange - self.sub_env = init_instance_by_config(sub_env, accept_types=BaseExecutor) - self.sub_strategy = init_instance_by_config(sub_strategy, accept_types=self.BaseStrategy) + + if generate_report and trade_exchange is not None: + self.trade_exchange = trade_exchange + + def reset_common_infra(self, common_infra): + """ + reset infrastructure for trading + - reset trade_exchange + - reset substrategy and subexecutor common infra + """ + super(SplitExecutor, self).reset_common_infra(common_infra) + + if self.generate_report and "trade_exchange" in common_infra: + self.trade_exchange = common_infra.get("trade_exchange") + + self.sub_executor.reset_common_infra(common_infra) + self.sub_strategy.reset_common_infra(common_infra) def get_init_state(self): - init_state = {"current": self.trade_account.current} - return init_state + return [] - def _init_sub_trading(self, order_list): - trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) - self.sub_env.reset(start_time=trade_start_time, end_time=trade_end_time) - self.sub_strategy.reset(start_time=trade_start_time, end_time=trade_end_time, trade_order_list=order_list) - sub_execute_state = self.sub_env.get_init_state() - return sub_execute_state + def _init_sub_trading(self, trade_decision): + trade_index = self.trade_calendar.get_trade_index() + trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) + self.sub_executor.reset(start_time=trade_start_time, end_time=trade_end_time) + sub_level_infra = self.sub_executor.get_level_infra() + self.sub_strategy.reset(level_infra=sub_level_infra, rely_trade_decision=trade_decision) def _update_trade_account(self): - trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) + trade_index = self.trade_calendar.get_trade_index() + trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) self.trade_account.update_bar_count() if self.generate_report: self.trade_account.update_bar_report( @@ -204,30 +202,38 @@ class SplitExecutor(BaseExecutor): trade_exchange=self.trade_exchange, ) - def execute(self, order_list): - super(SplitExecutor, self).step() - self._init_sub_trading(order_list) - sub_execute_state = self.sub_env.get_init_state() - while not self.sub_env.finished(): - _order_list = self.sub_strategy.generate_order_list(sub_execute_state) - sub_execute_state = self.sub_env.execute(order_list=_order_list) - self._update_trade_account() - return {"current": self.trade_account.current} + def execute(self, trade_decision): + self.trade_calendar.step() + self._init_sub_trading(trade_decision) + execute_state = [] + sub_execute_state = self.sub_executor.get_init_state() + while not self.sub_executor.finished(): + sub_trade_decison = self.sub_strategy.generate_trade_decision(sub_execute_state) + sub_execute_state = self.sub_executor.execute(trade_decision=sub_trade_decison) + execute_state.extend(sub_execute_state) + if hasattr(self, "trade_account"): + self._update_trade_account() - def get_data(self, order_list): + return execute_state + + def collect_data(self, trade_decision): if self.track_data: - yield order_list - super(SplitExecutor, self).step() - self._init_sub_trading(order_list) - sub_execute_state = self.sub_env.get_init_state() - while not self.sub_env.finished(): - _order_list = self.sub_strategy.generate_order_list(sub_execute_state) - sub_execute_state = yield from self.sub_env.get_data(order_list=_order_list) - self._update_trade_account() - return {"current": self.trade_account.current} + yield trade_decision + self.trade_calendar.step() + self._init_sub_trading(trade_decision) + execute_state = [] + sub_execute_state = self.sub_executor.get_init_state() + while not self.sub_executor.finished(): + sub_trade_decison = self.sub_strategy.generate_trade_decision(sub_execute_state) + sub_execute_state = yield from self.sub_executor.collect_data(trade_decision=sub_trade_decison) + execute_state.extend(sub_execute_state) + if hasattr(self, "trade_account"): + self._update_trade_account() + + return execute_state def get_report(self): - sub_env_report_dict = self.sub_env.get_report() + sub_env_report_dict = self.sub_executor.get_report() if self.generate_report: _report = self.trade_account.report.generate_report_dataframe() _positions = self.trade_account.get_positions() @@ -242,46 +248,57 @@ class SimulatorExecutor(BaseExecutor): step_bar: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - trade_account: Account = None, trade_exchange: Exchange = None, generate_report: bool = False, verbose: bool = False, track_data: bool = False, + common_infra: dict = {}, **kwargs, ): """ Parameters ---------- trade_exchange : Exchange - exchange that provides market info + exchange that provides market info, used to deal order and generate report + - If `trade_exchange` is None, self.trade_exchange will be set with common_infra """ super(SimulatorExecutor, self).__init__( step_bar=step_bar, start_time=start_time, end_time=end_time, - trade_account=trade_account, generate_report=generate_report, verbose=verbose, track_data=track_data, + common_infra=common_infra, **kwargs, ) - self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange + if trade_exchange is not None: + self.trade_exchange = trade_exchange + + def reset_common_infra(self, common_infra): + """ + reset infrastructure for trading + - reset trade_exchange + """ + super(SimulatorExecutor, self).reset_common_infra(common_infra) + if "trade_exchange" in common_infra: + self.trade_exchange = common_infra.get("trade_exchange") def get_init_state(self): - init_state = {"current": self.trade_account.current, "trade_info": []} - return init_state + return [] - def execute(self, order_list): - super(SimulatorExecutor, self).step() - trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) - trade_info = [] - for order in order_list: + def execute(self, trade_decision): + self.trade_calendar.step() + trade_index = self.trade_calendar.get_trade_index() + trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) + execute_state = [] + for order in trade_decision: if self.trade_exchange.check_order(order) is True: # execute the order trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( order, trade_account=self.trade_account ) - trade_info.append((order, trade_val, trade_cost, trade_price)) + execute_state.append((order, trade_val, trade_cost, trade_price)) if self.verbose: if order.direction == Order.SELL: # sell print( @@ -323,7 +340,7 @@ class SimulatorExecutor(BaseExecutor): trade_exchange=self.trade_exchange, ) - return {"current": self.trade_account.current, "trade_info": trade_info} + return execute_state def get_report(self): if self.generate_report: diff --git a/qlib/contrib/backtest/faculty.py b/qlib/contrib/backtest/faculty.py deleted file mode 100644 index 34ad14cbc..000000000 --- a/qlib/contrib/backtest/faculty.py +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - - -class Faculty: - def __init__(self): - self.__dict__["_faculty"] = dict() - - def __getitem__(self, key): - return self.__dict__["_faculty"][key] - - def __getattr__(self, attr): - if attr in self.__dict__["_faculty"]: - return self.__dict__["_faculty"][attr] - - raise AttributeError(f"No such {attr} in self._faculty") - - def __setitem__(self, key, value): - self.__dict__["_faculty"][key] = value - - def __setattr__(self, attr, value): - self.__dict__["_faculty"][attr] = value - - def update(self, *args, **kwargs): - self.__dict__["_faculty"].update(*args, **kwargs) - - -common_faculty = Faculty() diff --git a/qlib/contrib/backtest/position.py b/qlib/contrib/backtest/position.py index 0b39990b3..978ea0387 100644 --- a/qlib/contrib/backtest/position.py +++ b/qlib/contrib/backtest/position.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - +import numpy as np import pandas as pd import copy import pathlib diff --git a/qlib/contrib/backtest/report.py b/qlib/contrib/backtest/report.py index 57e56c9a3..3763f5214 100644 --- a/qlib/contrib/backtest/report.py +++ b/qlib/contrib/backtest/report.py @@ -3,16 +3,51 @@ from collections import OrderedDict +from logging import warning import pandas as pd import pathlib +import warnings + +from pandas.core.frame import DataFrame + +from ...utils.resam import parse_freq, resam_ts_data +from ...data import D class Report: # daily report of the account # contain those followings: returns, costs turnovers, accounts, cash, bench, value # update report - def __init__(self): + def __init__(self, freq: str = "day", benchmark_config: dict = {}): + """ + Parameters + ---------- + freq : str + frequency of trading bar, used for updating hold count of trading bar + benchmark_config : dict + config of benchmark, may including the following arguments: + - benchmark : Union[str, list, pd.Series] + - If `benchmark` is pd.Series, `index` is trading date; the value T is the change from T-1 to T. + example: + print(D.features(D.instruments('csi500'), ['$close/Ref($close, 1)-1'])['$close/Ref($close, 1)-1'].head()) + 2017-01-04 0.011693 + 2017-01-05 0.000721 + 2017-01-06 -0.004322 + 2017-01-09 0.006874 + 2017-01-10 -0.003350 + - If `benchmark` is list, will use the daily average change of the stock pool in the list as the 'bench'. + - If `benchmark` is str, will use the daily change as the 'bench'. + benchmark code, default is SH000300 CSI300 + - start_time : Union[str, pd.Timestamp], optional + - If `benchmark` is pd.Series, it will be ignored + - Else, it represent start time of benchmark, by default None + - end_time : Union[str, pd.Timestamp], optional + - If `benchmark` is pd.Series, it will be ignored + - Else, it represent end time of benchmark, by default None + + """ self.init_vars() + self.init_bench(freq=freq, benchmark_config=benchmark_config) def init_vars(self): self.accounts = OrderedDict() # account postion value for each trade date @@ -24,6 +59,49 @@ class Report: self.benches = OrderedDict() self.latest_report_time = None # pd.TimeStamp + def init_bench(self, freq=None, benchmark_config=None): + if freq is not None: + self.freq = freq + if benchmark_config is not None: + self.benchmark_config = benchmark_config + self.bench = self._cal_benchmark(self.benchmark_config, self.freq) + + def _cal_benchmark(self, benchmark_config, freq): + benchmark = benchmark_config.get("benchmark", "SH000300") + if isinstance(benchmark, pd.Series): + return benchmark + else: + start_time = benchmark_config.get("start_time", None) + end_time = benchmark_config.get("end_time", None) + + if freq is None: + raise ValueError("benchmark freq can't be None!") + _codes = benchmark if isinstance(benchmark, list) else [benchmark] + fields = ["$close/Ref($close,1)-1"] + try: + _temp_result = D.features(_codes, fields, start_time, end_time, freq=freq, disk_cache=1) + except ValueError: + _, norm_freq = parse_freq(freq) + if norm_freq in ["month", "week", "day"]: + try: + _temp_result = D.features(_codes, fields, start_time, end_time, freq="day", disk_cache=1) + except ValueError: + _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) + elif norm_freq == "minute": + _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) + else: + raise ValueError(f"benchmark freq {freq} is not supported") + if len(_temp_result) == 0: + raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") + return _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean().fillna(0) + + def _sample_benchmark(self, bench, trade_start_time, trade_end_time): + def cal_change(x): + return (x + 1).prod() - 1 + + _ret = resam_ts_data(bench, trade_start_time, trade_end_time, method=cal_change) + return 0.0 if _ret is None else _ret + def is_empty(self): return len(self.accounts) == 0 @@ -35,30 +113,39 @@ class Report: def update_report_record( self, - trade_time=None, + trade_start_time=None, + trade_end_time=None, account_value=None, cash=None, return_rate=None, turnover_rate=None, cost_rate=None, stock_value=None, - bench_value=None, ): # check data - if None in [trade_time, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value, bench_value]: + if None in [ + trade_start_time, + trade_end_time, + account_value, + cash, + return_rate, + turnover_rate, + cost_rate, + stock_value, + ]: raise ValueError( - "None in [trade_date, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value, bench_value]" + "None in [trade_start_time, trade_end_time, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value]" ) # update report data - self.accounts[trade_time] = account_value - self.returns[trade_time] = return_rate - self.turnovers[trade_time] = turnover_rate - self.costs[trade_time] = cost_rate - self.values[trade_time] = stock_value - self.cashes[trade_time] = cash - self.benches[trade_time] = bench_value + self.accounts[trade_start_time] = account_value + self.returns[trade_start_time] = return_rate + self.turnovers[trade_start_time] = turnover_rate + self.costs[trade_start_time] = cost_rate + self.values[trade_start_time] = stock_value + self.cashes[trade_start_time] = cash + self.benches[trade_start_time] = self._sample_benchmark(self.bench, trade_start_time, trade_end_time) # update latest_report_date - self.latest_report_time = trade_time + self.latest_report_time = trade_start_time # finish daily report update def generate_report_dataframe(self): diff --git a/qlib/contrib/backtest/utils.py b/qlib/contrib/backtest/utils.py new file mode 100644 index 000000000..1a4173887 --- /dev/null +++ b/qlib/contrib/backtest/utils.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pandas as pd +from typing import Union + +from ...utils.resam import get_resam_calendar +from ...data.data import Cal + + +class TradeCalendarManager: + """ + Manager for trading calendar + - BaseStrategy and BaseExecutor will use it + """ + + def __init__( + self, step_bar: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None + ): + """ + Parameters + ---------- + step_bar : str + frequency of each trading calendar + start_time : Union[str, pd.Timestamp], optional + closed start of the trading calendar, by default None + If `start_time` is None, it must be reset before trading. + end_time : Union[str, pd.Timestamp], optional + closed end of the trade time range, by default None + If `end_time` is None, it must be reset before trading. + """ + self.step_bar = step_bar + self.start_time = pd.Timestamp(start_time) if start_time else None + self.end_time = pd.Timestamp(start_time) if start_time else None + self._init_trade_calendar(step_bar=step_bar, start_time=start_time, end_time=end_time) + + def _init_trade_calendar(self, step_bar, start_time, end_time): + """reset trade calendar""" + _calendar, freq, freq_sam = get_resam_calendar(freq=step_bar) + self.calendar = _calendar + _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) + self.start_index = _start_index + self.end_index = _end_index + self.trade_len = _end_index - _start_index + 1 + self.trade_index = 0 + + def finished(self): + return self.trade_index >= self.trade_len + + def step(self): + if self.finished(): + raise RuntimeError(f"The calendar is finished, please reset it if you want to call it!") + self.trade_index = self.trade_index + 1 + + def get_step_bar(self): + return self.step_bar + + def get_trade_len(self): + return self.trade_len + + def get_trade_index(self): + return self.trade_index + + def get_calendar_time(self, trade_index=1, shift=0): + trade_index = trade_index - shift + calendar_index = self.start_index + trade_index + return self.calendar[calendar_index - 1], self.calendar[calendar_index] - pd.Timedelta(seconds=1) diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index 10f80671e..59a831f3e 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -3,6 +3,7 @@ from __future__ import division from __future__ import print_function +from logging import warn import numpy as np import pandas as pd @@ -10,7 +11,7 @@ import warnings from ..log import get_module_logger from .backtest import get_exchange, backtest as backtest_func from ..utils import get_date_range -from ..utils.sample import parse_freq +from ..utils.resam import parse_freq from ..data import D from ..config import C @@ -20,7 +21,7 @@ from ..data.dataset.utils import get_level_index logger = get_module_logger("Evaluate") -def risk_analysis(r, N: int = None, freq: str = None): +def risk_analysis(r, N: int = None, freq: str = "day"): """Risk Analysis Parameters @@ -36,8 +37,8 @@ def risk_analysis(r, N: int = None, freq: str = None): def cal_risk_analysis_scaler(freq): _count, _freq = parse_freq(freq) _freq_scaler = { - "minute": 240 * 250, - "day": 250, + "minute": 240 * 252, + "day": 252, "week": 50, "month": 12, } @@ -45,6 +46,8 @@ def risk_analysis(r, N: int = None, freq: str = None): if N is None and freq is None: raise ValueError("at least one of `N` and `freq` should exist") + if N is not None and freq is not None: + warnings.warn("risk_analysis freq will be ignored") if N is None: N = cal_risk_analysis_scaler(freq) diff --git a/qlib/contrib/online/operator.py b/qlib/contrib/online/operator.py index d2307dad5..8d78f2c50 100644 --- a/qlib/contrib/online/operator.py +++ b/qlib/contrib/online/operator.py @@ -118,7 +118,7 @@ class Operator: user.strategy.update(score_series, pred_date, trade_date) # generate and save order list - order_list = user.strategy.generate_order_list( + order_list = user.strategy.generate_trade_decision( score_series=score_series, current=user.account.current, trade_exchange=trade_exchange, @@ -208,7 +208,7 @@ class Operator: self.logger.info("Update account state {} for {}".format(trade_date, user_id)) def simulate(self, id, config, exchange_config, start, end, path, bench="SH000905"): - """Run the ( generate_order_list -> execute_order_list -> update_account) process everyday + """Run the ( generate_trade_decision -> execute_order_list -> update_account) process everyday from start date to end date. Parameters @@ -256,7 +256,7 @@ class Operator: user.strategy.update(score_series, pred_date, trade_date) # 3. generate and save order list - order_list = user.strategy.generate_order_list( + order_list = user.strategy.generate_trade_decision( score_series=score_series, current=user.account.current, trade_exchange=trade_exchange, diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index 8b3e3db18..58e3fccc4 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -10,17 +10,15 @@ import copy class SoftTopkStrategy(WeightStrategyBase): def __init__( self, - step_bar, model, dataset, topk, - start_time=None, - end_time=None, order_generator_cls_or_obj=OrderGenWInteract, - trade_exchange=None, max_sold_weight=1.0, risk_degree=0.95, buy_method="first_fill", + level_infra={}, + common_infra={}, **kwargs, ): """Parameter @@ -33,7 +31,7 @@ class SoftTopkStrategy(WeightStrategyBase): average_fill: assign the weight to the stocks rank high averagely. """ super(SoftTopkStrategy, self).__init__( - step_bar, model, dataset, start_time, end_time, order_generator_cls_or_obj, trade_exchange + model, dataset, order_generator_cls_or_obj, level_infra, common_infra, **kwargs ) self.topk = topk self.max_sold_weight = max_sold_weight diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index b3bb33a88..336cfa534 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -3,29 +3,26 @@ import warnings import numpy as np import pandas as pd -from ...utils.sample import sample_feature +from ...utils.resam import resam_ts_data from ...strategy.base import ModelStrategy from ..backtest.order import Order -from ..backtest.faculty import common_faculty from .order_generator import OrderGenWInteract class TopkDropoutStrategy(ModelStrategy): def __init__( self, - step_bar, model, dataset, topk, n_drop, - start_time=None, - end_time=None, - trade_exchange=None, method_sell="bottom", method_buy="top", risk_degree=0.95, hold_thresh=1, only_tradable=False, + level_infra={}, + common_infra={}, **kwargs, ): """ @@ -51,8 +48,9 @@ class TopkDropoutStrategy(ModelStrategy): else: strategy will make decision with the tradable state of the stock info and avoid buy and sell them. """ - super(TopkDropoutStrategy, self).__init__(step_bar, model, dataset, start_time, end_time, **kwargs) - self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange + super(TopkDropoutStrategy, self).__init__( + model, dataset, level_infra=level_infra, common_infra=common_infra, **kwargs + ) self.topk = topk self.n_drop = n_drop self.method_sell = method_sell @@ -61,6 +59,20 @@ class TopkDropoutStrategy(ModelStrategy): self.hold_thresh = hold_thresh self.only_tradable = only_tradable + def reset_common_infra(self, common_infra): + """ + Parameters + ---------- + common_infra : dict, optional + common infrastructure for backtesting, by default None + - It should include `trade_account`, used to get position + - It should include `trade_exchange`, used to provide market info + """ + super(TopkDropoutStrategy, self).reset_common_infra(common_infra) + + if "trade_exchange" in common_infra: + self.trade_exchange = common_infra.get("trade_exchange") + def get_risk_degree(self, trade_index=None): """get_risk_degree Return the proportion of your total value you will used in investment. @@ -69,11 +81,11 @@ class TopkDropoutStrategy(ModelStrategy): # It will use 95% amoutn of your total value by default return self.risk_degree - def generate_order_list(self, execute_state): - super(TopkDropoutStrategy, self).step() - trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) - pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) - pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") + def generate_trade_decision(self, execute_state): + trade_index = self.trade_calendar.get_trade_index() + trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) + pred_start_time, pred_end_time = self.trade_calendar.get_calendar_time(trade_index, shift=1) + pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: return [] if self.only_tradable: @@ -115,8 +127,7 @@ class TopkDropoutStrategy(ModelStrategy): def filter_stock(l): return l - current = execute_state.get("current") - current_temp = copy.deepcopy(current) + current_temp = copy.deepcopy(self.trade_position) # generate order list for this adjust date sell_order_list = [] buy_order_list = [] @@ -168,7 +179,8 @@ class TopkDropoutStrategy(ModelStrategy): continue if code in sell: # check hold limit - if current_temp.get_stock_count(code, bar=self.step_bar) < self.hold_thresh: + step_bar = self.trade_calendar.get_step_bar() + if current_temp.get_stock_count(code, bar=step_bar) < self.hold_thresh: continue # sell order sell_amount = current_temp.get_stock_amount(code=code) @@ -228,22 +240,35 @@ class TopkDropoutStrategy(ModelStrategy): class WeightStrategyBase(ModelStrategy): def __init__( self, - step_bar, model, dataset, - start_time=None, - end_time=None, order_generator_cls_or_obj=OrderGenWInteract, - trade_exchange=None, + level_infra={}, + common_infra={}, **kwargs, ): - super(WeightStrategyBase, self).__init__(step_bar, model, dataset, start_time, end_time, **kwargs) - self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange + super(WeightStrategyBase, self).__init__( + model, dataset, level_infra=level_infra, common_infra=common_infra, **kwargs + ) if isinstance(order_generator_cls_or_obj, type): self.order_generator = order_generator_cls_or_obj() else: self.order_generator = order_generator_cls_or_obj + def reset_common_infra(self, common_infra): + """ + Parameters + ---------- + common_infra : dict, optional + common infrastructure for backtesting, by default None + - It should include `trade_account`, used to get position + - It should include `trade_exchange`, used to provide market info + """ + super(WeightStrategyBase, self).reset_common_infra(common_infra) + + if "trade_exchange" in common_infra: + self.trade_exchange = common_infra.get("trade_exchange") + def get_risk_degree(self, trade_index=None): """get_risk_degree Return the proportion of your total value you will used in investment. @@ -267,7 +292,7 @@ class WeightStrategyBase(ModelStrategy): """ raise NotImplementedError() - def generate_order_list(self, execute_state): + def generate_trade_decision(self, execute_state): """ Parameters ----------- @@ -280,23 +305,22 @@ class WeightStrategyBase(ModelStrategy): trade_date : pd.Timestamp date. """ - # generate_order_list + # generate_trade_decision # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list - super(WeightStrategyBase, self).step() - trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) - pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) - pred_score = sample_feature(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") + trade_index = self.trade_calendar.get_trade_index() + trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) + pred_start_time, pred_end_time = self.trade_calendar.get_calendar_time(trade_index, shift=1) + pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: return [] - current = execute_state.get("current") - current_temp = copy.deepcopy(current) + current_temp = copy.deepcopy(self.trade_position) target_weight_position = self.generate_target_weight_position( score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time ) order_list = self.order_generator.generate_order_list_from_target_weight_position( current=current_temp, trade_exchange=self.trade_exchange, - risk_degree=self.get_risk_degree(self.trade_index), + risk_degree=self.get_risk_degree(trade_index), target_weight_position=target_weight_position, pred_start_time=pred_start_time, pred_end_time=pred_end_time, diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 0e0f2b907..2265a9dc5 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -1,80 +1,77 @@ -import copy import warnings -import numpy as np -import pandas as pd -from typing import Union -from ...utils.sample import sample_feature +from ...utils.resam import resam_ts_data from ...data.data import D from ...data.dataset.utils import convert_index_format -from ...strategy.base import RuleStrategy, OrderEnhancement +from ...strategy.base import RuleStrategy from ..backtest.order import Order -from ..backtest.faculty import common_faculty -class TWAPStrategy(RuleStrategy, OrderEnhancement): +class TWAPStrategy(RuleStrategy): """TWAP Strategy for trading""" - def __init__( - self, - step_bar, - start_time=None, - end_time=None, - trade_exchange=None, - trade_order_list=[], - **kwargs, - ): + def reset_common_infra(self, common_infra): """ Parameters ---------- - trade_exchange : Exchange, optional - exchange that provides market info, by default None - - If `trade_exchange` is None, self.trade_exchange will be set with common_faculty - trade_order_list : list, optional - order list to trade, which the strategy will trade in [start_time , end_time] , by default [] + common_infra : dict, optional + common infrastructure for backtesting, by default None + - It should include `trade_account`, used to get position + - It should include `trade_exchange`, used to provide market info """ - super(TWAPStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) - self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange - self.trade_order_list = trade_order_list + super(TWAPStrategy, self).reset_common_infra(common_infra) + if common_infra is not None: + if "trade_exchange" in common_infra: + self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, trade_order_list: list = None, **kwargs): - super(TWAPStrategy, self).reset(**kwargs) - OrderEnhancement.reset(self, trade_order_list=trade_order_list) - if trade_order_list is not None: + def reset(self, rely_trade_decision: object = None, **kwargs): + """ + Parameters + ---------- + rely_trade_decision : object, optional + """ + + super(TWAPStrategy, self).reset(rely_trade_decision=rely_trade_decision, common_infra=common_infra, **kwargs) + if rely_trade_decision is not None: self.trade_amount = {} - for order in self.trade_order_list: + for order in rely_trade_decision: self.trade_amount[(order.stock_id, order.direction)] = order.amount - def generate_order_list(self, execute_state): - super(TWAPStrategy, self).step() - trade_info = execute_state.get("trade_info") + def generate_trade_decision(self, execute_state): + + # update the order amount + trade_info = execute_state for order, _, _, _ in trade_info: self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) + trade_index = self.trade_calendar.get_trade_index() + trade_len = self.trade_calendar.get_trade_len() + trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) order_list = [] - for order in self.trade_order_list: + for order in self.rely_trade_decision: if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): continue _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) _order_amount = None + # consider trade unit if _amount_trade_unit is None: - _order_amount = self.trade_amount[(order.stock_id, order.direction)] / ( - self.trade_len - self.trade_index + 1 - ) - if self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + # split the order equally + _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_index + 1) + # without considering trade unit + elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + # split the order equally + # floor((trade_unit_cnt + trade_len - trade_index) / (trade_len - trade_index + 1)) == ceil(trade_unit_cnt / (trade_len - trade_index + 1)) trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) _order_amount = ( - (trade_unit_cnt + self.trade_len - self.trade_index) - // (self.trade_len - self.trade_index + 1) - * _amount_trade_unit + (trade_unit_cnt + trade_len - trade_index) // (trade_len - trade_index + 1) * _amount_trade_unit ) if order.direction == order.SELL: + # sell all amount at last if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount is None or self.trade_index == self.trade_len + _order_amount is None or trade_index == trade_len ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] @@ -92,7 +89,7 @@ class TWAPStrategy(RuleStrategy, OrderEnhancement): return order_list -class SBBStrategyBase(RuleStrategy, OrderEnhancement): +class SBBStrategyBase(RuleStrategy): """ (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy. """ @@ -101,81 +98,80 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): TREND_SHORT = 1 TREND_LONG = 2 - def __init__( - self, - step_bar, - start_time=None, - end_time=None, - trade_exchange=None, - trade_order_list=[], - **kwargs, - ): + def reset_common_infra(self, common_infra): + super(SBBStrategyBase, self).reset_common_infra(common_infra) + if common_infra is not None: + if "trade_exchange" in common_infra: + self.trade_exchange = common_infra.get("trade_exchange") + + def reset(self, rely_trade_decision=None, **kwargs): """ Parameters ---------- - trade_exchange : Exchange, optional - exchange that provides market info, by default None - - If `trade_exchange` is None, self.trade_exchange will be set with common_faculty - trade_order_list : list, optional - order list to trade, which the strategy will trade in [start_time , end_time] , by default [] + rely_trade_decision : object, optional + common_infra : None, optional + common infrastructure for backtesting, by default None + - It should include `trade_account`, used to get position + - It should include `trade_exchange`, used to provide market info """ - super(SBBStrategyBase, self).__init__(step_bar, start_time, end_time, **kwargs) - self.trade_exchange = common_faculty.trade_exchange if trade_exchange is None else trade_exchange - self.trade_order_list = trade_order_list - - def reset(self, trade_order_list=None, **kwargs): - super(SBBStrategyBase, self).reset(**kwargs) - OrderEnhancement.reset(self, trade_order_list=trade_order_list) - if trade_order_list is not None: + super(SBBStrategyBase, self).reset(rely_trade_decision=rely_trade_decision, **kwargs) + if rely_trade_decision is not None: self.trade_trend = {} self.trade_amount = {} - for order in self.trade_order_list: + # init the trade amount of order and predicted trade trend + for order in rely_trade_decision: self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID self.trade_amount[(order.stock_id, order.direction)] = order.amount def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") - def generate_order_list(self, execute_state): - super(SBBStrategyBase, self).step() + def generate_trade_decision(self, execute_state): - trade_info = execute_state.get("trade_info") + # update the order amount + trade_info = execute_state for order, _, _, _ in trade_info: self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - - trade_start_time, trade_end_time = self._get_calendar_time(self.trade_index) - pred_start_time, pred_end_time = self._get_calendar_time(self.trade_index, shift=1) + trade_index = self.trade_calendar.get_trade_index() + trade_len = self.trade_calendar.get_trade_len() + trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) + pred_start_time, pred_end_time = self.trade_calendar.get_calendar_time(trade_index, shift=1) order_list = [] - for order in self.trade_order_list: - if self.trade_index % 2 == 1: + # for each order in in self.rely_trade_decision + for order in self.rely_trade_decision: + # predict the price trend + if trade_index % 2 == 1: _pred_trend = self._pred_price_trend(order.stock_id, pred_start_time, pred_end_time) else: _pred_trend = self.trade_trend[(order.stock_id, order.direction)] - + # if not tradable, continue if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): - if self.trade_index % 2 == 1: + if trade_index % 2 == 1: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend continue - + # get amount of one trade unit _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) if _pred_trend == self.TREND_MID: _order_amount = None + # considering trade unit if _amount_trade_unit is None: - _order_amount = self.trade_amount[(order.stock_id, order.direction)] / ( - self.trade_len - self.trade_index + 1 - ) + # split the order equally + _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_index + 1) + # without considering trade unit elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + # cal how many trade unit trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + # split the order equally + # floor((trade_unit_cnt + trade_len - trade_index) / (trade_len - trade_index + 1)) == ceil(trade_unit_cnt / (trade_len - trade_index + 1)) _order_amount = ( - (trade_unit_cnt + self.trade_len - self.trade_index) - // (self.trade_len - self.trade_index + 1) - * _amount_trade_unit + (trade_unit_cnt + trade_len - trade_index) // (trade_len - trade_index + 1) * _amount_trade_unit ) if order.direction == order.SELL: + # sell all amount at last if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount is None or self.trade_index == self.trade_len + _order_amount is None or trade_index == trade_len ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] @@ -185,36 +181,43 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): amount=_order_amount, start_time=trade_start_time, end_time=trade_end_time, - direction=order.direction, # 1 for buy + direction=order.direction, factor=order.factor, ) order_list.append(_order) - # print("DEBUG AMOUNT", _order_amount, self.trade_amount[(order.stock_id, order.direction)], _amount_trade_unit) + else: _order_amount = None + # considering trade unit if _amount_trade_unit is None: + # N trade day last, split the order into N + 1 parts, and trade 2 parts _order_amount = ( - 2 - * self.trade_amount[(order.stock_id, order.direction)] - / (self.trade_len - self.trade_index + 2) + 2 * self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_index + 2) ) + # without considering trade unit elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: + # cal how many trade unit trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + # N trade day last, split the order into N + 1 parts, and trade 2 parts _order_amount = ( - (trade_unit_cnt + self.trade_len - self.trade_index + 1) - // (self.trade_len - self.trade_index + 2) + (trade_unit_cnt + trade_len - trade_index + 1) + // (trade_len - trade_index + 2) * 2 * _amount_trade_unit ) if order.direction == order.SELL: + # sell all amount at last if self.trade_amount[(order.stock_id, order.direction)] >= 1e-5 and ( - _order_amount is None or self.trade_index == self.trade_len + _order_amount is None or trade_index == trade_len ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] if _order_amount: _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) - if self.trade_index % 2 == 1: + if trade_index % 2 == 1: + # in the first of two adjacent bar + # if look short on the price, sell the stock more + # if look long on the price, sell the stock more if ( _pred_trend == self.TREND_SHORT and order.direction == order.SELL @@ -231,6 +234,9 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): ) order_list.append(_order) else: + # in the second of two adjacent bar + # if look short on the price, buy the stock more + # if look long on the price, sell the stock more if ( _pred_trend == self.TREND_SHORT and order.direction == order.BUY @@ -246,8 +252,8 @@ class SBBStrategyBase(RuleStrategy, OrderEnhancement): factor=order.factor, ) order_list.append(_order) - # print("DEBUG AMOUNT", _order_amount, self.trade_amount[(order.stock_id, order.direction)], _amount_trade_unit) - if self.trade_index % 2 == 1: + + if trade_index % 2 == 1: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend return order_list @@ -260,13 +266,11 @@ class SBBStrategyEMA(SBBStrategyBase): def __init__( self, - step_bar, - start_time=None, - end_time=None, - trade_exchange=None, - trade_order_list=[], + rely_trade_decision=[], instruments="csi300", freq="day", + level_infra={}, + common_infra={}, **kwargs, ): """ @@ -278,47 +282,49 @@ class SBBStrategyEMA(SBBStrategyBase): freq of EMA signal, by default "day" Note: `freq` may be different from `steb_bar` """ - super(SBBStrategyEMA, self).__init__(step_bar, start_time, end_time, trade_exchange, trade_order_list, **kwargs) if instruments is None: warnings.warn("`instruments` is not set, will load all stocks") self.instruments = "all" if isinstance(instruments, str): self.instruments = D.instruments(instruments) self.freq = freq + super(SBBStrategyEMA, self).__init__(rely_trade_decision, level_infra, common_infra, **kwargs) - def reset(self, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, **kwargs): + def _reset_signal(self): + trade_len = self.trade_calendar.get_trade_len() + fields = ["EMA($close, 10)-EMA($close, 20)"] + signal_start_time, _ = self.trade_calendar.get_calendar_time(trade_index=1, shift=1) + _, signal_end_time = self.trade_calendar.get_calendar_time(trade_index=trade_len, shift=1) + signal_df = D.features( + self.instruments, fields, start_time=signal_start_time, end_time=signal_end_time, freq=self.freq + ) + signal_df = convert_index_format(signal_df) + signal_df.columns = ["signal"] + self.signal = {} + for stock_id, stock_val in signal_df.groupby(level="instrument"): + self.signal[stock_id] = stock_val + + def reset_level_infra(self, level_infra): """ - Reset EMA signal for trading - - Parameters - ---------- - start_time : Union[str, pd.Timestamp], optional - start time for trading, also used to calculate the start time of EMA signal, by default None - - end_time : Union[str, pd.Timestamp], optional - end time for trading, also used to calculate the end time of EMA signal, by default None + reset level-shared infra + - After reset the trade_calendar, the signal will be changed """ - super(SBBStrategyEMA, self).reset(start_time=start_time, end_time=end_time, **kwargs) - if self.start_time and self.end_time and (start_time or end_time): - fields = ["EMA($close, 10)-EMA($close, 20)"] - signal_start_time, _ = self._get_calendar_time(trade_index=1, shift=1) - _, signal_end_time = self._get_calendar_time(trade_index=self.trade_len, shift=1) - signal_df = D.features( - self.instruments, fields, start_time=signal_start_time, end_time=signal_end_time, freq=self.freq - ) - signal_df = convert_index_format(signal_df) - signal_df.columns = ["signal"] - self.signal = {} - for stock_id, stock_val in signal_df.groupby(level="instrument"): - self.signal[stock_id] = stock_val + if not hasattr(self, "level_infra"): + self.level_infra = level_infra + else: + self.level_infra.update(level_infra) + + if "trade_calendar" in level_infra: + self.trade_calendar = level_infra.get("trade_calendar") + self._reset_signal() def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): if stock_id not in self.signal: return self.TREND_MID else: - _sample_signal = sample_feature( - self.signal[stock_id], pred_start_time, pred_end_time, fields="signal", method="last" + _sample_signal = resam_ts_data( + self.signal[stock_id]["signal"], pred_start_time, pred_end_time, method="last" ) if _sample_signal is None or _sample_signal.iloc[0] == 0: return self.TREND_MID diff --git a/qlib/data/data.py b/qlib/data/data.py index 91a21da9f..394c3271e 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -26,7 +26,7 @@ from ..utils import parse_field, read_bin, hash_args, normalize_cache_fields, co from .base import Feature from .cache import DiskDatasetCache, DiskExpressionCache from ..utils import Wrapper, init_instance_by_config, register_wrapper, get_module_by_module_path -from ..utils.sample import sample_calendar +from ..utils.resam import resam_calendar class CalendarProvider(abc.ABC): @@ -133,7 +133,7 @@ class CalendarProvider(abc.ABC): if freq_sam is None: return _calendar, _calendar_index else: - _calendar_sam = sample_calendar(_calendar, freq, freq_sam) + _calendar_sam = resam_calendar(_calendar, freq, freq_sam) _calendar_sam_index = {x: i for i, x in enumerate(_calendar_sam)} H["c"][flag] = _calendar_sam, _calendar_sam_index return _calendar_sam, _calendar_sam_index diff --git a/qlib/rl/env.py b/qlib/rl/env.py index fae17918d..2fef7a659 100644 --- a/qlib/rl/env.py +++ b/qlib/rl/env.py @@ -1,9 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from .interpreter import StateInterpreter, ActionInterpreter +from typing import Union +from .interpreter import StateInterpreter, ActionInterpreter from ..contrib.backtest.executor import BaseExecutor +from ..utils import init_instance_by_config class BaseRLEnv: @@ -52,35 +54,22 @@ class QlibIntRLEnv(QlibRLEnv): def __init__( self, executor: BaseExecutor, - state_interpreter: StateInterpreter, - action_interpreter: ActionInterpreter, - state_interpret_kwargs: dict = {}, - action_interpret_kwargs: dict = {}, + state_interpreter: Union[dict, StateInterpreter], + action_interpreter: Union[dict, ActionInterpreter], ): """ Parameters ---------- - state_interpreter : StateInterpreter + state_interpreter : Union[dict, StateInterpreter] interpretor that interprets the qlib execute result into rl env state. - action_interpreter : ActionInterpreter + + action_interpreter : Union[dict, ActionInterpreter] interpretor that interprets the rl agent action into qlib order list - state_interpret_kwargs : dict, optional - arguments may be used in `state_interpreter.interpret`, by default {} - such as the following arguments: - - trade exchange : Exchange - Exchange that can provide market info - action_interpret_kwargs: dict, optional - arguments may be used in `action_interpreter.interpret`, by default {} - such as the following arguments: - - trade_order_list : List[Order] - If the strategy is used to split order, it presents the trade order pool. """ super(QlibIntRLEnv, self).__init__(executor=executor) - self.state_interpreter = state_interpreter - self.action_interpreter = action_interpreter - self.state_interpret_kwargs = state_interpret_kwargs - self.action_interpret_kwargs = action_interpret_kwargs + self.state_interpreter = init_instance_by_config(state_interpreter) + self.action_interpreter = init_instance_by_config(action_interpreter) def step(self, action): """ @@ -96,11 +85,9 @@ class QlibIntRLEnv(QlibRLEnv): Returns ------- - env state to rl rl policy + env state to rl policy """ - _interpret_action = self.action_interpreter.interpret(action=action, **self.state_interpret_kwargs) + _interpret_action = self.action_interpreter.interpret(action=action) _execute_result = self.executor.execute(_interpret_action) - _interpret_state = self.state_interpreter.interpret( - execute_result=_execute_result, **self.action_interpret_kwargs - ) + _interpret_state = self.state_interpreter.interpret(execute_result=_execute_result) return _interpret_state diff --git a/qlib/rl/interpreter.py b/qlib/rl/interpreter.py index 3c94aac09..1e310e8ad 100644 --- a/qlib/rl/interpreter.py +++ b/qlib/rl/interpreter.py @@ -5,7 +5,6 @@ class BaseInterpreter: """Base Interpreter""" - @staticmethod def interpret(**kwargs): raise NotImplementedError("interpret is not implemented!") @@ -13,7 +12,6 @@ class BaseInterpreter: class ActionInterpreter(BaseInterpreter): """Action Interpreter that interpret rl agent action into qlib orders""" - @staticmethod def interpret(action, **kwargs): """interpret method @@ -34,7 +32,6 @@ class ActionInterpreter(BaseInterpreter): class StateInterpreter(BaseInterpreter): """State Interpreter that interpret execution result of qlib executor into rl env state""" - @staticmethod def interpret(execute_result, **kwargs): """interpret method diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 5534998e9..dad994303 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import copy import pandas as pd from typing import List, Union @@ -9,16 +10,70 @@ from ..model.base import BaseModel from ..data.dataset import DatasetH from ..data.dataset.utils import convert_index_format from ..contrib.backtest.order import Order -from ..contrib.backtest.executor import BaseTradeCalendar from ..rl.interpreter import ActionInterpreter, StateInterpreter +from ..utils import init_instance_by_config -class BaseStrategy(BaseTradeCalendar): +class BaseStrategy: """Base strategy for trading""" - def generate_order_list(self, execute_state): - """Generate order list in each trading bar""" - raise NotImplementedError("generator_order_list is not implemented!") + def __init__( + self, + rely_trade_decision: object = None, + level_infra: dict = {}, + common_infra: dict = {}, + ): + """ + Parameters + ---------- + rely_trade_decision : object, optional + the high-level trade decison on which the startegy rely, and it will be traded in [start_time , end_time] , by default None + - If the strategy is used to split trade decison, it will be used + - If the strategy is used for portfolio management, it can be ignored + level_infra : dict, optional + level shared infrastructure for backtesting, including trade_calendar + common_infra : dict, optional + common infrastructure for backtesting, including trade_account, trade_exchange, .etc + """ + + self.reset(level_infra=level_infra, common_infra=common_infra, rely_trade_decision=rely_trade_decision) + + def reset_level_infra(self, level_infra): + if not hasattr(self, "level_infra"): + self.level_infra = level_infra + else: + self.level_infra.update(level_infra) + + if "trade_calendar" in level_infra: + self.trade_calendar = level_infra.get("trade_calendar") + + def reset_common_infra(self, common_infra): + if not hasattr(self, "common_infra"): + self.common_infra = common_infra + else: + self.common_infra.update(common_infra) + + if "trade_account" in common_infra: + self.trade_position = common_infra.get("trade_account").current + + def reset(self, level_infra: dict = None, common_infra: dict = None, rely_trade_decision=None, **kwargs): + """ + - reset `level_infra`, used to reset trade_calendar, .etc + - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc + - reset `rely_trade_decision`, used to make split decison + """ + if level_infra is not None: + self.reset_level_infra(level_infra) + + if common_infra is not None: + self.reset_common_infra(common_infra) + + if rely_trade_decision is not None: + self.rely_trade_decision = rely_trade_decision + + def generate_trade_decision(self, execute_state): + """Generate trade decision in each trading bar""" + raise NotImplementedError("generate_trade_decision is not implemented!") class RuleStrategy(BaseStrategy): @@ -32,11 +87,11 @@ class ModelStrategy(BaseStrategy): def __init__( self, - step_bar: str, model: BaseModel, dataset: DatasetH, - start_time: Union[str, pd.Timestamp] = None, - end_time: Union[str, pd.Timestamp] = None, + rely_trade_decision: object = None, + level_infra: dict = {}, + common_infra: dict = {}, **kwargs, ): """ @@ -49,11 +104,10 @@ class ModelStrategy(BaseStrategy): kwargs : dict arguments that will be passed into `reset` method """ + super(ModelStrategy, self).__init__(rely_trade_decision, level_infra, common_infra, **kwargs) self.model = model self.dataset = dataset self.pred_scores = convert_index_format(self.model.predict(dataset), level="datetime") - # pred_score_dates = self.pred_scores.index.get_level_values(level="datetime") - super(ModelStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) def _update_model(self): """ @@ -70,10 +124,10 @@ class RLStrategy(BaseStrategy): def __init__( self, - step_bar: str, policy, - start_time: Union[str, pd.Timestamp] = None, - end_time: Union[str, pd.Timestamp] = None, + rely_trade_decision: object = None, + level_infra: dict = {}, + common_infra: dict = {}, **kwargs, ): """ @@ -82,7 +136,7 @@ class RLStrategy(BaseStrategy): policy : RL policy for generate action """ - super(RLStrategy, self).__init__(step_bar, start_time, end_time, **kwargs) + super(RLStrategy, self).__init__(rely_trade_decision, level_infra, common_infra, **kwargs) self.policy = policy @@ -91,14 +145,12 @@ class RLIntStrategy(RLStrategy): def __init__( self, - step_bar: str, policy, state_interpreter: StateInterpreter, action_interpreter: ActionInterpreter, - start_time: Union[str, pd.Timestamp] = None, - end_time: Union[str, pd.Timestamp] = None, - state_interpret_kwargs: dict = {}, - action_interpret_kwargs: dict = {}, + rely_trade_decision: object = None, + level_infra: dict = {}, + common_infra: dict = {}, **kwargs, ): """ @@ -112,49 +164,16 @@ class RLIntStrategy(RLStrategy): start time of trading, by default None end_time : Union[str, pd.Timestamp], optional end time of trading, by default None - state_interpret_kwargs : dict, optional - arguments may be used in `state_interpreter.interpret`, by default {} - such as the following arguments: - - trade exchange : Exchange - Exchange that can provide market info - action_interpret_kwargs: dict, optional - arguments may be used in `action_interpreter.interpret`, by default {} - such as the following arguments: - - trade_order_list : List[Order] - If the strategy is used to split order, it presents the trade order pool. """ - super(RLIntStrategy, self).__init__(step_bar, policy, start_time, end_time, **kwargs) + super(RLIntStrategy, self).__init__(policy, rely_trade_decision, level_infra, common_infra, **kwargs) self.policy = policy - self.action_interpreter = action_interpreter - self.state_interpreter = state_interpreter - self.state_interpret_kwargs = state_interpret_kwargs - self.action_interpret_kwargs = action_interpret_kwargs + self.state_interpreter = init_instance_by_config(state_interpreter) + self.action_interpreter = init_instance_by_config(action_interpreter) - def generate_order_list(self, execute_state): + def generate_trade_decision(self, execute_state): super(RLStrategy, self).step() - _interpret_state = self.state_interpretor.interpret( - execute_result=execute_state, **self.action_interpret_kwargs - ) + _interpret_state = self.state_interpretor.interpret(execute_result=execute_state) _policy_action = self.policy.step(_interpret_state) - _order_list = self.action_interpreter.interpret(action=_policy_action, **self.state_interpret_kwargs) + _order_list = self.action_interpreter.interpret(action=_policy_action) return _order_list - - -class OrderEnhancement: - """ - Order enhancement for strategy - - If the strategy is used to split orders, the enhancement should be inherited - - If the strategy is used for portfolio management, the enhancement can be ignored - """ - - def reset(self, trade_order_list: List[Order] = None): - """reset trade orders for split strategy - - Parameters - ---------- - trade_order_list for split strategy: List[Order], optional - trading orders , by default None - """ - if trade_order_list is not None: - self.trade_order_list = trade_order_list diff --git a/qlib/utils/sample.py b/qlib/utils/resam.py similarity index 75% rename from qlib/utils/sample.py rename to qlib/utils/resam.py index 9f67d4981..8933b3a82 100644 --- a/qlib/utils/sample.py +++ b/qlib/utils/resam.py @@ -1,8 +1,13 @@ import re +import datetime + import numpy as np import pandas as pd from typing import Tuple, List, Union, Optional, Callable +from . import lazy_sort_index +from ..config import C + def parse_freq(freq: str) -> Tuple[int, str]: """ @@ -50,9 +55,10 @@ def parse_freq(freq: str) -> Tuple[int, str]: return _count, _freq_format_dict[_freq] -def sample_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np.ndarray: +def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np.ndarray: """ - Sample the calendar with frequency freq_raw into the calendar with frequency freq_sam + Resample the calendar with frequency freq_raw into the calendar with frequency freq_sam + Assumption: The fix length (240) of the calendar in each day. Parameters ---------- @@ -72,24 +78,36 @@ def sample_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> n sam_count, freq_sam = parse_freq(freq_sam) if not len(calendar_raw): return calendar_raw + + # if freq_sam is xminute, divide each trading day into several bars evenly if freq_sam == "minute": - def cal_next_sam_minute(x, sam_minutes): - hour = x.hour - minute = x.minute - if (hour == 9 and minute >= 30) or (9 < hour < 11) or (hour == 11 and minute < 30): - minute_index = (hour - 9) * 60 + minute - 30 - elif 13 <= hour < 15: - minute_index = (hour - 13) * 60 + minute + 120 + def cal_sam_minute(x, sam_minutes): + day_time = pd.Timestamp(x.date()) + shift = C.min_data_shift + # shift represents the shift minute the market time + # - open time of stock market is [9:30 - shift*pd.Timedelta(minutes=1)] + # - mid close time of stock market is [11:29 - shift*pd.Timedelta(minutes=1)] + # - mid open time of stock market is [13:30 - shift*pd.Timedelta(minutes=1)] + # - close time of stock market is [14:59 - shift*pd.Timedelta(minutes=1)] + open_time = day_time + pd.Timedelta(hours=9, minutes=30) - shift * pd.Timedelta(minutes=1) + mid_close_time = day_time + pd.Timedelta(hours=11, minutes=29) - shift * pd.Timedelta(minutes=1) + mid_open_time = day_time + pd.Timedelta(hours=13, minutes=30) - shift * pd.Timedelta(minutes=1) + close_time = day_time + pd.Timedelta(hours=14, minutes=59) - shift * pd.Timedelta(minutes=1) + + if open_time <= x <= mid_close_time: + minute_index = (x - open_time).seconds // 60 + elif mid_open_time <= x <= close_time: + minute_index = (x - mid_open_time).seconds // 60 + 120 else: - raise ValueError("calendar hour must be in [9, 11] or [13, 15]") + raise ValueError("datetime of calendar is out of range") minute_index = minute_index // sam_minutes * sam_minutes if 0 <= minute_index < 120: - return 9 + (minute_index + 30) // 60, (minute_index + 30) % 60 + return open_time + minute_index * pd.Timedelta(minutes=1) elif 120 <= minute_index < 240: - return 13 + (minute_index - 120) // 60, (minute_index - 120) % 60 + return mid_open_time + (minute_index - 120) * pd.Timedelta(minutes=1) else: raise ValueError("calendar minute_index error") @@ -98,14 +116,10 @@ def sample_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> n else: if raw_count > sam_count: raise ValueError("raw freq must be higher than sampling freq") - _calendar_minute = np.unique( - list( - map(lambda x: pd.Timestamp(x.year, x.month, x.day, *cal_next_sam_minute(x, sam_count), 0), calendar_raw) - ) - ) - if calendar_raw[0] > _calendar_minute[0]: - _calendar_minute[0] = calendar_raw[0] + _calendar_minute = np.unique(list(map(lambda x: cal_sam_minute(x, sam_count), calendar_raw))) return _calendar_minute + + # else, convert the raw calendar into day calendar, and divide the whole calendar into several bars evenly else: _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 0, 0, 0), calendar_raw))) if freq_sam == "day": @@ -124,14 +138,14 @@ def sample_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> n raise ValueError("sampling freq must be xmin, xd, xw, xm") -def get_sample_freq_calendar( +def get_resam_calendar( start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, freq: str = "day", future: bool = False, ) -> Tuple[np.ndarray, str, Optional[str]]: """ - Get the calendar with frequency freq. + Get the resampled calendar with frequency freq. - If the calendar with the raw frequency freq exists, return it directly @@ -186,16 +200,15 @@ def get_sample_freq_calendar( return _calendar, freq, freq_sam -def sample_feature( - feature: Union[pd.DataFrame, pd.Series], +def resam_ts_data( + ts_feature: Union[pd.DataFrame, pd.Series], start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - fields: Union[str, List[str]] = None, method: Union[str, Callable] = "last", method_kwargs: dict = {}, ): """ - Sample value from pandas DataFrame or Series for each stock + Resample value from time-series data - If `feature` has MultiIndex[instrument, datetime], apply the `method` to each instruemnt data with datetime in [start_time, end_time] Example: @@ -217,7 +230,7 @@ def sample_feature( 2010-01-12 2788.688232 164587.937500 2010-01-13 2790.604004 145460.453125 - print(sample_feature(feature, start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + print(resam_ts_data(feature, start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) $close $volume instrument SH600000 87.433578 28117442.0 @@ -236,25 +249,23 @@ def sample_feature( 2010-01-07 83.788803 20813402.0 2010-01-08 84.730675 16044853.0 - print(sample_feature(feature, start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + print(resam_ts_data(feature, start_time="2010-01-04", end_time="2010-01-05", method="last")) $close 87.433578 $volume 28117442.0 - print(sample_feature(feature, start_time="2010-01-04", end_time="2010-01-05", fields="$close", method="last")) + print(resam_ts_data(feature['$close'], start_time="2010-01-04", end_time="2010-01-05", method="last")) 87.433578 Parameters ---------- feature : Union[pd.DataFrame, pd.Series] - Raw feature to be sampled + Raw time-series feature to be resampled start_time : Union[str, pd.Timestamp], optional start sampling time, by default None end_time : Union[str, pd.Timestamp], optional end sampling time, by default None - fields : Union[str, List[str]], optional - column names, it's ignored when sample pd.Series data, by default None(all columns) method : Union[str, Callable], optional sample method, apply method function to each stock series data, by default "last" - If type(method) is str, it should be an attribute of SeriesGroupBy or DataFrameGroupby, and run feature.groupby @@ -264,24 +275,19 @@ def sample_feature( Returns ------- - The Sampled DataFrame/Series/Value + The Resampled DataFrame/Series/Value """ selector_datetime = slice(start_time, end_time) - if fields is None: - fields = slice(None) from ..data.dataset.utils import get_level_index + feature = lazy_sort_index(ts_feature) datetime_level = get_level_index(feature, level="datetime") == 0 - if isinstance(feature, pd.Series): - feature = feature.loc[selector_datetime] if datetime_level else feature.loc[(slice(None), selector_datetime)] - elif isinstance(feature, pd.DataFrame): - feature = ( - feature.loc[selector_datetime, fields] - if datetime_level - else feature.loc[(slice(None), selector_datetime), fields] - ) + if datetime_level: + feature = feature.loc[selector_datetime] + else: + feature = feature.loc[(slice(None), selector_datetime)] if feature.empty: return None if isinstance(feature.index, pd.MultiIndex): @@ -296,5 +302,4 @@ def sample_feature( return method_func(feature, **method_kwargs) elif isinstance(method, str): return getattr(feature, method)(**method_kwargs) - return feature diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 6bb6341f0..02a282035 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -15,7 +15,7 @@ from ..data.dataset.handler import DataHandlerLP from ..utils import init_instance_by_config, get_module_by_module_path from ..log import get_module_logger from ..utils import flatten_dict -from ..utils.sample import parse_freq +from ..utils.resam import parse_freq from ..strategy.base import BaseStrategy from ..contrib.eva.alpha import calc_ic, calc_long_short_return @@ -291,8 +291,8 @@ class PortAnaRecord(RecordTemp): """ config["strategy"] : dict define the strategy class as well as the kwargs. - config["env"] : dict - define the env class as well as the kwargs. + config["executor"] : dict + define the executor class as well as the kwargs. config["backtest"] : dict define the backtest kwargs. risk_analysis_freq : int @@ -301,24 +301,26 @@ class PortAnaRecord(RecordTemp): super().__init__(recorder=recorder, **kwargs) self.strategy_config = config["strategy"] - self.env_config = config["env"] + self.executor_config = config["executor"] self.backtest_config = config["backtest"] _count, _freq = parse_freq(risk_analysis_freq) self.risk_analysis_freq = f"{_count}{_freq}" - self.report_freq = self._get_report_freq(self.env_config) + self.report_freq = self._get_report_freq(self.executor_config) - def _get_report_freq(self, env_config): + def _get_report_freq(self, executor_config): ret_freq = [] - if env_config["kwargs"].get("generate_report", False): - _count, _freq = parse_freq(env_config["kwargs"]["step_bar"]) + if executor_config["kwargs"].get("generate_report", False): + _count, _freq = parse_freq(executor_config["kwargs"]["step_bar"]) ret_freq.append(f"{_count}{_freq}") - if "sub_env" in env_config["kwargs"]: - ret_freq.extend(self._get_report_freq(env_config["kwargs"]["sub_env"])) + if "sub_env" in executor_config["kwargs"]: + ret_freq.extend(self._get_report_freq(executor_config["kwargs"]["sub_env"])) return ret_freq def generate(self, **kwargs): # custom strategy and get backtest - report_dict = normal_backtest(env=self.env_config, strategy=self.strategy_config, **self.backtest_config) + report_dict = normal_backtest( + executor=self.executor_config, strategy=self.strategy_config, **self.backtest_config + ) for report_freq, (report_normal, positions_normal) in report_dict.items(): self.recorder.save_objects( **{f"report_normal_{report_freq}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path() From 2ad61f12b3e08b1fbf736eca6c9abed20b8341f6 Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 27 May 2021 17:03:53 +0800 Subject: [PATCH 026/187] rename var in backtest --- examples/multi_level_trading/workflow.py | 11 +- examples/rolling_process_data/workflow.py | 1 - qlib/contrib/backtest/backtest.py | 12 +- qlib/contrib/backtest/executor.py | 128 +++++++++++----------- qlib/contrib/backtest/utils.py | 18 +-- qlib/contrib/strategy/model_strategy.py | 20 ++-- qlib/contrib/strategy/rule_strategy.py | 72 ++++++------ qlib/rl/env.py | 9 +- qlib/strategy/base.py | 58 +++++----- qlib/workflow/record_temp.py | 2 +- 10 files changed, 165 insertions(+), 166 deletions(-) diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index 390044480..ea11d4e7f 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -61,24 +61,24 @@ class MultiLevelTradingWorkflow: } trade_start_time = "2017-01-01" - trade_end_time = "2020-08-01" + trade_end_time = "2017-02-01" port_analysis_config = { "executor": { "class": "SplitExecutor", "module_path": "qlib.contrib.backtest.executor", "kwargs": { - "step_bar": "week", - "sub_executor": { + "time_per_step": "week", + "inner_executor": { "class": "SimulatorExecutor", "module_path": "qlib.contrib.backtest.executor", "kwargs": { - "step_bar": "day", + "time_per_step": "day", "verbose": True, "generate_report": True, }, }, - "sub_strategy": { + "inner_strategy": { "class": "SBBStrategyEMA", "module_path": "qlib.contrib.strategy.rule_strategy", "kwargs": { @@ -107,7 +107,6 @@ class MultiLevelTradingWorkflow: def _init_qlib(self): """initialize qlib""" - # use yahoo_cn_1min data provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir if not exists_qlib_data(provider_uri): print(f"Qlib data is not found in {provider_uri}") diff --git a/examples/rolling_process_data/workflow.py b/examples/rolling_process_data/workflow.py index 5757aaa87..048253f0d 100644 --- a/examples/rolling_process_data/workflow.py +++ b/examples/rolling_process_data/workflow.py @@ -23,7 +23,6 @@ class RollingDataWorkflow: def _init_qlib(self): """initialize qlib""" - # use yahoo_cn_1min data provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir if not exists_qlib_data(provider_uri): print(f"Qlib data is not found in {provider_uri}") diff --git a/qlib/contrib/backtest/backtest.py b/qlib/contrib/backtest/backtest.py index 33c73de7a..1f0d2ac38 100644 --- a/qlib/contrib/backtest/backtest.py +++ b/qlib/contrib/backtest/backtest.py @@ -8,10 +8,10 @@ def backtest(start_time, end_time, trade_strategy, trade_executor): level_infra = trade_executor.get_level_infra() trade_strategy.reset(level_infra=level_infra) - sub_execute_state = trade_executor.get_init_state() + _execute_result = None while not trade_executor.finished(): - sub_trade_decision = trade_strategy.generate_trade_decision(sub_execute_state) - sub_execute_state = trade_executor.execute(sub_trade_decision) + _trade_decision = trade_strategy.generate_trade_decision(_execute_result) + _execute_result = trade_executor.execute(_trade_decision) return trade_executor.get_report() @@ -22,9 +22,9 @@ def collect_data(start_time, end_time, trade_strategy, trade_executor): level_infra = trade_executor.get_level_infra() trade_strategy.reset(level_infra=level_infra) - sub_execute_state = trade_executor.get_init_state() + _execute_result = None while not trade_executor.finished(): - sub_trade_decision = trade_strategy.generate_trade_decision(sub_execute_state) - sub_execute_state = yield from trade_executor.collect_data(sub_trade_decision) + _trade_decision = trade_strategy.generate_trade_decision(_execute_result) + _execute_result = yield from trade_executor.collect_data(_trade_decision) return trade_executor.get_report() diff --git a/qlib/contrib/backtest/executor.py b/qlib/contrib/backtest/executor.py index 8a57d2986..c896f802d 100644 --- a/qlib/contrib/backtest/executor.py +++ b/qlib/contrib/backtest/executor.py @@ -8,7 +8,6 @@ from ...utils.resam import parse_freq from .order import Order -from .account import Account from .exchange import Exchange from .utils import TradeCalendarManager @@ -18,7 +17,7 @@ class BaseExecutor: def __init__( self, - step_bar: str, + time_per_step: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, generate_report: bool = False, @@ -30,6 +29,8 @@ class BaseExecutor: """ Parameters ---------- + time_per_step : str + trade time per trading step, used for genreate trade calendar generate_report : bool, optional whether to generate report, by default False verbose : bool, optional @@ -46,7 +47,7 @@ class BaseExecutor: exchange that provides market info """ - self.step_bar = step_bar + self.time_per_step = time_per_step self.generate_report = generate_report self.verbose = verbose self.track_data = track_data @@ -64,7 +65,7 @@ class BaseExecutor: if "trade_account" in common_infra: self.trade_account = copy.copy(common_infra.get("trade_account")) - self.trade_account.reset(freq=self.step_bar, init_report=True) + self.trade_account.reset(freq=self.time_per_step, init_report=True) def reset(self, track_data: bool = None, common_infra: dict = None, **kwargs): """ @@ -76,19 +77,19 @@ class BaseExecutor: if track_data is not None: self.track_data = track_data - if common_infra is not None: - self.reset_common_infra(common_infra) - if "start_time" in kwargs or "end_time" in kwargs: start_time = kwargs.get("start_time") end_time = kwargs.get("end_time") - self.trade_calendar = TradeCalendarManager(step_bar=self.step_bar, start_time=start_time, end_time=end_time) + self.calendar = TradeCalendarManager(freq=self.time_per_step, start_time=start_time, end_time=end_time) + + if common_infra is not None: + self.reset_common_infra(common_infra) def get_level_infra(self): - return {"trade_calendar": self.trade_calendar} + return {"calendar": self.calendar} def finished(self): - return self.trade_calendar.finished() + return self.calendar.finished() def execute(self, trade_decision): """execute the trade decision and return the executed result @@ -99,8 +100,8 @@ class BaseExecutor: Returns ---------- - executed state : List[Tuple[Order, float, float, float]] - - Each element in the list represents (order, trade value, trade cost, trade price) + execute_result : List[object] + the executed result for trade decison """ raise NotImplementedError("execute is not implemented!") @@ -109,9 +110,6 @@ class BaseExecutor: yield trade_decision return self.execute(trade_decision) - def get_init_state(self): - raise NotImplementedError("get_init_state in not implemeted!") - def get_trade_account(self): raise NotImplementedError("get_trade_account is not implemented!") @@ -124,9 +122,9 @@ class SplitExecutor(BaseExecutor): def __init__( self, - step_bar: str, - sub_executor: Union[BaseExecutor, dict], - sub_strategy: Union[BaseStrategy, dict], + time_per_step: str, + inner_executor: Union[BaseExecutor, dict], + inner_strategy: Union[BaseStrategy, dict], start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, trade_exchange: Exchange = None, @@ -139,22 +137,24 @@ class SplitExecutor(BaseExecutor): """ Parameters ---------- - sub_executor : BaseExecutor + inner_executor : BaseExecutor trading env in each trading bar. - sub_strategy : BaseStrategy + inner_strategy : BaseStrategy trading strategy in each trading bar trade_exchange : Exchange exchange that provides market info, used to generate report - If generate_report is None, trade_exchange will be ignored - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra """ - self.sub_executor = init_instance_by_config(sub_executor, common_infra=common_infra, accept_types=BaseExecutor) - self.sub_strategy = init_instance_by_config( - sub_strategy, common_infra=common_infra, accept_types=self.BaseStrategy + self.inner_executor = init_instance_by_config( + inner_executor, common_infra=common_infra, accept_types=BaseExecutor + ) + self.inner_strategy = init_instance_by_config( + inner_strategy, common_infra=common_infra, accept_types=self.BaseStrategy ) super(SplitExecutor, self).__init__( - step_bar=step_bar, + time_per_step=time_per_step, start_time=start_time, end_time=end_time, generate_report=generate_report, @@ -171,29 +171,26 @@ class SplitExecutor(BaseExecutor): """ reset infrastructure for trading - reset trade_exchange - - reset substrategy and subexecutor common infra + - reset inner_strategyand inner_executor common infra """ super(SplitExecutor, self).reset_common_infra(common_infra) if self.generate_report and "trade_exchange" in common_infra: self.trade_exchange = common_infra.get("trade_exchange") - self.sub_executor.reset_common_infra(common_infra) - self.sub_strategy.reset_common_infra(common_infra) - - def get_init_state(self): - return [] + self.inner_executor.reset_common_infra(common_infra) + self.inner_strategy.reset_common_infra(common_infra) def _init_sub_trading(self, trade_decision): - trade_index = self.trade_calendar.get_trade_index() - trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) - self.sub_executor.reset(start_time=trade_start_time, end_time=trade_end_time) - sub_level_infra = self.sub_executor.get_level_infra() - self.sub_strategy.reset(level_infra=sub_level_infra, rely_trade_decision=trade_decision) + trade_index = self.calendar.get_trade_index() + trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + self.inner_executor.reset(start_time=trade_start_time, end_time=trade_end_time) + sub_level_infra = self.inner_executor.get_level_infra() + self.inner_strategy.reset(level_infra=sub_level_infra, outer_trade_decision=trade_decision) def _update_trade_account(self): - trade_index = self.trade_calendar.get_trade_index() - trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) + trade_index = self.calendar.get_trade_index() + trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) self.trade_account.update_bar_count() if self.generate_report: self.trade_account.update_bar_report( @@ -203,41 +200,41 @@ class SplitExecutor(BaseExecutor): ) def execute(self, trade_decision): - self.trade_calendar.step() + self.calendar.step() self._init_sub_trading(trade_decision) - execute_state = [] - sub_execute_state = self.sub_executor.get_init_state() - while not self.sub_executor.finished(): - sub_trade_decison = self.sub_strategy.generate_trade_decision(sub_execute_state) - sub_execute_state = self.sub_executor.execute(trade_decision=sub_trade_decison) - execute_state.extend(sub_execute_state) + execute_result = [] + _inner_execute_result = None + while not self.inner_executor.finished(): + _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + _inner_execute_result = self.inner_executor.execute(trade_decision=_inner_trade_decision) + execute_result.extend(_inner_execute_result) if hasattr(self, "trade_account"): self._update_trade_account() - return execute_state + return execute_result def collect_data(self, trade_decision): if self.track_data: yield trade_decision - self.trade_calendar.step() + self.calendar.step() self._init_sub_trading(trade_decision) - execute_state = [] - sub_execute_state = self.sub_executor.get_init_state() - while not self.sub_executor.finished(): - sub_trade_decison = self.sub_strategy.generate_trade_decision(sub_execute_state) - sub_execute_state = yield from self.sub_executor.collect_data(trade_decision=sub_trade_decison) - execute_state.extend(sub_execute_state) + execute_result = [] + _inner_execute_result = None + while not self.inner_executor.finished(): + _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) + execute_result.extend(_inner_execute_result) if hasattr(self, "trade_account"): self._update_trade_account() - return execute_state + return execute_result def get_report(self): - sub_env_report_dict = self.sub_executor.get_report() + sub_env_report_dict = self.inner_executor.get_report() if self.generate_report: _report = self.trade_account.report.generate_report_dataframe() _positions = self.trade_account.get_positions() - _count, _freq = parse_freq(self.step_bar) + _count, _freq = parse_freq(self.time_per_step) sub_env_report_dict.update({f"{_count}{_freq}": (_report, _positions)}) return sub_env_report_dict @@ -245,7 +242,7 @@ class SplitExecutor(BaseExecutor): class SimulatorExecutor(BaseExecutor): def __init__( self, - step_bar: str, + time_per_step: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, trade_exchange: Exchange = None, @@ -263,7 +260,7 @@ class SimulatorExecutor(BaseExecutor): - If `trade_exchange` is None, self.trade_exchange will be set with common_infra """ super(SimulatorExecutor, self).__init__( - step_bar=step_bar, + time_per_step=time_per_step, start_time=start_time, end_time=end_time, generate_report=generate_report, @@ -284,21 +281,18 @@ class SimulatorExecutor(BaseExecutor): if "trade_exchange" in common_infra: self.trade_exchange = common_infra.get("trade_exchange") - def get_init_state(self): - return [] - def execute(self, trade_decision): - self.trade_calendar.step() - trade_index = self.trade_calendar.get_trade_index() - trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) - execute_state = [] + self.calendar.step() + trade_index = self.calendar.get_trade_index() + trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + execute_result = [] for order in trade_decision: if self.trade_exchange.check_order(order) is True: # execute the order trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( order, trade_account=self.trade_account ) - execute_state.append((order, trade_val, trade_cost, trade_price)) + execute_result.append((order, trade_val, trade_cost, trade_price)) if self.verbose: if order.direction == Order.SELL: # sell print( @@ -340,13 +334,13 @@ class SimulatorExecutor(BaseExecutor): trade_exchange=self.trade_exchange, ) - return execute_state + return execute_result def get_report(self): if self.generate_report: _report = self.trade_account.report.generate_report_dataframe() _positions = self.trade_account.get_positions() - _count, _freq = parse_freq(self.step_bar) + _count, _freq = parse_freq(self.time_per_step) return {f"{_count}{_freq}": (_report, _positions)} else: return {} diff --git a/qlib/contrib/backtest/utils.py b/qlib/contrib/backtest/utils.py index 1a4173887..622816753 100644 --- a/qlib/contrib/backtest/utils.py +++ b/qlib/contrib/backtest/utils.py @@ -15,13 +15,13 @@ class TradeCalendarManager: """ def __init__( - self, step_bar: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None + self, freq: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None ): """ Parameters ---------- - step_bar : str - frequency of each trading calendar + freq : str + frequency of trading calendar, also trade time per trading step start_time : Union[str, pd.Timestamp], optional closed start of the trading calendar, by default None If `start_time` is None, it must be reset before trading. @@ -29,14 +29,14 @@ class TradeCalendarManager: closed end of the trade time range, by default None If `end_time` is None, it must be reset before trading. """ - self.step_bar = step_bar + self.freq = freq self.start_time = pd.Timestamp(start_time) if start_time else None self.end_time = pd.Timestamp(start_time) if start_time else None - self._init_trade_calendar(step_bar=step_bar, start_time=start_time, end_time=end_time) + self._init_trade_calendar(freq=freq, start_time=start_time, end_time=end_time) - def _init_trade_calendar(self, step_bar, start_time, end_time): + def _init_trade_calendar(self, freq, start_time, end_time): """reset trade calendar""" - _calendar, freq, freq_sam = get_resam_calendar(freq=step_bar) + _calendar, freq, freq_sam = get_resam_calendar(freq=freq) self.calendar = _calendar _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) self.start_index = _start_index @@ -52,8 +52,8 @@ class TradeCalendarManager: raise RuntimeError(f"The calendar is finished, please reset it if you want to call it!") self.trade_index = self.trade_index + 1 - def get_step_bar(self): - return self.step_bar + def get_freq(self): + return self.freq def get_trade_len(self): return self.trade_len diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 336cfa534..d797729be 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -81,10 +81,10 @@ class TopkDropoutStrategy(ModelStrategy): # It will use 95% amoutn of your total value by default return self.risk_degree - def generate_trade_decision(self, execute_state): - trade_index = self.trade_calendar.get_trade_index() - trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) - pred_start_time, pred_end_time = self.trade_calendar.get_calendar_time(trade_index, shift=1) + def generate_trade_decision(self, execute_result=None): + trade_index = self.calendar.get_trade_index() + trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + pred_start_time, pred_end_time = self.calendar.get_calendar_time(trade_index, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: return [] @@ -179,8 +179,8 @@ class TopkDropoutStrategy(ModelStrategy): continue if code in sell: # check hold limit - step_bar = self.trade_calendar.get_step_bar() - if current_temp.get_stock_count(code, bar=step_bar) < self.hold_thresh: + time_per_step = self.calendar.get_freq() + if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh: continue # sell order sell_amount = current_temp.get_stock_amount(code=code) @@ -292,7 +292,7 @@ class WeightStrategyBase(ModelStrategy): """ raise NotImplementedError() - def generate_trade_decision(self, execute_state): + def generate_trade_decision(self, execute_result=None): """ Parameters ----------- @@ -307,9 +307,9 @@ class WeightStrategyBase(ModelStrategy): """ # generate_trade_decision # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list - trade_index = self.trade_calendar.get_trade_index() - trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) - pred_start_time, pred_end_time = self.trade_calendar.get_calendar_time(trade_index, shift=1) + trade_index = self.calendar.get_trade_index() + trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + pred_start_time, pred_end_time = self.calendar.get_calendar_time(trade_index, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: return [] diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 2265a9dc5..1f42c451c 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -24,31 +24,31 @@ class TWAPStrategy(RuleStrategy): if "trade_exchange" in common_infra: self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, rely_trade_decision: object = None, **kwargs): + def reset(self, outer_trade_decision: object = None, **kwargs): """ Parameters ---------- - rely_trade_decision : object, optional + outer_trade_decision : object, optional """ - super(TWAPStrategy, self).reset(rely_trade_decision=rely_trade_decision, common_infra=common_infra, **kwargs) - if rely_trade_decision is not None: + super(TWAPStrategy, self).reset(outer_trade_decision=outer_trade_decision, common_infra=common_infra, **kwargs) + if outer_trade_decision is not None: self.trade_amount = {} - for order in rely_trade_decision: + for order in outer_trade_decision: self.trade_amount[(order.stock_id, order.direction)] = order.amount - def generate_trade_decision(self, execute_state): + def generate_trade_decision(self, execute_result=None): # update the order amount - trade_info = execute_state - for order, _, _, _ in trade_info: - self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount + if execute_result is not None: + for order, _, _, _ in execute_result: + self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - trade_index = self.trade_calendar.get_trade_index() - trade_len = self.trade_calendar.get_trade_len() - trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) + trade_index = self.calendar.get_trade_index() + trade_len = self.calendar.get_trade_len() + trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) order_list = [] - for order in self.rely_trade_decision: + for order in self.outer_trade_decision: if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): @@ -104,41 +104,41 @@ class SBBStrategyBase(RuleStrategy): if "trade_exchange" in common_infra: self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, rely_trade_decision=None, **kwargs): + def reset(self, outer_trade_decision=None, **kwargs): """ Parameters ---------- - rely_trade_decision : object, optional + outer_trade_decision : object, optional common_infra : None, optional common infrastructure for backtesting, by default None - It should include `trade_account`, used to get position - It should include `trade_exchange`, used to provide market info """ - super(SBBStrategyBase, self).reset(rely_trade_decision=rely_trade_decision, **kwargs) - if rely_trade_decision is not None: + super(SBBStrategyBase, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) + if outer_trade_decision is not None: self.trade_trend = {} self.trade_amount = {} # init the trade amount of order and predicted trade trend - for order in rely_trade_decision: + for order in outer_trade_decision: self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID self.trade_amount[(order.stock_id, order.direction)] = order.amount def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") - def generate_trade_decision(self, execute_state): + def generate_trade_decision(self, execute_result=None): # update the order amount - trade_info = execute_state - for order, _, _, _ in trade_info: - self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - trade_index = self.trade_calendar.get_trade_index() - trade_len = self.trade_calendar.get_trade_len() - trade_start_time, trade_end_time = self.trade_calendar.get_calendar_time(trade_index) - pred_start_time, pred_end_time = self.trade_calendar.get_calendar_time(trade_index, shift=1) + if execute_result is not None: + for order, _, _, _ in execute_result: + self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount + trade_index = self.calendar.get_trade_index() + trade_len = self.calendar.get_trade_len() + trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + pred_start_time, pred_end_time = self.calendar.get_calendar_time(trade_index, shift=1) order_list = [] - # for each order in in self.rely_trade_decision - for order in self.rely_trade_decision: + # for each order in in self.outer_trade_decision + for order in self.outer_trade_decision: # predict the price trend if trade_index % 2 == 1: _pred_trend = self._pred_price_trend(order.stock_id, pred_start_time, pred_end_time) @@ -266,7 +266,7 @@ class SBBStrategyEMA(SBBStrategyBase): def __init__( self, - rely_trade_decision=[], + outer_trade_decision=[], instruments="csi300", freq="day", level_infra={}, @@ -288,13 +288,13 @@ class SBBStrategyEMA(SBBStrategyBase): if isinstance(instruments, str): self.instruments = D.instruments(instruments) self.freq = freq - super(SBBStrategyEMA, self).__init__(rely_trade_decision, level_infra, common_infra, **kwargs) + super(SBBStrategyEMA, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs) def _reset_signal(self): - trade_len = self.trade_calendar.get_trade_len() + trade_len = self.calendar.get_trade_len() fields = ["EMA($close, 10)-EMA($close, 20)"] - signal_start_time, _ = self.trade_calendar.get_calendar_time(trade_index=1, shift=1) - _, signal_end_time = self.trade_calendar.get_calendar_time(trade_index=trade_len, shift=1) + signal_start_time, _ = self.calendar.get_calendar_time(trade_index=1, shift=1) + _, signal_end_time = self.calendar.get_calendar_time(trade_index=trade_len, shift=1) signal_df = D.features( self.instruments, fields, start_time=signal_start_time, end_time=signal_end_time, freq=self.freq ) @@ -307,15 +307,15 @@ class SBBStrategyEMA(SBBStrategyBase): def reset_level_infra(self, level_infra): """ reset level-shared infra - - After reset the trade_calendar, the signal will be changed + - After reset the trade calendar, the signal will be changed """ if not hasattr(self, "level_infra"): self.level_infra = level_infra else: self.level_infra.update(level_infra) - if "trade_calendar" in level_infra: - self.trade_calendar = level_infra.get("trade_calendar") + if "calendar" in level_infra: + self.calendar = level_infra.get("calendar") self._reset_signal() def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): diff --git a/qlib/rl/env.py b/qlib/rl/env.py index 2fef7a659..faf9c026e 100644 --- a/qlib/rl/env.py +++ b/qlib/rl/env.py @@ -6,6 +6,7 @@ from typing import Union from .interpreter import StateInterpreter, ActionInterpreter from ..contrib.backtest.executor import BaseExecutor from ..utils import init_instance_by_config +from .interpreter import BaseInterpreter class BaseRLEnv: @@ -68,8 +69,8 @@ class QlibIntRLEnv(QlibRLEnv): interpretor that interprets the rl agent action into qlib order list """ super(QlibIntRLEnv, self).__init__(executor=executor) - self.state_interpreter = init_instance_by_config(state_interpreter) - self.action_interpreter = init_instance_by_config(action_interpreter) + self.state_interpreter = init_instance_by_config(state_interpreter, accept_types=StateInterpreter) + self.action_interpreter = init_instance_by_config(action_interpreter, accept_types=ActionInterpreter) def step(self, action): """ @@ -87,7 +88,7 @@ class QlibIntRLEnv(QlibRLEnv): ------- env state to rl policy """ - _interpret_action = self.action_interpreter.interpret(action=action) - _execute_result = self.executor.execute(_interpret_action) + _interpret_decision = self.action_interpreter.interpret(action=action) + _execute_result = self.executor.execute(trade_decision=_interpret_decision) _interpret_state = self.state_interpreter.interpret(execute_result=_execute_result) return _interpret_state diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index dad994303..59d9d72e3 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -19,24 +19,24 @@ class BaseStrategy: def __init__( self, - rely_trade_decision: object = None, + outer_trade_decision: object = None, level_infra: dict = {}, common_infra: dict = {}, ): """ Parameters ---------- - rely_trade_decision : object, optional - the high-level trade decison on which the startegy rely, and it will be traded in [start_time , end_time] , by default None + outer_trade_decision : object, optional + the trade decison of outer strategy which this startegy relies, and it will be traded in [start_time, end_time], by default None - If the strategy is used to split trade decison, it will be used - If the strategy is used for portfolio management, it can be ignored level_infra : dict, optional - level shared infrastructure for backtesting, including trade_calendar + level shared infrastructure for backtesting, including trade calendar common_infra : dict, optional common infrastructure for backtesting, including trade_account, trade_exchange, .etc """ - self.reset(level_infra=level_infra, common_infra=common_infra, rely_trade_decision=rely_trade_decision) + self.reset(level_infra=level_infra, common_infra=common_infra, outer_trade_decision=outer_trade_decision) def reset_level_infra(self, level_infra): if not hasattr(self, "level_infra"): @@ -44,8 +44,8 @@ class BaseStrategy: else: self.level_infra.update(level_infra) - if "trade_calendar" in level_infra: - self.trade_calendar = level_infra.get("trade_calendar") + if "calendar" in level_infra: + self.calendar = level_infra.get("calendar") def reset_common_infra(self, common_infra): if not hasattr(self, "common_infra"): @@ -56,11 +56,11 @@ class BaseStrategy: if "trade_account" in common_infra: self.trade_position = common_infra.get("trade_account").current - def reset(self, level_infra: dict = None, common_infra: dict = None, rely_trade_decision=None, **kwargs): + def reset(self, level_infra: dict = None, common_infra: dict = None, outer_trade_decision=None, **kwargs): """ - - reset `level_infra`, used to reset trade_calendar, .etc + - reset `level_infra`, used to reset trade calendar, .etc - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc - - reset `rely_trade_decision`, used to make split decison + - reset `outer_trade_decision`, used to make split decison """ if level_infra is not None: self.reset_level_infra(level_infra) @@ -68,11 +68,18 @@ class BaseStrategy: if common_infra is not None: self.reset_common_infra(common_infra) - if rely_trade_decision is not None: - self.rely_trade_decision = rely_trade_decision + if outer_trade_decision is not None: + self.outer_trade_decision = outer_trade_decision - def generate_trade_decision(self, execute_state): - """Generate trade decision in each trading bar""" + def generate_trade_decision(self, execute_result=None): + """Generate trade decision in each trading bar + + Parameters + ---------- + execute_result : List[object], optional + the executed result for trade decison, by default None + - When call the generate_trade_decision firstly, `execute_result` could be None + """ raise NotImplementedError("generate_trade_decision is not implemented!") @@ -89,7 +96,7 @@ class ModelStrategy(BaseStrategy): self, model: BaseModel, dataset: DatasetH, - rely_trade_decision: object = None, + outer_trade_decision: object = None, level_infra: dict = {}, common_infra: dict = {}, **kwargs, @@ -104,7 +111,7 @@ class ModelStrategy(BaseStrategy): kwargs : dict arguments that will be passed into `reset` method """ - super(ModelStrategy, self).__init__(rely_trade_decision, level_infra, common_infra, **kwargs) + super(ModelStrategy, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs) self.model = model self.dataset = dataset self.pred_scores = convert_index_format(self.model.predict(dataset), level="datetime") @@ -125,7 +132,7 @@ class RLStrategy(BaseStrategy): def __init__( self, policy, - rely_trade_decision: object = None, + outer_trade_decision: object = None, level_infra: dict = {}, common_infra: dict = {}, **kwargs, @@ -136,7 +143,7 @@ class RLStrategy(BaseStrategy): policy : RL policy for generate action """ - super(RLStrategy, self).__init__(rely_trade_decision, level_infra, common_infra, **kwargs) + super(RLStrategy, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs) self.policy = policy @@ -148,7 +155,7 @@ class RLIntStrategy(RLStrategy): policy, state_interpreter: StateInterpreter, action_interpreter: ActionInterpreter, - rely_trade_decision: object = None, + outer_trade_decision: object = None, level_infra: dict = {}, common_infra: dict = {}, **kwargs, @@ -165,15 +172,14 @@ class RLIntStrategy(RLStrategy): end_time : Union[str, pd.Timestamp], optional end time of trading, by default None """ - super(RLIntStrategy, self).__init__(policy, rely_trade_decision, level_infra, common_infra, **kwargs) + super(RLIntStrategy, self).__init__(policy, outer_trade_decision, level_infra, common_infra, **kwargs) self.policy = policy self.state_interpreter = init_instance_by_config(state_interpreter) self.action_interpreter = init_instance_by_config(action_interpreter) - def generate_trade_decision(self, execute_state): - super(RLStrategy, self).step() - _interpret_state = self.state_interpretor.interpret(execute_result=execute_state) - _policy_action = self.policy.step(_interpret_state) - _order_list = self.action_interpreter.interpret(action=_policy_action) - return _order_list + def generate_trade_decision(self, execute_result=None): + _interpret_state = self.state_interpretor.interpret(execute_result=execute_result) + _action = self.policy.step(_interpret_state) + _trade_decision = self.action_interpreter.interpret(action=_action) + return _trade_decision diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 1f80bd051..a32ef9729 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -317,7 +317,7 @@ class PortAnaRecord(RecordTemp): def _get_report_freq(self, executor_config): ret_freq = [] if executor_config["kwargs"].get("generate_report", False): - _count, _freq = parse_freq(executor_config["kwargs"]["step_bar"]) + _count, _freq = parse_freq(executor_config["kwargs"]["time_per_step"]) ret_freq.append(f"{_count}{_freq}") if "sub_env" in executor_config["kwargs"]: ret_freq.extend(self._get_report_freq(executor_config["kwargs"]["sub_env"])) From 4085b447aab98e23f3c6106038705fd1a423471d Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 27 May 2021 21:14:39 +0800 Subject: [PATCH 027/187] move backtest to core, fix calendar bugs, add some docstring --- examples/multi_level_trading/workflow.py | 10 +- examples/workflow_by_code.ipynb | 1 - examples/workflow_by_code.py | 11 -- qlib/{contrib => }/backtest/__init__.py | 8 +- qlib/{contrib => }/backtest/account.py | 2 + qlib/{contrib => }/backtest/backtest.py | 0 qlib/{contrib => }/backtest/exchange.py | 12 +- qlib/{contrib => }/backtest/executor.py | 54 ++++---- qlib/{contrib => }/backtest/order.py | 0 qlib/{contrib => }/backtest/position.py | 0 .../backtest/profit_attribution.py | 4 +- qlib/{contrib => }/backtest/report.py | 8 +- qlib/backtest/utils.py | 98 ++++++++++++++ qlib/config.py | 6 +- qlib/contrib/backtest/utils.py | 67 ---------- qlib/contrib/evaluate.py | 6 +- .../analysis_position/parse_position.py | 2 +- .../report/analysis_position/rank_label.py | 2 +- qlib/contrib/strategy/cost_control.py | 5 +- qlib/contrib/strategy/model_strategy.py | 34 +++-- qlib/contrib/strategy/order_generator.py | 4 +- qlib/contrib/strategy/rule_strategy.py | 126 ++++++++++++------ qlib/data/data.py | 4 +- qlib/rl/env.py | 3 +- qlib/strategy/base.py | 16 +-- qlib/utils/resam.py | 29 ++-- qlib/workflow/record_temp.py | 2 +- 27 files changed, 298 insertions(+), 216 deletions(-) rename qlib/{contrib => }/backtest/__init__.py (96%) rename qlib/{contrib => }/backtest/account.py (99%) rename qlib/{contrib => }/backtest/backtest.py (100%) rename qlib/{contrib => }/backtest/exchange.py (98%) rename qlib/{contrib => }/backtest/executor.py (89%) rename qlib/{contrib => }/backtest/order.py (100%) rename qlib/{contrib => }/backtest/position.py (100%) rename qlib/{contrib => }/backtest/profit_attribution.py (99%) rename qlib/{contrib => }/backtest/report.py (97%) create mode 100644 qlib/backtest/utils.py delete mode 100644 qlib/contrib/backtest/utils.py diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index ea11d4e7f..8096fc76f 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -10,7 +10,7 @@ from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict from qlib.workflow import R from qlib.workflow.record_temp import SignalRecord, PortAnaRecord from qlib.tests.data import GetData -from qlib.contrib.backtest import collect_data +from qlib.backtest import collect_data class MultiLevelTradingWorkflow: @@ -61,17 +61,17 @@ class MultiLevelTradingWorkflow: } trade_start_time = "2017-01-01" - trade_end_time = "2017-02-01" + trade_end_time = "2020-08-01" port_analysis_config = { "executor": { - "class": "SplitExecutor", - "module_path": "qlib.contrib.backtest.executor", + "class": "NestedExecutor", + "module_path": "qlib.backtest.executor", "kwargs": { "time_per_step": "week", "inner_executor": { "class": "SimulatorExecutor", - "module_path": "qlib.contrib.backtest.executor", + "module_path": "qlib.backtest.executor", "kwargs": { "time_per_step": "day", "verbose": True, diff --git a/examples/workflow_by_code.ipynb b/examples/workflow_by_code.ipynb index 1dda1c621..b4da1bfe4 100644 --- a/examples/workflow_by_code.ipynb +++ b/examples/workflow_by_code.ipynb @@ -66,7 +66,6 @@ "from qlib.config import REG_CN\n", "from qlib.contrib.model.gbdt import LGBModel\n", "from qlib.contrib.data.handler import Alpha158\n", - "from qlib.contrib.strategy.strategy import TopkDropoutStrategy\n", "from qlib.contrib.evaluate import (\n", " backtest as normal_backtest,\n", " risk_analysis,\n", diff --git a/examples/workflow_by_code.py b/examples/workflow_by_code.py index d5dab8917..92ce6aa34 100644 --- a/examples/workflow_by_code.py +++ b/examples/workflow_by_code.py @@ -1,19 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import sys -from pathlib import Path - import qlib -import pandas as pd from qlib.config import REG_CN -from qlib.contrib.model.gbdt import LGBModel -from qlib.contrib.data.handler import Alpha158 -from qlib.contrib.strategy.strategy import TopkDropoutStrategy -from qlib.contrib.evaluate import ( - backtest as normal_backtest, - risk_analysis, -) from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict from qlib.workflow import R from qlib.workflow.record_temp import SignalRecord, PortAnaRecord diff --git a/qlib/contrib/backtest/__init__.py b/qlib/backtest/__init__.py similarity index 96% rename from qlib/contrib/backtest/__init__.py rename to qlib/backtest/__init__.py index effab026b..12db0a314 100644 --- a/qlib/contrib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -7,10 +7,10 @@ from .executor import BaseExecutor from .backtest import backtest as backtest_func from .backtest import collect_data as data_generator -from ...strategy.base import BaseStrategy -from ...utils import init_instance_by_config -from ...log import get_module_logger -from ...config import C +from ..strategy.base import BaseStrategy +from ..utils import init_instance_by_config +from ..log import get_module_logger +from ..config import C logger = get_module_logger("backtest caller") diff --git a/qlib/contrib/backtest/account.py b/qlib/backtest/account.py similarity index 99% rename from qlib/contrib/backtest/account.py rename to qlib/backtest/account.py index c7571bc98..dfe248c68 100644 --- a/qlib/contrib/backtest/account.py +++ b/qlib/backtest/account.py @@ -24,6 +24,8 @@ rtn & earning in the Account **is consider cost** while earning is the difference of two position value, so it considers cost, it is the true return rate in the specific accomplishment for rtn, it does not consider cost, in other words, rtn - cost = earning + +Now rtn has been removed in the hierarchical backtest implemention. """ diff --git a/qlib/contrib/backtest/backtest.py b/qlib/backtest/backtest.py similarity index 100% rename from qlib/contrib/backtest/backtest.py rename to qlib/backtest/backtest.py diff --git a/qlib/contrib/backtest/exchange.py b/qlib/backtest/exchange.py similarity index 98% rename from qlib/contrib/backtest/exchange.py rename to qlib/backtest/exchange.py index 09b7f2a63..de2df98be 100644 --- a/qlib/contrib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -8,11 +8,11 @@ import logging import numpy as np import pandas as pd -from ...data.data import D -from ...data.dataset.utils import get_level_index -from ...config import C, REG_CN -from ...utils.resam import resam_ts_data -from ...log import get_module_logger +from ..data.data import D +from ..data.dataset.utils import get_level_index +from ..config import C, REG_CN +from ..utils.resam import resam_ts_data +from ..log import get_module_logger from .order import Order @@ -35,7 +35,7 @@ class Exchange: """__init__ :param freq: frequency of data - :param start_time: closed start time for backtest + :param start_time: closed start time for backtest :param end_time: closed end time for backtest :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) :param deal_price: str, 'close', 'open', 'vwap' diff --git a/qlib/contrib/backtest/executor.py b/qlib/backtest/executor.py similarity index 89% rename from qlib/contrib/backtest/executor.py rename to qlib/backtest/executor.py index c896f802d..88a219f41 100644 --- a/qlib/contrib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -3,8 +3,8 @@ import warnings import pandas as pd from typing import Union -from ...utils import init_instance_by_config -from ...utils.resam import parse_freq +from ..utils import init_instance_by_config +from ..utils.resam import parse_freq from .order import Order @@ -30,7 +30,7 @@ class BaseExecutor: Parameters ---------- time_per_step : str - trade time per trading step, used for genreate trade calendar + trade time per trading step, used for genreate the trade calendar generate_report : bool, optional whether to generate report, by default False verbose : bool, optional @@ -80,16 +80,18 @@ class BaseExecutor: if "start_time" in kwargs or "end_time" in kwargs: start_time = kwargs.get("start_time") end_time = kwargs.get("end_time") - self.calendar = TradeCalendarManager(freq=self.time_per_step, start_time=start_time, end_time=end_time) + self.trade_calendar = TradeCalendarManager( + freq=self.time_per_step, start_time=start_time, end_time=end_time + ) if common_infra is not None: self.reset_common_infra(common_infra) def get_level_infra(self): - return {"calendar": self.calendar} + return {"trade_calendar": self.trade_calendar} def finished(self): - return self.calendar.finished() + return self.trade_calendar.finished() def execute(self, trade_decision): """execute the trade decision and return the executed result @@ -117,8 +119,13 @@ class BaseExecutor: raise NotImplementedError("get_report is not implemented!") -class SplitExecutor(BaseExecutor): - from ...strategy.base import BaseStrategy +class NestedExecutor(BaseExecutor): + """ + Nested Executor with inner strategy and executor + - At each time `execute` is called, it will call the inner strategy and executor to execute the `trade_decision` in a higher frequency env. + """ + + from ..strategy.base import BaseStrategy def __init__( self, @@ -127,10 +134,10 @@ class SplitExecutor(BaseExecutor): inner_strategy: Union[BaseStrategy, dict], start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - trade_exchange: Exchange = None, generate_report: bool = False, verbose: bool = False, track_data: bool = False, + trade_exchange: Exchange = None, common_infra: dict = {}, **kwargs, ): @@ -153,7 +160,7 @@ class SplitExecutor(BaseExecutor): inner_strategy, common_infra=common_infra, accept_types=self.BaseStrategy ) - super(SplitExecutor, self).__init__( + super(NestedExecutor, self).__init__( time_per_step=time_per_step, start_time=start_time, end_time=end_time, @@ -173,7 +180,7 @@ class SplitExecutor(BaseExecutor): - reset trade_exchange - reset inner_strategyand inner_executor common infra """ - super(SplitExecutor, self).reset_common_infra(common_infra) + super(NestedExecutor, self).reset_common_infra(common_infra) if self.generate_report and "trade_exchange" in common_infra: self.trade_exchange = common_infra.get("trade_exchange") @@ -182,15 +189,15 @@ class SplitExecutor(BaseExecutor): self.inner_strategy.reset_common_infra(common_infra) def _init_sub_trading(self, trade_decision): - trade_index = self.calendar.get_trade_index() - trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) self.inner_executor.reset(start_time=trade_start_time, end_time=trade_end_time) sub_level_infra = self.inner_executor.get_level_infra() self.inner_strategy.reset(level_infra=sub_level_infra, outer_trade_decision=trade_decision) def _update_trade_account(self): - trade_index = self.calendar.get_trade_index() - trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) self.trade_account.update_bar_count() if self.generate_report: self.trade_account.update_bar_report( @@ -200,7 +207,6 @@ class SplitExecutor(BaseExecutor): ) def execute(self, trade_decision): - self.calendar.step() self._init_sub_trading(trade_decision) execute_result = [] _inner_execute_result = None @@ -210,13 +216,13 @@ class SplitExecutor(BaseExecutor): execute_result.extend(_inner_execute_result) if hasattr(self, "trade_account"): self._update_trade_account() - + self.trade_calendar.step() return execute_result def collect_data(self, trade_decision): if self.track_data: yield trade_decision - self.calendar.step() + self.trade_calendar.step() self._init_sub_trading(trade_decision) execute_result = [] _inner_execute_result = None @@ -240,15 +246,17 @@ class SplitExecutor(BaseExecutor): class SimulatorExecutor(BaseExecutor): + """Executor that simulate the true market""" + def __init__( self, time_per_step: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - trade_exchange: Exchange = None, generate_report: bool = False, verbose: bool = False, track_data: bool = False, + trade_exchange: Exchange = None, common_infra: dict = {}, **kwargs, ): @@ -282,9 +290,9 @@ class SimulatorExecutor(BaseExecutor): self.trade_exchange = common_infra.get("trade_exchange") def execute(self, trade_decision): - self.calendar.step() - trade_index = self.calendar.get_trade_index() - trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) execute_result = [] for order in trade_decision: if self.trade_exchange.check_order(order) is True: @@ -333,7 +341,7 @@ class SimulatorExecutor(BaseExecutor): trade_end_time=trade_end_time, trade_exchange=self.trade_exchange, ) - + self.trade_calendar.step() return execute_result def get_report(self): diff --git a/qlib/contrib/backtest/order.py b/qlib/backtest/order.py similarity index 100% rename from qlib/contrib/backtest/order.py rename to qlib/backtest/order.py diff --git a/qlib/contrib/backtest/position.py b/qlib/backtest/position.py similarity index 100% rename from qlib/contrib/backtest/position.py rename to qlib/backtest/position.py diff --git a/qlib/contrib/backtest/profit_attribution.py b/qlib/backtest/profit_attribution.py similarity index 99% rename from qlib/contrib/backtest/profit_attribution.py rename to qlib/backtest/profit_attribution.py index 20c6f638f..7e1844a6f 100644 --- a/qlib/contrib/backtest/profit_attribution.py +++ b/qlib/backtest/profit_attribution.py @@ -5,8 +5,8 @@ import numpy as np import pandas as pd from .position import Position -from ...data import D -from ...config import C +from ..data import D +from ..config import C import datetime from pathlib import Path diff --git a/qlib/contrib/backtest/report.py b/qlib/backtest/report.py similarity index 97% rename from qlib/contrib/backtest/report.py rename to qlib/backtest/report.py index 3763f5214..c26c46f9d 100644 --- a/qlib/contrib/backtest/report.py +++ b/qlib/backtest/report.py @@ -10,8 +10,8 @@ import warnings from pandas.core.frame import DataFrame -from ...utils.resam import parse_freq, resam_ts_data -from ...data import D +from ..utils.resam import parse_freq, resam_ts_data +from ..data import D class Report: @@ -86,9 +86,9 @@ class Report: try: _temp_result = D.features(_codes, fields, start_time, end_time, freq="day", disk_cache=1) except ValueError: - _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) + _temp_result = D.features(_codes, fields, start_time, end_time, freq="1min", disk_cache=1) elif norm_freq == "minute": - _temp_result = D.features(_codes, fields, start_time, end_time, freq="minute", disk_cache=1) + _temp_result = D.features(_codes, fields, start_time, end_time, freq="1min", disk_cache=1) else: raise ValueError(f"benchmark freq {freq} is not supported") if len(_temp_result) == 0: diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py new file mode 100644 index 000000000..fe51c99f3 --- /dev/null +++ b/qlib/backtest/utils.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pandas as pd +from typing import Union + +from ..utils.resam import get_resam_calendar +from ..data.data import Cal + + +class TradeCalendarManager: + """ + Manager for trading calendar + - BaseStrategy and BaseExecutor will use it + """ + + def __init__( + self, freq: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None + ): + """ + Parameters + ---------- + freq : str + frequency of trading calendar, also trade time per trading step + start_time : Union[str, pd.Timestamp], optional + closed start of the trading calendar, by default None + If `start_time` is None, it must be reset before trading. + end_time : Union[str, pd.Timestamp], optional + closed end of the trade time range, by default None + If `end_time` is None, it must be reset before trading. + """ + self.freq = freq + self.start_time = pd.Timestamp(start_time) if start_time else None + self.end_time = pd.Timestamp(end_time) if end_time else None + self._init_trade_calendar(freq=freq, start_time=start_time, end_time=end_time) + + def _init_trade_calendar(self, freq, start_time, end_time): + """ + Reset the trade calendar + - self.trade_len : The total count for trading step + - self.trade_step : The number of trading step finished, self.trade_step can be [0, 1, 2, ..., self.trade_len - 1] + """ + _calendar, freq, freq_sam = get_resam_calendar(freq=freq) + self.trade_calendar = _calendar + _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) + self.start_index = _start_index + self.end_index = _end_index + self.trade_len = _end_index - _start_index + 1 + self.trade_step = 0 + + def finished(self): + """ + Check if the trading finished + - Should check before calling strategy.generate_decisions and executor.execute + - If self.trade_step >= self.self.trade_len, it means the trading is finished + - If self.trade_step < self.self.trade_len, it means the number of trading step finished is self.trade_step + """ + return self.trade_step >= self.trade_len + + def step(self): + if self.finished(): + raise RuntimeError(f"The calendar is finished, please reset it if you want to call it!") + self.trade_step = self.trade_step + 1 + + def get_freq(self): + return self.freq + + def get_trade_len(self): + return self.trade_len + + def get_trade_step(self): + return self.trade_step + + def get_step_time(self, trade_step=0, shift=0): + """ + Get the time range of trading step + + Parameters + ---------- + trade_step : int, optional + the number of trading step finished, by default 0 + shift : int, optional + shift bars , by default 0 + + Returns + ------- + Tuple[pd.Timestamp, pd.Timestap] + - If shift == 0, return the trading time range + - If shift > 0, return the trading time range of the earlier shift bars + - If shift < 0, return the trading time range of the later shift bar + """ + trade_step = trade_step - shift + calendar_index = self.start_index + trade_step + return self.trade_calendar[calendar_index], self.trade_calendar[calendar_index + 1] - pd.Timedelta(seconds=1) + + def get_all_time(self): + """Get the start_time and end_time for trading""" + return self.start_time, self.end_time diff --git a/qlib/config.py b/qlib/config.py index df28ac939..c3085ae68 100644 --- a/qlib/config.py +++ b/qlib/config.py @@ -149,9 +149,9 @@ _default_config = { "task_db_name": "default_task_db", }, # Shift minute for highfreq minite data, used in backtest - # if min_data_shift == 0, use default market time [9:30, 11:29, 1:30, 2:59] - # if min_data_shift != 0, use shifted market time [9:30, 11:29, 1:30, 2:59] - shift*minute - "min_data_shift": {0}, + # if min_data_shift == 0, use default market time [9:30, 11:29, 1:00, 2:59] + # if min_data_shift != 0, use shifted market time [9:30, 11:29, 1:00, 2:59] - shift*minute + "min_data_shift": 0, } MODE_CONF = { diff --git a/qlib/contrib/backtest/utils.py b/qlib/contrib/backtest/utils.py deleted file mode 100644 index 622816753..000000000 --- a/qlib/contrib/backtest/utils.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import pandas as pd -from typing import Union - -from ...utils.resam import get_resam_calendar -from ...data.data import Cal - - -class TradeCalendarManager: - """ - Manager for trading calendar - - BaseStrategy and BaseExecutor will use it - """ - - def __init__( - self, freq: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None - ): - """ - Parameters - ---------- - freq : str - frequency of trading calendar, also trade time per trading step - start_time : Union[str, pd.Timestamp], optional - closed start of the trading calendar, by default None - If `start_time` is None, it must be reset before trading. - end_time : Union[str, pd.Timestamp], optional - closed end of the trade time range, by default None - If `end_time` is None, it must be reset before trading. - """ - self.freq = freq - self.start_time = pd.Timestamp(start_time) if start_time else None - self.end_time = pd.Timestamp(start_time) if start_time else None - self._init_trade_calendar(freq=freq, start_time=start_time, end_time=end_time) - - def _init_trade_calendar(self, freq, start_time, end_time): - """reset trade calendar""" - _calendar, freq, freq_sam = get_resam_calendar(freq=freq) - self.calendar = _calendar - _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) - self.start_index = _start_index - self.end_index = _end_index - self.trade_len = _end_index - _start_index + 1 - self.trade_index = 0 - - def finished(self): - return self.trade_index >= self.trade_len - - def step(self): - if self.finished(): - raise RuntimeError(f"The calendar is finished, please reset it if you want to call it!") - self.trade_index = self.trade_index + 1 - - def get_freq(self): - return self.freq - - def get_trade_len(self): - return self.trade_len - - def get_trade_index(self): - return self.trade_index - - def get_calendar_time(self, trade_index=1, shift=0): - trade_index = trade_index - shift - calendar_index = self.start_index + trade_index - return self.calendar[calendar_index - 1], self.calendar[calendar_index] - pd.Timedelta(seconds=1) diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index 59a831f3e..8d4052cdb 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -9,7 +9,7 @@ import numpy as np import pandas as pd import warnings from ..log import get_module_logger -from .backtest import get_exchange, backtest as backtest_func +from ..backtest import get_exchange, backtest as backtest_func from ..utils import get_date_range from ..utils.resam import parse_freq @@ -141,9 +141,7 @@ def backtest(pred, account=1e9, shift=1, benchmark="SH000905", verbose=True, **k whether to print log. """ - warnings.warn( - "this function is deprecated, please use backtest function in qlib.contrib.backtest", DeprecationWarning - ) + warnings.warn("this function is deprecated, please use backtest function in qlib.backtest", DeprecationWarning) report_dict = backtest_func( pred=pred, account=account, shift=shift, benchmark=benchmark, verbose=verbose, return_order=False, **kwargs ) diff --git a/qlib/contrib/report/analysis_position/parse_position.py b/qlib/contrib/report/analysis_position/parse_position.py index c5d48ff8e..1373d902f 100644 --- a/qlib/contrib/report/analysis_position/parse_position.py +++ b/qlib/contrib/report/analysis_position/parse_position.py @@ -4,7 +4,7 @@ import pandas as pd -from ...backtest.profit_attribution import get_stock_weight_df +from ....backtest.profit_attribution import get_stock_weight_df def parse_position(position: dict = None) -> pd.DataFrame: diff --git a/qlib/contrib/report/analysis_position/rank_label.py b/qlib/contrib/report/analysis_position/rank_label.py index 77743b10c..2927f12a2 100644 --- a/qlib/contrib/report/analysis_position/rank_label.py +++ b/qlib/contrib/report/analysis_position/rank_label.py @@ -97,7 +97,7 @@ def rank_label_graph( qcr.analysis_position.rank_label_graph(positions, features_df, pred_df_dates.min(), pred_df_dates.max()) - :param position: position data; **qlib.contrib.backtest.backtest.backtest** result. + :param position: position data; **qlib.backtest.backtest** result. :param label_data: **D.features** result; index is **pd.MultiIndex**, index name is **[instrument, datetime]**; columns names is **[label]**. **The label T is the change from T to T+1**, it is recommended to use ``close``, example: `D.features(D.instruments('csi500'), ['Ref($close, -1)/$close-1'])`. diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index 58e3fccc4..e7f6cce04 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -17,6 +17,7 @@ class SoftTopkStrategy(WeightStrategyBase): max_sold_weight=1.0, risk_degree=0.95, buy_method="first_fill", + trade_exchange=None, level_infra={}, common_infra={}, **kwargs, @@ -31,14 +32,14 @@ class SoftTopkStrategy(WeightStrategyBase): average_fill: assign the weight to the stocks rank high averagely. """ super(SoftTopkStrategy, self).__init__( - model, dataset, order_generator_cls_or_obj, level_infra, common_infra, **kwargs + model, dataset, order_generator_cls_or_obj, trade_exchange, level_infra, common_infra, **kwargs ) self.topk = topk self.max_sold_weight = max_sold_weight self.risk_degree = risk_degree self.buy_method = buy_method - def get_risk_degree(self, trade_index=None): + def get_risk_degree(self, trade_step=None): """get_risk_degree Return the proportion of your total value you will used in investment. Dynamically risk_degree will result in Market timing diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index d797729be..d563bccea 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -5,7 +5,7 @@ import pandas as pd from ...utils.resam import resam_ts_data from ...strategy.base import ModelStrategy -from ..backtest.order import Order +from ...backtest.order import Order from .order_generator import OrderGenWInteract @@ -21,6 +21,7 @@ class TopkDropoutStrategy(ModelStrategy): risk_degree=0.95, hold_thresh=1, only_tradable=False, + trade_exchange=None, level_infra={}, common_infra={}, **kwargs, @@ -47,6 +48,9 @@ class TopkDropoutStrategy(ModelStrategy): strategy will make buy sell decision without checking the tradable state of the stock. else: strategy will make decision with the tradable state of the stock info and avoid buy and sell them. + trade_exchange : Exchange + exchange that provides market info, used to deal order and generate report + - If `trade_exchange` is None, self.trade_exchange will be set with common_infra """ super(TopkDropoutStrategy, self).__init__( model, dataset, level_infra=level_infra, common_infra=common_infra, **kwargs @@ -58,6 +62,8 @@ class TopkDropoutStrategy(ModelStrategy): self.risk_degree = risk_degree self.hold_thresh = hold_thresh self.only_tradable = only_tradable + if trade_exchange is not None: + self.trade_exchange = trade_exchange def reset_common_infra(self, common_infra): """ @@ -73,7 +79,7 @@ class TopkDropoutStrategy(ModelStrategy): if "trade_exchange" in common_infra: self.trade_exchange = common_infra.get("trade_exchange") - def get_risk_degree(self, trade_index=None): + def get_risk_degree(self, trade_step=None): """get_risk_degree Return the proportion of your total value you will used in investment. Dynamically risk_degree will result in Market timing. @@ -82,9 +88,10 @@ class TopkDropoutStrategy(ModelStrategy): return self.risk_degree def generate_trade_decision(self, execute_result=None): - trade_index = self.calendar.get_trade_index() - trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) - pred_start_time, pred_end_time = self.calendar.get_calendar_time(trade_index, shift=1) + # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: return [] @@ -179,7 +186,7 @@ class TopkDropoutStrategy(ModelStrategy): continue if code in sell: # check hold limit - time_per_step = self.calendar.get_freq() + time_per_step = self.trade_calendar.get_freq() if current_temp.get_stock_count(code, bar=time_per_step) < self.hold_thresh: continue # sell order @@ -243,6 +250,7 @@ class WeightStrategyBase(ModelStrategy): model, dataset, order_generator_cls_or_obj=OrderGenWInteract, + trade_exchange=None, level_infra={}, common_infra={}, **kwargs, @@ -254,6 +262,8 @@ class WeightStrategyBase(ModelStrategy): self.order_generator = order_generator_cls_or_obj() else: self.order_generator = order_generator_cls_or_obj + if trade_exchange is not None: + self.trade_exchange = trade_exchange def reset_common_infra(self, common_infra): """ @@ -269,7 +279,7 @@ class WeightStrategyBase(ModelStrategy): if "trade_exchange" in common_infra: self.trade_exchange = common_infra.get("trade_exchange") - def get_risk_degree(self, trade_index=None): + def get_risk_degree(self, trade_step=None): """get_risk_degree Return the proportion of your total value you will used in investment. Dynamically risk_degree will result in Market timing. @@ -307,9 +317,11 @@ class WeightStrategyBase(ModelStrategy): """ # generate_trade_decision # generate_target_weight_position() and generate_order_list_from_target_weight_position() to generate order_list - trade_index = self.calendar.get_trade_index() - trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) - pred_start_time, pred_end_time = self.calendar.get_calendar_time(trade_index, shift=1) + + # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: return [] @@ -320,7 +332,7 @@ class WeightStrategyBase(ModelStrategy): order_list = self.order_generator.generate_order_list_from_target_weight_position( current=current_temp, trade_exchange=self.trade_exchange, - risk_degree=self.get_risk_degree(trade_index), + risk_degree=self.get_risk_degree(trade_step), target_weight_position=target_weight_position, pred_start_time=pred_start_time, pred_end_time=pred_end_time, diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index db2c1de0d..d3e94551a 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -4,8 +4,8 @@ """ This order generator is for strategies based on WeightStrategyBase """ -from ..backtest.position import Position -from ..backtest.exchange import Exchange +from ...backtest.position import Position +from ...backtest.exchange import Exchange import pandas as pd import copy diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 1f42c451c..24873caae 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -3,13 +3,35 @@ import warnings from ...utils.resam import resam_ts_data from ...data.data import D from ...data.dataset.utils import convert_index_format -from ...strategy.base import RuleStrategy -from ..backtest.order import Order +from ...strategy.base import BaseStrategy +from ...backtest.order import Order +from ...backtest.exchange import Exchange -class TWAPStrategy(RuleStrategy): +class TWAPStrategy(BaseStrategy): """TWAP Strategy for trading""" + def __init__( + self, + outer_trade_decision: object = None, + trade_exchange: Exchange = None, + level_infra: dict = {}, + common_infra: dict = {}, + ): + """ + Parameters + ---------- + trade_exchange : Exchange + exchange that provides market info, used to deal order and generate report + - If `trade_exchange` is None, self.trade_exchange will be set with common_infra + """ + super(TWAPStrategy, self).__init__( + outer_trade_decision=outer_trade_decision, level_infra=level_infra, common_infra=common_infra + ) + + if trade_exchange is not None: + self.trade_exchange = trade_exchange + def reset_common_infra(self, common_infra): """ Parameters @@ -44,9 +66,11 @@ class TWAPStrategy(RuleStrategy): for order, _, _, _ in execute_result: self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - trade_index = self.calendar.get_trade_index() - trade_len = self.calendar.get_trade_len() - trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) + # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] + trade_step = self.trade_calendar.get_trade_step() + # get the total count of trading step + trade_len = self.trade_calendar.get_trade_len() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) order_list = [] for order in self.outer_trade_decision: if not self.trade_exchange.is_stock_tradable( @@ -57,21 +81,21 @@ class TWAPStrategy(RuleStrategy): _order_amount = None # consider trade unit if _amount_trade_unit is None: - # split the order equally - _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_index + 1) + # divide the order equally + _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step + 1) # without considering trade unit elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: - # split the order equally - # floor((trade_unit_cnt + trade_len - trade_index) / (trade_len - trade_index + 1)) == ceil(trade_unit_cnt / (trade_len - trade_index + 1)) + # divide the order equally + # floor((trade_unit_cnt + trade_len - trade_step) / (trade_len - trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - trade_step + 1)) trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) _order_amount = ( - (trade_unit_cnt + trade_len - trade_index) // (trade_len - trade_index + 1) * _amount_trade_unit + (trade_unit_cnt + trade_len - trade_step) // (trade_len - trade_step + 1) * _amount_trade_unit ) if order.direction == order.SELL: # sell all amount at last if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount is None or trade_index == trade_len + _order_amount is None or trade_step == trade_len ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] @@ -89,7 +113,7 @@ class TWAPStrategy(RuleStrategy): return order_list -class SBBStrategyBase(RuleStrategy): +class SBBStrategyBase(BaseStrategy): """ (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy. """ @@ -98,6 +122,27 @@ class SBBStrategyBase(RuleStrategy): TREND_SHORT = 1 TREND_LONG = 2 + def __init__( + self, + outer_trade_decision: object = None, + trade_exchange: Exchange = None, + level_infra: dict = {}, + common_infra: dict = {}, + ): + """ + Parameters + ---------- + trade_exchange : Exchange + exchange that provides market info, used to deal order and generate report + - If `trade_exchange` is None, self.trade_exchange will be set with common_infra + """ + super(SBBStrategyBase, self).__init__( + outer_trade_decision=outer_trade_decision, level_infra=level_infra, common_infra=common_infra + ) + + if trade_exchange is not None: + self.trade_exchange = trade_exchange + def reset_common_infra(self, common_infra): super(SBBStrategyBase, self).reset_common_infra(common_infra) if common_infra is not None: @@ -132,15 +177,17 @@ class SBBStrategyBase(RuleStrategy): if execute_result is not None: for order, _, _, _ in execute_result: self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - trade_index = self.calendar.get_trade_index() - trade_len = self.calendar.get_trade_len() - trade_start_time, trade_end_time = self.calendar.get_calendar_time(trade_index) - pred_start_time, pred_end_time = self.calendar.get_calendar_time(trade_index, shift=1) + # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] + trade_step = self.trade_calendar.get_trade_step() + # get the total count of trading step + trade_len = self.trade_calendar.get_trade_len() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) order_list = [] # for each order in in self.outer_trade_decision for order in self.outer_trade_decision: # predict the price trend - if trade_index % 2 == 1: + if trade_step % 2 == 0: _pred_trend = self._pred_price_trend(order.stock_id, pred_start_time, pred_end_time) else: _pred_trend = self.trade_trend[(order.stock_id, order.direction)] @@ -148,7 +195,7 @@ class SBBStrategyBase(RuleStrategy): if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): - if trade_index % 2 == 1: + if trade_step % 2 == 0: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend continue # get amount of one trade unit @@ -157,21 +204,21 @@ class SBBStrategyBase(RuleStrategy): _order_amount = None # considering trade unit if _amount_trade_unit is None: - # split the order equally - _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_index + 1) + # divide the order equally + _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step) # without considering trade unit elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: # cal how many trade unit trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) - # split the order equally - # floor((trade_unit_cnt + trade_len - trade_index) / (trade_len - trade_index + 1)) == ceil(trade_unit_cnt / (trade_len - trade_index + 1)) + # divide the order equally + # floor((trade_unit_cnt + trade_len - trade_step - 1) / (trade_len - trade_step)) == ceil(trade_unit_cnt / (trade_len - trade_step)) _order_amount = ( - (trade_unit_cnt + trade_len - trade_index) // (trade_len - trade_index + 1) * _amount_trade_unit + (trade_unit_cnt + trade_len - trade_step - 1) // (trade_len - trade_step) * _amount_trade_unit ) if order.direction == order.SELL: # sell all amount at last if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount is None or trade_index == trade_len + _order_amount is None or trade_step == trade_len - 1 ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] @@ -190,31 +237,31 @@ class SBBStrategyBase(RuleStrategy): _order_amount = None # considering trade unit if _amount_trade_unit is None: - # N trade day last, split the order into N + 1 parts, and trade 2 parts + # N trade day left, divide the order into N + 1 parts, and trade 2 parts _order_amount = ( - 2 * self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_index + 2) + 2 * self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step + 1) ) # without considering trade unit elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: # cal how many trade unit trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) - # N trade day last, split the order into N + 1 parts, and trade 2 parts + # N trade day left, divide the order into N + 1 parts, and trade 2 parts _order_amount = ( - (trade_unit_cnt + trade_len - trade_index + 1) - // (trade_len - trade_index + 2) + (trade_unit_cnt + trade_len - trade_step) + // (trade_len - trade_step + 1) * 2 * _amount_trade_unit ) if order.direction == order.SELL: # sell all amount at last if self.trade_amount[(order.stock_id, order.direction)] >= 1e-5 and ( - _order_amount is None or trade_index == trade_len + _order_amount is None or trade_step == trade_len - 1 ): _order_amount = self.trade_amount[(order.stock_id, order.direction)] if _order_amount: _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) - if trade_index % 2 == 1: + if trade_step % 2 == 0: # in the first of two adjacent bar # if look short on the price, sell the stock more # if look long on the price, sell the stock more @@ -253,7 +300,7 @@ class SBBStrategyBase(RuleStrategy): ) order_list.append(_order) - if trade_index % 2 == 1: + if trade_step % 2 == 0: self.trade_trend[(order.stock_id, order.direction)] = _pred_trend return order_list @@ -269,6 +316,7 @@ class SBBStrategyEMA(SBBStrategyBase): outer_trade_decision=[], instruments="csi300", freq="day", + trade_exchange: Exchange = None, level_infra={}, common_infra={}, **kwargs, @@ -288,13 +336,13 @@ class SBBStrategyEMA(SBBStrategyBase): if isinstance(instruments, str): self.instruments = D.instruments(instruments) self.freq = freq - super(SBBStrategyEMA, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs) + super(SBBStrategyEMA, self).__init__(outer_trade_decision, trade_exchange, level_infra, common_infra, **kwargs) def _reset_signal(self): - trade_len = self.calendar.get_trade_len() + trade_len = self.trade_calendar.get_trade_len() fields = ["EMA($close, 10)-EMA($close, 20)"] - signal_start_time, _ = self.calendar.get_calendar_time(trade_index=1, shift=1) - _, signal_end_time = self.calendar.get_calendar_time(trade_index=trade_len, shift=1) + signal_start_time, _ = self.trade_calendar.get_step_time(trade_step=0, shift=1) + _, signal_end_time = self.trade_calendar.get_step_time(trade_step=trade_len - 1, shift=1) signal_df = D.features( self.instruments, fields, start_time=signal_start_time, end_time=signal_end_time, freq=self.freq ) @@ -314,8 +362,8 @@ class SBBStrategyEMA(SBBStrategyBase): else: self.level_infra.update(level_infra) - if "calendar" in level_infra: - self.calendar = level_infra.get("calendar") + if "trade_calendar" in level_infra: + self.trade_calendar = level_infra.get("trade_calendar") self._reset_signal() def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): diff --git a/qlib/data/data.py b/qlib/data/data.py index 394c3271e..9c61c225a 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -775,7 +775,7 @@ class ClientCalendarProvider(CalendarProvider): def calendar(self, start_time=None, end_time=None, freq="day", freq_sam=None, future=False): self.conn.send_request( - request_type="calendar", + request_type="trade_calendar", request_content={ "start_time": str(start_time), "end_time": str(end_time), @@ -990,7 +990,7 @@ class LocalProvider(BaseProvider): :param type: The type of resource for the uri :param **kwargs: """ - if type == "calendar": + if type == "trade_calendar": return Cal._uri(**kwargs) elif type == "instrument": return Inst._uri(**kwargs) diff --git a/qlib/rl/env.py b/qlib/rl/env.py index faf9c026e..3a77d2295 100644 --- a/qlib/rl/env.py +++ b/qlib/rl/env.py @@ -3,8 +3,9 @@ from typing import Union + +from ..backtest.executor import BaseExecutor from .interpreter import StateInterpreter, ActionInterpreter -from ..contrib.backtest.executor import BaseExecutor from ..utils import init_instance_by_config from .interpreter import BaseInterpreter diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 59d9d72e3..7828db609 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,15 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import copy -import pandas as pd -from typing import List, Union - - from ..model.base import BaseModel from ..data.dataset import DatasetH from ..data.dataset.utils import convert_index_format -from ..contrib.backtest.order import Order from ..rl.interpreter import ActionInterpreter, StateInterpreter from ..utils import init_instance_by_config @@ -44,8 +38,8 @@ class BaseStrategy: else: self.level_infra.update(level_infra) - if "calendar" in level_infra: - self.calendar = level_infra.get("calendar") + if "trade_calendar" in level_infra: + self.trade_calendar = level_infra.get("trade_calendar") def reset_common_infra(self, common_infra): if not hasattr(self, "common_infra"): @@ -83,12 +77,6 @@ class BaseStrategy: raise NotImplementedError("generate_trade_decision is not implemented!") -class RuleStrategy(BaseStrategy): - """Rule-based Trading strategy""" - - pass - - class ModelStrategy(BaseStrategy): """Model-based trading strategy, use model to make predictions for trading""" diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index b121b6130..cdac48533 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -40,7 +40,7 @@ def parse_freq(freq: str) -> Tuple[int, str]: raise ValueError( "freq format is not supported, the freq should be like (n)month/mon, (n)week/w, (n)day/d, (n)minute/min" ) - _count = int(match_obj.group(1)) if match_obj.group(1) is None else 1 + _count = int(match_obj.group(1)) if match_obj.group(1) else 1 _freq = match_obj.group(2) _freq_format_dict = { "month": "month", @@ -58,7 +58,8 @@ def parse_freq(freq: str) -> Tuple[int, str]: def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np.ndarray: """ Resample the calendar with frequency freq_raw into the calendar with frequency freq_sam - Assumption: The fix length (240) of the calendar in each day. + Assumption: + - Fix length (240) of the calendar in each day. Parameters ---------- @@ -83,16 +84,19 @@ def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np if freq_sam == "minute": def cal_sam_minute(x, sam_minutes): + """ + Sample raw calendar into calendar with sam_minutes freq, shift represents the shift minute the market time + - open time of stock market is [9:30 - shift*pd.Timedelta(minutes=1)] + - mid close time of stock market is [11:29 - shift*pd.Timedelta(minutes=1)] + - mid open time of stock market is [13:00 - shift*pd.Timedelta(minutes=1)] + - close time of stock market is [14:59 - shift*pd.Timedelta(minutes=1)] + """ day_time = pd.Timestamp(x.date()) shift = C.min_data_shift - # shift represents the shift minute the market time - # - open time of stock market is [9:30 - shift*pd.Timedelta(minutes=1)] - # - mid close time of stock market is [11:29 - shift*pd.Timedelta(minutes=1)] - # - mid open time of stock market is [13:30 - shift*pd.Timedelta(minutes=1)] - # - close time of stock market is [14:59 - shift*pd.Timedelta(minutes=1)] + open_time = day_time + pd.Timedelta(hours=9, minutes=30) - shift * pd.Timedelta(minutes=1) mid_close_time = day_time + pd.Timedelta(hours=11, minutes=29) - shift * pd.Timedelta(minutes=1) - mid_open_time = day_time + pd.Timedelta(hours=13, minutes=30) - shift * pd.Timedelta(minutes=1) + mid_open_time = day_time + pd.Timedelta(hours=13, minutes=00) - shift * pd.Timedelta(minutes=1) close_time = day_time + pd.Timedelta(hours=14, minutes=59) - shift * pd.Timedelta(minutes=1) if open_time <= x <= mid_close_time: @@ -101,7 +105,6 @@ def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np minute_index = (x - mid_open_time).seconds // 60 + 120 else: raise ValueError("datetime of calendar is out of range") - minute_index = minute_index // sam_minutes * sam_minutes if 0 <= minute_index < 120: @@ -109,7 +112,7 @@ def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np elif 120 <= minute_index < 240: return mid_open_time + (minute_index - 120) * pd.Timedelta(minutes=1) else: - raise ValueError("calendar minute_index error") + raise ValueError("calendar minute_index error, check `min_data_shift` in qlib.config.C") if freq_raw != "minute": raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") @@ -189,11 +192,13 @@ def get_resam_calendar( freq = "day" except ValueError: _calendar = Cal.calendar( - start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, future=future + start_time=start_time, end_time=end_time, freq="1min", freq_sam=freq, future=future ) freq = "min" elif norm_freq == "minute": - _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq="min", freq_sam=freq, future=future) + _calendar = Cal.calendar( + start_time=start_time, end_time=end_time, freq="1min", freq_sam=freq, future=future + ) freq = "min" else: raise ValueError(f"freq {freq} is not supported") diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index a32ef9729..8abcd6c14 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -8,10 +8,10 @@ import pandas as pd from pathlib import Path from pprint import pprint from ..contrib.evaluate import risk_analysis -from ..contrib.backtest import backtest as normal_backtest from ..data.dataset import DatasetH from ..data.dataset.handler import DataHandlerLP +from ..backtest import backtest as normal_backtest from ..utils import init_instance_by_config, get_module_by_module_path from ..log import get_module_logger from ..utils import flatten_dict From c26bee126bc920654b5ab9526a90314bd835c595 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Fri, 28 May 2021 17:31:08 +0800 Subject: [PATCH 028/187] Support loading for backtest --- examples/multi_level_trading/workflow.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index 8096fc76f..2b70d4411 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from typing import Optional import qlib import fire @@ -124,11 +125,17 @@ class MultiLevelTradingWorkflow: sr = SignalRecord(model, dataset, recorder) sr.generate() - def backtest(self): + def _load_model(self, load): + return R.get_recorder(load, experiment_name="train").load_object("params.pkl") + + def backtest(self, load_model: Optional[str] = None): self._init_qlib() model = init_instance_by_config(self.task["model"]) dataset = init_instance_by_config(self.task["dataset"]) - self._train_model(model, dataset) + if load_model is None: + self._train_model(model, dataset) + else: + model = self._load_model(load_model) strategy_config = { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", From 029b63c9ddc75aeb22243d4e79092c564677ea25 Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 28 May 2021 22:29:21 +0800 Subject: [PATCH 029/187] fix bugs & add highfreq backtest example --- examples/multi_level_trading/README.md | 17 +++- examples/multi_level_trading/workflow.py | 109 +++++++++++++++++++++-- qlib/backtest/executor.py | 4 +- qlib/backtest/utils.py | 4 +- qlib/contrib/strategy/model_strategy.py | 3 + qlib/contrib/strategy/rule_strategy.py | 56 +++++++----- qlib/strategy/base.py | 15 ++-- qlib/utils/resam.py | 2 + 8 files changed, 168 insertions(+), 42 deletions(-) diff --git a/examples/multi_level_trading/README.md b/examples/multi_level_trading/README.md index 6761b84ff..2910de58f 100644 --- a/examples/multi_level_trading/README.md +++ b/examples/multi_level_trading/README.md @@ -8,9 +8,12 @@ Qlib supports backtesting of various strategies, including portfolio management And, Qlib also supports multi-level trading and backtesting. It means that users can use different strategies to trade at different frequencies. -This example uses a DropoutTopkStrategy (a strategy based on the daily frequency Lightgbm model) in weekly frequency for portfolio generation. And, at the daily frequency level, this example uses SBBStrategyEMA (a rule-based strategy that uses EMA for decision-making) to split orders. -## Usage +## Weekly Portfolio Generation and Daily Order Execution + +This workflow provides an example that uses a DropoutTopkStrategy (a strategy based on the daily frequency Lightgbm model) in weekly frequency for portfolio generation and uses SBBStrategyEMA (a rule-based strategy that uses EMA for decision-making) to execute orders in daily frequency. + +### Usage Start backtesting by running the following command: ```bash @@ -22,3 +25,13 @@ Start collecting data by running the following command: python workflow.py collect_data ``` +## Daily Portfolio Generation and Minutely Order Execution + +This workflow also provides a high-frequency example that uses a DropoutTopkStrategy for portfolio generation in daily frequency and uses SBBStrategyEMA to execute orders in minutely frequency. + +### Usage + +Start backtesting by running the following command: +```bash + python workflow.py backtest_highfreq +``` \ No newline at end of file diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index 8096fc76f..08c91936a 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -4,8 +4,9 @@ import qlib import fire -from qlib.config import REG_CN - +from qlib import backtest +from qlib.config import REG_CN, HIGH_FREQ_CONFIG +from qlib.data import D from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict from qlib.workflow import R from qlib.workflow.record_temp import SignalRecord, PortAnaRecord @@ -20,7 +21,7 @@ class MultiLevelTradingWorkflow: data_handler_config = { "start_time": "2008-01-01", - "end_time": "2020-08-01", + "end_time": "2021-01-20", "fit_start_time": "2008-01-01", "fit_end_time": "2014-12-31", "instruments": market, @@ -54,15 +55,12 @@ class MultiLevelTradingWorkflow: "segments": { "train": ("2008-01-01", "2014-12-31"), "valid": ("2015-01-01", "2016-12-31"), - "test": ("2017-01-01", "2020-08-01"), + "test": ("2017-01-01", "2021-01-20"), }, }, }, } - trade_start_time = "2017-01-01" - trade_end_time = "2020-08-01" - port_analysis_config = { "executor": { "class": "NestedExecutor", @@ -86,12 +84,13 @@ class MultiLevelTradingWorkflow: "instruments": market, }, }, + "generate_report": True, "track_data": True, }, }, "backtest": { - "start_time": trade_start_time, - "end_time": trade_end_time, + "start_time": "2017-01-01", + "end_time": "2020-08-01", "account": 100000000, "benchmark": benchmark, "exchange_kwargs": { @@ -167,6 +166,98 @@ class MultiLevelTradingWorkflow: for trade_decision in data_generator: print(trade_decision) + def _init_qlib_with_backend(self): + provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") + if not exists_qlib_data(provider_uri_1min): + print(f"Qlib data is not found in {provider_uri_1min}") + GetData().qlib_data(target_dir=provider_uri_1min, interval="1min", region=REG_CN) + + # TODO: update new data + # provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir + # if not exists_qlib_data(provider_uri_day): + # print(f"Qlib data is not found in {provider_uri_day}") + # GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN) + provider_uri_day = "/data/csdesign/qlib" + provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} + client_config = { + "calendar_provider": { + "class": "LocalCalendarProvider", + "module_path": "qlib.data.data", + "kwargs": { + "backend": { + "class": "FileCalendarStorage", + "module_path": "qlib.data.storage.file_storage", + "kwargs": {"provider_uri_map": provider_uri_map}, + } + }, + }, + "feature_provider": { + "class": "LocalFeatureProvider", + "module_path": "qlib.data.data", + "kwargs": { + "backend": { + "class": "FileFeatureStorage", + "module_path": "qlib.data.storage.file_storage", + "kwargs": {"provider_uri_map": provider_uri_map}, + } + }, + }, + } + qlib.init(provider_uri=provider_uri_day, **client_config) + + def _get_highfreq_config(self, model, dataset): + + executor_config = self.port_analysis_config["executor"] + # update executor with hierarchical decison freq ["day", "1min"] + executor_config["kwargs"]["time_per_step"] = "day" + executor_config["kwargs"]["inner_executor"]["kwargs"]["time_per_step"] = "1min" + backtest_config = self.port_analysis_config["backtest"] + + # yahoo highfreq data time + backtest_config["start_time"] = "2020-09-20" + backtest_config["end_time"] = "2021-01-20" + + # update benchmark, yahoo data don't have SH000300 + instruments = D.instruments(market="csi300") + instrument_list = D.list_instruments(instruments=instruments, as_list=True) + backtest_config["benchmark"] = instrument_list + + # update exchange config + backtest_config["exchange_kwargs"]["freq"] = "1min" + + # set strategy + strategy_config = { + "class": "TopkDropoutStrategy", + "module_path": "qlib.contrib.strategy.model_strategy", + "kwargs": { + "model": model, + "dataset": dataset, + "topk": 50, + "n_drop": 5, + }, + } + + return executor_config, strategy_config, backtest_config + + def backtest_highfreq(self): + self._init_qlib_with_backend() + model = init_instance_by_config(self.task["model"]) + dataset = init_instance_by_config(self.task["dataset"]) + self._train_model(model, dataset) + executor_config, strategy_config, backtest_config = self._get_highfreq_config(model, dataset) + + highfreq_port_analysis_config = { + "executor": executor_config, + "strategy": strategy_config, + "backtest": backtest_config, + } + + with R.start(experiment_name="backtest_highfreq"): + + recorder = R.get_recorder() + par = PortAnaRecord(recorder, highfreq_port_analysis_config, "day") + par.generate() + if __name__ == "__main__": fire.Fire(MultiLevelTradingWorkflow) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 88a219f41..c51fc4d9d 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -304,7 +304,7 @@ class SimulatorExecutor(BaseExecutor): if self.verbose: if order.direction == Order.SELL: # sell print( - "[I {:%Y-%m-%d}]: sell {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( + "[I {:%Y-%m-%d %H:%M:%S}]: sell {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( trade_start_time, order.stock_id, trade_price, @@ -316,7 +316,7 @@ class SimulatorExecutor(BaseExecutor): ) else: print( - "[I {:%Y-%m-%d}]: buy {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( + "[I {:%Y-%m-%d %H:%M:%S}]: buy {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( trade_start_time, order.stock_id, trade_price, diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index fe51c99f3..f66fa091d 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -41,7 +41,7 @@ class TradeCalendarManager: - self.trade_step : The number of trading step finished, self.trade_step can be [0, 1, 2, ..., self.trade_len - 1] """ _calendar, freq, freq_sam = get_resam_calendar(freq=freq) - self.trade_calendar = _calendar + self._calendar = _calendar _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) self.start_index = _start_index self.end_index = _end_index @@ -91,7 +91,7 @@ class TradeCalendarManager: """ trade_step = trade_step - shift calendar_index = self.start_index + trade_step - return self.trade_calendar[calendar_index], self.trade_calendar[calendar_index + 1] - pd.Timedelta(seconds=1) + return self._calendar[calendar_index], self._calendar[calendar_index + 1] - pd.Timedelta(seconds=1) def get_all_time(self): """Get the start_time and end_time for trading""" diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index d563bccea..3a1087be4 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -93,6 +93,9 @@ class TopkDropoutStrategy(ModelStrategy): trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") + print( + trade_step, pred_start_time, pred_end_time, trade_start_time, trade_end_time, pred_score, self.pred_scores + ) if pred_score is None: return [] if self.only_tradable: diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 24873caae..a85b81636 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -53,7 +53,7 @@ class TWAPStrategy(BaseStrategy): outer_trade_decision : object, optional """ - super(TWAPStrategy, self).reset(outer_trade_decision=outer_trade_decision, common_infra=common_infra, **kwargs) + super(TWAPStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: self.trade_amount = {} for order in outer_trade_decision: @@ -73,21 +73,24 @@ class TWAPStrategy(BaseStrategy): trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) order_list = [] for order in self.outer_trade_decision: + # if not tradable, continue if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): continue _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) _order_amount = None - # consider trade unit + # considering trade unit if _amount_trade_unit is None: - # divide the order equally + # divide the order into equal parts, and trade one part _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step + 1) # without considering trade unit elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: - # divide the order equally - # floor((trade_unit_cnt + trade_len - trade_step) / (trade_len - trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - trade_step + 1)) + # divide the order into equal parts, and trade one part + # calculate the total count of trade units to trade trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + # calculate the amount of one part, ceil the amount + # floor((trade_unit_cnt + trade_len - trade_step) / (trade_len - trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - trade_step + 1)) _order_amount = ( (trade_unit_cnt + trade_len - trade_step) // (trade_len - trade_step + 1) * _amount_trade_unit ) @@ -144,6 +147,14 @@ class SBBStrategyBase(BaseStrategy): self.trade_exchange = trade_exchange def reset_common_infra(self, common_infra): + """ + Parameters + ---------- + common_infra : dict, optional + common infrastructure for backtesting, by default None + - It should include `trade_account`, used to get position + - It should include `trade_exchange`, used to provide market info + """ super(SBBStrategyBase, self).reset_common_infra(common_infra) if common_infra is not None: if "trade_exchange" in common_infra: @@ -154,10 +165,6 @@ class SBBStrategyBase(BaseStrategy): Parameters ---------- outer_trade_decision : object, optional - common_infra : None, optional - common infrastructure for backtesting, by default None - - It should include `trade_account`, used to get position - - It should include `trade_exchange`, used to provide market info """ super(SBBStrategyBase, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: @@ -186,10 +193,12 @@ class SBBStrategyBase(BaseStrategy): order_list = [] # for each order in in self.outer_trade_decision for order in self.outer_trade_decision: - # predict the price trend + # get the price trend if trade_step % 2 == 0: + # in the first of two adjacent bars, predict the price trend _pred_trend = self._pred_price_trend(order.stock_id, pred_start_time, pred_end_time) else: + # in the second of two adjacent bars, use the trend predicted in the first one _pred_trend = self.trade_trend[(order.stock_id, order.direction)] # if not tradable, continue if not self.trade_exchange.is_stock_tradable( @@ -204,13 +213,14 @@ class SBBStrategyBase(BaseStrategy): _order_amount = None # considering trade unit if _amount_trade_unit is None: - # divide the order equally + # divide the order into equal parts, and trade one part _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step) # without considering trade unit elif self.trade_amount[(order.stock_id, order.direction)] >= _amount_trade_unit: - # cal how many trade unit + # divide the order into equal parts, and trade one part + # calculate the total count of trade units to trade trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) - # divide the order equally + # calculate the amount of one part, ceil the amount # floor((trade_unit_cnt + trade_len - trade_step - 1) / (trade_len - trade_step)) == ceil(trade_unit_cnt / (trade_len - trade_step)) _order_amount = ( (trade_unit_cnt + trade_len - trade_step - 1) // (trade_len - trade_step) * _amount_trade_unit @@ -262,9 +272,9 @@ class SBBStrategyBase(BaseStrategy): if _order_amount: _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) if trade_step % 2 == 0: - # in the first of two adjacent bar + # in the first one of two adjacent bars # if look short on the price, sell the stock more - # if look long on the price, sell the stock more + # if look long on the price, buy the stock more if ( _pred_trend == self.TREND_SHORT and order.direction == order.SELL @@ -281,7 +291,7 @@ class SBBStrategyBase(BaseStrategy): ) order_list.append(_order) else: - # in the second of two adjacent bar + # in the second one of two adjacent bars # if look short on the price, buy the stock more # if look long on the price, sell the stock more if ( @@ -301,6 +311,7 @@ class SBBStrategyBase(BaseStrategy): order_list.append(_order) if trade_step % 2 == 0: + # in the first one of two adjacent bars, store the trend for the second one to use self.trade_trend[(order.stock_id, order.direction)] = _pred_trend return order_list @@ -328,7 +339,7 @@ class SBBStrategyEMA(SBBStrategyBase): instruments of EMA signal, by default "csi300" freq : str, optional freq of EMA signal, by default "day" - Note: `freq` may be different from `steb_bar` + Note: `freq` may be different from `time_per_step` """ if instruments is None: warnings.warn("`instruments` is not set, will load all stocks") @@ -349,8 +360,10 @@ class SBBStrategyEMA(SBBStrategyBase): signal_df = convert_index_format(signal_df) signal_df.columns = ["signal"] self.signal = {} - for stock_id, stock_val in signal_df.groupby(level="instrument"): - self.signal[stock_id] = stock_val + + if not signal_df.empty: + for stock_id, stock_val in signal_df.groupby(level="instrument"): + self.signal[stock_id] = stock_val def reset_level_infra(self, level_infra): """ @@ -367,16 +380,19 @@ class SBBStrategyEMA(SBBStrategyBase): self._reset_signal() def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): - + # if no signal, return mid trend if stock_id not in self.signal: return self.TREND_MID else: _sample_signal = resam_ts_data( self.signal[stock_id]["signal"], pred_start_time, pred_end_time, method="last" ) + # if EMA signal == 0 or None, return mid trend if _sample_signal is None or _sample_signal.iloc[0] == 0: return self.TREND_MID + # if EMA signal > 0, return long trend elif _sample_signal.iloc[0] > 0: return self.TREND_LONG + # if EMA signal > 0, return short trend else: return self.TREND_SHORT diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 7828db609..f04bcb097 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from typing import Union from ..model.base import BaseModel from ..data.dataset import DatasetH @@ -141,8 +142,8 @@ class RLIntStrategy(RLStrategy): def __init__( self, policy, - state_interpreter: StateInterpreter, - action_interpreter: ActionInterpreter, + state_interpreter: Union[dict, StateInterpreter], + action_interpreter: Union[dict, ActionInterpreter], outer_trade_decision: object = None, level_infra: dict = {}, common_infra: dict = {}, @@ -151,9 +152,9 @@ class RLIntStrategy(RLStrategy): """ Parameters ---------- - state_interpreter : StateInterpreter - interpretor that interprets the qlib execute result into rl env state. - action_interpreter : ActionInterpreter + state_interpreter : Union[dict, StateInterpreter] + interpretor that interprets the qlib execute result into rl env state + action_interpreter : Union[dict, ActionInterpreter] interpretor that interprets the rl agent action into qlib order list start_time : Union[str, pd.Timestamp], optional start time of trading, by default None @@ -163,8 +164,8 @@ class RLIntStrategy(RLStrategy): super(RLIntStrategy, self).__init__(policy, outer_trade_decision, level_infra, common_infra, **kwargs) self.policy = policy - self.state_interpreter = init_instance_by_config(state_interpreter) - self.action_interpreter = init_instance_by_config(action_interpreter) + self.state_interpreter = init_instance_by_config(state_interpreter, accept_types=StateInterpreter) + self.action_interpreter = init_instance_by_config(action_interpreter, accept_types=ActionInterpreter) def generate_trade_decision(self, execute_result=None): _interpret_state = self.state_interpretor.interpret(execute_result=execute_result) diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index cdac48533..026870077 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -288,11 +288,13 @@ def resam_ts_data( from ..data.dataset.utils import get_level_index feature = lazy_sort_index(ts_feature) + datetime_level = get_level_index(feature, level="datetime") == 0 if datetime_level: feature = feature.loc[selector_datetime] else: feature = feature.loc[(slice(None), selector_datetime)] + if feature.empty: return None if isinstance(feature.index, pd.MultiIndex): From 96e393b599c718931af3d8b5289c17c6aafbcf13 Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 28 May 2021 22:32:33 +0800 Subject: [PATCH 030/187] del DEBUG log --- qlib/contrib/strategy/model_strategy.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 3a1087be4..d563bccea 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -93,9 +93,6 @@ class TopkDropoutStrategy(ModelStrategy): trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") - print( - trade_step, pred_start_time, pred_end_time, trade_start_time, trade_end_time, pred_score, self.pred_scores - ) if pred_score is None: return [] if self.only_tradable: From bf3b757294772f635798b42e27064e75afe01558 Mon Sep 17 00:00:00 2001 From: bxdd Date: Sat, 29 May 2021 00:31:40 +0800 Subject: [PATCH 031/187] fix bugs --- examples/multi_level_trading/workflow.py | 12 ++++++------ qlib/backtest/report.py | 4 ++-- qlib/contrib/strategy/model_strategy.py | 2 -- qlib/utils/resam.py | 8 ++++---- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/examples/multi_level_trading/workflow.py b/examples/multi_level_trading/workflow.py index 08c91936a..531b88f64 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/multi_level_trading/workflow.py @@ -173,11 +173,11 @@ class MultiLevelTradingWorkflow: GetData().qlib_data(target_dir=provider_uri_1min, interval="1min", region=REG_CN) # TODO: update new data - # provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir - # if not exists_qlib_data(provider_uri_day): - # print(f"Qlib data is not found in {provider_uri_day}") - # GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN) - provider_uri_day = "/data/csdesign/qlib" + provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir + if not exists_qlib_data(provider_uri_day): + print(f"Qlib data is not found in {provider_uri_day}") + GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN) + provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} client_config = { "calendar_provider": { @@ -210,7 +210,7 @@ class MultiLevelTradingWorkflow: executor_config = self.port_analysis_config["executor"] # update executor with hierarchical decison freq ["day", "1min"] executor_config["kwargs"]["time_per_step"] = "day" - executor_config["kwargs"]["inner_executor"]["kwargs"]["time_per_step"] = "1min" + executor_config["kwargs"]["inner_executor"]["kwargs"]["time_per_step"] = "15min" backtest_config = self.port_analysis_config["backtest"] # yahoo highfreq data time diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index c26c46f9d..4b9b0ce26 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -80,12 +80,12 @@ class Report: fields = ["$close/Ref($close,1)-1"] try: _temp_result = D.features(_codes, fields, start_time, end_time, freq=freq, disk_cache=1) - except ValueError: + except (ValueError, KeyError): _, norm_freq = parse_freq(freq) if norm_freq in ["month", "week", "day"]: try: _temp_result = D.features(_codes, fields, start_time, end_time, freq="day", disk_cache=1) - except ValueError: + except (ValueError, KeyError): _temp_result = D.features(_codes, fields, start_time, end_time, freq="1min", disk_cache=1) elif norm_freq == "minute": _temp_result = D.features(_codes, fields, start_time, end_time, freq="1min", disk_cache=1) diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index d563bccea..9125329d4 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -177,8 +177,6 @@ class TopkDropoutStrategy(ModelStrategy): # Get the stock list we really want to buy buy = today[: len(sell) + self.topk - len(last)] - # print("INTRANEL BAR", len(sell), len(sell) + self.topk - len(last), len(last)) - # print("flag", len(sell), len(buy), self.topk, len(last)) for code in current_stock_list: if not self.trade_exchange.is_stock_tradable( stock_id=code, start_time=trade_start_time, end_time=trade_end_time diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index 026870077..71e0aa654 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -182,7 +182,7 @@ def get_resam_calendar( try: _calendar = Cal.calendar(start_time=start_time, end_time=end_time, freq=freq, future=future) freq, freq_sam = freq, None - except ValueError: + except (ValueError, KeyError): freq_sam = freq if norm_freq in ["month", "week", "day"]: try: @@ -190,16 +190,16 @@ def get_resam_calendar( start_time=start_time, end_time=end_time, freq="day", freq_sam=freq, future=future ) freq = "day" - except ValueError: + except (ValueError, KeyError): _calendar = Cal.calendar( start_time=start_time, end_time=end_time, freq="1min", freq_sam=freq, future=future ) - freq = "min" + freq = "1min" elif norm_freq == "minute": _calendar = Cal.calendar( start_time=start_time, end_time=end_time, freq="1min", freq_sam=freq, future=future ) - freq = "min" + freq = "1min" else: raise ValueError(f"freq {freq} is not supported") return _calendar, freq, freq_sam From 60e082e44662da769d76a07e7d811b8818ca97bb Mon Sep 17 00:00:00 2001 From: bxdd Date: Mon, 31 May 2021 20:40:11 +0800 Subject: [PATCH 032/187] add infra interface & fix no KeyboardInterpret bug --- .../README.md | 11 +--- .../workflow.py | 7 ++- qlib/backtest/__init__.py | 6 +-- qlib/backtest/executor.py | 20 +++---- qlib/backtest/utils.py | 44 +++++++++++++++ qlib/contrib/strategy/cost_control.py | 4 +- qlib/contrib/strategy/model_strategy.py | 12 ++--- qlib/contrib/strategy/rule_strategy.py | 54 ++++++++++--------- qlib/strategy/base.py | 33 +++++++----- qlib/workflow/utils.py | 1 + 10 files changed, 120 insertions(+), 72 deletions(-) rename examples/{multi_level_trading => nested_decision_execution}/README.md (67%) rename examples/{multi_level_trading => nested_decision_execution}/workflow.py (98%) diff --git a/examples/multi_level_trading/README.md b/examples/nested_decision_execution/README.md similarity index 67% rename from examples/multi_level_trading/README.md rename to examples/nested_decision_execution/README.md index 2910de58f..312f94d31 100644 --- a/examples/multi_level_trading/README.md +++ b/examples/nested_decision_execution/README.md @@ -1,13 +1,6 @@ -# Multi-level Trading - -This worflow is an example for multi-level trading. - -## Introduction - -Qlib supports backtesting of various strategies, including portfolio management strategies, order split strategies, model-based strategies (such as deep learning models), rule-based strategies, and RL-based strategies. - -And, Qlib also supports multi-level trading and backtesting. It means that users can use different strategies to trade at different frequencies. +# Nested Decision Execution +This worflow is an example for nested decision execution in backtesting. Qlib supports nested decision execution in backtesting. It means that users can use different strategies to make trade decision in different frequencies. ## Weekly Portfolio Generation and Daily Order Execution diff --git a/examples/multi_level_trading/workflow.py b/examples/nested_decision_execution/workflow.py similarity index 98% rename from examples/multi_level_trading/workflow.py rename to examples/nested_decision_execution/workflow.py index 531b88f64..b8e9e5fb5 100644 --- a/examples/multi_level_trading/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -4,7 +4,6 @@ import qlib import fire -from qlib import backtest from qlib.config import REG_CN, HIGH_FREQ_CONFIG from qlib.data import D from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict @@ -14,7 +13,7 @@ from qlib.tests.data import GetData from qlib.backtest import collect_data -class MultiLevelTradingWorkflow: +class NestedDecisonExecutionWorkflow: market = "csi300" benchmark = "SH000300" @@ -172,7 +171,7 @@ class MultiLevelTradingWorkflow: print(f"Qlib data is not found in {provider_uri_1min}") GetData().qlib_data(target_dir=provider_uri_1min, interval="1min", region=REG_CN) - # TODO: update new data + # TODO: update latest data provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir if not exists_qlib_data(provider_uri_day): print(f"Qlib data is not found in {provider_uri_day}") @@ -260,4 +259,4 @@ class MultiLevelTradingWorkflow: if __name__ == "__main__": - fire.Fire(MultiLevelTradingWorkflow) + fire.Fire(NestedDecisonExecutionWorkflow) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 12db0a314..33c2cb2d8 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -7,6 +7,7 @@ from .executor import BaseExecutor from .backtest import backtest as backtest_func from .backtest import collect_data as data_generator +from .utils import CommonInfrastructure from ..strategy.base import BaseStrategy from ..utils import init_instance_by_config from ..log import get_module_logger @@ -101,10 +102,7 @@ def get_strategy_executor( ) trade_exchange = get_exchange(**exchange_kwargs) - common_infra = { - "trade_account": trade_account, - "trade_exchange": trade_exchange, - } + common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=trade_exchange) trade_strategy = init_instance_by_config(strategy, accept_types=BaseStrategy, common_infra=common_infra) trade_executor = init_instance_by_config(executor, accept_types=BaseExecutor, common_infra=common_infra) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index c51fc4d9d..1cc198bf6 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -9,7 +9,7 @@ from ..utils.resam import parse_freq from .order import Order from .exchange import Exchange -from .utils import TradeCalendarManager +from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure class BaseExecutor: @@ -23,7 +23,7 @@ class BaseExecutor: generate_report: bool = False, verbose: bool = False, track_data: bool = False, - common_infra: dict = {}, + common_infra: CommonInfrastructure = None, **kwargs, ): """ @@ -39,7 +39,7 @@ class BaseExecutor: whether to generate trade_decision, will be used when making data for multi-level training - If `self.track_data` is true, when making data for training, the input `trade_decision` of `execute` will be generated by `collect_data` - Else, `trade_decision` will not be generated - common_infra : dict, optional: + common_infra : CommonInfrastructure, optional: common infrastructure for backtesting, may including: - trade_account : Account, optional trade account for trading @@ -63,11 +63,11 @@ class BaseExecutor: else: self.common_infra.update(common_infra) - if "trade_account" in common_infra: + if common_infra.has("trade_account"): self.trade_account = copy.copy(common_infra.get("trade_account")) self.trade_account.reset(freq=self.time_per_step, init_report=True) - def reset(self, track_data: bool = None, common_infra: dict = None, **kwargs): + def reset(self, track_data: bool = None, common_infra: CommonInfrastructure = None, **kwargs): """ - reset `start_time` and `end_time`, used in trade calendar - reset `track_data`, used when making data for multi-level training @@ -88,7 +88,7 @@ class BaseExecutor: self.reset_common_infra(common_infra) def get_level_infra(self): - return {"trade_calendar": self.trade_calendar} + return LevelInfrastructure(trade_calendar=self.trade_calendar) def finished(self): return self.trade_calendar.finished() @@ -138,7 +138,7 @@ class NestedExecutor(BaseExecutor): verbose: bool = False, track_data: bool = False, trade_exchange: Exchange = None, - common_infra: dict = {}, + common_infra: CommonInfrastructure = None, **kwargs, ): """ @@ -182,7 +182,7 @@ class NestedExecutor(BaseExecutor): """ super(NestedExecutor, self).reset_common_infra(common_infra) - if self.generate_report and "trade_exchange" in common_infra: + if self.generate_report and common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") self.inner_executor.reset_common_infra(common_infra) @@ -257,7 +257,7 @@ class SimulatorExecutor(BaseExecutor): verbose: bool = False, track_data: bool = False, trade_exchange: Exchange = None, - common_infra: dict = {}, + common_infra: CommonInfrastructure = None, **kwargs, ): """ @@ -286,7 +286,7 @@ class SimulatorExecutor(BaseExecutor): - reset trade_exchange """ super(SimulatorExecutor, self).reset_common_infra(common_infra) - if "trade_exchange" in common_infra: + if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") def execute(self, trade_decision): diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index f66fa091d..8582cfe28 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import pandas as pd +import warnings from typing import Union from ..utils.resam import get_resam_calendar @@ -96,3 +97,46 @@ class TradeCalendarManager: def get_all_time(self): """Get the start_time and end_time for trading""" return self.start_time, self.end_time + + +class BaseInfrastructure: + def __init__(self, **kwargs): + self.reset_infra(**kwargs) + + def get_support_infra(self): + raise NotImplementedError("`get_support_infra` is not implemented!") + + def reset_infra(self, **kwargs): + support_infra = self.get_support_infra() + for k, v in kwargs.items(): + if k in support_infra: + setattr(self, k, v) + else: + warnings.warn(f"{k} is ignored in `reset_infra`!") + + def get(self, infra_name): + if hasattr(self, infra_name): + return getattr(self, infra_name) + else: + warnings.warn(f"infra {infra_name} is not found!") + + def has(self, infra_name): + if infra_name in self.get_support_infra() and hasattr(self, infra_name): + return True + else: + return False + + def update(self, other): + support_infra = other.get_support_infra() + infra_dict = {_infra: getattr(other, _infra) for _infra in support_infra if hasattr(other, _infra)} + self.reset_infra(**infra_dict) + + +class CommonInfrastructure(BaseInfrastructure): + def get_support_infra(self): + return ["trade_account", "trade_exchange"] + + +class LevelInfrastructure(BaseInfrastructure): + def get_support_infra(self): + return ["trade_calendar"] diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index e7f6cce04..88e35b2e4 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -18,8 +18,8 @@ class SoftTopkStrategy(WeightStrategyBase): risk_degree=0.95, buy_method="first_fill", trade_exchange=None, - level_infra={}, - common_infra={}, + level_infra=None, + common_infra=None, **kwargs, ): """Parameter diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 9125329d4..ba1e3c785 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -22,8 +22,8 @@ class TopkDropoutStrategy(ModelStrategy): hold_thresh=1, only_tradable=False, trade_exchange=None, - level_infra={}, - common_infra={}, + level_infra=None, + common_infra=None, **kwargs, ): """ @@ -76,7 +76,7 @@ class TopkDropoutStrategy(ModelStrategy): """ super(TopkDropoutStrategy, self).reset_common_infra(common_infra) - if "trade_exchange" in common_infra: + if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") def get_risk_degree(self, trade_step=None): @@ -249,8 +249,8 @@ class WeightStrategyBase(ModelStrategy): dataset, order_generator_cls_or_obj=OrderGenWInteract, trade_exchange=None, - level_infra={}, - common_infra={}, + level_infra=None, + common_infra=None, **kwargs, ): super(WeightStrategyBase, self).__init__( @@ -274,7 +274,7 @@ class WeightStrategyBase(ModelStrategy): """ super(WeightStrategyBase, self).reset_common_infra(common_infra) - if "trade_exchange" in common_infra: + if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") def get_risk_degree(self, trade_step=None): diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index a85b81636..b72f32c29 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -1,4 +1,5 @@ import warnings +from typing import List, Union from ...utils.resam import resam_ts_data from ...data.data import D @@ -6,6 +7,7 @@ from ...data.dataset.utils import convert_index_format from ...strategy.base import BaseStrategy from ...backtest.order import Order from ...backtest.exchange import Exchange +from ...backtest.utils import CommonInfrastructure, LevelInfrastructure class TWAPStrategy(BaseStrategy): @@ -13,17 +15,20 @@ class TWAPStrategy(BaseStrategy): def __init__( self, - outer_trade_decision: object = None, + outer_trade_decision: List[Order] = None, trade_exchange: Exchange = None, - level_infra: dict = {}, - common_infra: dict = {}, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, ): """ Parameters ---------- + outer_trade_decision : List[Order] + the trade decison of outer strategy which this startegy relies, it should be List[Order] in TWAPStrategy trade_exchange : Exchange exchange that provides market info, used to deal order and generate report - If `trade_exchange` is None, self.trade_exchange will be set with common_infra + """ super(TWAPStrategy, self).__init__( outer_trade_decision=outer_trade_decision, level_infra=level_infra, common_infra=common_infra @@ -36,21 +41,21 @@ class TWAPStrategy(BaseStrategy): """ Parameters ---------- - common_infra : dict, optional + common_infra : CommonInfrastructure, optional common infrastructure for backtesting, by default None - It should include `trade_account`, used to get position - It should include `trade_exchange`, used to provide market info """ super(TWAPStrategy, self).reset_common_infra(common_infra) - if common_infra is not None: - if "trade_exchange" in common_infra: - self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision: object = None, **kwargs): + if common_infra.has("trade_exchange"): + self.trade_exchange = common_infra.get("trade_exchange") + + def reset(self, outer_trade_decision: List[Order] = None, **kwargs): """ Parameters ---------- - outer_trade_decision : object, optional + outer_trade_decision : List[Order], optional """ super(TWAPStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) @@ -127,14 +132,16 @@ class SBBStrategyBase(BaseStrategy): def __init__( self, - outer_trade_decision: object = None, + outer_trade_decision: List[Order] = None, trade_exchange: Exchange = None, - level_infra: dict = {}, - common_infra: dict = {}, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, ): """ Parameters ---------- + outer_trade_decision : List[Order] + the trade decison of outer strategy which this startegy relies, it should be List[Order] in SBBStrategyBase trade_exchange : Exchange exchange that provides market info, used to deal order and generate report - If `trade_exchange` is None, self.trade_exchange will be set with common_infra @@ -156,15 +163,14 @@ class SBBStrategyBase(BaseStrategy): - It should include `trade_exchange`, used to provide market info """ super(SBBStrategyBase, self).reset_common_infra(common_infra) - if common_infra is not None: - if "trade_exchange" in common_infra: - self.trade_exchange = common_infra.get("trade_exchange") + if common_infra.has("trade_exchange"): + self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision=None, **kwargs): + def reset(self, outer_trade_decision: List[Order] = None, **kwargs): """ Parameters ---------- - outer_trade_decision : object, optional + outer_trade_decision : List[Order], optional """ super(SBBStrategyBase, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: @@ -324,18 +330,18 @@ class SBBStrategyEMA(SBBStrategyBase): def __init__( self, - outer_trade_decision=[], - instruments="csi300", - freq="day", + outer_trade_decision: List[Order] = None, + instruments: Union[List, str] = "csi300", + freq: str = "day", trade_exchange: Exchange = None, - level_infra={}, - common_infra={}, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, **kwargs, ): """ Parameters ---------- - instruments : str, optional + instruments : Union[List, str], optional instruments of EMA signal, by default "csi300" freq : str, optional freq of EMA signal, by default "day" @@ -375,7 +381,7 @@ class SBBStrategyEMA(SBBStrategyBase): else: self.level_infra.update(level_infra) - if "trade_calendar" in level_infra: + if level_infra.has("trade_calendar"): self.trade_calendar = level_infra.get("trade_calendar") self._reset_signal() diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index f04bcb097..9d3e0c72b 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -7,6 +7,7 @@ from ..data.dataset import DatasetH from ..data.dataset.utils import convert_index_format from ..rl.interpreter import ActionInterpreter, StateInterpreter from ..utils import init_instance_by_config +from ..backtest.utils import CommonInfrastructure, LevelInfrastructure class BaseStrategy: @@ -15,8 +16,8 @@ class BaseStrategy: def __init__( self, outer_trade_decision: object = None, - level_infra: dict = {}, - common_infra: dict = {}, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, ): """ Parameters @@ -25,9 +26,9 @@ class BaseStrategy: the trade decison of outer strategy which this startegy relies, and it will be traded in [start_time, end_time], by default None - If the strategy is used to split trade decison, it will be used - If the strategy is used for portfolio management, it can be ignored - level_infra : dict, optional + level_infra : LevelInfrastructure, optional level shared infrastructure for backtesting, including trade calendar - common_infra : dict, optional + common_infra : CommonInfrastructure, optional common infrastructure for backtesting, including trade_account, trade_exchange, .etc """ @@ -39,7 +40,7 @@ class BaseStrategy: else: self.level_infra.update(level_infra) - if "trade_calendar" in level_infra: + if level_infra.has("trade_calendar"): self.trade_calendar = level_infra.get("trade_calendar") def reset_common_infra(self, common_infra): @@ -48,10 +49,16 @@ class BaseStrategy: else: self.common_infra.update(common_infra) - if "trade_account" in common_infra: + if common_infra.has("trade_account"): self.trade_position = common_infra.get("trade_account").current - def reset(self, level_infra: dict = None, common_infra: dict = None, outer_trade_decision=None, **kwargs): + def reset( + self, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, + outer_trade_decision=None, + **kwargs, + ): """ - reset `level_infra`, used to reset trade calendar, .etc - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc @@ -86,8 +93,8 @@ class ModelStrategy(BaseStrategy): model: BaseModel, dataset: DatasetH, outer_trade_decision: object = None, - level_infra: dict = {}, - common_infra: dict = {}, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, **kwargs, ): """ @@ -122,8 +129,8 @@ class RLStrategy(BaseStrategy): self, policy, outer_trade_decision: object = None, - level_infra: dict = {}, - common_infra: dict = {}, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, **kwargs, ): """ @@ -145,8 +152,8 @@ class RLIntStrategy(RLStrategy): state_interpreter: Union[dict, StateInterpreter], action_interpreter: Union[dict, ActionInterpreter], outer_trade_decision: object = None, - level_infra: dict = {}, - common_infra: dict = {}, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, **kwargs, ): """ diff --git a/qlib/workflow/utils.py b/qlib/workflow/utils.py index 596ff0927..cd87187e9 100644 --- a/qlib/workflow/utils.py +++ b/qlib/workflow/utils.py @@ -46,3 +46,4 @@ def experiment_kill_signal_handler(signum, frame): End an experiment when user kill the program through keyboard (CTRL+C, etc.). """ R.end_exp(recorder_status=Recorder.STATUS_FA) + raise KeyboardInterrupt From d3dac068df5e21d54bb453bb1b9a3eaacf389a06 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Tue, 1 Jun 2021 11:33:44 +0800 Subject: [PATCH 033/187] Update simple playground --- qlib/strategy/__init__.py | 2 + qlib/strategy/base.py | 2 + rl_playground.py | 137 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 rl_playground.py diff --git a/qlib/strategy/__init__.py b/qlib/strategy/__init__.py index 59e481eb9..e3fcd8e26 100644 --- a/qlib/strategy/__init__.py +++ b/qlib/strategy/__init__.py @@ -1,2 +1,4 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + +from .base import * diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 7828db609..37897da5a 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -7,6 +7,8 @@ from ..data.dataset.utils import convert_index_format from ..rl.interpreter import ActionInterpreter, StateInterpreter from ..utils import init_instance_by_config +__all__ = ['BaseStrategy', 'ModelStrategy', 'RLStrategy', 'RLIntStrategy'] + class BaseStrategy: """Base strategy for trading""" diff --git a/rl_playground.py b/rl_playground.py new file mode 100644 index 000000000..3a4291495 --- /dev/null +++ b/rl_playground.py @@ -0,0 +1,137 @@ +import logging +import pickle +from enum import Enum +from typing import Iterable, Optional, Any + +import gym +import numpy as np + +import torch +from torch.utils.data import Dataset + +from qlib.backtest import get_exchange, Account, BaseExecutor +from qlib.rl.interpreter import StateInterpreter, ActionInterpreter +from qlib.utils import init_instance_by_config + + +def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): + trade_account = Account( + init_cash=account, + benchmark_config={ + "benchmark": benchmark, + "start_time": start_time, + "end_time": end_time, + }, + ) + trade_exchange = get_exchange(**exchange_kwargs) + + common_infra = { + "trade_account": trade_account, + "trade_exchange": trade_exchange, + } + + trade_executor = init_instance_by_config(executor, accept_types=BaseExecutor, common_infra=common_infra) + + return common_infra, trade_executor + + +class QlibOrderDataset(Dataset): + def __init__(self, order_file): + with open(order_file, 'rb') as f: + self.orders = pickle.load(f) + + def __len__(self): + return len(self.orders) + + def __getitem__(self, index): + return self.orders[index] + + +class OrderEnv(gym.Env): + def __init__(self, + state_interpreter: StateInterpreter, + action_interpreter: ActionInterpreter, + reward: Any, + dataloader: Iterable, + executor: BaseExecutor): + self.action_interpreter = action_interpreter + self.state_interpreter = state_interpreter + self.reward = reward + self.dataloader = dataloader + self.executor = executor + + @property + def action_space(self): + return self.action.action_space + + @property + def observation_space(self): + return self.observation.observation_space + + def reset(self): + try: + self.cur_order = next(self.dataloader) + except StopIteration: + self.dataloader = None + return None + + self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) + self.level_infra = self.executor.get_level_infra() + self.execute_result = [] + + # TODO: how to fetch data after feature engineering? + + # TODO: can be rewritten as dataclasses.asdict(self.cur_order) is Order is written to be a dataclass + return self.state_interpreter(self.cur_order, self.level_infra) + + def step(self, action): + assert self.dataloader is not None + + assert not self.executor.finished() + + trade_decision = self.action_interpreter(action) + self.execute_result.extend(self.executor.execute(trade_decision)) + reward, rew_info = self.reward() + + done = self.executor.finished() + info = { + 'action_history': self.action_history, + 'category': self.ep_state.flow_dir.value, + 'reward': rew_info + } + if self.ep_state.done: + info['logs'] = self.ep_state.logs() + info['index'] = { + 'ins': self._sample.ins, + 'date': self._sample.date + } + + # TODO: how to collect metrics + return self.state_interpreter(self.cur_order, self.level_infra), reward, done, info + + +def _main(): + executor_config = { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": "day", + "verbose": True, + "generate_report": True, + } + } + # TODO: why is there a benchmark? + trade_start_time = "2017-01-01" + trade_end_time = "2020-08-01" + benchmark = "SH000300" + executor = get_executor( + trade_start_time, trade_end_time, executor_config, + benchmark, 1000000000, exchange_kwargs={ + "freq": "day", + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + } + ) From bf16e1ab4749b185600da92330756b6cd3fbc32b Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 1 Jun 2021 16:19:01 +0800 Subject: [PATCH 034/187] update Order with dataclass --- qlib/backtest/order.py | 51 +++++++++++++++++++++++------------------- setup.py | 1 + 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 0d637d9db..88926b553 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -1,30 +1,35 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import pandas as pd +from dataclasses import dataclass, field +from typing import ClassVar - +@dataclass class Order: - - SELL = 0 - BUY = 1 - - def __init__(self, stock_id, amount, start_time, end_time, direction, factor): - """Parameter - direction : Order.SELL for sell; Order.BUY for buy - stock_id : str - amount : float - trade_date : pd.Timestamp - factor : float + """ + stock_id : str + amount : float + start_time : pd.Timestamp + closed start time for order generation + end_time : pd.Timestamp + closed end time for order generation + direction : Order.SELL for sell; Order.BUY for buy + factor : float presents the weight factor assigned in Exchange() - """ - # check direction - if direction not in {Order.SELL, Order.BUY}: + """ + stock_id : str + amount : float + start_time : pd.Timestamp + end_time : pd.Timestamp + direction : int + factor : float + deal_amount : float = field(init=False) + SELL : ClassVar[int] = 0 + BUY : ClassVar[int] = 1 + + + def __post_init__(self): + if self.direction not in {Order.SELL, Order.BUY}: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") - self.stock_id = stock_id - # amount of generated orders - self.amount = amount - # amount of successfully completed orders self.deal_amount = 0 - self.start_time = start_time - self.end_time = end_time - self.direction = direction - self.factor = factor + diff --git a/setup.py b/setup.py index 92c9ccc0c..0205ab087 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ REQUIRED = [ "pymongo==3.7.2", # For task management "scikit-learn>=0.22", "dill", + "dataclasses;python_version<'3.7'", ] # Numpy include From a46d99a2be413ac368a00d1620e1cd14f117bb3f Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 1 Jun 2021 16:20:21 +0800 Subject: [PATCH 035/187] black format --- qlib/backtest/order.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 88926b553..30cc0c623 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -4,6 +4,7 @@ import pandas as pd from dataclasses import dataclass, field from typing import ClassVar + @dataclass class Order: """ @@ -17,19 +18,18 @@ class Order: factor : float presents the weight factor assigned in Exchange() """ - stock_id : str - amount : float - start_time : pd.Timestamp - end_time : pd.Timestamp - direction : int - factor : float - deal_amount : float = field(init=False) - SELL : ClassVar[int] = 0 - BUY : ClassVar[int] = 1 - + + stock_id: str + amount: float + start_time: pd.Timestamp + end_time: pd.Timestamp + direction: int + factor: float + deal_amount: float = field(init=False) + SELL: ClassVar[int] = 0 + BUY: ClassVar[int] = 1 def __post_init__(self): if self.direction not in {Order.SELL, Order.BUY}: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") self.deal_amount = 0 - From a183d8a63178c1a0543bcaa8cca33074690b2214 Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 1 Jun 2021 17:44:22 +0800 Subject: [PATCH 036/187] update workflow_by_code & update executor --- examples/workflow_by_code.py | 39 ++++++++++++++++++++++++------------ qlib/backtest/__init__.py | 1 + qlib/backtest/executor.py | 6 ++++++ qlib/backtest/order.py | 7 ++++--- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/examples/workflow_by_code.py b/examples/workflow_by_code.py index 92ce6aa34..1708a634e 100644 --- a/examples/workflow_by_code.py +++ b/examples/workflow_by_code.py @@ -66,32 +66,45 @@ if __name__ == "__main__": }, } + # model initialization + model = init_instance_by_config(task["model"]) + dataset = init_instance_by_config(task["dataset"]) + port_analysis_config = { + "executor": { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": "day", + "generate_report": True, + }, + }, "strategy": { "class": "TopkDropoutStrategy", - "module_path": "qlib.contrib.strategy.strategy", + "module_path": "qlib.contrib.strategy.model_strategy", "kwargs": { + "model": model, + "dataset": dataset, "topk": 50, "n_drop": 5, }, }, "backtest": { - "verbose": False, - "limit_threshold": 0.095, + "start_time": "2017-01-01", + "end_time": "2020-08-01", "account": 100000000, "benchmark": benchmark, - "deal_price": "close", - "open_cost": 0.0005, - "close_cost": 0.0015, - "min_cost": 5, - "return_order": True, + "exchange_kwargs": { + "freq": "day", + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + }, }, } - # model initialization - model = init_instance_by_config(task["model"]) - dataset = init_instance_by_config(task["dataset"]) - # NOTE: This line is optional # It demonstrates that the dataset can be used standalone. example_df = dataset.prepare("train") @@ -110,5 +123,5 @@ if __name__ == "__main__": # backtest. If users want to use backtest based on their own prediction, # please refer to https://qlib.readthedocs.io/en/latest/component/recorder.html#record-template. - par = PortAnaRecord(recorder, port_analysis_config) + par = PortAnaRecord(recorder, port_analysis_config, "day") par.generate() diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 33c2cb2d8..1adad91d2 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -8,6 +8,7 @@ from .backtest import backtest as backtest_func from .backtest import collect_data as data_generator from .utils import CommonInfrastructure +from .order import Order from ..strategy.base import BaseStrategy from ..utils import init_instance_by_config from ..log import get_module_logger diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 1cc198bf6..e68047e38 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -118,6 +118,9 @@ class BaseExecutor: def get_report(self): raise NotImplementedError("get_report is not implemented!") + def get_all_executor(self): + return [self] + class NestedExecutor(BaseExecutor): """ @@ -244,6 +247,9 @@ class NestedExecutor(BaseExecutor): sub_env_report_dict.update({f"{_count}{_freq}": (_report, _positions)}) return sub_env_report_dict + def get_all_executor(self): + return [self, *self.inner_executor.get_all_executor()] + class SimulatorExecutor(BaseExecutor): """Executor that simulate the true market""" diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 30cc0c623..e4bf41f1e 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -11,10 +11,11 @@ class Order: stock_id : str amount : float start_time : pd.Timestamp - closed start time for order generation + closed start time for order trading end_time : pd.Timestamp - closed end time for order generation - direction : Order.SELL for sell; Order.BUY for buy + closed end time for order trading + direction : int + Order.SELL for sell; Order.BUY for buy factor : float presents the weight factor assigned in Exchange() """ From 449e3f40c88ea6acb9ad8884c2a52515bf54b5af Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Tue, 1 Jun 2021 17:51:29 +0800 Subject: [PATCH 037/187] Update init in backtest --- qlib/backtest/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 33c2cb2d8..1d9e91bb3 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -6,6 +6,7 @@ from .exchange import Exchange from .executor import BaseExecutor from .backtest import backtest as backtest_func from .backtest import collect_data as data_generator +from .order import Order from .utils import CommonInfrastructure from ..strategy.base import BaseStrategy From 83535bff6af1e6b288f9d00110424b547afd55a5 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Tue, 1 Jun 2021 18:08:11 +0800 Subject: [PATCH 038/187] Playground checkpoint --- rl_playground.py | 307 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 263 insertions(+), 44 deletions(-) diff --git a/rl_playground.py b/rl_playground.py index 3a4291495..de1fb15dd 100644 --- a/rl_playground.py +++ b/rl_playground.py @@ -1,17 +1,20 @@ -import logging import pickle -from enum import Enum -from typing import Iterable, Optional, Any +from dataclasses import dataclass +from typing import Iterable, Any -import gym import numpy as np - -import torch -from torch.utils.data import Dataset - -from qlib.backtest import get_exchange, Account, BaseExecutor +import gym +import qlib +from qlib.backtest import get_exchange, Account, BaseExecutor, CommonInfrastructure, Order +from qlib.config import REG_CN +from qlib.data import D from qlib.rl.interpreter import StateInterpreter, ActionInterpreter -from qlib.utils import init_instance_by_config +from qlib.tests.data import GetData +from qlib.utils import init_instance_by_config, exists_qlib_data +from torch.utils.data import Dataset, DataLoader +from tianshou.data import Batch, Collector +from tianshou.env import DummyVectorEnv +from tianshou.policy import BasePolicy def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): @@ -25,14 +28,10 @@ def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1 ) trade_exchange = get_exchange(**exchange_kwargs) - common_infra = { - "trade_account": trade_account, - "trade_exchange": trade_exchange, - } - + common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=trade_exchange) trade_executor = init_instance_by_config(executor, accept_types=BaseExecutor, common_infra=common_infra) - return common_infra, trade_executor + return trade_executor class QlibOrderDataset(Dataset): @@ -47,19 +46,180 @@ class QlibOrderDataset(Dataset): return self.orders[index] -class OrderEnv(gym.Env): +class DummyCallable: + def __call__(self, *args, **kwargs): + if args: + return args[0] + if kwargs: + for v in kwargs.values(): + return v + + +class DummyPolicy(BasePolicy): + def forward(self, batch, state=None, **kwargs): + return Batch(act=0) + + def learn(self, *args, **kwargs): + pass + + +@dataclass +class EpisodicState: + """ + A simplified data structure for RL-related components to process observations and rewards + """ + # requirements + start_time: int + end_time: int + num_step: int + time_per_step: int + target: float + target_limit: float + vol_limit: Optional[float] + flow_dir: int + market_price: np.ndarray + market_vol: np.ndarray + + # agent state + cur_time: int = -1 + cur_step: int = 0 + done: bool = False + position: Optional[float] = None + exec_vol: Optional[np.ndarray] = None + last_step_duration: Optional[int] = None + position_history: Optional[np.ndarray] = None + + # calculated statistics + turnover: Optional[float] = None + baseline_twap: Optional[float] = None + baseline_vwap: Optional[float] = None + exec_avg_price: Optional[float] = None + pa_twap: Optional[float] = None + pa_vwap: Optional[float] = None + fulfill_rate: Optional[float] = None + + def __post_init__(self): + assert self.target >= 0 + self.cur_time = self.start_time + self.position = self.target + self.position_history = np.full((self.num_step + 1), np.nan) + self.position_history[0] = self.position + self.baseline_twap = np.mean(self.market_price) + if self.market_vol.sum() == 0: + self.baseline_vwap = np.mean(self.market_price) + else: + self.baseline_vwap = np.average(self.market_price, weights=self.market_vol) + + def update_stats(self): + market_price = self.market_price[:len(self.exec_vol)] + self.turnover = (self.exec_vol * market_price).sum() + # exec_vol can be zero + if np.isclose(self.exec_vol.sum(), 0): + self.exec_avg_price = market_price[0] + else: + self.exec_avg_price = np.average(market_price, weights=self.exec_vol) + self.pa_twap = price_advantage(self.exec_avg_price, self.baseline_twap, self.flow_dir) + self.pa_vwap = price_advantage(self.exec_avg_price, self.baseline_vwap, self.flow_dir) + self.fulfill_rate = (self.target - self.position) / self.target_limit + if abs(self.fulfill_rate - 1.0) < EPSILON: + self.fulfill_rate = 1.0 + self.fulfill_rate *= 100 + + def logs(self): + logs = { + 'stop_time': self.cur_time - self.start_time, + 'stop_step': self.cur_step, + 'turnover': self.turnover, + 'baseline_twap': self.baseline_twap, + 'baseline_vwap': self.baseline_vwap, + 'exec_avg_price': self.exec_avg_price, + 'pa_twap': self.pa_twap, + 'pa_vwap': self.pa_vwap, + 'ffr': self.fulfill_rate + } + return logs + + def next_duration(self) -> int: + return min(self.time_per_step, self.end_time - self.cur_time) + + def step(self, exec_vol): + self.last_step_duration = len(exec_vol) + self.position -= exec_vol.sum() + assert self.position > -EPSILON and (exec_vol > -EPSILON).all(), \ + f'Execution volume is invalid: {exec_vol} (position = {self.position})' + self.position_history[self.cur_step + 1] = self.position + self.cur_time += self.last_step_duration + self.cur_step += 1 + if self.cur_step == self.num_step: + assert self.cur_time == self.end_time + if self.exec_vol is None: + self.exec_vol = exec_vol + else: + self.exec_vol = np.concatenate((self.exec_vol, exec_vol)) + + self.done = self.position < EPSILON or self.cur_step == self.num_step + if self.done: + self.update_stats() + + l, r = self.cur_time - self.last_step_duration - self.start_time, self.cur_time - self.start_time + assert 0 <= l < r + return StepState(self.exec_vol[l:r], self.market_vol[l:r], self.market_price[l:r], self) + + +@dataclass +class StepState: + exec_vol: np.ndarray + market_vol: np.ndarray + market_price: np.ndarray + + # episode info + episode_state: EpisodicState + + # calculated statistics + turnover: Optional[float] = None + exec_avg_price: Optional[float] = None + pa_twap: Optional[float] = None + pa_vwap: Optional[float] = None + + def __post_init__(self): + assert len(self.exec_vol) == len(self.market_price) == len(self.market_vol) + self.turnover = (self.exec_vol * self.market_price).sum() + if np.isclose(self.market_vol.sum(), 0): + self.exec_avg_price = self.market_price[0] + else: + self.exec_avg_price = np.average(self.market_price, weights=self.market_vol) + self.pa_twap = price_advantage(self.exec_avg_price, self.episode_state.baseline_twap, + self.episode_state.flow_dir) + self.pa_vwap = price_advantage(self.exec_avg_price, self.episode_state.baseline_vwap, + self.episode_state.flow_dir) + + +def price_advantage(exec_price: float, baseline_price: float, flow: FlowDirection) -> float: + if baseline_price == 0: + return 0. + if flow == FlowDirection.ACQUIRE: + return (1 - exec_price / baseline_price) * 10000 + else: + return (exec_price / baseline_price - 1) * 10000 + + + +class SingleOrderEnv(gym.Env): + MAX_STEPS = 10 def __init__(self, - state_interpreter: StateInterpreter, - action_interpreter: ActionInterpreter, + observation: StateInterpreter, + action: ActionInterpreter, reward: Any, dataloader: Iterable, executor: BaseExecutor): - self.action_interpreter = action_interpreter - self.state_interpreter = state_interpreter + self.action = action + self.observation = observation self.reward = reward self.dataloader = dataloader self.executor = executor + self.inner_frequency = self.executor.get_all_executor()[-1].time_per_step + @property def action_space(self): return self.action.action_space @@ -68,32 +228,53 @@ class OrderEnv(gym.Env): def observation_space(self): return self.observation.observation_space + def retrieve_data(self, cur_order: Order): + return D.features( + [cur_order.stock_id], + ['$open', '$close', '$high', '$low', '$volume'], + start_time=cur_order.start_time.date(), + end_time=cur_order.end_time.date(), + freq=self.inner_frequency + ) + + def initialize_state(self): + self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) + return EpisodicState() + + def update_state(self, action): + trade_decision = action + execute_result = self.executor.execute(trade_decision) + def reset(self): try: - self.cur_order = next(self.dataloader) + cur_order = next(self.dataloader) except StopIteration: self.dataloader = None return None - self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) - self.level_infra = self.executor.get_level_infra() + self.cur_sample = self._retrieve_data(cur_order) self.execute_result = [] + self.ep_state = self.initialize_state() + + self.action_history = np.full(self.MAX_STEPS, np.nan) + return self.observation(self.cur_sample, self.ep_state) + # TODO: how to fetch data after feature engineering? # TODO: can be rewritten as dataclasses.asdict(self.cur_order) is Order is written to be a dataclass - return self.state_interpreter(self.cur_order, self.level_infra) + return self.observation def step(self, action): assert self.dataloader is not None assert not self.executor.finished() - trade_decision = self.action_interpreter(action) - self.execute_result.extend(self.executor.execute(trade_decision)) - reward, rew_info = self.reward() + exec_vol = self.action(action, self.ep_state) + step_state = self.ep_state.step(exec_vol) + + reward, rew_info = self.reward(self.ep_state, step_state) - done = self.executor.finished() info = { 'action_history': self.action_history, 'category': self.ep_state.flow_dir.value, @@ -102,31 +283,45 @@ class OrderEnv(gym.Env): if self.ep_state.done: info['logs'] = self.ep_state.logs() info['index'] = { - 'ins': self._sample.ins, - 'date': self._sample.date + 'ins': self.cur_sample.ins, + 'date': self.cur_sample.date } - # TODO: how to collect metrics - return self.state_interpreter(self.cur_order, self.level_infra), reward, done, info + return self.observation(self.cur_sample, self.ep_state), reward, self.ep_state.done, info + + +def _init_qlib(): + provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir + if not exists_qlib_data(provider_uri): + print(f"Qlib data is not found in {provider_uri}") + GetData().qlib_data(target_dir=provider_uri, region=REG_CN) + qlib.init(provider_uri=provider_uri, region=REG_CN) def _main(): - executor_config = { - "class": "SimulatorExecutor", - "module_path": "qlib.backtest.executor", - "kwargs": { - "time_per_step": "day", - "verbose": True, - "generate_report": True, - } - } + _init_qlib() + # TODO: why is there a benchmark? trade_start_time = "2017-01-01" trade_end_time = "2020-08-01" benchmark = "SH000300" + time_per_step = "day" + executor_config = { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": time_per_step, + "verbose": True, + "generate_report": False, + } + } executor = get_executor( - trade_start_time, trade_end_time, executor_config, - benchmark, 1000000000, exchange_kwargs={ + trade_start_time, + trade_end_time, + executor_config, + benchmark, + 1000000000, + exchange_kwargs={ "freq": "day", "limit_threshold": 0.095, "deal_price": "close", @@ -135,3 +330,27 @@ def _main(): "min_cost": 5, } ) + + import pdb; pdb.set_trace() + + observation = DummyCallable() + action = DummyCallable() + reward_fn = DummyCallable() + # TODO: this probably won't work with multiprocess + dataloader = iter(DataLoader(QlibOrderDataset('rl.pkl'), batch_size=None, shuffle=True)) + + def dummy_env(): return OrderEnv(observation, action, reward_fn, dataloader, executor) + policy = DummyPolicy() + + # env = dummy_env() + # obs = env.reset() + # print(obs.__dict__) + + envs = DummyVectorEnv([dummy_env for _ in range(4)]) + test_collector = Collector(policy, envs) + policy.eval() + test_collector.collect(n_episode=10) + + +if __name__ == '__main__': + _main() From 4d48c96d30c82fec6450b78df25f33e2e63cfa62 Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 1 Jun 2021 18:50:50 +0800 Subject: [PATCH 039/187] fix CI --- examples/workflow_by_code.ipynb | 50 +++++++++++++------- examples/workflow_by_code.py | 59 +++-------------------- qlib/backtest/executor.py | 8 ++-- qlib/backtest/report.py | 3 +- qlib/contrib/evaluate.py | 2 +- tests/test_all_pipeline.py | 83 +++++++++++++++++++-------------- 6 files changed, 96 insertions(+), 109 deletions(-) diff --git a/examples/workflow_by_code.ipynb b/examples/workflow_by_code.ipynb index b4da1bfe4..3d99bf1e1 100644 --- a/examples/workflow_by_code.ipynb +++ b/examples/workflow_by_code.ipynb @@ -196,27 +196,40 @@ "# prediction, backtest & analysis\n", "###################################\n", "port_analysis_config = {\n", + " \"executor\": {\n", + " \"class\": \"SimulatorExecutor\",\n", + " \"module_path\": \"qlib.backtest.executor\",\n", + " \"kwargs\": {\n", + " \"time_per_step\": \"day\",\n", + " \"generate_report\": True,\n", + " },\n", + " },\n", " \"strategy\": {\n", " \"class\": \"TopkDropoutStrategy\",\n", - " \"module_path\": \"qlib.contrib.strategy.strategy\",\n", + " \"module_path\": \"qlib.contrib.strategy.model_strategy\",\n", " \"kwargs\": {\n", + " \"model\": model,\n", + " \"dataset\": dataset,\n", " \"topk\": 50,\n", " \"n_drop\": 5,\n", " },\n", " },\n", " \"backtest\": {\n", - " \"verbose\": False,\n", - " \"limit_threshold\": 0.095,\n", + " \"start_time\": \"2017-01-01\",\n", + " \"end_time\": \"2020-08-01\",\n", " \"account\": 100000000,\n", " \"benchmark\": benchmark,\n", - " \"deal_price\": \"close\",\n", - " \"open_cost\": 0.0005,\n", - " \"close_cost\": 0.0015,\n", - " \"min_cost\": 5,\n", + " \"exchange_kwargs\": {\n", + " \"freq\": \"day\",\n", + " \"limit_threshold\": 0.095,\n", + " \"deal_price\": \"close\",\n", + " \"open_cost\": 0.0005,\n", + " \"close_cost\": 0.0015,\n", + " \"min_cost\": 5,\n", + " },\n", " },\n", "}\n", "\n", - "\n", "# backtest and analysis\n", "with R.start(experiment_name=\"backtest_analysis\"):\n", " recorder = R.get_recorder(rid, experiment_name=\"train_model\")\n", @@ -229,7 +242,7 @@ " sr.generate()\n", "\n", " # backtest & analysis\n", - " par = PortAnaRecord(recorder, port_analysis_config)\n", + " par = PortAnaRecord(recorder, port_analysis_config, \"day\")\n", " par.generate()\n" ] }, @@ -249,11 +262,12 @@ "from qlib.contrib.report import analysis_model, analysis_position\n", "from qlib.data import D\n", "recorder = R.get_recorder(ba_rid, experiment_name=\"backtest_analysis\")\n", + "print(recorder)\n", "pred_df = recorder.load_object(\"pred.pkl\")\n", "pred_df_dates = pred_df.index.get_level_values(level='datetime')\n", - "report_normal_df = recorder.load_object(\"portfolio_analysis/report_normal.pkl\")\n", - "positions = recorder.load_object(\"portfolio_analysis/positions_normal.pkl\")\n", - "analysis_df = recorder.load_object(\"portfolio_analysis/port_analysis.pkl\")" + "report_normal_df = recorder.load_object(\"portfolio_analysis/report_normal_1day.pkl\")\n", + "positions = recorder.load_object(\"portfolio_analysis/positions_normal_1day.pkl\")\n", + "analysis_df = recorder.load_object(\"portfolio_analysis/port_analysis_1day.pkl\")" ] }, { @@ -348,9 +362,8 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" + "name": "pythonjvsc74a57bd0fcc004278713aaede7c629a6a43738a929cb09abb52817d4f72eb70db44cd87b", + "display_name": "Python 3.8 ('qlib_backtest': conda)" }, "language_info": { "codemirror_mode": { @@ -362,7 +375,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.3" + "version": "3.8" }, "toc": { "base_numbering": 1, @@ -376,6 +389,11 @@ "toc_position": {}, "toc_section_display": true, "toc_window_display": false + }, + "metadata": { + "interpreter": { + "hash": "fcc004278713aaede7c629a6a43738a929cb09abb52817d4f72eb70db44cd87b" + } } }, "nbformat": 4, diff --git a/examples/workflow_by_code.py b/examples/workflow_by_code.py index b02ea91b1..d7bb544f9 100644 --- a/examples/workflow_by_code.py +++ b/examples/workflow_by_code.py @@ -3,10 +3,12 @@ import qlib from qlib.config import REG_CN -from qlib.utils import exists_qlib_data, init_instance_by_config, flatten_dict +from qlib.utils import init_instance_by_config, flatten_dict from qlib.workflow import R from qlib.workflow.record_temp import SignalRecord, PortAnaRecord from qlib.tests.data import GetData +from qlib.tests.config import CSI300_BENCH, CSI300_GBDT_TASK + if __name__ == "__main__": @@ -15,57 +17,8 @@ if __name__ == "__main__": GetData().qlib_data(target_dir=provider_uri, region=REG_CN, exists_skip=True) qlib.init(provider_uri=provider_uri, region=REG_CN) - market = "csi300" - benchmark = "SH000300" - - ################################### - # train model - ################################### - data_handler_config = { - "start_time": "2008-01-01", - "end_time": "2020-08-01", - "fit_start_time": "2008-01-01", - "fit_end_time": "2014-12-31", - "instruments": market, - } - - task = { - "model": { - "class": "LGBModel", - "module_path": "qlib.contrib.model.gbdt", - "kwargs": { - "loss": "mse", - "colsample_bytree": 0.8879, - "learning_rate": 0.0421, - "subsample": 0.8789, - "lambda_l1": 205.6999, - "lambda_l2": 580.9768, - "max_depth": 8, - "num_leaves": 210, - "num_threads": 20, - }, - }, - "dataset": { - "class": "DatasetH", - "module_path": "qlib.data.dataset", - "kwargs": { - "handler": { - "class": "Alpha158", - "module_path": "qlib.contrib.data.handler", - "kwargs": data_handler_config, - }, - "segments": { - "train": ("2008-01-01", "2014-12-31"), - "valid": ("2015-01-01", "2016-12-31"), - "test": ("2017-01-01", "2020-08-01"), - }, - }, - }, - } - - # model initialization - model = init_instance_by_config(task["model"]) - dataset = init_instance_by_config(task["dataset"]) + model = init_instance_by_config(CSI300_GBDT_TASK["model"]) + dataset = init_instance_by_config(CSI300_GBDT_TASK["dataset"]) port_analysis_config = { "executor": { @@ -90,7 +43,7 @@ if __name__ == "__main__": "start_time": "2017-01-01", "end_time": "2020-08-01", "account": 100000000, - "benchmark": benchmark, + "benchmark": CSI300_BENCH, "exchange_kwargs": { "freq": "day", "limit_threshold": 0.095, diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index e68047e38..656073759 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -118,7 +118,8 @@ class BaseExecutor: def get_report(self): raise NotImplementedError("get_report is not implemented!") - def get_all_executor(self): + def get_all_executors(self): + """Return all executors""" return [self] @@ -247,8 +248,9 @@ class NestedExecutor(BaseExecutor): sub_env_report_dict.update({f"{_count}{_freq}": (_report, _positions)}) return sub_env_report_dict - def get_all_executor(self): - return [self, *self.inner_executor.get_all_executor()] + def get_all_executors(self): + """Return all executors, including self and inner_executor.get_all_executors()""" + return [self, *self.inner_executor.get_all_executors()] class SimulatorExecutor(BaseExecutor): diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 4b9b0ce26..0668f81cf 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -12,6 +12,7 @@ from pandas.core.frame import DataFrame from ..utils.resam import parse_freq, resam_ts_data from ..data import D +from ..tests.config import CSI300_BENCH class Report: @@ -67,7 +68,7 @@ class Report: self.bench = self._cal_benchmark(self.benchmark_config, self.freq) def _cal_benchmark(self, benchmark_config, freq): - benchmark = benchmark_config.get("benchmark", "SH000300") + benchmark = benchmark_config.get("benchmark", CSI300_BENCH) if isinstance(benchmark, pd.Series): return benchmark else: diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index 8d4052cdb..0ef8f95a5 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -29,7 +29,7 @@ def risk_analysis(r, N: int = None, freq: str = "day"): r : pandas.Series daily return series. N: int - scaler for annualizing information_ratio (day: 250, week: 50, month: 12), at least one of `N` and `freq` should exist + scaler for annualizing information_ratio (day: 252, week: 50, month: 12), at least one of `N` and `freq` should exist freq: str analysis frequency used for calculating the scaler, at least one of `N` and `freq` should exist """ diff --git a/tests/test_all_pipeline.py b/tests/test_all_pipeline.py index 4c20405fa..ea171f31e 100644 --- a/tests/test_all_pipeline.py +++ b/tests/test_all_pipeline.py @@ -14,27 +14,6 @@ from qlib.workflow.record_temp import SignalRecord, SigAnaRecord, PortAnaRecord from qlib.tests import TestAutoData from qlib.tests.config import CSI300_GBDT_TASK, CSI300_BENCH -port_analysis_config = { - "strategy": { - "class": "TopkDropoutStrategy", - "module_path": "qlib.contrib.strategy.strategy", - "kwargs": { - "topk": 50, - "n_drop": 5, - }, - }, - "backtest": { - "verbose": False, - "limit_threshold": 0.095, - "account": 100000000, - "benchmark": CSI300_BENCH, - "deal_price": "close", - "open_cost": 0.0005, - "close_cost": 0.0015, - "min_cost": 5, - }, -} - def train(): """train model @@ -58,7 +37,7 @@ def train(): with R.start(experiment_name="workflow"): R.log_params(**flatten_dict(CSI300_GBDT_TASK)) model.fit(dataset) - + R.save_objects(trained_model=model) # prediction recorder = R.get_recorder() # To test __repr__ @@ -68,7 +47,6 @@ def train(): rid = recorder.id sr = SignalRecord(model, dataset, recorder) sr.generate() - pred_score = sr.load() # calculate ic and ric sar = SigAnaRecord(recorder) @@ -76,7 +54,7 @@ def train(): ic = sar.load(sar.get_path("ic.pkl")) ric = sar.load(sar.get_path("ric.pkl")) - return pred_score, {"ic": ic, "ric": ric}, rid + return {"ic": ic, "ric": ric}, rid def train_with_sigana(): @@ -103,10 +81,9 @@ def train_with_sigana(): sar.generate() ic = sar.load(sar.get_path("ic.pkl")) ric = sar.load(sar.get_path("ric.pkl")) - pred_score = sar.load("pred.pkl") uri_path = R.get_uri() - return pred_score, {"ic": ic, "ric": ric}, uri_path + return {"ic": ic, "ric": ric}, uri_path def fake_experiment(): @@ -130,13 +107,11 @@ def fake_experiment(): return default_uri == default_uri_to_check, current_uri == current_uri_to_check, current_uri -def backtest_analysis(pred, rid): +def backtest_analysis(rid): """backtest and analysis Parameters ---------- - pred : pandas.DataFrame - predict scores rid : str the id of the recorder to be used in this function @@ -147,16 +122,54 @@ def backtest_analysis(pred, rid): """ recorder = R.get_recorder(experiment_name="workflow", recorder_id=rid) + + dataset = init_instance_by_config(CSI300_GBDT_TASK["dataset"]) + model = recorder.load_object("trained_model") + + port_analysis_config = { + "executor": { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": "day", + "generate_report": True, + }, + }, + "strategy": { + "class": "TopkDropoutStrategy", + "module_path": "qlib.contrib.strategy.model_strategy", + "kwargs": { + "model": model, + "dataset": dataset, + "topk": 50, + "n_drop": 5, + }, + }, + "backtest": { + "start_time": "2017-01-01", + "end_time": "2020-08-01", + "account": 100000000, + "benchmark": CSI300_BENCH, + "exchange_kwargs": { + "freq": "day", + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + }, + }, + } + # backtest - par = PortAnaRecord(recorder, port_analysis_config) + par = PortAnaRecord(recorder, port_analysis_config, risk_analysis_freq="day") par.generate() - analysis_df = par.load(par.get_path("port_analysis.pkl")) + analysis_df = par.load(par.get_path("port_analysis_1day.pkl")) print(analysis_df) return analysis_df class TestAllFlow(TestAutoData): - PRED_SCORE = None REPORT_NORMAL = None POSITIONS = None RID = None @@ -166,18 +179,18 @@ class TestAllFlow(TestAutoData): shutil.rmtree(str(Path(C["exp_manager"]["kwargs"]["uri"].strip("file:")).resolve())) def test_0_train_with_sigana(self): - TestAllFlow.PRED_SCORE, ic_ric, uri_path = train_with_sigana() + ic_ric, uri_path = train_with_sigana() self.assertGreaterEqual(ic_ric["ic"].all(), 0, "train failed") self.assertGreaterEqual(ic_ric["ric"].all(), 0, "train failed") shutil.rmtree(str(Path(uri_path.strip("file:")).resolve())) def test_1_train(self): - TestAllFlow.PRED_SCORE, ic_ric, TestAllFlow.RID = train() + ic_ric, TestAllFlow.RID = train() self.assertGreaterEqual(ic_ric["ic"].all(), 0, "train failed") self.assertGreaterEqual(ic_ric["ric"].all(), 0, "train failed") def test_2_backtest(self): - analyze_df = backtest_analysis(TestAllFlow.PRED_SCORE, TestAllFlow.RID) + analyze_df = backtest_analysis(TestAllFlow.RID) self.assertGreaterEqual( analyze_df.loc(axis=0)["excess_return_with_cost", "annualized_return"].values[0], 0.10, From 3200bb88c85a754b0282832741e3e0a2258e88b1 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 2 Jun 2021 15:11:38 +0800 Subject: [PATCH 040/187] Update an initial version of RL --- rl_playground.py | 293 +++++++++++++++++++++++++++++++---------------- 1 file changed, 194 insertions(+), 99 deletions(-) diff --git a/rl_playground.py b/rl_playground.py index de1fb15dd..cac9134c6 100644 --- a/rl_playground.py +++ b/rl_playground.py @@ -1,10 +1,12 @@ import pickle -from dataclasses import dataclass -from typing import Iterable, Any +from dataclasses import dataclass, asdict +from typing import Iterable, Any, Optional, Tuple, Dict -import numpy as np import gym +import numpy as np +import pandas as pd import qlib +from gym import spaces from qlib.backtest import get_exchange, Account, BaseExecutor, CommonInfrastructure, Order from qlib.config import REG_CN from qlib.data import D @@ -17,7 +19,10 @@ from tianshou.env import DummyVectorEnv from tianshou.policy import BasePolicy -def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): +MAX_STEPS = 10 + + +def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}) -> BaseExecutor: trade_account = Account( init_cash=account, benchmark_config={ @@ -34,6 +39,19 @@ def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1 return trade_executor +def price_advantage(exec_price: float, baseline_price: float, direction: int) -> float: + if baseline_price == 0: + return 0. + if direction == 1: + return (1 - exec_price / baseline_price) * 10000 + else: + return (exec_price / baseline_price - 1) * 10000 + + +def _to_int32(val): return np.array(int(val), dtype=np.int32) +def _to_float32(val): return np.array(val, dtype=np.float32) + + class QlibOrderDataset(Dataset): def __init__(self, order_file): with open(order_file, 'rb') as f: @@ -46,18 +64,10 @@ class QlibOrderDataset(Dataset): return self.orders[index] -class DummyCallable: - def __call__(self, *args, **kwargs): - if args: - return args[0] - if kwargs: - for v in kwargs.values(): - return v - - class DummyPolicy(BasePolicy): def forward(self, batch, state=None, **kwargs): - return Batch(act=0) + print(batch) + return Batch(act=np.random.randint(5)) def learn(self, *args, **kwargs): pass @@ -69,20 +79,22 @@ class EpisodicState: A simplified data structure for RL-related components to process observations and rewards """ # requirements - start_time: int - end_time: int - num_step: int - time_per_step: int + stock_id: int + start_time: pd.Timestamp + end_time: pd.Timestamp + direction: int target: float - target_limit: float - vol_limit: Optional[float] - flow_dir: int + num_step: int + + # simplified market data used to calculate backtest metrics + # this may contains information from future so be careful market_price: np.ndarray market_vol: np.ndarray # agent state - cur_time: int = -1 + cur_time: Optional[pd.Timestamp] = None cur_step: int = 0 + cur_tick: int = 0 # tick is the most fine-grained time unit (typically minute) done: bool = False position: Optional[float] = None exec_vol: Optional[np.ndarray] = None @@ -100,6 +112,7 @@ class EpisodicState: def __post_init__(self): assert self.target >= 0 + assert len(self.market_price) == len(self.market_vol) self.cur_time = self.start_time self.position = self.target self.position_history = np.full((self.num_step + 1), np.nan) @@ -118,10 +131,10 @@ class EpisodicState: self.exec_avg_price = market_price[0] else: self.exec_avg_price = np.average(market_price, weights=self.exec_vol) - self.pa_twap = price_advantage(self.exec_avg_price, self.baseline_twap, self.flow_dir) - self.pa_vwap = price_advantage(self.exec_avg_price, self.baseline_vwap, self.flow_dir) - self.fulfill_rate = (self.target - self.position) / self.target_limit - if abs(self.fulfill_rate - 1.0) < EPSILON: + self.pa_twap = price_advantage(self.exec_avg_price, self.baseline_twap, self.direction) + self.pa_vwap = price_advantage(self.exec_avg_price, self.baseline_vwap, self.direction) + self.fulfill_rate = (self.target - self.position) / self.target + if abs(self.fulfill_rate - 1.0) < 1e-5: self.fulfill_rate = 1.0 self.fulfill_rate *= 100 @@ -139,35 +152,10 @@ class EpisodicState: } return logs - def next_duration(self) -> int: - return min(self.time_per_step, self.end_time - self.cur_time) - - def step(self, exec_vol): - self.last_step_duration = len(exec_vol) - self.position -= exec_vol.sum() - assert self.position > -EPSILON and (exec_vol > -EPSILON).all(), \ - f'Execution volume is invalid: {exec_vol} (position = {self.position})' - self.position_history[self.cur_step + 1] = self.position - self.cur_time += self.last_step_duration - self.cur_step += 1 - if self.cur_step == self.num_step: - assert self.cur_time == self.end_time - if self.exec_vol is None: - self.exec_vol = exec_vol - else: - self.exec_vol = np.concatenate((self.exec_vol, exec_vol)) - - self.done = self.position < EPSILON or self.cur_step == self.num_step - if self.done: - self.update_stats() - - l, r = self.cur_time - self.last_step_duration - self.start_time, self.cur_time - self.start_time - assert 0 <= l < r - return StepState(self.exec_vol[l:r], self.market_vol[l:r], self.market_price[l:r], self) - @dataclass class StepState: + # market info and execution volume for current step exec_vol: np.ndarray market_vol: np.ndarray market_price: np.ndarray @@ -189,23 +177,109 @@ class StepState: else: self.exec_avg_price = np.average(self.market_price, weights=self.market_vol) self.pa_twap = price_advantage(self.exec_avg_price, self.episode_state.baseline_twap, - self.episode_state.flow_dir) + self.episode_state.direction) self.pa_vwap = price_advantage(self.exec_avg_price, self.episode_state.baseline_vwap, - self.episode_state.flow_dir) + self.episode_state.direction) -def price_advantage(exec_price: float, baseline_price: float, flow: FlowDirection) -> float: - if baseline_price == 0: +class Observation: + def __init__(self, time_per_step): + self.time_per_step = time_per_step + + def __call__(self, ep_state: EpisodicState) -> Any: + obs = self.observe(ep_state) + if not self.validate(obs): + raise ValueError(f'Observation space does not contain obs. Space: {self.observation_space} Sample: {obs}') + return obs + + def validate(self, obs: Any) -> bool: + return self.observation_space.contains(obs) + + @property + def observation_space(self): + space = { + 'direction': spaces.Discrete(2), + 'cur_step': spaces.Box(0, MAX_STEPS - 1, shape=(), dtype=np.int32), + 'num_step': spaces.Box(MAX_STEPS, MAX_STEPS, shape=(), dtype=np.int32), + 'target': spaces.Box(-1e-5, np.inf, shape=()), + 'position': spaces.Box(-1e-5, np.inf, shape=()), + 'features': spaces.Box(-np.inf, np.inf, shape=(5, )) + } + return spaces.Dict(space) + + def observe(self, ep_state: EpisodicState) -> Any: + return { + 'acquiring': _to_int32(ep_state.direction), + 'cur_step': _to_int32(min(ep_state.cur_step, ep_state.num_step - 1)), + 'num_step': _to_int32(ep_state.num_step), + 'target': _to_float32(ep_state.target), + 'position': _to_float32(ep_state.position), + 'features': D.features( + [ep_state.stock_id], + ['$open', '$close', '$high', '$low', '$volume'], + start_time=ep_state.start_time, + end_time=ep_state.end_time, + freq=self.time_per_step + ) + } + + +class Action: + @property + def action_space(self): + return spaces.Discrete(5) + + def __call__(self, action: Any, ep_state: EpisodicState) -> Any: + if not self.validate(action): + raise ValueError(f'Action space does not contain action. Space: {self.action_space} Sample: {action}') + act_ = self.to_volume(action, ep_state) + return act_ + + def validate(self, action: Any) -> bool: + return self.action_space.contains(action) + + def to_volume(self, action: Any, ep_state: EpisodicState): + exec_vol = ep_state.position / 5 * action + if ep_state.cur_step + 1 >= ep_state.num_step: + exec_vol = ep_state.position + # TODO: might need to check whether the stock is tradable or whether it satisfies trade unit? + return exec_vol + + +class Reward: + weight = 1.0 + + def __call__(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: + rew, info = 0., {} + if ep_state.done: + ep_rew, ep_info = self._to_tuple(self.episode_end(ep_state)) + rew += ep_rew + info.update({f'ep/{k}': v for k, v in ep_info.items()}) + st_rew, st_info = self._to_tuple(self.step_end(ep_state, st_state)) + rew += st_rew + info.update({f'st/{k}': v for k, v in st_info.items()}) + return rew * self.weight, info + + @staticmethod + def _to_tuple(x): + if isinstance(x, tuple): + return x + return x, {} + + def episode_end(self, ep_state: EpisodicState) -> Tuple[float, Dict[str, float]]: return 0. - if flow == FlowDirection.ACQUIRE: - return (1 - exec_price / baseline_price) * 10000 - else: - return (exec_price / baseline_price - 1) * 10000 + + def step_end(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: + assert ep_state.target > 0 + baseline_price = st_state.pa_twap + pa = baseline_price * st_state.exec_vol.sum() / ep_state.target + penalty = -self.penalty * ((st_state.exec_vol / ep_state.target) ** 2).sum() + reward = pa + penalty + return reward, {'pa': pa, 'penalty': penalty} class SingleOrderEnv(gym.Env): - MAX_STEPS = 10 def __init__(self, observation: StateInterpreter, action: ActionInterpreter, @@ -228,50 +302,73 @@ class SingleOrderEnv(gym.Env): def observation_space(self): return self.observation.observation_space - def retrieve_data(self, cur_order: Order): + def retrieve_backtest_data(self, field: str): return D.features( - [cur_order.stock_id], + [self.cur_order.stock_id], ['$open', '$close', '$high', '$low', '$volume'], - start_time=cur_order.start_time.date(), - end_time=cur_order.end_time.date(), + start_time=self.cur_order.start_time, + end_time=self.cur_order.end_time, freq=self.inner_frequency - ) + )[field].to_numpy() def initialize_state(self): self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) - return EpisodicState() + return EpisodicState( + stock_id=self.cur_order.stock_id, + start_time=self.cur_order.start_time, + end_time=self.cur_order.end_time, + direction=self.cur_order.direction, + target=self.cur_order.amount, + num_step=self.executor.trade_calendar.get_trade_len(), + market_price=self.retrieve_backtest_data('$close'), + market_vol=self.retrieve_backtest_data('$volume'), + ) - def update_state(self, action): - trade_decision = action - execute_result = self.executor.execute(trade_decision) + def update_state(self, exec_vol): + trade_step = self.trade_calendar.get_trade_step() + trade_start_time = self.executor.trade_calendar.get_step_time(trade_step) + trade_end_time = self.executor.trade_calendar.get_step_time(trade_step, shift=1) + trade_decision = Order(**asdict(self.cur_order), + start_time=trade_start_time, end_time=trade_end_time, amount=exec_vol) + execute_result = self.executor.execute([trade_decision]) + cur_tick = self.ep_state.cur_tick + + inner_exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) + ticks_this_step = len(inner_exec_vol) + state = self.ep_state + state.cur_step = trade_step = self.executor.trade_calendar.get_trade_step() + state.cur_time = self.executor.trade_calendar.get_step_time(trade_step) + state.cur_tick += ticks_this_step + state.position -= np.sum(inner_exec_vol) + state.position_history[trade_step] = state.position + state.exec_vol = inner_exec_vol if state.exec_vol is None else np.concatenate((state.exec_vol, inner_exec_vol)) + + state.done = self.executor.finished() + if state.done: + state.update_stats() + + l, r = cur_tick, cur_tick + ticks_this_step + assert 0 <= l < r + return StepState(inner_exec_vol, state.market_vol[l:r], state.market_price[l:r], state) def reset(self): try: - cur_order = next(self.dataloader) + self.cur_order = next(self.dataloader) except StopIteration: self.dataloader = None return None - self.cur_sample = self._retrieve_data(cur_order) self.execute_result = [] self.ep_state = self.initialize_state() - self.action_history = np.full(self.MAX_STEPS, np.nan) + self.action_history = np.full(self.ep_state.num_step, np.nan) return self.observation(self.cur_sample, self.ep_state) - - # TODO: how to fetch data after feature engineering? - - # TODO: can be rewritten as dataclasses.asdict(self.cur_order) is Order is written to be a dataclass - return self.observation - def step(self, action): assert self.dataloader is not None - assert not self.executor.finished() - exec_vol = self.action(action, self.ep_state) - step_state = self.ep_state.step(exec_vol) + step_state = self.update_state(exec_vol) reward, rew_info = self.reward(self.ep_state, step_state) @@ -283,8 +380,8 @@ class SingleOrderEnv(gym.Env): if self.ep_state.done: info['logs'] = self.ep_state.logs() info['index'] = { - 'ins': self.cur_sample.ins, - 'date': self.cur_sample.date + 'ins': self.ep_state.stock_id, + 'date': self.ep_state.start_time, } return self.observation(self.cur_sample, self.ep_state), reward, self.ep_state.done, info @@ -331,25 +428,23 @@ def _main(): } ) - import pdb; pdb.set_trace() + observation = Observation(time_per_step) + action = Action() + reward_fn = Reward() - observation = DummyCallable() - action = DummyCallable() - reward_fn = DummyCallable() - # TODO: this probably won't work with multiprocess - dataloader = iter(DataLoader(QlibOrderDataset('rl.pkl'), batch_size=None, shuffle=True)) - - def dummy_env(): return OrderEnv(observation, action, reward_fn, dataloader, executor) + def dummy_env(): return SingleOrderEnv( + observation, action, reward_fn, + DataLoader(QlibOrderDataset('rl.pkl'), batch_size=None, shuffle=True), executor) policy = DummyPolicy() - # env = dummy_env() - # obs = env.reset() - # print(obs.__dict__) + env = dummy_env() + obs = env.reset() + print(obs) - envs = DummyVectorEnv([dummy_env for _ in range(4)]) - test_collector = Collector(policy, envs) - policy.eval() - test_collector.collect(n_episode=10) + # envs = DummyVectorEnv([dummy_env for _ in range(4)]) + # test_collector = Collector(policy, envs) + # policy.eval() + # test_collector.collect(n_episode=10) if __name__ == '__main__': From d515efb46e069a7334e5ee26cebb6a3adffc7908 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 2 Jun 2021 16:41:18 +0800 Subject: [PATCH 041/187] Finish RL dummy example --- qlib/backtest/order.py | 4 +- rl_playground.py | 345 +++++++++++++++++++++-------------------- 2 files changed, 183 insertions(+), 166 deletions(-) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index e4bf41f1e..47a859aa3 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import pandas as pd from dataclasses import dataclass, field -from typing import ClassVar +from typing import ClassVar, Optional @dataclass @@ -26,7 +26,7 @@ class Order: end_time: pd.Timestamp direction: int factor: float - deal_amount: float = field(init=False) + deal_amount: Optional[float] = None SELL: ClassVar[int] = 0 BUY: ClassVar[int] = 1 diff --git a/rl_playground.py b/rl_playground.py index cac9134c6..482615215 100644 --- a/rl_playground.py +++ b/rl_playground.py @@ -1,5 +1,6 @@ import pickle from dataclasses import dataclass, asdict +from pprint import pprint from typing import Iterable, Any, Optional, Tuple, Dict import gym @@ -22,7 +23,7 @@ from tianshou.policy import BasePolicy MAX_STEPS = 10 -def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}) -> BaseExecutor: +def get_executor(start_time, end_time, executor, exchange, benchmark="SH000300", account=1e9) -> BaseExecutor: trade_account = Account( init_cash=account, benchmark_config={ @@ -31,9 +32,8 @@ def get_executor(start_time, end_time, executor, benchmark="SH000300", account=1 "end_time": end_time, }, ) - trade_exchange = get_exchange(**exchange_kwargs) - common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=trade_exchange) + common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=exchange) trade_executor = init_instance_by_config(executor, accept_types=BaseExecutor, common_infra=common_infra) return trade_executor @@ -48,31 +48,6 @@ def price_advantage(exec_price: float, baseline_price: float, direction: int) -> return (exec_price / baseline_price - 1) * 10000 -def _to_int32(val): return np.array(int(val), dtype=np.int32) -def _to_float32(val): return np.array(val, dtype=np.float32) - - -class QlibOrderDataset(Dataset): - def __init__(self, order_file): - with open(order_file, 'rb') as f: - self.orders = pickle.load(f) - - def __len__(self): - return len(self.orders) - - def __getitem__(self, index): - return self.orders[index] - - -class DummyPolicy(BasePolicy): - def forward(self, batch, state=None, **kwargs): - print(batch) - return Batch(act=np.random.randint(5)) - - def learn(self, *args, **kwargs): - pass - - @dataclass class EpisodicState: """ @@ -182,103 +157,6 @@ class StepState: self.episode_state.direction) -class Observation: - def __init__(self, time_per_step): - self.time_per_step = time_per_step - - def __call__(self, ep_state: EpisodicState) -> Any: - obs = self.observe(ep_state) - if not self.validate(obs): - raise ValueError(f'Observation space does not contain obs. Space: {self.observation_space} Sample: {obs}') - return obs - - def validate(self, obs: Any) -> bool: - return self.observation_space.contains(obs) - - @property - def observation_space(self): - space = { - 'direction': spaces.Discrete(2), - 'cur_step': spaces.Box(0, MAX_STEPS - 1, shape=(), dtype=np.int32), - 'num_step': spaces.Box(MAX_STEPS, MAX_STEPS, shape=(), dtype=np.int32), - 'target': spaces.Box(-1e-5, np.inf, shape=()), - 'position': spaces.Box(-1e-5, np.inf, shape=()), - 'features': spaces.Box(-np.inf, np.inf, shape=(5, )) - } - return spaces.Dict(space) - - def observe(self, ep_state: EpisodicState) -> Any: - return { - 'acquiring': _to_int32(ep_state.direction), - 'cur_step': _to_int32(min(ep_state.cur_step, ep_state.num_step - 1)), - 'num_step': _to_int32(ep_state.num_step), - 'target': _to_float32(ep_state.target), - 'position': _to_float32(ep_state.position), - 'features': D.features( - [ep_state.stock_id], - ['$open', '$close', '$high', '$low', '$volume'], - start_time=ep_state.start_time, - end_time=ep_state.end_time, - freq=self.time_per_step - ) - } - - -class Action: - @property - def action_space(self): - return spaces.Discrete(5) - - def __call__(self, action: Any, ep_state: EpisodicState) -> Any: - if not self.validate(action): - raise ValueError(f'Action space does not contain action. Space: {self.action_space} Sample: {action}') - act_ = self.to_volume(action, ep_state) - return act_ - - def validate(self, action: Any) -> bool: - return self.action_space.contains(action) - - def to_volume(self, action: Any, ep_state: EpisodicState): - exec_vol = ep_state.position / 5 * action - if ep_state.cur_step + 1 >= ep_state.num_step: - exec_vol = ep_state.position - # TODO: might need to check whether the stock is tradable or whether it satisfies trade unit? - return exec_vol - - -class Reward: - weight = 1.0 - - def __call__(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: - rew, info = 0., {} - if ep_state.done: - ep_rew, ep_info = self._to_tuple(self.episode_end(ep_state)) - rew += ep_rew - info.update({f'ep/{k}': v for k, v in ep_info.items()}) - st_rew, st_info = self._to_tuple(self.step_end(ep_state, st_state)) - rew += st_rew - info.update({f'st/{k}': v for k, v in st_info.items()}) - return rew * self.weight, info - - @staticmethod - def _to_tuple(x): - if isinstance(x, tuple): - return x - return x, {} - - def episode_end(self, ep_state: EpisodicState) -> Tuple[float, Dict[str, float]]: - return 0. - - def step_end(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: - assert ep_state.target > 0 - baseline_price = st_state.pa_twap - pa = baseline_price * st_state.exec_vol.sum() / ep_state.target - penalty = -self.penalty * ((st_state.exec_vol / ep_state.target) ** 2).sum() - reward = pa + penalty - return reward, {'pa': pa, 'penalty': penalty} - - - class SingleOrderEnv(gym.Env): def __init__(self, observation: StateInterpreter, @@ -313,7 +191,7 @@ class SingleOrderEnv(gym.Env): def initialize_state(self): self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) - return EpisodicState( + state = EpisodicState( stock_id=self.cur_order.stock_id, start_time=self.cur_order.start_time, end_time=self.cur_order.end_time, @@ -323,29 +201,37 @@ class SingleOrderEnv(gym.Env): market_price=self.retrieve_backtest_data('$close'), market_vol=self.retrieve_backtest_data('$volume'), ) + state.cur_step = self.executor.trade_calendar.get_trade_step() + assert state.cur_step == 0 + state.cur_time, _ = self.executor.trade_calendar.get_step_time(state.cur_step) + return state def update_state(self, exec_vol): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time = self.executor.trade_calendar.get_step_time(trade_step) - trade_end_time = self.executor.trade_calendar.get_step_time(trade_step, shift=1) - trade_decision = Order(**asdict(self.cur_order), - start_time=trade_start_time, end_time=trade_end_time, amount=exec_vol) + calendar = self.executor.trade_calendar + state = self.ep_state + + trade_step = calendar.get_trade_step() + trade_start_time, trade_end_time = calendar.get_step_time(trade_step) + order_kwargs = asdict(self.cur_order) + order_kwargs.update(start_time=trade_start_time, end_time=trade_end_time, amount=exec_vol) + trade_decision = Order(**order_kwargs) execute_result = self.executor.execute([trade_decision]) - cur_tick = self.ep_state.cur_tick + cur_tick = state.cur_tick inner_exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) ticks_this_step = len(inner_exec_vol) - state = self.ep_state - state.cur_step = trade_step = self.executor.trade_calendar.get_trade_step() - state.cur_time = self.executor.trade_calendar.get_step_time(trade_step) + state.cur_step = trade_step = calendar.get_trade_step() state.cur_tick += ticks_this_step state.position -= np.sum(inner_exec_vol) state.position_history[trade_step] = state.position - state.exec_vol = inner_exec_vol if state.exec_vol is None else np.concatenate((state.exec_vol, inner_exec_vol)) - state.done = self.executor.finished() + state.exec_vol = inner_exec_vol if state.exec_vol is None else \ + np.concatenate((state.exec_vol, inner_exec_vol)) + if state.done: state.update_stats() + else: + state.cur_time, _ = calendar.get_step_time(trade_step) l, r = cur_tick, cur_tick + ticks_this_step assert 0 <= l < r @@ -362,19 +248,23 @@ class SingleOrderEnv(gym.Env): self.ep_state = self.initialize_state() self.action_history = np.full(self.ep_state.num_step, np.nan) - return self.observation(self.cur_sample, self.ep_state) + return self.observation(self.ep_state) def step(self, action): assert self.dataloader is not None + assert not self.executor.finished() + self.action_history[self.ep_state.cur_step] = action exec_vol = self.action(action, self.ep_state) step_state = self.update_state(exec_vol) + if self.executor.finished(): + assert self.ep_state.done reward, rew_info = self.reward(self.ep_state, step_state) info = { 'action_history': self.action_history, - 'category': self.ep_state.flow_dir.value, + 'category': self.ep_state.direction, 'reward': rew_info } if self.ep_state.done: @@ -383,8 +273,9 @@ class SingleOrderEnv(gym.Env): 'ins': self.ep_state.stock_id, 'date': self.ep_state.start_time, } + pprint(info) - return self.observation(self.cur_sample, self.ep_state), reward, self.ep_state.done, info + return self.observation(self.ep_state), reward, self.ep_state.done, info def _init_qlib(): @@ -412,39 +303,165 @@ def _main(): "generate_report": False, } } - executor = get_executor( - trade_start_time, - trade_end_time, - executor_config, - benchmark, - 1000000000, - exchange_kwargs={ - "freq": "day", - "limit_threshold": 0.095, - "deal_price": "close", - "open_cost": 0.0005, - "close_cost": 0.0015, - "min_cost": 5, - } + exchange = get_exchange( + freq="day", + limit_threshold=0.095, + deal_price="close", + open_cost=0.0005, + close_cost=0.0015, + min_cost=5 ) observation = Observation(time_per_step) action = Action() reward_fn = Reward() - def dummy_env(): return SingleOrderEnv( - observation, action, reward_fn, - DataLoader(QlibOrderDataset('rl.pkl'), batch_size=None, shuffle=True), executor) + def dummy_env(): + executor = get_executor( + trade_start_time, + trade_end_time, + executor_config, + exchange, + benchmark, + 1000000000, + ) + return SingleOrderEnv( + observation, action, reward_fn, + iter(DataLoader(QlibOrderDataset('rl.pkl'), batch_size=None, shuffle=True)), executor) + policy = DummyPolicy() - env = dummy_env() - obs = env.reset() - print(obs) + envs = DummyVectorEnv([dummy_env for _ in range(4)]) + test_collector = Collector(policy, envs) + policy.eval() + test_collector.collect(n_episode=10) - # envs = DummyVectorEnv([dummy_env for _ in range(4)]) - # test_collector = Collector(policy, envs) - # policy.eval() - # test_collector.collect(n_episode=10) + +### This is a full RL strategy ### + + +class QlibOrderDataset(Dataset): + def __init__(self, order_file): + with open(order_file, 'rb') as f: + self.orders = pickle.load(f) + + def __len__(self): + return len(self.orders) + + def __getitem__(self, index): + return self.orders[index] + + +class DummyPolicy(BasePolicy): + def forward(self, batch, state=None, **kwargs): + return Batch(act=np.random.randint(0, 5, size=(len(batch), ))) + + def learn(self, *args, **kwargs): + pass + + +class Observation: + def __init__(self, time_per_step): + self.time_per_step = time_per_step + + def __call__(self, ep_state: EpisodicState) -> Any: + obs = self.observe(ep_state) + if not self.validate(obs): + raise ValueError(f'Observation space does not contain obs. Space: {self.observation_space} Sample: {obs}') + return obs + + def validate(self, obs: Any) -> bool: + return self.observation_space.contains(obs) + + @property + def observation_space(self): + space = { + 'direction': spaces.Discrete(2), + 'cur_step': spaces.Box(0, MAX_STEPS, shape=(), dtype=np.int32), + 'num_step': spaces.Box(0, MAX_STEPS, shape=(), dtype=np.int32), + 'target': spaces.Box(-1e-5, np.inf, shape=()), + 'position': spaces.Box(-1e-5, np.inf, shape=()), + 'features': spaces.Box(-np.inf, np.inf, shape=(5, )) + } + return spaces.Dict(space) + + def observe(self, ep_state: EpisodicState) -> Any: + return { + 'direction': _to_int32(ep_state.direction), + 'cur_step': _to_int32(min(ep_state.cur_step, ep_state.num_step - 1)), + 'num_step': _to_int32(ep_state.num_step), + 'target': _to_float32(ep_state.target), + 'position': _to_float32(ep_state.position), + 'features': D.features( + [ep_state.stock_id], + ['$open', '$close', '$high', '$low', '$volume'], + start_time=ep_state.start_time, + end_time=ep_state.end_time, + freq=self.time_per_step + ).loc[(ep_state.stock_id, ep_state.cur_time)].to_numpy(), + } + + +class Action: + denominator = 4 + + @property + def action_space(self): + return spaces.Discrete(self.denominator + 1) + + def __call__(self, action: Any, ep_state: EpisodicState) -> Any: + if not self.validate(action): + raise ValueError(f'Action space does not contain action. Space: {self.action_space} Sample: {action}') + act_ = self.to_volume(action, ep_state) + return act_ + + def validate(self, action: Any) -> bool: + return self.action_space.contains(action) + + def to_volume(self, action: Any, ep_state: EpisodicState): + exec_vol = ep_state.position / self.denominator * action + if ep_state.cur_step + 1 >= ep_state.num_step: + exec_vol = ep_state.position + # TODO: might need to check whether the stock is tradable or whether it satisfies trade unit? + return exec_vol + + +class Reward: + weight = 1.0 + + def __call__(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: + rew, info = 0., {} + if ep_state.done: + ep_rew, ep_info = self._to_tuple(self.episode_end(ep_state)) + rew += ep_rew + info.update({f'ep/{k}': v for k, v in ep_info.items()}) + st_rew, st_info = self._to_tuple(self.step_end(ep_state, st_state)) + rew += st_rew + info.update({f'st/{k}': v for k, v in st_info.items()}) + return rew * self.weight, info + + @staticmethod + def _to_tuple(x): + if isinstance(x, tuple): + return x + return x, {} + + def episode_end(self, ep_state: EpisodicState) -> Tuple[float, Dict[str, float]]: + return 0. + + def step_end(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: + assert ep_state.target > 0 + baseline_price = st_state.pa_twap + pa = baseline_price * st_state.exec_vol.sum() / ep_state.target + penalty = -100 * ((st_state.exec_vol / ep_state.target) ** 2).sum() # penalize too much volume at one step + reward = pa + penalty + return reward, {'pa': pa, 'penalty': penalty} + + +def _to_int32(val): return np.array(int(val), dtype=np.int32) +def _to_float32(val): return np.array(val, dtype=np.float32) + +### End of RL strategy ### if __name__ == '__main__': From cc8339acd925a2df0027ae64bb0b8a4a360ed504 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 2 Jun 2021 16:49:52 +0800 Subject: [PATCH 042/187] Add a few comments --- rl_orders | Bin 0 -> 3464 bytes rl_playground.py | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 rl_orders diff --git a/rl_orders b/rl_orders new file mode 100644 index 0000000000000000000000000000000000000000..7902b901c000bfd82fb7fcc0386c588f3f78cbb4 GIT binary patch literal 3464 zcmai$eM}Q~7{^;->?j+k)cJzoL`)bn&F}ioCT}CMX-(Nc=HkTe`a-UQzU+G4Bus`Q zI>BQSJ%TO}L`B0UZjDnjmStp_EHR5j&`k+(i2|Dw#>|9GmWBPU9rq*4-LLmYFYP_~ z-1m8&@9%k97u&MuNk#Z7=QFwFx2oKBjh%8-vaSHD@i9&p!*h=nhwn%DXZG@YU=$Hx zeU3_-+sSi8=}SfcOhCtTag@gw^s+p+%p3Iht2GWE zVH$dr2E9}wiP)^8GkM_zf_6G4Qc*e%7IFswTD`%@(*z_fDI|2)$?me_$pzt9dbveG zub@-uH2P_Jtwl+v!=Uwrv709+a%EvAg9!UZb-%Y|UHDQ}%7Dpe7Gb78?v?ND zOvJ;c2G2?=6Z~Qzvqjjb>Wh7UcMV5+Dwk$~(<%vrH3Pt?6Je)8NAJDdB)qbph&f3Z zR7L`Tl>J^@(iBX}!o$vOJcNOvZZ?uRz2~V7cv$N`ZODF0d10vz53|RWOTvWXWLR*}S6m-_MO`qBw?_Vz zOvwfaHPdTR&GPqMiNE!58D3eX@~EUTAx)hQS%Yc(Zt9o#=kT!BHPTZ$G zyve?R77v@K@XIPQk;rE=LqiGwG)HMbd#Oea21ql4)sN1mzA(KGudM4wP7)?GK&vN3 zpB-%P?w@Rk#lub{zbe^Hp=M?if;GDQ>ANoK@vzksJ+jK+zXnm+{qJwi7HzqS_u2N2 z?U8{0?M*?M&Wx<__>JOEE~Uiam2G|PUCA_e=-?4BqrI6j=WQ+x#p7X*KD#TKrl5?> zMmylzdy8|`Em7uK_o75;Wx{bHkuvqQnz8(kpTaA9N7X5>%z(zWZ+emj5i%p_1m(_;NTX0w)?lfma&mM zJgkmgFAFm**a5@A(LP&wl!fx_;d;4l0>gssba_1WTw7=aU$fym6_POMGq?lL)OfK! zcs@f#;?qp&|4=qfok=U!?D*>1*`lPCcvwS^^mYpGO%nNxFPkXaoCNT&+GB*IvhX|u zNLk6u!L7u)O?cSd99D7(3pInIR!q~dZQOS8Oq80b_n(qe28RZM{b0Er^8OlS1gRdL Jo`< @dataclass class EpisodicState: """ - A simplified data structure for RL-related components to process observations and rewards + A simplified data structure as the input of RL-related components to calculate observations and rewards. + Some of the metrics info are calculated on-the-fly in this class. """ # requirements stock_id: int @@ -181,6 +182,7 @@ class SingleOrderEnv(gym.Env): return self.observation.observation_space def retrieve_backtest_data(self, field: str): + # Retrieve backtest data for RL-specific use (including reward calculation) return D.features( [self.cur_order.stock_id], ['$open', '$close', '$high', '$low', '$volume'], @@ -190,6 +192,7 @@ class SingleOrderEnv(gym.Env): )[field].to_numpy() def initialize_state(self): + # Synchronous state for executor to EpisodicState self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) state = EpisodicState( stock_id=self.cur_order.stock_id, @@ -207,6 +210,7 @@ class SingleOrderEnv(gym.Env): return state def update_state(self, exec_vol): + # Synchronous exec_vol to executor and synchronous back to EpisodicState calendar = self.executor.trade_calendar state = self.ep_state @@ -273,6 +277,7 @@ class SingleOrderEnv(gym.Env): 'ins': self.ep_state.stock_id, 'date': self.ep_state.start_time, } + # TODO: collect logs pprint(info) return self.observation(self.ep_state), reward, self.ep_state.done, info @@ -327,13 +332,18 @@ def _main(): ) return SingleOrderEnv( observation, action, reward_fn, - iter(DataLoader(QlibOrderDataset('rl.pkl'), batch_size=None, shuffle=True)), executor) + iter(DataLoader(QlibOrderDataset('rl_orders'), batch_size=None, shuffle=True)), executor) policy = DummyPolicy() + # This can not be replaced with SubprocVectorEnv + # File "/xxx/qlib/qlib/data/data.py", line 462, in dataset_processor + # p = Pool(processes=workers) + # AssertionError: daemonic processes are not allowed to have children envs = DummyVectorEnv([dummy_env for _ in range(4)]) test_collector = Collector(policy, envs) policy.eval() + # TODO: create a queue for all orders and make it auto-complete when all the orders are processed test_collector.collect(n_episode=10) From 231440561324e3592e7eb5ed82fafe8a2a9d55ce Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 2 Jun 2021 16:53:39 +0800 Subject: [PATCH 043/187] Rename files --- .../nested_decision_execution/assets/orders | Bin .../nested_decision_execution/rl_dummy.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename rl_orders => examples/nested_decision_execution/assets/orders (100%) rename rl_playground.py => examples/nested_decision_execution/rl_dummy.py (99%) diff --git a/rl_orders b/examples/nested_decision_execution/assets/orders similarity index 100% rename from rl_orders rename to examples/nested_decision_execution/assets/orders diff --git a/rl_playground.py b/examples/nested_decision_execution/rl_dummy.py similarity index 99% rename from rl_playground.py rename to examples/nested_decision_execution/rl_dummy.py index fa2022dcb..1ea444cdf 100644 --- a/rl_playground.py +++ b/examples/nested_decision_execution/rl_dummy.py @@ -332,7 +332,7 @@ def _main(): ) return SingleOrderEnv( observation, action, reward_fn, - iter(DataLoader(QlibOrderDataset('rl_orders'), batch_size=None, shuffle=True)), executor) + iter(DataLoader(QlibOrderDataset('assets/orders'), batch_size=None, shuffle=True)), executor) policy = DummyPolicy() From f5ac6230e13e80b1eee1a33ecb0e590b3e072758 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 2 Jun 2021 22:04:54 +0800 Subject: [PATCH 044/187] Refactor for strategy --- .../nested_decision_execution/rl_dummy.py | 134 ++++++++++-------- 1 file changed, 71 insertions(+), 63 deletions(-) diff --git a/examples/nested_decision_execution/rl_dummy.py b/examples/nested_decision_execution/rl_dummy.py index 1ea444cdf..3eec91789 100644 --- a/examples/nested_decision_execution/rl_dummy.py +++ b/examples/nested_decision_execution/rl_dummy.py @@ -1,7 +1,7 @@ import pickle from dataclasses import dataclass, asdict from pprint import pprint -from typing import Iterable, Any, Optional, Tuple, Dict +from typing import Iterable, Any, Optional, Tuple, Dict, List import gym import numpy as np @@ -128,6 +128,48 @@ class EpisodicState: } return logs + @classmethod + def from_order_and_executor(cls, order: Order, executor: BaseExecutor, frequency: str) -> "EpisodicState": + # Synchronous state for executor to EpisodicState + executor.reset(start_time=order.start_time, end_time=order.end_time) + state = cls( + stock_id=order.stock_id, + start_time=order.start_time, + end_time=order.end_time, + direction=order.direction, + target=order.amount, + num_step=executor.trade_calendar.get_trade_len(), + market_price=_retrieve_backtest_data(order, '$close', frequency), + market_vol=_retrieve_backtest_data(order, '$volume', frequency), + ) + state.cur_step = executor.trade_calendar.get_trade_step() + assert state.cur_step == 0 + state.cur_time, _ = executor.trade_calendar.get_step_time(state.cur_step) + return state + + def update(self, execute_result: List[Order], executor: BaseExecutor) -> "StepState": + exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) + # Synchronous exec_vol to executor and synchronous back to EpisodicState + calendar = executor.trade_calendar + cur_tick = self.cur_tick + ticks_this_step = len(exec_vol) + self.cur_step = trade_step = calendar.get_trade_step() + self.cur_tick += ticks_this_step + self.position -= np.sum(exec_vol) + self.position_history[trade_step] = self.position + self.done = executor.finished() + self.exec_vol = exec_vol if self.exec_vol is None else \ + np.concatenate((self.exec_vol, exec_vol)) + + if self.done: + self.update_stats() + else: + self.cur_time, _ = calendar.get_step_time(trade_step) + + l, r = cur_tick, cur_tick + ticks_this_step + assert 0 <= l < r + return StepState(exec_vol, self.market_vol[l:r], self.market_price[l:r], self) + @dataclass class StepState: @@ -158,6 +200,28 @@ class StepState: self.episode_state.direction) +def _retrieve_backtest_data(order: Order, field: str, frequency: str) -> np.ndarray: + # Retrieve backtest data for RL-specific use (including reward calculation) + return D.features( + [order.stock_id], + ['$open', '$close', '$high', '$low', '$volume'], + start_time=order.start_time, + end_time=order.end_time, + freq=frequency + )[field].to_numpy() + + +def create_sub_order(exec_vol: float, executor: BaseExecutor, original_order: Order) -> Order: + # Convert a real number to an order + calendar = executor.trade_calendar + trade_step = calendar.get_trade_step() + trade_start_time, trade_end_time = calendar.get_step_time(trade_step) + order_kwargs = asdict(original_order) + order_kwargs.update(start_time=trade_start_time, end_time=trade_end_time, amount=exec_vol) + trade_decision = Order(**order_kwargs) + return trade_decision + + class SingleOrderEnv(gym.Env): def __init__(self, observation: StateInterpreter, @@ -181,66 +245,6 @@ class SingleOrderEnv(gym.Env): def observation_space(self): return self.observation.observation_space - def retrieve_backtest_data(self, field: str): - # Retrieve backtest data for RL-specific use (including reward calculation) - return D.features( - [self.cur_order.stock_id], - ['$open', '$close', '$high', '$low', '$volume'], - start_time=self.cur_order.start_time, - end_time=self.cur_order.end_time, - freq=self.inner_frequency - )[field].to_numpy() - - def initialize_state(self): - # Synchronous state for executor to EpisodicState - self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) - state = EpisodicState( - stock_id=self.cur_order.stock_id, - start_time=self.cur_order.start_time, - end_time=self.cur_order.end_time, - direction=self.cur_order.direction, - target=self.cur_order.amount, - num_step=self.executor.trade_calendar.get_trade_len(), - market_price=self.retrieve_backtest_data('$close'), - market_vol=self.retrieve_backtest_data('$volume'), - ) - state.cur_step = self.executor.trade_calendar.get_trade_step() - assert state.cur_step == 0 - state.cur_time, _ = self.executor.trade_calendar.get_step_time(state.cur_step) - return state - - def update_state(self, exec_vol): - # Synchronous exec_vol to executor and synchronous back to EpisodicState - calendar = self.executor.trade_calendar - state = self.ep_state - - trade_step = calendar.get_trade_step() - trade_start_time, trade_end_time = calendar.get_step_time(trade_step) - order_kwargs = asdict(self.cur_order) - order_kwargs.update(start_time=trade_start_time, end_time=trade_end_time, amount=exec_vol) - trade_decision = Order(**order_kwargs) - execute_result = self.executor.execute([trade_decision]) - cur_tick = state.cur_tick - - inner_exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) - ticks_this_step = len(inner_exec_vol) - state.cur_step = trade_step = calendar.get_trade_step() - state.cur_tick += ticks_this_step - state.position -= np.sum(inner_exec_vol) - state.position_history[trade_step] = state.position - state.done = self.executor.finished() - state.exec_vol = inner_exec_vol if state.exec_vol is None else \ - np.concatenate((state.exec_vol, inner_exec_vol)) - - if state.done: - state.update_stats() - else: - state.cur_time, _ = calendar.get_step_time(trade_step) - - l, r = cur_tick, cur_tick + ticks_this_step - assert 0 <= l < r - return StepState(inner_exec_vol, state.market_vol[l:r], state.market_price[l:r], state) - def reset(self): try: self.cur_order = next(self.dataloader) @@ -249,7 +253,9 @@ class SingleOrderEnv(gym.Env): return None self.execute_result = [] - self.ep_state = self.initialize_state() + self.ep_state = EpisodicState.from_order_and_executor( + self.cur_order, self.executor, self.inner_frequency + ) self.action_history = np.full(self.ep_state.num_step, np.nan) return self.observation(self.ep_state) @@ -260,7 +266,9 @@ class SingleOrderEnv(gym.Env): self.action_history[self.ep_state.cur_step] = action exec_vol = self.action(action, self.ep_state) - step_state = self.update_state(exec_vol) + trade_decision = create_sub_order(exec_vol, self.executor, self.cur_order) + execute_result = self.executor.execute([trade_decision]) + step_state = self.ep_state.update(execute_result, self.executor) if self.executor.finished(): assert self.ep_state.done From bf02fc23f8a63e901ba969b546bc45366f6038d7 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 2 Jun 2021 23:20:27 +0800 Subject: [PATCH 045/187] Add RL strategy demo --- .../nested_decision_execution/rl_dummy.py | 78 +++++++++++++++---- qlib/backtest/__init__.py | 1 + 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/examples/nested_decision_execution/rl_dummy.py b/examples/nested_decision_execution/rl_dummy.py index 3eec91789..61f1bba59 100644 --- a/examples/nested_decision_execution/rl_dummy.py +++ b/examples/nested_decision_execution/rl_dummy.py @@ -1,17 +1,19 @@ import pickle +from collections import OrderedDict, defaultdict from dataclasses import dataclass, asdict from pprint import pprint -from typing import Iterable, Any, Optional, Tuple, Dict, List +from typing import Iterable, Any, Optional, OrderedDict, Tuple, Dict, List import gym import numpy as np import pandas as pd import qlib from gym import spaces -from qlib.backtest import get_exchange, Account, BaseExecutor, CommonInfrastructure, Order +from qlib.backtest import get_exchange, Account, BaseExecutor, CommonInfrastructure, Order, TradeCalendarManager from qlib.config import REG_CN from qlib.data import D from qlib.rl.interpreter import StateInterpreter, ActionInterpreter +from qlib.strategy import BaseStrategy from qlib.tests.data import GetData from qlib.utils import init_instance_by_config, exists_qlib_data from torch.utils.data import Dataset, DataLoader @@ -129,35 +131,36 @@ class EpisodicState: return logs @classmethod - def from_order_and_executor(cls, order: Order, executor: BaseExecutor, frequency: str) -> "EpisodicState": + def from_order_and_executor(cls, order: Order, calendar: TradeCalendarManager, frequency: str) -> "EpisodicState": # Synchronous state for executor to EpisodicState - executor.reset(start_time=order.start_time, end_time=order.end_time) state = cls( stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time, direction=order.direction, target=order.amount, - num_step=executor.trade_calendar.get_trade_len(), + num_step=calendar.get_trade_len(), market_price=_retrieve_backtest_data(order, '$close', frequency), market_vol=_retrieve_backtest_data(order, '$volume', frequency), ) - state.cur_step = executor.trade_calendar.get_trade_step() + state.cur_step = calendar.get_trade_step() assert state.cur_step == 0 - state.cur_time, _ = executor.trade_calendar.get_step_time(state.cur_step) + state.cur_time, _ = calendar.get_step_time(state.cur_step) return state - def update(self, execute_result: List[Order], executor: BaseExecutor) -> "StepState": + def update(self, execute_result: List[Order], calendar: TradeCalendarManager, done: Optional[bool] = None) -> "StepState": exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) # Synchronous exec_vol to executor and synchronous back to EpisodicState - calendar = executor.trade_calendar cur_tick = self.cur_tick ticks_this_step = len(exec_vol) self.cur_step = trade_step = calendar.get_trade_step() self.cur_tick += ticks_this_step self.position -= np.sum(exec_vol) self.position_history[trade_step] = self.position - self.done = executor.finished() + if done is not None: + self.done = done + else: + self.done = self.position < 1e-5 self.exec_vol = exec_vol if self.exec_vol is None else \ np.concatenate((self.exec_vol, exec_vol)) @@ -211,9 +214,8 @@ def _retrieve_backtest_data(order: Order, field: str, frequency: str) -> np.ndar )[field].to_numpy() -def create_sub_order(exec_vol: float, executor: BaseExecutor, original_order: Order) -> Order: +def create_sub_order(exec_vol: float, calendar: TradeCalendarManager, original_order: Order) -> Order: # Convert a real number to an order - calendar = executor.trade_calendar trade_step = calendar.get_trade_step() trade_start_time, trade_end_time = calendar.get_step_time(trade_step) order_kwargs = asdict(original_order) @@ -253,8 +255,9 @@ class SingleOrderEnv(gym.Env): return None self.execute_result = [] + self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) self.ep_state = EpisodicState.from_order_and_executor( - self.cur_order, self.executor, self.inner_frequency + self.cur_order, self.executor.trade_calendar, self.inner_frequency ) self.action_history = np.full(self.ep_state.num_step, np.nan) @@ -266,9 +269,9 @@ class SingleOrderEnv(gym.Env): self.action_history[self.ep_state.cur_step] = action exec_vol = self.action(action, self.ep_state) - trade_decision = create_sub_order(exec_vol, self.executor, self.cur_order) + trade_decision = create_sub_order(exec_vol, self.executor.trade_calendar, self.cur_order) execute_result = self.executor.execute([trade_decision]) - step_state = self.ep_state.update(execute_result, self.executor) + step_state = self.ep_state.update(execute_result, self.executor.trade_calendar) if self.executor.finished(): assert self.ep_state.done @@ -291,6 +294,47 @@ class SingleOrderEnv(gym.Env): return self.observation(self.ep_state), reward, self.ep_state.done, info +class RLStrategy(BaseStrategy): + """When inference and do the backtest from end to end, use this strategy.""" + # TODO This strategy is still for code demo purpose only. + # It has not been end-to-end tested. + + def __init__( + self, + observation: "Observation", + action: "Action", + policy: BasePolicy, + **kwargs + ): + super().__init__(**kwargs) + self.observation = observation + self.action = action + self.policy = policy + + def reset(self, outer_trade_decision: List[Order] = None, **kwargs): + super().reset(outer_trade_decision=outer_trade_decision, **kwargs) + if outer_trade_decision is not None: + self.states = OrderedDict() # explicitly make it ordered + for order in outer_trade_decision: + # TODO: how to get inner frequency + state = EpisodicState.from_order_and_executor(order, self.trade_calendar, "day") + self.states[order.stock_id, order.direction] = state + + def generate_trade_decision(self, execute_result=None): + # apply results from the last step + if execute_result is not None: + orders = defaultdict(list) + for order, _, __, in execute_result: + orders[order.stock_id, order.direction].append(order) + for (stock_id, direction), state in self.states.items(): + state.update(orders[stock_id, direction]) + + obs_batch = Batch([{"obs": self.observation(state)} for state in self.states.values()]) + act = self.policy(obs_batch) + exec_vols = [self.action(a) for a in act.act] + return [create_sub_order(v, self.trade_calendar, order) for v in exec_vols] + + def _init_qlib(): provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir if not exists_qlib_data(provider_uri): @@ -299,7 +343,7 @@ def _init_qlib(): qlib.init(provider_uri=provider_uri, region=REG_CN) -def _main(): +def _main_tianshou(): _init_qlib() # TODO: why is there a benchmark? @@ -483,4 +527,4 @@ def _to_float32(val): return np.array(val, dtype=np.float32) if __name__ == '__main__': - _main() + _main_tianshou() diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index f80f7ebeb..c053269ef 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -7,6 +7,7 @@ from .executor import BaseExecutor from .backtest import backtest as backtest_func from .backtest import collect_data as data_generator from .order import Order +from .utils import TradeCalendarManager from .utils import CommonInfrastructure from .order import Order From 8aee853a1145effe8dd9cf5835319a0ee090d7da Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 4 Jun 2021 00:55:10 +0800 Subject: [PATCH 046/187] update Exchange --- qlib/backtest/exchange.py | 93 ++++++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index de2df98be..3da1ebfdc 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -26,6 +26,7 @@ class Exchange: deal_price=None, subscribe_fields=[], limit_threshold=None, + volume_threshold=None, open_cost=0.0015, close_cost=0.0025, trade_unit=None, @@ -41,6 +42,7 @@ class Exchange: :param deal_price: str, 'close', 'open', 'vwap' :param subscribe_fields: list, subscribe fields :param limit_threshold: float, 0.1 for example, default None + :param volume_threshold: float, 0.1 for example, default None :param open_cost: cost rate for open, default 0.0015 :param close_cost: cost rate for close, default 0.0025 :param trade_unit: trade unit, 100 for China A market @@ -60,6 +62,7 @@ class Exchange: self.freq = freq self.start_time = start_time self.end_time = end_time + if trade_unit is None: trade_unit = C.trade_unit if limit_threshold is None: @@ -70,7 +73,6 @@ class Exchange: self.logger = get_module_logger("online operator", level=logging.INFO) self.trade_unit = trade_unit - # TODO: the quote, trade_dates, codes are not necessray. # It is just for performance consideration. if limit_threshold is None: @@ -100,7 +102,7 @@ class Exchange: self.close_cost = close_cost self.min_cost = min_cost self.limit_threshold = limit_threshold - + self.volume_threshold = volume_threshold self.extra_quote = extra_quote self.set_quote(codes, start_time, end_time) @@ -120,14 +122,19 @@ class Exchange: # Use adjusted price self.trade_w_adj_price = True self.logger.warning("factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.") + if self.trade_unit is not None: + self.logger.warning(f"trade unit {self.trade_unit} is not supported in adjusted_price mode.") + else: # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` # Use normal price self.trade_w_adj_price = False + # update limit # check limit_threshold if self.limit_threshold is None: - self.quote["limit"] = False + self.quote["limit_buy"] = False + self.quote["limit_sell"] = False else: # set limit self._update_limit(buy_limit=self.limit_threshold, sell_limit=self.limit_threshold) @@ -143,9 +150,13 @@ class Exchange: if "$factor" not in self.extra_quote.columns: self.extra_quote["$factor"] = 1.0 self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") - if "limit" not in self.extra_quote.columns: - self.extra_quote["limit"] = False - self.logger.warning("No limit set for extra_quote. All stock will be tradable.") + if "limit_sell" not in self.extra_quote.columns: + self.extra_quote["limit_sell"] = False + self.logger.warning("No limit_sell set for extra_quote. All stock will be able to be sold.") + if "limit_buy" not in self.extra_quote.columns: + self.extra_quote["limit_buy"] = False + self.logger.warning("No limit_buy set for extra_quote. All stock will be able to be bought.") + assert set(self.extra_quote.columns) == set(quote_df.columns) - {"$change"} quote_df = pd.concat([quote_df, self.extra_quote], sort=False, axis=0) @@ -160,15 +171,30 @@ class Exchange: self.quote = quote_dict def _update_limit(self, buy_limit, sell_limit): - self.quote["limit"] = ~self.quote["$change"].between(-sell_limit, buy_limit, inclusive=False) + self.quote["limit_buy"] = ~self.quote["$change"].lt(buy_limit) + self.quote["limit_sell"] = ~self.quote["$change"].gt(-sell_limit) - def check_stock_limit(self, stock_id, start_time, end_time): - """Parameter - stock_id - trade_date - is limtited + def check_stock_limit(self, stock_id, start_time, end_time, direction=None): """ - return resam_ts_data(self.quote[stock_id]["limit"], start_time, end_time, method="all").iloc[0] + Parameters + ---------- + direction : int, optional + trade direction, by default None + - if direction is None, check if tradable for buying and selling. + - if direction == Order.BUY, check the if tradable for buying + - if direction == Order.SELL, check the sell limit for selling. + + """ + if direction is None: + buy_limit = resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all").iloc[0] + sell_limit = resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all").iloc[0] + return buy_limit or sell_limit + elif direction == Order.BUY: + return resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all").iloc[0] + elif direction == Order.SELL: + return resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all").iloc[0] + else: + raise ValueError(f"direction {direction} is not supported!") def check_stock_suspended(self, stock_id, start_time, end_time): # is suspended @@ -177,11 +203,11 @@ class Exchange: else: return True - def is_stock_tradable(self, stock_id, start_time, end_time): + def is_stock_tradable(self, stock_id, start_time, end_time, direction=None): # check if stock can be traded # same as check in check_order if self.check_stock_suspended(stock_id, start_time, end_time) or self.check_stock_limit( - stock_id, start_time, end_time + stock_id, start_time, end_time, direction ): return False else: @@ -190,7 +216,7 @@ class Exchange: def check_order(self, order): # check limit and suspended if self.check_stock_suspended(order.stock_id, order.start_time, order.end_time) or self.check_stock_limit( - order.stock_id, order.start_time, order.end_time + order.stock_id, order.start_time, order.end_time, order.direction ): return False else: @@ -393,7 +419,7 @@ class Exchange: return value def get_amount_of_trade_unit(self, factor): - if not self.trade_w_adj_price: + if not self.trade_w_adj_price and self.trade_unit is not None: return self.trade_unit / factor else: return None @@ -404,11 +430,18 @@ class Exchange: factor : float, adjusted factor return : float, real amount """ - if not self.trade_w_adj_price: + if not self.trade_w_adj_price and self.trade_unit is not None: # the minimal amount is 1. Add 0.1 for solving precision problem. return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount + def _get_amount_by_volume(self, stock_id, trade_start_time, trade_end_time, deal_amount): + if self.volume_threshold is not None: + tradable_amount = self.get_volume(stock_id, trade_start_time, trade_end_time) * self.volume_threshold + return max(min(tradable_amount, deal_amount), 0) + else: + return deal_amount + def _calc_trade_info_by_order(self, order, position): """ Calculation of trade info @@ -421,17 +454,34 @@ class Exchange: trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) if order.direction == Order.SELL: # sell + current_amount = position.get_stock_amount(order.stock_id) + if position is not None: - if np.isclose(order.amount, position.get_stock_amount(order.stock_id)): + if np.isclose(order.amount, current_amount): # when selling last stock. The amount don't need rounding order.deal_amount = order.amount + elif order.amount > current_amount: + self.logger.warning( + f"order amount {order.amount} is greater than current amount {current_amount}, {current_amount} amount of stock is dealed" + ) + order.deal_amount = self.round_amount_by_trade_unit(current_amount, order.factor) else: order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) else: # TODO: We don't know current position. # We choose to sell all - order.deal_amount = order.amount + if not np.isclose(order.amount, current_amount) and order.amount > current_amount: + self.logger.warning( + f"order amount {order.amount} is greater than current amount {current_amount}, {current_amount} amount of stock is dealed" + ) + order.deal_amount = current_amount + else: + order.deal_amount = order.amount + + order.deal_amount = self._get_amount_by_volume( + order.stock_id, order.start_time, order.end_time, order.deal_amount + ) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.close_cost, self.min_cost) elif order.direction == Order.BUY: @@ -451,6 +501,9 @@ class Exchange: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + order.deal_amount = self._get_amount_by_volume( + order.stock_id, order.start_time, order.end_time, order.deal_amount + ) trade_val = order.deal_amount * trade_price trade_cost = trade_val * self.open_cost else: From c43805eff60475eddc5f3f17ce39936cc81de335 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Fri, 4 Jun 2021 12:20:27 +0800 Subject: [PATCH 047/187] Update end-to-end example and requirements --- .../requirements.txt | 2 + .../nested_decision_execution/rl_dummy.py | 175 +++++++++++------- 2 files changed, 113 insertions(+), 64 deletions(-) create mode 100644 examples/nested_decision_execution/requirements.txt diff --git a/examples/nested_decision_execution/requirements.txt b/examples/nested_decision_execution/requirements.txt new file mode 100644 index 000000000..2ad0a826f --- /dev/null +++ b/examples/nested_decision_execution/requirements.txt @@ -0,0 +1,2 @@ +tianshou>=0.4.1 +torch>=1.8.0 diff --git a/examples/nested_decision_execution/rl_dummy.py b/examples/nested_decision_execution/rl_dummy.py index 61f1bba59..4a8f50ad0 100644 --- a/examples/nested_decision_execution/rl_dummy.py +++ b/examples/nested_decision_execution/rl_dummy.py @@ -4,12 +4,14 @@ from dataclasses import dataclass, asdict from pprint import pprint from typing import Iterable, Any, Optional, OrderedDict, Tuple, Dict, List +import fire import gym import numpy as np import pandas as pd import qlib from gym import spaces -from qlib.backtest import get_exchange, Account, BaseExecutor, CommonInfrastructure, Order, TradeCalendarManager +from qlib.backtest import get_exchange, Account, BaseExecutor, CommonInfrastructure, Order, TradeCalendarManager, backtest_func +from qlib.backtest.executor import NestedExecutor, SimulatorExecutor from qlib.config import REG_CN from qlib.data import D from qlib.rl.interpreter import StateInterpreter, ActionInterpreter @@ -21,6 +23,8 @@ from tianshou.data import Batch, Collector from tianshou.env import DummyVectorEnv, SubprocVectorEnv from tianshou.policy import BasePolicy +from workflow import NestedDecisonExecutionWorkflow + MAX_STEPS = 10 @@ -324,79 +328,122 @@ class RLStrategy(BaseStrategy): # apply results from the last step if execute_result is not None: orders = defaultdict(list) - for order, _, __, in execute_result: - orders[order.stock_id, order.direction].append(order) + for e in execute_result: + orders[e[0].stock_id, e[0].direction].append(e) for (stock_id, direction), state in self.states.items(): - state.update(orders[stock_id, direction]) - + state.update(orders[stock_id, direction], self.trade_calendar) + + if not self.states: + return [] + obs_batch = Batch([{"obs": self.observation(state)} for state in self.states.values()]) act = self.policy(obs_batch) - exec_vols = [self.action(a) for a in act.act] - return [create_sub_order(v, self.trade_calendar, order) for v in exec_vols] + exec_vols = [self.action(a, s) for a, s in zip(act.act, self.states.values())] + return [create_sub_order(v, self.trade_calendar, o) for v, o in zip(exec_vols, self.outer_trade_decision)] -def _init_qlib(): - provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir - if not exists_qlib_data(provider_uri): - print(f"Qlib data is not found in {provider_uri}") - GetData().qlib_data(target_dir=provider_uri, region=REG_CN) - qlib.init(provider_uri=provider_uri, region=REG_CN) +class RlWorkflow(NestedDecisonExecutionWorkflow): + def tianshou(self): + self._init_qlib() -def _main_tianshou(): - _init_qlib() - - # TODO: why is there a benchmark? - trade_start_time = "2017-01-01" - trade_end_time = "2020-08-01" - benchmark = "SH000300" - time_per_step = "day" - executor_config = { - "class": "SimulatorExecutor", - "module_path": "qlib.backtest.executor", - "kwargs": { - "time_per_step": time_per_step, - "verbose": True, - "generate_report": False, + # TODO: why is there a benchmark? + trade_start_time = "2017-01-01" + trade_end_time = "2020-08-01" + benchmark = "SH000300" + time_per_step = "day" + executor_config = { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": time_per_step, + "verbose": True, + "generate_report": False, + } } - } - exchange = get_exchange( - freq="day", - limit_threshold=0.095, - deal_price="close", - open_cost=0.0005, - close_cost=0.0015, - min_cost=5 - ) - - observation = Observation(time_per_step) - action = Action() - reward_fn = Reward() - - def dummy_env(): - executor = get_executor( - trade_start_time, - trade_end_time, - executor_config, - exchange, - benchmark, - 1000000000, + exchange = get_exchange( + freq="day", + limit_threshold=0.095, + deal_price="close", + open_cost=0.0005, + close_cost=0.0015, + min_cost=5 ) - return SingleOrderEnv( - observation, action, reward_fn, - iter(DataLoader(QlibOrderDataset('assets/orders'), batch_size=None, shuffle=True)), executor) - policy = DummyPolicy() + observation = Observation(time_per_step) + action = Action() + reward_fn = Reward() - # This can not be replaced with SubprocVectorEnv - # File "/xxx/qlib/qlib/data/data.py", line 462, in dataset_processor - # p = Pool(processes=workers) - # AssertionError: daemonic processes are not allowed to have children - envs = DummyVectorEnv([dummy_env for _ in range(4)]) - test_collector = Collector(policy, envs) - policy.eval() - # TODO: create a queue for all orders and make it auto-complete when all the orders are processed - test_collector.collect(n_episode=10) + def dummy_env(): + executor = get_executor( + trade_start_time, + trade_end_time, + executor_config, + exchange, + benchmark, + 1000000000, + ) + return SingleOrderEnv( + observation, action, reward_fn, + iter(DataLoader(QlibOrderDataset('assets/orders'), batch_size=None, shuffle=True)), executor) + + policy = DummyPolicy() + + # This can not be replaced with SubprocVectorEnv + # File "/xxx/qlib/qlib/data/data.py", line 462, in dataset_processor + # p = Pool(processes=workers) + # AssertionError: daemonic processes are not allowed to have children + envs = DummyVectorEnv([dummy_env for _ in range(4)]) + test_collector = Collector(policy, envs) + policy.eval() + # TODO: create a queue for all orders and make it auto-complete when all the orders are processed + test_collector.collect(n_episode=10) + + def rl_day(self, load_model: Optional[str] = None): + self._init_qlib() + model = init_instance_by_config(self.task["model"]) + dataset = init_instance_by_config(self.task["dataset"]) + if load_model is None: + self._train_model(model, dataset) + else: + model = self._load_model(load_model) + trade_start_time = "2017-01-01" + trade_end_time = "2020-08-01" + trade_account = Account( + init_cash=int(1e9), + benchmark_config={ + "benchmark": "SH000300", + "start_time": trade_start_time, + "end_time": trade_end_time, + }, + ) + exchange = get_exchange( + freq="day", + limit_threshold=0.095, + deal_price="close", + open_cost=0.0005, + close_cost=0.0015, + min_cost=5 + ) + common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=exchange) + strategy = init_instance_by_config({ + "class": "TopkDropoutStrategy", + "module_path": "qlib.contrib.strategy.model_strategy", + "kwargs": { + "model": model, + "dataset": dataset, + "topk": 50, + "n_drop": 5, + }, + }, common_infra=common_infra) + executor = NestedExecutor( + time_per_step="week", + inner_executor=SimulatorExecutor(time_per_step="day", verbose=True), + inner_strategy=RLStrategy(Observation("day"), Action(), DummyPolicy()), + common_infra=common_infra + ) + report_dict = backtest_func(trade_start_time, trade_end_time, strategy, executor) + print(report_dict) ### This is a full RL strategy ### @@ -527,4 +574,4 @@ def _to_float32(val): return np.array(val, dtype=np.float32) if __name__ == '__main__': - _main_tianshou() + fire.Fire(RlWorkflow) From 1581ef12accdb32f41a5272c189105184992abd6 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Fri, 4 Jun 2021 13:01:49 +0800 Subject: [PATCH 048/187] Update impl for robustness --- .../nested_decision_execution/rl_dummy.py | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/examples/nested_decision_execution/rl_dummy.py b/examples/nested_decision_execution/rl_dummy.py index 4a8f50ad0..cd0961f66 100644 --- a/examples/nested_decision_execution/rl_dummy.py +++ b/examples/nested_decision_execution/rl_dummy.py @@ -152,8 +152,13 @@ class EpisodicState: state.cur_time, _ = calendar.get_step_time(state.cur_step) return state - def update(self, execute_result: List[Order], calendar: TradeCalendarManager, done: Optional[bool] = None) -> "StepState": - exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) + def update(self, execute_result: List[Order], calendar: TradeCalendarManager, + done: Optional[bool] = None, length: Optional[int] = None) -> "StepState": + if length is not None: + exec_vol = np.zeros(length) + exec_vol[:len(execute_result)] = np.array([order.deal_amount for order, _, __, ___ in execute_result]) + else: + exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) # Synchronous exec_vol to executor and synchronous back to EpisodicState cur_tick = self.cur_tick ticks_this_step = len(exec_vol) @@ -300,8 +305,6 @@ class SingleOrderEnv(gym.Env): class RLStrategy(BaseStrategy): """When inference and do the backtest from end to end, use this strategy.""" - # TODO This strategy is still for code demo purpose only. - # It has not been end-to-end tested. def __init__( self, @@ -315,12 +318,15 @@ class RLStrategy(BaseStrategy): self.action = action self.policy = policy + # TODO: how to get inner frequency and trade len + self.inner_frequency = "day" + self.inner_trade_len = 1 + def reset(self, outer_trade_decision: List[Order] = None, **kwargs): super().reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: self.states = OrderedDict() # explicitly make it ordered for order in outer_trade_decision: - # TODO: how to get inner frequency state = EpisodicState.from_order_and_executor(order, self.trade_calendar, "day") self.states[order.stock_id, order.direction] = state @@ -331,7 +337,7 @@ class RLStrategy(BaseStrategy): for e in execute_result: orders[e[0].stock_id, e[0].direction].append(e) for (stock_id, direction), state in self.states.items(): - state.update(orders[stock_id, direction], self.trade_calendar) + state.update(orders[stock_id, direction], self.trade_calendar, length=self.inner_trade_len) if not self.states: return [] @@ -495,19 +501,21 @@ class Observation: return spaces.Dict(space) def observe(self, ep_state: EpisodicState) -> Any: + features = D.features( + [ep_state.stock_id], + ['$open', '$close', '$high', '$low', '$volume'], + start_time=ep_state.start_time, + end_time=ep_state.end_time, + freq=self.time_per_step + ).loc[(ep_state.stock_id, ep_state.cur_time)].to_numpy() + features = np.nan_to_num(features) return { 'direction': _to_int32(ep_state.direction), 'cur_step': _to_int32(min(ep_state.cur_step, ep_state.num_step - 1)), 'num_step': _to_int32(ep_state.num_step), 'target': _to_float32(ep_state.target), 'position': _to_float32(ep_state.position), - 'features': D.features( - [ep_state.stock_id], - ['$open', '$close', '$high', '$low', '$volume'], - start_time=ep_state.start_time, - end_time=ep_state.end_time, - freq=self.time_per_step - ).loc[(ep_state.stock_id, ep_state.cur_time)].to_numpy(), + 'features': features, } From 46d253b45784882733ecc78c22a0ce25c7897448 Mon Sep 17 00:00:00 2001 From: bxdd Date: Fri, 4 Jun 2021 14:41:38 +0800 Subject: [PATCH 049/187] update Exchange.deal_order --- qlib/backtest/exchange.py | 21 ++++++--------------- qlib/backtest/position.py | 3 +++ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 3da1ebfdc..4fc01d8e2 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -246,8 +246,8 @@ class Exchange: order, trade_account.current if trade_account else position ) # update account - if trade_val > 0: - # If the order can only be deal 0 trade_val. Nothing to be updated + if order.deal_amount > 1e-5: + # If the order can only be deal 0 aomount. Nothing to be updated # Otherwise, it will result some stock with 0 amount in the position if trade_account: trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) @@ -454,30 +454,21 @@ class Exchange: trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) if order.direction == Order.SELL: # sell - current_amount = position.get_stock_amount(order.stock_id) - if position is not None: + current_amount = ( + position.get_stock_amount(order.stock_id) if position.check_stock(order.stock_id) else 0 + ) if np.isclose(order.amount, current_amount): # when selling last stock. The amount don't need rounding order.deal_amount = order.amount elif order.amount > current_amount: - self.logger.warning( - f"order amount {order.amount} is greater than current amount {current_amount}, {current_amount} amount of stock is dealed" - ) order.deal_amount = self.round_amount_by_trade_unit(current_amount, order.factor) else: order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) else: # TODO: We don't know current position. # We choose to sell all - - if not np.isclose(order.amount, current_amount) and order.amount > current_amount: - self.logger.warning( - f"order amount {order.amount} is greater than current amount {current_amount}, {current_amount} amount of stock is dealed" - ) - order.deal_amount = current_amount - else: - order.deal_amount = order.amount + order.deal_amount = order.amount order.deal_amount = self._get_amount_by_volume( order.stock_id, order.start_time, order.end_time, order.deal_amount diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index c6368606a..92b549063 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -73,6 +73,9 @@ class Position: def del_stock(self, stock_id): del self.position[stock_id] + def check_stock(self, stock_id): + return stock_id in self.position + def update_order(self, order, trade_val, cost, trade_price): # handle order, order is a order class, defined in exchange.py if order.direction == Order.BUY: From 76be5d50e50904d1eb712ca91c57d76dcf3d9b1d Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 7 Jun 2021 10:56:12 +0800 Subject: [PATCH 050/187] Refine example --- examples/nested_decision_execution/rl_dummy.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/nested_decision_execution/rl_dummy.py b/examples/nested_decision_execution/rl_dummy.py index cd0961f66..c42e28be4 100644 --- a/examples/nested_decision_execution/rl_dummy.py +++ b/examples/nested_decision_execution/rl_dummy.py @@ -319,6 +319,7 @@ class RLStrategy(BaseStrategy): self.policy = policy # TODO: how to get inner frequency and trade len + # This should be no longer required when PA is provided by qlib. self.inner_frequency = "day" self.inner_trade_len = 1 @@ -432,6 +433,12 @@ class RlWorkflow(NestedDecisonExecutionWorkflow): min_cost=5 ) common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=exchange) + executor = NestedExecutor( + time_per_step="week", + inner_executor=SimulatorExecutor(time_per_step="day", verbose=True), + inner_strategy=RLStrategy(Observation("day"), Action(), DummyPolicy()), + common_infra=common_infra + ) strategy = init_instance_by_config({ "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", @@ -442,12 +449,6 @@ class RlWorkflow(NestedDecisonExecutionWorkflow): "n_drop": 5, }, }, common_infra=common_infra) - executor = NestedExecutor( - time_per_step="week", - inner_executor=SimulatorExecutor(time_per_step="day", verbose=True), - inner_strategy=RLStrategy(Observation("day"), Action(), DummyPolicy()), - common_infra=common_infra - ) report_dict = backtest_func(trade_start_time, trade_end_time, strategy, executor) print(report_dict) @@ -463,7 +464,7 @@ class QlibOrderDataset(Dataset): def __len__(self): return len(self.orders) - def __getitem__(self, index): + def __getitem__(self, index) -> Order: return self.orders[index] @@ -535,7 +536,7 @@ class Action: def validate(self, action: Any) -> bool: return self.action_space.contains(action) - def to_volume(self, action: Any, ep_state: EpisodicState): + def to_volume(self, action: Any, ep_state: EpisodicState) -> Any: exec_vol = ep_state.position / self.denominator * action if ep_state.cur_step + 1 >= ep_state.num_step: exec_vol = ep_state.position From 9e45528165cd1bf011b74a595e98ae0a7c0f6313 Mon Sep 17 00:00:00 2001 From: bxdd Date: Mon, 14 Jun 2021 22:31:31 +0800 Subject: [PATCH 051/187] update backtest time range --- examples/nested_decision_execution/workflow.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index 2286f4f12..910011887 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -19,10 +19,10 @@ class NestedDecisonExecutionWorkflow: benchmark = "SH000300" data_handler_config = { - "start_time": "2010-01-01", + "start_time": "2008-01-01", "end_time": "2021-05-28", - "fit_start_time": "2010-01-01", - "fit_end_time": "2017-12-31", + "fit_start_time": "2008-01-01", + "fit_end_time": "2014-12-31", "instruments": market, } @@ -52,9 +52,9 @@ class NestedDecisonExecutionWorkflow: "kwargs": data_handler_config, }, "segments": { - "train": ("2010-01-01", "2017-12-31"), - "valid": ("2018-01-01", "2019-12-31"), - "test": ("2020-01-01", "2021-05-28"), + "train": ("2007-01-01", "2014-12-31"), + "valid": ("2015-01-01", "2016-12-31"), + "test": ("2020-09-01", "2021-05-28"), }, }, }, From 4ac6e6e246c8f1a542c21ab288f6c8eb77c5180e Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 22 Jun 2021 02:42:09 +0800 Subject: [PATCH 052/187] fix account bug & update indicator_analysis & fix some comments --- .../nested_decision_execution/workflow.py | 47 ++-- qlib/backtest/__init__.py | 8 +- qlib/backtest/account.py | 164 +++++++++--- qlib/backtest/backtest.py | 53 ++-- qlib/backtest/exchange.py | 7 +- qlib/backtest/executor.py | 167 ++++++------ qlib/backtest/report.py | 253 +++++++++++------- qlib/contrib/evaluate.py | 58 +++- qlib/workflow/record_temp.py | 69 +++-- 9 files changed, 524 insertions(+), 302 deletions(-) diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index 910011887..689602013 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -17,7 +17,6 @@ class NestedDecisonExecutionWorkflow: market = "csi300" benchmark = "SH000300" - data_handler_config = { "start_time": "2008-01-01", "end_time": "2021-05-28", @@ -67,28 +66,19 @@ class NestedDecisonExecutionWorkflow: "kwargs": { "time_per_step": "week", "inner_executor": { - "class": "NestedExecutor", + "class": "SimulatorExecutor", "module_path": "qlib.backtest.executor", "kwargs": { "time_per_step": "day", - "inner_executor": { - "class": "SimulatorExecutor", - "module_path": "qlib.backtest.executor", - "kwargs": { - "time_per_step": "15min", - "generate_report": True, - "verbose": True, - }, + "generate_report": True, + "verbose": True, + "indicator_config": { + "show_indicator": True, }, - "inner_strategy": { - "class": "TWAPStrategy", - "module_path": "qlib.contrib.strategy.rule_strategy", - }, - "show_indicator": True, }, }, "inner_strategy": { - "class": "VAStrategy", + "class": "SBBStrategyEMA", "module_path": "qlib.contrib.strategy.rule_strategy", "kwargs": { "freq": "day", @@ -96,7 +86,10 @@ class NestedDecisonExecutionWorkflow: }, }, "track_data": True, - "show_indicator": True, + "generate_report": True, + "indicator_config": { + "show_indicator": True, + }, }, }, "backtest": { @@ -105,7 +98,7 @@ class NestedDecisonExecutionWorkflow: "account": 100000000, "benchmark": benchmark, "exchange_kwargs": { - "freq": "1min", + "freq": "day", "limit_threshold": 0.095, "deal_price": "close", "open_cost": 0.0005, @@ -124,7 +117,7 @@ class NestedDecisonExecutionWorkflow: GetData().qlib_data( target_dir=provider_uri_1min, interval="1min", region=REG_CN, version="v2", exists_skip=True ) - + provider_uri_day = "/data/csdesign/qlib" provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} client_config = { "calendar_provider": { @@ -179,12 +172,25 @@ class NestedDecisonExecutionWorkflow: }, } self.port_analysis_config["strategy"] = strategy_config + self.port_analysis_config["backtest"]["benchmark"] = D.list_instruments( + instruments=D.instruments(market=self.market), as_list=True + ) with R.start(experiment_name="backtest"): recorder = R.get_recorder() - par = PortAnaRecord(recorder, self.port_analysis_config, "15minute") + par = PortAnaRecord( + recorder, + self.port_analysis_config, + risk_analysis_freq=["week", "day"], + indicator_analysis_freq=["week", "day"], + indicator_analysis_method="value_weighted", + ) par.generate() + # report_normal_df = recorder.load_object("portfolio_analysis/report_normal_1day.pkl") + # from qlib.contrib.report import analysis_position + # analysis_position.report_graph(report_normal_df) + def collect_data(self): self._init_qlib() model = init_instance_by_config(self.task["model"]) @@ -192,6 +198,7 @@ class NestedDecisonExecutionWorkflow: self._train_model(model, dataset) executor_config = self.port_analysis_config["executor"] backtest_config = self.port_analysis_config["backtest"] + backtest_config["benchmark"] = D.list_instruments(instruments=D.instruments(market=self.market), as_list=True) strategy_config = { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index a3706008a..f8f30f183 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -116,9 +116,9 @@ def backtest(start_time, end_time, strategy, executor, benchmark="SH000300", acc trade_strategy, trade_executor = get_strategy_executor( start_time, end_time, strategy, executor, benchmark, account, exchange_kwargs ) - report_dict = backtest_loop(start_time, end_time, trade_strategy, trade_executor) + report_dict, indicator_dict = backtest_loop(start_time, end_time, trade_strategy, trade_executor) - return report_dict + return report_dict, indicator_dict def collect_data(start_time, end_time, strategy, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): @@ -126,6 +126,4 @@ def collect_data(start_time, end_time, strategy, executor, benchmark="SH000300", trade_strategy, trade_executor = get_strategy_executor( start_time, end_time, strategy, executor, benchmark, account, exchange_kwargs ) - report_dict = yield from collect_data_loop(start_time, end_time, trade_strategy, trade_executor) - - return report_dict + yield from collect_data_loop(start_time, end_time, trade_strategy, trade_executor) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 71214036a..85ca57fa5 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -9,7 +9,7 @@ import pandas as pd from .position import Position from .report import Report, Indicator from .order import Order - +from .exchange import Exchange """ rtn & earning in the Account @@ -25,10 +25,42 @@ rtn & earning in the Account while earning is the difference of two position value, so it considers cost, it is the true return rate in the specific accomplishment for rtn, it does not consider cost, in other words, rtn - cost = earning -Now rtn has been removed in the hierarchical backtest implemention. """ +class AccumulatedInfo: + """accumulated trading info, including accumulated return\cost\turnover""" + + def __init__(self): + self.reset() + + def reset(self): + self.rtn = 0 # accumulated return, do not consider cost + self.cost = 0 # accumulated cost + self.to = 0 # accumulated turnover + + def add_return_value(self, value): + self.rtn += value + + def add_cost(self, value): + self.cost += value + + def add_turnover(self, value): + self.to += value + + @property + def get_return(self): + return self.rtn + + @property + def get_cost(self): + return self.cost + + @property + def get_turnover(self): + return self.to + + class Account: def __init__(self, init_cash, freq: str = "day", benchmark_config: dict = {}): self.init_vars(init_cash, freq, benchmark_config) @@ -38,17 +70,13 @@ class Account: # init cash self.init_cash = init_cash self.current = Position(cash=init_cash) + self.accum_info = AccumulatedInfo() self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) def reset_report(self, freq, benchmark_config): self.report = Report(freq, benchmark_config) self.indicator = Indicator() self.positions = {} - self.rtn = 0 - self.ct = 0 - self.to = 0 - self.val = 0 - self.earning = 0 def reset(self, freq=None, benchmark_config=None, init_report=False): """reset freq and report of account @@ -78,21 +106,22 @@ class Account: def _update_state_from_order(self, order, trade_val, cost, trade_price): # update turnover - self.to += trade_val + self.accum_info.add_turnover(trade_val) # update cost - self.ct += cost - # update return - # update self.rtn from order + self.accum_info.add_cost(cost) + + # update return from order trade_amount = trade_val / trade_price if order.direction == Order.SELL: # 0 for sell # when sell stock, get profit from price change profit = trade_val - self.current.get_stock_price(order.stock_id) * trade_amount - self.rtn += profit # note here do not consider cost + self.accum_info.add_return_value(profit) # note here do not consider cost + elif order.direction == Order.BUY: # 1 for buy # when buy stock, we get return for the rtn computing method - # profit in buy order is to make self.rtn is consistent with self.earning at the end of date + # profit in buy order is to make rtn is consistent with earning at the end of bar profit = self.current.get_stock_price(order.stock_id) * trade_amount - trade_val - self.rtn += profit + self.accum_info.add_return_value(profit) # note here do not consider cost def update_order(self, order, trade_val, cost, trade_price): # if stock is sold out, no stock price information in Position, then we should update account first, then update current position @@ -111,23 +140,12 @@ class Account: self._update_state_from_order(order, trade_val, cost, trade_price) def update_bar_count(self): + """at the end of the trading bar, update holding bar, count of stock""" + # update holding day count self.current.add_count_all(bar=self.freq) - def update_bar_report(self, trade_start_time, trade_end_time, trade_exchange): - """ - trade_start_time: pd.TimeStamp - trade_end_time: pd.TimeStamp - quote: pd.DataFrame (code, date), collumns - when the end of trade date - - update rtn - - update price for each asset - - update value for this account - - update earning (2nd view of return ) - - update holding day, count of stock - - update position hitory - - update report - :return: None - """ + def update_current(self, trade_start_time, trade_end_time, trade_exchange): + """update current to make rtn consistent with earning at the end of bar""" # update price for stock in the position and the profit from changed_price stock_list = self.current.get_stock_list() for code in stock_list: @@ -136,22 +154,28 @@ class Account: continue bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) self.current.update_stock_price(stock_id=code, price=bar_close) - # update holding day count - # update value - self.val = self.current.calculate_value() - # update earning + def update_report(self, trade_start_time, trade_end_time): + """update position history, report""" + # calculate earning # account_value - last_account_value # for the first trade date, account_value - init_cash # self.report.is_empty() to judge is_first_trade_date - # get last_account_value, now_account_value, now_stock_value + # get last_account_value, last_total_cost, last_total_turnover if self.report.is_empty(): last_account_value = self.init_cash + last_total_cost = 0 + last_total_turnover = 0 else: last_account_value = self.report.get_latest_account_value() + last_total_cost = self.report.get_latest_total_cost() + last_total_turnover = self.report.get_latest_total_turnover() + # get now_account_value, now_stock_value, now_earning, now_cost, now_turnover now_account_value = self.current.calculate_value() now_stock_value = self.current.calculate_stock_value() - self.earning = now_account_value - last_account_value + now_earning = now_account_value - last_account_value + now_cost = self.accum_info.get_cost - last_total_cost + now_turnover = self.accum_info.get_turnover - last_total_turnover # update report for today # judge whether the the trading is begin. # and don't add init account state into report, due to we don't have excess return in those days. @@ -160,11 +184,13 @@ class Account: trade_end_time=trade_end_time, account_value=now_account_value, cash=self.current.position["cash"], - return_rate=(self.earning + self.ct) / last_account_value, + return_rate=(now_earning + now_cost) / last_account_value, # here use earning to calculate return, position's view, earning consider cost, true return # in order to make same definition with original backtest in evaluate.py - turnover_rate=self.to / last_account_value, - cost_rate=self.ct / last_account_value, + total_turnover=self.accum_info.get_turnover, + turnover_rate=now_turnover / last_account_value, + total_cost=self.accum_info.get_cost, + cost_rate=now_cost / last_account_value, stock_value=now_stock_value, ) # set now_account_value to position @@ -174,8 +200,60 @@ class Account: # note use deepcopy self.positions[trade_start_time] = copy.deepcopy(self.current) - # finish today's updation - # reset the bar variables - self.rtn = 0 - self.ct = 0 - self.to = 0 + def update_bar_end( + self, + trade_start_time: pd.Timestamp, + trade_end_time: pd.Timestamp, + trade_exchange: Exchange, + atomic: bool, + generate_report: bool = False, + trade_info: list = None, + inner_order_indicators: Indicator = None, + indicator_config: dict = {}, + ): + """update account at each trading bar step + + Parameters + ---------- + trade_start_time : pd.Timestamp + closed start time of step + trade_end_time : pd.Timestamp + closed end time of step + trade_exchange : Exchange + trading exchange, used to update current + atomic : bool + whether the trading executor is atomic, which means there is no higher-frequency trading executor inside it + - if atomic is True, calculate the indicators with trade_info + - else, aggregate indicators with inner indicators + generate_report : bool, optional + whether to generate report, by default False + trade_info : List[(Order, float, float, float)], optional + trading information, by default None + - necessary if atomic is True + - list of tuple(order, trade_val, trade_cost, trade_price) + inner_order_indicators : Indicator, optional + indicators of inner executor, by default None + - necessary if atomic is False + - used to aggregate outer indicators + indicator_config : dict, optional + config of calculating indicators, by default {} + """ + if atomic is True and trade_info is None: + raise ValueError("trade_info is necessary in atomic executor") + elif atomic is False and inner_order_indicators is None: + raise ValueError("inner_order_indicators is necessary in unatomic executor") + + self.update_bar_count() + self.update_current(trade_start_time, trade_end_time, trade_exchange) + if generate_report: + self.update_report(trade_start_time, trade_end_time) + + self.indicator.clear() + + if atomic: + self.indicator.update_order_indicators(trade_start_time, trade_end_time, trade_info, trade_exchange) + else: + self.indicator.agg_order_indicators(inner_order_indicators, indicator_config) + + self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) + self.indicator.record(trade_start_time) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index e9d864c92..3892fde41 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -1,10 +1,25 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from ..utils.resam import parse_freq def backtest_loop(start_time, end_time, trade_strategy, trade_executor): """backtest funciton for the interaction of the outermost strategy and executor in the nested decison execution + Returns + ------- + report: Report + it records the trading report information + """ + return_value = {} + for _decison in collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value): + pass + return return_value.get("report"), return_value.get("indicator") + + +def collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value: dict = None): + """Generator for collecting the trade decision data for rl training + Parameters ---------- start_time : pd.Timestamp|str @@ -15,26 +30,8 @@ def backtest_loop(start_time, end_time, trade_strategy, trade_executor): the outermost portfolio strategy trade_executor : BaseExecutor the outermost executor - - Returns - ------- - report: Report - it records the trading report information - """ - trade_executor.reset(start_time=start_time, end_time=end_time) - level_infra = trade_executor.get_level_infra() - trade_strategy.reset(level_infra=level_infra) - - _execute_result = None - while not trade_executor.finished(): - _trade_decision = trade_strategy.generate_trade_decision(_execute_result) - _execute_result = trade_executor.execute(_trade_decision) - - return trade_executor.get_report() - - -def collect_data_loop(start_time, end_time, trade_strategy, trade_executor): - """Generator for collecting the trade decision data for rl training + return_value : dict + used for backtest_loop Yields ------- @@ -49,3 +46,19 @@ def collect_data_loop(start_time, end_time, trade_strategy, trade_executor): while not trade_executor.finished(): _trade_decision = trade_strategy.generate_trade_decision(_execute_result) _execute_result = yield from trade_executor.collect_data(_trade_decision) + + if return_value is not None: + all_executors = trade_executor.get_all_executors() + + all_reports = { + "{}{}".format(*parse_freq(_executor.time_per_step)): _executor.get_report() + for _executor in all_executors + if _executor.generate_report + } + all_indicators = { + "{}{}".format( + *parse_freq(_executor.time_per_step) + ): _executor.get_trade_indicator().generate_trade_indicators_dataframe() + for _executor in all_executors + } + return_value.update({"report": all_reports, "indicator": all_indicators}) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 6accb5e05..b80663245 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -48,14 +48,17 @@ class Exchange: :param trade_unit: trade unit, 100 for China A market :param min_cost: min cost, default 5 :param extra_quote: pandas, dataframe consists of - columns: like ['$vwap', '$close', '$factor', 'limit']. + columns: like ['$vwap', '$close', '$volume', '$factor', 'limit_sell', 'limit_buy']. The limit indicates that the etf is tradable on a specific day. Necessary fields: $close is for calculating the total value at end of each day. Optional fields: + $volume is only necessary when we limit the trade amount or caculate PA(vwap) indicator $vwap is only necessary when we use the $vwap price as the deal price $factor is for rounding to the trading unit - limit will be set to False by default(False indicates we can buy this + limit_sell will be set to False by default(False indicates we can sell this + target on this day). + limit_buy will be set to False by default(False indicates we can buy this target on this day). index: MultipleIndex(instrument, pd.Datetime) """ diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index d68ff3ab1..c216a461c 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -20,7 +20,7 @@ class BaseExecutor: time_per_step: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - show_indicator: bool = False, + indicator_config: dict = {}, generate_report: bool = False, verbose: bool = False, track_data: bool = False, @@ -33,7 +33,40 @@ class BaseExecutor: time_per_step : str trade time per trading step, used for genreate the trade calendar show_indicator: bool, optional - whether to show indicators, such as FFR/PA/POS, .etc + whether to show indicators, : + - 'pa', the price advantage + - 'pos', the positive rate + - 'ffr', the fulfill rate + indicator_config: dict, optional + config for calculating trade indicator, including the following fields: + - 'show_indicator': whether to show indicators, optional, default by False. The indicators includes + - 'pa', the price advantage + - 'pos', the positive rate + - 'ffr', the fulfill rate + - 'pa_config': config for calculating price advantage(pa), optional + - 'base_price': the based price than which the trading price is advanced, Optional, default by 'twap' + - If 'base_price' is 'twap', the based price is the time weighted average price + - If 'base_price' is 'vwap', the based price is the volume weighted average price + - 'weight_method': weighted method when calculating total trading pa by different orders' pa in each step, optional, default by 'mean' + - If 'weight_method' is 'mean', calculating mean value of different orders' pa + - If 'weight_method' is 'amount_weighted', calculating amount weighted average value of different orders' pa + - If 'weight_method' is 'value_weighted', calculating value weighted average value of different orders' pa + - 'ffr_config': config for calculating fulfill rate(ffr), optional + - 'weight_method': weighted method when calculating total trading ffr by different orders' ffr in each step, optional, default by 'mean' + - If 'weight_method' is 'mean', calculating mean value of different orders' ffr + - If 'weight_method' is 'amount_weighted', calculating amount weighted average value of different orders' ffr + - If 'weight_method' is 'value_weighted', calculating value weighted average value of different orders' ffr + Example: + { + 'show_indicator': True, + 'pa_config': { + 'base_value': 'twap', + 'weight_method': 'value_weighted', + }, + 'ffr_config':{ + 'weight_method': 'value_weighted', + } + } generate_report : bool, optional whether to generate report, by default False verbose : bool, optional @@ -51,7 +84,7 @@ class BaseExecutor: """ self.time_per_step = time_per_step - self.show_indicator = show_indicator + self.indicator_config = indicator_config self.generate_report = generate_report self.verbose = verbose self.track_data = track_data @@ -132,18 +165,20 @@ class BaseExecutor: yield trade_decision return self.execute(trade_decision) - def get_trade_account(self): - raise NotImplementedError("get_trade_account is not implemented!") - def get_report(self): - raise NotImplementedError("get_report is not implemented!") + if self.generate_report: + _report = self.trade_account.report.generate_report_dataframe() + _positions = self.trade_account.get_positions() + return _report, _positions + else: + raise ValueError("generate_report should be True if you want to generate report") def get_all_executors(self): """Return all executors""" return [self] def get_trade_indicator(self): - return self.trade_account.indicator.trade_indicator + return self.trade_account.indicator class NestedExecutor(BaseExecutor): @@ -159,7 +194,7 @@ class NestedExecutor(BaseExecutor): inner_strategy: Union[BaseStrategy, dict], start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - show_indicator: bool = False, + indicator_config: dict = {}, generate_report: bool = False, verbose: bool = False, track_data: bool = False, @@ -190,7 +225,7 @@ class NestedExecutor(BaseExecutor): time_per_step=time_per_step, start_time=start_time, end_time=end_time, - show_indicator=show_indicator, + indicator_config=indicator_config, generate_report=generate_report, verbose=verbose, track_data=track_data, @@ -198,7 +233,7 @@ class NestedExecutor(BaseExecutor): **kwargs, ) - if generate_report and trade_exchange is not None: + if trade_exchange is not None: self.trade_exchange = trade_exchange def reset_common_infra(self, common_infra): @@ -209,7 +244,7 @@ class NestedExecutor(BaseExecutor): """ super(NestedExecutor, self).reset_common_infra(common_infra) - if self.generate_report and common_infra.has("trade_exchange"): + if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") self.inner_executor.reset_common_infra(common_infra) @@ -222,66 +257,43 @@ class NestedExecutor(BaseExecutor): sub_level_infra = self.inner_executor.get_level_infra() self.inner_strategy.reset(level_infra=sub_level_infra, outer_trade_decision=trade_decision) - def _update_trade_account(self, inner_indicators): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) - self.trade_account.update_bar_count() - if self.generate_report: - self.trade_account.update_bar_report( - trade_start_time=trade_start_time, - trade_end_time=trade_end_time, - trade_exchange=self.trade_exchange, - ) - - self.trade_account.indicator.clear() - self.trade_account.indicator.agg_report_info(inner_indicators=inner_indicators) - self.trade_account.indicator.agg_FFR() - self.trade_account.indicator.agg_PA(inner_indicators=inner_indicators) - - if self.show_indicator: - FFR_value = self.trade_account.indicator.get_statistics_FFR(method="value_weighted") - PA_value = self.trade_account.indicator.get_statistics_PA(method="value_weighted") - POS_values = self.trade_account.indicator.get_statistics_POS() - print( - "[Indicator({}) {:%Y-%m-%d}]: FFR: {}, PA: {}, POS: {}".format( - self.time_per_step, trade_start_time, FFR_value, PA_value, POS_values - ) - ) - def execute(self, trade_decision): - for _data in self.collect_data(trade_decision): + return_value = {} + for _decison in self.collect_data(trade_decision, return_value): pass - return self._execute_result + return return_value.get("execute_result") - def collect_data(self, trade_decision): + def collect_data(self, trade_decision, return_value=None): if self.track_data: yield trade_decision self._init_sub_trading(trade_decision) execute_result = [] - inner_indicators = [] + inner_order_indicators = [] _inner_execute_result = None while not self.inner_executor.finished(): _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) execute_result.extend(_inner_execute_result) - inner_indicators.append(self.inner_executor.get_trade_indicator()) + inner_order_indicators.append(self.inner_executor.get_trade_indicator().get_order_indicator) if hasattr(self, "trade_account"): - self._update_trade_account(inner_indicators=inner_indicators) + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + self.trade_account.update_bar_end( + trade_start_time, + trade_end_time, + self.trade_exchange, + atomic=False, + generate_report=self.generate_report, + inner_order_indicators=inner_order_indicators, + indicator_config=self.indicator_config, + ) self.trade_calendar.step() - self._execute_result = execute_result + if return_value is not None: + return_value.update({"execute_result": execute_result}) return execute_result - def get_report(self): - sub_env_report_dict = self.inner_executor.get_report() - if self.generate_report: - _report = self.trade_account.report.generate_report_dataframe() - _positions = self.trade_account.get_positions() - _count, _freq = parse_freq(self.time_per_step) - sub_env_report_dict.update({f"{_count}{_freq}": (_report, _positions)}) - return sub_env_report_dict - def get_all_executors(self): """Return all executors, including self and inner_executor.get_all_executors()""" return [self, *self.inner_executor.get_all_executors()] @@ -295,7 +307,7 @@ class SimulatorExecutor(BaseExecutor): time_per_step: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, - show_indicator: bool = False, + indicator_config: dict = {}, generate_report: bool = False, verbose: bool = False, track_data: bool = False, @@ -314,7 +326,7 @@ class SimulatorExecutor(BaseExecutor): time_per_step=time_per_step, start_time=start_time, end_time=end_time, - show_indicator=show_indicator, + indicator_config=indicator_config, generate_report=generate_report, verbose=verbose, track_data=track_data, @@ -377,41 +389,14 @@ class SimulatorExecutor(BaseExecutor): # do nothing pass - self.trade_account.update_bar_count() - - if self.generate_report: - self.trade_account.update_bar_report( - trade_start_time=trade_start_time, - trade_end_time=trade_end_time, - trade_exchange=self.trade_exchange, - ) - - self.trade_account.indicator.clear() - self.trade_account.indicator.update_trade_info(trade_info=execute_result) - self.trade_account.indicator.update_FFR() - self.trade_account.indicator.update_PA( - freq=self.time_per_step, trade_start_time=trade_start_time, trade_end_time=trade_end_time + self.trade_account.update_bar_end( + trade_start_time, + trade_end_time, + self.trade_exchange, + atomic=True, + generate_report=self.generate_report, + trade_info=execute_result, + indicator_config=self.indicator_config, ) - self.trade_account.indicator.record(trade_start_time=trade_start_time) - - if self.show_indicator: - FFR_value = self.trade_account.indicator.get_statistics_FFR(method="value_weighted") - PA_value = self.trade_account.indicator.get_statistics_PA(method="value_weighted") - POS_values = self.trade_account.indicator.get_statistics_POS() - print( - "[Indicator({}) {:%Y-%m-%d %H:%M:%S}]: FFR: {}, PA: {}, POS: {}".format( - self.time_per_step, trade_start_time, FFR_value, PA_value, POS_values - ) - ) - self.trade_calendar.step() return execute_result - - def get_report(self): - if self.generate_report: - _report = self.trade_account.report.generate_report_dataframe() - _positions = self.trade_account.get_positions() - _count, _freq = parse_freq(self.time_per_step) - return {f"{_count}{_freq}": (_report, _positions)} - else: - return {} diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index d12595db5..5052a1e88 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -52,11 +52,13 @@ class Report: self.init_bench(freq=freq, benchmark_config=benchmark_config) def init_vars(self): - self.accounts = OrderedDict() # account postion value for each trade date - self.returns = OrderedDict() # daily return rate for each trade date - self.turnovers = OrderedDict() # turnover for each trade date - self.costs = OrderedDict() # trade cost for each trade date - self.values = OrderedDict() # value for each trade date + self.accounts = OrderedDict() # account postion value for each trade time + self.returns = OrderedDict() # daily return rate for each trade time + self.total_turnovers = OrderedDict() # total turnover for each trade time + self.turnovers = OrderedDict() # turnover for each trade time + self.total_costs = OrderedDict() # total trade cost for each trade time + self.costs = OrderedDict() # trade cost rate for each trade time + self.values = OrderedDict() # value for each trade time self.cashes = OrderedDict() self.benches = OrderedDict() self.latest_report_time = None # pd.TimeStamp @@ -87,10 +89,10 @@ class Report: def _sample_benchmark(self, bench, trade_start_time, trade_end_time): def cal_change(x): - return (x + 1).prod() - 1 + return (x + 1).prod() _ret = resam_ts_data(bench, trade_start_time, trade_end_time, method=cal_change) - return 0.0 if _ret is None else _ret + return 0.0 if _ret is None else _ret - 1 def is_empty(self): return len(self.accounts) == 0 @@ -101,6 +103,12 @@ class Report: def get_latest_account_value(self): return self.accounts[self.latest_report_time] + def get_latest_total_cost(self): + return self.total_costs[self.latest_report_time] + + def get_latest_total_turnover(self): + return self.total_turnovers[self.latest_report_time] + def update_report_record( self, trade_start_time=None, @@ -108,7 +116,9 @@ class Report: account_value=None, cash=None, return_rate=None, + total_turnover=None, turnover_rate=None, + total_cost=None, cost_rate=None, stock_value=None, bench_value=None, @@ -119,12 +129,14 @@ class Report: account_value, cash, return_rate, + total_turnover, turnover_rate, + total_cost, cost_rate, stock_value, ]: raise ValueError( - "None in [trade_start_time, account_value, cash, return_rate, turnover_rate, cost_rate, stock_value]" + "None in [trade_start_time, account_value, cash, return_rate, total_turnover, turnover_rate, total_cost, cost_rate, stock_value]" ) if trade_end_time is None and bench_value is None: @@ -135,20 +147,24 @@ class Report: # update report data self.accounts[trade_start_time] = account_value self.returns[trade_start_time] = return_rate + self.total_turnovers[trade_start_time] = total_turnover self.turnovers[trade_start_time] = turnover_rate + self.total_costs[trade_start_time] = total_cost self.costs[trade_start_time] = cost_rate self.values[trade_start_time] = stock_value self.cashes[trade_start_time] = cash self.benches[trade_start_time] = bench_value # update latest_report_date self.latest_report_time = trade_start_time - # finish daily report update + # finish report update in each step def generate_report_dataframe(self): report = pd.DataFrame() report["account"] = pd.Series(self.accounts) report["return"] = pd.Series(self.returns) + report["total_turnover"] = pd.Series(self.total_turnovers) report["turnover"] = pd.Series(self.turnovers) + report["total_cost"] = pd.Series(self.total_costs) report["cost"] = pd.Series(self.costs) report["value"] = pd.Series(self.values) report["cash"] = pd.Series(self.cashes) @@ -163,7 +179,7 @@ class Report: def load_report(self, path): """load report from a file should have format like - columns = ['account', 'return', 'turnover', 'cost', 'value', 'cash', 'bench'] + columns = ['account', 'return', 'total_turnover', 'turnover', 'cost', 'total_cost', 'value', 'cash', 'bench'] :param path: str/ pathlib.Path() """ @@ -179,7 +195,9 @@ class Report: account_value=r.loc[trade_start_time]["account"], cash=r.loc[trade_start_time]["cash"], return_rate=r.loc[trade_start_time]["return"], + total_turnover=r.loc[trade_start_time]["total_turnover"], turnover_rate=r.loc[trade_start_time]["turnover"], + total_cost=r.loc[trade_start_time]["total_cost"], cost_rate=r.loc[trade_start_time]["cost"], stock_value=r.loc[trade_start_time]["value"], bench_value=r.loc[trade_start_time]["bench"], @@ -188,147 +206,184 @@ class Report: class Indicator: def __init__(self): - self.indicator_his = dict() - self.trade_indicator = dict() - - def __getitem__(self, key): - return self.trade_indicator[key] - - def __setitem__(self, key, value): - self.trade_indicator[key] = value - - def __contains__(self, key): - return key in self.trade_indicator + self.order_indicator_his = OrderedDict() + self.order_indicator = OrderedDict() + self.trade_indicator_his = OrderedDict() + self.trade_indicator = OrderedDict() def clear(self): - self.trade_indicator = dict() + self.order_indicator = OrderedDict() + self.trade_indicator = OrderedDict() def record(self, trade_start_time): - self.indicator_his[trade_start_time] = pd.DataFrame(self.trade_indicator) + self.order_indicator_his[trade_start_time] = self.order_indicator + self.trade_indicator_his[trade_start_time] = self.trade_indicator - def update_trade_info(self, trade_info: list): + def _update_order_trade_info(self, trade_info: list): amount = dict() deal_amount = dict() trade_price = dict() + trade_value = dict() trade_cost = dict() for order, _trade_val, _trade_cost, _trade_price in trade_info: amount[order.stock_id] = order.amount * (order.direction * 2 - 1) deal_amount[order.stock_id] = order.deal_amount * (order.direction * 2 - 1) trade_price[order.stock_id] = _trade_price + trade_value[order.stock_id] = _trade_val * (order.direction * 2 - 1) trade_cost[order.stock_id] = _trade_cost - self["amount"] = pd.Series(amount) - self["deal_amount"] = pd.Series(deal_amount) - self["trade_price"] = pd.Series(trade_price) - self["trade_cost"] = pd.Series(trade_cost) + self.order_indicator["amount"] = pd.Series(amount) + self.order_indicator["deal_amount"] = pd.Series(deal_amount) + self.order_indicator["trade_price"] = pd.Series(trade_price) + self.order_indicator["trade_value"] = pd.Series(trade_value) + self.order_indicator["trade_cost"] = pd.Series(trade_cost) - def update_FFR(self): - self["fulfill_rate"] = self["deal_amount"] / self["amount"] + def _update_order_fulfill_rate(self): + self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def update_PA(self, freq, trade_start_time, trade_end_time, base_price="twap"): - base_price = base_price.lower() + def _update_order_price_advantage(self, trade_exchange, trade_start_time, trade_end_time): + self.order_indicator["base_price"] = self.order_indicator["trade_price"] + instruments = list(self.order_indicator["base_price"].index) + self.order_indicator["volume"] = pd.Series( + [ + trade_exchange.get_volume(stock_id=inst, start_time=trade_start_time, end_time=trade_end_time) + for inst in instruments + ], + index=instruments, + ) + self.order_indicator["pa"] = ( + self.order_indicator["trade_price"] - self.order_indicator["base_price"] + ) / self.order_indicator["base_price"] - instruments = list(self["amount"].index) - if base_price == "twap": - # too slow - # price_info, _ = get_higher_freq_feature(instruments, fields=["$close"], start_time=trade_start_time, end_time=trade_end_time, freq=freq) - # price_info = price_info.astype(float) - - # self["base_price"] = price_info["$close"].groupby(level="instrument").mean() - self["base_price"] = self["trade_price"] - - elif base_price == "vwap": - # too slow - price_info, _ = get_higher_freq_feature( - instruments, - fields=["$close", "$volume"], - start_time=trade_start_time, - end_time=trade_end_time, - freq=freq, - ) - price_info = price_info.astype(float) - self["base_price"] = price_info.groupby(level="instrument").apply( - lambda x: (x["$close"] * x["$volume"]).sum() / x["$volume"].sum() - ) - self["volume"] = price_info["$volume"].groupby(level="instrument").sum() - else: - raise ValueError(f"base_price {base_price} is not supported!") - - self["pa"] = (self["trade_price"] - self["base_price"]) / self["base_price"] - - def agg_report_info(self, inner_indicators): + def _agg_order_trade_info(self, inner_order_indicators): amount = pd.Series() deal_amount = pd.Series() trade_price = pd.Series() + trade_value = pd.Series() trade_cost = pd.Series() - for inner_indicator in inner_indicators: - amount = amount.add(inner_indicator["amount"], fill_value=0) - deal_amount = deal_amount.add(inner_indicator["deal_amount"], fill_value=0) - trade_price = trade_price.add(inner_indicator["trade_price"] * inner_indicator["deal_amount"], fill_value=0) - trade_cost = trade_cost.add(inner_indicator["trade_cost"], fill_value=0) + for _order_indicator in inner_order_indicators: + amount = amount.add(_order_indicator["amount"], fill_value=0) + deal_amount = deal_amount.add(_order_indicator["deal_amount"], fill_value=0) + trade_price = trade_price.add( + _order_indicator["trade_price"] * _order_indicator["deal_amount"], fill_value=0 + ) + trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) + trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) - self["amount"] = amount - self["deal_amount"] = deal_amount - trade_price /= self["deal_amount"] - self["trade_price"] = trade_price - self["trade_cost"] = trade_cost + self.order_indicator["amount"] = amount + self.order_indicator["deal_amount"] = deal_amount + trade_price /= self.order_indicator["deal_amount"] + self.order_indicator["trade_price"] = trade_price + self.order_indicator["trade_value"] = trade_value + self.order_indicator["trade_cost"] = trade_cost - def agg_FFR(self): - self["fulfill_rate"] = self["deal_amount"] / self["amount"] + def _agg_order_fulfill_rate(self): + self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def agg_PA(self, inner_indicators, base_price="twap"): + def _agg_order_price_advantage(self, inner_order_indicators, base_price="twap"): base_price = base_price.lower() + volume = pd.Series() + for _order_indicator in inner_order_indicators: + volume = volume.add(_order_indicator["volume"], fill_value=0) + self.order_indicator["volume"] = volume if base_price == "twap": base_price = pd.Series() price_count = pd.Series() - for inner_indicator in inner_indicators: - base_price = base_price.add(inner_indicator["base_price"], fill_value=0) - price_count = price_count.add(pd.Series(1, index=inner_indicator["base_price"].index), fill_value=0) + for _order_indicator in inner_order_indicators: + base_price = base_price.add(_order_indicator["base_price"], fill_value=0) + price_count = price_count.add(pd.Series(1, index=_order_indicator["base_price"].index), fill_value=0) base_price /= price_count - self["base_price"] = base_price + self.order_indicator["base_price"] = base_price elif base_price == "vwap": base_price = pd.Series() - volume = pd.Series() - for inner_indicator in inner_indicators: - base_price = base_price.add(inner_indicator["base_price"] * inner_indicator["volume"], fill_value=0) - volume = volume.add(inner_indicator["volume"], fill_value=0) - base_price /= volume - self["base_price"] = base_price - self["volume"] = volume + for _order_indicator in inner_order_indicators: + base_price = base_price.add(_order_indicator["base_price"] * _order_indicator["volume"], fill_value=0) + base_price /= self.order_indicator["volume"] + self.order_indicator["base_price"] = base_price + else: raise ValueError(f"base_price {base_price} is not supported!") - self["pa"] = (self["trade_price"] - self["base_price"]) / self["base_price"] + self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 - def get_statistics_FFR(self, method="mean"): + def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": - return self["fulfill_rate"].mean() + return self.order_indicator["ffr"].mean() elif method == "amount_weighted": - weights = self["deal_amount"].abs() - return (self["fulfill_rate"] * weights).sum() / weights.sum() + weights = self.order_indicator["deal_amount"].abs() + return (self.order_indicator["ffr"] * weights).sum() / weights.sum() elif method == "value_weighted": - weights = (self["deal_amount"] * self["trade_price"]).abs() - return (self["fulfill_rate"] * weights).sum() / weights.sum() + weights = self.order_indicator["trade_value"].abs() + return (self.order_indicator["ffr"] * weights).sum() / weights.sum() else: raise ValueError(f"method {method} is not supported!") - def get_statistics_PA(self, method="mean"): - pa_order = self["pa"] * (self["amount"] < 0).astype(int) + def _cal_trade_price_advantage(self, method="mean"): + pa_order = self.order_indicator["pa"] * (self.order_indicator["amount"] < 0).astype(int) if method == "mean": return pa_order.mean() elif method == "amount_weighted": - weights = self["deal_amount"].abs() + weights = self.order_indicator["deal_amount"].abs() return (pa_order * weights).sum() / weights.sum() elif method == "value_weighted": - weights = (self["deal_amount"] * self["trade_price"]).abs() + weights = self.order_indicator["trade_value"].abs() return (pa_order * weights).sum() / weights.sum() else: raise ValueError(f"method {method} is not supported!") - def get_statistics_POS(self): - pa_order = self["pa"] * (self["amount"] < 0).astype(int) - return (pa_order > 1e-8).astype(int).sum() / len(pa_order) + def _cal_trade_positive_rate(self): + pa_order = self.order_indicator["pa"] * (self.order_indicator["amount"] < 0).astype(int) + return (pa_order > 0).astype(int).sum() / len(pa_order) + + def _cal_trade_amount(self): + return self.order_indicator["deal_amount"].abs().sum() + + def _cal_trade_value(self): + return self.order_indicator["trade_value"].abs().sum() + + def update_order_indicators(self, trade_start_time, trade_end_time, trade_info, trade_exchange): + self._update_order_trade_info(trade_info=trade_info) + self._update_order_fulfill_rate() + self._update_order_price_advantage(trade_exchange, trade_start_time, trade_end_time) + + def agg_order_indicators(self, inner_order_indicators, indicator_config={}): + self._agg_order_trade_info(inner_order_indicators) + self._agg_order_fulfill_rate() + pa_config = indicator_config.get("pa_config", {}) + self._agg_order_price_advantage(inner_order_indicators, base_price=pa_config.get("base_price", "twap")) + + def cal_trade_indicators(self, trade_start_time, freq, indicator_config={}): + show_indicator = indicator_config.get("show_indicator", False) + ffr_config = indicator_config.get("ffr_config", {}) + pa_config = indicator_config.get("pa_config", {}) + fulfill_rate = self._cal_trade_fulfill_rate(method=ffr_config.get("weight_method", "mean")) + price_advantage = self._cal_trade_price_advantage(method=pa_config.get("weight_method", "mean")) + positive_rate = self._cal_trade_positive_rate() + trade_amount = self._cal_trade_amount() + trade_value = self._cal_trade_value() + self.trade_indicator["ffr"] = fulfill_rate + self.trade_indicator["pa"] = price_advantage + self.trade_indicator["pos"] = positive_rate + self.trade_indicator["amount"] = trade_amount + self.trade_indicator["value"] = trade_value + if show_indicator: + print( + "[Indicator({}) {:%Y-%m-%d %H:%M:%S}]: FFR: {}, PA: {}, POS: {}".format( + freq, trade_start_time, fulfill_rate, price_advantage, positive_rate + ) + ) + + @property + def get_order_indicator(self): + return self.order_indicator + + @property + def get_trade_indicator(self): + return self.trade_indicator + + def generate_trade_indicators_dataframe(self): + return pd.DataFrame.from_dict(self.trade_indicator_his, orient="index") diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index 0ef8f95a5..a048ead30 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -11,7 +11,7 @@ import warnings from ..log import get_module_logger from ..backtest import get_exchange, backtest as backtest_func from ..utils import get_date_range -from ..utils.resam import parse_freq +from ..utils.resam import parse_freq, NORM_FREQ_MONTH, NORM_FREQ_WEEK, NORM_FREQ_DAY, NORM_FREQ_MINUTE from ..data import D from ..config import C @@ -37,12 +37,12 @@ def risk_analysis(r, N: int = None, freq: str = "day"): def cal_risk_analysis_scaler(freq): _count, _freq = parse_freq(freq) _freq_scaler = { - "minute": 240 * 252, - "day": 252, - "week": 50, - "month": 12, + NORM_FREQ_MINUTE: 240 * 252, + NORM_FREQ_DAY: 252, + NORM_FREQ_WEEK: 50, + NORM_FREQ_MONTH: 12, } - return _count * _freq_scaler[_freq] + return _freq_scaler[_freq] / _count if N is None and freq is None: raise ValueError("at least one of `N` and `freq` should exist") @@ -63,7 +63,51 @@ def risk_analysis(r, N: int = None, freq: str = "day"): "information_ratio": information_ratio, "max_drawdown": max_drawdown, } - res = pd.Series(data, index=data.keys()).to_frame("risk") + res = pd.Series(data).to_frame("risk") + return res + + +def indicator_analysis(df, method="mean"): + """analyze statistical time-series indicators of trading + + Parameters + ---------- + df : pandas.DataFrame + columns: like ['pa', 'pos', 'ffr', 'amount', 'value']. + Necessary fields: + - 'pa' is the price advantage in trade indicators + - 'pos' is the positive rate in trade indicators + - 'ffr' is the fulfill rate in trade indicators + Optional fields: + - 'amount' is the total deal amount, only necessary when method is 'amount_weighted' + - 'value' is the total trade value, only necessary when method is 'value_weighted' + + index: Index(datetime) + method : str, optional + statistics method, by default "mean" + - if method is 'mean', count the mean statistical value of each trade indicator + - if method is 'amount_weighted', count the amount weighted mean statistical value of each trade indicator + - if method is 'value_weighted', count the value weighted mean statistical value of each trade indicator + + Returns + ------- + pd.DataFrame + statistical value of each trade indicator + """ + indicators_df = df[["pa", "pos", "ffr"]] + + if method == "mean": + res = indicators_df.mean() + elif method == "amount_weighted": + weights = df["amount"].abs() + res = indicators_df.mul(weights, axis=0).sum() / weights.sum() + elif method == "value_weighted": + weights = df["value"].abs() + res = indicators_df.mul(weights, axis=0).sum() / weights.sum() + else: + raise ValueError(f"indicator_analysis method {method} is not supported!") + + res = res.to_frame("value") return res diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 9516d363a..4ecd5ccdf 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -7,7 +7,7 @@ import warnings import pandas as pd from pathlib import Path from pprint import pprint -from ..contrib.evaluate import risk_analysis +from ..contrib.evaluate import indicator_analysis, risk_analysis, indicator_analysis from ..data.dataset import DatasetH from ..data.dataset.handler import DataHandlerLP @@ -294,7 +294,9 @@ class PortAnaRecord(RecordTemp): artifact_path = "portfolio_analysis" - def __init__(self, recorder, config, risk_analysis_freq, **kwargs): + def __init__( + self, recorder, config, risk_analysis_freq, indicator_analysis_freq, indicator_analysis_method=None, **kwargs + ): """ config["strategy"] : dict define the strategy class as well as the kwargs. @@ -304,6 +306,10 @@ class PortAnaRecord(RecordTemp): define the backtest kwargs. risk_analysis_freq : str|List[str] risk analysis freq of report + indicator_analysis_freq : str|List[str] + indicator analysis freq of report + indicator_analysis_method : str, optional, default by None + the candidated values include 'mean', 'amount_weighted', 'value_weighted' """ super().__init__(recorder=recorder, **kwargs) @@ -312,10 +318,17 @@ class PortAnaRecord(RecordTemp): self.backtest_config = config["backtest"] if isinstance(risk_analysis_freq, str): risk_analysis_freq = [risk_analysis_freq] + if isinstance(indicator_analysis_freq, str): + indicator_analysis_freq = [indicator_analysis_freq] + self.risk_analysis_freq = [ "{0}{1}".format(*parse_freq(_analysis_freq)) for _analysis_freq in risk_analysis_freq ] - self.report_freq = self._get_report_freq(self.executor_config) + self.indicator_analysis_freq = [ + "{0}{1}".format(*parse_freq(_analysis_freq)) for _analysis_freq in indicator_analysis_freq + ] + self.indicator_analysis_method = indicator_analysis_method + self.all_freq = self._get_report_freq(self.executor_config) def _get_report_freq(self, executor_config): ret_freq = [] @@ -328,21 +341,26 @@ class PortAnaRecord(RecordTemp): def generate(self, **kwargs): # custom strategy and get backtest - report_dict = normal_backtest( + report_dict, indicator_dict = normal_backtest( executor=self.executor_config, strategy=self.strategy_config, **self.backtest_config ) - for report_freq, (report_normal, positions_normal) in report_dict.items(): + for _freq, (report_normal, positions_normal) in report_dict.items(): self.recorder.save_objects( - **{f"report_normal_{report_freq}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path() + **{f"report_normal_{_freq}.pkl": report_normal}, artifact_path=PortAnaRecord.get_path() ) self.recorder.save_objects( - **{f"positions_normal_{report_freq}.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path() + **{f"positions_normal_{_freq}.pkl": positions_normal}, artifact_path=PortAnaRecord.get_path() + ) + + for _freq, indicators_normal in indicator_dict.items(): + self.recorder.save_objects( + **{f"indicators_normal_{_freq}.pkl": indicators_normal}, artifact_path=PortAnaRecord.get_path() ) for _analysis_freq in self.risk_analysis_freq: if _analysis_freq not in report_dict: warnings.warn( - f"the freq {_analysis_freq} report is not found, please set the corresponding env with `generate_report==True`" + f"the freq {_analysis_freq} report is not found, please set the corresponding env with `generate_report=True`" ) else: report_normal, _ = report_dict.get(_analysis_freq) @@ -353,25 +371,46 @@ class PortAnaRecord(RecordTemp): analysis["excess_return_with_cost"] = risk_analysis( report_normal["return"] - report_normal["bench"] - report_normal["cost"], freq=_analysis_freq ) + analysis_df = pd.concat(analysis) # type: pd.DataFrame # log metrics - self.recorder.log_metrics(**flatten_dict(analysis_df["risk"].unstack().T.to_dict())) + analysis_dict = flatten_dict(analysis_df["risk"].unstack().T.to_dict()) + self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()}) # save results self.recorder.save_objects( - **{f"port_analysis_{report_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path() + **{f"port_analysis_{_analysis_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path() ) logger.info( - f"Portfolio analysis record 'port_analysis_{report_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" + f"Portfolio analysis record 'port_analysis_{_analysis_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" ) # print out results - pprint("The following are analysis results of the excess return without cost.") + pprint(f"The following are analysis results of benchmark return({_analysis_freq}).") + pprint(risk_analysis(report_normal["bench"], freq=_analysis_freq)) + pprint(f"The following are analysis results of the excess return without cost({_analysis_freq}).") pprint(analysis["excess_return_without_cost"]) - pprint("The following are analysis results of the excess return with cost.") + pprint(f"The following are analysis results of the excess return with cost({_analysis_freq}).") pprint(analysis["excess_return_with_cost"]) + for _analysis_freq in self.indicator_analysis_freq: + indicators_normal = indicator_dict.get(_analysis_freq) + if self.indicator_analysis_method is None: + analysis_df = indicator_analysis(indicators_normal) + else: + analysis_df = indicator_analysis(indicators_normal, method=self.indicator_analysis_method) + + # log metrics + analysis_dict = analysis_df["value"].to_dict() + self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()}) + # save results + self.recorder.save_objects( + **{f"indicator_analysis_{_analysis_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path() + ) + pprint(f"The following are analysis results of indicators({_analysis_freq}).") + pprint(analysis_df) + def list(self): list_path = [] - for _freq in self.report_freq: + for _freq in self.all_freq: list_path.extend( [ PortAnaRecord.get_path(f"report_normal_{_freq}.pkl"), @@ -380,7 +419,7 @@ class PortAnaRecord(RecordTemp): ) for _analysis_freq in self.risk_analysis_freq: - if _analysis_freq in self.report_freq: + if _analysis_freq in self.all_freq: list_path.append(PortAnaRecord.get_path(f"port_analysis_{_analysis_freq}.pkl")) else: warnings.warn(f"{_analysis_freq} is not found") From 7525854beda2c0c0303b265c97b52c994561221c Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Tue, 22 Jun 2021 03:47:39 +0000 Subject: [PATCH 053/187] Add shortcut in init --- qlib/backtest/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 91eedd736..edfc907cd 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -8,7 +8,7 @@ from .backtest import backtest_loop from .backtest import collect_data_loop from .order import Order -from .utils import CommonInfrastructure +from .utils import CommonInfrastructure, TradeCalendarManager from ..strategy.base import BaseStrategy from ..utils import init_instance_by_config from ..log import get_module_logger From ab97e8248443789ce1e0f90a9b5596e5fee60566 Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 22 Jun 2021 15:03:05 +0800 Subject: [PATCH 054/187] fix bug in Exchange --- examples/nested_decision_execution/workflow.py | 14 +++++--------- qlib/backtest/__init__.py | 10 ++++++++-- qlib/backtest/exchange.py | 4 ++-- qlib/workflow/record_temp.py | 9 ++++++++- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index 689602013..e01895bf1 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -19,7 +19,7 @@ class NestedDecisonExecutionWorkflow: benchmark = "SH000300" data_handler_config = { "start_time": "2008-01-01", - "end_time": "2021-05-28", + "end_time": "2020-12-31", "fit_start_time": "2008-01-01", "fit_end_time": "2014-12-31", "instruments": market, @@ -53,7 +53,7 @@ class NestedDecisonExecutionWorkflow: "segments": { "train": ("2007-01-01", "2014-12-31"), "valid": ("2015-01-01", "2016-12-31"), - "test": ("2020-09-01", "2021-05-28"), + "test": ("2020-01-01", "2020-12-31"), }, }, }, @@ -78,12 +78,8 @@ class NestedDecisonExecutionWorkflow: }, }, "inner_strategy": { - "class": "SBBStrategyEMA", + "class": "TWAPStrategy", "module_path": "qlib.contrib.strategy.rule_strategy", - "kwargs": { - "freq": "day", - "instruments": market, - }, }, "track_data": True, "generate_report": True, @@ -93,8 +89,8 @@ class NestedDecisonExecutionWorkflow: }, }, "backtest": { - "start_time": "2020-09-20", - "end_time": "2021-05-28", + "start_time": "2020-01-01", + "end_time": "2020-12-31", "account": 100000000, "benchmark": benchmark, "exchange_kwargs": { diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index f8f30f183..96941776c 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -1,14 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import copy from .account import Account from .exchange import Exchange from .executor import BaseExecutor from .backtest import backtest_loop from .backtest import collect_data_loop - from .utils import CommonInfrastructure from .order import Order + from ..strategy.base import BaseStrategy from ..utils import init_instance_by_config from ..log import get_module_logger @@ -101,10 +102,15 @@ def get_strategy_executor( "end_time": end_time, }, ) + + exchange_kwargs = copy.copy(exchange_kwargs) + if "start_time" not in exchange_kwargs: + exchange_kwargs["start_time"] = start_time + if "end_time" not in exchange_kwargs: + exchange_kwargs["end_time"] = end_time trade_exchange = get_exchange(**exchange_kwargs) common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=trade_exchange) - trade_strategy = init_instance_by_config(strategy, accept_types=BaseStrategy, common_infra=common_infra) trade_executor = init_instance_by_config(executor, accept_types=BaseExecutor, common_infra=common_infra) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index b80663245..06ecbaa5b 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -174,8 +174,8 @@ class Exchange: self.quote = quote_dict def _update_limit(self, buy_limit, sell_limit): - self.quote["limit_buy"] = ~self.quote["$change"].lt(buy_limit) - self.quote["limit_sell"] = ~self.quote["$change"].gt(-sell_limit) + self.quote["limit_buy"] = self.quote["$change"].ge(buy_limit) + self.quote["limit_sell"] = self.quote["$change"].le(-sell_limit) def check_stock_limit(self, stock_id, start_time, end_time, direction=None): """ diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 4ecd5ccdf..f880406c3 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -7,6 +7,7 @@ import warnings import pandas as pd from pathlib import Path from pprint import pprint +from typing import Union, List from ..contrib.evaluate import indicator_analysis, risk_analysis, indicator_analysis from ..data.dataset import DatasetH @@ -295,7 +296,13 @@ class PortAnaRecord(RecordTemp): artifact_path = "portfolio_analysis" def __init__( - self, recorder, config, risk_analysis_freq, indicator_analysis_freq, indicator_analysis_method=None, **kwargs + self, + recorder, + config, + risk_analysis_freq: Union[List, str] = [], + indicator_analysis_freq: Union[List, str] = [], + indicator_analysis_method=None, + **kwargs, ): """ config["strategy"] : dict From 583fbbef3ce714bdc4b3130b74620f79873119bb Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Tue, 22 Jun 2021 07:07:19 +0000 Subject: [PATCH 055/187] Resolve init conflict --- qlib/backtest/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 107f97782..ae07cdbdf 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -7,15 +7,9 @@ from .exchange import Exchange from .executor import BaseExecutor from .backtest import backtest_loop from .backtest import collect_data_loop -<<<<<<< HEAD from .order import Order from .utils import CommonInfrastructure, TradeCalendarManager -======= -from .utils import CommonInfrastructure -from .order import Order - ->>>>>>> ab97e8248443789ce1e0f90a9b5596e5fee60566 from ..strategy.base import BaseStrategy from ..utils import init_instance_by_config from ..log import get_module_logger From 1517a9eb91708a2aa0aaf54b1261cbc5d359afeb Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 24 Jun 2021 13:59:10 +0000 Subject: [PATCH 056/187] add default executor config & update bug in indicator --- .../nested_decision_execution/workflow.py | 46 +++++++++---- qlib/backtest/executor.py | 12 ++-- qlib/backtest/report.py | 17 +++-- qlib/contrib/evaluate.py | 30 +++++---- qlib/contrib/strategy/rule_strategy.py | 10 +-- qlib/utils/resam.py | 32 ++------- qlib/workflow/record_temp.py | 65 +++++++++++++------ setup.py | 2 +- 8 files changed, 123 insertions(+), 91 deletions(-) diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index e01895bf1..a44aee4ca 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -64,22 +64,41 @@ class NestedDecisonExecutionWorkflow: "class": "NestedExecutor", "module_path": "qlib.backtest.executor", "kwargs": { - "time_per_step": "week", + "time_per_step": "day", "inner_executor": { - "class": "SimulatorExecutor", + "class": "NestedExecutor", "module_path": "qlib.backtest.executor", "kwargs": { - "time_per_step": "day", + "time_per_step": "30min", + "inner_executor": { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": "5min", + "generate_report": True, + "verbose": True, + "indicator_config": { + "show_indicator": True, + }, + }, + }, + "inner_strategy": { + "class": "TWAPStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + }, "generate_report": True, - "verbose": True, "indicator_config": { "show_indicator": True, }, }, }, "inner_strategy": { - "class": "TWAPStrategy", + "class": "SBBStrategyEMA", "module_path": "qlib.contrib.strategy.rule_strategy", + "kwargs": { + "instruments": market, + "freq": "1min", + }, }, "track_data": True, "generate_report": True, @@ -92,9 +111,8 @@ class NestedDecisonExecutionWorkflow: "start_time": "2020-01-01", "end_time": "2020-12-31", "account": 100000000, - "benchmark": benchmark, "exchange_kwargs": { - "freq": "day", + "freq": "1min", "limit_threshold": 0.095, "deal_price": "close", "open_cost": 0.0005, @@ -106,14 +124,14 @@ class NestedDecisonExecutionWorkflow: def _init_qlib(self): """initialize qlib""" - provider_uri_day = "/data1/v-xiabi/qlib/qlib_data/cn_data" # target_dir + # provider_uri_day = "/data/stock_data/huaxia/qlib" + # provider_uri_1min = "/data2/stock_data/huaxia_1min_qlib" + provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN, version="v2", exists_skip=True) - # provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") - provider_uri_1min = "/data1/v-xiabi/qlib/qlib_data/cn_data_highfreq" + provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") GetData().qlib_data( target_dir=provider_uri_1min, interval="1min", region=REG_CN, version="v2", exists_skip=True ) - provider_uri_day = "/data/csdesign/qlib" provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} client_config = { "calendar_provider": { @@ -139,7 +157,7 @@ class NestedDecisonExecutionWorkflow: }, }, } - qlib.init(provider_uri=provider_uri_day, **client_config) + qlib.init(provider_uri=provider_uri_day, **client_config, redis_port=-1) def _train_model(self, model, dataset): with R.start(experiment_name="train"): @@ -177,8 +195,8 @@ class NestedDecisonExecutionWorkflow: par = PortAnaRecord( recorder, self.port_analysis_config, - risk_analysis_freq=["week", "day"], - indicator_analysis_freq=["week", "day"], + risk_analysis_freq=["day", "30min", "5min"], + indicator_analysis_freq=["day", "30min", "5min"], indicator_analysis_method="value_weighted", ) par.generate() diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index c216a461c..226f112b7 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -166,6 +166,7 @@ class BaseExecutor: return self.execute(trade_decision) def get_report(self): + """get the history report and postions instance""" if self.generate_report: _report = self.trade_account.report.generate_report_dataframe() _positions = self.trade_account.get_positions() @@ -173,13 +174,14 @@ class BaseExecutor: else: raise ValueError("generate_report should be True if you want to generate report") - def get_all_executors(self): - """Return all executors""" - return [self] - def get_trade_indicator(self): + """get the trade indicator instance, which has pa/pos/ffr info.""" return self.trade_account.indicator + def get_all_executors(self): + """get all executors""" + return [self] + class NestedExecutor(BaseExecutor): """ @@ -295,7 +297,7 @@ class NestedExecutor(BaseExecutor): return execute_result def get_all_executors(self): - """Return all executors, including self and inner_executor.get_all_executors()""" + """get all executors, including self and inner_executor.get_all_executors()""" return [self, *self.inner_executor.get_all_executors()] diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 5052a1e88..a3bb0b10e 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -11,7 +11,7 @@ from pandas.core import groupby from pandas.core.frame import DataFrame -from ..utils.resam import parse_freq, resam_ts_data, get_higher_freq_feature +from ..utils.resam import parse_freq, resam_ts_data, get_higher_eq_freq_feature from ..data import D from ..tests.config import CSI300_BENCH @@ -82,7 +82,7 @@ class Report: raise ValueError("benchmark freq can't be None!") _codes = benchmark if isinstance(benchmark, list) else [benchmark] fields = ["$close/Ref($close,1)-1"] - _temp_result, _ = get_higher_freq_feature(_codes, fields, start_time, end_time, freq=freq) + _temp_result, _ = get_higher_eq_freq_feature(_codes, fields, start_time, end_time, freq=freq) if len(_temp_result) == 0: raise ValueError(f"The benchmark {_codes} does not exist. Please provide the right benchmark") return _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean().fillna(0) @@ -308,6 +308,7 @@ class Indicator: raise ValueError(f"base_price {base_price} is not supported!") self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 + # print("trade_price", self.order_indicator["trade_price"], "base_price", self.order_indicator["base_price"], "pa", self.order_indicator["pa"]* (2 * (self.order_indicator["amount"] < 0).astype(int) - 1)) def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": @@ -322,8 +323,7 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_price_advantage(self, method="mean"): - - pa_order = self.order_indicator["pa"] * (self.order_indicator["amount"] < 0).astype(int) + pa_order = self.order_indicator["pa"] * (2 * (self.order_indicator["amount"] < 0).astype(int) - 1) if method == "mean": return pa_order.mean() elif method == "amount_weighted": @@ -336,8 +336,8 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_positive_rate(self): - pa_order = self.order_indicator["pa"] * (self.order_indicator["amount"] < 0).astype(int) - return (pa_order > 0).astype(int).sum() / len(pa_order) + pa_order = self.order_indicator["pa"] * (2 * (self.order_indicator["amount"] < 0).astype(int) - 1) + return (pa_order > 0).astype(int).sum() / pa_order.count() def _cal_trade_amount(self): return self.order_indicator["deal_amount"].abs().sum() @@ -345,6 +345,9 @@ class Indicator: def _cal_trade_value(self): return self.order_indicator["trade_value"].abs().sum() + def _cal_trade_order_count(self): + return self.order_indicator["amount"].count() + def update_order_indicators(self, trade_start_time, trade_end_time, trade_info, trade_exchange): self._update_order_trade_info(trade_info=trade_info) self._update_order_fulfill_rate() @@ -365,11 +368,13 @@ class Indicator: positive_rate = self._cal_trade_positive_rate() trade_amount = self._cal_trade_amount() trade_value = self._cal_trade_value() + order_count = self._cal_trade_order_count() self.trade_indicator["ffr"] = fulfill_rate self.trade_indicator["pa"] = price_advantage self.trade_indicator["pos"] = positive_rate self.trade_indicator["amount"] = trade_amount self.trade_indicator["value"] = trade_value + self.trade_indicator["count"] = order_count if show_indicator: print( "[Indicator({}) {:%Y-%m-%d %H:%M:%S}]: FFR: {}, PA: {}, POS: {}".format( diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index a048ead30..a50be144a 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -84,29 +84,33 @@ def indicator_analysis(df, method="mean"): index: Index(datetime) method : str, optional - statistics method, by default "mean" + statistics method of pa/ffr, by default "mean" - if method is 'mean', count the mean statistical value of each trade indicator - if method is 'amount_weighted', count the amount weighted mean statistical value of each trade indicator - if method is 'value_weighted', count the value weighted mean statistical value of each trade indicator + Note: statistics method of pos is always "mean" Returns ------- pd.DataFrame - statistical value of each trade indicator + statistical value of each trade indicators """ - indicators_df = df[["pa", "pos", "ffr"]] - - if method == "mean": - res = indicators_df.mean() - elif method == "amount_weighted": - weights = df["amount"].abs() - res = indicators_df.mul(weights, axis=0).sum() / weights.sum() - elif method == "value_weighted": - weights = df["value"].abs() - res = indicators_df.mul(weights, axis=0).sum() / weights.sum() - else: + weights_dict = { + "mean": df["count"], + "amount_weighted": df["amount"].abs(), + "value_weighted": df["value"].abs(), + } + if method not in weights_dict: raise ValueError(f"indicator_analysis method {method} is not supported!") + # statistic pa/ffr indicator + indicators_df = df[["ffr", "pa"]] + weights = weights_dict.get(method) + res = indicators_df.mul(weights, axis=0).sum() / weights.sum() + + # statistic pos + weights = weights_dict.get("mean") + res.loc["pos"] = df["pos"].mul(weights).sum() / weights.sum() res = res.to_frame("value") return res diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 300c983a0..9f0cca8c8 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -414,12 +414,12 @@ class SBBStrategyEMA(SBBStrategyBase): # if EMA signal > 0, return long trend elif _sample_signal.iloc[0] > 0: return self.TREND_LONG - # if EMA signal > 0, return short trend + # if EMA signal < 0, return short trend else: return self.TREND_SHORT -class VAStrategy(BaseStrategy): +class ACStrategy(BaseStrategy): def __init__( self, lamb: float = 1e-6, @@ -451,7 +451,7 @@ class VAStrategy(BaseStrategy): if isinstance(instruments, str): self.instruments = D.instruments(instruments) self.freq = freq - super(VAStrategy, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs) + super(ACStrategy, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs) if trade_exchange is not None: self.trade_exchange = trade_exchange @@ -483,7 +483,7 @@ class VAStrategy(BaseStrategy): - It should include `trade_account`, used to get position - It should include `trade_exchange`, used to provide market info """ - super(VAStrategy, self).reset_common_infra(common_infra) + super(ACStrategy, self).reset_common_infra(common_infra) if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") @@ -508,7 +508,7 @@ class VAStrategy(BaseStrategy): ---------- outer_trade_decision : List[Order], optional """ - super(VAStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) + super(ACStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: self.trade_amount = {} # init the trade amount of order and predicted trade trend diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index d8198fc99..b4c0e8f28 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -210,33 +210,12 @@ def get_resam_calendar( return _calendar, freq, freq_sam -def get_higher_freq_feature(instruments, fields, start_time=None, end_time=None, freq="day", disk_cache=1): - """[summary] - - Parameters - ---------- - instruments : [type] - [description] - fields : [type] - [description] - start_time : [type], optional - [description], by default None - end_time : [type], optional - [description], by default None - freq : str, optional - [description], by default "day" - disk_cache : int, optional - [description], by default 1 - +def get_higher_eq_freq_feature(instruments, fields, start_time=None, end_time=None, freq="day", disk_cache=1): + """get the feature with higher or equal frequency than `freq`. Returns ------- - [type] - [description] - - Raises - ------ - ValueError - [description] + pd.DataFrame + the feature with higher or equal frequency """ from ..data.data import D @@ -331,13 +310,12 @@ def resam_ts_data( sample method, apply method function to each stock series data, by default "last" - If type(method) is str or callable function, it should be an attribute of SeriesGroupBy or DataFrameGroupby, and applies groupy.method for the sliced time-series data - If method is None, do nothing for the sliced time-series data. - - Only when the index `feature` is MultiIndex[instrument, datetime], the method is valid. method_kwargs : dict, optional arguments of method, by default {} Returns ------- - The Resampled DataFrame/Series/Value + The resampled DataFrame/Series/value, return None when the resampled data is empty. """ selector_datetime = slice(start_time, end_time) diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index f880406c3..0f6950587 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -299,8 +299,8 @@ class PortAnaRecord(RecordTemp): self, recorder, config, - risk_analysis_freq: Union[List, str] = [], - indicator_analysis_freq: Union[List, str] = [], + risk_analysis_freq: Union[List, str] = None, + indicator_analysis_freq: Union[List, str] = None, indicator_analysis_method=None, **kwargs, ): @@ -321,8 +321,23 @@ class PortAnaRecord(RecordTemp): super().__init__(recorder=recorder, **kwargs) self.strategy_config = config["strategy"] - self.executor_config = config["executor"] + _default_executor_config = { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": "day", + "generate_report": True, + }, + } + self.executor_config = config.get("executor", _default_executor_config) self.backtest_config = config["backtest"] + + self.all_freq = self._get_report_freq(self.executor_config) + if risk_analysis_freq is None: + risk_analysis_freq = [self.all_freq[0]] + if indicator_analysis_freq is None: + indicator_analysis_freq = [self.all_freq[0]] + if isinstance(risk_analysis_freq, str): risk_analysis_freq = [risk_analysis_freq] if isinstance(indicator_analysis_freq, str): @@ -335,7 +350,6 @@ class PortAnaRecord(RecordTemp): "{0}{1}".format(*parse_freq(_analysis_freq)) for _analysis_freq in indicator_analysis_freq ] self.indicator_analysis_method = indicator_analysis_method - self.all_freq = self._get_report_freq(self.executor_config) def _get_report_freq(self, executor_config): ret_freq = [] @@ -399,21 +413,26 @@ class PortAnaRecord(RecordTemp): pprint(analysis["excess_return_with_cost"]) for _analysis_freq in self.indicator_analysis_freq: - indicators_normal = indicator_dict.get(_analysis_freq) - if self.indicator_analysis_method is None: - analysis_df = indicator_analysis(indicators_normal) + if _analysis_freq not in indicator_dict: + warnings.warn(f"the freq {_analysis_freq} indicator is not found") else: - analysis_df = indicator_analysis(indicators_normal, method=self.indicator_analysis_method) - - # log metrics - analysis_dict = analysis_df["value"].to_dict() - self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()}) - # save results - self.recorder.save_objects( - **{f"indicator_analysis_{_analysis_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path() - ) - pprint(f"The following are analysis results of indicators({_analysis_freq}).") - pprint(analysis_df) + indicators_normal = indicator_dict.get(_analysis_freq) + if self.indicator_analysis_method is None: + analysis_df = indicator_analysis(indicators_normal) + else: + analysis_df = indicator_analysis(indicators_normal, method=self.indicator_analysis_method) + # log metrics + analysis_dict = analysis_df["value"].to_dict() + self.recorder.log_metrics(**{f"{_analysis_freq}.{k}": v for k, v in analysis_dict.items()}) + # save results + self.recorder.save_objects( + **{f"indicator_analysis_{_analysis_freq}.pkl": analysis_df}, artifact_path=PortAnaRecord.get_path() + ) + logger.info( + f"Indicator analysis record 'indicator_analysis_{_analysis_freq}.pkl' has been saved as the artifact of the Experiment {self.recorder.experiment_id}" + ) + pprint(f"The following are analysis results of indicators({_analysis_freq}).") + pprint(analysis_df) def list(self): list_path = [] @@ -424,10 +443,16 @@ class PortAnaRecord(RecordTemp): PortAnaRecord.get_path(f"positions_normal_{_freq}.pkl"), ] ) - for _analysis_freq in self.risk_analysis_freq: if _analysis_freq in self.all_freq: list_path.append(PortAnaRecord.get_path(f"port_analysis_{_analysis_freq}.pkl")) else: - warnings.warn(f"{_analysis_freq} is not found") + warnings.warn(f"risk_analysis freq {_analysis_freq} is not found") + + for _analysis_freq in self.indicator_analysis_freq: + if _analysis_freq in self.all_freq: + list_path.append(PortAnaRecord.get_path(f"indicator_analysis_{_analysis_freq}.pkl")) + else: + warnings.warn(f"indicator_analysis freq {_analysis_freq} is not found") + return list_path diff --git a/setup.py b/setup.py index 0205ab087..2dead9fba 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ REQUIRED = [ "statsmodels", "xlrd>=1.0.0", "plotly==4.12.0", - "matplotlib==3.1.3", + "matplotlib==3.3", "tables>=3.6.1", "pyyaml>=5.3.1", "mlflow>=1.12.1", From b6564cd7600ac185630eea533c95c5254da8cb06 Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 24 Jun 2021 19:09:36 +0000 Subject: [PATCH 057/187] support trade decision update --- qlib/backtest/executor.py | 9 +- qlib/backtest/utils.py | 118 ++++++++++++++++- qlib/contrib/strategy/model_strategy.py | 6 +- qlib/contrib/strategy/order_generator.py | 6 +- qlib/contrib/strategy/rule_strategy.py | 155 ++++++++++++----------- qlib/strategy/base.py | 23 +++- 6 files changed, 228 insertions(+), 89 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 226f112b7..5cc2c00c3 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -5,7 +5,7 @@ from typing import Union from .order import Order from .exchange import Exchange -from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure +from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure, TradeDecison from ..utils import init_instance_by_config from ..utils.resam import parse_freq @@ -135,7 +135,7 @@ class BaseExecutor: Parameters ---------- - trade_decision : object + trade_decision : TradeDecison Returns ---------- @@ -149,7 +149,7 @@ class BaseExecutor: Parameters ---------- - trade_decision : object + trade_decision : TradeDecison Returns ---------- @@ -352,7 +352,8 @@ class SimulatorExecutor(BaseExecutor): trade_step = self.trade_calendar.get_trade_step() trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) execute_result = [] - for order in trade_decision: + order_generator = trade_decision.generator() + for order in order_generator: if self.trade_exchange.check_order(order) is True: # execute the order trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 25ddc45a4..120f80609 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from re import L import pandas as pd import warnings -from typing import Union +from typing import Union, List, Set from ..utils.resam import get_resam_calendar from ..data.data import Cal @@ -145,3 +146,118 @@ class CommonInfrastructure(BaseInfrastructure): class LevelInfrastructure(BaseInfrastructure): def get_support_infra(self): return ["trade_calendar"] + + +class TradeDecison: + """trade decison that made by strategy""" + + def __init__(self, order_list, ori_strategy, init_enable=False): + """ + Parameters + ---------- + order_list : list + the order list + ori_strategy : BaseStrategy + the original strategy that make the decison + init_enable : bool, optional + wether to enable order initially, default by False + """ + self.order_list = order_list + self.ori_strategy = ori_strategy + if init_enable: + self.enable_dict = {_order.stock_id: _order for _order in self.order_list} + self.disable_dict = dict() + else: + self.enable_dict = dict() + self.disable_dict = {_order.stock_id: _order for _order in self.order_list} + + def enable(self, enable_set: Union[List[str], Set[str]] = None, all_enable=False): + """enable order set + Parameters + ---------- + enable_set : Union[List[str], Set[str]], optional + the order set that will be enabled, by default None + - if all_enable is True, enable_set will be ignored + - else, enable the order whose stock_id in enable_set + all_enable : bool, optional + wether to enable all order, by default False + """ + if all_enable is True: + self.enable_dict.update(self.disable_dict) + self.disable_dict.clear() + if enable_set is not None: + warnings.warn(f"`enable_set` is ignored because `all_enable` is set True") + else: + enable_set = set(enable_set) + for _stock_id in enable_set: + enable_order = self.disable_dict.get(_stock_id) + if enable_order is None: + raise ValueError(f"_stock_id {_stock_id} is not found in disable set") + self.enable_order.update({_stock_id: enable_order}) + self.disable_dict.pop(_stock_id) + + def disable(self, disable_set: Union[List[str], Set[str]] = None, all_disable=False): + """disable order set + Parameters + ---------- + disable_set : Union[List[str], Set[str]], optional + the order set that will be disabled, by default None + - if all_disable is True, disable_set will be ignored + - else, disable the order whose stock_id in disable_set + all_disable : bool, optional + wether to disable all order, by default False + """ + if all_disable is True: + self.disable_dict.update(self.enable_dict) + self.enable_dict.clear() + if disable_set is not None: + warnings.warn(f"`disable_set` is ignored because `all_disable` is set True") + else: + disable_set = set(disable_set) + for _stock_id in disable_set: + disable_order = self.enable_dict.get(_stock_id) + if disable_order is None: + raise ValueError(f"_stock_id {_stock_id} is not found in enable set") + self.disable_dict.update({_stock_id: disable_order}) + self.enable_dict.pop(_stock_id) + + def generator(self, only_enable=False, only_disable=False): + """get order generator used for iteration + Parameters + ---------- + only_enable : bool, optional + wether to ignore disabled order, by default False + only_disable : bool, optional + wether to ignore enabled order, by default False + """ + if not only_disable and not only_enable: + yield from self.order_list + elif not only_disable: + yield from self.enable_dict.values() + elif not only_enable: + yield from self.disable_dict.values() + + def get_order_list(self, only_enable=False, only_disable=False): + """get the order list + + Parameters + ---------- + only_enable : bool, optional + wether to ignore disabled order, by default False + only_disable : bool, optional + wether to ignore enabled order, by default False + Returns + ------- + List[Order] + the order list + """ + if not only_disable and not only_enable: + return self.order_list + elif not only_disable: + return list(self.enable_dict.values()) + elif not only_enable: + return list(self.disable_dict.values()) + + def update(self, trade_step, trade_len): + """make the original strategy update the enabled status of orders.""" + self.ori_strategy.update_trade_decision(self, trade_step, trade_len) diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index d88dcd7d6..679385043 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -6,6 +6,8 @@ import pandas as pd from ...utils.resam import resam_ts_data from ...strategy.base import ModelStrategy from ...backtest.order import Order +from ...backtest.utils import TradeDecison + from .order_generator import OrderGenWInteract @@ -244,7 +246,7 @@ class TopkDropoutStrategy(ModelStrategy): factor=factor, ) buy_order_list.append(buy_order) - return sell_order_list + buy_order_list + return TradeDecison(order_list=sell_order_list + buy_order_list, ori_strategy=self) class WeightStrategyBase(ModelStrategy): @@ -339,4 +341,4 @@ class WeightStrategyBase(ModelStrategy): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return order_list + return TradeDecison(order_list=order_list, ori_strategy=self) diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index d3e94551a..7e4ee1a07 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -6,6 +6,8 @@ This order generator is for strategies based on WeightStrategyBase """ from ...backtest.position import Position from ...backtest.exchange import Exchange +from ...backtest.utils import TradeDecison + import pandas as pd import copy @@ -125,7 +127,7 @@ class OrderGenWInteract(OrderGenerator): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return order_list + return TradeDecison(order_list=order_list, ori_strategy=self) class OrderGenWOInteract(OrderGenerator): @@ -189,4 +191,4 @@ class OrderGenWOInteract(OrderGenerator): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return order_list + return TradeDecison(order_list=order_list, ori_strategy=self) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 9f0cca8c8..01eb42803 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -9,7 +9,7 @@ from ...data.dataset.utils import convert_index_format from ...strategy.base import BaseStrategy from ...backtest.order import Order from ...backtest.exchange import Exchange -from ...backtest.utils import CommonInfrastructure, LevelInfrastructure +from ...backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeDecison class TWAPStrategy(BaseStrategy): @@ -17,7 +17,7 @@ class TWAPStrategy(BaseStrategy): def __init__( self, - outer_trade_decision: List[Order] = None, + outer_trade_decision: TradeDecison = None, trade_exchange: Exchange = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, @@ -25,8 +25,8 @@ class TWAPStrategy(BaseStrategy): """ Parameters ---------- - outer_trade_decision : List[Order] - the trade decison of outer strategy which this startegy relies, it should be List[Order] in TWAPStrategy + outer_trade_decision : TradeDecison + the trade decison of outer strategy which this startegy relies trade_exchange : Exchange exchange that provides market info, used to deal order and generate report - If `trade_exchange` is None, self.trade_exchange will be set with common_infra @@ -57,33 +57,37 @@ class TWAPStrategy(BaseStrategy): if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision: List[Order] = None, **kwargs): + def reset(self, outer_trade_decision: TradeDecison = None, **kwargs): """ Parameters ---------- - outer_trade_decision : List[Order], optional + outer_trade_decision : TradeDecison, optional """ super(TWAPStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: self.trade_amount = {} - for order in outer_trade_decision: - self.trade_amount[(order.stock_id, order.direction)] = order.amount + outer_order_generator = outer_trade_decision.generator() + for order in outer_order_generator: + self.trade_amount[order.stock_id] = order.amount def generate_trade_decision(self, execute_result=None): - - # update the order amount - if execute_result is not None: - for order, _, _, _ in execute_result: - self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step trade_len = self.trade_calendar.get_trade_len() + # update outer trade decision + self.outer_trade_decision.update(trade_step, trade_len) + + # update the order amount + if execute_result is not None: + for order, _, _, _ in execute_result: + self.trade_amount[order.stock_id] -= order.deal_amount + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) order_list = [] - for order in self.outer_trade_decision: + outer_order_generator = self.outer_trade_decision.generator(only_enable=True) + for order in outer_order_generator: # if not tradable, continue if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time @@ -94,12 +98,12 @@ class TWAPStrategy(BaseStrategy): # considering trade unit if _amount_trade_unit is None: # divide the order into equal parts, and trade one part - _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step) + _order_amount = self.trade_amount[order.stock_id] / (trade_len - trade_step) # without considering trade unit else: # divide the order into equal parts, and trade one part # calculate the total count of trade units to trade - trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + trade_unit_cnt = int(self.trade_amount[order.stock_id] // _amount_trade_unit) # calculate the amount of one part, ceil the amount # floor((trade_unit_cnt + trade_len - trade_step) / (trade_len - trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - trade_step + 1)) _order_amount = ( @@ -108,12 +112,10 @@ class TWAPStrategy(BaseStrategy): if order.direction == order.SELL: # sell all amount at last - if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount < 1e-5 or trade_step == trade_len - 1 - ): - _order_amount = self.trade_amount[(order.stock_id, order.direction)] + if self.trade_amount[order.stock_id] > 1e-5 and (_order_amount < 1e-5 or trade_step == trade_len - 1): + _order_amount = self.trade_amount[order.stock_id] - _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) + _order_amount = min(_order_amount, self.trade_amount[order.stock_id]) if _order_amount > 1e-5: @@ -126,7 +128,7 @@ class TWAPStrategy(BaseStrategy): factor=order.factor, ) order_list.append(_order) - return order_list + return TradeDecison(order_list=order_list, ori_strategy=self) class SBBStrategyBase(BaseStrategy): @@ -140,7 +142,7 @@ class SBBStrategyBase(BaseStrategy): def __init__( self, - outer_trade_decision: List[Order] = None, + outer_trade_decision: TradeDecison = None, trade_exchange: Exchange = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, @@ -148,8 +150,8 @@ class SBBStrategyBase(BaseStrategy): """ Parameters ---------- - outer_trade_decision : List[Order] - the trade decison of outer strategy which this startegy relies, it should be List[Order] in SBBStrategyBase + outer_trade_decision : TradeDecison + the trade decison of outer strategy which this startegy relies trade_exchange : Exchange exchange that provides market info, used to deal order and generate report - If `trade_exchange` is None, self.trade_exchange will be set with common_infra @@ -178,52 +180,57 @@ class SBBStrategyBase(BaseStrategy): if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision: List[Order] = None, **kwargs): + def reset(self, outer_trade_decision: TradeDecison = None, **kwargs): """ Parameters ---------- - outer_trade_decision : List[Order], optional + outer_trade_decision : TradeDecison, optional """ super(SBBStrategyBase, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: self.trade_trend = {} self.trade_amount = {} # init the trade amount of order and predicted trade trend - for order in outer_trade_decision: - self.trade_trend[(order.stock_id, order.direction)] = self.TREND_MID - self.trade_amount[(order.stock_id, order.direction)] = order.amount + outer_order_generator = outer_trade_decision.generator() + for order in outer_order_generator: + self.trade_trend[order.stock_id] = self.TREND_MID + self.trade_amount[order.stock_id] = order.amount def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): raise NotImplementedError("pred_price_trend method is not implemented!") def generate_trade_decision(self, execute_result=None): - - # update the order amount - if execute_result is not None: - for order, _, _, _ in execute_result: - self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step trade_len = self.trade_calendar.get_trade_len() + # update outer trade decision + self.outer_trade_decision.update(trade_step, trade_len) + + # update the order amount + if execute_result is not None: + for order, _, _, _ in execute_result: + self.trade_amount[order.stock_id] -= order.deal_amount + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) order_list = [] # for each order in in self.outer_trade_decision - for order in self.outer_trade_decision: + outer_order_generator = self.outer_trade_decision.generator(only_enable=True) + for order in outer_order_generator: # get the price trend if trade_step % 2 == 0: # in the first of two adjacent bars, predict the price trend _pred_trend = self._pred_price_trend(order.stock_id, pred_start_time, pred_end_time) else: # in the second of two adjacent bars, use the trend predicted in the first one - _pred_trend = self.trade_trend[(order.stock_id, order.direction)] + _pred_trend = self.trade_trend[order.stock_id] # if not tradable, continue if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): if trade_step % 2 == 0: - self.trade_trend[(order.stock_id, order.direction)] = _pred_trend + self.trade_trend[order.stock_id] = _pred_trend continue # get amount of one trade unit _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) @@ -232,12 +239,12 @@ class SBBStrategyBase(BaseStrategy): # considering trade unit if _amount_trade_unit is None: # divide the order into equal parts, and trade one part - _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step) + _order_amount = self.trade_amount[order.stock_id] / (trade_len - trade_step) # without considering trade unit else: # divide the order into equal parts, and trade one part # calculate the total count of trade units to trade - trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + trade_unit_cnt = int(self.trade_amount[order.stock_id] // _amount_trade_unit) # calculate the amount of one part, ceil the amount # floor((trade_unit_cnt + trade_len - trade_step - 1) / (trade_len - trade_step)) == ceil(trade_unit_cnt / (trade_len - trade_step)) _order_amount = ( @@ -245,12 +252,12 @@ class SBBStrategyBase(BaseStrategy): ) if order.direction == order.SELL: # sell all amount at last - if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( + if self.trade_amount[order.stock_id] > 1e-5 and ( _order_amount < 1e-5 or trade_step == trade_len - 1 ): - _order_amount = self.trade_amount[(order.stock_id, order.direction)] + _order_amount = self.trade_amount[order.stock_id] - _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) + _order_amount = min(_order_amount, self.trade_amount[order.stock_id]) if _order_amount > 1e-5: _order = Order( @@ -268,13 +275,11 @@ class SBBStrategyBase(BaseStrategy): # considering trade unit if _amount_trade_unit is None: # N trade day left, divide the order into N + 1 parts, and trade 2 parts - _order_amount = ( - 2 * self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step + 1) - ) + _order_amount = 2 * self.trade_amount[order.stock_id] / (trade_len - trade_step + 1) # without considering trade unit else: # cal how many trade unit - trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + trade_unit_cnt = int(self.trade_amount[order.stock_id] // _amount_trade_unit) # N trade day left, divide the order into N + 1 parts, and trade 2 parts _order_amount = ( (trade_unit_cnt + trade_len - trade_step) @@ -284,12 +289,12 @@ class SBBStrategyBase(BaseStrategy): ) if order.direction == order.SELL: # sell all amount at last - if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( + if self.trade_amount[order.stock_id] > 1e-5 and ( _order_amount < 1e-5 or trade_step == trade_len - 1 ): - _order_amount = self.trade_amount[(order.stock_id, order.direction)] + _order_amount = self.trade_amount[order.stock_id] - _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) + _order_amount = min(_order_amount, self.trade_amount[order.stock_id]) if _order_amount > 1e-5: if trade_step % 2 == 0: @@ -333,9 +338,9 @@ class SBBStrategyBase(BaseStrategy): if trade_step % 2 == 0: # in the first one of two adjacent bars, store the trend for the second one to use - self.trade_trend[(order.stock_id, order.direction)] = _pred_trend + self.trade_trend[order.stock_id] = _pred_trend - return order_list + return TradeDecison(order_list=order_list, ori_strategy=self) class SBBStrategyEMA(SBBStrategyBase): @@ -345,7 +350,7 @@ class SBBStrategyEMA(SBBStrategyBase): def __init__( self, - outer_trade_decision: List[Order] = None, + outer_trade_decision: TradeDecison = None, instruments: Union[List, str] = "csi300", freq: str = "day", trade_exchange: Exchange = None, @@ -425,7 +430,7 @@ class ACStrategy(BaseStrategy): lamb: float = 1e-6, eta: float = 2.5e-6, window_size: int = 20, - outer_trade_decision: List[Order] = None, + outer_trade_decision: TradeDecison = None, instruments: Union[List, str] = "csi300", freq: str = "day", trade_exchange: Exchange = None, @@ -502,34 +507,38 @@ class ACStrategy(BaseStrategy): self.trade_calendar = level_infra.get("trade_calendar") self._reset_signal() - def reset(self, outer_trade_decision: List[Order] = None, **kwargs): + def reset(self, outer_trade_decision: TradeDecison = None, **kwargs): """ Parameters ---------- - outer_trade_decision : List[Order], optional + outer_trade_decision : TradeDecison, optional """ super(ACStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: self.trade_amount = {} # init the trade amount of order and predicted trade trend - for order in outer_trade_decision: - self.trade_amount[(order.stock_id, order.direction)] = order.amount + outer_order_generator = outer_trade_decision.generator() + for order in outer_order_generator: + self.trade_amount[order.stock_id] = order.amount def generate_trade_decision(self, execute_result=None): - - # update the order amount - if execute_result is not None: - for order, _, _, _ in execute_result: - self.trade_amount[(order.stock_id, order.direction)] -= order.deal_amount - # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step trade_len = self.trade_calendar.get_trade_len() + # update outer trade decision + self.outer_trade_decision.update(trade_step, trade_len) + + # update the order amount + if execute_result is not None: + for order, _, _, _ in execute_result: + self.trade_amount[order.stock_id] -= order.deal_amount + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) order_list = [] - for order in self.outer_trade_decision: + outer_order_generator = self.outer_trade_decision.generator(only_enable=True) + for order in outer_order_generator: # if not tradable, continue if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time @@ -549,11 +558,11 @@ class ACStrategy(BaseStrategy): _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) if _amount_trade_unit is None: # divide the order into equal parts, and trade one part - _order_amount = self.trade_amount[(order.stock_id, order.direction)] / (trade_len - trade_step) + _order_amount = self.trade_amount[order.stock_id] / (trade_len - trade_step) else: # divide the order into equal parts, and trade one part # calculate the total count of trade units to trade - trade_unit_cnt = int(self.trade_amount[(order.stock_id, order.direction)] // _amount_trade_unit) + trade_unit_cnt = int(self.trade_amount[order.stock_id] // _amount_trade_unit) # calculate the amount of one part, ceil the amount # floor((trade_unit_cnt + trade_len - trade_step - 1) / (trade_len - trade_step)) == ceil(trade_unit_cnt / (trade_len - trade_step)) _order_amount = ( @@ -571,12 +580,10 @@ class ACStrategy(BaseStrategy): if order.direction == order.SELL: # sell all amount at last - if self.trade_amount[(order.stock_id, order.direction)] > 1e-5 and ( - _order_amount < 1e-5 or trade_step == trade_len - 1 - ): - _order_amount = self.trade_amount[(order.stock_id, order.direction)] + if self.trade_amount[order.stock_id] > 1e-5 and (_order_amount < 1e-5 or trade_step == trade_len - 1): + _order_amount = self.trade_amount[order.stock_id] - _order_amount = min(_order_amount, self.trade_amount[(order.stock_id, order.direction)]) + _order_amount = min(_order_amount, self.trade_amount[order.stock_id]) if _order_amount > 1e-5: @@ -589,4 +596,4 @@ class ACStrategy(BaseStrategy): factor=order.factor, ) order_list.append(_order) - return order_list + return TradeDecison(order_list=order_list, ori_strategy=self) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 961fb5044..9f9feb3b1 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -7,7 +7,7 @@ from ..data.dataset import DatasetH from ..data.dataset.utils import convert_index_format from ..rl.interpreter import ActionInterpreter, StateInterpreter from ..utils import init_instance_by_config -from ..backtest.utils import CommonInfrastructure, LevelInfrastructure +from ..backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeDecison class BaseStrategy: @@ -15,14 +15,14 @@ class BaseStrategy: def __init__( self, - outer_trade_decision: object = None, + outer_trade_decision: TradeDecison = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, ): """ Parameters ---------- - outer_trade_decision : object, optional + outer_trade_decision : TradeDecison, optional the trade decison of outer strategy which this startegy relies, and it will be traded in [start_time, end_time], by default None - If the strategy is used to split trade decison, it will be used - If the strategy is used for portfolio management, it can be ignored @@ -84,6 +84,17 @@ class BaseStrategy: """ raise NotImplementedError("generate_trade_decision is not implemented!") + def update_trade_decision(self, trade_decison: TradeDecison, trade_step, trade_len): + """update trade decision in each step of inner execution, this method enable all order + + Parameters + ---------- + trade_decison : TradeDecison + the trade decison that will be updated + """ + if trade_step == 0: + trade_decison.enable(all_enable=True) + class ModelStrategy(BaseStrategy): """Model-based trading strategy, use model to make predictions for trading""" @@ -92,7 +103,7 @@ class ModelStrategy(BaseStrategy): self, model: BaseModel, dataset: DatasetH, - outer_trade_decision: object = None, + outer_trade_decision: TradeDecison = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, **kwargs, @@ -128,7 +139,7 @@ class RLStrategy(BaseStrategy): def __init__( self, policy, - outer_trade_decision: object = None, + outer_trade_decision: TradeDecison = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, **kwargs, @@ -151,7 +162,7 @@ class RLIntStrategy(RLStrategy): policy, state_interpreter: Union[dict, StateInterpreter], action_interpreter: Union[dict, ActionInterpreter], - outer_trade_decision: object = None, + outer_trade_decision: TradeDecison = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, **kwargs, From 284d96761b0a34e47fd252fe6ae94c810f10a54e Mon Sep 17 00:00:00 2001 From: bxdd Date: Sun, 27 Jun 2021 17:49:49 +0000 Subject: [PATCH 058/187] fix bug in resam feature --- qlib/utils/resam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index b4c0e8f28..d28076d88 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -328,7 +328,7 @@ def resam_ts_data( if datetime_level: feature = feature.loc[selector_datetime] else: - feature = feature.loc[(slice(None), selector_datetime)] + feature = feature.loc(axis=0)[(slice(None), selector_datetime)] if feature.empty: return None From 4f384d37ced5f0b379b2795ae61b2cbf1a1f551d Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 25 Jun 2021 05:48:21 +0000 Subject: [PATCH 059/187] API enhancement --- qlib/backtest/backtest.py | 9 ++-- qlib/backtest/executor.py | 12 ++++- qlib/backtest/utils.py | 66 ++++++++++++++++++++++++-- qlib/contrib/strategy/rule_strategy.py | 40 +++++++++++++++- qlib/strategy/base.py | 25 ++++++++-- 5 files changed, 137 insertions(+), 15 deletions(-) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 3892fde41..18573115b 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -1,9 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from qlib.backtest.utils import TradeDecison +from qlib.strategy.base import BaseStrategy +from qlib.backtest.executor import BaseExecutor from ..utils.resam import parse_freq -def backtest_loop(start_time, end_time, trade_strategy, trade_executor): +def backtest_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_executor: BaseExecutor): """backtest funciton for the interaction of the outermost strategy and executor in the nested decison execution Returns @@ -17,7 +20,7 @@ def backtest_loop(start_time, end_time, trade_strategy, trade_executor): return return_value.get("report"), return_value.get("indicator") -def collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value: dict = None): +def collect_data_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_executor: BaseExecutor, return_value: dict = None): """Generator for collecting the trade decision data for rl training Parameters @@ -44,7 +47,7 @@ def collect_data_loop(start_time, end_time, trade_strategy, trade_executor, retu _execute_result = None while not trade_executor.finished(): - _trade_decision = trade_strategy.generate_trade_decision(_execute_result) + _trade_decision: TradeDecison = trade_strategy.generate_trade_decision(_execute_result) _execute_result = yield from trade_executor.collect_data(_trade_decision) if return_value is not None: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 5cc2c00c3..d86d5e25a 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -5,7 +5,7 @@ from typing import Union from .order import Order from .exchange import Exchange -from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure, TradeDecison +from .utils import BaseTradeDecision, TradeCalendarManager, CommonInfrastructure, LevelInfrastructure, TradeDecison from ..utils import init_instance_by_config from ..utils.resam import parse_freq @@ -265,7 +265,7 @@ class NestedExecutor(BaseExecutor): pass return return_value.get("execute_result") - def collect_data(self, trade_decision, return_value=None): + def collect_data(self, trade_decision: BaseTradeDecision, return_value=None): if self.track_data: yield trade_decision self._init_sub_trading(trade_decision) @@ -273,6 +273,14 @@ class NestedExecutor(BaseExecutor): inner_order_indicators = [] _inner_execute_result = None while not self.inner_executor.finished(): + # outter strategy have chance to update decision each iterator + updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) + if updated_trade_decision is not None: + trade_decision = updated_trade_decision + # NEW UPDATE + # create a hook for inner strategy to update outter decision + self.inner_strategy.alter_decision(trade_decision) + _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) execute_result.extend(_inner_execute_result) diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 120f80609..f524d09fe 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,10 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from re import L +from qlib.strategy.base import BaseStrategy +from qlib.backtest.exchange import Exchange +from qlib.backtest.account import Account import pandas as pd import warnings -from typing import Union, List, Set +from typing import Tuple, Union, List, Set from ..utils.resam import get_resam_calendar from ..data.data import Cal @@ -138,6 +140,7 @@ class BaseInfrastructure: self.reset_infra(**infra_dict) + class CommonInfrastructure(BaseInfrastructure): def get_support_infra(self): return ["trade_account", "trade_exchange"] @@ -148,8 +151,63 @@ class LevelInfrastructure(BaseInfrastructure): return ["trade_calendar"] -class TradeDecison: - """trade decison that made by strategy""" +class BaseTradeDecision: + # TODO: put it into order.py; and replace it with decision.py + def __init__(self, strategy: BaseStrategy): + self.strategy = strategy + + def get_decision(self) -> List[object]: + """ + get the concrete decision of the order + This will be called by the inner strategy + + Returns + ------- + List[object]: + The decision result. Typically it is some orders + Example: + []: + Decision not available + concrete_decision: + available + """ + raise NotImplementedError(f"This type of input is not supported") + + NOT_AVAIL = 0 + NO_UPDATE = 1 + NEW_UPDATE = 2 + def update(self, trade_step: int, trade_len: int) -> "BaseTradeDecison": + """ + Be called at the **start** of each step + + Returns + ------- + None: + No update, use previous decision(or unavailable) + BaseTradeDecison: + New update, use new decision + """ + return self.strategy.update_trade_decision(self, trade_step, trade_len) + + def get_range_limit(self) -> Tuple[int, int]: + """ + return the expected step range for limiting the dealing time of the order + + Returns + ------- + Tuple[int, int]: + + + Raises + ------ + NotImplementedError: + If the decision can't provide a unified start and end + """ + raise NotImplementedError(f"This type of input is not supported") + + +class TradeDecison(BaseTradeDecision): + """trade decision that made by strategy""" def __init__(self, order_list, ori_strategy, init_enable=False): """ diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 01eb42803..ad3e06ce1 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -1,7 +1,7 @@ import warnings import numpy as np import pandas as pd -from typing import List, Union +from typing import List, Tuple, Union from ...utils.resam import resam_ts_data from ...data.data import D @@ -597,3 +597,41 @@ class ACStrategy(BaseStrategy): ) order_list.append(_order) return TradeDecison(order_list=order_list, ori_strategy=self) + + +class RandomOrderStrategy(BaseStrategy): + + def __init__(self, + time_range: Tuple = ("9:30", "15:00"), # left closed and right closed. + sample_ratio: float = 1., + volume_ratio: float = 0.01, + market: str = "all", + *args, + **kwargs): + """ + Parameters + ---------- + time_range : Tuple + the intra day time range of the orders + the left and right is closed. + sample_ratio : float + the ratio of all orders are sampled + volume_ratio : float + the volume of the total day + raito of the total volume of a specific day + market : str + stock pool for sampling + """ + + super().__init__(*args, **kwargs) + self.time_range = time_range + self.sample_ratio = sample_ratio + self.volume_ratio = volume_ratio + self.market = market + exch: Exchange = self.common_infra.get("exchange") + self.volume = D.features(D.instruments("market"), ["Mean($volume, 10)"], start_time=exch.start_time, end_time=exch.end_time) + + def generate_trade_decision(self, execute_result=None): + + + return super().generate_trade_decision(execute_result=execute_result) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 9f9feb3b1..6c8917658 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,13 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from typing import Union +from typing import List, Union from ..model.base import BaseModel from ..data.dataset import DatasetH from ..data.dataset.utils import convert_index_format from ..rl.interpreter import ActionInterpreter, StateInterpreter from ..utils import init_instance_by_config -from ..backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeDecison +from ..backtest.utils import BaseTradeDecision, CommonInfrastructure, LevelInfrastructure, TradeDecison class BaseStrategy: @@ -43,9 +43,9 @@ class BaseStrategy: if level_infra.has("trade_calendar"): self.trade_calendar = level_infra.get("trade_calendar") - def reset_common_infra(self, common_infra): + def reset_common_infra(self, common_infra: CommonInfrastructure): if not hasattr(self, "common_infra"): - self.common_infra = common_infra + self.common_infra: CommonInfrastructure = common_infra else: self.common_infra.update(common_infra) @@ -84,17 +84,32 @@ class BaseStrategy: """ raise NotImplementedError("generate_trade_decision is not implemented!") - def update_trade_decision(self, trade_decison: TradeDecison, trade_step, trade_len): + def update_trade_decision(self, trade_decison: BaseTradeDecision, trade_step: int, trade_len: int) -> BaseTradeDecision: """update trade decision in each step of inner execution, this method enable all order Parameters ---------- trade_decison : TradeDecison the trade decison that will be updated + Returns + ------- + BaseTradeDecision: """ if trade_step == 0: trade_decison.enable(all_enable=True) + def alter_outer_trade_decision(self, outer_trade_decision: BaseTradeDecision): + """ + A method for updating the outer_trade_decision. + The outer strategy may change its decision during updating. + + Parameters + ---------- + outer_trade_decision : BaseTradeDecision + the decision updated by the outer strategy + """ + self.outer_trade_decision = outer_trade_decision + class ModelStrategy(BaseStrategy): """Model-based trading strategy, use model to make predictions for trading""" From b68294da93d043cfbd8f19a477dbab3a551ff974 Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 25 Jun 2021 14:00:21 +0000 Subject: [PATCH 060/187] add InfPosition --- qlib/backtest/__init__.py | 87 +++++++- qlib/backtest/account.py | 36 +++- qlib/backtest/executor.py | 3 + qlib/backtest/position.py | 265 +++++++++++++++++++++--- qlib/backtest/profit_attribution.py | 4 +- qlib/backtest/report.py | 12 +- qlib/backtest/utils.py | 39 ++-- qlib/contrib/strategy/cost_control.py | 3 + qlib/contrib/strategy/model_strategy.py | 3 + qlib/contrib/strategy/rule_strategy.py | 11 +- qlib/strategy/base.py | 17 +- 11 files changed, 408 insertions(+), 72 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 96941776c..a4c20f730 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. import copy +from typing import Union from .account import Account from .exchange import Exchange @@ -91,17 +92,53 @@ def get_exchange( return init_instance_by_config(exchange, accept_types=Exchange) -def get_strategy_executor( - start_time, end_time, strategy, executor, benchmark="SH000300", account=1e9, exchange_kwargs={} -): - trade_account = Account( - init_cash=account, - benchmark_config={ +def create_account_instance(start_time, end_time, benchmark: str, account: float, pos_type: str="Position") -> Account: + """ + # TODO: is very strange pass benchmark_config in the account(maybe for report) + # There should be a post-step to process the report. + + Parameters + ---------- + start_time : + start time of the benchmark + end_time : + end time of the benchmark + benchmark : str + the benchmark for reporting + account : Union[float, str] + information for describing how to creating the account + For `float` + Using Account with a normal position + For `str`: + Using account with a specific Position + """ + kwargs = { + "init_cash": account, + "benchmark_config": { "benchmark": benchmark, "start_time": start_time, "end_time": end_time, }, - ) + "pos_type": pos_type + } + return Account(**kwargs) + + +def get_strategy_executor(start_time, + end_time, + strategy: BaseStrategy, + executor: BaseExecutor, + benchmark: str = "SH000300", + account: Union[float, str] = 1e9, + exchange_kwargs: dict = {}, + pos_type: str = "Position", + ): + + trade_account = create_account_instance(start_time=start_time, + end_time=end_time, + benchmark=benchmark, + account=account, + pos_type=pos_type) exchange_kwargs = copy.copy(exchange_kwargs) if "start_time" not in exchange_kwargs: @@ -117,19 +154,47 @@ def get_strategy_executor( return trade_strategy, trade_executor -def backtest(start_time, end_time, strategy, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): +def backtest(start_time, + end_time, + strategy, + executor, + benchmark="SH000300", + account=1e9, + exchange_kwargs={}, + pos_type: str = "Position"): trade_strategy, trade_executor = get_strategy_executor( - start_time, end_time, strategy, executor, benchmark, account, exchange_kwargs + start_time, + end_time, + strategy, + executor, + benchmark, + account, + exchange_kwargs, + pos_type=pos_type, ) report_dict, indicator_dict = backtest_loop(start_time, end_time, trade_strategy, trade_executor) return report_dict, indicator_dict -def collect_data(start_time, end_time, strategy, executor, benchmark="SH000300", account=1e9, exchange_kwargs={}): +def collect_data(start_time, + end_time, + strategy, + executor, + benchmark="SH000300", + account=1e9, + exchange_kwargs={}, + pos_type: str = "Position"): trade_strategy, trade_executor = get_strategy_executor( - start_time, end_time, strategy, executor, benchmark, account, exchange_kwargs + start_time, + end_time, + strategy, + executor, + benchmark, + account, + exchange_kwargs, + pos_type=pos_type, ) yield from collect_data_loop(start_time, end_time, trade_strategy, trade_executor) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 85ca57fa5..be1c25f95 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -3,10 +3,11 @@ import copy +from qlib.utils import init_instance_by_config import warnings import pandas as pd -from .position import Position +from .position import BasePosition, InfPosition, Position from .report import Report, Indicator from .order import Order from .exchange import Exchange @@ -62,22 +63,32 @@ class AccumulatedInfo: class Account: - def __init__(self, init_cash, freq: str = "day", benchmark_config: dict = {}): + def __init__(self, init_cash: float=1e9, freq: str = "day", benchmark_config: dict = {}, pos_type:str = "Position"): + self.pos_type = pos_type self.init_vars(init_cash, freq, benchmark_config) def init_vars(self, init_cash, freq: str, benchmark_config: dict): # init cash self.init_cash = init_cash - self.current = Position(cash=init_cash) + self.current: BasePosition = init_instance_by_config({ + 'class': self.pos_type, + 'kwargs': { + "cash": init_cash + }, + 'model_path': "qlib.backtest.position", + }) self.accum_info = AccumulatedInfo() self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) def reset_report(self, freq, benchmark_config): + # portfolio related metrics self.report = Report(freq, benchmark_config) - self.indicator = Indicator() self.positions = {} + # trading related matric(e.g. high-frequency trading) + self.indicator = Indicator() + def reset(self, freq=None, benchmark_config=None, init_report=False): """reset freq and report of account @@ -102,7 +113,7 @@ class Account: return self.positions def get_cash(self): - return self.current.position["cash"] + return self.current.get_cash() def _update_state_from_order(self, order, trade_val, cost, trade_price): # update turnover @@ -124,6 +135,11 @@ class Account: self.accum_info.add_return_value(profit) # note here do not consider cost def update_order(self, order, trade_val, cost, trade_price): + if self.current.skip_update(): + # TODO: supporting polymorphism for account + # updating order for infinite position is meaningless + return + # if stock is sold out, no stock price information in Position, then we should update account first, then update current position # if stock is bought, there is no stock in current position, update current, then update account # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation @@ -142,7 +158,8 @@ class Account: def update_bar_count(self): """at the end of the trading bar, update holding bar, count of stock""" # update holding day count - self.current.add_count_all(bar=self.freq) + if not self.current.skip_update(): + self.current.add_count_all(bar=self.freq) def update_current(self, trade_start_time, trade_end_time, trade_exchange): """update current to make rtn consistent with earning at the end of bar""" @@ -243,11 +260,14 @@ class Account: elif atomic is False and inner_order_indicators is None: raise ValueError("inner_order_indicators is necessary in unatomic executor") - self.update_bar_count() - self.update_current(trade_start_time, trade_end_time, trade_exchange) if generate_report: + # report is portfolio related analysis + # TODO: `update_bar_count` and `update_current` should placed in Position and be merged. + self.update_bar_count() + self.update_current(trade_start_time, trade_end_time, trade_exchange) self.update_report(trade_start_time, trade_end_time) + # indicator is trading (e.g. high-frequency order execution) related analysis self.indicator.clear() if atomic: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index d86d5e25a..bc4831f32 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -282,7 +282,10 @@ class NestedExecutor(BaseExecutor): self.inner_strategy.alter_decision(trade_decision) _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + + # NOTE: Trade Calendar will step forward in the follow line _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) + execute_result.extend(_inner_execute_result) inner_order_indicators.append(self.inner_executor.get_trade_indicator().get_order_indicator) diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 92b549063..6b021c913 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -4,30 +4,182 @@ import copy import pathlib +from typing import Dict, List import pandas as pd import numpy as np from .order import Order -""" -Position module -""" -""" -current state of position -a typical example is :{ - : { - 'count': , - 'amount': , - 'price': , - 'weight': , - }, -} +class BasePosition: + """ + The Position want to maintain the position like a dictionary + Please refer to the `Position` class for the position + """ + def __init__(self, cash=0., *args, **kwargs) -> None: + pass -""" + def skip_update(self) -> bool: + """ + Should we skip updating operation for this position + For example, updating is meaningless for InfPosition + + Returns + ------- + bool: + should we skip the updating operator + """ + return False + + def update_order(self, order: Order, trade_val: float, cost: float, trade_price: float): + """ + Parameters + ---------- + order : Order + the order to update the position + trade_val : float + the trade value(money) of dealing results + cost : float + the trade cost of the dealing results + trade_price : float + the trade price of the dealing results + """ + raise NotImplementedError(f"Please implement the `update_order` method") + + def update_stock_price(self, stock_id, price: float): + """ + Updating the latest price of the order + The useful when clearing balance at each bar end + + Parameters + ---------- + stock_id : + the id of the stock + price : float + the price to be updated + """ + raise NotImplementedError(f"Please implement the `update stock price` method") + + def calculate_stock_value(self) -> float: + """ + calculate the value of the all assets except cash in the position + + Returns + ------- + float: + the value(money) of all the stock + """ + raise NotImplementedError(f"Please implement the `calculate_stock_value` method") + def get_stock_list(self) -> List: + """ + Get the list of stocks in the position. + """ + raise NotImplementedError(f"Please implement the `get_stock_list` method") + + def get_stock_price(self, code) -> float: + """ + get the latest price of the stock + + Parameters + ---------- + code : + the code of the stock + """ + raise NotImplementedError(f"Please implement the `get_stock_price` method") + + def get_stock_amount(self, code) -> float: + """ + get the amount of the stock + + Parameters + ---------- + code : + the code of the stock + + Returns + ------- + float: + the amount of the stock + """ + raise NotImplementedError(f"Please implement the `get_stock_amount` method") + + def get_cash(self) -> float: + """ + + Returns + ------- + float: + the cash in position + """ + raise NotImplementedError(f"Please implement the `get_cash` method") + + def get_stock_amount_dict(self) -> Dict: + """ + generate stock amount dict {stock_id : amount of stock} + + Returns + ------- + Dict: + {stock_id : amount of stock} + """ + raise NotImplementedError(f"Please implement the `get_stock_amount_dict` method") + + def get_stock_weight_dict(self, only_stock: bool=False) -> Dict: + """ + generate stock weight dict {stock_id : value weight of stock in the position} + it is meaningful in the beginning or the end of each trade date + + Parameters + ---------- + only_stock : bool + If only_stock=True, the weight of each stock in total stock will be returned + If only_stock=False, the weight of each stock in total assets(stock + cash) will be returned + + Returns + ------- + Dict: + {stock_id : value weight of stock in the position} + """ + raise NotImplementedError(f"Please implement the `get_stock_weight_dict` method") + + def add_count_all(self, bar): + """ + Will be called at the end of each bar on each level + + Parameters + ---------- + bar : + The level to be updated + """ + raise NotImplementedError(f"Please implement the `add_count_all` method") + + def update_weight_all(self): + """ + Updating the position weight; + + # TODO: this function is a little weird. The weight data in the position is in a wrong state after dealing order + # and before updating weight. + + Parameters + ---------- + bar : + The level to be updated + """ + raise NotImplementedError(f"Please implement the `add_count_all` method") -class Position: - """Position""" +class Position(BasePosition): + """Position + + current state of position + a typical example is :{ + : { + 'count': , + 'amount': , + 'price': , + 'weight': , + }, + } + """ def __init__(self, cash=0, position_dict={}, now_account_value=0): # NOTE: The position dict must be copied!!! @@ -37,23 +189,35 @@ class Position: self.position["cash"] = cash self.position["now_account_value"] = now_account_value - def init_stock(self, stock_id, amount, price=None): + def _init_stock(self, stock_id, amount, price=None): + """ + initialization the stock in current position + + Parameters + ---------- + stock_id : + the id of the stock + amount : float + the amount of the stock + price : + the price when buying the init stock + """ self.position[stock_id] = {} self.position[stock_id]["amount"] = amount self.position[stock_id]["price"] = price self.position[stock_id]["weight"] = 0 # update the weight in the end of the trade date - def buy_stock(self, stock_id, trade_val, cost, trade_price): + def _buy_stock(self, stock_id, trade_val, cost, trade_price): trade_amount = trade_val / trade_price if stock_id not in self.position: - self.init_stock(stock_id=stock_id, amount=trade_amount, price=trade_price) + self._init_stock(stock_id=stock_id, amount=trade_amount, price=trade_price) else: # exist, add amount self.position[stock_id]["amount"] += trade_amount self.position["cash"] -= trade_val + cost - def sell_stock(self, stock_id, trade_val, cost, trade_price): + def _sell_stock(self, stock_id, trade_val, cost, trade_price): trade_amount = trade_val / trade_price if stock_id not in self.position: raise KeyError("{} not in current position".format(stock_id)) @@ -66,11 +230,11 @@ class Position: "only have {} {}, require {}".format(self.position[stock_id]["amount"], stock_id, trade_amount) ) elif abs(self.position[stock_id]["amount"]) <= 1e-5: - self.del_stock(stock_id) + self._del_stock(stock_id) self.position["cash"] += trade_val - cost - def del_stock(self, stock_id): + def _del_stock(self, stock_id): del self.position[stock_id] def check_stock(self, stock_id): @@ -80,10 +244,10 @@ class Position: # handle order, order is a order class, defined in exchange.py if order.direction == Order.BUY: # BUY - self.buy_stock(order.stock_id, trade_val, cost, trade_price) + self._buy_stock(order.stock_id, trade_val, cost, trade_price) elif order.direction == Order.SELL: # SELL - self.sell_stock(order.stock_id, trade_val, cost, trade_price) + self._sell_stock(order.stock_id, trade_val, cost, trade_price) else: raise NotImplementedError("do not support order direction {}".format(order.direction)) @@ -122,6 +286,7 @@ class Position: return self.position[code]["amount"] def get_stock_count(self, code, bar): + """the days the account has been hold, it may be used in some special strategies""" if f"count_{bar}" in self.position[code]: return self.position[code][f"count_{bar}"] else: @@ -215,3 +380,55 @@ class Position: self.position = positions self.position["cash"] = cash self.position["now_account_value"] = now_account_value + + + +class InfPosition(BasePosition): + """ + Position with infinite cash and amount. + + This is useful for generating random orders. + """ + def skip_update(self) -> bool: + """ Updating state is meaningless for InfPosition """ + return True + + def update_order(self, order: Order, trade_val: float, cost: float, trade_price: float): + pass + + def update_stock_price(self, stock_id, price: float): + pass + + def calculate_stock_value(self) -> float: + """ + Returns + ------- + float: + infinity stock value + """ + return np.inf + + def get_stock_list(self) -> List: + raise NotImplementedError(f"InfPosition doesn't support stock list position") + + def get_stock_price(self, code) -> float: + """the price of the inf position is meaningless""" + return np.nan + + def get_stock_amount(self, code) -> float: + return np.inf + + def get_cash(self) -> float: + return np.inf + + def get_stock_amount_dict(self) -> Dict: + raise NotImplementedError(f"InfPosition doesn't support get_stock_amount_dict") + + def get_stock_weight_dict(self, only_stock: bool) -> Dict: + raise NotImplementedError(f"InfPosition doesn't support get_stock_weight_dict") + + def add_count_all(self, bar): + raise NotImplementedError(f"InfPosition doesn't support get_stock_weight_dict") + + def update_weight_all(self): + raise NotImplementedError(f"InfPosition doesn't support update_weight_all") diff --git a/qlib/backtest/profit_attribution.py b/qlib/backtest/profit_attribution.py index 7e1844a6f..05ee138cb 100644 --- a/qlib/backtest/profit_attribution.py +++ b/qlib/backtest/profit_attribution.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - +""" +This module is not well maintained. +""" import numpy as np import pandas as pd diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index a3bb0b10e..75b743694 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -17,9 +17,15 @@ from ..tests.config import CSI300_BENCH class Report: - # daily report of the account - # contain those followings: returns, costs turnovers, accounts, cash, bench, value - # update report + ''' + Motivation: + Report is for supporting portfolio related metrics. + + Implementation: + daily report of the account + contain those followings: returns, costs turnovers, accounts, cash, bench, value + update report + ''' def __init__(self, freq: str = "day", benchmark_config: dict = {}): """ Parameters diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index f524d09fe..85d88068a 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from qlib.backtest.order import Order from qlib.strategy.base import BaseStrategy from qlib.backtest.exchange import Exchange from qlib.backtest.account import Account @@ -158,7 +159,7 @@ class BaseTradeDecision: def get_decision(self) -> List[object]: """ - get the concrete decision of the order + get the **concrete decision** (e.g. concrete decision) This will be called by the inner strategy Returns @@ -173,13 +174,15 @@ class BaseTradeDecision: """ raise NotImplementedError(f"This type of input is not supported") - NOT_AVAIL = 0 - NO_UPDATE = 1 - NEW_UPDATE = 2 - def update(self, trade_step: int, trade_len: int) -> "BaseTradeDecison": + def update(self, trade_calendar: TradeCalendarManager) -> "BaseTradeDecison": """ Be called at the **start** of each step + Parameters + ---------- + trade_calendar : TradeCalendarManager + The calendar of the **inner strategy**!!!!! + Returns ------- None: @@ -187,23 +190,28 @@ class BaseTradeDecision: BaseTradeDecison: New update, use new decision """ - return self.strategy.update_trade_decision(self, trade_step, trade_len) + return self.strategy.update_trade_decision(self, trade_calendar) def get_range_limit(self) -> Tuple[int, int]: """ - return the expected step range for limiting the dealing time of the order + return the expected step range for limiting the decision execution time Returns ------- Tuple[int, int]: - Raises ------ NotImplementedError: If the decision can't provide a unified start and end """ - raise NotImplementedError(f"This type of input is not supported") + raise NotImplementedError(f"Please implement the `func` method") + + +class TradeDecisonWO(BaseTradeDecision): + def __init__(self, order_list: List[Order], strategy: BaseStrategy): + super().__init__(strategy) + self.order_list = order_list class TradeDecison(BaseTradeDecision): @@ -316,6 +324,13 @@ class TradeDecison(BaseTradeDecision): elif not only_enable: return list(self.disable_dict.values()) - def update(self, trade_step, trade_len): - """make the original strategy update the enabled status of orders.""" - self.ori_strategy.update_trade_decision(self, trade_step, trade_len) + def update(self, trade_calendar: TradeCalendarManager): + """ + make the original strategy update the enabled status of orders. + + Parameters + ---------- + trade_calendar : TradeCalendarManager + the trade calendar for sub strategy + """ + self.ori_strategy.update_trade_decision(self, trade_calendar) diff --git a/qlib/contrib/strategy/cost_control.py b/qlib/contrib/strategy/cost_control.py index 88e35b2e4..b45c03ae9 100644 --- a/qlib/contrib/strategy/cost_control.py +++ b/qlib/contrib/strategy/cost_control.py @@ -1,5 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +""" +This strategy is not well maintained +""" from .order_generator import OrderGenWInteract diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 679385043..71f9ee509 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -1,4 +1,5 @@ import copy +from qlib.backtest.position import Position import warnings import numpy as np import pandas as pd @@ -328,6 +329,8 @@ class WeightStrategyBase(ModelStrategy): if pred_score is None: return [] current_temp = copy.deepcopy(self.trade_position) + assert(isinstance(current_temp, Position)) # Avoid InfPosition + target_weight_position = self.generate_target_weight_position( score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time ) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index ad3e06ce1..c0993f44e 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -76,8 +76,6 @@ class TWAPStrategy(BaseStrategy): trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step trade_len = self.trade_calendar.get_trade_len() - # update outer trade decision - self.outer_trade_decision.update(trade_step, trade_len) # update the order amount if execute_result is not None: @@ -204,8 +202,6 @@ class SBBStrategyBase(BaseStrategy): trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step trade_len = self.trade_calendar.get_trade_len() - # update outer trade decision - self.outer_trade_decision.update(trade_step, trade_len) # update the order amount if execute_result is not None: @@ -527,7 +523,7 @@ class ACStrategy(BaseStrategy): # get the total count of trading step trade_len = self.trade_calendar.get_trade_len() # update outer trade decision - self.outer_trade_decision.update(trade_step, trade_len) + self.outer_trade_decision.update(self.trade_calendar) # update the order amount if execute_result is not None: @@ -602,7 +598,7 @@ class ACStrategy(BaseStrategy): class RandomOrderStrategy(BaseStrategy): def __init__(self, - time_range: Tuple = ("9:30", "15:00"), # left closed and right closed. + time_range: Tuple = ("9:30", "15:00"), # The range is closed on both left and right. sample_ratio: float = 1., volume_ratio: float = 0.01, market: str = "all", @@ -614,6 +610,7 @@ class RandomOrderStrategy(BaseStrategy): time_range : Tuple the intra day time range of the orders the left and right is closed. + # TODO: this is a time_range level limitation. We'll implement a more detailed limitation later. sample_ratio : float the ratio of all orders are sampled volume_ratio : float @@ -632,6 +629,4 @@ class RandomOrderStrategy(BaseStrategy): self.volume = D.features(D.instruments("market"), ["Mean($volume, 10)"], start_time=exch.start_time, end_time=exch.end_time) def generate_trade_decision(self, execute_result=None): - - return super().generate_trade_decision(execute_result=execute_result) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 6c8917658..f060ccdb7 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -7,7 +7,7 @@ from ..data.dataset import DatasetH from ..data.dataset.utils import convert_index_format from ..rl.interpreter import ActionInterpreter, StateInterpreter from ..utils import init_instance_by_config -from ..backtest.utils import BaseTradeDecision, CommonInfrastructure, LevelInfrastructure, TradeDecison +from ..backtest.utils import BaseTradeDecision, CommonInfrastructure, LevelInfrastructure, TradeCalendarManager, TradeDecison class BaseStrategy: @@ -84,19 +84,23 @@ class BaseStrategy: """ raise NotImplementedError("generate_trade_decision is not implemented!") - def update_trade_decision(self, trade_decison: BaseTradeDecision, trade_step: int, trade_len: int) -> BaseTradeDecision: - """update trade decision in each step of inner execution, this method enable all order + def update_trade_decision(self, trade_decison: BaseTradeDecision, trade_calendar: TradeCalendarManager) -> Union[BaseTradeDecision, None]: + """ + update trade decision in each step of inner execution, this method enable all order Parameters ---------- trade_decison : TradeDecison the trade decison that will be updated + trade_calendar : TradeCalendarManager + The calendar of the **inner strategy**!!!!! + Returns ------- BaseTradeDecision: """ - if trade_step == 0: - trade_decison.enable(all_enable=True) + # default to return None, which indicates that the trade decision is not changed + return None def alter_outer_trade_decision(self, outer_trade_decision: BaseTradeDecision): """ @@ -108,6 +112,9 @@ class BaseStrategy: outer_trade_decision : BaseTradeDecision the decision updated by the outer strategy """ + + # default to reset the decision directly + # NOTE: normally, user should do something to the strategy due to the change of outer decision self.outer_trade_decision = outer_trade_decision From b41267fa593a83047713c4b099541dc640ecfb4b Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 25 Jun 2021 20:12:39 +0000 Subject: [PATCH 061/187] successful run random order gen in day script --- .../nested_decision_execution/workflow.py | 4 +- qlib/backtest/account.py | 17 +- qlib/backtest/backtest.py | 23 +- qlib/backtest/exchange.py | 27 ++- qlib/backtest/executor.py | 17 +- qlib/backtest/order.py | 199 +++++++++++++++++- qlib/backtest/position.py | 21 ++ qlib/backtest/report.py | 9 +- qlib/backtest/utils.py | 188 ----------------- qlib/contrib/evaluate.py | 12 +- qlib/contrib/strategy/model_strategy.py | 7 +- qlib/contrib/strategy/order_generator.py | 6 +- qlib/contrib/strategy/rule_strategy.py | 96 +++++---- qlib/strategy/base.py | 31 +-- qlib/utils/resam.py | 79 ++----- qlib/utils/time.py | 115 ++++++++++ qlib/workflow/record_temp.py | 8 +- 17 files changed, 505 insertions(+), 354 deletions(-) create mode 100644 qlib/utils/time.py diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index a44aee4ca..b6c1362fd 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -13,7 +13,7 @@ from qlib.tests.data import GetData from qlib.backtest import collect_data -class NestedDecisonExecutionWorkflow: +class NestedDecisionExecutionWorkflow: market = "csi300" benchmark = "SH000300" @@ -229,4 +229,4 @@ class NestedDecisonExecutionWorkflow: if __name__ == "__main__": - fire.Fire(NestedDecisonExecutionWorkflow) + fire.Fire(NestedDecisionExecutionWorkflow) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index be1c25f95..64a814dba 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -76,7 +76,7 @@ class Account: 'kwargs': { "cash": init_cash }, - 'model_path': "qlib.backtest.position", + 'module_path': "qlib.backtest.position", }) self.accum_info = AccumulatedInfo() self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) @@ -164,13 +164,14 @@ class Account: def update_current(self, trade_start_time, trade_end_time, trade_exchange): """update current to make rtn consistent with earning at the end of bar""" # update price for stock in the position and the profit from changed_price - stock_list = self.current.get_stock_list() - for code in stock_list: - # if suspend, no new price to be updated, profit is 0 - if trade_exchange.check_stock_suspended(code, trade_start_time, trade_end_time): - continue - bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) - self.current.update_stock_price(stock_id=code, price=bar_close) + if not self.current.skip_update(): + stock_list = self.current.get_stock_list() + for code in stock_list: + # if suspend, no new price to be updated, profit is 0 + if trade_exchange.check_stock_suspended(code, trade_start_time, trade_end_time): + continue + bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) + self.current.update_stock_price(stock_id=code, price=bar_close) def update_report(self, trade_start_time, trade_end_time): """update position history, report""" diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 18573115b..6ab17c5c5 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -1,13 +1,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from qlib.backtest.utils import TradeDecison +from qlib.backtest.order import BaseTradeDecision from qlib.strategy.base import BaseStrategy from qlib.backtest.executor import BaseExecutor -from ..utils.resam import parse_freq +from ..utils.time import Freq +from tqdm.auto import tqdm def backtest_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_executor: BaseExecutor): - """backtest funciton for the interaction of the outermost strategy and executor in the nested decison execution + """backtest funciton for the interaction of the outermost strategy and executor in the nested decision execution Returns ------- @@ -15,7 +16,7 @@ def backtest_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_exec it records the trading report information """ return_value = {} - for _decison in collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value): + for _decision in collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value): pass return return_value.get("report"), return_value.get("indicator") @@ -45,22 +46,24 @@ def collect_data_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_ level_infra = trade_executor.get_level_infra() trade_strategy.reset(level_infra=level_infra) - _execute_result = None - while not trade_executor.finished(): - _trade_decision: TradeDecison = trade_strategy.generate_trade_decision(_execute_result) - _execute_result = yield from trade_executor.collect_data(_trade_decision) + with tqdm(total=trade_executor.trade_calendar.get_trade_len(), desc="backtest loop") as bar: + _execute_result = None + while not trade_executor.finished(): + _trade_decision: BaseTradeDecision = trade_strategy.generate_trade_decision(_execute_result) + _execute_result = yield from trade_executor.collect_data(_trade_decision) + bar.update(trade_executor.trade_calendar.get_trade_step()) if return_value is not None: all_executors = trade_executor.get_all_executors() all_reports = { - "{}{}".format(*parse_freq(_executor.time_per_step)): _executor.get_report() + "{}{}".format(*Freq.parse(_executor.time_per_step)): _executor.get_report() for _executor in all_executors if _executor.generate_report } all_indicators = { "{}{}".format( - *parse_freq(_executor.time_per_step) + *Freq.parse(_executor.time_per_step) ): _executor.get_trade_indicator().generate_trade_indicators_dataframe() for _executor in all_executors } diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 06ecbaa5b..cffa98ba6 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -4,6 +4,7 @@ import random import logging +from typing import Union import numpy as np import pandas as pd @@ -259,6 +260,16 @@ class Exchange: return trade_val, trade_cost, trade_price + def create_order(self, code, amount, start_time, end_time, direction) -> Order: + return Order( + stock_id=code, + amount=amount, + start_time=start_time, + end_time=end_time, + direction=direction, + factor=self.get_factor(code, start_time, end_time), + ) + def get_quote_info(self, stock_id, start_time, end_time): return resam_ts_data(self.quote[stock_id], start_time, end_time, method="last").iloc[0] @@ -278,8 +289,20 @@ class Exchange: deal_price = self.get_close(stock_id, start_time, end_time) return deal_price - def get_factor(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method="last").iloc[0] + def get_factor(self, stock_id, start_time, end_time) -> Union[float, None]: + """ + Returns + ------- + Union[float, None]: + `None`: if the stock is suspended `None` may be returned + `float`: return factor if the factor exists + """ + if stock_id not in self.quote: + return None + res = resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method="last") + if res is not None: + res = res.iloc[0] + return res def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): """ diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index bc4831f32..b6d16d58f 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -3,12 +3,12 @@ import warnings import pandas as pd from typing import Union -from .order import Order +from .order import Order, BaseTradeDecision from .exchange import Exchange -from .utils import BaseTradeDecision, TradeCalendarManager, CommonInfrastructure, LevelInfrastructure, TradeDecison +from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure from ..utils import init_instance_by_config -from ..utils.resam import parse_freq +from ..utils.time import Freq from ..strategy.base import BaseStrategy @@ -135,7 +135,7 @@ class BaseExecutor: Parameters ---------- - trade_decision : TradeDecison + trade_decision : BaseTradeDecision Returns ---------- @@ -149,7 +149,7 @@ class BaseExecutor: Parameters ---------- - trade_decision : TradeDecison + trade_decision : BaseTradeDecision Returns ---------- @@ -261,7 +261,7 @@ class NestedExecutor(BaseExecutor): def execute(self, trade_decision): return_value = {} - for _decison in self.collect_data(trade_decision, return_value): + for _decision in self.collect_data(trade_decision, return_value): pass return return_value.get("execute_result") @@ -358,13 +358,12 @@ class SimulatorExecutor(BaseExecutor): if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") - def execute(self, trade_decision): + def execute(self, trade_decision: BaseTradeDecision): trade_step = self.trade_calendar.get_trade_step() trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) execute_result = [] - order_generator = trade_decision.generator() - for order in order_generator: + for order in trade_decision.get_decision(): if self.trade_exchange.check_order(order) is True: # execute the order trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index e4bf41f1e..d1b5f6d08 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -1,8 +1,16 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# TODO: rename it with decision.py +from __future__ import annotations +# try to fix circular imports when enabling type hints +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from qlib.strategy.base import BaseStrategy +from qlib.backtest.utils import TradeCalendarManager +import warnings import pandas as pd from dataclasses import dataclass, field -from typing import ClassVar +from typing import ClassVar, Union, List, Set, Tuple @dataclass @@ -34,3 +42,192 @@ class Order: if self.direction not in {Order.SELL, Order.BUY}: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") self.deal_amount = 0 + + +class BaseTradeDecision: + """ + Trade decisions ara made by strategy and executed by exeuter + + Motivation: + Here are several typical scenarios for `BaseTradeDecision` + + Case 1: + 1. Outer strategy makes a decision. The decision is not available at the start of current interval + 2. After a period of time, the decision are updated and become available + 3. The inner strategy try to get the decision and start to execute the decision according to `get_range_limit` + Case 2: + 1. The strategy is available at the start of the interval + 2. Same as `case 1.3` + """ + def __init__(self, strategy: BaseStrategy): + """ + Parameters + ---------- + strategy : BaseStrategy + The strategy who make the decision + """ + self.strategy = strategy + + def get_decision(self) -> List[object]: + """ + get the **concrete decision** (e.g. execution orders) + This will be called by the inner strategy + + Returns + ------- + List[object]: + The decision result. Typically it is some orders + Example: + []: + Decision not available + concrete_decision: + available + """ + raise NotImplementedError(f"This type of input is not supported") + + def update(self, trade_calendar: TradeCalendarManager) -> Union["BaseTradeDecision", None]: + """ + Be called at the **start** of each step + + Parameters + ---------- + trade_calendar : TradeCalendarManager + The calendar of the **inner strategy**!!!!! + + Returns + ------- + None: + No update, use previous decision(or unavailable) + BaseTradeDecision: + New update, use new decision + """ + return self.strategy.update_trade_decision(self, trade_calendar) + + def get_range_limit(self) -> Tuple[int, int]: + """ + return the expected step range for limiting the decision execution time + Both left and right are **closed** + + Returns + ------- + Tuple[int, int]: + + Raises + ------ + NotImplementedError: + If the decision can't provide a unified start and end + """ + raise NotImplementedError(f"Please implement the `func` method") + + +class TradeDecisionWO(BaseTradeDecision): + """ + Trade Decision (W)ith (O)rder. + Besides, the time_range is also included. + """ + def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple=None): + super().__init__(strategy) + self.order_list = order_list + self.idx_range = idx_range + + def get_range_limit(self) -> Tuple[int, int]: + if self.idx_range is None: + # Default to get full index + return 0, self.strategy.trade_calendar.get_trade_len() - 1 + return self.idx_range + + def get_decision(self) -> List[object]: + return self.order_list + + +# TODO: the orders below need to be discussed ------------------------------------ +class TradeDecisionWithOrderPool: + """trade decision that made by strategy""" + + def __init__(self, strategy, order_pool): + """ + Parameters + ---------- + strategy : BaseStrategy + the original strategy that make the decision + order_pool : list, optional + the candinate order pool for generate trade decision + """ + super(TradeDecisionWithOrderPool, self).__init__(strategy) + self.order_pool = order_pool + self.order_list = [] + + def pop_order_pool(self, pop_len): + if pop_len > len(self.order_pool): + warnings.warn( + f"pop len {pop_len} is too much length than order pool, cut it as pool length {len(self.order_pool)}" + ) + pop_len = len(self.order_pool) + res = self.order_pool[:pop_len] + del self.order_pool[:pop_len] + return res + + def push_order_list(self, order_list): + self.order_list.extend(order_list) + + def get_decision(self): + """get the order list + + Parameters + ---------- + only_enable : bool, optional + wether to ignore disabled order, by default False + only_disable : bool, optional + wether to ignore enabled order, by default False + Returns + ------- + List[Order] + the order list + """ + return self.order_list + + def update(self, trade_calendar): + """make the original strategy update the enabled status of orders.""" + self.ori_strategy.update_trade_decision(self, trade_calendar) + + +class BaseDecisionUpdater: + def update_decision(self, decision, trade_calendar) -> BaseTradeDecision: + """[summary] + + Parameters + ---------- + decision : BaseTradeDecision + the trade decision to be updated + trade_calendar : BaseTradeCalendar + the trade calendar of inner execution + + Returns + ------- + BaseTradeDecision + the updated decision + """ + raise NotImplementedError(f"This method is not implemented") + + +class DecisionUpdaterWithOrderPool: + def __init__(self, plan_config=None): + """ + Parameters + ---------- + plan_config : Dict[Tuple(int, float)], optional + the plan config, by default None + """ + if plan_config is None: + self.plan_config = [(0, 1)] + else: + self.plan_config = plan_config + + def update_decision(self, decision, trade_calendar) -> BaseTradeDecision: + # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] + trade_step = self.trade_calendar.get_trade_step() + for _index, _ratio in self.plan_config: + if trade_step == _index: + pop_len = len(decision.order_pool) * _ratio + pop_order_list = decision.pop_order_pool(pop_len) + decision.push_order_list(pop_order_list) diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 6b021c913..70272f688 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -30,6 +30,23 @@ class BasePosition: """ return False + def check_stock(self, stock_id: str) -> bool: + """ + check if is the stock in the position + + Parameters + ---------- + stock_id : str + the id of the stock + + Returns + ------- + bool: + if is the stock in the position + """ + raise NotImplementedError(f"Please implement the `check_stock` method") + + def update_order(self, order: Order, trade_val: float, cost: float, trade_price: float): """ Parameters @@ -393,6 +410,10 @@ class InfPosition(BasePosition): """ Updating state is meaningless for InfPosition """ return True + def check_stock(self, stock_id: str) -> bool: + # InfPosition always have any stocks + return True + def update_order(self, order: Order, trade_val: float, cost: float, trade_price: float): pass diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 75b743694..70ebd724e 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -11,7 +11,8 @@ from pandas.core import groupby from pandas.core.frame import DataFrame -from ..utils.resam import parse_freq, resam_ts_data, get_higher_eq_freq_feature +from ..utils.time import Freq +from ..utils.resam import resam_ts_data, get_higher_eq_freq_feature from ..data import D from ..tests.config import CSI300_BENCH @@ -78,6 +79,9 @@ class Report: def _cal_benchmark(self, benchmark_config, freq): benchmark = benchmark_config.get("benchmark", CSI300_BENCH) + if benchmark is None: + return None + if isinstance(benchmark, pd.Series): return benchmark else: @@ -94,6 +98,9 @@ class Report: return _temp_result.groupby(level="datetime")[_temp_result.columns.tolist()[0]].mean().fillna(0) def _sample_benchmark(self, bench, trade_start_time, trade_end_time): + if self.bench is None: + return None + def cal_change(x): return (x + 1).prod() diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 85d88068a..d2441dd3a 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,10 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from qlib.backtest.order import Order -from qlib.strategy.base import BaseStrategy -from qlib.backtest.exchange import Exchange -from qlib.backtest.account import Account import pandas as pd import warnings from typing import Tuple, Union, List, Set @@ -150,187 +146,3 @@ class CommonInfrastructure(BaseInfrastructure): class LevelInfrastructure(BaseInfrastructure): def get_support_infra(self): return ["trade_calendar"] - - -class BaseTradeDecision: - # TODO: put it into order.py; and replace it with decision.py - def __init__(self, strategy: BaseStrategy): - self.strategy = strategy - - def get_decision(self) -> List[object]: - """ - get the **concrete decision** (e.g. concrete decision) - This will be called by the inner strategy - - Returns - ------- - List[object]: - The decision result. Typically it is some orders - Example: - []: - Decision not available - concrete_decision: - available - """ - raise NotImplementedError(f"This type of input is not supported") - - def update(self, trade_calendar: TradeCalendarManager) -> "BaseTradeDecison": - """ - Be called at the **start** of each step - - Parameters - ---------- - trade_calendar : TradeCalendarManager - The calendar of the **inner strategy**!!!!! - - Returns - ------- - None: - No update, use previous decision(or unavailable) - BaseTradeDecison: - New update, use new decision - """ - return self.strategy.update_trade_decision(self, trade_calendar) - - def get_range_limit(self) -> Tuple[int, int]: - """ - return the expected step range for limiting the decision execution time - - Returns - ------- - Tuple[int, int]: - - Raises - ------ - NotImplementedError: - If the decision can't provide a unified start and end - """ - raise NotImplementedError(f"Please implement the `func` method") - - -class TradeDecisonWO(BaseTradeDecision): - def __init__(self, order_list: List[Order], strategy: BaseStrategy): - super().__init__(strategy) - self.order_list = order_list - - -class TradeDecison(BaseTradeDecision): - """trade decision that made by strategy""" - - def __init__(self, order_list, ori_strategy, init_enable=False): - """ - Parameters - ---------- - order_list : list - the order list - ori_strategy : BaseStrategy - the original strategy that make the decison - init_enable : bool, optional - wether to enable order initially, default by False - """ - self.order_list = order_list - self.ori_strategy = ori_strategy - if init_enable: - self.enable_dict = {_order.stock_id: _order for _order in self.order_list} - self.disable_dict = dict() - else: - self.enable_dict = dict() - self.disable_dict = {_order.stock_id: _order for _order in self.order_list} - - def enable(self, enable_set: Union[List[str], Set[str]] = None, all_enable=False): - """enable order set - Parameters - ---------- - enable_set : Union[List[str], Set[str]], optional - the order set that will be enabled, by default None - - if all_enable is True, enable_set will be ignored - - else, enable the order whose stock_id in enable_set - all_enable : bool, optional - wether to enable all order, by default False - """ - if all_enable is True: - self.enable_dict.update(self.disable_dict) - self.disable_dict.clear() - if enable_set is not None: - warnings.warn(f"`enable_set` is ignored because `all_enable` is set True") - else: - enable_set = set(enable_set) - for _stock_id in enable_set: - enable_order = self.disable_dict.get(_stock_id) - if enable_order is None: - raise ValueError(f"_stock_id {_stock_id} is not found in disable set") - self.enable_order.update({_stock_id: enable_order}) - self.disable_dict.pop(_stock_id) - - def disable(self, disable_set: Union[List[str], Set[str]] = None, all_disable=False): - """disable order set - Parameters - ---------- - disable_set : Union[List[str], Set[str]], optional - the order set that will be disabled, by default None - - if all_disable is True, disable_set will be ignored - - else, disable the order whose stock_id in disable_set - all_disable : bool, optional - wether to disable all order, by default False - """ - if all_disable is True: - self.disable_dict.update(self.enable_dict) - self.enable_dict.clear() - if disable_set is not None: - warnings.warn(f"`disable_set` is ignored because `all_disable` is set True") - else: - disable_set = set(disable_set) - for _stock_id in disable_set: - disable_order = self.enable_dict.get(_stock_id) - if disable_order is None: - raise ValueError(f"_stock_id {_stock_id} is not found in enable set") - self.disable_dict.update({_stock_id: disable_order}) - self.enable_dict.pop(_stock_id) - - def generator(self, only_enable=False, only_disable=False): - """get order generator used for iteration - Parameters - ---------- - only_enable : bool, optional - wether to ignore disabled order, by default False - only_disable : bool, optional - wether to ignore enabled order, by default False - """ - if not only_disable and not only_enable: - yield from self.order_list - elif not only_disable: - yield from self.enable_dict.values() - elif not only_enable: - yield from self.disable_dict.values() - - def get_order_list(self, only_enable=False, only_disable=False): - """get the order list - - Parameters - ---------- - only_enable : bool, optional - wether to ignore disabled order, by default False - only_disable : bool, optional - wether to ignore enabled order, by default False - Returns - ------- - List[Order] - the order list - """ - if not only_disable and not only_enable: - return self.order_list - elif not only_disable: - return list(self.enable_dict.values()) - elif not only_enable: - return list(self.disable_dict.values()) - - def update(self, trade_calendar: TradeCalendarManager): - """ - make the original strategy update the enabled status of orders. - - Parameters - ---------- - trade_calendar : TradeCalendarManager - the trade calendar for sub strategy - """ - self.ori_strategy.update_trade_decision(self, trade_calendar) diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index a50be144a..f7728f911 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -11,7 +11,7 @@ import warnings from ..log import get_module_logger from ..backtest import get_exchange, backtest as backtest_func from ..utils import get_date_range -from ..utils.resam import parse_freq, NORM_FREQ_MONTH, NORM_FREQ_WEEK, NORM_FREQ_DAY, NORM_FREQ_MINUTE +from ..utils.resam import Freq from ..data import D from ..config import C @@ -35,12 +35,12 @@ def risk_analysis(r, N: int = None, freq: str = "day"): """ def cal_risk_analysis_scaler(freq): - _count, _freq = parse_freq(freq) + _count, _freq = Freq.parse(freq) _freq_scaler = { - NORM_FREQ_MINUTE: 240 * 252, - NORM_FREQ_DAY: 252, - NORM_FREQ_WEEK: 50, - NORM_FREQ_MONTH: 12, + Freq.NORM_FREQ_MINUTE: 240 * 252, + Freq.NORM_FREQ_DAY: 252, + Freq.NORM_FREQ_WEEK: 50, + Freq.NORM_FREQ_MONTH: 12, } return _freq_scaler[_freq] / _count diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 71f9ee509..14e6f0810 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -6,8 +6,7 @@ import pandas as pd from ...utils.resam import resam_ts_data from ...strategy.base import ModelStrategy -from ...backtest.order import Order -from ...backtest.utils import TradeDecison +from ...backtest.order import Order, BaseTradeDecision from .order_generator import OrderGenWInteract @@ -247,7 +246,7 @@ class TopkDropoutStrategy(ModelStrategy): factor=factor, ) buy_order_list.append(buy_order) - return TradeDecison(order_list=sell_order_list + buy_order_list, ori_strategy=self) + return TradeDecision(order_list=sell_order_list + buy_order_list, ori_strategy=self) class WeightStrategyBase(ModelStrategy): @@ -344,4 +343,4 @@ class WeightStrategyBase(ModelStrategy): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return TradeDecison(order_list=order_list, ori_strategy=self) + return TradeDecision(order_list=order_list, ori_strategy=self) diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index 7e4ee1a07..f822609c8 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -6,7 +6,7 @@ This order generator is for strategies based on WeightStrategyBase """ from ...backtest.position import Position from ...backtest.exchange import Exchange -from ...backtest.utils import TradeDecison +from ...backtest.order import BaseTradeDecision import pandas as pd import copy @@ -127,7 +127,7 @@ class OrderGenWInteract(OrderGenerator): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return TradeDecison(order_list=order_list, ori_strategy=self) + return TradeDecision(order_list=order_list, ori_strategy=self) class OrderGenWOInteract(OrderGenerator): @@ -191,4 +191,4 @@ class OrderGenWOInteract(OrderGenerator): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return TradeDecison(order_list=order_list, ori_strategy=self) + return TradeDecision(order_list=order_list, ori_strategy=self) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index c0993f44e..0d44e02a5 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -7,9 +7,9 @@ from ...utils.resam import resam_ts_data from ...data.data import D from ...data.dataset.utils import convert_index_format from ...strategy.base import BaseStrategy -from ...backtest.order import Order +from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO from ...backtest.exchange import Exchange -from ...backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeDecison +from ...backtest.utils import CommonInfrastructure, LevelInfrastructure class TWAPStrategy(BaseStrategy): @@ -17,7 +17,7 @@ class TWAPStrategy(BaseStrategy): def __init__( self, - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, trade_exchange: Exchange = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, @@ -25,8 +25,8 @@ class TWAPStrategy(BaseStrategy): """ Parameters ---------- - outer_trade_decision : TradeDecison - the trade decison of outer strategy which this startegy relies + outer_trade_decision : BaseTradeDecision + the trade decision of outer strategy which this startegy relies trade_exchange : Exchange exchange that provides market info, used to deal order and generate report - If `trade_exchange` is None, self.trade_exchange will be set with common_infra @@ -57,25 +57,35 @@ class TWAPStrategy(BaseStrategy): if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision: TradeDecison = None, **kwargs): + def reset(self, outer_trade_decision: BaseTradeDecision = None, **kwargs): """ Parameters ---------- - outer_trade_decision : TradeDecison, optional + outer_trade_decision : BaseTradeDecision, optional """ super(TWAPStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: self.trade_amount = {} - outer_order_generator = outer_trade_decision.generator() - for order in outer_order_generator: + for order in outer_trade_decision.get_decision(): self.trade_amount[order.stock_id] = order.amount def generate_trade_decision(self, execute_result=None): + # strategy is not available. Give an empty decision + if len(self.outer_trade_decision.get_decision()) == 0: + return TradeDecisionWO(order_list=[], strategy=self) + # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step - trade_len = self.trade_calendar.get_trade_len() + start_idx, end_idx = self.outer_trade_decision.get_range_limit() + trade_len = end_idx - start_idx + 1 + + if trade_step < start_idx: + # It is not time to start trading + return TradeDecisionWO(order_list=[], strategy=self) + + rel_trade_step = trade_step - start_idx # trade_step relative to start_idx # update the order amount if execute_result is not None: @@ -84,8 +94,7 @@ class TWAPStrategy(BaseStrategy): trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) order_list = [] - outer_order_generator = self.outer_trade_decision.generator(only_enable=True) - for order in outer_order_generator: + for order in self.outer_trade_decision.get_decision(): # if not tradable, continue if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time @@ -96,21 +105,21 @@ class TWAPStrategy(BaseStrategy): # considering trade unit if _amount_trade_unit is None: # divide the order into equal parts, and trade one part - _order_amount = self.trade_amount[order.stock_id] / (trade_len - trade_step) + _order_amount = self.trade_amount[order.stock_id] / (trade_len - rel_trade_step) # without considering trade unit else: # divide the order into equal parts, and trade one part # calculate the total count of trade units to trade trade_unit_cnt = int(self.trade_amount[order.stock_id] // _amount_trade_unit) # calculate the amount of one part, ceil the amount - # floor((trade_unit_cnt + trade_len - trade_step) / (trade_len - trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - trade_step + 1)) + # floor((trade_unit_cnt + trade_len - rel_trade_step) / (trade_len - rel_trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - rel_trade_step + 1)) _order_amount = ( - (trade_unit_cnt + trade_len - trade_step - 1) // (trade_len - trade_step) * _amount_trade_unit + (trade_unit_cnt + trade_len - rel_trade_step - 1) // (trade_len - rel_trade_step) * _amount_trade_unit ) if order.direction == order.SELL: # sell all amount at last - if self.trade_amount[order.stock_id] > 1e-5 and (_order_amount < 1e-5 or trade_step == trade_len - 1): + if self.trade_amount[order.stock_id] > 1e-5 and (_order_amount < 1e-5 or rel_trade_step == trade_len - 1): _order_amount = self.trade_amount[order.stock_id] _order_amount = min(_order_amount, self.trade_amount[order.stock_id]) @@ -126,7 +135,7 @@ class TWAPStrategy(BaseStrategy): factor=order.factor, ) order_list.append(_order) - return TradeDecison(order_list=order_list, ori_strategy=self) + return TradeDecisionWO(order_list=order_list, strategy=self) class SBBStrategyBase(BaseStrategy): @@ -140,7 +149,7 @@ class SBBStrategyBase(BaseStrategy): def __init__( self, - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, trade_exchange: Exchange = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, @@ -148,8 +157,8 @@ class SBBStrategyBase(BaseStrategy): """ Parameters ---------- - outer_trade_decision : TradeDecison - the trade decison of outer strategy which this startegy relies + outer_trade_decision : BaseTradeDecision + the trade decision of outer strategy which this startegy relies trade_exchange : Exchange exchange that provides market info, used to deal order and generate report - If `trade_exchange` is None, self.trade_exchange will be set with common_infra @@ -178,11 +187,11 @@ class SBBStrategyBase(BaseStrategy): if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision: TradeDecison = None, **kwargs): + def reset(self, outer_trade_decision: BaseTradeDecision = None, **kwargs): """ Parameters ---------- - outer_trade_decision : TradeDecison, optional + outer_trade_decision : BaseTradeDecision, optional """ super(SBBStrategyBase, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: @@ -336,7 +345,7 @@ class SBBStrategyBase(BaseStrategy): # in the first one of two adjacent bars, store the trend for the second one to use self.trade_trend[order.stock_id] = _pred_trend - return TradeDecison(order_list=order_list, ori_strategy=self) + return TradeDecision(order_list=order_list, ori_strategy=self) class SBBStrategyEMA(SBBStrategyBase): @@ -346,7 +355,7 @@ class SBBStrategyEMA(SBBStrategyBase): def __init__( self, - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, instruments: Union[List, str] = "csi300", freq: str = "day", trade_exchange: Exchange = None, @@ -426,7 +435,7 @@ class ACStrategy(BaseStrategy): lamb: float = 1e-6, eta: float = 2.5e-6, window_size: int = 20, - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, instruments: Union[List, str] = "csi300", freq: str = "day", trade_exchange: Exchange = None, @@ -503,11 +512,11 @@ class ACStrategy(BaseStrategy): self.trade_calendar = level_infra.get("trade_calendar") self._reset_signal() - def reset(self, outer_trade_decision: TradeDecison = None, **kwargs): + def reset(self, outer_trade_decision: BaseTradeDecision = None, **kwargs): """ Parameters ---------- - outer_trade_decision : TradeDecison, optional + outer_trade_decision : BaseTradeDecision, optional """ super(ACStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: @@ -592,13 +601,13 @@ class ACStrategy(BaseStrategy): factor=order.factor, ) order_list.append(_order) - return TradeDecison(order_list=order_list, ori_strategy=self) + return TradeDecision(order_list=order_list, ori_strategy=self) class RandomOrderStrategy(BaseStrategy): def __init__(self, - time_range: Tuple = ("9:30", "15:00"), # The range is closed on both left and right. + index_range: Tuple[int, int], # The range is closed on both left and right. sample_ratio: float = 1., volume_ratio: float = 0.01, market: str = "all", @@ -607,10 +616,10 @@ class RandomOrderStrategy(BaseStrategy): """ Parameters ---------- - time_range : Tuple - the intra day time range of the orders + index_range : Tuple + the intra day time index range of the orders the left and right is closed. - # TODO: this is a time_range level limitation. We'll implement a more detailed limitation later. + # TODO: this is a index_range level limitation. We'll implement a more detailed limitation later. sample_ratio : float the ratio of all orders are sampled volume_ratio : float @@ -621,12 +630,27 @@ class RandomOrderStrategy(BaseStrategy): """ super().__init__(*args, **kwargs) - self.time_range = time_range + self.index_range = index_range self.sample_ratio = sample_ratio self.volume_ratio = volume_ratio self.market = market - exch: Exchange = self.common_infra.get("exchange") - self.volume = D.features(D.instruments("market"), ["Mean($volume, 10)"], start_time=exch.start_time, end_time=exch.end_time) + exch: Exchange = self.common_infra.get("trade_exchange") + self.volume = D.features(D.instruments(market), ["Mean(Ref($volume, 1), 10)"], start_time=exch.start_time, end_time=exch.end_time) + self.volume_df = self.volume.iloc[:, 0].unstack() def generate_trade_decision(self, execute_result=None): - return super().generate_trade_decision(execute_result=execute_result) + trade_step = self.trade_calendar.get_trade_step() + step_time_start, step_time_end = self.trade_calendar.get_step_time(trade_step) + + order_list = [] + for direction in Order.SELL, Order.BUY: + for stock_id, volume in self.volume_df[step_time_start].dropna().sample(frac=self.sample_ratio).items(): + order_list.append( + self.common_infra.get("trade_exchange").create_order( + code=stock_id, + amount=volume * self.volume_ratio, + start_time=step_time_start, + end_time=step_time_end, + direction=direction, # 1 for buy + )) + return TradeDecisionWO(order_list, self) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index f060ccdb7..b20b0db66 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -7,7 +7,8 @@ from ..data.dataset import DatasetH from ..data.dataset.utils import convert_index_format from ..rl.interpreter import ActionInterpreter, StateInterpreter from ..utils import init_instance_by_config -from ..backtest.utils import BaseTradeDecision, CommonInfrastructure, LevelInfrastructure, TradeCalendarManager, TradeDecison +from ..backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager +from ..backtest.order import BaseTradeDecision class BaseStrategy: @@ -15,16 +16,16 @@ class BaseStrategy: def __init__( self, - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, ): """ Parameters ---------- - outer_trade_decision : TradeDecison, optional - the trade decison of outer strategy which this startegy relies, and it will be traded in [start_time, end_time], by default None - - If the strategy is used to split trade decison, it will be used + outer_trade_decision : BaseTradeDecision, optional + the trade decision of outer strategy which this startegy relies, and it will be traded in [start_time, end_time], by default None + - If the strategy is used to split trade decision, it will be used - If the strategy is used for portfolio management, it can be ignored level_infra : LevelInfrastructure, optional level shared infrastructure for backtesting, including trade calendar @@ -34,14 +35,14 @@ class BaseStrategy: self.reset(level_infra=level_infra, common_infra=common_infra, outer_trade_decision=outer_trade_decision) - def reset_level_infra(self, level_infra): + def reset_level_infra(self, level_infra: LevelInfrastructure): if not hasattr(self, "level_infra"): self.level_infra = level_infra else: self.level_infra.update(level_infra) if level_infra.has("trade_calendar"): - self.trade_calendar = level_infra.get("trade_calendar") + self.trade_calendar: TradeCalendarManager = level_infra.get("trade_calendar") def reset_common_infra(self, common_infra: CommonInfrastructure): if not hasattr(self, "common_infra"): @@ -62,7 +63,7 @@ class BaseStrategy: """ - reset `level_infra`, used to reset trade calendar, .etc - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc - - reset `outer_trade_decision`, used to make split decison + - reset `outer_trade_decision`, used to make split decision """ if level_infra is not None: self.reset_level_infra(level_infra) @@ -79,19 +80,19 @@ class BaseStrategy: Parameters ---------- execute_result : List[object], optional - the executed result for trade decison, by default None + the executed result for trade decision, by default None - When call the generate_trade_decision firstly, `execute_result` could be None """ raise NotImplementedError("generate_trade_decision is not implemented!") - def update_trade_decision(self, trade_decison: BaseTradeDecision, trade_calendar: TradeCalendarManager) -> Union[BaseTradeDecision, None]: + def update_trade_decision(self, trade_decision: BaseTradeDecision, trade_calendar: TradeCalendarManager) -> Union[BaseTradeDecision, None]: """ update trade decision in each step of inner execution, this method enable all order Parameters ---------- - trade_decison : TradeDecison - the trade decison that will be updated + trade_decision : BaseTradeDecision + the trade decision that will be updated trade_calendar : TradeCalendarManager The calendar of the **inner strategy**!!!!! @@ -125,7 +126,7 @@ class ModelStrategy(BaseStrategy): self, model: BaseModel, dataset: DatasetH, - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, **kwargs, @@ -161,7 +162,7 @@ class RLStrategy(BaseStrategy): def __init__( self, policy, - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, **kwargs, @@ -184,7 +185,7 @@ class RLIntStrategy(RLStrategy): policy, state_interpreter: Union[dict, StateInterpreter], action_interpreter: Union[dict, ActionInterpreter], - outer_trade_decision: TradeDecison = None, + outer_trade_decision: BaseTradeDecision = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, **kwargs, diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index d28076d88..ae0cdf9d1 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -7,58 +7,7 @@ from typing import Tuple, List, Union, Optional, Callable from . import lazy_sort_index from ..config import C - -NORM_FREQ_MONTH = "month" -NORM_FREQ_WEEK = "week" -NORM_FREQ_DAY = "day" -NORM_FREQ_MINUTE = "minute" - - -def parse_freq(freq: str) -> Tuple[int, str]: - """ - Parse freq into a unified format - - Parameters - ---------- - freq : str - Raw freq, supported freq should match the re '^([0-9]*)(month|mon|week|w|day|d|minute|min)$' - - Returns - ------- - freq: Tuple[int, str] - Unified freq, including freq count and unified freq unit. The freq unit should be '[month|week|day|minute]'. - Example: - - .. code-block:: - - print(parse_freq("day")) - (1, "day" ) - print(parse_freq("2mon")) - (2, "month") - print(parse_freq("10w")) - (10, "week") - - """ - freq = freq.lower() - match_obj = re.match("^([0-9]*)(month|mon|week|w|day|d|minute|min)$", freq) - if match_obj is None: - raise ValueError( - "freq format is not supported, the freq should be like (n)month/mon, (n)week/w, (n)day/d, (n)minute/min" - ) - _count = int(match_obj.group(1)) if match_obj.group(1) else 1 - _freq = match_obj.group(2) - _freq_format_dict = { - "month": NORM_FREQ_MONTH, - "mon": NORM_FREQ_MONTH, - "week": NORM_FREQ_WEEK, - "w": NORM_FREQ_WEEK, - "day": NORM_FREQ_DAY, - "d": NORM_FREQ_DAY, - "minute": NORM_FREQ_MINUTE, - "min": NORM_FREQ_MINUTE, - } - return _count, _freq_format_dict[_freq] - +from .time import Freq def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np.ndarray: """ @@ -80,13 +29,13 @@ def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np np.ndarray The calendar with frequency freq_sam """ - raw_count, freq_raw = parse_freq(freq_raw) - sam_count, freq_sam = parse_freq(freq_sam) + raw_count, freq_raw = Freq.parse(freq_raw) + sam_count, freq_sam = Freq.parse(freq_sam) if not len(calendar_raw): return calendar_raw # if freq_sam is xminute, divide each trading day into several bars evenly - if freq_sam == NORM_FREQ_MINUTE: + if freq_sam == Freq.NORM_FREQ_MINUTE: def cal_sam_minute(x, sam_minutes): """ @@ -119,7 +68,7 @@ def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np else: raise ValueError("calendar minute_index error, check `min_data_shift` in qlib.config.C") - if freq_raw != NORM_FREQ_MINUTE: + if freq_raw != Freq.NORM_FREQ_MINUTE: raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") else: if raw_count > sam_count: @@ -130,15 +79,15 @@ def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np # else, convert the raw calendar into day calendar, and divide the whole calendar into several bars evenly else: _calendar_day = np.unique(list(map(lambda x: pd.Timestamp(x.year, x.month, x.day, 0, 0, 0), calendar_raw))) - if freq_sam == NORM_FREQ_DAY: + if freq_sam == Freq.NORM_FREQ_DAY: return _calendar_day[::sam_count] - elif freq_sam == NORM_FREQ_WEEK: + elif freq_sam == Freq.NORM_FREQ_WEEK: _day_in_week = np.array(list(map(lambda x: x.dayofweek, _calendar_day))) _calendar_week = _calendar_day[np.ediff1d(_day_in_week, to_begin=-1) < 0] return _calendar_week[::sam_count] - elif freq_sam == NORM_FREQ_MONTH: + elif freq_sam == Freq.NORM_FREQ_MONTH: _day_in_month = np.array(list(map(lambda x: x.day, _calendar_day))) _calendar_month = _calendar_day[np.ediff1d(_day_in_month, to_begin=-1) < 0] return _calendar_month[::sam_count] @@ -180,7 +129,7 @@ def get_resam_calendar( """ - _, norm_freq = parse_freq(freq) + _, norm_freq = Freq.parse(freq) from ..data.data import Cal @@ -189,7 +138,7 @@ def get_resam_calendar( freq, freq_sam = freq, None except (ValueError, KeyError): freq_sam = freq - if norm_freq in [NORM_FREQ_MONTH, NORM_FREQ_WEEK, NORM_FREQ_DAY]: + if norm_freq in [Freq.NORM_FREQ_MONTH, Freq.NORM_FREQ_WEEK, Freq.NORM_FREQ_DAY]: try: _calendar = Cal.calendar( start_time=start_time, end_time=end_time, freq="day", freq_sam=freq, future=future @@ -200,7 +149,7 @@ def get_resam_calendar( start_time=start_time, end_time=end_time, freq="1min", freq_sam=freq, future=future ) freq = "1min" - elif norm_freq == NORM_FREQ_MINUTE: + elif norm_freq == Freq.NORM_FREQ_MINUTE: _calendar = Cal.calendar( start_time=start_time, end_time=end_time, freq="1min", freq_sam=freq, future=future ) @@ -224,15 +173,15 @@ def get_higher_eq_freq_feature(instruments, fields, start_time=None, end_time=No _result = D.features(instruments, fields, start_time, end_time, freq=freq, disk_cache=disk_cache) _freq = freq except (ValueError, KeyError): - _, norm_freq = parse_freq(freq) - if norm_freq in [NORM_FREQ_MONTH, NORM_FREQ_WEEK, NORM_FREQ_DAY]: + _, norm_freq = Freq.parse(freq) + if norm_freq in [Freq.NORM_FREQ_MONTH, Freq.NORM_FREQ_WEEK, Freq.NORM_FREQ_DAY]: try: _result = D.features(instruments, fields, start_time, end_time, freq="day", disk_cache=disk_cache) _freq = "day" except (ValueError, KeyError): _result = D.features(instruments, fields, start_time, end_time, freq="1min", disk_cache=disk_cache) _freq = "1min" - elif norm_freq == NORM_FREQ_MINUTE: + elif norm_freq == Freq.NORM_FREQ_MINUTE: _result = D.features(instruments, fields, start_time, end_time, freq="1min", disk_cache=disk_cache) _freq = "1min" else: diff --git a/qlib/utils/time.py b/qlib/utils/time.py new file mode 100644 index 000000000..6e3bd71a3 --- /dev/null +++ b/qlib/utils/time.py @@ -0,0 +1,115 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +""" +Time related utils are compiled in this script +""" +import bisect +from datetime import time +from typing import List, Tuple +import re +from numpy import append +import pandas as pd + + +def get_min_cal() -> List[time]: + """ + get the minute level calendar in day period + + Returns + ------- + List[time]: + + """ + cal = [] + for ts in list(pd.date_range("9:30", "11:29", freq="1min")) + list(pd.date_range("13:00", "14:59", freq="1min")): + cal.append(ts.time()) + return cal + + +class Freq: + NORM_FREQ_MONTH = "month" + NORM_FREQ_WEEK = "week" + NORM_FREQ_DAY = "day" + NORM_FREQ_MINUTE = "minute" + SUPPORT_CAL_LIST = [NORM_FREQ_MINUTE] + + MIN_CAL = get_min_cal() + + def __init__(self, freq: str) -> None: + self.count, self.base = self.parse(freq) + + @staticmethod + def parse(freq: str) -> Tuple[int, str]: + """ + Parse freq into a unified format + + Parameters + ---------- + freq : str + Raw freq, supported freq should match the re '^([0-9]*)(month|mon|week|w|day|d|minute|min)$' + + Returns + ------- + freq: Tuple[int, str] + Unified freq, including freq count and unified freq unit. The freq unit should be '[month|week|day|minute]'. + Example: + + .. code-block:: + + print(Freq.parse("day")) + (1, "day" ) + print(Freq.parse("2mon")) + (2, "month") + print(Freq.parse("10w")) + (10, "week") + + """ + freq = freq.lower() + match_obj = re.match("^([0-9]*)(month|mon|week|w|day|d|minute|min)$", freq) + if match_obj is None: + raise ValueError( + "freq format is not supported, the freq should be like (n)month/mon, (n)week/w, (n)day/d, (n)minute/min" + ) + _count = int(match_obj.group(1)) if match_obj.group(1) else 1 + _freq = match_obj.group(2) + _freq_format_dict = { + "month": Freq.NORM_FREQ_MONTH, + "mon": Freq.NORM_FREQ_MONTH, + "week": Freq.NORM_FREQ_WEEK, + "w": Freq.NORM_FREQ_WEEK, + "day": Freq.NORM_FREQ_DAY, + "d": Freq.NORM_FREQ_DAY, + "minute": Freq.NORM_FREQ_MINUTE, + "min": Freq.NORM_FREQ_MINUTE, + } + return _count, _freq_format_dict[_freq] + + +def get_day_min_idx_range(start: str, end: str, freq: str) -> Tuple[int, int]: + """ + get the min-bar index in a day for a time range (both left and right is closed) given a fixed frequency + Parameters + ---------- + start : str + e.g. "9:30" + end : str + e.g. "14:30" + freq : str + "1min" + + Returns + ------- + Tuple[int, int]: + The index of start and end in the calendar. Both left and right are **closed** + """ + start = pd.Timestamp(start).time() + end = pd.Timestamp(end).time() + freq = Freq(freq) + in_day_cal = Freq.MIN_CAL[::freq.count] + left_idx = bisect.bisect_left(in_day_cal, start) + right_idx = bisect.bisect_right(in_day_cal, end) - 1 + return left_idx, right_idx + + +if __name__ == "__main__": + print(get_day_min_idx_range("8:30", "14:59", "10min")) diff --git a/qlib/workflow/record_temp.py b/qlib/workflow/record_temp.py index 0f6950587..549658071 100644 --- a/qlib/workflow/record_temp.py +++ b/qlib/workflow/record_temp.py @@ -16,7 +16,7 @@ from ..backtest import backtest as normal_backtest from ..utils import init_instance_by_config, get_module_by_module_path from ..log import get_module_logger from ..utils import flatten_dict -from ..utils.resam import parse_freq +from ..utils.time import Freq from ..strategy.base import BaseStrategy from ..contrib.eva.alpha import calc_ic, calc_long_short_return, calc_long_short_prec @@ -344,17 +344,17 @@ class PortAnaRecord(RecordTemp): indicator_analysis_freq = [indicator_analysis_freq] self.risk_analysis_freq = [ - "{0}{1}".format(*parse_freq(_analysis_freq)) for _analysis_freq in risk_analysis_freq + "{0}{1}".format(*Freq.parse(_analysis_freq)) for _analysis_freq in risk_analysis_freq ] self.indicator_analysis_freq = [ - "{0}{1}".format(*parse_freq(_analysis_freq)) for _analysis_freq in indicator_analysis_freq + "{0}{1}".format(*Freq.parse(_analysis_freq)) for _analysis_freq in indicator_analysis_freq ] self.indicator_analysis_method = indicator_analysis_method def _get_report_freq(self, executor_config): ret_freq = [] if executor_config["kwargs"].get("generate_report", False): - _count, _freq = parse_freq(executor_config["kwargs"]["time_per_step"]) + _count, _freq = Freq.parse(executor_config["kwargs"]["time_per_step"]) ret_freq.append(f"{_count}{_freq}") if "sub_env" in executor_config["kwargs"]: ret_freq.extend(self._get_report_freq(executor_config["kwargs"]["sub_env"])) From 9b91758aedb469487908244a1dfecbfedeeefad4 Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 27 Jun 2021 09:24:55 +0000 Subject: [PATCH 062/187] performance optimization for cal_sam_minute --- qlib/data/data.py | 18 ++++++-- qlib/log.py | 2 +- qlib/utils/resam.py | 34 +-------------- qlib/utils/time.py | 46 +++++++++++++++++++-- tests/misc/test_utils.py | 89 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 41 deletions(-) create mode 100644 tests/misc/test_utils.py diff --git a/qlib/data/data.py b/qlib/data/data.py index 978fe6186..116861e78 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -15,6 +15,7 @@ import bisect import logging import importlib import traceback +from typing import List, Union import numpy as np import pandas as pd from multiprocessing import Pool @@ -212,19 +213,22 @@ class InstrumentProvider(abc.ABC, ProviderBackendMixin): self.backend = kwargs.get("backend", {}) @staticmethod - def instruments(market="all", filter_pipe=None): + def instruments(market: Union[List, str]="all", filter_pipe: Union[List, None]=None): """Get the general config dictionary for a base market adding several dynamic filters. Parameters ---------- - market : str - market/industry/index shortname, e.g. all/sse/szse/sse50/csi300/csi500. + market : Union[List, str] + str: + market/industry/index shortname, e.g. all/sse/szse/sse50/csi300/csi500. + list: + ["ID1", "ID2"]. A list of stocks filter_pipe : list the list of dynamic filters. Returns ---------- - dict + dict: if insinstance(market, str) dict of stockpool config. {`market`=>base market name, `filter_pipe`=>list of filters} @@ -242,7 +246,13 @@ class InstrumentProvider(abc.ABC, ProviderBackendMixin): 'name_rule_re': 'SH[0-9]{4}55', 'filter_start_time': None, 'filter_end_time': None}]} + + list: if insinstance(market, list) + just return the original list directly. + NOTE: this will make the instruments compatible with more cases. The user code will be simpler. """ + if isinstance(market, list): + return market if filter_pipe is None: filter_pipe = [] config = {"market": market, "filter_pipe": []} diff --git a/qlib/log.py b/qlib/log.py index 379544392..f0b04bcaa 100644 --- a/qlib/log.py +++ b/qlib/log.py @@ -68,7 +68,7 @@ def get_module_logger(module_name, level: Optional[int] = None) -> logging.Logge class TimeInspector: - timer_logger = get_module_logger("timer", level=logging.WARNING) + timer_logger = get_module_logger("timer", level=logging.INFO) time_marks = [] diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index ae0cdf9d1..76d97e1bc 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -7,7 +7,7 @@ from typing import Tuple, List, Union, Optional, Callable from . import lazy_sort_index from ..config import C -from .time import Freq +from .time import Freq, cal_sam_minute def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np.ndarray: """ @@ -36,38 +36,6 @@ def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np # if freq_sam is xminute, divide each trading day into several bars evenly if freq_sam == Freq.NORM_FREQ_MINUTE: - - def cal_sam_minute(x, sam_minutes): - """ - Sample raw calendar into calendar with sam_minutes freq, shift represents the shift minute the market time - - open time of stock market is [9:30 - shift*pd.Timedelta(minutes=1)] - - mid close time of stock market is [11:29 - shift*pd.Timedelta(minutes=1)] - - mid open time of stock market is [13:00 - shift*pd.Timedelta(minutes=1)] - - close time of stock market is [14:59 - shift*pd.Timedelta(minutes=1)] - """ - day_time = pd.Timestamp(x.date()) - shift = C.min_data_shift - - open_time = day_time + pd.Timedelta(hours=9, minutes=30) - shift * pd.Timedelta(minutes=1) - mid_close_time = day_time + pd.Timedelta(hours=11, minutes=29) - shift * pd.Timedelta(minutes=1) - mid_open_time = day_time + pd.Timedelta(hours=13, minutes=00) - shift * pd.Timedelta(minutes=1) - close_time = day_time + pd.Timedelta(hours=14, minutes=59) - shift * pd.Timedelta(minutes=1) - - if open_time <= x <= mid_close_time: - minute_index = (x - open_time).seconds // 60 - elif mid_open_time <= x <= close_time: - minute_index = (x - mid_open_time).seconds // 60 + 120 - else: - raise ValueError("datetime of calendar is out of range") - minute_index = minute_index // sam_minutes * sam_minutes - - if 0 <= minute_index < 120: - return open_time + minute_index * pd.Timedelta(minutes=1) - elif 120 <= minute_index < 240: - return mid_open_time + (minute_index - 120) * pd.Timedelta(minutes=1) - else: - raise ValueError("calendar minute_index error, check `min_data_shift` in qlib.config.C") - if freq_raw != Freq.NORM_FREQ_MINUTE: raise ValueError("when sampling minute calendar, freq of raw calendar must be minute or min") else: diff --git a/qlib/utils/time.py b/qlib/utils/time.py index 6e3bd71a3..fb37fd0a4 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -4,24 +4,34 @@ Time related utils are compiled in this script """ import bisect -from datetime import time +from datetime import datetime, time from typing import List, Tuple import re from numpy import append import pandas as pd +from qlib.config import C +import functools -def get_min_cal() -> List[time]: +@functools.lru_cache(maxsize=240) +def get_min_cal(shift: int=0) -> List[time]: """ get the minute level calendar in day period + Parameters + ---------- + shift : int + the shift direction would be like pandas shift. + series.shift(1) will replace the value at `i`-th with the one at `i-1`-th + Returns ------- List[time]: """ cal = [] - for ts in list(pd.date_range("9:30", "11:29", freq="1min")) + list(pd.date_range("13:00", "14:59", freq="1min")): + for ts in list(pd.date_range("9:30", "11:29", freq="1min") - pd.Timedelta(minutes=shift)) +\ + list(pd.date_range("13:00", "14:59", freq="1min") - pd.Timedelta(minutes=shift)): cal.append(ts.time()) return cal @@ -111,5 +121,35 @@ def get_day_min_idx_range(start: str, end: str, freq: str) -> Tuple[int, int]: return left_idx, right_idx +def cal_sam_minute(x: pd.Timestamp, sam_minutes: int) -> pd.Timestamp: + """ + align the minute-level data to a down sampled calendar + + e.g. align 10:38 to 10:35 in 5 minute-level(10:30 in 10 minute-level) + + Parameters + ---------- + x : pd.Timestamp + datetime to be aligned + sam_minutes : int + align to `sam_minutes` minute-level calendar + + Returns + ------- + pd.Timestamp: + the datetime after aligned + """ + cal = get_min_cal(C.min_data_shift)[::sam_minutes] + idx = bisect.bisect_right(cal, x.time()) - 1 + date, new_time = x.date(), cal[idx] + return pd.Timestamp( + datetime(date.year, + month=date.month, + day=date.day, + hour=new_time.hour, + minute=new_time.minute, + second=new_time.second, + microsecond=new_time.microsecond)) + if __name__ == "__main__": print(get_day_min_idx_range("8:30", "14:59", "10min")) diff --git a/tests/misc/test_utils.py b/tests/misc/test_utils.py new file mode 100644 index 000000000..4dabf5ed8 --- /dev/null +++ b/tests/misc/test_utils.py @@ -0,0 +1,89 @@ +from unittest.case import TestCase +import unittest +import pandas as pd +import numpy as np +from datetime import datetime +from qlib import init +from qlib.config import C +from qlib.log import TimeInspector +from qlib.utils.time import cal_sam_minute as cal_sam_minute_new, get_min_cal + + +def cal_sam_minute(x, sam_minutes): + """ + Sample raw calendar into calendar with sam_minutes freq, shift represents the shift minute the market time + - open time of stock market is [9:30 - shift*pd.Timedelta(minutes=1)] + - mid close time of stock market is [11:29 - shift*pd.Timedelta(minutes=1)] + - mid open time of stock market is [13:00 - shift*pd.Timedelta(minutes=1)] + - close time of stock market is [14:59 - shift*pd.Timedelta(minutes=1)] + """ + # TODO: actually, this version is much faster when no cache or optimization + day_time = pd.Timestamp(x.date()) + shift = C.min_data_shift + + open_time = day_time + pd.Timedelta(hours=9, minutes=30) - shift * pd.Timedelta(minutes=1) + mid_close_time = day_time + pd.Timedelta(hours=11, minutes=29) - shift * pd.Timedelta(minutes=1) + mid_open_time = day_time + pd.Timedelta(hours=13, minutes=00) - shift * pd.Timedelta(minutes=1) + close_time = day_time + pd.Timedelta(hours=14, minutes=59) - shift * pd.Timedelta(minutes=1) + + if open_time <= x <= mid_close_time: + minute_index = (x - open_time).seconds // 60 + elif mid_open_time <= x <= close_time: + minute_index = (x - mid_open_time).seconds // 60 + 120 + else: + raise ValueError("datetime of calendar is out of range") + minute_index = minute_index // sam_minutes * sam_minutes + + if 0 <= minute_index < 120: + return open_time + minute_index * pd.Timedelta(minutes=1) + elif 120 <= minute_index < 240: + return mid_open_time + (minute_index - 120) * pd.Timedelta(minutes=1) + else: + raise ValueError("calendar minute_index error, check `min_data_shift` in qlib.config.C") + + +class TimeUtils(TestCase): + @classmethod + def setUpClass(cls): + init() + + def test_cal_sam_minute(self): + # test the correctness of the code + random_n = 1000 + cal = get_min_cal() + + def gen_args(): + for time in np.random.choice(cal, size=random_n, replace=True): + sam_minutes = np.random.choice([1, 2, 3, 4, 5, 6]) + dt = pd.Timestamp( + datetime( + 2021, + month=3, + day=3, + hour=time.hour, + minute=time.minute, + second=time.second, + microsecond=time.microsecond, + ) + ) + args = dt, sam_minutes + yield args + + for args in gen_args(): + assert cal_sam_minute(*args) == cal_sam_minute_new(*args) + + # test the performance of the code + + args_l = list(gen_args()) + + with TimeInspector.logt(): + for args in args_l: + cal_sam_minute(*args) + + with TimeInspector.logt(): + for args in args_l: + cal_sam_minute_new(*args) + + +if __name__ == "__main__": + unittest.main() From e78cdd4a08426db741b51d9e883a30233a9c256b Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 27 Jun 2021 10:13:25 +0000 Subject: [PATCH 063/187] return the detailed order indicator --- qlib/backtest/backtest.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 6ab17c5c5..f5cbfb047 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -61,10 +61,9 @@ def collect_data_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_ for _executor in all_executors if _executor.generate_report } - all_indicators = { - "{}{}".format( - *Freq.parse(_executor.time_per_step) - ): _executor.get_trade_indicator().generate_trade_indicators_dataframe() - for _executor in all_executors - } + all_indicators = {} + for _executor in all_executors: + key = "{}{}".format( *Freq.parse(_executor.time_per_step)) + all_indicators[key] = _executor.get_trade_indicator().generate_trade_indicators_dataframe() + all_indicators[key + "_obj"] = _executor.get_trade_indicator() return_value.update({"report": all_reports, "indicator": all_indicators}) From c907d8deb47d0a27e370e027156253540df02da4 Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 27 Jun 2021 12:27:15 +0000 Subject: [PATCH 064/187] fix bugs of random strategy --- qlib/backtest/backtest.py | 2 +- qlib/contrib/strategy/rule_strategy.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index f5cbfb047..82397abdb 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -51,7 +51,7 @@ def collect_data_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_ while not trade_executor.finished(): _trade_decision: BaseTradeDecision = trade_strategy.generate_trade_decision(_execute_result) _execute_result = yield from trade_executor.collect_data(_trade_decision) - bar.update(trade_executor.trade_calendar.get_trade_step()) + bar.update(1) if return_value is not None: all_executors = trade_executor.get_all_executors() diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 0d44e02a5..9c024276a 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -635,6 +635,7 @@ class RandomOrderStrategy(BaseStrategy): self.volume_ratio = volume_ratio self.market = market exch: Exchange = self.common_infra.get("trade_exchange") + # TODO: this can't be online self.volume = D.features(D.instruments(market), ["Mean(Ref($volume, 1), 10)"], start_time=exch.start_time, end_time=exch.end_time) self.volume_df = self.volume.iloc[:, 0].unstack() @@ -644,13 +645,14 @@ class RandomOrderStrategy(BaseStrategy): order_list = [] for direction in Order.SELL, Order.BUY: - for stock_id, volume in self.volume_df[step_time_start].dropna().sample(frac=self.sample_ratio).items(): - order_list.append( - self.common_infra.get("trade_exchange").create_order( - code=stock_id, - amount=volume * self.volume_ratio, - start_time=step_time_start, - end_time=step_time_end, - direction=direction, # 1 for buy - )) + if step_time_start in self.volume_df: + for stock_id, volume in self.volume_df[step_time_start].dropna().sample(frac=self.sample_ratio).items(): + order_list.append( + self.common_infra.get("trade_exchange").create_order( + code=stock_id, + amount=volume * self.volume_ratio, + start_time=step_time_start, + end_time=step_time_end, + direction=direction, # 1 for buy + )) return TradeDecisionWO(order_list, self) From 72c9593aa7c2abd871b9f6eee22d44371bc294ea Mon Sep 17 00:00:00 2001 From: Young Date: Mon, 28 Jun 2021 07:30:34 +0000 Subject: [PATCH 065/187] adapting strategies to latest interfaces. --- qlib/backtest/backtest.py | 5 +++ qlib/backtest/executor.py | 8 +++-- qlib/backtest/order.py | 9 ++++-- qlib/backtest/report.py | 2 -- qlib/backtest/utils.py | 3 ++ qlib/contrib/strategy/model_strategy.py | 12 +++++-- qlib/contrib/strategy/order_generator.py | 6 ++-- qlib/contrib/strategy/rule_strategy.py | 41 +++++++++++++++++++++--- qlib/strategy/base.py | 3 +- 9 files changed, 70 insertions(+), 19 deletions(-) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 82397abdb..81395dc73 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -10,6 +10,8 @@ from tqdm.auto import tqdm def backtest_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_executor: BaseExecutor): """backtest funciton for the interaction of the outermost strategy and executor in the nested decision execution + please refer to the docs of `collect_data_loop` + Returns ------- report: Report @@ -28,8 +30,11 @@ def collect_data_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_ ---------- start_time : pd.Timestamp|str closed start time for backtest + **NOTE**: This will be applied to the outmost executor's calendar. end_time : pd.Timestamp|str closed end time for backtest + **NOTE**: This will be applied to the outmost executor's calendar. + E.g. Executor[day](Executor[1min]), setting `end_time == 20XX0301` will include all the minutes on 20XX0301 trade_strategy : BaseStrategy the outermost portfolio strategy trade_executor : BaseExecutor diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index b6d16d58f..3f7b2f4ed 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -3,6 +3,8 @@ import warnings import pandas as pd from typing import Union +from qlib.backtest.report import Indicator + from .order import Order, BaseTradeDecision from .exchange import Exchange from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure @@ -174,7 +176,7 @@ class BaseExecutor: else: raise ValueError("generate_report should be True if you want to generate report") - def get_trade_indicator(self): + def get_trade_indicator(self) -> Indicator: """get the trade indicator instance, which has pa/pos/ffr info.""" return self.trade_account.indicator @@ -279,7 +281,7 @@ class NestedExecutor(BaseExecutor): trade_decision = updated_trade_decision # NEW UPDATE # create a hook for inner strategy to update outter decision - self.inner_strategy.alter_decision(trade_decision) + self.inner_strategy.alter_outer_trade_decision(trade_decision) _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) @@ -287,7 +289,7 @@ class NestedExecutor(BaseExecutor): _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) execute_result.extend(_inner_execute_result) - inner_order_indicators.append(self.inner_executor.get_trade_indicator().get_order_indicator) + inner_order_indicators.append(self.inner_executor.get_trade_indicator().get_order_indicator()) if hasattr(self, "trade_account"): trade_step = self.trade_calendar.get_trade_step() diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index d1b5f6d08..6324a9be9 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -56,7 +56,7 @@ class BaseTradeDecision: 2. After a period of time, the decision are updated and become available 3. The inner strategy try to get the decision and start to execute the decision according to `get_range_limit` Case 2: - 1. The strategy is available at the start of the interval + 1. The outer strategy's decision is available at the start of the interval 2. Same as `case 1.3` """ def __init__(self, strategy: BaseStrategy): @@ -133,14 +133,19 @@ class TradeDecisionWO(BaseTradeDecision): def get_range_limit(self) -> Tuple[int, int]: if self.idx_range is None: # Default to get full index - return 0, self.strategy.trade_calendar.get_trade_len() - 1 + raise NotImplementedError(f"The decision didn't provide an index range") return self.idx_range def get_decision(self) -> List[object]: return self.order_list + def __repr__(self) -> str: + return f"strategy: {self.strategy}; idx_range: {self.idx_range}; order_list[{len(self.order_list)}]" + # TODO: the orders below need to be discussed ------------------------------------ +# - The classes below are designed for Case 1 +# - However, Case 1 can't take `order_pool` as the an argument as the constructor function class TradeDecisionWithOrderPool: """trade decision that made by strategy""" diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 70ebd724e..3f2649839 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -395,11 +395,9 @@ class Indicator: ) ) - @property def get_order_indicator(self): return self.order_indicator - @property def get_trade_indicator(self): return self.trade_indicator diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index d2441dd3a..720eb627e 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -103,6 +103,9 @@ class TradeCalendarManager: """Get the start_time and end_time for trading""" return self.start_time, self.end_time + def __repr__(self) -> str: + return f"{self.start_time}[{self.start_index}]~{self.end_time}[{self.end_index}]: [{self.trade_step}/{self.trade_len}]" + class BaseInfrastructure: def __init__(self, **kwargs): diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 14e6f0810..2e72cb32c 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -6,12 +6,15 @@ import pandas as pd from ...utils.resam import resam_ts_data from ...strategy.base import ModelStrategy -from ...backtest.order import Order, BaseTradeDecision +from ...backtest.order import Order, BaseTradeDecision, TradeDecisionWO from .order_generator import OrderGenWInteract class TopkDropoutStrategy(ModelStrategy): + # TODO: + # 1. Supporting leverage the get_range_limit result from the decision + # 2. Supporting alter_outer_trade_decision def __init__( self, model, @@ -246,10 +249,13 @@ class TopkDropoutStrategy(ModelStrategy): factor=factor, ) buy_order_list.append(buy_order) - return TradeDecision(order_list=sell_order_list + buy_order_list, ori_strategy=self) + return TradeDecisionWO(sell_order_list + buy_order_list, self) class WeightStrategyBase(ModelStrategy): + # TODO: + # 1. Supporting leverage the get_range_limit result from the decision + # 2. Supporting alter_outer_trade_decision def __init__( self, model, @@ -343,4 +349,4 @@ class WeightStrategyBase(ModelStrategy): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return TradeDecision(order_list=order_list, ori_strategy=self) + return TradeDecisionWO(order_list, self) diff --git a/qlib/contrib/strategy/order_generator.py b/qlib/contrib/strategy/order_generator.py index f822609c8..c1be982cc 100644 --- a/qlib/contrib/strategy/order_generator.py +++ b/qlib/contrib/strategy/order_generator.py @@ -6,7 +6,7 @@ This order generator is for strategies based on WeightStrategyBase """ from ...backtest.position import Position from ...backtest.exchange import Exchange -from ...backtest.order import BaseTradeDecision +from ...backtest.order import BaseTradeDecision, TradeDecisionWO import pandas as pd import copy @@ -127,7 +127,7 @@ class OrderGenWInteract(OrderGenerator): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return TradeDecision(order_list=order_list, ori_strategy=self) + return TradeDecisionWO(order_list, self) class OrderGenWOInteract(OrderGenerator): @@ -191,4 +191,4 @@ class OrderGenWOInteract(OrderGenerator): trade_start_time=trade_start_time, trade_end_time=trade_end_time, ) - return TradeDecision(order_list=order_list, ori_strategy=self) + return TradeDecisionWO(order_list, self) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 9c024276a..b8a900b85 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -12,6 +12,29 @@ from ...backtest.exchange import Exchange from ...backtest.utils import CommonInfrastructure, LevelInfrastructure +def get_start_end_idx(strategy: BaseStrategy, outer_trade_decision: BaseTradeDecision) -> Union[int, int]: + """ + A helper function for getting the decision-level index range limitation for inner strategy + - NOTE: this function is not applicable to order-level + + Parameters + ---------- + strategy : BaseStrategy + the inner strawtegy + outer_trade_decision : BaseTradeDecision + the trade decision made by outer strategy + + Returns + ------- + Union[int, int]: + start index and end index + """ + try: + return outer_trade_decision.get_range_limit() + except NotImplementedError: + return 0, strategy.trade_calendar.get_trade_len() - 1 + + class TWAPStrategy(BaseStrategy): """TWAP Strategy for trading""" @@ -78,7 +101,7 @@ class TWAPStrategy(BaseStrategy): # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step - start_idx, end_idx = self.outer_trade_decision.get_range_limit() + start_idx, end_idx = get_start_end_idx(self, self.outer_trade_decision) trade_len = end_idx - start_idx + 1 if trade_step < start_idx: @@ -147,6 +170,10 @@ class SBBStrategyBase(BaseStrategy): TREND_SHORT = 1 TREND_LONG = 2 + # TODO: + # 1. Supporting leverage the get_range_limit result from the decision + # 2. Supporting alter_outer_trade_decision + def __init__( self, outer_trade_decision: BaseTradeDecision = None, @@ -345,13 +372,16 @@ class SBBStrategyBase(BaseStrategy): # in the first one of two adjacent bars, store the trend for the second one to use self.trade_trend[order.stock_id] = _pred_trend - return TradeDecision(order_list=order_list, ori_strategy=self) + return TradeDecisionWO(order_list, self) class SBBStrategyEMA(SBBStrategyBase): """ (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA) signal. """ + # TODO: + # 1. Supporting leverage the get_range_limit result from the decision + # 2. Supporting alter_outer_trade_decision def __init__( self, @@ -430,6 +460,9 @@ class SBBStrategyEMA(SBBStrategyBase): class ACStrategy(BaseStrategy): + # TODO: + # 1. Supporting leverage the get_range_limit result from the decision + # 2. Supporting alter_outer_trade_decision def __init__( self, lamb: float = 1e-6, @@ -601,7 +634,7 @@ class ACStrategy(BaseStrategy): factor=order.factor, ) order_list.append(_order) - return TradeDecision(order_list=order_list, ori_strategy=self) + return TradeDecisionWO(order_list, self) class RandomOrderStrategy(BaseStrategy): @@ -655,4 +688,4 @@ class RandomOrderStrategy(BaseStrategy): end_time=step_time_end, direction=direction, # 1 for buy )) - return TradeDecisionWO(order_list, self) + return TradeDecisionWO(order_list, self, self.index_range) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index b20b0db66..734d25721 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -113,10 +113,9 @@ class BaseStrategy: outer_trade_decision : BaseTradeDecision the decision updated by the outer strategy """ - # default to reset the decision directly # NOTE: normally, user should do something to the strategy due to the change of outer decision - self.outer_trade_decision = outer_trade_decision + raise NotImplementedError(f"Please implement the `alter_outer_trade_decision` method") class ModelStrategy(BaseStrategy): From 27f0db669f97eb592cf78ee5940391f47e85b92d Mon Sep 17 00:00:00 2001 From: Young Date: Mon, 28 Jun 2021 08:16:51 +0000 Subject: [PATCH 066/187] black format & add comments & add randStrategy direction --- qlib/backtest/__init__.py | 69 ++++++++++++----------- qlib/backtest/account.py | 18 +++--- qlib/backtest/backtest.py | 6 +- qlib/backtest/order.py | 9 ++- qlib/backtest/position.py | 9 +-- qlib/backtest/report.py | 5 +- qlib/backtest/utils.py | 1 - qlib/contrib/strategy/model_strategy.py | 8 ++- qlib/contrib/strategy/rule_strategy.py | 73 ++++++++++++++----------- qlib/data/data.py | 2 +- qlib/strategy/base.py | 4 +- qlib/utils/resam.py | 1 + qlib/utils/time.py | 29 ++++++---- 13 files changed, 132 insertions(+), 102 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index a4c20f730..0de290f02 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -92,7 +92,9 @@ def get_exchange( return init_instance_by_config(exchange, accept_types=Exchange) -def create_account_instance(start_time, end_time, benchmark: str, account: float, pos_type: str="Position") -> Account: +def create_account_instance( + start_time, end_time, benchmark: str, account: float, pos_type: str = "Position" +) -> Account: """ # TODO: is very strange pass benchmark_config in the account(maybe for report) # There should be a post-step to process the report. @@ -119,26 +121,25 @@ def create_account_instance(start_time, end_time, benchmark: str, account: float "start_time": start_time, "end_time": end_time, }, - "pos_type": pos_type + "pos_type": pos_type, } return Account(**kwargs) -def get_strategy_executor(start_time, - end_time, - strategy: BaseStrategy, - executor: BaseExecutor, - benchmark: str = "SH000300", - account: Union[float, str] = 1e9, - exchange_kwargs: dict = {}, - pos_type: str = "Position", - ): +def get_strategy_executor( + start_time, + end_time, + strategy: BaseStrategy, + executor: BaseExecutor, + benchmark: str = "SH000300", + account: Union[float, str] = 1e9, + exchange_kwargs: dict = {}, + pos_type: str = "Position", +): - trade_account = create_account_instance(start_time=start_time, - end_time=end_time, - benchmark=benchmark, - account=account, - pos_type=pos_type) + trade_account = create_account_instance( + start_time=start_time, end_time=end_time, benchmark=benchmark, account=account, pos_type=pos_type + ) exchange_kwargs = copy.copy(exchange_kwargs) if "start_time" not in exchange_kwargs: @@ -154,14 +155,16 @@ def get_strategy_executor(start_time, return trade_strategy, trade_executor -def backtest(start_time, - end_time, - strategy, - executor, - benchmark="SH000300", - account=1e9, - exchange_kwargs={}, - pos_type: str = "Position"): +def backtest( + start_time, + end_time, + strategy, + executor, + benchmark="SH000300", + account=1e9, + exchange_kwargs={}, + pos_type: str = "Position", +): trade_strategy, trade_executor = get_strategy_executor( start_time, @@ -178,14 +181,16 @@ def backtest(start_time, return report_dict, indicator_dict -def collect_data(start_time, - end_time, - strategy, - executor, - benchmark="SH000300", - account=1e9, - exchange_kwargs={}, - pos_type: str = "Position"): +def collect_data( + start_time, + end_time, + strategy, + executor, + benchmark="SH000300", + account=1e9, + exchange_kwargs={}, + pos_type: str = "Position", +): trade_strategy, trade_executor = get_strategy_executor( start_time, diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 64a814dba..a6ef2f6b8 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -63,7 +63,9 @@ class AccumulatedInfo: class Account: - def __init__(self, init_cash: float=1e9, freq: str = "day", benchmark_config: dict = {}, pos_type:str = "Position"): + def __init__( + self, init_cash: float = 1e9, freq: str = "day", benchmark_config: dict = {}, pos_type: str = "Position" + ): self.pos_type = pos_type self.init_vars(init_cash, freq, benchmark_config) @@ -71,13 +73,13 @@ class Account: # init cash self.init_cash = init_cash - self.current: BasePosition = init_instance_by_config({ - 'class': self.pos_type, - 'kwargs': { - "cash": init_cash - }, - 'module_path': "qlib.backtest.position", - }) + self.current: BasePosition = init_instance_by_config( + { + "class": self.pos_type, + "kwargs": {"cash": init_cash}, + "module_path": "qlib.backtest.position", + } + ) self.accum_info = AccumulatedInfo() self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 81395dc73..0ac4581da 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -23,7 +23,9 @@ def backtest_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_exec return return_value.get("report"), return_value.get("indicator") -def collect_data_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_executor: BaseExecutor, return_value: dict = None): +def collect_data_loop( + start_time, end_time, trade_strategy: BaseStrategy, trade_executor: BaseExecutor, return_value: dict = None +): """Generator for collecting the trade decision data for rl training Parameters @@ -68,7 +70,7 @@ def collect_data_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_ } all_indicators = {} for _executor in all_executors: - key = "{}{}".format( *Freq.parse(_executor.time_per_step)) + key = "{}{}".format(*Freq.parse(_executor.time_per_step)) all_indicators[key] = _executor.get_trade_indicator().generate_trade_indicators_dataframe() all_indicators[key + "_obj"] = _executor.get_trade_indicator() return_value.update({"report": all_reports, "indicator": all_indicators}) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 6324a9be9..19ea807c1 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -2,8 +2,10 @@ # Licensed under the MIT License. # TODO: rename it with decision.py from __future__ import annotations + # try to fix circular imports when enabling type hints from typing import TYPE_CHECKING + if TYPE_CHECKING: from qlib.strategy.base import BaseStrategy from qlib.backtest.utils import TradeCalendarManager @@ -59,6 +61,7 @@ class BaseTradeDecision: 1. The outer strategy's decision is available at the start of the interval 2. Same as `case 1.3` """ + def __init__(self, strategy: BaseStrategy): """ Parameters @@ -125,7 +128,8 @@ class TradeDecisionWO(BaseTradeDecision): Trade Decision (W)ith (O)rder. Besides, the time_range is also included. """ - def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple=None): + + def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple = None): super().__init__(strategy) self.order_list = order_list self.idx_range = idx_range @@ -198,8 +202,7 @@ class TradeDecisionWithOrderPool: class BaseDecisionUpdater: def update_decision(self, decision, trade_calendar) -> BaseTradeDecision: - """[summary] - + """ Parameters ---------- decision : BaseTradeDecision diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 70272f688..0f36e4959 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -15,7 +15,8 @@ class BasePosition: The Position want to maintain the position like a dictionary Please refer to the `Position` class for the position """ - def __init__(self, cash=0., *args, **kwargs) -> None: + + def __init__(self, cash=0.0, *args, **kwargs) -> None: pass def skip_update(self) -> bool: @@ -46,7 +47,6 @@ class BasePosition: """ raise NotImplementedError(f"Please implement the `check_stock` method") - def update_order(self, order: Order, trade_val: float, cost: float, trade_price: float): """ Parameters @@ -86,6 +86,7 @@ class BasePosition: the value(money) of all the stock """ raise NotImplementedError(f"Please implement the `calculate_stock_value` method") + def get_stock_list(self) -> List: """ Get the list of stocks in the position. @@ -140,7 +141,7 @@ class BasePosition: """ raise NotImplementedError(f"Please implement the `get_stock_amount_dict` method") - def get_stock_weight_dict(self, only_stock: bool=False) -> Dict: + def get_stock_weight_dict(self, only_stock: bool = False) -> Dict: """ generate stock weight dict {stock_id : value weight of stock in the position} it is meaningful in the beginning or the end of each trade date @@ -399,13 +400,13 @@ class Position(BasePosition): self.position["now_account_value"] = now_account_value - class InfPosition(BasePosition): """ Position with infinite cash and amount. This is useful for generating random orders. """ + def skip_update(self) -> bool: """ Updating state is meaningless for InfPosition """ return True diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 3f2649839..f217ea169 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -18,7 +18,7 @@ from ..tests.config import CSI300_BENCH class Report: - ''' + """ Motivation: Report is for supporting portfolio related metrics. @@ -26,7 +26,8 @@ class Report: daily report of the account contain those followings: returns, costs turnovers, accounts, cash, bench, value update report - ''' + """ + def __init__(self, freq: str = "day", benchmark_config: dict = {}): """ Parameters diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 720eb627e..0ba607bdb 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -140,7 +140,6 @@ class BaseInfrastructure: self.reset_infra(**infra_dict) - class CommonInfrastructure(BaseInfrastructure): def get_support_infra(self): return ["trade_account", "trade_exchange"] diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 2e72cb32c..67ba4c5bc 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -15,6 +15,7 @@ class TopkDropoutStrategy(ModelStrategy): # TODO: # 1. Supporting leverage the get_range_limit result from the decision # 2. Supporting alter_outer_trade_decision + # 3. Supporting checking the availability of trade decision def __init__( self, model, @@ -104,7 +105,7 @@ class TopkDropoutStrategy(ModelStrategy): pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: - return [] + return TradeDecisionWO([], self) if self.only_tradable: # If The strategy only consider tradable stock when make decision # It needs following actions to filter stocks @@ -256,6 +257,7 @@ class WeightStrategyBase(ModelStrategy): # TODO: # 1. Supporting leverage the get_range_limit result from the decision # 2. Supporting alter_outer_trade_decision + # 3. Supporting checking the availability of trade decision def __init__( self, model, @@ -332,9 +334,9 @@ class WeightStrategyBase(ModelStrategy): pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) pred_score = resam_ts_data(self.pred_scores, start_time=pred_start_time, end_time=pred_end_time, method="last") if pred_score is None: - return [] + return TradeDecisionWO([], self) current_temp = copy.deepcopy(self.trade_position) - assert(isinstance(current_temp, Position)) # Avoid InfPosition + assert isinstance(current_temp, Position) # Avoid InfPosition target_weight_position = self.generate_target_weight_position( score=pred_score, current=current_temp, trade_start_time=trade_start_time, trade_end_time=trade_end_time diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index b8a900b85..0fb98e8ac 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -102,7 +102,7 @@ class TWAPStrategy(BaseStrategy): trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step start_idx, end_idx = get_start_end_idx(self, self.outer_trade_decision) - trade_len = end_idx - start_idx + 1 + trade_len = end_idx - start_idx + 1 if trade_step < start_idx: # It is not time to start trading @@ -137,12 +137,16 @@ class TWAPStrategy(BaseStrategy): # calculate the amount of one part, ceil the amount # floor((trade_unit_cnt + trade_len - rel_trade_step) / (trade_len - rel_trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - rel_trade_step + 1)) _order_amount = ( - (trade_unit_cnt + trade_len - rel_trade_step - 1) // (trade_len - rel_trade_step) * _amount_trade_unit + (trade_unit_cnt + trade_len - rel_trade_step - 1) + // (trade_len - rel_trade_step) + * _amount_trade_unit ) if order.direction == order.SELL: # sell all amount at last - if self.trade_amount[order.stock_id] > 1e-5 and (_order_amount < 1e-5 or rel_trade_step == trade_len - 1): + if self.trade_amount[order.stock_id] > 1e-5 and ( + _order_amount < 1e-5 or rel_trade_step == trade_len - 1 + ): _order_amount = self.trade_amount[order.stock_id] _order_amount = min(_order_amount, self.trade_amount[order.stock_id]) @@ -173,6 +177,7 @@ class SBBStrategyBase(BaseStrategy): # TODO: # 1. Supporting leverage the get_range_limit result from the decision # 2. Supporting alter_outer_trade_decision + # 3. Supporting checking the availability of trade decision def __init__( self, @@ -225,8 +230,7 @@ class SBBStrategyBase(BaseStrategy): self.trade_trend = {} self.trade_amount = {} # init the trade amount of order and predicted trade trend - outer_order_generator = outer_trade_decision.generator() - for order in outer_order_generator: + for order in outer_trade_decision.get_decision(): self.trade_trend[order.stock_id] = self.TREND_MID self.trade_amount[order.stock_id] = order.amount @@ -248,8 +252,7 @@ class SBBStrategyBase(BaseStrategy): pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) order_list = [] # for each order in in self.outer_trade_decision - outer_order_generator = self.outer_trade_decision.generator(only_enable=True) - for order in outer_order_generator: + for order in self.outer_trade_decision.get_decision(): # get the price trend if trade_step % 2 == 0: # in the first of two adjacent bars, predict the price trend @@ -379,9 +382,11 @@ class SBBStrategyEMA(SBBStrategyBase): """ (S)elect the (B)etter one among every two adjacent trading (B)ars to sell or buy with (EMA) signal. """ + # TODO: # 1. Supporting leverage the get_range_limit result from the decision # 2. Supporting alter_outer_trade_decision + # 3. Supporting checking the availability of trade decision def __init__( self, @@ -463,6 +468,7 @@ class ACStrategy(BaseStrategy): # TODO: # 1. Supporting leverage the get_range_limit result from the decision # 2. Supporting alter_outer_trade_decision + # 3. Supporting checking the availability of trade decision def __init__( self, lamb: float = 1e-6, @@ -555,8 +561,7 @@ class ACStrategy(BaseStrategy): if outer_trade_decision is not None: self.trade_amount = {} # init the trade amount of order and predicted trade trend - outer_order_generator = outer_trade_decision.generator() - for order in outer_order_generator: + for order in outer_trade_decision.get_decision(): self.trade_amount[order.stock_id] = order.amount def generate_trade_decision(self, execute_result=None): @@ -564,8 +569,6 @@ class ACStrategy(BaseStrategy): trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step trade_len = self.trade_calendar.get_trade_len() - # update outer trade decision - self.outer_trade_decision.update(self.trade_calendar) # update the order amount if execute_result is not None: @@ -575,8 +578,7 @@ class ACStrategy(BaseStrategy): trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) pred_start_time, pred_end_time = self.trade_calendar.get_step_time(trade_step, shift=1) order_list = [] - outer_order_generator = self.outer_trade_decision.generator(only_enable=True) - for order in outer_order_generator: + for order in self.outer_trade_decision.get_decision(): # if not tradable, continue if not self.trade_exchange.is_stock_tradable( stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time @@ -638,14 +640,16 @@ class ACStrategy(BaseStrategy): class RandomOrderStrategy(BaseStrategy): - - def __init__(self, - index_range: Tuple[int, int], # The range is closed on both left and right. - sample_ratio: float = 1., - volume_ratio: float = 0.01, - market: str = "all", - *args, - **kwargs): + def __init__( + self, + index_range: Tuple[int, int], # The range is closed on both left and right. + sample_ratio: float = 1.0, + volume_ratio: float = 0.01, + market: str = "all", + direction: int = Order.BUY, + *args, + **kwargs, + ): """ Parameters ---------- @@ -667,9 +671,12 @@ class RandomOrderStrategy(BaseStrategy): self.sample_ratio = sample_ratio self.volume_ratio = volume_ratio self.market = market + self.direction = direction exch: Exchange = self.common_infra.get("trade_exchange") # TODO: this can't be online - self.volume = D.features(D.instruments(market), ["Mean(Ref($volume, 1), 10)"], start_time=exch.start_time, end_time=exch.end_time) + self.volume = D.features( + D.instruments(market), ["Mean(Ref($volume, 1), 10)"], start_time=exch.start_time, end_time=exch.end_time + ) self.volume_df = self.volume.iloc[:, 0].unstack() def generate_trade_decision(self, execute_result=None): @@ -677,15 +684,15 @@ class RandomOrderStrategy(BaseStrategy): step_time_start, step_time_end = self.trade_calendar.get_step_time(trade_step) order_list = [] - for direction in Order.SELL, Order.BUY: - if step_time_start in self.volume_df: - for stock_id, volume in self.volume_df[step_time_start].dropna().sample(frac=self.sample_ratio).items(): - order_list.append( - self.common_infra.get("trade_exchange").create_order( - code=stock_id, - amount=volume * self.volume_ratio, - start_time=step_time_start, - end_time=step_time_end, - direction=direction, # 1 for buy - )) + if step_time_start in self.volume_df: + for stock_id, volume in self.volume_df[step_time_start].dropna().sample(frac=self.sample_ratio).items(): + order_list.append( + self.common_infra.get("trade_exchange").create_order( + code=stock_id, + amount=volume * self.volume_ratio, + start_time=step_time_start, + end_time=step_time_end, + direction=self.direction, + ) + ) return TradeDecisionWO(order_list, self, self.index_range) diff --git a/qlib/data/data.py b/qlib/data/data.py index 116861e78..d6735b4e6 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -213,7 +213,7 @@ class InstrumentProvider(abc.ABC, ProviderBackendMixin): self.backend = kwargs.get("backend", {}) @staticmethod - def instruments(market: Union[List, str]="all", filter_pipe: Union[List, None]=None): + def instruments(market: Union[List, str] = "all", filter_pipe: Union[List, None] = None): """Get the general config dictionary for a base market adding several dynamic filters. Parameters diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 734d25721..c8a326e80 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -85,7 +85,9 @@ class BaseStrategy: """ raise NotImplementedError("generate_trade_decision is not implemented!") - def update_trade_decision(self, trade_decision: BaseTradeDecision, trade_calendar: TradeCalendarManager) -> Union[BaseTradeDecision, None]: + def update_trade_decision( + self, trade_decision: BaseTradeDecision, trade_calendar: TradeCalendarManager + ) -> Union[BaseTradeDecision, None]: """ update trade decision in each step of inner execution, this method enable all order diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index 76d97e1bc..4df155946 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -9,6 +9,7 @@ from . import lazy_sort_index from ..config import C from .time import Freq, cal_sam_minute + def resam_calendar(calendar_raw: np.ndarray, freq_raw: str, freq_sam: str) -> np.ndarray: """ Resample the calendar with frequency freq_raw into the calendar with frequency freq_sam diff --git a/qlib/utils/time.py b/qlib/utils/time.py index fb37fd0a4..bfbdb9f1f 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -14,7 +14,7 @@ import functools @functools.lru_cache(maxsize=240) -def get_min_cal(shift: int=0) -> List[time]: +def get_min_cal(shift: int = 0) -> List[time]: """ get the minute level calendar in day period @@ -30,8 +30,9 @@ def get_min_cal(shift: int=0) -> List[time]: """ cal = [] - for ts in list(pd.date_range("9:30", "11:29", freq="1min") - pd.Timedelta(minutes=shift)) +\ - list(pd.date_range("13:00", "14:59", freq="1min") - pd.Timedelta(minutes=shift)): + for ts in list(pd.date_range("9:30", "11:29", freq="1min") - pd.Timedelta(minutes=shift)) + list( + pd.date_range("13:00", "14:59", freq="1min") - pd.Timedelta(minutes=shift) + ): cal.append(ts.time()) return cal @@ -115,7 +116,7 @@ def get_day_min_idx_range(start: str, end: str, freq: str) -> Tuple[int, int]: start = pd.Timestamp(start).time() end = pd.Timestamp(end).time() freq = Freq(freq) - in_day_cal = Freq.MIN_CAL[::freq.count] + in_day_cal = Freq.MIN_CAL[:: freq.count] left_idx = bisect.bisect_left(in_day_cal, start) right_idx = bisect.bisect_right(in_day_cal, end) - 1 return left_idx, right_idx @@ -141,15 +142,19 @@ def cal_sam_minute(x: pd.Timestamp, sam_minutes: int) -> pd.Timestamp: """ cal = get_min_cal(C.min_data_shift)[::sam_minutes] idx = bisect.bisect_right(cal, x.time()) - 1 - date, new_time = x.date(), cal[idx] + date, new_time = x.date(), cal[idx] return pd.Timestamp( - datetime(date.year, - month=date.month, - day=date.day, - hour=new_time.hour, - minute=new_time.minute, - second=new_time.second, - microsecond=new_time.microsecond)) + datetime( + date.year, + month=date.month, + day=date.day, + hour=new_time.hour, + minute=new_time.minute, + second=new_time.second, + microsecond=new_time.microsecond, + ) + ) + if __name__ == "__main__": print(get_day_min_idx_range("8:30", "14:59", "10min")) From e1b6f310c9ea11d33e89a0bcc50be9b884f79159 Mon Sep 17 00:00:00 2001 From: bxdd Date: Mon, 28 Jun 2021 20:06:15 +0000 Subject: [PATCH 067/187] add Handler Storage --- qlib/backtest/exchange.py | 4 -- qlib/backtest/position.py | 2 +- qlib/contrib/strategy/rule_strategy.py | 3 - qlib/data/dataset/handler.py | 18 ++---- qlib/data/dataset/processor.py | 9 +++ qlib/data/dataset/storage.py | 85 ++++++++++++++++++++++++++ qlib/data/dataset/utils.py | 16 ++++- 7 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 qlib/data/dataset/storage.py diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index cffa98ba6..a759dbd86 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -164,10 +164,6 @@ class Exchange: assert set(self.extra_quote.columns) == set(quote_df.columns) - {"$change"} quote_df = pd.concat([quote_df, self.extra_quote], sort=False, axis=0) - # update quote: pd.DataFrame to dict, for search use - if get_level_index(quote_df, level="datetime") == 1: - quote_df = quote_df.swaplevel().sort_index() - quote_dict = {} for stock_id, stock_val in quote_df.groupby(level="instrument"): quote_dict[stock_id] = stock_val diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 0f36e4959..b1037d460 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -408,7 +408,7 @@ class InfPosition(BasePosition): """ def skip_update(self) -> bool: - """ Updating state is meaningless for InfPosition """ + """Updating state is meaningless for InfPosition""" return True def check_stock(self, stock_id: str) -> bool: diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 0fb98e8ac..20099d4d3 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -5,7 +5,6 @@ from typing import List, Tuple, Union from ...utils.resam import resam_ts_data from ...data.data import D -from ...data.dataset.utils import convert_index_format from ...strategy.base import BaseStrategy from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO from ...backtest.exchange import Exchange @@ -423,7 +422,6 @@ class SBBStrategyEMA(SBBStrategyBase): signal_df = D.features( self.instruments, fields, start_time=signal_start_time, end_time=signal_end_time, freq=self.freq ) - signal_df = convert_index_format(signal_df) signal_df.columns = ["signal"] self.signal = {} @@ -515,7 +513,6 @@ class ACStrategy(BaseStrategy): signal_df = D.features( self.instruments, fields, start_time=signal_start_time, end_time=signal_end_time, freq=self.freq ) - signal_df = convert_index_format(signal_df) signal_df.columns = ["volatility"] self.signal = {} diff --git a/qlib/data/dataset/handler.py b/qlib/data/dataset/handler.py index c6338832a..30cfa7732 100644 --- a/qlib/data/dataset/handler.py +++ b/qlib/data/dataset/handler.py @@ -17,7 +17,7 @@ from ...data import D from ...config import C from ...utils import parse_config, transform_end_date, init_instance_by_config from ...utils.serial import Serializable -from .utils import fetch_df_by_index +from .utils import fetch_df_by_index, fetch_df_by_col from pathlib import Path from .loader import DataLoader @@ -152,14 +152,6 @@ class DataHandler(Serializable): CS_ALL = "__all" # return all columns with single-level index column CS_RAW = "__raw" # return raw data with multi-level index column - def _fetch_df_by_col(self, df: pd.DataFrame, col_set: str) -> pd.DataFrame: - if not isinstance(df.columns, pd.MultiIndex) or col_set == self.CS_RAW: - return df - elif col_set == self.CS_ALL: - return df.droplevel(axis=1, level=0) - else: - return df.loc(axis=1)[col_set] - def fetch( self, selector: Union[pd.Timestamp, slice, str] = slice(None, None), @@ -213,7 +205,7 @@ class DataHandler(Serializable): df = proc_func(fetch_df_by_index(self._data, selector, level, fetch_orig=self.fetch_orig).copy()) # Fetch column first will be more friendly to SepDataFrame - df = self._fetch_df_by_col(df, col_set) + df = fetch_df_by_col(df, col_set) df = fetch_df_by_index(df, selector, level, fetch_orig=self.fetch_orig) if squeeze: # squeeze columns @@ -238,7 +230,7 @@ class DataHandler(Serializable): list of column names """ df = self._data.head() - df = self._fetch_df_by_col(df, col_set) + df = fetch_df_by_col(df, col_set) return df.columns.to_list() def get_range_selector(self, cur_date: Union[pd.Timestamp, str], periods: int) -> slice: @@ -525,7 +517,7 @@ class DataHandlerLP(DataHandler): # Copy incase of `proc_func` changing the data inplace.... df = proc_func(fetch_df_by_index(df, selector, level, fetch_orig=self.fetch_orig).copy()) # Fetch column first will be more friendly to SepDataFrame - df = self._fetch_df_by_col(df, col_set) + df = fetch_df_by_col(df, col_set) return fetch_df_by_index(df, selector, level, fetch_orig=self.fetch_orig) def get_cols(self, col_set=DataHandler.CS_ALL, data_key: str = DK_I) -> list: @@ -545,5 +537,5 @@ class DataHandlerLP(DataHandler): list of column names """ df = self._get_df_by_key(data_key).head() - df = self._fetch_df_by_col(df, col_set) + df = fetch_df_by_col(df, col_set) return df.columns.to_list() diff --git a/qlib/data/dataset/processor.py b/qlib/data/dataset/processor.py index fce22ddfc..1e1ed8dfb 100644 --- a/qlib/data/dataset/processor.py +++ b/qlib/data/dataset/processor.py @@ -310,3 +310,12 @@ class CSZFillna(Processor): cols = get_group_columns(df, self.fields_group) df[cols] = df[cols].groupby("datetime").apply(lambda x: x.fillna(x.mean())) return df + + +class HashingStock(Processor): + """Process the df into hasing stock storage""" + + def __call__(self, df: pd.DataFrame): + from .storage import HasingStockStorage + + return HasingStockStorage.from_df(df) diff --git a/qlib/data/dataset/storage.py b/qlib/data/dataset/storage.py new file mode 100644 index 000000000..1849b6fcb --- /dev/null +++ b/qlib/data/dataset/storage.py @@ -0,0 +1,85 @@ +import pandas as pd +import numpy as np + +from .handler import DataHandler +from typing import Tuple, Union, List + +from .utils import get_level_index, fetch_df_by_index, fetch_df_by_col + + +class BaseHandlerStorage: + def fetch( + self, + selector: Union[pd.Timestamp, slice, str, list] = slice(None, None), + level: Union[str, int] = "datetime", + col_set: Union[str, List[str]] = DataHandler.CS_ALL, + **kwargs, + ) -> pd.DataFrame: + raise NotImplementedError("fetch is method not implemented!") + + @staticmethod + def from_df(df: pd.DataFrame): + raise NotImplementedError("from_df method is not implemented!") + + +class HasingStockStorage(BaseHandlerStorage): + def __init__(self, df): + self.hash_df = dict() + self.stock_level = get_level_index(df, "instrument") + for k, v in df.groupby(level="instrument"): + self.hash_df[k] = v + self.columns = df.columns + + @staticmethod + def from_df(df): + return HasingStockStorage(df) + + def _fetch_hash_df_by_stock(self, selector, level): + stock_selector = slice(None) + + if level is None: + if isinstance(selector, tuple) and self.stock_level < len(selector): + stock_selector = selector[self.stock_level] + elif isinstance(selector, (list, str)) and self.stock_level == 0: + stock_selector = selector + elif level == "instrument" or level == self.stock_level: + if isinstance(selector, tuple): + stock_selector = selector[0] + elif isinstance(selector, (list, str)): + stock_selector = selector + + if not isinstance(stock_selector, (list, str)) and stock_selector != slice(None): + raise TypeError(f"stock selector must be type str|list, or slice(None), rather than {stock_selector}") + print(stock_selector) + if stock_selector == slice(None): + return self.hash_df + + if isinstance(stock_selector, str): + stock_selector = [stock_selector] + + select_dict = dict() + for each_stock in sorted(stock_selector): + if each_stock in self.hash_df: + select_dict[each_stock] = self.hash_df[each_stock] + return select_dict + + def fetch( + self, + selector: Union[pd.Timestamp, slice, str] = slice(None, None), + level: Union[str, int] = "datetime", + col_set: Union[str, List[str]] = DataHandler.CS_ALL, + ) -> pd.DataFrame: + fetch_stock_df_list = list(self._fetch_hash_df_by_stock(selector=selector, level=level).values()) + for _index, stock_df in enumerate(fetch_stock_df_list): + fetch_col_df = fetch_df_by_col(df=stock_df, col_set=col_set) + fetch_index_df = fetch_df_by_index(df=fetch_col_df, selector=selector, level=level) + fetch_stock_df_list[_index] = fetch_index_df + if len(fetch_stock_df_list) == 0: + index_names = ("instrument", "datetime") if self.stock_level == 0 else ("datetime", "instrument") + return pd.DataFrame( + index=pd.MultiIndex.from_arrays([[], []], names=index_names), columns=self.columns, dtype=np.float32 + ) + elif len(fetch_stock_df_list) == 1: + return fetch_stock_df_list[0] + else: + return pd.concat(fetch_stock_df_list, axis=0, sort=False) diff --git a/qlib/data/dataset/utils.py b/qlib/data/dataset/utils.py index f7b07d563..3cb4dd3e2 100644 --- a/qlib/data/dataset/utils.py +++ b/qlib/data/dataset/utils.py @@ -1,5 +1,8 @@ -from typing import Union +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import pandas as pd +from typing import Union, List def get_level_index(df: pd.DataFrame, level=Union[str, int]) -> int: @@ -72,6 +75,17 @@ def fetch_df_by_index( ] +def fetch_df_by_col(df: pd.DataFrame, col_set: Union[str, List[str]]) -> pd.DataFrame: + from .handler import DataHandler + + if not isinstance(df.columns, pd.MultiIndex) or col_set == DataHandler.CS_RAW: + return df + elif col_set == DataHandler.CS_ALL: + return df.droplevel(axis=1, level=0) + else: + return df.loc(axis=1)[col_set] + + def convert_index_format(df: Union[pd.DataFrame, pd.Series], level: str = "datetime") -> Union[pd.DataFrame, pd.Series]: """ Convert the format of df.MultiIndex according to the following rules: From 90bbf2b7c6a6456f3dbe8ac237c0f0ae0f33c19b Mon Sep 17 00:00:00 2001 From: you-n-g Date: Wed, 30 Jun 2021 08:29:47 +0800 Subject: [PATCH 068/187] Fix account update bar_count bug --- qlib/backtest/account.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index a6ef2f6b8..6167ee407 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -263,11 +263,11 @@ class Account: elif atomic is False and inner_order_indicators is None: raise ValueError("inner_order_indicators is necessary in unatomic executor") + # TODO: `update_bar_count` and `update_current` should placed in Position and be merged. + self.update_bar_count() + self.update_current(trade_start_time, trade_end_time, trade_exchange) if generate_report: # report is portfolio related analysis - # TODO: `update_bar_count` and `update_current` should placed in Position and be merged. - self.update_bar_count() - self.update_current(trade_start_time, trade_end_time, trade_exchange) self.update_report(trade_start_time, trade_end_time) # indicator is trading (e.g. high-frequency order execution) related analysis From 9985befe6955c4953e1bb8b57854171b5df24181 Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 29 Jun 2021 12:02:27 +0000 Subject: [PATCH 069/187] update HashingStockStorage --- qlib/data/dataset/handler.py | 65 ++++++++++++++------- qlib/data/dataset/storage.py | 28 ++++++++- tests/test_handler_storage.py | 107 ++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 tests/test_handler_storage.py diff --git a/qlib/data/dataset/handler.py b/qlib/data/dataset/handler.py index 30cfa7732..475601625 100644 --- a/qlib/data/dataset/handler.py +++ b/qlib/data/dataset/handler.py @@ -175,7 +175,7 @@ class DataHandler(Serializable): select a set of meaningful columns.(e.g. features, columns) - if cal_set == CS_RAW: + if col_set == CS_RAW: the raw dataset will be returned. - if isinstance(col_set, List[str]): @@ -197,23 +197,33 @@ class DataHandler(Serializable): ------- pd.DataFrame. """ - if proc_func is None: - df = self._data - else: - # FIXME: fetching by time first will be more friendly to `proc_func` - # Copy in case of `proc_func` changing the data inplace.... - df = proc_func(fetch_df_by_index(self._data, selector, level, fetch_orig=self.fetch_orig).copy()) + from .storage import HasingStockStorage + + data_storage = self._data + if isinstance(data_storage, pd.DataFrame): + data_df = data_storage + if proc_func is not None: + # FIXME: fetching by time first will be more friendly to `proc_func` + # Copy in case of `proc_func` changing the data inplace.... + data_df = proc_func(fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig).copy()) + + # Fetch column first will be more friendly to SepDataFrame + data_df = fetch_df_by_col(data_df, col_set) + data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) + elif isinstance(data_storage, HasingStockStorage): + if proc_func is not None: + warnings.warn(f"proc_func is not supported by the HasingStockStorage") + data_df = data_storage.fetch(selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig) + else: + raise TypeError(f"data_storage should be pd.DataFrame|HasingStockStorage, not {type(data_storage)}") - # Fetch column first will be more friendly to SepDataFrame - df = fetch_df_by_col(df, col_set) - df = fetch_df_by_index(df, selector, level, fetch_orig=self.fetch_orig) if squeeze: # squeeze columns - df = df.squeeze() + data_df = data_df.squeeze() # squeeze index if isinstance(selector, (str, pd.Timestamp)): - df = df.reset_index(level=level, drop=True) - return df + data_df = data_df.reset_index(level=level, drop=True) + return data_df def get_cols(self, col_set=CS_ALL) -> list: """ @@ -511,14 +521,27 @@ class DataHandlerLP(DataHandler): ------- pd.DataFrame: """ - df = self._get_df_by_key(data_key) - if proc_func is not None: - # FIXME: fetch by time first will be more friendly to proc_func - # Copy incase of `proc_func` changing the data inplace.... - df = proc_func(fetch_df_by_index(df, selector, level, fetch_orig=self.fetch_orig).copy()) - # Fetch column first will be more friendly to SepDataFrame - df = fetch_df_by_col(df, col_set) - return fetch_df_by_index(df, selector, level, fetch_orig=self.fetch_orig) + from .storage import HasingStockStorage + + data_storage = self._get_df_by_key(data_key) + if isinstance(data_storage, pd.DataFrame): + data_df = data_storage + if proc_func is not None: + # FIXME: fetch by time first will be more friendly to proc_func + # Copy incase of `proc_func` changing the data inplace.... + data_df = proc_func(fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig).copy()) + # Fetch column first will be more friendly to SepDataFrame + data_df = fetch_df_by_col(data_df, col_set) + data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) + + elif isinstance(data_storage, HasingStockStorage): + if proc_func is not None: + warnings.warn(f"proc_func is not supported by the HasingStockStorage") + data_df = data_storage.fetch(selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig) + else: + raise TypeError(f"data_storage should be pd.DataFrame|HasingStockStorage, not {type(data_storage)}") + + return data_df def get_cols(self, col_set=DataHandler.CS_ALL, data_key: str = DK_I) -> list: """ diff --git a/qlib/data/dataset/storage.py b/qlib/data/dataset/storage.py index 1849b6fcb..66895cfe7 100644 --- a/qlib/data/dataset/storage.py +++ b/qlib/data/dataset/storage.py @@ -2,7 +2,7 @@ import pandas as pd import numpy as np from .handler import DataHandler -from typing import Tuple, Union, List +from typing import Tuple, Union, List, Callable from .utils import get_level_index, fetch_df_by_index, fetch_df_by_col @@ -13,8 +13,29 @@ class BaseHandlerStorage: selector: Union[pd.Timestamp, slice, str, list] = slice(None, None), level: Union[str, int] = "datetime", col_set: Union[str, List[str]] = DataHandler.CS_ALL, + fetch_orig: bool = True, **kwargs, ) -> pd.DataFrame: + """fetch data from the data storage + + Parameters + ---------- + selector : Union[pd.Timestamp, slice, str] + describe how to select data by index + level : Union[str, int] + which index level to select the data + col_set : Union[str, List[str]] + - if isinstance(col_set, str): + select a set of meaningful columns.(e.g. features, columns) + if col_set == DataHandler.CS_RAW: + the raw dataset will be returned. + - if isinstance(col_set, List[str]): + select several sets of meaningful columns, the returned data has multiple level + fetch_orig : bool + Return the original data instead of copy if possible. + + """ + raise NotImplementedError("fetch is method not implemented!") @staticmethod @@ -68,11 +89,12 @@ class HasingStockStorage(BaseHandlerStorage): selector: Union[pd.Timestamp, slice, str] = slice(None, None), level: Union[str, int] = "datetime", col_set: Union[str, List[str]] = DataHandler.CS_ALL, + fetch_orig: bool = True, ) -> pd.DataFrame: fetch_stock_df_list = list(self._fetch_hash_df_by_stock(selector=selector, level=level).values()) for _index, stock_df in enumerate(fetch_stock_df_list): fetch_col_df = fetch_df_by_col(df=stock_df, col_set=col_set) - fetch_index_df = fetch_df_by_index(df=fetch_col_df, selector=selector, level=level) + fetch_index_df = fetch_df_by_index(df=fetch_col_df, selector=selector, level=level, fetch_orig=fetch_orig) fetch_stock_df_list[_index] = fetch_index_df if len(fetch_stock_df_list) == 0: index_names = ("instrument", "datetime") if self.stock_level == 0 else ("datetime", "instrument") @@ -82,4 +104,4 @@ class HasingStockStorage(BaseHandlerStorage): elif len(fetch_stock_df_list) == 1: return fetch_stock_df_list[0] else: - return pd.concat(fetch_stock_df_list, axis=0, sort=False) + return pd.concat(fetch_stock_df_list, sort=False, copy=~fetch_orig) diff --git a/tests/test_handler_storage.py b/tests/test_handler_storage.py new file mode 100644 index 000000000..be36788bd --- /dev/null +++ b/tests/test_handler_storage.py @@ -0,0 +1,107 @@ +import unittest +import qlib +import time +import pandas as pd + +from qlib.data import D +from qlib.tests import TestAutoData + +from qlib.data.dataset.handler import DataHandlerLP +from qlib.data.dataset.processor import Processor +from qlib.contrib.data.handler import check_transform_proc +from qlib.utils import init_instance_by_config +from qlib.log import TimeInspector + + +class TestHandler(DataHandlerLP): + def __init__( + self, + instruments="csi300", + start_time=None, + end_time=None, + infer_processors=[], + learn_processors=[], + fit_start_time=None, + fit_end_time=None, + drop_raw=True, + ): + + infer_processors = check_transform_proc(infer_processors, fit_start_time, fit_end_time) + learn_processors = check_transform_proc(learn_processors, fit_start_time, fit_end_time) + + data_loader = { + "class": "QlibDataLoader", + "kwargs": { + "freq": "day", + "config": self.get_feature_config(), + "swap_level": False, + }, + } + + super().__init__( + instruments=instruments, + start_time=start_time, + end_time=end_time, + data_loader=data_loader, + infer_processors=infer_processors, + learn_processors=learn_processors, + drop_raw=drop_raw, + ) + + def get_feature_config(self): + fields = ["Ref($open, 1)", "Ref($close, 1)", "Ref($volume, 1)", "$open", "$close", "$volume"] + names = ["open_0", "close_0", "volume_0", "open_1", "close_1", "volume_1"] + return fields, names + + +class MiniTimer: + def __init__(self, name): + self.name = name + + def __enter__(self): + self.start = time.time() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.end = time.time() + print(f"[MyTimer Info] <{self.name}> process costs {self.end - self.start} seconds") + + +class TestHandlerStorage(TestAutoData): + + market = "all" + + start_time = "2020-01-01" + end_time = "2020-12-31" + train_end_time = "2020-05-31" + test_start_time = "2020-06-01" + + data_handler_kwargs = { + "start_time": start_time, + "end_time": end_time, + "fit_start_time": start_time, + "fit_end_time": train_end_time, + "instruments": market, + "infer_processors": ["HashingStock"], + } + + def test_handler_storage(self): + with MiniTimer("init data hanlder"): + data_handler = TestHandler(**self.data_handler_kwargs) + + with MiniTimer("random fetch"): + print(data_handler.fetch(selector=("SH600170", slice(None)), level=None)) + print( + data_handler.fetch( + selector=("SH600170", slice(pd.Timestamp("2020-01-01"), pd.Timestamp("2020-02-01"))), level=None + ) + ) + print( + data_handler.fetch( + selector=(["SH600170", "SH600383"], slice(pd.Timestamp("2020-01-01"), pd.Timestamp("2020-02-01"))), + level=None, + ) + ) + + +if __name__ == "__main__": + unittest.main() From 8d1b1979d9f69211e65adf62486b539d8f1284d4 Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 29 Jun 2021 15:51:41 +0000 Subject: [PATCH 070/187] update handler_storage test --- qlib/data/dataset/handler.py | 21 ++++++----- qlib/data/dataset/processor.py | 4 +- qlib/data/dataset/storage.py | 2 +- tests/test_handler_storage.py | 69 ++++++++++++++++++++++------------ 4 files changed, 59 insertions(+), 37 deletions(-) diff --git a/qlib/data/dataset/handler.py b/qlib/data/dataset/handler.py index 475601625..edcc1ede2 100644 --- a/qlib/data/dataset/handler.py +++ b/qlib/data/dataset/handler.py @@ -206,13 +206,14 @@ class DataHandler(Serializable): # FIXME: fetching by time first will be more friendly to `proc_func` # Copy in case of `proc_func` changing the data inplace.... data_df = proc_func(fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig).copy()) - - # Fetch column first will be more friendly to SepDataFrame - data_df = fetch_df_by_col(data_df, col_set) - data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) + data_df = fetch_df_by_col(data_df, col_set) + else: + # Fetch column first will be more friendly to SepDataFrame + data_df = fetch_df_by_col(data_df, col_set) + data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) elif isinstance(data_storage, HasingStockStorage): if proc_func is not None: - warnings.warn(f"proc_func is not supported by the HasingStockStorage") + raise ValueError("proc_func is not supported by the HasingStockStorage") data_df = data_storage.fetch(selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig) else: raise TypeError(f"data_storage should be pd.DataFrame|HasingStockStorage, not {type(data_storage)}") @@ -530,13 +531,15 @@ class DataHandlerLP(DataHandler): # FIXME: fetch by time first will be more friendly to proc_func # Copy incase of `proc_func` changing the data inplace.... data_df = proc_func(fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig).copy()) - # Fetch column first will be more friendly to SepDataFrame - data_df = fetch_df_by_col(data_df, col_set) - data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) + data_df = fetch_df_by_col(data_df, col_set) + else: + # Fetch column first will be more friendly to SepDataFrame + data_df = fetch_df_by_col(data_df, col_set) + data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) elif isinstance(data_storage, HasingStockStorage): if proc_func is not None: - warnings.warn(f"proc_func is not supported by the HasingStockStorage") + raise ValueError("proc_func is not supported by the HasingStockStorage") data_df = data_storage.fetch(selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig) else: raise TypeError(f"data_storage should be pd.DataFrame|HasingStockStorage, not {type(data_storage)}") diff --git a/qlib/data/dataset/processor.py b/qlib/data/dataset/processor.py index 1e1ed8dfb..cc6dcdfd3 100644 --- a/qlib/data/dataset/processor.py +++ b/qlib/data/dataset/processor.py @@ -312,8 +312,8 @@ class CSZFillna(Processor): return df -class HashingStock(Processor): - """Process the df into hasing stock storage""" +class HashStockFormat(Processor): + """Process the storage of from df into hasing stock format""" def __call__(self, df: pd.DataFrame): from .storage import HasingStockStorage diff --git a/qlib/data/dataset/storage.py b/qlib/data/dataset/storage.py index 66895cfe7..247970481 100644 --- a/qlib/data/dataset/storage.py +++ b/qlib/data/dataset/storage.py @@ -71,7 +71,7 @@ class HasingStockStorage(BaseHandlerStorage): if not isinstance(stock_selector, (list, str)) and stock_selector != slice(None): raise TypeError(f"stock selector must be type str|list, or slice(None), rather than {stock_selector}") - print(stock_selector) + if stock_selector == slice(None): return self.hash_df diff --git a/tests/test_handler_storage.py b/tests/test_handler_storage.py index be36788bd..e41286cb2 100644 --- a/tests/test_handler_storage.py +++ b/tests/test_handler_storage.py @@ -1,15 +1,11 @@ import unittest -import qlib import time -import pandas as pd - +import numpy as np from qlib.data import D from qlib.tests import TestAutoData from qlib.data.dataset.handler import DataHandlerLP -from qlib.data.dataset.processor import Processor from qlib.contrib.data.handler import check_transform_proc -from qlib.utils import init_instance_by_config from qlib.log import TimeInspector @@ -63,17 +59,17 @@ class MiniTimer: def __exit__(self, exc_type, exc_val, exc_tb): self.end = time.time() - print(f"[MyTimer Info] <{self.name}> process costs {self.end - self.start} seconds") + print(f"[Timer Info] <{self.name}> process costs {self.end - self.start} seconds") class TestHandlerStorage(TestAutoData): market = "all" - start_time = "2020-01-01" + start_time = "2010-01-01" end_time = "2020-12-31" - train_end_time = "2020-05-31" - test_start_time = "2020-06-01" + train_end_time = "2015-12-31" + test_start_time = "2016-01-01" data_handler_kwargs = { "start_time": start_time, @@ -81,26 +77,49 @@ class TestHandlerStorage(TestAutoData): "fit_start_time": start_time, "fit_end_time": train_end_time, "instruments": market, - "infer_processors": ["HashingStock"], } def test_handler_storage(self): - with MiniTimer("init data hanlder"): - data_handler = TestHandler(**self.data_handler_kwargs) + # init data handler + data_handler = TestHandler(**self.data_handler_kwargs) - with MiniTimer("random fetch"): - print(data_handler.fetch(selector=("SH600170", slice(None)), level=None)) - print( - data_handler.fetch( - selector=("SH600170", slice(pd.Timestamp("2020-01-01"), pd.Timestamp("2020-02-01"))), level=None - ) - ) - print( - data_handler.fetch( - selector=(["SH600170", "SH600383"], slice(pd.Timestamp("2020-01-01"), pd.Timestamp("2020-02-01"))), - level=None, - ) - ) + # init data handler with hasing storage + data_handler_hs = TestHandler(**self.data_handler_kwargs, infer_processors=["HashStockFormat"]) + + fetch_start_time = "2019-01-01" + fetch_end_time = "2019-12-31" + instruments = D.instruments(market=self.market) + instruments = D.list_instruments( + instruments=instruments, start_time=fetch_start_time, end_time=fetch_end_time, as_list=True + ) + + with TimeInspector.logt("random fetch with DataFrame Storage"): + + # single stock + for i in range(100): + random_index = np.random.randint(len(instruments), size=1)[0] + fetch_stock = instruments[random_index] + data_handler.fetch(selector=(fetch_stock, slice(fetch_start_time, fetch_end_time)), level=None) + + # multi stocks + for i in range(100): + random_indexs = np.random.randint(len(instruments), size=5) + fetch_stocks = [instruments[_index] for _index in random_indexs] + data_handler.fetch(selector=(fetch_stocks, slice(fetch_start_time, fetch_end_time)), level=None) + + with TimeInspector.logt("random fetch with HasingStock Storage"): + + # single stock + for i in range(100): + random_index = np.random.randint(len(instruments), size=1)[0] + fetch_stock = instruments[random_index] + data_handler_hs.fetch(selector=(fetch_stock, slice(fetch_start_time, fetch_end_time)), level=None) + + # multi stocks + for i in range(100): + random_indexs = np.random.randint(len(instruments), size=5) + fetch_stocks = [instruments[_index] for _index in random_indexs] + data_handler_hs.fetch(selector=(fetch_stocks, slice(fetch_start_time, fetch_end_time)), level=None) if __name__ == "__main__": From b242d6e1e1f9bfb063b7e2ccf2e3d1df6f8079bc Mon Sep 17 00:00:00 2001 From: bxdd Date: Tue, 29 Jun 2021 15:54:20 +0000 Subject: [PATCH 071/187] delMiniTimer in haandler storage test --- tests/test_handler_storage.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/test_handler_storage.py b/tests/test_handler_storage.py index e41286cb2..056595063 100644 --- a/tests/test_handler_storage.py +++ b/tests/test_handler_storage.py @@ -50,18 +50,6 @@ class TestHandler(DataHandlerLP): return fields, names -class MiniTimer: - def __init__(self, name): - self.name = name - - def __enter__(self): - self.start = time.time() - - def __exit__(self, exc_type, exc_val, exc_tb): - self.end = time.time() - print(f"[Timer Info] <{self.name}> process costs {self.end - self.start} seconds") - - class TestHandlerStorage(TestAutoData): market = "all" From bbf5d1bbbb9d7337a19d6c2bd2f69e23f2898781 Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 30 Jun 2021 07:34:23 +0000 Subject: [PATCH 072/187] add file order strategy --- qlib/backtest/exchange.py | 8 ++- qlib/backtest/order.py | 90 +++++++++++++++++++++++++- qlib/contrib/strategy/rule_strategy.py | 66 ++++++++++++++++++- qlib/data/dataset/utils.py | 3 + qlib/utils/file.py | 37 +++++++++++ tests/backtest/test_file_strategy.py | 86 ++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 qlib/utils/file.py create mode 100644 tests/backtest/test_file_strategy.py diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index a759dbd86..8177d53ee 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -14,7 +14,7 @@ from ..data.dataset.utils import get_level_index from ..config import C, REG_CN from ..utils.resam import resam_ts_data from ..log import get_module_logger -from .order import Order +from .order import Order, OrderDir, OrderHelper class Exchange: @@ -526,3 +526,9 @@ class Exchange: raise NotImplementedError("order type {} error".format(order.type)) return trade_val, trade_cost + + def get_order_helper(self) -> OrderHelper: + if not hasattr(self, "_order_helper"): + # cache to avoid recreate the same instance + self._order_helper = OrderHelper(self) + return self._order_helper diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 19ea807c1..9df162263 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -2,12 +2,14 @@ # Licensed under the MIT License. # TODO: rename it with decision.py from __future__ import annotations +from enum import IntEnum # try to fix circular imports when enabling type hints from typing import TYPE_CHECKING if TYPE_CHECKING: from qlib.strategy.base import BaseStrategy + from qlib.backtest.exchange import Exchange from qlib.backtest.utils import TradeCalendarManager import warnings import pandas as pd @@ -15,6 +17,12 @@ from dataclasses import dataclass, field from typing import ClassVar, Union, List, Set, Tuple +class OrderDir(IntEnum): + # Order direction + SELL = 0 + BUY = 1 + + @dataclass class Order: """ @@ -32,19 +40,97 @@ class Order: stock_id: str amount: float + + # The interval of the order which belongs to (NOTE: this is not the expected order dealing range time) start_time: pd.Timestamp end_time: pd.Timestamp + direction: int factor: float deal_amount: float = field(init=False) - SELL: ClassVar[int] = 0 - BUY: ClassVar[int] = 1 + + # FIXME: + # for compatible now. + # Plese remove them in the future + SELL: ClassVar[OrderDir] = OrderDir.SELL + BUY: ClassVar[OrderDir] = OrderDir.BUY def __post_init__(self): if self.direction not in {Order.SELL, Order.BUY}: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") self.deal_amount = 0 + @staticmethod + def parse_dir(direction: Union[str, int, OrderDir]) -> OrderDir: + if isinstance(direction, OrderDir): + return direction + elif isinstance(direction, int): + return OrderDir(direction) + elif isinstance(direction, str): + dl = direction.lower() + if dl.strip() == "sell": + return OrderDir.SELL + elif dl.strip() == "buy": + return OrderDir.BUY + else: + raise NotImplementedError(f"This type of input is not supported") + else: + raise NotImplementedError(f"This type of input is not supported") + + +class OrderHelper: + """ + Motivation + - Make generating order easier + - User may have no knowledge about the adjust-factor information about the system. + - It involves to much interaction with the exchange when generating orders. + """ + + def __init__(self, exchange: Exchange): + self.exchange = exchange + + def create( + self, + code: str, + amount: float, + direction: OrderDir, + start_time: Union[str, pd.Timestamp], + end_time: Union[str, pd.Timestamp], + ) -> Order: + """ + help to create a order + + # TODO: create order for unadjusted amount order + + Parameters + ---------- + code : str + the id of the instrument + amount : float + **adjusted trading amount** + direction : OrderDir + trading direction + start_time : Union[str, pd.Timestamp] + The interval of the order which belongs to + end_time : Union[str, pd.Timestamp] + The interval of the order which belongs to + + Returns + ------- + Order: + The created order + """ + start_time = pd.Timestamp(start_time) + end_time = pd.Timestamp(end_time) + return Order( + stock_id=code, + amount=amount, + start_time=start_time, + end_time=end_time, + direction=direction, + factor=self.exchange.get_factor(code, start_time, end_time), + ) + class BaseTradeDecision: """ diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 20099d4d3..22483a79c 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -1,14 +1,19 @@ +from pathlib import Path import warnings import numpy as np import pandas as pd -from typing import List, Tuple, Union +from typing import IO, List, Tuple, Union +from qlib.data.dataset.utils import convert_index_format + +from qlib.utils import lazy_sort_index from ...utils.resam import resam_ts_data from ...data.data import D from ...strategy.base import BaseStrategy from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO -from ...backtest.exchange import Exchange +from ...backtest.exchange import Exchange, OrderHelper from ...backtest.utils import CommonInfrastructure, LevelInfrastructure +from qlib.utils.file import get_io_object def get_start_end_idx(strategy: BaseStrategy, outer_trade_decision: BaseTradeDecision) -> Union[int, int]: @@ -653,6 +658,9 @@ class RandomOrderStrategy(BaseStrategy): index_range : Tuple the intra day time index range of the orders the left and right is closed. + + If you want to get the index_range in intra-day + - `qlib/utils/time.py:def get_day_min_idx_range` can help you create the index range easier # TODO: this is a index_range level limitation. We'll implement a more detailed limitation later. sample_ratio : float the ratio of all orders are sampled @@ -684,7 +692,9 @@ class RandomOrderStrategy(BaseStrategy): if step_time_start in self.volume_df: for stock_id, volume in self.volume_df[step_time_start].dropna().sample(frac=self.sample_ratio).items(): order_list.append( - self.common_infra.get("trade_exchange").create_order( + self.common_infra.get("trade_exchange") + .get_order_helper() + .create( code=stock_id, amount=volume * self.volume_ratio, start_time=step_time_start, @@ -693,3 +703,53 @@ class RandomOrderStrategy(BaseStrategy): ) ) return TradeDecisionWO(order_list, self, self.index_range) + + +class FileOrderStrategy(BaseStrategy): + """ + Motivtaion: + - This class provides an interface for user to read orders from csv files. + - It is supposed to be used in + """ + + def __init__(self, file: Union[IO, str, Path], index_range: Tuple[int, int] = None, *args, **kwargs): + super().__init__(*args, **kwargs) + with get_io_object(file) as f: + self.order_df = pd.read_csv(f, dtype={"datetime": np.str}) + + self.order_df["datetime"] = self.order_df["datetime"].apply(pd.Timestamp) + self.order_df = self.order_df.set_index(["datetime", "instrument"]) + + # make sure the datetime is the first level for fast indexing + self.order_df = lazy_sort_index(convert_index_format(self.order_df, level="datetime")) + self.index_range = index_range + + def generate_trade_decision(self, execute_result=None) -> TradeDecisionWO: + """ + Parameters + ---------- + execute_result : + execute_result will be ignored in FileOrderStrategy + """ + oh: OrderHelper = self.common_infra.get("trade_exchange").get_order_helper() + tc = self.trade_calendar + step = tc.get_trade_step() + start, end = tc.get_step_time(step) + # CONVERSION: the bar is indexed by the time + try: + df = self.order_df.loc(axis=0)[start] + except KeyError: + return TradeDecisionWO([], self) + else: + order_list = [] + for idx, row in df.iterrows(): + order_list.append( + oh.create( + code=idx, + amount=row["amount"], + direction=Order.parse_dir(row["direction"]), + start_time=start, + end_time=end, + ) + ) + return TradeDecisionWO(order_list, self, self.index_range) diff --git a/qlib/data/dataset/utils.py b/qlib/data/dataset/utils.py index 3cb4dd3e2..c6b3d97b6 100644 --- a/qlib/data/dataset/utils.py +++ b/qlib/data/dataset/utils.py @@ -92,6 +92,9 @@ def convert_index_format(df: Union[pd.DataFrame, pd.Series], level: str = "datet - If `level` is the first level of df.MultiIndex, do nothing - If `level` is the second level of df.MultiIndex, swap the level of index. + NOTE: + the number of levels of df.MultiIndex should be 2 + Parameters ---------- df : Union[pd.DataFrame, pd.Series] diff --git a/qlib/utils/file.py b/qlib/utils/file.py new file mode 100644 index 000000000..611260c86 --- /dev/null +++ b/qlib/utils/file.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# TODO: move file related utils into this module +import contextlib +from typing import IO, Union +from pathlib import Path + + +@contextlib.contextmanager +def get_io_object(file: Union[IO, str, Path], *args, **kwargs) -> IO: + """ + providing a easy interface to get an IO object + + Parameters + ---------- + file : Union[IO, str, Path] + a object representing the file + + Returns + ------- + IO: + a IO-like object + + Raises + ------ + NotImplementedError: + """ + if isinstance(file, IO): + yield file + else: + if isinstance(file, str): + file = Path(file) + if not isinstance(file, Path): + raise NotImplementedError(f"This type[{type(file)}] of input is not supported") + with file.open(*args, **kwargs) as f: + yield f diff --git a/tests/backtest/test_file_strategy.py b/tests/backtest/test_file_strategy.py new file mode 100644 index 000000000..da52b0d53 --- /dev/null +++ b/tests/backtest/test_file_strategy.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest +from qlib.backtest import backtest, order +from qlib.tests import TestAutoData +import pandas as pd +from pathlib import Path + +DIRNAME = Path(__file__).absolute().resolve().parent + + +class FileStrTest(TestAutoData): + + TEST_INST = "SH600519" + + EXAMPLE_FILE = DIRNAME / "order_example.csv" + + def _gen_orders(self) -> pd.DataFrame: + headers = [ + "datetime", + "instrument", + "amount", + "direction", + ] + orders = [ + ["20200102", self.TEST_INST, "1000", "sell"], + ["20200103", self.TEST_INST, "1000", "buy"], + ["20200106", self.TEST_INST, "1000", "sell"], + ] + return pd.DataFrame(orders, columns=headers).set_index(["datetime", "instrument"]) + + def test_file_str(self): + + orders = self._gen_orders() + print(orders) + orders.to_csv(self.EXAMPLE_FILE) + + orders = pd.read_csv(self.EXAMPLE_FILE, index_col=["datetime", "instrument"]) + + strategy_config = { + "class": "FileOrderStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + "kwargs": {"file": self.EXAMPLE_FILE}, + } + + freq = "day" + start_time = "2020-01-01" + end_time = "2020-01-16" + codes = [self.TEST_INST] + + backtest_config = { + "start_time": start_time, + "end_time": end_time, + "account": 100000000, + "benchmark": None, # benchmark is not required here for trading + "exchange_kwargs": { + "freq": freq, + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + "codes": codes, + }, + # "pos_type": "InfPosition" # Position with infinitive position + } + executor_config = { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": freq, + "generate_report": False, + "verbose": True, + "indicator_config": { + "show_indicator": False, + }, + }, + } + backtest(executor=executor_config, strategy=strategy_config, **backtest_config) + + self.EXAMPLE_FILE.unlink() + + +if __name__ == "__main__": + unittest.main() From a401f1eafe68398b77b0142445663bbcdf0e080f Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 30 Jun 2021 08:50:03 +0000 Subject: [PATCH 073/187] improve the docstring --- qlib/contrib/strategy/rule_strategy.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 22483a79c..e6779f124 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -707,12 +707,33 @@ class RandomOrderStrategy(BaseStrategy): class FileOrderStrategy(BaseStrategy): """ - Motivtaion: + Motivation: - This class provides an interface for user to read orders from csv files. - - It is supposed to be used in """ def __init__(self, file: Union[IO, str, Path], index_range: Tuple[int, int] = None, *args, **kwargs): + """ + + Parameters + ---------- + file : Union[IO, str, Path] + this parameters will specify the info of expected orders + Here is an example of the content + + datetime,instrument,amount,direction + 20200102, SH600519, 1000, sell + 20200103, SH600519, 1000, buy + 20200106, SH600519, 1000, sell + + index_range : Tuple[int, int] + the intra day time index range of the orders + the left and right is closed. + + If you want to get the index_range in intra-day + - `qlib/utils/time.py:def get_day_min_idx_range` can help you create the index range easier + # TODO: this is a index_range level limitation. We'll implement a more detailed limitation later. + + """ super().__init__(*args, **kwargs) with get_io_object(file) as f: self.order_df = pd.read_csv(f, dtype={"datetime": np.str}) From 2b4a493617d759d28f49768310c43c99daa169f9 Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Thu, 1 Jul 2021 09:41:08 +0000 Subject: [PATCH 074/187] Order patch --- qlib/backtest/order.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index b013d8723..32c4121fc 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from qlib.backtest.exchange import Exchange from qlib.backtest.utils import TradeCalendarManager import warnings +import numpy as np import pandas as pd from dataclasses import dataclass, field from typing import ClassVar, Optional, Union, List, Set, Tuple @@ -47,7 +48,7 @@ class Order: direction: int factor: float - deal_amount: float = field(init=False) + deal_amount: Optional[float] = None # FIXME: # for compatible now. @@ -62,11 +63,11 @@ class Order: self.deal_amount = 0 @staticmethod - def parse_dir(direction: Union[str, int, OrderDir]) -> OrderDir: + def parse_dir(direction: Union[str, int, np.integer, OrderDir]) -> OrderDir: if isinstance(direction, OrderDir): return direction - elif isinstance(direction, int): - return OrderDir(direction) + elif isinstance(direction, (int, float, np.integer, np.floating)): + return OrderDir(int(direction)) elif isinstance(direction, str): dl = direction.lower() if dl.strip() == "sell": From 8b85b9eee79b930c0cb3de44456935e5562a281b Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 1 Jul 2021 14:35:49 +0000 Subject: [PATCH 075/187] optimize performance of resam data in rule_strategy & exchange --- qlib/backtest/exchange.py | 25 +++++++-------- qlib/contrib/strategy/rule_strategy.py | 21 +++++++------ qlib/utils/resam.py | 42 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 23 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index a759dbd86..f5a366510 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -12,7 +12,7 @@ import pandas as pd from ..data.data import D from ..data.dataset.utils import get_level_index from ..config import C, REG_CN -from ..utils.resam import resam_ts_data +from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order @@ -166,7 +166,7 @@ class Exchange: quote_dict = {} for stock_id, stock_val in quote_df.groupby(level="instrument"): - quote_dict[stock_id] = stock_val + quote_dict[stock_id] = stock_val.droplevel(level="instrument") self.quote = quote_dict @@ -186,13 +186,13 @@ class Exchange: """ if direction is None: - buy_limit = resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all").iloc[0] - sell_limit = resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all").iloc[0] + buy_limit = resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all") + sell_limit = resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all") return buy_limit or sell_limit elif direction == Order.BUY: - return resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all").iloc[0] + return resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all") elif direction == Order.SELL: - return resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all").iloc[0] + return resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all") else: raise ValueError(f"direction {direction} is not supported!") @@ -267,16 +267,16 @@ class Exchange: ) def get_quote_info(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id], start_time, end_time, method="last").iloc[0] + return resam_ts_data(self.quote[stock_id], start_time, end_time, method=ts_data_last) def get_close(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method="last").iloc[0] + return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method=ts_data_last) def get_volume(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method="sum").iloc[0] + return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method="sum") def get_deal_price(self, stock_id, start_time, end_time): - deal_price = resam_ts_data(self.quote[stock_id][self.deal_price], start_time, end_time, method="last").iloc[0] + deal_price = resam_ts_data(self.quote[stock_id][self.deal_price], start_time, end_time, method=ts_data_last) if np.isclose(deal_price, 0.0) or np.isnan(deal_price): self.logger.warning( f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {self.deal_price}): {deal_price}!!!" @@ -295,10 +295,7 @@ class Exchange: """ if stock_id not in self.quote: return None - res = resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method="last") - if res is not None: - res = res.iloc[0] - return res + return resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method=ts_data_last) def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): """ diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 20099d4d3..5d26f0e30 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -3,7 +3,7 @@ import numpy as np import pandas as pd from typing import List, Tuple, Union -from ...utils.resam import resam_ts_data +from ...utils.resam import resam_ts_data, ts_data_last from ...data.data import D from ...strategy.base import BaseStrategy from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO @@ -427,7 +427,7 @@ class SBBStrategyEMA(SBBStrategyBase): if not signal_df.empty: for stock_id, stock_val in signal_df.groupby(level="instrument"): - self.signal[stock_id] = stock_val + self.signal[stock_id] = stock_val["signal"].droplevel(level="instrument") def reset_level_infra(self, level_infra): """ @@ -449,13 +449,16 @@ class SBBStrategyEMA(SBBStrategyBase): return self.TREND_MID else: _sample_signal = resam_ts_data( - self.signal[stock_id]["signal"], pred_start_time, pred_end_time, method="last" + self.signal[stock_id], + pred_start_time, + pred_end_time, + method=ts_data_last, ) # if EMA signal == 0 or None, return mid trend - if _sample_signal is None or _sample_signal.iloc[0] == 0: + if _sample_signal is None or np.isnan(_sample_signal) or _sample_signal == 0: return self.TREND_MID # if EMA signal > 0, return long trend - elif _sample_signal.iloc[0] > 0: + elif _sample_signal > 0: return self.TREND_LONG # if EMA signal < 0, return short trend else: @@ -518,7 +521,7 @@ class ACStrategy(BaseStrategy): if not signal_df.empty: for stock_id, stock_val in signal_df.groupby(level="instrument"): - self.signal[stock_id] = stock_val + self.signal[stock_id] = stock_val["volatility"].droplevel(level="instrument") def reset_common_infra(self, common_infra): """ @@ -585,12 +588,12 @@ class ACStrategy(BaseStrategy): # considering trade unit sig_sam = ( - resam_ts_data(self.signal[order.stock_id]["volatility"], pred_start_time, pred_end_time, method="last") + resam_ts_data(self.signal[order.stock_id], pred_start_time, pred_end_time, method=ts_data_last) if order.stock_id in self.signal else None ) - if sig_sam is None or sig_sam.iloc[0] is None: + if sig_sam is None or np.isnan(sig_sam): # no signal, TWAP _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) if _amount_trade_unit is None: @@ -607,7 +610,7 @@ class ACStrategy(BaseStrategy): ) else: # VA strategy - kappa_tild = self.lamb / self.eta * sig_sam.iloc[0] * sig_sam.iloc[0] + kappa_tild = self.lamb / self.eta * sig_sam * sig_sam kappa = np.arccosh(kappa_tild / 2 + 1) amount_ratio = ( np.sinh(kappa * (trade_len - trade_step)) - np.sinh(kappa * (trade_len - trade_step - 1)) diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index 4df155946..7782b8486 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -263,3 +263,45 @@ def resam_ts_data( elif isinstance(method, str): return getattr(feature, method)(**method_kwargs) return feature + + +def get_valid_value(series, last=True): + """get the first/last not nan value of pd.Series with single level index + Parameters + ---------- + series : pd.Seires + last : bool, optional + wether to get the last valid value, by default True + - if last is True, get the last valid value + - else, get the first valid value + + Returns + ------- + Nan | float + the first/last valid value + """ + x = series.dropna() + if x.empty: + return np.nan + else: + return x.iloc[-1] if last else x.iloc[0] + + +def ts_data_last(ts_feature): + """get the last not nan value of pd.Series|DataFrame with single level index""" + if isinstance(ts_feature, pd.DataFrame): + return ts_feature.apply(lambda column: get_valid_value(column, last=True)) + elif isinstance(ts_feature, pd.Series): + return get_valid_value(ts_feature, last=True) + else: + raise TypeError(f"ts_feature should be pd.DataFrame/Series, not {type(ts_feature)}") + + +def ts_data_first(ts_feature): + """get the first not nan value of pd.Series|DataFrame with single level index""" + if isinstance(ts_feature, pd.DataFrame): + return ts_feature.apply(lambda column: get_valid_value(column, last=False)) + elif isinstance(ts_feature, pd.Series): + return get_valid_value(ts_feature, last=False) + else: + raise TypeError(f"ts_feature should be pd.DataFrame/Series, not {type(ts_feature)}") From 8dd5788bacb313ad023e80eef5fc263186e045f3 Mon Sep 17 00:00:00 2001 From: bxdd Date: Thu, 1 Jul 2021 16:31:58 +0000 Subject: [PATCH 076/187] fix comments & update resam ts_last method --- .../nested_decision_execution/workflow.py | 16 ++++----- qlib/backtest/report.py | 2 +- qlib/data/dataset/handler.py | 34 +++++++++++++------ qlib/data/dataset/storage.py | 11 +++++- qlib/utils/resam.py | 7 ++-- 5 files changed, 45 insertions(+), 25 deletions(-) diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index b6c1362fd..3108960c8 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -124,14 +124,14 @@ class NestedDecisionExecutionWorkflow: def _init_qlib(self): """initialize qlib""" - # provider_uri_day = "/data/stock_data/huaxia/qlib" - # provider_uri_1min = "/data2/stock_data/huaxia_1min_qlib" - provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir - GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN, version="v2", exists_skip=True) - provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") - GetData().qlib_data( - target_dir=provider_uri_1min, interval="1min", region=REG_CN, version="v2", exists_skip=True - ) + provider_uri_day = "/data/stock_data/huaxia/qlib" + provider_uri_1min = "/data2/stock_data/huaxia_1min_qlib" + # provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir + # GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN, version="v2", exists_skip=True) + # provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") + # GetData().qlib_data( + # target_dir=provider_uri_1min, interval="1min", region=REG_CN, version="v2", exists_skip=True + # ) provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} client_config = { "calendar_provider": { diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index f217ea169..7623af551 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -91,7 +91,7 @@ class Report: if freq is None: raise ValueError("benchmark freq can't be None!") - _codes = benchmark if isinstance(benchmark, list) else [benchmark] + _codes = benchmark if isinstance(benchmark, (list, dict)) else [benchmark] fields = ["$close/Ref($close,1)-1"] _temp_result, _ = get_higher_eq_freq_feature(_codes, fields, start_time, end_time, freq=freq) if len(_temp_result) == 0: diff --git a/qlib/data/dataset/handler.py b/qlib/data/dataset/handler.py index edcc1ede2..2d5159292 100644 --- a/qlib/data/dataset/handler.py +++ b/qlib/data/dataset/handler.py @@ -197,7 +197,7 @@ class DataHandler(Serializable): ------- pd.DataFrame. """ - from .storage import HasingStockStorage + from .storage import BaseHandlerStorage data_storage = self._data if isinstance(data_storage, pd.DataFrame): @@ -211,10 +211,17 @@ class DataHandler(Serializable): # Fetch column first will be more friendly to SepDataFrame data_df = fetch_df_by_col(data_df, col_set) data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) - elif isinstance(data_storage, HasingStockStorage): - if proc_func is not None: - raise ValueError("proc_func is not supported by the HasingStockStorage") - data_df = data_storage.fetch(selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig) + elif isinstance(data_storage, BaseHandlerStorage): + if not data_storage.is_proc_func_supported(): + if proc_func is not None: + raise ValueError(f"proc_func is not supported by the storage {type(data_storage)}") + data_df = data_storage.fetch( + selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig + ) + else: + data_df = data_storage.fetch( + selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig, proc_func=proc_func + ) else: raise TypeError(f"data_storage should be pd.DataFrame|HasingStockStorage, not {type(data_storage)}") @@ -522,7 +529,7 @@ class DataHandlerLP(DataHandler): ------- pd.DataFrame: """ - from .storage import HasingStockStorage + from .storage import BaseHandlerStorage data_storage = self._get_df_by_key(data_key) if isinstance(data_storage, pd.DataFrame): @@ -537,10 +544,17 @@ class DataHandlerLP(DataHandler): data_df = fetch_df_by_col(data_df, col_set) data_df = fetch_df_by_index(data_df, selector, level, fetch_orig=self.fetch_orig) - elif isinstance(data_storage, HasingStockStorage): - if proc_func is not None: - raise ValueError("proc_func is not supported by the HasingStockStorage") - data_df = data_storage.fetch(selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig) + elif isinstance(data_storage, BaseHandlerStorage): + if not data_storage.is_proc_func_supported(): + if proc_func is not None: + raise ValueError(f"proc_func is not supported by the storage {type(data_storage)}") + data_df = data_storage.fetch( + selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig + ) + else: + data_df = data_storage.fetch( + selector=selector, level=level, col_set=col_set, fetch_orig=self.fetch_orig, proc_func=proc_func + ) else: raise TypeError(f"data_storage should be pd.DataFrame|HasingStockStorage, not {type(data_storage)}") diff --git a/qlib/data/dataset/storage.py b/qlib/data/dataset/storage.py index 247970481..cd38bbefa 100644 --- a/qlib/data/dataset/storage.py +++ b/qlib/data/dataset/storage.py @@ -14,6 +14,7 @@ class BaseHandlerStorage: level: Union[str, int] = "datetime", col_set: Union[str, List[str]] = DataHandler.CS_ALL, fetch_orig: bool = True, + proc_func: Callable = None, **kwargs, ) -> pd.DataFrame: """fetch data from the data storage @@ -24,6 +25,7 @@ class BaseHandlerStorage: describe how to select data by index level : Union[str, int] which index level to select the data + - if level is None, apply selector to df directly col_set : Union[str, List[str]] - if isinstance(col_set, str): select a set of meaningful columns.(e.g. features, columns) @@ -33,7 +35,8 @@ class BaseHandlerStorage: select several sets of meaningful columns, the returned data has multiple level fetch_orig : bool Return the original data instead of copy if possible. - + proc_func: Callable + please refer to the doc of DataHandler.fetch """ raise NotImplementedError("fetch is method not implemented!") @@ -42,6 +45,9 @@ class BaseHandlerStorage: def from_df(df: pd.DataFrame): raise NotImplementedError("from_df method is not implemented!") + def is_proc_func_supported(self): + raise NotImplementedError("is_proc_func_supported method is not implemented!") + class HasingStockStorage(BaseHandlerStorage): def __init__(self, df): @@ -105,3 +111,6 @@ class HasingStockStorage(BaseHandlerStorage): return fetch_stock_df_list[0] else: return pd.concat(fetch_stock_df_list, sort=False, copy=~fetch_orig) + + def is_proc_func_supported(self): + return False diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index 7782b8486..7e0dc141c 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -270,6 +270,7 @@ def get_valid_value(series, last=True): Parameters ---------- series : pd.Seires + series should not be empty last : bool, optional wether to get the last valid value, by default True - if last is True, get the last valid value @@ -280,11 +281,7 @@ def get_valid_value(series, last=True): Nan | float the first/last valid value """ - x = series.dropna() - if x.empty: - return np.nan - else: - return x.iloc[-1] if last else x.iloc[0] + return series.fillna(method="ffill").iloc[-1] if last else series.fillna(method="bfill").iloc[0] def ts_data_last(ts_feature): From ef7fe8aa75c7e0fb48518721b5109ff10a651f55 Mon Sep 17 00:00:00 2001 From: Young Date: Sat, 3 Jul 2021 08:46:09 +0000 Subject: [PATCH 077/187] support parallel HF trading --- qlib/backtest/exchange.py | 13 ++----- qlib/backtest/executor.py | 52 ++++++++++++++++++++++++-- qlib/contrib/strategy/rule_strategy.py | 3 ++ 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 8177d53ee..34c0ef744 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -242,6 +242,7 @@ class Exchange: raise ValueError("trade_account and position can only choose one") trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) + # NOTE: order will be changed in this function trade_val, trade_cost = self._calc_trade_info_by_order( order, trade_account.current if trade_account else position ) @@ -256,16 +257,6 @@ class Exchange: return trade_val, trade_cost, trade_price - def create_order(self, code, amount, start_time, end_time, direction) -> Order: - return Order( - stock_id=code, - amount=amount, - start_time=start_time, - end_time=end_time, - direction=direction, - factor=self.get_factor(code, start_time, end_time), - ) - def get_quote_info(self, stock_id, start_time, end_time): return resam_ts_data(self.quote[stock_id], start_time, end_time, method="last").iloc[0] @@ -471,6 +462,8 @@ class Exchange: """ Calculation of trade info + **NOTE**: Order will be changed in this function + :param order: :param position: Position :return: trade_val, trade_cost diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 3f7b2f4ed..ea2a0567d 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -1,7 +1,7 @@ import copy import warnings import pandas as pd -from typing import Union +from typing import List, Union from qlib.backtest.report import Indicator @@ -317,6 +317,15 @@ class NestedExecutor(BaseExecutor): class SimulatorExecutor(BaseExecutor): """Executor that simulate the true market""" + # available trade_types + TT_SERIAL = "serial" + ## The orders will be executed serially in a sequence + # In each trading step, it is possible that users sell instruments first and use the money to buy new instruments + TT_PARAL = "parallel" + ## The orders will be executed parallelly + # In each trading step, if users try to sell instruments first and buy new instruments with money, failure will + # occur + def __init__( self, time_per_step: str, @@ -328,6 +337,7 @@ class SimulatorExecutor(BaseExecutor): track_data: bool = False, trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, + trade_type: str = TT_PARAL, **kwargs, ): """ @@ -336,6 +346,8 @@ class SimulatorExecutor(BaseExecutor): trade_exchange : Exchange exchange that provides market info, used to deal order and generate report - If `trade_exchange` is None, self.trade_exchange will be set with common_infra + trade_type: str + please refer to the doc of `TT_SERIAL` & `TT_PARAL` """ super(SimulatorExecutor, self).__init__( time_per_step=time_per_step, @@ -351,6 +363,8 @@ class SimulatorExecutor(BaseExecutor): if trade_exchange is not None: self.trade_exchange = trade_exchange + self.trade_type = trade_type + def reset_common_infra(self, common_infra): """ reset infrastructure for trading @@ -360,14 +374,45 @@ class SimulatorExecutor(BaseExecutor): if common_infra.has("trade_exchange"): self.trade_exchange = common_infra.get("trade_exchange") + def _get_order_iterator(self, trade_decision: BaseTradeDecision) -> List[Order]: + """ + + Parameters + ---------- + trade_decision : BaseTradeDecision + the trade decision given by the strategy + + Returns + ------- + List[Order]: + get a list orders according to `self.trade_type` + """ + orders = trade_decision.get_decision() + + if self.trade_type == self.TT_SERIAL: + # Orders will be traded in a parallel way + order_it = orders + elif self.trade_type == self.TT_PARAL: + # NOTE: !!!!!!! + # Assumption: there will not be orders in different trading direction in a single step of a strategy !!!! + # The parallel trading failure will be caused only by the confliction of money + # Therefore, make the buying go first will make sure the confliction happen. + # It equals to parallel trading after sorting the order by direction + order_it = sorted(orders, key=lambda order: -order.direction) + else: + raise NotImplementedError(f"This type of input is not supported") + return order_it + def execute(self, trade_decision: BaseTradeDecision): trade_step = self.trade_calendar.get_trade_step() trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) execute_result = [] - for order in trade_decision.get_decision(): + + for order in self._get_order_iterator(trade_decision): if self.trade_exchange.check_order(order) is True: - # execute the order + # execute the order. + # NOTE: The trade_account will be changed in this function trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( order, trade_account=self.trade_account ) @@ -404,6 +449,7 @@ class SimulatorExecutor(BaseExecutor): # do nothing pass + # Account will not be changed in this function self.trade_account.update_bar_end( trade_start_time, trade_end_time, diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index e6779f124..2bc01045d 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -718,8 +718,11 @@ class FileOrderStrategy(BaseStrategy): ---------- file : Union[IO, str, Path] this parameters will specify the info of expected orders + Here is an example of the content + 1) Amount (**adjusted**) based strategy + datetime,instrument,amount,direction 20200102, SH600519, 1000, sell 20200103, SH600519, 1000, buy From ecf2f24d598f19022b5dbf3e2a3819801bd5c7a9 Mon Sep 17 00:00:00 2001 From: bxdd Date: Sat, 3 Jul 2021 18:42:40 +0000 Subject: [PATCH 078/187] fix comments --- .../nested_decision_execution/workflow.py | 16 +++++++-------- qlib/data/dataset/storage.py | 8 +++++++- qlib/utils/resam.py | 20 ++++++++----------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index 3108960c8..b6c1362fd 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -124,14 +124,14 @@ class NestedDecisionExecutionWorkflow: def _init_qlib(self): """initialize qlib""" - provider_uri_day = "/data/stock_data/huaxia/qlib" - provider_uri_1min = "/data2/stock_data/huaxia_1min_qlib" - # provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir - # GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN, version="v2", exists_skip=True) - # provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") - # GetData().qlib_data( - # target_dir=provider_uri_1min, interval="1min", region=REG_CN, version="v2", exists_skip=True - # ) + # provider_uri_day = "/data/stock_data/huaxia/qlib" + # provider_uri_1min = "/data2/stock_data/huaxia_1min_qlib" + provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir + GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN, version="v2", exists_skip=True) + provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") + GetData().qlib_data( + target_dir=provider_uri_1min, interval="1min", region=REG_CN, version="v2", exists_skip=True + ) provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} client_config = { "calendar_provider": { diff --git a/qlib/data/dataset/storage.py b/qlib/data/dataset/storage.py index cd38bbefa..9325807f9 100644 --- a/qlib/data/dataset/storage.py +++ b/qlib/data/dataset/storage.py @@ -37,8 +37,12 @@ class BaseHandlerStorage: Return the original data instead of copy if possible. proc_func: Callable please refer to the doc of DataHandler.fetch - """ + Returns + ------- + pd.DataFrame + the dataframe fetched + """ raise NotImplementedError("fetch is method not implemented!") @staticmethod @@ -46,6 +50,7 @@ class BaseHandlerStorage: raise NotImplementedError("from_df method is not implemented!") def is_proc_func_supported(self): + """whether the arg `proc_func` in `fetch` method is supported.""" raise NotImplementedError("is_proc_func_supported method is not implemented!") @@ -113,4 +118,5 @@ class HasingStockStorage(BaseHandlerStorage): return pd.concat(fetch_stock_df_list, sort=False, copy=~fetch_orig) def is_proc_func_supported(self): + """the arg `proc_func` in `fetch` method is not supported in HasingStockStorage""" return False diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index 7e0dc141c..9e9590e30 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -3,6 +3,8 @@ import datetime import numpy as np import pandas as pd + +from functools import partial from typing import Tuple, List, Union, Optional, Callable from . import lazy_sort_index @@ -284,21 +286,15 @@ def get_valid_value(series, last=True): return series.fillna(method="ffill").iloc[-1] if last else series.fillna(method="bfill").iloc[0] -def ts_data_last(ts_feature): - """get the last not nan value of pd.Series|DataFrame with single level index""" +def _ts_data_valid(ts_feature, last=False): + """get the first/last not nan value of pd.Series|DataFrame with single level index""" if isinstance(ts_feature, pd.DataFrame): - return ts_feature.apply(lambda column: get_valid_value(column, last=True)) + return ts_feature.apply(lambda column: get_valid_value(column, last=last)) elif isinstance(ts_feature, pd.Series): - return get_valid_value(ts_feature, last=True) + return get_valid_value(ts_feature, last=last) else: raise TypeError(f"ts_feature should be pd.DataFrame/Series, not {type(ts_feature)}") -def ts_data_first(ts_feature): - """get the first not nan value of pd.Series|DataFrame with single level index""" - if isinstance(ts_feature, pd.DataFrame): - return ts_feature.apply(lambda column: get_valid_value(column, last=False)) - elif isinstance(ts_feature, pd.Series): - return get_valid_value(ts_feature, last=False) - else: - raise TypeError(f"ts_feature should be pd.DataFrame/Series, not {type(ts_feature)}") +ts_data_last = partial(_ts_data_valid, last=False) +ts_data_first = partial(_ts_data_valid, last=True) From 50c0e99f9895c6176266d52ff83849eb50e7b32e Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 4 Jul 2021 06:41:34 +0000 Subject: [PATCH 079/187] fix ffr and order amount --- qlib/backtest/account.py | 7 +++++-- qlib/backtest/executor.py | 2 ++ qlib/backtest/order.py | 31 +++++++++++++++++++++++++++++-- qlib/backtest/report.py | 29 +++++++++++++++++++++-------- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 6167ee407..0d89dde87 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -9,7 +9,7 @@ import pandas as pd from .position import BasePosition, InfPosition, Position from .report import Report, Indicator -from .order import Order +from .order import BaseTradeDecision, Order from .exchange import Exchange """ @@ -226,6 +226,7 @@ class Account: trade_end_time: pd.Timestamp, trade_exchange: Exchange, atomic: bool, + outer_trade_decision: BaseTradeDecision, generate_report: bool = False, trade_info: list = None, inner_order_indicators: Indicator = None, @@ -276,7 +277,9 @@ class Account: if atomic: self.indicator.update_order_indicators(trade_start_time, trade_end_time, trade_info, trade_exchange) else: - self.indicator.agg_order_indicators(inner_order_indicators, indicator_config) + self.indicator.agg_order_indicators( + inner_order_indicators, indicator_config=indicator_config, outer_trade_decision=outer_trade_decision + ) self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) self.indicator.record(trade_start_time) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index ea2a0567d..14d97e825 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -299,6 +299,7 @@ class NestedExecutor(BaseExecutor): trade_end_time, self.trade_exchange, atomic=False, + outer_trade_decision=trade_decision, generate_report=self.generate_report, inner_order_indicators=inner_order_indicators, indicator_config=self.indicator_config, @@ -455,6 +456,7 @@ class SimulatorExecutor(BaseExecutor): trade_end_time, self.trade_exchange, atomic=True, + outer_trade_decision=trade_decision, generate_report=self.generate_report, trade_info=execute_result, indicator_config=self.indicator_config, diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 9df162263..1767deb62 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -39,7 +39,7 @@ class Order: """ stock_id: str - amount: float + amount: float # `amount` is a non-negative value # The interval of the order which belongs to (NOTE: this is not the expected order dealing range time) start_time: pd.Timestamp @@ -47,7 +47,7 @@ class Order: direction: int factor: float - deal_amount: float = field(init=False) + deal_amount: float = field(init=False) # `deal_amount` is a non-negative value # FIXME: # for compatible now. @@ -60,6 +60,33 @@ class Order: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") self.deal_amount = 0 + @property + def amount_delta(self) -> float: + """ + return the delta of amount. + - Positive value indicates buying `amount` of share + - Negative value indicates selling `amount` of share + """ + return self.amount * self.sign + + @property + def deal_amount_delta(self) -> float: + """ + return the delta of deal_amount. + - Positive value indicates buying `deal_amount` of share + - Negative value indicates selling `deal_amount` of share + """ + return self.deal_amount * self.sign + + @property + def sign(self) -> float: + """ + return the sign of trading + - `+1` indicates buying + - `-1` value indicates selling + """ + return self.direction * 2 - 1 + @staticmethod def parse_dir(direction: Union[str, int, OrderDir]) -> OrderDir: if isinstance(direction, OrderDir): diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 7623af551..ce2812bd0 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -4,6 +4,8 @@ from collections import OrderedDict from logging import warning +from typing import List +from qlib.backtest.order import BaseTradeDecision, Order import pandas as pd import pathlib import warnings @@ -241,13 +243,13 @@ class Indicator: trade_cost = dict() for order, _trade_val, _trade_cost, _trade_price in trade_info: - amount[order.stock_id] = order.amount * (order.direction * 2 - 1) - deal_amount[order.stock_id] = order.deal_amount * (order.direction * 2 - 1) + amount[order.stock_id] = order.amount_delta + deal_amount[order.stock_id] = order.deal_amount_delta trade_price[order.stock_id] = _trade_price - trade_value[order.stock_id] = _trade_val * (order.direction * 2 - 1) + trade_value[order.stock_id] = _trade_val * order.sign trade_cost[order.stock_id] = _trade_cost - self.order_indicator["amount"] = pd.Series(amount) + self.order_indicator["amount"] = self.order_indicator["inner_amount"] = pd.Series(amount) self.order_indicator["deal_amount"] = pd.Series(deal_amount) self.order_indicator["trade_price"] = pd.Series(trade_price) self.order_indicator["trade_value"] = pd.Series(trade_value) @@ -271,13 +273,13 @@ class Indicator: ) / self.order_indicator["base_price"] def _agg_order_trade_info(self, inner_order_indicators): - amount = pd.Series() + inner_amount = pd.Series() deal_amount = pd.Series() trade_price = pd.Series() trade_value = pd.Series() trade_cost = pd.Series() for _order_indicator in inner_order_indicators: - amount = amount.add(_order_indicator["amount"], fill_value=0) + inner_amount = inner_amount.add(_order_indicator["inner_amount"], fill_value=0) deal_amount = deal_amount.add(_order_indicator["deal_amount"], fill_value=0) trade_price = trade_price.add( _order_indicator["trade_price"] * _order_indicator["deal_amount"], fill_value=0 @@ -285,13 +287,21 @@ class Indicator: trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) - self.order_indicator["amount"] = amount + self.order_indicator["inner_amount"] = inner_amount self.order_indicator["deal_amount"] = deal_amount trade_price /= self.order_indicator["deal_amount"] self.order_indicator["trade_price"] = trade_price self.order_indicator["trade_value"] = trade_value self.order_indicator["trade_cost"] = trade_cost + def _update_trade_amount(self, outer_trade_decision: BaseTradeDecision): + # NOTE: these indicator is designed for order execution, so the + decision: List[Order] = outer_trade_decision.get_decision() + if decision is None: + self.order_indicator["amount"] = pd.Series() + else: + self.order_indicator["amount"] = pd.Series({order.stock_id: order.amount_delta for order in decision}) + def _agg_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] @@ -367,8 +377,11 @@ class Indicator: self._update_order_fulfill_rate() self._update_order_price_advantage(trade_exchange, trade_start_time, trade_end_time) - def agg_order_indicators(self, inner_order_indicators, indicator_config={}): + def agg_order_indicators( + self, inner_order_indicators, outer_trade_decision: BaseTradeDecision, indicator_config={} + ): self._agg_order_trade_info(inner_order_indicators) + self._update_trade_amount(outer_trade_decision) self._agg_order_fulfill_rate() pa_config = indicator_config.get("pa_config", {}) self._agg_order_price_advantage(inner_order_indicators, base_price=pa_config.get("base_price", "twap")) From 7048bef7c69e3a3e56bbf8ffb34b85eac490c192 Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 4 Jul 2021 06:41:34 +0000 Subject: [PATCH 080/187] fix ffr and order amount --- qlib/backtest/account.py | 7 +++++-- qlib/backtest/executor.py | 2 ++ qlib/backtest/order.py | 31 +++++++++++++++++++++++++++++-- qlib/backtest/report.py | 29 +++++++++++++++++++++-------- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 6167ee407..0d89dde87 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -9,7 +9,7 @@ import pandas as pd from .position import BasePosition, InfPosition, Position from .report import Report, Indicator -from .order import Order +from .order import BaseTradeDecision, Order from .exchange import Exchange """ @@ -226,6 +226,7 @@ class Account: trade_end_time: pd.Timestamp, trade_exchange: Exchange, atomic: bool, + outer_trade_decision: BaseTradeDecision, generate_report: bool = False, trade_info: list = None, inner_order_indicators: Indicator = None, @@ -276,7 +277,9 @@ class Account: if atomic: self.indicator.update_order_indicators(trade_start_time, trade_end_time, trade_info, trade_exchange) else: - self.indicator.agg_order_indicators(inner_order_indicators, indicator_config) + self.indicator.agg_order_indicators( + inner_order_indicators, indicator_config=indicator_config, outer_trade_decision=outer_trade_decision + ) self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) self.indicator.record(trade_start_time) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 3f7b2f4ed..7341e5225 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -299,6 +299,7 @@ class NestedExecutor(BaseExecutor): trade_end_time, self.trade_exchange, atomic=False, + outer_trade_decision=trade_decision, generate_report=self.generate_report, inner_order_indicators=inner_order_indicators, indicator_config=self.indicator_config, @@ -409,6 +410,7 @@ class SimulatorExecutor(BaseExecutor): trade_end_time, self.trade_exchange, atomic=True, + outer_trade_decision=trade_decision, generate_report=self.generate_report, trade_info=execute_result, indicator_config=self.indicator_config, diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 32c4121fc..64ff2a56f 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -40,7 +40,7 @@ class Order: """ stock_id: str - amount: float + amount: float # `amount` is a non-negative value # The interval of the order which belongs to (NOTE: this is not the expected order dealing range time) start_time: pd.Timestamp @@ -48,7 +48,7 @@ class Order: direction: int factor: float - deal_amount: Optional[float] = None + deal_amount: Optional[float] = None # `deal_amount` is a non-negative value # FIXME: # for compatible now. @@ -62,6 +62,33 @@ class Order: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") self.deal_amount = 0 + @property + def amount_delta(self) -> float: + """ + return the delta of amount. + - Positive value indicates buying `amount` of share + - Negative value indicates selling `amount` of share + """ + return self.amount * self.sign + + @property + def deal_amount_delta(self) -> float: + """ + return the delta of deal_amount. + - Positive value indicates buying `deal_amount` of share + - Negative value indicates selling `deal_amount` of share + """ + return self.deal_amount * self.sign + + @property + def sign(self) -> float: + """ + return the sign of trading + - `+1` indicates buying + - `-1` value indicates selling + """ + return self.direction * 2 - 1 + @staticmethod def parse_dir(direction: Union[str, int, np.integer, OrderDir]) -> OrderDir: if isinstance(direction, OrderDir): diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index f217ea169..4f645c564 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -4,6 +4,8 @@ from collections import OrderedDict from logging import warning +from typing import List +from qlib.backtest.order import BaseTradeDecision, Order import pandas as pd import pathlib import warnings @@ -241,13 +243,13 @@ class Indicator: trade_cost = dict() for order, _trade_val, _trade_cost, _trade_price in trade_info: - amount[order.stock_id] = order.amount * (order.direction * 2 - 1) - deal_amount[order.stock_id] = order.deal_amount * (order.direction * 2 - 1) + amount[order.stock_id] = order.amount_delta + deal_amount[order.stock_id] = order.deal_amount_delta trade_price[order.stock_id] = _trade_price - trade_value[order.stock_id] = _trade_val * (order.direction * 2 - 1) + trade_value[order.stock_id] = _trade_val * order.sign trade_cost[order.stock_id] = _trade_cost - self.order_indicator["amount"] = pd.Series(amount) + self.order_indicator["amount"] = self.order_indicator["inner_amount"] = pd.Series(amount) self.order_indicator["deal_amount"] = pd.Series(deal_amount) self.order_indicator["trade_price"] = pd.Series(trade_price) self.order_indicator["trade_value"] = pd.Series(trade_value) @@ -271,13 +273,13 @@ class Indicator: ) / self.order_indicator["base_price"] def _agg_order_trade_info(self, inner_order_indicators): - amount = pd.Series() + inner_amount = pd.Series() deal_amount = pd.Series() trade_price = pd.Series() trade_value = pd.Series() trade_cost = pd.Series() for _order_indicator in inner_order_indicators: - amount = amount.add(_order_indicator["amount"], fill_value=0) + inner_amount = inner_amount.add(_order_indicator["inner_amount"], fill_value=0) deal_amount = deal_amount.add(_order_indicator["deal_amount"], fill_value=0) trade_price = trade_price.add( _order_indicator["trade_price"] * _order_indicator["deal_amount"], fill_value=0 @@ -285,13 +287,21 @@ class Indicator: trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) - self.order_indicator["amount"] = amount + self.order_indicator["inner_amount"] = inner_amount self.order_indicator["deal_amount"] = deal_amount trade_price /= self.order_indicator["deal_amount"] self.order_indicator["trade_price"] = trade_price self.order_indicator["trade_value"] = trade_value self.order_indicator["trade_cost"] = trade_cost + def _update_trade_amount(self, outer_trade_decision: BaseTradeDecision): + # NOTE: these indicator is designed for order execution, so the + decision: List[Order] = outer_trade_decision.get_decision() + if decision is None: + self.order_indicator["amount"] = pd.Series() + else: + self.order_indicator["amount"] = pd.Series({order.stock_id: order.amount_delta for order in decision}) + def _agg_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] @@ -367,8 +377,11 @@ class Indicator: self._update_order_fulfill_rate() self._update_order_price_advantage(trade_exchange, trade_start_time, trade_end_time) - def agg_order_indicators(self, inner_order_indicators, indicator_config={}): + def agg_order_indicators( + self, inner_order_indicators, outer_trade_decision: BaseTradeDecision, indicator_config={} + ): self._agg_order_trade_info(inner_order_indicators) + self._update_trade_amount(outer_trade_decision) self._agg_order_fulfill_rate() pa_config = indicator_config.get("pa_config", {}) self._agg_order_price_advantage(inner_order_indicators, base_price=pa_config.get("base_price", "twap")) From 82645233e7cf4efcc9cfecfa3bdc3bf67c10b237 Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Tue, 6 Jul 2021 03:50:34 +0000 Subject: [PATCH 081/187] Support order dataframe --- qlib/contrib/strategy/rule_strategy.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index d18eb2a27..8152b13de 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -714,12 +714,12 @@ class FileOrderStrategy(BaseStrategy): - This class provides an interface for user to read orders from csv files. """ - def __init__(self, file: Union[IO, str, Path], index_range: Tuple[int, int] = None, *args, **kwargs): + def __init__(self, file: Union[IO, str, Path, pd.DataFrame], index_range: Tuple[int, int] = None, *args, **kwargs): """ Parameters ---------- - file : Union[IO, str, Path] + file : Union[IO, str, Path, pd.DataFrame] this parameters will specify the info of expected orders Here is an example of the content @@ -741,8 +741,11 @@ class FileOrderStrategy(BaseStrategy): """ super().__init__(*args, **kwargs) - with get_io_object(file) as f: - self.order_df = pd.read_csv(f, dtype={"datetime": np.str}) + if isinstance(file, pd.DataFrame): + self.order_df = file + else: + with get_io_object(file) as f: + self.order_df = pd.read_csv(f, dtype={"datetime": np.str}) self.order_df["datetime"] = self.order_df["datetime"].apply(pd.Timestamp) self.order_df = self.order_df.set_index(["datetime", "instrument"]) From cb72857710ebe008a4c18f2c421d315ab8ec70ac Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 6 Jul 2021 05:20:53 +0000 Subject: [PATCH 082/187] fix annotation recursive error --- qlib/backtest/__init__.py | 12 ++++++++++-- qlib/backtest/backtest.py | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 0de290f02..b2c3f8c09 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -1,9 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from __future__ import annotations import copy -from typing import Union +from typing import Union, TYPE_CHECKING from .account import Account + +if TYPE_CHECKING: + from ..strategy.base import BaseStrategy from .exchange import Exchange from .executor import BaseExecutor from .backtest import backtest_loop @@ -11,7 +15,6 @@ from .backtest import collect_data_loop from .utils import CommonInfrastructure from .order import Order -from ..strategy.base import BaseStrategy from ..utils import init_instance_by_config from ..log import get_module_logger from ..config import C @@ -137,6 +140,11 @@ def get_strategy_executor( pos_type: str = "Position", ): + # NOTE: + # - for avoiding recursive import + # - typing annotations is not reliable + from ..strategy.base import BaseStrategy + trade_account = create_account_instance( start_time=start_time, end_time=end_time, benchmark=benchmark, account=account, pos_type=pos_type ) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 0ac4581da..48d06db6c 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -1,7 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + +from __future__ import annotations from qlib.backtest.order import BaseTradeDecision -from qlib.strategy.base import BaseStrategy +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from qlib.strategy.base import BaseStrategy from qlib.backtest.executor import BaseExecutor from ..utils.time import Freq from tqdm.auto import tqdm From bdac9f4ddab70f2afda4206dc32dc5a7663d30ac Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 6 Jul 2021 06:28:14 +0000 Subject: [PATCH 083/187] supporting seperated buy and sell price --- qlib/backtest/__init__.py | 20 +++--- qlib/backtest/exchange.py | 86 ++++++++++++++++++------- qlib/contrib/strategy/model_strategy.py | 4 +- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index b2c3f8c09..bc7210259 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from __future__ import annotations import copy -from typing import Union, TYPE_CHECKING +from typing import List, Tuple, Union, TYPE_CHECKING from .account import Account @@ -35,7 +35,7 @@ def get_exchange( min_cost=5.0, trade_unit=None, limit_threshold=None, - deal_price=None, + deal_price: Union[str, Tuple[str], List[str]] = None, ): """get_exchange @@ -54,8 +54,15 @@ def get_exchange( min transaction cost. trade_unit : int 100 for China A. - deal_price: str - dealing price type: 'close', 'open', 'vwap'. + deal_price: Union[str, Tuple[str], List[str]] + The `deal_price` supports following two types of input + - : str + - (, ): Tuple[str] or List[str] + + , or := + := str + - for example '$close', '$open', '$vwap' ("close" is OK. `Exchange` will help to prepend + "$" to the expression) limit_threshold : float limit move 0.1 (10%) for example, long and short with same limit. @@ -69,13 +76,8 @@ def get_exchange( trade_unit = C.trade_unit if limit_threshold is None: limit_threshold = C.limit_threshold - if deal_price is None: - deal_price = C.deal_price if exchange is None: logger.info("Create new exchange") - # handle exception for deal_price - if deal_price[0] != "$": - deal_price = "$" + deal_price exchange = Exchange( freq=freq, diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index ccd5f4b45..9d4c96f48 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -4,7 +4,7 @@ import random import logging -from typing import Union +from typing import List, Tuple, Union import numpy as np import pandas as pd @@ -24,7 +24,7 @@ class Exchange: start_time=None, end_time=None, codes="all", - deal_price=None, + deal_price: Union[str, Tuple[str], List[str]] = None, subscribe_fields=[], limit_threshold=None, volume_threshold=None, @@ -40,7 +40,17 @@ class Exchange: :param start_time: closed start time for backtest :param end_time: closed end time for backtest :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) - :param deal_price: str, 'close', 'open', 'vwap' + + :param deal_price: Union[str, Tuple[str], List[str]] + The `deal_price` supports following two types of input + - : str + - (, ): Tuple[str] or List[str] + + , or := + := str + - for example '$close', '$open', '$vwap' ("close" is OK. `Exchange` will help to prepend + "$" to the expression) + :param subscribe_fields: list, subscribe fields :param limit_threshold: float, 0.1 for example, default None :param volume_threshold: float, 0.1 for example, default None @@ -86,10 +96,15 @@ class Exchange: if C.region == REG_CN: self.logger.warning(f"limit_threshold may not be set to a reasonable value") - if deal_price[0] != "$": - self.deal_price = "$" + deal_price + if isinstance(deal_price, str): + if deal_price[0] != "$": + deal_price = "$" + deal_price + self.buy_price = self.sell_price = deal_price + elif isinstance(deal_price, (tuple, list)): + self.buy_price, self.sell_price = deal_price else: - self.deal_price = deal_price + raise NotImplementedError(f"This type of input is not supported") + if isinstance(codes, str): codes = D.instruments(codes) self.codes = codes @@ -98,7 +113,7 @@ class Exchange: # $factor is for rounding to the trading unit # $change is for calculating the limit of the stock - necessary_fields = {self.deal_price, "$close", "$change", "$factor", "$volume"} + necessary_fields = {self.buy_price, self.sell_price, "$close", "$change", "$factor", "$volume"} subscribe_fields = list(necessary_fields | set(subscribe_fields)) all_fields = list(necessary_fields | set(subscribe_fields)) self.all_fields = all_fields @@ -118,8 +133,10 @@ class Exchange: ) self.quote.columns = self.all_fields - if self.quote[self.deal_price].isna().any(): - self.logger.warning("{} field data contains nan.".format(self.deal_price)) + for attr in "buy_price", "sell_price": + pstr = getattr(self, attr) # price string + if self.quote[pstr].isna().any(): + self.logger.warning("{} field data contains nan.".format(pstr)) if self.quote["$factor"].isna().any(): # The 'factor.day.bin' file not exists, and `factor` field contains `nan` @@ -148,9 +165,11 @@ class Exchange: # process extra_quote if "$close" not in self.extra_quote: raise ValueError("$close is necessray in extra_quote") - if self.deal_price not in self.extra_quote.columns: - self.extra_quote[self.deal_price] = self.extra_quote["$close"] - self.logger.warning("No deal_price set for extra_quote. Use $close as deal_price.") + for attr in "buy_price", "sell_price": + pstr = getattr(self, attr) # price string + if pstr not in self.extra_quote.columns: + self.extra_quote[pstr] = self.extra_quote["$close"] + self.logger.warning(f"No {pstr} set for extra_quote. Use $close as {pstr}.") if "$factor" not in self.extra_quote.columns: self.extra_quote["$factor"] = 1.0 self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") @@ -241,7 +260,7 @@ class Exchange: if trade_account is not None and position is not None: raise ValueError("trade_account and position can only choose one") - trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) + trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, order.direction) # NOTE: order will be changed in this function trade_val, trade_cost = self._calc_trade_info_by_order( order, trade_account.current if trade_account else position @@ -266,12 +285,16 @@ class Exchange: def get_volume(self, stock_id, start_time, end_time): return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method="sum") - def get_deal_price(self, stock_id, start_time, end_time): - deal_price = resam_ts_data(self.quote[stock_id][self.deal_price], start_time, end_time, method=ts_data_last) + def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir): + if direction == OrderDir.SELL: + pstr = self.sell_price + elif direction == OrderDir.BUY: + pstr = self.buy_price + else: + raise NotImplementedError(f"This type of input is not supported") + deal_price = resam_ts_data(self.quote[stock_id][pstr], start_time, end_time, method=ts_data_last) if np.isclose(deal_price, 0.0) or np.isnan(deal_price): - self.logger.warning( - f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {self.deal_price}): {deal_price}!!!" - ) + self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {pstr}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") deal_price = self.get_close(stock_id, start_time, end_time) return deal_price @@ -288,7 +311,9 @@ class Exchange: return None return resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method=ts_data_last) - def generate_amount_position_from_weight_position(self, weight_position, cash, start_time, end_time): + def generate_amount_position_from_weight_position( + self, weight_position, cash, start_time, end_time, direction=OrderDir.BUY + ): """ The generate the target position according to the weight and the cash. NOTE: All the cash will assigned to the tadable stock. @@ -297,7 +322,10 @@ class Exchange: weight_position : dict {stock_id : weight}; allocate cash by weight_position among then, weight must be in this range: 0 < weight < 1 cash : cash - trade_date : trade date + start_time : the start time point of the step + end_time : the end time point of the step + direction : the direction of the deal price for estimating the amount + # NOTE: this function is used for calculating target position. So the default direction is buy """ # calculate the total weight of tradable value @@ -324,7 +352,9 @@ class Exchange: cash * weight_position[stock_id] / tradable_weight - // self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) + // self.get_deal_price( + stock_id=stock_id, start_time=start_time, end_time=end_time, direction=direction + ) ) return amount_dict @@ -414,10 +444,16 @@ class Exchange: # return order_list : buy + sell return sell_order_list + buy_order_list - def calculate_amount_position_value(self, amount_dict, start_time, end_time, only_tradable=False): + def calculate_amount_position_value( + self, amount_dict, start_time, end_time, only_tradable=False, direction=OrderDir.SELL + ): """Parameter position : Position() amount_dict : {stock_id : amount} + direction : the direction of the deal price for estimating the amount + # NOTE: + This function is used for calculating current position value. + So the default direction is sell. """ value = 0 for stock_id in amount_dict: @@ -426,7 +462,9 @@ class Exchange: and self.check_stock_limit(stock_id=stock_id, start_time=start_time, end_time=end_time) is False ): value += ( - self.get_deal_price(stock_id=stock_id, start_time=start_time, end_time=end_time) + self.get_deal_price( + stock_id=stock_id, start_time=start_time, end_time=end_time, direction=direction + ) * amount_dict[stock_id] ) return value @@ -466,7 +504,7 @@ class Exchange: :return: trade_val, trade_cost """ - trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time) + trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction) if order.direction == Order.SELL: # sell if position is not None: diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 67ba4c5bc..e2a79db27 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -6,7 +6,7 @@ import pandas as pd from ...utils.resam import resam_ts_data from ...strategy.base import ModelStrategy -from ...backtest.order import Order, BaseTradeDecision, TradeDecisionWO +from ...backtest.order import Order, BaseTradeDecision, OrderDir, TradeDecisionWO from .order_generator import OrderGenWInteract @@ -236,7 +236,7 @@ class TopkDropoutStrategy(ModelStrategy): continue # buy order buy_price = self.trade_exchange.get_deal_price( - stock_id=code, start_time=trade_start_time, end_time=trade_end_time + stock_id=code, start_time=trade_start_time, end_time=trade_end_time, direction=OrderDir.BUY ) buy_amount = value / buy_price factor = self.trade_exchange.get_factor(stock_id=code, start_time=trade_start_time, end_time=trade_end_time) From 354f7e68c2f9065971887c9c35b278215873ba7a Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Tue, 6 Jul 2021 08:47:55 +0000 Subject: [PATCH 084/187] Constrain TWAP trade step --- qlib/contrib/strategy/rule_strategy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 8152b13de..3ca325bf6 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -108,8 +108,8 @@ class TWAPStrategy(BaseStrategy): start_idx, end_idx = get_start_end_idx(self, self.outer_trade_decision) trade_len = end_idx - start_idx + 1 - if trade_step < start_idx: - # It is not time to start trading + if trade_step < start_idx or trade_step > end_idx: + # It is not time to start trading or trading has ended. return TradeDecisionWO(order_list=[], strategy=self) rel_trade_step = trade_step - start_idx # trade_step relative to start_idx From 03d6facbd22a8362573fdfe7f8b7d3eb3b45c5a8 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 6 Jul 2021 10:02:20 +0000 Subject: [PATCH 085/187] fix TWAP strategy --- qlib/contrib/strategy/rule_strategy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index d18eb2a27..e2e34e112 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -108,8 +108,8 @@ class TWAPStrategy(BaseStrategy): start_idx, end_idx = get_start_end_idx(self, self.outer_trade_decision) trade_len = end_idx - start_idx + 1 - if trade_step < start_idx: - # It is not time to start trading + if trade_step < start_idx or trade_step > end_idx: + # It is not time to start trading or trading has ended. return TradeDecisionWO(order_list=[], strategy=self) rel_trade_step = trade_step - start_idx # trade_step relative to start_idx From dd8231edebff2dc8108ce28450f507a14263f434 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 6 Jul 2021 11:09:25 +0000 Subject: [PATCH 086/187] simplify the portfolio-based report --- qlib/backtest/account.py | 52 ++++++++++++++++++++++++++++++--------- qlib/backtest/backtest.py | 8 +++--- qlib/backtest/executor.py | 44 +++++++++++---------------------- qlib/backtest/order.py | 1 - qlib/strategy/base.py | 2 +- 5 files changed, 61 insertions(+), 46 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 0d89dde87..b394d5823 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -64,34 +64,49 @@ class AccumulatedInfo: class Account: def __init__( - self, init_cash: float = 1e9, freq: str = "day", benchmark_config: dict = {}, pos_type: str = "Position" + self, + init_cash: float = 1e9, + freq: str = "day", + benchmark_config: dict = {}, + pos_type: str = "Position", + port_metr_enabled: bool = True, ): - self.pos_type = pos_type + self._pos_type = pos_type + self._port_metr_enabled = port_metr_enabled self.init_vars(init_cash, freq, benchmark_config) + def is_port_metr_enabled(self): + """ + Is portfolio-based metrics enabled. + """ + return self._port_metr_enabled and not self.current.skip_update() + def init_vars(self, init_cash, freq: str, benchmark_config: dict): # init cash self.init_cash = init_cash self.current: BasePosition = init_instance_by_config( { - "class": self.pos_type, + "class": self._pos_type, "kwargs": {"cash": init_cash}, "module_path": "qlib.backtest.position", } ) self.accum_info = AccumulatedInfo() + self.report = None + self.positions = {} self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) def reset_report(self, freq, benchmark_config): # portfolio related metrics - self.report = Report(freq, benchmark_config) - self.positions = {} + if self.is_port_metr_enabled(): + self.report = Report(freq, benchmark_config) + self.positions = {} # trading related matric(e.g. high-frequency trading) self.indicator = Indicator() - def reset(self, freq=None, benchmark_config=None, init_report=False): + def reset(self, freq=None, benchmark_config=None, init_report=False, port_metr_enabled: bool = None): """reset freq and report of account Parameters @@ -108,6 +123,9 @@ class Account: if benchmark_config is not None: self.benchmark_config = benchmark_config + if port_metr_enabled is not None: + self._port_metr_enabled = port_metr_enabled + if freq is not None or benchmark_config is not None or init_report: self.reset_report(self.freq, self.benchmark_config) @@ -137,7 +155,7 @@ class Account: self.accum_info.add_return_value(profit) # note here do not consider cost def update_order(self, order, trade_val, cost, trade_price): - if self.current.skip_update(): + if not self.is_port_metr_enabled(): # TODO: supporting polymorphism for account # updating order for infinite position is meaningless return @@ -160,12 +178,14 @@ class Account: def update_bar_count(self): """at the end of the trading bar, update holding bar, count of stock""" # update holding day count + # NOTE: updating bar_count does not only serve portfolio metrics, it also serve the strategy if not self.current.skip_update(): self.current.add_count_all(bar=self.freq) def update_current(self, trade_start_time, trade_end_time, trade_exchange): """update current to make rtn consistent with earning at the end of bar""" # update price for stock in the position and the profit from changed_price + # NOTE: updating position does not only serve portfolio metrics, it also serve the strategy if not self.current.skip_update(): stock_list = self.current.get_stock_list() for code in stock_list: @@ -227,7 +247,6 @@ class Account: trade_exchange: Exchange, atomic: bool, outer_trade_decision: BaseTradeDecision, - generate_report: bool = False, trade_info: list = None, inner_order_indicators: Indicator = None, indicator_config: dict = {}, @@ -246,8 +265,6 @@ class Account: whether the trading executor is atomic, which means there is no higher-frequency trading executor inside it - if atomic is True, calculate the indicators with trade_info - else, aggregate indicators with inner indicators - generate_report : bool, optional - whether to generate report, by default False trade_info : List[(Order, float, float, float)], optional trading information, by default None - necessary if atomic is True @@ -267,7 +284,7 @@ class Account: # TODO: `update_bar_count` and `update_current` should placed in Position and be merged. self.update_bar_count() self.update_current(trade_start_time, trade_end_time, trade_exchange) - if generate_report: + if self.is_port_metr_enabled(): # report is portfolio related analysis self.update_report(trade_start_time, trade_end_time) @@ -283,3 +300,16 @@ class Account: self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) self.indicator.record(trade_start_time) + + def get_report(self): + """get the history report and postions instance""" + if self.is_port_metr_enabled(): + _report = self.report.generate_report_dataframe() + _positions = self.get_positions() + return _report, _positions + else: + raise ValueError("generate_report should be True if you want to generate report") + + def get_trade_indicator(self) -> Indicator: + """get the trade indicator instance, which has pa/pos/ffr info.""" + return self.indicator diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 48d06db6c..573c874b0 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -69,13 +69,13 @@ def collect_data_loop( all_executors = trade_executor.get_all_executors() all_reports = { - "{}{}".format(*Freq.parse(_executor.time_per_step)): _executor.get_report() + "{}{}".format(*Freq.parse(_executor.time_per_step)): _executor.trade_account.get_report() for _executor in all_executors - if _executor.generate_report + if _executor.trade_account.is_port_metr_enabled() } all_indicators = {} for _executor in all_executors: key = "{}{}".format(*Freq.parse(_executor.time_per_step)) - all_indicators[key] = _executor.get_trade_indicator().generate_trade_indicators_dataframe() - all_indicators[key + "_obj"] = _executor.get_trade_indicator() + all_indicators[key] = _executor.trade_account.get_trade_indicator().generate_trade_indicators_dataframe() + all_indicators[key + "_obj"] = _executor.trade_account.get_trade_indicator() return_value.update({"report": all_reports, "indicator": all_indicators}) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 14d97e825..adea9dde0 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -103,8 +103,10 @@ class BaseExecutor: self.common_infra.update(common_infra) if common_infra.has("trade_account"): + # NOTE: there is a trick in the code. + # copy is used instead of deepcopy. So positions are shared self.trade_account = copy.copy(common_infra.get("trade_account")) - self.trade_account.reset(freq=self.time_per_step, init_report=True) + self.trade_account.reset(freq=self.time_per_step, init_report=True, port_metr_enabled=self.generate_report) def reset(self, track_data: bool = None, common_infra: CommonInfrastructure = None, **kwargs): """ @@ -167,19 +169,6 @@ class BaseExecutor: yield trade_decision return self.execute(trade_decision) - def get_report(self): - """get the history report and postions instance""" - if self.generate_report: - _report = self.trade_account.report.generate_report_dataframe() - _positions = self.trade_account.get_positions() - return _report, _positions - else: - raise ValueError("generate_report should be True if you want to generate report") - - def get_trade_indicator(self) -> Indicator: - """get the trade indicator instance, which has pa/pos/ffr info.""" - return self.trade_account.indicator - def get_all_executors(self): """get all executors""" return [self] @@ -289,21 +278,19 @@ class NestedExecutor(BaseExecutor): _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) execute_result.extend(_inner_execute_result) - inner_order_indicators.append(self.inner_executor.get_trade_indicator().get_order_indicator()) + inner_order_indicators.append(self.inner_executor.trade_account.get_trade_indicator().get_order_indicator()) - if hasattr(self, "trade_account"): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) - self.trade_account.update_bar_end( - trade_start_time, - trade_end_time, - self.trade_exchange, - atomic=False, - outer_trade_decision=trade_decision, - generate_report=self.generate_report, - inner_order_indicators=inner_order_indicators, - indicator_config=self.indicator_config, - ) + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + self.trade_account.update_bar_end( + trade_start_time, + trade_end_time, + self.trade_exchange, + atomic=False, + outer_trade_decision=trade_decision, + inner_order_indicators=inner_order_indicators, + indicator_config=self.indicator_config, + ) self.trade_calendar.step() if return_value is not None: @@ -457,7 +444,6 @@ class SimulatorExecutor(BaseExecutor): self.trade_exchange, atomic=True, outer_trade_decision=trade_decision, - generate_report=self.generate_report, trade_info=execute_result, indicator_config=self.indicator_config, ) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 64ff2a56f..535309d91 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -56,7 +56,6 @@ class Order: SELL: ClassVar[OrderDir] = OrderDir.SELL BUY: ClassVar[OrderDir] = OrderDir.BUY - def __post_init__(self): if self.direction not in {Order.SELL, Order.BUY}: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index bac59acfb..a787c098f 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -10,7 +10,7 @@ from ..utils import init_instance_by_config from ..backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager from ..backtest.order import BaseTradeDecision -__all__ = ['BaseStrategy', 'ModelStrategy', 'RLStrategy', 'RLIntStrategy'] +__all__ = ["BaseStrategy", "ModelStrategy", "RLStrategy", "RLIntStrategy"] class BaseStrategy: From 6fd50a5bfa3a20d153bd6b86ec8305a725bef228 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 6 Jul 2021 12:08:53 +0000 Subject: [PATCH 087/187] Supporting skip empty decisions --- qlib/backtest/executor.py | 44 ++++++++++++++++++++++++++------------- qlib/backtest/order.py | 5 ++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index adea9dde0..c4807ebde 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -191,6 +191,7 @@ class NestedExecutor(BaseExecutor): generate_report: bool = False, verbose: bool = False, track_data: bool = False, + skip_empty_decision: bool = True, trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, **kwargs, @@ -206,6 +207,11 @@ class NestedExecutor(BaseExecutor): exchange that provides market info, used to generate report - If generate_report is None, trade_exchange will be ignored - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra + skip_empty_decision: bool + Will the executor skip the inner loop when the decision is empty. + It should be False in following cases + - The decisions may be updated by steps + - The inner executor may not follow the decisions from the outer strategy """ self.inner_executor = init_instance_by_config( inner_executor, common_infra=common_infra, accept_types=BaseExecutor @@ -214,6 +220,8 @@ class NestedExecutor(BaseExecutor): inner_strategy, common_infra=common_infra, accept_types=BaseStrategy ) + self._skip_empty_decision = skip_empty_decision + super(NestedExecutor, self).__init__( time_per_step=time_per_step, start_time=start_time, @@ -259,26 +267,32 @@ class NestedExecutor(BaseExecutor): def collect_data(self, trade_decision: BaseTradeDecision, return_value=None): if self.track_data: yield trade_decision - self._init_sub_trading(trade_decision) execute_result = [] inner_order_indicators = [] - _inner_execute_result = None - while not self.inner_executor.finished(): - # outter strategy have chance to update decision each iterator - updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) - if updated_trade_decision is not None: - trade_decision = updated_trade_decision - # NEW UPDATE - # create a hook for inner strategy to update outter decision - self.inner_strategy.alter_outer_trade_decision(trade_decision) - _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + if not (trade_decision.empty() and self._skip_empty_decision): + _inner_execute_result = None + self._init_sub_trading(trade_decision) + while not self.inner_executor.finished(): + # outter strategy have chance to update decision each iterator + updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) + if updated_trade_decision is not None: + trade_decision = updated_trade_decision + # NEW UPDATE + # create a hook for inner strategy to update outter decision + self.inner_strategy.alter_outer_trade_decision(trade_decision) - # NOTE: Trade Calendar will step forward in the follow line - _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) + _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) - execute_result.extend(_inner_execute_result) - inner_order_indicators.append(self.inner_executor.trade_account.get_trade_indicator().get_order_indicator()) + # NOTE: Trade Calendar will step forward in the follow line + _inner_execute_result = yield from self.inner_executor.collect_data( + trade_decision=_inner_trade_decision + ) + + execute_result.extend(_inner_execute_result) + inner_order_indicators.append( + self.inner_executor.trade_account.get_trade_indicator().get_order_indicator() + ) trade_step = self.trade_calendar.get_trade_step() trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 535309d91..1953426fd 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -197,7 +197,7 @@ class BaseTradeDecision: Example: []: Decision not available - concrete_decision: + [concrete_decision]: available """ raise NotImplementedError(f"This type of input is not supported") @@ -236,6 +236,9 @@ class BaseTradeDecision: """ raise NotImplementedError(f"Please implement the `func` method") + def empty(self) -> bool: + return len(self.get_decision()) == 0 + class TradeDecisionWO(BaseTradeDecision): """ From 4e41e9c8f253d555605156644ab79efa5bc4009f Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 6 Jul 2021 11:09:25 +0000 Subject: [PATCH 088/187] simplify the portfolio-based report --- qlib/backtest/account.py | 52 ++++++++++++++++++++++++++++++--------- qlib/backtest/backtest.py | 8 +++--- qlib/backtest/executor.py | 44 +++++++++++---------------------- qlib/strategy/base.py | 2 ++ 4 files changed, 62 insertions(+), 44 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 0d89dde87..b394d5823 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -64,34 +64,49 @@ class AccumulatedInfo: class Account: def __init__( - self, init_cash: float = 1e9, freq: str = "day", benchmark_config: dict = {}, pos_type: str = "Position" + self, + init_cash: float = 1e9, + freq: str = "day", + benchmark_config: dict = {}, + pos_type: str = "Position", + port_metr_enabled: bool = True, ): - self.pos_type = pos_type + self._pos_type = pos_type + self._port_metr_enabled = port_metr_enabled self.init_vars(init_cash, freq, benchmark_config) + def is_port_metr_enabled(self): + """ + Is portfolio-based metrics enabled. + """ + return self._port_metr_enabled and not self.current.skip_update() + def init_vars(self, init_cash, freq: str, benchmark_config: dict): # init cash self.init_cash = init_cash self.current: BasePosition = init_instance_by_config( { - "class": self.pos_type, + "class": self._pos_type, "kwargs": {"cash": init_cash}, "module_path": "qlib.backtest.position", } ) self.accum_info = AccumulatedInfo() + self.report = None + self.positions = {} self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) def reset_report(self, freq, benchmark_config): # portfolio related metrics - self.report = Report(freq, benchmark_config) - self.positions = {} + if self.is_port_metr_enabled(): + self.report = Report(freq, benchmark_config) + self.positions = {} # trading related matric(e.g. high-frequency trading) self.indicator = Indicator() - def reset(self, freq=None, benchmark_config=None, init_report=False): + def reset(self, freq=None, benchmark_config=None, init_report=False, port_metr_enabled: bool = None): """reset freq and report of account Parameters @@ -108,6 +123,9 @@ class Account: if benchmark_config is not None: self.benchmark_config = benchmark_config + if port_metr_enabled is not None: + self._port_metr_enabled = port_metr_enabled + if freq is not None or benchmark_config is not None or init_report: self.reset_report(self.freq, self.benchmark_config) @@ -137,7 +155,7 @@ class Account: self.accum_info.add_return_value(profit) # note here do not consider cost def update_order(self, order, trade_val, cost, trade_price): - if self.current.skip_update(): + if not self.is_port_metr_enabled(): # TODO: supporting polymorphism for account # updating order for infinite position is meaningless return @@ -160,12 +178,14 @@ class Account: def update_bar_count(self): """at the end of the trading bar, update holding bar, count of stock""" # update holding day count + # NOTE: updating bar_count does not only serve portfolio metrics, it also serve the strategy if not self.current.skip_update(): self.current.add_count_all(bar=self.freq) def update_current(self, trade_start_time, trade_end_time, trade_exchange): """update current to make rtn consistent with earning at the end of bar""" # update price for stock in the position and the profit from changed_price + # NOTE: updating position does not only serve portfolio metrics, it also serve the strategy if not self.current.skip_update(): stock_list = self.current.get_stock_list() for code in stock_list: @@ -227,7 +247,6 @@ class Account: trade_exchange: Exchange, atomic: bool, outer_trade_decision: BaseTradeDecision, - generate_report: bool = False, trade_info: list = None, inner_order_indicators: Indicator = None, indicator_config: dict = {}, @@ -246,8 +265,6 @@ class Account: whether the trading executor is atomic, which means there is no higher-frequency trading executor inside it - if atomic is True, calculate the indicators with trade_info - else, aggregate indicators with inner indicators - generate_report : bool, optional - whether to generate report, by default False trade_info : List[(Order, float, float, float)], optional trading information, by default None - necessary if atomic is True @@ -267,7 +284,7 @@ class Account: # TODO: `update_bar_count` and `update_current` should placed in Position and be merged. self.update_bar_count() self.update_current(trade_start_time, trade_end_time, trade_exchange) - if generate_report: + if self.is_port_metr_enabled(): # report is portfolio related analysis self.update_report(trade_start_time, trade_end_time) @@ -283,3 +300,16 @@ class Account: self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) self.indicator.record(trade_start_time) + + def get_report(self): + """get the history report and postions instance""" + if self.is_port_metr_enabled(): + _report = self.report.generate_report_dataframe() + _positions = self.get_positions() + return _report, _positions + else: + raise ValueError("generate_report should be True if you want to generate report") + + def get_trade_indicator(self) -> Indicator: + """get the trade indicator instance, which has pa/pos/ffr info.""" + return self.indicator diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 48d06db6c..573c874b0 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -69,13 +69,13 @@ def collect_data_loop( all_executors = trade_executor.get_all_executors() all_reports = { - "{}{}".format(*Freq.parse(_executor.time_per_step)): _executor.get_report() + "{}{}".format(*Freq.parse(_executor.time_per_step)): _executor.trade_account.get_report() for _executor in all_executors - if _executor.generate_report + if _executor.trade_account.is_port_metr_enabled() } all_indicators = {} for _executor in all_executors: key = "{}{}".format(*Freq.parse(_executor.time_per_step)) - all_indicators[key] = _executor.get_trade_indicator().generate_trade_indicators_dataframe() - all_indicators[key + "_obj"] = _executor.get_trade_indicator() + all_indicators[key] = _executor.trade_account.get_trade_indicator().generate_trade_indicators_dataframe() + all_indicators[key + "_obj"] = _executor.trade_account.get_trade_indicator() return_value.update({"report": all_reports, "indicator": all_indicators}) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 14d97e825..adea9dde0 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -103,8 +103,10 @@ class BaseExecutor: self.common_infra.update(common_infra) if common_infra.has("trade_account"): + # NOTE: there is a trick in the code. + # copy is used instead of deepcopy. So positions are shared self.trade_account = copy.copy(common_infra.get("trade_account")) - self.trade_account.reset(freq=self.time_per_step, init_report=True) + self.trade_account.reset(freq=self.time_per_step, init_report=True, port_metr_enabled=self.generate_report) def reset(self, track_data: bool = None, common_infra: CommonInfrastructure = None, **kwargs): """ @@ -167,19 +169,6 @@ class BaseExecutor: yield trade_decision return self.execute(trade_decision) - def get_report(self): - """get the history report and postions instance""" - if self.generate_report: - _report = self.trade_account.report.generate_report_dataframe() - _positions = self.trade_account.get_positions() - return _report, _positions - else: - raise ValueError("generate_report should be True if you want to generate report") - - def get_trade_indicator(self) -> Indicator: - """get the trade indicator instance, which has pa/pos/ffr info.""" - return self.trade_account.indicator - def get_all_executors(self): """get all executors""" return [self] @@ -289,21 +278,19 @@ class NestedExecutor(BaseExecutor): _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) execute_result.extend(_inner_execute_result) - inner_order_indicators.append(self.inner_executor.get_trade_indicator().get_order_indicator()) + inner_order_indicators.append(self.inner_executor.trade_account.get_trade_indicator().get_order_indicator()) - if hasattr(self, "trade_account"): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) - self.trade_account.update_bar_end( - trade_start_time, - trade_end_time, - self.trade_exchange, - atomic=False, - outer_trade_decision=trade_decision, - generate_report=self.generate_report, - inner_order_indicators=inner_order_indicators, - indicator_config=self.indicator_config, - ) + trade_step = self.trade_calendar.get_trade_step() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + self.trade_account.update_bar_end( + trade_start_time, + trade_end_time, + self.trade_exchange, + atomic=False, + outer_trade_decision=trade_decision, + inner_order_indicators=inner_order_indicators, + indicator_config=self.indicator_config, + ) self.trade_calendar.step() if return_value is not None: @@ -457,7 +444,6 @@ class SimulatorExecutor(BaseExecutor): self.trade_exchange, atomic=True, outer_trade_decision=trade_decision, - generate_report=self.generate_report, trade_info=execute_result, indicator_config=self.indicator_config, ) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index c8a326e80..a787c098f 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -10,6 +10,8 @@ from ..utils import init_instance_by_config from ..backtest.utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager from ..backtest.order import BaseTradeDecision +__all__ = ["BaseStrategy", "ModelStrategy", "RLStrategy", "RLIntStrategy"] + class BaseStrategy: """Base strategy for trading""" From e42aa67f529ae7ec85cff23d3915f22abeaa07c5 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 6 Jul 2021 12:08:53 +0000 Subject: [PATCH 089/187] Supporting skip empty decisions --- qlib/backtest/executor.py | 44 ++++++++++++++++++++++++++------------- qlib/backtest/order.py | 5 ++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index adea9dde0..c4807ebde 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -191,6 +191,7 @@ class NestedExecutor(BaseExecutor): generate_report: bool = False, verbose: bool = False, track_data: bool = False, + skip_empty_decision: bool = True, trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, **kwargs, @@ -206,6 +207,11 @@ class NestedExecutor(BaseExecutor): exchange that provides market info, used to generate report - If generate_report is None, trade_exchange will be ignored - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra + skip_empty_decision: bool + Will the executor skip the inner loop when the decision is empty. + It should be False in following cases + - The decisions may be updated by steps + - The inner executor may not follow the decisions from the outer strategy """ self.inner_executor = init_instance_by_config( inner_executor, common_infra=common_infra, accept_types=BaseExecutor @@ -214,6 +220,8 @@ class NestedExecutor(BaseExecutor): inner_strategy, common_infra=common_infra, accept_types=BaseStrategy ) + self._skip_empty_decision = skip_empty_decision + super(NestedExecutor, self).__init__( time_per_step=time_per_step, start_time=start_time, @@ -259,26 +267,32 @@ class NestedExecutor(BaseExecutor): def collect_data(self, trade_decision: BaseTradeDecision, return_value=None): if self.track_data: yield trade_decision - self._init_sub_trading(trade_decision) execute_result = [] inner_order_indicators = [] - _inner_execute_result = None - while not self.inner_executor.finished(): - # outter strategy have chance to update decision each iterator - updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) - if updated_trade_decision is not None: - trade_decision = updated_trade_decision - # NEW UPDATE - # create a hook for inner strategy to update outter decision - self.inner_strategy.alter_outer_trade_decision(trade_decision) - _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + if not (trade_decision.empty() and self._skip_empty_decision): + _inner_execute_result = None + self._init_sub_trading(trade_decision) + while not self.inner_executor.finished(): + # outter strategy have chance to update decision each iterator + updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) + if updated_trade_decision is not None: + trade_decision = updated_trade_decision + # NEW UPDATE + # create a hook for inner strategy to update outter decision + self.inner_strategy.alter_outer_trade_decision(trade_decision) - # NOTE: Trade Calendar will step forward in the follow line - _inner_execute_result = yield from self.inner_executor.collect_data(trade_decision=_inner_trade_decision) + _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) - execute_result.extend(_inner_execute_result) - inner_order_indicators.append(self.inner_executor.trade_account.get_trade_indicator().get_order_indicator()) + # NOTE: Trade Calendar will step forward in the follow line + _inner_execute_result = yield from self.inner_executor.collect_data( + trade_decision=_inner_trade_decision + ) + + execute_result.extend(_inner_execute_result) + inner_order_indicators.append( + self.inner_executor.trade_account.get_trade_indicator().get_order_indicator() + ) trade_step = self.trade_calendar.get_trade_step() trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 1767deb62..fb9b8edd7 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -196,7 +196,7 @@ class BaseTradeDecision: Example: []: Decision not available - concrete_decision: + [concrete_decision]: available """ raise NotImplementedError(f"This type of input is not supported") @@ -235,6 +235,9 @@ class BaseTradeDecision: """ raise NotImplementedError(f"Please implement the `func` method") + def empty(self) -> bool: + return len(self.get_decision()) == 0 + class TradeDecisionWO(BaseTradeDecision): """ From d6984a3f2de2d1f007dbd54c129638fd17f48352 Mon Sep 17 00:00:00 2001 From: xixi <920435730@qq.com> Date: Sat, 19 Jun 2021 17:32:28 +0800 Subject: [PATCH 090/187] fill_placehorder --- qlib/model/trainer.py | 37 ++++++++++++++++++---- test.yaml | 72 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 test.yaml diff --git a/qlib/model/trainer.py b/qlib/model/trainer.py index 28d854477..44a7e56d2 100644 --- a/qlib/model/trainer.py +++ b/qlib/model/trainer.py @@ -45,6 +45,35 @@ def begin_task_train(task_config: dict, experiment_name: str, recorder_name: str return recorder +def fill_placeholder(kwargs, model, dataset): + """ + Detect placeholder( and ) in dict and fill them. + + Args: + kwargs (Dict): the parameter dict will be filled + model (Model): fill + dataset (Dataset): fill + + Returns: + Dict: the parameter dict + """ + top = 0 + tail = 1 + dict_quene = [kwargs] + while(top < tail): + now_dict = dict_quene[top] + top += 1 + for key in now_dict.keys(): + if(isinstance(now_dict[key], dict)): + dict_quene.append(now_dict[key]) + tail += 1 + elif(now_dict[key] == ""): + now_dict[key] = model + elif(now_dict[key] == ""): + now_dict[key] = dataset + return kwargs + + def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: """ Finish task training with real model fitting and saving. @@ -73,13 +102,9 @@ def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: records = [records] for record in records: cls, kwargs = get_cls_kwargs(record, default_module="qlib.workflow.record_temp") - if cls is SignalRecord: - rconf = {"model": model, "dataset": dataset, "recorder": rec} - else: - rconf = {"recorder": rec} - r = cls(**kwargs, **rconf) + kwargs = fill_placeholder(kwargs, model, dataset) + r = cls(**kwargs, **{"record", record}) r.generate() - return rec diff --git a/test.yaml b/test.yaml new file mode 100644 index 000000000..c8287cf36 --- /dev/null +++ b/test.yaml @@ -0,0 +1,72 @@ +qlib_init: + provider_uri: "~/.qlib/qlib_data/cn_data" + region: cn +market: &market csi300 +benchmark: &benchmark SH000300 +data_handler_config: &data_handler_config + start_time: 2008-01-01 + end_time: 2020-08-01 + fit_start_time: 2008-01-01 + fit_end_time: 2014-12-31 + instruments: *market +port_analysis_config: &port_analysis_config + strategy: + class: TopkDropoutStrategy + module_path: qlib.contrib.strategy.strategy + kwargs: + model: + dataset: + topk: 50 + n_drop: 5 + backtest: + verbose: False + limit_threshold: 0.095 + account: 100000000 + benchmark: *benchmark + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 +task: + model: + class: LGBModel + module_path: qlib.contrib.model.gbdt + kwargs: + loss: mse + colsample_bytree: 0.8879 + learning_rate: 0.2 + subsample: 0.8789 + lambda_l1: 205.6999 + lambda_l2: 580.9768 + max_depth: 8 + num_leaves: 210 + num_threads: 20 + dataset: + class: DatasetH + module_path: qlib.data.dataset + kwargs: + handler: + class: Alpha158 + module_path: qlib.contrib.data.handler + kwargs: *data_handler_config + segments: + train: [2008-01-01, 2014-12-31] + valid: [2015-01-01, 2016-12-31] + test: [2017-01-01, 2020-08-01] + record: + - class: SignalRecord + module_path: qlib.workflow.record_temp + kwargs: + model: + dataset: + - class: SigAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + model: + dataset: + ana_long_short: False + ann_scaler: 252 + - class: PortAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + config: *port_analysis_config \ No newline at end of file From d1b8ed96134e859ca67f98c02201a170277850a7 Mon Sep 17 00:00:00 2001 From: xixi <920435730@qq.com> Date: Tue, 22 Jun 2021 10:04:38 +0800 Subject: [PATCH 091/187] fix qrun --- qlib/model/trainer.py | 3 +- test.yaml | 72 ------------------------------------------- 2 files changed, 2 insertions(+), 73 deletions(-) delete mode 100644 test.yaml diff --git a/qlib/model/trainer.py b/qlib/model/trainer.py index 44a7e56d2..8ba7c13c3 100644 --- a/qlib/model/trainer.py +++ b/qlib/model/trainer.py @@ -103,7 +103,8 @@ def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: for record in records: cls, kwargs = get_cls_kwargs(record, default_module="qlib.workflow.record_temp") kwargs = fill_placeholder(kwargs, model, dataset) - r = cls(**kwargs, **{"record", record}) + kwargs["recorder"] = rec + r = cls(**kwargs) r.generate() return rec diff --git a/test.yaml b/test.yaml deleted file mode 100644 index c8287cf36..000000000 --- a/test.yaml +++ /dev/null @@ -1,72 +0,0 @@ -qlib_init: - provider_uri: "~/.qlib/qlib_data/cn_data" - region: cn -market: &market csi300 -benchmark: &benchmark SH000300 -data_handler_config: &data_handler_config - start_time: 2008-01-01 - end_time: 2020-08-01 - fit_start_time: 2008-01-01 - fit_end_time: 2014-12-31 - instruments: *market -port_analysis_config: &port_analysis_config - strategy: - class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy - kwargs: - model: - dataset: - topk: 50 - n_drop: 5 - backtest: - verbose: False - limit_threshold: 0.095 - account: 100000000 - benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 -task: - model: - class: LGBModel - module_path: qlib.contrib.model.gbdt - kwargs: - loss: mse - colsample_bytree: 0.8879 - learning_rate: 0.2 - subsample: 0.8789 - lambda_l1: 205.6999 - lambda_l2: 580.9768 - max_depth: 8 - num_leaves: 210 - num_threads: 20 - dataset: - class: DatasetH - module_path: qlib.data.dataset - kwargs: - handler: - class: Alpha158 - module_path: qlib.contrib.data.handler - kwargs: *data_handler_config - segments: - train: [2008-01-01, 2014-12-31] - valid: [2015-01-01, 2016-12-31] - test: [2017-01-01, 2020-08-01] - record: - - class: SignalRecord - module_path: qlib.workflow.record_temp - kwargs: - model: - dataset: - - class: SigAnaRecord - module_path: qlib.workflow.record_temp - kwargs: - model: - dataset: - ana_long_short: False - ann_scaler: 252 - - class: PortAnaRecord - module_path: qlib.workflow.record_temp - kwargs: - config: *port_analysis_config \ No newline at end of file From 85c75a6639b1f76057a48cce53b2a261635176e0 Mon Sep 17 00:00:00 2001 From: "Wenxi Wang (FA Talent)" Date: Tue, 22 Jun 2021 17:11:00 +0800 Subject: [PATCH 092/187] config_extend --- qlib/model/trainer.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/qlib/model/trainer.py b/qlib/model/trainer.py index 8ba7c13c3..5c66fb368 100644 --- a/qlib/model/trainer.py +++ b/qlib/model/trainer.py @@ -45,21 +45,25 @@ def begin_task_train(task_config: dict, experiment_name: str, recorder_name: str return recorder -def fill_placeholder(kwargs, model, dataset): +def fill_placeholder(config: dict, config_extend: dict): """ - Detect placeholder( and ) in dict and fill them. + Detect placeholder in config and fill them with config_extend. - Args: - kwargs (Dict): the parameter dict will be filled - model (Model): fill - dataset (Dataset): fill + Parameters + ---------- + config : dict + the parameter dict will be filled + config_extend : dict + the value of all placeholders - Returns: - Dict: the parameter dict - """ + Returns + ------- + dict + the parameter dict + """ top = 0 tail = 1 - dict_quene = [kwargs] + dict_quene = [config] while(top < tail): now_dict = dict_quene[top] top += 1 @@ -67,11 +71,9 @@ def fill_placeholder(kwargs, model, dataset): if(isinstance(now_dict[key], dict)): dict_quene.append(now_dict[key]) tail += 1 - elif(now_dict[key] == ""): - now_dict[key] = model - elif(now_dict[key] == ""): - now_dict[key] = dataset - return kwargs + elif(now_dict[key] in config_extend.keys()): + now_dict[key] = config_extend[now_dict[key]] + return config def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: @@ -90,6 +92,7 @@ def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: # model & dataset initiation model: Model = init_instance_by_config(task_config["model"]) dataset: Dataset = init_instance_by_config(task_config["dataset"]) + placehorder_value = {"": model, "": dataset} # model training model.fit(dataset) R.save_objects(**{"params.pkl": model}) @@ -102,7 +105,7 @@ def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: records = [records] for record in records: cls, kwargs = get_cls_kwargs(record, default_module="qlib.workflow.record_temp") - kwargs = fill_placeholder(kwargs, model, dataset) + kwargs = fill_placeholder(kwargs, placehorder_value) kwargs["recorder"] = rec r = cls(**kwargs) r.generate() From cbe7c5285a8a967d7a64827abf069d6d584566b2 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 23 Jun 2021 11:52:21 +0800 Subject: [PATCH 093/187] high_fre_yaml --- .../highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml b/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml index 45c59c670..0152cfd63 100644 --- a/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml +++ b/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml @@ -59,7 +59,9 @@ task: record: - class: "SignalRecord" module_path: "qlib.workflow.record_temp" - kwargs: {} + kwargs: + model: + dataset: - class: "HFSignalRecord" module_path: "qlib.workflow.record_temp" kwargs: {} \ No newline at end of file From bd6080b8f5a69e02e73bb5c7e0bdc1c5b3828438 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 23 Jun 2021 11:54:05 +0800 Subject: [PATCH 094/187] yaml update --- .../ALSTM/workflow_config_alstm_Alpha158.yaml | 36 +++++++++++---- .../ALSTM/workflow_config_alstm_Alpha360.yaml | 38 ++++++++++++---- .../workflow_config_catboost_Alpha158.yaml | 36 +++++++++++---- .../workflow_config_catboost_Alpha360.yaml | 36 +++++++++++---- ...rkflow_config_doubleensemble_Alpha158.yaml | 42 +++++++++++++----- ...rkflow_config_doubleensemble_Alpha360.yaml | 44 ++++++++++++++----- .../GATs/workflow_config_gats_Alpha158.yaml | 38 ++++++++++++---- .../GATs/workflow_config_gats_Alpha360.yaml | 38 ++++++++++++---- .../GRU/workflow_config_gru_Alpha158.yaml | 36 +++++++++++---- .../GRU/workflow_config_gru_Alpha360.yaml | 38 ++++++++++++---- .../LSTM/workflow_config_lstm_Alpha158.yaml | 36 +++++++++++---- .../LSTM/workflow_config_lstm_Alpha360.yaml | 38 ++++++++++++---- .../workflow_config_lightgbm_Alpha158.yaml | 38 ++++++++++++---- .../workflow_config_lightgbm_Alpha360.yaml | 38 ++++++++++++---- ..._config_lightgbm_configurable_dataset.yaml | 38 ++++++++++++---- .../workflow_config_linear_Alpha158.yaml | 44 ++++++++++++++----- .../MLP/workflow_config_mlp_Alpha158.yaml | 38 ++++++++++++---- .../MLP/workflow_config_mlp_Alpha360.yaml | 38 ++++++++++++---- .../SFM/workflow_config_sfm_Alpha360.yaml | 36 +++++++++++---- .../TCTS/workflow_config_tcts_Alpha360.yaml | 38 ++++++++++++---- .../TFT/workflow_config_tft_Alpha158.yaml | 36 +++++++++++---- .../workflow_config_TabNet_Alpha158.yaml | 36 +++++++++++---- .../workflow_config_TabNet_Alpha360.yaml | 36 +++++++++++---- .../workflow_config_xgboost_Alpha158.yaml | 36 +++++++++++---- .../workflow_config_xgboost_Alpha360.yaml | 36 +++++++++++---- 25 files changed, 722 insertions(+), 222 deletions(-) diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml index 878e3c065..74c69873f 100755 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml @@ -34,19 +34,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: ALSTM @@ -81,13 +96,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml index 6226cdaf2..95eab9f76 100644 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: ALSTM @@ -71,13 +86,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml index af556dc87..72f906f0e 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml @@ -12,19 +12,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: CatBoostModel @@ -53,13 +68,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml index f7dc26f5d..82e7d8bf2 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml @@ -19,19 +19,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: CatBoostModel @@ -60,13 +75,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml index a12df802d..a18399e9d 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml @@ -12,19 +12,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: DEnsembleModel @@ -75,16 +90,21 @@ task: train: [2008-01-01, 2014-12-31] valid: [2015-01-01, 2016-12-31] test: [2017-01-01, 2020-08-01] - record: + record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp - kwargs: + kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp - kwargs: + kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml index 415448f0b..50c908253 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml @@ -19,19 +19,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: DEnsembleModel @@ -82,16 +97,21 @@ task: train: [2008-01-01, 2014-12-31] valid: [2015-01-01, 2016-12-31] test: [2017-01-01, 2020-08-01] - record: + record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp - kwargs: + kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp - kwargs: - config: *port_analysis_config \ No newline at end of file + kwargs: + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml index 71454e7f9..643c73d49 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml @@ -33,19 +33,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: GATs @@ -80,13 +95,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml index d778c9b1b..fd6051dbe 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: GATs @@ -72,13 +87,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml index d3078314c..0418afda2 100755 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml @@ -34,19 +34,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: GRU @@ -80,13 +95,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml index 2494d40f0..fad0a27e6 100644 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: GRU @@ -70,13 +85,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml index 14dd69d0a..e4b71cd52 100755 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml @@ -34,19 +34,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: LSTM @@ -80,13 +95,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml index 2aa5fd061..4690e9f78 100644 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: LSTM @@ -70,13 +85,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml index 88202be1b..38fa6a043 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml @@ -12,19 +12,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: LGBModel @@ -54,13 +69,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml index 92df15133..f5b94ce1b 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml @@ -19,19 +19,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: LGBModel @@ -61,13 +76,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml index 335dc2093..4454f884e 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml @@ -27,19 +27,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: LGBModel @@ -69,13 +84,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml index ef2fee4c5..179538149 100644 --- a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml +++ b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: LinearModel @@ -57,16 +72,21 @@ task: train: [2008-01-01, 2014-12-31] valid: [2015-01-01, 2016-12-31] test: [2017-01-01, 2020-08-01] - record: + record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp - kwargs: - ana_long_short: True + kwargs: + ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp - kwargs: + kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml index b177a810b..befd4b62c 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml @@ -39,19 +39,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: DNNModelPytorch @@ -83,13 +98,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml index 18920399f..cc0e9a62c 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml @@ -27,19 +27,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: DNNModelPytorch @@ -70,13 +85,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml index a23fe3854..885eddebf 100644 --- a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml +++ b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: SFM @@ -73,13 +88,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml index 589f4b43e..9e97ad874 100644 --- a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml +++ b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml @@ -30,19 +30,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: TCTS @@ -81,13 +96,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config + risk_analysis_freq: day \ No newline at end of file diff --git a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml index dba37ab63..92b64cfbf 100644 --- a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml +++ b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml @@ -14,19 +14,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: TFTModel @@ -46,13 +61,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml index cf8ef7411..c8ebaf8fb 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: TabnetModel @@ -63,13 +78,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml index 5023e9b3d..fffdfcad1 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml @@ -26,19 +26,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: TabnetModel @@ -63,13 +78,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml index 4caaa6f62..6d6762027 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml @@ -12,19 +12,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: XGBModel @@ -52,13 +67,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml index 7887a25a6..ed8e8c495 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml @@ -19,19 +19,34 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + executor: + class: NestedExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: week + inner_executor: + class: SimulatorExecutor + module_path: qlib.backtest.executor + kwargs: + time_per_step: day + generate_report: True + verbose: True + inner_strategy: + class: TWAPStrategy + module_path: qlib.contrib.strategy.rule_strategy + track_data: True + generate_report: True task: model: class: XGBModel @@ -59,13 +74,18 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: ana_long_short: False ann_scaler: 252 + model: + dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config + risk_analysis_freq: day From 4488c3b625000ff2ee2022fde30f4cf843308b97 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 23 Jun 2021 17:37:39 +0800 Subject: [PATCH 095/187] code optimization --- qlib/model/trainer.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/qlib/model/trainer.py b/qlib/model/trainer.py index 5c66fb368..9429bc15c 100644 --- a/qlib/model/trainer.py +++ b/qlib/model/trainer.py @@ -61,6 +61,13 @@ def fill_placeholder(config: dict, config_extend: dict): dict the parameter dict """ + # check the format of config_extend + import re + for placeholder in config_extend.keys(): + assert re.match(r"<[^<>]+>", placeholder) + re.match() + + # bfs top = 0 tail = 1 dict_quene = [config] @@ -92,20 +99,21 @@ def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: # model & dataset initiation model: Model = init_instance_by_config(task_config["model"]) dataset: Dataset = init_instance_by_config(task_config["dataset"]) - placehorder_value = {"": model, "": dataset} # model training model.fit(dataset) R.save_objects(**{"params.pkl": model}) # this dataset is saved for online inference. So the concrete data should not be dumped dataset.config(dump_all=False, recursive=True) R.save_objects(**{"dataset": dataset}) + # fill placehorder + placehorder_value = {"": model, "": dataset} + task_config = fill_placeholder(task_config, placehorder_value) # generate records: prediction, backtest, and analysis records = task_config.get("record", []) if isinstance(records, dict): # prevent only one dict records = [records] for record in records: cls, kwargs = get_cls_kwargs(record, default_module="qlib.workflow.record_temp") - kwargs = fill_placeholder(kwargs, placehorder_value) kwargs["recorder"] = rec r = cls(**kwargs) r.generate() From 8b28575dad03b5fbfe61df9f6189ed1dfa537411 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Thu, 24 Jun 2021 20:46:20 +0800 Subject: [PATCH 096/187] fill placehorder dict and list --- qlib/model/trainer.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/qlib/model/trainer.py b/qlib/model/trainer.py index 9429bc15c..d8aead0fc 100644 --- a/qlib/model/trainer.py +++ b/qlib/model/trainer.py @@ -13,6 +13,7 @@ In ``DelayTrainer``, the first step is only to save some necessary info to model import socket import time +import re from typing import Callable, List from qlib.data.dataset import Dataset @@ -62,24 +63,26 @@ def fill_placeholder(config: dict, config_extend: dict): the parameter dict """ # check the format of config_extend - import re for placeholder in config_extend.keys(): assert re.match(r"<[^<>]+>", placeholder) - re.match() # bfs top = 0 tail = 1 - dict_quene = [config] + item_quene = [config] while(top < tail): - now_dict = dict_quene[top] + now_item = item_quene[top] top += 1 - for key in now_dict.keys(): - if(isinstance(now_dict[key], dict)): - dict_quene.append(now_dict[key]) + if(isinstance(now_item, list)): + item_keys = range(len(now_item)) + elif(isinstance(now_item, dict)): + item_keys = now_item.keys() + for key in item_keys: + if(isinstance(now_item[key], list) or isinstance(now_item[key], dict)): + item_quene.append(now_item[key]) tail += 1 - elif(now_dict[key] in config_extend.keys()): - now_dict[key] = config_extend[now_dict[key]] + elif(now_item[key] in config_extend.keys()): + now_item[key] = config_extend[now_item[key]] return config From 267ee3555d0536b08008d08ebb959cb7feab8290 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Mon, 28 Jun 2021 21:29:12 +0800 Subject: [PATCH 097/187] fix all example --- .../ALSTM/workflow_config_alstm_Alpha158.yaml | 18 ----------------- .../ALSTM/workflow_config_alstm_Alpha360.yaml | 20 +------------------ .../workflow_config_catboost_Alpha158.yaml | 18 ----------------- .../workflow_config_catboost_Alpha360.yaml | 18 ----------------- ...rkflow_config_doubleensemble_Alpha158.yaml | 18 ----------------- ...rkflow_config_doubleensemble_Alpha360.yaml | 20 +------------------ .../GATs/workflow_config_gats_Alpha158.yaml | 20 +------------------ .../GATs/workflow_config_gats_Alpha360.yaml | 20 +------------------ .../GRU/workflow_config_gru_Alpha158.yaml | 18 ----------------- .../GRU/workflow_config_gru_Alpha360.yaml | 20 +------------------ .../LSTM/workflow_config_lstm_Alpha158.yaml | 18 ----------------- .../LSTM/workflow_config_lstm_Alpha360.yaml | 20 +------------------ .../workflow_config_lightgbm_Alpha158.yaml | 20 +------------------ .../workflow_config_lightgbm_Alpha360.yaml | 20 +------------------ ..._config_lightgbm_configurable_dataset.yaml | 20 +------------------ .../workflow_config_linear_Alpha158.yaml | 18 ----------------- .../MLP/workflow_config_mlp_Alpha158.yaml | 20 +------------------ .../MLP/workflow_config_mlp_Alpha360.yaml | 20 +------------------ .../SFM/workflow_config_sfm_Alpha360.yaml | 18 ----------------- .../TCTS/workflow_config_tcts_Alpha360.yaml | 20 +------------------ .../TFT/workflow_config_tft_Alpha158.yaml | 18 ----------------- .../workflow_config_TabNet_Alpha158.yaml | 18 ----------------- .../workflow_config_TabNet_Alpha360.yaml | 18 ----------------- .../workflow_config_xgboost_Alpha158.yaml | 18 ----------------- .../workflow_config_xgboost_Alpha360.yaml | 18 ----------------- 25 files changed, 12 insertions(+), 462 deletions(-) diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml index 74c69873f..b39673880 100755 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml @@ -45,23 +45,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: ALSTM @@ -110,4 +93,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml index 95eab9f76..b03df6c4d 100644 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: ALSTM @@ -99,5 +82,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml index 72f906f0e..f36aec008 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml @@ -23,23 +23,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: CatBoostModel @@ -82,4 +65,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml index 82e7d8bf2..12241f226 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml @@ -30,23 +30,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: CatBoostModel @@ -89,4 +72,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml index a18399e9d..ce4b55ff3 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml @@ -23,23 +23,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: DEnsembleModel @@ -107,4 +90,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml index 50c908253..93e142d0d 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml @@ -30,23 +30,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: DEnsembleModel @@ -113,5 +96,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml index 643c73d49..fc4188623 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml @@ -44,23 +44,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: GATs @@ -108,5 +91,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml index fd6051dbe..b9df52baa 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: GATs @@ -100,5 +83,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml index 0418afda2..e8a5dc612 100755 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml @@ -45,23 +45,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: GRU @@ -109,4 +92,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml index fad0a27e6..ffdc6fb66 100644 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: GRU @@ -98,5 +81,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml index e4b71cd52..7cbfb357d 100755 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml @@ -45,23 +45,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: LSTM @@ -109,4 +92,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml index 4690e9f78..1f6d11dfd 100644 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: LSTM @@ -98,5 +81,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml index 38fa6a043..4eb919505 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml @@ -23,23 +23,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: LGBModel @@ -82,5 +65,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml index f5b94ce1b..249d74fce 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml @@ -30,23 +30,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: LGBModel @@ -89,5 +72,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml index 4454f884e..2c3def064 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml @@ -38,23 +38,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: LGBModel @@ -97,5 +80,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml index 179538149..5c5e08a30 100644 --- a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml +++ b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: LinearModel @@ -89,4 +72,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml index befd4b62c..fc9a973c0 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml @@ -50,23 +50,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: DNNModelPytorch @@ -111,5 +94,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml index cc0e9a62c..ffd9499c3 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml @@ -38,23 +38,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: DNNModelPytorch @@ -98,5 +81,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml index 885eddebf..5c4536d44 100644 --- a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml +++ b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: SFM @@ -102,4 +85,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml index 9e97ad874..0c2b40723 100644 --- a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml +++ b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml @@ -41,23 +41,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: TCTS @@ -109,5 +92,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config - risk_analysis_freq: day \ No newline at end of file + config: *port_analysis_config \ No newline at end of file diff --git a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml index 92b64cfbf..74994d875 100644 --- a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml +++ b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml @@ -25,23 +25,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: TFTModel @@ -75,4 +58,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml index c8ebaf8fb..92b3f8933 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: TabnetModel @@ -92,4 +75,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml index fffdfcad1..2211ef66e 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml @@ -37,23 +37,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: TabnetModel @@ -92,4 +75,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml index 6d6762027..6feed7cc4 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml @@ -23,23 +23,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: XGBModel @@ -81,4 +64,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml index ed8e8c495..f6a07f3a7 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml @@ -30,23 +30,6 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - executor: - class: NestedExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: week - inner_executor: - class: SimulatorExecutor - module_path: qlib.backtest.executor - kwargs: - time_per_step: day - generate_report: True - verbose: True - inner_strategy: - class: TWAPStrategy - module_path: qlib.contrib.strategy.rule_strategy - track_data: True - generate_report: True task: model: class: XGBModel @@ -88,4 +71,3 @@ task: module_path: qlib.workflow.record_temp kwargs: config: *port_analysis_config - risk_analysis_freq: day From 93796bdcefe8d11a5cb9def02b3e2ac6d5105282 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 29 Jun 2021 21:34:36 +0800 Subject: [PATCH 098/187] add exchange kwargs --- .../benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml | 6 ++++++ .../benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml | 6 ++++++ .../CatBoost/workflow_config_catboost_Alpha158.yaml | 6 ++++++ .../CatBoost/workflow_config_catboost_Alpha360.yaml | 6 ++++++ .../workflow_config_doubleensemble_Alpha158.yaml | 6 ++++++ .../workflow_config_doubleensemble_Alpha360.yaml | 8 +++++++- .../benchmarks/GATs/workflow_config_gats_Alpha158.yaml | 6 ++++++ .../benchmarks/GATs/workflow_config_gats_Alpha360.yaml | 6 ++++++ examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml | 6 ++++++ examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml | 6 ++++++ .../benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml | 6 ++++++ .../benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml | 6 ++++++ .../LightGBM/workflow_config_lightgbm_Alpha158.yaml | 6 ++++++ .../LightGBM/workflow_config_lightgbm_Alpha360.yaml | 6 ++++++ .../workflow_config_lightgbm_configurable_dataset.yaml | 6 ++++++ .../Linear/workflow_config_linear_Alpha158.yaml | 8 +++++++- examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml | 6 ++++++ examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml | 6 ++++++ examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml | 6 ++++++ .../benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml | 6 ++++++ examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml | 6 ++++++ .../TabNet/workflow_config_TabNet_Alpha158.yaml | 6 ++++++ .../TabNet/workflow_config_TabNet_Alpha360.yaml | 6 ++++++ .../XGBoost/workflow_config_xgboost_Alpha158.yaml | 6 ++++++ .../XGBoost/workflow_config_xgboost_Alpha360.yaml | 6 ++++++ 25 files changed, 152 insertions(+), 2 deletions(-) diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml index b39673880..ea38ae19c 100755 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml @@ -45,6 +45,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: ALSTM diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml index b03df6c4d..83720b4b2 100644 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: ALSTM diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml index f36aec008..0ffe19e1b 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml @@ -23,6 +23,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: CatBoostModel diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml index 12241f226..57c1751a1 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml @@ -30,6 +30,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: CatBoostModel diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml index ce4b55ff3..71f0d3e64 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml @@ -23,6 +23,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: DEnsembleModel diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml index 93e142d0d..8a185f05f 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml @@ -30,6 +30,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: DEnsembleModel @@ -88,7 +94,7 @@ task: dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp - kwargs: + kwargs: ana_long_short: False ann_scaler: 252 model: diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml index fc4188623..37a992335 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml @@ -44,6 +44,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: GATs diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml index b9df52baa..c37fd0ee5 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: GATs diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml index e8a5dc612..42286fecd 100755 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml @@ -45,6 +45,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: GRU diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml index ffdc6fb66..bd1a6e1bf 100644 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: GRU diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml index 7cbfb357d..687404419 100755 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml @@ -45,6 +45,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LSTM diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml index 1f6d11dfd..e6c3b5736 100644 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LSTM diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml index 4eb919505..9d6f45076 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml @@ -23,6 +23,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LGBModel diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml index 249d74fce..ba96b076c 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml @@ -30,6 +30,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LGBModel diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml index 2c3def064..0f71b2a36 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml @@ -38,6 +38,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LGBModel diff --git a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml index 5c5e08a30..1cf28024e 100644 --- a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml +++ b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LinearModel @@ -64,7 +70,7 @@ task: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: - ana_long_short: False + ana_long_short: True ann_scaler: 252 model: dataset: diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml index fc9a973c0..bc005b43e 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml @@ -50,6 +50,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: DNNModelPytorch diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml index ffd9499c3..a4ceab8da 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml @@ -38,6 +38,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: DNNModelPytorch diff --git a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml index 5c4536d44..e42f75aec 100644 --- a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml +++ b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: SFM diff --git a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml index 0c2b40723..ce336af6c 100644 --- a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml +++ b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml @@ -41,6 +41,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: TCTS diff --git a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml index 74994d875..a396371dc 100644 --- a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml +++ b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml @@ -25,6 +25,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: TFTModel diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml index 92b3f8933..71d41be63 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: TabnetModel diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml index 2211ef66e..f43af104c 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml @@ -37,6 +37,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: TabnetModel diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml index 6feed7cc4..dee169f18 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml @@ -23,6 +23,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: XGBModel diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml index f6a07f3a7..926224f84 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml @@ -30,6 +30,12 @@ port_analysis_config: &port_analysis_config end_time: 2020-08-01 account: 100000000 benchmark: *benchmark + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: XGBModel From 8c743a46c798f16cd89b486815d228205bca7dca Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sun, 4 Jul 2021 20:25:35 +0800 Subject: [PATCH 099/187] use init_instance_by_config --- qlib/model/trainer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qlib/model/trainer.py b/qlib/model/trainer.py index d8aead0fc..0f1e76413 100644 --- a/qlib/model/trainer.py +++ b/qlib/model/trainer.py @@ -49,6 +49,7 @@ def begin_task_train(task_config: dict, experiment_name: str, recorder_name: str def fill_placeholder(config: dict, config_extend: dict): """ Detect placeholder in config and fill them with config_extend. + The item of dict must be single item(int, str, etc), dict and list. Tuples are not supported. Parameters ---------- @@ -116,9 +117,7 @@ def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: if isinstance(records, dict): # prevent only one dict records = [records] for record in records: - cls, kwargs = get_cls_kwargs(record, default_module="qlib.workflow.record_temp") - kwargs["recorder"] = rec - r = cls(**kwargs) + r = init_instance_by_config(record, recorder = rec) r.generate() return rec From 0c946cffd6a51367f5d0dafbdff594646d88f7b7 Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 7 Jul 2021 10:47:54 +0000 Subject: [PATCH 100/187] add supporting setting trade unit in exchange --- qlib/backtest/__init__.py | 8 +++----- qlib/backtest/exchange.py | 15 ++++++++++----- tests/backtest/test_file_strategy.py | 1 + 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index bc7210259..6aa83e687 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -33,9 +33,9 @@ def get_exchange( open_cost=0.0015, close_cost=0.0025, min_cost=5.0, - trade_unit=None, limit_threshold=None, deal_price: Union[str, Tuple[str], List[str]] = None, + **kwargs, ): """get_exchange @@ -53,7 +53,7 @@ def get_exchange( min_cost : float min transaction cost. trade_unit : int - 100 for China A. + Included in kwargs. Please refer to the docs of `__init__` of `Exchange` deal_price: Union[str, Tuple[str], List[str]] The `deal_price` supports following two types of input - : str @@ -72,8 +72,6 @@ def get_exchange( an initialized Exchange object """ - if trade_unit is None: - trade_unit = C.trade_unit if limit_threshold is None: limit_threshold = C.limit_threshold if exchange is None: @@ -89,8 +87,8 @@ def get_exchange( limit_threshold=limit_threshold, open_cost=open_cost, close_cost=close_cost, - trade_unit=trade_unit, min_cost=min_cost, + **kwargs ) return exchange else: diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 9d4c96f48..26fae378f 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -30,9 +30,9 @@ class Exchange: volume_threshold=None, open_cost=0.0015, close_cost=0.0025, - trade_unit=None, min_cost=5, extra_quote=None, + **kwargs, ): """__init__ @@ -56,7 +56,11 @@ class Exchange: :param volume_threshold: float, 0.1 for example, default None :param open_cost: cost rate for open, default 0.0015 :param close_cost: cost rate for close, default 0.0025 - :param trade_unit: trade unit, 100 for China A market + :param trade_unit: trade unit, 100 for China A market. + None for disable trade unit. + **NOTE**: `trade_unit` is included in the `kwargs`. It is necessary because we must + distinguish `not set` and `disable trade_unit` + :param min_cost: min cost, default 5 :param extra_quote: pandas, dataframe consists of columns: like ['$vwap', '$close', '$volume', '$factor', 'limit_sell', 'limit_buy']. @@ -77,8 +81,10 @@ class Exchange: self.start_time = start_time self.end_time = end_time - if trade_unit is None: - trade_unit = C.trade_unit + self.trade_unit = kwargs.pop("trade_unit", C.trade_unit) + if len(kwargs) > 0: + raise ValueError(f"Get Unexpected arguments {kwargs}") + if limit_threshold is None: limit_threshold = C.limit_threshold if deal_price is None: @@ -86,7 +92,6 @@ class Exchange: self.logger = get_module_logger("online operator", level=logging.INFO) - self.trade_unit = trade_unit # TODO: the quote, trade_dates, codes are not necessray. # It is just for performance consideration. if limit_threshold is None: diff --git a/tests/backtest/test_file_strategy.py b/tests/backtest/test_file_strategy.py index da52b0d53..8210e4809 100644 --- a/tests/backtest/test_file_strategy.py +++ b/tests/backtest/test_file_strategy.py @@ -62,6 +62,7 @@ class FileStrTest(TestAutoData): "close_cost": 0.0015, "min_cost": 5, "codes": codes, + "trade_unit": None, }, # "pos_type": "InfPosition" # Position with infinitive position } From e8f5a1e49164c3c0ae3c74042afaa09330cf3ce6 Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 7 Jul 2021 10:52:52 +0000 Subject: [PATCH 101/187] black format --- qlib/backtest/__init__.py | 2 +- qlib/model/trainer.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 6aa83e687..8471022f4 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -88,7 +88,7 @@ def get_exchange( open_cost=open_cost, close_cost=close_cost, min_cost=min_cost, - **kwargs + **kwargs, ) return exchange else: diff --git a/qlib/model/trainer.py b/qlib/model/trainer.py index 223800252..0dc69297b 100644 --- a/qlib/model/trainer.py +++ b/qlib/model/trainer.py @@ -62,7 +62,7 @@ def fill_placeholder(config: dict, config_extend: dict): ------- dict the parameter dict - """ + """ # check the format of config_extend for placeholder in config_extend.keys(): assert re.match(r"<[^<>]+>", placeholder) @@ -71,18 +71,18 @@ def fill_placeholder(config: dict, config_extend: dict): top = 0 tail = 1 item_quene = [config] - while(top < tail): + while top < tail: now_item = item_quene[top] top += 1 - if(isinstance(now_item, list)): + if isinstance(now_item, list): item_keys = range(len(now_item)) - elif(isinstance(now_item, dict)): + elif isinstance(now_item, dict): item_keys = now_item.keys() for key in item_keys: - if(isinstance(now_item[key], list) or isinstance(now_item[key], dict)): + if isinstance(now_item[key], list) or isinstance(now_item[key], dict): item_quene.append(now_item[key]) tail += 1 - elif(now_item[key] in config_extend.keys()): + elif now_item[key] in config_extend.keys(): now_item[key] = config_extend[now_item[key]] return config @@ -117,7 +117,7 @@ def end_task_train(rec: Recorder, experiment_name: str) -> Recorder: if isinstance(records, dict): # prevent only one dict records = [records] for record in records: - r = init_instance_by_config(record, recorder = rec) + r = init_instance_by_config(record, recorder=rec) r.generate() return rec From 32ae6e42597bb3f64523d42255c116bcbc1524ab Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 8 Jul 2021 05:54:36 +0000 Subject: [PATCH 102/187] fix calculating base_price --- qlib/backtest/account.py | 12 ++- qlib/backtest/exchange.py | 20 ++--- qlib/backtest/order.py | 5 +- qlib/backtest/report.py | 151 +++++++++++++++++++++++++++----------- 4 files changed, 130 insertions(+), 58 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index b394d5823..67f7b056a 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -3,6 +3,7 @@ import copy +from typing import Dict, List from qlib.utils import init_instance_by_config import warnings import pandas as pd @@ -248,7 +249,7 @@ class Account: atomic: bool, outer_trade_decision: BaseTradeDecision, trade_info: list = None, - inner_order_indicators: Indicator = None, + inner_order_indicators: List[Dict[str, pd.Series]] = None, indicator_config: dict = {}, ): """update account at each trading bar step @@ -292,10 +293,15 @@ class Account: self.indicator.clear() if atomic: - self.indicator.update_order_indicators(trade_start_time, trade_end_time, trade_info, trade_exchange) + self.indicator.update_order_indicators(trade_info) else: self.indicator.agg_order_indicators( - inner_order_indicators, indicator_config=indicator_config, outer_trade_decision=outer_trade_decision + trade_start_time, + trade_end_time, + inner_order_indicators, + outer_trade_decision=outer_trade_decision, + trade_exchange=trade_exchange, + indicator_config=indicator_config, ) self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 26fae378f..3794651dc 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -281,27 +281,27 @@ class Exchange: return trade_val, trade_cost, trade_price - def get_quote_info(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id], start_time, end_time, method=ts_data_last) + def get_quote_info(self, stock_id, start_time, end_time, method=ts_data_last): + return resam_ts_data(self.quote[stock_id], start_time, end_time, method=method) - def get_close(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method=ts_data_last) + def get_close(self, stock_id, start_time, end_time, method=ts_data_last): + return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method=method) - def get_volume(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method="sum") + def get_volume(self, stock_id, start_time, end_time, method="sum"): + return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method=method) - def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir): + def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir, method=ts_data_last): if direction == OrderDir.SELL: pstr = self.sell_price elif direction == OrderDir.BUY: pstr = self.buy_price else: raise NotImplementedError(f"This type of input is not supported") - deal_price = resam_ts_data(self.quote[stock_id][pstr], start_time, end_time, method=ts_data_last) - if np.isclose(deal_price, 0.0) or np.isnan(deal_price): + deal_price = resam_ts_data(self.quote[stock_id][pstr], start_time, end_time, method=method) + if method is not None and (np.isclose(deal_price, 0.0) or np.isnan(deal_price)): self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {pstr}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") - deal_price = self.get_close(stock_id, start_time, end_time) + deal_price = self.get_close(stock_id, start_time, end_time, method) return deal_price def get_factor(self, stock_id, start_time, end_time) -> Union[float, None]: diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 1953426fd..20c97aa90 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -93,7 +93,10 @@ class Order: if isinstance(direction, OrderDir): return direction elif isinstance(direction, (int, float, np.integer, np.floating)): - return OrderDir(int(direction)) + if direction > 0: + return Order.BUY + else: + return Order.SELL elif isinstance(direction, str): dl = direction.lower() if dl.strip() == "sell": diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index ce2812bd0..43a6a455b 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -4,9 +4,11 @@ from collections import OrderedDict from logging import warning -from typing import List -from qlib.backtest.order import BaseTradeDecision, Order +from qlib.backtest.exchange import Exchange +from typing import Dict, List +from qlib.backtest.order import BaseTradeDecision, Order, OrderDir import pandas as pd +import numpy as np import pathlib import warnings from pandas.core import groupby @@ -221,6 +223,33 @@ class Report: class Indicator: + """ + `Indicator` is implemented in a aggregate way. + All the metrics are calculated aggregately. + All the metrics are calculated for a seperated stock and in a specific step on a specific level. + + | indicator | desc. | + |--------------+--------------------------------------------------------------| + | amount | the *target* amount given by the outer strategy | + | inner_amount | the total *target* amount of inner strategy | + | trade_price | the average deal price | + | trade_value | the total trade value | + | trade_cost | the total trade cost (base price need drection) | + | trade_dir | the trading direction | + | ffr | full fill rate | + | pa | price advantage | + | pos | win rate | + | base_price | the price of baseline | + | base_volume | the volume of baseline (for weighted aggregating base_price) | + + **NOTE**: + The `base_price` and `base_volume` can't be NaN when there are not trading on that step. Otherwise + aggregating get wrong results. + + So `base_price` will not be calculated in a aggregate way!! + + """ + def __init__(self): self.order_indicator_his = OrderedDict() self.order_indicator = OrderedDict() @@ -241,6 +270,7 @@ class Indicator: trade_price = dict() trade_value = dict() trade_cost = dict() + trade_dir = dict() for order, _trade_val, _trade_cost, _trade_price in trade_info: amount[order.stock_id] = order.amount_delta @@ -248,36 +278,32 @@ class Indicator: trade_price[order.stock_id] = _trade_price trade_value[order.stock_id] = _trade_val * order.sign trade_cost[order.stock_id] = _trade_cost + trade_dir[order.stock_id] = order.direction self.order_indicator["amount"] = self.order_indicator["inner_amount"] = pd.Series(amount) self.order_indicator["deal_amount"] = pd.Series(deal_amount) + # NOTE: trade_price and baseline price will be same on the lowest-level self.order_indicator["trade_price"] = pd.Series(trade_price) self.order_indicator["trade_value"] = pd.Series(trade_value) self.order_indicator["trade_cost"] = pd.Series(trade_cost) + self.order_indicator["trade_dir"] = pd.Series(trade_dir) def _update_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def _update_order_price_advantage(self, trade_exchange, trade_start_time, trade_end_time): - self.order_indicator["base_price"] = self.order_indicator["trade_price"] - instruments = list(self.order_indicator["base_price"].index) - self.order_indicator["volume"] = pd.Series( - [ - trade_exchange.get_volume(stock_id=inst, start_time=trade_start_time, end_time=trade_end_time) - for inst in instruments - ], - index=instruments, - ) - self.order_indicator["pa"] = ( - self.order_indicator["trade_price"] - self.order_indicator["base_price"] - ) / self.order_indicator["base_price"] + def _update_order_price_advantage(self): + # NOTE: + # trade_price and baseline price will be same on the lowest-level + # So Pa should be 0 + self.order_indicator["pa"] = 0 - def _agg_order_trade_info(self, inner_order_indicators): + def _agg_order_trade_info(self, inner_order_indicators: List[Dict[str, pd.Series]]): inner_amount = pd.Series() deal_amount = pd.Series() trade_price = pd.Series() trade_value = pd.Series() trade_cost = pd.Series() + trade_dir = pd.Series() for _order_indicator in inner_order_indicators: inner_amount = inner_amount.add(_order_indicator["inner_amount"], fill_value=0) deal_amount = deal_amount.add(_order_indicator["deal_amount"], fill_value=0) @@ -286,6 +312,9 @@ class Indicator: ) trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) + trade_dir = trade_dir.add(_order_indicator["trade_dir"]) + + trade_dir = trade_dir.apply(Order.parse_dir) self.order_indicator["inner_amount"] = inner_amount self.order_indicator["deal_amount"] = deal_amount @@ -293,6 +322,7 @@ class Indicator: self.order_indicator["trade_price"] = trade_price self.order_indicator["trade_value"] = trade_value self.order_indicator["trade_cost"] = trade_cost + self.order_indicator["trade_dir"] = trade_dir def _update_trade_amount(self, outer_trade_decision: BaseTradeDecision): # NOTE: these indicator is designed for order execution, so the @@ -305,34 +335,59 @@ class Indicator: def _agg_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def _agg_order_price_advantage(self, inner_order_indicators, base_price="twap"): - base_price = base_price.lower() - volume = pd.Series() - for _order_indicator in inner_order_indicators: - volume = volume.add(_order_indicator["volume"], fill_value=0) - self.order_indicator["volume"] = volume + def _agg_order_price_advantage( + self, + inner_order_indicators: List[Dict[str, pd.Series]], + trade_start_time: pd.Timestamp, + trade_end_time: pd.Timestamp, + trade_exchange: Exchange, + pa_config: dict = {}, + ): + """ - if base_price == "twap": - base_price = pd.Series() - price_count = pd.Series() - for _order_indicator in inner_order_indicators: - base_price = base_price.add(_order_indicator["base_price"], fill_value=0) - price_count = price_count.add(pd.Series(1, index=_order_indicator["base_price"].index), fill_value=0) - base_price /= price_count - self.order_indicator["base_price"] = base_price + Parameters + ---------- + inner_order_indicators : List[Dict[str, pd.Series]] + the indicators of account of inner executor + trade_start_time : pd.Timestamp + the start_time of the trade period, for slicing + trade_end_time : pd.Timestamp + the end_time of the trade period, for slicing (so it may include more time at the end) + trade_exchange : Exchange + for retrieving trading price + pa_config : dict + For example + { + "agg": "twap", # "vwap" + "price": "$close", # TODO: this is not supported now!!!!! + # default to use deal price of the exchange + } + """ - elif base_price == "vwap": - base_price = pd.Series() - for _order_indicator in inner_order_indicators: - base_price = base_price.add(_order_indicator["base_price"] * _order_indicator["volume"], fill_value=0) - base_price /= self.order_indicator["volume"] - self.order_indicator["base_price"] = base_price + agg = pa_config.get("agg", "twap").lower() + price = pa_config.get("price", "deal_price").lower() - else: - raise ValueError(f"base_price {base_price} is not supported!") + base_price = {} + for inst, dir in self.order_indicator["trade_dir"].items(): - self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 - # print("trade_price", self.order_indicator["trade_price"], "base_price", self.order_indicator["base_price"], "pa", self.order_indicator["pa"]* (2 * (self.order_indicator["amount"] < 0).astype(int) - 1)) + if price == "deal_price": + price_s = trade_exchange.get_deal_price(inst, trade_start_time, trade_end_time, dir, method=None) + else: + raise NotImplementedError(f"This type of input is not supported") + + # there are some zeros in the trading price. These cases are known meaningless + price_s = price_s.mask(np.isclose(price_s, 0)) + + if agg == "vwap": + volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) + base_price[inst] = ((price_s * volume_s).sum() / volume_s.sum()).item() + elif agg == "twap": + base_price[inst] = price_s.mean().item() + + base_price = pd.Series(base_price) + + # update PA + self.order_indicator["pa"] = self.order_indicator["trade_price"] / base_price - 1 def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": @@ -372,19 +427,27 @@ class Indicator: def _cal_trade_order_count(self): return self.order_indicator["amount"].count() - def update_order_indicators(self, trade_start_time, trade_end_time, trade_info, trade_exchange): + def update_order_indicators(self, trade_info: list): self._update_order_trade_info(trade_info=trade_info) self._update_order_fulfill_rate() - self._update_order_price_advantage(trade_exchange, trade_start_time, trade_end_time) + self._update_order_price_advantage() def agg_order_indicators( - self, inner_order_indicators, outer_trade_decision: BaseTradeDecision, indicator_config={} + self, + trade_start_time, + trade_end_time, + inner_order_indicators: List[Dict[str, pd.Series]], + outer_trade_decision: BaseTradeDecision, + trade_exchange: Exchange, + indicator_config={}, ): self._agg_order_trade_info(inner_order_indicators) self._update_trade_amount(outer_trade_decision) self._agg_order_fulfill_rate() pa_config = indicator_config.get("pa_config", {}) - self._agg_order_price_advantage(inner_order_indicators, base_price=pa_config.get("base_price", "twap")) + self._agg_order_price_advantage( + inner_order_indicators, trade_start_time, trade_end_time, trade_exchange, pa_config=pa_config + ) def cal_trade_indicators(self, trade_start_time, freq, indicator_config={}): show_indicator = indicator_config.get("show_indicator", False) From eada8640b9d8f9e81fad9244c692853a62789c8c Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 8 Jul 2021 13:37:20 +0000 Subject: [PATCH 103/187] align range limit --- qlib/backtest/__init__.py | 2 +- qlib/backtest/account.py | 19 +- qlib/backtest/backtest.py | 5 +- qlib/backtest/executor.py | 275 +++++++++++++++---------- qlib/backtest/order.py | 64 ++++-- qlib/backtest/report.py | 200 ++++++++++++------ qlib/backtest/utils.py | 64 +++++- qlib/contrib/strategy/rule_strategy.py | 26 +-- qlib/strategy/base.py | 15 +- 9 files changed, 438 insertions(+), 232 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index fa57e354b..ab3d29408 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -13,7 +13,7 @@ from .executor import BaseExecutor from .backtest import backtest_loop from .backtest import collect_data_loop from .order import Order -from .utils import CommonInfrastructure, TradeCalendarManager +from .utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager from ..utils import init_instance_by_config from ..log import get_module_logger from ..config import C diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 67f7b056a..3ef1cdd03 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -3,7 +3,7 @@ import copy -from typing import Dict, List +from typing import Dict, List, Tuple from qlib.utils import init_instance_by_config import warnings import pandas as pd @@ -250,6 +250,7 @@ class Account: outer_trade_decision: BaseTradeDecision, trade_info: list = None, inner_order_indicators: List[Dict[str, pd.Series]] = None, + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]] = None, indicator_config: dict = {}, ): """update account at each trading bar step @@ -274,6 +275,9 @@ class Account: indicators of inner executor, by default None - necessary if atomic is False - used to aggregate outer indicators + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]] = None, + The decision list of the inner level: List[Tuple[, , ]] + The inner level indicator_config : dict, optional config of calculating indicators, by default {} """ @@ -289,22 +293,27 @@ class Account: # report is portfolio related analysis self.update_report(trade_start_time, trade_end_time) - # indicator is trading (e.g. high-frequency order execution) related analysis - self.indicator.clear() + # TODO: will skip empty decisions make it faster? `outer_trade_decision.empty():` + # indicator is trading (e.g. high-frequency order execution) related analysis + self.indicator.reset() + + # aggregate the information for each order if atomic: self.indicator.update_order_indicators(trade_info) else: self.indicator.agg_order_indicators( - trade_start_time, - trade_end_time, inner_order_indicators, + decision_list=decision_list, outer_trade_decision=outer_trade_decision, trade_exchange=trade_exchange, indicator_config=indicator_config, ) + # aggregate all the order metrics a single step self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) + + # record the metrics self.indicator.record(trade_start_time) def get_report(self): diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 573c874b0..89b8c7830 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -55,14 +55,13 @@ def collect_data_loop( trade decision """ trade_executor.reset(start_time=start_time, end_time=end_time) - level_infra = trade_executor.get_level_infra() - trade_strategy.reset(level_infra=level_infra) + trade_strategy.reset(level_infra=trade_executor.get_level_infra()) with tqdm(total=trade_executor.trade_calendar.get_trade_len(), desc="backtest loop") as bar: _execute_result = None while not trade_executor.finished(): _trade_decision: BaseTradeDecision = trade_strategy.generate_trade_decision(_execute_result) - _execute_result = yield from trade_executor.collect_data(_trade_decision) + _execute_result = yield from trade_executor.collect_data(_trade_decision, level=0) bar.update(1) if return_value is not None: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index c4807ebde..b99380c54 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -1,13 +1,16 @@ +from abc import abstractclassmethod, abstractmethod import copy +from types import GeneratorType +from qlib.backtest.account import Account import warnings import pandas as pd -from typing import List, Union +from typing import List, Tuple, Union from qlib.backtest.report import Indicator -from .order import Order, BaseTradeDecision +from .order import EmptyTradeDecision, Order, BaseTradeDecision from .exchange import Exchange -from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure +from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure, get_start_end_idx from ..utils import init_instance_by_config from ..utils.time import Freq @@ -26,6 +29,7 @@ class BaseExecutor: generate_report: bool = False, verbose: bool = False, track_data: bool = False, + trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, **kwargs, ): @@ -62,8 +66,8 @@ class BaseExecutor: { 'show_indicator': True, 'pa_config': { - 'base_value': 'twap', - 'weight_method': 'value_weighted', + "agg": "twap", # "vwap" + "price": "$close", # default to use deal price of the exchange }, 'ffr_config':{ 'weight_method': 'value_weighted', @@ -77,6 +81,12 @@ class BaseExecutor: whether to generate trade_decision, will be used when training rl agent - If `self.track_data` is true, when making data for training, the input `trade_decision` of `execute` will be generated by `collect_data` - Else, `trade_decision` will not be generated + + trade_exchange : Exchange + exchange that provides market info, used to generate report + - If generate_report is None, trade_exchange will be ignored + - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra + common_infra : CommonInfrastructure, optional: common infrastructure for backtesting, may including: - trade_account : Account, optional @@ -90,7 +100,9 @@ class BaseExecutor: self.generate_report = generate_report self.verbose = verbose self.track_data = track_data - self.reset(start_time=start_time, end_time=end_time, track_data=track_data, common_infra=common_infra) + self._trade_exchange = trade_exchange + self.level_infra = LevelInfrastructure() + self.reset(start_time=start_time, end_time=end_time, common_infra=common_infra) def reset_common_infra(self, common_infra): """ @@ -105,60 +117,106 @@ class BaseExecutor: if common_infra.has("trade_account"): # NOTE: there is a trick in the code. # copy is used instead of deepcopy. So positions are shared - self.trade_account = copy.copy(common_infra.get("trade_account")) + self.trade_account: Account = copy.copy(common_infra.get("trade_account")) self.trade_account.reset(freq=self.time_per_step, init_report=True, port_metr_enabled=self.generate_report) - def reset(self, track_data: bool = None, common_infra: CommonInfrastructure = None, **kwargs): + @property + def trade_exchange(self) -> Exchange: + """get trade exchange in a prioritized order""" + return getattr(self, "_trade_exchange", None) or self.common_infra.get("trade_exchange") + + @property + def trade_calendar(self) -> TradeCalendarManager: + """ + Though trade calendar can be accessed from multiple sources, but managing in a centralized way will make the + code easier + """ + return self.level_infra.get("trade_calendar") + + def reset(self, common_infra: CommonInfrastructure = None, **kwargs): """ - reset `start_time` and `end_time`, used in trade calendar - - reset `track_data`, used when making data for multi-level training - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc """ - if track_data is not None: - self.track_data = track_data - if "start_time" in kwargs or "end_time" in kwargs: start_time = kwargs.get("start_time") end_time = kwargs.get("end_time") - self.trade_calendar = TradeCalendarManager( - freq=self.time_per_step, start_time=start_time, end_time=end_time - ) - + self.level_infra.reset_cal(freq=self.time_per_step, start_time=start_time, end_time=end_time) if common_infra is not None: self.reset_common_infra(common_infra) def get_level_infra(self): - return LevelInfrastructure(trade_calendar=self.trade_calendar) + return self.level_infra def finished(self): return self.trade_calendar.finished() - def execute(self, trade_decision): + def execute(self, trade_decision: BaseTradeDecision, level: int = 0): """execute the trade decision and return the executed result + NOTE: this function is never used directly in the framework. Should we delete it? + Parameters ---------- trade_decision : BaseTradeDecision + level : int + the level of current executor + Returns ---------- execute_result : List[object] the executed result for trade decision """ - raise NotImplementedError("execute is not implemented!") + return_value = {} + for _decision in self.collect_data(trade_decision, return_value=return_value, level=level): + pass + return return_value.get("execute_result") - def collect_data(self, trade_decision): + @abstractclassmethod + def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0) -> Tuple[List[object], dict]: + """ + Please refer to the doc of collect_data + The only difference between `_collect_data` and `collect_data` is that some common steps are moved into + collect_data + + Parameters + ---------- + Please refer to the doc of collect_data + + + Returns + ------- + Tuple[List[object], dict]: + (, ) + """ + + def collect_data( + self, trade_decision: BaseTradeDecision, return_value: dict = None, level: int = 0 + ) -> List[object]: """Generator for collecting the trade decision data for rl training + his function will make a step forward + Parameters ---------- trade_decision : BaseTradeDecision + level : int + the level of current executor. 0 indicates the top level + + return_value : dict + the mem address to return the value + e.g. {"return_value": } + Returns ---------- execute_result : List[object] - the executed result for trade decision + the executed result for trade decision. + ** NOTE!!!! **: + 1) This is necessary, The return value of geenrator will be used in NestedExecutor + 2) Please note the executed results are not merged. Yields ------- @@ -167,7 +225,36 @@ class BaseExecutor: """ if self.track_data: yield trade_decision - return self.execute(trade_decision) + + atomic = not issubclass(self.__class__, NestedExecutor) # issubclass(A, A) is True + + if atomic and trade_decision.get_range_limit(default_value=None) is not None: + raise ValueError("atomic executor doesn't support specify `range_limit`") + + obj = self._collect_data(trade_decision=trade_decision, level=level) + + if isinstance(obj, GeneratorType): + res, kwargs = yield from obj + else: + # Some concrete executor don't have inner decisions + res, kwargs = obj + + trade_start_time, trade_end_time = self.trade_calendar.get_cur_step_time() + # Account will not be changed in this function + self.trade_account.update_bar_end( + trade_start_time, + trade_end_time, + self.trade_exchange, + atomic=atomic, + outer_trade_decision=trade_decision, + indicator_config=self.indicator_config, + **kwargs, + ) + + self.trade_calendar.step() + if return_value is not None: + return_value.update({"execute_result": res}) + return res def get_all_executors(self): """get all executors""" @@ -192,7 +279,7 @@ class NestedExecutor(BaseExecutor): verbose: bool = False, track_data: bool = False, skip_empty_decision: bool = True, - trade_exchange: Exchange = None, + align_range_limit: bool = True, common_infra: CommonInfrastructure = None, **kwargs, ): @@ -203,24 +290,24 @@ class NestedExecutor(BaseExecutor): trading env in each trading bar. inner_strategy : BaseStrategy trading strategy in each trading bar - trade_exchange : Exchange - exchange that provides market info, used to generate report - - If generate_report is None, trade_exchange will be ignored - - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra skip_empty_decision: bool - Will the executor skip the inner loop when the decision is empty. + Will the executor skip call inner loop when the decision is empty. It should be False in following cases - The decisions may be updated by steps - The inner executor may not follow the decisions from the outer strategy + align_range_limit: bool + force to align the index_range decision + It is only for nested executor, because range_limit is given by outer strategy """ - self.inner_executor = init_instance_by_config( + self.inner_executor: BaseExecutor = init_instance_by_config( inner_executor, common_infra=common_infra, accept_types=BaseExecutor ) - self.inner_strategy = init_instance_by_config( + self.inner_strategy: BaseStrategy = init_instance_by_config( inner_strategy, common_infra=common_infra, accept_types=BaseStrategy ) self._skip_empty_decision = skip_empty_decision + self._align_range_limit = align_range_limit super(NestedExecutor, self).__init__( time_per_step=time_per_step, @@ -234,82 +321,82 @@ class NestedExecutor(BaseExecutor): **kwargs, ) - if trade_exchange is not None: - self.trade_exchange = trade_exchange - def reset_common_infra(self, common_infra): """ reset infrastructure for trading - - reset trade_exchange - reset inner_strategyand inner_executor common infra """ super(NestedExecutor, self).reset_common_infra(common_infra) - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") - self.inner_executor.reset_common_infra(common_infra) self.inner_strategy.reset_common_infra(common_infra) def _init_sub_trading(self, trade_decision): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + trade_start_time, trade_end_time = self.trade_calendar.get_cur_step_time() self.inner_executor.reset(start_time=trade_start_time, end_time=trade_end_time) sub_level_infra = self.inner_executor.get_level_infra() + self.level_infra.set_sub_level_infra(sub_level_infra) self.inner_strategy.reset(level_infra=sub_level_infra, outer_trade_decision=trade_decision) - def execute(self, trade_decision): - return_value = {} - for _decision in self.collect_data(trade_decision, return_value): - pass - return return_value.get("execute_result") + def _update_trade_decision(self, trade_decision: BaseTradeDecision) -> BaseTradeDecision: + # outter strategy have chance to update decision each iterator + updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) + if updated_trade_decision is not None: + trade_decision = updated_trade_decision + # NEW UPDATE + # create a hook for inner strategy to update outter decision + self.inner_strategy.alter_outer_trade_decision(trade_decision) + return trade_decision - def collect_data(self, trade_decision: BaseTradeDecision, return_value=None): - if self.track_data: - yield trade_decision + # def _get_inner_trade_decision(self, outer_trade_decision: BaseTradeDecision, inner_execute_result): + # # In some cases, the inner strategy can be skipped, but the inner executor should keep running + # if outer_trade_decision.empty() and self._skip_empty_decision: + # return EmptyTradeDecision(self.inner_strategy) + # return self.inner_strategy.generate_trade_decision(inner_execute_result) + # _inner_trade_decision = self._get_inner_trade_decision(trade_decision, _inner_execute_result) + + def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): execute_result = [] inner_order_indicators = [] + decision_list = [] + # NOTE: + # - this is necessary to calculating the steps in sub level + # - more detailed information will be set into trade decision + self._init_sub_trading(trade_decision) - if not (trade_decision.empty() and self._skip_empty_decision): - _inner_execute_result = None - self._init_sub_trading(trade_decision) - while not self.inner_executor.finished(): - # outter strategy have chance to update decision each iterator - updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) - if updated_trade_decision is not None: - trade_decision = updated_trade_decision - # NEW UPDATE - # create a hook for inner strategy to update outter decision - self.inner_strategy.alter_outer_trade_decision(trade_decision) + _inner_execute_result = None + while not self.inner_executor.finished(): + trade_decision = self._update_trade_decision(trade_decision) + + if trade_decision.empty() and self._skip_empty_decision: + # give one chance for outer stategy to update the strategy + # - For updating some information in the sub executor(the strategy have no knowledge of the inner + # executor when generating the decision) + break + + sub_cal: TradeCalendarManager = self.inner_executor.trade_calendar + start_idx, end_idx = get_start_end_idx(sub_cal, trade_decision) + if not self._align_range_limit or start_idx <= sub_cal.get_trade_step() <= end_idx: + # if force align the range limit, skip the steps outside the decision range limit _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + # NOTE sub_cal.get_cur_step_time() must be called before collect_data in case of step shifting + decision_list.append((_inner_trade_decision, *sub_cal.get_cur_step_time())) # NOTE: Trade Calendar will step forward in the follow line _inner_execute_result = yield from self.inner_executor.collect_data( - trade_decision=_inner_trade_decision + trade_decision=_inner_trade_decision, level=level + 1 ) - execute_result.extend(_inner_execute_result) + inner_order_indicators.append( self.inner_executor.trade_account.get_trade_indicator().get_order_indicator() ) + else: + # do nothing and just step forward + sub_cal.step() - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) - self.trade_account.update_bar_end( - trade_start_time, - trade_end_time, - self.trade_exchange, - atomic=False, - outer_trade_decision=trade_decision, - inner_order_indicators=inner_order_indicators, - indicator_config=self.indicator_config, - ) - - self.trade_calendar.step() - if return_value is not None: - return_value.update({"execute_result": execute_result}) - return execute_result + return execute_result, {"inner_order_indicators": inner_order_indicators, "decision_list": decision_list} def get_all_executors(self): """get all executors, including self and inner_executor.get_all_executors()""" @@ -337,17 +424,13 @@ class SimulatorExecutor(BaseExecutor): generate_report: bool = False, verbose: bool = False, track_data: bool = False, - trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, - trade_type: str = TT_PARAL, + trade_type: str = TT_SERIAL, **kwargs, ): """ Parameters ---------- - trade_exchange : Exchange - exchange that provides market info, used to deal order and generate report - - If `trade_exchange` is None, self.trade_exchange will be set with common_infra trade_type: str please refer to the doc of `TT_SERIAL` & `TT_PARAL` """ @@ -362,20 +445,9 @@ class SimulatorExecutor(BaseExecutor): common_infra=common_infra, **kwargs, ) - if trade_exchange is not None: - self.trade_exchange = trade_exchange self.trade_type = trade_type - def reset_common_infra(self, common_infra): - """ - reset infrastructure for trading - - reset trade_exchange - """ - super(SimulatorExecutor, self).reset_common_infra(common_infra) - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") - def _get_order_iterator(self, trade_decision: BaseTradeDecision) -> List[Order]: """ @@ -405,10 +477,9 @@ class SimulatorExecutor(BaseExecutor): raise NotImplementedError(f"This type of input is not supported") return order_it - def execute(self, trade_decision: BaseTradeDecision): + def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + trade_start_time, _ = self.trade_calendar.get_cur_step_time() execute_result = [] for order in self._get_order_iterator(trade_decision): @@ -450,16 +521,4 @@ class SimulatorExecutor(BaseExecutor): print("[W {:%Y-%m-%d %H:%M:%S}]: {} wrong.".format(trade_start_time, order.stock_id)) # do nothing pass - - # Account will not be changed in this function - self.trade_account.update_bar_end( - trade_start_time, - trade_end_time, - self.trade_exchange, - atomic=True, - outer_trade_decision=trade_decision, - trade_info=execute_result, - indicator_config=self.indicator_config, - ) - self.trade_calendar.step() - return execute_result + return execute_result, {"trade_info": execute_result} diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 20c97aa90..1a88ded93 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -3,6 +3,7 @@ # TODO: rename it with decision.py from __future__ import annotations from enum import IntEnum +from qlib.log import get_module_logger # try to fix circular imports when enabling type hints from typing import TYPE_CHECKING @@ -179,7 +180,7 @@ class BaseTradeDecision: 2. Same as `case 1.3` """ - def __init__(self, strategy: BaseStrategy): + def __init__(self, strategy: BaseStrategy, idx_range: Tuple[int, int] = None): """ Parameters ---------- @@ -187,6 +188,8 @@ class BaseTradeDecision: The strategy who make the decision """ self.strategy = strategy + self.total_step = None # upper strategy has no knowledge about the sub executor before `_init_sub_trading` + self.idx_range = idx_range def get_decision(self) -> List[object]: """ @@ -207,7 +210,11 @@ class BaseTradeDecision: def update(self, trade_calendar: TradeCalendarManager) -> Union["BaseTradeDecision", None]: """ - Be called at the **start** of each step + Be called at the **start** of each step. + + This function is designn for following purpose + 1) Leave a hook for the strategy who make `self` decision to update the decision itself + 2) Update some information from the inner executor calendar Parameters ---------- @@ -221,13 +228,27 @@ class BaseTradeDecision: BaseTradeDecision: New update, use new decision """ + # purpose 1) + self.total_step = trade_calendar.get_trade_len() + if self.idx_range is not None: + logger = get_module_logger("decision") + start_idx, end_idx = self.idx_range + if start_idx < 0 or end_idx >= self.total_step: + logger.warning(f"{self.idx_range} go beyound the total_step({self.total_step}), it will be clipped") + self.idx_range = max(0, start_idx), min(self.total_step - 1, end_idx) + + # purpose 2) return self.strategy.update_trade_decision(self, trade_calendar) - def get_range_limit(self) -> Tuple[int, int]: + def get_range_limit(self, **kwargs) -> Tuple[int, int]: """ return the expected step range for limiting the decision execution time Both left and right are **closed** + **kwargs: + {"default_value": } + # using dict is for distinguish no value provided or None provided + Returns ------- Tuple[int, int]: @@ -235,12 +256,32 @@ class BaseTradeDecision: Raises ------ NotImplementedError: - If the decision can't provide a unified start and end + If the following criteria meet + 1) the decision can't provide a unified start and end + 2) default_value is None """ - raise NotImplementedError(f"Please implement the `func` method") + if self.idx_range is None: + if "default_value" in kwargs: + return kwargs["default_value"] + else: + # Default to get full index + raise NotImplementedError(f"The decision didn't provide an index range") + return self.idx_range def empty(self) -> bool: - return len(self.get_decision()) == 0 + for obj in self.get_decision(): + if isinstance(obj, Order): + # Zero amount order will be treated as empty + if not np.isclose(obj.amount, 0.0): + return False + else: + return True + return True + + +class EmptyTradeDecision(BaseTradeDecision): + def empty(self) -> bool: + return True class TradeDecisionWO(BaseTradeDecision): @@ -249,16 +290,9 @@ class TradeDecisionWO(BaseTradeDecision): Besides, the time_range is also included. """ - def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple = None): - super().__init__(strategy) + def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple[int, int] = None): + super().__init__(strategy, idx_range=idx_range) self.order_list = order_list - self.idx_range = idx_range - - def get_range_limit(self) -> Tuple[int, int]: - if self.idx_range is None: - # Default to get full index - raise NotImplementedError(f"The decision didn't provide an index range") - return self.idx_range def get_decision(self) -> List[object]: return self.order_list diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 43a6a455b..138a44faa 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -4,21 +4,23 @@ from collections import OrderedDict from logging import warning -from qlib.backtest.exchange import Exchange -from typing import Dict, List -from qlib.backtest.order import BaseTradeDecision, Order, OrderDir -import pandas as pd -import numpy as np import pathlib +from typing import Dict, List, Tuple import warnings -from pandas.core import groupby +import numpy as np +import pandas as pd +from pandas.core import groupby from pandas.core.frame import DataFrame -from ..utils.time import Freq -from ..utils.resam import resam_ts_data, get_higher_eq_freq_feature +from qlib.backtest.exchange import Exchange +from qlib.backtest.order import BaseTradeDecision, Order, OrderDir +from qlib.backtest.utils import TradeCalendarManager + from ..data import D from ..tests.config import CSI300_BENCH +from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data +from ..utils.time import Freq class Report: @@ -251,14 +253,21 @@ class Indicator: """ def __init__(self): + # order indicator is metrics for a single order for a specific step self.order_indicator_his = OrderedDict() - self.order_indicator = OrderedDict() - self.trade_indicator_his = OrderedDict() - self.trade_indicator = OrderedDict() + self.order_indicator: Dict[str, pd.Series] = OrderedDict() - def clear(self): + # trade indicator is metrics for all orders for a specific step + self.trade_indicator_his = OrderedDict() + self.trade_indicator: Dict[str, float] = OrderedDict() + + self._trade_calendar = None + + # def reset(self, trade_calendar: TradeCalendarManager): + def reset(self): self.order_indicator = OrderedDict() self.trade_indicator = OrderedDict() + # self._trade_calendar = trade_calendar def record(self, trade_start_time): self.order_indicator_his[trade_start_time] = self.order_indicator @@ -294,9 +303,14 @@ class Indicator: def _update_order_price_advantage(self): # NOTE: # trade_price and baseline price will be same on the lowest-level - # So Pa should be 0 + # So Pa should be 0 or do nothing self.order_indicator["pa"] = 0 + def update_order_indicators(self, trade_info: list): + self._update_order_trade_info(trade_info=trade_info) + self._update_order_fulfill_rate() + self._update_order_price_advantage() + def _agg_order_trade_info(self, inner_order_indicators: List[Dict[str, pd.Series]]): inner_amount = pd.Series() deal_amount = pd.Series() @@ -312,7 +326,7 @@ class Indicator: ) trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) - trade_dir = trade_dir.add(_order_indicator["trade_dir"]) + trade_dir = trade_dir.add(_order_indicator["trade_dir"], fill_value=0) trade_dir = trade_dir.apply(Order.parse_dir) @@ -335,24 +349,77 @@ class Indicator: def _agg_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def _agg_order_price_advantage( + def _get_base_vol_pri( self, - inner_order_indicators: List[Dict[str, pd.Series]], + inst: str, trade_start_time: pd.Timestamp, trade_end_time: pd.Timestamp, + direction: OrderDir, + decision: BaseTradeDecision, + trade_exchange: Exchange, + pa_config: dict = {}, + ): + """Get the base volume and price information""" + + agg = pa_config.get("agg", "twap").lower() + price = pa_config.get("price", "deal_price").lower() + + if price == "deal_price": + price_s = trade_exchange.get_deal_price( + inst, trade_start_time, trade_end_time, direction=direction, method=None + ) + else: + raise NotImplementedError(f"This type of input is not supported") + + # NOTE: there are some zeros in the trading price. These cases are known meaningless + # for aligning the previous logic, remove it. + # price_s = price_s.mask(np.isclose(price_s, 0)) + + if agg == "vwap": + volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) + elif agg == "twap": + volume_s = pd.Series(1, index=price_s.index) + else: + raise NotImplementedError(f"This type of input is not supported") + + # no sub executor on the lowest level + # So range_limit an total step will all be None + total_step = decision.total_step + if total_step is None: + total_step = 1 + range_limit = decision.get_range_limit(default_value=(0, total_step - 1)) + + assert volume_s.shape[0] % total_step == 0, "The price series can't be divided by step length" + factor = volume_s.shape[0] // total_step + + slc = slice(range_limit[0] * factor, (range_limit[1] + 1) * factor) + + volume_s = volume_s.iloc[slc] + price_s = price_s.iloc[slc] + + base_volume = volume_s.sum().item() + base_price = ((price_s * volume_s).sum() / base_volume).item() + + return base_price, base_volume + + def _agg_base_price( + self, + inner_order_indicators: List[Dict[str, pd.Series]], + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], trade_exchange: Exchange, pa_config: dict = {}, ): """ + # NOTE:!!!! + # Strong assumption!!!!!! + # the correctness of the base_price relies on that the **same** exchange is used Parameters ---------- inner_order_indicators : List[Dict[str, pd.Series]] the indicators of account of inner executor - trade_start_time : pd.Timestamp - the start_time of the trade period, for slicing - trade_end_time : pd.Timestamp - the end_time of the trade period, for slicing (so it may include more time at the end) + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], + a list of decisions according to inner_order_indicators trade_exchange : Exchange for retrieving trading price pa_config : dict @@ -362,32 +429,61 @@ class Indicator: "price": "$close", # TODO: this is not supported now!!!!! # default to use deal price of the exchange } + """ - agg = pa_config.get("agg", "twap").lower() - price = pa_config.get("price", "deal_price").lower() + # TODO: I think there are potentials to be optimized + trade_dir = self.order_indicator["trade_dir"] + if len(trade_dir) > 0: + bp_all, bv_all = [], [] + # + for oi, (dec, start, end) in zip(inner_order_indicators, decision_list): + bp_s = oi.get("base_price", pd.Series()).reindex(trade_dir.index) + bv_s = oi.get("base_volume", pd.Series()).reindex(trade_dir.index) + bp_new, bv_new = {}, {} + for pr, v, (inst, direction) in zip(bp_s.values, bv_s.values, trade_dir.items()): + if np.isnan(pr): + bp_new[inst], bv_new[inst] = self._get_base_vol_pri( + inst, + start, + end, + decision=dec, + direction=direction, + trade_exchange=trade_exchange, + pa_config=pa_config, + ) + else: + bp_new[inst], bv_new[inst] = pr, v - base_price = {} - for inst, dir in self.order_indicator["trade_dir"].items(): + bp_new, bv_new = pd.Series(bp_new), pd.Series(bv_new) + bp_all.append(bp_new) + bv_all.append(bv_new) + bp_all = pd.concat(bp_all, axis=1) + bv_all = pd.concat(bv_all, axis=1) - if price == "deal_price": - price_s = trade_exchange.get_deal_price(inst, trade_start_time, trade_end_time, dir, method=None) - else: - raise NotImplementedError(f"This type of input is not supported") + self.order_indicator["base_volume"] = bv_all.sum(axis=1) + self.order_indicator["base_price"] = (bp_all * bv_all).sum(axis=1) / self.order_indicator["base_volume"] - # there are some zeros in the trading price. These cases are known meaningless - price_s = price_s.mask(np.isclose(price_s, 0)) + def _agg_order_price_advantage(self): + if not self.order_indicator["trade_price"].empty: + self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 + else: + self.order_indicator["pa"] = pd.Series() - if agg == "vwap": - volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) - base_price[inst] = ((price_s * volume_s).sum() / volume_s.sum()).item() - elif agg == "twap": - base_price[inst] = price_s.mean().item() - - base_price = pd.Series(base_price) - - # update PA - self.order_indicator["pa"] = self.order_indicator["trade_price"] / base_price - 1 + def agg_order_indicators( + self, + inner_order_indicators: List[Dict[str, pd.Series]], + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], + outer_trade_decision: BaseTradeDecision, + trade_exchange: Exchange, + indicator_config={}, + ): + self._agg_order_trade_info(inner_order_indicators) + self._update_trade_amount(outer_trade_decision) + self._agg_order_fulfill_rate() + pa_config = indicator_config.get("pa_config", {}) + self._agg_base_price(inner_order_indicators, decision_list, trade_exchange, pa_config=pa_config) + self._agg_order_price_advantage() def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": @@ -402,7 +498,7 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_price_advantage(self, method="mean"): - pa_order = self.order_indicator["pa"] * (2 * (self.order_indicator["amount"] < 0).astype(int) - 1) + pa_order = self.order_indicator["pa"] * (1 - self.order_indicator["trade_dir"] * 2) if method == "mean": return pa_order.mean() elif method == "amount_weighted": @@ -427,28 +523,6 @@ class Indicator: def _cal_trade_order_count(self): return self.order_indicator["amount"].count() - def update_order_indicators(self, trade_info: list): - self._update_order_trade_info(trade_info=trade_info) - self._update_order_fulfill_rate() - self._update_order_price_advantage() - - def agg_order_indicators( - self, - trade_start_time, - trade_end_time, - inner_order_indicators: List[Dict[str, pd.Series]], - outer_trade_decision: BaseTradeDecision, - trade_exchange: Exchange, - indicator_config={}, - ): - self._agg_order_trade_info(inner_order_indicators) - self._update_trade_amount(outer_trade_decision) - self._agg_order_fulfill_rate() - pa_config = indicator_config.get("pa_config", {}) - self._agg_order_price_advantage( - inner_order_indicators, trade_start_time, trade_end_time, trade_exchange, pa_config=pa_config - ) - def cal_trade_indicators(self, trade_start_time, freq, indicator_config={}): show_indicator = indicator_config.get("show_indicator", False) ffr_config = indicator_config.get("ffr_config", {}) diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 0ba607bdb..5c643df30 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,9 +1,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from __future__ import annotations +from typing import Union, TYPE_CHECKING, Tuple, Union, List, Set + +if TYPE_CHECKING: + from qlib.backtest.order import BaseTradeDecision + from qlib.strategy.base import BaseStrategy import pandas as pd import warnings -from typing import Tuple, Union, List, Set from ..utils.resam import get_resam_calendar from ..data.data import Cal @@ -30,17 +35,20 @@ class TradeCalendarManager: closed end of the trade time range, by default None If `end_time` is None, it must be reset before trading. """ - self.freq = freq - self.start_time = pd.Timestamp(start_time) if start_time else None - self.end_time = pd.Timestamp(end_time) if end_time else None - self._init_trade_calendar(freq=freq, start_time=start_time, end_time=end_time) + self.reset(freq=freq, start_time=start_time, end_time=end_time) - def _init_trade_calendar(self, freq, start_time, end_time): + def reset(self, freq, start_time, end_time): """ + Please refer to the docs of `__init__` + Reset the trade calendar - self.trade_len : The total count for trading step - self.trade_step : The number of trading step finished, self.trade_step can be [0, 1, 2, ..., self.trade_len - 1] """ + self.freq = freq + self.start_time = pd.Timestamp(start_time) if start_time else None + self.end_time = pd.Timestamp(end_time) if end_time else None + _calendar, freq, freq_sam = get_resam_calendar(freq=freq) self._calendar = _calendar _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) @@ -67,6 +75,7 @@ class TradeCalendarManager: return self.freq def get_trade_len(self): + """get the total step length""" return self.trade_len def get_trade_step(self): @@ -99,6 +108,12 @@ class TradeCalendarManager: calendar_index = self.start_index + trade_step return self._calendar[calendar_index], self._calendar[calendar_index + 1] - pd.Timedelta(seconds=1) + def get_cur_step_time(self): + """ + get current step time + """ + return self.get_step_time(self.get_trade_step()) + def get_all_time(self): """Get the start_time and end_time for trading""" return self.start_time, self.end_time @@ -146,5 +161,40 @@ class CommonInfrastructure(BaseInfrastructure): class LevelInfrastructure(BaseInfrastructure): + """level instrastructure is created by executor, and then shared to strategies on the same level""" + def get_support_infra(self): - return ["trade_calendar"] + return ["trade_calendar", "sub_level_infra"] + + def reset_cal(self, freq, start_time, end_time): + """reset trade calendar manager""" + if self.has("trade_calendar"): + self.get("trade_calendar").reset(freq, start_time=start_time, end_time=end_time) + else: + self.reset_infra(trade_calendar=TradeCalendarManager(freq, start_time=start_time, end_time=end_time)) + + def set_sub_level_infra(self, sub_level_infra: LevelInfrastructure): + """this will make the calendar access easier when acrossing multi-levels""" + self.reset_infra(sub_level_infra=sub_level_infra) + + +def get_start_end_idx(trade_calendar: TradeCalendarManager, outer_trade_decision: BaseTradeDecision) -> Union[int, int]: + """ + A helper function for getting the decision-level index range limitation for inner strategy + - NOTE: this function is not applicable to order-level + + Parameters + ---------- + trade_calendar : TradeCalendarManager + outer_trade_decision : BaseTradeDecision + the trade decision made by outer strategy + + Returns + ------- + Union[int, int]: + start index and end index + """ + try: + return outer_trade_decision.get_range_limit() + except NotImplementedError: + return 0, trade_calendar.get_trade_len() - 1 diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 3ca325bf6..026afc8bb 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -14,29 +14,7 @@ from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO from ...backtest.exchange import Exchange, OrderHelper from ...backtest.utils import CommonInfrastructure, LevelInfrastructure from qlib.utils.file import get_io_object - - -def get_start_end_idx(strategy: BaseStrategy, outer_trade_decision: BaseTradeDecision) -> Union[int, int]: - """ - A helper function for getting the decision-level index range limitation for inner strategy - - NOTE: this function is not applicable to order-level - - Parameters - ---------- - strategy : BaseStrategy - the inner strawtegy - outer_trade_decision : BaseTradeDecision - the trade decision made by outer strategy - - Returns - ------- - Union[int, int]: - start index and end index - """ - try: - return outer_trade_decision.get_range_limit() - except NotImplementedError: - return 0, strategy.trade_calendar.get_trade_len() - 1 +from qlib.backtest.utils import get_start_end_idx class TWAPStrategy(BaseStrategy): @@ -105,7 +83,7 @@ class TWAPStrategy(BaseStrategy): # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step - start_idx, end_idx = get_start_end_idx(self, self.outer_trade_decision) + start_idx, end_idx = get_start_end_idx(self.trade_calendar, self.outer_trade_decision) trade_len = end_idx - start_idx + 1 if trade_step < start_idx or trade_step > end_idx: diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index a787c098f..23d6b520a 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from qlib.backtest.position import BasePosition from typing import List, Union from ..model.base import BaseModel @@ -37,24 +38,26 @@ class BaseStrategy: self.reset(level_infra=level_infra, common_infra=common_infra, outer_trade_decision=outer_trade_decision) + @property + def trade_calendar(self) -> TradeCalendarManager: + return self.level_infra.get("trade_calendar") + + @property + def trade_position(self) -> BasePosition: + return self.common_infra.get("trade_account").current + def reset_level_infra(self, level_infra: LevelInfrastructure): if not hasattr(self, "level_infra"): self.level_infra = level_infra else: self.level_infra.update(level_infra) - if level_infra.has("trade_calendar"): - self.trade_calendar: TradeCalendarManager = level_infra.get("trade_calendar") - def reset_common_infra(self, common_infra: CommonInfrastructure): if not hasattr(self, "common_infra"): self.common_infra: CommonInfrastructure = common_infra else: self.common_infra.update(common_infra) - if common_infra.has("trade_account"): - self.trade_position = common_infra.get("trade_account").current - def reset( self, level_infra: LevelInfrastructure = None, From 17d8b8a7cc344328b149af3ed17681b4f0b076fd Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 8 Jul 2021 05:54:36 +0000 Subject: [PATCH 104/187] fix calculating base_price --- qlib/backtest/account.py | 12 ++- qlib/backtest/exchange.py | 20 ++--- qlib/backtest/order.py | 10 ++- qlib/backtest/report.py | 151 +++++++++++++++++++++++++++----------- 4 files changed, 133 insertions(+), 60 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index b394d5823..67f7b056a 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -3,6 +3,7 @@ import copy +from typing import Dict, List from qlib.utils import init_instance_by_config import warnings import pandas as pd @@ -248,7 +249,7 @@ class Account: atomic: bool, outer_trade_decision: BaseTradeDecision, trade_info: list = None, - inner_order_indicators: Indicator = None, + inner_order_indicators: List[Dict[str, pd.Series]] = None, indicator_config: dict = {}, ): """update account at each trading bar step @@ -292,10 +293,15 @@ class Account: self.indicator.clear() if atomic: - self.indicator.update_order_indicators(trade_start_time, trade_end_time, trade_info, trade_exchange) + self.indicator.update_order_indicators(trade_info) else: self.indicator.agg_order_indicators( - inner_order_indicators, indicator_config=indicator_config, outer_trade_decision=outer_trade_decision + trade_start_time, + trade_end_time, + inner_order_indicators, + outer_trade_decision=outer_trade_decision, + trade_exchange=trade_exchange, + indicator_config=indicator_config, ) self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 26fae378f..3794651dc 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -281,27 +281,27 @@ class Exchange: return trade_val, trade_cost, trade_price - def get_quote_info(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id], start_time, end_time, method=ts_data_last) + def get_quote_info(self, stock_id, start_time, end_time, method=ts_data_last): + return resam_ts_data(self.quote[stock_id], start_time, end_time, method=method) - def get_close(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method=ts_data_last) + def get_close(self, stock_id, start_time, end_time, method=ts_data_last): + return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method=method) - def get_volume(self, stock_id, start_time, end_time): - return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method="sum") + def get_volume(self, stock_id, start_time, end_time, method="sum"): + return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method=method) - def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir): + def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir, method=ts_data_last): if direction == OrderDir.SELL: pstr = self.sell_price elif direction == OrderDir.BUY: pstr = self.buy_price else: raise NotImplementedError(f"This type of input is not supported") - deal_price = resam_ts_data(self.quote[stock_id][pstr], start_time, end_time, method=ts_data_last) - if np.isclose(deal_price, 0.0) or np.isnan(deal_price): + deal_price = resam_ts_data(self.quote[stock_id][pstr], start_time, end_time, method=method) + if method is not None and (np.isclose(deal_price, 0.0) or np.isnan(deal_price)): self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {pstr}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") - deal_price = self.get_close(stock_id, start_time, end_time) + deal_price = self.get_close(stock_id, start_time, end_time, method) return deal_price def get_factor(self, stock_id, start_time, end_time) -> Union[float, None]: diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index fb9b8edd7..02e38f2df 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from qlib.backtest.utils import TradeCalendarManager import warnings import pandas as pd +import numpy as np from dataclasses import dataclass, field from typing import ClassVar, Union, List, Set, Tuple @@ -88,11 +89,14 @@ class Order: return self.direction * 2 - 1 @staticmethod - def parse_dir(direction: Union[str, int, OrderDir]) -> OrderDir: + def parse_dir(direction: Union[str, int, float, np.integer, np.floating, OrderDir]) -> OrderDir: if isinstance(direction, OrderDir): return direction - elif isinstance(direction, int): - return OrderDir(direction) + elif isinstance(direction, (int, float, np.integer, np.floating)): + if direction > 0: + return Order.BUY + else: + return Order.SELL elif isinstance(direction, str): dl = direction.lower() if dl.strip() == "sell": diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index ce2812bd0..43a6a455b 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -4,9 +4,11 @@ from collections import OrderedDict from logging import warning -from typing import List -from qlib.backtest.order import BaseTradeDecision, Order +from qlib.backtest.exchange import Exchange +from typing import Dict, List +from qlib.backtest.order import BaseTradeDecision, Order, OrderDir import pandas as pd +import numpy as np import pathlib import warnings from pandas.core import groupby @@ -221,6 +223,33 @@ class Report: class Indicator: + """ + `Indicator` is implemented in a aggregate way. + All the metrics are calculated aggregately. + All the metrics are calculated for a seperated stock and in a specific step on a specific level. + + | indicator | desc. | + |--------------+--------------------------------------------------------------| + | amount | the *target* amount given by the outer strategy | + | inner_amount | the total *target* amount of inner strategy | + | trade_price | the average deal price | + | trade_value | the total trade value | + | trade_cost | the total trade cost (base price need drection) | + | trade_dir | the trading direction | + | ffr | full fill rate | + | pa | price advantage | + | pos | win rate | + | base_price | the price of baseline | + | base_volume | the volume of baseline (for weighted aggregating base_price) | + + **NOTE**: + The `base_price` and `base_volume` can't be NaN when there are not trading on that step. Otherwise + aggregating get wrong results. + + So `base_price` will not be calculated in a aggregate way!! + + """ + def __init__(self): self.order_indicator_his = OrderedDict() self.order_indicator = OrderedDict() @@ -241,6 +270,7 @@ class Indicator: trade_price = dict() trade_value = dict() trade_cost = dict() + trade_dir = dict() for order, _trade_val, _trade_cost, _trade_price in trade_info: amount[order.stock_id] = order.amount_delta @@ -248,36 +278,32 @@ class Indicator: trade_price[order.stock_id] = _trade_price trade_value[order.stock_id] = _trade_val * order.sign trade_cost[order.stock_id] = _trade_cost + trade_dir[order.stock_id] = order.direction self.order_indicator["amount"] = self.order_indicator["inner_amount"] = pd.Series(amount) self.order_indicator["deal_amount"] = pd.Series(deal_amount) + # NOTE: trade_price and baseline price will be same on the lowest-level self.order_indicator["trade_price"] = pd.Series(trade_price) self.order_indicator["trade_value"] = pd.Series(trade_value) self.order_indicator["trade_cost"] = pd.Series(trade_cost) + self.order_indicator["trade_dir"] = pd.Series(trade_dir) def _update_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def _update_order_price_advantage(self, trade_exchange, trade_start_time, trade_end_time): - self.order_indicator["base_price"] = self.order_indicator["trade_price"] - instruments = list(self.order_indicator["base_price"].index) - self.order_indicator["volume"] = pd.Series( - [ - trade_exchange.get_volume(stock_id=inst, start_time=trade_start_time, end_time=trade_end_time) - for inst in instruments - ], - index=instruments, - ) - self.order_indicator["pa"] = ( - self.order_indicator["trade_price"] - self.order_indicator["base_price"] - ) / self.order_indicator["base_price"] + def _update_order_price_advantage(self): + # NOTE: + # trade_price and baseline price will be same on the lowest-level + # So Pa should be 0 + self.order_indicator["pa"] = 0 - def _agg_order_trade_info(self, inner_order_indicators): + def _agg_order_trade_info(self, inner_order_indicators: List[Dict[str, pd.Series]]): inner_amount = pd.Series() deal_amount = pd.Series() trade_price = pd.Series() trade_value = pd.Series() trade_cost = pd.Series() + trade_dir = pd.Series() for _order_indicator in inner_order_indicators: inner_amount = inner_amount.add(_order_indicator["inner_amount"], fill_value=0) deal_amount = deal_amount.add(_order_indicator["deal_amount"], fill_value=0) @@ -286,6 +312,9 @@ class Indicator: ) trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) + trade_dir = trade_dir.add(_order_indicator["trade_dir"]) + + trade_dir = trade_dir.apply(Order.parse_dir) self.order_indicator["inner_amount"] = inner_amount self.order_indicator["deal_amount"] = deal_amount @@ -293,6 +322,7 @@ class Indicator: self.order_indicator["trade_price"] = trade_price self.order_indicator["trade_value"] = trade_value self.order_indicator["trade_cost"] = trade_cost + self.order_indicator["trade_dir"] = trade_dir def _update_trade_amount(self, outer_trade_decision: BaseTradeDecision): # NOTE: these indicator is designed for order execution, so the @@ -305,34 +335,59 @@ class Indicator: def _agg_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def _agg_order_price_advantage(self, inner_order_indicators, base_price="twap"): - base_price = base_price.lower() - volume = pd.Series() - for _order_indicator in inner_order_indicators: - volume = volume.add(_order_indicator["volume"], fill_value=0) - self.order_indicator["volume"] = volume + def _agg_order_price_advantage( + self, + inner_order_indicators: List[Dict[str, pd.Series]], + trade_start_time: pd.Timestamp, + trade_end_time: pd.Timestamp, + trade_exchange: Exchange, + pa_config: dict = {}, + ): + """ - if base_price == "twap": - base_price = pd.Series() - price_count = pd.Series() - for _order_indicator in inner_order_indicators: - base_price = base_price.add(_order_indicator["base_price"], fill_value=0) - price_count = price_count.add(pd.Series(1, index=_order_indicator["base_price"].index), fill_value=0) - base_price /= price_count - self.order_indicator["base_price"] = base_price + Parameters + ---------- + inner_order_indicators : List[Dict[str, pd.Series]] + the indicators of account of inner executor + trade_start_time : pd.Timestamp + the start_time of the trade period, for slicing + trade_end_time : pd.Timestamp + the end_time of the trade period, for slicing (so it may include more time at the end) + trade_exchange : Exchange + for retrieving trading price + pa_config : dict + For example + { + "agg": "twap", # "vwap" + "price": "$close", # TODO: this is not supported now!!!!! + # default to use deal price of the exchange + } + """ - elif base_price == "vwap": - base_price = pd.Series() - for _order_indicator in inner_order_indicators: - base_price = base_price.add(_order_indicator["base_price"] * _order_indicator["volume"], fill_value=0) - base_price /= self.order_indicator["volume"] - self.order_indicator["base_price"] = base_price + agg = pa_config.get("agg", "twap").lower() + price = pa_config.get("price", "deal_price").lower() - else: - raise ValueError(f"base_price {base_price} is not supported!") + base_price = {} + for inst, dir in self.order_indicator["trade_dir"].items(): - self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 - # print("trade_price", self.order_indicator["trade_price"], "base_price", self.order_indicator["base_price"], "pa", self.order_indicator["pa"]* (2 * (self.order_indicator["amount"] < 0).astype(int) - 1)) + if price == "deal_price": + price_s = trade_exchange.get_deal_price(inst, trade_start_time, trade_end_time, dir, method=None) + else: + raise NotImplementedError(f"This type of input is not supported") + + # there are some zeros in the trading price. These cases are known meaningless + price_s = price_s.mask(np.isclose(price_s, 0)) + + if agg == "vwap": + volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) + base_price[inst] = ((price_s * volume_s).sum() / volume_s.sum()).item() + elif agg == "twap": + base_price[inst] = price_s.mean().item() + + base_price = pd.Series(base_price) + + # update PA + self.order_indicator["pa"] = self.order_indicator["trade_price"] / base_price - 1 def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": @@ -372,19 +427,27 @@ class Indicator: def _cal_trade_order_count(self): return self.order_indicator["amount"].count() - def update_order_indicators(self, trade_start_time, trade_end_time, trade_info, trade_exchange): + def update_order_indicators(self, trade_info: list): self._update_order_trade_info(trade_info=trade_info) self._update_order_fulfill_rate() - self._update_order_price_advantage(trade_exchange, trade_start_time, trade_end_time) + self._update_order_price_advantage() def agg_order_indicators( - self, inner_order_indicators, outer_trade_decision: BaseTradeDecision, indicator_config={} + self, + trade_start_time, + trade_end_time, + inner_order_indicators: List[Dict[str, pd.Series]], + outer_trade_decision: BaseTradeDecision, + trade_exchange: Exchange, + indicator_config={}, ): self._agg_order_trade_info(inner_order_indicators) self._update_trade_amount(outer_trade_decision) self._agg_order_fulfill_rate() pa_config = indicator_config.get("pa_config", {}) - self._agg_order_price_advantage(inner_order_indicators, base_price=pa_config.get("base_price", "twap")) + self._agg_order_price_advantage( + inner_order_indicators, trade_start_time, trade_end_time, trade_exchange, pa_config=pa_config + ) def cal_trade_indicators(self, trade_start_time, freq, indicator_config={}): show_indicator = indicator_config.get("show_indicator", False) From cbd52b7905156c05673806f2abf3929e9e704fa9 Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 8 Jul 2021 13:37:20 +0000 Subject: [PATCH 105/187] align range limit --- qlib/backtest/__init__.py | 1 - qlib/backtest/account.py | 19 +- qlib/backtest/backtest.py | 5 +- qlib/backtest/executor.py | 275 +++++++++++++++---------- qlib/backtest/order.py | 64 ++++-- qlib/backtest/report.py | 200 ++++++++++++------ qlib/backtest/utils.py | 64 +++++- qlib/contrib/strategy/rule_strategy.py | 26 +-- qlib/strategy/base.py | 15 +- 9 files changed, 437 insertions(+), 232 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 8471022f4..99e5b8790 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -14,7 +14,6 @@ from .backtest import backtest_loop from .backtest import collect_data_loop from .utils import CommonInfrastructure from .order import Order - from ..utils import init_instance_by_config from ..log import get_module_logger from ..config import C diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 67f7b056a..3ef1cdd03 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -3,7 +3,7 @@ import copy -from typing import Dict, List +from typing import Dict, List, Tuple from qlib.utils import init_instance_by_config import warnings import pandas as pd @@ -250,6 +250,7 @@ class Account: outer_trade_decision: BaseTradeDecision, trade_info: list = None, inner_order_indicators: List[Dict[str, pd.Series]] = None, + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]] = None, indicator_config: dict = {}, ): """update account at each trading bar step @@ -274,6 +275,9 @@ class Account: indicators of inner executor, by default None - necessary if atomic is False - used to aggregate outer indicators + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]] = None, + The decision list of the inner level: List[Tuple[, , ]] + The inner level indicator_config : dict, optional config of calculating indicators, by default {} """ @@ -289,22 +293,27 @@ class Account: # report is portfolio related analysis self.update_report(trade_start_time, trade_end_time) - # indicator is trading (e.g. high-frequency order execution) related analysis - self.indicator.clear() + # TODO: will skip empty decisions make it faster? `outer_trade_decision.empty():` + # indicator is trading (e.g. high-frequency order execution) related analysis + self.indicator.reset() + + # aggregate the information for each order if atomic: self.indicator.update_order_indicators(trade_info) else: self.indicator.agg_order_indicators( - trade_start_time, - trade_end_time, inner_order_indicators, + decision_list=decision_list, outer_trade_decision=outer_trade_decision, trade_exchange=trade_exchange, indicator_config=indicator_config, ) + # aggregate all the order metrics a single step self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) + + # record the metrics self.indicator.record(trade_start_time) def get_report(self): diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 573c874b0..89b8c7830 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -55,14 +55,13 @@ def collect_data_loop( trade decision """ trade_executor.reset(start_time=start_time, end_time=end_time) - level_infra = trade_executor.get_level_infra() - trade_strategy.reset(level_infra=level_infra) + trade_strategy.reset(level_infra=trade_executor.get_level_infra()) with tqdm(total=trade_executor.trade_calendar.get_trade_len(), desc="backtest loop") as bar: _execute_result = None while not trade_executor.finished(): _trade_decision: BaseTradeDecision = trade_strategy.generate_trade_decision(_execute_result) - _execute_result = yield from trade_executor.collect_data(_trade_decision) + _execute_result = yield from trade_executor.collect_data(_trade_decision, level=0) bar.update(1) if return_value is not None: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index c4807ebde..b99380c54 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -1,13 +1,16 @@ +from abc import abstractclassmethod, abstractmethod import copy +from types import GeneratorType +from qlib.backtest.account import Account import warnings import pandas as pd -from typing import List, Union +from typing import List, Tuple, Union from qlib.backtest.report import Indicator -from .order import Order, BaseTradeDecision +from .order import EmptyTradeDecision, Order, BaseTradeDecision from .exchange import Exchange -from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure +from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure, get_start_end_idx from ..utils import init_instance_by_config from ..utils.time import Freq @@ -26,6 +29,7 @@ class BaseExecutor: generate_report: bool = False, verbose: bool = False, track_data: bool = False, + trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, **kwargs, ): @@ -62,8 +66,8 @@ class BaseExecutor: { 'show_indicator': True, 'pa_config': { - 'base_value': 'twap', - 'weight_method': 'value_weighted', + "agg": "twap", # "vwap" + "price": "$close", # default to use deal price of the exchange }, 'ffr_config':{ 'weight_method': 'value_weighted', @@ -77,6 +81,12 @@ class BaseExecutor: whether to generate trade_decision, will be used when training rl agent - If `self.track_data` is true, when making data for training, the input `trade_decision` of `execute` will be generated by `collect_data` - Else, `trade_decision` will not be generated + + trade_exchange : Exchange + exchange that provides market info, used to generate report + - If generate_report is None, trade_exchange will be ignored + - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra + common_infra : CommonInfrastructure, optional: common infrastructure for backtesting, may including: - trade_account : Account, optional @@ -90,7 +100,9 @@ class BaseExecutor: self.generate_report = generate_report self.verbose = verbose self.track_data = track_data - self.reset(start_time=start_time, end_time=end_time, track_data=track_data, common_infra=common_infra) + self._trade_exchange = trade_exchange + self.level_infra = LevelInfrastructure() + self.reset(start_time=start_time, end_time=end_time, common_infra=common_infra) def reset_common_infra(self, common_infra): """ @@ -105,60 +117,106 @@ class BaseExecutor: if common_infra.has("trade_account"): # NOTE: there is a trick in the code. # copy is used instead of deepcopy. So positions are shared - self.trade_account = copy.copy(common_infra.get("trade_account")) + self.trade_account: Account = copy.copy(common_infra.get("trade_account")) self.trade_account.reset(freq=self.time_per_step, init_report=True, port_metr_enabled=self.generate_report) - def reset(self, track_data: bool = None, common_infra: CommonInfrastructure = None, **kwargs): + @property + def trade_exchange(self) -> Exchange: + """get trade exchange in a prioritized order""" + return getattr(self, "_trade_exchange", None) or self.common_infra.get("trade_exchange") + + @property + def trade_calendar(self) -> TradeCalendarManager: + """ + Though trade calendar can be accessed from multiple sources, but managing in a centralized way will make the + code easier + """ + return self.level_infra.get("trade_calendar") + + def reset(self, common_infra: CommonInfrastructure = None, **kwargs): """ - reset `start_time` and `end_time`, used in trade calendar - - reset `track_data`, used when making data for multi-level training - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc """ - if track_data is not None: - self.track_data = track_data - if "start_time" in kwargs or "end_time" in kwargs: start_time = kwargs.get("start_time") end_time = kwargs.get("end_time") - self.trade_calendar = TradeCalendarManager( - freq=self.time_per_step, start_time=start_time, end_time=end_time - ) - + self.level_infra.reset_cal(freq=self.time_per_step, start_time=start_time, end_time=end_time) if common_infra is not None: self.reset_common_infra(common_infra) def get_level_infra(self): - return LevelInfrastructure(trade_calendar=self.trade_calendar) + return self.level_infra def finished(self): return self.trade_calendar.finished() - def execute(self, trade_decision): + def execute(self, trade_decision: BaseTradeDecision, level: int = 0): """execute the trade decision and return the executed result + NOTE: this function is never used directly in the framework. Should we delete it? + Parameters ---------- trade_decision : BaseTradeDecision + level : int + the level of current executor + Returns ---------- execute_result : List[object] the executed result for trade decision """ - raise NotImplementedError("execute is not implemented!") + return_value = {} + for _decision in self.collect_data(trade_decision, return_value=return_value, level=level): + pass + return return_value.get("execute_result") - def collect_data(self, trade_decision): + @abstractclassmethod + def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0) -> Tuple[List[object], dict]: + """ + Please refer to the doc of collect_data + The only difference between `_collect_data` and `collect_data` is that some common steps are moved into + collect_data + + Parameters + ---------- + Please refer to the doc of collect_data + + + Returns + ------- + Tuple[List[object], dict]: + (, ) + """ + + def collect_data( + self, trade_decision: BaseTradeDecision, return_value: dict = None, level: int = 0 + ) -> List[object]: """Generator for collecting the trade decision data for rl training + his function will make a step forward + Parameters ---------- trade_decision : BaseTradeDecision + level : int + the level of current executor. 0 indicates the top level + + return_value : dict + the mem address to return the value + e.g. {"return_value": } + Returns ---------- execute_result : List[object] - the executed result for trade decision + the executed result for trade decision. + ** NOTE!!!! **: + 1) This is necessary, The return value of geenrator will be used in NestedExecutor + 2) Please note the executed results are not merged. Yields ------- @@ -167,7 +225,36 @@ class BaseExecutor: """ if self.track_data: yield trade_decision - return self.execute(trade_decision) + + atomic = not issubclass(self.__class__, NestedExecutor) # issubclass(A, A) is True + + if atomic and trade_decision.get_range_limit(default_value=None) is not None: + raise ValueError("atomic executor doesn't support specify `range_limit`") + + obj = self._collect_data(trade_decision=trade_decision, level=level) + + if isinstance(obj, GeneratorType): + res, kwargs = yield from obj + else: + # Some concrete executor don't have inner decisions + res, kwargs = obj + + trade_start_time, trade_end_time = self.trade_calendar.get_cur_step_time() + # Account will not be changed in this function + self.trade_account.update_bar_end( + trade_start_time, + trade_end_time, + self.trade_exchange, + atomic=atomic, + outer_trade_decision=trade_decision, + indicator_config=self.indicator_config, + **kwargs, + ) + + self.trade_calendar.step() + if return_value is not None: + return_value.update({"execute_result": res}) + return res def get_all_executors(self): """get all executors""" @@ -192,7 +279,7 @@ class NestedExecutor(BaseExecutor): verbose: bool = False, track_data: bool = False, skip_empty_decision: bool = True, - trade_exchange: Exchange = None, + align_range_limit: bool = True, common_infra: CommonInfrastructure = None, **kwargs, ): @@ -203,24 +290,24 @@ class NestedExecutor(BaseExecutor): trading env in each trading bar. inner_strategy : BaseStrategy trading strategy in each trading bar - trade_exchange : Exchange - exchange that provides market info, used to generate report - - If generate_report is None, trade_exchange will be ignored - - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra skip_empty_decision: bool - Will the executor skip the inner loop when the decision is empty. + Will the executor skip call inner loop when the decision is empty. It should be False in following cases - The decisions may be updated by steps - The inner executor may not follow the decisions from the outer strategy + align_range_limit: bool + force to align the index_range decision + It is only for nested executor, because range_limit is given by outer strategy """ - self.inner_executor = init_instance_by_config( + self.inner_executor: BaseExecutor = init_instance_by_config( inner_executor, common_infra=common_infra, accept_types=BaseExecutor ) - self.inner_strategy = init_instance_by_config( + self.inner_strategy: BaseStrategy = init_instance_by_config( inner_strategy, common_infra=common_infra, accept_types=BaseStrategy ) self._skip_empty_decision = skip_empty_decision + self._align_range_limit = align_range_limit super(NestedExecutor, self).__init__( time_per_step=time_per_step, @@ -234,82 +321,82 @@ class NestedExecutor(BaseExecutor): **kwargs, ) - if trade_exchange is not None: - self.trade_exchange = trade_exchange - def reset_common_infra(self, common_infra): """ reset infrastructure for trading - - reset trade_exchange - reset inner_strategyand inner_executor common infra """ super(NestedExecutor, self).reset_common_infra(common_infra) - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") - self.inner_executor.reset_common_infra(common_infra) self.inner_strategy.reset_common_infra(common_infra) def _init_sub_trading(self, trade_decision): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + trade_start_time, trade_end_time = self.trade_calendar.get_cur_step_time() self.inner_executor.reset(start_time=trade_start_time, end_time=trade_end_time) sub_level_infra = self.inner_executor.get_level_infra() + self.level_infra.set_sub_level_infra(sub_level_infra) self.inner_strategy.reset(level_infra=sub_level_infra, outer_trade_decision=trade_decision) - def execute(self, trade_decision): - return_value = {} - for _decision in self.collect_data(trade_decision, return_value): - pass - return return_value.get("execute_result") + def _update_trade_decision(self, trade_decision: BaseTradeDecision) -> BaseTradeDecision: + # outter strategy have chance to update decision each iterator + updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) + if updated_trade_decision is not None: + trade_decision = updated_trade_decision + # NEW UPDATE + # create a hook for inner strategy to update outter decision + self.inner_strategy.alter_outer_trade_decision(trade_decision) + return trade_decision - def collect_data(self, trade_decision: BaseTradeDecision, return_value=None): - if self.track_data: - yield trade_decision + # def _get_inner_trade_decision(self, outer_trade_decision: BaseTradeDecision, inner_execute_result): + # # In some cases, the inner strategy can be skipped, but the inner executor should keep running + # if outer_trade_decision.empty() and self._skip_empty_decision: + # return EmptyTradeDecision(self.inner_strategy) + # return self.inner_strategy.generate_trade_decision(inner_execute_result) + # _inner_trade_decision = self._get_inner_trade_decision(trade_decision, _inner_execute_result) + + def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): execute_result = [] inner_order_indicators = [] + decision_list = [] + # NOTE: + # - this is necessary to calculating the steps in sub level + # - more detailed information will be set into trade decision + self._init_sub_trading(trade_decision) - if not (trade_decision.empty() and self._skip_empty_decision): - _inner_execute_result = None - self._init_sub_trading(trade_decision) - while not self.inner_executor.finished(): - # outter strategy have chance to update decision each iterator - updated_trade_decision = trade_decision.update(self.inner_executor.trade_calendar) - if updated_trade_decision is not None: - trade_decision = updated_trade_decision - # NEW UPDATE - # create a hook for inner strategy to update outter decision - self.inner_strategy.alter_outer_trade_decision(trade_decision) + _inner_execute_result = None + while not self.inner_executor.finished(): + trade_decision = self._update_trade_decision(trade_decision) + + if trade_decision.empty() and self._skip_empty_decision: + # give one chance for outer stategy to update the strategy + # - For updating some information in the sub executor(the strategy have no knowledge of the inner + # executor when generating the decision) + break + + sub_cal: TradeCalendarManager = self.inner_executor.trade_calendar + start_idx, end_idx = get_start_end_idx(sub_cal, trade_decision) + if not self._align_range_limit or start_idx <= sub_cal.get_trade_step() <= end_idx: + # if force align the range limit, skip the steps outside the decision range limit _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + # NOTE sub_cal.get_cur_step_time() must be called before collect_data in case of step shifting + decision_list.append((_inner_trade_decision, *sub_cal.get_cur_step_time())) # NOTE: Trade Calendar will step forward in the follow line _inner_execute_result = yield from self.inner_executor.collect_data( - trade_decision=_inner_trade_decision + trade_decision=_inner_trade_decision, level=level + 1 ) - execute_result.extend(_inner_execute_result) + inner_order_indicators.append( self.inner_executor.trade_account.get_trade_indicator().get_order_indicator() ) + else: + # do nothing and just step forward + sub_cal.step() - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) - self.trade_account.update_bar_end( - trade_start_time, - trade_end_time, - self.trade_exchange, - atomic=False, - outer_trade_decision=trade_decision, - inner_order_indicators=inner_order_indicators, - indicator_config=self.indicator_config, - ) - - self.trade_calendar.step() - if return_value is not None: - return_value.update({"execute_result": execute_result}) - return execute_result + return execute_result, {"inner_order_indicators": inner_order_indicators, "decision_list": decision_list} def get_all_executors(self): """get all executors, including self and inner_executor.get_all_executors()""" @@ -337,17 +424,13 @@ class SimulatorExecutor(BaseExecutor): generate_report: bool = False, verbose: bool = False, track_data: bool = False, - trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, - trade_type: str = TT_PARAL, + trade_type: str = TT_SERIAL, **kwargs, ): """ Parameters ---------- - trade_exchange : Exchange - exchange that provides market info, used to deal order and generate report - - If `trade_exchange` is None, self.trade_exchange will be set with common_infra trade_type: str please refer to the doc of `TT_SERIAL` & `TT_PARAL` """ @@ -362,20 +445,9 @@ class SimulatorExecutor(BaseExecutor): common_infra=common_infra, **kwargs, ) - if trade_exchange is not None: - self.trade_exchange = trade_exchange self.trade_type = trade_type - def reset_common_infra(self, common_infra): - """ - reset infrastructure for trading - - reset trade_exchange - """ - super(SimulatorExecutor, self).reset_common_infra(common_infra) - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") - def _get_order_iterator(self, trade_decision: BaseTradeDecision) -> List[Order]: """ @@ -405,10 +477,9 @@ class SimulatorExecutor(BaseExecutor): raise NotImplementedError(f"This type of input is not supported") return order_it - def execute(self, trade_decision: BaseTradeDecision): + def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): - trade_step = self.trade_calendar.get_trade_step() - trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) + trade_start_time, _ = self.trade_calendar.get_cur_step_time() execute_result = [] for order in self._get_order_iterator(trade_decision): @@ -450,16 +521,4 @@ class SimulatorExecutor(BaseExecutor): print("[W {:%Y-%m-%d %H:%M:%S}]: {} wrong.".format(trade_start_time, order.stock_id)) # do nothing pass - - # Account will not be changed in this function - self.trade_account.update_bar_end( - trade_start_time, - trade_end_time, - self.trade_exchange, - atomic=True, - outer_trade_decision=trade_decision, - trade_info=execute_result, - indicator_config=self.indicator_config, - ) - self.trade_calendar.step() - return execute_result + return execute_result, {"trade_info": execute_result} diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 02e38f2df..a1beeec38 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -3,6 +3,7 @@ # TODO: rename it with decision.py from __future__ import annotations from enum import IntEnum +from qlib.log import get_module_logger # try to fix circular imports when enabling type hints from typing import TYPE_CHECKING @@ -179,7 +180,7 @@ class BaseTradeDecision: 2. Same as `case 1.3` """ - def __init__(self, strategy: BaseStrategy): + def __init__(self, strategy: BaseStrategy, idx_range: Tuple[int, int] = None): """ Parameters ---------- @@ -187,6 +188,8 @@ class BaseTradeDecision: The strategy who make the decision """ self.strategy = strategy + self.total_step = None # upper strategy has no knowledge about the sub executor before `_init_sub_trading` + self.idx_range = idx_range def get_decision(self) -> List[object]: """ @@ -207,7 +210,11 @@ class BaseTradeDecision: def update(self, trade_calendar: TradeCalendarManager) -> Union["BaseTradeDecision", None]: """ - Be called at the **start** of each step + Be called at the **start** of each step. + + This function is designn for following purpose + 1) Leave a hook for the strategy who make `self` decision to update the decision itself + 2) Update some information from the inner executor calendar Parameters ---------- @@ -221,13 +228,27 @@ class BaseTradeDecision: BaseTradeDecision: New update, use new decision """ + # purpose 1) + self.total_step = trade_calendar.get_trade_len() + if self.idx_range is not None: + logger = get_module_logger("decision") + start_idx, end_idx = self.idx_range + if start_idx < 0 or end_idx >= self.total_step: + logger.warning(f"{self.idx_range} go beyound the total_step({self.total_step}), it will be clipped") + self.idx_range = max(0, start_idx), min(self.total_step - 1, end_idx) + + # purpose 2) return self.strategy.update_trade_decision(self, trade_calendar) - def get_range_limit(self) -> Tuple[int, int]: + def get_range_limit(self, **kwargs) -> Tuple[int, int]: """ return the expected step range for limiting the decision execution time Both left and right are **closed** + **kwargs: + {"default_value": } + # using dict is for distinguish no value provided or None provided + Returns ------- Tuple[int, int]: @@ -235,12 +256,32 @@ class BaseTradeDecision: Raises ------ NotImplementedError: - If the decision can't provide a unified start and end + If the following criteria meet + 1) the decision can't provide a unified start and end + 2) default_value is None """ - raise NotImplementedError(f"Please implement the `func` method") + if self.idx_range is None: + if "default_value" in kwargs: + return kwargs["default_value"] + else: + # Default to get full index + raise NotImplementedError(f"The decision didn't provide an index range") + return self.idx_range def empty(self) -> bool: - return len(self.get_decision()) == 0 + for obj in self.get_decision(): + if isinstance(obj, Order): + # Zero amount order will be treated as empty + if not np.isclose(obj.amount, 0.0): + return False + else: + return True + return True + + +class EmptyTradeDecision(BaseTradeDecision): + def empty(self) -> bool: + return True class TradeDecisionWO(BaseTradeDecision): @@ -249,16 +290,9 @@ class TradeDecisionWO(BaseTradeDecision): Besides, the time_range is also included. """ - def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple = None): - super().__init__(strategy) + def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple[int, int] = None): + super().__init__(strategy, idx_range=idx_range) self.order_list = order_list - self.idx_range = idx_range - - def get_range_limit(self) -> Tuple[int, int]: - if self.idx_range is None: - # Default to get full index - raise NotImplementedError(f"The decision didn't provide an index range") - return self.idx_range def get_decision(self) -> List[object]: return self.order_list diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 43a6a455b..138a44faa 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -4,21 +4,23 @@ from collections import OrderedDict from logging import warning -from qlib.backtest.exchange import Exchange -from typing import Dict, List -from qlib.backtest.order import BaseTradeDecision, Order, OrderDir -import pandas as pd -import numpy as np import pathlib +from typing import Dict, List, Tuple import warnings -from pandas.core import groupby +import numpy as np +import pandas as pd +from pandas.core import groupby from pandas.core.frame import DataFrame -from ..utils.time import Freq -from ..utils.resam import resam_ts_data, get_higher_eq_freq_feature +from qlib.backtest.exchange import Exchange +from qlib.backtest.order import BaseTradeDecision, Order, OrderDir +from qlib.backtest.utils import TradeCalendarManager + from ..data import D from ..tests.config import CSI300_BENCH +from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data +from ..utils.time import Freq class Report: @@ -251,14 +253,21 @@ class Indicator: """ def __init__(self): + # order indicator is metrics for a single order for a specific step self.order_indicator_his = OrderedDict() - self.order_indicator = OrderedDict() - self.trade_indicator_his = OrderedDict() - self.trade_indicator = OrderedDict() + self.order_indicator: Dict[str, pd.Series] = OrderedDict() - def clear(self): + # trade indicator is metrics for all orders for a specific step + self.trade_indicator_his = OrderedDict() + self.trade_indicator: Dict[str, float] = OrderedDict() + + self._trade_calendar = None + + # def reset(self, trade_calendar: TradeCalendarManager): + def reset(self): self.order_indicator = OrderedDict() self.trade_indicator = OrderedDict() + # self._trade_calendar = trade_calendar def record(self, trade_start_time): self.order_indicator_his[trade_start_time] = self.order_indicator @@ -294,9 +303,14 @@ class Indicator: def _update_order_price_advantage(self): # NOTE: # trade_price and baseline price will be same on the lowest-level - # So Pa should be 0 + # So Pa should be 0 or do nothing self.order_indicator["pa"] = 0 + def update_order_indicators(self, trade_info: list): + self._update_order_trade_info(trade_info=trade_info) + self._update_order_fulfill_rate() + self._update_order_price_advantage() + def _agg_order_trade_info(self, inner_order_indicators: List[Dict[str, pd.Series]]): inner_amount = pd.Series() deal_amount = pd.Series() @@ -312,7 +326,7 @@ class Indicator: ) trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) - trade_dir = trade_dir.add(_order_indicator["trade_dir"]) + trade_dir = trade_dir.add(_order_indicator["trade_dir"], fill_value=0) trade_dir = trade_dir.apply(Order.parse_dir) @@ -335,24 +349,77 @@ class Indicator: def _agg_order_fulfill_rate(self): self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] - def _agg_order_price_advantage( + def _get_base_vol_pri( self, - inner_order_indicators: List[Dict[str, pd.Series]], + inst: str, trade_start_time: pd.Timestamp, trade_end_time: pd.Timestamp, + direction: OrderDir, + decision: BaseTradeDecision, + trade_exchange: Exchange, + pa_config: dict = {}, + ): + """Get the base volume and price information""" + + agg = pa_config.get("agg", "twap").lower() + price = pa_config.get("price", "deal_price").lower() + + if price == "deal_price": + price_s = trade_exchange.get_deal_price( + inst, trade_start_time, trade_end_time, direction=direction, method=None + ) + else: + raise NotImplementedError(f"This type of input is not supported") + + # NOTE: there are some zeros in the trading price. These cases are known meaningless + # for aligning the previous logic, remove it. + # price_s = price_s.mask(np.isclose(price_s, 0)) + + if agg == "vwap": + volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) + elif agg == "twap": + volume_s = pd.Series(1, index=price_s.index) + else: + raise NotImplementedError(f"This type of input is not supported") + + # no sub executor on the lowest level + # So range_limit an total step will all be None + total_step = decision.total_step + if total_step is None: + total_step = 1 + range_limit = decision.get_range_limit(default_value=(0, total_step - 1)) + + assert volume_s.shape[0] % total_step == 0, "The price series can't be divided by step length" + factor = volume_s.shape[0] // total_step + + slc = slice(range_limit[0] * factor, (range_limit[1] + 1) * factor) + + volume_s = volume_s.iloc[slc] + price_s = price_s.iloc[slc] + + base_volume = volume_s.sum().item() + base_price = ((price_s * volume_s).sum() / base_volume).item() + + return base_price, base_volume + + def _agg_base_price( + self, + inner_order_indicators: List[Dict[str, pd.Series]], + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], trade_exchange: Exchange, pa_config: dict = {}, ): """ + # NOTE:!!!! + # Strong assumption!!!!!! + # the correctness of the base_price relies on that the **same** exchange is used Parameters ---------- inner_order_indicators : List[Dict[str, pd.Series]] the indicators of account of inner executor - trade_start_time : pd.Timestamp - the start_time of the trade period, for slicing - trade_end_time : pd.Timestamp - the end_time of the trade period, for slicing (so it may include more time at the end) + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], + a list of decisions according to inner_order_indicators trade_exchange : Exchange for retrieving trading price pa_config : dict @@ -362,32 +429,61 @@ class Indicator: "price": "$close", # TODO: this is not supported now!!!!! # default to use deal price of the exchange } + """ - agg = pa_config.get("agg", "twap").lower() - price = pa_config.get("price", "deal_price").lower() + # TODO: I think there are potentials to be optimized + trade_dir = self.order_indicator["trade_dir"] + if len(trade_dir) > 0: + bp_all, bv_all = [], [] + # + for oi, (dec, start, end) in zip(inner_order_indicators, decision_list): + bp_s = oi.get("base_price", pd.Series()).reindex(trade_dir.index) + bv_s = oi.get("base_volume", pd.Series()).reindex(trade_dir.index) + bp_new, bv_new = {}, {} + for pr, v, (inst, direction) in zip(bp_s.values, bv_s.values, trade_dir.items()): + if np.isnan(pr): + bp_new[inst], bv_new[inst] = self._get_base_vol_pri( + inst, + start, + end, + decision=dec, + direction=direction, + trade_exchange=trade_exchange, + pa_config=pa_config, + ) + else: + bp_new[inst], bv_new[inst] = pr, v - base_price = {} - for inst, dir in self.order_indicator["trade_dir"].items(): + bp_new, bv_new = pd.Series(bp_new), pd.Series(bv_new) + bp_all.append(bp_new) + bv_all.append(bv_new) + bp_all = pd.concat(bp_all, axis=1) + bv_all = pd.concat(bv_all, axis=1) - if price == "deal_price": - price_s = trade_exchange.get_deal_price(inst, trade_start_time, trade_end_time, dir, method=None) - else: - raise NotImplementedError(f"This type of input is not supported") + self.order_indicator["base_volume"] = bv_all.sum(axis=1) + self.order_indicator["base_price"] = (bp_all * bv_all).sum(axis=1) / self.order_indicator["base_volume"] - # there are some zeros in the trading price. These cases are known meaningless - price_s = price_s.mask(np.isclose(price_s, 0)) + def _agg_order_price_advantage(self): + if not self.order_indicator["trade_price"].empty: + self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 + else: + self.order_indicator["pa"] = pd.Series() - if agg == "vwap": - volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) - base_price[inst] = ((price_s * volume_s).sum() / volume_s.sum()).item() - elif agg == "twap": - base_price[inst] = price_s.mean().item() - - base_price = pd.Series(base_price) - - # update PA - self.order_indicator["pa"] = self.order_indicator["trade_price"] / base_price - 1 + def agg_order_indicators( + self, + inner_order_indicators: List[Dict[str, pd.Series]], + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], + outer_trade_decision: BaseTradeDecision, + trade_exchange: Exchange, + indicator_config={}, + ): + self._agg_order_trade_info(inner_order_indicators) + self._update_trade_amount(outer_trade_decision) + self._agg_order_fulfill_rate() + pa_config = indicator_config.get("pa_config", {}) + self._agg_base_price(inner_order_indicators, decision_list, trade_exchange, pa_config=pa_config) + self._agg_order_price_advantage() def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": @@ -402,7 +498,7 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_price_advantage(self, method="mean"): - pa_order = self.order_indicator["pa"] * (2 * (self.order_indicator["amount"] < 0).astype(int) - 1) + pa_order = self.order_indicator["pa"] * (1 - self.order_indicator["trade_dir"] * 2) if method == "mean": return pa_order.mean() elif method == "amount_weighted": @@ -427,28 +523,6 @@ class Indicator: def _cal_trade_order_count(self): return self.order_indicator["amount"].count() - def update_order_indicators(self, trade_info: list): - self._update_order_trade_info(trade_info=trade_info) - self._update_order_fulfill_rate() - self._update_order_price_advantage() - - def agg_order_indicators( - self, - trade_start_time, - trade_end_time, - inner_order_indicators: List[Dict[str, pd.Series]], - outer_trade_decision: BaseTradeDecision, - trade_exchange: Exchange, - indicator_config={}, - ): - self._agg_order_trade_info(inner_order_indicators) - self._update_trade_amount(outer_trade_decision) - self._agg_order_fulfill_rate() - pa_config = indicator_config.get("pa_config", {}) - self._agg_order_price_advantage( - inner_order_indicators, trade_start_time, trade_end_time, trade_exchange, pa_config=pa_config - ) - def cal_trade_indicators(self, trade_start_time, freq, indicator_config={}): show_indicator = indicator_config.get("show_indicator", False) ffr_config = indicator_config.get("ffr_config", {}) diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 0ba607bdb..5c643df30 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,9 +1,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from __future__ import annotations +from typing import Union, TYPE_CHECKING, Tuple, Union, List, Set + +if TYPE_CHECKING: + from qlib.backtest.order import BaseTradeDecision + from qlib.strategy.base import BaseStrategy import pandas as pd import warnings -from typing import Tuple, Union, List, Set from ..utils.resam import get_resam_calendar from ..data.data import Cal @@ -30,17 +35,20 @@ class TradeCalendarManager: closed end of the trade time range, by default None If `end_time` is None, it must be reset before trading. """ - self.freq = freq - self.start_time = pd.Timestamp(start_time) if start_time else None - self.end_time = pd.Timestamp(end_time) if end_time else None - self._init_trade_calendar(freq=freq, start_time=start_time, end_time=end_time) + self.reset(freq=freq, start_time=start_time, end_time=end_time) - def _init_trade_calendar(self, freq, start_time, end_time): + def reset(self, freq, start_time, end_time): """ + Please refer to the docs of `__init__` + Reset the trade calendar - self.trade_len : The total count for trading step - self.trade_step : The number of trading step finished, self.trade_step can be [0, 1, 2, ..., self.trade_len - 1] """ + self.freq = freq + self.start_time = pd.Timestamp(start_time) if start_time else None + self.end_time = pd.Timestamp(end_time) if end_time else None + _calendar, freq, freq_sam = get_resam_calendar(freq=freq) self._calendar = _calendar _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) @@ -67,6 +75,7 @@ class TradeCalendarManager: return self.freq def get_trade_len(self): + """get the total step length""" return self.trade_len def get_trade_step(self): @@ -99,6 +108,12 @@ class TradeCalendarManager: calendar_index = self.start_index + trade_step return self._calendar[calendar_index], self._calendar[calendar_index + 1] - pd.Timedelta(seconds=1) + def get_cur_step_time(self): + """ + get current step time + """ + return self.get_step_time(self.get_trade_step()) + def get_all_time(self): """Get the start_time and end_time for trading""" return self.start_time, self.end_time @@ -146,5 +161,40 @@ class CommonInfrastructure(BaseInfrastructure): class LevelInfrastructure(BaseInfrastructure): + """level instrastructure is created by executor, and then shared to strategies on the same level""" + def get_support_infra(self): - return ["trade_calendar"] + return ["trade_calendar", "sub_level_infra"] + + def reset_cal(self, freq, start_time, end_time): + """reset trade calendar manager""" + if self.has("trade_calendar"): + self.get("trade_calendar").reset(freq, start_time=start_time, end_time=end_time) + else: + self.reset_infra(trade_calendar=TradeCalendarManager(freq, start_time=start_time, end_time=end_time)) + + def set_sub_level_infra(self, sub_level_infra: LevelInfrastructure): + """this will make the calendar access easier when acrossing multi-levels""" + self.reset_infra(sub_level_infra=sub_level_infra) + + +def get_start_end_idx(trade_calendar: TradeCalendarManager, outer_trade_decision: BaseTradeDecision) -> Union[int, int]: + """ + A helper function for getting the decision-level index range limitation for inner strategy + - NOTE: this function is not applicable to order-level + + Parameters + ---------- + trade_calendar : TradeCalendarManager + outer_trade_decision : BaseTradeDecision + the trade decision made by outer strategy + + Returns + ------- + Union[int, int]: + start index and end index + """ + try: + return outer_trade_decision.get_range_limit() + except NotImplementedError: + return 0, trade_calendar.get_trade_len() - 1 diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index e2e34e112..f689b4003 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -14,29 +14,7 @@ from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO from ...backtest.exchange import Exchange, OrderHelper from ...backtest.utils import CommonInfrastructure, LevelInfrastructure from qlib.utils.file import get_io_object - - -def get_start_end_idx(strategy: BaseStrategy, outer_trade_decision: BaseTradeDecision) -> Union[int, int]: - """ - A helper function for getting the decision-level index range limitation for inner strategy - - NOTE: this function is not applicable to order-level - - Parameters - ---------- - strategy : BaseStrategy - the inner strawtegy - outer_trade_decision : BaseTradeDecision - the trade decision made by outer strategy - - Returns - ------- - Union[int, int]: - start index and end index - """ - try: - return outer_trade_decision.get_range_limit() - except NotImplementedError: - return 0, strategy.trade_calendar.get_trade_len() - 1 +from qlib.backtest.utils import get_start_end_idx class TWAPStrategy(BaseStrategy): @@ -105,7 +83,7 @@ class TWAPStrategy(BaseStrategy): # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] trade_step = self.trade_calendar.get_trade_step() # get the total count of trading step - start_idx, end_idx = get_start_end_idx(self, self.outer_trade_decision) + start_idx, end_idx = get_start_end_idx(self.trade_calendar, self.outer_trade_decision) trade_len = end_idx - start_idx + 1 if trade_step < start_idx or trade_step > end_idx: diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index a787c098f..23d6b520a 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from qlib.backtest.position import BasePosition from typing import List, Union from ..model.base import BaseModel @@ -37,24 +38,26 @@ class BaseStrategy: self.reset(level_infra=level_infra, common_infra=common_infra, outer_trade_decision=outer_trade_decision) + @property + def trade_calendar(self) -> TradeCalendarManager: + return self.level_infra.get("trade_calendar") + + @property + def trade_position(self) -> BasePosition: + return self.common_infra.get("trade_account").current + def reset_level_infra(self, level_infra: LevelInfrastructure): if not hasattr(self, "level_infra"): self.level_infra = level_infra else: self.level_infra.update(level_infra) - if level_infra.has("trade_calendar"): - self.trade_calendar: TradeCalendarManager = level_infra.get("trade_calendar") - def reset_common_infra(self, common_infra: CommonInfrastructure): if not hasattr(self, "common_infra"): self.common_infra: CommonInfrastructure = common_infra else: self.common_infra.update(common_infra) - if common_infra.has("trade_account"): - self.trade_position = common_infra.get("trade_account").current - def reset( self, level_infra: LevelInfrastructure = None, From 80f54266936d5b56919f98008d435a3385e9c24f Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 9 Jul 2021 08:29:19 +0000 Subject: [PATCH 106/187] update docsting --- qlib/backtest/executor.py | 7 ------- qlib/backtest/utils.py | 6 ++++++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index b99380c54..45c9082ed 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -348,13 +348,6 @@ class NestedExecutor(BaseExecutor): self.inner_strategy.alter_outer_trade_decision(trade_decision) return trade_decision - # def _get_inner_trade_decision(self, outer_trade_decision: BaseTradeDecision, inner_execute_result): - # # In some cases, the inner strategy can be skipped, but the inner executor should keep running - # if outer_trade_decision.empty() and self._skip_empty_decision: - # return EmptyTradeDecision(self.inner_strategy) - # return self.inner_strategy.generate_trade_decision(inner_execute_result) - # _inner_trade_decision = self._get_inner_trade_decision(trade_decision, _inner_execute_result) - def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): execute_result = [] inner_order_indicators = [] diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 5c643df30..7cce7b8d0 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -164,6 +164,12 @@ class LevelInfrastructure(BaseInfrastructure): """level instrastructure is created by executor, and then shared to strategies on the same level""" def get_support_infra(self): + """ + Descriptions about the infrastructure + + sub_level_infra: + - **NOTE**: this will only work after _init_sub_trading !!! + """ return ["trade_calendar", "sub_level_infra"] def reset_cal(self, freq, start_time, end_time): From 155019ba353bcd7d6758dd23914698f2c34395d8 Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 9 Jul 2021 10:33:41 +0000 Subject: [PATCH 107/187] move the pa sign from last step to first --- qlib/backtest/report.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 138a44faa..8a49af490 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -466,7 +466,10 @@ class Indicator: def _agg_order_price_advantage(self): if not self.order_indicator["trade_price"].empty: - self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 + sign = 1 - self.order_indicator["trade_dir"] * 2 + self.order_indicator["pa"] = sign * ( + self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 + ) else: self.order_indicator["pa"] = pd.Series() @@ -498,7 +501,11 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_price_advantage(self, method="mean"): - pa_order = self.order_indicator["pa"] * (1 - self.order_indicator["trade_dir"] * 2) + pa_order = self.order_indicator["pa"] + if isinstance(pa_order, (int, float)): + # pa from atomic executor + return pa_order + if method == "mean": return pa_order.mean() elif method == "amount_weighted": @@ -511,7 +518,10 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_positive_rate(self): - pa_order = self.order_indicator["pa"] * (2 * (self.order_indicator["amount"] < 0).astype(int) - 1) + pa_order = self.order_indicator["pa"] + if isinstance(pa_order, (int, float)): + # pa from atomic executor + return pa_order return (pa_order > 0).astype(int).sum() / pa_order.count() def _cal_trade_amount(self): From 45bde7527e9e8481a64f5a40f34d3302a2550af0 Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 9 Jul 2021 10:33:41 +0000 Subject: [PATCH 108/187] move the pa sign from last step to first --- qlib/backtest/report.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 138a44faa..8a49af490 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -466,7 +466,10 @@ class Indicator: def _agg_order_price_advantage(self): if not self.order_indicator["trade_price"].empty: - self.order_indicator["pa"] = self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 + sign = 1 - self.order_indicator["trade_dir"] * 2 + self.order_indicator["pa"] = sign * ( + self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 + ) else: self.order_indicator["pa"] = pd.Series() @@ -498,7 +501,11 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_price_advantage(self, method="mean"): - pa_order = self.order_indicator["pa"] * (1 - self.order_indicator["trade_dir"] * 2) + pa_order = self.order_indicator["pa"] + if isinstance(pa_order, (int, float)): + # pa from atomic executor + return pa_order + if method == "mean": return pa_order.mean() elif method == "amount_weighted": @@ -511,7 +518,10 @@ class Indicator: raise ValueError(f"method {method} is not supported!") def _cal_trade_positive_rate(self): - pa_order = self.order_indicator["pa"] * (2 * (self.order_indicator["amount"] < 0).astype(int) - 1) + pa_order = self.order_indicator["pa"] + if isinstance(pa_order, (int, float)): + # pa from atomic executor + return pa_order return (pa_order > 0).astype(int).sum() / pa_order.count() def _cal_trade_amount(self): From c29e5b262191557a3a3d08ef68a8a80a3a28973b Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Mon, 12 Jul 2021 13:50:13 +0000 Subject: [PATCH 109/187] Fix circular import --- qlib/strategy/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qlib/strategy/__init__.py b/qlib/strategy/__init__.py index e3fcd8e26..59e481eb9 100644 --- a/qlib/strategy/__init__.py +++ b/qlib/strategy/__init__.py @@ -1,4 +1,2 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - -from .base import * From 4c4b30ebeca4628e7d3e20f7d00d7270e89cfcde Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 13 Jul 2021 16:15:52 +0800 Subject: [PATCH 110/187] fix base price and volumn --- qlib/backtest/report.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 8a49af490..389b325bb 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -371,6 +371,10 @@ class Indicator: else: raise NotImplementedError(f"This type of input is not supported") + # if there is no stock data during the time period + if(price_s is None): + return None, None + # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. # price_s = price_s.mask(np.isclose(price_s, 0)) @@ -443,7 +447,7 @@ class Indicator: bp_new, bv_new = {}, {} for pr, v, (inst, direction) in zip(bp_s.values, bv_s.values, trade_dir.items()): if np.isnan(pr): - bp_new[inst], bv_new[inst] = self._get_base_vol_pri( + bp_tmp, bv_tmp = self._get_base_vol_pri( inst, start, end, @@ -452,6 +456,8 @@ class Indicator: trade_exchange=trade_exchange, pa_config=pa_config, ) + if((bp_tmp is not None) and (bv_tmp is not None)): + bp_new[inst], bv_new[inst] = bp_tmp, bv_tmp else: bp_new[inst], bv_new[inst] = pr, v From 9b38e62f21e1a05e935dfea70d6c35ed58b2a896 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 13 Jul 2021 14:46:53 +0000 Subject: [PATCH 111/187] Add more friendly index range by timing --- qlib/backtest/executor.py | 8 +- qlib/backtest/order.py | 151 ++++++++++++++++++++++++++++++++++---- qlib/backtest/report.py | 4 +- qlib/backtest/utils.py | 30 +++++++- qlib/utils/time.py | 28 ++++--- 5 files changed, 189 insertions(+), 32 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 45c9082ed..8c32077e7 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -368,11 +368,17 @@ class NestedExecutor(BaseExecutor): break sub_cal: TradeCalendarManager = self.inner_executor.trade_calendar + + # NOTE: make sure get_start_end_idx is after `self._update_trade_decision` start_idx, end_idx = get_start_end_idx(sub_cal, trade_decision) if not self._align_range_limit or start_idx <= sub_cal.get_trade_step() <= end_idx: # if force align the range limit, skip the steps outside the decision range limit - _inner_trade_decision = self.inner_strategy.generate_trade_decision(_inner_execute_result) + _inner_trade_decision: BaseTradeDecision = self.inner_strategy.generate_trade_decision( + _inner_execute_result + ) + trade_decision.mod_inner_decision(_inner_trade_decision) # propagate part of decision information + # NOTE sub_cal.get_cur_step_time() must be called before collect_data in case of step shifting decision_list.append((_inner_trade_decision, *sub_cal.get_cur_step_time())) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index a1beeec38..eee9bd8f2 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -3,10 +3,11 @@ # TODO: rename it with decision.py from __future__ import annotations from enum import IntEnum +from qlib.utils.time import concat_date_time from qlib.log import get_module_logger # try to fix circular imports when enabling type hints -from typing import TYPE_CHECKING +from typing import Callable, TYPE_CHECKING if TYPE_CHECKING: from qlib.strategy.base import BaseStrategy @@ -164,6 +165,35 @@ class OrderHelper: ) +class IndexRangeByTime: + """This is a helper function for make decisions""" + + def __init__(self, start_time: str, end_time: str): + """ + This is a callable class. + + **NOTE**: + - It is designed for minute-bar for intraday trading!!!!! + - Both start_time and end_time are **closed** in the range + + Parameters + ---------- + start_time : str + e.g. "9:30" + end_time : str + e.g. "14:30" + """ + self.start_time = pd.Timestamp(start_time).time() + self.end_time = pd.Timestamp(end_time).time() + + def __call__(self, trade_calendar: TradeCalendarManager) -> Tuple[int, int]: + start = trade_calendar.start_time + val_start, val_end = concat_date_time(start.date(), self.start_time), concat_date_time( + start.date(), self.end_time + ) + return trade_calendar.get_range_idx(val_start, val_end) + + class BaseTradeDecision: """ Trade decisions ara made by strategy and executed by exeuter @@ -180,16 +210,54 @@ class BaseTradeDecision: 2. Same as `case 1.3` """ - def __init__(self, strategy: BaseStrategy, idx_range: Tuple[int, int] = None): + def __init__(self, strategy: BaseStrategy, idx_range: Union[Tuple[int, int], Callable] = None): """ Parameters ---------- strategy : BaseStrategy The strategy who make the decision + idx_range: Union[Tuple[int, int], Callable] (optional) + The index range for underlying strategy. + + Here are two examples of idx_range for each type + + 1) Tuple[int, int] + start_index and end_index of the underlying factor(both sides are closed) + + + 2) Callable + + .. code-block:: python + def idx_range(time_per_step: str) -> Tuple[int, int]: + # time_per_step is the strategy's time_per_step (not inner strategy. It's the `self` strategy in + # `self._idx_range` ) + # e.g. + # For example, strategy A with 30min each step and strategy B with 1min each step + # strategy A's will use "30min" when calling `idx_range`. + """ self.strategy = strategy self.total_step = None # upper strategy has no knowledge about the sub executor before `_init_sub_trading` - self.idx_range = idx_range + self._idx_range = idx_range + + @staticmethod + def _calc_idx_range( + idx_range: Union[Tuple[int, int], Callable], inner_calendar: TradeCalendarManager = None + ) -> Tuple[int, int]: + """calculate index range for `idx_range` in different cases""" + if idx_range is None: + # not set, return nothing + return None, None + elif isinstance(idx_range, tuple): + return idx_range + elif isinstance(idx_range, Callable): + if inner_calendar is None: + # time_per_step is a required parameter for `def idx_range` + return None, None + else: + return idx_range(inner_calendar) + else: + raise NotImplementedError(f"This type of input is not supported") def get_decision(self) -> List[object]: """ @@ -212,7 +280,7 @@ class BaseTradeDecision: """ Be called at the **start** of each step. - This function is designn for following purpose + This function is design for following purpose 1) Leave a hook for the strategy who make `self` decision to update the decision itself 2) Update some information from the inner executor calendar @@ -230,12 +298,6 @@ class BaseTradeDecision: """ # purpose 1) self.total_step = trade_calendar.get_trade_len() - if self.idx_range is not None: - logger = get_module_logger("decision") - start_idx, end_idx = self.idx_range - if start_idx < 0 or end_idx >= self.total_step: - logger.warning(f"{self.idx_range} go beyound the total_step({self.total_step}), it will be clipped") - self.idx_range = max(0, start_idx), min(self.total_step - 1, end_idx) # purpose 2) return self.strategy.update_trade_decision(self, trade_calendar) @@ -245,9 +307,28 @@ class BaseTradeDecision: return the expected step range for limiting the decision execution time Both left and right are **closed** + if no available _idx_range, `default_value` will be returned + + It is only used in `NestedExecutor` + - The outmost strategy will not follow any range limit (but it may give range_limit) + - The inner most strategy's range_limit will be useless due to atomic executors don't have such + features. + + **NOTE**: + 1) This function must be called after `self.update` in following cases(ensured by NestedExecutor): + - user relies on the auto-clip feature of `self.update` + + 2) This function will be called after _init_sub_trading in NestedExecutor. + + Parameters + ---------- **kwargs: - {"default_value": } - # using dict is for distinguish no value provided or None provided + { + "default_value": , # using dict is for distinguish no value provided or None provided + "inner_calendar": + # because the range limit will control the step range of inner strategy, inner calendar will be a + # important parameter when _idx_range is callable + } Returns ------- @@ -258,15 +339,32 @@ class BaseTradeDecision: NotImplementedError: If the following criteria meet 1) the decision can't provide a unified start and end - 2) default_value is None + 2) default_value is not provided """ - if self.idx_range is None: + + # get index + _start_idx, _end_idx = self._calc_idx_range(self._idx_range, inner_calendar=kwargs.get("inner_calendar")) + if _start_idx is None or _end_idx is None: + # handle case without decision + # TODO: time range in the order should be checked. + + # _start_idx and _end_idx should be used instead of _idx_range + # because it is possible that no limitation when _idx_range is callable and return None if "default_value" in kwargs: return kwargs["default_value"] else: # Default to get full index raise NotImplementedError(f"The decision didn't provide an index range") - return self.idx_range + else: + # clip index + if getattr(self, "total_step", None) is not None: + # if `self.update` is called. + # Then the _start_idx, _end_idx should be clipped + if _start_idx < 0 or _end_idx >= self.total_step: + logger = get_module_logger("decision") + logger.warning(f"{self._idx_range} go beyoud the total_step({self.total_step}), it will be clipped") + _start_idx, _end_idx = max(0, _start_idx), min(self.total_step - 1, _end_idx) + return _start_idx, _end_idx def empty(self) -> bool: for obj in self.get_decision(): @@ -278,6 +376,27 @@ class BaseTradeDecision: return True return True + def mod_inner_decision(self, inner_trade_decision: BaseTradeDecision): + """ + + This method will be called on the inner_trade_decision after it is generated. + `inner_trade_decision` will be changed **inplaced**. + + Motivation of the `mod_inner_decision` + - Leave a hook for outer decision to affact the decision generated by the inner strategy + - e.g. the outmost strategy generate a time range for trading. But the upper layer can only affact the + nearest layer in the original design. With `mod_inner_decision`, the decision can passed through multiple + layers + + Parameters + ---------- + inner_trade_decision : BaseTradeDecision + """ + # base class provide a default behaviour to modify inner_trade_decision + # callable _idx_range should be propagated when inner _idx_range is not set + if isinstance(self._idx_range, Callable) and inner_trade_decision._idx_range is None: + inner_trade_decision._idx_range = self._idx_range + class EmptyTradeDecision(BaseTradeDecision): def empty(self) -> bool: @@ -298,7 +417,7 @@ class TradeDecisionWO(BaseTradeDecision): return self.order_list def __repr__(self) -> str: - return f"strategy: {self.strategy}; idx_range: {self.idx_range}; order_list[{len(self.order_list)}]" + return f"strategy: {self.strategy}; idx_range: {self._idx_range}; order_list[{len(self.order_list)}]" # TODO: the orders below need to be discussed ------------------------------------ diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 389b325bb..cb650beb7 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -372,7 +372,7 @@ class Indicator: raise NotImplementedError(f"This type of input is not supported") # if there is no stock data during the time period - if(price_s is None): + if price_s is None: return None, None # NOTE: there are some zeros in the trading price. These cases are known meaningless @@ -456,7 +456,7 @@ class Indicator: trade_exchange=trade_exchange, pa_config=pa_config, ) - if((bp_tmp is not None) and (bv_tmp is not None)): + if (bp_tmp is not None) and (bv_tmp is not None): bp_new[inst], bv_new[inst] = bp_tmp, bv_tmp else: bp_new[inst], bv_new[inst] = pr, v diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 7cce7b8d0..60a49b0e2 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. from __future__ import annotations +import bisect from typing import Union, TYPE_CHECKING, Tuple, Union, List, Set if TYPE_CHECKING: @@ -118,6 +119,33 @@ class TradeCalendarManager: """Get the start_time and end_time for trading""" return self.start_time, self.end_time + # helper functions + def get_range_idx(self, start_time: pd.Timestamp, end_time: pd.Timestamp) -> Tuple[int, int]: + """ + get the range index which involve start_time~end_time (both sides are closed) + + Parameters + ---------- + start_time : pd.Timestamp + end_time : pd.Timestamp + + Returns + ------- + Tuple[int, int]: + the index of the range. **the left and right are closed** + """ + left, right = ( + bisect.bisect_right(self._calendar, start_time) - 1, + bisect.bisect_right(self._calendar, end_time) - 1, + ) + left -= self.start_index + right -= self.start_index + + def clip(idx): + return min(max(0, idx), self.trade_len - 1) + + return clip(left), clip(right) + def __repr__(self) -> str: return f"{self.start_time}[{self.start_index}]~{self.end_time}[{self.end_index}]: [{self.trade_step}/{self.trade_len}]" @@ -201,6 +229,6 @@ def get_start_end_idx(trade_calendar: TradeCalendarManager, outer_trade_decision start index and end index """ try: - return outer_trade_decision.get_range_limit() + return outer_trade_decision.get_range_limit(inner_calendar=trade_calendar) except NotImplementedError: return 0, trade_calendar.get_trade_len() - 1 diff --git a/qlib/utils/time.py b/qlib/utils/time.py index bfbdb9f1f..f4913dde4 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -4,7 +4,7 @@ Time related utils are compiled in this script """ import bisect -from datetime import datetime, time +from datetime import datetime, time, date from typing import List, Tuple import re from numpy import append @@ -122,6 +122,20 @@ def get_day_min_idx_range(start: str, end: str, freq: str) -> Tuple[int, int]: return left_idx, right_idx +def concat_date_time(date_obj: date, time_obj: time) -> pd.Timestamp: + return pd.Timestamp( + datetime( + date_obj.year, + month=date_obj.month, + day=date_obj.day, + hour=time_obj.hour, + minute=time_obj.minute, + second=time_obj.second, + microsecond=time_obj.microsecond, + ) + ) + + def cal_sam_minute(x: pd.Timestamp, sam_minutes: int) -> pd.Timestamp: """ align the minute-level data to a down sampled calendar @@ -143,17 +157,7 @@ def cal_sam_minute(x: pd.Timestamp, sam_minutes: int) -> pd.Timestamp: cal = get_min_cal(C.min_data_shift)[::sam_minutes] idx = bisect.bisect_right(cal, x.time()) - 1 date, new_time = x.date(), cal[idx] - return pd.Timestamp( - datetime( - date.year, - month=date.month, - day=date.day, - hour=new_time.hour, - minute=new_time.minute, - second=new_time.second, - microsecond=new_time.microsecond, - ) - ) + return concat_date_time(date, new_time) if __name__ == "__main__": From ca14e36f7a5d9e281939d58490c23a8ae063ddd0 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 13 Jul 2021 20:54:58 +0800 Subject: [PATCH 112/187] initial account by position --- qlib/backtest/__init__.py | 24 ++++++++++++++++++------ qlib/backtest/account.py | 10 +++++++--- qlib/backtest/position.py | 4 ++-- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 99e5b8790..a171ef81e 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -8,6 +8,7 @@ from .account import Account if TYPE_CHECKING: from ..strategy.base import BaseStrategy +from .position import Position from .exchange import Exchange from .executor import BaseExecutor from .backtest import backtest_loop @@ -95,7 +96,7 @@ def get_exchange( def create_account_instance( - start_time, end_time, benchmark: str, account: float, pos_type: str = "Position" + start_time, end_time, benchmark: str, account: Union[float, int, Position], pos_type: str = "Position" ) -> Account: """ # TODO: is very strange pass benchmark_config in the account(maybe for report) @@ -109,13 +110,23 @@ def create_account_instance( end time of the benchmark benchmark : str the benchmark for reporting - account : Union[float, str] + account : Union[float, int, Position] information for describing how to creating the account - For `float` - Using Account with a normal position - For `str`: - Using account with a specific Position + For `float` or `int`: + Using Account with only initial cash + For `Position`: + Using Account with a Position """ + if(type(account) in (int, float)): + pos_kwargs = {"init_cash": account} + elif(type(account) is Position): + pos_kwargs = { + "init_cash": account.position["cash"], + "position_dict": account.position, + } + else: + raise ValueError("account must be in (int, float, Position)") + kwargs = { "init_cash": account, "benchmark_config": { @@ -125,6 +136,7 @@ def create_account_instance( }, "pos_type": pos_type, } + kwargs.update(pos_kwargs) return Account(**kwargs) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 3ef1cdd03..fee0a98c4 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -67,6 +67,7 @@ class Account: def __init__( self, init_cash: float = 1e9, + position_dict: dict = {}, freq: str = "day", benchmark_config: dict = {}, pos_type: str = "Position", @@ -74,7 +75,7 @@ class Account: ): self._pos_type = pos_type self._port_metr_enabled = port_metr_enabled - self.init_vars(init_cash, freq, benchmark_config) + self.init_vars(init_cash, position_dict, freq, benchmark_config) def is_port_metr_enabled(self): """ @@ -82,14 +83,17 @@ class Account: """ return self._port_metr_enabled and not self.current.skip_update() - def init_vars(self, init_cash, freq: str, benchmark_config: dict): + def init_vars(self, init_cash, position_dict, freq: str, benchmark_config: dict): # init cash self.init_cash = init_cash self.current: BasePosition = init_instance_by_config( { "class": self._pos_type, - "kwargs": {"cash": init_cash}, + "kwargs": { + "cash": init_cash, + "position_dict": position_dict, + }, "module_path": "qlib.backtest.position", } ) diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index b1037d460..7c32edc81 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -199,13 +199,13 @@ class Position(BasePosition): } """ - def __init__(self, cash=0, position_dict={}, now_account_value=0): + def __init__(self, cash=0, position_dict={}): # NOTE: The position dict must be copied!!! # Otherwise the initial value self.init_cash = cash self.position = position_dict.copy() self.position["cash"] = cash - self.position["now_account_value"] = now_account_value + self.position["now_account_value"] = self.calculate_value() def _init_stock(self, stock_id, amount, price=None): """ From 0646e53d24df78d76e20215482d2c5af51f668b8 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 13 Jul 2021 21:07:38 +0800 Subject: [PATCH 113/187] fix spell error --- qlib/backtest/executor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 8c32077e7..009e3300f 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -215,7 +215,7 @@ class BaseExecutor: execute_result : List[object] the executed result for trade decision. ** NOTE!!!! **: - 1) This is necessary, The return value of geenrator will be used in NestedExecutor + 1) This is necessary, The return value of generator will be used in NestedExecutor 2) Please note the executed results are not merged. Yields From 7b9e338a0d86d9596a4449fbbd3095e5018afe1d Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 13 Jul 2021 22:01:40 +0800 Subject: [PATCH 114/187] add docs --- qlib/backtest/__init__.py | 50 +++++++++++++++++++++++++++++++++++---- qlib/backtest/account.py | 2 +- qlib/backtest/backtest.py | 2 ++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index a171ef81e..b4c3e32b8 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -117,13 +117,13 @@ def create_account_instance( For `Position`: Using Account with a Position """ - if(type(account) in (int, float)): + if isinstance(account, (int, float)): pos_kwargs = {"init_cash": account} - elif(type(account) is Position): + elif isinstance(account, Position): pos_kwargs = { "init_cash": account.position["cash"], "position_dict": account.position, - } + } else: raise ValueError("account must be in (int, float, Position)") @@ -146,7 +146,7 @@ def get_strategy_executor( strategy: BaseStrategy, executor: BaseExecutor, benchmark: str = "SH000300", - account: Union[float, str] = 1e9, + account: Union[float, int, Position] = 1e9, exchange_kwargs: dict = {}, pos_type: str = "Position", ): @@ -184,7 +184,41 @@ def backtest( exchange_kwargs={}, pos_type: str = "Position", ): + """initialize the strategy and executor, then backtest funciton for the interaction of the outermost strategy and executor in the nested decision execution + Parameters + ---------- + start_time : pd.Timestamp|str + closed start time for backtest + **NOTE**: This will be applied to the outmost executor's calendar. + end_time : pd.Timestamp|str + closed end time for backtest + **NOTE**: This will be applied to the outmost executor's calendar. + E.g. Executor[day](Executor[1min]), setting `end_time == 20XX0301` will include all the minutes on 20XX0301 + strategy : Union[str, dict, BaseStrategy] + for initializing outermost portfolio strategy. Please refer to the docs of init_instance_by_config for more information. + executor : Union[str, dict, BaseExecutor] + for initializing the outermost executor. + benchmark: str + the benchmark for reporting. + account : Union[float, int, Position] + information for describing how to creating the account + For `float` or `int`: + Using Account with only initial cash + For `Position`: + Using Account with a Position + exchange_kwargs : dict + the kwargs for initializing Exchange + pos_type : str + the type of Position. + + Returns + ------- + report_dict: Report + it records the trading report information + indicator_dict: Indicator + it computes the trading indicator + """ trade_strategy, trade_executor = get_strategy_executor( start_time, end_time, @@ -210,7 +244,15 @@ def collect_data( exchange_kwargs={}, pos_type: str = "Position", ): + """initialize the strategy and executor, then collect the trade decision data for rl training + please refer to the docs of the backtest for the explanation of the parameters + + Yields + ------- + object + trade decision + """ trade_strategy, trade_executor = get_strategy_executor( start_time, end_time, diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index fee0a98c4..806f88a96 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -93,7 +93,7 @@ class Account: "kwargs": { "cash": init_cash, "position_dict": position_dict, - }, + }, "module_path": "qlib.backtest.position", } ) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 89b8c7830..5c9948b1b 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -21,6 +21,8 @@ def backtest_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_exec ------- report: Report it records the trading report information + indicator: Indicator + it computes the trading indicator """ return_value = {} for _decision in collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value): From 571d27cba7949c65efdfa6b5f48fee8a9c1759e5 Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 14 Jul 2021 13:05:36 +0000 Subject: [PATCH 115/187] exchange support expression buy sell limit --- qlib/backtest/exchange.py | 63 +++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 3794651dc..58f57ed73 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -26,7 +26,7 @@ class Exchange: codes="all", deal_price: Union[str, Tuple[str], List[str]] = None, subscribe_fields=[], - limit_threshold=None, + limit_threshold: Union[Tuple[str, str], float, None] = None, volume_threshold=None, open_cost=0.0015, close_cost=0.0025, @@ -41,7 +41,7 @@ class Exchange: :param end_time: closed end time for backtest :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) - :param deal_price: Union[str, Tuple[str], List[str]] + :param deal_price: Union[str, Tuple[str, str], List[str]] The `deal_price` supports following two types of input - : str - (, ): Tuple[str] or List[str] @@ -51,8 +51,16 @@ class Exchange: - for example '$close', '$open', '$vwap' ("close" is OK. `Exchange` will help to prepend "$" to the expression) - :param subscribe_fields: list, subscribe fields - :param limit_threshold: float, 0.1 for example, default None + :param subscribe_fields: list, subscribe fields. This expressions will be added to the query and `self.quote`. + It is useful when users want more fields to be queried + + :param limit_threshold: Union[Tuple[str, str], float, None] + 1) `None`: no limitation + 2) float, 0.1 for example, default None + 3) Tuple[str, str]: (, + ) + `False` value indicates the stock is tradable + `True` value indicates the stock is limited and not tradable :param volume_threshold: float, 0.1 for example, default None :param open_cost: cost rate for open, default 0.0015 :param close_cost: cost rate for close, default 0.0025 @@ -97,7 +105,7 @@ class Exchange: if limit_threshold is None: if C.region == REG_CN: self.logger.warning(f"limit_threshold not set. The stocks hit the limit may be bought/sold") - elif abs(limit_threshold) > 0.1: + elif self._get_limit_type(limit_threshold) == self.LT_FLT and abs(limit_threshold) > 0.1: if C.region == REG_CN: self.logger.warning(f"limit_threshold may not be set to a reasonable value") @@ -119,13 +127,17 @@ class Exchange: # $change is for calculating the limit of the stock necessary_fields = {self.buy_price, self.sell_price, "$close", "$change", "$factor", "$volume"} + if self._get_limit_type(limit_threshold) == self.LT_TP_EXP: + for exp in limit_threshold: + necessary_fields.add(exp) subscribe_fields = list(necessary_fields | set(subscribe_fields)) all_fields = list(necessary_fields | set(subscribe_fields)) + self.all_fields = all_fields self.open_cost = open_cost self.close_cost = close_cost self.min_cost = min_cost - self.limit_threshold = limit_threshold + self.limit_threshold: Union[Tuple[str, str], float, None] = limit_threshold self.volume_threshold = volume_threshold self.extra_quote = extra_quote self.set_quote(codes, start_time, end_time) @@ -133,6 +145,7 @@ class Exchange: def set_quote(self, codes, start_time, end_time): if len(codes) == 0: codes = D.instruments() + self.quote = D.features(codes, self.all_fields, start_time, end_time, freq=self.freq, disk_cache=True).dropna( subset=["$close"] ) @@ -157,13 +170,7 @@ class Exchange: self.trade_w_adj_price = False # update limit - # check limit_threshold - if self.limit_threshold is None: - self.quote["limit_buy"] = False - self.quote["limit_sell"] = False - else: - # set limit - self._update_limit(buy_limit=self.limit_threshold, sell_limit=self.limit_threshold) + self._update_limit() quote_df = self.quote if self.extra_quote is not None: @@ -194,9 +201,33 @@ class Exchange: self.quote = quote_dict - def _update_limit(self, buy_limit, sell_limit): - self.quote["limit_buy"] = self.quote["$change"].ge(buy_limit) - self.quote["limit_sell"] = self.quote["$change"].le(-sell_limit) + LT_TP_EXP = "(exp)" # Tuple[str, str] + LT_FLT = "float" # float + LT_NONE = "none" # none + + def _get_limit_type(self, limit_threshold): + if isinstance(limit_threshold, Tuple): + return self.LT_TP_EXP + elif isinstance(limit_threshold, float): + return self.LT_FLT + elif limit_threshold is None: + return self.LT_NONE + else: + raise NotImplementedError(f"This type of `limit_threshold` is not supported") + + def _update_limit(self): + # check limit_threshold + lt_type = self._get_limit_type(self.limit_threshold) + if lt_type == self.LT_NONE: + self.quote["limit_buy"] = False + self.quote["limit_sell"] = False + elif lt_type == self.LT_TP_EXP: + # set limit + self.quote["limit_buy"] = self.quote[self.limit_threshold[0]] + self.quote["limit_sell"] = self.quote[self.limit_threshold[1]] + elif lt_type == self.LT_FLT: + self.quote["limit_buy"] = self.quote["$change"].ge(self.limit_threshold) + self.quote["limit_sell"] = self.quote["$change"].le(-self.limit_threshold) # pylint: disable=E1130 def check_stock_limit(self, stock_id, start_time, end_time, direction=None): """ From 94b456714de74917b629f7bc042527b662363432 Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 15 Jul 2021 07:54:27 +0000 Subject: [PATCH 116/187] refactor index_range to trade_range --- qlib/backtest/__init__.py | 2 +- qlib/backtest/executor.py | 2 +- qlib/backtest/order.py | 258 ++++++++++--------------- qlib/backtest/report.py | 20 +- qlib/contrib/strategy/rule_strategy.py | 29 ++- 5 files changed, 121 insertions(+), 190 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index b4c3e32b8..23b8ec9c5 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -247,7 +247,7 @@ def collect_data( """initialize the strategy and executor, then collect the trade decision data for rl training please refer to the docs of the backtest for the explanation of the parameters - + Yields ------- object diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 009e3300f..78cdbe5e0 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -296,7 +296,7 @@ class NestedExecutor(BaseExecutor): - The decisions may be updated by steps - The inner executor may not follow the decisions from the outer strategy align_range_limit: bool - force to align the index_range decision + force to align the trade_range decision It is only for nested executor, because range_limit is given by outer strategy """ self.inner_executor: BaseExecutor = init_instance_by_config( diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index eee9bd8f2..6bf1d5ad9 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -165,7 +165,60 @@ class OrderHelper: ) -class IndexRangeByTime: +class TradeRange: + def __call__(self, trade_calendar: TradeCalendarManager) -> Tuple[int, int]: + """ + This method will be call with following way + + The outer strategy give a decision with with `TradeRange` + The decision will be checked by the inner decision. + inner decision will pass its trade_calendar as parameter when getting the trading range + - The framework's step is integer-index based. + + Parameters + ---------- + trade_calendar : TradeCalendarManager + the trade_calendar is from inner strategy + + Returns + ------- + Tuple[int, int]: + the start index and end index which are tradable + + Raises + ------ + NotImplementedError: + Exceptions are raised when no range limitation + """ + raise NotImplementedError(f"Please implement the `__call__` method") + + def clip_time_range(self, start_time: pd.Timestamp, end_time: pd.Timestamp) -> Tuple[pd.Timestamp, pd.Timestamp]: + """ + Parameters + ---------- + start_time : pd.Timestamp + end_time : pd.Timestamp + Both sides (start_time, end_time) are closed + + Returns + ------- + Tuple[pd.Timestamp, pd.Timestamp]: + The tradable time range. + - It is intersection of [start_time, end_time] and the rule of TradeRange itself + """ + raise NotImplementedError(f"Please implement the `clip_time_range` method") + + +class IdxTradeRange(TradeRange): + def __init__(self, start_idx: int, end_idx: int): + self._start_idx = start_idx + self._end_idx = end_idx + + def __call__(self, trade_calendar: TradeCalendarManager = None) -> Tuple[int, int]: + return self._start_idx, self._end_idx + + +class TradeRangeByTime(TradeRange): """This is a helper function for make decisions""" def __init__(self, start_time: str, end_time: str): @@ -185,14 +238,24 @@ class IndexRangeByTime: """ self.start_time = pd.Timestamp(start_time).time() self.end_time = pd.Timestamp(end_time).time() + assert self.start_time < self.end_time - def __call__(self, trade_calendar: TradeCalendarManager) -> Tuple[int, int]: + def __call__(self, trade_calendar: TradeCalendarManager = None) -> Tuple[int, int]: + if trade_calendar is None: + raise NotImplementedError("trade_calendar is necessary for getting TradeRangeByTime.") start = trade_calendar.start_time val_start, val_end = concat_date_time(start.date(), self.start_time), concat_date_time( start.date(), self.end_time ) return trade_calendar.get_range_idx(val_start, val_end) + def clip_time_range(self, start_time: pd.Timestamp, end_time: pd.Timestamp) -> Tuple[pd.Timestamp, pd.Timestamp]: + start_date = start_time.date() + val_start, val_end = concat_date_time(start_date, self.start_time), concat_date_time(start_date, self.end_time) + # NOTE: `end_date` should not be used. Because the `end_date` is for slicing. It may be in the next day + # Assumption: start_time and end_time is for intraday trading. So it is OK for only using start_date + return max(val_start, start_time), min(val_end, end_time) + class BaseTradeDecision: """ @@ -210,54 +273,29 @@ class BaseTradeDecision: 2. Same as `case 1.3` """ - def __init__(self, strategy: BaseStrategy, idx_range: Union[Tuple[int, int], Callable] = None): + def __init__(self, strategy: BaseStrategy, trade_range: Union[Tuple[int, int], TradeRange] = None): """ Parameters ---------- strategy : BaseStrategy The strategy who make the decision - idx_range: Union[Tuple[int, int], Callable] (optional) + trade_range: Union[Tuple[int, int], Callable] (optional) The index range for underlying strategy. - Here are two examples of idx_range for each type + Here are two examples of trade_range for each type 1) Tuple[int, int] - start_index and end_index of the underlying factor(both sides are closed) + start_index and end_index of the underlying strategy(both sides are closed) - - 2) Callable - - .. code-block:: python - def idx_range(time_per_step: str) -> Tuple[int, int]: - # time_per_step is the strategy's time_per_step (not inner strategy. It's the `self` strategy in - # `self._idx_range` ) - # e.g. - # For example, strategy A with 30min each step and strategy B with 1min each step - # strategy A's will use "30min" when calling `idx_range`. + 2) TradeRange """ self.strategy = strategy self.total_step = None # upper strategy has no knowledge about the sub executor before `_init_sub_trading` - self._idx_range = idx_range - - @staticmethod - def _calc_idx_range( - idx_range: Union[Tuple[int, int], Callable], inner_calendar: TradeCalendarManager = None - ) -> Tuple[int, int]: - """calculate index range for `idx_range` in different cases""" - if idx_range is None: - # not set, return nothing - return None, None - elif isinstance(idx_range, tuple): - return idx_range - elif isinstance(idx_range, Callable): - if inner_calendar is None: - # time_per_step is a required parameter for `def idx_range` - return None, None - else: - return idx_range(inner_calendar) - else: - raise NotImplementedError(f"This type of input is not supported") + if isinstance(trade_range, Tuple): + # for Tuple[int, int] + trade_range = IdxTradeRange(**trade_range) + self.trade_range: TradeRange = trade_range def get_decision(self) -> List[object]: """ @@ -302,12 +340,18 @@ class BaseTradeDecision: # purpose 2) return self.strategy.update_trade_decision(self, trade_calendar) + def _get_range_limit(self, **kwargs) -> Tuple[int, int]: + if self.trade_range is not None: + return self.trade_range(trade_calendar=kwargs.get("inner_calendar")) + else: + raise NotImplementedError("The decision didn't provide an index range") + def get_range_limit(self, **kwargs) -> Tuple[int, int]: """ return the expected step range for limiting the decision execution time Both left and right are **closed** - if no available _idx_range, `default_value` will be returned + if no available trade_range, `default_value` will be returned It is only used in `NestedExecutor` - The outmost strategy will not follow any range limit (but it may give range_limit) @@ -327,7 +371,7 @@ class BaseTradeDecision: "default_value": , # using dict is for distinguish no value provided or None provided "inner_calendar": # because the range limit will control the step range of inner strategy, inner calendar will be a - # important parameter when _idx_range is callable + # important parameter when trade_range is callable } Returns @@ -341,29 +385,25 @@ class BaseTradeDecision: 1) the decision can't provide a unified start and end 2) default_value is not provided """ - - # get index - _start_idx, _end_idx = self._calc_idx_range(self._idx_range, inner_calendar=kwargs.get("inner_calendar")) - if _start_idx is None or _end_idx is None: - # handle case without decision - # TODO: time range in the order should be checked. - - # _start_idx and _end_idx should be used instead of _idx_range - # because it is possible that no limitation when _idx_range is callable and return None + try: + _start_idx, _end_idx = self._get_range_limit(**kwargs) + except NotImplementedError: if "default_value" in kwargs: return kwargs["default_value"] else: # Default to get full index raise NotImplementedError(f"The decision didn't provide an index range") - else: - # clip index - if getattr(self, "total_step", None) is not None: - # if `self.update` is called. - # Then the _start_idx, _end_idx should be clipped - if _start_idx < 0 or _end_idx >= self.total_step: - logger = get_module_logger("decision") - logger.warning(f"{self._idx_range} go beyoud the total_step({self.total_step}), it will be clipped") - _start_idx, _end_idx = max(0, _start_idx), min(self.total_step - 1, _end_idx) + + # clip index + if getattr(self, "total_step", None) is not None: + # if `self.update` is called. + # Then the _start_idx, _end_idx should be clipped + if _start_idx < 0 or _end_idx >= self.total_step: + logger = get_module_logger("decision") + logger.warning( + f"[{_start_idx},{_end_idx}] go beyoud the total_step({self.total_step}), it will be clipped" + ) + _start_idx, _end_idx = max(0, _start_idx), min(self.total_step - 1, _end_idx) return _start_idx, _end_idx def empty(self) -> bool: @@ -393,9 +433,9 @@ class BaseTradeDecision: inner_trade_decision : BaseTradeDecision """ # base class provide a default behaviour to modify inner_trade_decision - # callable _idx_range should be propagated when inner _idx_range is not set - if isinstance(self._idx_range, Callable) and inner_trade_decision._idx_range is None: - inner_trade_decision._idx_range = self._idx_range + # trade_range should be propagated when inner trade_range is not set + if inner_trade_decision.trade_range is None: + inner_trade_decision.trade_range = self.trade_range class EmptyTradeDecision(BaseTradeDecision): @@ -409,106 +449,12 @@ class TradeDecisionWO(BaseTradeDecision): Besides, the time_range is also included. """ - def __init__(self, order_list: List[Order], strategy: BaseStrategy, idx_range: Tuple[int, int] = None): - super().__init__(strategy, idx_range=idx_range) + def __init__(self, order_list: List[Order], strategy: BaseStrategy, trade_range: Tuple[int, int] = None): + super().__init__(strategy, trade_range=trade_range) self.order_list = order_list def get_decision(self) -> List[object]: return self.order_list def __repr__(self) -> str: - return f"strategy: {self.strategy}; idx_range: {self._idx_range}; order_list[{len(self.order_list)}]" - - -# TODO: the orders below need to be discussed ------------------------------------ -# - The classes below are designed for Case 1 -# - However, Case 1 can't take `order_pool` as the an argument as the constructor function -class TradeDecisionWithOrderPool: - """trade decision that made by strategy""" - - def __init__(self, strategy, order_pool): - """ - Parameters - ---------- - strategy : BaseStrategy - the original strategy that make the decision - order_pool : list, optional - the candinate order pool for generate trade decision - """ - super(TradeDecisionWithOrderPool, self).__init__(strategy) - self.order_pool = order_pool - self.order_list = [] - - def pop_order_pool(self, pop_len): - if pop_len > len(self.order_pool): - warnings.warn( - f"pop len {pop_len} is too much length than order pool, cut it as pool length {len(self.order_pool)}" - ) - pop_len = len(self.order_pool) - res = self.order_pool[:pop_len] - del self.order_pool[:pop_len] - return res - - def push_order_list(self, order_list): - self.order_list.extend(order_list) - - def get_decision(self): - """get the order list - - Parameters - ---------- - only_enable : bool, optional - wether to ignore disabled order, by default False - only_disable : bool, optional - wether to ignore enabled order, by default False - Returns - ------- - List[Order] - the order list - """ - return self.order_list - - def update(self, trade_calendar): - """make the original strategy update the enabled status of orders.""" - self.ori_strategy.update_trade_decision(self, trade_calendar) - - -class BaseDecisionUpdater: - def update_decision(self, decision, trade_calendar) -> BaseTradeDecision: - """ - Parameters - ---------- - decision : BaseTradeDecision - the trade decision to be updated - trade_calendar : BaseTradeCalendar - the trade calendar of inner execution - - Returns - ------- - BaseTradeDecision - the updated decision - """ - raise NotImplementedError(f"This method is not implemented") - - -class DecisionUpdaterWithOrderPool: - def __init__(self, plan_config=None): - """ - Parameters - ---------- - plan_config : Dict[Tuple(int, float)], optional - the plan config, by default None - """ - if plan_config is None: - self.plan_config = [(0, 1)] - else: - self.plan_config = plan_config - - def update_decision(self, decision, trade_calendar) -> BaseTradeDecision: - # get the number of trading step finished, trade_step can be [0, 1, 2, ..., trade_len - 1] - trade_step = self.trade_calendar.get_trade_step() - for _index, _ratio in self.plan_config: - if trade_step == _index: - pop_len = len(decision.order_pool) * _ratio - pop_order_list = decision.pop_order_pool(pop_len) - decision.push_order_list(pop_order_list) + return f"strategy: {self.strategy}; trade_range: {self.trade_range}; order_list[{len(self.order_list)}]" diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index cb650beb7..0d4d3f0d7 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -364,6 +364,11 @@ class Indicator: agg = pa_config.get("agg", "twap").lower() price = pa_config.get("price", "deal_price").lower() + # NOTE: IndexTradeRange is not supported!!!!! Because inner index is not available + trade_start_time, trade_end_time = decision.trade_range.clip_time_range( + start_time=trade_start_time, end_time=trade_end_time + ) + if price == "deal_price": price_s = trade_exchange.get_deal_price( inst, trade_start_time, trade_end_time, direction=direction, method=None @@ -386,21 +391,6 @@ class Indicator: else: raise NotImplementedError(f"This type of input is not supported") - # no sub executor on the lowest level - # So range_limit an total step will all be None - total_step = decision.total_step - if total_step is None: - total_step = 1 - range_limit = decision.get_range_limit(default_value=(0, total_step - 1)) - - assert volume_s.shape[0] % total_step == 0, "The price series can't be divided by step length" - factor = volume_s.shape[0] // total_step - - slc = slice(range_limit[0] * factor, (range_limit[1] + 1) * factor) - - volume_s = volume_s.iloc[slc] - price_s = price_s.iloc[slc] - base_volume = volume_s.sum().item() base_price = ((price_s * volume_s).sum() / base_volume).item() diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index f689b4003..56884cd48 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -10,7 +10,7 @@ from qlib.utils import lazy_sort_index from ...utils.resam import resam_ts_data, ts_data_last from ...data.data import D from ...strategy.base import BaseStrategy -from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO +from ...backtest.order import BaseTradeDecision, Order, TradeDecisionWO, TradeRange from ...backtest.exchange import Exchange, OrderHelper from ...backtest.utils import CommonInfrastructure, LevelInfrastructure from qlib.utils.file import get_io_object @@ -625,7 +625,7 @@ class ACStrategy(BaseStrategy): class RandomOrderStrategy(BaseStrategy): def __init__( self, - index_range: Tuple[int, int], # The range is closed on both left and right. + trade_range: Union[Tuple[int, int], TradeRange], # The range is closed on both left and right. sample_ratio: float = 1.0, volume_ratio: float = 0.01, market: str = "all", @@ -636,13 +636,8 @@ class RandomOrderStrategy(BaseStrategy): """ Parameters ---------- - index_range : Tuple - the intra day time index range of the orders - the left and right is closed. - - If you want to get the index_range in intra-day - - `qlib/utils/time.py:def get_day_min_idx_range` can help you create the index range easier - # TODO: this is a index_range level limitation. We'll implement a more detailed limitation later. + trade_range : Tuple + please refer to the `trade_range` parameter of BaseStrategy sample_ratio : float the ratio of all orders are sampled volume_ratio : float @@ -653,7 +648,6 @@ class RandomOrderStrategy(BaseStrategy): """ super().__init__(*args, **kwargs) - self.index_range = index_range self.sample_ratio = sample_ratio self.volume_ratio = volume_ratio self.market = market @@ -664,6 +658,7 @@ class RandomOrderStrategy(BaseStrategy): D.instruments(market), ["Mean(Ref($volume, 1), 10)"], start_time=exch.start_time, end_time=exch.end_time ) self.volume_df = self.volume.iloc[:, 0].unstack() + self.trade_range = trade_range def generate_trade_decision(self, execute_result=None): trade_step = self.trade_calendar.get_trade_step() @@ -683,7 +678,7 @@ class RandomOrderStrategy(BaseStrategy): direction=self.direction, ) ) - return TradeDecisionWO(order_list, self, self.index_range) + return TradeDecisionWO(order_list, self, self.trade_range) class FileOrderStrategy(BaseStrategy): @@ -692,7 +687,7 @@ class FileOrderStrategy(BaseStrategy): - This class provides an interface for user to read orders from csv files. """ - def __init__(self, file: Union[IO, str, Path], index_range: Tuple[int, int] = None, *args, **kwargs): + def __init__(self, file: Union[IO, str, Path], trade_range: Union[Tuple[int, int], TradeRange]= None, *args, **kwargs): """ Parameters @@ -709,13 +704,13 @@ class FileOrderStrategy(BaseStrategy): 20200103, SH600519, 1000, buy 20200106, SH600519, 1000, sell - index_range : Tuple[int, int] + trade_range : Tuple[int, int] the intra day time index range of the orders the left and right is closed. - If you want to get the index_range in intra-day + If you want to get the trade_range in intra-day - `qlib/utils/time.py:def get_day_min_idx_range` can help you create the index range easier - # TODO: this is a index_range level limitation. We'll implement a more detailed limitation later. + # TODO: this is a trade_range level limitation. We'll implement a more detailed limitation later. """ super().__init__(*args, **kwargs) @@ -727,7 +722,7 @@ class FileOrderStrategy(BaseStrategy): # make sure the datetime is the first level for fast indexing self.order_df = lazy_sort_index(convert_index_format(self.order_df, level="datetime")) - self.index_range = index_range + self.trade_range = trade_range def generate_trade_decision(self, execute_result=None) -> TradeDecisionWO: """ @@ -757,4 +752,4 @@ class FileOrderStrategy(BaseStrategy): end_time=end, ) ) - return TradeDecisionWO(order_list, self, self.index_range) + return TradeDecisionWO(order_list, self, self.trade_range) From d907817ce922b7f96079f28e72fde9db9b007bfa Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 15 Jul 2021 13:17:26 +0000 Subject: [PATCH 117/187] unify variable names --- qlib/backtest/report.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 0d4d3f0d7..308decd12 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -520,7 +520,7 @@ class Indicator: return pa_order return (pa_order > 0).astype(int).sum() / pa_order.count() - def _cal_trade_amount(self): + def _cal_deal_amount(self): return self.order_indicator["deal_amount"].abs().sum() def _cal_trade_value(self): @@ -536,13 +536,13 @@ class Indicator: fulfill_rate = self._cal_trade_fulfill_rate(method=ffr_config.get("weight_method", "mean")) price_advantage = self._cal_trade_price_advantage(method=pa_config.get("weight_method", "mean")) positive_rate = self._cal_trade_positive_rate() - trade_amount = self._cal_trade_amount() + deal_amount = self._cal_deal_amount() trade_value = self._cal_trade_value() order_count = self._cal_trade_order_count() self.trade_indicator["ffr"] = fulfill_rate self.trade_indicator["pa"] = price_advantage self.trade_indicator["pos"] = positive_rate - self.trade_indicator["amount"] = trade_amount + self.trade_indicator["deal_amount"] = deal_amount self.trade_indicator["value"] = trade_value self.trade_indicator["count"] = order_count if show_indicator: From aae4b02ab8f66fa90059415afa81806689d69c80 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Thu, 15 Jul 2021 13:34:39 +0000 Subject: [PATCH 118/187] *tuple --- qlib/backtest/order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 6bf1d5ad9..84264d0a9 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -294,7 +294,7 @@ class BaseTradeDecision: self.total_step = None # upper strategy has no knowledge about the sub executor before `_init_sub_trading` if isinstance(trade_range, Tuple): # for Tuple[int, int] - trade_range = IdxTradeRange(**trade_range) + trade_range = IdxTradeRange(*trade_range) self.trade_range: TradeRange = trade_range def get_decision(self) -> List[object]: From 344f4f69d2a4f526a48db511609acffeda0244ab Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 16 Jul 2021 03:11:07 +0000 Subject: [PATCH 119/187] add data calendar API and refine order cal api --- qlib/backtest/executor.py | 11 +- qlib/backtest/order.py | 78 ++++++++++++- qlib/backtest/utils.py | 57 ++++++++-- qlib/contrib/strategy/model_strategy.py | 36 +----- qlib/contrib/strategy/rule_strategy.py | 142 +++--------------------- qlib/strategy/base.py | 38 ++++++- qlib/utils/__init__.py | 15 ++- qlib/utils/time.py | 27 +++++ 8 files changed, 215 insertions(+), 189 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 78cdbe5e0..ff87a61cf 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -102,6 +102,7 @@ class BaseExecutor: self.track_data = track_data self._trade_exchange = trade_exchange self.level_infra = LevelInfrastructure() + self.level_infra.reset_infra(common_infra=common_infra) self.reset(start_time=start_time, end_time=end_time, common_infra=common_infra) def reset_common_infra(self, common_infra): @@ -239,7 +240,7 @@ class BaseExecutor: # Some concrete executor don't have inner decisions res, kwargs = obj - trade_start_time, trade_end_time = self.trade_calendar.get_cur_step_time() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time() # Account will not be changed in this function self.trade_account.update_bar_end( trade_start_time, @@ -332,7 +333,7 @@ class NestedExecutor(BaseExecutor): self.inner_strategy.reset_common_infra(common_infra) def _init_sub_trading(self, trade_decision): - trade_start_time, trade_end_time = self.trade_calendar.get_cur_step_time() + trade_start_time, trade_end_time = self.trade_calendar.get_step_time() self.inner_executor.reset(start_time=trade_start_time, end_time=trade_end_time) sub_level_infra = self.inner_executor.get_level_infra() self.level_infra.set_sub_level_infra(sub_level_infra) @@ -379,8 +380,8 @@ class NestedExecutor(BaseExecutor): ) trade_decision.mod_inner_decision(_inner_trade_decision) # propagate part of decision information - # NOTE sub_cal.get_cur_step_time() must be called before collect_data in case of step shifting - decision_list.append((_inner_trade_decision, *sub_cal.get_cur_step_time())) + # NOTE sub_cal.get_step_time() must be called before collect_data in case of step shifting + decision_list.append((_inner_trade_decision, *sub_cal.get_step_time())) # NOTE: Trade Calendar will step forward in the follow line _inner_execute_result = yield from self.inner_executor.collect_data( @@ -478,7 +479,7 @@ class SimulatorExecutor(BaseExecutor): def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): - trade_start_time, _ = self.trade_calendar.get_cur_step_time() + trade_start_time, _ = self.trade_calendar.get_step_time() execute_result = [] for order in self._get_order_iterator(trade_decision): diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 6bf1d5ad9..88d47dd73 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -3,7 +3,8 @@ # TODO: rename it with decision.py from __future__ import annotations from enum import IntEnum -from qlib.utils.time import concat_date_time +from qlib.data.data import Cal +from qlib.utils.time import concat_date_time, epsilon_change from qlib.log import get_module_logger # try to fix circular imports when enabling type hints @@ -127,8 +128,8 @@ class OrderHelper: code: str, amount: float, direction: OrderDir, - start_time: Union[str, pd.Timestamp], - end_time: Union[str, pd.Timestamp], + start_time: Union[str, pd.Timestamp]=None, + end_time: Union[str, pd.Timestamp]=None, ) -> Order: """ help to create a order @@ -153,8 +154,10 @@ class OrderHelper: Order: The created order """ - start_time = pd.Timestamp(start_time) - end_time = pd.Timestamp(end_time) + if start_time is not None: + start_time = pd.Timestamp(start_time) + if end_time is not None: + end_time = pd.Timestamp(end_time) return Order( stock_id=code, amount=amount, @@ -291,10 +294,11 @@ class BaseTradeDecision: """ self.strategy = strategy + self.start_time, self.end_time = strategy.trade_calendar.get_step_time() self.total_step = None # upper strategy has no knowledge about the sub executor before `_init_sub_trading` if isinstance(trade_range, Tuple): # for Tuple[int, int] - trade_range = IdxTradeRange(**trade_range) + trade_range = IdxTradeRange(*trade_range) self.trade_range: TradeRange = trade_range def get_decision(self) -> List[object]: @@ -406,6 +410,62 @@ class BaseTradeDecision: _start_idx, _end_idx = max(0, _start_idx), min(self.total_step - 1, _end_idx) return _start_idx, _end_idx + def get_data_cal_range_limit(self, rtype: str="full", raise_error: bool = False) -> Tuple[int, int]: + """ + get the range limit based on data calendar + + NOTE: it is **total** range limit instead of a single step + + The following assumptions are made + 1) The frequency of the exchange in common_infra is the same as the data calendar + 2) Users want the index mod by **day** (i.e. 240 min) + + Parameters + ---------- + rtype: str + - "full": return the full limitation of the deicsion in the day + - "step": return the limitation of current step + + raise_error: bool + True: raise error if no trade_range is set + False: return full trade calendar. + + It is useful in following cases + - users want to follow the order specific trading time range when decision level trade range is not + available. Raising NotImplementedError to indicates that range limit is not available + + Returns + ------- + Tuple[int, int]: + the range limit in data calendar + + Raises + ------ + NotImplementedError: + If the following criteria meet + 1) the decision can't provide a unified start and end + 2) raise_error is True + """ + # potential performance issue + day_start = pd.Timestamp(self.start_time.date()) + day_end = epsilon_change(day_start + pd.Timedelta(days=1)) + freq = self.strategy.trade_exchange.freq + _, _, day_start_idx, day_end_idx = Cal.locate_index(day_start, day_end, freq=freq) + if self.trade_range is None: + if raise_error: + raise NotImplementedError(f"There is no trade_range in this case") + else: + return 0, day_end_idx - day_start_idx + else: + if rtype == "full": + val_start, val_end = self.trade_range.clip_time_range(day_start, day_end) + elif rtype == "step": + val_start, val_end = self.trade_range.clip_time_range(self.start_time, self.end_time) + else: + raise ValueError(f"This type of input {rtype} is not supported") + _, _, start_idx, end_index = Cal.locate_index(val_start, val_end, freq=freq) + return start_idx - day_start_idx, end_index - day_start_idx + def empty(self) -> bool: for obj in self.get_decision(): if isinstance(obj, Order): @@ -452,6 +512,12 @@ class TradeDecisionWO(BaseTradeDecision): def __init__(self, order_list: List[Order], strategy: BaseStrategy, trade_range: Tuple[int, int] = None): super().__init__(strategy, trade_range=trade_range) self.order_list = order_list + start, end = strategy.trade_calendar.get_step_time() + for o in order_list: + if o.start_time: + o.start_time = start + if o.end_time: + o.end_time = end def get_decision(self) -> List[object]: return self.order_list diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 60a49b0e2..8937434c9 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from __future__ import annotations import bisect +from qlib.utils.time import epsilon_change from typing import Union, TYPE_CHECKING, Tuple, Union, List, Set if TYPE_CHECKING: @@ -22,7 +23,11 @@ class TradeCalendarManager: """ def __init__( - self, freq: str, start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None + self, + freq: str, + start_time: Union[str, pd.Timestamp] = None, + end_time: Union[str, pd.Timestamp] = None, + level_infra: "LevelInfrastructure" = None, ): """ Parameters @@ -36,6 +41,7 @@ class TradeCalendarManager: closed end of the trade time range, by default None If `end_time` is None, it must be reset before trading. """ + self.level_infra = level_infra self.reset(freq=freq, start_time=start_time, end_time=end_time) def reset(self, freq, start_time, end_time): @@ -82,19 +88,19 @@ class TradeCalendarManager: def get_trade_step(self): return self.trade_step - def get_step_time(self, trade_step=0, shift=0): + def get_step_time(self, trade_step=None, shift=0): """ Get the left and right endpoints of the trade_step'th trading interval About the endpoints: - Qlib uses the closed interval in time-series data selection, which has the same performance as pandas.Series.loc - - The returned right endpoints should minus 1 seconds becasue of the closed interval representation in Qlib. - Note: Qlib supports up to minutely decision execution, so 1 seconds is less than any trading time interval. + # - The returned right endpoints should minus 1 seconds becasue of the closed interval representation in Qlib. + # Note: Qlib supports up to minutely decision execution, so 1 seconds is less than any trading time interval. Parameters ---------- trade_step : int, optional - the number of trading step finished, by default 0 + the number of trading step finished, by default None to indicate shift : int, optional shift bars , by default 0 @@ -105,15 +111,43 @@ class TradeCalendarManager: - If shift > 0, return the trading time range of the earlier shift bars - If shift < 0, return the trading time range of the later shift bar """ + if trade_step is None: + trade_step = self.get_trade_step() trade_step = trade_step - shift calendar_index = self.start_index + trade_step - return self._calendar[calendar_index], self._calendar[calendar_index + 1] - pd.Timedelta(seconds=1) + return self._calendar[calendar_index], epsilon_change(self._calendar[calendar_index + 1]) - def get_cur_step_time(self): + def get_data_cal_range(self, rtype: str = "full") -> Tuple[int, int]: """ - get current step time + get the calendar range + The following assumptions are made + 1) The frequency of the exchange in common_infra is the same as the data calendar + 2) Users want the **data index** mod by **day** (i.e. 240 min) + + Parameters + ---------- + rtype: str + - "full": return the full limitation of the deicsion in the day + - "step": return the limitation of current step + + Returns + ------- + Tuple[int, int]: """ - return self.get_step_time(self.get_trade_step()) + # potential performance issue + day_start = pd.Timestamp(self.start_time.date()) + day_end = epsilon_change(day_start + pd.Timedelta(days=1)) + freq = self.level_infra.get("common_infra").get("trade_exchange").freq + _, _, day_start_idx, _ = Cal.locate_index(day_start, day_end, freq=freq) + + if rtype == "full": + _, _, start_idx, end_index = Cal.locate_index(self.start_time, self.end_time, freq=freq) + elif rtype == "step": + _, _, start_idx, end_index = Cal.locate_index(*self.get_step_time(), freq=freq) + else: + raise ValueError(f"This type of input {rtype} is not supported") + + return start_idx - day_start_idx, end_index - day_start_idx def get_all_time(self): """Get the start_time and end_time for trading""" @@ -198,14 +232,15 @@ class LevelInfrastructure(BaseInfrastructure): sub_level_infra: - **NOTE**: this will only work after _init_sub_trading !!! """ - return ["trade_calendar", "sub_level_infra"] + return ["trade_calendar", "sub_level_infra", "common_infra"] def reset_cal(self, freq, start_time, end_time): """reset trade calendar manager""" if self.has("trade_calendar"): self.get("trade_calendar").reset(freq, start_time=start_time, end_time=end_time) else: - self.reset_infra(trade_calendar=TradeCalendarManager(freq, start_time=start_time, end_time=end_time)) + self.reset_infra(trade_calendar=TradeCalendarManager(freq, start_time=start_time, end_time=end_time, + level_infra=self)) def set_sub_level_infra(self, sub_level_infra: LevelInfrastructure): """this will make the calendar access easier when acrossing multi-levels""" diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index e2a79db27..17a13e155 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -64,7 +64,7 @@ class TopkDropoutStrategy(ModelStrategy): """ super(TopkDropoutStrategy, self).__init__( - model, dataset, level_infra=level_infra, common_infra=common_infra, **kwargs + model, dataset, level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs ) self.topk = topk self.n_drop = n_drop @@ -73,22 +73,6 @@ class TopkDropoutStrategy(ModelStrategy): self.risk_degree = risk_degree self.hold_thresh = hold_thresh self.only_tradable = only_tradable - if trade_exchange is not None: - self.trade_exchange = trade_exchange - - def reset_common_infra(self, common_infra): - """ - Parameters - ---------- - common_infra : dict, optional - common infrastructure for backtesting, by default None - - It should include `trade_account`, used to get position - - It should include `trade_exchange`, used to provide market info - """ - super(TopkDropoutStrategy, self).reset_common_infra(common_infra) - - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") def get_risk_degree(self, trade_step=None): """get_risk_degree @@ -278,28 +262,12 @@ class WeightStrategyBase(ModelStrategy): - In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended. """ super(WeightStrategyBase, self).__init__( - model, dataset, level_infra=level_infra, common_infra=common_infra, **kwargs + model, dataset, level_infra=level_infra, common_infra=common_infra, trade_exchange=trade_exchange, **kwargs ) if isinstance(order_generator_cls_or_obj, type): self.order_generator = order_generator_cls_or_obj() else: self.order_generator = order_generator_cls_or_obj - if trade_exchange is not None: - self.trade_exchange = trade_exchange - - def reset_common_infra(self, common_infra): - """ - Parameters - ---------- - common_infra : dict, optional - common infrastructure for backtesting, by default None - - It should include `trade_account`, used to get position - - It should include `trade_exchange`, used to provide market info - """ - super(WeightStrategyBase, self).reset_common_infra(common_infra) - - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") def get_risk_degree(self, trade_step=None): """get_risk_degree diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 56884cd48..2fc1a1768 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -20,48 +20,6 @@ from qlib.backtest.utils import get_start_end_idx class TWAPStrategy(BaseStrategy): """TWAP Strategy for trading""" - def __init__( - self, - outer_trade_decision: BaseTradeDecision = None, - trade_exchange: Exchange = None, - level_infra: LevelInfrastructure = None, - common_infra: CommonInfrastructure = None, - ): - """ - Parameters - ---------- - outer_trade_decision : BaseTradeDecision - the trade decision of outer strategy which this startegy relies - trade_exchange : Exchange - exchange that provides market info, used to deal order and generate report - - If `trade_exchange` is None, self.trade_exchange will be set with common_infra - - It allowes different trade_exchanges is used in different executions. - - For example: - - In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it run faster. - - In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended. - - """ - super(TWAPStrategy, self).__init__( - outer_trade_decision=outer_trade_decision, level_infra=level_infra, common_infra=common_infra - ) - - if trade_exchange is not None: - self.trade_exchange = trade_exchange - - def reset_common_infra(self, common_infra): - """ - Parameters - ---------- - common_infra : CommonInfrastructure, optional - common infrastructure for backtesting, by default None - - It should include `trade_account`, used to get position - - It should include `trade_exchange`, used to provide market info - """ - super(TWAPStrategy, self).reset_common_infra(common_infra) - - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision: BaseTradeDecision = None, **kwargs): """ Parameters @@ -161,46 +119,6 @@ class SBBStrategyBase(BaseStrategy): # 2. Supporting alter_outer_trade_decision # 3. Supporting checking the availability of trade decision - def __init__( - self, - outer_trade_decision: BaseTradeDecision = None, - trade_exchange: Exchange = None, - level_infra: LevelInfrastructure = None, - common_infra: CommonInfrastructure = None, - ): - """ - Parameters - ---------- - outer_trade_decision : BaseTradeDecision - the trade decision of outer strategy which this startegy relies - trade_exchange : Exchange - exchange that provides market info, used to deal order and generate report - - If `trade_exchange` is None, self.trade_exchange will be set with common_infra - - It allowes different trade_exchanges is used in different executions. - - For example: - - In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it run faster. - - In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended. - """ - super(SBBStrategyBase, self).__init__( - outer_trade_decision=outer_trade_decision, level_infra=level_infra, common_infra=common_infra - ) - - if trade_exchange is not None: - self.trade_exchange = trade_exchange - - def reset_common_infra(self, common_infra): - """ - Parameters - ---------- - common_infra : dict, optional - common infrastructure for backtesting, by default None - - It should include `trade_account`, used to get position - - It should include `trade_exchange`, used to provide market info - """ - super(SBBStrategyBase, self).reset_common_infra(common_infra) - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") - def reset(self, outer_trade_decision: BaseTradeDecision = None, **kwargs): """ Parameters @@ -395,7 +313,7 @@ class SBBStrategyEMA(SBBStrategyBase): if isinstance(instruments, str): self.instruments = D.instruments(instruments) self.freq = freq - super(SBBStrategyEMA, self).__init__(outer_trade_decision, trade_exchange, level_infra, common_infra, **kwargs) + super(SBBStrategyEMA, self).__init__(outer_trade_decision, level_infra, common_infra, trade_exchange=trade_exchange, **kwargs) def _reset_signal(self): trade_len = self.trade_calendar.get_trade_len() @@ -417,14 +335,8 @@ class SBBStrategyEMA(SBBStrategyBase): reset level-shared infra - After reset the trade calendar, the signal will be changed """ - if not hasattr(self, "level_infra"): - self.level_infra = level_infra - else: - self.level_infra.update(level_infra) - - if level_infra.has("trade_calendar"): - self.trade_calendar = level_infra.get("trade_calendar") - self._reset_signal() + super().reset_level_infra(level_infra) + self._reset_signal() def _pred_price_trend(self, stock_id, pred_start_time=None, pred_end_time=None): # if no signal, return mid trend @@ -484,10 +396,11 @@ class ACStrategy(BaseStrategy): if isinstance(instruments, str): self.instruments = D.instruments(instruments) self.freq = freq - super(ACStrategy, self).__init__(outer_trade_decision, level_infra, common_infra, **kwargs) - - if trade_exchange is not None: - self.trade_exchange = trade_exchange + super(ACStrategy, self).__init__(outer_trade_decision, + level_infra, + common_infra, + trade_exchange=trade_exchange, + **kwargs) def _reset_signal(self): trade_len = self.trade_calendar.get_trade_len() @@ -506,33 +419,13 @@ class ACStrategy(BaseStrategy): for stock_id, stock_val in signal_df.groupby(level="instrument"): self.signal[stock_id] = stock_val["volatility"].droplevel(level="instrument") - def reset_common_infra(self, common_infra): - """ - Parameters - ---------- - common_infra : CommonInfrastructure, optional - common infrastructure for backtesting, by default None - - It should include `trade_account`, used to get position - - It should include `trade_exchange`, used to provide market info - """ - super(ACStrategy, self).reset_common_infra(common_infra) - - if common_infra.has("trade_exchange"): - self.trade_exchange = common_infra.get("trade_exchange") - def reset_level_infra(self, level_infra): """ reset level-shared infra - After reset the trade calendar, the signal will be changed """ - if not hasattr(self, "level_infra"): - self.level_infra = level_infra - else: - self.level_infra.update(level_infra) - - if level_infra.has("trade_calendar"): - self.trade_calendar = level_infra.get("trade_calendar") - self._reset_signal() + super().reset_level_infra(level_infra) + self._reset_signal() def reset(self, outer_trade_decision: BaseTradeDecision = None, **kwargs): """ @@ -668,16 +561,11 @@ class RandomOrderStrategy(BaseStrategy): if step_time_start in self.volume_df: for stock_id, volume in self.volume_df[step_time_start].dropna().sample(frac=self.sample_ratio).items(): order_list.append( - self.common_infra.get("trade_exchange") - .get_order_helper() - .create( + self.common_infra.get("trade_exchange").get_order_helper().create( code=stock_id, amount=volume * self.volume_ratio, - start_time=step_time_start, - end_time=step_time_end, direction=self.direction, - ) - ) + )) return TradeDecisionWO(order_list, self, self.trade_range) @@ -732,9 +620,7 @@ class FileOrderStrategy(BaseStrategy): execute_result will be ignored in FileOrderStrategy """ oh: OrderHelper = self.common_infra.get("trade_exchange").get_order_helper() - tc = self.trade_calendar - step = tc.get_trade_step() - start, end = tc.get_step_time(step) + start, _ = self.trade_calendar.get_step_time() # CONVERSION: the bar is indexed by the time try: df = self.order_df.loc(axis=0)[start] @@ -748,8 +634,6 @@ class FileOrderStrategy(BaseStrategy): code=idx, amount=row["amount"], direction=Order.parse_dir(row["direction"]), - start_time=start, - end_time=end, ) ) return TradeDecisionWO(order_list, self, self.trade_range) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 23d6b520a..15cad4986 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from qlib.backtest.exchange import Exchange from qlib.backtest.position import BasePosition from typing import List, Union @@ -22,6 +23,7 @@ class BaseStrategy: outer_trade_decision: BaseTradeDecision = None, level_infra: LevelInfrastructure = None, common_infra: CommonInfrastructure = None, + trade_exchange: Exchange = None, ): """ Parameters @@ -34,9 +36,18 @@ class BaseStrategy: level shared infrastructure for backtesting, including trade calendar common_infra : CommonInfrastructure, optional common infrastructure for backtesting, including trade_account, trade_exchange, .etc + + trade_exchange : Exchange + exchange that provides market info, used to deal order and generate report + - If `trade_exchange` is None, self.trade_exchange will be set with common_infra + - It allowes different trade_exchanges is used in different executions. + - For example: + - In daily execution, both daily exchange and minutely are usable, but the daily exchange is recommended because it run faster. + - In minutely execution, the daily exchange is not usable, only the minutely exchange is recommended. """ - self.reset(level_infra=level_infra, common_infra=common_infra, outer_trade_decision=outer_trade_decision) + self._reset(level_infra=level_infra, common_infra=common_infra, outer_trade_decision=outer_trade_decision) + self._trade_exchange = trade_exchange @property def trade_calendar(self) -> TradeCalendarManager: @@ -46,6 +57,11 @@ class BaseStrategy: def trade_position(self) -> BasePosition: return self.common_infra.get("trade_account").current + @property + def trade_exchange(self) -> Exchange: + """get trade exchange in a prioritized order""" + return getattr(self, "_trade_exchange", None) or self.common_infra.get("trade_exchange") + def reset_level_infra(self, level_infra: LevelInfrastructure): if not hasattr(self, "level_infra"): self.level_infra = level_infra @@ -69,6 +85,25 @@ class BaseStrategy: - reset `level_infra`, used to reset trade calendar, .etc - reset `common_infra`, used to reset `trade_account`, `trade_exchange`, .etc - reset `outer_trade_decision`, used to make split decision + + **NOTE**: + split this function into `reset` and `_reset` will make following cases more convenient + 1. Users want to initialize his strategy by overriding `reset`, but they don't want to affect the `_reset` called + when initialization + """ + self._reset(level_infra=level_infra, + common_infra=common_infra, + outer_trade_decision=outer_trade_decision, + **kwargs) + + def _reset( + self, + level_infra: LevelInfrastructure = None, + common_infra: CommonInfrastructure = None, + outer_trade_decision=None, + ): + """ + Please refer to the docs of `reset` """ if level_infra is not None: self.reset_level_infra(level_infra) @@ -79,6 +114,7 @@ class BaseStrategy: if outer_trade_decision is not None: self.outer_trade_decision = outer_trade_decision + def generate_trade_decision(self, execute_result=None): """Generate trade decision in each trading bar diff --git a/qlib/utils/__init__.py b/qlib/utils/__init__.py index 5900fb286..1cce56918 100644 --- a/qlib/utils/__init__.py +++ b/qlib/utils/__init__.py @@ -210,10 +210,13 @@ def get_cls_kwargs(config: Union[dict, str], default_module: Union[str, ModuleTy the class object and it's arguments. """ if isinstance(config, dict): - module = get_module_by_module_path(config.get("module_path", default_module)) + if isinstance(config["class"], str): + module = get_module_by_module_path(config.get("module_path", default_module)) - # raise AttributeError - klass = getattr(module, config["class"]) + # raise AttributeError + klass = getattr(module, config["class"]) + else: + klass = config["class"] # the class type itself is passed in kwargs = config.get("kwargs", {}) elif isinstance(config, str): module = get_module_by_module_path(default_module) @@ -235,11 +238,17 @@ def init_instance_by_config( ---------- config : Union[str, dict, object] dict example. + case 1) { 'class': 'ClassName', 'kwargs': dict, # It is optional. {} will be used if not given 'model_path': path, # It is optional if module is given } + case 2) + { + 'class': , + 'kwargs': dict, # It is optional. {} will be used if not given + } str example. 1) specify a pickle object - path like 'file:////obj.pkl' diff --git a/qlib/utils/time.py b/qlib/utils/time.py index f4913dde4..e365de6d8 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -160,5 +160,32 @@ def cal_sam_minute(x: pd.Timestamp, sam_minutes: int) -> pd.Timestamp: return concat_date_time(date, new_time) +def epsilon_change(datetime: pd.Timestamp, direction: str = "backward") -> pd.Timestamp: + """ + change the time by infinitely small quantity. + + + Parameters + ---------- + datetime : pd.Timestamp + the original time + direction : str + the direction the time are going to + - "backward" for going to history + - "forward" for going to the future + + Returns + ------- + pd.Timestamp: + the shifted time + """ + if direction == "backward": + return datetime - pd.Timedelta(seconds=1) + elif direction == "forward": + return datetime + pd.Timedelta(seconds=1) + else: + raise ValueError("Wrong input") + + if __name__ == "__main__": print(get_day_min_idx_range("8:30", "14:59", "10min")) From 65b44349cd9ccb8d6aba04e4e4a434636234895e Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 16 Jul 2021 08:29:32 +0000 Subject: [PATCH 120/187] add PandasQuote --- qlib/backtest/exchange.py | 272 ++++++++++++++++++++++++-------------- 1 file changed, 171 insertions(+), 101 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 58f57ed73..8d4739251 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -102,10 +102,11 @@ class Exchange: # TODO: the quote, trade_dates, codes are not necessray. # It is just for performance consideration. + self.limit_type = BaseQuote._get_limit_type(limit_threshold) if limit_threshold is None: if C.region == REG_CN: self.logger.warning(f"limit_threshold not set. The stocks hit the limit may be bought/sold") - elif self._get_limit_type(limit_threshold) == self.LT_FLT and abs(limit_threshold) > 0.1: + elif self.limit_type == BaseQuote.LT_FLT and abs(limit_threshold) > 0.1: if C.region == REG_CN: self.logger.warning(f"limit_threshold may not be set to a reasonable value") @@ -127,10 +128,9 @@ class Exchange: # $change is for calculating the limit of the stock necessary_fields = {self.buy_price, self.sell_price, "$close", "$change", "$factor", "$volume"} - if self._get_limit_type(limit_threshold) == self.LT_TP_EXP: + if self.limit_type == BaseQuote.LT_TP_EXP: for exp in limit_threshold: necessary_fields.add(exp) - subscribe_fields = list(necessary_fields | set(subscribe_fields)) all_fields = list(necessary_fields | set(subscribe_fields)) self.all_fields = all_fields @@ -140,94 +140,22 @@ class Exchange: self.limit_threshold: Union[Tuple[str, str], float, None] = limit_threshold self.volume_threshold = volume_threshold self.extra_quote = extra_quote - self.set_quote(codes, start_time, end_time) - def set_quote(self, codes, start_time, end_time): - if len(codes) == 0: - codes = D.instruments() - - self.quote = D.features(codes, self.all_fields, start_time, end_time, freq=self.freq, disk_cache=True).dropna( - subset=["$close"] + # init quote + self.quote = PandasQuote( + start_time = self.start_time, + end_time = self.end_time, + freq = self.freq, + codes = self.codes, + all_fields = self.all_fields, + limit_threshold = self.limit_threshold, + buy_price = self.buy_price, + sell_price = self.sell_price, + extra_quote = self.extra_quote, ) - self.quote.columns = self.all_fields - - for attr in "buy_price", "sell_price": - pstr = getattr(self, attr) # price string - if self.quote[pstr].isna().any(): - self.logger.warning("{} field data contains nan.".format(pstr)) - - if self.quote["$factor"].isna().any(): - # The 'factor.day.bin' file not exists, and `factor` field contains `nan` - # Use adjusted price - self.trade_w_adj_price = True - self.logger.warning("factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.") - if self.trade_unit is not None: - self.logger.warning(f"trade unit {self.trade_unit} is not supported in adjusted_price mode.") - - else: - # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` - # Use normal price - self.trade_w_adj_price = False - - # update limit - self._update_limit() - - quote_df = self.quote - if self.extra_quote is not None: - # process extra_quote - if "$close" not in self.extra_quote: - raise ValueError("$close is necessray in extra_quote") - for attr in "buy_price", "sell_price": - pstr = getattr(self, attr) # price string - if pstr not in self.extra_quote.columns: - self.extra_quote[pstr] = self.extra_quote["$close"] - self.logger.warning(f"No {pstr} set for extra_quote. Use $close as {pstr}.") - if "$factor" not in self.extra_quote.columns: - self.extra_quote["$factor"] = 1.0 - self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") - if "limit_sell" not in self.extra_quote.columns: - self.extra_quote["limit_sell"] = False - self.logger.warning("No limit_sell set for extra_quote. All stock will be able to be sold.") - if "limit_buy" not in self.extra_quote.columns: - self.extra_quote["limit_buy"] = False - self.logger.warning("No limit_buy set for extra_quote. All stock will be able to be bought.") - - assert set(self.extra_quote.columns) == set(quote_df.columns) - {"$change"} - quote_df = pd.concat([quote_df, self.extra_quote], sort=False, axis=0) - - quote_dict = {} - for stock_id, stock_val in quote_df.groupby(level="instrument"): - quote_dict[stock_id] = stock_val.droplevel(level="instrument") - - self.quote = quote_dict - - LT_TP_EXP = "(exp)" # Tuple[str, str] - LT_FLT = "float" # float - LT_NONE = "none" # none - - def _get_limit_type(self, limit_threshold): - if isinstance(limit_threshold, Tuple): - return self.LT_TP_EXP - elif isinstance(limit_threshold, float): - return self.LT_FLT - elif limit_threshold is None: - return self.LT_NONE - else: - raise NotImplementedError(f"This type of `limit_threshold` is not supported") - - def _update_limit(self): - # check limit_threshold - lt_type = self._get_limit_type(self.limit_threshold) - if lt_type == self.LT_NONE: - self.quote["limit_buy"] = False - self.quote["limit_sell"] = False - elif lt_type == self.LT_TP_EXP: - # set limit - self.quote["limit_buy"] = self.quote[self.limit_threshold[0]] - self.quote["limit_sell"] = self.quote[self.limit_threshold[1]] - elif lt_type == self.LT_FLT: - self.quote["limit_buy"] = self.quote["$change"].ge(self.limit_threshold) - self.quote["limit_sell"] = self.quote["$change"].le(-self.limit_threshold) # pylint: disable=E1130 + self.trade_w_adj_price = self.quote.get_trade_w_adj_price() + if(self.trade_w_adj_price and (self.trade_unit is not None)): + self.logger.warning(f"trade unit {self.trade_unit} is not supported in adjusted_price mode.") def check_stock_limit(self, stock_id, start_time, end_time, direction=None): """ @@ -241,20 +169,20 @@ class Exchange: """ if direction is None: - buy_limit = resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all") - sell_limit = resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all") + buy_limit = self.quote.get_data(stock_id, start_time, end_time, fields="limit_buy", method="all") + sell_limit = self.quote.get_data(stock_id, start_time, end_time, fields="limit_sell", method="all") return buy_limit or sell_limit elif direction == Order.BUY: - return resam_ts_data(self.quote[stock_id]["limit_buy"], start_time, end_time, method="all") + return self.quote.get_data(stock_id, start_time, end_time, fields="limit_buy", method="all") elif direction == Order.SELL: - return resam_ts_data(self.quote[stock_id]["limit_sell"], start_time, end_time, method="all") + return self.quote.get_data(stock_id, start_time, end_time, fields="limit_sell", method="all") else: raise ValueError(f"direction {direction} is not supported!") def check_stock_suspended(self, stock_id, start_time, end_time): # is suspended - if stock_id in self.quote: - return resam_ts_data(self.quote[stock_id], start_time, end_time, method=None) is None + if stock_id in self.quote.get_all_stock(): + return self.quote.get_data(stock_id, start_time, end_time) is None else: return True @@ -313,13 +241,13 @@ class Exchange: return trade_val, trade_cost, trade_price def get_quote_info(self, stock_id, start_time, end_time, method=ts_data_last): - return resam_ts_data(self.quote[stock_id], start_time, end_time, method=method) + return self.quote.get_data(stock_id, start_time, end_time, method=method) def get_close(self, stock_id, start_time, end_time, method=ts_data_last): - return resam_ts_data(self.quote[stock_id]["$close"], start_time, end_time, method=method) + return self.quote.get_data(stock_id, start_time, end_time, fields="$close", method=method) def get_volume(self, stock_id, start_time, end_time, method="sum"): - return resam_ts_data(self.quote[stock_id]["$volume"], start_time, end_time, method=method) + return self.quote.get_data(stock_id, start_time, end_time, fields="$volume", method=method) def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir, method=ts_data_last): if direction == OrderDir.SELL: @@ -328,7 +256,7 @@ class Exchange: pstr = self.buy_price else: raise NotImplementedError(f"This type of input is not supported") - deal_price = resam_ts_data(self.quote[stock_id][pstr], start_time, end_time, method=method) + deal_price = self.quote.get_data(stock_id, start_time, end_time, fields=pstr, method=method) if method is not None and (np.isclose(deal_price, 0.0) or np.isnan(deal_price)): self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {pstr}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") @@ -343,9 +271,9 @@ class Exchange: `None`: if the stock is suspended `None` may be returned `float`: return factor if the factor exists """ - if stock_id not in self.quote: + if stock_id not in self.quote.get_all_stock(): return None - return resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method=ts_data_last) + return self.quote.get_data(stock_id, start_time, end_time, fields="$factor", method=ts_data_last) def generate_amount_position_from_weight_position( self, weight_position, cash, start_time, end_time, direction=OrderDir.BUY @@ -596,3 +524,145 @@ class Exchange: # cache to avoid recreate the same instance self._order_helper = OrderHelper(self) return self._order_helper + + +class BaseQuote: + + def __init__(self): + self.logger = get_module_logger("online operator", level=logging.INFO) + + def _update_limit(self, limit_threshold): + raise NotImplementedError(f"Please implement the `_update_limit` method") + + def get_trade_w_adj_price(self): + raise NotImplementedError(f"Please implement the `get_trade_w_adj_price` method") + + def get_all_stock(self): + raise NotImplementedError(f"Please implement the `get_all_stock` method") + + def get_data(self, stock_id, start_time, end_time, fields, method): + raise NotImplementedError(f"Please implement the `get_data` method") + + LT_TP_EXP = "(exp)" # Tuple[str, str] + LT_FLT = "float" # float + LT_NONE = "none" # none + + @staticmethod + def _get_limit_type(limit_threshold): + if isinstance(limit_threshold, Tuple): + return BaseQuote.LT_TP_EXP + elif isinstance(limit_threshold, float): + return BaseQuote.LT_FLT + elif limit_threshold is None: + return BaseQuote.LT_NONE + else: + raise NotImplementedError(f"This type of `limit_threshold` is not supported") + + +class PandasQuote(BaseQuote): + + def __init__( + self, + start_time, + end_time, + freq, + codes, + all_fields, + limit_threshold, + buy_price, + sell_price, + extra_quote + ): + + super().__init__() + + # get stock data from qlib + if len(codes) == 0: + codes = D.instruments() + self.data = D.features( + codes, + all_fields, + start_time, + end_time, + freq=freq, + disk_cache=True + ).dropna(subset=["$close"]) + self.data.columns = all_fields + + # check buy_price data and sell_price data + self.buy_price = buy_price + self.sell_price = sell_price + for attr in "buy_price", "sell_price": + pstr = getattr(self, attr) # price string + if self.data[pstr].isna().any(): + self.logger.warning("{} field data contains nan.".format(pstr)) + + # update trade_w_adj_price + if self.data["$factor"].isna().any(): + # The 'factor.day.bin' file not exists, and `factor` field contains `nan` + # Use adjusted price + self.logger.warning("factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.") + self.trade_w_adj_price = True + else: + # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` + # Use normal price + self.trade_w_adj_price = False + + # update limit + self._update_limit(limit_threshold) + + # concat extra_quote + quote_df = self.data + if extra_quote is not None: + # process extra_quote + if "$close" not in extra_quote: + raise ValueError("$close is necessray in extra_quote") + for attr in "buy_price", "sell_price": + pstr = getattr(self, attr) # price string + if pstr not in extra_quote.columns: + extra_quote[pstr] = extra_quote["$close"] + self.logger.warning(f"No {pstr} set for extra_quote. Use $close as {pstr}.") + if "$factor" not in extra_quote.columns: + extra_quote["$factor"] = 1.0 + self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") + if "limit_sell" not in extra_quote.columns: + extra_quote["limit_sell"] = False + self.logger.warning("No limit_sell set for extra_quote. All stock will be able to be sold.") + if "limit_buy" not in extra_quote.columns: + extra_quote["limit_buy"] = False + self.logger.warning("No limit_buy set for extra_quote. All stock will be able to be bought.") + assert set(extra_quote.columns) == set(quote_df.columns) - {"$change"} + quote_df = pd.concat([quote_df, extra_quote], sort=False, axis=0) + + quote_dict = {} + for stock_id, stock_val in quote_df.groupby(level="instrument"): + quote_dict[stock_id] = stock_val.droplevel(level="instrument") + self.data = quote_dict + + def _update_limit(self, limit_threshold): + # check limit_threshold + limit_type = self._get_limit_type(limit_threshold) + if limit_type == self.LT_NONE: + self.data["limit_buy"] = False + self.data["limit_sell"] = False + elif limit_type == self.LT_TP_EXP: + # set limit + self.data["limit_buy"] = self.data[limit_threshold[0]] + self.data["limit_sell"] = self.data[limit_threshold[1]] + elif limit_type == self.LT_FLT: + self.data["limit_buy"] = self.data["$change"].ge(limit_threshold) + self.data["limit_sell"] = self.data["$change"].le(-limit_threshold) # pylint: disable=E1130 + + def get_all_stock(self): + return self.data.keys() + + def get_data(self, stock_id, start_time, end_time, fields = None, method = None): + if(fields is None): + return resam_ts_data(self.data[stock_id], start_time, end_time, method=method) + elif(isinstance(fields, (str, list))): + return resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) + else: + raise ValueError(f"fields must be None, str or list") + + def get_trade_w_adj_price(self): + return self.trade_w_adj_price \ No newline at end of file From 110141ddac97dbeeed1723a7103fb9d777d223c6 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 16 Jul 2021 09:17:29 +0000 Subject: [PATCH 121/187] add doc --- qlib/backtest/exchange.py | 65 +++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 8d4739251..2e865d591 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -532,15 +532,24 @@ class BaseQuote: self.logger = get_module_logger("online operator", level=logging.INFO) def _update_limit(self, limit_threshold): + """add limitation information to data based on limit_threshold + """ raise NotImplementedError(f"Please implement the `_update_limit` method") def get_trade_w_adj_price(self): + """return whether use the trade price with adjusted weight + """ raise NotImplementedError(f"Please implement the `get_trade_w_adj_price` method") def get_all_stock(self): + """return all stock codes + """ raise NotImplementedError(f"Please implement the `get_all_stock` method") - def get_data(self, stock_id, start_time, end_time, fields, method): + def get_data(self, stock_id, start_time, end_time, fields=None, method=None): + """get the specific fields of stock data during start time and end_time, + and apply method to the data, please refer to resam_ts_data + """ raise NotImplementedError(f"Please implement the `get_data` method") LT_TP_EXP = "(exp)" # Tuple[str, str] @@ -549,6 +558,8 @@ class BaseQuote: @staticmethod def _get_limit_type(limit_threshold): + """get limit type + """ if isinstance(limit_threshold, Tuple): return BaseQuote.LT_TP_EXP elif isinstance(limit_threshold, float): @@ -560,6 +571,8 @@ class BaseQuote: class PandasQuote(BaseQuote): + """ + """ def __init__( self, @@ -567,12 +580,52 @@ class PandasQuote(BaseQuote): end_time, freq, codes, - all_fields, - limit_threshold, - buy_price, - sell_price, - extra_quote + all_fields: List[str], + limit_threshold: Union[Tuple[str, str], float, None], + buy_price: str, + sell_price: str, + extra_quote: pd.DataFrame, ): + """init stock data based on pandas + + Parameters + ---------- + start_time : pd.Timestamp|str + closed start time for backtest + end_time : pd.Timestamp|str + closed end time for backtest + freq : str + frequency of data + codes : [type] + all stock code + all_fields : List[str] + all subscribe fields in qlib + limit_threshold : Union[Tuple[str, str], float, None] + 1) `None`: no limitation + 2) float, 0.1 for example, default None + 3) Tuple[str, str]: (, + ) + `False` value indicates the stock is tradable + `True` value indicates the stock is limited and not tradable + buy_price : str + the data field for buying stock + sell_price : str + the data field for selling stock + extra_quote : pd.DataFrame + columns: like ['$vwap', '$close', '$volume', '$factor', 'limit_sell', 'limit_buy']. + The limit indicates that the etf is tradable on a specific day. + Necessary fields: + $close is for calculating the total value at end of each day. + Optional fields: + $volume is only necessary when we limit the trade amount or caculate PA(vwap) indicator + $vwap is only necessary when we use the $vwap price as the deal price + $factor is for rounding to the trading unit + limit_sell will be set to False by default(False indicates we can sell this + target on this day). + limit_buy will be set to False by default(False indicates we can buy this + target on this day). + index: MultipleIndex(instrument, pd.Datetime) + """ super().__init__() From 567841e1c663964b41e6d4bcfb0689540c43d2b5 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 16 Jul 2021 12:56:49 +0000 Subject: [PATCH 122/187] get qlib data in exchange --- qlib/backtest/exchange.py | 310 +++++++++++++++++--------------------- 1 file changed, 139 insertions(+), 171 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 2e865d591..82f57462e 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -102,11 +102,11 @@ class Exchange: # TODO: the quote, trade_dates, codes are not necessray. # It is just for performance consideration. - self.limit_type = BaseQuote._get_limit_type(limit_threshold) + self.limit_type = self._get_limit_type(limit_threshold) if limit_threshold is None: if C.region == REG_CN: self.logger.warning(f"limit_threshold not set. The stocks hit the limit may be bought/sold") - elif self.limit_type == BaseQuote.LT_FLT and abs(limit_threshold) > 0.1: + elif self.limit_type == self.LT_FLT and abs(limit_threshold) > 0.1: if C.region == REG_CN: self.logger.warning(f"limit_threshold may not be set to a reasonable value") @@ -128,7 +128,7 @@ class Exchange: # $change is for calculating the limit of the stock necessary_fields = {self.buy_price, self.sell_price, "$close", "$change", "$factor", "$volume"} - if self.limit_type == BaseQuote.LT_TP_EXP: + if self.limit_type == self.LT_TP_EXP: for exp in limit_threshold: necessary_fields.add(exp) all_fields = list(necessary_fields | set(subscribe_fields)) @@ -140,22 +140,98 @@ class Exchange: self.limit_threshold: Union[Tuple[str, str], float, None] = limit_threshold self.volume_threshold = volume_threshold self.extra_quote = extra_quote + self.get_quote_from_qlib() - # init quote - self.quote = PandasQuote( - start_time = self.start_time, - end_time = self.end_time, - freq = self.freq, - codes = self.codes, - all_fields = self.all_fields, - limit_threshold = self.limit_threshold, - buy_price = self.buy_price, - sell_price = self.sell_price, - extra_quote = self.extra_quote, - ) - self.trade_w_adj_price = self.quote.get_trade_w_adj_price() - if(self.trade_w_adj_price and (self.trade_unit is not None)): - self.logger.warning(f"trade unit {self.trade_unit} is not supported in adjusted_price mode.") + # init quote by quote_df + self.quote = PandasQuote(self.quote_df) + + def get_quote_from_qlib(self): + # get stock data from qlib + if len(self.codes) == 0: + self.codes = D.instruments() + self.quote_df = D.features( + self.codes, + self.all_fields, + self.start_time, + self.end_time, + freq=self.freq, + disk_cache=True + ).dropna(subset=["$close"]) + self.quote_df.columns = self.all_fields + + # check buy_price data and sell_price data + for attr in "buy_price", "sell_price": + pstr = getattr(self, attr) # price string + if self.quote_df[pstr].isna().any(): + self.logger.warning("{} field data contains nan.".format(pstr)) + + # update trade_w_adj_price + if self.quote_df["$factor"].isna().any(): + # The 'factor.day.bin' file not exists, and `factor` field contains `nan` + # Use adjusted price + self.trade_w_adj_price = True + self.logger.warning("factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.") + if self.trade_unit is not None: + self.logger.warning(f"trade unit {self.trade_unit} is not supported in adjusted_price mode.") + else: + # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` + # Use normal price + self.trade_w_adj_price = False + + # update limit + self._update_limit(self.limit_threshold) + + # concat extra_quote + if self.extra_quote is not None: + # process extra_quote + if "$close" not in self.extra_quote: + raise ValueError("$close is necessray in extra_quote") + for attr in "buy_price", "sell_price": + pstr = getattr(self, attr) # price string + if pstr not in self.extra_quote.columns: + self.extra_quote[pstr] = self.extra_quote["$close"] + self.logger.warning(f"No {pstr} set for extra_quote. Use $close as {pstr}.") + if "$factor" not in self.extra_quote.columns: + self.extra_quote["$factor"] = 1.0 + self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") + if "limit_sell" not in self.extra_quote.columns: + self.extra_quote["limit_sell"] = False + self.logger.warning("No limit_sell set for extra_quote. All stock will be able to be sold.") + if "limit_buy" not in self.extra_quote.columns: + self.extra_quote["limit_buy"] = False + self.logger.warning("No limit_buy set for extra_quote. All stock will be able to be bought.") + assert set(self.extra_quote.columns) == set(self.quote_df.columns) - {"$change"} + self.quote_df = pd.concat([self.quote_df, extra_quote], sort=False, axis=0) + + LT_TP_EXP = "(exp)" # Tuple[str, str] + LT_FLT = "float" # float + LT_NONE = "none" # none + + def _get_limit_type(self, limit_threshold): + """get limit type + """ + if isinstance(limit_threshold, Tuple): + return self.LT_TP_EXP + elif isinstance(limit_threshold, float): + return self.LT_FLT + elif limit_threshold is None: + return self.LT_NONE + else: + raise NotImplementedError(f"This type of `limit_threshold` is not supported") + + def _update_limit(self, limit_threshold): + # check limit_threshold + limit_type = self._get_limit_type(limit_threshold) + if limit_type == self.LT_NONE: + self.quote_df["limit_buy"] = False + self.quote_df["limit_sell"] = False + elif limit_type == self.LT_TP_EXP: + # set limit + self.quote_df["limit_buy"] = self.quote_df[limit_threshold[0]] + self.quote_df["limit_sell"] = self.quote_df[limit_threshold[1]] + elif limit_type == self.LT_FLT: + self.quote_df["limit_buy"] = self.quote_df["$change"].ge(limit_threshold) + self.quote_df["limit_sell"] = self.quote_df["$change"].le(-limit_threshold) # pylint: disable=E1130 def check_stock_limit(self, stock_id, start_time, end_time, direction=None): """ @@ -528,184 +604,79 @@ class Exchange: class BaseQuote: - def __init__(self): + def __init__(self, quote_df: pd.DataFrame): self.logger = get_module_logger("online operator", level=logging.INFO) - def _update_limit(self, limit_threshold): - """add limitation information to data based on limit_threshold - """ - raise NotImplementedError(f"Please implement the `_update_limit` method") - - def get_trade_w_adj_price(self): - """return whether use the trade price with adjusted weight - """ - raise NotImplementedError(f"Please implement the `get_trade_w_adj_price` method") - def get_all_stock(self): """return all stock codes + + Return + ------ + Union[list, Dict.keys(), set, tuple] + all stock codes """ raise NotImplementedError(f"Please implement the `get_all_stock` method") - def get_data(self, stock_id, start_time, end_time, fields=None, method=None): + def get_data(self, stock_id: str, start_time, end_time, fields: Union[str, list]=None, method=None): """get the specific fields of stock data during start time and end_time, - and apply method to the data, please refer to resam_ts_data - """ - raise NotImplementedError(f"Please implement the `get_data` method") + and apply method to the data. + + Example: + .. code-block:: + $close $volume + instrument datetime + SH600000 2010-01-04 86.778313 16162960.0 + 2010-01-05 87.433578 28117442.0 + 2010-01-06 85.713585 23632884.0 + 2010-01-07 83.788803 20813402.0 + 2010-01-08 84.730675 16044853.0 - LT_TP_EXP = "(exp)" # Tuple[str, str] - LT_FLT = "float" # float - LT_NONE = "none" # none + SH600655 2010-01-04 2699.567383 158193.328125 + 2010-01-08 2612.359619 77501.406250 + 2010-01-11 2712.982422 160852.390625 + 2010-01-12 2788.688232 164587.937500 + 2010-01-13 2790.604004 145460.453125 - @staticmethod - def _get_limit_type(limit_threshold): - """get limit type - """ - if isinstance(limit_threshold, Tuple): - return BaseQuote.LT_TP_EXP - elif isinstance(limit_threshold, float): - return BaseQuote.LT_FLT - elif limit_threshold is None: - return BaseQuote.LT_NONE - else: - raise NotImplementedError(f"This type of `limit_threshold` is not supported") + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + + $close 87.433578 + $volume 28117442.0 + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields="$close", method="last")) -class PandasQuote(BaseQuote): - """ - """ - - def __init__( - self, - start_time, - end_time, - freq, - codes, - all_fields: List[str], - limit_threshold: Union[Tuple[str, str], float, None], - buy_price: str, - sell_price: str, - extra_quote: pd.DataFrame, - ): - """init stock data based on pandas + 87.433578 Parameters ---------- + stock_id: Union[str, list] start_time : pd.Timestamp|str closed start time for backtest end_time : pd.Timestamp|str closed end time for backtest - freq : str - frequency of data - codes : [type] - all stock code - all_fields : List[str] - all subscribe fields in qlib - limit_threshold : Union[Tuple[str, str], float, None] - 1) `None`: no limitation - 2) float, 0.1 for example, default None - 3) Tuple[str, str]: (, - ) - `False` value indicates the stock is tradable - `True` value indicates the stock is limited and not tradable - buy_price : str - the data field for buying stock - sell_price : str - the data field for selling stock - extra_quote : pd.DataFrame - columns: like ['$vwap', '$close', '$volume', '$factor', 'limit_sell', 'limit_buy']. - The limit indicates that the etf is tradable on a specific day. - Necessary fields: - $close is for calculating the total value at end of each day. - Optional fields: - $volume is only necessary when we limit the trade amount or caculate PA(vwap) indicator - $vwap is only necessary when we use the $vwap price as the deal price - $factor is for rounding to the trading unit - limit_sell will be set to False by default(False indicates we can sell this - target on this day). - limit_buy will be set to False by default(False indicates we can buy this - target on this day). - index: MultipleIndex(instrument, pd.Datetime) + fields : Union[str, List] + the columns of data to fetch + method : Union[str, Callable] + the method apply to data. + e.g ["None", "last", "all", "sum", "mean", qlib/utils/resam.py/ts_data_last] + + Return + ---------- + Union[None, float, pd.Series] + The resampled Series/value, return None when the resampled data is empty. """ - super().__init__() + raise NotImplementedError(f"Please implement the `get_data` method") - # get stock data from qlib - if len(codes) == 0: - codes = D.instruments() - self.data = D.features( - codes, - all_fields, - start_time, - end_time, - freq=freq, - disk_cache=True - ).dropna(subset=["$close"]) - self.data.columns = all_fields - # check buy_price data and sell_price data - self.buy_price = buy_price - self.sell_price = sell_price - for attr in "buy_price", "sell_price": - pstr = getattr(self, attr) # price string - if self.data[pstr].isna().any(): - self.logger.warning("{} field data contains nan.".format(pstr)) - - # update trade_w_adj_price - if self.data["$factor"].isna().any(): - # The 'factor.day.bin' file not exists, and `factor` field contains `nan` - # Use adjusted price - self.logger.warning("factor.day.bin file not exists or factor contains `nan`. Order using adjusted_price.") - self.trade_w_adj_price = True - else: - # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` - # Use normal price - self.trade_w_adj_price = False - - # update limit - self._update_limit(limit_threshold) - - # concat extra_quote - quote_df = self.data - if extra_quote is not None: - # process extra_quote - if "$close" not in extra_quote: - raise ValueError("$close is necessray in extra_quote") - for attr in "buy_price", "sell_price": - pstr = getattr(self, attr) # price string - if pstr not in extra_quote.columns: - extra_quote[pstr] = extra_quote["$close"] - self.logger.warning(f"No {pstr} set for extra_quote. Use $close as {pstr}.") - if "$factor" not in extra_quote.columns: - extra_quote["$factor"] = 1.0 - self.logger.warning("No $factor set for extra_quote. Use 1.0 as $factor.") - if "limit_sell" not in extra_quote.columns: - extra_quote["limit_sell"] = False - self.logger.warning("No limit_sell set for extra_quote. All stock will be able to be sold.") - if "limit_buy" not in extra_quote.columns: - extra_quote["limit_buy"] = False - self.logger.warning("No limit_buy set for extra_quote. All stock will be able to be bought.") - assert set(extra_quote.columns) == set(quote_df.columns) - {"$change"} - quote_df = pd.concat([quote_df, extra_quote], sort=False, axis=0) +class PandasQuote(BaseQuote): + def __init__(self, quote_df: pd.DataFrame): + super().__init__(quote_df=quote_df) quote_dict = {} for stock_id, stock_val in quote_df.groupby(level="instrument"): quote_dict[stock_id] = stock_val.droplevel(level="instrument") self.data = quote_dict - def _update_limit(self, limit_threshold): - # check limit_threshold - limit_type = self._get_limit_type(limit_threshold) - if limit_type == self.LT_NONE: - self.data["limit_buy"] = False - self.data["limit_sell"] = False - elif limit_type == self.LT_TP_EXP: - # set limit - self.data["limit_buy"] = self.data[limit_threshold[0]] - self.data["limit_sell"] = self.data[limit_threshold[1]] - elif limit_type == self.LT_FLT: - self.data["limit_buy"] = self.data["$change"].ge(limit_threshold) - self.data["limit_sell"] = self.data["$change"].le(-limit_threshold) # pylint: disable=E1130 - def get_all_stock(self): return self.data.keys() @@ -715,7 +686,4 @@ class PandasQuote(BaseQuote): elif(isinstance(fields, (str, list))): return resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) else: - raise ValueError(f"fields must be None, str or list") - - def get_trade_w_adj_price(self): - return self.trade_w_adj_price \ No newline at end of file + raise ValueError(f"fields must be None, str or list") \ No newline at end of file From 6ad52e8cf5f7f8da56bbbfaac757de304343695c Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 16 Jul 2021 13:55:49 +0000 Subject: [PATCH 123/187] black and doc --- qlib/backtest/exchange.py | 59 ++++++++++++++------------ qlib/contrib/strategy/rule_strategy.py | 4 +- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 82f57462e..7733891fe 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -150,12 +150,7 @@ class Exchange: if len(self.codes) == 0: self.codes = D.instruments() self.quote_df = D.features( - self.codes, - self.all_fields, - self.start_time, - self.end_time, - freq=self.freq, - disk_cache=True + self.codes, self.all_fields, self.start_time, self.end_time, freq=self.freq, disk_cache=True ).dropna(subset=["$close"]) self.quote_df.columns = self.all_fields @@ -177,10 +172,9 @@ class Exchange: # The `factor.day.bin` file exists and all data `close` and `factor` are not `nan` # Use normal price self.trade_w_adj_price = False - # update limit self._update_limit(self.limit_threshold) - + # concat extra_quote if self.extra_quote is not None: # process extra_quote @@ -199,7 +193,7 @@ class Exchange: self.logger.warning("No limit_sell set for extra_quote. All stock will be able to be sold.") if "limit_buy" not in self.extra_quote.columns: self.extra_quote["limit_buy"] = False - self.logger.warning("No limit_buy set for extra_quote. All stock will be able to be bought.") + self.logger.warning("No limit_buy set for extra_quote. All stock will be able to be bought.") assert set(self.extra_quote.columns) == set(self.quote_df.columns) - {"$change"} self.quote_df = pd.concat([self.quote_df, extra_quote], sort=False, axis=0) @@ -208,8 +202,7 @@ class Exchange: LT_NONE = "none" # none def _get_limit_type(self, limit_threshold): - """get limit type - """ + """get limit type""" if isinstance(limit_threshold, Tuple): return self.LT_TP_EXP elif isinstance(limit_threshold, float): @@ -603,7 +596,6 @@ class Exchange: class BaseQuote: - def __init__(self, quote_df: pd.DataFrame): self.logger = get_module_logger("online operator", level=logging.INFO) @@ -617,10 +609,17 @@ class BaseQuote: """ raise NotImplementedError(f"Please implement the `get_all_stock` method") - def get_data(self, stock_id: str, start_time, end_time, fields: Union[str, list]=None, method=None): + def get_data( + self, + stock_id: Union[str, list], + start_time: Union[pd.Timestamp, str], + end_time: Union[pd.Timestamp, str], + fields: Union[str, list] = None, + method: Union[str, Callable] = None, + ): """get the specific fields of stock data during start time and end_time, and apply method to the data. - + Example: .. code-block:: $close $volume @@ -637,8 +636,15 @@ class BaseQuote: 2010-01-12 2788.688232 164587.937500 2010-01-13 2790.604004 145460.453125 + print(get_data(stock_id=["SH600000", "SH600655"], start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + + $close $volume + instrument + SH600000 87.433578 28117442.0 + SH600655 2699.567383 158193.328125 + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) - + $close 87.433578 $volume 28117442.0 @@ -649,27 +655,26 @@ class BaseQuote: Parameters ---------- stock_id: Union[str, list] - start_time : pd.Timestamp|str + start_time : Union[pd.Timestamp, str] closed start time for backtest - end_time : pd.Timestamp|str + end_time : Union[pd.Timestamp, str] closed end time for backtest fields : Union[str, List] the columns of data to fetch method : Union[str, Callable] - the method apply to data. - e.g ["None", "last", "all", "sum", "mean", qlib/utils/resam.py/ts_data_last] + the method apply to data. + e.g ["None", "last", "all", "sum", "mean", "any", qlib/utils/resam.py/ts_data_last] Return ---------- - Union[None, float, pd.Series] - The resampled Series/value, return None when the resampled data is empty. + Union[None, float, pd.Series, pd.DataFrame] + The resampled DataFrame/Series/value, return None when the resampled data is empty. """ - raise NotImplementedError(f"Please implement the `get_data` method") + raise NotImplementedError(f"Please implement the `get_data` method") class PandasQuote(BaseQuote): - def __init__(self, quote_df: pd.DataFrame): super().__init__(quote_df=quote_df) quote_dict = {} @@ -680,10 +685,10 @@ class PandasQuote(BaseQuote): def get_all_stock(self): return self.data.keys() - def get_data(self, stock_id, start_time, end_time, fields = None, method = None): - if(fields is None): + def get_data(self, stock_id, start_time, end_time, fields=None, method=None): + if fields is None: return resam_ts_data(self.data[stock_id], start_time, end_time, method=method) - elif(isinstance(fields, (str, list))): + elif isinstance(fields, (str, list)): return resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) else: - raise ValueError(f"fields must be None, str or list") \ No newline at end of file + raise ValueError(f"fields must be None, str or list") diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 56884cd48..970734df5 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -687,7 +687,9 @@ class FileOrderStrategy(BaseStrategy): - This class provides an interface for user to read orders from csv files. """ - def __init__(self, file: Union[IO, str, Path], trade_range: Union[Tuple[int, int], TradeRange]= None, *args, **kwargs): + def __init__( + self, file: Union[IO, str, Path], trade_range: Union[Tuple[int, int], TradeRange] = None, *args, **kwargs + ): """ Parameters From 2b8d4dc3c2cb744ac27a0e78860304d6d3218073 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 16 Jul 2021 14:09:36 +0000 Subject: [PATCH 124/187] callable --- qlib/backtest/exchange.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 7733891fe..8d02e7893 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -615,7 +615,7 @@ class BaseQuote: start_time: Union[pd.Timestamp, str], end_time: Union[pd.Timestamp, str], fields: Union[str, list] = None, - method: Union[str, Callable] = None, + method: Union[str, "Callable"] = None, ): """get the specific fields of stock data during start time and end_time, and apply method to the data. From 7738f39546ee43a75a82d983df06626448ff50aa Mon Sep 17 00:00:00 2001 From: Young Date: Sat, 17 Jul 2021 06:53:51 +0000 Subject: [PATCH 125/187] filter zero base price --- qlib/backtest/report.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 308decd12..6b64bf3b1 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -359,7 +359,10 @@ class Indicator: trade_exchange: Exchange, pa_config: dict = {}, ): - """Get the base volume and price information""" + """ + Get the base volume and price information + All the base price values are rooted from this function + """ agg = pa_config.get("agg", "twap").lower() price = pa_config.get("price", "deal_price").lower() @@ -382,10 +385,12 @@ class Indicator: # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. - # price_s = price_s.mask(np.isclose(price_s, 0)) + price_s = price_s[~(price_s < 1e-08)] # remove zero and negative values. + # NOTE ~(price_s < 1e-08) is different from price_s >= 1e-8 if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) + volume_s = volume_s.reindex(price_s.index) elif agg == "twap": volume_s = pd.Series(1, index=price_s.index) else: From ed12c7fca359799f89755758d169bf6b10a1c947 Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 18 Jul 2021 03:13:15 +0000 Subject: [PATCH 126/187] add common_infra warning and fix time bug --- qlib/backtest/executor.py | 3 +++ qlib/backtest/order.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index ff87a61cf..999e6d8a7 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -1,5 +1,6 @@ from abc import abstractclassmethod, abstractmethod import copy +from qlib.log import get_module_logger from types import GeneratorType from qlib.backtest.account import Account import warnings @@ -104,6 +105,8 @@ class BaseExecutor: self.level_infra = LevelInfrastructure() self.level_infra.reset_infra(common_infra=common_infra) self.reset(start_time=start_time, end_time=end_time, common_infra=common_infra) + if common_infra is None: + get_module_logger("BaseExecutor").warning(f"`common_infra` is not set for {self}") def reset_common_infra(self, common_infra): """ diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 49b200833..15a43beb3 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -514,9 +514,9 @@ class TradeDecisionWO(BaseTradeDecision): self.order_list = order_list start, end = strategy.trade_calendar.get_step_time() for o in order_list: - if o.start_time: + if o.start_time is None: o.start_time = start - if o.end_time: + if o.end_time is None: o.end_time = end def get_decision(self) -> List[object]: From 4a62e02fca6d26cae00993e72f174e412fc89ee0 Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 18 Jul 2021 07:05:10 +0000 Subject: [PATCH 127/187] add get_data_cal_avail_range method --- qlib/backtest/order.py | 2 +- qlib/backtest/utils.py | 2 +- qlib/strategy/base.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 15a43beb3..4f1426e4f 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -523,4 +523,4 @@ class TradeDecisionWO(BaseTradeDecision): return self.order_list def __repr__(self) -> str: - return f"strategy: {self.strategy}; trade_range: {self.trade_range}; order_list[{len(self.order_list)}]" + return f"class: {self.__class__.__name__}; strategy: {self.strategy}; trade_range: {self.trade_range}; order_list[{len(self.order_list)}]" diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index 34c91151f..b5ff84c54 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -181,7 +181,7 @@ class TradeCalendarManager: return clip(left), clip(right) def __repr__(self) -> str: - return f"{self.start_time}[{self.start_index}]~{self.end_time}[{self.end_index}]: [{self.trade_step}/{self.trade_len}]" + return f"class: {self.__class__.__name__}; {self.start_time}[{self.start_index}]~{self.end_time}[{self.end_index}]: [{self.trade_step}/{self.trade_len}]" class BaseInfrastructure: diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 265ff309c..fa21fae5f 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from qlib.backtest.exchange import Exchange from qlib.backtest.position import BasePosition -from typing import List, Union +from typing import List, Tuple, Union from ..model.base import BaseModel from ..data.dataset import DatasetH @@ -158,6 +158,36 @@ class BaseStrategy: # NOTE: normally, user should do something to the strategy due to the change of outer decision raise NotImplementedError(f"Please implement the `alter_outer_trade_decision` method") + # helper methods: not necessary but for convenience + def get_data_cal_avail_range(self, rtype: str = "full") -> Tuple[int, int]: + """ + return data calendar's available decision range for `self` strategy + the range consider following factors + - data calendar in the charge of `self` strategy + - trading range limitation from the decision of outer strategy + + + related methods + - TradeCalendarManager.get_data_cal_range + - BaseTradeDecision.get_data_cal_range_limit + + Parameters + ---------- + rtype: str + - "full": return the available data index range of the strategy from `start_time` to `end_time` + - "step": return the available data index range of the strategy of current step + + Returns + ------- + Tuple[int, int]: + the available range both sides are closed + """ + cal_range = self.trade_calendar.get_data_cal_range(rtype=rtype) + if self.outer_trade_decision is None: + raise ValueError(f"There is not limitation for strategy {self}") + range_limit = self.outer_trade_decision.get_data_cal_range_limit(rtype=rtype) + return max(cal_range[0], range_limit[0]), min(cal_range[1], range_limit[1]) + class ModelStrategy(BaseStrategy): """Model-based trading strategy, use model to make predictions for trading""" From 92f2891664263a7d3bbab19d9f25c63a3a23c5a2 Mon Sep 17 00:00:00 2001 From: Young Date: Mon, 19 Jul 2021 01:57:22 +0000 Subject: [PATCH 128/187] fix order factor setting issue Move the factor setting from init phase to dealing phase. --- qlib/backtest/exchange.py | 47 +++++++++++++++++++++++-- qlib/backtest/order.py | 20 +++++++---- qlib/contrib/evaluate.py | 8 ++--- qlib/contrib/strategy/model_strategy.py | 2 -- qlib/contrib/strategy/rule_strategy.py | 21 ++++++----- 5 files changed, 75 insertions(+), 23 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 58f57ed73..a22754885 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. +from qlib.backtest.position import Position import random import logging from typing import List, Tuple, Union @@ -281,6 +282,8 @@ class Exchange: """ Deal order when the actual transaction + the results section in `Order` will be changed. + :param order: Deal the order. :param trade_account: Trade account to be updated after dealing the order. :param position: position to be updated after dealing the order. @@ -343,6 +346,7 @@ class Exchange: `None`: if the stock is suspended `None` may be returned `float`: return factor if the factor exists """ + assert (start_time is not None and end_time is not None, "the time range must be given") if stock_id not in self.quote: return None return resam_ts_data(self.quote[stock_id]["$factor"], start_time, end_time, method=ts_data_last) @@ -505,20 +509,56 @@ class Exchange: ) return value - def get_amount_of_trade_unit(self, factor): + def _get_factor_or_raise_erorr(self, factor: float = None, stock_id: str = None, start_time=None, end_time=None): + """Please refer to the docs of get_amount_of_trade_unit""" + if factor is None: + if stock_id is not None and start_time is not None and end_time is not None : + factor = self.get_factor(stock_id=stock_id, start_time=start_time, end_time=end_time) + else: + raise ValueError(f"`factor` and (`stock_id`, `start_time`, `end_time`) can't both be None") + return factor + + def get_amount_of_trade_unit(self, factor: float = None, stock_id: str = None, start_time=None, end_time=None): + """ + get the trade unit of amount based on **factor** + + the factor can be given directly or calculated in given time range and stock id. + `factor` has higher priority than `stock_id`, `start_time` and `end_time` + + Parameters + ---------- + factor : float + the adjusted factor + stock_id : str + the id of the stock + start_time : + the start time of trading range + end_time : + the end time of trading range + """ if not self.trade_w_adj_price and self.trade_unit is not None: + factor = self._get_factor_or_raise_erorr(factor=factor, + stock_id=stock_id, + start_time=start_time, + end_time=end_time) return self.trade_unit / factor else: return None - def round_amount_by_trade_unit(self, deal_amount, factor): + def round_amount_by_trade_unit(self, deal_amount, factor: float = None, stock_id: str = None, start_time=None, end_time=None): """Parameter + Please refer to the docs of get_amount_of_trade_unit + deal_amount : float, adjusted amount factor : float, adjusted factor return : float, real amount """ if not self.trade_w_adj_price and self.trade_unit is not None: # the minimal amount is 1. Add 0.1 for solving precision problem. + factor = self._get_factor_or_raise_erorr(factor=factor, + stock_id=stock_id, + start_time=start_time, + end_time=end_time) return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount @@ -529,7 +569,7 @@ class Exchange: else: return deal_amount - def _calc_trade_info_by_order(self, order, position): + def _calc_trade_info_by_order(self, order, position: Position): """ Calculation of trade info @@ -541,6 +581,7 @@ class Exchange: """ trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction) + order.factor = self.get_factor(order.stock_id, order.start_time, order.end_time) if order.direction == Order.SELL: # sell if position is not None: diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 4f1426e4f..b99cdb8e3 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -42,16 +42,24 @@ class Order: presents the weight factor assigned in Exchange() """ + # 1) time invariant values + # - they are set by users and is time-invariant. stock_id: str - amount: float # `amount` is a non-negative value + amount: float # `amount` is a non-negative and adjusted value + direction: int + # 2) time variant values: + # - Users may want to set these values when using lower level APIs + # - If users don't, TradeDecisionWO will help users to set them # The interval of the order which belongs to (NOTE: this is not the expected order dealing range time) start_time: pd.Timestamp end_time: pd.Timestamp - direction: int - factor: float + # 3) results + # - users should not care about these values + # - they are set by the backtest system after finishing the results. deal_amount: float = field(init=False) # `deal_amount` is a non-negative value + factor: float = field(init=False) # FIXME: # for compatible now. @@ -144,9 +152,9 @@ class OrderHelper: **adjusted trading amount** direction : OrderDir trading direction - start_time : Union[str, pd.Timestamp] + start_time : Union[str, pd.Timestamp] (optional) The interval of the order which belongs to - end_time : Union[str, pd.Timestamp] + end_time : Union[str, pd.Timestamp] (optional) The interval of the order which belongs to Returns @@ -158,13 +166,13 @@ class OrderHelper: start_time = pd.Timestamp(start_time) if end_time is not None: end_time = pd.Timestamp(end_time) + # NOTE: factor is a value belongs to the results section. User don't have to care about it when creating orders return Order( stock_id=code, amount=amount, start_time=start_time, end_time=end_time, direction=direction, - factor=self.exchange.get_factor(code, start_time, end_time), ) diff --git a/qlib/contrib/evaluate.py b/qlib/contrib/evaluate.py index f7728f911..e08039413 100644 --- a/qlib/contrib/evaluate.py +++ b/qlib/contrib/evaluate.py @@ -73,20 +73,20 @@ def indicator_analysis(df, method="mean"): Parameters ---------- df : pandas.DataFrame - columns: like ['pa', 'pos', 'ffr', 'amount', 'value']. + columns: like ['pa', 'pos', 'ffr', 'deal_amount', 'value']. Necessary fields: - 'pa' is the price advantage in trade indicators - 'pos' is the positive rate in trade indicators - 'ffr' is the fulfill rate in trade indicators Optional fields: - - 'amount' is the total deal amount, only necessary when method is 'amount_weighted' + - 'deal_amount' is the total deal deal_amount, only necessary when method is 'amount_weighted' - 'value' is the total trade value, only necessary when method is 'value_weighted' index: Index(datetime) method : str, optional statistics method of pa/ffr, by default "mean" - if method is 'mean', count the mean statistical value of each trade indicator - - if method is 'amount_weighted', count the amount weighted mean statistical value of each trade indicator + - if method is 'amount_weighted', count the deal_amount weighted mean statistical value of each trade indicator - if method is 'value_weighted', count the value weighted mean statistical value of each trade indicator Note: statistics method of pos is always "mean" @@ -97,7 +97,7 @@ def indicator_analysis(df, method="mean"): """ weights_dict = { "mean": df["count"], - "amount_weighted": df["amount"].abs(), + "amount_weighted": df["deal_amount"].abs(), "value_weighted": df["value"].abs(), } if method not in weights_dict: diff --git a/qlib/contrib/strategy/model_strategy.py b/qlib/contrib/strategy/model_strategy.py index 17a13e155..48d96686a 100644 --- a/qlib/contrib/strategy/model_strategy.py +++ b/qlib/contrib/strategy/model_strategy.py @@ -194,7 +194,6 @@ class TopkDropoutStrategy(ModelStrategy): start_time=trade_start_time, end_time=trade_end_time, direction=Order.SELL, # 0 for sell, 1 for buy - factor=factor, ) # is order executable if self.trade_exchange.check_order(sell_order): @@ -231,7 +230,6 @@ class TopkDropoutStrategy(ModelStrategy): start_time=trade_start_time, end_time=trade_end_time, direction=Order.BUY, # 1 for buy - factor=factor, ) buy_order_list.append(buy_order) return TradeDecisionWO(sell_order_list + buy_order_list, self) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index c16af7ae5..24386f723 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -63,7 +63,9 @@ class TWAPStrategy(BaseStrategy): stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): continue - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, + start_time=order.start_time, + end_time=order.end_time) _order_amount = None # considering trade unit if _amount_trade_unit is None: @@ -99,7 +101,6 @@ class TWAPStrategy(BaseStrategy): start_time=trade_start_time, end_time=trade_end_time, direction=order.direction, # 1 for buy - factor=order.factor, ) order_list.append(_order) return TradeDecisionWO(order_list=order_list, strategy=self) @@ -168,7 +169,9 @@ class SBBStrategyBase(BaseStrategy): self.trade_trend[order.stock_id] = _pred_trend continue # get amount of one trade unit - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, + start_time=order.start_time, + end_time=order.end_time) if _pred_trend == self.TREND_MID: _order_amount = None # considering trade unit @@ -201,7 +204,6 @@ class SBBStrategyBase(BaseStrategy): start_time=trade_start_time, end_time=trade_end_time, direction=order.direction, - factor=order.factor, ) order_list.append(_order) @@ -248,7 +250,6 @@ class SBBStrategyBase(BaseStrategy): start_time=trade_start_time, end_time=trade_end_time, direction=order.direction, # 1 for buy - factor=order.factor, ) order_list.append(_order) else: @@ -267,7 +268,6 @@ class SBBStrategyBase(BaseStrategy): start_time=trade_start_time, end_time=trade_end_time, direction=order.direction, # 1 for buy - factor=order.factor, ) order_list.append(_order) @@ -471,7 +471,9 @@ class ACStrategy(BaseStrategy): if sig_sam is None or np.isnan(sig_sam): # no signal, TWAP - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(order.factor) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, + start_time=order.start_time, + end_time=order.end_time) if _amount_trade_unit is None: # divide the order into equal parts, and trade one part _order_amount = self.trade_amount[order.stock_id] / (trade_len - trade_step) @@ -492,7 +494,10 @@ class ACStrategy(BaseStrategy): np.sinh(kappa * (trade_len - trade_step)) - np.sinh(kappa * (trade_len - trade_step - 1)) ) / np.sinh(kappa * trade_len) _order_amount = order.amount * amount_ratio - _order_amount = self.trade_exchange.round_amount_by_trade_unit(_order_amount, order.factor) + _order_amount = self.trade_exchange.round_amount_by_trade_unit(_order_amount, + stock_id=order.stock_id, + start_time=order.start_time, + end_time=order.end_time) if order.direction == order.SELL: # sell all amount at last From 4e862f7d1fec5b765cbec44f1cd41f8ff377e7ca Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 20 Jul 2021 05:12:22 +0000 Subject: [PATCH 129/187] add print cash in verbose mode and code format --- qlib/backtest/account.py | 2 +- qlib/backtest/exchange.py | 20 +++++++------- qlib/backtest/executor.py | 36 ++++++++++---------------- qlib/contrib/strategy/rule_strategy.py | 25 +++++++++--------- 4 files changed, 37 insertions(+), 46 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 806f88a96..13213c344 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -160,7 +160,7 @@ class Account: self.accum_info.add_return_value(profit) # note here do not consider cost def update_order(self, order, trade_val, cost, trade_price): - if not self.is_port_metr_enabled(): + if self.current.skip_update(): # TODO: supporting polymorphism for account # updating order for infinite position is meaningless return diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index a22754885..ea1d012eb 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -512,7 +512,7 @@ class Exchange: def _get_factor_or_raise_erorr(self, factor: float = None, stock_id: str = None, start_time=None, end_time=None): """Please refer to the docs of get_amount_of_trade_unit""" if factor is None: - if stock_id is not None and start_time is not None and end_time is not None : + if stock_id is not None and start_time is not None and end_time is not None: factor = self.get_factor(stock_id=stock_id, start_time=start_time, end_time=end_time) else: raise ValueError(f"`factor` and (`stock_id`, `start_time`, `end_time`) can't both be None") @@ -537,15 +537,16 @@ class Exchange: the end time of trading range """ if not self.trade_w_adj_price and self.trade_unit is not None: - factor = self._get_factor_or_raise_erorr(factor=factor, - stock_id=stock_id, - start_time=start_time, - end_time=end_time) + factor = self._get_factor_or_raise_erorr( + factor=factor, stock_id=stock_id, start_time=start_time, end_time=end_time + ) return self.trade_unit / factor else: return None - def round_amount_by_trade_unit(self, deal_amount, factor: float = None, stock_id: str = None, start_time=None, end_time=None): + def round_amount_by_trade_unit( + self, deal_amount, factor: float = None, stock_id: str = None, start_time=None, end_time=None + ): """Parameter Please refer to the docs of get_amount_of_trade_unit @@ -555,10 +556,9 @@ class Exchange: """ if not self.trade_w_adj_price and self.trade_unit is not None: # the minimal amount is 1. Add 0.1 for solving precision problem. - factor = self._get_factor_or_raise_erorr(factor=factor, - stock_id=stock_id, - start_time=start_time, - end_time=end_time) + factor = self._get_factor_or_raise_erorr( + factor=factor, stock_id=stock_id, start_time=start_time, end_time=end_time + ) return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 999e6d8a7..b05b73801 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -495,30 +495,22 @@ class SimulatorExecutor(BaseExecutor): execute_result.append((order, trade_val, trade_cost, trade_price)) if self.verbose: if order.direction == Order.SELL: # sell - print( - "[I {:%Y-%m-%d %H:%M:%S}]: sell {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( - trade_start_time, - order.stock_id, - trade_price, - order.amount, - order.deal_amount, - order.factor, - trade_val, - ) - ) + action = "sell" else: - print( - "[I {:%Y-%m-%d %H:%M:%S}]: buy {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}.".format( - trade_start_time, - order.stock_id, - trade_price, - order.amount, - order.deal_amount, - order.factor, - trade_val, - ) + action = "buy" + print( + "[I {:%Y-%m-%d %H:%M:%S}]: {} {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}, cach {:.2f}.".format( + trade_start_time, + action, + order.stock_id, + trade_price, + order.amount, + order.deal_amount, + order.factor, + trade_val, + self.trade_account.get_cash(), ) - + ) else: if self.verbose: print("[W {:%Y-%m-%d %H:%M:%S}]: {} wrong.".format(trade_start_time, order.stock_id)) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 24386f723..36059f5a0 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -63,9 +63,9 @@ class TWAPStrategy(BaseStrategy): stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): continue - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( + stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) _order_amount = None # considering trade unit if _amount_trade_unit is None: @@ -169,9 +169,9 @@ class SBBStrategyBase(BaseStrategy): self.trade_trend[order.stock_id] = _pred_trend continue # get amount of one trade unit - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( + stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) if _pred_trend == self.TREND_MID: _order_amount = None # considering trade unit @@ -471,9 +471,9 @@ class ACStrategy(BaseStrategy): if sig_sam is None or np.isnan(sig_sam): # no signal, TWAP - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( + stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) if _amount_trade_unit is None: # divide the order into equal parts, and trade one part _order_amount = self.trade_amount[order.stock_id] / (trade_len - trade_step) @@ -494,10 +494,9 @@ class ACStrategy(BaseStrategy): np.sinh(kappa * (trade_len - trade_step)) - np.sinh(kappa * (trade_len - trade_step - 1)) ) / np.sinh(kappa * trade_len) _order_amount = order.amount * amount_ratio - _order_amount = self.trade_exchange.round_amount_by_trade_unit(_order_amount, - stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _order_amount = self.trade_exchange.round_amount_by_trade_unit( + _order_amount, stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) if order.direction == order.SELL: # sell all amount at last From 9bf8c999e67520a45ad4bf1b0351ec311debb2ec Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Tue, 20 Jul 2021 06:14:40 +0000 Subject: [PATCH 130/187] type checking update --- qlib/strategy/base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index fa21fae5f..7a267b511 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from qlib.backtest.exchange import Exchange +from __future__ import annotations +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from qlib.backtest.exchange import Exchange from qlib.backtest.position import BasePosition from typing import List, Tuple, Union From 83d4387e9f51fb8c51b695952877a039f6b7d25d Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 21 Jul 2021 12:47:31 +0000 Subject: [PATCH 131/187] pandas_order_indicator --- qlib/backtest/report.py | 330 ++++++++++++++++++++++++++++++---------- 1 file changed, 251 insertions(+), 79 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 308decd12..8e093e0a6 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -5,8 +5,9 @@ from collections import OrderedDict from logging import warning import pathlib -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Union import warnings +import inspect import numpy as np import pandas as pd @@ -62,6 +63,7 @@ class Report: - Else, it represent end time of benchmark, by default None """ + self.init_vars() self.init_bench(freq=freq, benchmark_config=benchmark_config) @@ -255,7 +257,7 @@ class Indicator: def __init__(self): # order indicator is metrics for a single order for a specific step self.order_indicator_his = OrderedDict() - self.order_indicator: Dict[str, pd.Series] = OrderedDict() + self.order_indicator = PandasOrderIndicator() # trade indicator is metrics for all orders for a specific step self.trade_indicator_his = OrderedDict() @@ -265,12 +267,12 @@ class Indicator: # def reset(self, trade_calendar: TradeCalendarManager): def reset(self): - self.order_indicator = OrderedDict() + self.order_indicator = PandasOrderIndicator() self.trade_indicator = OrderedDict() # self._trade_calendar = trade_calendar def record(self, trade_start_time): - self.order_indicator_his[trade_start_time] = self.order_indicator + self.order_indicator_his[trade_start_time] = self.order_indicator.data self.trade_indicator_his[trade_start_time] = self.trade_indicator def _update_order_trade_info(self, trade_info: list): @@ -280,6 +282,7 @@ class Indicator: trade_value = dict() trade_cost = dict() trade_dir = dict() + pa = dict() for order, _trade_val, _trade_cost, _trade_price in trade_info: amount[order.stock_id] = order.amount_delta @@ -288,66 +291,58 @@ class Indicator: trade_value[order.stock_id] = _trade_val * order.sign trade_cost[order.stock_id] = _trade_cost trade_dir[order.stock_id] = order.direction + pa[order.stock_id] = 0 - self.order_indicator["amount"] = self.order_indicator["inner_amount"] = pd.Series(amount) - self.order_indicator["deal_amount"] = pd.Series(deal_amount) + self.order_indicator.assign("amount", amount) + self.order_indicator.assign("inner_amount", amount) + self.order_indicator.assign("deal_amount", deal_amount) # NOTE: trade_price and baseline price will be same on the lowest-level - self.order_indicator["trade_price"] = pd.Series(trade_price) - self.order_indicator["trade_value"] = pd.Series(trade_value) - self.order_indicator["trade_cost"] = pd.Series(trade_cost) - self.order_indicator["trade_dir"] = pd.Series(trade_dir) + self.order_indicator.assign("trade_price", trade_price) + self.order_indicator.assign("trade_value", trade_value) + self.order_indicator.assign("trade_cost", trade_cost) + self.order_indicator.assign("trade_dir", trade_dir) + self.order_indicator.assign("pa", pa) def _update_order_fulfill_rate(self): - self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] + def func(deal_amount, amount): + return deal_amount / amount + self.order_indicator.transfer(func, "ffr") + """ def _update_order_price_advantage(self): # NOTE: # trade_price and baseline price will be same on the lowest-level # So Pa should be 0 or do nothing - self.order_indicator["pa"] = 0 + self.order_indicator.assign("pa", 0) + """ def update_order_indicators(self, trade_info: list): self._update_order_trade_info(trade_info=trade_info) self._update_order_fulfill_rate() - self._update_order_price_advantage() + # self._update_order_price_advantage() def _agg_order_trade_info(self, inner_order_indicators: List[Dict[str, pd.Series]]): - inner_amount = pd.Series() - deal_amount = pd.Series() - trade_price = pd.Series() - trade_value = pd.Series() - trade_cost = pd.Series() - trade_dir = pd.Series() - for _order_indicator in inner_order_indicators: - inner_amount = inner_amount.add(_order_indicator["inner_amount"], fill_value=0) - deal_amount = deal_amount.add(_order_indicator["deal_amount"], fill_value=0) - trade_price = trade_price.add( - _order_indicator["trade_price"] * _order_indicator["deal_amount"], fill_value=0 - ) - trade_value = trade_value.add(_order_indicator["trade_value"], fill_value=0) - trade_cost = trade_cost.add(_order_indicator["trade_cost"], fill_value=0) - trade_dir = trade_dir.add(_order_indicator["trade_dir"], fill_value=0) + all_metric = ["inner_amount", "deal_amount", "trade_price", + "trade_value", "trade_cost", "trade_dir"] + metric_dict = PandasOrderIndicator.agg_all_indicators(inner_order_indicators, all_metric, fill_value=0) + for metric in metric_dict: + self.order_indicator.assign(metric, metric_dict[metric]) - trade_dir = trade_dir.apply(Order.parse_dir) + def func(trade_price, deal_amount): + return trade_price / deal_amount + self.order_indicator.transfer(func, "trade_price") - self.order_indicator["inner_amount"] = inner_amount - self.order_indicator["deal_amount"] = deal_amount - trade_price /= self.order_indicator["deal_amount"] - self.order_indicator["trade_price"] = trade_price - self.order_indicator["trade_value"] = trade_value - self.order_indicator["trade_cost"] = trade_cost - self.order_indicator["trade_dir"] = trade_dir + def func_apply(trade_dir): + return trade_dir.apply(Order.parse_dir) + self.order_indicator.transfer(func_apply, "trade_dir") def _update_trade_amount(self, outer_trade_decision: BaseTradeDecision): # NOTE: these indicator is designed for order execution, so the decision: List[Order] = outer_trade_decision.get_decision() if decision is None: - self.order_indicator["amount"] = pd.Series() + self.order_indicator.assign("amount", {}) else: - self.order_indicator["amount"] = pd.Series({order.stock_id: order.amount_delta for order in decision}) - - def _agg_order_fulfill_rate(self): - self.order_indicator["ffr"] = self.order_indicator["deal_amount"] / self.order_indicator["amount"] + self.order_indicator.assign("amount", {order.stock_id: order.amount_delta for order in decision}) def _get_base_vol_pri( self, @@ -423,17 +418,16 @@ class Indicator: "price": "$close", # TODO: this is not supported now!!!!! # default to use deal price of the exchange } - """ # TODO: I think there are potentials to be optimized - trade_dir = self.order_indicator["trade_dir"] + trade_dir = self.order_indicator.get_metric_series("trade_dir") if len(trade_dir) > 0: bp_all, bv_all = [], [] # for oi, (dec, start, end) in zip(inner_order_indicators, decision_list): - bp_s = oi.get("base_price", pd.Series()).reindex(trade_dir.index) - bv_s = oi.get("base_volume", pd.Series()).reindex(trade_dir.index) + bp_s = oi.get_metric_series("base_price").reindex(trade_dir.index) + bv_s = oi.get_metric_series("base_volume").reindex(trade_dir.index) bp_new, bv_new = {}, {} for pr, v, (inst, direction) in zip(bp_s.values, bv_s.values, trade_dir.items()): if np.isnan(pr): @@ -457,17 +451,21 @@ class Indicator: bp_all = pd.concat(bp_all, axis=1) bv_all = pd.concat(bv_all, axis=1) - self.order_indicator["base_volume"] = bv_all.sum(axis=1) - self.order_indicator["base_price"] = (bp_all * bv_all).sum(axis=1) / self.order_indicator["base_volume"] + base_volume = bv_all.sum(axis=1) + self.order_indicator.assign("base_volume", base_volume) + self.order_indicator.assign("base_price", (bp_all * bv_all).sum(axis=1) / base_volume) def _agg_order_price_advantage(self): - if not self.order_indicator["trade_price"].empty: - sign = 1 - self.order_indicator["trade_dir"] * 2 - self.order_indicator["pa"] = sign * ( - self.order_indicator["trade_price"] / self.order_indicator["base_price"] - 1 - ) + def if_empty_func(trade_price): + return trade_price.empty + if_empty = self.order_indicator.transfer(if_empty_func) + if not if_empty: + def func(trade_dir, trade_price, base_price): + sign = 1 - trade_dir * 2 + return sign * (trade_price / base_price - 1) + self.order_indicator.transfer(func, "pa") else: - self.order_indicator["pa"] = pd.Series() + self.order_indicator.assign("pa", {}) def agg_order_indicators( self, @@ -477,57 +475,60 @@ class Indicator: trade_exchange: Exchange, indicator_config={}, ): - self._agg_order_trade_info(inner_order_indicators) + self._agg_order_trade_info(inner_order_indicators) # TODO self._update_trade_amount(outer_trade_decision) - self._agg_order_fulfill_rate() + self._update_order_fulfill_rate() pa_config = indicator_config.get("pa_config", {}) - self._agg_base_price(inner_order_indicators, decision_list, trade_exchange, pa_config=pa_config) + self._agg_base_price(inner_order_indicators, decision_list, trade_exchange, pa_config=pa_config) # TODO self._agg_order_price_advantage() def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": - return self.order_indicator["ffr"].mean() + def func(ffr): + return ffr.mean() elif method == "amount_weighted": - weights = self.order_indicator["deal_amount"].abs() - return (self.order_indicator["ffr"] * weights).sum() / weights.sum() + def func(ffr, deal_amount): + return (ffr * deal_amount.abs()).sum() / (deal_amount.abs().sum()) elif method == "value_weighted": - weights = self.order_indicator["trade_value"].abs() - return (self.order_indicator["ffr"] * weights).sum() / weights.sum() + def func(ffr, trade_value): + return (ffr * trade_value.abs()).sum() / (trade_value.abs().sum()) else: raise ValueError(f"method {method} is not supported!") + return self.order_indicator.transfer(func) def _cal_trade_price_advantage(self, method="mean"): - pa_order = self.order_indicator["pa"] - if isinstance(pa_order, (int, float)): - # pa from atomic executor - return pa_order - if method == "mean": - return pa_order.mean() + def func(pa): + return pa.mean() elif method == "amount_weighted": - weights = self.order_indicator["deal_amount"].abs() - return (pa_order * weights).sum() / weights.sum() + def func(pa, deal_amount): + return (pa * deal_amount.abs()).sum() / (deal_amount.abs().sum()) elif method == "value_weighted": - weights = self.order_indicator["trade_value"].abs() - return (pa_order * weights).sum() / weights.sum() + def func(pa, trade_value): + return (pa * trade_value.abs()).sum() / (trade_value.abs().sum()) else: raise ValueError(f"method {method} is not supported!") + return self.order_indicator.transfer(func) def _cal_trade_positive_rate(self): - pa_order = self.order_indicator["pa"] - if isinstance(pa_order, (int, float)): - # pa from atomic executor - return pa_order - return (pa_order > 0).astype(int).sum() / pa_order.count() + def func(pa): + return (pa > 0).astype(int).sum() / pa.count() + return self.order_indicator.transfer(func) def _cal_deal_amount(self): - return self.order_indicator["deal_amount"].abs().sum() + def func(deal_amount): + return deal_amount.abs().sum() + return self.order_indicator.transfer(func) def _cal_trade_value(self): - return self.order_indicator["trade_value"].abs().sum() + def func(trade_value): + return trade_value.abs().sum() + return self.order_indicator.transfer(func) def _cal_trade_order_count(self): - return self.order_indicator["amount"].count() + def func(amount): + return amount.count() + return self.order_indicator.transfer(func) def cal_trade_indicators(self, trade_start_time, freq, indicator_config={}): show_indicator = indicator_config.get("show_indicator", False) @@ -560,3 +561,174 @@ class Indicator: def generate_trade_indicators_dataframe(self): return pd.DataFrame.from_dict(self.trade_indicator_his, orient="index") + + +class BaseOrderIndicator: + + def __init__(self): + pass + + def assign(self, col: str, metric: Union[dict, pd.Series]): + pass + + def transfer(self, func: "Callable", new_col = None): + pass + + def get_metric_series(self, metric: str): + pass + + @classmethod + def agg_all_indicators(indicators, metrics: Union[str, List[str]], fill_value = None): + pass + + +class PandasOrderIndicator(BaseOrderIndicator): + + class SingleMetric: + def __init__(self, metric: Union[dict, pd.Series]): + if isinstance(metric, dict): + self.metric = pd.Series(metric) + elif isinstance(metric, pd.Series): + self.metric = metric + else: + raise ValueError(f"metric must be dict or pd.Series") + + def __add__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(self.metric + other) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(self.metric + other.metric) + else: + return NotImplemented + + def __radd__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(other + self.metric) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(other.metric + self.metric) + else: + return NotImplemented + + def __sub__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(self.metric - other) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(self.metric - other.metric) + else: + return NotImplemented + + def __rsub__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(other - self.metric) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(other.metric - self.metric) + else: + return NotImplemented + + def __mul__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(self.metric * other) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(self.metric * other.metric) + else: + return NotImplemented + + def __truediv__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(self.metric / other) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(self.metric / other.metric) + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(self.metric == other) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(self.metric == other.metric) + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(self.metric < other) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(self.metric < other.metric) + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, (int, float)): + return PandasOrderIndicator.SingleMetric(self.metric > other) + elif isinstance(other, PandasOrderIndicator.SingleMetric): + return PandasOrderIndicator.SingleMetric(self.metric > other.metric) + else: + return NotImplemented + + def __len__(self): + return len(self.metric) + + def sum(self): + return self.metric.sum() + + def mean(self): + return self.metric.mean() + + def count(self): + return self.metric.count() + + def abs(self): + return PandasOrderIndicator.SingleMetric(self.metric.abs()) + + def astype(self, type): + return PandasOrderIndicator.SingleMetric(self.metric.astype(type)) + + @property + def empty(self): + return self.metric.empty + + """ + @property + def index(self): + return self.metric.index + """ + + def add(self, other, fill_value: None): + return PandasOrderIndicator.SingleMetric(self.metric.add(other.metric, fill_value = fill_value)) + + def apply(self, map_dict: dict): + return PandasOrderIndicator.SingleMetric(self.metric.apply(map_dict)) + + def __init__(self): + self.data: Dict[str, self.SingleMetric] = OrderedDict() + + def assign(self, col: str, metric: Union[dict, pd.Series]): + self.data[col] = self.SingleMetric(metric) + + def transfer(self, func: "Callable", new_col = None): + func_sig = inspect.signature(func).parameters.keys() + func_kwargs = {sig: self.data[sig] for sig in func_sig} + tmp_metric = func(**func_kwargs) + if(new_col is not None): + self.data[new_col] = tmp_metric + return tmp_metric + + def get_metric_series(self, metric: str): + if(metric in self.data): + return self.data[metric].metric + else: + return pd.Series() + + @staticmethod + def agg_all_indicators(indicators: list, metrics: Union[str, List[str]], fill_value = None): + """add all order indicators with same metric""" + + metric_dict = {} + if isinstance(metrics, str): + metrics = [metrics] + for metric in metrics: + tmp_metric = PandasOrderIndicator.SingleMetric({}) + for indicator in indicators: + tmp_metric.add(indicator.data[metric], fill_value) + metric_dict[metric] = tmp_metric.metric + return metric_dict \ No newline at end of file From 10c182e2b06d9a66ecd32b71b2ddc4f835df96cf Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 21 Jul 2021 14:09:12 +0000 Subject: [PATCH 132/187] add order_indicator doc --- qlib/backtest/report.py | 96 ++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 21 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 8e093e0a6..1ae50f5e2 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -308,14 +308,6 @@ class Indicator: return deal_amount / amount self.order_indicator.transfer(func, "ffr") - """ - def _update_order_price_advantage(self): - # NOTE: - # trade_price and baseline price will be same on the lowest-level - # So Pa should be 0 or do nothing - self.order_indicator.assign("pa", 0) - """ - def update_order_indicators(self, trade_info: list): self._update_order_trade_info(trade_info=trade_info) self._update_order_fulfill_rate() @@ -475,7 +467,7 @@ class Indicator: trade_exchange: Exchange, indicator_config={}, ): - self._agg_order_trade_info(inner_order_indicators) # TODO + self._agg_order_trade_info(inner_order_indicators) self._update_trade_amount(outer_trade_decision) self._update_order_fulfill_rate() pa_config = indicator_config.get("pa_config", {}) @@ -564,27 +556,97 @@ class Indicator: class BaseOrderIndicator: + """The data structure of order indicator. + """ def __init__(self): pass def assign(self, col: str, metric: Union[dict, pd.Series]): + """assign one metric. + + Parameters + ---------- + col : str + the metric name of one metric. + metric : Union[dict, pd.Series] + the metric data. + """ + pass - def transfer(self, func: "Callable", new_col = None): + def transfer(self, func: "Callable", new_col: str = None): + """compute new metric with existing. + + Parameters + ---------- + func : Callable + the func of computing new metric. + the kwargs of func will be replaced with metric data by name in this function. + e.g. + def func(pa): + return (pa > 0).astype(int).sum() / pa.count() + new_col : str, optional + New metric will be assigned in the data if new_col is not None, by default None. + + Return + ---------- + SingleMetric + new metric. + """ + pass def get_metric_series(self, metric: str): + """return the single metric with pd.Series format + + Parameters + ---------- + metric : str + the metric name. + + Return + ---------- + pd.Series + the single metric. + If there is no metric name in the data, return pd.Series(). + """ + pass @classmethod - def agg_all_indicators(indicators, metrics: Union[str, List[str]], fill_value = None): + def agg_all_indicators(indicators: list, metrics: Union[str, List[str]], fill_value: float = None): + """sum indicators with the same metrics. + + Parameters + ---------- + indicators : List[BaseOrderIndicator] + the list of all inner indicators. + metrics : Union[str, List[str]] + all metrics needs ot be sumed. + fill_value : float, optional + fill np.NaN with value. By default None. + + Return + ---------- + Dict[str: SingleMetric] + a dict of metric name and data. + """ + pass class PandasOrderIndicator(BaseOrderIndicator): + """The data structure is OrderedDict(str: SingleMetric). + Each SingleMetric based on pd.Series is one metric. + Str is the name of metric. + """ class SingleMetric: + """The data structure of the single metric. + The following methods are used for computing metrics in one indicator. + """ + def __init__(self, metric: Union[dict, pd.Series]): if isinstance(metric, dict): self.metric = pd.Series(metric) @@ -687,12 +749,6 @@ class PandasOrderIndicator(BaseOrderIndicator): def empty(self): return self.metric.empty - """ - @property - def index(self): - return self.metric.index - """ - def add(self, other, fill_value: None): return PandasOrderIndicator.SingleMetric(self.metric.add(other.metric, fill_value = fill_value)) @@ -705,7 +761,7 @@ class PandasOrderIndicator(BaseOrderIndicator): def assign(self, col: str, metric: Union[dict, pd.Series]): self.data[col] = self.SingleMetric(metric) - def transfer(self, func: "Callable", new_col = None): + def transfer(self, func: "Callable", new_col: str = None): func_sig = inspect.signature(func).parameters.keys() func_kwargs = {sig: self.data[sig] for sig in func_sig} tmp_metric = func(**func_kwargs) @@ -721,14 +777,12 @@ class PandasOrderIndicator(BaseOrderIndicator): @staticmethod def agg_all_indicators(indicators: list, metrics: Union[str, List[str]], fill_value = None): - """add all order indicators with same metric""" - metric_dict = {} if isinstance(metrics, str): metrics = [metrics] for metric in metrics: tmp_metric = PandasOrderIndicator.SingleMetric({}) for indicator in indicators: - tmp_metric.add(indicator.data[metric], fill_value) + tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) metric_dict[metric] = tmp_metric.metric return metric_dict \ No newline at end of file From 2c8a3ded08c249f18d8d3e71670c3b6c68ac79cb Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Thu, 22 Jul 2021 15:20:03 +0000 Subject: [PATCH 133/187] high_performance_data_structure --- qlib/backtest/exchange.py | 106 +------ qlib/backtest/high_performance_ds.py | 414 +++++++++++++++++++++++++++ qlib/backtest/report.py | 277 +++--------------- 3 files changed, 453 insertions(+), 344 deletions(-) create mode 100644 qlib/backtest/high_performance_ds.py diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 8d02e7893..edcd7baaf 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -4,7 +4,7 @@ import random import logging -from typing import List, Tuple, Union +from typing import List, Tuple, Union, Callable, Iterable import numpy as np import pandas as pd @@ -15,6 +15,7 @@ from ..config import C, REG_CN from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order, OrderDir, OrderHelper +from .high_performane_ds import PandasQuote class Exchange: @@ -32,6 +33,7 @@ class Exchange: close_cost=0.0025, min_cost=5, extra_quote=None, + quote_cls=PandasQuote, **kwargs, ): """__init__ @@ -143,7 +145,8 @@ class Exchange: self.get_quote_from_qlib() # init quote by quote_df - self.quote = PandasQuote(self.quote_df) + self.quote_cls = quote_cls + self.quote = self.quote_cls(self.quote_df) def get_quote_from_qlib(self): # get stock data from qlib @@ -593,102 +596,3 @@ class Exchange: # cache to avoid recreate the same instance self._order_helper = OrderHelper(self) return self._order_helper - - -class BaseQuote: - def __init__(self, quote_df: pd.DataFrame): - self.logger = get_module_logger("online operator", level=logging.INFO) - - def get_all_stock(self): - """return all stock codes - - Return - ------ - Union[list, Dict.keys(), set, tuple] - all stock codes - """ - raise NotImplementedError(f"Please implement the `get_all_stock` method") - - def get_data( - self, - stock_id: Union[str, list], - start_time: Union[pd.Timestamp, str], - end_time: Union[pd.Timestamp, str], - fields: Union[str, list] = None, - method: Union[str, "Callable"] = None, - ): - """get the specific fields of stock data during start time and end_time, - and apply method to the data. - - Example: - .. code-block:: - $close $volume - instrument datetime - SH600000 2010-01-04 86.778313 16162960.0 - 2010-01-05 87.433578 28117442.0 - 2010-01-06 85.713585 23632884.0 - 2010-01-07 83.788803 20813402.0 - 2010-01-08 84.730675 16044853.0 - - SH600655 2010-01-04 2699.567383 158193.328125 - 2010-01-08 2612.359619 77501.406250 - 2010-01-11 2712.982422 160852.390625 - 2010-01-12 2788.688232 164587.937500 - 2010-01-13 2790.604004 145460.453125 - - print(get_data(stock_id=["SH600000", "SH600655"], start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) - - $close $volume - instrument - SH600000 87.433578 28117442.0 - SH600655 2699.567383 158193.328125 - - print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) - - $close 87.433578 - $volume 28117442.0 - - print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields="$close", method="last")) - - 87.433578 - - Parameters - ---------- - stock_id: Union[str, list] - start_time : Union[pd.Timestamp, str] - closed start time for backtest - end_time : Union[pd.Timestamp, str] - closed end time for backtest - fields : Union[str, List] - the columns of data to fetch - method : Union[str, Callable] - the method apply to data. - e.g ["None", "last", "all", "sum", "mean", "any", qlib/utils/resam.py/ts_data_last] - - Return - ---------- - Union[None, float, pd.Series, pd.DataFrame] - The resampled DataFrame/Series/value, return None when the resampled data is empty. - """ - - raise NotImplementedError(f"Please implement the `get_data` method") - - -class PandasQuote(BaseQuote): - def __init__(self, quote_df: pd.DataFrame): - super().__init__(quote_df=quote_df) - quote_dict = {} - for stock_id, stock_val in quote_df.groupby(level="instrument"): - quote_dict[stock_id] = stock_val.droplevel(level="instrument") - self.data = quote_dict - - def get_all_stock(self): - return self.data.keys() - - def get_data(self, stock_id, start_time, end_time, fields=None, method=None): - if fields is None: - return resam_ts_data(self.data[stock_id], start_time, end_time, method=method) - elif isinstance(fields, (str, list)): - return resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) - else: - raise ValueError(f"fields must be None, str or list") diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py new file mode 100644 index 000000000..3e5a9d8e2 --- /dev/null +++ b/qlib/backtest/high_performance_ds.py @@ -0,0 +1,414 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import logging +from typing import List, Tuple, Union, Callable, Iterable, Dict +from collections import OrderedDict + +import inspect +import pandas as pd + +from ..utils.resam import resam_ts_data +from ..log import get_module_logger + + +class BaseQuote: + def __init__(self, quote_df: pd.DataFrame): + self.logger = get_module_logger("online operator", level=logging.INFO) + + def get_all_stock(self) -> Iterable: + """return all stock codes + + Return + ------ + Iterable + all stock codes + """ + raise NotImplementedError(f"Please implement the `get_all_stock` method") + + def get_data( + self, + stock_id: Union[str, list], + start_time: Union[pd.Timestamp, str], + end_time: Union[pd.Timestamp, str], + fields: Union[str, list] = None, + method: Union[str, Callable] = None, + ) -> Union[None, float, pd.Series, pd.DataFrame]: + """get the specific fields of stock data during start time and end_time, + and apply method to the data. + + Example: + .. code-block:: + $close $volume + instrument datetime + SH600000 2010-01-04 86.778313 16162960.0 + 2010-01-05 87.433578 28117442.0 + 2010-01-06 85.713585 23632884.0 + 2010-01-07 83.788803 20813402.0 + 2010-01-08 84.730675 16044853.0 + + SH600655 2010-01-04 2699.567383 158193.328125 + 2010-01-08 2612.359619 77501.406250 + 2010-01-11 2712.982422 160852.390625 + 2010-01-12 2788.688232 164587.937500 + 2010-01-13 2790.604004 145460.453125 + + print(get_data(stock_id=["SH600000", "SH600655"], start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + + $close $volume + instrument + SH600000 87.433578 28117442.0 + SH600655 2699.567383 158193.328125 + + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + + $close 87.433578 + $volume 28117442.0 + + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields="$close", method="last")) + + 87.433578 + + Parameters + ---------- + stock_id: Union[str, list] + start_time : Union[pd.Timestamp, str] + closed start time for backtest + end_time : Union[pd.Timestamp, str] + closed end time for backtest + fields : Union[str, List] + the columns of data to fetch + method : Union[str, Callable] + the method apply to data. + e.g ["None", "last", "all", "sum", "mean", "any", qlib/utils/resam.py/ts_data_last] + + Return + ---------- + Union[None, float, pd.Series, pd.DataFrame] + The resampled DataFrame/Series/value, return None when the resampled data is empty. + """ + + raise NotImplementedError(f"Please implement the `get_data` method") + + +class PandasQuote(BaseQuote): + def __init__(self, quote_df: pd.DataFrame): + super().__init__(quote_df=quote_df) + quote_dict = {} + for stock_id, stock_val in quote_df.groupby(level="instrument"): + quote_dict[stock_id] = stock_val.droplevel(level="instrument") + self.data = quote_dict + + def get_all_stock(self): + return self.data.keys() + + def get_data(self, stock_id, start_time, end_time, fields=None, method=None): + if fields is None: + return resam_ts_data(self.data[stock_id], start_time, end_time, method=method) + elif isinstance(fields, (str, list)): + return resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) + else: + raise ValueError(f"fields must be None, str or list") + + +class BaseSingleMetric: + """ + The data structure of the single metric. + The following methods are used for computing metrics in one indicator. + """ + + def __init__(self, metric: Union[dict, pd.Series]): + pass + + def __add__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __radd__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + return self + other + + def __sub__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __rsub__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __mul__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __truediv__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __eq__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __gt__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __lt__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": + pass + + def __len__(self) -> int: + pass + + def sum(self) -> float: + pass + + def mean(self) -> float: + pass + + def count(self) -> int: + pass + + def abs(self) -> "BaseSingleMetric": + pass + + def astype(self, type: type) -> "BaseSingleMetric": + pass + + @property + def empty(self) -> bool: + """If metric is empyt, return True.""" + pass + + def add(self, other: "BaseSingleMetric", fill_value: float = None) -> "BaseSingleMetric": + """Replace np.NaN with fill_value in two metrics and add them.""" + pass + + def apply(self, map_dict: dict) -> "BaseSingleMetric": + """Replace the value of metric according to map_dict.""" + pass + + +class BaseOrderIndicator: + """ + The data structure of order indicator. + !!!NOTE: There are two ways to organize the data structure. Please choose a better way. + 1. one way is use BaseSingleMetric to represent each metric. For example, the data + structure of PandasOrderIndicator is Dict[str: PandasSingleMetric]. It uses + PandasSingleMetric based on pd.Series to represent each metric. + 2. the another way doesn't BaseSingleMetric to represent each metric. The data + structure of PandasOrderIndicator is a whole matrix. + """ + + def assign(self, col: str, metric: Union[dict, pd.Series]): + """assign one metric. + + Parameters + ---------- + col : str + the metric name of one metric. + metric : Union[dict, pd.Series] + the metric data. + """ + + pass + + def transfer(self, func: Callable, new_col: str = None) -> Union[None, BaseSingleMetric]: + """compute new metric with existing metrics. + + Parameters + ---------- + func : Callable + the func of computing new metric. + the kwargs of func will be replaced with metric data by name in this function. + e.g. + def func(pa): + return (pa > 0).astype(int).sum() / pa.count() + new_col : str, optional + New metric will be assigned in the data if new_col is not None, by default None. + + Return + ---------- + BaseSingleMetric + new metric. + """ + + pass + + def get_metric_series(self, metric: str) -> pd.Series: + """return the single metric with pd.Series format. + + Parameters + ---------- + metric : str + the metric name. + + Return + ---------- + pd.Series + the single metric. + If there is no metric name in the data, return pd.Series(). + """ + + pass + + @staticmethod + def sum_all_indicators( + indicators: list, metrics: Union[str, List[str]], fill_value: float = None + ) -> Dict[str, BaseSingleMetric]: + """sum indicators with the same metrics. + + Parameters + ---------- + indicators : List[BaseOrderIndicator] + the list of all inner indicators. + metrics : Union[str, List[str]] + all metrics needs ot be sumed. + fill_value : float, optional + fill np.NaN with value. By default None. + + Return + ---------- + Dict[str: PandasSingleMetric] + a dict of metric name and data. + """ + + pass + + +class PandasSingleMetric: + """Each SingleMetric is based on pd.Series.""" + + def __init__(self, metric: Union[dict, pd.Series]): + if isinstance(metric, dict): + self.metric = pd.Series(metric) + elif isinstance(metric, pd.Series): + self.metric = metric + else: + raise ValueError(f"metric must be dict or pd.Series") + + def __add__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(self.metric + other) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(self.metric + other.metric) + else: + return NotImplemented + + def __sub__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(self.metric - other) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(self.metric - other.metric) + else: + return NotImplemented + + def __rsub__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(other - self.metric) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(other.metric - self.metric) + else: + return NotImplemented + + def __mul__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(self.metric * other) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(self.metric * other.metric) + else: + return NotImplemented + + def __truediv__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(self.metric / other) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(self.metric / other.metric) + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(self.metric == other) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(self.metric == other.metric) + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(self.metric < other) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(self.metric < other.metric) + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, (int, float)): + return PandasSingleMetric(self.metric > other) + elif isinstance(other, PandasSingleMetric): + return PandasSingleMetric(self.metric > other.metric) + else: + return NotImplemented + + def __len__(self): + return len(self.metric) + + def sum(self): + return self.metric.sum() + + def mean(self): + return self.metric.mean() + + def count(self): + return self.metric.count() + + def abs(self): + return PandasSingleMetric(self.metric.abs()) + + def astype(self, type): + return PandasSingleMetric(self.metric.astype(type)) + + @property + def empty(self): + return self.metric.empty + + def add(self, other, fill_value=None): + return PandasSingleMetric(self.metric.add(other.metric, fill_value=fill_value)) + + def apply(self, map_dict: dict): + return PandasSingleMetric(self.metric.apply(map_dict)) + + +class PandasOrderIndicator(BaseOrderIndicator): + """ + The data structure is OrderedDict(str: PandasSingleMetric). + Each PandasSingleMetric based on pd.Series is one metric. + Str is the name of metric. + """ + + def __init__(self): + self.data: Dict[str, PandasSingleMetric] = OrderedDict() + + def assign(self, col: str, metric: Union[dict, pd.Series]): + self.data[col] = PandasSingleMetric(metric) + + def transfer(self, func: Callable, new_col: str = None) -> Union[None, PandasSingleMetric]: + func_sig = inspect.signature(func).parameters.keys() + func_kwargs = {sig: self.data[sig] for sig in func_sig} + tmp_metric = func(**func_kwargs) + if new_col is not None: + self.data[new_col] = tmp_metric + else: + return tmp_metric + + def get_metric_series(self, metric: str) -> Union[pd.Series]: + if metric in self.data: + return self.data[metric].metric + else: + return pd.Series() + + @staticmethod + def sum_all_indicators( + indicators: list, metrics: Union[str, List[str]], fill_value=None + ) -> Dict[str, PandasSingleMetric]: + metric_dict = {} + if isinstance(metrics, str): + metrics = [metrics] + for metric in metrics: + tmp_metric = PandasSingleMetric({}) + for indicator in indicators: + tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) + metric_dict[metric] = tmp_metric.metric + return metric_dict diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 1ae50f5e2..98d8b4f63 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -5,7 +5,7 @@ from collections import OrderedDict from logging import warning import pathlib -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Tuple, Union, Callable import warnings import inspect @@ -18,6 +18,7 @@ from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir from qlib.backtest.utils import TradeCalendarManager +from .high_performane_ds import PandasOrderIndicator from ..data import D from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data @@ -254,10 +255,12 @@ class Indicator: """ - def __init__(self): + def __init__(self, order_indicator_cls=PandasOrderIndicator): + self.order_indicator_cls = order_indicator_cls + # order indicator is metrics for a single order for a specific step self.order_indicator_his = OrderedDict() - self.order_indicator = PandasOrderIndicator() + self.order_indicator = self.order_indicator_cls() # trade indicator is metrics for all orders for a specific step self.trade_indicator_his = OrderedDict() @@ -267,7 +270,7 @@ class Indicator: # def reset(self, trade_calendar: TradeCalendarManager): def reset(self): - self.order_indicator = PandasOrderIndicator() + self.order_indicator = self.order_indicator_cls() self.trade_indicator = OrderedDict() # self._trade_calendar = trade_calendar @@ -291,6 +294,7 @@ class Indicator: trade_value[order.stock_id] = _trade_val * order.sign trade_cost[order.stock_id] = _trade_cost trade_dir[order.stock_id] = order.direction + # The PA in the innermost layer is meanless pa[order.stock_id] = 0 self.order_indicator.assign("amount", amount) @@ -306,32 +310,33 @@ class Indicator: def _update_order_fulfill_rate(self): def func(deal_amount, amount): return deal_amount / amount + self.order_indicator.transfer(func, "ffr") def update_order_indicators(self, trade_info: list): self._update_order_trade_info(trade_info=trade_info) self._update_order_fulfill_rate() - # self._update_order_price_advantage() def _agg_order_trade_info(self, inner_order_indicators: List[Dict[str, pd.Series]]): - all_metric = ["inner_amount", "deal_amount", "trade_price", - "trade_value", "trade_cost", "trade_dir"] - metric_dict = PandasOrderIndicator.agg_all_indicators(inner_order_indicators, all_metric, fill_value=0) + all_metric = ["inner_amount", "deal_amount", "trade_price", "trade_value", "trade_cost", "trade_dir"] + metric_dict = self.order_indicator_cls.sum_all_indicators(inner_order_indicators, all_metric, fill_value=0) for metric in metric_dict: self.order_indicator.assign(metric, metric_dict[metric]) def func(trade_price, deal_amount): return trade_price / deal_amount + self.order_indicator.transfer(func, "trade_price") def func_apply(trade_dir): return trade_dir.apply(Order.parse_dir) + self.order_indicator.transfer(func_apply, "trade_dir") def _update_trade_amount(self, outer_trade_decision: BaseTradeDecision): # NOTE: these indicator is designed for order execution, so the decision: List[Order] = outer_trade_decision.get_decision() - if decision is None: + if len(decision) == 0: self.order_indicator.assign("amount", {}) else: self.order_indicator.assign("amount", {order.stock_id: order.amount_delta for order in decision}) @@ -450,11 +455,14 @@ class Indicator: def _agg_order_price_advantage(self): def if_empty_func(trade_price): return trade_price.empty + if_empty = self.order_indicator.transfer(if_empty_func) if not if_empty: + def func(trade_dir, trade_price, base_price): sign = 1 - trade_dir * 2 return sign * (trade_price / base_price - 1) + self.order_indicator.transfer(func, "pa") else: self.order_indicator.assign("pa", {}) @@ -471,33 +479,45 @@ class Indicator: self._update_trade_amount(outer_trade_decision) self._update_order_fulfill_rate() pa_config = indicator_config.get("pa_config", {}) - self._agg_base_price(inner_order_indicators, decision_list, trade_exchange, pa_config=pa_config) # TODO + self._agg_base_price(inner_order_indicators, decision_list, trade_exchange, pa_config=pa_config) # TODO self._agg_order_price_advantage() def _cal_trade_fulfill_rate(self, method="mean"): if method == "mean": + def func(ffr): return ffr.mean() + elif method == "amount_weighted": + def func(ffr, deal_amount): return (ffr * deal_amount.abs()).sum() / (deal_amount.abs().sum()) + elif method == "value_weighted": + def func(ffr, trade_value): return (ffr * trade_value.abs()).sum() / (trade_value.abs().sum()) + else: raise ValueError(f"method {method} is not supported!") return self.order_indicator.transfer(func) def _cal_trade_price_advantage(self, method="mean"): if method == "mean": + def func(pa): return pa.mean() + elif method == "amount_weighted": + def func(pa, deal_amount): return (pa * deal_amount.abs()).sum() / (deal_amount.abs().sum()) + elif method == "value_weighted": + def func(pa, trade_value): return (pa * trade_value.abs()).sum() / (trade_value.abs().sum()) + else: raise ValueError(f"method {method} is not supported!") return self.order_indicator.transfer(func) @@ -505,21 +525,25 @@ class Indicator: def _cal_trade_positive_rate(self): def func(pa): return (pa > 0).astype(int).sum() / pa.count() + return self.order_indicator.transfer(func) def _cal_deal_amount(self): def func(deal_amount): return deal_amount.abs().sum() + return self.order_indicator.transfer(func) def _cal_trade_value(self): def func(trade_value): return trade_value.abs().sum() + return self.order_indicator.transfer(func) def _cal_trade_order_count(self): def func(amount): return amount.count() + return self.order_indicator.transfer(func) def cal_trade_indicators(self, trade_start_time, freq, indicator_config={}): @@ -553,236 +577,3 @@ class Indicator: def generate_trade_indicators_dataframe(self): return pd.DataFrame.from_dict(self.trade_indicator_his, orient="index") - - -class BaseOrderIndicator: - """The data structure of order indicator. - """ - - def __init__(self): - pass - - def assign(self, col: str, metric: Union[dict, pd.Series]): - """assign one metric. - - Parameters - ---------- - col : str - the metric name of one metric. - metric : Union[dict, pd.Series] - the metric data. - """ - - pass - - def transfer(self, func: "Callable", new_col: str = None): - """compute new metric with existing. - - Parameters - ---------- - func : Callable - the func of computing new metric. - the kwargs of func will be replaced with metric data by name in this function. - e.g. - def func(pa): - return (pa > 0).astype(int).sum() / pa.count() - new_col : str, optional - New metric will be assigned in the data if new_col is not None, by default None. - - Return - ---------- - SingleMetric - new metric. - """ - - pass - - def get_metric_series(self, metric: str): - """return the single metric with pd.Series format - - Parameters - ---------- - metric : str - the metric name. - - Return - ---------- - pd.Series - the single metric. - If there is no metric name in the data, return pd.Series(). - """ - - pass - - @classmethod - def agg_all_indicators(indicators: list, metrics: Union[str, List[str]], fill_value: float = None): - """sum indicators with the same metrics. - - Parameters - ---------- - indicators : List[BaseOrderIndicator] - the list of all inner indicators. - metrics : Union[str, List[str]] - all metrics needs ot be sumed. - fill_value : float, optional - fill np.NaN with value. By default None. - - Return - ---------- - Dict[str: SingleMetric] - a dict of metric name and data. - """ - - pass - - -class PandasOrderIndicator(BaseOrderIndicator): - """The data structure is OrderedDict(str: SingleMetric). - Each SingleMetric based on pd.Series is one metric. - Str is the name of metric. - """ - - class SingleMetric: - """The data structure of the single metric. - The following methods are used for computing metrics in one indicator. - """ - - def __init__(self, metric: Union[dict, pd.Series]): - if isinstance(metric, dict): - self.metric = pd.Series(metric) - elif isinstance(metric, pd.Series): - self.metric = metric - else: - raise ValueError(f"metric must be dict or pd.Series") - - def __add__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(self.metric + other) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(self.metric + other.metric) - else: - return NotImplemented - - def __radd__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(other + self.metric) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(other.metric + self.metric) - else: - return NotImplemented - - def __sub__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(self.metric - other) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(self.metric - other.metric) - else: - return NotImplemented - - def __rsub__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(other - self.metric) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(other.metric - self.metric) - else: - return NotImplemented - - def __mul__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(self.metric * other) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(self.metric * other.metric) - else: - return NotImplemented - - def __truediv__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(self.metric / other) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(self.metric / other.metric) - else: - return NotImplemented - - def __eq__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(self.metric == other) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(self.metric == other.metric) - else: - return NotImplemented - - def __gt__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(self.metric < other) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(self.metric < other.metric) - else: - return NotImplemented - - def __lt__(self, other): - if isinstance(other, (int, float)): - return PandasOrderIndicator.SingleMetric(self.metric > other) - elif isinstance(other, PandasOrderIndicator.SingleMetric): - return PandasOrderIndicator.SingleMetric(self.metric > other.metric) - else: - return NotImplemented - - def __len__(self): - return len(self.metric) - - def sum(self): - return self.metric.sum() - - def mean(self): - return self.metric.mean() - - def count(self): - return self.metric.count() - - def abs(self): - return PandasOrderIndicator.SingleMetric(self.metric.abs()) - - def astype(self, type): - return PandasOrderIndicator.SingleMetric(self.metric.astype(type)) - - @property - def empty(self): - return self.metric.empty - - def add(self, other, fill_value: None): - return PandasOrderIndicator.SingleMetric(self.metric.add(other.metric, fill_value = fill_value)) - - def apply(self, map_dict: dict): - return PandasOrderIndicator.SingleMetric(self.metric.apply(map_dict)) - - def __init__(self): - self.data: Dict[str, self.SingleMetric] = OrderedDict() - - def assign(self, col: str, metric: Union[dict, pd.Series]): - self.data[col] = self.SingleMetric(metric) - - def transfer(self, func: "Callable", new_col: str = None): - func_sig = inspect.signature(func).parameters.keys() - func_kwargs = {sig: self.data[sig] for sig in func_sig} - tmp_metric = func(**func_kwargs) - if(new_col is not None): - self.data[new_col] = tmp_metric - return tmp_metric - - def get_metric_series(self, metric: str): - if(metric in self.data): - return self.data[metric].metric - else: - return pd.Series() - - @staticmethod - def agg_all_indicators(indicators: list, metrics: Union[str, List[str]], fill_value = None): - metric_dict = {} - if isinstance(metrics, str): - metrics = [metrics] - for metric in metrics: - tmp_metric = PandasOrderIndicator.SingleMetric({}) - for indicator in indicators: - tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) - metric_dict[metric] = tmp_metric.metric - return metric_dict \ No newline at end of file From 0ec6b87d39e9f8fcccc99abb9a5c904bd3e1c8b0 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 23 Jul 2021 05:50:41 +0000 Subject: [PATCH 134/187] fix little bug --- qlib/backtest/exchange.py | 2 +- qlib/backtest/high_performance_ds.py | 53 +++++++++++++++------------- qlib/backtest/report.py | 6 ++-- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index edcd7baaf..5677e855d 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -15,7 +15,7 @@ from ..config import C, REG_CN from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order, OrderDir, OrderHelper -from .high_performane_ds import PandasQuote +from .high_performance_ds import PandasQuote class Exchange: diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 3e5a9d8e2..8a908fbf0 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -25,6 +25,7 @@ class BaseQuote: Iterable all stock codes """ + raise NotImplementedError(f"Please implement the `get_all_stock` method") def get_data( @@ -119,76 +120,80 @@ class BaseSingleMetric: """ def __init__(self, metric: Union[dict, pd.Series]): - pass + raise NotImplementedError(f"Please implement the `__init__` method") def __add__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__add__` method") def __radd__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": return self + other def __sub__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__sub__` method") def __rsub__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__rsub__` method") def __mul__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__mul__` method") def __truediv__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__truediv__` method") def __eq__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__eq__` method") def __gt__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__gt__` method") def __lt__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `__lt__` method") def __len__(self) -> int: - pass + raise NotImplementedError(f"Please implement the `__len__` method") def sum(self) -> float: - pass + raise NotImplementedError(f"Please implement the `sum` method") def mean(self) -> float: - pass + raise NotImplementedError(f"Please implement the `mean` method") def count(self) -> int: - pass + """Return the count of the single metric, NaN is not included. + """ + + raise NotImplementedError(f"Please implement the `count` method") def abs(self) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `abs` method") def astype(self, type: type) -> "BaseSingleMetric": - pass + raise NotImplementedError(f"Please implement the `astype` method") @property def empty(self) -> bool: """If metric is empyt, return True.""" - pass + raise NotImplementedError(f"Please implement the `empty` method") def add(self, other: "BaseSingleMetric", fill_value: float = None) -> "BaseSingleMetric": """Replace np.NaN with fill_value in two metrics and add them.""" - pass + raise NotImplementedError(f"Please implement the `add` method") - def apply(self, map_dict: dict) -> "BaseSingleMetric": + def map(self, map_dict: dict) -> "BaseSingleMetric": """Replace the value of metric according to map_dict.""" - pass + raise NotImplementedError(f"Please implement the `map` method") class BaseOrderIndicator: """ The data structure of order indicator. !!!NOTE: There are two ways to organize the data structure. Please choose a better way. - 1. one way is use BaseSingleMetric to represent each metric. For example, the data - structure of PandasOrderIndicator is Dict[str: PandasSingleMetric]. It uses + 1. One way is using BaseSingleMetric to represent each metric. For example, the data + structure of PandasOrderIndicator is Dict[str, PandasSingleMetric]. It uses PandasSingleMetric based on pd.Series to represent each metric. - 2. the another way doesn't BaseSingleMetric to represent each metric. The data - structure of PandasOrderIndicator is a whole matrix. + 2. The another way doesn't use BaseSingleMetric to represent each metric. The data + structure of PandasOrderIndicator is a whole matrix. It means you are not neccesary + to inherit the BaseSingleMetric. """ def assign(self, col: str, metric: Union[dict, pd.Series]): @@ -367,7 +372,7 @@ class PandasSingleMetric: def add(self, other, fill_value=None): return PandasSingleMetric(self.metric.add(other.metric, fill_value=fill_value)) - def apply(self, map_dict: dict): + def map(self, map_dict: dict): return PandasSingleMetric(self.metric.apply(map_dict)) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 98d8b4f63..375100cba 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -6,8 +6,6 @@ from collections import OrderedDict from logging import warning import pathlib from typing import Dict, List, Tuple, Union, Callable -import warnings -import inspect import numpy as np import pandas as pd @@ -18,7 +16,7 @@ from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir from qlib.backtest.utils import TradeCalendarManager -from .high_performane_ds import PandasOrderIndicator +from .high_performance_ds import PandasOrderIndicator from ..data import D from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data @@ -329,7 +327,7 @@ class Indicator: self.order_indicator.transfer(func, "trade_price") def func_apply(trade_dir): - return trade_dir.apply(Order.parse_dir) + return trade_dir.map(Order.parse_dir) self.order_indicator.transfer(func_apply, "trade_dir") From a8ea66b83ea7345d12746a3da334ba070347ff8f Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 23 Jul 2021 09:33:04 +0000 Subject: [PATCH 135/187] black --- qlib/backtest/high_performance_ds.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 8a908fbf0..104be5b9c 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -159,8 +159,7 @@ class BaseSingleMetric: raise NotImplementedError(f"Please implement the `mean` method") def count(self) -> int: - """Return the count of the single metric, NaN is not included. - """ + """Return the count of the single metric, NaN is not included.""" raise NotImplementedError(f"Please implement the `count` method") From 9d732e964667aa39fa72cf0348d20f1a535f72f2 Mon Sep 17 00:00:00 2001 From: wangwenxi-handsome <77676340+wangwenxi-handsome@users.noreply.github.com> Date: Fri, 23 Jul 2021 18:25:24 +0800 Subject: [PATCH 136/187] Update Action --- .github/workflows/test.yml | 22 +++++++++++----------- .github/workflows/test_macos.yml | 8 ++++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a78d2d9a..490c06246 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,16 +56,16 @@ jobs: fi shell: bash - - name: Test workflow by config (install from pip) - run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe qlib\\workflow\\cli.py examples\\benchmarks\\LightGBM\\workflow_config_lightgbm_Alpha158.yaml - $CONDA\\python.exe -m pip uninstall -y pyqlib - else - $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml - sudo $CONDA/bin/python -m pip uninstall -y pyqlib - fi - shell: bash + # - name: Test workflow by config (install from pip) + # run: | + # if [ "$RUNNER_OS" == "Windows" ]; then + # $CONDA\\python.exe qlib\\workflow\\cli.py examples\\benchmarks\\LightGBM\\workflow_config_lightgbm_Alpha158.yaml + # $CONDA\\python.exe -m pip uninstall -y pyqlib + # else + # $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + # sudo $CONDA/bin/python -m pip uninstall -y pyqlib + # fi + # shell: bash # Test Qlib installed from source - name: Install Qlib from source @@ -111,4 +111,4 @@ jobs: else $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml fi - shell: bash \ No newline at end of file + shell: bash diff --git a/.github/workflows/test_macos.yml b/.github/workflows/test_macos.yml index 57aa87ded..5b34d84c2 100644 --- a/.github/workflows/test_macos.yml +++ b/.github/workflows/test_macos.yml @@ -44,10 +44,10 @@ jobs: run: | $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - - name: Test workflow by config (install from pip) - run: | - $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml - sudo $CONDA/bin/python -m pip uninstall -y pyqlib + # - name: Test workflow by config (install from pip) + # run: | + # $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + # sudo $CONDA/bin/python -m pip uninstall -y pyqlib # Test Qlib installed from source - name: Install Qlib from source From 6dcbf51298f8f5abf0c9d2a0ed90bba29626073d Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sat, 24 Jul 2021 11:36:12 +0000 Subject: [PATCH 137/187] update action --- .github/workflows/test.yml | 45 +++++++++++++++++++------------- .github/workflows/test_macos.yml | 18 ++++++++----- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 490c06246..29265b1eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,25 +36,25 @@ jobs: shell: bash # Test Qlib installed with pip - - name: Install Qlib with pip - run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe -m pip install numpy==1.19.5 - $CONDA\\python.exe -m pip install pyqlib --ignore-installed ruamel.yaml numpy --user - else - sudo $CONDA/bin/python -m pip install numpy==1.19.5 - sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy - fi - shell: bash + # - name: Install Qlib with pip + # run: | + # if [ "$RUNNER_OS" == "Windows" ]; then + # $CONDA\\python.exe -m pip install numpy==1.19.5 + # $CONDA\\python.exe -m pip install pyqlib --ignore-installed ruamel.yaml numpy --user + # else + # sudo $CONDA/bin/python -m pip install numpy==1.19.5 + # sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy + # fi + # shell: bash - - name: Test data downloads - run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - else - $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - fi - shell: bash + # - name: Test data downloads + # run: | + # if [ "$RUNNER_OS" == "Windows" ]; then + # $CONDA\\python.exe scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + # else + # $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + # fi + # shell: bash # - name: Test workflow by config (install from pip) # run: | @@ -83,6 +83,15 @@ jobs: fi shell: bash + - name: Test data downloads + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + $CONDA\\python.exe scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + else + $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + fi + shell: bash + - name: Install test dependencies run: | if [ "$RUNNER_OS" == "Windows" ]; then diff --git a/.github/workflows/test_macos.yml b/.github/workflows/test_macos.yml index 5b34d84c2..e52c27786 100644 --- a/.github/workflows/test_macos.yml +++ b/.github/workflows/test_macos.yml @@ -30,19 +30,19 @@ jobs: $CONDA/bin/python -m black qlib -l 120 --check --diff # Test Qlib installed with pip - - name: Install Qlib with pip - run: | - sudo $CONDA/bin/python -m pip install numpy==1.19.5 - sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy + # - name: Install Qlib with pip + # run: | + # sudo $CONDA/bin/python -m pip install numpy==1.19.5 + # sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy - name: Install Lightgbm for MacOS run: | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Microsoft/qlib/main/.github/brew_install.sh)" HOMEBREW_NO_AUTO_UPDATE=1 brew install lightgbm - - name: Test data downloads - run: | - $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + # - name: Test data downloads + # run: | + # $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn # - name: Test workflow by config (install from pip) # run: | @@ -57,6 +57,10 @@ jobs: sudo $CONDA/bin/python -m pip install -U scipy scikit-learn # installing without this line will cause errors on GitHub Actions, while instsalling locally won't sudo $CONDA/bin/python setup.py install + - name: Test data downloads + run: | + $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + - name: Install test dependencies run: | sudo $CONDA/bin/python -m pip install --upgrade pip From 4ffb05ae596468cff11f1ec70d0b7a2169a9617a Mon Sep 17 00:00:00 2001 From: wangwenxi-handsome <77676340+wangwenxi-handsome@users.noreply.github.com> Date: Fri, 23 Jul 2021 18:25:24 +0800 Subject: [PATCH 138/187] Update Action --- .github/workflows/test.yml | 67 ++++++++++++++++++-------------- .github/workflows/test_macos.yml | 26 +++++++------ 2 files changed, 53 insertions(+), 40 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a78d2d9a..29265b1eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,36 +36,36 @@ jobs: shell: bash # Test Qlib installed with pip - - name: Install Qlib with pip - run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe -m pip install numpy==1.19.5 - $CONDA\\python.exe -m pip install pyqlib --ignore-installed ruamel.yaml numpy --user - else - sudo $CONDA/bin/python -m pip install numpy==1.19.5 - sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy - fi - shell: bash + # - name: Install Qlib with pip + # run: | + # if [ "$RUNNER_OS" == "Windows" ]; then + # $CONDA\\python.exe -m pip install numpy==1.19.5 + # $CONDA\\python.exe -m pip install pyqlib --ignore-installed ruamel.yaml numpy --user + # else + # sudo $CONDA/bin/python -m pip install numpy==1.19.5 + # sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy + # fi + # shell: bash - - name: Test data downloads - run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - else - $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - fi - shell: bash + # - name: Test data downloads + # run: | + # if [ "$RUNNER_OS" == "Windows" ]; then + # $CONDA\\python.exe scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + # else + # $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + # fi + # shell: bash - - name: Test workflow by config (install from pip) - run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe qlib\\workflow\\cli.py examples\\benchmarks\\LightGBM\\workflow_config_lightgbm_Alpha158.yaml - $CONDA\\python.exe -m pip uninstall -y pyqlib - else - $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml - sudo $CONDA/bin/python -m pip uninstall -y pyqlib - fi - shell: bash + # - name: Test workflow by config (install from pip) + # run: | + # if [ "$RUNNER_OS" == "Windows" ]; then + # $CONDA\\python.exe qlib\\workflow\\cli.py examples\\benchmarks\\LightGBM\\workflow_config_lightgbm_Alpha158.yaml + # $CONDA\\python.exe -m pip uninstall -y pyqlib + # else + # $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + # sudo $CONDA/bin/python -m pip uninstall -y pyqlib + # fi + # shell: bash # Test Qlib installed from source - name: Install Qlib from source @@ -83,6 +83,15 @@ jobs: fi shell: bash + - name: Test data downloads + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + $CONDA\\python.exe scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + else + $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + fi + shell: bash + - name: Install test dependencies run: | if [ "$RUNNER_OS" == "Windows" ]; then @@ -111,4 +120,4 @@ jobs: else $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml fi - shell: bash \ No newline at end of file + shell: bash diff --git a/.github/workflows/test_macos.yml b/.github/workflows/test_macos.yml index 57aa87ded..e52c27786 100644 --- a/.github/workflows/test_macos.yml +++ b/.github/workflows/test_macos.yml @@ -30,24 +30,24 @@ jobs: $CONDA/bin/python -m black qlib -l 120 --check --diff # Test Qlib installed with pip - - name: Install Qlib with pip - run: | - sudo $CONDA/bin/python -m pip install numpy==1.19.5 - sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy + # - name: Install Qlib with pip + # run: | + # sudo $CONDA/bin/python -m pip install numpy==1.19.5 + # sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy - name: Install Lightgbm for MacOS run: | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Microsoft/qlib/main/.github/brew_install.sh)" HOMEBREW_NO_AUTO_UPDATE=1 brew install lightgbm - - name: Test data downloads - run: | - $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + # - name: Test data downloads + # run: | + # $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - - name: Test workflow by config (install from pip) - run: | - $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml - sudo $CONDA/bin/python -m pip uninstall -y pyqlib + # - name: Test workflow by config (install from pip) + # run: | + # $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + # sudo $CONDA/bin/python -m pip uninstall -y pyqlib # Test Qlib installed from source - name: Install Qlib from source @@ -57,6 +57,10 @@ jobs: sudo $CONDA/bin/python -m pip install -U scipy scikit-learn # installing without this line will cause errors on GitHub Actions, while instsalling locally won't sudo $CONDA/bin/python setup.py install + - name: Test data downloads + run: | + $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + - name: Install test dependencies run: | sudo $CONDA/bin/python -m pip install --upgrade pip From e88c45e13ceeafbf8761f068e6b1398265404f38 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sun, 25 Jul 2021 12:38:54 +0000 Subject: [PATCH 139/187] update position --- qlib/backtest/account.py | 14 ++++++++ qlib/backtest/position.py | 68 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 13213c344..03e51c740 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -73,6 +73,18 @@ class Account: pos_type: str = "Position", port_metr_enabled: bool = True, ): + """the trade account of backtest. + + Parameters + ---------- + init_cash : float, optional + initial cash, by default 1e9 + position_dict : Dict[stock_id, {"amount": int, "price"(optional): float}], optional + initial stocks with amount and price, + if there is no price key in the dict of stocks, it will be filled by latest close price from qlib. + by default {}. + """ + self._pos_type = pos_type self._port_metr_enabled = port_metr_enabled self.init_vars(init_cash, position_dict, freq, benchmark_config) @@ -93,6 +105,8 @@ class Account: "kwargs": { "cash": init_cash, "position_dict": position_dict, + "start_time": benchmark_config["start_time"], + "freq": freq, }, "module_path": "qlib.backtest.position", } diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 7c32edc81..92b66a342 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -4,10 +4,14 @@ import copy import pathlib -from typing import Dict, List +from typing import Dict, List, Union + import pandas as pd +from datetime import timedelta import numpy as np + from .order import Order +from ..data.data import D class BasePosition: @@ -199,14 +203,72 @@ class Position(BasePosition): } """ - def __init__(self, cash=0, position_dict={}): + def __init__(self, start_time, freq, cash: float = 0, position_dict: Dict[str, Dict[str, float]] = {}): + """Init position by cash and position_dict. + + Parameters + ---------- + start_time : + the start time of backtest. It's for filling the initial value of stocks. + cash : float, optional + initial cash in account, by default 0 + position_dict : Dict[stock_id, {"amount": int, "price"(optional): float}], optional + initial stocks with parameters amount and price, + if there is no price key in the dict of stocks, it will be filled by _fill_stock_value. + by default {}. + """ + # NOTE: The position dict must be copied!!! # Otherwise the initial value self.init_cash = cash - self.position = position_dict.copy() + self.position = self._fill_stock_value(position_dict.copy(), start_time, freq) self.position["cash"] = cash self.position["now_account_value"] = self.calculate_value() + def _fill_stock_value( + self, position_dict: dict, start_time: Union[str, pd.Timestamp], freq: str, last_days: int = 30 + ): + """fill the stock value by the close price of latest last_days from qlib. + + Parameters + ---------- + position_dict : Dict[stock_id, {"amount": int, "price": float}] + initial holding stocks. + start_time : + the start time of backtest. + last_days : int, optional + the days to get the latest close price, by default 30. + + Return + ---------- + Dict[stock_id, {"amount": int, "price": float}] + initial holding stocks with filled price. + """ + + stock_list = [] + for stock in position_dict: + if ("price" not in position_dict[stock]) or (position_dict[stock]["price"] is None): + stock_list.append(stock) + + if len(stock_list) == 0: + return position_dict + + start_time = pd.Timestamp(start_time) + # note that start time is 2020-01-01 00:00:00 if raw start time is "2020-01-01" + price_end_time = start_time + price_start_time = start_time - timedelta(days=last_days) + price_df = D.features( + stock_list, ["$close"], price_start_time, price_end_time, freq=freq, disk_cache=True + ).dropna() + price_dict = price_df.groupby(["instrument"]).tail(1).reset_index(level=1, drop=True)["$close"].to_dict() + + if len(price_dict) < len(stock_list): + raise ValueError(f"there is no close price in qlib") + + for stock in stock_list: + position_dict[stock]["price"] = price_dict[stock] + return position_dict + def _init_stock(self, stock_id, amount, price=None): """ initialization the stock in current position From bdebe12cf29ad7b7cad3b261e6a603a579f2d458 Mon Sep 17 00:00:00 2001 From: Young Date: Mon, 26 Jul 2021 06:14:57 +0000 Subject: [PATCH 140/187] support empty benchmark Empty benchmark could accelerate the learning process --- qlib/backtest/__init__.py | 3 ++- qlib/backtest/account.py | 21 +++++++++-------- qlib/backtest/exchange.py | 20 ++++++++-------- qlib/backtest/report.py | 5 ++-- qlib/contrib/strategy/rule_strategy.py | 32 ++++++++++++++------------ qlib/strategy/base.py | 3 ++- 6 files changed, 45 insertions(+), 39 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 19dbe87ce..dbfbd4a0e 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -8,9 +8,9 @@ from .account import Account if TYPE_CHECKING: from ..strategy.base import BaseStrategy + from .executor import BaseExecutor from .position import Position from .exchange import Exchange -from .executor import BaseExecutor from .backtest import backtest_loop from .backtest import collect_data_loop from .order import Order @@ -155,6 +155,7 @@ def get_strategy_executor( # - for avoiding recursive import # - typing annotations is not reliable from ..strategy.base import BaseStrategy + from .executor import BaseExecutor trade_account = create_account_instance( start_time=start_time, end_time=end_time, benchmark=benchmark, account=account, pos_type=pos_type diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 806f88a96..9b9a25c23 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -75,17 +75,7 @@ class Account: ): self._pos_type = pos_type self._port_metr_enabled = port_metr_enabled - self.init_vars(init_cash, position_dict, freq, benchmark_config) - def is_port_metr_enabled(self): - """ - Is portfolio-based metrics enabled. - """ - return self._port_metr_enabled and not self.current.skip_update() - - def init_vars(self, init_cash, position_dict, freq: str, benchmark_config: dict): - - # init cash self.init_cash = init_cash self.current: BasePosition = init_instance_by_config( { @@ -100,8 +90,19 @@ class Account: self.accum_info = AccumulatedInfo() self.report = None self.positions = {} + + # in of reset ignore None values + self.benchmark_config = benchmark_config + self.freq = freq + self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) + def is_port_metr_enabled(self): + """ + Is portfolio-based metrics enabled. + """ + return self._port_metr_enabled and not self.current.skip_update() + def reset_report(self, freq, benchmark_config): # portfolio related metrics if self.is_port_metr_enabled(): diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index a22754885..ea1d012eb 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -512,7 +512,7 @@ class Exchange: def _get_factor_or_raise_erorr(self, factor: float = None, stock_id: str = None, start_time=None, end_time=None): """Please refer to the docs of get_amount_of_trade_unit""" if factor is None: - if stock_id is not None and start_time is not None and end_time is not None : + if stock_id is not None and start_time is not None and end_time is not None: factor = self.get_factor(stock_id=stock_id, start_time=start_time, end_time=end_time) else: raise ValueError(f"`factor` and (`stock_id`, `start_time`, `end_time`) can't both be None") @@ -537,15 +537,16 @@ class Exchange: the end time of trading range """ if not self.trade_w_adj_price and self.trade_unit is not None: - factor = self._get_factor_or_raise_erorr(factor=factor, - stock_id=stock_id, - start_time=start_time, - end_time=end_time) + factor = self._get_factor_or_raise_erorr( + factor=factor, stock_id=stock_id, start_time=start_time, end_time=end_time + ) return self.trade_unit / factor else: return None - def round_amount_by_trade_unit(self, deal_amount, factor: float = None, stock_id: str = None, start_time=None, end_time=None): + def round_amount_by_trade_unit( + self, deal_amount, factor: float = None, stock_id: str = None, start_time=None, end_time=None + ): """Parameter Please refer to the docs of get_amount_of_trade_unit @@ -555,10 +556,9 @@ class Exchange: """ if not self.trade_w_adj_price and self.trade_unit is not None: # the minimal amount is 1. Add 0.1 for solving precision problem. - factor = self._get_factor_or_raise_erorr(factor=factor, - stock_id=stock_id, - start_time=start_time, - end_time=end_time) + factor = self._get_factor_or_raise_erorr( + factor=factor, stock_id=stock_id, start_time=start_time, end_time=end_time + ) return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 6b64bf3b1..84cae2568 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -80,11 +80,12 @@ class Report: def init_bench(self, freq=None, benchmark_config=None): if freq is not None: self.freq = freq - if benchmark_config is not None: - self.benchmark_config = benchmark_config + self.benchmark_config = benchmark_config self.bench = self._cal_benchmark(self.benchmark_config, self.freq) def _cal_benchmark(self, benchmark_config, freq): + if benchmark_config is None: + return None benchmark = benchmark_config.get("benchmark", CSI300_BENCH) if benchmark is None: return None diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 1ec054e45..b42c4f578 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -63,9 +63,9 @@ class TWAPStrategy(BaseStrategy): stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time ): continue - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( + stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) _order_amount = None # considering trade unit if _amount_trade_unit is None: @@ -169,9 +169,9 @@ class SBBStrategyBase(BaseStrategy): self.trade_trend[order.stock_id] = _pred_trend continue # get amount of one trade unit - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( + stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) if _pred_trend == self.TREND_MID: _order_amount = None # considering trade unit @@ -471,9 +471,9 @@ class ACStrategy(BaseStrategy): if sig_sam is None or np.isnan(sig_sam): # no signal, TWAP - _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit(stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( + stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) if _amount_trade_unit is None: # divide the order into equal parts, and trade one part _order_amount = self.trade_amount[order.stock_id] / (trade_len - trade_step) @@ -494,10 +494,9 @@ class ACStrategy(BaseStrategy): np.sinh(kappa * (trade_len - trade_step)) - np.sinh(kappa * (trade_len - trade_step - 1)) ) / np.sinh(kappa * trade_len) _order_amount = order.amount * amount_ratio - _order_amount = self.trade_exchange.round_amount_by_trade_unit(_order_amount, - stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time) + _order_amount = self.trade_exchange.round_amount_by_trade_unit( + _order_amount, stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time + ) if order.direction == order.SELL: # sell all amount at last @@ -584,8 +583,11 @@ class FileOrderStrategy(BaseStrategy): """ def __init__( - self, file: Union[IO, str, Path, pd.DataFrame], - trade_range: Union[Tuple[int, int], TradeRange] = None, *args, **kwargs + self, + file: Union[IO, str, Path, pd.DataFrame], + trade_range: Union[Tuple[int, int], TradeRange] = None, + *args, + **kwargs, ): """ diff --git a/qlib/strategy/base.py b/qlib/strategy/base.py index 7a267b511..c47d2494f 100644 --- a/qlib/strategy/base.py +++ b/qlib/strategy/base.py @@ -2,9 +2,10 @@ # Licensed under the MIT License. from __future__ import annotations from typing import TYPE_CHECKING + if TYPE_CHECKING: from qlib.backtest.exchange import Exchange -from qlib.backtest.position import BasePosition + from qlib.backtest.position import BasePosition from typing import List, Tuple, Union from ..model.base import BaseModel From c202a4b1e635e3bd342ac458ffae8e34e763876a Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Mon, 26 Jul 2021 11:21:05 +0000 Subject: [PATCH 141/187] fix _get_base_vol_pri clip_time_range --- qlib/backtest/report.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 5f8238504..6bc7cc379 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -21,6 +21,7 @@ from ..data import D from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data from ..utils.time import Freq +from .order import IdxTradeRange class Report: @@ -357,9 +358,11 @@ class Indicator: agg = pa_config.get("agg", "twap").lower() price = pa_config.get("price", "deal_price").lower() - # NOTE: IndexTradeRange is not supported!!!!! Because inner index is not available - trade_start_time, trade_end_time = decision.trade_range.clip_time_range( - start_time=trade_start_time, end_time=trade_end_time + if(decision.trade_range is not None): + if(isinstance(decision.trade_range, IdxTradeRange)): + raise TypeError(f"IdxTradeRange is not supported") + trade_start_time, trade_end_time = decision.trade_range.clip_time_range( + start_time=trade_start_time, end_time=trade_end_time ) if price == "deal_price": From 4924717276a48ed9b1e36bee8d48d50649dba02f Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Mon, 26 Jul 2021 11:25:14 +0000 Subject: [PATCH 142/187] fix black --- qlib/backtest/report.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 6bc7cc379..95048ba84 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -358,12 +358,12 @@ class Indicator: agg = pa_config.get("agg", "twap").lower() price = pa_config.get("price", "deal_price").lower() - if(decision.trade_range is not None): - if(isinstance(decision.trade_range, IdxTradeRange)): + if decision.trade_range is not None: + if isinstance(decision.trade_range, IdxTradeRange): raise TypeError(f"IdxTradeRange is not supported") trade_start_time, trade_end_time = decision.trade_range.clip_time_range( start_time=trade_start_time, end_time=trade_end_time - ) + ) if price == "deal_price": price_s = trade_exchange.get_deal_price( From fcca242807c18c4ec5053876232145a6234c9b78 Mon Sep 17 00:00:00 2001 From: Young Date: Mon, 26 Jul 2021 17:05:33 +0000 Subject: [PATCH 143/187] add cash settlement mechanism --- qlib/backtest/__init__.py | 2 +- qlib/backtest/account.py | 15 ++- qlib/backtest/exchange.py | 39 ++++--- qlib/backtest/executor.py | 63 ++++++------ qlib/backtest/order.py | 10 +- qlib/backtest/position.py | 134 ++++++++++++++----------- qlib/contrib/strategy/rule_strategy.py | 20 +++- qlib/utils/exceptions.py | 7 +- 8 files changed, 170 insertions(+), 120 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 23b8ec9c5..a97841da7 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -184,7 +184,7 @@ def backtest( exchange_kwargs={}, pos_type: str = "Position", ): - """initialize the strategy and executor, then backtest funciton for the interaction of the outermost strategy and executor in the nested decision execution + """initialize the strategy and executor, then backtest function for the interaction of the outermost strategy and executor in the nested decision execution Parameters ---------- diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 03e51c740..773e1a037 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -1,9 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - - +from __future__ import annotations import copy -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, TYPE_CHECKING from qlib.utils import init_instance_by_config import warnings import pandas as pd @@ -11,7 +10,9 @@ import pandas as pd from .position import BasePosition, InfPosition, Position from .report import Report, Indicator from .order import BaseTradeDecision, Order -from .exchange import Exchange + +if TYPE_CHECKING: + from .exchange import Exchange """ rtn & earning in the Account @@ -105,8 +106,6 @@ class Account: "kwargs": { "cash": init_cash, "position_dict": position_dict, - "start_time": benchmark_config["start_time"], - "freq": freq, }, "module_path": "qlib.backtest.position", } @@ -122,7 +121,7 @@ class Account: self.report = Report(freq, benchmark_config) self.positions = {} - # trading related matric(e.g. high-frequency trading) + # trading related metrics(e.g. high-frequency trading) self.indicator = Indicator() def reset(self, freq=None, benchmark_config=None, init_report=False, port_metr_enabled: bool = None): @@ -302,7 +301,7 @@ class Account: if atomic is True and trade_info is None: raise ValueError("trade_info is necessary in atomic executor") elif atomic is False and inner_order_indicators is None: - raise ValueError("inner_order_indicators is necessary in unatomic executor") + raise ValueError("inner_order_indicators is necessary in un-atomic executor") # TODO: `update_bar_count` and `update_current` should placed in Position and be merged. self.update_bar_count() diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index e73510743..9044179e0 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -1,8 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from __future__ import annotations +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .account import Account -from qlib.backtest.position import Position +from qlib.backtest.position import BasePosition, Position import random import logging from typing import List, Tuple, Union, Callable, Iterable @@ -278,7 +282,7 @@ class Exchange: else: return True - def deal_order(self, order, trade_account=None, position=None): + def deal_order(self, order, trade_account: Account = None, position: BasePosition = None): """ Deal order when the actual transaction @@ -289,13 +293,12 @@ class Exchange: :param position: position to be updated after dealing the order. :return: trade_val, trade_cost, trade_price """ - # need to check order first - # TODO: check the order unit limit in the exchange!!!! - # The order limit is related to the adj factor and the cur_amount. - # factor = self.quote[(order.stock_id, order.trade_date)]['$factor'] - # cur_amount = trade_account.current.get_stock_amount(order.stock_id) + # check order first. if self.check_order(order) is False: - raise AttributeError("need to check order first") + order.deal_amount = 0.0 + # using np.nan instead of None to make it more convenient to should the value in format string + return 0.0, 0.0, np.nan + if trade_account is not None and position is not None: raise ValueError("trade_account and position can only choose one") @@ -304,14 +307,18 @@ class Exchange: trade_val, trade_cost = self._calc_trade_info_by_order( order, trade_account.current if trade_account else position ) - # update account if order.deal_amount > 1e-5: - # If the order can only be deal 0 aomount. Nothing to be updated - # Otherwise, it will result some stock with 0 amount in the position + # If the order can only be deal 0 amount. Nothing to be updated + # Otherwise, it will result in + # 1) some stock with 0 amount in the position + # 2) `trade_unit` of trade_cost will be lost in user account if trade_account: trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) elif position: position.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) + else: + # if dealing is not successful, the trade_cost should be zero + trade_cost = 0 return trade_val, trade_cost, trade_price @@ -346,7 +353,7 @@ class Exchange: `None`: if the stock is suspended `None` may be returned `float`: return factor if the factor exists """ - assert (start_time is not None and end_time is not None, "the time range must be given") + assert start_time is not None and end_time is not None, "the time range must be given" if stock_id not in self.quote.get_all_stock(): return None return self.quote.get_data(stock_id, start_time, end_time, fields="$factor", method=ts_data_last) @@ -509,7 +516,7 @@ class Exchange: ) return value - def _get_factor_or_raise_erorr(self, factor: float = None, stock_id: str = None, start_time=None, end_time=None): + def _get_factor_or_raise_error(self, factor: float = None, stock_id: str = None, start_time=None, end_time=None): """Please refer to the docs of get_amount_of_trade_unit""" if factor is None: if stock_id is not None and start_time is not None and end_time is not None: @@ -537,7 +544,7 @@ class Exchange: the end time of trading range """ if not self.trade_w_adj_price and self.trade_unit is not None: - factor = self._get_factor_or_raise_erorr( + factor = self._get_factor_or_raise_error( factor=factor, stock_id=stock_id, start_time=start_time, end_time=end_time ) return self.trade_unit / factor @@ -556,7 +563,7 @@ class Exchange: """ if not self.trade_w_adj_price and self.trade_unit is not None: # the minimal amount is 1. Add 0.1 for solving precision problem. - factor = self._get_factor_or_raise_erorr( + factor = self._get_factor_or_raise_error( factor=factor, stock_id=stock_id, start_time=start_time, end_time=end_time ) return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor @@ -626,7 +633,7 @@ class Exchange: order.stock_id, order.start_time, order.end_time, order.deal_amount ) trade_val = order.deal_amount * trade_price - trade_cost = trade_val * self.open_cost + trade_cost = max(trade_val * self.open_cost, self.min_cost) else: raise NotImplementedError("order type {} error".format(order.type)) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index b05b73801..89f5a2c4a 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -1,5 +1,6 @@ from abc import abstractclassmethod, abstractmethod import copy +from qlib.backtest.position import BasePosition from qlib.log import get_module_logger from types import GeneratorType from qlib.backtest.account import Account @@ -32,6 +33,7 @@ class BaseExecutor: track_data: bool = False, trade_exchange: Exchange = None, common_infra: CommonInfrastructure = None, + settle_type=BasePosition.ST_NO, **kwargs, ): """ @@ -95,6 +97,8 @@ class BaseExecutor: - trade_exchange : Exchange, optional exchange that provides market info + settle_type : str + Please refer to the docs of BasePosition.settle_start """ self.time_per_step = time_per_step self.indicator_config = indicator_config @@ -104,6 +108,7 @@ class BaseExecutor: self._trade_exchange = trade_exchange self.level_infra = LevelInfrastructure() self.level_infra.reset_infra(common_infra=common_infra) + self._settle_type = settle_type self.reset(start_time=start_time, end_time=end_time, common_infra=common_infra) if common_infra is None: get_module_logger("BaseExecutor").warning(f"`common_infra` is not set for {self}") @@ -235,6 +240,9 @@ class BaseExecutor: if atomic and trade_decision.get_range_limit(default_value=None) is not None: raise ValueError("atomic executor doesn't support specify `range_limit`") + if self._settle_type != BasePosition.ST_NO: + self.trade_account.current.settle_start(self._settle_type) + obj = self._collect_data(trade_decision=trade_decision, level=level) if isinstance(obj, GeneratorType): @@ -256,6 +264,10 @@ class BaseExecutor: ) self.trade_calendar.step() + + if self._settle_type != BasePosition.ST_NO: + self.trade_account.current.settle_commit() + if return_value is not None: return_value.update({"execute_result": res}) return res @@ -366,7 +378,7 @@ class NestedExecutor(BaseExecutor): trade_decision = self._update_trade_decision(trade_decision) if trade_decision.empty() and self._skip_empty_decision: - # give one chance for outer stategy to update the strategy + # give one chance for outer strategy to update the strategy # - For updating some information in the sub executor(the strategy have no knowledge of the inner # executor when generating the decision) break @@ -409,6 +421,9 @@ class NestedExecutor(BaseExecutor): class SimulatorExecutor(BaseExecutor): """Executor that simulate the true market""" + # TODO: TT_SERIAL & TT_PARAL will be replaced by feature fix_pos now. + # Please remove them in the future. + # available trade_types TT_SERIAL = "serial" ## The orders will be executed serially in a sequence @@ -486,34 +501,22 @@ class SimulatorExecutor(BaseExecutor): execute_result = [] for order in self._get_order_iterator(trade_decision): - if self.trade_exchange.check_order(order) is True: - # execute the order. - # NOTE: The trade_account will be changed in this function - trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( - order, trade_account=self.trade_account - ) - execute_result.append((order, trade_val, trade_cost, trade_price)) - if self.verbose: - if order.direction == Order.SELL: # sell - action = "sell" - else: - action = "buy" - print( - "[I {:%Y-%m-%d %H:%M:%S}]: {} {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}, cach {:.2f}.".format( - trade_start_time, - action, - order.stock_id, - trade_price, - order.amount, - order.deal_amount, - order.factor, - trade_val, - self.trade_account.get_cash(), - ) + # execute the order. + # NOTE: The trade_account will be changed in this function + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(order, trade_account=self.trade_account) + execute_result.append((order, trade_val, trade_cost, trade_price)) + if self.verbose: + print( + "[I {:%Y-%m-%d %H:%M:%S}]: {} {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}, cash {:.2f}.".format( + trade_start_time, + "sell" if order.direction == Order.SELL else "buy", + order.stock_id, + trade_price, + order.amount, + order.deal_amount, + order.factor, + trade_val, + self.trade_account.get_cash(), ) - else: - if self.verbose: - print("[W {:%Y-%m-%d %H:%M:%S}]: {} wrong.".format(trade_start_time, order.stock_id)) - # do nothing - pass + ) return execute_result, {"trade_info": execute_result} diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index b99cdb8e3..bb615dc06 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -58,12 +58,19 @@ class Order: # 3) results # - users should not care about these values # - they are set by the backtest system after finishing the results. + # What the value should be about in all kinds of cases + # - not tradable: the deal_amount == 0 , factor is None + # - the stock is suspended and the entire order fails. No cost for this order + # - dealed or partially dealed: deal_amount >= 0 and factor is not None deal_amount: float = field(init=False) # `deal_amount` is a non-negative value factor: float = field(init=False) + # TODO: + # a status field to indicate the dealing result of the order + # FIXME: # for compatible now. - # Plese remove them in the future + # Please remove them in the future SELL: ClassVar[OrderDir] = OrderDir.SELL BUY: ClassVar[OrderDir] = OrderDir.BUY @@ -71,6 +78,7 @@ class Order: if self.direction not in {Order.SELL, Order.BUY}: raise NotImplementedError("direction not supported, `Order.SELL` for sell, `Order.BUY` for buy") self.deal_amount = 0 + self.factor = None @property def amount_delta(self) -> float: diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 92b66a342..e4f1ab40c 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -20,8 +20,8 @@ class BasePosition: Please refer to the `Position` class for the position """ - def __init__(self, cash=0.0, *args, **kwargs) -> None: - pass + def __init__(self, cash=0.0, *args, **kwargs): + self._settle_type = self.ST_NO def skip_update(self) -> bool: """ @@ -124,13 +124,16 @@ class BasePosition: """ raise NotImplementedError(f"Please implement the `get_stock_amount` method") - def get_cash(self) -> float: + def get_cash(self, include_settle: bool = False) -> float: """ Returns ------- float: - the cash in position + the available(tradable) cash in position + include_settle: + will the unsettled(delayed) cash included + Default: not include those unavailable cash """ raise NotImplementedError(f"Please implement the `get_cash` method") @@ -188,6 +191,37 @@ class BasePosition: """ raise NotImplementedError(f"Please implement the `add_count_all` method") + ST_CASH = "cash" + ST_NO = None + + def settle_start(self, settle_type: str): + """ + settlement start + It will act like start and commit a transaction + + Parameters + ---------- + settle_type : str + Should we make delay the settlement in each execution (each execution will make the executor a step forward) + - "cash": make the cash settlement delayed. + - The cash you get can't be used in current step (e.g. you can't sell a stock to get cash to buy another + stock) + - None: not settlement mechanism + - TODO: other assets will be supported in the future. + """ + raise NotImplementedError(f"Please implement the `settle_conf` method") + + def settle_commit(self): + """ + settlement commit + + Parameters + ---------- + settle_type : str + please refer to the documents of Executor + """ + raise NotImplementedError(f"Please implement the `settle_commit` method") + class Position(BasePosition): """Position @@ -203,7 +237,7 @@ class Position(BasePosition): } """ - def __init__(self, start_time, freq, cash: float = 0, position_dict: Dict[str, Dict[str, float]] = {}): + def __init__(self, cash: float = 0, position_dict: Dict[str, Dict[str, float]] = {}): """Init position by cash and position_dict. Parameters @@ -217,11 +251,12 @@ class Position(BasePosition): if there is no price key in the dict of stocks, it will be filled by _fill_stock_value. by default {}. """ + super().__init__() # NOTE: The position dict must be copied!!! # Otherwise the initial value self.init_cash = cash - self.position = self._fill_stock_value(position_dict.copy(), start_time, freq) + self.position = position_dict.copy() self.position["cash"] = cash self.position["now_account_value"] = self.calculate_value() @@ -312,7 +347,13 @@ class Position(BasePosition): elif abs(self.position[stock_id]["amount"]) <= 1e-5: self._del_stock(stock_id) - self.position["cash"] += trade_val - cost + new_cash = trade_val - cost + if self._settle_type == self.ST_CASH: + self.position["cash_delay"] += new_cash + elif self._settle_type == self.ST_NO: + self.position["cash"] += new_cash + else: + raise NotImplementedError(f"This type of input is not supported") def _del_stock(self, stock_id): del self.position[stock_id] @@ -340,9 +381,6 @@ class Position(BasePosition): def update_stock_weight(self, stock_id, weight): self.position[stock_id]["weight"] = weight - def update_cash(self, cash): - self.position["cash"] = cash - def calculate_stock_value(self): stock_list = self.get_stock_list() value = 0 @@ -352,11 +390,11 @@ class Position(BasePosition): def calculate_value(self): value = self.calculate_stock_value() - value += self.position["cash"] + value += self.position["cash"] + self.position.get("cash_delay", 0.0) return value def get_stock_list(self): - stock_list = list(set(self.position.keys()) - {"cash", "now_account_value"}) + stock_list = list(set(self.position.keys()) - {"cash", "now_account_value", "cash_delay"}) return stock_list def get_stock_price(self, code): @@ -375,8 +413,11 @@ class Position(BasePosition): def get_stock_weight(self, code): return self.position[code]["weight"] - def get_cash(self): - return self.position["cash"] + def get_cash(self, include_settle=False): + cash = self.position["cash"] + if include_settle: + cash += self.position.get("cash_delay", 0.0) + return cash def get_stock_amount_dict(self): """generate stock amount dict {stock_id : amount of stock}""" @@ -388,7 +429,7 @@ class Position(BasePosition): def get_stock_weight_dict(self, only_stock=False): """get_stock_weight_dict - generate stock weight fict {stock_id : value weight of stock in the position} + generate stock weight dict {stock_id : value weight of stock in the position} it is meaningful in the beginning or the end of each trade date :param only_stock: If only_stock=True, the weight of each stock in total stock will be returned @@ -417,49 +458,20 @@ class Position(BasePosition): for stock_code, weight in weight_dict.items(): self.update_stock_weight(stock_code, weight) - def save_position(self, path): - path = pathlib.Path(path) - p = copy.deepcopy(self.position) - cash = pd.Series(dtype=float) - cash["init_cash"] = self.init_cash - cash["cash"] = p["cash"] - cash["now_account_value"] = p["now_account_value"] - del p["cash"] - del p["now_account_value"] - positions = pd.DataFrame.from_dict(p, orient="index") - with pd.ExcelWriter(path) as writer: - positions.to_excel(writer, sheet_name="position") - cash.to_excel(writer, sheet_name="info") + def settle_start(self, settle_type): + assert self._settle_type == self.ST_NO, "Currently, settlement can't be nested!!!!!" + self._settle_type = settle_type + if settle_type == self.ST_CASH: + self.position["cash_delay"] = 0.0 - def load_position(self, path): - """load position information from a file - should have format below - sheet "position" - columns: ['stock', f'count_{bar}', 'amount', 'price', 'weight'] - f'count_{bar}': , - 'amount': , - 'price': , - 'weight': , - - sheet "cash" - index: ['init_cash', 'cash', 'now_account_value'] - 'init_cash': , - 'cash': , - 'now_account_value': - """ - path = pathlib.Path(path) - positions = pd.read_excel(open(path, "rb"), sheet_name="position", index_col=0) - cash_record = pd.read_excel(open(path, "rb"), sheet_name="info", index_col=0) - positions = positions.to_dict(orient="index") - init_cash = cash_record.loc["init_cash"].values[0] - cash = cash_record.loc["cash"].values[0] - now_account_value = cash_record.loc["now_account_value"].values[0] - # assign values - self.position = {} - self.init_cash = init_cash - self.position = positions - self.position["cash"] = cash - self.position["now_account_value"] = now_account_value + def settle_commit(self): + if self._settle_type != self.ST_NO: + if self._settle_type == self.ST_CASH: + self.position["cash"] += self.position["cash_delay"] + del self.position["cash_delay"] + else: + raise NotImplementedError(f"This type of input is not supported") + self._settle_type = self.ST_NO class InfPosition(BasePosition): @@ -502,7 +514,7 @@ class InfPosition(BasePosition): def get_stock_amount(self, code) -> float: return np.inf - def get_cash(self) -> float: + def get_cash(self, include_settle=False) -> float: return np.inf def get_stock_amount_dict(self) -> Dict: @@ -516,3 +528,9 @@ class InfPosition(BasePosition): def update_weight_all(self): raise NotImplementedError(f"InfPosition doesn't support update_weight_all") + + def settle_start(self, settle_type: str): + pass + + def settle_commit(self): + pass diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 36059f5a0..57ca005ff 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -18,7 +18,12 @@ from qlib.backtest.utils import get_start_end_idx class TWAPStrategy(BaseStrategy): - """TWAP Strategy for trading""" + """TWAP Strategy for trading + + NOTE: + - This TWAP strategy will celling round when trading. This will make the TWAP trading strategy produce the order + ealier when the total trade unit of amount is less than the trading step + """ def reset(self, outer_trade_decision: BaseTradeDecision = None, **kwargs): """ @@ -583,7 +588,11 @@ class FileOrderStrategy(BaseStrategy): """ def __init__( - self, file: Union[IO, str, Path], trade_range: Union[Tuple[int, int], TradeRange] = None, *args, **kwargs + self, + file: Union[IO, str, Path, pd.DataFrame], + trade_range: Union[Tuple[int, int], TradeRange] = None, + *args, + **kwargs, ): """ @@ -611,8 +620,11 @@ class FileOrderStrategy(BaseStrategy): """ super().__init__(*args, **kwargs) - with get_io_object(file) as f: - self.order_df = pd.read_csv(f, dtype={"datetime": np.str}) + if isinstance(file, pd.DataFrame): + self.order_df = file + else: + with get_io_object(file) as f: + self.order_df = pd.read_csv(f, dtype={"datetime": np.str}) self.order_df["datetime"] = self.order_df["datetime"].apply(pd.Timestamp) self.order_df = self.order_df.set_index(["datetime", "instrument"]) diff --git a/qlib/utils/exceptions.py b/qlib/utils/exceptions.py index dad12506b..dd9b3eaf6 100644 --- a/qlib/utils/exceptions.py +++ b/qlib/utils/exceptions.py @@ -1,17 +1,20 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + # Base exception class class QlibException(Exception): def __init__(self, message): super(QlibException, self).__init__(message) -# Error type for reinitialization when starting an experiment class RecorderInitializationError(QlibException): + """Error type for re-initialization when starting an experiment""" + pass -# Error type for Recorder when can not load object class LoadObjectError(QlibException): + """Error type for Recorder when can not load object""" + pass From 66971d5f0ddc596083e69559e7373ca750c294ab Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 27 Jul 2021 09:06:13 +0000 Subject: [PATCH 144/187] fix indicator --- qlib/backtest/high_performance_ds.py | 30 ++++++++++++++++++++++------ qlib/backtest/report.py | 8 +++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 104be5b9c..d556f303c 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -172,15 +172,25 @@ class BaseSingleMetric: @property def empty(self) -> bool: """If metric is empyt, return True.""" + raise NotImplementedError(f"Please implement the `empty` method") def add(self, other: "BaseSingleMetric", fill_value: float = None) -> "BaseSingleMetric": """Replace np.NaN with fill_value in two metrics and add them.""" + raise NotImplementedError(f"Please implement the `add` method") - def map(self, map_dict: dict) -> "BaseSingleMetric": - """Replace the value of metric according to map_dict.""" - raise NotImplementedError(f"Please implement the `map` method") + def replace(self, replace_dict: dict) -> "BaseSingleMetric": + """Replace the value of metric according to replace_dict.""" + + raise NotImplementedError(f"Please implement the `replace` method") + + def apply(self, func: dict) -> "BaseSingleMetric": + """Replace the value of metric with func(metric). + Currently, the func is only qlib/backtest/order/Order.parse_dir. + """ + + raise NotImplementedError(f"Please implement the 'apply' method") class BaseOrderIndicator: @@ -371,8 +381,11 @@ class PandasSingleMetric: def add(self, other, fill_value=None): return PandasSingleMetric(self.metric.add(other.metric, fill_value=fill_value)) - def map(self, map_dict: dict): - return PandasSingleMetric(self.metric.apply(map_dict)) + def replace(self, replace_dict: dict): + return PandasSingleMetric(self.metric.replace(replace_dict)) + + def apply(self, func: Callable): + return PandasSingleMetric(self.metric.apply(func)) class PandasOrderIndicator(BaseOrderIndicator): @@ -413,6 +426,11 @@ class PandasOrderIndicator(BaseOrderIndicator): for metric in metrics: tmp_metric = PandasSingleMetric({}) for indicator in indicators: - tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) + if(metric == "trade_price"): + tmp_metric = tmp_metric.add( + indicator.data["trade_price"] * indicator.data["deal_amount"], fill_value + ) + else: + tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) metric_dict[metric] = tmp_metric.metric return metric_dict diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 95048ba84..64d00b436 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -308,7 +308,8 @@ class Indicator: def _update_order_fulfill_rate(self): def func(deal_amount, amount): - return deal_amount / amount + tmp_deal_amount = deal_amount.replace({np.NaN: 0}) + return deal_amount / tmp_deal_amount self.order_indicator.transfer(func, "ffr") @@ -323,12 +324,13 @@ class Indicator: self.order_indicator.assign(metric, metric_dict[metric]) def func(trade_price, deal_amount): - return trade_price / deal_amount + tmp_deal_amount = deal_amount.replace({0: np.NaN}) + return trade_price / tmp_deal_amount self.order_indicator.transfer(func, "trade_price") def func_apply(trade_dir): - return trade_dir.map(Order.parse_dir) + return trade_dir.apply(Order.parse_dir) self.order_indicator.transfer(func_apply, "trade_dir") From ba1c575aa9e79b3e86f4d9b9a56bd298d88fbb0a Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 27 Jul 2021 12:14:43 +0000 Subject: [PATCH 145/187] doc and black for indicator --- qlib/backtest/high_performance_ds.py | 17 ++++++----------- qlib/backtest/report.py | 12 +++++++++++- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index d556f303c..123725832 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -82,7 +82,7 @@ class BaseQuote: the columns of data to fetch method : Union[str, Callable] the method apply to data. - e.g ["None", "last", "all", "sum", "mean", "any", qlib/utils/resam.py/ts_data_last] + e.g [None, "last", "all", "sum", "mean", "any", qlib/utils/resam.py/ts_data_last] Return ---------- @@ -177,19 +177,19 @@ class BaseSingleMetric: def add(self, other: "BaseSingleMetric", fill_value: float = None) -> "BaseSingleMetric": """Replace np.NaN with fill_value in two metrics and add them.""" - + raise NotImplementedError(f"Please implement the `add` method") def replace(self, replace_dict: dict) -> "BaseSingleMetric": """Replace the value of metric according to replace_dict.""" - + raise NotImplementedError(f"Please implement the `replace` method") def apply(self, func: dict) -> "BaseSingleMetric": """Replace the value of metric with func(metric). - Currently, the func is only qlib/backtest/order/Order.parse_dir. + Currently, the func is only qlib/backtest/order/Order.parse_dir. """ - + raise NotImplementedError(f"Please implement the 'apply' method") @@ -426,11 +426,6 @@ class PandasOrderIndicator(BaseOrderIndicator): for metric in metrics: tmp_metric = PandasSingleMetric({}) for indicator in indicators: - if(metric == "trade_price"): - tmp_metric = tmp_metric.add( - indicator.data["trade_price"] * indicator.data["deal_amount"], fill_value - ) - else: - tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) + tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) metric_dict[metric] = tmp_metric.metric return metric_dict diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 64d00b436..e37642244 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -308,8 +308,9 @@ class Indicator: def _update_order_fulfill_rate(self): def func(deal_amount, amount): + # deal_amount is np.NaN when there is no inner decision. So full fill rate is 0. tmp_deal_amount = deal_amount.replace({np.NaN: 0}) - return deal_amount / tmp_deal_amount + return tmp_deal_amount / amount self.order_indicator.transfer(func, "ffr") @@ -318,12 +319,21 @@ class Indicator: self._update_order_fulfill_rate() def _agg_order_trade_info(self, inner_order_indicators: List[Dict[str, pd.Series]]): + # calculate total trade amount with each inner order indicator. + def trade_amount_func(deal_amount, trade_price): + return deal_amount * trade_price + + for indicator in inner_order_indicators: + indicator.transfer(trade_amount_func, "trade_price") + + # sum inner order indicators with same metric. all_metric = ["inner_amount", "deal_amount", "trade_price", "trade_value", "trade_cost", "trade_dir"] metric_dict = self.order_indicator_cls.sum_all_indicators(inner_order_indicators, all_metric, fill_value=0) for metric in metric_dict: self.order_indicator.assign(metric, metric_dict[metric]) def func(trade_price, deal_amount): + # trade_price is np.NaN instead of inf when deal_amount is zero. tmp_deal_amount = deal_amount.replace({0: np.NaN}) return trade_price / tmp_deal_amount From 0d41ca26ab5fc7f70ed435214312a0730525a129 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 27 Jul 2021 14:16:18 +0000 Subject: [PATCH 146/187] fix data format bug & twap peeking strategy --- qlib/backtest/executor.py | 2 +- qlib/backtest/high_performance_ds.py | 20 +++++++++++++++++++- qlib/backtest/report.py | 10 ++++++---- qlib/contrib/strategy/rule_strategy.py | 10 +++++----- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 89f5a2c4a..0121a904e 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -405,7 +405,7 @@ class NestedExecutor(BaseExecutor): execute_result.extend(_inner_execute_result) inner_order_indicators.append( - self.inner_executor.trade_account.get_trade_indicator().get_order_indicator() + self.inner_executor.trade_account.get_trade_indicator().get_order_indicator(raw=True) ) else: # do nothing and just step forward diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 123725832..c60d3f97e 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -3,7 +3,7 @@ import logging -from typing import List, Tuple, Union, Callable, Iterable, Dict +from typing import List, Text, Tuple, Union, Callable, Iterable, Dict from collections import OrderedDict import inspect @@ -280,6 +280,21 @@ class BaseOrderIndicator: pass + def to_series(self) -> Dict[Text, pd.Series]: + """return the metrics as pandas series + + for example: { "ffr": + SH600068 NaN + SH600079 1.0 + SH600266 NaN + ... + SZ300692 NaN + SZ300719 NaN, + ... + } + """ + raise NotImplementedError(f"Please implement the `to_series` method") + class PandasSingleMetric: """Each SingleMetric is based on pd.Series.""" @@ -429,3 +444,6 @@ class PandasOrderIndicator(BaseOrderIndicator): tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) metric_dict[metric] = tmp_metric.metric return metric_dict + + def to_series(self): + return {k: v.metric for k, v in self.data.items()} diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index e37642244..fb1eeedfa 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -274,8 +274,8 @@ class Indicator: # self._trade_calendar = trade_calendar def record(self, trade_start_time): - self.order_indicator_his[trade_start_time] = self.order_indicator.data - self.trade_indicator_his[trade_start_time] = self.trade_indicator + self.order_indicator_his[trade_start_time] = self.get_order_indicator() + self.trade_indicator_his[trade_start_time] = self.get_trade_indicator() def _update_order_trade_info(self, trade_info: list): amount = dict() @@ -587,8 +587,10 @@ class Indicator: ) ) - def get_order_indicator(self): - return self.order_indicator + def get_order_indicator(self, raw: bool = False): + if raw: + return self.order_indicator + return self.order_indicator.to_series() def get_trade_indicator(self): return self.trade_indicator diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 57ca005ff..eabbe357b 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -63,11 +63,11 @@ class TWAPStrategy(BaseStrategy): trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) order_list = [] for order in self.outer_trade_decision.get_decision(): - # if not tradable, continue - if not self.trade_exchange.is_stock_tradable( - stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time - ): - continue + # Don't peek the future information + # if not self.trade_exchange.is_stock_tradable( + # stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time + # ): + # continue _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time ) From e817413769c648a7cd6e9a902f9de568b3c08a5c Mon Sep 17 00:00:00 2001 From: v-mingzhehan Date: Tue, 27 Jul 2021 14:52:29 +0000 Subject: [PATCH 147/187] Restore examples --- .../nested_decision_execution/assets/orders | Bin 3464 -> 0 bytes .../requirements.txt | 2 - .../nested_decision_execution/rl_dummy.py | 586 ------------------ .../nested_decision_execution/workflow.py | 11 +- 4 files changed, 2 insertions(+), 597 deletions(-) delete mode 100644 examples/nested_decision_execution/assets/orders delete mode 100644 examples/nested_decision_execution/requirements.txt delete mode 100644 examples/nested_decision_execution/rl_dummy.py diff --git a/examples/nested_decision_execution/assets/orders b/examples/nested_decision_execution/assets/orders deleted file mode 100644 index 7902b901c000bfd82fb7fcc0386c588f3f78cbb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3464 zcmai$eM}Q~7{^;->?j+k)cJzoL`)bn&F}ioCT}CMX-(Nc=HkTe`a-UQzU+G4Bus`Q zI>BQSJ%TO}L`B0UZjDnjmStp_EHR5j&`k+(i2|Dw#>|9GmWBPU9rq*4-LLmYFYP_~ z-1m8&@9%k97u&MuNk#Z7=QFwFx2oKBjh%8-vaSHD@i9&p!*h=nhwn%DXZG@YU=$Hx zeU3_-+sSi8=}SfcOhCtTag@gw^s+p+%p3Iht2GWE zVH$dr2E9}wiP)^8GkM_zf_6G4Qc*e%7IFswTD`%@(*z_fDI|2)$?me_$pzt9dbveG zub@-uH2P_Jtwl+v!=Uwrv709+a%EvAg9!UZb-%Y|UHDQ}%7Dpe7Gb78?v?ND zOvJ;c2G2?=6Z~Qzvqjjb>Wh7UcMV5+Dwk$~(<%vrH3Pt?6Je)8NAJDdB)qbph&f3Z zR7L`Tl>J^@(iBX}!o$vOJcNOvZZ?uRz2~V7cv$N`ZODF0d10vz53|RWOTvWXWLR*}S6m-_MO`qBw?_Vz zOvwfaHPdTR&GPqMiNE!58D3eX@~EUTAx)hQS%Yc(Zt9o#=kT!BHPTZ$G zyve?R77v@K@XIPQk;rE=LqiGwG)HMbd#Oea21ql4)sN1mzA(KGudM4wP7)?GK&vN3 zpB-%P?w@Rk#lub{zbe^Hp=M?if;GDQ>ANoK@vzksJ+jK+zXnm+{qJwi7HzqS_u2N2 z?U8{0?M*?M&Wx<__>JOEE~Uiam2G|PUCA_e=-?4BqrI6j=WQ+x#p7X*KD#TKrl5?> zMmylzdy8|`Em7uK_o75;Wx{bHkuvqQnz8(kpTaA9N7X5>%z(zWZ+emj5i%p_1m(_;NTX0w)?lfma&mM zJgkmgFAFm**a5@A(LP&wl!fx_;d;4l0>gssba_1WTw7=aU$fym6_POMGq?lL)OfK! zcs@f#;?qp&|4=qfok=U!?D*>1*`lPCcvwS^^mYpGO%nNxFPkXaoCNT&+GB*IvhX|u zNLk6u!L7u)O?cSd99D7(3pInIR!q~dZQOS8Oq80b_n(qe28RZM{b0Er^8OlS1gRdL Jo`<=0.4.1 -torch>=1.8.0 diff --git a/examples/nested_decision_execution/rl_dummy.py b/examples/nested_decision_execution/rl_dummy.py deleted file mode 100644 index c42e28be4..000000000 --- a/examples/nested_decision_execution/rl_dummy.py +++ /dev/null @@ -1,586 +0,0 @@ -import pickle -from collections import OrderedDict, defaultdict -from dataclasses import dataclass, asdict -from pprint import pprint -from typing import Iterable, Any, Optional, OrderedDict, Tuple, Dict, List - -import fire -import gym -import numpy as np -import pandas as pd -import qlib -from gym import spaces -from qlib.backtest import get_exchange, Account, BaseExecutor, CommonInfrastructure, Order, TradeCalendarManager, backtest_func -from qlib.backtest.executor import NestedExecutor, SimulatorExecutor -from qlib.config import REG_CN -from qlib.data import D -from qlib.rl.interpreter import StateInterpreter, ActionInterpreter -from qlib.strategy import BaseStrategy -from qlib.tests.data import GetData -from qlib.utils import init_instance_by_config, exists_qlib_data -from torch.utils.data import Dataset, DataLoader -from tianshou.data import Batch, Collector -from tianshou.env import DummyVectorEnv, SubprocVectorEnv -from tianshou.policy import BasePolicy - -from workflow import NestedDecisonExecutionWorkflow - - -MAX_STEPS = 10 - - -def get_executor(start_time, end_time, executor, exchange, benchmark="SH000300", account=1e9) -> BaseExecutor: - trade_account = Account( - init_cash=account, - benchmark_config={ - "benchmark": benchmark, - "start_time": start_time, - "end_time": end_time, - }, - ) - - common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=exchange) - trade_executor = init_instance_by_config(executor, accept_types=BaseExecutor, common_infra=common_infra) - - return trade_executor - - -def price_advantage(exec_price: float, baseline_price: float, direction: int) -> float: - if baseline_price == 0: - return 0. - if direction == 1: - return (1 - exec_price / baseline_price) * 10000 - else: - return (exec_price / baseline_price - 1) * 10000 - - -@dataclass -class EpisodicState: - """ - A simplified data structure as the input of RL-related components to calculate observations and rewards. - Some of the metrics info are calculated on-the-fly in this class. - """ - # requirements - stock_id: int - start_time: pd.Timestamp - end_time: pd.Timestamp - direction: int - target: float - num_step: int - - # simplified market data used to calculate backtest metrics - # this may contains information from future so be careful - market_price: np.ndarray - market_vol: np.ndarray - - # agent state - cur_time: Optional[pd.Timestamp] = None - cur_step: int = 0 - cur_tick: int = 0 # tick is the most fine-grained time unit (typically minute) - done: bool = False - position: Optional[float] = None - exec_vol: Optional[np.ndarray] = None - last_step_duration: Optional[int] = None - position_history: Optional[np.ndarray] = None - - # calculated statistics - turnover: Optional[float] = None - baseline_twap: Optional[float] = None - baseline_vwap: Optional[float] = None - exec_avg_price: Optional[float] = None - pa_twap: Optional[float] = None - pa_vwap: Optional[float] = None - fulfill_rate: Optional[float] = None - - def __post_init__(self): - assert self.target >= 0 - assert len(self.market_price) == len(self.market_vol) - self.cur_time = self.start_time - self.position = self.target - self.position_history = np.full((self.num_step + 1), np.nan) - self.position_history[0] = self.position - self.baseline_twap = np.mean(self.market_price) - if self.market_vol.sum() == 0: - self.baseline_vwap = np.mean(self.market_price) - else: - self.baseline_vwap = np.average(self.market_price, weights=self.market_vol) - - def update_stats(self): - market_price = self.market_price[:len(self.exec_vol)] - self.turnover = (self.exec_vol * market_price).sum() - # exec_vol can be zero - if np.isclose(self.exec_vol.sum(), 0): - self.exec_avg_price = market_price[0] - else: - self.exec_avg_price = np.average(market_price, weights=self.exec_vol) - self.pa_twap = price_advantage(self.exec_avg_price, self.baseline_twap, self.direction) - self.pa_vwap = price_advantage(self.exec_avg_price, self.baseline_vwap, self.direction) - self.fulfill_rate = (self.target - self.position) / self.target - if abs(self.fulfill_rate - 1.0) < 1e-5: - self.fulfill_rate = 1.0 - self.fulfill_rate *= 100 - - def logs(self): - logs = { - 'stop_time': self.cur_time - self.start_time, - 'stop_step': self.cur_step, - 'turnover': self.turnover, - 'baseline_twap': self.baseline_twap, - 'baseline_vwap': self.baseline_vwap, - 'exec_avg_price': self.exec_avg_price, - 'pa_twap': self.pa_twap, - 'pa_vwap': self.pa_vwap, - 'ffr': self.fulfill_rate - } - return logs - - @classmethod - def from_order_and_executor(cls, order: Order, calendar: TradeCalendarManager, frequency: str) -> "EpisodicState": - # Synchronous state for executor to EpisodicState - state = cls( - stock_id=order.stock_id, - start_time=order.start_time, - end_time=order.end_time, - direction=order.direction, - target=order.amount, - num_step=calendar.get_trade_len(), - market_price=_retrieve_backtest_data(order, '$close', frequency), - market_vol=_retrieve_backtest_data(order, '$volume', frequency), - ) - state.cur_step = calendar.get_trade_step() - assert state.cur_step == 0 - state.cur_time, _ = calendar.get_step_time(state.cur_step) - return state - - def update(self, execute_result: List[Order], calendar: TradeCalendarManager, - done: Optional[bool] = None, length: Optional[int] = None) -> "StepState": - if length is not None: - exec_vol = np.zeros(length) - exec_vol[:len(execute_result)] = np.array([order.deal_amount for order, _, __, ___ in execute_result]) - else: - exec_vol = np.array([order.deal_amount for order, _, __, ___ in execute_result]) - # Synchronous exec_vol to executor and synchronous back to EpisodicState - cur_tick = self.cur_tick - ticks_this_step = len(exec_vol) - self.cur_step = trade_step = calendar.get_trade_step() - self.cur_tick += ticks_this_step - self.position -= np.sum(exec_vol) - self.position_history[trade_step] = self.position - if done is not None: - self.done = done - else: - self.done = self.position < 1e-5 - self.exec_vol = exec_vol if self.exec_vol is None else \ - np.concatenate((self.exec_vol, exec_vol)) - - if self.done: - self.update_stats() - else: - self.cur_time, _ = calendar.get_step_time(trade_step) - - l, r = cur_tick, cur_tick + ticks_this_step - assert 0 <= l < r - return StepState(exec_vol, self.market_vol[l:r], self.market_price[l:r], self) - - -@dataclass -class StepState: - # market info and execution volume for current step - exec_vol: np.ndarray - market_vol: np.ndarray - market_price: np.ndarray - - # episode info - episode_state: EpisodicState - - # calculated statistics - turnover: Optional[float] = None - exec_avg_price: Optional[float] = None - pa_twap: Optional[float] = None - pa_vwap: Optional[float] = None - - def __post_init__(self): - assert len(self.exec_vol) == len(self.market_price) == len(self.market_vol) - self.turnover = (self.exec_vol * self.market_price).sum() - if np.isclose(self.market_vol.sum(), 0): - self.exec_avg_price = self.market_price[0] - else: - self.exec_avg_price = np.average(self.market_price, weights=self.market_vol) - self.pa_twap = price_advantage(self.exec_avg_price, self.episode_state.baseline_twap, - self.episode_state.direction) - self.pa_vwap = price_advantage(self.exec_avg_price, self.episode_state.baseline_vwap, - self.episode_state.direction) - - -def _retrieve_backtest_data(order: Order, field: str, frequency: str) -> np.ndarray: - # Retrieve backtest data for RL-specific use (including reward calculation) - return D.features( - [order.stock_id], - ['$open', '$close', '$high', '$low', '$volume'], - start_time=order.start_time, - end_time=order.end_time, - freq=frequency - )[field].to_numpy() - - -def create_sub_order(exec_vol: float, calendar: TradeCalendarManager, original_order: Order) -> Order: - # Convert a real number to an order - trade_step = calendar.get_trade_step() - trade_start_time, trade_end_time = calendar.get_step_time(trade_step) - order_kwargs = asdict(original_order) - order_kwargs.update(start_time=trade_start_time, end_time=trade_end_time, amount=exec_vol) - trade_decision = Order(**order_kwargs) - return trade_decision - - -class SingleOrderEnv(gym.Env): - def __init__(self, - observation: StateInterpreter, - action: ActionInterpreter, - reward: Any, - dataloader: Iterable, - executor: BaseExecutor): - self.action = action - self.observation = observation - self.reward = reward - self.dataloader = dataloader - self.executor = executor - - self.inner_frequency = self.executor.get_all_executor()[-1].time_per_step - - @property - def action_space(self): - return self.action.action_space - - @property - def observation_space(self): - return self.observation.observation_space - - def reset(self): - try: - self.cur_order = next(self.dataloader) - except StopIteration: - self.dataloader = None - return None - - self.execute_result = [] - self.executor.reset(start_time=self.cur_order.start_time, end_time=self.cur_order.end_time) - self.ep_state = EpisodicState.from_order_and_executor( - self.cur_order, self.executor.trade_calendar, self.inner_frequency - ) - - self.action_history = np.full(self.ep_state.num_step, np.nan) - return self.observation(self.ep_state) - - def step(self, action): - assert self.dataloader is not None - assert not self.executor.finished() - self.action_history[self.ep_state.cur_step] = action - - exec_vol = self.action(action, self.ep_state) - trade_decision = create_sub_order(exec_vol, self.executor.trade_calendar, self.cur_order) - execute_result = self.executor.execute([trade_decision]) - step_state = self.ep_state.update(execute_result, self.executor.trade_calendar) - if self.executor.finished(): - assert self.ep_state.done - - reward, rew_info = self.reward(self.ep_state, step_state) - - info = { - 'action_history': self.action_history, - 'category': self.ep_state.direction, - 'reward': rew_info - } - if self.ep_state.done: - info['logs'] = self.ep_state.logs() - info['index'] = { - 'ins': self.ep_state.stock_id, - 'date': self.ep_state.start_time, - } - # TODO: collect logs - pprint(info) - - return self.observation(self.ep_state), reward, self.ep_state.done, info - - -class RLStrategy(BaseStrategy): - """When inference and do the backtest from end to end, use this strategy.""" - - def __init__( - self, - observation: "Observation", - action: "Action", - policy: BasePolicy, - **kwargs - ): - super().__init__(**kwargs) - self.observation = observation - self.action = action - self.policy = policy - - # TODO: how to get inner frequency and trade len - # This should be no longer required when PA is provided by qlib. - self.inner_frequency = "day" - self.inner_trade_len = 1 - - def reset(self, outer_trade_decision: List[Order] = None, **kwargs): - super().reset(outer_trade_decision=outer_trade_decision, **kwargs) - if outer_trade_decision is not None: - self.states = OrderedDict() # explicitly make it ordered - for order in outer_trade_decision: - state = EpisodicState.from_order_and_executor(order, self.trade_calendar, "day") - self.states[order.stock_id, order.direction] = state - - def generate_trade_decision(self, execute_result=None): - # apply results from the last step - if execute_result is not None: - orders = defaultdict(list) - for e in execute_result: - orders[e[0].stock_id, e[0].direction].append(e) - for (stock_id, direction), state in self.states.items(): - state.update(orders[stock_id, direction], self.trade_calendar, length=self.inner_trade_len) - - if not self.states: - return [] - - obs_batch = Batch([{"obs": self.observation(state)} for state in self.states.values()]) - act = self.policy(obs_batch) - exec_vols = [self.action(a, s) for a, s in zip(act.act, self.states.values())] - return [create_sub_order(v, self.trade_calendar, o) for v, o in zip(exec_vols, self.outer_trade_decision)] - - -class RlWorkflow(NestedDecisonExecutionWorkflow): - - def tianshou(self): - self._init_qlib() - - # TODO: why is there a benchmark? - trade_start_time = "2017-01-01" - trade_end_time = "2020-08-01" - benchmark = "SH000300" - time_per_step = "day" - executor_config = { - "class": "SimulatorExecutor", - "module_path": "qlib.backtest.executor", - "kwargs": { - "time_per_step": time_per_step, - "verbose": True, - "generate_report": False, - } - } - exchange = get_exchange( - freq="day", - limit_threshold=0.095, - deal_price="close", - open_cost=0.0005, - close_cost=0.0015, - min_cost=5 - ) - - observation = Observation(time_per_step) - action = Action() - reward_fn = Reward() - - def dummy_env(): - executor = get_executor( - trade_start_time, - trade_end_time, - executor_config, - exchange, - benchmark, - 1000000000, - ) - return SingleOrderEnv( - observation, action, reward_fn, - iter(DataLoader(QlibOrderDataset('assets/orders'), batch_size=None, shuffle=True)), executor) - - policy = DummyPolicy() - - # This can not be replaced with SubprocVectorEnv - # File "/xxx/qlib/qlib/data/data.py", line 462, in dataset_processor - # p = Pool(processes=workers) - # AssertionError: daemonic processes are not allowed to have children - envs = DummyVectorEnv([dummy_env for _ in range(4)]) - test_collector = Collector(policy, envs) - policy.eval() - # TODO: create a queue for all orders and make it auto-complete when all the orders are processed - test_collector.collect(n_episode=10) - - def rl_day(self, load_model: Optional[str] = None): - self._init_qlib() - model = init_instance_by_config(self.task["model"]) - dataset = init_instance_by_config(self.task["dataset"]) - if load_model is None: - self._train_model(model, dataset) - else: - model = self._load_model(load_model) - trade_start_time = "2017-01-01" - trade_end_time = "2020-08-01" - trade_account = Account( - init_cash=int(1e9), - benchmark_config={ - "benchmark": "SH000300", - "start_time": trade_start_time, - "end_time": trade_end_time, - }, - ) - exchange = get_exchange( - freq="day", - limit_threshold=0.095, - deal_price="close", - open_cost=0.0005, - close_cost=0.0015, - min_cost=5 - ) - common_infra = CommonInfrastructure(trade_account=trade_account, trade_exchange=exchange) - executor = NestedExecutor( - time_per_step="week", - inner_executor=SimulatorExecutor(time_per_step="day", verbose=True), - inner_strategy=RLStrategy(Observation("day"), Action(), DummyPolicy()), - common_infra=common_infra - ) - strategy = init_instance_by_config({ - "class": "TopkDropoutStrategy", - "module_path": "qlib.contrib.strategy.model_strategy", - "kwargs": { - "model": model, - "dataset": dataset, - "topk": 50, - "n_drop": 5, - }, - }, common_infra=common_infra) - report_dict = backtest_func(trade_start_time, trade_end_time, strategy, executor) - print(report_dict) - - -### This is a full RL strategy ### - - -class QlibOrderDataset(Dataset): - def __init__(self, order_file): - with open(order_file, 'rb') as f: - self.orders = pickle.load(f) - - def __len__(self): - return len(self.orders) - - def __getitem__(self, index) -> Order: - return self.orders[index] - - -class DummyPolicy(BasePolicy): - def forward(self, batch, state=None, **kwargs): - return Batch(act=np.random.randint(0, 5, size=(len(batch), ))) - - def learn(self, *args, **kwargs): - pass - - -class Observation: - def __init__(self, time_per_step): - self.time_per_step = time_per_step - - def __call__(self, ep_state: EpisodicState) -> Any: - obs = self.observe(ep_state) - if not self.validate(obs): - raise ValueError(f'Observation space does not contain obs. Space: {self.observation_space} Sample: {obs}') - return obs - - def validate(self, obs: Any) -> bool: - return self.observation_space.contains(obs) - - @property - def observation_space(self): - space = { - 'direction': spaces.Discrete(2), - 'cur_step': spaces.Box(0, MAX_STEPS, shape=(), dtype=np.int32), - 'num_step': spaces.Box(0, MAX_STEPS, shape=(), dtype=np.int32), - 'target': spaces.Box(-1e-5, np.inf, shape=()), - 'position': spaces.Box(-1e-5, np.inf, shape=()), - 'features': spaces.Box(-np.inf, np.inf, shape=(5, )) - } - return spaces.Dict(space) - - def observe(self, ep_state: EpisodicState) -> Any: - features = D.features( - [ep_state.stock_id], - ['$open', '$close', '$high', '$low', '$volume'], - start_time=ep_state.start_time, - end_time=ep_state.end_time, - freq=self.time_per_step - ).loc[(ep_state.stock_id, ep_state.cur_time)].to_numpy() - features = np.nan_to_num(features) - return { - 'direction': _to_int32(ep_state.direction), - 'cur_step': _to_int32(min(ep_state.cur_step, ep_state.num_step - 1)), - 'num_step': _to_int32(ep_state.num_step), - 'target': _to_float32(ep_state.target), - 'position': _to_float32(ep_state.position), - 'features': features, - } - - -class Action: - denominator = 4 - - @property - def action_space(self): - return spaces.Discrete(self.denominator + 1) - - def __call__(self, action: Any, ep_state: EpisodicState) -> Any: - if not self.validate(action): - raise ValueError(f'Action space does not contain action. Space: {self.action_space} Sample: {action}') - act_ = self.to_volume(action, ep_state) - return act_ - - def validate(self, action: Any) -> bool: - return self.action_space.contains(action) - - def to_volume(self, action: Any, ep_state: EpisodicState) -> Any: - exec_vol = ep_state.position / self.denominator * action - if ep_state.cur_step + 1 >= ep_state.num_step: - exec_vol = ep_state.position - # TODO: might need to check whether the stock is tradable or whether it satisfies trade unit? - return exec_vol - - -class Reward: - weight = 1.0 - - def __call__(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: - rew, info = 0., {} - if ep_state.done: - ep_rew, ep_info = self._to_tuple(self.episode_end(ep_state)) - rew += ep_rew - info.update({f'ep/{k}': v for k, v in ep_info.items()}) - st_rew, st_info = self._to_tuple(self.step_end(ep_state, st_state)) - rew += st_rew - info.update({f'st/{k}': v for k, v in st_info.items()}) - return rew * self.weight, info - - @staticmethod - def _to_tuple(x): - if isinstance(x, tuple): - return x - return x, {} - - def episode_end(self, ep_state: EpisodicState) -> Tuple[float, Dict[str, float]]: - return 0. - - def step_end(self, ep_state: EpisodicState, st_state: StepState) -> Tuple[float, Dict[str, float]]: - assert ep_state.target > 0 - baseline_price = st_state.pa_twap - pa = baseline_price * st_state.exec_vol.sum() / ep_state.target - penalty = -100 * ((st_state.exec_vol / ep_state.target) ** 2).sum() # penalize too much volume at one step - reward = pa + penalty - return reward, {'pa': pa, 'penalty': penalty} - - -def _to_int32(val): return np.array(int(val), dtype=np.int32) -def _to_float32(val): return np.array(val, dtype=np.float32) - -### End of RL strategy ### - - -if __name__ == '__main__': - fire.Fire(RlWorkflow) diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index a90e7281c..b6c1362fd 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from typing import Optional import qlib import fire @@ -171,17 +170,11 @@ class NestedDecisionExecutionWorkflow: sr = SignalRecord(model, dataset, recorder) sr.generate() - def _load_model(self, load): - return R.get_recorder(load, experiment_name="train").load_object("params.pkl") - - def backtest(self, load_model: Optional[str] = None): + def backtest(self): self._init_qlib() model = init_instance_by_config(self.task["model"]) dataset = init_instance_by_config(self.task["dataset"]) - if load_model is None: - self._train_model(model, dataset) - else: - model = self._load_model(load_model) + self._train_model(model, dataset) strategy_config = { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", From ab3c4a2c053afb407f7a211ef9a8fe6b2e54cbd3 Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 28 Jul 2021 03:10:32 +0000 Subject: [PATCH 148/187] new twap (more even) --- qlib/contrib/strategy/rule_strategy.py | 61 ++++++++++++++------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 7f04f444e..580137d8b 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -34,11 +34,16 @@ class TWAPStrategy(BaseStrategy): super(TWAPStrategy, self).reset(outer_trade_decision=outer_trade_decision, **kwargs) if outer_trade_decision is not None: - self.trade_amount = {} + self.trade_amount_remain = {} for order in outer_trade_decision.get_decision(): - self.trade_amount[order.stock_id] = order.amount + self.trade_amount_remain[order.stock_id] = order.amount def generate_trade_decision(self, execute_result=None): + # NOTE: corner cases!!! + # - If using upperbound round, please don't sell the amount which should in next step + # - the coordinate of the amount between steps is hard to be dealed between steps in the same level. It + # is easier to be dealed in upper steps + # strategy is not available. Give an empty decision if len(self.outer_trade_decision.get_decision()) == 0: return TradeDecisionWO(order_list=[], strategy=self) @@ -53,16 +58,28 @@ class TWAPStrategy(BaseStrategy): # It is not time to start trading or trading has ended. return TradeDecisionWO(order_list=[], strategy=self) - rel_trade_step = trade_step - start_idx # trade_step relative to start_idx + rel_trade_step = trade_step - start_idx # trade_step relative to start_idx (number of steps has already passed) # update the order amount if execute_result is not None: for order, _, _, _ in execute_result: - self.trade_amount[order.stock_id] -= order.deal_amount + self.trade_amount_remain[order.stock_id] -= order.deal_amount trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) order_list = [] for order in self.outer_trade_decision.get_decision(): + # the expected trade amount after current step + amount_expect = order.amount / trade_len * (rel_trade_step + 1) + + # remain amount + amount_remain = self.trade_amount_remain[order.stock_id] + + # the amount has already been finished now. + amount_finished = order.amount - amount_remain + + # the expected amount of current step + amount_delta = amount_expect - amount_finished + # Don't peek the future information # if not self.trade_exchange.is_stock_tradable( # stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time @@ -71,38 +88,26 @@ class TWAPStrategy(BaseStrategy): _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time ) - _order_amount = None - # considering trade unit + + # round the amount_delta by trade_unit and clip by remain + # NOTE: this could be more than expected. if _amount_trade_unit is None: # divide the order into equal parts, and trade one part - _order_amount = self.trade_amount[order.stock_id] / (trade_len - rel_trade_step) - # without considering trade unit + amount_delta_target = amount_delta else: - # divide the order into equal parts, and trade one part - # calculate the total count of trade units to trade - trade_unit_cnt = int(self.trade_amount[order.stock_id] // _amount_trade_unit) - # calculate the amount of one part, ceil the amount - # floor((trade_unit_cnt + trade_len - rel_trade_step) / (trade_len - rel_trade_step + 1)) == ceil(trade_unit_cnt / (trade_len - rel_trade_step + 1)) - _order_amount = ( - (trade_unit_cnt + trade_len - rel_trade_step - 1) - // (trade_len - rel_trade_step) - * _amount_trade_unit + amount_delta_target = min( + np.round(amount_delta / _amount_trade_unit) * _amount_trade_unit, amount_remain ) - if order.direction == order.SELL: - # sell all amount at last - if self.trade_amount[order.stock_id] > 1e-5 and ( - _order_amount < 1e-5 or rel_trade_step == trade_len - 1 - ): - _order_amount = self.trade_amount[order.stock_id] - - _order_amount = min(_order_amount, self.trade_amount[order.stock_id]) - - if _order_amount > 1e-5: + # handle last step to make sure all positions have gone + # necessity: the last step can't be rounded to the a unit (e.g. reminder < 0.5 unit) + if rel_trade_step == trade_len - 1: + amount_delta_target = amount_remain + if amount_delta_target > 1e-5: _order = Order( stock_id=order.stock_id, - amount=_order_amount, + amount=amount_delta_target, start_time=trade_start_time, end_time=trade_end_time, direction=order.direction, # 1 for buy From 73f5cc0a2be893e697373a411ae84edae731e5ed Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 29 Jul 2021 04:05:39 +0000 Subject: [PATCH 149/187] add suspend check in twap --- qlib/contrib/strategy/rule_strategy.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/qlib/contrib/strategy/rule_strategy.py b/qlib/contrib/strategy/rule_strategy.py index 580137d8b..3286c9fd9 100644 --- a/qlib/contrib/strategy/rule_strategy.py +++ b/qlib/contrib/strategy/rule_strategy.py @@ -68,6 +68,15 @@ class TWAPStrategy(BaseStrategy): trade_start_time, trade_end_time = self.trade_calendar.get_step_time(trade_step) order_list = [] for order in self.outer_trade_decision.get_decision(): + # Don't peek the future information, so we use check_stock_suspended instead of is_stock_tradable + # necessity of this + # - if stock is suspended, the quote values of stocks is NaN. The following code will raise error when + # encountering NaN factor + if self.trade_exchange.check_stock_suspended( + stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time + ): + continue + # the expected trade amount after current step amount_expect = order.amount / trade_len * (rel_trade_step + 1) @@ -80,11 +89,6 @@ class TWAPStrategy(BaseStrategy): # the expected amount of current step amount_delta = amount_expect - amount_finished - # Don't peek the future information - # if not self.trade_exchange.is_stock_tradable( - # stock_id=order.stock_id, start_time=trade_start_time, end_time=trade_end_time - # ): - # continue _amount_trade_unit = self.trade_exchange.get_amount_of_trade_unit( stock_id=order.stock_id, start_time=order.start_time, end_time=order.end_time ) From 5c2ddac7f0041dadce8eba7f3c6985eedabced12 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sat, 31 Jul 2021 09:31:01 +0000 Subject: [PATCH 150/187] volume limit --- qlib/backtest/exchange.py | 167 ++++++++++++++++++++++++++++++---- qlib/backtest/executor.py | 28 +++++- qlib/contrib/ops/high_freq.py | 55 +++++++++++ 3 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 qlib/contrib/ops/high_freq.py diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 9044179e0..eae7bb4f6 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -68,7 +68,31 @@ class Exchange: ) `False` value indicates the stock is tradable `True` value indicates the stock is limited and not tradable - :param volume_threshold: float, 0.1 for example, default None + + :param volume_threshold: Union[ + Dict[ + "all": Union(str, List[str], Tuple[str]), + "buy": Union(str, List[str], Tuple[str]), + "sell": Union(str, List[str], Tuple[str]), + ], + Union(str, List[str], Tuple[str], + ] + 1) str means one volume limit. In another words, each volume limit is a string. + There are two kinds of string to represent limit. + - the first kind of string is qlib data expression but it must starts with "$". + such as "$askV1", "$bidV1 * 0.8" + - the second kind of string is composed of special fields. Currently we only + supports #market and #dealed. #market is market volume so far that day. + !!!Note that if you use the #market field, you must register the DayCumsum operator + in qlib.contrib.ops.high_freq when initial the qlib. #dealed is dealed order num so far that day. + such as "0.8 * #market - #dealed", "0.6 * #market" + 2) "all" means the volume limits are both of buying and selling. + "buy" means the volume limits of buying. "sell" means the volume limits of selling. + Different volume limits will be aggregated with min(). If volume_threshold is only + Union(str, List[str], Tuple[str]) instead of a dict, the volume limits are for + both by deault. + 3) e.g. {"all": ("#market * 0.2 - #dealed"), "buy": ("$askV1"), "sell": ("$bidV1")} + :param open_cost: cost rate for open, default 0.0015 :param close_cost: cost rate for close, default 0.0025 :param trade_unit: trade unit, 100 for China A market. @@ -134,11 +158,15 @@ class Exchange: # $factor is for rounding to the trading unit # $change is for calculating the limit of the stock + #  get volume limit from kwargs + self.buy_vol_limit, self.sell_vol_limit, vol_lt_fields = self._get_vol_limit(volume_threshold) + necessary_fields = {self.buy_price, self.sell_price, "$close", "$change", "$factor", "$volume"} if self.limit_type == self.LT_TP_EXP: for exp in limit_threshold: necessary_fields.add(exp) - all_fields = list(necessary_fields | set(subscribe_fields)) + all_fields = necessary_fields | vol_lt_fields + all_fields = list(all_fields | set(subscribe_fields)) self.all_fields = all_fields self.open_cost = open_cost @@ -234,6 +262,61 @@ class Exchange: self.quote_df["limit_buy"] = self.quote_df["$change"].ge(limit_threshold) self.quote_df["limit_sell"] = self.quote_df["$change"].le(-limit_threshold) # pylint: disable=E1130 + def _get_vol_limit(self, volume_threshold): + """ + preproccess the volume limit. + get the fields need to get from qlib. + get the volume limit list of buying and selling which is composed of all limits. + + Parameters + ---------- + volume_threshold : + please refer to the doc of exchange. + + Returns + ------- + fields: set + the fields need to get from qlib. + buy_vol_limit: List[str] + all volume limits of buying. + sell_vol_limit: List[str] + all volume limits of selling. + + Raises + ------ + ValueError + the format of volume_threshold is not supported. + """ + if volume_threshold is None: + return None, None, set() + + fields = set() + buy_vol_limit = [] + sell_vol_limit = [] + if isinstance(volume_threshold, (str, tuple, list)): + volume_threshold = {"all": volume_threshold} + + for key in volume_threshold: + vol_limits = volume_threshold[key] + if isinstance(vol_limits, str): + vol_limits = [vol_limits] + for vol_lt in vol_limits: + # the str is qlib data expression when the first character is "$". + if vol_lt[0] == "$": + fields.add(vol_lt) + # the str is composed of special_fields + elif "#market" in vol_lt: + fields.add("DayCumsum($volume)") + else: + raise ValueError(f"volume limit string must be qlib expression or special_fields") + + if key in ("buy", "all"): + buy_vol_limit.append(vol_lt) + if key in ("sell", "all"): + sell_vol_limit.append(vol_lt) + + return buy_vol_limit, sell_vol_limit, fields + def check_stock_limit(self, stock_id, start_time, end_time, direction=None): """ Parameters @@ -282,7 +365,9 @@ class Exchange: else: return True - def deal_order(self, order, trade_account: Account = None, position: BasePosition = None): + def deal_order( + self, order, trade_account: Account = None, position: BasePosition = None, deal_order_num: dict = None + ): """ Deal order when the actual transaction @@ -291,6 +376,7 @@ class Exchange: :param order: Deal the order. :param trade_account: Trade account to be updated after dealing the order. :param position: position to be updated after dealing the order. + :param deal_order_num: the dealed order num dict with the format of {"buy":{stock_id: int}, "sell":{stock_id: int}} :return: trade_val, trade_cost, trade_price """ # check order first. @@ -305,7 +391,7 @@ class Exchange: trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, order.direction) # NOTE: order will be changed in this function trade_val, trade_cost = self._calc_trade_info_by_order( - order, trade_account.current if trade_account else position + order, trade_account.current if trade_account else position, deal_order_num ) if order.deal_amount > 1e-5: # If the order can only be deal 0 amount. Nothing to be updated @@ -569,14 +655,64 @@ class Exchange: return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount - def _get_amount_by_volume(self, stock_id, trade_start_time, trade_end_time, deal_amount): - if self.volume_threshold is not None: - tradable_amount = self.get_volume(stock_id, trade_start_time, trade_end_time) * self.volume_threshold - return max(min(tradable_amount, deal_amount), 0) - else: - return deal_amount + def _get_amount_by_volume(self, order: Order, deal_order_num: dict) -> int: + """parse the capacity limit string and return the actual number of orders that can be executed. - def _calc_trade_info_by_order(self, order, position: Position): + Parameters + ---------- + order : Order + the order to be executed. + deal_order_num : dict + the dealed order num dict with the format of {"buy":{stock_id: int}, "sell":{stock_id: int}} + + Returns + ------- + int + the actual number of orders that can be executed, due to the volume limit. + """ + if order.direction == Order.BUY: + vol_limit = self.buy_vol_limit + deal_order_num = deal_order_num["buy"] + elif order.direction == Order.SELL: + vol_limit = self.sell_vol_limit + deal_order_num = deal_order_num["sell"] + + if vol_limit is None: + return order.deal_amount + + vol_limit_num = [] + for limit in vol_limit: + assert isinstance(limit, str) + if limit[0] == "$": + vol_limit_num.append( + str( + self.quote.get_data( + order.stock_id, + order.start_time, + order.end_time, + fields=limit, + method=ts_data_last, + ) + ) + ) + else: + if "#market in limit": + market_limit = self.quote.get_data( + order.stock_id, + order.start_time, + order.end_time, + fields="DayCumsum($volume)", + method=ts_data_last, + ) + limit_tmp = limit.replace("#market", f"{market_limit}") + if "#dealed in limit": + limit_tmp = limit_tmp.replace("#dealed", f"{deal_order_num[order.stock_id]}") + vol_limit_num.append(limit_tmp) + + vol_limit_num = min([eval(i) for i in vol_limit_num]) + return max(min(vol_limit_num, order.deal_amount), 0) + + def _calc_trade_info_by_order(self, order, position: Position, deal_order_num): """ Calculation of trade info @@ -584,6 +720,7 @@ class Exchange: :param order: :param position: Position + :param deal_order_num: the dealed order num dict with the format of {"buy":{stock_id: int}, "sell":{stock_id: int}} :return: trade_val, trade_cost """ @@ -607,9 +744,7 @@ class Exchange: # We choose to sell all order.deal_amount = order.amount - order.deal_amount = self._get_amount_by_volume( - order.stock_id, order.start_time, order.end_time, order.deal_amount - ) + order.deal_amount = self._get_amount_by_volume(order, deal_order_num) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.close_cost, self.min_cost) elif order.direction == Order.BUY: @@ -629,9 +764,7 @@ class Exchange: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - order.deal_amount = self._get_amount_by_volume( - order.stock_id, order.start_time, order.end_time, order.deal_amount - ) + order.deal_amount = self._get_amount_by_volume(order, deal_order_num) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.open_cost, self.min_cost) else: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 0121a904e..b79de011a 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -7,6 +7,7 @@ from qlib.backtest.account import Account import warnings import pandas as pd from typing import List, Tuple, Union +from collections import defaultdict from qlib.backtest.report import Indicator @@ -466,6 +467,10 @@ class SimulatorExecutor(BaseExecutor): self.trade_type = trade_type + # record deal order num in one day + self.deal_order_num = {"buy": defaultdict(int), "sell": defaultdict(int)} + self.deal_day = None + def _get_order_iterator(self, trade_decision: BaseTradeDecision) -> List[Order]: """ @@ -495,6 +500,22 @@ class SimulatorExecutor(BaseExecutor): raise NotImplementedError(f"This type of input is not supported") return order_it + def _update_order_num(self, order): + """update date and dealed order num in the day.""" + + now_deal_day = order.start_time.floor(freq="D") + if self.deal_day is None: + self.deal_day = now_deal_day + if now_deal_day > self.deal_day: + self.deal_order_num = {"buy": defaultdict(int), "sell": defaultdict(int)} + self.deal_day = now_deal_day + if order.direction == Order.BUY: + self.deal_order_num["buy"][order.stock_id] += order.deal_amount + elif order.direction == Order.SELL: + self.deal_order_num["sell"][order.stock_id] += order.deal_amount + else: + raise NotImplementedError(f"order type {order.type} error") + def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): trade_start_time, _ = self.trade_calendar.get_step_time() @@ -503,8 +524,13 @@ class SimulatorExecutor(BaseExecutor): for order in self._get_order_iterator(trade_decision): # execute the order. # NOTE: The trade_account will be changed in this function - trade_val, trade_cost, trade_price = self.trade_exchange.deal_order(order, trade_account=self.trade_account) + trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( + order, + trade_account=self.trade_account, + deal_order_num=self.deal_order_num, + ) execute_result.append((order, trade_val, trade_cost, trade_price)) + self._update_order_num(order) if self.verbose: print( "[I {:%Y-%m-%d %H:%M:%S}]: {} {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}, cash {:.2f}.".format( diff --git a/qlib/contrib/ops/high_freq.py b/qlib/contrib/ops/high_freq.py new file mode 100644 index 000000000..eee28c275 --- /dev/null +++ b/qlib/contrib/ops/high_freq.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +from pathlib import Path +import numpy as np +import pandas as pd + +import qlib +from qlib.data import D +from qlib.data.cache import H +from qlib.data.data import Cal +from qlib.data.ops import ElemOperator + + +def get_calendar_day(freq="1min", future=False): + """Load High-Freq Calendar Date Using Memcache. + + Parameters + ---------- + freq : str + frequency of read calendar file. + future : bool + whether including future trading day. + + Returns + ------- + _calendar: + array of date. + """ + flag = f"{freq}_future_{future}_day" + if flag in H["c"]: + _calendar = H["c"][flag] + else: + _calendar = np.array(list(map(lambda x: x.date(), Cal.load_calendar(freq, future)))) + H["c"][flag] = _calendar + return _calendar + + +class DayCumsum(ElemOperator): + """DayLast Operator + + Parameters + ---------- + feature : Expression + feature instance + + Returns + ---------- + feature: + a series of that each value equals the last value of its day + """ + + def _load_internal(self, instrument, start_index, end_index, freq): + _calendar = get_calendar_day(freq=freq) + series = self.feature.load(instrument, start_index, end_index, freq) + return series.groupby(_calendar[series.index]).cumsum() From 0f2d85d098d2dca3016c2abf1d76cfac9442bdf4 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sun, 1 Aug 2021 16:03:08 +0000 Subject: [PATCH 151/187] volume limit update --- examples/highfreq/highfreq_ops.py | 25 +---- qlib/backtest/exchange.py | 151 +++++++++++++++--------------- qlib/backtest/executor.py | 31 +++--- qlib/contrib/ops/high_freq.py | 48 +++++++++- 4 files changed, 132 insertions(+), 123 deletions(-) diff --git a/examples/highfreq/highfreq_ops.py b/examples/highfreq/highfreq_ops.py index ef784b34c..175f4f66b 100644 --- a/examples/highfreq/highfreq_ops.py +++ b/examples/highfreq/highfreq_ops.py @@ -5,30 +5,7 @@ from qlib.data.ops import ElemOperator, PairOperator from qlib.config import C from qlib.data.cache import H from qlib.data.data import Cal - - -def get_calendar_day(freq="day", future=False): - """Load High-Freq Calendar Date Using Memcache. - - Parameters - ---------- - freq : str - frequency of read calendar file. - future : bool - whether including future trading day. - - Returns - ------- - _calendar: - array of date. - """ - flag = f"{freq}_future_{future}_day" - if flag in H["c"]: - _calendar = H["c"][flag] - else: - _calendar = np.array(list(map(lambda x: pd.Timestamp(x.date()), Cal.load_calendar(freq, future)))) - H["c"][flag] = _calendar - return _calendar +from qlib.contrib.ops.high_freq import get_calendar_day class DayLast(ElemOperator): diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index eae7bb4f6..eeee269bd 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. from __future__ import annotations +from collections import defaultdict from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -68,30 +69,37 @@ class Exchange: ) `False` value indicates the stock is tradable `True` value indicates the stock is limited and not tradable - - :param volume_threshold: Union[ + + :param volume_threshold: Union[ Dict[ - "all": Union(str, List[str], Tuple[str]), - "buy": Union(str, List[str], Tuple[str]), - "sell": Union(str, List[str], Tuple[str]), + "all": ("cum" or "current", limit_str), + "buy": ("cum" or "current", limit_str), + "sell":("cum" or "current", limit_str), ], - Union(str, List[str], Tuple[str], - ] - 1) str means one volume limit. In another words, each volume limit is a string. - There are two kinds of string to represent limit. - - the first kind of string is qlib data expression but it must starts with "$". - such as "$askV1", "$bidV1 * 0.8" - - the second kind of string is composed of special fields. Currently we only - supports #market and #dealed. #market is market volume so far that day. - !!!Note that if you use the #market field, you must register the DayCumsum operator - in qlib.contrib.ops.high_freq when initial the qlib. #dealed is dealed order num so far that day. - such as "0.8 * #market - #dealed", "0.6 * #market" - 2) "all" means the volume limits are both of buying and selling. + ("cum" or "current", limit_str), + ] + 1) ("cum" or "current", limit_str) denotes a single volume limit. + - limit_str is qlib data expression which is allowed to define your own Operator. + Please refer to qlib/contrib/ops/high_freq.py, here are any custom operator for high frequency, + such as DayCumsum. !!!NOTE: if you want you use the custom operator, you need to + register it in qlib_init. + - "cum" means that this is a cumulative value over time, such as cumulative market volume. + So when it is used as a volume limit, it is necessary to subtract the dealed amount. + - "current" means that this is a real-time value and will not accumulate over time, + so it can be directly used as a capacity limit. + e.g. ("cum", "0.2 * DayCumsum($volume, '9:45', '14:45')"), ("current", "$bidV1") + + 2) "all" means the volume limits are both buying and selling. "buy" means the volume limits of buying. "sell" means the volume limits of selling. Different volume limits will be aggregated with min(). If volume_threshold is only - Union(str, List[str], Tuple[str]) instead of a dict, the volume limits are for - both by deault. - 3) e.g. {"all": ("#market * 0.2 - #dealed"), "buy": ("$askV1"), "sell": ("$bidV1")} + ("cum" or "current", limit_str) instead of a dict, the volume limits are for + both by deault. In other words, it is same as {"all": ("cum" or "current", limit_str)}. + + 3) e.g. "volume_threshold": { + "all": ("cum", "0.2 * DayCumsum($volume, '9:45', '14:45')"), + "buy": ("current", "$askV1"), + "sell": ("current", "$bidV1"), + } :param open_cost: cost rate for open, default 0.0015 :param close_cost: cost rate for close, default 0.0025 @@ -277,9 +285,9 @@ class Exchange: ------- fields: set the fields need to get from qlib. - buy_vol_limit: List[str] + buy_vol_limit: List[Tuple[str]] all volume limits of buying. - sell_vol_limit: List[str] + sell_vol_limit: List[Tuple[str]] all volume limits of selling. Raises @@ -293,27 +301,19 @@ class Exchange: fields = set() buy_vol_limit = [] sell_vol_limit = [] - if isinstance(volume_threshold, (str, tuple, list)): + if isinstance(volume_threshold, tuple): volume_threshold = {"all": volume_threshold} + assert type(volume_threshold) == dict for key in volume_threshold: - vol_limits = volume_threshold[key] - if isinstance(vol_limits, str): - vol_limits = [vol_limits] - for vol_lt in vol_limits: - # the str is qlib data expression when the first character is "$". - if vol_lt[0] == "$": - fields.add(vol_lt) - # the str is composed of special_fields - elif "#market" in vol_lt: - fields.add("DayCumsum($volume)") - else: - raise ValueError(f"volume limit string must be qlib expression or special_fields") + vol_limit = volume_threshold[key] + assert type(vol_limit) == tuple + fields.add(vol_limit[1]) - if key in ("buy", "all"): - buy_vol_limit.append(vol_lt) - if key in ("sell", "all"): - sell_vol_limit.append(vol_lt) + if key in ("buy", "all"): + buy_vol_limit.append(vol_limit) + if key in ("sell", "all"): + sell_vol_limit.append(vol_limit) return buy_vol_limit, sell_vol_limit, fields @@ -366,7 +366,11 @@ class Exchange: return True def deal_order( - self, order, trade_account: Account = None, position: BasePosition = None, deal_order_num: dict = None + self, + order, + trade_account: Account = None, + position: BasePosition = None, + dealed_order_amount: defaultdict = defaultdict(float), ): """ Deal order when the actual transaction @@ -376,7 +380,7 @@ class Exchange: :param order: Deal the order. :param trade_account: Trade account to be updated after dealing the order. :param position: position to be updated after dealing the order. - :param deal_order_num: the dealed order num dict with the format of {"buy":{stock_id: int}, "sell":{stock_id: int}} + :param dealed_order_amount: the dealed order amount dict with the format of {stock_id: float} :return: trade_val, trade_cost, trade_price """ # check order first. @@ -391,7 +395,7 @@ class Exchange: trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, order.direction) # NOTE: order will be changed in this function trade_val, trade_cost = self._calc_trade_info_by_order( - order, trade_account.current if trade_account else position, deal_order_num + order, trade_account.current if trade_account else position, dealed_order_amount ) if order.deal_amount > 1e-5: # If the order can only be deal 0 amount. Nothing to be updated @@ -655,64 +659,59 @@ class Exchange: return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount - def _get_amount_by_volume(self, order: Order, deal_order_num: dict) -> int: - """parse the capacity limit string and return the actual number of orders that can be executed. + def _get_amount_by_volume(self, order: Order, dealed_order_amount: dict) -> int: + """parse the capacity limit string and return the actual amount of orders that can be executed. Parameters ---------- order : Order the order to be executed. - deal_order_num : dict - the dealed order num dict with the format of {"buy":{stock_id: int}, "sell":{stock_id: int}} + dealed_order_amount : dict + :param dealed_order_amount: the dealed order amount dict with the format of {stock_id: float} Returns ------- int - the actual number of orders that can be executed, due to the volume limit. - """ + the actual amount of orders that can be executed, due to the volume limit. + """ if order.direction == Order.BUY: vol_limit = self.buy_vol_limit - deal_order_num = deal_order_num["buy"] elif order.direction == Order.SELL: vol_limit = self.sell_vol_limit - deal_order_num = deal_order_num["sell"] if vol_limit is None: return order.deal_amount vol_limit_num = [] for limit in vol_limit: - assert isinstance(limit, str) - if limit[0] == "$": + assert isinstance(limit, tuple) + if limit[0] == "current": vol_limit_num.append( - str( - self.quote.get_data( - order.stock_id, - order.start_time, - order.end_time, - fields=limit, - method=ts_data_last, - ) - ) - ) - else: - if "#market in limit": - market_limit = self.quote.get_data( + self.quote.get_data( order.stock_id, order.start_time, order.end_time, - fields="DayCumsum($volume)", + fields=limit[1], method=ts_data_last, ) - limit_tmp = limit.replace("#market", f"{market_limit}") - if "#dealed in limit": - limit_tmp = limit_tmp.replace("#dealed", f"{deal_order_num[order.stock_id]}") - vol_limit_num.append(limit_tmp) - - vol_limit_num = min([eval(i) for i in vol_limit_num]) + ) + elif limit[0] == "cum": + vol_limit_num.append( + self.quote.get_data( + order.stock_id, + order.start_time, + order.end_time, + fields=limit[1], + method=ts_data_last, + ) + - dealed_order_amount[order.stock_id] + ) + else: + raise ValueError(f"{limit[0]} is not supported") + vol_limit_num = min(vol_limit_num) return max(min(vol_limit_num, order.deal_amount), 0) - def _calc_trade_info_by_order(self, order, position: Position, deal_order_num): + def _calc_trade_info_by_order(self, order, position: Position, dealed_order_amount): """ Calculation of trade info @@ -720,7 +719,7 @@ class Exchange: :param order: :param position: Position - :param deal_order_num: the dealed order num dict with the format of {"buy":{stock_id: int}, "sell":{stock_id: int}} + :param dealed_order_amount: the dealed order amount dict with the format of {stock_id: float} :return: trade_val, trade_cost """ @@ -744,7 +743,7 @@ class Exchange: # We choose to sell all order.deal_amount = order.amount - order.deal_amount = self._get_amount_by_volume(order, deal_order_num) + order.deal_amount = self._get_amount_by_volume(order, dealed_order_amount) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.close_cost, self.min_cost) elif order.direction == Order.BUY: @@ -764,7 +763,7 @@ class Exchange: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - order.deal_amount = self._get_amount_by_volume(order, deal_order_num) + order.deal_amount = self._get_amount_by_volume(order, dealed_order_amount) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.open_cost, self.min_cost) else: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index b79de011a..6b44bd1b7 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -114,6 +114,10 @@ class BaseExecutor: if common_infra is None: get_module_logger("BaseExecutor").warning(f"`common_infra` is not set for {self}") + # record deal order amount in one day + self.dealed_order_amount = defaultdict(float) + self.deal_day = None + def reset_common_infra(self, common_infra): """ reset infrastructure for trading @@ -467,10 +471,6 @@ class SimulatorExecutor(BaseExecutor): self.trade_type = trade_type - # record deal order num in one day - self.deal_order_num = {"buy": defaultdict(int), "sell": defaultdict(int)} - self.deal_day = None - def _get_order_iterator(self, trade_decision: BaseTradeDecision) -> List[Order]: """ @@ -500,21 +500,14 @@ class SimulatorExecutor(BaseExecutor): raise NotImplementedError(f"This type of input is not supported") return order_it - def _update_order_num(self, order): - """update date and dealed order num in the day.""" + def _update_dealed_order_amount(self, order): + """update date and dealed order amount in the day.""" - now_deal_day = order.start_time.floor(freq="D") - if self.deal_day is None: + now_deal_day = self.trade_calendar.get_step_time()[0].floor(freq="D") + if self.deal_day is None or now_deal_day > self.deal_day: + self.dealed_order_amount = defaultdict(float) self.deal_day = now_deal_day - if now_deal_day > self.deal_day: - self.deal_order_num = {"buy": defaultdict(int), "sell": defaultdict(int)} - self.deal_day = now_deal_day - if order.direction == Order.BUY: - self.deal_order_num["buy"][order.stock_id] += order.deal_amount - elif order.direction == Order.SELL: - self.deal_order_num["sell"][order.stock_id] += order.deal_amount - else: - raise NotImplementedError(f"order type {order.type} error") + self.dealed_order_amount[order.stock_id] += order.deal_amount def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): @@ -527,10 +520,10 @@ class SimulatorExecutor(BaseExecutor): trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( order, trade_account=self.trade_account, - deal_order_num=self.deal_order_num, + dealed_order_amount=self.dealed_order_amount, ) execute_result.append((order, trade_val, trade_cost, trade_price)) - self._update_order_num(order) + self._update_dealed_order_amount(order) if self.verbose: print( "[I {:%Y-%m-%d %H:%M:%S}]: {} {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}, cash {:.2f}.".format( diff --git a/qlib/contrib/ops/high_freq.py b/qlib/contrib/ops/high_freq.py index eee28c275..6f03b71cf 100644 --- a/qlib/contrib/ops/high_freq.py +++ b/qlib/contrib/ops/high_freq.py @@ -3,6 +3,7 @@ from pathlib import Path import numpy as np import pandas as pd +from datetime import datetime import qlib from qlib.data import D @@ -12,7 +13,9 @@ from qlib.data.ops import ElemOperator def get_calendar_day(freq="1min", future=False): - """Load High-Freq Calendar Date Using Memcache. + """ + Load High-Freq Calendar Date Using Memcache. + !!!NOTE: Loading the calendar is quite slow. So loading calendar before start multiprocessing will make it faster. Parameters ---------- @@ -36,20 +39,57 @@ def get_calendar_day(freq="1min", future=False): class DayCumsum(ElemOperator): - """DayLast Operator + """DayCumsum Operator during start time and end time. Parameters ---------- feature : Expression feature instance + start : str + the start time of backtest in one day. + !!!NOTE: "9:30" means the time period of (9:30, 9:31) is in transaction. + end : str + the end time of backtest in one day. + !!!NOTE: "14:59" means the time period of (14:59, 15:00) is in transaction, + but (15:00, 15:01) is not. + So start="9:30" and end="14:59" means trading all day. Returns ---------- feature: - a series of that each value equals the last value of its day + a series of that each value equals the cumsum value during start time and end time. + Otherwise, the value is zero. """ + def __init__(self, feature, start: str = "9:30", end: str = "14:59"): + self.feature = feature + self.start = datetime.strptime(start, "%H:%M") + self.end = datetime.strptime(end, "%H:%M") + + self.morning_open = datetime.strptime("9:30", "%H:%M") + self.morning_close = datetime.strptime("11:30", "%H:%M") + self.noon_open = datetime.strptime("13:00", "%H:%M") + self.noon_close = datetime.strptime("15:00", "%H:%M") + + self.start_id = self.time_to_index(self.start) + self.end_id = self.time_to_index(self.end) + + def time_to_index(self, t): + if t >= self.morning_open and t < self.morning_close: + return int((t - self.morning_open).total_seconds() / 60) + elif t >= self.noon_open and t < self.noon_close: + return int((t - self.noon_open).total_seconds() / 60) + 120 + else: + raise ValueError(f"{t} is not the opening time of the stock market") + + def period_cusum(self, df): + assert len(df) == 240 + df.iloc[0 : self.start_id] = 0 + df = df.cumsum() + df.iloc[self.end_id + 1 : 240] = 0 + return df + def _load_internal(self, instrument, start_index, end_index, freq): _calendar = get_calendar_day(freq=freq) series = self.feature.load(instrument, start_index, end_index, freq) - return series.groupby(_calendar[series.index]).cumsum() + return series.groupby(_calendar[series.index]).transform(self.period_cusum) From f5db0e1b0520b54ca56ab2a3cf97b3e3976c3f20 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Mon, 2 Aug 2021 03:49:03 +0000 Subject: [PATCH 152/187] fix vol limit bug --- qlib/backtest/exchange.py | 54 ++++++++++++++--------------------- qlib/backtest/executor.py | 14 ++++----- qlib/contrib/ops/high_freq.py | 14 +++------ qlib/utils/time.py | 24 ++++++++++++++++ 4 files changed, 57 insertions(+), 49 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index eeee269bd..43b2e95d6 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -84,7 +84,7 @@ class Exchange: such as DayCumsum. !!!NOTE: if you want you use the custom operator, you need to register it in qlib_init. - "cum" means that this is a cumulative value over time, such as cumulative market volume. - So when it is used as a volume limit, it is necessary to subtract the dealed amount. + So when it is used as a volume limit, it is necessary to subtract the dealt amount. - "current" means that this is a real-time value and will not accumulate over time, so it can be directly used as a capacity limit. e.g. ("cum", "0.2 * DayCumsum($volume, '9:45', '14:45')"), ("current", "$bidV1") @@ -304,10 +304,10 @@ class Exchange: if isinstance(volume_threshold, tuple): volume_threshold = {"all": volume_threshold} - assert type(volume_threshold) == dict + assert isinstance(volume_threshold, dict) for key in volume_threshold: vol_limit = volume_threshold[key] - assert type(vol_limit) == tuple + assert isinstance(vol_limit, tuple) fields.add(vol_limit[1]) if key in ("buy", "all"): @@ -370,7 +370,7 @@ class Exchange: order, trade_account: Account = None, position: BasePosition = None, - dealed_order_amount: defaultdict = defaultdict(float), + dealt_order_amount: defaultdict = defaultdict(float), ): """ Deal order when the actual transaction @@ -380,7 +380,7 @@ class Exchange: :param order: Deal the order. :param trade_account: Trade account to be updated after dealing the order. :param position: position to be updated after dealing the order. - :param dealed_order_amount: the dealed order amount dict with the format of {stock_id: float} + :param dealt_order_amount: the dealt order amount dict with the format of {stock_id: float} :return: trade_val, trade_cost, trade_price """ # check order first. @@ -395,7 +395,7 @@ class Exchange: trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, order.direction) # NOTE: order will be changed in this function trade_val, trade_cost = self._calc_trade_info_by_order( - order, trade_account.current if trade_account else position, dealed_order_amount + order, trade_account.current if trade_account else position, dealt_order_amount ) if order.deal_amount > 1e-5: # If the order can only be deal 0 amount. Nothing to be updated @@ -659,15 +659,15 @@ class Exchange: return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount - def _get_amount_by_volume(self, order: Order, dealed_order_amount: dict) -> int: + def _get_amount_by_volume(self, order: Order, dealt_order_amount: dict) -> int: """parse the capacity limit string and return the actual amount of orders that can be executed. Parameters ---------- order : Order the order to be executed. - dealed_order_amount : dict - :param dealed_order_amount: the dealed order amount dict with the format of {stock_id: float} + dealt_order_amount : dict + :param dealt_order_amount: the dealt order amount dict with the format of {stock_id: float} Returns ------- @@ -685,33 +685,23 @@ class Exchange: vol_limit_num = [] for limit in vol_limit: assert isinstance(limit, tuple) + limit_value = self.quote.get_data( + order.stock_id, + order.start_time, + order.end_time, + fields=limit[1], + method=ts_data_last, + ) if limit[0] == "current": - vol_limit_num.append( - self.quote.get_data( - order.stock_id, - order.start_time, - order.end_time, - fields=limit[1], - method=ts_data_last, - ) - ) + vol_limit_num.append(limit_value) elif limit[0] == "cum": - vol_limit_num.append( - self.quote.get_data( - order.stock_id, - order.start_time, - order.end_time, - fields=limit[1], - method=ts_data_last, - ) - - dealed_order_amount[order.stock_id] - ) + vol_limit_num.append(limit_value - dealt_order_amount[order.stock_id]) else: raise ValueError(f"{limit[0]} is not supported") vol_limit_num = min(vol_limit_num) return max(min(vol_limit_num, order.deal_amount), 0) - def _calc_trade_info_by_order(self, order, position: Position, dealed_order_amount): + def _calc_trade_info_by_order(self, order, position: Position, dealt_order_amount): """ Calculation of trade info @@ -719,7 +709,7 @@ class Exchange: :param order: :param position: Position - :param dealed_order_amount: the dealed order amount dict with the format of {stock_id: float} + :param dealt_order_amount: the dealt order amount dict with the format of {stock_id: float} :return: trade_val, trade_cost """ @@ -743,7 +733,7 @@ class Exchange: # We choose to sell all order.deal_amount = order.amount - order.deal_amount = self._get_amount_by_volume(order, dealed_order_amount) + order.deal_amount = self._get_amount_by_volume(order, dealt_order_amount) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.close_cost, self.min_cost) elif order.direction == Order.BUY: @@ -763,7 +753,7 @@ class Exchange: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - order.deal_amount = self._get_amount_by_volume(order, dealed_order_amount) + order.deal_amount = self._get_amount_by_volume(order, dealt_order_amount) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.open_cost, self.min_cost) else: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index 6b44bd1b7..e7882714a 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -115,7 +115,7 @@ class BaseExecutor: get_module_logger("BaseExecutor").warning(f"`common_infra` is not set for {self}") # record deal order amount in one day - self.dealed_order_amount = defaultdict(float) + self.dealt_order_amount = defaultdict(float) self.deal_day = None def reset_common_infra(self, common_infra): @@ -500,14 +500,14 @@ class SimulatorExecutor(BaseExecutor): raise NotImplementedError(f"This type of input is not supported") return order_it - def _update_dealed_order_amount(self, order): - """update date and dealed order amount in the day.""" + def _update_dealt_order_amount(self, order): + """update date and dealt order amount in the day.""" now_deal_day = self.trade_calendar.get_step_time()[0].floor(freq="D") if self.deal_day is None or now_deal_day > self.deal_day: - self.dealed_order_amount = defaultdict(float) + self.dealt_order_amount = defaultdict(float) self.deal_day = now_deal_day - self.dealed_order_amount[order.stock_id] += order.deal_amount + self.dealt_order_amount[order.stock_id] += order.deal_amount def _collect_data(self, trade_decision: BaseTradeDecision, level: int = 0): @@ -520,10 +520,10 @@ class SimulatorExecutor(BaseExecutor): trade_val, trade_cost, trade_price = self.trade_exchange.deal_order( order, trade_account=self.trade_account, - dealed_order_amount=self.dealed_order_amount, + dealt_order_amount=self.dealt_order_amount, ) execute_result.append((order, trade_val, trade_cost, trade_price)) - self._update_dealed_order_amount(order) + self._update_dealt_order_amount(order) if self.verbose: print( "[I {:%Y-%m-%d %H:%M:%S}]: {} {}, price {:.2f}, amount {}, deal_amount {}, factor {}, value {:.2f}, cash {:.2f}.".format( diff --git a/qlib/contrib/ops/high_freq.py b/qlib/contrib/ops/high_freq.py index 6f03b71cf..3ce5c961f 100644 --- a/qlib/contrib/ops/high_freq.py +++ b/qlib/contrib/ops/high_freq.py @@ -10,6 +10,7 @@ from qlib.data import D from qlib.data.cache import H from qlib.data.data import Cal from qlib.data.ops import ElemOperator +from qlib.utils.time import time_to_day_index def get_calendar_day(freq="1min", future=False): @@ -71,18 +72,11 @@ class DayCumsum(ElemOperator): self.noon_open = datetime.strptime("13:00", "%H:%M") self.noon_close = datetime.strptime("15:00", "%H:%M") - self.start_id = self.time_to_index(self.start) - self.end_id = self.time_to_index(self.end) - - def time_to_index(self, t): - if t >= self.morning_open and t < self.morning_close: - return int((t - self.morning_open).total_seconds() / 60) - elif t >= self.noon_open and t < self.noon_close: - return int((t - self.noon_open).total_seconds() / 60) + 120 - else: - raise ValueError(f"{t} is not the opening time of the stock market") + self.start_id = time_to_day_index(self.start) + self.end_id = time_to_day_index(self.end) def period_cusum(self, df): + df = df.copy() assert len(df) == 240 df.iloc[0 : self.start_id] = 0 df = df.cumsum() diff --git a/qlib/utils/time.py b/qlib/utils/time.py index e365de6d8..54d30a9aa 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -11,6 +11,7 @@ from numpy import append import pandas as pd from qlib.config import C import functools +from typing import Union @functools.lru_cache(maxsize=240) @@ -96,6 +97,29 @@ class Freq: return _count, _freq_format_dict[_freq] +cn_time = [datetime.strptime("9:30", "%H:%M"), datetime.strptime("11:30", "%H:%M"), + datetime.strptime("13:00", "%H:%M"), datetime.strptime("15:00", "%H:%M")] +us_time = [datetime.strptime("9:30", "%H:%M"), datetime.strptime("16:00", "%H:%M")] +def time_to_day_index(time_obj: Union[str, datetime], region: str="cn"): + if isinstance(time_obj, str): + time_obj = datetime.strptime(time_obj, "%H:%M") + + if region == "cn": + if time_obj >= cn_time[0] and time_obj < cn_time[1]: + return int((time_obj - cn_time[0]).total_seconds() / 60) + elif time_obj >= cn_time[2] and time_obj < cn_time[3]: + return int((time_obj - cn_time[2]).total_seconds() / 60) + 120 + else: + raise ValueError(f"{time_obj} is not the opening time of the {region} stock market") + elif region == "us": + if time_obj >= us_time[0] and time_obj < us_time[1]: + return int((time_obj - us_time[0]).total_seconds() / 60) + else: + raise ValueError(f"{time_obj} is not the opening time of the {region} stock market") + else: + raise ValueError(f"{region} is not supported") + + def get_day_min_idx_range(start: str, end: str, freq: str) -> Tuple[int, int]: """ get the min-bar index in a day for a time range (both left and right is closed) given a fixed frequency From 3ff1d91d61ff763ddd6e8eca80c7e5970c8f5356 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Mon, 2 Aug 2021 07:45:03 +0000 Subject: [PATCH 153/187] add __init__ --- qlib/contrib/ops/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 qlib/contrib/ops/__init__.py diff --git a/qlib/contrib/ops/__init__.py b/qlib/contrib/ops/__init__.py new file mode 100644 index 000000000..e69de29bb From 8e87950292bf2fb1a852add9dc0cf1275d8be53d Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 4 Aug 2021 11:03:38 +0000 Subject: [PATCH 154/187] Print volume limitation log --- qlib/backtest/backtest.py | 2 +- qlib/backtest/exchange.py | 52 +++++++++++++++++++++++++-------------- qlib/config.py | 19 +++++++++++++- qlib/utils/time.py | 12 ++++++--- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index 5c9948b1b..c707aa1f0 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from qlib.strategy.base import BaseStrategy -from qlib.backtest.executor import BaseExecutor + from qlib.backtest.executor import BaseExecutor from ..utils.time import Freq from tqdm.auto import tqdm diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 43b2e95d6..0aab35e67 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -137,9 +137,10 @@ class Exchange: if deal_price is None: deal_price = C.deal_price - self.logger = get_module_logger("online operator", level=logging.INFO) + # we have some verbose information here. So logging is enable + self.logger = get_module_logger("online operator") - # TODO: the quote, trade_dates, codes are not necessray. + # TODO: the quote, trade_dates, codes are not necessary. # It is just for performance consideration. self.limit_type = self._get_limit_type(limit_threshold) if limit_threshold is None: @@ -387,6 +388,7 @@ class Exchange: if self.check_order(order) is False: order.deal_amount = 0.0 # using np.nan instead of None to make it more convenient to should the value in format string + self.logger.debug(f"Order failed due to trading limitation: {order}") return 0.0, 0.0, np.nan if trade_account is not None and position is not None: @@ -659,20 +661,19 @@ class Exchange: return (deal_amount * factor + 0.1) // self.trade_unit * self.trade_unit / factor return deal_amount - def _get_amount_by_volume(self, order: Order, dealt_order_amount: dict) -> int: + def _clip_amount_by_volume(self, order: Order, dealt_order_amount: dict) -> int: """parse the capacity limit string and return the actual amount of orders that can be executed. + NOTE: + this function will change the order.deal_amount **inplace** + - This will make the order info more accurate + Parameters ---------- order : Order the order to be executed. dealt_order_amount : dict :param dealt_order_amount: the dealt order amount dict with the format of {stock_id: float} - - Returns - ------- - int - the actual amount of orders that can be executed, due to the volume limit. """ if order.direction == Order.BUY: vol_limit = self.buy_vol_limit @@ -685,21 +686,33 @@ class Exchange: vol_limit_num = [] for limit in vol_limit: assert isinstance(limit, tuple) - limit_value = self.quote.get_data( - order.stock_id, - order.start_time, - order.end_time, - fields=limit[1], - method=ts_data_last, - ) if limit[0] == "current": + limit_value = self.quote.get_data( + order.stock_id, + order.start_time, + order.end_time, + fields=limit[1], + method="sum", + ) vol_limit_num.append(limit_value) elif limit[0] == "cum": + limit_value = self.quote.get_data( + order.stock_id, + order.start_time, + order.end_time, + fields=limit[1], + method=ts_data_last, + ) vol_limit_num.append(limit_value - dealt_order_amount[order.stock_id]) else: raise ValueError(f"{limit[0]} is not supported") - vol_limit_num = min(vol_limit_num) - return max(min(vol_limit_num, order.deal_amount), 0) + vol_limit_min = min(vol_limit_num) + orig_deal_amount = order.deal_amount + order.deal_amount = max(min(vol_limit_min, orig_deal_amount), 0) + if vol_limit_min < orig_deal_amount: + self.logger.debug( + f"Order clipped due to volume limitation: {order}, {[(vol, rule) for vol, rule in zip(vol_limit_num, vol_limit)]}" + ) def _calc_trade_info_by_order(self, order, position: Position, dealt_order_amount): """ @@ -733,7 +746,7 @@ class Exchange: # We choose to sell all order.deal_amount = order.amount - order.deal_amount = self._get_amount_by_volume(order, dealt_order_amount) + self._clip_amount_by_volume(order, dealt_order_amount) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.close_cost, self.min_cost) elif order.direction == Order.BUY: @@ -746,6 +759,7 @@ class Exchange: order.deal_amount = self.round_amount_by_trade_unit( cash / (1 + self.open_cost) / trade_price, order.factor ) + self.logger.debug(f"Order clipped due to cash limitation: {order}") else: # THe money is enough order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) @@ -753,7 +767,7 @@ class Exchange: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - order.deal_amount = self._get_amount_by_volume(order, dealt_order_amount) + self._clip_amount_by_volume(order, dealt_order_amount) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * self.open_cost, self.min_cost) else: diff --git a/qlib/config.py b/qlib/config.py index 7ed0aeed3..18984c7ce 100644 --- a/qlib/config.py +++ b/qlib/config.py @@ -273,7 +273,24 @@ class QlibConfig(Config): else: raise NotImplementedError(f"This type of uri is not supported") - def set(self, default_conf="client", **kwargs): + def set(self, default_conf: str = "client", **kwargs): + """ + configure qlib based on the input parameters + + The configure will act like a dictionary. + + Normally, it literally replace the value according to the keys. + However, sometimes it is hard for users to set the config when the configure is nested and complicated + + So this API provides some special parameters for users to set the keys in a more convenient way. + - region: REG_CN, REG_US + - several region-related config will be changed + + Parameters + ---------- + default_conf : str + the default config template chosen by user: "server", "client" + """ from .utils import set_log_with_config, get_module_logger, can_use_cache self.reset() diff --git a/qlib/utils/time.py b/qlib/utils/time.py index 54d30a9aa..f61c825d2 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -97,10 +97,16 @@ class Freq: return _count, _freq_format_dict[_freq] -cn_time = [datetime.strptime("9:30", "%H:%M"), datetime.strptime("11:30", "%H:%M"), - datetime.strptime("13:00", "%H:%M"), datetime.strptime("15:00", "%H:%M")] +cn_time = [ + datetime.strptime("9:30", "%H:%M"), + datetime.strptime("11:30", "%H:%M"), + datetime.strptime("13:00", "%H:%M"), + datetime.strptime("15:00", "%H:%M"), +] us_time = [datetime.strptime("9:30", "%H:%M"), datetime.strptime("16:00", "%H:%M")] -def time_to_day_index(time_obj: Union[str, datetime], region: str="cn"): + + +def time_to_day_index(time_obj: Union[str, datetime], region: str = "cn"): if isinstance(time_obj, str): time_obj = datetime.strptime(time_obj, "%H:%M") From 74e1ee69211b93547f61e46264df00162e1e24f9 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 6 Aug 2021 04:34:30 +0000 Subject: [PATCH 155/187] update position and negative cash --- qlib/backtest/__init__.py | 18 +++++--- qlib/backtest/account.py | 5 ++- qlib/backtest/exchange.py | 90 ++++++++++++++++++++++++++++----------- qlib/backtest/position.py | 36 ++++++++-------- qlib/utils/time.py | 16 +++---- 5 files changed, 106 insertions(+), 59 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 1babd08c7..948af670a 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -96,7 +96,7 @@ def get_exchange( def create_account_instance( - start_time, end_time, benchmark: str, account: Union[float, int, Position], pos_type: str = "Position" + start_time, end_time, benchmark: str, account: Union[float, int, dict], pos_type: str = "Position" ) -> Account: """ # TODO: is very strange pass benchmark_config in the account(maybe for report) @@ -110,19 +110,23 @@ def create_account_instance( end time of the benchmark benchmark : str the benchmark for reporting - account : Union[float, int, Position] + account : Union[float, int, {"cash": float, "stock1": {"amount": int, "price"(optional): float}, "stock2": {"amount": int}}] information for describing how to creating the account For `float` or `int`: Using Account with only initial cash - For `Position`: - Using Account with a Position + For `dict`: + key "cash" means initial cash. + key "stock1" means the first stock information with amount and price(optional). + ... """ if isinstance(account, (int, float)): pos_kwargs = {"init_cash": account} - elif isinstance(account, Position): + elif isinstance(account, dict): + init_cash = account["cash"] + del account["cash"] pos_kwargs = { - "init_cash": account.position["cash"], - "position_dict": account.position, + "init_cash": init_cash, + "position_dict": account, } else: raise ValueError("account must be in (int, float, Position)") diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 542c0fba2..cc984b061 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -100,7 +100,6 @@ class Account: "module_path": "qlib.backtest.position", } ) - self.accum_info = AccumulatedInfo() self.report = None self.positions = {} @@ -119,8 +118,11 @@ class Account: def reset_report(self, freq, benchmark_config): # portfolio related metrics if self.is_port_metr_enabled(): + self.accum_info = AccumulatedInfo() self.report = Report(freq, benchmark_config) self.positions = {} + # fill stock value + self.current.fill_stock_value(self.benchmark_config["start_time"], self.freq) # trading related metrics(e.g. high-frequency trading) self.indicator = Indicator() @@ -309,6 +311,7 @@ class Account: self.update_current(trade_start_time, trade_end_time, trade_exchange) if self.is_port_metr_enabled(): # report is portfolio related analysis + print(trade_start_time, trade_end_time) self.update_report(trade_start_time, trade_end_time) # TODO: will skip empty decisions make it faster? `outer_trade_decision.empty():` diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 0aab35e67..d64af0172 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -394,9 +394,8 @@ class Exchange: if trade_account is not None and position is not None: raise ValueError("trade_account and position can only choose one") - trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, order.direction) # NOTE: order will be changed in this function - trade_val, trade_cost = self._calc_trade_info_by_order( + trade_price, trade_val, trade_cost = self._calc_trade_info_by_order( order, trade_account.current if trade_account else position, dealt_order_amount ) if order.deal_amount > 1e-5: @@ -714,6 +713,63 @@ class Exchange: f"Order clipped due to volume limitation: {order}, {[(vol, rule) for vol, rule in zip(vol_limit_num, vol_limit)]}" ) + def _cal_trade_amount_by_cash_limit(self, now_trade_amount, trade_price, order, position): + """return the real order amount after cash limit. + + Parameters + ---------- + now_trade_amount : float + trade_price : float + order : Order + position : Position + + Return + ---------- + float + the real order amount after cash limit. + """ + cash = position.get_cash() + trade_val = now_trade_amount * trade_price + if order.direction == Order.SELL: + if cash < trade_val * self.close_cost: + # The money is not enough + self.logger.debug(f"Order clipped due to cash limitation: {order}") + return self.round_amount_by_trade_unit(cash / self.close_cost, order.factor) + elif order.direction == Order.BUY: + if cash < trade_val * (1 + self.open_cost): + # The money is not enough + self.logger.debug(f"Order clipped due to cash limitation: {order}") + return self.round_amount_by_trade_unit(cash / (1 + self.open_cost) / trade_price, order.factor) + + # The money is enough + return self.round_amount_by_trade_unit(now_trade_amount, order.factor) + + def _cal_trade_amount_by_stock_limit(self, now_trade_amount, order, position): + """return the real order amount after stock amount limit. + + Parameters + ---------- + now_trade_amount : float + order : Order + position : Position + + Return + ---------- + float + the real order amount after stock amount limit. + """ + if order.direction == Order.SELL: + current_amount = position.get_stock_amount(order.stock_id) if position.check_stock(order.stock_id) else 0 + if np.isclose(now_trade_amount, current_amount): + # when selling last stock. The amount don't need rounding + return now_trade_amount + elif now_trade_amount > current_amount: + return self.round_amount_by_trade_unit(current_amount, order.factor) + else: + return self.round_amount_by_trade_unit(now_trade_amount, order.factor) + elif order.direction == Order.BUY: + return self.round_amount_by_trade_unit(now_trade_amount, order.factor) + def _calc_trade_info_by_order(self, order, position: Position, dealt_order_amount): """ Calculation of trade info @@ -731,16 +787,10 @@ class Exchange: if order.direction == Order.SELL: # sell if position is not None: - current_amount = ( - position.get_stock_amount(order.stock_id) if position.check_stock(order.stock_id) else 0 - ) - if np.isclose(order.amount, current_amount): - # when selling last stock. The amount don't need rounding - order.deal_amount = order.amount - elif order.amount > current_amount: - order.deal_amount = self.round_amount_by_trade_unit(current_amount, order.factor) - else: - order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + now_trade_amount = order.amount + now_trade_amount = self._cal_trade_amount_by_stock_limit(now_trade_amount, order, position) + now_trade_amount = self._cal_trade_amount_by_cash_limit(now_trade_amount, trade_price, order, position) + order.deal_amount = now_trade_amount else: # TODO: We don't know current position. # We choose to sell all @@ -752,17 +802,9 @@ class Exchange: elif order.direction == Order.BUY: # buy if position is not None: - cash = position.get_cash() - trade_val = order.amount * trade_price - if cash < trade_val * (1 + self.open_cost): - # The money is not enough - order.deal_amount = self.round_amount_by_trade_unit( - cash / (1 + self.open_cost) / trade_price, order.factor - ) - self.logger.debug(f"Order clipped due to cash limitation: {order}") - else: - # THe money is enough - order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + now_trade_amount = order.amount + now_trade_amount = self._cal_trade_amount_by_cash_limit(now_trade_amount, trade_price, order, position) + order.deal_amount = now_trade_amount else: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) @@ -773,7 +815,7 @@ class Exchange: else: raise NotImplementedError("order type {} error".format(order.type)) - return trade_val, trade_cost + return trade_price, trade_val, trade_cost def get_order_helper(self) -> OrderHelper: if not hasattr(self, "_order_helper"): diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index e4f1ab40c..6747d7a7a 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -256,37 +256,33 @@ class Position(BasePosition): # NOTE: The position dict must be copied!!! # Otherwise the initial value self.init_cash = cash - self.position = position_dict.copy() + self.init_stock_info = position_dict.copy() + self.position = self.init_stock_info.copy() self.position["cash"] = cash - self.position["now_account_value"] = self.calculate_value() - def _fill_stock_value( - self, position_dict: dict, start_time: Union[str, pd.Timestamp], freq: str, last_days: int = 30 - ): + # If the stock price information is missing, the account value will not be calculated temporarily + try: + self.position["now_account_value"] = self.calculate_value() + except KeyError: + pass + + def fill_stock_value(self, start_time: Union[str, pd.Timestamp], freq: str, last_days: int = 30): """fill the stock value by the close price of latest last_days from qlib. Parameters ---------- - position_dict : Dict[stock_id, {"amount": int, "price": float}] - initial holding stocks. start_time : the start time of backtest. last_days : int, optional the days to get the latest close price, by default 30. - - Return - ---------- - Dict[stock_id, {"amount": int, "price": float}] - initial holding stocks with filled price. """ - stock_list = [] - for stock in position_dict: - if ("price" not in position_dict[stock]) or (position_dict[stock]["price"] is None): + for stock in self.init_stock_info: + if ("price" not in self.position[stock]) or (self.position[stock]["price"] is None): stock_list.append(stock) if len(stock_list) == 0: - return position_dict + return start_time = pd.Timestamp(start_time) # note that start time is 2020-01-01 00:00:00 if raw start time is "2020-01-01" @@ -298,11 +294,13 @@ class Position(BasePosition): price_dict = price_df.groupby(["instrument"]).tail(1).reset_index(level=1, drop=True)["$close"].to_dict() if len(price_dict) < len(stock_list): - raise ValueError(f"there is no close price in qlib") + lack_stock = set(stock_list) - set(price_dict) + raise ValueError(f"{lack_stock} doesn't have close price in qlib in the latest {last_days} days") for stock in stock_list: - position_dict[stock]["price"] = price_dict[stock] - return position_dict + self.init_stock_info[stock]["price"] = price_dict[stock] + self.position.update(self.init_stock_info) + self.position["now_account_value"] = self.calculate_value() def _init_stock(self, stock_id, amount, price=None): """ diff --git a/qlib/utils/time.py b/qlib/utils/time.py index f61c825d2..c18d76b14 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -97,13 +97,13 @@ class Freq: return _count, _freq_format_dict[_freq] -cn_time = [ +CN_TIME = [ datetime.strptime("9:30", "%H:%M"), datetime.strptime("11:30", "%H:%M"), datetime.strptime("13:00", "%H:%M"), datetime.strptime("15:00", "%H:%M"), ] -us_time = [datetime.strptime("9:30", "%H:%M"), datetime.strptime("16:00", "%H:%M")] +US_TIME = [datetime.strptime("9:30", "%H:%M"), datetime.strptime("16:00", "%H:%M")] def time_to_day_index(time_obj: Union[str, datetime], region: str = "cn"): @@ -111,15 +111,15 @@ def time_to_day_index(time_obj: Union[str, datetime], region: str = "cn"): time_obj = datetime.strptime(time_obj, "%H:%M") if region == "cn": - if time_obj >= cn_time[0] and time_obj < cn_time[1]: - return int((time_obj - cn_time[0]).total_seconds() / 60) - elif time_obj >= cn_time[2] and time_obj < cn_time[3]: - return int((time_obj - cn_time[2]).total_seconds() / 60) + 120 + if time_obj >= CN_TIME[0] and time_obj < CN_TIME[1]: + return int((time_obj - CN_TIME[0]).total_seconds() / 60) + elif time_obj >= CN_TIME[2] and time_obj < CN_TIME[3]: + return int((time_obj - CN_TIME[2]).total_seconds() / 60) + 120 else: raise ValueError(f"{time_obj} is not the opening time of the {region} stock market") elif region == "us": - if time_obj >= us_time[0] and time_obj < us_time[1]: - return int((time_obj - us_time[0]).total_seconds() / 60) + if time_obj >= US_TIME[0] and time_obj < US_TIME[1]: + return int((time_obj - US_TIME[0]).total_seconds() / 60) else: raise ValueError(f"{time_obj} is not the opening time of the {region} stock market") else: From 7c858803f0d9d622be2648204c7816ab11f2f2fd Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sun, 8 Aug 2021 14:32:33 +0000 Subject: [PATCH 156/187] add position test --- qlib/backtest/__init__.py | 19 +++-- qlib/backtest/account.py | 21 +++-- qlib/backtest/exchange.py | 73 ++++++++-------- qlib/backtest/position.py | 21 +++-- tests/backtest/test_file_strategy.py | 4 +- tests/backtest/test_init_position.py | 119 +++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 52 deletions(-) create mode 100644 tests/backtest/test_init_position.py diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 948af670a..bcd07fa23 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -104,19 +104,28 @@ def create_account_instance( Parameters ---------- - start_time : + start_time start time of the benchmark - end_time : + end_time end time of the benchmark benchmark : str the benchmark for reporting - account : Union[float, int, {"cash": float, "stock1": {"amount": int, "price"(optional): float}, "stock2": {"amount": int}}] + account : Union[ + float, + { + "cash": float, + "stock1": Union[ + int, # it is equal to {"amount": int} + {"amount": int, "price"(optional): float}, + ] + }, + ] information for describing how to creating the account - For `float` or `int`: + For `float`: Using Account with only initial cash For `dict`: key "cash" means initial cash. - key "stock1" means the first stock information with amount and price(optional). + key "stock1" means the information of first stock with amount and price(optional). ... """ if isinstance(account, (int, float)): diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index cc984b061..69065536e 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -80,9 +80,15 @@ class Account: ---------- init_cash : float, optional initial cash, by default 1e9 - position_dict : Dict[stock_id, {"amount": int, "price"(optional): float}], optional - initial stocks with amount and price, - if there is no price key in the dict of stocks, it will be filled by latest close price from qlib. + position_dict : Dict[ + stock_id, + Union[ + int, # it is equal to {"amount": int} + {"amount": int, "price"(optional): float}, + ] + ] + initial stocks with parameters amount and price, + if there is no price key in the dict of stocks, it will be filled by _fill_stock_value. by default {}. """ @@ -122,6 +128,8 @@ class Account: self.report = Report(freq, benchmark_config) self.positions = {} # fill stock value + # The frequency of account may not align with the trading frequency. + # This may result in obscure bugs when data quality is low. self.current.fill_stock_value(self.benchmark_config["start_time"], self.freq) # trading related metrics(e.g. high-frequency trading) @@ -186,7 +194,8 @@ class Account: # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation if order.direction == Order.SELL: # sell stock - self._update_state_from_order(order, trade_val, cost, trade_price) + if getattr(self, "accum_info") is not None: + self._update_state_from_order(order, trade_val, cost, trade_price) # update current position # for may sell all of stock_id self.current.update_order(order, trade_val, cost, trade_price) @@ -194,7 +203,8 @@ class Account: # buy stock # deal order, then update state self.current.update_order(order, trade_val, cost, trade_price) - self._update_state_from_order(order, trade_val, cost, trade_price) + if getattr(self, "accum_info") is not None: + self._update_state_from_order(order, trade_val, cost, trade_price) def update_bar_count(self): """at the end of the trading bar, update holding bar, count of stock""" @@ -311,7 +321,6 @@ class Account: self.update_current(trade_start_time, trade_end_time, trade_exchange) if self.is_port_metr_enabled(): # report is portfolio related analysis - print(trade_start_time, trade_end_time) self.update_report(trade_start_time, trade_end_time) # TODO: will skip empty decisions make it faster? `outer_trade_decision.empty():` diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index d64af0172..91c8ca30d 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -713,12 +713,11 @@ class Exchange: f"Order clipped due to volume limitation: {order}, {[(vol, rule) for vol, rule in zip(vol_limit_num, vol_limit)]}" ) - def _cal_trade_amount_by_cash_limit(self, now_trade_amount, trade_price, order, position): + def _get_max_amount_by_cash_limit(self, trade_price, order, position): """return the real order amount after cash limit. Parameters ---------- - now_trade_amount : float trade_price : float order : Order position : Position @@ -729,27 +728,24 @@ class Exchange: the real order amount after cash limit. """ cash = position.get_cash() - trade_val = now_trade_amount * trade_price - if order.direction == Order.SELL: - if cash < trade_val * self.close_cost: - # The money is not enough - self.logger.debug(f"Order clipped due to cash limitation: {order}") - return self.round_amount_by_trade_unit(cash / self.close_cost, order.factor) - elif order.direction == Order.BUY: - if cash < trade_val * (1 + self.open_cost): - # The money is not enough - self.logger.debug(f"Order clipped due to cash limitation: {order}") - return self.round_amount_by_trade_unit(cash / (1 + self.open_cost) / trade_price, order.factor) + max_trade_amount = 0 + if cash >= self.min_cost: + if order.direction == Order.SELL: + max_trade_amount = cash / self.close_cost / trade_price + elif order.direction == Order.BUY: + critical_amount = self.min_cost / (self.open_cost * trade_price) + critical_price = critical_amount * trade_price + self.min_cost + if cash >= critical_price: + max_trade_amount = cash / (1 + self.open_cost) / trade_price + else: + max_trade_amount = (cash - self.min_cost) / trade_price + return max_trade_amount - # The money is enough - return self.round_amount_by_trade_unit(now_trade_amount, order.factor) - - def _cal_trade_amount_by_stock_limit(self, now_trade_amount, order, position): + def _get_max_amount_by_stock_limit(self, order, position): """return the real order amount after stock amount limit. Parameters ---------- - now_trade_amount : float order : Order position : Position @@ -760,15 +756,9 @@ class Exchange: """ if order.direction == Order.SELL: current_amount = position.get_stock_amount(order.stock_id) if position.check_stock(order.stock_id) else 0 - if np.isclose(now_trade_amount, current_amount): - # when selling last stock. The amount don't need rounding - return now_trade_amount - elif now_trade_amount > current_amount: - return self.round_amount_by_trade_unit(current_amount, order.factor) - else: - return self.round_amount_by_trade_unit(now_trade_amount, order.factor) + return current_amount elif order.direction == Order.BUY: - return self.round_amount_by_trade_unit(now_trade_amount, order.factor) + return np.inf def _calc_trade_info_by_order(self, order, position: Position, dealt_order_amount): """ @@ -779,18 +769,33 @@ class Exchange: :param order: :param position: Position :param dealt_order_amount: the dealt order amount dict with the format of {stock_id: float} - :return: trade_val, trade_cost + :return: trade_price, trade_val, trade_cost """ trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction) order.factor = self.get_factor(order.stock_id, order.start_time, order.end_time) + # get all limits amount + # cash limit + cash_max_amount = self._get_max_amount_by_cash_limit(trade_price, order, position) + # held stock limit + stock_max_amount = self._get_max_amount_by_stock_limit(order, position) + if order.direction == Order.SELL: # sell if position is not None: - now_trade_amount = order.amount - now_trade_amount = self._cal_trade_amount_by_stock_limit(now_trade_amount, order, position) - now_trade_amount = self._cal_trade_amount_by_cash_limit(now_trade_amount, trade_price, order, position) - order.deal_amount = now_trade_amount + if np.isclose(order.amount, stock_max_amount): + # when selling last stock. The amount don't need rounding + if stock_max_amount <= cash_max_amount: + order.deal_amount = stock_max_amount + else: + order.deal_amount = self.round_amount_by_trade_unit(cash_max_amount, order.factor) + else: + now_trade_amount = min(order.amount, stock_max_amount) + if now_trade_amount > cash_max_amount: + self.logger.debug(f"Order clipped due to cash limitation: {order}") + order.deal_amount = self.round_amount_by_trade_unit( + min(now_trade_amount, cash_max_amount), order.factor + ) else: # TODO: We don't know current position. # We choose to sell all @@ -802,9 +807,9 @@ class Exchange: elif order.direction == Order.BUY: # buy if position is not None: - now_trade_amount = order.amount - now_trade_amount = self._cal_trade_amount_by_cash_limit(now_trade_amount, trade_price, order, position) - order.deal_amount = now_trade_amount + if order.amount > cash_max_amount: + self.logger.debug(f"Order clipped due to cash limitation: {order}") + order.deal_amount = self.round_amount_by_trade_unit(min(order.amount, cash_max_amount), order.factor) else: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 6747d7a7a..234ec08b9 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -246,7 +246,13 @@ class Position(BasePosition): the start time of backtest. It's for filling the initial value of stocks. cash : float, optional initial cash in account, by default 0 - position_dict : Dict[stock_id, {"amount": int, "price"(optional): float}], optional + position_dict : Dict[ + stock_id, + Union[ + int, # it is equal to {"amount": int} + {"amount": int, "price"(optional): float}, + ] + ] initial stocks with parameters amount and price, if there is no price key in the dict of stocks, it will be filled by _fill_stock_value. by default {}. @@ -256,8 +262,10 @@ class Position(BasePosition): # NOTE: The position dict must be copied!!! # Otherwise the initial value self.init_cash = cash - self.init_stock_info = position_dict.copy() - self.position = self.init_stock_info.copy() + self.position = position_dict.copy() + for stock in self.position: + if isinstance(self.position[stock], int): + self.position[stock] = {"amount": self.position[stock]} self.position["cash"] = cash # If the stock price information is missing, the account value will not be calculated temporarily @@ -277,7 +285,9 @@ class Position(BasePosition): the days to get the latest close price, by default 30. """ stock_list = [] - for stock in self.init_stock_info: + for stock in self.position: + if not isinstance(self.position[stock], dict): + continue if ("price" not in self.position[stock]) or (self.position[stock]["price"] is None): stock_list.append(stock) @@ -298,8 +308,7 @@ class Position(BasePosition): raise ValueError(f"{lack_stock} doesn't have close price in qlib in the latest {last_days} days") for stock in stock_list: - self.init_stock_info[stock]["price"] = price_dict[stock] - self.position.update(self.init_stock_info) + self.position[stock]["price"] = price_dict[stock] self.position["now_account_value"] = self.calculate_value() def _init_stock(self, stock_id, amount, price=None): diff --git a/tests/backtest/test_file_strategy.py b/tests/backtest/test_file_strategy.py index 8210e4809..cbc29bad7 100644 --- a/tests/backtest/test_file_strategy.py +++ b/tests/backtest/test_file_strategy.py @@ -27,6 +27,8 @@ class FileStrTest(TestAutoData): ["20200102", self.TEST_INST, "1000", "sell"], ["20200103", self.TEST_INST, "1000", "buy"], ["20200106", self.TEST_INST, "1000", "sell"], + ["20200106", self.TEST_INST, "1000", "buy"], + ["20200106", self.TEST_INST, "949.7773413058803", "sell"], ] return pd.DataFrame(orders, columns=headers).set_index(["datetime", "instrument"]) @@ -62,7 +64,7 @@ class FileStrTest(TestAutoData): "close_cost": 0.0015, "min_cost": 5, "codes": codes, - "trade_unit": None, + "trade_unit": 100, }, # "pos_type": "InfPosition" # Position with infinitive position } diff --git a/tests/backtest/test_init_position.py b/tests/backtest/test_init_position.py new file mode 100644 index 000000000..ec3dca714 --- /dev/null +++ b/tests/backtest/test_init_position.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest +import qlib +from qlib.backtest import backtest, order +from qlib.tests import TestAutoData +from qlib.backtest.order import TradeDecisionWO, TradeRangeByTime +import pandas as pd +from pathlib import Path + + +class FileStrTest(TestAutoData): + + TEST_INST = "SH600519" + + def init_qlib(self): + provider_uri_day = "/nfs_data1/stock_data/huaxia_1d_qlib" + provider_uri_1min = "/nfs_data1/stock_data/huaxia_1min_qlib" + provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} + + client_config = { + "calendar_provider": { + "class": "LocalCalendarProvider", + "module_path": "qlib.data.data", + "kwargs": { + "backend": { + "class": "FileCalendarStorage", + "module_path": "qlib.data.storage.file_storage", + "kwargs": {"provider_uri_map": provider_uri_map}, + } + }, + }, + "feature_provider": { + "class": "LocalFeatureProvider", + "module_path": "qlib.data.data", + "kwargs": { + "backend": { + "class": "FileFeatureStorage", + "module_path": "qlib.data.storage.file_storage", + "kwargs": {"provider_uri_map": provider_uri_map}, + } + }, + }, + } + qlib.init(provider_uri=provider_uri_day, **client_config, expression_cache=None, dataset_cache=None) + + def test_file_str(self): + freq = "1min" + inst = ["SH600000", "SH600011"] + start_time = "2020-01-01" + end_time = "2020-01-15 15:00" + + strategy_config = { + "class": "RandomOrderStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + "kwargs": { + "trade_range": TradeRangeByTime("9:30", "15:00"), + "sample_ratio": 1.0, + "volume_ratio": 0.01, + "market": inst, + }, + } + position_dict = { + "cash": 100000000, + "SH600000": {"amount": 100}, + "SH600011": {"amount": 101}, + } + backtest_config = { + "start_time": start_time, + "end_time": end_time, + "account": position_dict, + "benchmark": None, # benchmark is not required here for trading + "exchange_kwargs": { + "freq": freq, + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + "codes": inst, + }, + "pos_type": "Position", # Position with infinitive position + } + executor_config = { + "class": "NestedExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": "day", + "inner_executor": { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": freq, + "generate_report": False, + "verbose": False, + # "verbose": True, + "indicator_config": { + "show_indicator": False, + }, + }, + }, + "inner_strategy": { + "class": "TWAPStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + }, + "track_data": True, + "generate_report": True, + "indicator_config": { + "show_indicator": True, + }, + }, + } + self.init_qlib() + backtest(executor=executor_config, strategy=strategy_config, **backtest_config) + + +if __name__ == "__main__": + unittest.main() From 05b9fb5a47db9a6e51b86569b4fb43108b18ec3b Mon Sep 17 00:00:00 2001 From: you-n-g Date: Mon, 9 Aug 2021 19:23:17 +0800 Subject: [PATCH 157/187] Fix bug when Account.benchmark_config is None --- qlib/backtest/account.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 69065536e..a2e354f8c 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -130,7 +130,8 @@ class Account: # fill stock value # The frequency of account may not align with the trading frequency. # This may result in obscure bugs when data quality is low. - self.current.fill_stock_value(self.benchmark_config["start_time"], self.freq) + if isinstance(self.benchmark_config, dict) and self.benchmark_config.get("start_time") is not None: + self.current.fill_stock_value(self.benchmark_config["start_time"], self.freq) # trading related metrics(e.g. high-frequency trading) self.indicator = Indicator() From 735153a50dc096be50144b214f307e6ab345c07e Mon Sep 17 00:00:00 2001 From: wangwenxi-handsome <77676340+wangwenxi-handsome@users.noreply.github.com> Date: Thu, 12 Aug 2021 23:44:22 +0800 Subject: [PATCH 158/187] Cash Update (#559) * fix negative cash * update order test * fix bug * update file_order_test --- qlib/backtest/account.py | 39 +++++---- qlib/backtest/exchange.py | 106 ++++++++++-------------- tests/backtest/test_file_strategy.py | 32 +++++-- tests/backtest/test_init_position.py | 119 --------------------------- 4 files changed, 87 insertions(+), 209 deletions(-) delete mode 100644 tests/backtest/test_init_position.py diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index a2e354f8c..163ee8c26 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -165,24 +165,25 @@ class Account: def get_cash(self): return self.current.get_cash() - def _update_state_from_order(self, order, trade_val, cost, trade_price): - # update turnover - self.accum_info.add_turnover(trade_val) - # update cost - self.accum_info.add_cost(cost) + def _update_accum_info_from_order(self, order, trade_val, cost, trade_price): + if self.is_port_metr_enabled(): + # update turnover + self.accum_info.add_turnover(trade_val) + # update cost + self.accum_info.add_cost(cost) - # update return from order - trade_amount = trade_val / trade_price - if order.direction == Order.SELL: # 0 for sell - # when sell stock, get profit from price change - profit = trade_val - self.current.get_stock_price(order.stock_id) * trade_amount - self.accum_info.add_return_value(profit) # note here do not consider cost + # update return from order + trade_amount = trade_val / trade_price + if order.direction == Order.SELL: # 0 for sell + # when sell stock, get profit from price change + profit = trade_val - self.current.get_stock_price(order.stock_id) * trade_amount + self.accum_info.add_return_value(profit) # note here do not consider cost - elif order.direction == Order.BUY: # 1 for buy - # when buy stock, we get return for the rtn computing method - # profit in buy order is to make rtn is consistent with earning at the end of bar - profit = self.current.get_stock_price(order.stock_id) * trade_amount - trade_val - self.accum_info.add_return_value(profit) # note here do not consider cost + elif order.direction == Order.BUY: # 1 for buy + # when buy stock, we get return for the rtn computing method + # profit in buy order is to make rtn is consistent with earning at the end of bar + profit = self.current.get_stock_price(order.stock_id) * trade_amount - trade_val + self.accum_info.add_return_value(profit) # note here do not consider cost def update_order(self, order, trade_val, cost, trade_price): if self.current.skip_update(): @@ -195,8 +196,7 @@ class Account: # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation if order.direction == Order.SELL: # sell stock - if getattr(self, "accum_info") is not None: - self._update_state_from_order(order, trade_val, cost, trade_price) + self._update_accum_info_from_order(order, trade_val, cost, trade_price) # update current position # for may sell all of stock_id self.current.update_order(order, trade_val, cost, trade_price) @@ -204,8 +204,7 @@ class Account: # buy stock # deal order, then update state self.current.update_order(order, trade_val, cost, trade_price) - if getattr(self, "accum_info") is not None: - self._update_state_from_order(order, trade_val, cost, trade_price) + self._update_accum_info_from_order(order, trade_val, cost, trade_price) def update_bar_count(self): """at the end of the trading bar, update holding bar, count of stock""" diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 91c8ca30d..d36675b01 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -407,9 +407,6 @@ class Exchange: trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) elif position: position.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) - else: - # if dealing is not successful, the trade_cost should be zero - trade_cost = 0 return trade_val, trade_cost, trade_price @@ -713,53 +710,31 @@ class Exchange: f"Order clipped due to volume limitation: {order}, {[(vol, rule) for vol, rule in zip(vol_limit_num, vol_limit)]}" ) - def _get_max_amount_by_cash_limit(self, trade_price, order, position): - """return the real order amount after cash limit. + def _get_buy_amount_by_cash_limit(self, trade_price, cash): + """return the real order amount after cash limit for buying. Parameters ---------- trade_price : float - order : Order - position : Position + position : cash Return ---------- float - the real order amount after cash limit. + the real order amount after cash limit for buying. """ - cash = position.get_cash() max_trade_amount = 0 if cash >= self.min_cost: - if order.direction == Order.SELL: - max_trade_amount = cash / self.close_cost / trade_price - elif order.direction == Order.BUY: - critical_amount = self.min_cost / (self.open_cost * trade_price) - critical_price = critical_amount * trade_price + self.min_cost - if cash >= critical_price: - max_trade_amount = cash / (1 + self.open_cost) / trade_price - else: - max_trade_amount = (cash - self.min_cost) / trade_price + # critical_amount means the stock transaction amount when the service fee is equal to min_cost. + critical_amount = self.min_cost / self.open_cost + self.min_cost + if cash >= critical_amount: + # the service fee is equal to open_cost * trade_amount + max_trade_amount = cash / (1 + self.open_cost) / trade_price + else: + # the service fee is equal to min_cost + max_trade_amount = (cash - self.min_cost) / trade_price return max_trade_amount - def _get_max_amount_by_stock_limit(self, order, position): - """return the real order amount after stock amount limit. - - Parameters - ---------- - order : Order - position : Position - - Return - ---------- - float - the real order amount after stock amount limit. - """ - if order.direction == Order.SELL: - current_amount = position.get_stock_amount(order.stock_id) if position.check_stock(order.stock_id) else 0 - return current_amount - elif order.direction == Order.BUY: - return np.inf - def _calc_trade_info_by_order(self, order, position: Position, dealt_order_amount): """ Calculation of trade info @@ -771,55 +746,60 @@ class Exchange: :param dealt_order_amount: the dealt order amount dict with the format of {stock_id: float} :return: trade_price, trade_val, trade_cost """ - trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction) order.factor = self.get_factor(order.stock_id, order.start_time, order.end_time) - # get all limits amount - # cash limit - cash_max_amount = self._get_max_amount_by_cash_limit(trade_price, order, position) - # held stock limit - stock_max_amount = self._get_max_amount_by_stock_limit(order, position) - if order.direction == Order.SELL: + cost_ratio = self.close_cost # sell if position is not None: - if np.isclose(order.amount, stock_max_amount): + current_amount = ( + position.get_stock_amount(order.stock_id) if position.check_stock(order.stock_id) else 0 + ) + if np.isclose(order.amount, current_amount): # when selling last stock. The amount don't need rounding - if stock_max_amount <= cash_max_amount: - order.deal_amount = stock_max_amount - else: - order.deal_amount = self.round_amount_by_trade_unit(cash_max_amount, order.factor) + order.deal_amount = order.amount else: - now_trade_amount = min(order.amount, stock_max_amount) - if now_trade_amount > cash_max_amount: - self.logger.debug(f"Order clipped due to cash limitation: {order}") - order.deal_amount = self.round_amount_by_trade_unit( - min(now_trade_amount, cash_max_amount), order.factor - ) + order.deal_amount = self.round_amount_by_trade_unit(min(current_amount, order.amount), order.factor) + + # in case of negative value of cash + if position.get_cash() + order.deal_amount * trade_price < max( + order.deal_amount * trade_price * cost_ratio, + self.min_cost, + ): + order.deal_amount = 0 + self.logger.debug(f"Order clipped due to cash limitation: {order}") else: # TODO: We don't know current position. # We choose to sell all order.deal_amount = order.amount - self._clip_amount_by_volume(order, dealt_order_amount) - trade_val = order.deal_amount * trade_price - trade_cost = max(trade_val * self.close_cost, self.min_cost) elif order.direction == Order.BUY: + cost_ratio = self.open_cost # buy if position is not None: - if order.amount > cash_max_amount: + cash = position.get_cash() + trade_val = order.amount * trade_price + if cash < trade_val + max(trade_val * cost_ratio, self.min_cost): + # The money is not enough + max_buy_amount = self._get_buy_amount_by_cash_limit(trade_price, cash) + order.deal_amount = self.round_amount_by_trade_unit(max_buy_amount, order.factor) self.logger.debug(f"Order clipped due to cash limitation: {order}") - order.deal_amount = self.round_amount_by_trade_unit(min(order.amount, cash_max_amount), order.factor) + else: + # The money is enough + order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) else: # Unknown amount of money. Just round the amount order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) - self._clip_amount_by_volume(order, dealt_order_amount) - trade_val = order.deal_amount * trade_price - trade_cost = max(trade_val * self.open_cost, self.min_cost) else: raise NotImplementedError("order type {} error".format(order.type)) + self._clip_amount_by_volume(order, dealt_order_amount) + trade_val = order.deal_amount * trade_price + trade_cost = max(trade_val * cost_ratio, self.min_cost) + if trade_val <= 1e-5: + # if dealing is not successful, the trade_cost should be zero. + trade_cost = 0 return trade_price, trade_val, trade_cost def get_order_helper(self) -> OrderHelper: diff --git a/tests/backtest/test_file_strategy.py b/tests/backtest/test_file_strategy.py index cbc29bad7..9229581ac 100644 --- a/tests/backtest/test_file_strategy.py +++ b/tests/backtest/test_file_strategy.py @@ -16,6 +16,8 @@ class FileStrTest(TestAutoData): EXAMPLE_FILE = DIRNAME / "order_example.csv" + DEAL_NUM_FOR_1000 = 123.47105436976445 + def _gen_orders(self) -> pd.DataFrame: headers = [ "datetime", @@ -24,11 +26,18 @@ class FileStrTest(TestAutoData): "direction", ] orders = [ - ["20200102", self.TEST_INST, "1000", "sell"], + # test cash limit for buying ["20200103", self.TEST_INST, "1000", "buy"], + # test min_cost for buying + ["20200103", self.TEST_INST, "1", "buy"], + # test held stock limit for selling ["20200106", self.TEST_INST, "1000", "sell"], - ["20200106", self.TEST_INST, "1000", "buy"], - ["20200106", self.TEST_INST, "949.7773413058803", "sell"], + # test cash limit for buying + ["20200107", self.TEST_INST, "1000", "buy"], + # test min_cost for selling + ["20200108", self.TEST_INST, "1", "sell"], + # test selling all stocks + ["20200110", self.TEST_INST, str(self.DEAL_NUM_FOR_1000), "sell"], ] return pd.DataFrame(orders, columns=headers).set_index(["datetime", "instrument"]) @@ -54,7 +63,7 @@ class FileStrTest(TestAutoData): backtest_config = { "start_time": start_time, "end_time": end_time, - "account": 100000000, + "account": 30000, "benchmark": None, # benchmark is not required here for trading "exchange_kwargs": { "freq": freq, @@ -62,9 +71,9 @@ class FileStrTest(TestAutoData): "deal_price": "close", "open_cost": 0.0005, "close_cost": 0.0015, - "min_cost": 5, + "min_cost": 500, "codes": codes, - "trade_unit": 100, + "trade_unit": 2, }, # "pos_type": "InfPosition" # Position with infinitive position } @@ -80,7 +89,16 @@ class FileStrTest(TestAutoData): }, }, } - backtest(executor=executor_config, strategy=strategy_config, **backtest_config) + report_dict, indicator_dict = backtest(executor=executor_config, strategy=strategy_config, **backtest_config) + + # ffr valid + ffr_dict = indicator_dict["1day"]["ffr"].to_dict() + ffr_dict = {str(date).split()[0]: ffr_dict[date] for date in ffr_dict} + assert ffr_dict["2020-01-03"] == 0 + assert ffr_dict["2020-01-06"] == self.DEAL_NUM_FOR_1000 / 1000 + assert ffr_dict["2020-01-07"] == self.DEAL_NUM_FOR_1000 / 1000 + assert ffr_dict["2020-01-08"] == 0 + assert ffr_dict["2020-01-10"] == 1 self.EXAMPLE_FILE.unlink() diff --git a/tests/backtest/test_init_position.py b/tests/backtest/test_init_position.py deleted file mode 100644 index ec3dca714..000000000 --- a/tests/backtest/test_init_position.py +++ /dev/null @@ -1,119 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import unittest -import qlib -from qlib.backtest import backtest, order -from qlib.tests import TestAutoData -from qlib.backtest.order import TradeDecisionWO, TradeRangeByTime -import pandas as pd -from pathlib import Path - - -class FileStrTest(TestAutoData): - - TEST_INST = "SH600519" - - def init_qlib(self): - provider_uri_day = "/nfs_data1/stock_data/huaxia_1d_qlib" - provider_uri_1min = "/nfs_data1/stock_data/huaxia_1min_qlib" - provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} - - client_config = { - "calendar_provider": { - "class": "LocalCalendarProvider", - "module_path": "qlib.data.data", - "kwargs": { - "backend": { - "class": "FileCalendarStorage", - "module_path": "qlib.data.storage.file_storage", - "kwargs": {"provider_uri_map": provider_uri_map}, - } - }, - }, - "feature_provider": { - "class": "LocalFeatureProvider", - "module_path": "qlib.data.data", - "kwargs": { - "backend": { - "class": "FileFeatureStorage", - "module_path": "qlib.data.storage.file_storage", - "kwargs": {"provider_uri_map": provider_uri_map}, - } - }, - }, - } - qlib.init(provider_uri=provider_uri_day, **client_config, expression_cache=None, dataset_cache=None) - - def test_file_str(self): - freq = "1min" - inst = ["SH600000", "SH600011"] - start_time = "2020-01-01" - end_time = "2020-01-15 15:00" - - strategy_config = { - "class": "RandomOrderStrategy", - "module_path": "qlib.contrib.strategy.rule_strategy", - "kwargs": { - "trade_range": TradeRangeByTime("9:30", "15:00"), - "sample_ratio": 1.0, - "volume_ratio": 0.01, - "market": inst, - }, - } - position_dict = { - "cash": 100000000, - "SH600000": {"amount": 100}, - "SH600011": {"amount": 101}, - } - backtest_config = { - "start_time": start_time, - "end_time": end_time, - "account": position_dict, - "benchmark": None, # benchmark is not required here for trading - "exchange_kwargs": { - "freq": freq, - "limit_threshold": 0.095, - "deal_price": "close", - "open_cost": 0.0005, - "close_cost": 0.0015, - "min_cost": 5, - "codes": inst, - }, - "pos_type": "Position", # Position with infinitive position - } - executor_config = { - "class": "NestedExecutor", - "module_path": "qlib.backtest.executor", - "kwargs": { - "time_per_step": "day", - "inner_executor": { - "class": "SimulatorExecutor", - "module_path": "qlib.backtest.executor", - "kwargs": { - "time_per_step": freq, - "generate_report": False, - "verbose": False, - # "verbose": True, - "indicator_config": { - "show_indicator": False, - }, - }, - }, - "inner_strategy": { - "class": "TWAPStrategy", - "module_path": "qlib.contrib.strategy.rule_strategy", - }, - "track_data": True, - "generate_report": True, - "indicator_config": { - "show_indicator": True, - }, - }, - } - self.init_qlib() - backtest(executor=executor_config, strategy=strategy_config, **backtest_config) - - -if __name__ == "__main__": - unittest.main() From 309dfa36cc5175541579de4e35aef377ad5bfed4 Mon Sep 17 00:00:00 2001 From: Young Date: Sun, 15 Aug 2021 15:22:48 +0000 Subject: [PATCH 159/187] Add a example to collecting all the decisions --- qlib/backtest/__init__.py | 53 ++++++++- qlib/backtest/high_performance_ds.py | 10 +- qlib/tests/__init__.py | 63 ++++++++++- qlib/tests/data.py | 2 +- tests/backtest/test_high_freq_trading.py | 133 +++++++++++++++++++++++ 5 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 tests/backtest/test_high_freq_trading.py diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index bcd07fa23..cd113c8ab 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -9,11 +9,11 @@ from .account import Account if TYPE_CHECKING: from ..strategy.base import BaseStrategy from .executor import BaseExecutor + from .order import BaseTradeDecision from .position import Position from .exchange import Exchange from .backtest import backtest_loop from .backtest import collect_data_loop -from .order import Order from .utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager from ..utils import init_instance_by_config from ..log import get_module_logger @@ -228,10 +228,13 @@ def backtest( Returns ------- - report_dict: Report + report: Report it records the trading report information - indicator_dict: Indicator + It is organized in a dict format + indicator: Indicator it computes the trading indicator + It is organized in a dict format + """ trade_strategy, trade_executor = get_strategy_executor( start_time, @@ -243,9 +246,9 @@ def backtest( exchange_kwargs, pos_type=pos_type, ) - report_dict, indicator_dict = backtest_loop(start_time, end_time, trade_strategy, trade_executor) + report, indicator = backtest_loop(start_time, end_time, trade_strategy, trade_executor) - return report_dict, indicator_dict + return report, indicator def collect_data( @@ -257,6 +260,7 @@ def collect_data( account=1e9, exchange_kwargs={}, pos_type: str = "Position", + return_value: dict = None, ): """initialize the strategy and executor, then collect the trade decision data for rl training @@ -277,4 +281,41 @@ def collect_data( exchange_kwargs, pos_type=pos_type, ) - yield from collect_data_loop(start_time, end_time, trade_strategy, trade_executor) + yield from collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value=return_value) + + +def format_decisions( + decisions: List[BaseTradeDecision], +) -> Tuple[str, List[Tuple[BaseTradeDecision, Union[Tuple, None]]]]: + """ + format the decisions collected by `qlib.backtest.collect_data` + The decisions will be organized into a tree-like structure. + + Parameters + ---------- + decisions : List[BaseTradeDecision] + decisions collected by `qlib.backtest.collect_data` + + Returns + ------- + Tuple[str, List[Tuple[BaseTradeDecision, Union[Tuple, None]]]]: + + reformat the list of decisions into a more user-friendly format + := Tuple[, List[Tuple[, ]]] + - := ` in lower level` | None + - := "day" | "30min" | "1min" | ... + - := + """ + if len(decisions) == 0: + return None + + cur_freq = decisions[0].strategy.trade_calendar.get_freq() + + res = (cur_freq, []) + last_dec_idx = 0 + for i, dec in enumerate(decisions[1:], 1): + if dec.strategy.trade_calendar.get_freq() == cur_freq: + res[1].append((decisions[last_dec_idx], format_decisions(decisions[last_dec_idx + 1 : i]))) + last_dec_idx = i + res[1].append((decisions[last_dec_idx], format_decisions(decisions[last_dec_idx + 1 :]))) + return res diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index c60d3f97e..eabe84a0a 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -171,7 +171,7 @@ class BaseSingleMetric: @property def empty(self) -> bool: - """If metric is empyt, return True.""" + """If metric is empty, return True.""" raise NotImplementedError(f"Please implement the `empty` method") @@ -357,17 +357,17 @@ class PandasSingleMetric: def __gt__(self, other): if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric < other) + return PandasSingleMetric(self.metric > other) elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric < other.metric) + return PandasSingleMetric(self.metric > other.metric) else: return NotImplemented def __lt__(self, other): if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric > other) + return PandasSingleMetric(self.metric < other) elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric > other.metric) + return PandasSingleMetric(self.metric < other.metric) else: return NotImplemented diff --git a/qlib/tests/__init__.py b/qlib/tests/__init__.py index 7f43cd99a..cc452ae0f 100644 --- a/qlib/tests/__init__.py +++ b/qlib/tests/__init__.py @@ -8,17 +8,72 @@ class TestAutoData(unittest.TestCase): _setup_kwargs = {} provider_uri = "~/.qlib/qlib_data/cn_data_simple" # target_dir + provider_uri_1day = "~/.qlib/qlib_data/cn_data" # target_dir + provider_uri_1min = "~/.qlib/qlib_data/cn_data_1min" @classmethod - def setUpClass(cls) -> None: + def setUpClass(cls, enable_1d_type="simple", enable_1min=False) -> None: # use default data + if enable_1d_type == "simple": + provider_uri_day = cls.provider_uri + name_day = "qlib_data_simple" + elif enable_1d_type == "full": + provider_uri_day = cls.provider_uri_1day + name_day = "qlib_data" + else: + raise NotImplementedError(f"This type of input is not supported") + GetData().qlib_data( - name="qlib_data_simple", + name=name_day, region=REG_CN, interval="1d", - target_dir=cls.provider_uri, + target_dir=provider_uri_day, delete_old=False, exists_skip=True, ) - init(provider_uri=cls.provider_uri, region=REG_CN, **cls._setup_kwargs) + + if enable_1min: + GetData().qlib_data( + name="qlib_data", + region=REG_CN, + interval="1min", + target_dir=cls.provider_uri_1min, + delete_old=False, + exists_skip=True, + ) + + provider_uri_map = {"1min": cls.provider_uri_1min, "day": provider_uri_day} + + client_config = { + "calendar_provider": { + "class": "LocalCalendarProvider", + "module_path": "qlib.data.data", + "kwargs": { + "backend": { + "class": "FileCalendarStorage", + "module_path": "qlib.data.storage.file_storage", + "kwargs": {"provider_uri_map": provider_uri_map}, + } + }, + }, + "feature_provider": { + "class": "LocalFeatureProvider", + "module_path": "qlib.data.data", + "kwargs": { + "backend": { + "class": "FileFeatureStorage", + "module_path": "qlib.data.storage.file_storage", + "kwargs": {"provider_uri_map": provider_uri_map}, + } + }, + }, + } + init( + provider_uri=cls.provider_uri, + region=REG_CN, + expression_cache=None, + dataset_cache=None, + **client_config, + **cls._setup_kwargs, + ) diff --git a/qlib/tests/data.py b/qlib/tests/data.py index 2bfe43590..b38fd7eee 100644 --- a/qlib/tests/data.py +++ b/qlib/tests/data.py @@ -14,7 +14,7 @@ from qlib.utils import exists_qlib_data class GetData: - DATASET_VERSION = "v1" + DATASET_VERSION = "v2" REMOTE_URL = "http://fintech.msra.cn/stock_data/downloads" QLIB_DATA_NAME = "{dataset_name}_{region}_{interval}_{qlib_version}.zip" diff --git a/tests/backtest/test_high_freq_trading.py b/tests/backtest/test_high_freq_trading.py new file mode 100644 index 000000000..628ec1e78 --- /dev/null +++ b/tests/backtest/test_high_freq_trading.py @@ -0,0 +1,133 @@ +from typing import List, Tuple, Union +from qlib.backtest.position import Position +from qlib.backtest import collect_data, format_decisions +from qlib.backtest.order import BaseTradeDecision, TradeRangeByTime +import qlib +from qlib.tests import TestAutoData +import unittest +from qlib.config import REG_CN, HIGH_FREQ_CONFIG +import pandas as pd + + +@unittest.skip("This test takes a lot of time due to the large size of high-frequency data") +class TestHFBacktest(TestAutoData): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass(enable_1min=True, enable_1d_type="full") + + def _gen_orders(self, inst, date, pos) -> pd.DataFrame: + headers = [ + "datetime", + "instrument", + "amount", + "direction", + ] + orders = [ + [date, inst, pos, "sell"], + ] + return pd.DataFrame(orders, columns=headers) + + def test_trading(self): + + # date = "2020-02-03" + # inst = "SH600068" + # pos = 2.0167 + pos = 100000 + inst, date = "SH600519", "2021-01-18" + market = [inst] + + start_time = f"{date}" + end_time = f"{date} 15:00" # include the high-freq data on the end day + freq_l0 = "day" + freq_l1 = "30min" + freq_l2 = "1min" + + orders = self._gen_orders(inst=inst, date=date, pos=pos * 0.90) + + strategy_config = { + "class": "FileOrderStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + "kwargs": { + "trade_range": TradeRangeByTime("10:45", "14:44"), + "file": orders, + }, + } + backtest_config = { + "start_time": start_time, + "end_time": end_time, + "account": { + "cash": 0, + inst: pos, + }, + "benchmark": None, # benchmark is not required here for trading + "exchange_kwargs": { + "freq": freq_l2, # use the most fine-grained data as the exchange + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + "codes": market, + "trade_unit": 100, + }, + # "pos_type": "InfPosition" # Position with infinitive position + } + executor_config = { + "class": "NestedExecutor", # Level 1 Order execution + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": freq_l0, + "inner_executor": { + "class": "NestedExecutor", # Leve 2 Order Execution + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": freq_l1, + "inner_executor": { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": freq_l2, + "generate_report": False, + "verbose": True, + "indicator_config": { + "show_indicator": False, + }, + "track_data": True, + }, + }, + "inner_strategy": { + "class": "TWAPStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + }, + "generate_report": False, + "indicator_config": { + "show_indicator": True, + }, + "track_data": True, + }, + }, + "inner_strategy": { + "class": "TWAPStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + }, + "generate_report": False, + "indicator_config": { + "show_indicator": True, + }, + "track_data": True, + }, + } + + ret_val = {} + decisions = list( + collect_data(executor=executor_config, strategy=strategy_config, **backtest_config, return_value=ret_val) + ) + report, indicator = ret_val["report"], ret_val["indicator"] + # NOTE: please refer to the docs of format_decisions + # NOTE: `"track_data": True,` is very NECESSARY for collecting the decision!!!!! + f_dec = format_decisions(decisions) + print(indicator["1day"]) + + +if __name__ == "__main__": + unittest.main() From 2da6a8c77050fe361bb1fee8a0bd7e86b5203d29 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 31 Aug 2021 11:57:14 +0000 Subject: [PATCH 160/187] fix Path re --- qlib/config.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qlib/config.py b/qlib/config.py index 18984c7ce..0478b7659 100644 --- a/qlib/config.py +++ b/qlib/config.py @@ -255,9 +255,12 @@ class QlibConfig(Config): self["provider_uri"] = str(Path(self["provider_uri"]).expanduser().resolve()) def get_uri_type(self): - is_win = re.match("^[a-zA-Z]:.*", self["provider_uri"]) is not None # such as 'C:\\data', 'D:' + path = self["provider_uri"] + if isinstance(path, Path): + path = str(path) + is_win = re.match("^[a-zA-Z]:.*", path) is not None # such as 'C:\\data', 'D:' is_nfs_or_win = ( - re.match("^[^/]+:.+", self["provider_uri"]) is not None + re.match("^[^/]+:.+", path) is not None ) # such as 'host:/data/' (User may define short hostname by themselves or use localhost) if is_nfs_or_win and not is_win: From f67b99a30e6890519bd532a96452687b3f7d795c Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sun, 15 Aug 2021 12:45:29 +0000 Subject: [PATCH 161/187] update exchange --- qlib/backtest/exchange.py | 10 +- qlib/backtest/high_performance_ds.py | 135 ++++++++++++++++++++++++++- qlib/backtest/report.py | 3 + tests/backtest/test_file_strategy.py | 15 +-- 4 files changed, 150 insertions(+), 13 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index d36675b01..9327e6f15 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -21,7 +21,7 @@ from ..config import C, REG_CN from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order, OrderDir, OrderHelper -from .high_performance_ds import PandasQuote +from .high_performance_ds import PandasQuote, NumpyQuote class Exchange: @@ -39,7 +39,7 @@ class Exchange: close_cost=0.0025, min_cost=5, extra_quote=None, - quote_cls=PandasQuote, + quote_cls=NumpyQuote, **kwargs, ): """__init__ @@ -725,9 +725,9 @@ class Exchange: """ max_trade_amount = 0 if cash >= self.min_cost: - # critical_amount means the stock transaction amount when the service fee is equal to min_cost. - critical_amount = self.min_cost / self.open_cost + self.min_cost - if cash >= critical_amount: + # critical_price means the stock transaction price when the service fee is equal to min_cost. + critical_price = self.min_cost / self.open_cost + self.min_cost + if cash >= critical_price: # the service fee is equal to open_cost * trade_amount max_trade_amount = cash / (1 + self.open_cost) / trade_price else: diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index eabe84a0a..9bf2ca2b8 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -3,13 +3,16 @@ import logging +from qlib.data.base import Feature from typing import List, Text, Tuple, Union, Callable, Iterable, Dict from collections import OrderedDict import inspect +import bisect import pandas as pd +import numpy as np -from ..utils.resam import resam_ts_data +from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger @@ -112,6 +115,136 @@ class PandasQuote(BaseQuote): else: raise ValueError(f"fields must be None, str or list") + def _if_single_data(self, start_time, end_time): + if end_time - start_time < np.timedelta64(1, 'm'): + return True + if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: + return True + if start_time.hour == 14 and start_time.minute == 59 and start_time.second == 0: + return True + return False + + +class NumpyQuote(BaseQuote): + def __init__(self, quote_df: pd.DataFrame): + """NumpyQuote + + Parameters + ---------- + quote_df : pd.DataFrame + the init dataframe from qlib. + + Variables + self.data: Dict[stock_id, np.array] + each stock has one two-dimensional np.array to represent data. + self.columns: Dict[str, int] + map column name to column id in self.data. + self.dates: Dict[stock_id, Dict[pd.Timestap, int]] + map timestap to row id in self.data. + self.dates_list: Dict[stock_id, List[pd.Timestap]] + the dates of each stock for searching. + """ + super().__init__(quote_df=quote_df) + # init data + columns = quote_df.columns.values + self.columns = dict(zip(columns, range(len(columns)))) + self.data, self.dates, self.dates_list = self._to_numpy(quote_df) + + # lru + self.muti_lru = {} + + def _to_numpy(self, quote_df): + """convert dataframe to numpy. + """ + quote_dict = {} + date_dict = {} + date_list = {} + for stock_id, stock_val in quote_df.groupby(level="instrument"): + quote_dict[stock_id] = stock_val.values + date_dict[stock_id] = stock_val.index.get_level_values("datetime") + date_list[stock_id] = list(date_dict[stock_id]) + for stock_id in date_dict: + date_dict[stock_id] = dict(zip(date_dict[stock_id], range(len(date_dict[stock_id])))) + return quote_dict, date_dict, date_list + + def get_all_stock(self): + return self.data.keys() + + def get_data(self, stock_id, start_time, end_time, fields=None, method=None): + # check stock id + if stock_id not in self.get_all_stock(): + return None + + # get single data + if self._if_single_data(start_time, end_time): + if start_time not in self.dates[stock_id]: + return None + if fields is None: + # it used for check if data is None + return self.data[stock_id][self.dates[stock_id][start_time]] + else: + return self.data[stock_id][self.dates[stock_id][start_time]][self.columns[fields]] + # get muti row data + else: + # check lru + if (start_time, end_time, fields, method) in self.muti_lru: + return self.muti_lru[(start_time, end_time, fields, method)] + + start_id = bisect.bisect_left(self.dates_list[stock_id], start_time) + end_id = bisect.bisect_right(self.dates_list[stock_id], end_time) + if start_id == end_id: + return None + # it used for check if data is None + if fields is None: + return self.data[stock_id][start_id: end_id] + agg_stock_data = self._agg_data(self.data[stock_id][start_id: end_id, self.columns[fields]], method) + + # result lru + self.muti_lru[(start_time, end_time, fields, method)] = agg_stock_data + return agg_stock_data + + def _agg_data(self, data, method): + """Agg data by specific method. + """ + if method == "sum": + return data.sum() + if method == "mean": + return data.mean() + if method == "last": + return data[-1] + if method == "all": + return data.all() + if method == "any": + return data.any() + if method == ts_data_last: + valid_data = data[data != np.NaN] + if len(valid_data) == 0: + return None + else: + return valid_data[0] + + def _if_single_data(self, start_time, end_time): + """Is there only one piece of data to obtaine. + + Parameters + ---------- + start_time : Union[pd.Timestamp, str] + closed start time for data. + end_time : Union[pd.Timestamp, str] + closed end time for data. + Returns + ------- + bool + True means one piece of data to obtaine. + """ + if end_time - start_time < np.timedelta64(1, 'm'): + return True + if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: + return True + if start_time.hour == 14 and start_time.minute == 59 and start_time.second == 0: + return True + return False + class BaseSingleMetric: """ diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 2d188dd18..9f957c0ac 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -389,6 +389,9 @@ class Indicator: if price_s is None: return None, None + if isinstance(price_s, (int, float)): + price_s = pd.Series(price_s, index=[trade_start_time]) + # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. price_s = price_s[~(price_s < 1e-08)] # remove zero and negative values. diff --git a/tests/backtest/test_file_strategy.py b/tests/backtest/test_file_strategy.py index 9229581ac..945f142c6 100644 --- a/tests/backtest/test_file_strategy.py +++ b/tests/backtest/test_file_strategy.py @@ -29,13 +29,13 @@ class FileStrTest(TestAutoData): # test cash limit for buying ["20200103", self.TEST_INST, "1000", "buy"], # test min_cost for buying - ["20200103", self.TEST_INST, "1", "buy"], + ["20200106", self.TEST_INST, "1", "buy"], # test held stock limit for selling - ["20200106", self.TEST_INST, "1000", "sell"], + ["20200107", self.TEST_INST, "1000", "sell"], # test cash limit for buying - ["20200107", self.TEST_INST, "1000", "buy"], + ["20200108", self.TEST_INST, "1000", "buy"], # test min_cost for selling - ["20200108", self.TEST_INST, "1", "sell"], + ["20200109", self.TEST_INST, "1", "sell"], # test selling all stocks ["20200110", self.TEST_INST, str(self.DEAL_NUM_FOR_1000), "sell"], ] @@ -94,10 +94,11 @@ class FileStrTest(TestAutoData): # ffr valid ffr_dict = indicator_dict["1day"]["ffr"].to_dict() ffr_dict = {str(date).split()[0]: ffr_dict[date] for date in ffr_dict} - assert ffr_dict["2020-01-03"] == 0 - assert ffr_dict["2020-01-06"] == self.DEAL_NUM_FOR_1000 / 1000 + assert ffr_dict["2020-01-03"] == self.DEAL_NUM_FOR_1000 / 1000 + assert ffr_dict["2020-01-06"] == 0 assert ffr_dict["2020-01-07"] == self.DEAL_NUM_FOR_1000 / 1000 - assert ffr_dict["2020-01-08"] == 0 + assert ffr_dict["2020-01-08"] == self.DEAL_NUM_FOR_1000 / 1000 + assert ffr_dict["2020-01-09"] == 0 assert ffr_dict["2020-01-10"] == 1 self.EXAMPLE_FILE.unlink() From 222c2fd21ad436ef74b9478999c4493e9b55ff60 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Sun, 15 Aug 2021 14:17:26 +0000 Subject: [PATCH 162/187] fix exchange bug --- qlib/backtest/high_performance_ds.py | 41 ++++++++++++++-------------- qlib/backtest/report.py | 4 +-- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 9bf2ca2b8..f2bdbe651 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -116,12 +116,12 @@ class PandasQuote(BaseQuote): raise ValueError(f"fields must be None, str or list") def _if_single_data(self, start_time, end_time): - if end_time - start_time < np.timedelta64(1, 'm'): + if end_time - start_time < np.timedelta64(1, "m"): return True if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: - return True + return True if start_time.hour == 14 and start_time.minute == 59 and start_time.second == 0: - return True + return True return False @@ -152,10 +152,10 @@ class NumpyQuote(BaseQuote): # lru self.muti_lru = {} + self.max_lru_len = 256 def _to_numpy(self, quote_df): - """convert dataframe to numpy. - """ + """convert dataframe to numpy.""" quote_dict = {} date_dict = {} date_list = {} @@ -166,7 +166,7 @@ class NumpyQuote(BaseQuote): for stock_id in date_dict: date_dict[stock_id] = dict(zip(date_dict[stock_id], range(len(date_dict[stock_id])))) return quote_dict, date_dict, date_list - + def get_all_stock(self): return self.data.keys() @@ -187,25 +187,26 @@ class NumpyQuote(BaseQuote): # get muti row data else: # check lru - if (start_time, end_time, fields, method) in self.muti_lru: - return self.muti_lru[(start_time, end_time, fields, method)] - + if (stock_id, start_time, end_time, fields, method) in self.muti_lru: + return self.muti_lru[(stock_id, start_time, end_time, fields, method)] + start_id = bisect.bisect_left(self.dates_list[stock_id], start_time) end_id = bisect.bisect_right(self.dates_list[stock_id], end_time) if start_id == end_id: return None # it used for check if data is None if fields is None: - return self.data[stock_id][start_id: end_id] - agg_stock_data = self._agg_data(self.data[stock_id][start_id: end_id, self.columns[fields]], method) - + return self.data[stock_id][start_id:end_id] + agg_stock_data = self._agg_data(self.data[stock_id][start_id:end_id, self.columns[fields]], method) + # result lru - self.muti_lru[(start_time, end_time, fields, method)] = agg_stock_data + if len(self.muti_lru) >= self.max_lru_len: + self.muti_lru = self.muti_lru[64:] + self.muti_lru[(stock_id, start_time, end_time, fields, method)] = agg_stock_data return agg_stock_data def _agg_data(self, data, method): - """Agg data by specific method. - """ + """Agg data by specific method.""" if method == "sum": return data.sum() if method == "mean": @@ -215,11 +216,11 @@ class NumpyQuote(BaseQuote): if method == "all": return data.all() if method == "any": - return data.any() + return data.any() if method == ts_data_last: valid_data = data[data != np.NaN] if len(valid_data) == 0: - return None + return None else: return valid_data[0] @@ -237,12 +238,12 @@ class NumpyQuote(BaseQuote): bool True means one piece of data to obtaine. """ - if end_time - start_time < np.timedelta64(1, 'm'): + if end_time - start_time < np.timedelta64(1, "m"): return True if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: - return True + return True if start_time.hour == 14 and start_time.minute == 59 and start_time.second == 0: - return True + return True return False diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 9f957c0ac..dea72e46a 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -390,8 +390,8 @@ class Indicator: return None, None if isinstance(price_s, (int, float)): - price_s = pd.Series(price_s, index=[trade_start_time]) - + price_s = pd.Series(price_s, index=[trade_start_time]) + # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. price_s = price_s[~(price_s < 1e-08)] # remove zero and negative values. From 8eb7a1fddcd2c771208fae7f50b0cc3a8a186d8b Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 17 Aug 2021 07:15:45 +0000 Subject: [PATCH 163/187] numpy_order_indicator --- qlib/backtest/high_performance_ds.py | 267 +++++++++++++++++++++++++-- qlib/backtest/report.py | 9 +- 2 files changed, 255 insertions(+), 21 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index f2bdbe651..0f47c7df1 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -3,8 +3,9 @@ import logging +from pandas._config.config import is_instance_factory from qlib.data.base import Feature -from typing import List, Text, Tuple, Union, Callable, Iterable, Dict +from typing import List, Text, Tuple, Union, Callable, Iterable, Dict, ValuesView from collections import OrderedDict import inspect @@ -135,8 +136,8 @@ class NumpyQuote(BaseQuote): the init dataframe from qlib. Variables - self.data: Dict[stock_id, np.array] - each stock has one two-dimensional np.array to represent data. + self.data: Dict[stock_id, np.ndarray] + each stock has one two-dimensional np.ndarray to represent data. self.columns: Dict[str, int] map column name to column id in self.data. self.dates: Dict[stock_id, Dict[pd.Timestap, int]] @@ -144,6 +145,7 @@ class NumpyQuote(BaseQuote): self.dates_list: Dict[stock_id, List[pd.Timestap]] the dates of each stock for searching. """ + super().__init__(quote_df=quote_df) # init data columns = quote_df.columns.values @@ -156,6 +158,7 @@ class NumpyQuote(BaseQuote): def _to_numpy(self, quote_df): """convert dataframe to numpy.""" + quote_dict = {} date_dict = {} date_list = {} @@ -201,12 +204,13 @@ class NumpyQuote(BaseQuote): # result lru if len(self.muti_lru) >= self.max_lru_len: - self.muti_lru = self.muti_lru[64:] + self.muti_lru.clear() self.muti_lru[(stock_id, start_time, end_time, fields, method)] = agg_stock_data return agg_stock_data def _agg_data(self, data, method): """Agg data by specific method.""" + if method == "sum": return data.sum() if method == "mean": @@ -238,6 +242,7 @@ class NumpyQuote(BaseQuote): bool True means one piece of data to obtaine. """ + if end_time - start_time < np.timedelta64(1, "m"): return True if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: @@ -393,23 +398,21 @@ class BaseOrderIndicator: @staticmethod def sum_all_indicators( - indicators: list, metrics: Union[str, List[str]], fill_value: float = None - ) -> Dict[str, BaseSingleMetric]: + cls, indicators: list, metrics: Union[str, List[str]], fill_value: float = None + ): """sum indicators with the same metrics. + and assign to the cls(BaseOrderIndicator). Parameters ---------- + cls : BaseOrderIndicator + the order indicator to assign. indicators : List[BaseOrderIndicator] the list of all inner indicators. metrics : Union[str, List[str]] all metrics needs ot be sumed. fill_value : float, optional fill np.NaN with value. By default None. - - Return - ---------- - Dict[str: PandasSingleMetric] - a dict of metric name and data. """ pass @@ -567,17 +570,249 @@ class PandasOrderIndicator(BaseOrderIndicator): @staticmethod def sum_all_indicators( - indicators: list, metrics: Union[str, List[str]], fill_value=None - ) -> Dict[str, PandasSingleMetric]: - metric_dict = {} + cls, indicators: list, metrics: Union[str, List[str]], fill_value=None + ): if isinstance(metrics, str): metrics = [metrics] for metric in metrics: tmp_metric = PandasSingleMetric({}) for indicator in indicators: tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) - metric_dict[metric] = tmp_metric.metric - return metric_dict + cls.assign(metric, tmp_metric.metric) def to_series(self): return {k: v.metric for k, v in self.data.items()} + + +class NumpySingleMetric(BaseSingleMetric): + def __init__(self, metric: np.ndarray): + self.metric = metric + + def __add__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(self.metric + other) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(self.metric + other.metric) + else: + return NotImplemented + + def __sub__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(self.metric - other) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(self.metric - other.metric) + else: + return NotImplemented + + def __rsub__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(other - self.metric) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(other.metric - self.metric) + else: + return NotImplemented + + def __mul__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(self.metric * other) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(self.metric * other.metric) + else: + return NotImplemented + + def __truediv__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(self.metric / other) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(self.metric / other.metric) + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(self.metric == other) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(self.metric == other.metric) + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(self.metric > other) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(self.metric > other.metric) + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, (int, float)): + return NumpySingleMetric(self.metric < other) + elif isinstance(other, NumpySingleMetric): + return NumpySingleMetric(self.metric < other.metric) + else: + return NotImplemented + + def __len__(self): + return len(self.metric) + + def sum(self): + return self.metric.sum() + + def mean(self): + return self.metric.mean() + + def count(self): + return len(self.metric[~np.isnan(self.metric)]) + + def abs(self): + return NumpySingleMetric(np.absolute(self.metric)) + + def astype(self, type): + return NumpySingleMetric(self.metric.astype(type)) + + @property + def empty(self): + return len(self.metric) == 0 + + def replace(self, replace_dict: dict): + tmp_metric = self.metric.copy() + for num in replace_dict: + tmp_metric[tmp_metric == num] = replace_dict[num] + return NumpySingleMetric(tmp_metric) + + def apply(self, func: Callable): + tmp_metric = self.metric.copy() + for i in range(len(tmp_metric)): + tmp_metric[i] = func(tmp_metric[i]) + return NumpySingleMetric(tmp_metric) + + +class NumpyOrderIndicator(BaseOrderIndicator): + # all metrics + ROW = [ + "amount", + "deal_amount", + "inner_amount", + "trade_price", + "trade_value", + "trade_cost", + "trade_dir", + "ffr", + "pa", + "pos", + "base_price", + "base_volume", + ] + ROW_MAP = dict(zip(ROW, range(len(ROW)))) + + def __init__(self): + self.row_tag = [0 for tag in range(len(NumpyOrderIndicator.ROW))] + self.data = None + + def assign(self, col: str, metric: Union[dict, np.ndarray, pd.Series]): + if col not in NumpyOrderIndicator.ROW: + raise ValueError(f"{col} metric is not supoorted") + if not isinstance(metric, (dict, np.ndarray, pd.Series)): + raise ValueError(f"metric must be dict, pd.Series or np.ndarray") + if isinstance(metric, (pd.Series, np.ndarray)) and self.data is None: + raise ValueError(f"data can not be None when metric is np.ndarray or pd.Series") + + # if data is None, init numpy ndarray + if self.data is None: + self.data = np.zeros((len(NumpyOrderIndicator.ROW), len(metric))) + self.column = list(metric.keys()) + self.column_map = dict(zip(self.column, range(len(self.column)))) + + metric_column = list(metric.keys()) + if self.column != metric_column: + assert len(set(self.column) - set(metric_column)) == 0 + # modify the order + tmp_metric = {} + for column in self.column: + tmp_metric[column] = metric[column] + metric = tmp_metric + + # assign data + self.row_tag[NumpyOrderIndicator.ROW_MAP[col]] = 1 + if isinstance(metric, dict): + self.data[NumpyOrderIndicator.ROW_MAP[col]] = list(metric.values()) + elif isinstance(metric, np.ndarray): + self.data[NumpyOrderIndicator.ROW_MAP[col]] = metric + elif isinstance(metric, pd.Series): + self.data[NumpyOrderIndicator.ROW_MAP[col]] = metric.values + + def transfer(self, func: Callable, new_col: str = None) -> Union[None, NumpySingleMetric]: + func_sig = inspect.signature(func).parameters.keys() + func_kwargs = {} + for sig in func_sig: + if self._if_valid_metric(sig): + func_kwargs[sig] = NumpySingleMetric(self.data[NumpyOrderIndicator.ROW_MAP[sig]]) + else: + print(f"{sig} is not assigned") + func_kwargs[sig] = NumpySingleMetric(np.array([])) + tmp_metric = func(**func_kwargs) + if new_col is not None: + self.row_tag[NumpyOrderIndicator.ROW_MAP[new_col]] = 1 + self.data[NumpyOrderIndicator.ROW_MAP[new_col]] = tmp_metric.metric + else: + return tmp_metric + + def get_metric_series(self, metric: str) -> Union[pd.Series]: + if self._if_valid_metric(metric): + return pd.Series(self.data[NumpyOrderIndicator.ROW_MAP[metric]], index=self.column) + else: + return pd.Series() + + def to_series(self) -> Dict[str, pd.Series]: + tmp_metric_dict = {} + for metric in NumpyOrderIndicator.ROW: + tmp_metric_dict[metric] = self.get_metric_series(metric) + return tmp_metric_dict + + def _if_valid_metric(self, metric): + if metric in NumpyOrderIndicator.ROW and self.row_tag[NumpyOrderIndicator.ROW_MAP[metric]] == 1: + return True + else: + return False + + @staticmethod + def sum_all_indicators( + cls, indicators: list, metrics: Union[str, List[str]], fill_value=None + ) -> Dict[str, NumpySingleMetric]: + # metrics is all metrics to add + # metrics_id means the index in the NumpyOrderIndicator.ROW for metrics. + if isinstance(metrics, str): + metrics = [metrics] + metrics_id = [NumpyOrderIndicator.ROW_MAP[metric] for metric in metrics] + + # get all stock_id and all metric data + stocks = set() + indicator_metrics = [] + for indicator in indicators: + stocks = stocks | set(indicator.column) + indicator_metrics.append(indicator.data[metrics_id, :].copy()) + stocks = list(stocks) + stocks_map = dict(zip(stocks, range(len(stocks)))) + + # fill value + if fill_value is not None: + base_metrics = fill_value * np.ones((len(metrics), len(stocks))) + for i in range(len(indicators)): + tmp_netrics = base_metrics.copy() + stocks_index = [stocks_map[stock] for stock in indicators[i].column] + tmp_netrics[:, stocks_index] = indicator_metrics[i] + indicator_metrics[i] = tmp_netrics + else: + raise ValueError(f"fill value can not be None in NumpyOrderIndicator") + + # add metric and assign to cls + metric_sum = sum(indicator_metrics) + if cls.data is not None: + raise ValueError(f"this function must assign to an empty order indicator") + cls.data = np.zeros((len(NumpyOrderIndicator.ROW), len(stocks))) + cls.column = stocks + cls.column_map = dict(zip(stocks, range(len(stocks)))) + for i in range(len(metrics)): + cls.row_tag[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = 1 + cls.data[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = metric_sum[i] + diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index dea72e46a..21e5986ea 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -16,7 +16,7 @@ from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir from qlib.backtest.utils import TradeCalendarManager -from .high_performance_ds import PandasOrderIndicator +from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator from ..data import D from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data @@ -236,6 +236,7 @@ class Indicator: | indicator | desc. | |--------------+--------------------------------------------------------------| | amount | the *target* amount given by the outer strategy | + | deal_amount | the real deal amount | | inner_amount | the total *target* amount of inner strategy | | trade_price | the average deal price | | trade_value | the total trade value | @@ -255,7 +256,7 @@ class Indicator: """ - def __init__(self, order_indicator_cls=PandasOrderIndicator): + def __init__(self, order_indicator_cls=NumpyOrderIndicator): self.order_indicator_cls = order_indicator_cls # order indicator is metrics for a single order for a specific step @@ -329,9 +330,7 @@ class Indicator: # sum inner order indicators with same metric. all_metric = ["inner_amount", "deal_amount", "trade_price", "trade_value", "trade_cost", "trade_dir"] - metric_dict = self.order_indicator_cls.sum_all_indicators(inner_order_indicators, all_metric, fill_value=0) - for metric in metric_dict: - self.order_indicator.assign(metric, metric_dict[metric]) + self.order_indicator_cls.sum_all_indicators(self.order_indicator, inner_order_indicators, all_metric, fill_value=0) def func(trade_price, deal_amount): # trade_price is np.NaN instead of inf when deal_amount is zero. From f7d7f1a2239486263cc8674467911d61cd1857e8 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Tue, 17 Aug 2021 12:45:37 +0000 Subject: [PATCH 164/187] fix nanmean --- qlib/backtest/high_performance_ds.py | 17 ++++++----------- qlib/backtest/report.py | 4 +++- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 0f47c7df1..7788cc619 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -397,9 +397,7 @@ class BaseOrderIndicator: pass @staticmethod - def sum_all_indicators( - cls, indicators: list, metrics: Union[str, List[str]], fill_value: float = None - ): + def sum_all_indicators(cls, indicators: list, metrics: Union[str, List[str]], fill_value: float = None): """sum indicators with the same metrics. and assign to the cls(BaseOrderIndicator). @@ -569,9 +567,7 @@ class PandasOrderIndicator(BaseOrderIndicator): return pd.Series() @staticmethod - def sum_all_indicators( - cls, indicators: list, metrics: Union[str, List[str]], fill_value=None - ): + def sum_all_indicators(cls, indicators: list, metrics: Union[str, List[str]], fill_value=None): if isinstance(metrics, str): metrics = [metrics] for metric in metrics: @@ -656,10 +652,10 @@ class NumpySingleMetric(BaseSingleMetric): return len(self.metric) def sum(self): - return self.metric.sum() + return np.nansum(self.metric) def mean(self): - return self.metric.mean() + return np.nanmean(self.metric) def count(self): return len(self.metric[~np.isnan(self.metric)]) @@ -722,7 +718,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): self.data = np.zeros((len(NumpyOrderIndicator.ROW), len(metric))) self.column = list(metric.keys()) self.column_map = dict(zip(self.column, range(len(self.column)))) - + metric_column = list(metric.keys()) if self.column != metric_column: assert len(set(self.column) - set(metric_column)) == 0 @@ -805,7 +801,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): else: raise ValueError(f"fill value can not be None in NumpyOrderIndicator") - # add metric and assign to cls + # add metric and assign to cls metric_sum = sum(indicator_metrics) if cls.data is not None: raise ValueError(f"this function must assign to an empty order indicator") @@ -815,4 +811,3 @@ class NumpyOrderIndicator(BaseOrderIndicator): for i in range(len(metrics)): cls.row_tag[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = 1 cls.data[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = metric_sum[i] - diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 21e5986ea..81a0b3c9b 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -330,7 +330,9 @@ class Indicator: # sum inner order indicators with same metric. all_metric = ["inner_amount", "deal_amount", "trade_price", "trade_value", "trade_cost", "trade_dir"] - self.order_indicator_cls.sum_all_indicators(self.order_indicator, inner_order_indicators, all_metric, fill_value=0) + self.order_indicator_cls.sum_all_indicators( + self.order_indicator, inner_order_indicators, all_metric, fill_value=0 + ) def func(trade_price, deal_amount): # trade_price is np.NaN instead of inf when deal_amount is zero. From 16b954866f863b6c3b60a029179357fe83bfe828 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 18 Aug 2021 13:30:28 +0000 Subject: [PATCH 165/187] get_base_info --- qlib/backtest/high_performance_ds.py | 138 ++++++++++++++++++++++++--- qlib/backtest/report.py | 40 ++++---- 2 files changed, 147 insertions(+), 31 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 7788cc619..e5534dfcd 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -200,7 +200,12 @@ class NumpyQuote(BaseQuote): # it used for check if data is None if fields is None: return self.data[stock_id][start_id:end_id] - agg_stock_data = self._agg_data(self.data[stock_id][start_id:end_id, self.columns[fields]], method) + elif method is None: + stock_data = self.data[stock_id][start_id:end_id, self.columns[fields]] + stock_dates = self.dates_list[stock_id][start_id:end_id].to_list() + return IndexData(stock_data, [stock_id], stock_dates) + else: + agg_stock_data = self._agg_data(self.data[stock_id][start_id:end_id, self.columns[fields]], method) # result lru if len(self.muti_lru) >= self.max_lru_len: @@ -705,20 +710,18 @@ class NumpyOrderIndicator(BaseOrderIndicator): self.row_tag = [0 for tag in range(len(NumpyOrderIndicator.ROW))] self.data = None - def assign(self, col: str, metric: Union[dict, np.ndarray, pd.Series]): + def assign(self, col: str, metric: dict): if col not in NumpyOrderIndicator.ROW: raise ValueError(f"{col} metric is not supoorted") - if not isinstance(metric, (dict, np.ndarray, pd.Series)): - raise ValueError(f"metric must be dict, pd.Series or np.ndarray") - if isinstance(metric, (pd.Series, np.ndarray)) and self.data is None: - raise ValueError(f"data can not be None when metric is np.ndarray or pd.Series") + if not isinstance(metric, dict): + raise ValueError(f"metric must be dict") # if data is None, init numpy ndarray if self.data is None: self.data = np.zeros((len(NumpyOrderIndicator.ROW), len(metric))) self.column = list(metric.keys()) self.column_map = dict(zip(self.column, range(len(self.column)))) - + metric_column = list(metric.keys()) if self.column != metric_column: assert len(set(self.column) - set(metric_column)) == 0 @@ -730,12 +733,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): # assign data self.row_tag[NumpyOrderIndicator.ROW_MAP[col]] = 1 - if isinstance(metric, dict): - self.data[NumpyOrderIndicator.ROW_MAP[col]] = list(metric.values()) - elif isinstance(metric, np.ndarray): - self.data[NumpyOrderIndicator.ROW_MAP[col]] = metric - elif isinstance(metric, pd.Series): - self.data[NumpyOrderIndicator.ROW_MAP[col]] = metric.values + self.data[NumpyOrderIndicator.ROW_MAP[col]] = list(metric.values()) def transfer(self, func: Callable, new_col: str = None) -> Union[None, NumpySingleMetric]: func_sig = inspect.signature(func).parameters.keys() @@ -753,6 +751,12 @@ class NumpyOrderIndicator(BaseOrderIndicator): else: return tmp_metric + def get_index_data(self, metric): + if self._if_valid_metric(metric): + return IndexData(self.data[NumpyOrderIndicator.ROW_MAP[metric]], [metric], self.column) + else: + return IndexData([], [], []) + def get_metric_series(self, metric: str) -> Union[pd.Series]: if self._if_valid_metric(metric): return pd.Series(self.data[NumpyOrderIndicator.ROW_MAP[metric]], index=self.column) @@ -788,6 +792,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): stocks = stocks | set(indicator.column) indicator_metrics.append(indicator.data[metrics_id, :].copy()) stocks = list(stocks) + stocks.sort() stocks_map = dict(zip(stocks, range(len(stocks)))) # fill value @@ -811,3 +816,110 @@ class NumpyOrderIndicator(BaseOrderIndicator): for i in range(len(metrics)): cls.row_tag[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = 1 cls.data[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = metric_sum[i] + + +class IndexData: + def __init__(self, data, row, column): + if isinstance(data, list): + self.data = np.array([data]) + elif isinstance(data, np.ndarray): + if data.ndim == 1: + self.data = data[np.newaxis, :] + elif data.ndim == 2: + self.data = data + else: + raise ValueError(f"the dimension of data must <= 2") + else: + raise ValueError(f"data must be list or np.ndarray") + self.data = data + + assert isinstance(row, list) + self.row = row + self.row_map = dict(zip(self.row, range(len(self.row)))) + assert isinstance(column, list) + self.col = column + self.col_map = dict(zip(self.col, range(len(self.col)))) + + def reindex(self, new_column): + tmp_data = self.data.copy() + for row_id, row in enumerate(self.row): + for col_id, col in new_column: + if col in self.col: + tmp_data[row_id, col_id] = self.data[row_id, self.row_map[col]] + else: + tmp_data[row_id, col_id] = np.NaN + return IndexData(tmp_data, self.row, list(new_column)) + + def to_dict(self): + assert len(self.row) == 1 + if self.data.size == 0: + return {col: np.NaN for col in self.col} + else: + return dict(zip(self.col, self.data[0, :].tolist())) + + @staticmethod + def concat_by_col(index_data_list): + # get all col and row + all_col = set() + all_row = [] + for index_data in index_data_list: + all_col = all_col | set(index_data.col) + all_row.append(index_data.row[0]) + all_col = list(all_col) + all_col.sort() + all_col_map = dict(zip(all_col, range(len(all_col)))) + + # concat all + tmp_data = np.full((len(index_data_list), len(all_col)), np.NaN) + for data_id, index_data in enumerate(index_data_list): + now_data_map = [all_col_map[col] for col in index_data.col] + tmp_data[data_id, now_data_map] = index_data.data + return IndexData(tmp_data, all_row, all_col) + + def sum(self, axis = None): + if axis is None: + return np.nansum(self.data) + if axis == 0: + tmp_data = np.nansum(self.data, axis=0) + return IndexData(tmp_data, [self.row[0]], self.col) + else: + raise NotImplementedError(f"axis must be 0 or None") + + def keep_positive(self, limit = 1e-08): + assert len(self.row) == 1 + new_col = [] + new_data = [] + for col_id, col in enumerate(self.col): + if self.data[0: col_id] < 1e-08: + continue + else: + new_col.append(col) + new_data.append(self.data[0: col_id]) + return IndexData(new_data, self.row, new_col) + + def __mul__(self, other): + if isinstance(other, IndexData): + assert len(self.row) == len(other.row) + assert self.col == other.col + return IndexData(self.data * other.data, ["mul"], self.col) + else: + return NotImplemented + + def __truediv__(self, other): + if isinstance(other, IndexData): + assert len(self.row) == len(other.row) + assert self.col == other.col + return IndexData(self.data / other.data, ["div"], self.col) + else: + return NotImplemented + + def __len__(self): + return len(self.col) + + + + + + + + \ No newline at end of file diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 81a0b3c9b..c59ca4ea2 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -16,7 +16,7 @@ from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir from qlib.backtest.utils import TradeCalendarManager -from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator +from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator, IndexData from ..data import D from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data @@ -391,23 +391,26 @@ class Indicator: return None, None if isinstance(price_s, (int, float)): - price_s = pd.Series(price_s, index=[trade_start_time]) + price_s = IndexData([price_s], [inst], [trade_start_time]) # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. - price_s = price_s[~(price_s < 1e-08)] # remove zero and negative values. + # remove zero and negative values. + price_s = price_s.keep_positive(1e-08) # NOTE ~(price_s < 1e-08) is different from price_s >= 1e-8 if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) - volume_s = volume_s.reindex(price_s.index) + if isinstance(volume_s, (int, float)): + volume_s = IndexData([volume_s], [inst], [trade_start_time]) + volume_s = volume_s.reindex(price_s.col) elif agg == "twap": - volume_s = pd.Series(1, index=price_s.index) + volume_s = IndexData([1 for i in range(price_s.col)], [inst], price_s.col) else: raise NotImplementedError(f"This type of input is not supported") - base_volume = volume_s.sum().item() - base_price = ((price_s * volume_s).sum() / base_volume).item() + base_volume = volume_s.sum() + base_price = (price_s * volume_s).sum() / base_volume return base_price, base_volume @@ -441,15 +444,15 @@ class Indicator: """ # TODO: I think there are potentials to be optimized - trade_dir = self.order_indicator.get_metric_series("trade_dir") + trade_dir = self.order_indicator.get_index_data("trade_dir") if len(trade_dir) > 0: bp_all, bv_all = [], [] # for oi, (dec, start, end) in zip(inner_order_indicators, decision_list): - bp_s = oi.get_metric_series("base_price").reindex(trade_dir.index) - bv_s = oi.get_metric_series("base_volume").reindex(trade_dir.index) + bp_s = oi.get_index_data("base_price").reindex(trade_dir.col) + bv_s = oi.get_index_data("base_volume").reindex(trade_dir.col) bp_new, bv_new = {}, {} - for pr, v, (inst, direction) in zip(bp_s.values, bv_s.values, trade_dir.items()): + for pr, v, (inst, direction) in zip(bp_s.data, bv_s.data, zip(trade_dir.col, trade_dir.data)): if np.isnan(pr): bp_tmp, bv_tmp = self._get_base_vol_pri( inst, @@ -465,15 +468,16 @@ class Indicator: else: bp_new[inst], bv_new[inst] = pr, v - bp_new, bv_new = pd.Series(bp_new), pd.Series(bv_new) + bp_new = IndexData(list(bp_new.values()), ["base_price"], list(bp_new.keys())) + bv_new = IndexData(list(bv_new.values()), ["base_volume"], list(bv_new.keys())) bp_all.append(bp_new) bv_all.append(bv_new) - bp_all = pd.concat(bp_all, axis=1) - bv_all = pd.concat(bv_all, axis=1) + bp_all = IndexData.concat_by_col(bp_all) + bv_all = IndexData.concat_by_col(bv_all) - base_volume = bv_all.sum(axis=1) - self.order_indicator.assign("base_volume", base_volume) - self.order_indicator.assign("base_price", (bp_all * bv_all).sum(axis=1) / base_volume) + base_volume = bv_all.sum(axis = 0) + self.order_indicator.assign("base_volume", base_volume.to_dict()) + self.order_indicator.assign("base_price", ((bp_all * bv_all).sum(axis = 0) / base_volume).to_dict()) def _agg_order_price_advantage(self): def if_empty_func(trade_price): @@ -592,7 +596,7 @@ class Indicator: ) ) - def get_order_indicator(self, raw: bool = False): + def get_order_indicator(self, raw: bool = True): if raw: return self.order_indicator return self.order_indicator.to_series() From e134c358fde74f3f671306bc60ca33b70f5cf910 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 18 Aug 2021 14:18:19 +0000 Subject: [PATCH 166/187] fix index data bug --- qlib/backtest/high_performance_ds.py | 138 ++++++++++++--------------- qlib/backtest/report.py | 16 ++-- 2 files changed, 67 insertions(+), 87 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index e5534dfcd..38488b1f7 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -203,7 +203,7 @@ class NumpyQuote(BaseQuote): elif method is None: stock_data = self.data[stock_id][start_id:end_id, self.columns[fields]] stock_dates = self.dates_list[stock_id][start_id:end_id].to_list() - return IndexData(stock_data, [stock_id], stock_dates) + return IndexData(stock_data, stock_dates) else: agg_stock_data = self._agg_data(self.data[stock_id][start_id:end_id, self.columns[fields]], method) @@ -721,7 +721,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): self.data = np.zeros((len(NumpyOrderIndicator.ROW), len(metric))) self.column = list(metric.keys()) self.column_map = dict(zip(self.column, range(len(self.column)))) - + metric_column = list(metric.keys()) if self.column != metric_column: assert len(set(self.column) - set(metric_column)) == 0 @@ -753,9 +753,9 @@ class NumpyOrderIndicator(BaseOrderIndicator): def get_index_data(self, metric): if self._if_valid_metric(metric): - return IndexData(self.data[NumpyOrderIndicator.ROW_MAP[metric]], [metric], self.column) + return IndexData(self.data[NumpyOrderIndicator.ROW_MAP[metric]], self.column) else: - return IndexData([], [], []) + return IndexData([], []) def get_metric_series(self, metric: str) -> Union[pd.Series]: if self._if_valid_metric(metric): @@ -819,52 +819,80 @@ class NumpyOrderIndicator(BaseOrderIndicator): class IndexData: - def __init__(self, data, row, column): + def __init__(self, data, column): if isinstance(data, list): - self.data = np.array([data]) + self.data = np.array(data) elif isinstance(data, np.ndarray): - if data.ndim == 1: - self.data = data[np.newaxis, :] - elif data.ndim == 2: - self.data = data - else: - raise ValueError(f"the dimension of data must <= 2") + self.data = data else: raise ValueError(f"data must be list or np.ndarray") - self.data = data + self.ndim = self.data.ndim - assert isinstance(row, list) - self.row = row - self.row_map = dict(zip(self.row, range(len(self.row)))) assert isinstance(column, list) self.col = column self.col_map = dict(zip(self.col, range(len(self.col)))) def reindex(self, new_column): - tmp_data = self.data.copy() - for row_id, row in enumerate(self.row): - for col_id, col in new_column: - if col in self.col: - tmp_data[row_id, col_id] = self.data[row_id, self.row_map[col]] - else: - tmp_data[row_id, col_id] = np.NaN - return IndexData(tmp_data, self.row, list(new_column)) + assert self.ndim == 1 + tmp_data = np.full(len(new_column), np.NaN) + for col_id, col in enumerate(new_column): + if col in self.col: + tmp_data[col_id] = self.data[self.col_map[col]] + return IndexData(tmp_data, list(new_column)) def to_dict(self): - assert len(self.row) == 1 - if self.data.size == 0: - return {col: np.NaN for col in self.col} + assert self.ndim == 1 + return dict(zip(self.col, self.data.tolist())) + + def keep_positive(self, limit=1e-08): + assert self.ndim == 1 + new_col = [] + new_data = [] + for col_id, col in enumerate(self.col): + if self.data[col_id] < 1e-08: + continue + else: + new_col.append(col) + new_data.append(self.data[col_id]) + return IndexData(new_data, new_col) + + def sum(self, axis=None): + if axis is None: + return np.nansum(self.data) + if axis == 0: + assert self.ndim == 2 + tmp_data = np.nansum(self.data, axis=0) + return IndexData(tmp_data, self.col) else: - return dict(zip(self.col, self.data[0, :].tolist())) + raise NotImplementedError(f"axis must be 0 or None") + + def __mul__(self, other): + if isinstance(other, IndexData): + assert self.ndim == other.ndim + assert self.col == other.col + assert len(self.data) == len(other.data) + return IndexData(self.data * other.data, self.col) + else: + return NotImplemented + + def __truediv__(self, other): + if isinstance(other, IndexData): + assert self.ndim == other.ndim + assert self.col == other.col + assert len(self.data) == len(other.data) + return IndexData(self.data / other.data, self.col) + else: + return NotImplemented + + def __len__(self): + return len(self.col) @staticmethod def concat_by_col(index_data_list): # get all col and row all_col = set() - all_row = [] for index_data in index_data_list: all_col = all_col | set(index_data.col) - all_row.append(index_data.row[0]) all_col = list(all_col) all_col.sort() all_col_map = dict(zip(all_col, range(len(all_col)))) @@ -874,52 +902,4 @@ class IndexData: for data_id, index_data in enumerate(index_data_list): now_data_map = [all_col_map[col] for col in index_data.col] tmp_data[data_id, now_data_map] = index_data.data - return IndexData(tmp_data, all_row, all_col) - - def sum(self, axis = None): - if axis is None: - return np.nansum(self.data) - if axis == 0: - tmp_data = np.nansum(self.data, axis=0) - return IndexData(tmp_data, [self.row[0]], self.col) - else: - raise NotImplementedError(f"axis must be 0 or None") - - def keep_positive(self, limit = 1e-08): - assert len(self.row) == 1 - new_col = [] - new_data = [] - for col_id, col in enumerate(self.col): - if self.data[0: col_id] < 1e-08: - continue - else: - new_col.append(col) - new_data.append(self.data[0: col_id]) - return IndexData(new_data, self.row, new_col) - - def __mul__(self, other): - if isinstance(other, IndexData): - assert len(self.row) == len(other.row) - assert self.col == other.col - return IndexData(self.data * other.data, ["mul"], self.col) - else: - return NotImplemented - - def __truediv__(self, other): - if isinstance(other, IndexData): - assert len(self.row) == len(other.row) - assert self.col == other.col - return IndexData(self.data / other.data, ["div"], self.col) - else: - return NotImplemented - - def __len__(self): - return len(self.col) - - - - - - - - \ No newline at end of file + return IndexData(tmp_data, all_col) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index c59ca4ea2..486c74d8b 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -391,7 +391,7 @@ class Indicator: return None, None if isinstance(price_s, (int, float)): - price_s = IndexData([price_s], [inst], [trade_start_time]) + price_s = IndexData([price_s], [trade_start_time]) # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. @@ -402,16 +402,15 @@ class Indicator: if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) if isinstance(volume_s, (int, float)): - volume_s = IndexData([volume_s], [inst], [trade_start_time]) + volume_s = IndexData([volume_s], [trade_start_time]) volume_s = volume_s.reindex(price_s.col) elif agg == "twap": - volume_s = IndexData([1 for i in range(price_s.col)], [inst], price_s.col) + volume_s = IndexData([1 for i in range(len(price_s.col))], price_s.col) else: raise NotImplementedError(f"This type of input is not supported") base_volume = volume_s.sum() base_price = (price_s * volume_s).sum() / base_volume - return base_price, base_volume def _agg_base_price( @@ -451,6 +450,7 @@ class Indicator: for oi, (dec, start, end) in zip(inner_order_indicators, decision_list): bp_s = oi.get_index_data("base_price").reindex(trade_dir.col) bv_s = oi.get_index_data("base_volume").reindex(trade_dir.col) + bp_new, bv_new = {}, {} for pr, v, (inst, direction) in zip(bp_s.data, bv_s.data, zip(trade_dir.col, trade_dir.data)): if np.isnan(pr): @@ -468,16 +468,16 @@ class Indicator: else: bp_new[inst], bv_new[inst] = pr, v - bp_new = IndexData(list(bp_new.values()), ["base_price"], list(bp_new.keys())) - bv_new = IndexData(list(bv_new.values()), ["base_volume"], list(bv_new.keys())) + bp_new = IndexData(list(bp_new.values()), list(bp_new.keys())) + bv_new = IndexData(list(bv_new.values()), list(bv_new.keys())) bp_all.append(bp_new) bv_all.append(bv_new) bp_all = IndexData.concat_by_col(bp_all) bv_all = IndexData.concat_by_col(bv_all) - base_volume = bv_all.sum(axis = 0) + base_volume = bv_all.sum(axis=0) self.order_indicator.assign("base_volume", base_volume.to_dict()) - self.order_indicator.assign("base_price", ((bp_all * bv_all).sum(axis = 0) / base_volume).to_dict()) + self.order_indicator.assign("base_price", ((bp_all * bv_all).sum(axis=0) / base_volume).to_dict()) def _agg_order_price_advantage(self): def if_empty_func(trade_price): From be0d9e6a229c047c3c8d3d9830317e0a8c298c12 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 18 Aug 2021 14:29:15 +0000 Subject: [PATCH 167/187] update freq --- qlib/backtest/high_performance_ds.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 38488b1f7..3e4d418f0 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -104,6 +104,7 @@ class PandasQuote(BaseQuote): for stock_id, stock_val in quote_df.groupby(level="instrument"): quote_dict[stock_id] = stock_val.droplevel(level="instrument") self.data = quote_dict + self.freq = np.timedelta64(1, "m") def get_all_stock(self): return self.data.keys() @@ -117,7 +118,7 @@ class PandasQuote(BaseQuote): raise ValueError(f"fields must be None, str or list") def _if_single_data(self, start_time, end_time): - if end_time - start_time < np.timedelta64(1, "m"): + if end_time - start_time < self.freq: return True if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: return True From f111e34bd2928fd590f469ca9612266a1798f050 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Thu, 19 Aug 2021 14:06:33 +0000 Subject: [PATCH 168/187] align interface --- qlib/backtest/exchange.py | 4 +- qlib/backtest/high_performance_ds.py | 664 +++++++++++++++------------ qlib/backtest/report.py | 18 +- qlib/utils/time.py | 36 +- 4 files changed, 415 insertions(+), 307 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 9327e6f15..b9b8d087b 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -21,7 +21,7 @@ from ..config import C, REG_CN from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order, OrderDir, OrderHelper -from .high_performance_ds import PandasQuote, NumpyQuote +from .high_performance_ds import PandasQuote, CN1Min_NumpyQuote class Exchange: @@ -39,7 +39,7 @@ class Exchange: close_cost=0.0025, min_cost=5, extra_quote=None, - quote_cls=NumpyQuote, + quote_cls=CN1Min_NumpyQuote, **kwargs, ): """__init__ diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 3e4d418f0..979ca7609 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -3,9 +3,7 @@ import logging -from pandas._config.config import is_instance_factory -from qlib.data.base import Feature -from typing import List, Text, Tuple, Union, Callable, Iterable, Dict, ValuesView +from typing import List, Text, Union, Callable, Iterable, Dict from collections import OrderedDict import inspect @@ -15,6 +13,7 @@ import numpy as np from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger +from ..utils.time import _if_single_data class BaseQuote: @@ -34,12 +33,12 @@ class BaseQuote: def get_data( self, - stock_id: Union[str, list], + stock_id: str, start_time: Union[pd.Timestamp, str], end_time: Union[pd.Timestamp, str], - fields: Union[str, list] = None, + fields: str = None, method: Union[str, Callable] = None, - ) -> Union[None, float, pd.Series, pd.DataFrame]: + ) -> Union[None, float, pd.Series, pd.DataFrame, "IndexData"]: """get the specific fields of stock data during start time and end_time, and apply method to the data. @@ -59,21 +58,40 @@ class BaseQuote: 2010-01-12 2788.688232 164587.937500 2010-01-13 2790.604004 145460.453125 - print(get_data(stock_id=["SH600000", "SH600655"], start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + this function is used for three case: - $close $volume - instrument - SH600000 87.433578 28117442.0 - SH600655 2699.567383 158193.328125 + 1. Both fields and method are not None. It returns float. + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields="$close", method="last")) - print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields=["$close", "$volume"], method="last")) + 85.713585 - $close 87.433578 - $volume 28117442.0 + 2. Both fields and method are None. It returns pd.Dataframe or np.ndarray. + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields=None, method=None)) - print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-05", fields="$close", method="last")) + 1) pd.Dataframe + $close $volume + datetime + 2010-01-04 86.778313 16162960.0 + 2010-01-05 87.433578 28117442.0 + 2010-01-06 85.713585 23632884.0 - 87.433578 + 2) np.ndarray + [ + [86.778313, 16162960.0], + [87.433578, 28117442.0], + [85.713585, 23632884.0], + ] + + 3. fields is not None, and method is None. It returns pd.Series or IndexData. + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields="$close", method=None)) + + 1) pd.Series + 2010-01-04 86.778313 + 2010-01-05 87.433578 + 2010-01-06 85.713585 + + 2) IndexData + IndexData([86.778313, 87.433578, 85.713585], [2010-01-04, 2010-01-05, 2010-01-06]) Parameters ---------- @@ -86,12 +104,12 @@ class BaseQuote: the columns of data to fetch method : Union[str, Callable] the method apply to data. - e.g [None, "last", "all", "sum", "mean", "any", qlib/utils/resam.py/ts_data_last] + e.g [None, "last", "all", "sum", "mean", qlib/utils/resam.py/ts_data_last] Return ---------- - Union[None, float, pd.Series, pd.DataFrame] - The resampled DataFrame/Series/value, return None when the resampled data is empty. + Union[None, float, pd.Series, pd.DataFrame, IndexData] + please refer to Example as following. """ raise NotImplementedError(f"Please implement the `get_data` method") @@ -104,7 +122,6 @@ class PandasQuote(BaseQuote): for stock_id, stock_val in quote_df.groupby(level="instrument"): quote_dict[stock_id] = stock_val.droplevel(level="instrument") self.data = quote_dict - self.freq = np.timedelta64(1, "m") def get_all_stock(self): return self.data.keys() @@ -117,19 +134,10 @@ class PandasQuote(BaseQuote): else: raise ValueError(f"fields must be None, str or list") - def _if_single_data(self, start_time, end_time): - if end_time - start_time < self.freq: - return True - if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: - return True - if start_time.hour == 14 and start_time.minute == 59 and start_time.second == 0: - return True - return False - -class NumpyQuote(BaseQuote): +class CN1Min_NumpyQuote(BaseQuote): def __init__(self, quote_df: pd.DataFrame): - """NumpyQuote + """CN1Min_NumpyQuote Parameters ---------- @@ -141,20 +149,20 @@ class NumpyQuote(BaseQuote): each stock has one two-dimensional np.ndarray to represent data. self.columns: Dict[str, int] map column name to column id in self.data. - self.dates: Dict[stock_id, Dict[pd.Timestap, int]] + self.dt2idx: Dict[stock_id, Dict[pd.Timestap, int]] map timestap to row id in self.data. - self.dates_list: Dict[stock_id, List[pd.Timestap]] - the dates of each stock for searching. + self.idx2dt: Dict[stock_id, List[pd.Timestap]] + the dt2idx of each stock for searching. """ super().__init__(quote_df=quote_df) # init data columns = quote_df.columns.values self.columns = dict(zip(columns, range(len(columns)))) - self.data, self.dates, self.dates_list = self._to_numpy(quote_df) + self.data, self.dt2idx, self.idx2dt = self._to_numpy(quote_df) # lru - self.muti_lru = {} + self.multi_lru = {} self.max_lru_len = 256 def _to_numpy(self, quote_df): @@ -175,27 +183,32 @@ class NumpyQuote(BaseQuote): return self.data.keys() def get_data(self, stock_id, start_time, end_time, fields=None, method=None): + # check fields + if isinstance(fields, list) and len(fields) > 1: + raise ValueError(f"get_data in CN1Min_NumpyQuote only supports one field") + # check stock id if stock_id not in self.get_all_stock(): return None # get single data - if self._if_single_data(start_time, end_time): - if start_time not in self.dates[stock_id]: + # single data is only one piece of data, so it don't need to agg by method. + if _if_single_data(start_time, end_time, np.timedelta64(1, "m")): + if start_time not in self.dt2idx[stock_id]: return None if fields is None: # it used for check if data is None - return self.data[stock_id][self.dates[stock_id][start_time]] + return self.data[stock_id][self.dt2idx[stock_id][start_time]] else: - return self.data[stock_id][self.dates[stock_id][start_time]][self.columns[fields]] + return self.data[stock_id][self.dt2idx[stock_id][start_time]][self.columns[fields]] # get muti row data else: # check lru - if (stock_id, start_time, end_time, fields, method) in self.muti_lru: - return self.muti_lru[(stock_id, start_time, end_time, fields, method)] + if (stock_id, start_time, end_time, fields, method) in self.multi_lru: + return self.multi_lru[(stock_id, start_time, end_time, fields, method)] - start_id = bisect.bisect_left(self.dates_list[stock_id], start_time) - end_id = bisect.bisect_right(self.dates_list[stock_id], end_time) + start_id = bisect.bisect_left(self.idx2dt[stock_id], start_time) + end_id = bisect.bisect_right(self.idx2dt[stock_id], end_time) if start_id == end_id: return None # it used for check if data is None @@ -203,59 +216,38 @@ class NumpyQuote(BaseQuote): return self.data[stock_id][start_id:end_id] elif method is None: stock_data = self.data[stock_id][start_id:end_id, self.columns[fields]] - stock_dates = self.dates_list[stock_id][start_id:end_id].to_list() - return IndexData(stock_data, stock_dates) + stock_dt2idx = self.idx2dt[stock_id][start_id:end_id].to_list() + return IndexData(stock_data, stock_dt2idx) else: agg_stock_data = self._agg_data(self.data[stock_id][start_id:end_id, self.columns[fields]], method) # result lru - if len(self.muti_lru) >= self.max_lru_len: - self.muti_lru.clear() - self.muti_lru[(stock_id, start_time, end_time, fields, method)] = agg_stock_data + if len(self.multi_lru) >= self.max_lru_len: + self.multi_lru.clear() + self.multi_lru[(stock_id, start_time, end_time, fields, method)] = agg_stock_data return agg_stock_data def _agg_data(self, data, method): """Agg data by specific method.""" - + valid_data = data[data != np.array(None)].copy() if method == "sum": - return data.sum() - if method == "mean": - return data.mean() - if method == "last": - return data[-1] - if method == "all": - return data.all() - if method == "any": - return data.any() - if method == ts_data_last: - valid_data = data[data != np.NaN] + return np.nansum(valid_data) + elif method == "mean": + return np.nanmean(valid_data) + elif method == "last": + return valid_data[-1] + elif method == "all": + return valid_data.all() + elif method == "any": + return valid_data.any() + elif method == ts_data_last: + valid_data = valid_data[valid_data != np.NaN] if len(valid_data) == 0: return None else: return valid_data[0] - - def _if_single_data(self, start_time, end_time): - """Is there only one piece of data to obtaine. - - Parameters - ---------- - start_time : Union[pd.Timestamp, str] - closed start time for data. - end_time : Union[pd.Timestamp, str] - closed end time for data. - Returns - ------- - bool - True means one piece of data to obtaine. - """ - - if end_time - start_time < np.timedelta64(1, "m"): - return True - if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: - return True - if start_time.hour == 14 and start_time.minute == 59 and start_time.second == 0: - return True - return False + else: + raise ValueError(f"{method} is not supported") class BaseSingleMetric: @@ -346,10 +338,13 @@ class BaseOrderIndicator: structure of PandasOrderIndicator is Dict[str, PandasSingleMetric]. It uses PandasSingleMetric based on pd.Series to represent each metric. 2. The another way doesn't use BaseSingleMetric to represent each metric. The data - structure of PandasOrderIndicator is a whole matrix. It means you are not neccesary + structure of PandasOrderIndicator is a whole matrix. It means you are not necessary to inherit the BaseSingleMetric. """ + def __init__(self): + self.logger = get_module_logger("online operator") + def assign(self, col: str, metric: Union[dict, pd.Series]): """assign one metric. @@ -358,10 +353,17 @@ class BaseOrderIndicator: col : str the metric name of one metric. metric : Union[dict, pd.Series] - the metric data. + one metric with stock_id index, such as deal_amount, ffr, etc. + for example: + SH600068 NaN + SH600079 1.0 + SH600266 NaN + ... + SZ300692 NaN + SZ300719 NaN, """ - pass + raise NotImplementedError(f"Please implement the 'assign' method") def transfer(self, func: Callable, new_col: str = None) -> Union[None, BaseSingleMetric]: """compute new metric with existing metrics. @@ -383,7 +385,7 @@ class BaseOrderIndicator: new metric. """ - pass + raise NotImplementedError(f"Please implement the 'transfer' method") def get_metric_series(self, metric: str) -> pd.Series: """return the single metric with pd.Series format. @@ -400,16 +402,32 @@ class BaseOrderIndicator: If there is no metric name in the data, return pd.Series(). """ - pass + raise NotImplementedError(f"Please implement the 'get_metric_series' method") - @staticmethod - def sum_all_indicators(cls, indicators: list, metrics: Union[str, List[str]], fill_value: float = None): - """sum indicators with the same metrics. - and assign to the cls(BaseOrderIndicator). + def get_index_data(self, metric): + """get one metric with the format of IndexData Parameters ---------- - cls : BaseOrderIndicator + metric : str + the metric name. + + Return + ------ + IndexData + one metric with the format of IndexData + """ + + raise NotImplementedError(f"Please implement the 'get_index_data' method") + + @staticmethod + def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value: float = None): + """sum indicators with the same metrics. + and assign to the order_indicator(BaseOrderIndicator). + + Parameters + ---------- + order_indicator : BaseOrderIndicator the order indicator to assign. indicators : List[BaseOrderIndicator] the list of all inner indicators. @@ -419,7 +437,7 @@ class BaseOrderIndicator: fill np.NaN with value. By default None. """ - pass + raise NotImplementedError(f"Please implement the 'sum_all_indicators' method") def to_series(self) -> Dict[Text, pd.Series]: """return the metrics as pandas series @@ -437,7 +455,76 @@ class BaseOrderIndicator: raise NotImplementedError(f"Please implement the `to_series` method") -class PandasSingleMetric: +class SingleMetric(BaseSingleMetric): + def __add__(self, other): + if isinstance(other, (int, float)): + return self.__class__(self.metric + other) + elif isinstance(other, self.__class__): + return self.__class__(self.metric + other.metric) + else: + return NotImplemented + + def __sub__(self, other): + if isinstance(other, (int, float)): + return self.__class__(self.metric - other) + elif isinstance(other, self.__class__): + return self.__class__(self.metric - other.metric) + else: + return NotImplemented + + def __rsub__(self, other): + if isinstance(other, (int, float)): + return self.__class__(other - self.metric) + elif isinstance(other, self.__class__): + return self.__class__(other.metric - self.metric) + else: + return NotImplemented + + def __mul__(self, other): + if isinstance(other, (int, float)): + return self.__class__(self.metric * other) + elif isinstance(other, self.__class__): + return self.__class__(self.metric * other.metric) + else: + return NotImplemented + + def __truediv__(self, other): + if isinstance(other, (int, float)): + return self.__class__(self.metric / other) + elif isinstance(other, self.__class__): + return self.__class__(self.metric / other.metric) + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, (int, float)): + return self.__class__(self.metric == other) + elif isinstance(other, self.__class__): + return self.__class__(self.metric == other.metric) + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, (int, float)): + return self.__class__(self.metric > other) + elif isinstance(other, self.__class__): + return self.__class__(self.metric > other.metric) + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, (int, float)): + return self.__class__(self.metric < other) + elif isinstance(other, self.__class__): + return self.__class__(self.metric < other.metric) + else: + return NotImplemented + + def __len__(self): + return len(self.metric) + + +class PandasSingleMetric(SingleMetric): """Each SingleMetric is based on pd.Series.""" def __init__(self, metric: Union[dict, pd.Series]): @@ -448,73 +535,6 @@ class PandasSingleMetric: else: raise ValueError(f"metric must be dict or pd.Series") - def __add__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric + other) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric + other.metric) - else: - return NotImplemented - - def __sub__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric - other) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric - other.metric) - else: - return NotImplemented - - def __rsub__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(other - self.metric) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(other.metric - self.metric) - else: - return NotImplemented - - def __mul__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric * other) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric * other.metric) - else: - return NotImplemented - - def __truediv__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric / other) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric / other.metric) - else: - return NotImplemented - - def __eq__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric == other) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric == other.metric) - else: - return NotImplemented - - def __gt__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric > other) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric > other.metric) - else: - return NotImplemented - - def __lt__(self, other): - if isinstance(other, (int, float)): - return PandasSingleMetric(self.metric < other) - elif isinstance(other, PandasSingleMetric): - return PandasSingleMetric(self.metric < other.metric) - else: - return NotImplemented - - def __len__(self): - return len(self.metric) - def sum(self): return self.metric.sum() @@ -525,23 +545,23 @@ class PandasSingleMetric: return self.metric.count() def abs(self): - return PandasSingleMetric(self.metric.abs()) + return self.__class__(self.metric.abs()) def astype(self, type): - return PandasSingleMetric(self.metric.astype(type)) + return self.__class__(self.metric.astype(type)) @property def empty(self): return self.metric.empty def add(self, other, fill_value=None): - return PandasSingleMetric(self.metric.add(other.metric, fill_value=fill_value)) + return self.__class__(self.metric.add(other.metric, fill_value=fill_value)) def replace(self, replace_dict: dict): - return PandasSingleMetric(self.metric.replace(replace_dict)) + return self.__class__(self.metric.replace(replace_dict)) def apply(self, func: Callable): - return PandasSingleMetric(self.metric.apply(func)) + return self.__class__(self.metric.apply(func)) class PandasOrderIndicator(BaseOrderIndicator): @@ -573,87 +593,29 @@ class PandasOrderIndicator(BaseOrderIndicator): return pd.Series() @staticmethod - def sum_all_indicators(cls, indicators: list, metrics: Union[str, List[str]], fill_value=None): + def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=None): if isinstance(metrics, str): metrics = [metrics] for metric in metrics: tmp_metric = PandasSingleMetric({}) for indicator in indicators: tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) - cls.assign(metric, tmp_metric.metric) + order_indicator.assign(metric, tmp_metric.metric) def to_series(self): return {k: v.metric for k, v in self.data.items()} + def get_index_data(self, metric): + if metric in self.data: + return IndexData(self.data[metric].values(), list(self.data[metric].index)) + else: + return IndexData([], []) -class NumpySingleMetric(BaseSingleMetric): + +class NumpySingleMetric(SingleMetric): def __init__(self, metric: np.ndarray): self.metric = metric - def __add__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(self.metric + other) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(self.metric + other.metric) - else: - return NotImplemented - - def __sub__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(self.metric - other) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(self.metric - other.metric) - else: - return NotImplemented - - def __rsub__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(other - self.metric) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(other.metric - self.metric) - else: - return NotImplemented - - def __mul__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(self.metric * other) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(self.metric * other.metric) - else: - return NotImplemented - - def __truediv__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(self.metric / other) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(self.metric / other.metric) - else: - return NotImplemented - - def __eq__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(self.metric == other) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(self.metric == other.metric) - else: - return NotImplemented - - def __gt__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(self.metric > other) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(self.metric > other.metric) - else: - return NotImplemented - - def __lt__(self, other): - if isinstance(other, (int, float)): - return NumpySingleMetric(self.metric < other) - elif isinstance(other, NumpySingleMetric): - return NumpySingleMetric(self.metric < other.metric) - else: - return NotImplemented - def __len__(self): return len(self.metric) @@ -667,10 +629,10 @@ class NumpySingleMetric(BaseSingleMetric): return len(self.metric[~np.isnan(self.metric)]) def abs(self): - return NumpySingleMetric(np.absolute(self.metric)) + return self.__class__(np.absolute(self.metric)) def astype(self, type): - return NumpySingleMetric(self.metric.astype(type)) + return self.__class__(self.metric.astype(type)) @property def empty(self): @@ -680,13 +642,13 @@ class NumpySingleMetric(BaseSingleMetric): tmp_metric = self.metric.copy() for num in replace_dict: tmp_metric[tmp_metric == num] = replace_dict[num] - return NumpySingleMetric(tmp_metric) + return self.__class__(tmp_metric) def apply(self, func: Callable): tmp_metric = self.metric.copy() for i in range(len(tmp_metric)): tmp_metric[i] = func(tmp_metric[i]) - return NumpySingleMetric(tmp_metric) + return self.__class__(tmp_metric) class NumpyOrderIndicator(BaseOrderIndicator): @@ -713,13 +675,13 @@ class NumpyOrderIndicator(BaseOrderIndicator): def assign(self, col: str, metric: dict): if col not in NumpyOrderIndicator.ROW: - raise ValueError(f"{col} metric is not supoorted") + raise ValueError(f"{col} metric is not supported") if not isinstance(metric, dict): raise ValueError(f"metric must be dict") # if data is None, init numpy ndarray if self.data is None: - self.data = np.zeros((len(NumpyOrderIndicator.ROW), len(metric))) + self.data = np.full((len(NumpyOrderIndicator.ROW), len(metric)), np.NaN) self.column = list(metric.keys()) self.column_map = dict(zip(self.column, range(len(self.column)))) @@ -743,7 +705,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): if self._if_valid_metric(sig): func_kwargs[sig] = NumpySingleMetric(self.data[NumpyOrderIndicator.ROW_MAP[sig]]) else: - print(f"{sig} is not assigned") + self.logger.warning(f"{sig} is not assigned") func_kwargs[sig] = NumpySingleMetric(np.array([])) tmp_metric = func(**func_kwargs) if new_col is not None: @@ -778,7 +740,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): @staticmethod def sum_all_indicators( - cls, indicators: list, metrics: Union[str, List[str]], fill_value=None + order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=None ) -> Dict[str, NumpySingleMetric]: # metrics is all metrics to add # metrics_id means the index in the NumpyOrderIndicator.ROW for metrics. @@ -800,27 +762,37 @@ class NumpyOrderIndicator(BaseOrderIndicator): if fill_value is not None: base_metrics = fill_value * np.ones((len(metrics), len(stocks))) for i in range(len(indicators)): - tmp_netrics = base_metrics.copy() + tmp_metrics = base_metrics.copy() stocks_index = [stocks_map[stock] for stock in indicators[i].column] - tmp_netrics[:, stocks_index] = indicator_metrics[i] - indicator_metrics[i] = tmp_netrics + tmp_metrics[:, stocks_index] = indicator_metrics[i] + indicator_metrics[i] = tmp_metrics else: raise ValueError(f"fill value can not be None in NumpyOrderIndicator") - # add metric and assign to cls + # add metric and assign to order_indicator metric_sum = sum(indicator_metrics) - if cls.data is not None: + if order_indicator.data is not None: raise ValueError(f"this function must assign to an empty order indicator") - cls.data = np.zeros((len(NumpyOrderIndicator.ROW), len(stocks))) - cls.column = stocks - cls.column_map = dict(zip(stocks, range(len(stocks)))) + order_indicator.data = np.zeros((len(NumpyOrderIndicator.ROW), len(stocks))) + order_indicator.column = stocks + order_indicator.column_map = dict(zip(stocks, range(len(stocks)))) for i in range(len(metrics)): - cls.row_tag[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = 1 - cls.data[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = metric_sum[i] + order_indicator.row_tag[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = 1 + order_indicator.data[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = metric_sum[i] class IndexData: - def __init__(self, data, column): + def __init__(self, data, index): + """A data structure of index and numpy data. + + Parameters + ---------- + data : np.ndarray + the dim of data must be 1 or 2. + different functions have dimensional limitations + index : list + the index of data. + """ if isinstance(data, list): self.data = np.array(data) elif isinstance(data, np.ndarray): @@ -829,78 +801,188 @@ class IndexData: raise ValueError(f"data must be list or np.ndarray") self.ndim = self.data.ndim - assert isinstance(column, list) - self.col = column - self.col_map = dict(zip(self.col, range(len(self.col)))) + assert isinstance(index, list) + self.index = index + self.index_map = dict(zip(self.index, range(len(self.index)))) - def reindex(self, new_column): + def reindex(self, new_index): + """reindex data and fill the missing value with np.NaN. + just for 1-dim data. + + Parameters + ---------- + new_index : list + new index + + Returns + ------- + IndexData + reindex data + """ assert self.ndim == 1 - tmp_data = np.full(len(new_column), np.NaN) - for col_id, col in enumerate(new_column): - if col in self.col: - tmp_data[col_id] = self.data[self.col_map[col]] - return IndexData(tmp_data, list(new_column)) + tmp_data = np.full(len(new_index), np.NaN) + for index_id, index in enumerate(new_index): + if index in self.index: + tmp_data[index_id] = self.data[self.index_map[index]] + return IndexData(tmp_data, list(new_index)) def to_dict(self): - assert self.ndim == 1 - return dict(zip(self.col, self.data.tolist())) + """convert IndexData to dict. + just for 1-dim data. - def keep_positive(self, limit=1e-08): + Returns + ------- + dict + data with the dict format. + """ assert self.ndim == 1 - new_col = [] - new_data = [] - for col_id, col in enumerate(self.col): - if self.data[col_id] < 1e-08: - continue - else: - new_col.append(col) - new_data.append(self.data[col_id]) - return IndexData(new_data, new_col) + return dict(zip(self.index, self.data.tolist())) def sum(self, axis=None): + """get the sum of data. + + Parameters + ---------- + axis : 0 or None, optional + which axis to sum, by default None + + Returns + ------- + Union[float, IndexData] + if axis is None, it sums all data, return float. + if axis == 1, it sums by row, return IndexData. + """ if axis is None: return np.nansum(self.data) if axis == 0: assert self.ndim == 2 tmp_data = np.nansum(self.data, axis=0) - return IndexData(tmp_data, self.col) + return IndexData(tmp_data, self.index) else: raise NotImplementedError(f"axis must be 0 or None") def __mul__(self, other): + """multiply with another IndexData. + + Returns + ------- + IndexData + """ if isinstance(other, IndexData): assert self.ndim == other.ndim - assert self.col == other.col + assert self.index == other.index assert len(self.data) == len(other.data) - return IndexData(self.data * other.data, self.col) + return IndexData(self.data * other.data, self.index) else: return NotImplemented def __truediv__(self, other): + """divide with another IndexData. + + Returns + ------- + IndexData + """ if isinstance(other, IndexData): assert self.ndim == other.ndim - assert self.col == other.col + assert self.index == other.index assert len(self.data) == len(other.data) - return IndexData(self.data / other.data, self.col) + return IndexData(self.data / other.data, self.index) else: return NotImplemented def __len__(self): - return len(self.col) + """the length of the data. + + Returns + ------- + int + the length of the data. + """ + return len(self.index) + + def __getitem__(self, bool_list: "IndexData"): + """get IndexData by a bool_list which has the same shape of self.data. + just for 1-dim data. + + Parameters + ---------- + bool_list : Union[list, np.ndarray] + a bool_list which has the same shape of self.data. such as array([True, False, True]). + True means the data of the position is reserved. False is not. + + Returns + ------- + IndexData + new IndexData. + """ + assert self.ndim == 1 + assert isinstance(bool_list, IndexData) + new_data = self.data[bool_list.data] + new_index = list(np.array(self.index)[bool_list.data]) + return IndexData(new_data, new_index) + + def __gt__(self, other): + if isinstance(other, (int, float)): + return IndexData(self.data > other, self.index) + elif isinstance(other, IndexData): + return IndexData(self.data > other.data, self.index) + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, (int, float)): + return IndexData(self.data < other, self.index) + elif isinstance(other, IndexData): + return IndexData(self.data < other.data, self.index) + else: + return NotImplemented + + def __invert__(self): + return IndexData(~self.data, self.index) @staticmethod - def concat_by_col(index_data_list): - # get all col and row - all_col = set() + def concat_by_index(index_data_list): + """concat all IndexData by index. + just for 1-dim data. + + Parameters + ---------- + index_data_list : List[IndexData] + the list of all IndexData to concat. + + Returns + ------- + IndexData + the IndexData with ndim == 2 + """ + # get all index and row + all_index = set() for index_data in index_data_list: - all_col = all_col | set(index_data.col) - all_col = list(all_col) - all_col.sort() - all_col_map = dict(zip(all_col, range(len(all_col)))) + all_index = all_index | set(index_data.index) + all_index = list(all_index) + all_index.sort() + all_index_map = dict(zip(all_index, range(len(all_index)))) # concat all - tmp_data = np.full((len(index_data_list), len(all_col)), np.NaN) + tmp_data = np.full((len(index_data_list), len(all_index)), np.NaN) for data_id, index_data in enumerate(index_data_list): - now_data_map = [all_col_map[col] for col in index_data.col] + assert index_data.ndim == 1 + now_data_map = [all_index_map[index] for index in index_data.index] tmp_data[data_id, now_data_map] = index_data.data - return IndexData(tmp_data, all_col) + return IndexData(tmp_data, all_index) + + @staticmethod + def ones(index): + """initial the IndexData with index, and fill data with 1. + + Parameters + ---------- + index : list + the index of new data. + + Returns + ------- + IndexData + """ + return IndexData([1 for i in range(len(index))], list(index)) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 486c74d8b..84ae4d5d4 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -390,22 +390,24 @@ class Indicator: if price_s is None: return None, None + if isinstance(price_s, pd.Series): + price_s = IndexData(price_s.values, list(price_s.index)) if isinstance(price_s, (int, float)): price_s = IndexData([price_s], [trade_start_time]) # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. # remove zero and negative values. - price_s = price_s.keep_positive(1e-08) + price_s = price_s[~(price_s < 1e-08)] # NOTE ~(price_s < 1e-08) is different from price_s >= 1e-8 if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) if isinstance(volume_s, (int, float)): volume_s = IndexData([volume_s], [trade_start_time]) - volume_s = volume_s.reindex(price_s.col) + volume_s = volume_s.reindex(price_s.index) elif agg == "twap": - volume_s = IndexData([1 for i in range(len(price_s.col))], price_s.col) + volume_s = IndexData.ones(price_s.index) else: raise NotImplementedError(f"This type of input is not supported") @@ -448,11 +450,11 @@ class Indicator: bp_all, bv_all = [], [] # for oi, (dec, start, end) in zip(inner_order_indicators, decision_list): - bp_s = oi.get_index_data("base_price").reindex(trade_dir.col) - bv_s = oi.get_index_data("base_volume").reindex(trade_dir.col) + bp_s = oi.get_index_data("base_price").reindex(trade_dir.index) + bv_s = oi.get_index_data("base_volume").reindex(trade_dir.index) bp_new, bv_new = {}, {} - for pr, v, (inst, direction) in zip(bp_s.data, bv_s.data, zip(trade_dir.col, trade_dir.data)): + for pr, v, (inst, direction) in zip(bp_s.data, bv_s.data, zip(trade_dir.index, trade_dir.data)): if np.isnan(pr): bp_tmp, bv_tmp = self._get_base_vol_pri( inst, @@ -472,8 +474,8 @@ class Indicator: bv_new = IndexData(list(bv_new.values()), list(bv_new.keys())) bp_all.append(bp_new) bv_all.append(bv_new) - bp_all = IndexData.concat_by_col(bp_all) - bv_all = IndexData.concat_by_col(bv_all) + bp_all = IndexData.concat_by_index(bp_all) + bv_all = IndexData.concat_by_index(bv_all) base_volume = bv_all.sum(axis=0) self.order_indicator.assign("base_volume", base_volume.to_dict()) diff --git a/qlib/utils/time.py b/qlib/utils/time.py index c18d76b14..efee8f5eb 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -5,13 +5,13 @@ Time related utils are compiled in this script """ import bisect from datetime import datetime, time, date -from typing import List, Tuple -import re -from numpy import append -import pandas as pd -from qlib.config import C +from typing import List, Tuple, Union import functools -from typing import Union +import re + +import pandas as pd + +from qlib.config import C @functools.lru_cache(maxsize=240) @@ -38,6 +38,30 @@ def get_min_cal(shift: int = 0) -> List[time]: return cal +def _if_single_data(start_time, end_time, freq): + """Is there only one piece of data to obtain. + + Parameters + ---------- + start_time : Union[pd.Timestamp, str] + closed start time for data. + end_time : Union[pd.Timestamp, str] + closed end time for data. + Returns + ------- + bool + True means one piece of data to obtaine. + """ + + if end_time - start_time < freq: + return True + if start_time.hour == 11 and start_time.minute == 29 and start_time.second == 0: + return True + if start_time.hour == 14 and start_time.minute == 59 and start_time.second == 0: + return True + return False + + class Freq: NORM_FREQ_MONTH = "month" NORM_FREQ_WEEK = "week" From 9c326fd3984d7a0deab861b62a2a51e47147d0f9 Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 20 Aug 2021 01:15:26 +0000 Subject: [PATCH 169/187] add import order --- qlib/backtest/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index cd113c8ab..53d148280 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from ..strategy.base import BaseStrategy from .executor import BaseExecutor from .order import BaseTradeDecision +from .order import Order from .position import Position from .exchange import Exchange from .backtest import backtest_loop @@ -18,6 +19,7 @@ from .utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManag from ..utils import init_instance_by_config from ..log import get_module_logger from ..config import C +# make import more user-friendly by enable `from qlib.backtest import STH` logger = get_module_logger("backtest caller") From 13a9b7cea053e90036af6b6565375bd8c8c2a541 Mon Sep 17 00:00:00 2001 From: Young Date: Fri, 20 Aug 2021 09:50:47 +0000 Subject: [PATCH 170/187] type error bug --- qlib/backtest/report.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 84ae4d5d4..6272258b7 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -392,8 +392,10 @@ class Indicator: if isinstance(price_s, pd.Series): price_s = IndexData(price_s.values, list(price_s.index)) - if isinstance(price_s, (int, float)): + elif isinstance(price_s, (int, float, np.floating)): price_s = IndexData([price_s], [trade_start_time]) + else: + raise NotImplementedError(f"This type of input is not supported") # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. From d9ad8ff791d8b3d889939229db83bf79fab95123 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Thu, 26 Aug 2021 12:41:12 +0000 Subject: [PATCH 171/187] index_data --- qlib/backtest/exchange.py | 2 +- qlib/backtest/high_performance_ds.py | 417 +++------------------------ qlib/backtest/order.py | 7 +- qlib/backtest/report.py | 27 +- qlib/utils/index_data.py | 410 ++++++++++++++++++++++++++ 5 files changed, 468 insertions(+), 395 deletions(-) create mode 100644 qlib/utils/index_data.py diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index b9b8d087b..21a1d2547 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -39,7 +39,7 @@ class Exchange: close_cost=0.0025, min_cost=5, extra_quote=None, - quote_cls=CN1Min_NumpyQuote, + quote_cls=PandasQuote, **kwargs, ): """__init__ diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 979ca7609..61bf636ae 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. +from builtins import ValueError, isinstance import logging from typing import List, Text, Union, Callable, Iterable, Dict from collections import OrderedDict @@ -11,6 +12,7 @@ import bisect import pandas as pd import numpy as np +from ..utils.index_data import IndexData from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from ..utils.time import _if_single_data @@ -38,7 +40,7 @@ class BaseQuote: end_time: Union[pd.Timestamp, str], fields: str = None, method: Union[str, Callable] = None, - ) -> Union[None, float, pd.Series, pd.DataFrame, "IndexData"]: + ) -> Union[None, float, "IndexData"]: """get the specific fields of stock data during start time and end_time, and apply method to the data. @@ -65,42 +67,28 @@ class BaseQuote: 85.713585 - 2. Both fields and method are None. It returns pd.Dataframe or np.ndarray. + 2. Both fields and method are None. It returns np.ndarray. print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields=None, method=None)) - 1) pd.Dataframe - $close $volume - datetime - 2010-01-04 86.778313 16162960.0 - 2010-01-05 87.433578 28117442.0 - 2010-01-06 85.713585 23632884.0 - - 2) np.ndarray [ [86.778313, 16162960.0], [87.433578, 28117442.0], [85.713585, 23632884.0], ] - 3. fields is not None, and method is None. It returns pd.Series or IndexData. + 3. fields is not None, and method is None. It returns IndexData. print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields="$close", method=None)) - 1) pd.Series - 2010-01-04 86.778313 - 2010-01-05 87.433578 - 2010-01-06 85.713585 - - 2) IndexData IndexData([86.778313, 87.433578, 85.713585], [2010-01-04, 2010-01-05, 2010-01-06]) Parameters ---------- - stock_id: Union[str, list] + stock_id: str start_time : Union[pd.Timestamp, str] closed start time for backtest end_time : Union[pd.Timestamp, str] closed end time for backtest - fields : Union[str, List] + fields : str the columns of data to fetch method : Union[str, Callable] the method apply to data. @@ -404,8 +392,8 @@ class BaseOrderIndicator: raise NotImplementedError(f"Please implement the 'get_metric_series' method") - def get_index_data(self, metric): - """get one metric with the format of IndexData + def get_index_data(self, metric) -> IndexData.Series: + """get one metric with the format of IndexData.Series Parameters ---------- @@ -414,8 +402,8 @@ class BaseOrderIndicator: Return ------ - IndexData - one metric with the format of IndexData + IndexData.Series + one metric with the format of IndexData.Series """ raise NotImplementedError(f"Please implement the 'get_index_data' method") @@ -586,12 +574,21 @@ class PandasOrderIndicator(BaseOrderIndicator): else: return tmp_metric + def get_index_data(self, metric): + if metric in self.data: + return IndexData.Series(self.data[metric].metric) + else: + return IndexData.Series() + def get_metric_series(self, metric: str) -> Union[pd.Series]: if metric in self.data: return self.data[metric].metric else: return pd.Series() + def to_series(self): + return {k: v.metric for k, v in self.data.items()} + @staticmethod def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=None): if isinstance(metrics, str): @@ -602,387 +599,45 @@ class PandasOrderIndicator(BaseOrderIndicator): tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) order_indicator.assign(metric, tmp_metric.metric) - def to_series(self): - return {k: v.metric for k, v in self.data.items()} - - def get_index_data(self, metric): - if metric in self.data: - return IndexData(self.data[metric].values(), list(self.data[metric].index)) - else: - return IndexData([], []) - - -class NumpySingleMetric(SingleMetric): - def __init__(self, metric: np.ndarray): - self.metric = metric - - def __len__(self): - return len(self.metric) - - def sum(self): - return np.nansum(self.metric) - - def mean(self): - return np.nanmean(self.metric) - - def count(self): - return len(self.metric[~np.isnan(self.metric)]) - - def abs(self): - return self.__class__(np.absolute(self.metric)) - - def astype(self, type): - return self.__class__(self.metric.astype(type)) - - @property - def empty(self): - return len(self.metric) == 0 - - def replace(self, replace_dict: dict): - tmp_metric = self.metric.copy() - for num in replace_dict: - tmp_metric[tmp_metric == num] = replace_dict[num] - return self.__class__(tmp_metric) - - def apply(self, func: Callable): - tmp_metric = self.metric.copy() - for i in range(len(tmp_metric)): - tmp_metric[i] = func(tmp_metric[i]) - return self.__class__(tmp_metric) - class NumpyOrderIndicator(BaseOrderIndicator): - # all metrics - ROW = [ - "amount", - "deal_amount", - "inner_amount", - "trade_price", - "trade_value", - "trade_cost", - "trade_dir", - "ffr", - "pa", - "pos", - "base_price", - "base_volume", - ] - ROW_MAP = dict(zip(ROW, range(len(ROW)))) def __init__(self): - self.row_tag = [0 for tag in range(len(NumpyOrderIndicator.ROW))] - self.data = None + self.data: Dict[str, IndexData.Series] = OrderedDict() def assign(self, col: str, metric: dict): - if col not in NumpyOrderIndicator.ROW: - raise ValueError(f"{col} metric is not supported") - if not isinstance(metric, dict): - raise ValueError(f"metric must be dict") + self.data[col] = IndexData.Series(metric) - # if data is None, init numpy ndarray - if self.data is None: - self.data = np.full((len(NumpyOrderIndicator.ROW), len(metric)), np.NaN) - self.column = list(metric.keys()) - self.column_map = dict(zip(self.column, range(len(self.column)))) - - metric_column = list(metric.keys()) - if self.column != metric_column: - assert len(set(self.column) - set(metric_column)) == 0 - # modify the order - tmp_metric = {} - for column in self.column: - tmp_metric[column] = metric[column] - metric = tmp_metric - - # assign data - self.row_tag[NumpyOrderIndicator.ROW_MAP[col]] = 1 - self.data[NumpyOrderIndicator.ROW_MAP[col]] = list(metric.values()) - - def transfer(self, func: Callable, new_col: str = None) -> Union[None, NumpySingleMetric]: + def transfer(self, func: Callable, new_col: str = None) -> Union[None, IndexData.Series]: func_sig = inspect.signature(func).parameters.keys() - func_kwargs = {} - for sig in func_sig: - if self._if_valid_metric(sig): - func_kwargs[sig] = NumpySingleMetric(self.data[NumpyOrderIndicator.ROW_MAP[sig]]) - else: - self.logger.warning(f"{sig} is not assigned") - func_kwargs[sig] = NumpySingleMetric(np.array([])) + func_kwargs = {sig: self.data[sig] for sig in func_sig} tmp_metric = func(**func_kwargs) if new_col is not None: - self.row_tag[NumpyOrderIndicator.ROW_MAP[new_col]] = 1 - self.data[NumpyOrderIndicator.ROW_MAP[new_col]] = tmp_metric.metric + self.data[new_col] = tmp_metric else: return tmp_metric def get_index_data(self, metric): - if self._if_valid_metric(metric): - return IndexData(self.data[NumpyOrderIndicator.ROW_MAP[metric]], self.column) + if metric in self.data: + return self.data[metric] else: - return IndexData([], []) + return IndexData.Series() def get_metric_series(self, metric: str) -> Union[pd.Series]: - if self._if_valid_metric(metric): - return pd.Series(self.data[NumpyOrderIndicator.ROW_MAP[metric]], index=self.column) - else: - return pd.Series() + return self.data[metric].to_pd_series() def to_series(self) -> Dict[str, pd.Series]: tmp_metric_dict = {} - for metric in NumpyOrderIndicator.ROW: + for metric in self.data: tmp_metric_dict[metric] = self.get_metric_series(metric) return tmp_metric_dict - def _if_valid_metric(self, metric): - if metric in NumpyOrderIndicator.ROW and self.row_tag[NumpyOrderIndicator.ROW_MAP[metric]] == 1: - return True - else: - return False - @staticmethod - def sum_all_indicators( - order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=None - ) -> Dict[str, NumpySingleMetric]: - # metrics is all metrics to add - # metrics_id means the index in the NumpyOrderIndicator.ROW for metrics. + def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=0): if isinstance(metrics, str): metrics = [metrics] - metrics_id = [NumpyOrderIndicator.ROW_MAP[metric] for metric in metrics] - - # get all stock_id and all metric data - stocks = set() - indicator_metrics = [] - for indicator in indicators: - stocks = stocks | set(indicator.column) - indicator_metrics.append(indicator.data[metrics_id, :].copy()) - stocks = list(stocks) - stocks.sort() - stocks_map = dict(zip(stocks, range(len(stocks)))) - - # fill value - if fill_value is not None: - base_metrics = fill_value * np.ones((len(metrics), len(stocks))) - for i in range(len(indicators)): - tmp_metrics = base_metrics.copy() - stocks_index = [stocks_map[stock] for stock in indicators[i].column] - tmp_metrics[:, stocks_index] = indicator_metrics[i] - indicator_metrics[i] = tmp_metrics - else: - raise ValueError(f"fill value can not be None in NumpyOrderIndicator") - - # add metric and assign to order_indicator - metric_sum = sum(indicator_metrics) - if order_indicator.data is not None: - raise ValueError(f"this function must assign to an empty order indicator") - order_indicator.data = np.zeros((len(NumpyOrderIndicator.ROW), len(stocks))) - order_indicator.column = stocks - order_indicator.column_map = dict(zip(stocks, range(len(stocks)))) - for i in range(len(metrics)): - order_indicator.row_tag[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = 1 - order_indicator.data[NumpyOrderIndicator.ROW_MAP[metrics[i]]] = metric_sum[i] - - -class IndexData: - def __init__(self, data, index): - """A data structure of index and numpy data. - - Parameters - ---------- - data : np.ndarray - the dim of data must be 1 or 2. - different functions have dimensional limitations - index : list - the index of data. - """ - if isinstance(data, list): - self.data = np.array(data) - elif isinstance(data, np.ndarray): - self.data = data - else: - raise ValueError(f"data must be list or np.ndarray") - self.ndim = self.data.ndim - - assert isinstance(index, list) - self.index = index - self.index_map = dict(zip(self.index, range(len(self.index)))) - - def reindex(self, new_index): - """reindex data and fill the missing value with np.NaN. - just for 1-dim data. - - Parameters - ---------- - new_index : list - new index - - Returns - ------- - IndexData - reindex data - """ - assert self.ndim == 1 - tmp_data = np.full(len(new_index), np.NaN) - for index_id, index in enumerate(new_index): - if index in self.index: - tmp_data[index_id] = self.data[self.index_map[index]] - return IndexData(tmp_data, list(new_index)) - - def to_dict(self): - """convert IndexData to dict. - just for 1-dim data. - - Returns - ------- - dict - data with the dict format. - """ - assert self.ndim == 1 - return dict(zip(self.index, self.data.tolist())) - - def sum(self, axis=None): - """get the sum of data. - - Parameters - ---------- - axis : 0 or None, optional - which axis to sum, by default None - - Returns - ------- - Union[float, IndexData] - if axis is None, it sums all data, return float. - if axis == 1, it sums by row, return IndexData. - """ - if axis is None: - return np.nansum(self.data) - if axis == 0: - assert self.ndim == 2 - tmp_data = np.nansum(self.data, axis=0) - return IndexData(tmp_data, self.index) - else: - raise NotImplementedError(f"axis must be 0 or None") - - def __mul__(self, other): - """multiply with another IndexData. - - Returns - ------- - IndexData - """ - if isinstance(other, IndexData): - assert self.ndim == other.ndim - assert self.index == other.index - assert len(self.data) == len(other.data) - return IndexData(self.data * other.data, self.index) - else: - return NotImplemented - - def __truediv__(self, other): - """divide with another IndexData. - - Returns - ------- - IndexData - """ - if isinstance(other, IndexData): - assert self.ndim == other.ndim - assert self.index == other.index - assert len(self.data) == len(other.data) - return IndexData(self.data / other.data, self.index) - else: - return NotImplemented - - def __len__(self): - """the length of the data. - - Returns - ------- - int - the length of the data. - """ - return len(self.index) - - def __getitem__(self, bool_list: "IndexData"): - """get IndexData by a bool_list which has the same shape of self.data. - just for 1-dim data. - - Parameters - ---------- - bool_list : Union[list, np.ndarray] - a bool_list which has the same shape of self.data. such as array([True, False, True]). - True means the data of the position is reserved. False is not. - - Returns - ------- - IndexData - new IndexData. - """ - assert self.ndim == 1 - assert isinstance(bool_list, IndexData) - new_data = self.data[bool_list.data] - new_index = list(np.array(self.index)[bool_list.data]) - return IndexData(new_data, new_index) - - def __gt__(self, other): - if isinstance(other, (int, float)): - return IndexData(self.data > other, self.index) - elif isinstance(other, IndexData): - return IndexData(self.data > other.data, self.index) - else: - return NotImplemented - - def __lt__(self, other): - if isinstance(other, (int, float)): - return IndexData(self.data < other, self.index) - elif isinstance(other, IndexData): - return IndexData(self.data < other.data, self.index) - else: - return NotImplemented - - def __invert__(self): - return IndexData(~self.data, self.index) - - @staticmethod - def concat_by_index(index_data_list): - """concat all IndexData by index. - just for 1-dim data. - - Parameters - ---------- - index_data_list : List[IndexData] - the list of all IndexData to concat. - - Returns - ------- - IndexData - the IndexData with ndim == 2 - """ - # get all index and row - all_index = set() - for index_data in index_data_list: - all_index = all_index | set(index_data.index) - all_index = list(all_index) - all_index.sort() - all_index_map = dict(zip(all_index, range(len(all_index)))) - - # concat all - tmp_data = np.full((len(index_data_list), len(all_index)), np.NaN) - for data_id, index_data in enumerate(index_data_list): - assert index_data.ndim == 1 - now_data_map = [all_index_map[index] for index in index_data.index] - tmp_data[data_id, now_data_map] = index_data.data - return IndexData(tmp_data, all_index) - - @staticmethod - def ones(index): - """initial the IndexData with index, and fill data with 1. - - Parameters - ---------- - index : list - the index of new data. - - Returns - ------- - IndexData - """ - return IndexData([1 for i in range(len(index))], list(index)) + for metric in metrics: + tmp_metric = IndexData.Series() + for indicator in indicators: + tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) + order_indicator.data[metric] = tmp_metric \ No newline at end of file diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index abd02554a..42af5f24e 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -109,7 +109,7 @@ class Order: return self.direction * 2 - 1 @staticmethod - def parse_dir(direction: Union[str, int, np.integer, OrderDir]) -> OrderDir: + def parse_dir(direction: Union[str, int, np.integer, OrderDir, np.ndarray]) -> OrderDir: if isinstance(direction, OrderDir): return direction elif isinstance(direction, (int, float, np.integer, np.floating)): @@ -125,6 +125,11 @@ class Order: return OrderDir.BUY else: raise NotImplementedError(f"This type of input is not supported") + elif isinstance(direction, np.ndarray): + direction_array = direction.copy() + direction_array[direction_array > 0] = Order.BUY + direction_array[direction_array <= 0] = Order.SELL + return direction_array else: raise NotImplementedError(f"This type of input is not supported") diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 6272258b7..dbda82dd6 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -16,7 +16,8 @@ from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir from qlib.backtest.utils import TradeCalendarManager -from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator, IndexData +from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator +from ..utils.index_data import IndexData, SingleData from ..data import D from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data @@ -391,9 +392,11 @@ class Indicator: return None, None if isinstance(price_s, pd.Series): - price_s = IndexData(price_s.values, list(price_s.index)) + price_s = IndexData.Series(price_s) elif isinstance(price_s, (int, float, np.floating)): - price_s = IndexData([price_s], [trade_start_time]) + price_s = IndexData.Series(price_s, [trade_start_time]) + elif isinstance(price_s, SingleData): + pass else: raise NotImplementedError(f"This type of input is not supported") @@ -405,11 +408,11 @@ class Indicator: if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) - if isinstance(volume_s, (int, float)): - volume_s = IndexData([volume_s], [trade_start_time]) + if isinstance(volume_s, (int, float, np.floating)): + volume_s = IndexData.Series(volume_s, [trade_start_time]) volume_s = volume_s.reindex(price_s.index) elif agg == "twap": - volume_s = IndexData.ones(price_s.index) + volume_s = IndexData.Series(1, price_s.index) else: raise NotImplementedError(f"This type of input is not supported") @@ -472,16 +475,16 @@ class Indicator: else: bp_new[inst], bv_new[inst] = pr, v - bp_new = IndexData(list(bp_new.values()), list(bp_new.keys())) - bv_new = IndexData(list(bv_new.values()), list(bv_new.keys())) + bp_new = IndexData.Series(bp_new) + bv_new = IndexData.Series(bv_new) bp_all.append(bp_new) bv_all.append(bv_new) - bp_all = IndexData.concat_by_index(bp_all) - bv_all = IndexData.concat_by_index(bv_all) + bp_all = IndexData.concat(bp_all, axis = 1) + bv_all = IndexData.concat(bv_all, axis = 1) - base_volume = bv_all.sum(axis=0) + base_volume = bv_all.sum(axis = 1) self.order_indicator.assign("base_volume", base_volume.to_dict()) - self.order_indicator.assign("base_price", ((bp_all * bv_all).sum(axis=0) / base_volume).to_dict()) + self.order_indicator.assign("base_price", ((bp_all * bv_all).sum(axis=1) / base_volume).to_dict()) def _agg_order_price_advantage(self): def if_empty_func(trade_price): diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py new file mode 100644 index 000000000..47e657c59 --- /dev/null +++ b/qlib/utils/index_data.py @@ -0,0 +1,410 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + + +import numpy as np +import pandas as pd +from typing import Union, Callable + + +class IndexData: + """This is a simplified version of pandas which is faster based on numpy. + """ + @staticmethod + def Series(data: Union[dict, pd.Series, int, float, np.floating, list, np.ndarray] = [], index: Union[list, pd.Index] = []): + if isinstance(data, dict): + return SingleData(list(data.values()), list(data.keys())) + elif isinstance(data, pd.Series): + return SingleData(data.values, data.index) + else: + return SingleData(data, index) + + @staticmethod + def DataFrame(data: Union[pd.DataFrame, list, np.ndarray] = [[]], index: Union[list, pd.Index] = [], columns: Union[list, pd.Index] = []): + if isinstance(data, pd.DataFrame): + return MultiData(data.values, data.index, data.columns) + else: + return MultiData(data, index, columns) + + @staticmethod + def concat(data_list, axis = 0): + """concat all SingleData by index. + just for 1-dim data. + + Parameters + ---------- + index_data_list : List[SingleData] + the list of all SingleData to concat. + + Returns + ------- + MultiData + the MultiData with ndim == 2 + """ + if axis == 0: + raise NotImplementedError(f"please implement this fuc when axis == 0") + elif axis == 1: + # get all index and row + all_index = set() + for index_data in data_list: + all_index = all_index | set(index_data.index) + all_index = list(all_index) + all_index.sort() + all_index_map = dict(zip(all_index, range(len(all_index)))) + + # concat all + tmp_data = np.full((len(all_index), len(data_list)), np.NaN) + for data_id, index_data in enumerate(data_list): + assert isinstance(index_data, SingleData) + now_data_map = [all_index_map[index] for index in index_data.index] + tmp_data[now_data_map, data_id] = index_data.data + return MultiData(tmp_data, all_index) + else: + raise ValueError(f"axis must be 0 or 1") + + +class BaseData: + """Base data structure of SingleData and MultiData. + """ + def __init__(self): + self.index_columns = self._get_index_columns() + + def _get_index_columns(self): + index_columns = [] + if hasattr(self, "index"): + index_columns.append(self.index) + if hasattr(self, "columns"): + index_columns.append(self.columns) + return index_columns + + def _align_index(self, other): + """Align index before performing the four arithmetic operations. + """ + raise NotImplementedError(f"please implement _align_index func") + + def __add__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(self.data + other, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data1.data + tmp_data2.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __sub__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(self.data - other, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data1.data - tmp_data2.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __rsub__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(other - self.data, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data2.data - tmp_data1.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __mul__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(self.data * other, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data1.data * tmp_data2.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __truediv__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(self.data / other, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data1.data / tmp_data2.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(self.data == other, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data1.data == tmp_data2.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(self.data > other, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data1.data > tmp_data2.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, (int, float, np.floating)): + return self.__class__(self.data < other, *self.index_columns) + elif isinstance(other, self.__class__): + tmp_data1, tmp_data2 = self._align_index(other) + return self.__class__(tmp_data1.data < tmp_data2.data, *tmp_data1.index_columns) + else: + return NotImplemented + + def __invert__(self): + return self.__class__(~self.data, *self.index_columns) + + def abs(self): + """get the abs of data except np.NaN. + """ + tmp_data = np.absolute(self.data) + return self.__class__(tmp_data, *self.index_columns) + + def astype(self, type): + """change the type of data. + """ + tmp_data = self.data.astype(type) + return self.__class__(tmp_data, *self.index_columns) + + def replace(self, to_replace: dict): + assert isinstance(to_replace, dict) + tmp_data = self.data.copy() + for num in to_replace: + if num in tmp_data: + tmp_data[tmp_data == num] = to_replace[num] + return self.__class__(tmp_data, *self.index_columns) + + def apply(self, func: Callable): + """apply a function to data. + """ + tmp_data = func(self.data) + return self.__class__(tmp_data, *self.index_columns) + + def __len__(self): + """the length of the data. + + Returns + ------- + int + the length of the data. + """ + return len(self.data) + + def sum(self, axis=None): + if axis is None: + return np.nansum(self.data) + elif axis == 0: + tmp_data = np.nansum(self.data, axis=0) + return SingleData(tmp_data, self.columns) + elif axis == 1: + tmp_data = np.nansum(self.data, axis=1) + return SingleData(tmp_data, self.index) + else: + raise ValueError(f"axis must be None, 0 or 1") + + def mean(self, axis=None): + if axis is None: + return np.nanmean(self.data) + elif axis == 0: + tmp_data = np.nanmean(self.data, axis=0) + return SingleData(tmp_data, self.columns) + elif axis == 1: + tmp_data = np.nanmean(self.data, axis=1) + return SingleData(tmp_data, self.index) + else: + raise ValueError(f"axis must be None, 0 or 1") + + def count(self): + return len(self.data[~np.isnan(self.data)]) + + @property + def empty(self): + return len(self.data) == 0 + + +class SingleData(BaseData): + def __init__(self, data: Union[int, float, np.floating, list, np.ndarray] = [], index: Union[list, pd.Index] = []): + """A data structure of index and numpy data. + It's used to replace pd.Series due to high-speed. + + Parameters + ---------- + data : Union[int, float, np.floating, list, np.ndarray] + the dim of data must be 1. + index : Union[list, pd.Index] + the index of data. + """ + # data + if isinstance(data, (int, float, np.floating)): + self.data = np.full(len(index), fill_value=data) + elif isinstance(data, list): + self.data = np.array(data) + elif isinstance(data, np.ndarray): + self.data = data + else: + raise ValueError(f"data must be list or np.ndarray") + # data in SingleData must be one dim + assert self.data.ndim == 1 + # replace int with float + if self.data.dtype == np.int: + self.data = self.data.astype(np.float64) + # replace None with np.NaN, because pd.Series does it. + if None in self.data: + self.data[self.data == None] = np.NaN + + # index + if isinstance(index, list): + if index == [] and len(self.data) > 0: + index = list(range(len(self.data))) + self.index = index + elif isinstance(index, pd.Index): + self.index = list(index) + else: + raise ValueError(f"index must be list or pd.Index") + assert len(self.data) == len(self.index) + # if data is not empty, + self.index_map = dict(zip(self.index, range(len(self.index)))) + + super(SingleData, self).__init__() + + def _align_index(self, other): + if self.index == other.index: + return self, other + elif set(self.index) == set(other.index): + return self, other.reindex(self.index) + else: + raise ValueError(f"The indexes of self and other do not meet the requirements of the four arithmetic operations") + + def reindex(self, index, fill_value=np.NaN): + """reindex data and fill the missing value with np.NaN. + + Parameters + ---------- + new_index : list + new index + + Returns + ------- + SingleData + reindex data + """ + tmp_data = np.full(len(index), fill_value, np.float64) + for index_id, index_item in enumerate(index): + if index_item in self.index: + tmp_data[index_id] = self.data[self.index_map[index_item]] + return SingleData(tmp_data, index) + + def add(self, other, fill_value=0): + common_index = list(set(self.index) | set(other.index)) + tmp_data1 = self.reindex(common_index,fill_value) + tmp_data2 = other.reindex(common_index,fill_value) + return tmp_data1 + tmp_data2 + + def to_dict(self): + """convert SingleData to dict. + + Returns + ------- + dict + data with the dict format. + """ + return dict(zip(self.index, self.data.tolist())) + + def to_frame(self): + """convert SingleData to MultiData. + + Returns + ------- + MultiData + data with the MultiData format. + """ + return MultiData(self.data[:, np.newaxis], self.index) + + def to_pd_series(self): + return pd.Series(self.data, index = self.index) + + def __getitem__(self, index: Union["SingleData", int, str]): + if isinstance(index, int): + return self.data[index] + elif isinstance(index, str): + return self.data[self.index_map[index]] + elif isinstance(index, SingleData): + new_data = self.data[index.data] + new_index = list(np.array(self.index)[index.data]) + return SingleData(new_data, new_index) + else: + raise ValueError(f"index must be SingleData, int, str") + + +class MultiData(BaseData): + def __init__(self, data: Union[list, np.ndarray] = [[]], index: Union[list, pd.Index] = [], columns: Union[list, pd.Index] = []): + """A data structure of index and numpy data. + It's used to replace pd.DataFrame due to high-speed. + + Parameters + ---------- + data : Union[list, np.ndarray] + the dim of data must be 2. + index : Union[list, pd.Index] + the index of data. + columns: Union[list, pd.Index] + the columns of data. + """ + # data + if isinstance(data, list): + self.data = np.array(data) + elif isinstance(data, np.ndarray): + self.data = data + else: + raise ValueError(f"data must be list or np.ndarray") + # data in SingleData must be two dim + assert self.data.ndim == 2 + # replace int with float + if self.data.dtype == np.int: + self.data = self.data.astype(np.float64) + # replace None with np.NaN, because pd.DataFrame does it. + if None in self.data: + self.data[self.data == None] = np.NaN + + # index + if isinstance(index, list): + if index == [] and self.data.shape[0] > 0: + index = list(range(self.data.shape[0])) + self.index = index + elif isinstance(index, pd.Index): + self.index = list(index) + else: + raise ValueError(f"index must be list or pd.Index") + assert self.data.shape[0] == len(self.index) + # if data is not empty, + self.index_map = dict(zip(self.index, range(len(self.index)))) + + # columns + if isinstance(columns, list): + if columns == [] and self.data.shape[1] > 0: + columns = list(range(self.data.shape[1])) + self.columns = columns + elif isinstance(columns, pd.Index): + self.columns = list(columns) + else: + raise ValueError(f"columns must be list or pd.Index") + assert self.data.shape[1] == len(self.columns) + # if data is not empty, + self.columns_map = dict(zip(self.columns, range(len(self.columns)))) + + super(MultiData, self).__init__() + + def _align_index(self, other): + if self.index_columns == other.index_columns: + return self, other + else: + raise ValueError(f"The indexes of self and other do not meet the requirements of the four arithmetic operations") + + def __getitem__(self, col) -> SingleData: + if col not in self.columns: + return SingleData() + else: + return SingleData(self.data[:, self.columns_map[col]], self.index) From 25f54ddaeb3010bbdc7fa4d2fbd98b4912d5f64b Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Thu, 26 Aug 2021 15:54:19 +0000 Subject: [PATCH 172/187] new high freq struc --- qlib/backtest/__init__.py | 1 + qlib/backtest/exchange.py | 9 +- qlib/backtest/high_performance_ds.py | 165 +++++++++++++-------------- qlib/backtest/report.py | 20 +--- qlib/utils/index_data.py | 99 ++++++++++------ qlib/utils/time.py | 2 +- 6 files changed, 151 insertions(+), 145 deletions(-) diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index 53d148280..b4a46614e 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -19,6 +19,7 @@ from .utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManag from ..utils import init_instance_by_config from ..log import get_module_logger from ..config import C + # make import more user-friendly by enable `from qlib.backtest import STH` diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 21a1d2547..4c726720c 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -9,19 +9,16 @@ if TYPE_CHECKING: from qlib.backtest.position import BasePosition, Position import random -import logging -from typing import List, Tuple, Union, Callable, Iterable - +from typing import List, Tuple, Union import numpy as np import pandas as pd from ..data.data import D -from ..data.dataset.utils import get_level_index from ..config import C, REG_CN from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order, OrderDir, OrderHelper -from .high_performance_ds import PandasQuote, CN1Min_NumpyQuote +from .high_performance_ds import PandasQuote, CN1min_NumpyQuote class Exchange: @@ -39,7 +36,7 @@ class Exchange: close_cost=0.0025, min_cost=5, extra_quote=None, - quote_cls=PandasQuote, + quote_cls=CN1min_NumpyQuote, **kwargs, ): """__init__ diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 61bf636ae..6f38b390a 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -3,6 +3,7 @@ from builtins import ValueError, isinstance +from functools import lru_cache import logging from typing import List, Text, Union, Callable, Iterable, Dict from collections import OrderedDict @@ -15,7 +16,7 @@ import numpy as np from ..utils.index_data import IndexData from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger -from ..utils.time import _if_single_data +from ..utils.time import if_single_data class BaseQuote: @@ -38,9 +39,9 @@ class BaseQuote: stock_id: str, start_time: Union[pd.Timestamp, str], end_time: Union[pd.Timestamp, str], - fields: str = None, - method: Union[str, Callable] = None, - ) -> Union[None, float, "IndexData"]: + fields: Union[str, None] = None, + method: Union[str, Callable, None] = None, + ) -> Union[None, Union[int, float, bool], "IndexData"]: """get the specific fields of stock data during start time and end_time, and apply method to the data. @@ -62,7 +63,7 @@ class BaseQuote: this function is used for three case: - 1. Both fields and method are not None. It returns float. + 1. Both fields and method are not None. It returns int/float/bool. print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields="$close", method="last")) 85.713585 @@ -88,15 +89,15 @@ class BaseQuote: closed start time for backtest end_time : Union[pd.Timestamp, str] closed end time for backtest - fields : str + fields : Union[str, None] the columns of data to fetch - method : Union[str, Callable] + method : Union[str, Callable, None] the method apply to data. e.g [None, "last", "all", "sum", "mean", qlib/utils/resam.py/ts_data_last] Return ---------- - Union[None, float, pd.Series, pd.DataFrame, IndexData] + Union[None, Union[int, float, bool], IndexData] please refer to Example as following. """ @@ -115,121 +116,105 @@ class PandasQuote(BaseQuote): return self.data.keys() def get_data(self, stock_id, start_time, end_time, fields=None, method=None): + if fields is None and method is not None: + raise ValueError(f"method must be None when fields is None") + if fields is None: - return resam_ts_data(self.data[stock_id], start_time, end_time, method=method) - elif isinstance(fields, (str, list)): - return resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) + stock_data = resam_ts_data(self.data[stock_id], start_time, end_time, method=method) + elif isinstance(fields, str): + stock_data = resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) else: - raise ValueError(f"fields must be None, str or list") + raise ValueError(f"fields must be None, str") + + if stock_data is None: + return None + elif isinstance(stock_data, (bool, np.bool_, int, float, np.signedinteger, np.floating)): + return stock_data + elif isinstance(stock_data, pd.Series): + return IndexData.Series(stock_data) + elif isinstance(stock_data, pd.DataFrame): + return stock_data.values + else: + raise ValueError(f"stock data from resam_ts_data must be a number, pd.Series or pd.DataFrame") -class CN1Min_NumpyQuote(BaseQuote): +class CN1min_NumpyQuote(BaseQuote): def __init__(self, quote_df: pd.DataFrame): - """CN1Min_NumpyQuote + """CN1min_NumpyQuote Parameters ---------- quote_df : pd.DataFrame the init dataframe from qlib. - - Variables - self.data: Dict[stock_id, np.ndarray] - each stock has one two-dimensional np.ndarray to represent data. - self.columns: Dict[str, int] - map column name to column id in self.data. - self.dt2idx: Dict[stock_id, Dict[pd.Timestap, int]] - map timestap to row id in self.data. - self.idx2dt: Dict[stock_id, List[pd.Timestap]] - the dt2idx of each stock for searching. + self.data : Dict(stock_id, IndexData.DataFrame) """ - super().__init__(quote_df=quote_df) - # init data - columns = quote_df.columns.values - self.columns = dict(zip(columns, range(len(columns)))) - self.data, self.dt2idx, self.idx2dt = self._to_numpy(quote_df) - - # lru - self.multi_lru = {} - self.max_lru_len = 256 - - def _to_numpy(self, quote_df): - """convert dataframe to numpy.""" - quote_dict = {} - date_dict = {} - date_list = {} for stock_id, stock_val in quote_df.groupby(level="instrument"): - quote_dict[stock_id] = stock_val.values - date_dict[stock_id] = stock_val.index.get_level_values("datetime") - date_list[stock_id] = list(date_dict[stock_id]) - for stock_id in date_dict: - date_dict[stock_id] = dict(zip(date_dict[stock_id], range(len(date_dict[stock_id])))) - return quote_dict, date_dict, date_list + quote_dict[stock_id] = IndexData.DataFrame(stock_val.droplevel(level="instrument")) + self.data = quote_dict + self.freq = np.timedelta64(1, "m") def get_all_stock(self): return self.data.keys() def get_data(self, stock_id, start_time, end_time, fields=None, method=None): - # check fields - if isinstance(fields, list) and len(fields) > 1: - raise ValueError(f"get_data in CN1Min_NumpyQuote only supports one field") + if fields is None and method is not None: + raise ValueError(f"method must be None when fields is None") # check stock id if stock_id not in self.get_all_stock(): return None - # get single data - # single data is only one piece of data, so it don't need to agg by method. - if _if_single_data(start_time, end_time, np.timedelta64(1, "m")): - if start_time not in self.dt2idx[stock_id]: + # single data + # If it don't consider the classification of single data, it will consume a lot of time. + if if_single_data(start_time, end_time, self.freq): + now_index_map = self.data[stock_id].index_map + now_columns_map = self.data[stock_id].columns_map + if start_time not in now_index_map: return None if fields is None: - # it used for check if data is None - return self.data[stock_id][self.dt2idx[stock_id][start_time]] + return self.data[stock_id].values[now_index_map[start_time]] else: - return self.data[stock_id][self.dt2idx[stock_id][start_time]][self.columns[fields]] - # get muti row data + return self.data[stock_id].values[now_index_map[start_time], now_columns_map[fields]] + + # multi data else: - # check lru - if (stock_id, start_time, end_time, fields, method) in self.multi_lru: - return self.multi_lru[(stock_id, start_time, end_time, fields, method)] - - start_id = bisect.bisect_left(self.idx2dt[stock_id], start_time) - end_id = bisect.bisect_right(self.idx2dt[stock_id], end_time) - if start_id == end_id: - return None - # it used for check if data is None - if fields is None: - return self.data[stock_id][start_id:end_id] - elif method is None: - stock_data = self.data[stock_id][start_id:end_id, self.columns[fields]] - stock_dt2idx = self.idx2dt[stock_id][start_id:end_id].to_list() - return IndexData(stock_data, stock_dt2idx) - else: - agg_stock_data = self._agg_data(self.data[stock_id][start_id:end_id, self.columns[fields]], method) - - # result lru - if len(self.multi_lru) >= self.max_lru_len: - self.multi_lru.clear() - self.multi_lru[(stock_id, start_time, end_time, fields, method)] = agg_stock_data - return agg_stock_data + if fields is None and method is None: + stock_data = self.data[stock_id].loc(start_time, end_time) + if stock_data.empty: + return None + else: + return stock_data.values + elif fields is not None and method is None: + stock_data = self.data[stock_id].loc(start_time, end_time, fields) + if stock_data.empty: + return None + else: + return stock_data + elif fields is not None and method is not None: + stock_data = self.data[stock_id].loc(start_time, end_time, fields) + if stock_data.empty: + return None + elif len(stock_data) == 1: + return stock_data[0] + else: + return self._agg_data(stock_data.values, method) def _agg_data(self, data, method): """Agg data by specific method.""" - valid_data = data[data != np.array(None)].copy() if method == "sum": - return np.nansum(valid_data) + return np.nansum(data) elif method == "mean": - return np.nanmean(valid_data) + return np.nanmean(data) elif method == "last": - return valid_data[-1] + return data[-1] elif method == "all": - return valid_data.all() + return data.all() elif method == "any": - return valid_data.any() + return data.any() elif method == ts_data_last: - valid_data = valid_data[valid_data != np.NaN] + valid_data = data[data != np.NaN] if len(valid_data) == 0: return None else: @@ -412,6 +397,7 @@ class BaseOrderIndicator: def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value: float = None): """sum indicators with the same metrics. and assign to the order_indicator(BaseOrderIndicator). + NOTE: indicators could be a empty list when orders in lower level all fail. Parameters ---------- @@ -601,6 +587,11 @@ class PandasOrderIndicator(BaseOrderIndicator): class NumpyOrderIndicator(BaseOrderIndicator): + """ + The data structure is OrderedDict(str: IndexData.Series). + Each IndexData.Series is one metric. + Str is the name of metric. + """ def __init__(self): self.data: Dict[str, IndexData.Series] = OrderedDict() @@ -640,4 +631,4 @@ class NumpyOrderIndicator(BaseOrderIndicator): tmp_metric = IndexData.Series() for indicator in indicators: tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) - order_indicator.data[metric] = tmp_metric \ No newline at end of file + order_indicator.data[metric] = tmp_metric diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index dbda82dd6..31c3e7b0a 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -3,25 +3,19 @@ from collections import OrderedDict -from logging import warning import pathlib -from typing import Dict, List, Tuple, Union, Callable +from typing import Dict, List, Tuple import numpy as np import pandas as pd -from pandas.core import groupby -from pandas.core.frame import DataFrame from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir -from qlib.backtest.utils import TradeCalendarManager from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator -from ..utils.index_data import IndexData, SingleData -from ..data import D +from ..utils.index_data import IndexData, SingleData from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data -from ..utils.time import Freq from .order import IdxTradeRange @@ -391,9 +385,7 @@ class Indicator: if price_s is None: return None, None - if isinstance(price_s, pd.Series): - price_s = IndexData.Series(price_s) - elif isinstance(price_s, (int, float, np.floating)): + if isinstance(price_s, (int, float, np.signedinteger, np.floating)): price_s = IndexData.Series(price_s, [trade_start_time]) elif isinstance(price_s, SingleData): pass @@ -479,10 +471,10 @@ class Indicator: bv_new = IndexData.Series(bv_new) bp_all.append(bp_new) bv_all.append(bv_new) - bp_all = IndexData.concat(bp_all, axis = 1) - bv_all = IndexData.concat(bv_all, axis = 1) + bp_all = IndexData.concat(bp_all, axis=1) + bv_all = IndexData.concat(bv_all, axis=1) - base_volume = bv_all.sum(axis = 1) + base_volume = bv_all.sum(axis=1) self.order_indicator.assign("base_volume", base_volume.to_dict()) self.order_indicator.assign("base_price", ((bp_all * bv_all).sum(axis=1) / base_volume).to_dict()) diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 47e657c59..8c0d1874e 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -2,16 +2,20 @@ # Licensed under the MIT License. +from typing import Union, Callable +import bisect + import numpy as np import pandas as pd -from typing import Union, Callable class IndexData: - """This is a simplified version of pandas which is faster based on numpy. - """ + """This is a simplified version of pandas which is faster based on numpy.""" + @staticmethod - def Series(data: Union[dict, pd.Series, int, float, np.floating, list, np.ndarray] = [], index: Union[list, pd.Index] = []): + def Series( + data: Union[dict, pd.Series, int, float, np.floating, list, np.ndarray] = [], index: Union[list, pd.Index] = [] + ): if isinstance(data, dict): return SingleData(list(data.values()), list(data.keys())) elif isinstance(data, pd.Series): @@ -20,16 +24,20 @@ class IndexData: return SingleData(data, index) @staticmethod - def DataFrame(data: Union[pd.DataFrame, list, np.ndarray] = [[]], index: Union[list, pd.Index] = [], columns: Union[list, pd.Index] = []): + def DataFrame( + data: Union[pd.DataFrame, list, np.ndarray] = [[]], + index: Union[list, pd.Index] = [], + columns: Union[list, pd.Index] = [], + ): if isinstance(data, pd.DataFrame): return MultiData(data.values, data.index, data.columns) - else: + else: return MultiData(data, index, columns) @staticmethod - def concat(data_list, axis = 0): + def concat(data_list, axis=0): """concat all SingleData by index. - just for 1-dim data. + TODO: now just for SingleData. Parameters ---------- @@ -57,15 +65,15 @@ class IndexData: for data_id, index_data in enumerate(data_list): assert isinstance(index_data, SingleData) now_data_map = [all_index_map[index] for index in index_data.index] - tmp_data[now_data_map, data_id] = index_data.data + tmp_data[now_data_map, data_id] = index_data.data return MultiData(tmp_data, all_index) else: raise ValueError(f"axis must be 0 or 1") class BaseData: - """Base data structure of SingleData and MultiData. - """ + """Base data structure of SingleData and MultiData.""" + def __init__(self): self.index_columns = self._get_index_columns() @@ -78,8 +86,7 @@ class BaseData: return index_columns def _align_index(self, other): - """Align index before performing the four arithmetic operations. - """ + """Align index before performing the four arithmetic operations.""" raise NotImplementedError(f"please implement _align_index func") def __add__(self, other): @@ -158,14 +165,12 @@ class BaseData: return self.__class__(~self.data, *self.index_columns) def abs(self): - """get the abs of data except np.NaN. - """ + """get the abs of data except np.NaN.""" tmp_data = np.absolute(self.data) return self.__class__(tmp_data, *self.index_columns) def astype(self, type): - """change the type of data. - """ + """change the type of data.""" tmp_data = self.data.astype(type) return self.__class__(tmp_data, *self.index_columns) @@ -178,8 +183,7 @@ class BaseData: return self.__class__(tmp_data, *self.index_columns) def apply(self, func: Callable): - """apply a function to data. - """ + """apply a function to data.""" tmp_data = func(self.data) return self.__class__(tmp_data, *self.index_columns) @@ -224,6 +228,10 @@ class BaseData: def empty(self): return len(self.data) == 0 + @property + def values(self): + return self.data + class SingleData(BaseData): def __init__(self, data: Union[int, float, np.floating, list, np.ndarray] = [], index: Union[list, pd.Index] = []): @@ -239,7 +247,7 @@ class SingleData(BaseData): """ # data if isinstance(data, (int, float, np.floating)): - self.data = np.full(len(index), fill_value=data) + self.data = np.full(len(index), fill_value=data, dtype=np.float64) elif isinstance(data, list): self.data = np.array(data) elif isinstance(data, np.ndarray): @@ -249,12 +257,12 @@ class SingleData(BaseData): # data in SingleData must be one dim assert self.data.ndim == 1 # replace int with float - if self.data.dtype == np.int: + if self.data.dtype == np.signedinteger: self.data = self.data.astype(np.float64) # replace None with np.NaN, because pd.Series does it. if None in self.data: self.data[self.data == None] = np.NaN - + # index if isinstance(index, list): if index == [] and len(self.data) > 0: @@ -265,18 +273,20 @@ class SingleData(BaseData): else: raise ValueError(f"index must be list or pd.Index") assert len(self.data) == len(self.index) - # if data is not empty, + # if data is not empty, self.index_map = dict(zip(self.index, range(len(self.index)))) super(SingleData, self).__init__() def _align_index(self, other): if self.index == other.index: - return self, other + return self, other elif set(self.index) == set(other.index): return self, other.reindex(self.index) else: - raise ValueError(f"The indexes of self and other do not meet the requirements of the four arithmetic operations") + raise ValueError( + f"The indexes of self and other do not meet the requirements of the four arithmetic operations" + ) def reindex(self, index, fill_value=np.NaN): """reindex data and fill the missing value with np.NaN. @@ -291,7 +301,7 @@ class SingleData(BaseData): SingleData reindex data """ - tmp_data = np.full(len(index), fill_value, np.float64) + tmp_data = np.full(len(index), fill_value, dtype=np.float64) for index_id, index_item in enumerate(index): if index_item in self.index: tmp_data[index_id] = self.data[self.index_map[index_item]] @@ -299,8 +309,8 @@ class SingleData(BaseData): def add(self, other, fill_value=0): common_index = list(set(self.index) | set(other.index)) - tmp_data1 = self.reindex(common_index,fill_value) - tmp_data2 = other.reindex(common_index,fill_value) + tmp_data1 = self.reindex(common_index, fill_value) + tmp_data2 = other.reindex(common_index, fill_value) return tmp_data1 + tmp_data2 def to_dict(self): @@ -324,7 +334,7 @@ class SingleData(BaseData): return MultiData(self.data[:, np.newaxis], self.index) def to_pd_series(self): - return pd.Series(self.data, index = self.index) + return pd.Series(self.data, index=self.index) def __getitem__(self, index: Union["SingleData", int, str]): if isinstance(index, int): @@ -340,7 +350,12 @@ class SingleData(BaseData): class MultiData(BaseData): - def __init__(self, data: Union[list, np.ndarray] = [[]], index: Union[list, pd.Index] = [], columns: Union[list, pd.Index] = []): + def __init__( + self, + data: Union[list, np.ndarray] = [[]], + index: Union[list, pd.Index] = [], + columns: Union[list, pd.Index] = [], + ): """A data structure of index and numpy data. It's used to replace pd.DataFrame due to high-speed. @@ -363,12 +378,12 @@ class MultiData(BaseData): # data in SingleData must be two dim assert self.data.ndim == 2 # replace int with float - if self.data.dtype == np.int: + if self.data.dtype == np.signedinteger: self.data = self.data.astype(np.float64) # replace None with np.NaN, because pd.DataFrame does it. if None in self.data: self.data[self.data == None] = np.NaN - + # index if isinstance(index, list): if index == [] and self.data.shape[0] > 0: @@ -379,7 +394,7 @@ class MultiData(BaseData): else: raise ValueError(f"index must be list or pd.Index") assert self.data.shape[0] == len(self.index) - # if data is not empty, + # if data is not empty, self.index_map = dict(zip(self.index, range(len(self.index)))) # columns @@ -392,19 +407,29 @@ class MultiData(BaseData): else: raise ValueError(f"columns must be list or pd.Index") assert self.data.shape[1] == len(self.columns) - # if data is not empty, - self.columns_map = dict(zip(self.columns, range(len(self.columns)))) + # if data is not empty, + self.columns_map = dict(zip(self.columns, range(len(self.columns)))) super(MultiData, self).__init__() def _align_index(self, other): if self.index_columns == other.index_columns: - return self, other + return self, other else: - raise ValueError(f"The indexes of self and other do not meet the requirements of the four arithmetic operations") + raise ValueError( + f"The indexes of self and other do not meet the requirements of the four arithmetic operations" + ) def __getitem__(self, col) -> SingleData: if col not in self.columns: return SingleData() else: return SingleData(self.data[:, self.columns_map[col]], self.index) + + def loc(self, start, end, col=None): + start_id = bisect.bisect_left(self.index, start) + end_id = bisect.bisect_right(self.index, end) + if col is None: + return MultiData(self.data[start_id:end_id], self.index[start_id:end_id], self.columns) + else: + return SingleData(self.data[start_id:end_id, self.columns_map[col]], self.index[start_id:end_id]) diff --git a/qlib/utils/time.py b/qlib/utils/time.py index efee8f5eb..e9ae82c5f 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -38,7 +38,7 @@ def get_min_cal(shift: int = 0) -> List[time]: return cal -def _if_single_data(start_time, end_time, freq): +def if_single_data(start_time, end_time, freq): """Is there only one piece of data to obtain. Parameters From 7ee4a207bc16825368ee179a3e216e4a0c826f30 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Thu, 26 Aug 2021 16:26:26 +0000 Subject: [PATCH 173/187] add lru --- qlib/backtest/high_performance_ds.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 6f38b390a..f17f6e14c 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -158,6 +158,7 @@ class CN1min_NumpyQuote(BaseQuote): def get_all_stock(self): return self.data.keys() + @lru_cache(maxsize=512) def get_data(self, stock_id, start_time, end_time, fields=None, method=None): if fields is None and method is not None: raise ValueError(f"method must be None when fields is None") From 43a8f502ede828c27e37a3092e9396821efa7850 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Fri, 27 Aug 2021 10:48:10 +0000 Subject: [PATCH 174/187] fix bug --- qlib/backtest/exchange.py | 26 ++--- qlib/backtest/high_performance_ds.py | 158 ++++++++++++--------------- qlib/backtest/order.py | 2 +- qlib/backtest/report.py | 16 +-- qlib/utils/index_data.py | 40 +++---- qlib/utils/time.py | 4 +- 6 files changed, 109 insertions(+), 137 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 4c726720c..347f33790 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -18,7 +18,7 @@ from ..config import C, REG_CN from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order, OrderDir, OrderHelper -from .high_performance_ds import PandasQuote, CN1min_NumpyQuote +from .high_performance_ds import PandasQuote, CN1minNumpyQuote class Exchange: @@ -36,7 +36,7 @@ class Exchange: close_cost=0.0025, min_cost=5, extra_quote=None, - quote_cls=CN1min_NumpyQuote, + quote_cls=CN1minNumpyQuote, **kwargs, ): """__init__ @@ -327,20 +327,20 @@ class Exchange: """ if direction is None: - buy_limit = self.quote.get_data(stock_id, start_time, end_time, fields="limit_buy", method="all") - sell_limit = self.quote.get_data(stock_id, start_time, end_time, fields="limit_sell", method="all") + buy_limit = self.quote.get_data(stock_id, start_time, end_time, field="limit_buy", method="all") + sell_limit = self.quote.get_data(stock_id, start_time, end_time, field="limit_sell", method="all") return buy_limit or sell_limit elif direction == Order.BUY: - return self.quote.get_data(stock_id, start_time, end_time, fields="limit_buy", method="all") + return self.quote.get_data(stock_id, start_time, end_time, field="limit_buy", method="all") elif direction == Order.SELL: - return self.quote.get_data(stock_id, start_time, end_time, fields="limit_sell", method="all") + return self.quote.get_data(stock_id, start_time, end_time, field="limit_sell", method="all") else: raise ValueError(f"direction {direction} is not supported!") def check_stock_suspended(self, stock_id, start_time, end_time): # is suspended if stock_id in self.quote.get_all_stock(): - return self.quote.get_data(stock_id, start_time, end_time) is None + return self.quote.get_data(stock_id, start_time, end_time, "$close") is None else: return True @@ -411,10 +411,10 @@ class Exchange: return self.quote.get_data(stock_id, start_time, end_time, method=method) def get_close(self, stock_id, start_time, end_time, method=ts_data_last): - return self.quote.get_data(stock_id, start_time, end_time, fields="$close", method=method) + return self.quote.get_data(stock_id, start_time, end_time, field="$close", method=method) def get_volume(self, stock_id, start_time, end_time, method="sum"): - return self.quote.get_data(stock_id, start_time, end_time, fields="$volume", method=method) + return self.quote.get_data(stock_id, start_time, end_time, field="$volume", method=method) def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir, method=ts_data_last): if direction == OrderDir.SELL: @@ -423,7 +423,7 @@ class Exchange: pstr = self.buy_price else: raise NotImplementedError(f"This type of input is not supported") - deal_price = self.quote.get_data(stock_id, start_time, end_time, fields=pstr, method=method) + deal_price = self.quote.get_data(stock_id, start_time, end_time, field=pstr, method=method) if method is not None and (np.isclose(deal_price, 0.0) or np.isnan(deal_price)): self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {pstr}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") @@ -441,7 +441,7 @@ class Exchange: assert start_time is not None and end_time is not None, "the time range must be given" if stock_id not in self.quote.get_all_stock(): return None - return self.quote.get_data(stock_id, start_time, end_time, fields="$factor", method=ts_data_last) + return self.quote.get_data(stock_id, start_time, end_time, field="$factor", method=ts_data_last) def generate_amount_position_from_weight_position( self, weight_position, cash, start_time, end_time, direction=OrderDir.BUY @@ -684,7 +684,7 @@ class Exchange: order.stock_id, order.start_time, order.end_time, - fields=limit[1], + field=limit[1], method="sum", ) vol_limit_num.append(limit_value) @@ -693,7 +693,7 @@ class Exchange: order.stock_id, order.start_time, order.end_time, - fields=limit[1], + field=limit[1], method=ts_data_last, ) vol_limit_num.append(limit_value - dealt_order_amount[order.stock_id]) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index f17f6e14c..d125fadc8 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -2,21 +2,19 @@ # Licensed under the MIT License. -from builtins import ValueError, isinstance from functools import lru_cache import logging from typing import List, Text, Union, Callable, Iterable, Dict from collections import OrderedDict import inspect -import bisect import pandas as pd import numpy as np -from ..utils.index_data import IndexData +from ..utils.index_data import IndexData, SingleData from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger -from ..utils.time import if_single_data +from ..utils.time import is_single_value class BaseQuote: @@ -39,10 +37,10 @@ class BaseQuote: stock_id: str, start_time: Union[pd.Timestamp, str], end_time: Union[pd.Timestamp, str], - fields: Union[str, None] = None, + field: Union[str], method: Union[str, Callable, None] = None, - ) -> Union[None, Union[int, float, bool], "IndexData"]: - """get the specific fields of stock data during start time and end_time, + ) -> Union[None, int, float, bool, "IndexData"]: + """get the specific field of stock data during start time and end_time, and apply method to the data. Example: @@ -63,22 +61,13 @@ class BaseQuote: this function is used for three case: - 1. Both fields and method are not None. It returns int/float/bool. - print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields="$close", method="last")) + 1. method is not None. It returns int/float/bool. + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", field="$close", method="last")) 85.713585 - 2. Both fields and method are None. It returns np.ndarray. - print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields=None, method=None)) - - [ - [86.778313, 16162960.0], - [87.433578, 28117442.0], - [85.713585, 23632884.0], - ] - - 3. fields is not None, and method is None. It returns IndexData. - print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", fields="$close", method=None)) + 2. method is None. It returns IndexData. + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", field="$close", method=None)) IndexData([86.778313, 87.433578, 85.713585], [2010-01-04, 2010-01-05, 2010-01-06]) @@ -89,7 +78,7 @@ class BaseQuote: closed start time for backtest end_time : Union[pd.Timestamp, str] closed end time for backtest - fields : Union[str, None] + field : str the columns of data to fetch method : Union[str, Callable, None] the method apply to data. @@ -97,7 +86,8 @@ class BaseQuote: Return ---------- - Union[None, Union[int, float, bool], IndexData] + Union[None, int, float, bool, IndexData] + None means there is no stock data from data source. please refer to Example as following. """ @@ -115,32 +105,21 @@ class PandasQuote(BaseQuote): def get_all_stock(self): return self.data.keys() - def get_data(self, stock_id, start_time, end_time, fields=None, method=None): - if fields is None and method is not None: - raise ValueError(f"method must be None when fields is None") - - if fields is None: - stock_data = resam_ts_data(self.data[stock_id], start_time, end_time, method=method) - elif isinstance(fields, str): - stock_data = resam_ts_data(self.data[stock_id][fields], start_time, end_time, method=method) - else: - raise ValueError(f"fields must be None, str") - + def get_data(self, stock_id, start_time, end_time, field, method=None): + stock_data = resam_ts_data(self.data[stock_id][field], start_time, end_time, method=method) if stock_data is None: return None - elif isinstance(stock_data, (bool, np.bool_, int, float, np.signedinteger, np.floating)): + elif isinstance(stock_data, (bool, np.bool_, int, float, np.number)): return stock_data elif isinstance(stock_data, pd.Series): return IndexData.Series(stock_data) - elif isinstance(stock_data, pd.DataFrame): - return stock_data.values else: raise ValueError(f"stock data from resam_ts_data must be a number, pd.Series or pd.DataFrame") -class CN1min_NumpyQuote(BaseQuote): +class CN1minNumpyQuote(BaseQuote): def __init__(self, quote_df: pd.DataFrame): - """CN1min_NumpyQuote + """CN1minNumpyQuote Parameters ---------- @@ -153,48 +132,37 @@ class CN1min_NumpyQuote(BaseQuote): for stock_id, stock_val in quote_df.groupby(level="instrument"): quote_dict[stock_id] = IndexData.DataFrame(stock_val.droplevel(level="instrument")) self.data = quote_dict - self.freq = np.timedelta64(1, "m") + self.freq = pd.Timedelta(minutes=1) def get_all_stock(self): return self.data.keys() @lru_cache(maxsize=512) - def get_data(self, stock_id, start_time, end_time, fields=None, method=None): - if fields is None and method is not None: - raise ValueError(f"method must be None when fields is None") - + def get_data(self, stock_id, start_time, end_time, field, method=None): # check stock id if stock_id not in self.get_all_stock(): return None # single data # If it don't consider the classification of single data, it will consume a lot of time. - if if_single_data(start_time, end_time, self.freq): + if is_single_value(start_time, end_time, self.freq): now_index_map = self.data[stock_id].index_map now_columns_map = self.data[stock_id].columns_map if start_time not in now_index_map: return None - if fields is None: - return self.data[stock_id].values[now_index_map[start_time]] else: - return self.data[stock_id].values[now_index_map[start_time], now_columns_map[fields]] + return self.data[stock_id].values[now_index_map[start_time], now_columns_map[field]] # multi data else: - if fields is None and method is None: - stock_data = self.data[stock_id].loc(start_time, end_time) - if stock_data.empty: - return None - else: - return stock_data.values - elif fields is not None and method is None: - stock_data = self.data[stock_id].loc(start_time, end_time, fields) + if method is None: + stock_data = self.data[stock_id].loc(start_time, end_time, field) if stock_data.empty: return None else: return stock_data - elif fields is not None and method is not None: - stock_data = self.data[stock_id].loc(start_time, end_time, fields) + else: + stock_data = self.data[stock_id].loc(start_time, end_time, field) if stock_data.empty: return None elif len(stock_data) == 1: @@ -231,6 +199,20 @@ class BaseSingleMetric: """ def __init__(self, metric: Union[dict, pd.Series]): + """Single data structure for each metric. + + Parameters + ---------- + metric : Union[dict, pd.Series] + keys/index is stock_id, value is the metric value. + for example: + SH600068 NaN + SH600079 1.0 + SH600266 NaN + ... + SZ300692 NaN + SZ300719 NaN, + """ raise NotImplementedError(f"Please implement the `__init__` method") def __add__(self, other: Union["BaseSingleMetric", int, float]) -> "BaseSingleMetric": @@ -277,7 +259,7 @@ class BaseSingleMetric: def abs(self) -> "BaseSingleMetric": raise NotImplementedError(f"Please implement the `abs` method") - def astype(self, type: type) -> "BaseSingleMetric": + def astype(self, dtype: type) -> "BaseSingleMetric": raise NotImplementedError(f"Please implement the `astype` method") @property @@ -316,7 +298,8 @@ class BaseOrderIndicator: to inherit the BaseSingleMetric. """ - def __init__(self): + def __init__(self, data): + self.data = data self.logger = get_module_logger("online operator") def assign(self, col: str, metric: Union[dict, pd.Series]): @@ -358,8 +341,13 @@ class BaseOrderIndicator: BaseSingleMetric new metric. """ - - raise NotImplementedError(f"Please implement the 'transfer' method") + func_sig = inspect.signature(func).parameters.keys() + func_kwargs = {sig: self.data[sig] for sig in func_sig} + tmp_metric = func(**func_kwargs) + if new_col is not None: + self.data[new_col] = tmp_metric + else: + return tmp_metric def get_metric_series(self, metric: str) -> pd.Series: """return the single metric with pd.Series format. @@ -378,8 +366,8 @@ class BaseOrderIndicator: raise NotImplementedError(f"Please implement the 'get_metric_series' method") - def get_index_data(self, metric) -> IndexData.Series: - """get one metric with the format of IndexData.Series + def get_index_data(self, metric) -> SingleData: + """get one metric with the format of SingleData Parameters ---------- @@ -389,7 +377,7 @@ class BaseOrderIndicator: Return ------ IndexData.Series - one metric with the format of IndexData.Series + one metric with the format of SingleData """ raise NotImplementedError(f"Please implement the 'get_index_data' method") @@ -431,6 +419,9 @@ class BaseOrderIndicator: class SingleMetric(BaseSingleMetric): + def __init__(self, metric): + self.metric = metric + def __add__(self, other): if isinstance(other, (int, float)): return self.__class__(self.metric + other) @@ -502,7 +493,7 @@ class SingleMetric(BaseSingleMetric): class PandasSingleMetric(SingleMetric): """Each SingleMetric is based on pd.Series.""" - def __init__(self, metric: Union[dict, pd.Series]): + def __init__(self, metric: Union[dict, pd.Series] = {}): if isinstance(metric, dict): self.metric = pd.Series(metric) elif isinstance(metric, pd.Series): @@ -522,13 +513,17 @@ class PandasSingleMetric(SingleMetric): def abs(self): return self.__class__(self.metric.abs()) - def astype(self, type): - return self.__class__(self.metric.astype(type)) + def astype(self, dtype): + return self.__class__(self.metric.astype(dtype)) @property def empty(self): return self.metric.empty + @property + def index(self): + return list(self.metric.index) + def add(self, other, fill_value=None): return self.__class__(self.metric.add(other.metric, fill_value=fill_value)) @@ -538,6 +533,9 @@ class PandasSingleMetric(SingleMetric): def apply(self, func: Callable): return self.__class__(self.metric.apply(func)) + def reindex(self, index, fill_value): + return self.__class__(self.metric.reindex(index, fill_value=fill_value)) + class PandasOrderIndicator(BaseOrderIndicator): """ @@ -552,15 +550,6 @@ class PandasOrderIndicator(BaseOrderIndicator): def assign(self, col: str, metric: Union[dict, pd.Series]): self.data[col] = PandasSingleMetric(metric) - def transfer(self, func: Callable, new_col: str = None) -> Union[None, PandasSingleMetric]: - func_sig = inspect.signature(func).parameters.keys() - func_kwargs = {sig: self.data[sig] for sig in func_sig} - tmp_metric = func(**func_kwargs) - if new_col is not None: - self.data[new_col] = tmp_metric - else: - return tmp_metric - def get_index_data(self, metric): if metric in self.data: return IndexData.Series(self.data[metric].metric) @@ -577,7 +566,7 @@ class PandasOrderIndicator(BaseOrderIndicator): return {k: v.metric for k, v in self.data.items()} @staticmethod - def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=None): + def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=0): if isinstance(metrics, str): metrics = [metrics] for metric in metrics: @@ -589,26 +578,17 @@ class PandasOrderIndicator(BaseOrderIndicator): class NumpyOrderIndicator(BaseOrderIndicator): """ - The data structure is OrderedDict(str: IndexData.Series). + The data structure is OrderedDict(str: SingleData). Each IndexData.Series is one metric. Str is the name of metric. """ def __init__(self): - self.data: Dict[str, IndexData.Series] = OrderedDict() + self.data: Dict[str, SingleData] = OrderedDict() def assign(self, col: str, metric: dict): self.data[col] = IndexData.Series(metric) - def transfer(self, func: Callable, new_col: str = None) -> Union[None, IndexData.Series]: - func_sig = inspect.signature(func).parameters.keys() - func_kwargs = {sig: self.data[sig] for sig in func_sig} - tmp_metric = func(**func_kwargs) - if new_col is not None: - self.data[new_col] = tmp_metric - else: - return tmp_metric - def get_index_data(self, metric): if metric in self.data: return self.data[metric] @@ -616,7 +596,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): return IndexData.Series() def get_metric_series(self, metric: str) -> Union[pd.Series]: - return self.data[metric].to_pd_series() + return self.data[metric].to_series() def to_series(self) -> Dict[str, pd.Series]: tmp_metric_dict = {} diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index 42af5f24e..e169ffd64 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -109,7 +109,7 @@ class Order: return self.direction * 2 - 1 @staticmethod - def parse_dir(direction: Union[str, int, np.integer, OrderDir, np.ndarray]) -> OrderDir: + def parse_dir(direction: Union[str, int, np.integer, OrderDir, np.ndarray]) -> Union[OrderDir, np.ndarray]: if isinstance(direction, OrderDir): return direction elif isinstance(direction, (int, float, np.integer, np.floating)): diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 31c3e7b0a..e29921da6 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -4,15 +4,14 @@ from collections import OrderedDict import pathlib -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Union import numpy as np import pandas as pd from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir - -from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator +from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator, SingleMetric from ..utils.index_data import IndexData, SingleData from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data @@ -305,8 +304,9 @@ class Indicator: def _update_order_fulfill_rate(self): def func(deal_amount, amount): - # deal_amount is np.NaN when there is no inner decision. So full fill rate is 0. - tmp_deal_amount = deal_amount.replace({np.NaN: 0}) + # deal_amount is np.NaN or None when there is no inner decision. So full fill rate is 0. + tmp_deal_amount = deal_amount.reindex(amount.index, 0) + tmp_deal_amount = tmp_deal_amount.replace({np.NaN: 0}) return tmp_deal_amount / amount self.order_indicator.transfer(func, "ffr") @@ -385,7 +385,7 @@ class Indicator: if price_s is None: return None, None - if isinstance(price_s, (int, float, np.signedinteger, np.floating)): + if isinstance(price_s, (int, float, np.number)): price_s = IndexData.Series(price_s, [trade_start_time]) elif isinstance(price_s, SingleData): pass @@ -400,7 +400,7 @@ class Indicator: if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) - if isinstance(volume_s, (int, float, np.floating)): + if isinstance(volume_s, (int, float, np.number)): volume_s = IndexData.Series(volume_s, [trade_start_time]) volume_s = volume_s.reindex(price_s.index) elif agg == "twap": @@ -414,7 +414,7 @@ class Indicator: def _agg_base_price( self, - inner_order_indicators: List[Dict[str, pd.Series]], + inner_order_indicators: List[Dict[str, Union[SingleMetric, SingleData]]], decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], trade_exchange: Exchange, pa_config: dict = {}, diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 8c0d1874e..8a92cfcf5 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -35,7 +35,7 @@ class IndexData: return MultiData(data, index, columns) @staticmethod - def concat(data_list, axis=0): + def concat(data_list: Union["SingleData"], axis=0) -> "MultiData": """concat all SingleData by index. TODO: now just for SingleData. @@ -50,7 +50,7 @@ class IndexData: the MultiData with ndim == 2 """ if axis == 0: - raise NotImplementedError(f"please implement this fuc when axis == 0") + raise NotImplementedError(f"please implement this func when axis == 0") elif axis == 1: # get all index and row all_index = set() @@ -90,7 +90,7 @@ class BaseData: raise NotImplementedError(f"please implement _align_index func") def __add__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(self.data + other, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -99,7 +99,7 @@ class BaseData: return NotImplemented def __sub__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(self.data - other, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -108,7 +108,7 @@ class BaseData: return NotImplemented def __rsub__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(other - self.data, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -117,7 +117,7 @@ class BaseData: return NotImplemented def __mul__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(self.data * other, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -126,7 +126,7 @@ class BaseData: return NotImplemented def __truediv__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(self.data / other, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -135,7 +135,7 @@ class BaseData: return NotImplemented def __eq__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(self.data == other, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -144,7 +144,7 @@ class BaseData: return NotImplemented def __gt__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(self.data > other, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -153,7 +153,7 @@ class BaseData: return NotImplemented def __lt__(self, other): - if isinstance(other, (int, float, np.floating)): + if isinstance(other, (int, float, np.number)): return self.__class__(self.data < other, *self.index_columns) elif isinstance(other, self.__class__): tmp_data1, tmp_data2 = self._align_index(other) @@ -169,9 +169,9 @@ class BaseData: tmp_data = np.absolute(self.data) return self.__class__(tmp_data, *self.index_columns) - def astype(self, type): + def astype(self, dtype): """change the type of data.""" - tmp_data = self.data.astype(type) + tmp_data = self.data.astype(dtype) return self.__class__(tmp_data, *self.index_columns) def replace(self, to_replace: dict): @@ -234,7 +234,7 @@ class BaseData: class SingleData(BaseData): - def __init__(self, data: Union[int, float, np.floating, list, np.ndarray] = [], index: Union[list, pd.Index] = []): + def __init__(self, data: Union[int, float, np.number, list] = [], index: Union[list, pd.Index] = []): """A data structure of index and numpy data. It's used to replace pd.Series due to high-speed. @@ -301,6 +301,8 @@ class SingleData(BaseData): SingleData reindex data """ + if self.index == index: + return self tmp_data = np.full(len(index), fill_value, dtype=np.float64) for index_id, index_item in enumerate(index): if index_item in self.index: @@ -323,17 +325,7 @@ class SingleData(BaseData): """ return dict(zip(self.index, self.data.tolist())) - def to_frame(self): - """convert SingleData to MultiData. - - Returns - ------- - MultiData - data with the MultiData format. - """ - return MultiData(self.data[:, np.newaxis], self.index) - - def to_pd_series(self): + def to_series(self): return pd.Series(self.data, index=self.index) def __getitem__(self, index: Union["SingleData", int, str]): diff --git a/qlib/utils/time.py b/qlib/utils/time.py index e9ae82c5f..2b2a9d8ec 100644 --- a/qlib/utils/time.py +++ b/qlib/utils/time.py @@ -38,8 +38,8 @@ def get_min_cal(shift: int = 0) -> List[time]: return cal -def if_single_data(start_time, end_time, freq): - """Is there only one piece of data to obtain. +def is_single_value(start_time, end_time, freq): + """Is there only one piece of data for cn stock market. Parameters ---------- From d39c8de800aa846eb313530b63c43bc066b12d74 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 31 Aug 2021 02:33:44 +0000 Subject: [PATCH 175/187] draft design --- qlib/backtest/__init__.py | 2 +- qlib/backtest/exchange.py | 2 +- qlib/backtest/high_performance_ds.py | 75 ++-- qlib/backtest/report.py | 22 +- qlib/data/ops.py | 2 +- qlib/utils/index_data.py | 588 +++++++++++++++------------ qlib/utils/resam.py | 4 +- tests/misc/test_index_data.py | 87 ++++ 8 files changed, 467 insertions(+), 315 deletions(-) create mode 100644 tests/misc/test_index_data.py diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index b4a46614e..d4a19eb25 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -20,7 +20,7 @@ from ..utils import init_instance_by_config from ..log import get_module_logger from ..config import C -# make import more user-friendly by enable `from qlib.backtest import STH` +# make import more user-friendly by adding `from qlib.backtest import STH` logger = get_module_logger("backtest caller") diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 347f33790..c55513d8b 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -424,7 +424,7 @@ class Exchange: else: raise NotImplementedError(f"This type of input is not supported") deal_price = self.quote.get_data(stock_id, start_time, end_time, field=pstr, method=method) - if method is not None and (np.isclose(deal_price, 0.0) or np.isnan(deal_price)): + if method is not None and (deal_price is None or np.isclose(deal_price, 0.0) or np.isnan(deal_price)): self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {pstr}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") deal_price = self.get_close(stock_id, start_time, end_time, method) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index d125fadc8..b94c6a279 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -15,6 +15,7 @@ from ..utils.index_data import IndexData, SingleData from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from ..utils.time import is_single_value +import qlib.utils.index_data as idd class BaseQuote: @@ -61,7 +62,9 @@ class BaseQuote: this function is used for three case: - 1. method is not None. It returns int/float/bool. + 1. method is not None. It returns int/float/bool/None. + - It will return None in one case, the method return None + print(get_data(stock_id="SH600000", start_time="2010-01-04", end_time="2010-01-06", field="$close", method="last")) 85.713585 @@ -87,8 +90,9 @@ class BaseQuote: Return ---------- Union[None, int, float, bool, IndexData] - None means there is no stock data from data source. - please refer to Example as following. + it will return None in following cases + - There is no stock data which meet the query criterion from data source. + - The `method` returns None """ raise NotImplementedError(f"Please implement the `get_data` method") @@ -112,7 +116,7 @@ class PandasQuote(BaseQuote): elif isinstance(stock_data, (bool, np.bool_, int, float, np.number)): return stock_data elif isinstance(stock_data, pd.Series): - return IndexData.Series(stock_data) + return idd.SingleData(stock_data) else: raise ValueError(f"stock data from resam_ts_data must be a number, pd.Series or pd.DataFrame") @@ -130,7 +134,8 @@ class CN1minNumpyQuote(BaseQuote): super().__init__(quote_df=quote_df) quote_dict = {} for stock_id, stock_val in quote_df.groupby(level="instrument"): - quote_dict[stock_id] = IndexData.DataFrame(stock_val.droplevel(level="instrument")) + quote_dict[stock_id] = idd.MultiData(stock_val.droplevel(level="instrument")) + quote_dict[stock_id].sort_index() # To support more flexible slicing, we must sort data first self.data = quote_dict self.freq = pd.Timedelta(minutes=1) @@ -145,32 +150,22 @@ class CN1minNumpyQuote(BaseQuote): # single data # If it don't consider the classification of single data, it will consume a lot of time. - if is_single_value(start_time, end_time, self.freq): - now_index_map = self.data[stock_id].index_map - now_columns_map = self.data[stock_id].columns_map - if start_time not in now_index_map: + if is_single_value(start_time, end_time, self.freq) and method is not None: + # this is a very special case. + # skip aggregating function to speed-up the query calculation + try: + self.data[stock_id].loc[start_time, field] + except KeyError: return None - else: - return self.data[stock_id].values[now_index_map[start_time], now_columns_map[field]] - - # multi data else: - if method is None: - stock_data = self.data[stock_id].loc(start_time, end_time, field) - if stock_data.empty: - return None - else: - return stock_data - else: - stock_data = self.data[stock_id].loc(start_time, end_time, field) - if stock_data.empty: - return None - elif len(stock_data) == 1: - return stock_data[0] - else: - return self._agg_data(stock_data.values, method) + data = self.data[stock_id].loc[start_time:end_time, field] + if data.empty: + return None + if method is not None: + data = self._agg_data(data, method) + return data - def _agg_data(self, data, method): + def _agg_data(self, data: IndexData, method): """Agg data by specific method.""" if method == "sum": return np.nansum(data) @@ -183,11 +178,11 @@ class CN1minNumpyQuote(BaseQuote): elif method == "any": return data.any() elif method == ts_data_last: - valid_data = data[data != np.NaN] + valid_data = data.loc[~data.isna().data.astype(bool)] if len(valid_data) == 0: return None else: - return valid_data[0] + return valid_data.iloc[-1] else: raise ValueError(f"{method} is not supported") @@ -259,9 +254,6 @@ class BaseSingleMetric: def abs(self) -> "BaseSingleMetric": raise NotImplementedError(f"Please implement the `abs` method") - def astype(self, dtype: type) -> "BaseSingleMetric": - raise NotImplementedError(f"Please implement the `astype` method") - @property def empty(self) -> bool: """If metric is empty, return True.""" @@ -332,7 +324,7 @@ class BaseOrderIndicator: the kwargs of func will be replaced with metric data by name in this function. e.g. def func(pa): - return (pa > 0).astype(int).sum() / pa.count() + return (pa > 0).sum() / pa.count() new_col : str, optional New metric will be assigned in the data if new_col is not None, by default None. @@ -513,9 +505,6 @@ class PandasSingleMetric(SingleMetric): def abs(self): return self.__class__(self.metric.abs()) - def astype(self, dtype): - return self.__class__(self.metric.astype(dtype)) - @property def empty(self): return self.metric.empty @@ -552,9 +541,9 @@ class PandasOrderIndicator(BaseOrderIndicator): def get_index_data(self, metric): if metric in self.data: - return IndexData.Series(self.data[metric].metric) + return idd.SingleData(self.data[metric].metric) else: - return IndexData.Series() + return idd.SingleData() def get_metric_series(self, metric: str) -> Union[pd.Series]: if metric in self.data: @@ -579,7 +568,7 @@ class PandasOrderIndicator(BaseOrderIndicator): class NumpyOrderIndicator(BaseOrderIndicator): """ The data structure is OrderedDict(str: SingleData). - Each IndexData.Series is one metric. + Each idd.SingleData is one metric. Str is the name of metric. """ @@ -587,13 +576,13 @@ class NumpyOrderIndicator(BaseOrderIndicator): self.data: Dict[str, SingleData] = OrderedDict() def assign(self, col: str, metric: dict): - self.data[col] = IndexData.Series(metric) + self.data[col] = idd.SingleData(metric) def get_index_data(self, metric): if metric in self.data: return self.data[metric] else: - return IndexData.Series() + return idd.SingleData() def get_metric_series(self, metric: str) -> Union[pd.Series]: return self.data[metric].to_series() @@ -609,7 +598,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): if isinstance(metrics, str): metrics = [metrics] for metric in metrics: - tmp_metric = IndexData.Series() + tmp_metric = IndexData.SingleData() for indicator in indicators: tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) order_indicator.data[metric] = tmp_metric diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index e29921da6..0dfc92582 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -12,10 +12,10 @@ import pandas as pd from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator, SingleMetric -from ..utils.index_data import IndexData, SingleData from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data from .order import IdxTradeRange +import qlib.utils.index_data as idd class Report: @@ -386,8 +386,8 @@ class Indicator: return None, None if isinstance(price_s, (int, float, np.number)): - price_s = IndexData.Series(price_s, [trade_start_time]) - elif isinstance(price_s, SingleData): + price_s = idd.SingleData(price_s, [trade_start_time]) + elif isinstance(price_s, idd.SingleData): pass else: raise NotImplementedError(f"This type of input is not supported") @@ -401,10 +401,10 @@ class Indicator: if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) if isinstance(volume_s, (int, float, np.number)): - volume_s = IndexData.Series(volume_s, [trade_start_time]) + volume_s = idd.SingleData(volume_s, [trade_start_time]) volume_s = volume_s.reindex(price_s.index) elif agg == "twap": - volume_s = IndexData.Series(1, price_s.index) + volume_s = idd.SingleData(1, price_s.index) else: raise NotImplementedError(f"This type of input is not supported") @@ -414,7 +414,7 @@ class Indicator: def _agg_base_price( self, - inner_order_indicators: List[Dict[str, Union[SingleMetric, SingleData]]], + inner_order_indicators: List[Dict[str, Union[SingleMetric, idd.SingleData]]], decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]], trade_exchange: Exchange, pa_config: dict = {}, @@ -467,12 +467,12 @@ class Indicator: else: bp_new[inst], bv_new[inst] = pr, v - bp_new = IndexData.Series(bp_new) - bv_new = IndexData.Series(bv_new) + bp_new = idd.SingleData(bp_new) + bv_new = idd.SingleData(bv_new) bp_all.append(bp_new) bv_all.append(bv_new) - bp_all = IndexData.concat(bp_all, axis=1) - bv_all = IndexData.concat(bv_all, axis=1) + bp_all = idd.concat(bp_all, axis=1) + bv_all = idd.concat(bv_all, axis=1) base_volume = bv_all.sum(axis=1) self.order_indicator.assign("base_volume", base_volume.to_dict()) @@ -550,7 +550,7 @@ class Indicator: def _cal_trade_positive_rate(self): def func(pa): - return (pa > 0).astype(int).sum() / pa.count() + return (pa > 0).sum() / pa.count() return self.order_indicator.transfer(func) diff --git a/qlib/data/ops.py b/qlib/data/ops.py index e044533d3..bd8832b49 100644 --- a/qlib/data/ops.py +++ b/qlib/data/ops.py @@ -1405,7 +1405,7 @@ class Corr(PairRolling): super(Corr, self).__init__(feature_left, feature_right, N, "corr") def _load_internal(self, instrument, start_index, end_index, freq): - res = super(Corr, self)._load_internal(instrument, start_index, end_index, freq) + res: pd.Series = super(Corr, self)._load_internal(instrument, start_index, end_index, freq) # NOTE: Load uses MemCache, so calling load again will not cause performance degradation series_left = self.feature_left.load(instrument, start_index, end_index, freq) diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 8a92cfcf5..78cd32b50 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -1,178 +1,334 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +""" +Motivation of index_data +- Pandas has a lot of user-friendly interfaces. However, integrating too much features in a single tool bring to much overhead and makes it much slower than numpy. + Some users just want a simple numpy dataframe with indices and don't want such a complicated tools. + Such users are the target of `index_data` +`index_data` try to behave like pandas (some API will be different because we try to be simpler and more intuitive) but don't compromize the performance. It provides the basic numpy data and simple indexing feature. If users call APIs which may compromize the performance, index_data will raise Errors. +""" -from typing import Union, Callable +from typing import Tuple, Union, Callable, List import bisect import numpy as np import pandas as pd -class IndexData: - """This is a simplified version of pandas which is faster based on numpy.""" +def concat(data_list: Union["SingleData"], axis=0) -> "MultiData": + """concat all SingleData by index. + TODO: now just for SingleData. - @staticmethod - def Series( - data: Union[dict, pd.Series, int, float, np.floating, list, np.ndarray] = [], index: Union[list, pd.Index] = [] - ): - if isinstance(data, dict): - return SingleData(list(data.values()), list(data.keys())) - elif isinstance(data, pd.Series): - return SingleData(data.values, data.index) + Parameters + ---------- + index_data_list : List[SingleData] + the list of all SingleData to concat. + + Returns + ------- + MultiData + the MultiData with ndim == 2 + """ + if axis == 0: + raise NotImplementedError(f"please implement this func when axis == 0") + elif axis == 1: + # get all index and row + all_index = set() + for index_data in data_list: + all_index = all_index | set(index_data.index) + all_index = list(all_index) + all_index.sort() + all_index_map = dict(zip(all_index, range(len(all_index)))) + + # concat all + tmp_data = np.full((len(all_index), len(data_list)), np.NaN) + for data_id, index_data in enumerate(data_list): + assert isinstance(index_data, SingleData) + now_data_map = [all_index_map[index] for index in index_data.index] + tmp_data[now_data_map, data_id] = index_data.data + return MultiData(tmp_data, all_index) + else: + raise ValueError(f"axis must be 0 or 1") + + +class Index: + """ + This is for indexing(rows or columns) + + Read-only operations has higher priorities than others. + So this class is designed in a **read-only** way to shared data for queries. + Modifications will results in new Index. + + NOTE: the indexing has following flaws + - duplicated index value is not well supported (only the first appearance will be considered) + - The order of the index is not considered!!!! So the slicing will not behave like pandas when indexings are ordered + """ + def __init__(self, idx_list: Union[List, pd.Index, "Index", int]): + self.idx_list: np.ndarray = None # using array type for index list will make things easier + if isinstance(idx_list, Index): + # Fast read-only copy + self.idx_list = idx_list.idx_list + self.index_map = idx_list.index_map + self._is_sorted = idx_list._is_sorted + elif isinstance(idx_list, int): + self.index_map = self.idx_list = np.arange(idx_list) + self._is_sorted = True else: - return SingleData(data, index) + self.idx_list = np.array(idx_list) + # NOTE: only the first appearance is indexed + self.index_map = dict(zip(self.idx_list, range(len(self)))) + self._is_sorted = False - @staticmethod - def DataFrame( - data: Union[pd.DataFrame, list, np.ndarray] = [[]], - index: Union[list, pd.Index] = [], - columns: Union[list, pd.Index] = [], - ): - if isinstance(data, pd.DataFrame): - return MultiData(data.values, data.index, data.columns) - else: - return MultiData(data, index, columns) + def __getitem__(self, i: int): + return self.idx_list[i] - @staticmethod - def concat(data_list: Union["SingleData"], axis=0) -> "MultiData": - """concat all SingleData by index. - TODO: now just for SingleData. + def index(self, item) -> int: + """ + Given the index value, get the integer index - Parameters - ---------- - index_data_list : List[SingleData] - the list of all SingleData to concat. + """ + return self.index_map[item] + + def __eq__(self, other: "Index"): + # NOTE: np.nan is not supported in the index + return (self.idx_list == other.idx_list).all() + + def __len__(self): + return len(self.idx_list) + + def is_sorted(self): + return self._is_sorted + + def sort(self) -> Tuple["Index", np.ndarray]: + """ + sort the index Returns ------- - MultiData - the MultiData with ndim == 2 + Tuple["Index", np.ndarray]: + the sorted Index and the changed index """ - if axis == 0: - raise NotImplementedError(f"please implement this func when axis == 0") - elif axis == 1: - # get all index and row - all_index = set() - for index_data in data_list: - all_index = all_index | set(index_data.index) - all_index = list(all_index) - all_index.sort() - all_index_map = dict(zip(all_index, range(len(all_index)))) + sorted_idx = np.argsort(self.idx_list) + idx = Index(self.idx_list[sorted_idx]) + idx._is_sorted = True + return idx, sorted_idx - # concat all - tmp_data = np.full((len(all_index), len(data_list)), np.NaN) - for data_id, index_data in enumerate(data_list): - assert isinstance(index_data, SingleData) - now_data_map = [all_index_map[index] for index in index_data.index] - tmp_data[now_data_map, data_id] = index_data.data - return MultiData(tmp_data, all_index) + + +class LocIndexer: + """ + `Indexer` will behave like the `LocIndexer` in Pandas + + Read-only operations has higher priorities than others. + So this class is designed in a read-only way to shared data for queries. + Modifications will results in new Index. + """ + def __init__(self, index_data: "IndexData", indices: List[Index], int_loc: bool = False): + self._indices: List[Index] = indices + self._bind_id = index_data # bind index data + self._int_loc = int_loc + assert self._bind_id.data.ndim == len(self._indices) + + @staticmethod + def proc_idx_l(indices: List[Union[List, pd.Index, Index]], data_shape: Tuple = None) -> List[Index]: + """ process the indices from user and output a list of `Index` """ + res = [] + for i, idx in enumerate(indices): + res.append(Index(data_shape[i] if len(idx) == 0 else idx)) + return res + + def _slc_convert(self, index: Index, indexing: slice) -> slice: + """ + convert value-based indexing to integer-based indexing. + + Parameters + ---------- + index : Index + index data. + indexing : slice + value based indexing data with slice type for indexing. + + Returns + ------- + slice: + the integer based slicing + """ + if index.is_sorted(): + int_start = None if indexing.start is None else bisect.bisect_left(index, indexing.start) + int_stop = None if indexing.stop is None else bisect.bisect_right(index, indexing.stop) else: - raise ValueError(f"axis must be 0 or 1") + int_start = None if indexing.start is None else index.index(indexing.start) + int_stop = None if indexing.stop is None else index.index(indexing.stop) + 1 + return slice(int_start, int_stop) + + def __getitem__(self, indexing): + """ + + Parameters + ---------- + indexing : + query for data + + Raises + ------ + KeyError: + If the non-slice index is queried but does not exist, `KeyError` is raised. + """ + # 1) convert slices to int loc + if not isinstance(indexing, tuple): + # NOTE: tuple is not supported for indexing + indexing = (indexing, ) + + # TODO: create a subclass for single value query + assert len(indexing) <= len(self._indices) + + int_indexing = [] + for dim, index in enumerate(self._indices): + if dim < len(indexing): + _indexing = indexing[dim] + if not self._int_loc: # type converting is only necessary when it is not `iloc` + if isinstance(_indexing, slice): + _indexing = self._slc_convert(index, _indexing) + elif isinstance(_indexing, (IndexData, np.ndarray)): + if isinstance(_indexing, IndexData): + _indexing = _indexing.data + assert _indexing.ndim == 1 + if _indexing.dtype != np.bool: + _indexing = np.array(list(index.index(i) for i in _indexing)) + else: + _indexing = index.index(_indexing) + else: + _indexing = slice(None) + int_indexing.append(_indexing) + + # 2) select data and index + new_data = self._bind_id.data[tuple(int_indexing)] + new_indices = [idx[indexing] for idx, indexing in zip(self._indices, int_indexing)] + + # 3) squash dimensions + new_indices = [idx for idx in new_indices if isinstance(idx, np.ndarray) and idx.ndim > 0] # squash the zero dim indexing + + if new_data.ndim == 0: + return new_data + else: + if new_data.ndim == 1: + cls = SingleData + elif new_data.ndim == 2: + cls = MultiData + else: + raise ValueError("Not supported") + return cls(new_data, *new_indices) -class BaseData: - """Base data structure of SingleData and MultiData.""" +class IndexData: + """ + Base data structure of SingleData and MultiData. - def __init__(self): - self.index_columns = self._get_index_columns() + NOTE: + - For performance issue, only **np.floating** is supported in the underlayer data !!! + - Boolean based on np.floating is also supported. Here are some examples - def _get_index_columns(self): - index_columns = [] - if hasattr(self, "index"): - index_columns.append(self.index) - if hasattr(self, "columns"): - index_columns.append(self.columns) - return index_columns + .. code-block:: python - def _align_index(self, other): + np.array([ np.nan]).any() -> True + np.array([ np.nan]).all() -> True + np.array([1. , 0.]).any() -> True + np.array([1. , 0.]).all() -> False + """ + + loc_idx_cls = LocIndexer + def __init__(self, data: np.ndarray, *indices: Union[List, pd.Index, Index]): + + self.data = data + self.indices = indices + + # get the expected data shape + # - The index has higher priority + self.data = np.array(data) + + expected_dim = max(self.data.ndim, len(indices)) + + data_shape = [] + for i in range(expected_dim): + idx_l = indices[i] if len(indices) > i else [] + if len(idx_l) == 0: + data_shape.append(self.data.shape[i]) + else: + data_shape.append(len(idx_l)) + data_shape = tuple(data_shape) + + # broadcast the data to expected shape + self.data = np.broadcast_to(self.data, data_shape) + + self.data = self.data.astype(np.float64) + # Please notice following cases when converting the type + # - np.array([None, 1]).astype(np.float64) -> array([nan, 1.]) + + # create index from user's index data. + self.indices: List[Index] = self.loc_idx_cls.proc_idx_l(indices, data_shape) + + for dim in range(expected_dim): + assert self.data.shape[dim] == len(self.indices[dim]) + + self.ndim = expected_dim + + # indexing related methods + @property + def loc(self): + return self.loc_idx_cls(index_data=self, indices=self.indices) + + @property + def iloc(self): + return self.loc_idx_cls(index_data=self, indices=self.indices, int_loc=True) + + @property + def index(self): + return self.indices[0] + + @property + def columns(self): + return self.indices[1] + + def _align_indices(self, other): """Align index before performing the four arithmetic operations.""" - raise NotImplementedError(f"please implement _align_index func") + raise NotImplementedError(f"please implement _align_indices func") - def __add__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(self.data + other, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data1.data + tmp_data2.data, *tmp_data1.index_columns) - else: - return NotImplemented + def sort_index(self, axis=0, inplace=True): + assert inplace, "Only support sorting inplace now" + self.indices[axis], sorted_idx = self.indices[axis].sort() + self.data = np.take(self.data, sorted_idx, axis=axis) - def __sub__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(self.data - other, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data1.data - tmp_data2.data, *tmp_data1.index_columns) - else: - return NotImplemented + # calculation related methods + def __getattribute__(self, attr_name: str): + # 1) use a unified operation for the basic operation - def __rsub__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(other - self.data, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data2.data - tmp_data1.data, *tmp_data1.index_columns) - else: - return NotImplemented + def _basic_binary_ops(other): + self_data_method = getattr(self.data, attr_name) - def __mul__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(self.data * other, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data1.data * tmp_data2.data, *tmp_data1.index_columns) - else: - return NotImplemented + if isinstance(other, (int, float, np.number)): + return self.__class__(self_data_method(other)) + elif isinstance(other, self.__class__): + # TODO: bad interface + tmp_data1, tmp_data2 = self._align_indices(other) + return self.__class__(self_data_method(tmp_data2.data), *self.indices) + else: + return NotImplemented - def __truediv__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(self.data / other, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data1.data / tmp_data2.data, *tmp_data1.index_columns) - else: - return NotImplemented + if attr_name in {"__add__", "__sub__", "__rsub__", "__mul__", "__truediv__", "__eq__", "__gt__", "__lt__"}: + return _basic_binary_ops - def __eq__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(self.data == other, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data1.data == tmp_data2.data, *tmp_data1.index_columns) - else: - return NotImplemented - - def __gt__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(self.data > other, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data1.data > tmp_data2.data, *tmp_data1.index_columns) - else: - return NotImplemented - - def __lt__(self, other): - if isinstance(other, (int, float, np.number)): - return self.__class__(self.data < other, *self.index_columns) - elif isinstance(other, self.__class__): - tmp_data1, tmp_data2 = self._align_index(other) - return self.__class__(tmp_data1.data < tmp_data2.data, *tmp_data1.index_columns) - else: - return NotImplemented + # 2) otherwise, follow the default behavior + return super().__getattribute__(attr_name) + # The code below could be simpler like methods in __getattribute__ def __invert__(self): - return self.__class__(~self.data, *self.index_columns) + return self.__class__(~self.data.astype(np.bool), *self.indices) def abs(self): """get the abs of data except np.NaN.""" tmp_data = np.absolute(self.data) - return self.__class__(tmp_data, *self.index_columns) - - def astype(self, dtype): - """change the type of data.""" - tmp_data = self.data.astype(dtype) - return self.__class__(tmp_data, *self.index_columns) + return self.__class__(tmp_data, *self.indices) def replace(self, to_replace: dict): assert isinstance(to_replace, dict) @@ -180,12 +336,12 @@ class BaseData: for num in to_replace: if num in tmp_data: tmp_data[tmp_data == num] = to_replace[num] - return self.__class__(tmp_data, *self.index_columns) + return self.__class__(tmp_data, *self.indices) def apply(self, func: Callable): """apply a function to data.""" tmp_data = func(self.data) - return self.__class__(tmp_data, *self.index_columns) + return self.__class__(tmp_data, *self.indices) def __len__(self): """the length of the data. @@ -221,6 +377,9 @@ class BaseData: else: raise ValueError(f"axis must be None, 0 or 1") + def isna(self): + return self.__class__(np.isnan(self.data), *self.indices) + def count(self): return len(self.data[~np.isnan(self.data)]) @@ -233,60 +392,37 @@ class BaseData: return self.data -class SingleData(BaseData): - def __init__(self, data: Union[int, float, np.number, list] = [], index: Union[list, pd.Index] = []): +class SingleData(IndexData): + def __init__(self, data: Union[int, float, np.number, list, dict, pd.Series] = [], index: Union[List, pd.Index, Index] = []): """A data structure of index and numpy data. It's used to replace pd.Series due to high-speed. Parameters ---------- - data : Union[int, float, np.floating, list, np.ndarray] - the dim of data must be 1. + data : Union[int, float, np.number, list, dict, pd.Series] + the input data index : Union[list, pd.Index] the index of data. + empty list indicates that auto filling the index to the length of data """ - # data - if isinstance(data, (int, float, np.floating)): - self.data = np.full(len(index), fill_value=data, dtype=np.float64) - elif isinstance(data, list): - self.data = np.array(data) - elif isinstance(data, np.ndarray): - self.data = data - else: - raise ValueError(f"data must be list or np.ndarray") - # data in SingleData must be one dim - assert self.data.ndim == 1 - # replace int with float - if self.data.dtype == np.signedinteger: - self.data = self.data.astype(np.float64) - # replace None with np.NaN, because pd.Series does it. - if None in self.data: - self.data[self.data == None] = np.NaN + # for special data type + if isinstance(data, dict): + assert len(index) == 0 + index, data = zip(*data.items()) + elif isinstance(data, pd.Series): + assert len(index) == 0 + index, data = data.index, data.values + super().__init__(data, index) + assert self.ndim == 1 - # index - if isinstance(index, list): - if index == [] and len(self.data) > 0: - index = list(range(len(self.data))) - self.index = index - elif isinstance(index, pd.Index): - self.index = list(index) - else: - raise ValueError(f"index must be list or pd.Index") - assert len(self.data) == len(self.index) - # if data is not empty, - self.index_map = dict(zip(self.index, range(len(self.index)))) - - super(SingleData, self).__init__() - - def _align_index(self, other): + def _align_indices(self, other): if self.index == other.index: return self, other elif set(self.index) == set(other.index): return self, other.reindex(self.index) else: raise ValueError( - f"The indexes of self and other do not meet the requirements of the four arithmetic operations" - ) + f"The indexes of self and other do not meet the requirements of the four arithmetic operations") def reindex(self, index, fill_value=np.NaN): """reindex data and fill the missing value with np.NaN. @@ -301,6 +437,7 @@ class SingleData(BaseData): SingleData reindex data """ + # TODO: This method can be more general if self.index == index: return self tmp_data = np.full(len(index), fill_value, dtype=np.float64) @@ -310,6 +447,7 @@ class SingleData(BaseData): return SingleData(tmp_data, index) def add(self, other, fill_value=0): + # TODO: add and __add__ are a little confusing. common_index = list(set(self.index) | set(other.index)) tmp_data1 = self.reindex(common_index, fill_value) tmp_data2 = other.reindex(common_index, fill_value) @@ -328,26 +466,15 @@ class SingleData(BaseData): def to_series(self): return pd.Series(self.data, index=self.index) - def __getitem__(self, index: Union["SingleData", int, str]): - if isinstance(index, int): - return self.data[index] - elif isinstance(index, str): - return self.data[self.index_map[index]] - elif isinstance(index, SingleData): - new_data = self.data[index.data] - new_index = list(np.array(self.index)[index.data]) - return SingleData(new_data, new_index) - else: - raise ValueError(f"index must be SingleData, int, str") + def __repr__(self) -> str: + return str(pd.Series(self.data, index=self.index)) -class MultiData(BaseData): - def __init__( - self, - data: Union[list, np.ndarray] = [[]], - index: Union[list, pd.Index] = [], - columns: Union[list, pd.Index] = [], - ): +class MultiData(IndexData): + def __init__(self, + data: Union[int, float, np.number, list] = [], + index: Union[List, pd.Index, Index] = [], + columns: Union[List, pd.Index, Index] = []): """A data structure of index and numpy data. It's used to replace pd.DataFrame due to high-speed. @@ -355,73 +482,22 @@ class MultiData(BaseData): ---------- data : Union[list, np.ndarray] the dim of data must be 2. - index : Union[list, pd.Index] + index : Union[List, pd.Index, Index] the index of data. - columns: Union[list, pd.Index] + columns: Union[List, pd.Index, Index] the columns of data. """ - # data - if isinstance(data, list): - self.data = np.array(data) - elif isinstance(data, np.ndarray): - self.data = data - else: - raise ValueError(f"data must be list or np.ndarray") - # data in SingleData must be two dim - assert self.data.ndim == 2 - # replace int with float - if self.data.dtype == np.signedinteger: - self.data = self.data.astype(np.float64) - # replace None with np.NaN, because pd.DataFrame does it. - if None in self.data: - self.data[self.data == None] = np.NaN + if isinstance(data, pd.DataFrame): + index, columns, data = data.index, data.columns, data.values + super().__init__(data, index, columns) + assert self.ndim == 2 - # index - if isinstance(index, list): - if index == [] and self.data.shape[0] > 0: - index = list(range(self.data.shape[0])) - self.index = index - elif isinstance(index, pd.Index): - self.index = list(index) - else: - raise ValueError(f"index must be list or pd.Index") - assert self.data.shape[0] == len(self.index) - # if data is not empty, - self.index_map = dict(zip(self.index, range(len(self.index)))) - - # columns - if isinstance(columns, list): - if columns == [] and self.data.shape[1] > 0: - columns = list(range(self.data.shape[1])) - self.columns = columns - elif isinstance(columns, pd.Index): - self.columns = list(columns) - else: - raise ValueError(f"columns must be list or pd.Index") - assert self.data.shape[1] == len(self.columns) - # if data is not empty, - self.columns_map = dict(zip(self.columns, range(len(self.columns)))) - - super(MultiData, self).__init__() - - def _align_index(self, other): + def _align_indices(self, other): if self.index_columns == other.index_columns: return self, other else: raise ValueError( - f"The indexes of self and other do not meet the requirements of the four arithmetic operations" - ) + f"The indexes of self and other do not meet the requirements of the four arithmetic operations") - def __getitem__(self, col) -> SingleData: - if col not in self.columns: - return SingleData() - else: - return SingleData(self.data[:, self.columns_map[col]], self.index) - - def loc(self, start, end, col=None): - start_id = bisect.bisect_left(self.index, start) - end_id = bisect.bisect_right(self.index, end) - if col is None: - return MultiData(self.data[start_id:end_id], self.index[start_id:end_id], self.columns) - else: - return SingleData(self.data[start_id:end_id, self.columns_map[col]], self.index[start_id:end_id]) + def __repr__(self) -> str: + return str(pd.DataFrame(self.data, index=self.index, columns=self.columns)) diff --git a/qlib/utils/resam.py b/qlib/utils/resam.py index 9e9590e30..d67ed6421 100644 --- a/qlib/utils/resam.py +++ b/qlib/utils/resam.py @@ -296,5 +296,5 @@ def _ts_data_valid(ts_feature, last=False): raise TypeError(f"ts_feature should be pd.DataFrame/Series, not {type(ts_feature)}") -ts_data_last = partial(_ts_data_valid, last=False) -ts_data_first = partial(_ts_data_valid, last=True) +ts_data_last = partial(_ts_data_valid, last=True) +ts_data_first = partial(_ts_data_valid, last=False) diff --git a/tests/misc/test_index_data.py b/tests/misc/test_index_data.py new file mode 100644 index 000000000..af5b31132 --- /dev/null +++ b/tests/misc/test_index_data.py @@ -0,0 +1,87 @@ +import numpy as np +import pandas as pd + +import qlib.utils.index_data as idd + +import unittest + + +class IndexDataTest(unittest.TestCase): + def test_index_single_data(self): + # Auto broadcast for scalar + sd = idd.SingleData(0, index=["foo", "bar"]) + print(sd) + + # Support empty value + sd = idd.SingleData() + print(sd) + + # Bad case: the input is not aligned + with self.assertRaises(ValueError): + idd.SingleData(range(10), index=["foo", "bar"]) + + # test indexing + sd = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) + print(sd) + print(sd.iloc[1]) # get second row + + # Bad case: it is not in the index + with self.assertRaises(KeyError): + print(sd.loc[1]) + + print(sd.loc["foo"]) + + # Test slicing + print(sd.loc[:"bar"]) + + print(sd.iloc[:3]) + + def test_index_multi_data(self): + # Auto broadcast for scalar + sd = idd.MultiData(0, index=["foo", "bar"], columns=["f", "g"]) + print(sd) + + # Bad case: the input is not aligned + with self.assertRaises(ValueError): + idd.MultiData(range(10), index=["foo", "bar"], columns=["f", "g"]) + + # test indexing + sd = idd.MultiData(np.arange(4).reshape(2, 2), index=["foo", "bar"], columns=["f", "g"]) + print(sd) + print(sd.iloc[1]) # get second row + + # Bad case: it is not in the index + with self.assertRaises(KeyError): + print(sd.loc[1]) + + print(sd.loc["foo"]) + + # Test slicing + + print(sd.loc[:"foo"]) + + print(sd.loc[:, "g":]) + + def test_sorting(self): + sd = idd.MultiData(np.arange(4).reshape(2, 2), index=["foo", "bar"], columns=["f", "g"]) + print(sd) + sd.sort_index() + + print(sd) + print(sd.loc[:"c"]) + + def test_corner_cases(self): + sd = idd.MultiData([[1, 2], [3, np.NaN]], index=["foo", "bar"], columns=["f", "g"]) + print(sd) + + self.assertTrue(np.isnan(sd.loc["bar", "g"])) + + + # support slicing + print(sd.loc[~sd.loc[:, "g"].isna().data.astype(np.bool)]) + + + + +if __name__ == "__main__": + unittest.main() From 9a74471ab6ad685717f952b9aff323d6728d0e62 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 31 Aug 2021 08:44:56 +0000 Subject: [PATCH 176/187] Pass basic tests --- qlib/backtest/exchange.py | 4 +- qlib/backtest/high_performance_ds.py | 6 +- qlib/backtest/report.py | 3 +- qlib/utils/index_data.py | 172 +++++++++++++++++++-------- tests/misc/test_index_data.py | 14 ++- 5 files changed, 141 insertions(+), 58 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index c55513d8b..125f7daca 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -18,7 +18,7 @@ from ..config import C, REG_CN from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger from .order import Order, OrderDir, OrderHelper -from .high_performance_ds import PandasQuote, CN1minNumpyQuote +from .high_performance_ds import BaseQuote, PandasQuote, CN1minNumpyQuote class Exchange: @@ -185,7 +185,7 @@ class Exchange: # init quote by quote_df self.quote_cls = quote_cls - self.quote = self.quote_cls(self.quote_df) + self.quote: BaseQuote = self.quote_cls(self.quote_df) def get_quote_from_qlib(self): # get stock data from qlib diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index b94c6a279..74927e2be 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -40,7 +40,7 @@ class BaseQuote: end_time: Union[pd.Timestamp, str], field: Union[str], method: Union[str, Callable, None] = None, - ) -> Union[None, int, float, bool, "IndexData"]: + ) -> Union[None, int, float, bool, IndexData]: """get the specific field of stock data during start time and end_time, and apply method to the data. @@ -154,7 +154,7 @@ class CN1minNumpyQuote(BaseQuote): # this is a very special case. # skip aggregating function to speed-up the query calculation try: - self.data[stock_id].loc[start_time, field] + return self.data[stock_id].loc[start_time, field] except KeyError: return None else: @@ -598,7 +598,7 @@ class NumpyOrderIndicator(BaseOrderIndicator): if isinstance(metrics, str): metrics = [metrics] for metric in metrics: - tmp_metric = IndexData.SingleData() + tmp_metric = idd.SingleData() for indicator in indicators: tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) order_indicator.data[metric] = tmp_metric diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index 0dfc92582..d76ad07d1 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -395,8 +395,9 @@ class Indicator: # NOTE: there are some zeros in the trading price. These cases are known meaningless # for aligning the previous logic, remove it. # remove zero and negative values. - price_s = price_s[~(price_s < 1e-08)] + price_s = price_s.loc[(price_s > 1e-08).data.astype(np.bool)] # NOTE ~(price_s < 1e-08) is different from price_s >= 1e-8 + # ~(np.NaN < 1e-8) -> ~(False) -> True if agg == "vwap": volume_s = trade_exchange.get_volume(inst, trade_start_time, trade_end_time, method=None) diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 78cd32b50..505f0dd33 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -9,6 +9,7 @@ Motivation of index_data `index_data` try to behave like pandas (some API will be different because we try to be simpler and more intuitive) but don't compromize the performance. It provides the basic numpy data and simple indexing feature. If users call APIs which may compromize the performance, index_data will raise Errors. """ +from functools import partial from typing import Tuple, Union, Callable, List import bisect @@ -64,6 +65,7 @@ class Index: - duplicated index value is not well supported (only the first appearance will be considered) - The order of the index is not considered!!!! So the slicing will not behave like pandas when indexings are ordered """ + def __init__(self, idx_list: Union[List, pd.Index, "Index", int]): self.idx_list: np.ndarray = None # using array type for index list will make things easier if isinstance(idx_list, Index): @@ -83,15 +85,56 @@ class Index: def __getitem__(self, i: int): return self.idx_list[i] + def _convert_type(self, item): + """ + + After user creates indices with Type A, user may query data with other types with the same info. + This method try to make type conversion and make query sane rather than raising KeyError strictly + + Parameters + ---------- + item : + The item to query index + """ + + if self.idx_list.dtype.type is np.datetime64: + if isinstance(item, pd.Timestamp): + # This happens often when creating index based on pandas.DatetimeIndex and query with pd.Timestamp + return item.to_numpy() + return item + def index(self, item) -> int: """ Given the index value, get the integer index + Parameters + ---------- + item : + The item to query + + Returns + ------- + int: + The index of the item + + Raises + ------ + KeyError: + If the query item does not exist """ - return self.index_map[item] + try: + return self.index_map[self._convert_type(item)] + except IndexError: + raise KeyError(f"{item} can't be found in {self}") + + def __or__(self, other: "Index"): + idx = Index(idx_list=list(set(self.idx_list) | set(other.idx_list))) + return idx def __eq__(self, other: "Index"): # NOTE: np.nan is not supported in the index + if self.idx_list.shape != other.idx_list.shape: + return False return (self.idx_list == other.idx_list).all() def __len__(self): @@ -115,7 +158,6 @@ class Index: return idx, sorted_idx - class LocIndexer: """ `Indexer` will behave like the `LocIndexer` in Pandas @@ -124,6 +166,7 @@ class LocIndexer: So this class is designed in a read-only way to shared data for queries. Modifications will results in new Index. """ + def __init__(self, index_data: "IndexData", indices: List[Index], int_loc: bool = False): self._indices: List[Index] = indices self._bind_id = index_data # bind index data @@ -132,7 +175,7 @@ class LocIndexer: @staticmethod def proc_idx_l(indices: List[Union[List, pd.Index, Index]], data_shape: Tuple = None) -> List[Index]: - """ process the indices from user and output a list of `Index` """ + """process the indices from user and output a list of `Index`""" res = [] for i, idx in enumerate(indices): res.append(Index(data_shape[i] if len(idx) == 0 else idx)) @@ -178,7 +221,7 @@ class LocIndexer: # 1) convert slices to int loc if not isinstance(indexing, tuple): # NOTE: tuple is not supported for indexing - indexing = (indexing, ) + indexing = (indexing,) # TODO: create a subclass for single value query assert len(indexing) <= len(self._indices) @@ -199,29 +242,64 @@ class LocIndexer: else: _indexing = index.index(_indexing) else: + # Default to select all when user input is not given _indexing = slice(None) int_indexing.append(_indexing) # 2) select data and index new_data = self._bind_id.data[tuple(int_indexing)] + # return directly if it is scalar + if new_data.ndim == 0: + return new_data + # otherwise we go on to the index part new_indices = [idx[indexing] for idx, indexing in zip(self._indices, int_indexing)] # 3) squash dimensions - new_indices = [idx for idx in new_indices if isinstance(idx, np.ndarray) and idx.ndim > 0] # squash the zero dim indexing + new_indices = [ + idx for idx in new_indices if isinstance(idx, np.ndarray) and idx.ndim > 0 + ] # squash the zero dim indexing - if new_data.ndim == 0: - return new_data + if new_data.ndim == 1: + cls = SingleData + elif new_data.ndim == 2: + cls = MultiData else: - if new_data.ndim == 1: - cls = SingleData - elif new_data.ndim == 2: - cls = MultiData - else: - raise ValueError("Not supported") - return cls(new_data, *new_indices) + raise ValueError("Not supported") + return cls(new_data, *new_indices) -class IndexData: +class BinaryOps: + def __init__(self, method_name): + self.method_name = method_name + + def __get__(self, obj, *args): + # bind object + self.obj = obj + return self + + def __call__(self, other): + self_data_method = getattr(self.obj.data, self.method_name) + + if isinstance(other, (int, float, np.number)): + return self.obj.__class__(self_data_method(other)) + elif isinstance(other, self.obj.__class__): + # TODO: bad interface + tmp_data1, tmp_data2 = self.obj._align_indices(other) + return self.obj.__class__(self_data_method(tmp_data2.data), *self.obj.indices) + else: + return NotImplemented + + +def index_data_ops_creator(*args, **kwargs): + """ + meta class for auto generating operations for index data. + """ + for method_name in ["__add__", "__sub__", "__rsub__", "__mul__", "__truediv__", "__eq__", "__gt__", "__lt__"]: + args[2][method_name] = BinaryOps(method_name=method_name) + return type(*args) + + +class IndexData(metaclass=index_data_ops_creator): """ Base data structure of SingleData and MultiData. @@ -238,6 +316,7 @@ class IndexData: """ loc_idx_cls = LocIndexer + def __init__(self, data: np.ndarray, *indices: Union[List, pd.Index, Index]): self.data = data @@ -299,28 +378,6 @@ class IndexData: self.indices[axis], sorted_idx = self.indices[axis].sort() self.data = np.take(self.data, sorted_idx, axis=axis) - # calculation related methods - def __getattribute__(self, attr_name: str): - # 1) use a unified operation for the basic operation - - def _basic_binary_ops(other): - self_data_method = getattr(self.data, attr_name) - - if isinstance(other, (int, float, np.number)): - return self.__class__(self_data_method(other)) - elif isinstance(other, self.__class__): - # TODO: bad interface - tmp_data1, tmp_data2 = self._align_indices(other) - return self.__class__(self_data_method(tmp_data2.data), *self.indices) - else: - return NotImplemented - - if attr_name in {"__add__", "__sub__", "__rsub__", "__mul__", "__truediv__", "__eq__", "__gt__", "__lt__"}: - return _basic_binary_ops - - # 2) otherwise, follow the default behavior - return super().__getattribute__(attr_name) - # The code below could be simpler like methods in __getattribute__ def __invert__(self): return self.__class__(~self.data.astype(np.bool), *self.indices) @@ -393,7 +450,9 @@ class IndexData: class SingleData(IndexData): - def __init__(self, data: Union[int, float, np.number, list, dict, pd.Series] = [], index: Union[List, pd.Index, Index] = []): + def __init__( + self, data: Union[int, float, np.number, list, dict, pd.Series] = [], index: Union[List, pd.Index, Index] = [] + ): """A data structure of index and numpy data. It's used to replace pd.Series due to high-speed. @@ -408,7 +467,10 @@ class SingleData(IndexData): # for special data type if isinstance(data, dict): assert len(index) == 0 - index, data = zip(*data.items()) + if len(data) > 0: + index, data = zip(*data.items()) + else: + index, data = [], [] elif isinstance(data, pd.Series): assert len(index) == 0 index, data = data.index, data.values @@ -422,9 +484,10 @@ class SingleData(IndexData): return self, other.reindex(self.index) else: raise ValueError( - f"The indexes of self and other do not meet the requirements of the four arithmetic operations") + f"The indexes of self and other do not meet the requirements of the four arithmetic operations" + ) - def reindex(self, index, fill_value=np.NaN): + def reindex(self, index: Index, fill_value=np.NaN): """reindex data and fill the missing value with np.NaN. Parameters @@ -442,13 +505,17 @@ class SingleData(IndexData): return self tmp_data = np.full(len(index), fill_value, dtype=np.float64) for index_id, index_item in enumerate(index): - if index_item in self.index: - tmp_data[index_id] = self.data[self.index_map[index_item]] + try: + tmp_data[index_id] = self.loc[index_item] + except KeyError: + pass return SingleData(tmp_data, index) - def add(self, other, fill_value=0): + def add(self, other: "SingleData", fill_value=0): # TODO: add and __add__ are a little confusing. - common_index = list(set(self.index) | set(other.index)) + # This could be a more general + common_index = self.index | other.index + common_index, _ = common_index.sort() tmp_data1 = self.reindex(common_index, fill_value) tmp_data2 = other.reindex(common_index, fill_value) return tmp_data1 + tmp_data2 @@ -471,10 +538,12 @@ class SingleData(IndexData): class MultiData(IndexData): - def __init__(self, - data: Union[int, float, np.number, list] = [], - index: Union[List, pd.Index, Index] = [], - columns: Union[List, pd.Index, Index] = []): + def __init__( + self, + data: Union[int, float, np.number, list] = [], + index: Union[List, pd.Index, Index] = [], + columns: Union[List, pd.Index, Index] = [], + ): """A data structure of index and numpy data. It's used to replace pd.DataFrame due to high-speed. @@ -493,11 +562,12 @@ class MultiData(IndexData): assert self.ndim == 2 def _align_indices(self, other): - if self.index_columns == other.index_columns: + if self.indices == other.indices: return self, other else: raise ValueError( - f"The indexes of self and other do not meet the requirements of the four arithmetic operations") + f"The indexes of self and other do not meet the requirements of the four arithmetic operations" + ) def __repr__(self) -> str: return str(pd.DataFrame(self.data, index=self.index, columns=self.columns)) diff --git a/tests/misc/test_index_data.py b/tests/misc/test_index_data.py index af5b31132..caa9b1897 100644 --- a/tests/misc/test_index_data.py +++ b/tests/misc/test_index_data.py @@ -76,11 +76,23 @@ class IndexDataTest(unittest.TestCase): self.assertTrue(np.isnan(sd.loc["bar", "g"])) - # support slicing print(sd.loc[~sd.loc[:, "g"].isna().data.astype(np.bool)]) + print(self.assertTrue(idd.SingleData().index == idd.SingleData().index)) + # empty dict + print(idd.SingleData({})) + print(idd.SingleData(pd.Series())) + + sd = idd.SingleData() + with self.assertRaises(KeyError): + sd.loc["foo"] + + def test_ops(self): + sd1 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) + sd2 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) + print(sd1 + sd2) if __name__ == "__main__": From 5f0ee6ce68671994f9169df7f0f75bc062bdf3c5 Mon Sep 17 00:00:00 2001 From: Young Date: Tue, 31 Aug 2021 09:52:32 +0000 Subject: [PATCH 177/187] fix bugs --- qlib/backtest/high_performance_ds.py | 3 +++ qlib/utils/index_data.py | 39 ++++++++++++++++++---------- tests/misc/test_index_data.py | 6 +++++ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 74927e2be..bb75ca8f6 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -167,11 +167,14 @@ class CN1minNumpyQuote(BaseQuote): def _agg_data(self, data: IndexData, method): """Agg data by specific method.""" + # FIXME: why not call the method of data directly? if method == "sum": return np.nansum(data) elif method == "mean": return np.nanmean(data) elif method == "last": + # FIXME: I've never seen that this method was called. + # Please merge it with "ts_data_last" return data[-1] elif method == "all": return data.all() diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 505f0dd33..c8d6bebee 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -10,7 +10,7 @@ Motivation of index_data """ from functools import partial -from typing import Tuple, Union, Callable, List +from typing import Dict, Tuple, Union, Callable, List import bisect import numpy as np @@ -128,8 +128,7 @@ class Index: raise KeyError(f"{item} can't be found in {self}") def __or__(self, other: "Index"): - idx = Index(idx_list=list(set(self.idx_list) | set(other.idx_list))) - return idx + return Index(idx_list=list(set(self.idx_list) | set(other.idx_list))) def __eq__(self, other: "Index"): # NOTE: np.nan is not supported in the index @@ -283,9 +282,8 @@ class BinaryOps: if isinstance(other, (int, float, np.number)): return self.obj.__class__(self_data_method(other)) elif isinstance(other, self.obj.__class__): - # TODO: bad interface - tmp_data1, tmp_data2 = self.obj._align_indices(other) - return self.obj.__class__(self_data_method(tmp_data2.data), *self.obj.indices) + other_aligned = self.obj._align_indices(other) + return self.obj.__class__(self_data_method(other_aligned.data), *self.obj.indices) else: return NotImplemented @@ -369,8 +367,21 @@ class IndexData(metaclass=index_data_ops_creator): def columns(self): return self.indices[1] - def _align_indices(self, other): - """Align index before performing the four arithmetic operations.""" + def _align_indices(self, other: "IndexData") -> "IndexData": + """ + Align all indices of `other` to `self` before performing the arithmetic operations. + This function will return a new IndexData rather than changing data in `other` inplace + + Parameters + ---------- + other : "IndexData" + the index in `other` is to be chagned + + Returns + ------- + IndexData: + the data in `other` with index aligned to `self` + """ raise NotImplementedError(f"please implement _align_indices func") def sort_index(self, axis=0, inplace=True): @@ -387,12 +398,12 @@ class IndexData(metaclass=index_data_ops_creator): tmp_data = np.absolute(self.data) return self.__class__(tmp_data, *self.indices) - def replace(self, to_replace: dict): + def replace(self, to_replace: Dict[np.number, np.number]): assert isinstance(to_replace, dict) tmp_data = self.data.copy() for num in to_replace: if num in tmp_data: - tmp_data[tmp_data == num] = to_replace[num] + tmp_data[self.data == num] = to_replace[num] return self.__class__(tmp_data, *self.indices) def apply(self, func: Callable): @@ -411,6 +422,7 @@ class IndexData(metaclass=index_data_ops_creator): return len(self.data) def sum(self, axis=None): + # FIXME: weird logic and not general if axis is None: return np.nansum(self.data) elif axis == 0: @@ -423,6 +435,7 @@ class IndexData(metaclass=index_data_ops_creator): raise ValueError(f"axis must be None, 0 or 1") def mean(self, axis=None): + # FIXME: weird logic and not general if axis is None: return np.nanmean(self.data) elif axis == 0: @@ -479,9 +492,9 @@ class SingleData(IndexData): def _align_indices(self, other): if self.index == other.index: - return self, other + return other elif set(self.index) == set(other.index): - return self, other.reindex(self.index) + return other.reindex(self.index) else: raise ValueError( f"The indexes of self and other do not meet the requirements of the four arithmetic operations" @@ -563,7 +576,7 @@ class MultiData(IndexData): def _align_indices(self, other): if self.indices == other.indices: - return self, other + return other else: raise ValueError( f"The indexes of self and other do not meet the requirements of the four arithmetic operations" diff --git a/tests/misc/test_index_data.py b/tests/misc/test_index_data.py index caa9b1897..c7a80fb0f 100644 --- a/tests/misc/test_index_data.py +++ b/tests/misc/test_index_data.py @@ -89,6 +89,12 @@ class IndexDataTest(unittest.TestCase): with self.assertRaises(KeyError): sd.loc["foo"] + # replace + sd = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) + sd = sd.replace(dict(zip(range(1, 5), range(2, 6)))) + print(sd) + self.assertTrue(sd.iloc[0] == 2) + def test_ops(self): sd1 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) sd2 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) From 5003e491974fc6aac89fe7d6b77ec4f0d684b549 Mon Sep 17 00:00:00 2001 From: Young Date: Wed, 1 Sep 2021 00:24:50 +0000 Subject: [PATCH 178/187] fix metric calculation error --- qlib/backtest/high_performance_ds.py | 9 +++++++++ qlib/backtest/report.py | 6 +++--- qlib/utils/index_data.py | 12 ++++++++++-- tests/misc/test_index_data.py | 13 +++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index bb75ca8f6..b185f0d51 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -528,6 +528,9 @@ class PandasSingleMetric(SingleMetric): def reindex(self, index, fill_value): return self.__class__(self.metric.reindex(index, fill_value=fill_value)) + def __repr__(self): + return repr(self.metric) + class PandasOrderIndicator(BaseOrderIndicator): """ @@ -567,6 +570,9 @@ class PandasOrderIndicator(BaseOrderIndicator): tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) order_indicator.assign(metric, tmp_metric.metric) + def __repr__(self): + return repr(self.data) + class NumpyOrderIndicator(BaseOrderIndicator): """ @@ -605,3 +611,6 @@ class NumpyOrderIndicator(BaseOrderIndicator): for indicator in indicators: tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) order_indicator.data[metric] = tmp_metric + + def __repr__(self): + return repr(self.data) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index d76ad07d1..b4b9c5f2e 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -11,7 +11,7 @@ import pandas as pd from qlib.backtest.exchange import Exchange from qlib.backtest.order import BaseTradeDecision, Order, OrderDir -from .high_performance_ds import PandasOrderIndicator, NumpyOrderIndicator, SingleMetric +from .high_performance_ds import BaseOrderIndicator, PandasOrderIndicator, NumpyOrderIndicator, SingleMetric from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data from .order import IdxTradeRange @@ -255,7 +255,7 @@ class Indicator: # order indicator is metrics for a single order for a specific step self.order_indicator_his = OrderedDict() - self.order_indicator = self.order_indicator_cls() + self.order_indicator: BaseOrderIndicator = self.order_indicator_cls() # trade indicator is metrics for all orders for a specific step self.trade_indicator_his = OrderedDict() @@ -265,7 +265,7 @@ class Indicator: # def reset(self, trade_calendar: TradeCalendarManager): def reset(self): - self.order_indicator = self.order_indicator_cls() + self.order_indicator: BaseOrderIndicator = self.order_indicator_cls() self.trade_indicator = OrderedDict() # self._trade_calendar = trade_calendar diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index c8d6bebee..79e2f08e3 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -280,7 +280,7 @@ class BinaryOps: self_data_method = getattr(self.obj.data, self.method_name) if isinstance(other, (int, float, np.number)): - return self.obj.__class__(self_data_method(other)) + return self.obj.__class__(self_data_method(other), *self.obj.indices) elif isinstance(other, self.obj.__class__): other_aligned = self.obj._align_indices(other) return self.obj.__class__(self_data_method(other_aligned.data), *self.obj.indices) @@ -450,6 +450,12 @@ class IndexData(metaclass=index_data_ops_creator): def isna(self): return self.__class__(np.isnan(self.data), *self.indices) + def fillna(self, value=0.0, inplace: bool = False): + if inplace: + self.data = np.nan_to_num(self.data, nan=value) + else: + return self.__class__(np.nan_to_num(self.data, nan=value), *self.indices) + def count(self): return len(self.data[~np.isnan(self.data)]) @@ -507,6 +513,8 @@ class SingleData(IndexData): ---------- new_index : list new index + fill_value: + what value to fill if index is missing Returns ------- @@ -531,7 +539,7 @@ class SingleData(IndexData): common_index, _ = common_index.sort() tmp_data1 = self.reindex(common_index, fill_value) tmp_data2 = other.reindex(common_index, fill_value) - return tmp_data1 + tmp_data2 + return tmp_data1.fillna(fill_value) + tmp_data2.fillna(fill_value) def to_dict(self): """convert SingleData to dict. diff --git a/tests/misc/test_index_data.py b/tests/misc/test_index_data.py index c7a80fb0f..010b32847 100644 --- a/tests/misc/test_index_data.py +++ b/tests/misc/test_index_data.py @@ -99,6 +99,19 @@ class IndexDataTest(unittest.TestCase): sd1 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) sd2 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) print(sd1 + sd2) + new_sd = sd2 * 2 + self.assertTrue(new_sd.index == sd2.index) + + sd1 = idd.SingleData([1, 2, None, 4], index=["foo", "bar", "f", "g"]) + sd2 = idd.SingleData([1, 2, 3, None], index=["foo", "bar", "f", "g"]) + self.assertTrue(np.isnan((sd1 + sd2).iloc[3])) + self.assertTrue(sd1.add(sd2).sum() == 13) + + def test_todo(self): + pass + # here are some examples which do not affect the current system, but it is weird not to support it + # sd2 = idd.SingleData([1, 2, 3, 4], index=["foo", "bar", "f", "g"]) + # 2 * sd2 if __name__ == "__main__": From 94461166425bf71ddd14d75a388ba37c3355f217 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 1 Sep 2021 07:38:45 +0000 Subject: [PATCH 179/187] redundant references --- qlib/utils/index_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 79e2f08e3..e4fc026d7 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -9,7 +9,6 @@ Motivation of index_data `index_data` try to behave like pandas (some API will be different because we try to be simpler and more intuitive) but don't compromize the performance. It provides the basic numpy data and simple indexing feature. If users call APIs which may compromize the performance, index_data will raise Errors. """ -from functools import partial from typing import Dict, Tuple, Union, Callable, List import bisect From 4da3f3b104dd36b1a73de77e93fa0d15ecc237dd Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 1 Sep 2021 13:17:55 +0000 Subject: [PATCH 180/187] broadcast_to and get single data --- qlib/backtest/high_performance_ds.py | 2 +- qlib/utils/index_data.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index b185f0d51..86d631bfa 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -150,7 +150,7 @@ class CN1minNumpyQuote(BaseQuote): # single data # If it don't consider the classification of single data, it will consume a lot of time. - if is_single_value(start_time, end_time, self.freq) and method is not None: + if is_single_value(start_time, end_time, self.freq): # this is a very special case. # skip aggregating function to speed-up the query calculation try: diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index e4fc026d7..5594a27f9 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -335,7 +335,8 @@ class IndexData(metaclass=index_data_ops_creator): data_shape = tuple(data_shape) # broadcast the data to expected shape - self.data = np.broadcast_to(self.data, data_shape) + if self.data.shape != data_shape: + self.data = np.broadcast_to(self.data, data_shape) self.data = self.data.astype(np.float64) # Please notice following cases when converting the type @@ -492,6 +493,8 @@ class SingleData(IndexData): elif isinstance(data, pd.Series): assert len(index) == 0 index, data = data.index, data.values + elif isinstance(data, (int, float, np.number)): + data = [data] super().__init__(data, index) assert self.ndim == 1 From 919380597b2a295e233828c175190c30cbd9e531 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 1 Sep 2021 14:48:23 +0000 Subject: [PATCH 181/187] close and reindex --- qlib/backtest/exchange.py | 2 +- qlib/utils/index_data.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 125f7daca..8b539de8b 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -424,7 +424,7 @@ class Exchange: else: raise NotImplementedError(f"This type of input is not supported") deal_price = self.quote.get_data(stock_id, start_time, end_time, field=pstr, method=method) - if method is not None and (deal_price is None or np.isclose(deal_price, 0.0) or np.isnan(deal_price)): + if method is not None and (deal_price is None or np.isnan(deal_price) or deal_price <= 1e-08): self.logger.warning(f"(stock_id:{stock_id}, trade_time:{(start_time, end_time)}, {pstr}): {deal_price}!!!") self.logger.warning(f"setting deal_price to close price") deal_price = self.get_close(stock_id, start_time, end_time, method) diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 5594a27f9..52cc385e0 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -529,7 +529,8 @@ class SingleData(IndexData): tmp_data = np.full(len(index), fill_value, dtype=np.float64) for index_id, index_item in enumerate(index): try: - tmp_data[index_id] = self.loc[index_item] + item_data = self.loc[index_item] + tmp_data[index_id] = item_data if item_data != np.NaN else fill_value except KeyError: pass return SingleData(tmp_data, index) @@ -541,7 +542,7 @@ class SingleData(IndexData): common_index, _ = common_index.sort() tmp_data1 = self.reindex(common_index, fill_value) tmp_data2 = other.reindex(common_index, fill_value) - return tmp_data1.fillna(fill_value) + tmp_data2.fillna(fill_value) + return tmp_data1 + tmp_data2 def to_dict(self): """convert SingleData to dict. From f71b0c11894c19bfad6026efdc6c39cc8b746a98 Mon Sep 17 00:00:00 2001 From: "wangwenxi.handsome" Date: Wed, 1 Sep 2021 16:20:52 +0000 Subject: [PATCH 182/187] 250s --- qlib/backtest/high_performance_ds.py | 16 ++++++++--- qlib/utils/index_data.py | 41 +++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 86d631bfa..97310ffb6 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -604,13 +604,21 @@ class NumpyOrderIndicator(BaseOrderIndicator): @staticmethod def sum_all_indicators(order_indicator, indicators: list, metrics: Union[str, List[str]], fill_value=0): + # get all index(stock_id) + stocks = set() + for indicator in indicators: + # set(np.ndarray.tolist()) is faster than set(np.ndarray) + stocks = stocks | set(indicator.data[metrics[0]].index.tolist()) + stocks = list(stocks) + stocks.sort() + + # add metric by index if isinstance(metrics, str): metrics = [metrics] for metric in metrics: - tmp_metric = idd.SingleData() - for indicator in indicators: - tmp_metric = tmp_metric.add(indicator.data[metric], fill_value) - order_indicator.data[metric] = tmp_metric + order_indicator.data[metric] = idd.sum_by_index( + [indicator.data[metric] for indicator in indicators], stocks, fill_value + ) def __repr__(self): return repr(self.data) diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 52cc385e0..9bd059add 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -22,7 +22,7 @@ def concat(data_list: Union["SingleData"], axis=0) -> "MultiData": Parameters ---------- - index_data_list : List[SingleData] + data_list : List[SingleData] the list of all SingleData to concat. Returns @@ -52,6 +52,36 @@ def concat(data_list: Union["SingleData"], axis=0) -> "MultiData": raise ValueError(f"axis must be 0 or 1") +def sum_by_index(data_list: Union["SingleData"], new_index: list, fill_value=0) -> "SingleData": + """concat all SingleData by new index. + + Parameters + ---------- + data_list : List[SingleData] + the list of all SingleData to sum. + new_index : list + the new_index of new SingleData. + fill_value : float + fill the missing values ​​or replace np.NaN. + + Returns + ------- + SingleData + the SingleData with new_index and values after sum. + """ + data_list = [data.to_dict() for data in data_list] + data_sum = {} + for id in new_index: + item_sum = 0 + for data in data_list: + if id in data and data[id] != np.NaN: + item_sum += data[id] + else: + item_sum += fill_value + data_sum[id] = item_sum + return SingleData(data_sum) + + class Index: """ This is for indexing(rows or columns) @@ -155,6 +185,10 @@ class Index: idx._is_sorted = True return idx, sorted_idx + def tolist(self): + """return the index with the format of list.""" + return self.idx_list.tolist() + class LocIndexer: """ @@ -529,8 +563,7 @@ class SingleData(IndexData): tmp_data = np.full(len(index), fill_value, dtype=np.float64) for index_id, index_item in enumerate(index): try: - item_data = self.loc[index_item] - tmp_data[index_id] = item_data if item_data != np.NaN else fill_value + tmp_data[index_id] = self.loc[index_item] except KeyError: pass return SingleData(tmp_data, index) @@ -542,7 +575,7 @@ class SingleData(IndexData): common_index, _ = common_index.sort() tmp_data1 = self.reindex(common_index, fill_value) tmp_data2 = other.reindex(common_index, fill_value) - return tmp_data1 + tmp_data2 + return tmp_data1.fillna(fill_value) + tmp_data2.fillna(fill_value) def to_dict(self): """convert SingleData to dict. From 88d2f9263e26fdb9d32761a9cfaf285c696a9c7d Mon Sep 17 00:00:00 2001 From: Young Date: Thu, 2 Sep 2021 00:45:04 +0000 Subject: [PATCH 183/187] fix sum index data bug --- qlib/backtest/order.py | 2 +- qlib/utils/index_data.py | 2 +- tests/misc/test_index_data.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/qlib/backtest/order.py b/qlib/backtest/order.py index e169ffd64..a1b21be0a 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/order.py @@ -492,7 +492,7 @@ class BaseTradeDecision: for obj in self.get_decision(): if isinstance(obj, Order): # Zero amount order will be treated as empty - if not np.isclose(obj.amount, 0.0): + if obj.amount > 1e-6: return False else: return True diff --git a/qlib/utils/index_data.py b/qlib/utils/index_data.py index 9bd059add..09ddbf471 100644 --- a/qlib/utils/index_data.py +++ b/qlib/utils/index_data.py @@ -74,7 +74,7 @@ def sum_by_index(data_list: Union["SingleData"], new_index: list, fill_value=0) for id in new_index: item_sum = 0 for data in data_list: - if id in data and data[id] != np.NaN: + if id in data and not np.isnan(data[id]): item_sum += data[id] else: item_sum += fill_value diff --git a/tests/misc/test_index_data.py b/tests/misc/test_index_data.py index 010b32847..3cd819a0f 100644 --- a/tests/misc/test_index_data.py +++ b/tests/misc/test_index_data.py @@ -107,6 +107,8 @@ class IndexDataTest(unittest.TestCase): self.assertTrue(np.isnan((sd1 + sd2).iloc[3])) self.assertTrue(sd1.add(sd2).sum() == 13) + self.assertTrue(idd.sum_by_index([sd1, sd2], sd1.index, fill_value=0.0).sum() == 13) + def test_todo(self): pass # here are some examples which do not affect the current system, but it is weird not to support it From 6203e4c09eefb9db8ae790f42eaa9d96009690de Mon Sep 17 00:00:00 2001 From: you-n-g Date: Mon, 13 Sep 2021 17:53:34 +0800 Subject: [PATCH 184/187] Update the docs of Report --- qlib/backtest/report.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index b4b9c5f2e..a364b10db 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -25,7 +25,15 @@ class Report: Implementation: daily report of the account - contain those followings: returns, costs turnovers, accounts, cash, bench, value + contain those followings: return, cost, turnover, account, cash, bench, value + For each step(bar/day/minute), each column represents + - return: the return of the portfolio generated by strategy **without transaction fee**. + - cost: the transaction fee and slippage. + - account: the total value of assets(cash and securities are both included) in user account based on the close price of each step. + - cash: the amount of cash in user's account. + - bench: the return of the benchmark + - value: the total value of securities/stocks/instruments (cash is excluded). + update report """ From 163e3c6266009dcb7fdc3bdfb2dbfcdecac6a0af Mon Sep 17 00:00:00 2001 From: you-n-g Date: Tue, 14 Sep 2021 01:16:03 +0800 Subject: [PATCH 185/187] replace multi processing with joblib (#477) * replace multi processing with joblib * update class Parallel and data.py * update class Parallel and data.py * update class Parallel and data.py * update class Parallel and data.py * update class Parallel and data.py * update class Parallel and data.py * update class Parallel and data.py * update class Parallel and data.py * Fix Parallel support for maxtasksperchild Co-authored-by: wangw <1666490690@qq.com> Co-authored-by: zhupr --- qlib/config.py | 2 + qlib/data/data.py | 127 ++++++++---------------------- qlib/utils/paral.py | 13 ++- tests/misc/test_get_multi_proc.py | 39 +++++++++ 4 files changed, 86 insertions(+), 95 deletions(-) create mode 100644 tests/misc/test_get_multi_proc.py diff --git a/qlib/config.py b/qlib/config.py index 0478b7659..796cc5ca6 100644 --- a/qlib/config.py +++ b/qlib/config.py @@ -92,6 +92,8 @@ _default_config = { "kernels": NUM_USABLE_CPU, # How many tasks belong to one process. Recommend 1 for high-frequency data and None for daily data. "maxtasksperchild": None, + # If joblib_backend is None, use loky + "joblib_backend": "multiprocessing", "default_disk_cache": 1, # 0:skip/1:use "mem_cache_size_limit": 500, # memory cache expire second, only in used 'DatasetURICache' and 'client D.calendar' diff --git a/qlib/data/data.py b/qlib/data/data.py index ccd35006b..1d5180735 100644 --- a/qlib/data/data.py +++ b/qlib/data/data.py @@ -9,16 +9,15 @@ import os import re import abc import copy -import time import queue import bisect -import logging -import importlib -import traceback from typing import List, Union + import numpy as np import pandas as pd -from multiprocessing import Pool + +# For supporting multiprocessing in outter code, joblib is used +from joblib import delayed from .cache import H from ..config import C @@ -29,6 +28,7 @@ from .base import Feature from .cache import DiskDatasetCache, DiskExpressionCache from ..utils import Wrapper, init_instance_by_config, register_wrapper, get_module_by_module_path from ..utils.resam import resam_calendar +from ..utils.paral import ParallelExt class ProviderBackendMixin: @@ -418,16 +418,7 @@ class DatasetProvider(abc.ABC): """ raise NotImplementedError("Subclass of DatasetProvider must implement `Dataset` method") - def _uri( - self, - instruments, - fields, - start_time=None, - end_time=None, - freq="day", - disk_cache=1, - **kwargs, - ): + def _uri(self, instruments, fields, start_time=None, end_time=None, freq="day", disk_cache=1, **kwargs): """Get task uri, used when generating rabbitmq task in qlib_server Parameters @@ -494,51 +485,37 @@ class DatasetProvider(abc.ABC): """ normalize_column_names = normalize_cache_fields(column_names) - data = dict() # One process for one task, so that the memory will be freed quicker. workers = max(min(C.kernels, len(instruments_d)), 1) - if C.maxtasksperchild is None: - p = Pool(processes=workers) - else: - p = Pool(processes=workers, maxtasksperchild=C.maxtasksperchild) + # create iterator if isinstance(instruments_d, dict): - for inst, spans in instruments_d.items(): - data[inst] = p.apply_async( - DatasetProvider.expression_calculator, - args=( - inst, - start_time, - end_time, - freq, - normalize_column_names, - spans, - C, - ), - ) + it = instruments_d.items() else: - for inst in instruments_d: - data[inst] = p.apply_async( - DatasetProvider.expression_calculator, - args=( - inst, - start_time, - end_time, - freq, - normalize_column_names, - None, - C, - ), - ) + it = zip(instruments_d, [None] * len(instruments_d)) - p.close() - p.join() + inst_l = [] + task_l = [] + for inst, spans in it: + inst_l.append(inst) + task_l.append( + delayed(DatasetProvider.expression_calculator)( + inst, start_time, end_time, freq, normalize_column_names, spans, C + ) + ) + + data = dict( + zip( + inst_l, + ParallelExt(n_jobs=workers, backend=C.joblib_backend, maxtasksperchild=C.maxtasksperchild)(task_l), + ) + ) new_data = dict() for inst in sorted(data.keys()): - if len(data[inst].get()) > 0: + if len(data[inst]) > 0: # NOTE: Python version >= 3.6; in versions after python3.6, dict will always guarantee the insertion order - new_data[inst] = data[inst].get() + new_data[inst] = data[inst] if len(new_data) > 0: data = pd.concat(new_data, names=["instrument"], sort=False) @@ -755,25 +732,11 @@ class LocalDatasetProvider(DatasetProvider): start_time = cal[0] end_time = cal[-1] workers = max(min(C.kernels, len(instruments_d)), 1) - if C.maxtasksperchild is None: - p = Pool(processes=workers) - else: - p = Pool(processes=workers, maxtasksperchild=C.maxtasksperchild) - for inst in instruments_d: - p.apply_async( - LocalDatasetProvider.cache_walker, - args=( - inst, - start_time, - end_time, - freq, - column_names, - ), - ) - - p.close() - p.join() + ParallelExt(n_jobs=workers, backend=C.joblib_backend, maxtasksperchild=C.maxtasksperchild)( + delayed(LocalDatasetProvider.cache_walker)(inst, start_time, end_time, freq, column_names) + for inst in instruments_d + ) @staticmethod def cache_walker(inst, start_time, end_time, freq, column_names): @@ -803,12 +766,7 @@ class ClientCalendarProvider(CalendarProvider): self.conn.send_request( request_type="calendar", - request_content={ - "start_time": str(start_time), - "end_time": str(end_time), - "freq": freq, - "future": future, - }, + request_content={"start_time": str(start_time), "end_time": str(end_time), "freq": freq, "future": future}, msg_queue=self.queue, msg_proc_func=lambda response_content: [pd.Timestamp(c) for c in response_content], ) @@ -871,16 +829,7 @@ class ClientDatasetProvider(DatasetProvider): self.conn = conn self.queue = queue.Queue() - def dataset( - self, - instruments, - fields, - start_time=None, - end_time=None, - freq="day", - disk_cache=0, - return_uri=False, - ): + def dataset(self, instruments, fields, start_time=None, end_time=None, freq="day", disk_cache=0, return_uri=False): if Inst.get_inst_type(instruments) == Inst.DICT: get_module_logger("data").warning( "Getting features from a dict of instruments is not recommended because the features will not be " @@ -984,15 +933,7 @@ class BaseProvider: def list_instruments(self, instruments, start_time=None, end_time=None, freq="day", as_list=False): return Inst.list_instruments(instruments, start_time, end_time, freq, as_list) - def features( - self, - instruments, - fields, - start_time=None, - end_time=None, - freq="day", - disk_cache=None, - ): + def features(self, instruments, fields, start_time=None, end_time=None, freq="day", disk_cache=None): """ Parameters: ----------- diff --git a/qlib/utils/paral.py b/qlib/utils/paral.py index a640b04ea..075a1adb8 100644 --- a/qlib/utils/paral.py +++ b/qlib/utils/paral.py @@ -1,8 +1,17 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from joblib import Parallel, delayed import pandas as pd +from joblib import Parallel, delayed +from joblib._parallel_backends import MultiprocessingBackend + + +class ParallelExt(Parallel): + def __init__(self, *args, **kwargs): + maxtasksperchild = kwargs.pop("maxtasksperchild", None) + super(ParallelExt, self).__init__(*args, **kwargs) + if isinstance(self._backend, MultiprocessingBackend): + self._backend_args["maxtasksperchild"] = maxtasksperchild def datetime_groupby_apply(df, apply_func, axis=0, level="datetime", resample_rule="M", n_jobs=-1, skip_group=False): @@ -31,7 +40,7 @@ def datetime_groupby_apply(df, apply_func, axis=0, level="datetime", resample_ru return df.groupby(axis=axis, level=level).apply(apply_func) if n_jobs != 1: - dfs = Parallel(n_jobs=n_jobs)( + dfs = ParallelExt(n_jobs=n_jobs)( delayed(_naive_group_apply)(sub_df) for idx, sub_df in df.resample(resample_rule, axis=axis, level=level) ) return pd.concat(dfs, axis=axis).sort_index() diff --git a/tests/misc/test_get_multi_proc.py b/tests/misc/test_get_multi_proc.py new file mode 100644 index 000000000..7e27781b6 --- /dev/null +++ b/tests/misc/test_get_multi_proc.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest + +import qlib +from qlib.data import D +from qlib.tests import TestAutoData +from multiprocessing import Pool + + +def get_features(fields): + qlib.init(provider_uri=TestAutoData.provider_uri, expression_cache=None, dataset_cache=None, joblib_backend="loky") + return D.features(D.instruments("csi300"), fields) + + +class TestGetData(TestAutoData): + FIELDS = "$open,$close,$high,$low,$volume,$factor,$change".split(",") + + def test_multi_proc(self): + """ + For testing if it will raise error + """ + iter_n = 2 + pool = Pool(iter_n) + + res = [] + for _ in range(iter_n): + res.append(pool.apply_async(get_features, (self.FIELDS,), {})) + + for r in res: + print(r.get()) + + pool.close() + pool.join() + + +if __name__ == "__main__": + unittest.main() From 3760a18a8d41992279e30d850c77c638652d0507 Mon Sep 17 00:00:00 2001 From: wangwenxi-handsome <77676340+wangwenxi-handsome@users.noreply.github.com> Date: Fri, 1 Oct 2021 02:15:30 +0800 Subject: [PATCH 186/187] Merge nested main (#597) * MVP for Indian Stocks in qlib using yahooquery * cleaned with black * cleaned with black * add YahooNormalizeIN and YahooNormalizeIN1d * cleaned the code * added 1min for IN and also updated readme * update comments * fix comments * recorder support upload both raw file and directory * fix comments * Update README.md * Fix docs of QlibRecorder * sort index after loader (#538) make sure the fetch method is based on a index-sorted pd.DataFrame * refactor online serving rolling api * refactor TRA * format by black * fix horizon * fix TRA when use single head * clean up * improve pretrain * update README * fix tra when logdir is None * fix tra when logdir is None * Update strategy.py * Update README.md * Update README.md * Conda Suggestion * code standard docs * Update ensemble.py (#560) * Fix CI Bug (#575) Co-authored-by: yuxwang * Update gen.py (#576) * Fix multi-process loop calls (#574) * check lexsort in the 'lazy_sort_index' function (#566) * check lexsort * check lexsort * lexsort comment * lexsort comment * Delete .DS_Store * Update README.md * bug fix & use oracle transport pretrain * mend * Add `backend_freq_config` parameter, support multi-freq uri * Add sample_config to QlibDataLoader, support multi-freq * add multi-freq example * get_cls_kwargs renamed get_callable_kwargs * support multi-freq uri * Add inst_processors to D.features * Fix typo * Fix the index type of the multi-freq example * Fix duplicate mlflow directories in tests * Add DataPathManager to QlibConfig && modify inst_processors to supports list only * Modify the default value in the multi_freq example * Modify client-server mode and dataset-cache to disable inst_processor * Add wheel package to github CI * fix comment * Update FAQ.rst * Update README.md Fix wrong link * Update the docs of TaskManager (#586) * Update manage.py * update yaml * update run_all_model * Modify the Feature to be case sensitive (#589) * update README * remove verbose * fix spell bug * fix typos (#592) * Update Release Note * fix portfolio bug * Add calendar support for resample * add freq kwargs * test.yml: Remove redundant code (#595) * Supporting shared processor (#596) * Supporting shared processor * fix readonly reverse bug * remove pytests dependency * with fit bug * fix parameter error * fix comments * Fix undefined names in Python code (#599) * Update pytorch_tabnet.py $ `flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics` ``` ./qlib/qlib/contrib/model/pytorch_tabnet.py:567:38: F821 undefined name 'inp' self.independ.append(GLU(inp, out_dim, vbs=vbs)) ^ ./qlib/examples/model_rolling/task_manager_rolling.py:75:18: F821 undefined name 'task_train' run_task(task_train, self.task_pool, experiment_name=self.experiment_name) ^ 2 F821 undefined name 'task_train' 2 ``` * Fix undefined names in Python code * from qlib.model.trainer import task_train * update seed * fix some docstring * add comments * Fix SimpleDatasetCache * Update setup.py updated classifiers * Update setup.py change to matplotlib==3.3 * Update python-publish.yml added python 3.9 * updategrade version number * Update model list * fix the type of filter_pipe * fix comment * fix record_temp * update cvxpy version * Update code_standard.rst (#587) * Update code_standard.rst * Update docs/developer/code_standard.rst Co-authored-by: you-n-g Co-authored-by: you-n-g * Add file lock for MLflowExpManager (#619) * fix torch version * Share version number (#620) * Update initialization.rst (#622) * Update initialization.rst * Update docs/start/initialization.rst Co-authored-by: you-n-g * Update docs/start/initialization.rst Co-authored-by: you-n-g Co-authored-by: you-n-g * fix bugs for running previous exmaple * fix deal amount bug * update change doc (#623) * Add files via upload * Update README.md * Update README.md * Update README.md * Delete change doc.gif * Add files via upload * Update README.md * Delete change doc.gif * Add files via upload * Delete change doc.gif * Add files via upload * Update README.md Co-authored-by: you-n-g Co-authored-by: you-n-g * update doc * simplify run all model * fix run all model bug * Fix Models (#483) * fix gat dataset * fix tft model * Update tft.py * Fix tft.py Co-authored-by: Pengrong Zhu * type and skip empty exp * fix model yaml config * fix tft import bug * skip empty result * fix model and yaml bug * fix wrong generate parameter * Modify multi-freq example (#626) * modify the example of multi-freq * add Copyright * add a comment to average_ops.py * modify the example of multi-freq * add comment to multi_freq_handler.py * add the Ref expression description to multi_freq_handler.py * add expression description to multi_freq_handler.py * update images * fix workflow and update framework Co-authored-by: Gaurav <2796gaurav@gmail.com> Co-authored-by: 2796gaurav <17353992+2796gaurav@users.noreply.github.com> Co-authored-by: bxdd Co-authored-by: Young Co-authored-by: you-n-g Co-authored-by: Dong Zhou Co-authored-by: ZhangTP1996 Co-authored-by: demon143 <59681577+demon143@users.noreply.github.com> Co-authored-by: Wangwuyi123 <51237097+Wangwuyi123@users.noreply.github.com> Co-authored-by: yuxwang Co-authored-by: Pengrong Zhu Co-authored-by: Mark Zhao <50850474+markzhao98@users.noreply.github.com> Co-authored-by: cslwqxx Co-authored-by: Dong Zhou Co-authored-by: SaintMalik <37118134+saintmalik@users.noreply.github.com> Co-authored-by: Christian Clauss Co-authored-by: Anurag Kumar Co-authored-by: demon143 <785696300@qq.com> --- .github/workflows/python-publish.yml | 2 +- .github/workflows/test.yml | 99 +- .github/workflows/test_macos.yml | 58 +- .gitignore | 1 + MANIFEST.in | 1 + README.md | 43 +- VERSION.txt | 1 + docs/FAQ/FAQ.rst | 55 +- .../img/analysis/analysis_model_IC.png | Bin 33497 -> 37744 bytes .../img/analysis/analysis_model_NDQ.png | Bin 23728 -> 23611 bytes .../analysis_model_auto_correlation.png | Bin 48415 -> 45122 bytes .../analysis_model_cumulative_return.png | Bin 64521 -> 54585 bytes .../analysis/analysis_model_long_short.png | Bin 16704 -> 15838 bytes .../analysis/analysis_model_monthly_IC.png | Bin 16922 -> 15815 bytes docs/_static/img/analysis/report.png | Bin 164378 -> 147442 bytes .../risk_analysis_annualized_return.png | Bin 46842 -> 46507 bytes .../img/analysis/risk_analysis_bar.png | Bin 12926 -> 10670 bytes .../risk_analysis_information_ratio.png | Bin 55540 -> 53269 bytes .../analysis/risk_analysis_max_drawdown.png | Bin 54377 -> 48861 bytes .../img/analysis/risk_analysis_std.png | Bin 48396 -> 45382 bytes docs/_static/img/analysis/score_ic.png | Bin 104343 -> 95451 bytes docs/_static/img/change doc.gif | Bin 0 -> 1355017 bytes docs/_static/img/framework.svg | 4 + docs/component/backtest.rst | 2 +- docs/component/highfreq.rst | 120 +++ docs/component/recorder.rst | 1 - docs/component/strategy.rst | 1 - docs/component/workflow.rst | 2 - docs/developer/code_standard.rst | 6 +- docs/hidden/tuner.rst | 2 - docs/introduction/introduction.rst | 2 +- docs/start/initialization.rst | 3 +- .../ALSTM/workflow_config_alstm_Alpha158.yaml | 2 - .../ALSTM/workflow_config_alstm_Alpha360.yaml | 2 - .../workflow_config_catboost_Alpha158.yaml | 2 - .../workflow_config_catboost_Alpha360.yaml | 2 - ...rkflow_config_doubleensemble_Alpha158.yaml | 2 - ...rkflow_config_doubleensemble_Alpha360.yaml | 2 - .../GATs/workflow_config_gats_Alpha158.yaml | 2 - .../GATs/workflow_config_gats_Alpha360.yaml | 2 - .../GRU/workflow_config_gru_Alpha158.yaml | 2 - .../GRU/workflow_config_gru_Alpha360.yaml | 2 - .../LSTM/workflow_config_lstm_Alpha158.yaml | 2 - .../LSTM/workflow_config_lstm_Alpha360.yaml | 2 - .../LightGBM/features_resample_N.py | 18 + .../benchmarks/LightGBM/multi_freq_handler.py | 135 +++ .../workflow_config_lightgbm_Alpha158.yaml | 2 - .../workflow_config_lightgbm_Alpha360.yaml | 2 - ..._config_lightgbm_configurable_dataset.yaml | 4 +- .../workflow_config_lightgbm_multi_freq.yaml | 86 ++ .../workflow_config_linear_Alpha158.yaml | 2 - .../workflow_config_localformer_Alpha158.yaml | 28 +- .../workflow_config_localformer_Alpha360.yaml | 44 +- .../MLP/workflow_config_mlp_Alpha158.yaml | 2 - .../MLP/workflow_config_mlp_Alpha360.yaml | 2 - examples/benchmarks/README.md | 3 + examples/benchmarks/SFM/requirements.txt | 2 +- .../SFM/workflow_config_sfm_Alpha360.yaml | 2 - examples/benchmarks/TCTS/requirements.txt | 4 + .../TCTS/workflow_config_tcts_Alpha360.yaml | 2 - examples/benchmarks/TFT/README.md | 2 +- examples/benchmarks/TFT/requirements.txt | 3 +- examples/benchmarks/TFT/tft.py | 26 +- .../TFT/workflow_config_tft_Alpha158.yaml | 2 - examples/benchmarks/TRA/README.md | 94 +- examples/benchmarks/TRA/requirements.txt | 5 + .../TRA/workflow_config_tra_Alpha158.yaml | 132 +++ .../workflow_config_tra_Alpha158_full.yaml | 126 +++ .../TRA/workflow_config_tra_Alpha360.yaml | 126 +++ .../workflow_config_TabNet_Alpha158.yaml | 2 - .../workflow_config_TabNet_Alpha360.yaml | 2 - .../workflow_config_transformer_Alpha158.yaml | 22 +- .../workflow_config_transformer_Alpha360.yaml | 24 +- .../workflow_config_xgboost_Alpha158.yaml | 2 - .../workflow_config_xgboost_Alpha360.yaml | 2 - ...rkflow_config_High_Freq_Tree_Alpha158.yaml | 2 - examples/model_rolling/requirements.txt | 1 + .../model_rolling/task_manager_rolling.py | 2 +- .../nested_decision_execution/workflow.py | 50 +- examples/run_all_model.py | 125 ++- examples/workflow_by_code.ipynb | 13 +- examples/workflow_by_code.py | 2 +- qlib/__init__.py | 78 +- qlib/backtest/__init__.py | 18 +- qlib/backtest/account.py | 217 ++-- qlib/backtest/backtest.py | 15 +- qlib/backtest/{order.py => decision.py} | 2 +- qlib/backtest/exchange.py | 98 +- qlib/backtest/executor.py | 28 +- qlib/backtest/high_performance_ds.py | 39 +- qlib/backtest/position.py | 9 +- qlib/backtest/profit_attribution.py | 10 +- qlib/backtest/report.py | 73 +- qlib/backtest/utils.py | 11 +- qlib/config.py | 124 ++- qlib/contrib/data/dataset.py | 346 +++++++ qlib/contrib/data/handler.py | 8 +- qlib/contrib/evaluate.py | 5 +- qlib/contrib/model/pytorch_gats_ts.py | 1 - qlib/contrib/model/pytorch_tabnet.py | 2 +- qlib/contrib/model/pytorch_tra.py | 944 ++++++++++++++++++ qlib/contrib/online/operator.py | 20 +- qlib/contrib/online/user.py | 10 +- qlib/contrib/report/graph.py | 10 +- qlib/contrib/strategy/model_strategy.py | 2 +- qlib/contrib/strategy/order_generator.py | 2 +- qlib/contrib/strategy/rule_strategy.py | 2 +- qlib/data/base.py | 4 +- qlib/data/cache.py | 359 ++++--- qlib/data/data.py | 247 +++-- qlib/data/dataset/handler.py | 99 +- qlib/data/dataset/loader.py | 79 +- qlib/data/dataset/processor.py | 17 + qlib/data/dataset/storage.py | 35 + qlib/data/inst_processor.py | 23 + qlib/data/ops.py | 4 +- qlib/data/storage/file_storage.py | 31 +- qlib/model/ens/ensemble.py | 14 + qlib/model/trainer.py | 2 +- qlib/strategy/base.py | 4 +- qlib/utils/__init__.py | 34 +- qlib/utils/index_data.py | 6 + qlib/utils/resam.py | 72 +- qlib/utils/serial.py | 25 +- qlib/utils/time.py | 113 ++- qlib/workflow/__init__.py | 4 +- qlib/workflow/cli.py | 4 +- qlib/workflow/expm.py | 9 + qlib/workflow/online/strategy.py | 24 +- qlib/workflow/online/update.py | 2 + qlib/workflow/online/utils.py | 4 +- qlib/workflow/record_temp.py | 40 +- qlib/workflow/recorder.py | 11 +- qlib/workflow/task/gen.py | 102 +- qlib/workflow/task/manage.py | 31 +- scripts/README.md | 2 +- scripts/data_collector/utils.py | 45 +- scripts/data_collector/yahoo/README.md | 14 +- scripts/data_collector/yahoo/collector.py | 50 + setup.py | 17 +- tests/backtest/test_file_strategy.py | 4 +- tests/backtest/test_high_freq_trading.py | 8 +- tests/storage_tests/test_storage.py | 13 +- tests/test_all_pipeline.py | 27 +- tests/test_contrib_workflow.py | 20 +- 145 files changed, 3982 insertions(+), 1221 deletions(-) create mode 100644 MANIFEST.in create mode 100644 VERSION.txt create mode 100644 docs/_static/img/change doc.gif create mode 100644 docs/_static/img/framework.svg create mode 100644 docs/component/highfreq.rst create mode 100644 examples/benchmarks/LightGBM/features_resample_N.py create mode 100644 examples/benchmarks/LightGBM/multi_freq_handler.py create mode 100644 examples/benchmarks/LightGBM/workflow_config_lightgbm_multi_freq.yaml create mode 100644 examples/benchmarks/TCTS/requirements.txt create mode 100644 examples/benchmarks/TRA/requirements.txt create mode 100644 examples/benchmarks/TRA/workflow_config_tra_Alpha158.yaml create mode 100644 examples/benchmarks/TRA/workflow_config_tra_Alpha158_full.yaml create mode 100644 examples/benchmarks/TRA/workflow_config_tra_Alpha360.yaml create mode 100644 examples/model_rolling/requirements.txt rename qlib/backtest/{order.py => decision.py} (99%) create mode 100644 qlib/contrib/data/dataset.py create mode 100644 qlib/contrib/model/pytorch_tra.py create mode 100644 qlib/data/inst_processor.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 08d41d198..8b94a2d3b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [windows-latest, macos-latest] - python-version: [3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29265b1eb..af386f6ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test +name: Test on: push: @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-16.04, ubuntu-18.04, ubuntu-20.04] - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v2 @@ -25,63 +25,29 @@ jobs: - name: Lint with Black run: | - cd .. - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe -m pip install black - $CONDA\\python.exe -m black qlib -l 120 --check --diff - else - sudo $CONDA/bin/python -m pip install black - $CONDA/bin/python -m black qlib -l 120 --check --diff - fi - shell: bash + pip install --upgrade pip + pip install black wheel + black qlib -l 120 --check --diff - # Test Qlib installed with pip - # - name: Install Qlib with pip - # run: | - # if [ "$RUNNER_OS" == "Windows" ]; then - # $CONDA\\python.exe -m pip install numpy==1.19.5 - # $CONDA\\python.exe -m pip install pyqlib --ignore-installed ruamel.yaml numpy --user - # else - # sudo $CONDA/bin/python -m pip install numpy==1.19.5 - # sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy - # fi - # shell: bash + - name: Install Qlib with pip + run: | + pip install numpy==1.19.5 ruamel.yaml + pip install pyqlib --ignore-installed - # - name: Test data downloads - # run: | - # if [ "$RUNNER_OS" == "Windows" ]; then - # $CONDA\\python.exe scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - # else - # $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - # fi - # shell: bash + - name: Test data downloads + run: | + python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - # - name: Test workflow by config (install from pip) - # run: | - # if [ "$RUNNER_OS" == "Windows" ]; then - # $CONDA\\python.exe qlib\\workflow\\cli.py examples\\benchmarks\\LightGBM\\workflow_config_lightgbm_Alpha158.yaml - # $CONDA\\python.exe -m pip uninstall -y pyqlib - # else - # $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml - # sudo $CONDA/bin/python -m pip uninstall -y pyqlib - # fi - # shell: bash - - # Test Qlib installed from source + - name: Test workflow by config (install from pip) + run: | + python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + python -m pip uninstall -y pyqlib + + # Test Qlib installed from source - name: Install Qlib from source run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe -m pip install --upgrade cython - $CONDA\\python.exe -m pip install numpy jupyter jupyter_contrib_nbextensions - $CONDA\\python.exe -m pip install -U scipy scikit-learn # installing without this line will cause errors on GitHub Actions, while instsalling locally won't - $CONDA\\python.exe setup.py install - else - sudo $CONDA/bin/python -m pip install --upgrade cython - sudo $CONDA/bin/python -m pip install numpy jupyter jupyter_contrib_nbextensions - sudo $CONDA/bin/python -m pip install -U scipy scikit-learn # installing without this line will cause errors on GitHub Actions, while instsalling locally won't - sudo $CONDA/bin/python setup.py install - fi - shell: bash + pip install --upgrade cython jupyter jupyter_contrib_nbextensions numpy scipy scikit-learn # installing without this line will cause errors on GitHub Actions, while instsalling locally won't + pip install -e . - name: Test data downloads run: | @@ -94,30 +60,15 @@ jobs: - name: Install test dependencies run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe -m pip install --upgrade pip - $CONDA\\python.exe -m pip install black pytest - else - sudo $CONDA/bin/python -m pip install --upgrade pip - sudo $CONDA/bin/python -m pip install black pytest - fi - shell: bash + pip install --upgrade pip + pip install black pytest - name: Unit tests with Pytest run: | cd tests - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe -m pytest . --durations=0 - else - $CONDA/bin/python -m pytest . --durations=0 - fi - shell: bash + python -m pytest . --durations=10 - name: Test workflow by config (install from source) run: | - if [ "$RUNNER_OS" == "Windows" ]; then - $CONDA\\python.exe qlib\\workflow\\cli.py examples\\benchmarks\\LightGBM\\workflow_config_lightgbm_Alpha158.yaml - else - $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml - fi - shell: bash + python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + diff --git a/.github/workflows/test_macos.yml b/.github/workflows/test_macos.yml index e52c27786..b6003f668 100644 --- a/.github/workflows/test_macos.yml +++ b/.github/workflows/test_macos.yml @@ -13,7 +13,7 @@ jobs: runs-on: macos-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v2 @@ -26,52 +26,46 @@ jobs: - name: Lint with Black run: | cd .. - sudo $CONDA/bin/python -m pip install black - $CONDA/bin/python -m black qlib -l 120 --check --diff - + python -m pip install pip --upgrade + python -m pip install wheel --upgrade + python -m pip install black + python -m black qlib -l 120 --check --diff # Test Qlib installed with pip - # - name: Install Qlib with pip - # run: | - # sudo $CONDA/bin/python -m pip install numpy==1.19.5 - # sudo $CONDA/bin/python -m pip install pyqlib --ignore-installed ruamel.yaml numpy + + - name: Install Qlib with pip + run: | + python -m pip install numpy==1.19.5 + python -m pip install pyqlib --ignore-installed ruamel.yaml numpy - name: Install Lightgbm for MacOS run: | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Microsoft/qlib/main/.github/brew_install.sh)" HOMEBREW_NO_AUTO_UPDATE=1 brew install lightgbm - # - name: Test data downloads - # run: | - # $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn - - # - name: Test workflow by config (install from pip) - # run: | - # $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml - # sudo $CONDA/bin/python -m pip uninstall -y pyqlib - + - name: Test data downloads + run: | + python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + - name: Test workflow by config (install from pip) + run: | + python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + python -m pip uninstall -y pyqlib # Test Qlib installed from source - name: Install Qlib from source run: | - sudo $CONDA/bin/python -m pip install --upgrade cython - sudo $CONDA/bin/python -m pip install numpy jupyter jupyter_contrib_nbextensions - sudo $CONDA/bin/python -m pip install -U scipy scikit-learn # installing without this line will cause errors on GitHub Actions, while instsalling locally won't - sudo $CONDA/bin/python setup.py install - - - name: Test data downloads - run: | - $CONDA/bin/python scripts/get_data.py qlib_data --target_dir ~/.qlib/qlib_data/cn_data --interval 1d --region cn + python -m pip install --upgrade cython + python -m pip install numpy jupyter jupyter_contrib_nbextensions + python -m pip install -U scipy scikit-learn # installing without this line will cause errors on GitHub Actions, while instsalling locally won't + python setup.py install - name: Install test dependencies run: | - sudo $CONDA/bin/python -m pip install --upgrade pip - sudo $CONDA/bin/python -m pip install -U pyopenssl idna - sudo $CONDA/bin/python -m pip install black pytest - + python -m pip install --upgrade pip + python -m pip install -U pyopenssl idna + python -m pip install black pytest - name: Unit tests with Pytest run: | cd tests - $CONDA/bin/python -m pytest . --durations=0 - + python -m pytest . --durations=0 - name: Test workflow by config (install from source) run: | - $CONDA/bin/python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml + python qlib/workflow/cli.py examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 33a2a2530..a563ed5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ dist/ .nvimrc .vscode +qlib/VERSION.txt qlib/data/_libs/expanding.cpp qlib/data/_libs/rolling.cpp examples/estimator/estimator_example/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..8dd91c79d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include qlib/VERSION.txt diff --git a/README.md b/README.md index 422046c13..6ceb26e66 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Recent released features | Feature | Status | | -- | ------ | +|Temporal Routing Adaptor (TRA) | [Released](https://github.com/microsoft/qlib/pull/531) on July 30, 2021 | | Transformer & Localformer | [Released](https://github.com/microsoft/qlib/pull/508) on July 22, 2021 | | Release Qlib v0.7.0 | [Released](https://github.com/microsoft/qlib/releases/tag/v0.7.0) on July 12, 2021 | | TCTS Model | [Released](https://github.com/microsoft/qlib/pull/491) on July 1, 2021 | @@ -23,10 +24,8 @@ Recent released features Features released before 2021 are not listed here. - -

- +

@@ -45,7 +44,7 @@ For more details, please refer to our paper ["Qlib: An AI-oriented Quantitative - [Data Preparation](#data-preparation) - [Auto Quant Research Workflow](#auto-quant-research-workflow) - [Building Customized Quant Research Workflow by Code](#building-customized-quant-research-workflow-by-code) -- [**Quant Model Zoo**](#quant-model-zoo) +- [**Quant Model(Paper) Zoo**](#quant-model-paper-zoo) - [Run a single model](#run-a-single-model) - [Run multiple models](#run-multiple-models) - [**Quant Dataset Zoo**](#quant-dataset-zoo) @@ -71,7 +70,7 @@ Your feedbacks about the features are very important. # Framework of Qlib
- +
@@ -107,8 +106,9 @@ This table demonstrates the supported Python version of `Qlib`: | Python 3.9 | :x: | :heavy_check_mark: | :x: | **Note**: +1. **Conda** is suggested for managing your Python environment. 1. Please pay attention that installing cython in Python 3.6 will raise some error when installing ``Qlib`` from source. If users use Python 3.6 on their machines, it is recommended to *upgrade* Python to version 3.7 or use `conda`'s Python to install ``Qlib`` from source. -2. For Python 3.9, `Qlib` supports running workflows such as training models, doing backtest and plot most of the related figures (those included in [notebook](examples/workflow_by_code.ipynb)). However, plotting for the *model performance* is not supported for now and we will fix this when the dependent packages are upgraded in the future. +1. For Python 3.9, `Qlib` supports running workflows such as training models, doing backtest and plot most of the related figures (those included in [notebook](examples/workflow_by_code.ipynb)). However, plotting for the *model performance* is not supported for now and we will fix this when the dependent packages are upgraded in the future. ### Install with pip Users can easily install ``Qlib`` by pip according to the following command. @@ -162,7 +162,7 @@ Users could create the same dataset with it. *Please pay **ATTENTION** that the data is collected from [Yahoo Finance](https://finance.yahoo.com/lookup), and the data might not be perfect. We recommend users to prepare their own data if they have a high-quality dataset. For more information, users can refer to the [related document](https://qlib.readthedocs.io/en/latest/component/data.html#converting-csv-format-into-qlib-format)*. -### Automatic update of daily frequency data(from yahoo finance) +### Automatic update of daily frequency data (from yahoo finance) > It is recommended that users update the data manually once (--trading_date 2021-05-25) and then set it to update automatically. > For more information refer to: [yahoo collector](https://github.com/microsoft/qlib/tree/main/scripts/data_collector/yahoo#automatic-update-of-daily-frequency-datafrom-yahoo-finance) @@ -247,19 +247,19 @@ Qlib provides a tool named `qrun` to run the whole workflow automatically (inclu 2. Graphical Reports Analysis: Run `examples/workflow_by_code.ipynb` with `jupyter notebook` to get graphical reports - Forecasting signal (model prediction) analysis - Cumulative Return of groups - ![Cumulative Return](http://fintech.msra.cn/images_v060/analysis/analysis_model_cumulative_return.png?v=0.1) + ![Cumulative Return](http://fintech.msra.cn/images_v070/analysis/analysis_model_cumulative_return.png?v=0.1) - Return distribution - ![long_short](http://fintech.msra.cn/images_v060/analysis/analysis_model_long_short.png?v=0.1) + ![long_short](http://fintech.msra.cn/images_v070/analysis/analysis_model_long_short.png?v=0.1) - Information Coefficient (IC) - ![Information Coefficient](http://fintech.msra.cn/images_v060/analysis/analysis_model_IC.png?v=0.1) - ![Monthly IC](http://fintech.msra.cn/images_v060/analysis/analysis_model_monthly_IC.png?v=0.1) - ![IC](http://fintech.msra.cn/images_v060/analysis/analysis_model_NDQ.png?v=0.1) + ![Information Coefficient](http://fintech.msra.cn/images_v070/analysis/analysis_model_IC.png?v=0.1) + ![Monthly IC](http://fintech.msra.cn/images_v070/analysis/analysis_model_monthly_IC.png?v=0.1) + ![IC](http://fintech.msra.cn/images_v070/analysis/analysis_model_NDQ.png?v=0.1) - Auto Correlation of forecasting signal (model prediction) - ![Auto Correlation](http://fintech.msra.cn/images_v060/analysis/analysis_model_auto_correlation.png?v=0.1) + ![Auto Correlation](http://fintech.msra.cn/images_v070/analysis/analysis_model_auto_correlation.png?v=0.1) - Portfolio analysis - Backtest return - ![Report](http://fintech.msra.cn/images_v060/analysis/report.png?v=0.1) + ![Report](http://fintech.msra.cn/images_v070/analysis/report.png?v=0.1) t5<9{}F6wb()*0-t;`a_hH(e4ULP6!nwCw`;GIB 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

n^-Pe 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 MLP5MlRL}4K0F@V%rvLx| literal 46842 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%0DcwD`v3IjFWVZ-N@mXzBI`{-Q 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

2lgd=R(+3)Gu9G-q|9W5`n=8oDg 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 diff --git a/docs/_static/img/analysis/risk_analysis_information_ratio.png b/docs/_static/img/analysis/risk_analysis_information_ratio.png index 7028eaf02108d1110aebde21ca082a72ad271936..f8c2328b6690f4524ddbd6019037fae5615e757a 100644 GIT binary patch literal 53269 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 N91Z|qGQsQ9 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 diff --git a/docs/_static/img/analysis/risk_analysis_max_drawdown.png b/docs/_static/img/analysis/risk_analysis_max_drawdown.png index b7f1ae130603e2bcd62c465d9cf1942326f46495..5f94a8ee13e4935033b9be47e7dde5322fb1296d 100644 GIT binary patch literal 48861 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

(VbNyvkhZwSOMUbu{*xXm{_T+X&oLeo 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 X2tGY4F-$^60{+QKDNB}!oA~`7d|`wv literal 54377 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 BssI20 diff --git a/docs/_static/img/analysis/score_ic.png b/docs/_static/img/analysis/score_ic.png index a5739a9babd609c93e2768e678c420f75314dd19..ffb26cee673c457711ad525404ba82221cd63668 100644 GIT binary patch literal 95451 zcmb@t1yqz@*EdWIGV}n_HGqJ0cY}nah=jCsceg`HNFxo3bcd9bBPER>ozh4%biNn< z@B4nA?|atzp7p)!T?-bAYv$~;&+hZv`-*&_rhtP5pTvhgS^m0@BRG%KlV)0~%H6=dkAu2TH8ZL&F*CQ`c4uP1_eL-5U1VIlp_7}4+=;Y0+N3Cfs+2~ zV! z2m_G7-&2s${j3!F{k@t%YN|)kAz71rSqZ5!Lz}^YJ((#?k~Seu0%9-Uat=u6udsBA z6a}$t)hvdUR|`SIn5iBGFSOEw4ZJN!UuV=I!vS$V2O|8ilzIQM=2FR#~D zj|q4vVQ+Ae|8;Jj0&lFZZZ!nubwbmm=>LA0jd{ahD0`^lS6~klH8sV2O78$WJyoE^ zgS&E!&Bk>h#R4k%%Z4k2&GV&v_z2V_ z?*GJ^|0m-AKOEzM)c?~}{x1{%|90SN-Gzk(Ms*w9nc-DsSy`yQ0l6(U&RhGTR%~D) zB*ujh=PkLc?8lyXb`Bf}HZlwuPAd2l@`L!{aYGZI6c-5m2xK<@3|Ptsvc;as>j5PD zuo4*;AoCMa%xxY`jtY_uHj(0bB*X`*?JoG#GYSy+uf$Trfjyx5|CQJ;QVtw|0N^G6 zX2V{?tcmYokB61qm=F7rqwt3VTz>@ibSp9KAY#3VLWT#M#AI57&7;Gm-qQld0hVL` zcNQ&6-X=u;;CKD30p11_RRb zhAS;o<o*tnGUH%t3rtn|7U1iuHLGw?v&NhG#0ebFfiVS~Hp<91LRf;WpzHbH+ zeCum5_rq3@7!4#~t5FCR{hXN{zh#R?+5hXqm9bF>LVV6GG9NV}u`Srd-S8LbU-Nm; znTw6}JD==5<{$!C5)mU?XV))x_0eeehD`+FDCEHr%(Fm0H`cnl^7Ki@k^f^Fzta3y zTDeeQy@`e@TH#-Z-!jc>j!a9)TV20hXUwEu|8L@1>9@-K5Re$Wd~rcv$P%*mGb&S{ z#|5GiRM{dF{EreP-k)h+&Xu?emO^2d_QMb{-v2m+=_hlF44*qU7w zK}9t4onfzd-d!}g|0QJ5-{^N$%DXUD_+VV=S{2?iK%M`%Ah4q!S4X;~rtyx}$-Ml% za!_H-G_aeE6FxFL?ES*u15U>z?-yQABP5#Lf+aqWkaR;-K3l)Nx<|YFGlGp%B|26q zuN&)?j<6@kg{7;krH1-I%5UWOuZg?1%>K8}ybDX*`?l3Z=vYDt;%rd2Zx=H!<)P$s zT9rMdKxX=TtZEN;&CIFGQRG#A2yNPma@ z!Ca}B5fm+-vGfU8MdW*!xG7fw3()av$iDp?;UAdKRAGIarhZv)*0%74dOgn|ii_oUk+dLihkdpyVW>W*ECUu`2^ekoZ3wNwIwcB&% z^0wgLq^aTIO|y04RCg5OHQe{ZGGr`6o}hum<7m((lP4A8pcxdC_PHoAYl*tFLf9K?<`5Y_NcfX$QI@c)#@L2Ty1B z?<+6J>R5;QFU=iD7`gr<3;w4y6A8!NXOWG%Jyr8rDY`##!Qq1nykRD9tVQ2{Hp54* z+eC(2aygRM5)2NeZgT#F+}wT8O#9|+V-0?Y4~A18yY5OhFRBO+YE((7b3q_k1Uk&; z1uF*Jf5ZU=ydPc}`bb>?A^<<$PEsP@O4!-^$o7f~G=yj2beZ0Mz8z#mkHAb_ci*^V zM%NgDRN%?fMz%39Tf8s+kPcq+^{4v!Wm@czUM0Wps+k!wFd7x)}0P z{LrIIcNR*AIAs|q0Nk>eIiFJ-t^`Zcr;K3GHg)yrk^YLil9#D8V5)f8@ zdZ>PI7`M3_=%G!`JeVu~y)5bwix;bk1pDNJ0-tS_Jv1an1vK+FCvpjbE3xmq&!A1* zj)dfZW-An^i@PmnM(14~K ze90j(m3GJWqH6YZQ19A*7ZbrId|j};nju zFp2QoK$K<(CI8nH|HOabDDT1&95Q%=sH~q16W}0&+4f}Ob^*iK( zV#OA~;P40Ymf|8e%`vC&_$t8n*>NPj@T?s_Qqpg+Ct}&=T<$^lfE*0;O!U}S(TC2s zzfxT2Rpm(*X&D0s@UR$4io;D)d+Vzpv3a=VI2Rg))drJPSAF3bMCa9`5|anG5WtVK zjUnpi5#K^L$jxhSjsWU^%A1GdX=OP$Y)Z)wb>I4^hVJ;use~1evuzL_CQlh6s%d8j zO6+qJyzNTeC84_5a5+&yBpAtV!FzSJ6j9!{sDvn!HJc=JOenCw=@LktO)PcwVg_o} zoL=RNa`KV+u8o+j>qR~zjp4ef?t4=gAtA&{F^HmDsyDm2>eUIb-rDroQa3IL|^v=h##v#lO#sedQkA>VoT zcNO{Q^qw1c-(1Hr58S1=7`ZOH6r%Jjph6uQ1M1c8LYx$ID_R7wow^6H|7WG;e5-DE zYw_vVq}kR`kvsJ3B9Z`}OCX~;wu^xPGiFQ@Dya=YS|Vv1cbX-=&R5X$Q0SQ4t)vF; zlap3#Zyk`4PYKS+F2zIqYsevkPF{br`QKVp$9;bOIL0JLt@`7L_)fZ-lykro^xacN7M0BY;4@*P;nS!xDp=5h@t|SqZH-b}ZN&(|{A-P}%E~jK=EH8LgJk`U768}F z!enT^`hDG{!3tZFx1QCZmd3cSR1tkw-@%K+aPDV2KC3Qhv4AxkFmhTZ8oy{JuWK! zO2qI%I?#c?tn+yH9m+r6iwH5%6TKUtskl;Ja;bY9DsVCNZCcJjQG+5XR? zzbdH41@BX&b3KAFmpbp9C$G##$2Mw`L~*}GhMm4$_L6lK67)Zbn7ZW|YNS;|i)V#5 zw~&BAB-4_^OZaL24TO*wZu3c6??XBaICV5Od*_ts@ui_)NiFI>?oSZ8YKrU3cD$NH zM-LTZ@#Q?gH<}~?)o7-S#gp@f45j0S3A8?SV z4!O~OfeVz#_P$_8_Ix#6>iXAY_K3b%(jN9(b1nalojrTW=Cw=kgpuV7vf-?bXHtiL zpNjs8M=FeJ<iXm(9_!fn0UbZZrawT3EPbq8D4pJ zXO5!~q?(`KLWT}!We?E~%N1}-G@&o9nYWZ`*j*nX0UMc>t^;*Ahw4JFlB|x#S<-%u#H5R0p~&X+;APdoMRS~k`%uLhL7pSO zSIpN_AK$&Z!pgQ(BZ!oi(@^rIDIKGzp6N{YpA8;Up zVhU(-N&H#O&vWpHa-U3deJ_ky&LI5t$%!nf_hRYVb|j7B~_r7Sm9R!kGjyKmOn^o?kwH~?t=O*<*(I6urhKjMh z`V>>8ObbW7Xk~*PB$)H0w>Qu5%IZ2#osk9>GH!nF9#ecP0Zio&8YiwZ@LWA;1y`4mgY_HxcKRdFyia8=s~a4_$#&jR8}EUo!*qv^7jHPN>3GIeR5Ucu*c)}b z>a3_;mHJCs08w4(^*K7*Q$6S{lOI$i`Qp2=>5Zk;9i?n_QSOw?TN6Fz7%GJIE{(jo zpI-B+E+a8HH0`d{>HwFDwwD+eRMet$fBFM2FO{HWC_i9khpO&A^P~5S#0D;$;ra)Q zmgG7LjG5OnVX_{9b>)`47o^28_SCkRG1~$Dd^L-yP{igc_+Iu`f!+gTB47tLGR;+- z&Qw!T8({z`_*A@>f~dGfF4WUwVA9v}yMYT^-W#kWWtJ3kmdKU~FQR~qxxvI}=7kxq zgP1RVnDqt?QNr{`;?K6MIy=xonUbJJO3TIJOTDTUsIEwI39Uu3>VjHO8~jolVbA|? z&nDhd%LJjo&=r_e+|bYXxaSZHj`{N>+(>-h8t9C} z9ya*GIPN7|wc=~ZyY63}(aHT{L8deJL5AVLmSfmEG6-P35ycLyO&g@57w?;qF?~Wi z%r_vEn$y#HO)173@$bM=)##4h-7L*j=hT^HnQ^Q= z7NBo$lL_rsr0L_6j?<`n1a}dgKM7sVD>oD6EvvI@8Ki;X;6xXr!d{0xb>TfD;RQYJ z-&cp>CRP6&q)ppLsK2)7tPThr?ik9!r+`)jCJ4sw!`1|e4gqjF^as66$iQLaW z?inHk*|8XYckwB7}0MIqK5sS8D!LPpkNG$3QE+fG|jhnjxABk<>@)O}ATs z27-SvxMSx#@*gUPrb(#1`hp4@LVNM}I|Y}07!Y_eImUnj>3g>J_~qsdJJyg`P<_%$ zYsB(xj-)eWP@>UZt3l5Uj`0uvmr19evq0;%8>k$AF)cD=-w51>#*;p(XG`3qGngmI zI=@b~PCfSuHc9W$HrN_}gDbuB3hChW7R|5bC*vQ^GP5s1pv^f+M+hjwY6{@vjdiK3 z53ChA``$-OimM<{P;L|tIxR+f0dZnT3tN^At4MZs6Zj_tTgC5%$=L3={i$H*)XcWx zkK~+q-ZT;8A8~!qZD_p3)>$saWvG~EVkLtFKgOVQCEu@>P$SvJ%>-?~cNrY-M%D@- zr+y}7WlA1|0k-rDyMs_xWZfgdEs>s@Q*!pNS_0M3^cp0qW_^m`ERyme@Cxxrp#4+n zXfC}y2{sL4Pj6!Rko6~dZe#ZRe(=fUh}t59&qa1n6r7WmfeQ$- zk$u%{jbTZpB2q3kqVh3FaDxc~xhYn5T{;wkPWvOtId=h-6p(;g<9@!M@b@WRM38wl zIg1;0E9m}9-ncNitvV|&QsD_I(;L1TBZ?|6P?<&wW{}SZgZv>T@SV^u_7btF@vtx= zZ22*IKSH4YZp0rPD0%YA?L2-au?sM}YdcQi()ss>?q7$JEs#L0{5%pflcGG_!u8*1 z)9&U;j_E`VoH@q+$^2q15HTSUmxG>S+LjJ1Xp>N4rrg$NZpZPIh~;-WB;+9DT^ffy zvPsFgKe2(|p%;j1O-oF8mU#Hp#n2x-O9jbInqS{g45%qZU;ZNZ`FfQ9qYnTo!-Kz9 zpp%G7AcF-QY>2ptb}M|N7gc3=y~`g*TM1D0tI$Y z5^uu?+WcjBtS;Utyl&uoDmO0cJnxGF3#pL&y%I{HXSiO`cJm^72k$W|%nB98pmTx@ zm$#zOnUtTe?xLrJPM6bq=SSGGe&Wb|sC<#(I>?}d5j)G{SGZ44&_9%Ib=}evo&-r& z&vy;E9fW4lL7Ql2eXX=TygdVCd|H}ixg>R4=ia{>-!8+41&Qi@&n2gERvcrRo6LD* zs&;GRmE!`T{Gc^5(koih^9ypriVRncnqzsad~9f3_~#{d1;>D_MOTsxwoyJ%_lq_< z9q<(t@;l#_U;XnY%aLt=CA8Vs$RKdOq2`ZSwN5=ow+x0>k) zS5xM&_h!t{n2hLIXxe%y>m&GH!P@lWn+pS(!;7_FFH@7{h-=jKo{3r4#34Lwti?yM zRR?1Mq_8=eaA9a&I=4Y+X=&^07yGG$XWD!|OXku+3}@N#ln(sbRcC#pW8!SDgGC(( z-l1T}sHG9Wwi}}E%=+&*#~=pPoge>P`51F+%qD)Ima5)QUX=}(IlP@;9c>^%Q*H36 z0g_fn&0x}F^+`|s+lCbx8mKM-jAB(_2n18L@6!y(qO3g&e5g19tzyGZ&R!LN@qfEuiY;zv!SB^DWjJs&&BYSO^H)~IujN9+ya`)r$>{I8H+ z47lHzj)2vX=42BFr4CVeRm!d;U%1j_T97S!)L$G5ti?Hd#}?o%aa7dO03 z3gea0b)1Z+QhHiU9bahdh53~=?{3~MKcnbBl^o=}npe%nNI_4oi@I(q{FEE}D$<~m zUf-dq7x0HW-F2R+zu4w%T=?vXc9oXyNfcY!(BpVvl9G0_3sb zF=cd!x65DoY7q(*4?O_@*~5WrM+{s-b_s&C31F8L-|D5EUs|-J%W(0QwXz#%&Q~Uh zses{32+VizqjyUqJrk%4QoF9Z*Pn`PMfiv--}A58dVO9sy`}ZG_00&RT#-F&Q6hAv zv$%NvR`qZr*{v|lq;XZt6{0f2{YYEKsB_)IvBW*6fqPwZypDf;gb>WJpsww$(2Na2 z#GT7b;E@`6#iy&p8oGcjWj*WA=UN7rQ>%EX<8LV zCVjXjw9dSH&)^4G=yDhuoQTAi-j)12oDkLT-?IRI7id(rp?;rzZX$pkWaQeG3 z)9Q_AhA`;oKgG_l2`>HUQ4l-;E}ZVAr9-NvNDspI;6H zro25KB0h@I!EwaP1GIKP2VSgT0%7qC9cyQ+c<>Z{@o%yjE@%+@yl8h%?s3`bl6nSQ zl9AySK-@XBm*Nt%iDKJ-fSNAx;7*{NHQ7UU;v@T38n^Ie09u^CqQQdaIu1-cf-6LG z9+EM)fX;E_I%CoZ|qNh+n$ddAkz4H-`qv+6FL&XFl^{_{Z2_v(1;b-=uei4 ziibl#gDh~?oA3b;h_goBf0Xvj_pZQ!pjL^LU8-s8Hfhv)bK7dN5Gq5nm6IZgeFmgiU0AWqHMMA zbcOS4iB4@ZgnIcFsk!P4Ou8p*));7#!}mKc!aLSRxb-Ve!I@%n5H?FT-UySxdga0| zlG@VlIpz7XmVf9}Z=-o4H^DFk^dH7Yx_n8>eoRl$flQvm01lj`e~$-u7o^Be%QFq6 zyLMj352lBDFUoMOMx_XjFq^Wd2sI7ns5%d7OjI7?u_=NZPHYwjwLi{3nuB<@zO8~FK0TWvIM^X$G1M?C9`O)Fer6oJ4k}X`p zg})P@%In;3sX)EW{r}+A!*|}Tc3dm;ybm^+3}e49-X`S&!(LaB^M)>es)-~b{uJS0 zR8ia3`0Wf|aOXg`%JXwXP(CjW*k8%i+)0F?HuZwnqsBHIV!-Ua>{JMuPxPBc%z-n{ z$yWj@p`h{EgmLFM7`IzcKmB`4Q@43it8j>ai8dmb9nyP9>rcWmS;I@!>1 zWtu@p5JYpUG3w^oYN-dt?JK^v2&(D+RC`e?kUnPlgqnvP#MF$XO?S0hX*Rs!$Z(Kc zY5Sm530W=%ftf4@@CN6d@Ey%WVi9|%GOAetdmFyS6@D0@*@ z&yY5*zFJM{e>y(mW9j?|?gq3rRK@F~S}s5T!F6I3qSVy$UBmIwSRqFjVc2CDTd)Mf z!z(L}X)nGN9^iy6Z;FjydJGpsVHiQGOJJMfUu(tBkcSyCistT(q`{0+XB(UV>_n@p zK2hNSwD|B}QUuv>+o4lcW>#PC1h3=*Kng0=APcij&sz89`{nO+_wbAr`@HRe`5YXzDzm)wYHrmhRW2jw z<`I$iLyF+`SJ!0|J*V0fN?2;c44`r2){*_LrbwsvSO4oA^zteeoZ5Q!U{B2C|G8z*6^KI_y=0J;e-B)@d(GUYF^+hMYC;o~l zjb{WQC;@y1?)>N@xRVw4kL*>|cXaUM|QwL!_`vpb5o!X$dqEB0AO^ ze^uXhSpFi*7=fq;Et(0RC+e&k#Jtd=oO#nA(W}xo(TfwZyrL-0SQN7P2=G9MlZ2(> zyGZttcMXQdP^{`5RUTD`TB8DSznEif&c=u~DZ^4;cF;LaoG4m&=`D635v^<#TjKup zmhIbRo!iq+sqRqCD_#MIqYEEpvYFc(2=}4)u_(x#T(;})^q)4`=rO_ zr?R6d za7@|khw{<_*H-q+wWnlt+BZa#6`U^5j$=|Hk;6ikMFprQ(P7&zT`vGIAd9ZQ&f07~ z`N!)JdtHiGxmYLGQPo=~5W+2(m#7J&+KQ`ui z-ryu0IyGCmU-hP?WA1fD8y}ag7zm+#;uqe7S|Pd0;_z)hH%Gs5gbLJ{s`)7+qSLm! zt?JUs*xak`p-X~zZZ&t6^Fn>|q(*q1(rMcJ{h_U}K7gTnwbNfz6ppV{WffbK0F}_> zn_)Q=nvNhbzp@3XgXI}I$tqpo3zMYrUi-ZCZDi!I`{l}H|`2)-PU$hqWEfV zZe`v7MubSR%z@KUBCA{4xom~lVTzQfE!Bfoh1jxp=aE$Pxa6}l|65-*YKrnetm?b@ zK3^Pn-15f2T6@?KswHUZ6=wa}nyDqr}EUR6nDQTmNXuAd4L zoQyg<)(d;~c6FsNB1BLWA5xAXs`BGpf;ZG8vT4#*eE&m##IioyN|E^uew5j-bw^sD zzL^3+vdoolMkzC8-RBmh9)GOA6kFIrd%pmn@a7F6hNYfXam&3WC6R!Re3Mu4e54ybvjXtkC=?uN@|5QG_j;}h|x$um^E22Zt3!>4X{1uA7ljhUo3`aq^zr6e5`V24RyXYNlb0cL7o+i4QoW(P zjPrN-SUl?|hF`ePfVn7|3VWl*lcEmqQZ=NE-!rGY+!0VyWQjWFTgPZwV(pHfK63&rU zfQe~n2XKN-Oe23?=3c2$|K_x7Uq6X^7KeuuW^&cX+g_f1F6NwZ+^U)c?KS?26hgA| zc#!&YIhDYbn#ZlfTGh2ApC%Y+;H_Pjt;Av1D0Li|IGY&5W3r$01rGnDfz3>K?SIUbJxLe)GxmK$SIY_rFEDzv&^*2wH{ znbF|`3*+u<FyX zrjlGrRA?8W__HGGtbb4)Q@)>GT@t{|!Hetp+l%H#e5ZJf}J_(3~AJ zqXb0<=+xDl&$BJ^b6u5NiRc^hWdsrD00xH6{#k(2lDc?yfZwk1k7t6W;jH<`+R{b| z+4>aM)Ipg;ponwc=|7nO20cz#!j>!Fat|Dul~%;(2s|rWC=+_)2vHe0b|&kHUwqCV zZdvy3CrGJvf+fdCRAFg>BM)D6*GCw<$exj+hMv?}wdbm(%b*VroayQM*c@td_u_@^ z^*4Ru_CBA2hnv4YAwKbb+oTh-J$-GhQCp6Ad!qo?5%n|oy0^Ht`gZ6zS(p;OQiKCbY*pcaJL9e>; zQ=oM=D(YY(i|xlwb#%rxuR5LotgdL-MJ`0?=iuYT^1_yRX=k;UPa;gdQqQVg6yL<* zVj660qrz3r$7Krit}idK^QN_i2i9iQ{%m3SgxRuZp@J=`*pFs*TpE9+%T#|W>*2o< z-C3#B`sy}`0@tq_CTy(Bb&ZC()OiD=Q&u{iE>bH1*S*yn{X6HBKhN*S*EvHn`KNNvAChA_1mQL$mr zEI$Q!L*4Ty8hg0q+@he2!Nz$D)(=Ip(zfQ7HR#1Fq-HXm$G1#BH+)X3y7p@{+B~n} zO47N^FRqe+37bhu=Cgl9KJ*z+n0wfLYLE2~MS6TI4rwjl@3e_I-^GItGtYZLoAb@Z zQJooQV|giO`^ay`U7FN`e!9p&*BbX)Z1B!&>zi$WTQ+G@<*7e{4IJ0uq=|X94+rgJ zFW)76f|P%`Y1TP5+7ZvRl>rylcS$6S7?sf-t5kFEU(k%71u+`BO&fNKd6Qzn;oIg< zoesKeKU=w+5pT#kucgboqM8;K;esvCgiBO-$(Evh-@Ql3NO9o=0Vx#(Qm#y^?YG-n z^!V!H40%(J?@vdFV^Gif(Z7#crA7ZK9>s+3TCKm=+^J3G&mc!Yo)u18 z*Bjlk9^K%6_P>Aa6GofnGjPNPbZ5kty67^zxnm+3L@6Mx2WB-TN%#9Yn8VbPYNB5ltlu>ffZ_Ui?h0&=PiH)yIFg-eEpq4jyH+TV zXTVhBlLbI0+---mr7L%V)Q~(;qxVW4V$c)`oj$Wh>rj$h^wnbmA^e&oZf}}!%PbX(9oCo7pp6pQRC*^~StS48*j5G}iGYp@^cn#68_kN!qNj9hlr? zNU8-!5lV<0S80XnHt67FkwjGTBP%(P6ChC0V{N4azCX2|JZ>;-JC5_(@;H41 zacIz|j8H6I(Ltdwdr1P4^lWta-a=b6eIf0J1P^&HXHc)Au73ZuW33QpFwxmWZ^6`0 zI!g_mp|RU9u(A~WJFEZl%i95<^#r(QRY0-0BSQy6` z5AKEQ&qzZ0tctkgI!PD$gA$u&IZ%M*?b}3FJowu<6ri5QLW42j!$Aqpz8+7AFRr3X_LVtze4Ppy3)%ZW{|u7bUJAUx^3W6fpP@x{Tr1Q@dh6 z8quYH=hB#2uQq)xIe`NoA4gHw_ou*{EFa(SaQx_1V|iG)cs86jX4(h;2?fV;mT!*G z-D`eLGMk?m!iL4k^V)f=4o|sB9KH^sGDsQA^^53L#e(nlJe&OO{`FeF)Rp>jvATO% ze!^_*Pj-xlC_3D0_KAv$>VOm%zLA!d4Hl0nwzJYl8W1lsxe6vcndiyc&Fx!t_f zggS2zf1MbQk3%FAQGPR@mVK&X`jk({qK|lR)^0{V)kX4#FWWt$tSkz$b4HALGo?J= z+CgS2o4xK%6%gTWRs`!Ggs;70edFvdLm3i(dMPf!+_F?@Eoip-`ywcLbMIvJ%g>PJ zp8ASfJ)xxP<4Fk=;@4FPyI6vcU~xB6uUvyl_M?Eo$Dj#LWg^yJ!ouz4Lro>U*5Ja^ zAvHL0^5<@nw9(*M-&mgGSTPX#^(^neH$qKgme$KX(hph(qyu6-k_XvpO(kcl5?<@H zLKiMT+gaDL;mG(?Vo;4a40w%>ReMXZ4vE$qh7RAeU<@^h{g8%u31MsRD>I}fG1sS7 zZ3dA2RBQKFWMqx*c!ZE$tJ^SWRN~6jRrKwZ6jue!`QuvWahoCm*LQjz!*ZrW2i6*h z{FM>co#>Tr6BJm>a9`5vjJ@#;sqB8*CiNpu>7`|@&(e@q4xYF)=r%gisy&Q9q}|om zL~`DgEVsg4qc`LHkT%T&A)yQ@XJ)UUEEqEW_R_QMS??#$nj0Xk)4;YzHaT&Xh=T(x z0erqW^Lr@7{y4=%d-3^%phJum4sA?U_jp$1ORD3`grgfp$vb>{^)*qTUn9v%tU&O^ zYF??ji+haK$3`{Jq#2mT=w>bxy<(u%C3`D(t#rSRc*aedE5;bM+>iAH)IpR?A8fM1 z|8b!n)yL1{?!>D#fV^MiIk)AXW?do&gkG2_#=|7Z;EeOje40VWSxDm`JAbBI*o?cc z`?w*&Ugky~A#COxwfQu)89&CM7kCwDmrr4Y#3*oazWOjr$G2#VwL2x$1`SvYvvJY{xDLDFS4Z`NH2aQo@aWlPaMk|;)qD?#91p5BE>opg}DoPE$ zoIYYE{VB_1wTUJPBdFKe{+qeS7mJy&cVUhqGgC zsCA}k;i_rxr>jh6^J2o@lB;NMX7qz>r0S;;VcH?ny--xJ5Movk}0%_TTm(?F<03Y#erzF|1Nsqy%x zAXV*aks4xBnnpX1VmN9~%?dBC*L=JwR7jh-wXQwc;cRk}=k>M0f1a@PLrc-3g>sL_ zT%wQBVC}BK9AuL@plBNg44C9ewp#qw-@F$g#G*eb8UEqKK-jKMwAK-svCGE%nbf4x*%!PPIlG4)D;+Aq&g#RW>AAuuETSG7Tk)LhR%e#REL(e z6$utZrbYmu`&YKN5evkP3O`&XpMp15!bdHx-7|YWxqhDFy{UQ_Y+tC|K90Ac)vddH zNexSpRA7o_cUYyvKKNl{a=%7HEx{$?(hY5D)LLF!Ym%Xc%>>$!gWF#`sZAgLsWCf- zIvy!-LeHnwTSs=-YPh4sRiy!z`q@*`U(jUtLo!=Aiych8mpUE8sU|F}_C(aS?)c5- znyg%fE5BzXUl@5muE1j$P|0uLp`1hjuPxBE`@VdRiX2T2wj|2)Ur*Uq`_NFAC_>fc zy;nTel$PJ~h@v%Ty?@v2mT0?QlP3T3bt3UAelTpWBFNS`@P!T0A?9uJQ^%FsU_6G} z6ab`bOzn>AJpoIKt47i`jqb^nb}X<#g-)0?NCK!YfLpaRm(`z-6tFPm?ggD!jx_}; z=7-ZS&pJ9W&Yp2DSs(;*sR^Pey3#?6T0Fle*;*wV-9P2dUT$Z{es~w8VFB4Vg&gTC z;O>vUes$$7`ZQV`q4px&+l^vC{=`_bp@j0*(=ovAqaxvc%0T=*QHEtx*6Un2klEHh zY;9rdKW?ne-S*s0RQUHSfJV;k)z(`l(C-DR^u#8#Fi5xdCUMc;*No(@p5KoH!f&(M z)~y0-*Ajc?DpoEJuIgAbXMWbTlqLP(75kjQDuxAbh=`h&ED>XQrz(BZuEy$AEBSKp zEJEDR>yYY1QKtHC!G3pHa4jdTb`>_OX*YT&(uc^}vP}qGHhj$wW6`R#9_tV#u>0Lk zpAo#=CcIgzUQ#IkRR;U3g%Ef{FKUnI*5auA2qPfpmd)p;z0X)PmzL?$P^0}xsXWOU zX7);kzCJ9c;cD?JFQ@YCC;iU5yfcB^6z1niso!#gQkUFR9I1v-85GblKl@S)bQ`~6 zRVe@cOSMZKFV{J~xfn}S8>9SC^Y+!{lV-@}VOg}@<34<;Y2nrFj+`aV-!$QO7!C!d zwBApoY14%f?E^+h&AM$k4EgIZQ`sx#0rghz%r_sOJBK93+$;#3%y6rBCwT_pHr@qO zi(_ZAZS4bn5`#D5f^2mbCbVf3dCh)nKx2x*E|Mt7-_mjJwrmj-Y2~LHGb(Kz8FVQ?JL|=|6-U_ziXh~KK_x#@eE=(e<1`WIHYxeINZ^3!QohU# z_uhN~+od;QMLXR=-L=#1cdV)Y#&3u3`<(9Rqz(6}mJCfS+I0l}B8n6yHQ^oe_?oWL zbq4tyee)F{nk#L?YDsQf!V`bqPL5419a#GPWWmeIdt&>fj#mnXVgp^tAB-`E&+zv+ zTqk{P@`%QXy;ABb_CM8p%x2kapTIq*=pZnFsAa!@^F8R@y*tadw3OvTlE_gCf>Z$s zl{@VVWdi0Fig)G=QRqT`0uI@<>uhFy-(t+pN{dW0w`62>U=_HjWjq?^YZ8pu*S6B(c`H>! zuHM%Ihr95o+jJ2^mH@lzwv1{F?UwIAG2%N1$|OTpmO7jmXsj_NeEq{`_fM75f%{9zQPG<) zGCJTD9oY{8;6ghi)b?74!RWMVehh~_Fx=^!dEQ*sAgbLb&9&2{WDG7WD>w$HG`@GC z?Hd1~!86ZMH)PraX zuR={W1Sq#^x{ZlAB74j|1c(QAdU+adb2PnH%*Jrk zB!OB`Tj6}kOkm~y>PzUe-|=NSwh2&&vi#J!G}8?!p{3gkLTSfs;g*aO-}#rmwF!qP zB}MfwQ6VRbw)vVDarZ`*x8M8-fS-sJxJReHfX?)TOrc9GBFvC>mA#~M&KD+9MwsYO zAivu4DDTeBq3R&+mnPyXf1@|9*V|myp)3tNYOTD5Qbf>1#bodu)wCTwu%(dw5?Aui z4O6I&7hF^&iOo7Ss2TkQnNOAZ6QwQv+u&Gl9GH}03+G2+1xA?qe<5p>}c#xW&lC{SS10$nCE#SK-U zw)=;O6kT>z;B3@WC$|mcFf||L@BH7s>!`kp3y3qq}qMMJw3DBb!#$KZSAJ< z5@-FEd)wPwm%TyKIUSdg%wt;YDvy@^*vL~MYt&r#gNrZm($#Bq!VfL%qp-z$o_`eO$YvLaP9zd%2BCyw{4cUs*m|n_E^ZUOLi{UFY(Xrs|t8I z#ejaJ(W3^r{$&iV90*JB$2InvLWZuQ^8s@}AIsjvn)}}l^Lp_NJ7 zgG4Eso1gV-``Z(Tq#RK6hO_9Yfk7-63V22=TYMh2;1#xu1|!}2NScT;@CR@M=o8l$ zK92uQDBqF9gK|wHLt8M5(2GR)vLdj9-o*wOt!-4o)lV9m2(Z<~DYc3NzH8%Kv@8-F z^fAoR0whC76INb4V7(F<_1r^cACLlUk@9uDDVmE1dB8La^4W<--V`Y_$%_{}V1w;6 zm5K49mrt;q|$AB4z$@Eo475hLLqBAafz?Sad0X`FOIUq* zXh_Xv(z5rVaKYnMw4}$d@6TAWdirx{%gL;7Axx(^#aJLL!=$~8&oq|cX90oamwiA! zc;EQ4+jSzyIA+HDClr}jobnds=F=M}(A$9S2j6!~9f}J;u!dx;v15Zxiyd)U2Hwc| zbZ;4PPdk==b8}i90HvQt`6U41)Y|6>88FQ;4hP^c>qnTW5yD39r-_Uqz<+$J?5CBU zJa_Dx>pCX`Hq{7|yMxhaRjec`xlJfm&8!!xerHg)6D+a0Iixh4F5uXEw0-|KKu<8# zWk79_!O+ZFmvUKLg7S=@oXmQKk}fWcx$bfElXY=g8HFYt7k1l6mCt)9egAsI~y4c3igM=WJ-Krut) zH_buv1*d}!;(UWT^q<-?+erAB1qN6HfV+qI^}H1{Zi5S2b>cBe;Rv4b6?$o&e{XO| z%}q_YpaywN`SDi)?<^kTwbR$FcF8~}rhs-2H0ChUcx^-UW)|8^mDc2iQf-lU0!=c7 z*B|mpK(?*wIA89+h3}LrqQjhJbAes#;ej$HG^DA0cvXPvtv0h8;%dwEXNp_uZQQ6F z&!6>|1g~Jj`v)3{7y&27433kZDO2*+Kd6aImoJI~H3N?ad2N7D15Q65zLOvJC5?w)Gy2XfA8QGphPfd-Y4mVe(O4oP$IL1s4bOKx(S&RIwK`7XY= zkMz&nVZ8q}b7X~Bvf>{ikJ-(lz%t1;d#=kQbBs9U|K{1~+@6QdYmB{9`S*3CIcJGr zcE{~o^@y3Oi*FJM+Pa=Pf75XTzx_d9idwpvn+;**n)uiBb<_K6p+Yj!(N>{(gER@i zLCh2$!>Q3ItTQh|%n3M{5bj3qT!yryv|D(~b86CwgWq@fDo1tk3l~W}phMx~!+cPO z<3-dL`>u8cYEL8qOeRa<0F9Tj&bP#y$R-mKy!VZ{=#U5MPo`I#!ML z#rB<8+|xX6^7_^Fj+U+#m(3%*Th0b@H#{TH2#SzvgY7r5GG_v*1|RhfOFQ1@{?JKZ zwWLWimARU%tI=Wp4)+miY#EwoO56qGoriT zb#F&Rl&;t0?h4qBkOZx>tLHLDd5CLX(2}!yr2{B#KR=n|A!*G_p~5aDcF$S!R40@$ zw=j-~z(u@aO+g3H8&$xc#HR+b_p&+~bk+GK46>~?sK5d_E>Ev&Ai@r=ah0E}Kc^6| zGb`S#ZoWEghtSeY2=0R{{|?>U7TeEbWCcZZ((>7vATk+pFL0QkYp1A#zl1Kaf5u%H?LhG=wYO5>&=eOVY=|l>SIIP9QTJ`xgqB+4 z{%YOF`4$!vaDslM{jXT0gmj;e6&JK@#l*wz2xehm28=$2^BKM5w(4hZ-638dtps;i zRoIBC3kEpS(k`17e8H6pDBRRQOBj1+*qh{!D@Be?mx+BhR>lHmH*NysswCZ1=8V(Z zZ8VH~v7bO~%ddd!)Yylk+p3=UqfG+#7RBbJ9RC#k6MI(&^=r!ejq>ODOF_q91?mAd z6P_V~qFuWi{!tGtdpij7>h+tMo~|6xfz%W{oi+)maVm@c}V#vn=4!cbw8Kc`^o{?Fb-il zaq1r~z1n?agdp;Bjnow%^S!&P=yt8e{OBT+u zJ*;YXl=+k?U3$#E0=6}Y^Shmp=O$kBqVh@Fg7CIK`xWglnS$vyPtd)|_2iX{ceki+!GHyL-9c3$8n z?m#hM1jyeq`3-D+JepqN{^#W`1GN6gzr*zXmf4sFfv#Ree_a-`j2<@|$j0=6mcN)W zH^u>I2k%A=fU3-5x>_-kdU}*G*wfkb9-|~s#KbBzX!fNHwz%ES@$^9j*AxSRt?&+} z)}W}}K!VPZhY4j0{pNg%B61(SM96v(8F!)uGB)Yp7ckjgrUTyapT<#4tC!-JLUN5Z zB&pK9QVZ9Q2Gf>|o~BXsibH)o&P&q&^_UpYu|_uZ;5L8BDJs-VnUYqpdFT75h;}0} zNfh7JTN1I{js08k6C$m@ZtOcpD$38M+P{TAUJMy3AvmoJrPy26wM@mo*0Uq;RXi`X~ zn*0K=lDfu>`C)(`PSIRnm~8vDjq^ZY=PjVOyJlKGUWwGeAXd3h`0Sh7JNw*Sj#X{? zdeLeJVGUq%-n6YUKhM;R2=8IR)W9uoykQ3@nZbk-kk}aJAOp0FXGNcmeCYCH(}ZOQ z-0N`c9pqQ6pAk{!T_ov6B)985CO*sM3oe#(>=s zO5ZKt2E@B<9QrQ1Ozd@GZE7_?DAC z&8>FKZjU@LVg3V&!~~CO2&2=1*x$17nF}C%=6&uMe^$2X(LREUi&Ss^`5d{!?PXXT zsf+R!G+D>bVS|c*@ws`W01Gr(?N%4`yAcM&DS<2#kY61MtiYZ*fMjpCj4Ar-mMwf@ zPpydlnSiN^p@-r#%9!bAbbq__udwI(wJg}wP8t>ZK_9VG)C1=Q?+|+y87c|j42*XV zHx1%!-um`r*@NHbc06dGqH^kWXejb328#XJ<{BCk;o>3J4lYD9(tmq9)mrlYE$2+B z+pU_owSVw~e{Tb<`5dh52}eD<*rpe2o*D+kZxR_hneW8Nwhel+&-qr>4GwCni&6)aLs|1F^4@m5!;&b{T3Hqhp3Rv?|y z1LGMA!O8rwsdxe^rfDExdoehfWWVveE^TicxkbvqnsBvgj1V5Rh!UP)hfI|Ur zIAJ5UjQ?&X!XeDH_DyA*39ok7M7-ZoYjZB7)Y{V(Q4juhFkp4j#DTK$Pa214sFl6f zaQT{g>@{ezzW8Iu;%`0W+_|XAbE!pNHWf6`>Q@qw?j2}C99dKU4LB?7-K~k^GcE2V z0XA+GACS@$YYss%f!)0PC}VEW(G#sCs6;O2;0+IMNYzBTt7-nhfA*V z4IpoGg8mN5oI>xvOP~jHAZ7uMxAB7n8sjvt$J>lF&7i66Db(}xWJGs?Osw%P+&(IMz9al?h`HQ!Rf z^caz{+I`o;xY>bYcN)NF6d%L zx5AYTF`&%0jnt*96c-m?L5-kM>%Ox{5uJmaX$Sw_z=qfDOa3Yib!Ir8XDV=mZW7{X zQYbJFeU@Sk&7o7+(0-C5gxh=|NAg1+$0Zc}i{rEeLWRWl;;(KrN)9S6u z2PS$s2@OqoZqQC>WY_^ga^^h$(O|NS?$P!usD*-plB1?tE~LWOa~XzFU<^LS@)5mF zIwumOOd++H$5(KAGfET7i|T6b%}spUc-#81mLv50vBv882I4WlI^$kX>GxTr!NE95 zvrNS$N}#Tj)<(wLBcTrh#7pq!g@&8%7PDs$+vfHAt#b9&9tPDphbN|nqT-1=-rULb zbBDyH1{UG^wz_{~5EDN(WO5U~hMnkmC%Oa||kKCIF1^=8;G=Gq~ z=zQ=hcd8BmihcvUz{fwRe`y4hRNn9?FS}X9HnVuhOkhJ>kpAt_-u_w_YGIFx_>-gY zRh3(~=ib{T>~4=87@Zi*gMA?{Bl|H~x%Pk-*kLPy40>3RCj5%6m)*;Bfwm!Cy^uWH zu6yRcz2?}z_(-gW0BaW$I!AU5yUc&kSb3`5;N<#xK>3d> z-#oc@Nu=6xO8wkB>?j}))2<60)PHZW-a1>xZN`zPbb4M z)a(s1c&5twezEjHagYSmwV5y&Xo=ryl%~>`P{wbL2q0J1FW^p1doiG+*40zb$@uft zhM-W3^@g$#s6Vmc>20$#Ie6QyLf#%>C(UyeHUMOG{|hHHf7|#g__u_f z*uS^c35Hv>Z7w~}*=o^-t^I9CK*JAQ0&J@^HbH)O^?PjtNZud(`gOayZ26@8qvKspOo-^kTsB~~nER3tuJ$E)Jsso5zI%SDL7bimOju~Zb_5WDq z*$zWjV;1%KOK&gUN6hu>sUS%s;{LIc-4tb0R_+t6)}y^N_&i+%NrtCtb%z+47Ao9T z>H%r|Cg#j@E8T2Guup>bSUxFK*DI@VL2mAKNatS0`shDrm48bg5MUiqCTg!(v$oEJ zsmwyy4zPAdc?r+!H%BCaahpG{>oc>q?ziY6^NOxc|B7f^>dWz|s?gmL4L*H*)1Z=B zLkoivW8io`}WLbPf0m(0&{JrG|m{{_n>w5F?=S)Uh41aNAZLp2qwqQ`! zKce>1{v)zu`kQmO))$?Mt+kIafo0h@xQIF&rbd$CAuDKtoIG5FJ7eSSQ4gwRZ~5im zrcS@tJZuPF1Cav!_n?cXKY&lKb6WcsN^~f^>-&q?nxK!&87Ig>4wS*h8p3K#r&LD@VK=2W(7;raoH zwKPm}-r3I)B0;BFAwa^NB`fR>vZSNWwtkVrM>CMUA7cynN2fJhBxvw)xkn*T&Xb}B z@^`PBP);B?y~90!dy1z7qek7WjKc|3H0RwX!!FAAgMR#8DsSzwOK7S!ngwF-k$Yve zl>3>4o~w^JtYIGTjg7w}+ErShx}D4l-!Ghyw*A$WDppVoh63NC0uofIXqVi57`WkF@h8*wGbn-#JXUIG0v7;A-86l4AY^eAag=r^Cs zp@yE)?@BJP&~A$<4P>}=feM-M{%-PWHJ-F~S-wFg&b2YiuGIZcw)Oy>tHG$P_XsIF z=Y)%q;DpLY$YHVr=6_P>7*N?To`(UQC;kXkdK}!4Uo9e8FW>H$b`}wR;vT!Z>5nKgb+NQjD7AhTb$liUIkNiRt?AOz z6yE}7bMB_+kIQzA0%_U@+sV$A5yHK-=|s-rcJB28k`^qm=LHOnE{?V(9>p?VQTLK$ zqZeV%oi)wgn(MQ!SB{R8<$F^3MLRn0IHz$YvE|ch(#@IJ+9tAI;)3DxnbD~lg@;*JHjpDw;PbB1yF5NKa0IuYnSF?=UtfU@J$pyieoEG zhv`*sV9CwWABV+r8#2EV{fZ`vPhl_xOYObDeU#UkJAI=l=CZn^eS$NSW}j1Z=efU+ z3|+^cs5asq^FWVDf4w(K@7}Lv>(dm%{?LZPg78=6U2uHHH=f+M^bKmCw%%<~9mwY! z&<%=q#cP;ezsPB+yyn@Y1}i2i^9pMY8^$=JnZm4}ks0@ob}#FlvxtVM5e7&G?k7Av zu7}>lU)mXLd+O4q?Id9&t}3(S8vaJE-8S-?d*2w7Sbuxc?2*P$Iegia;*HBE_0TB>~=nvX;Bhr)mWlz5y^4^2!_ADstSegcqO}F9|Yqq zT>KCKhosb$Vv=jXiHF$cB)jEEp<_*Y{gdavvErd}Tvlzhb1`Gtf0B#eLiChfUvs}P zo&26?v5xCW|Ec%)Ke^39_psmQHk&7h5Ol>K`WoV#ey6CDB=2f6z4dN_i^@y9w}-P8 zL{1OkHTw`Iwao7UMqdvH`iDkISou9DZ*BaH*wK2z-WqL2axI)iue`fO$ArUwRLu)^3Wo*|vb{h|b6Gw;qq##H|U|{7k z#X>%UXtZZLWOB)B)7xEELYi0#Azq*{u%KEN}RJ!+$ zfe<)27#S0$F8ZV<0LwFCzJ}&nIe?`SvjaJ@EAeoCT79P)rV?)DA{TPk1YtRq&31@5 z)xmKeHYKk%A(3J>u{N`)FRXs>b5V%4u;n7$B|-cNaRO@?O@g%2@4 zluE>?8a)aHI<~mr>!I@Z6gQ5eOG_#4u42BsUA2_jRA20EPqtC}O7A27y4?Xmc}l&{CF73}1}RxaYp) znTRIX(YS_V==FRN*J#K47e=PF7>Y0+#m*aiB3>^aAl<}G<@JF1fIWB$+YbLv8vX`deGQGrq#ea zN{$>}SZ-+tjbfmk|a`-`0FKYLgYZ5eaPLNSzPK{c2D=G*X=a8DhZE)1X9T{o2^bL${7B96Q zGO1OB>~Ti7kN%)9`jUs?q|T-OF;NX%$>Tve&xQI#v#8GdPPF=O&5}Shdo!(m<#$!G z=06IAlbdA5Q$k`OFpcJ{u+>Offh-T`ME3qQ`P(){y`N(=auB_Eo|g2gUrDtu2nH^f zVyF6uuxj%eBDB4`#S=9$3JNU(X8FD*ZAAUV9~xg7tAjja_X~O`36R03^2U%P`wlG% zH}Y9h52#=;T5luBBr~5eUGZ@={7)4JT z5?|kzzEQ}tJEW1%KH_}nu>*@)*UyDtgnT*gzoWC)O>Nr<^w=x}o;kA)^uhp$!N>ZdfyfP9o42@ z{isT?<$b+BcDp^ls#LjnwcYu$@tRcoZBUzFjV#fl<3`pugg>)+OmjeZl3>D6mSl2Es(v8^aF_IhLhw$cyL-kiX;ouy2IT~V`dOP$Z7a1V z?S9gFrycx4fvLatHe{>#?JM2~a?BLib%3kpnZzPry@jAxd3nGdln-oIS~0{`At3Se zFsYunI%bFRH!ln)-|ljSRXsc<@c;L2URV=1yKP{Eu&un4P1Z;W%brJHtMq zNPtYJCExF4yUW6}a;!6zWy8DAku*d4(L&^sw!iB}CyuEmcs#k8Et;J;?YBP{=+T52 zsi93FfTgfG_(6s!ZgO)*%(sq<{u}R!(z^Zy!jF=;cO{oIU|0;0&u*e>6mCU(4{DQG zLEBgv9SgE^{RGZl-jxeC+D_U@frPOm5J3 zeplM8-5hs@lK7T7%SbbOf)tynA|}f44oTinDpKT#tG@vk;o&i6&x8~V6YE)cw-!P4 zXedx!9B;mZ^I&dVz)kHv@CFtsO)fs;qHBu>UfJ8du<6lRMdUYH`w1;m-3=&Ca+tyd zo+n&gr5REYK1eib6;!liC5W1~)lH{t`t@L~&S4VS=b1O33C0&surYpHE?V7?U>0Id zXzKG91nWc$$WVt-g3!(^K7o-~zp1@%sLgDTQNOz>{M=lA_=bPPEueYm|(#rj20z$ z?=g}hSJQb404%xss!ecrPM^8=OGICn(Md+^S&xjZ0PM`?a65k-T4e9<0 zwftBgJ7i3wBG^L;3}ZFq51=kv8N`rRFccfG)COsY5gUd=Ru?uD z1+5BJ3DTy6!FXkGoxnd9AxzLuqBc_?0U9kt!0f7m2sI0dlph)WhXynMrX~o;n&U~! z*i2OHpiU|MpAjwzrc*hE&nzYv35o~{jONssFtJ54-YQI(=XUEFQV1Nt^l}g;+dNIu zLJ&wvxA{O(r22tnJYw#=Zuvvioix2_{5O#j*DOz~fWoTkA=_`kM)YYVZqUSC44+d@ zT_FQZr04UYKm~4_hEh*xSn6KEm3FQ~wFMp;D4&+P$f&JV;sid;0Ev9}P%XRM1R$c! zSarZUY1>ly2G*eXQ3Syn`Vkc1`jlc2EL^o0;d;aUz4bz`BD1oeu`XGa0Q2NkP8EYF z+XCA4D`-IE1}CLf$?d>JD+K|T52BgdNw-4pq$^YaKjU8MNYMk?b-GF&m%vu z$YR3m3c5`w)#&26Bi@0?BS=mSdibgotMQ>HqschXi?cjVF*T7#Dv1szh!=YSakMA{ z1btKl$HtFjW;(WLIeR@mBria0T<&n=G!>1$Y_C-D>| zLH6?5;*zy{5GmBK9tRs@HN5y#pBw9j*RwGhXp|RaE&N8Uy4yt`6o*tp zEj1RSuuYNZXt}KAUv5O$nxExoq(cp9Qhn;d{~Vy1B&iM7D~;b8gLJikdw4uS;h^8r z89?NjJ+1ZUIXaYah9yy)O>J5v3Q3FkL)E~DIxzQ^5y*pooawzC6$j(ZDBB!R)_>)$ z1P6=5e<0`y&2xY;W1`GkTsW*X@No2!U3+ow(D*^nrjf_RJ6iI6{VJtQIgwM$(L`OZ zwUY2xCiz#g9S|n^3jEWeccAX*v#>fYJbqBbgMb0}TI}@Y6$tH}G7l)9T`I^ZBOLw^ zW~6{cq2)(#JSxUkO+#2`X5q7+QNe)=Wj$f}Qgf9<_`o;q0>iSW=Da=L>1jzESY`oj zHmm@|>aEZ7v0h2&G1^FWod{q$WVABQLgkL)AD0^4^@@@zY3| zk^d$=A1ERr??=vvE}Pj;K{*uY+YFTXSlV=WjtG_v-J9atIsW?ilm|z#ezVF&tem6q zR8wj&l9F8rAGSuahWVf0Ahw#-#Rsq4B z-RXn_p?}4)Eox{!oJ$`U8s^mPajZQ;MP_E)gS7M;Hm^g5DeBEKG)#_`5ZlF%Bcalp zRkXs#<{?N5{m^#G>0Y~_Y&+Q1)G}~(;t$_=Eg0H6eK(%0+ccsP0uSU2nWJnjmzNJ+ zkZhp5-KX}7_Q$6cF3b!@Z9-zU<=mKvvPS{&4Y(`ip>@9RJ+1{M`v2x6q+wB!> zKmm@Ku$(mE4`C`mXAoi29t4x@i3u6En@|aPVP@DM*><{Zd2m+}guuVGm`ZV)SUZD< zJ^T`Xg2l>;D+$9h% zZ=DFziJ{Ebe) zsVJ2cs*FtYXXfi6g5^eBE@Pp2Q-=GlJApP)!Gb49RQ)l zIM^Ws<#(~9CZ7kW-D{#ifBX$k$G(4S^40>cyO{3||IYhuoIpar2G#DK8#KnJbaaNf z_wfVfJoBt=ZmV!BWwS=)^f7*CFd#AYbQXhQO_diGC-EP2UPHxI|Ls<9L5=j((*58+ zo@3&&74{#%X%GxBd4a z=-3-RpMsjuptur}?X0Yz7frF#)UrgD{MeWf@f-_4zD<!H%*3H+v+OXOi*QY< zQHH5rZBX6RXYB9eKLCuzI;8gLJd6`Vx`hY?YD&8Gd<8pOk`WWMK6;jN29qV#imphc zWf2$|pnzHCyu$%hbBIeMUBi7#BX4V`ldHv^wrTY_2{?>sf_`xBHyVF)B`{&(xVeS> zC;seks}87!`mEs}QpvyMZ%c{jt?^S=`_uE5v@N6k;2(!{e=t4mLC}4gX9R{>FQvO; ztLMP$2bG70`S?(!k!*nh9>J)vChHsj-Y|laBME9Lu+$SFCz`>vk{k84 z=@|%X=RLryHC|{3$BM3j%N@)ogyd}djrED1vL{2a8u$E+y4uS|8g^o(c$!m+(Zwun zPs6=KISDDX-oyL_5$`sBQ{J9PwoMQkIbKyKHW!xS0y(LgWfeFf2&=Dkq!3;?`iA{R zbi}rOA}1$=3xL?mwXIJdjP~N2tZGm}5k0JnhGo@PrXlc(@?^LFX-HV0>~ed4dUXl{ zQ++4kwx{4HIghu$Zq}#@40|V~_W}wY|mmKIt0&FSTqQZf%ZO3^wXfZM5Q5*)R*K}%)!RQpx$>`!m|z?Aq^7DI{5jq zwUMP$%xECk`22OzS1xsxzHeZ~8}j^S@9((fcb`$CP3-q?*q9`eCyc#IMq2dkyh=xo zvQ|})%}cu}Qbb5*n|hL`;3q_;sI#wOZQGeBz6bAbc4eixeN-=lzLZ|cxFh?}1D!IA ziWE(BWIo{(DV7ABnQ0QJpvTG;SUJ{j2g-k+-nlWA@I-TKB8#1V+E_gfnhf^_f!g$u z>iPQiAzp7Hw7y+}!~^`E1G$M%TeVzs&Wy~3c|plEibY&*>PEs?6a?3@l=slrP#AMg z<+`^!pc#Wh+^}tCh^K(t{fK!%s-Mz-WokH_IAn zW`RIoZ@~kC_~<{9*(ow8rWDZ4B$-*5w-RM$(L1;sx#IfP31B@bFwJd81S$aHB#Rc^ z3x$L)+u$H2f$ITObxVDwT;}jCq@6kQIBwZni)n0edC$tTn@xK3FPxEzKHURo&5bv1 z_hF>n_pxG1>&k2Wdj6Q1_Lcb-6;G%kF(?9(1&#B$+aKY_fWhwC&Ri8RqQ3dVkzOjj zX3RG0u3eFPPNJT|ZEap9K_1%$Oy67n&i?p!uY;6rDKbV<-1?yy8S+`e7q(oT-eiMK z22hJ)))ns%3WAp;2CMSoCrIQQ88EoB))(XCS2#n6(Wf;AQM(Zm1pUfNo9;)p-po|} zGvN|(Hgs}Y)-1epU_;UMJ<0RzKRp-=l%Av}%&X7#nFmzN7lD2@`teUVunPGVf!5TR zNWruE7-#D7%Pdcz_=AL41%?~xnA}(4Pn23NigAi%jTMqe_18TXPA7I{ebZ!WW%-og zz7GYW8wn%E`M*$;QiLhravY`qyb7t~nsv^~iV;TR#U#qyhrqQ6xo}n{cn>z@|MZvi z8@%lAoLrawE?=K25*HJb2>p#oD@hfaDT)Fr57xaUdI82K)nbWr?rN=*-`g3PowRDZ zhY7S~9ouTr>KM5mu0W&e(=Yz;IF6=V`@q>>6HDmYBzF(y!o&n$beUh?4D~QuX8(UL zKtxQa^W?Y=GY(1(yDrw+b>O>MMy zlYUF$;2yI6J&Sa=eT=lamENXnKiEX6myNOoT$+rpRj{_V8nJ)~8_1{kC25K4a^=Fg z#58DdKe1m=S_JUr8bqdR9s$e}`5=x0EsaJ$PJ|g4E;~e?><`{ z;cg-cFe-2Y_woSXu8paRF3~7wdpK8N?DHg?HT^+t%Ro zX(F)N)oS4qC!g{?ECx`wwVd5U&w`X}CmClitxp&^alEM&-#^MMc&h)mp=JX)$YW$K zWF#8DBF2KxZ=!}lD^JI}_x|^v2Hf^Ci8KQ+QdAWd6dzMl1xNdf{Tt_jv8QzjOqVE= z@l^n}gBB5kw|-G5%Wm7IQ@z<2iFF(O+GocLTCILg=*7-R$_xVg#Bm%3zJ)ev_l781 zVvUh9P%$(t>laz3^lDKptT$D*~>8Ec?@!pe{2RN5)M$ zk<)hTBh(P!{^FC9Zk`TUsV+Fv2oD^5MY9WG%G6^)bWH%r>9#4x&4BYoJi1+HAbyG(JYIM3P6-|`| zEMMKW-nB)5TJ$)XL{u?Bm{Z2$V^ZPyIOaC@7f5l5t-Yg20RnHXI8LR%GB z4Zpzf=8RrM_8vDj)v0o*IxluqhCCzRM&-~;-SLjL?6YMy3OR4UE%CrM@^*sxzs9y8 z0^C*t|4<7Rhs|A>zO2y3kv|}KCio*G4jw{2N1@PA5VRK!ua;uMFfM4@gloBEI^j5; zns@djoZx6Z;GSigQ-CS-wXs0qD7^gf*7@cvHA3arnWAH)&JK@)bgeq1&}7#1n122v z-uWIdYzG_1z0@Z}JjQgSaM$u2E()z~D0d&VekK0-+ZII6=x$eDd>Gkh4;s%BY;)vq#5>I{ zc3R+`*lzjc;;h8>{1yabdqZ%-mFV*Il&sdN!^%6wyk2Vvj0##@viI))a8K{OH!<+K zc@&)ybdu2xhHW4ipB8-m?_9oILA>Y-(y5wfwCklXpkvdPdE@gt=A@qKE3%5P0QDiwhJruCH?glY}T zuU?NFBxgH@8k*xW-)jSEUbCDF@Rj>#4taHB%8c-yzuzFmr+&3=^d05!-HQ$i^(tQI zN=60A44*BJ~j30S}+uh*uAI#tLbd_B$lqBay z&t$b3*NyN{G+_Uc!2;Oxerez@yI>j8O&t8D&}w87hBMJLiN2SqWp@8%I%a4>mQz24 z|JG$DS%T(rya1!nQ*rW%zOHNAwkh|A%{ti(DfS_QC~I-6WL#IB2pgyAc7ZQ9AeI># zk?$tMrYWPPF}~pcQ@#W-R;}yg8?efgh5SgZ5vy)cb@cMZ19xh-tqesQfIA~7ngtTe z`hsZE&tsKz|C*{7MLSA3O9;PxW7 zne`K`B7o+)9dlWj+;;GT=ei<&mALM%&PT{ht?)sbh?kP*(Q@9-sdwML=bKXBBd7J@ zrwI2P;a=t9=m<>(8m$vwiJdkOW^bek>}qw~)U$w(@o8kXo!K|3(|lacw}cBJGsy(1 z@08d?D7AD#br8bLn+slPC~%`viYBvxr2y#Igy8RRJXV>p#l< z^7FnBtx^=&m#PE@5;~w(a&I?3Gk^U>Ed52?jfdQ(sHB5*rI(=id)wbnaxO;2rx!lQ z)Z-d9&R_*_lH!u}^+P)!%mDm!&`6JzG=g-uNF$vi2uPQd^w84M zjdTe}Hw@jaAUSj+(#?5)|8t%5%}-t;Gtb_8t$VHKk@kxP2BwiW$E}+imi3SWb+yPr%;aqizJrBJC47@(doSx2g*}FcCx>dx{~@t^FyWU7?IZP=cVWf;o7Up zeGM2)BEU<97cVFd*I%aUu?!0#@_IxS1)JS+qvhyPBD$l6PVez!)r~|b;VZNkmK@+B z%MXtqaVTJKX>IF(-)~5?>`5oXxagK=QtrUtHz1`2$oOlg9%6C4j95*6n(MKR$?4^;tUxih1_g+j8+iad~@L5l`7Rn&k2kSP5pI1T-CUyt~MIv$52GL`FkZYFi- zq2zT|{xaJlp)V+)BZEOCC#k41abw@3LTF$fo9P9G8@Zi90*@tXbA?LV9M4K};DgyWQ zUk&JWWN`E_#OST5Mo<1A8DL81wFsREF=Al&ZTm(9B~jRi1KHJZhg?n>?XNFQ>ox%) z^Z|2~ciiZI^6K)v0fea_CC{yFG4GGa43u0r?r>R&=jgis_q&k*;7P8!^E^0W2liHi z1YEiH1yKAsD}z04O}U#hjQSsxEHEp-MOEP-#1AtUh-X9UqOfX5+>db+#fo0~zDw0D zsK=2>_F#;j#76u|P5vRQC%8_u^ra4W&vl zKt1Y}SY3?_UDIdXWiA%mq43RTLs+7VkMAjuG=P8f=@*l&gT+qU?TydAq~!kw?Y*0n(^kcv7C3p)3yjF= zvyYQGiR@@r8q2BEqiwQ&C|(y|IaS>K_EyQFNA`fXxU7k_WZ{GXWG?r{I0pYq+Q z#xB5U(8fF@e))C~qtFI93_(7Lf!*Uw6nCXx;Zn*+cp(QN;5SBFJ%TfDf>|$tXU3;- z^|b3NJz^+1Gk@Jxdy2f44W#1mqnafZ?{<@t$zAF6_8wQm4SP0mwcE_5T)$4=G6*G) zj^{|58P{y97TG((uC{4=!!qiaS5-m^08W2A#K8+!>wpc*WB%%;@;!Az2e}F z=j5v{Kp3Br6nF4SZkY^WjM;)@QvRcq)?^5L zDa1Kz0^U=cP%`mCTJ-n~A9o%(yMakD(nn+LIR}|SdVa+Iu2iP4)F%TumWt_Sax3D( zi)YN?Q^N`zt_$@Y+Hlw|mxngm=^g~^P#q5!8rr6fKvrf^CP(t@@Uq#+^gT@UmnD5m z4nApNDPjqCDfWNy^tCoFJyn4oUEQvg4Big$5s*4TJpia5$1uri&3!Sd8z-F(gWe&4 zXYOz8tqUF=$=VhkDRy-O7fmc>hCzIOK0-{n(U_u-YT~qM(wh{BYX)c%)ap{YGLld@ zXGAy2IwidhzPGCmkB+$+*xiicBF0N%iwf{dVkK2Y2O|cLF+;~U@Pc7Z-GFA>`f-oR zN#Pe!ykK@!sja#xe|4tp3%KtluytngYD`iQ$HBFWcYhoz465y`uu8|^TJ~~joY4hk z&jHt8vMQrFG=$YQxEbX@rXvvgW{xz*#`Vads>VN4S09Lx0%HDq=sXt>BF?aW2`%Mh zkQ*0ph*hcu>q@0h#%y)%@%Ij%>jzCfV|n90xQ_Wa8MofRx=nC&{>C)IR|%kVZ9grL z@_eAvWZmJ}F}xn%-!$Fa8N#9vnY%A+5k^5t)DJX=fJQ79ZvJ%7QAa%)__4NHqvB*1 z?m;B_K!}>sKs~fI_$OdsbBRhC4G}4aAcc>;3Xc#%+m7KXG%rAR>K+uacUMg`Om@%t z|Ktd6tcDQ4l{o&cL6nW-4&$V`7iLlaa(V2 zcBjj5V?ij;GUG~sS$l2n3$eR^z^mljTYgW+lArWs$-X5y%9D0dftm7w(GRv0r&{+wpHZWA2a}y0m6tu*v!opdNyCHuRd`niq9-%8$QgubQQ9 zB2DbRND%+_nJ}}Sp6Z+QH&F7qbTId0|Esc+=63opq_+3OAc57>ZOE~GUBSvdiv~Tg zJ;~}u*9s%{U_GO8{{DpsXrh-cm0g48lKsB&G-~KH43&m-*SHt$uW6^`nI-e~A-TQF z?hJ`T7&9P~2dV(n?9K29<`sDQ6hs7Y0BRagO<+cu7Gol0bGg45;`c~5rX|TZ-QKZY zrGBE`3xNTCf?8XYm$oMz)9X>>Gp23y`2Hq$$?6ez-GZk;I?umFg{Q72$vWaa?9yB* zuU)*6?k-h8M zxu(zVNvg-(4|vp=U!b0$UMC)Y@qb@TJB^I{U|)gvEor>qjFsnlQsau4aP9FWJKyk* zn_M)+-XHy=GmsK9PO-3cNIjXhY3b|3Xm@F+h%IBG@%75{muGjyL8faDw43xTnNdC% zSw6I6Y-2Mu33Eu(TP}&G|g6LbU0Sm*14PtfNdR7c=Z`% z=E>g*ZCa-kf3R?5shC4+8y@H!Dj{)gdPQKQ>pA4-HGm$wvdq5gc6Vt{@o@r=rkF;L zJq#JLwlQ^ww&x%UvRHL_=jgFCp>P%8i~hBDY6GbqA;H2ILk(Y(RwV;^tezOeO%Lb+ z3;O&zc-d#c^SiW@+A3OK^3_zOd6*u6J?#rKs2CGzbdYMp%7mg+>VAaPj_C0`~Gl3d=ujGVMzCv)mcs^1v zXn#jV{P>NL>9PO3s(@*i`Xae_pkbOWHE#}ECQ4AzJ5QP%iKT(k$xjxlX=o^kYmfxl zcXpXtMYeQ*O@pTH0B;3(zApfht-dQ#Lbbqe%cVG-gzdo4PQ5fcss_+&Kg;oIMBYg* z;(cALKImnX1B>g92xJlacQ}Vsjc%XyH!A9aa7FIdYgXf3$c8c6n0x=f zw(V&%?~ClE8W~n34;P1?x5$e1zdwM)nvd_cB20~F$?JlWPc#(BUo~LE|IBuDx?3)k zN^v&~v{cIq9|$9zLT;-yZWi#1ma-d}&NyaxG_Y?I}C_HW`TcHBCJ7c2~bY9 zNVbHjVav`N5zt&8&LIR$Y8PIv(#4yEBS5x<>_1x=eg;CgHqcx=gZq!Ohk0yfzV|8l zGAf(mJ!KSZNB!xhMy5D;q%0MIA-0WoA?_a@TY!-S{kdryqfol@#O@aaux)8{*S#S+ zB;bDUa9}^=N~VN_oc#k=2$HQqp{ZBDSWwE=BH|{&X7NA}qznC}bmB8XBRP&-96w<` zI^O?pX07^G0YFCy&=MnF1}t+KmRG0ECV+3{VF32W3^WLqLHzgARY?pQ+d8w9wRVK8 zQ0*x9NZ`J7(37<3tl1eID=27nL?YYfHZTCmxE(u@cP*VEJC88Gc!ghcS_)zRI7_fh zUy8q*S^wc-mTvwAl_)h`i`2b1ybyrGTR~ttGjOaS?1~s=y!OnUemsPv^)plL2CRx>t#OK*!$u-~YBSL1b^Co**?2 zHM*X$g8(u&+?&k5z8gE-(%gr}s926F#L`^Cz^2%Xr{A(dAndFGG{os&vp}R{0)LN) zO`UiI<=6L6$NOz7oo`wqNGB_r)l+{xI+*kly=(5bt?$Dn8xxJK_46_SK!hbmArIst z%>%}bb-)d7EgwCY*&Q0xyL-Bb5S2+JkKG-N#-+Nv1=5mfa;fh0qnt0JnVnJ=qD?=t~Ga?eQ~^TDq>H@ljpx z#rH=VKP4h)E9p{NUhbiWI;rSW^V!ZHz$k`}y0vtR70F4Y;%s|c%FOTu!lGKE*({*wTA(dJtD^TFC)r>G=02FvvEB z>-)=cRKvmg62u(Gf**XRV1{AG6;WsaghVxolO5-z^~Ksc0|lxUyydw-@83U53B%i& zpv@*s%3eb?B2jY+j$k+Sus`PVS?}albANS^pQ7}@D>)4=FJT}_O&u1T9)fZ3D_nHE zj4x%Hz$|;1A&oeOqYohceYCJ9Q^jlu0BoTswP?qMnYf~Kgv%6gs zpq}QpIb5kapK(BoqQOXWf`>n%2>LRPue`l@BL`r31o!UQlDVf@rWJxv<+lQ-Q|x{a ztQ;K_mevn}_^{swT~-ML68teSNTCyWxF1;lnbjp@g|Pn>CEV`sc^P!eBh8%|DwPuF zIM+S>yn1X%*1HhC(x9Lw*TK;_{$)Uk;`HbAr)c?d5ryLDyMd+qy!=R&TTph=y`HRQ zR_!S%&0lQJ(e`&0i*YZw`+*)pkp@r#nw>KWuFtAI0lZi#W2y*&ppoQ0gt~x9v0NQk zCPz!>zg$zf9qgq)>9J0ahduv--QFn^cVm^&L^X=2f!-hLL^H|4Z zoTnnQgrV04w*$c6)my z2Wq#lTgu?9ZtJB{FcHUfT~3C1Vqo(~zQFFYQIiE}3-UI$ka?1R8@utE)}Dwp`R0=h z_gra=H-dVqZ|@8wpqFFgJnZ*_|7svdm7|Y{J)$7^XE*Fa^>W^;!TPX+cmUZ`q%G4h z7)quXRcXPp#A&9vMb*aokx!TEsdm=TEL?M*_)~HT%`yO%ECmgO?cni{!1=rkp2MxB zayB`_L_@X*-KDvC$fAC^!sMQjRxy0NC?0QZ%KgiEti9T&-NbE!A?0Sp_7{){v&#fIS zJ64G3iUdWv5vVmOM%+_Dy8`PN4wEnaEA@{O!Iw!tEM?_CByhNGB}ly!7<*zHgz$Dh zUirm7$b7eZ0rF4UWX)J|Pms_Xp4;4F*Dpdv%xyXB8J-iHkXv`*A@aV*^@BFjXLhV9 z9Y7%L)2z~mO)Lop(2h?SrD9`xjWnZD;m>3;KE(gzcW+=cjLPzXx8K|&(zX%T1F@m! z9W>{O(*s-u{)NwmyRBWvax5}B9)58lnI-hU)w5yby-0wdXNK)KV=xvr5Wzi^*mg>( zN-wAMrOfqL{`6Fzh4lReqzFDC6Lw$4iiM;@S<> z6{h<`G!PQy`0sL7i^YKqM7s=Hadq3vi5kDa8Zl(GvfVnK!<%!554LE1%>>ile$F^D z0gxT5F1q=!FIxxiT$1SYOw8r(h1VH0mVwBe$`FvR$+S#_jrEz|k5l>Gjka{@Zp}U4 z>X~5n=}M)Fpq1YsW=v+x?A>V^h&;+!dxts<@xk|E#DPTlQwJBkj(K(N>|QL{ag&c1 z9`Q};7em#G9)U+Ug4bnXn z$s}fB=t?qNsmWm4T5ztJ2GF&WKP7DJo^7G$Mc)CCS_h}>$$ztHLv7LSO7Qc36-#>loUfi-9yWDVjrkb zuLt>6Cy#O*Zby>h-!07je-?lv4LuQ%mz{k*MIHzs+W(B=haWB{&U*f?;{lI*8qbOP z!uRAEiKR?bN`fH1FM1SA-|-N;IO*B26LeJg2!~^39ZvY0=)5_;!ZBAGNhi7L>e;1? zHIwC|9TUyw@h`_Ou5)$SO}i=+!xY4TaKc2glQB{4KsFM7vgQJIAE@33LWVJOe?g$d zb#2uJ7S)iE-S=s-d-&v zj}Ky#yITemK=u=jL8X+uofSsJQHvb`OD>ui3I~e>nn+0IC+Y~bv+fDezu9E~WR^H) zHL_P!M>uB^!sL{7z)^4{V_}KnYKKoV2u2~xdZX-y$tzjynvOCPVMkM_NpStP#r!jd zq|>%!N2$qkc$VH|jRm@S!mWI7y>5GDKD5c~Nb^GJ8z9UKd<;Xr^QJY1h_VQh%D;^y zxwCtfF{u#3j0`d{P2WlLpM75_GEfppYJYkNXf3Dv*#>{8RDKv}Qa!V=}N>25Y z>BEq7bb{Ujpj*>OR*U)Lmot+e$c-(@@mpU`D2mV{klFZOzTirgf3Nd(daCN*wy;r^H*;y z`~;wS<+KJq@)%HWFl!E%ny?Pv?OFGYy384!3C{b=4h%qcrv9Y#El9wgp%O` zA;{CV;Y&%U(k(G3!ie=$^_d+OpSbLw;zJ5;lbfFK03-lJaTyDnNFduz7Jwej@0(68?D_s-A;lN^pH{f0lH#y6`1K9Q|%!imQ#E`3>fzm_! z;cf`BbuEckpxm6!vjqfyo^s?8y07WZJFyu>{~u?Kx%x(H2KTqYF85_~gawY#Cd~tI z^-9NUEPjZQJv%ysxMUoa^3r^2v6lzPzA*L@IY=7Kjy)YAFIAFe!=bqaKY`VX&uD_o?UiG2}H zxvUIrSZFGsc;pGBOlxW&MS0)Rm+)#cAXw|(?7wpoOe5gkiga`D1{CxZXA;`eayF=|wm&Ucp!8t8#5VXnA5g0+RbLQ4etbI$c+mPlsAJsN z{omUzTbd=+k|&_5`g?NtotW3gHK%YAtMEC7a%! zfm2PTRimji3N}oZC>dVk^6P+^BuX&gyedHvj-n(}WIshn;0MiCnxhixhLTf6qrl^> zj;x9nZahMwFJ15wRNPs-{O~*MAOLm-#&4I^1apFERi%VkKr*!MwaVs|FRcqW!6rGv zNdclIcEW-dY?XDf2B)%>`qXe|<{2C-lkpeqn)k{T0fSmUSye(Iofft)ISE4U6ZZ#* z^Q&G0UzFVo9y^L`r0a(&LO$Z%9~|C^owqj|hK9!jUoHp>i~l5ErnvPxS7OHHPyzJi zSru>NSe2moD+lw6T4wCMB)uh3Pv%|1gI;o0+E}`IY7$ma!5!=su=1L;SLGz$D2GH5 zKDs`Yo{BYy5k)0$^VtSPDRnhrX+W7AXf zaU|jKW44)$tRo|sb0|VGnY2PqV=c-Y<(4r{NjWFu8*+irx3B12f{_Rkpj%PX2&hcJ zA&(958M&Om!wI{dB32EA88C2i=Y9q|EKP9Hj4@x@sP59*P++SvP7+S!Acoa_*Hgly za;HD*ee|R49BekUAyScm=4W==p$MfpXW=<5zk=A!>|hp2a=SK6+KmVlWZ@o{;UQWd z?u?v7^Ai!FbmW~3Bx=>IOtjQ+Y(-aVb2v;WJivP>JdfnTfU;uwA+Pn|A1GFxNXQb5 zxAKz?Ie){}8icO?Hhkx7r!d!F`i*sc{1i*;y1#lT46CU^B~7@OoC!ZHu0Z)ag#Ir7 zoxZIWxc|a~@!e?uRJIH^e{BtT6RMA%jEGFXf7$Ca*6Gn>_s>DhX@1hW($d@$ zv|pdT{gf(ue!FiY3B%`uriKTjJaLpc2p+o#^^{ZR)mj!$--U>w?AV623s8u_$*bR! z|I_8V$E;ms-`NOSiq`y=S51gP;RObQiakIwHJg}%QX{4)v!=4teQ#jR5xfip4NOlCmx-m-lcxDerFRCS+;%iOM#-)|qfkyxF3)s(}H(k_^oXk8O5+2ff;~3blo4Rg=v;;u^f&5h~+;;HnC1@k!{WCazzrP9uiiZfe z>9~rVx7+hg^Clm@36$^!()?|CEe z3tsYG9+{)UO>0QYt69EtsAmg)C4s9QwGdyw90zfwMj}m%L;I(ys$VX1?7bvuB4`M( z&(iJ!kD^!~;Flln@55n&0!H;>g9GjTTok1a4&(DV?-iL6WKNRi8?`D?+Hm_v*R-KP zBL@X=yyGDvjLoE)dcze8E#r9Og4ShA7wfap;A%o9vy9mLn4rbC;1xUi!h_(iI&0s; z+{T_>TV=+6mNylT2*<2kNUe(aF{ICH0bn12FxysWs;k1EN}ksCx z@f{G{Z%&hObh^^$aoU=qJ|tK0g}Z=tX0bh%1-ZHQA)}V~S;@Q=Ih^2*RN)d8rciT! zReAIm_fB6EqKGAXxURPqS(r^7QWAzE4~vfxkM*y%dKw;lf!ScrYl?C$NhBn z7`vqVr*|}R)um(uU}0OBq&zAz4ER6ez4N%U68d(oS){n9%%quCe>2!*2N+SK#)v!N z?n9!^Q~OpOCxCox_wyRfHlftA9)`B^;Nl{pcRc?$`1jjZbzGSh(A=k8l(S(n4oNLR z$)Yy8w$e_LMsTXr6)YpKQ#0G1!tz0^4c>={lMc_C7}1XEq1zHHazvCj$!EZVQ>5l0 z14ZiJmqEL8r*Qfuw1MaD`QP9Vz-RlUVomOP6tm>H8)8heIb=rf4!V!L%yHSj6!uJE{l-O!A{ zxWSF&{duFS?mpqKjjB+4I#MSndsZerJ9)!+LX%~~ixU7;d@)_pD`F!nNWJYMMTL8X%@aYh5eZdZFPF-rRi z&cxOyj^!U?Du~_e_6)dN!Cn-2=137*YrFcaIZg}E{6<4u^P?t8D!!WEb|0P><};*4 zeX2mNXu**`WC;XAKK@A$z=I=Q!_K9hw)^RU9-G6d54QsiT0awXg+BwB_y0VMls44$ z1f(Fsb{_nj*(`1Vf>Sh#>m395WxT1*tM0Cu-c+oTC;jS#U&Jq!_RToqjxsvrk04Ci2`~`)NH2}o`7)4sbxunxq>(rJCJsl)@H#eqG-+?0c z?QERn>`kI%wW``lx13OI1Jwh&Lp~9_KIm81lB6=e>)674I*i(PLnot%*)ta!;hMVm z7xTAUo5P@@v(2s!2!9D!=d#p6=d#^o?Js$nYs>{kkWrcG7cp+DiNayG3#Y6!@KK1b z2^nwPEx*&Y=1A)osNNguvryoLkaVwQ6Kgv1VEj<@6^ryZ?`RG1EdjtR`}=43SLReP z1vnb~Uo~f;kJK!Cq~LH@n)zdkIZzk(CH9Tb#e;m9dM4K-4}pKo#5KtmbOd8W0wZMf z46`WbO5a3!?GSFBR9%-jAyq+01BcZW1l9(r$0n@#k#VJ~U?RnRDq0ImChwtc4&P*) zUQmLR*1PR1<345hfjj&{oU!B~I18Y6BKfDYF5v#ohXaP!K4u=0ris#Y5<2>WG>hAw z0bCa@fS3MsXL>o$uzzXcz>u-iBOivuCga3WU4|bXJ|aD4RW-9&<3$RkoZKMIL)QtW zC)hZZehbW=iL{Vjc)zw>*MuYR-D}qU9 ztGW*M<@~&vTDmQ^V9vFDh5?~MfStW)N*5jQI1``^9;+3w zP8F_U;2M1rkU5d;EI33upZuQeA)(prUi=R`1tA(LF`*|H*Z3J5bTkfOX|JIZg< z#M#ahOBkIdI3U(BT8E+T&m`2br2#{W5AcwITF(+Gkg_| z6!ZM2j8%Gw?SVR2=k#2+Ky4NSAxi6bE0@CD)bM6Y^oo385#>p2T+AxVxD14l(QM4y z*^S6rYVT5t1E|TretkuS8ccL4()MB&jlk;Sxm~XeYE>JLyEN9*juNKYh}2jh$~Qoe*l(`+!N+q zq^H>u@V^WKnMNIRWRtd<5`6qg&$lr8U(tPpNzxDfGNqR)cnFs-O)5HX^cdfTWLE0j zAl(iBp5san_9pSga|B9PoT)7O?&IoN9TZK}xDvnl)BN{Y8$BT$QPCD`XuoG_>ovu) ze=mGaN+jl`b~x8k`N4cpck4w00kC*W3#{w8uoeNt}N2io^vnyB#*94KnQA!0krHtBSvM1^C?<`iUl-3LVP zW7u0J1gDSG)sXV*tJ&}UlnLNM==RYh{@$3tIrX$$)y-H7IDT(6rsWN4FFvsTdy87@ z`M6u!zamv$`U1TXb;;^ejl@*Vn3#E~BsW=3|Kbx1+L-jyz1Xh(U@l$8+b~$gWT7y~ zv_Y)$XR)1cJONx`d@1z&MTg=7`{DJ>*Pj}(8%wnkR*6AMA>P`^(|7C4EpG~{n~r8t ze9BudSERhvgv$E+n%<|B{iwp~pWEXR{op09K}9x(QJ(Tjo4wV}_pQU5dg%PZUfIKU zT&StB`{gTy_gaPj8V+^1=2b|~4iHvuKkZWLh+LAx{Y+yoYy2i7ubC|Pz^kKMzgk$7 zAK0ESujH&)rTtwdBD+msyWG7cub3Q{6;fgHsGfc^OHIb#A8UaoHW2pzwQIIE)p zyNi4od%jgUh8G0ug4ZRL0XY)2ZV!r=aGy1O7*hZ1t^I2+i#k6 zvSCrUV*BDuR$Q&57GHeXc41oI5AsykD2KI_7hAxn!wnJKx2W4(!2smmPnpAnJ^3I6 z0M{h0b$x%jg|wSF?I+LS2>n=kzT3PDN9u1o8ox#t&z7o=180k;37*}Dxa#yqewDO9 z`Kx&9FoRx$A%>luE3|6earDJ=&Tt-%MYek0OVno8l0w%GxnF%K1E4rxZw|5H=0-h! zWrTWZ>>8GQTRwA7sFD&Ylj4^K> zI*2}rE#uV=Cw8w-905H3irB$ue8EKv4bFD-EqsyTEEdU+DD39_h!Hz-t_yKBCJH$A zX!JKN|A*hqRQ~46g_PU#FtQ9Z+&jNbVSpsBU772O7sfy9?zfdbVZ2K~{%qM=gn+6p zt$=%t1y`1WeLyN(sCMQlRFIsN0erP?ARb*UKMeJ(%js=o{aA16i12oin|>uvM(w@J zy26y+Y|csb>A`>8r}h<7Jq$tvQ%;`d>tdZ)y1dlD&|%nfDT$D zmf#FS8ff>00*w*ElSDW&*1w?FlH7fP%fUiXkI<8MC|Hyi0R=M?DuYj zV8pe2GEfQt=GqTN8eZOQWy64Cof_r=i^9Jvd+)&&2%)o_a7e>d9RHlJb zZ49pFqarSwdT;ysag#fMd@^cOCt^_+LS?U`%&<;&ABCkJlnkP+sD0-EeVsmIK`T&Y z1ebC8v*y|!r1M&y7S0$3u)Ns2m=4jXKt#KXtQ2OhCuJVvaEj` z$i2 ziy!3Lbb49j6!&tBBIVY3D|Y~3%C?3FsI6M%P1^bI ztM`2%Z|=UKl#^s;NK~DjkfKyrt*VD$He4|TCgmeZ0#6xGE;ue}5<^tJGe0qmap5YF zXS^ANHZ2t5P4!ey+FRB?13uRRz0rBw&1LyJR5JnqXybH9Cj^Q;iMQD8CN0>M>c@;w=- z34OZuHFeZ^L3#6&C<}csjL+^MEvWn8W zcjKXkTZnJKW(19pVnDAX2^hUL=AiOyt=*rv-EaJN{!376&EAA_&8O%>B6TGlJ?}vL zWY_kSu3q99d{HGYG+pc}gP*B`E#dlZz&Cz|DxVJs3vg4z5%}N5_@ILJV;wZAayF^0 zJ>L+ebx4i999s@)RpwpZ9fL|45-1hiL+C=e>&$>v*So|d_-YfLin$vZj>dea!8@Vp zdisq@F=RLry1a;wK(gc~^f2Sy$ml-2jmUJB#Me#VmIOXUa`(InTX@KcL+wB${m&f2 zH_yW;ZIV4l-kxM=xO?cUF)L+@dAF)Zu|bLdd_kw#Pf}4DvMINz^}GKU_~j{v+td)-t10k) zz2UORG-dedPvS5hqLJ!b_-4>Q)8KyJr*QjnjPA#rXtd+FpQXEBTN*;R&pbdAb3@&5aQx7 z(7bc5`c(qv#M1Pli_mONQ67v3enWt_z;_uN<7BMrG?6E$K?M(~V8MZB`Hlkk9e$Se z?+UW;?H@fxWEW9x`&2XCV&s+-L0G>!Xqz2ce~_UdM2rDoNiln^;A5wi*XZAMvOk|i zj+O?x*R<$}n5uJkUP#$wZ2b_yK+IJT#%kCv?W`LJAf2g$F?_@7nfuy8_TRTWWDWUh z0XX)?^j2i?izjg#FB&8HQ0rGHi26d7l&%(vpVSjG@1KXfD)s-wDY1!FaUGMq_|+hL zs%`9fvV&Ev-@5Lf8|=zB*2*5b>w#Shbz|GashH_Q)v&8+Ttr<0?N+wO+H5%WK=6l^ z)i}jr>=quQ#z(R?DAZ%I-a>Nh<)=qT8O$oJJ+Wm5BgYXd3yuB8d!t0Es z28`uO5p*vmyOqd3X1nFJslU1STu%)DC5mVkQT-%ST|cu$eO9`6!w!jJQ$svBYX#!h zOD3^VtuwBM0O7J`vD|oH;Nryd58v0!zK;8|-;g6}28IcIFDVf@|(Ye3$T zj$P2{ee6{KgrcNHQo7wg_5479S{S=>ZE57MocdEHzEX~4GCiWVUONL;{?_TF5>-63@osUx1W$klRQKap@@V9(4Abumw zU4jK_ypfRY7t~)#R5pBcXl@5ASt|I12yx1X;9%f4-wrv%1gRNJ(}f}NAM>kZw#m*R z)!aPzsjcjHwLk*NN&hHiK%xEYCeCCv;G=eKeg{3-K)y?H@t6<{@05@zxjd0t&Ox(_v-38S?#i}*4(?SduSW^x$4Q> z=s)73KMr0SOvMeWW4M0Qju0R|Zc9#TPlZ?YRBX-kc}`Yj5SmKdvm;D>=6#-i@u+j~ z5Y|kVVjvzrk>z=?hHtU0$3wr<#?Iqd0u6?}KSB5fp-bHD_{@ghDSw+DNQrv7-{0_9 z(+&8oW0Ql`NrS|7#LWiU2xmHcmC*1eYl~{EleS2lPQR-PX}5_vgF~|lRs}i_Yukcz ze10dQ$f|{UfF>j{Ek2mH27Ipyd>{QniPY_RO1#LA*v&B-A#nMVAH_N28`UX`d=1H~ z&sa#dmLoM6H6^W^-kseI8&_I=DNsFo*RlCa1fS@`mFB*;bn(+tx7eUdZLaT$Kin`w z2;P>jc25{aE3TAT^ZxGI~@%tKMx4qi$66q&;vQru!30W5>sS z`hZMY(-}kEsMZZ2yT$!v=#v=8>^FR#dXFPu#6+;Pt+Wwxp6Y?atHpVNr85>EfYb{j z|NYDB%cJtyRl$v%Z&LoE_B{>4qV4rK`LMwM>jki*xE-_5p5qeiQ3&0n(Dh?(A~E2#Q;$qXh zYS~^cqKRskE1}un$9yVHNik~AMqYO8j~m}iaW%=9U;Pzd|VM0 zPdM3G-a|BcrYplp`qBGVwwak6_qwxt9SmOayx%9pw_fmrvGvnJqqS!7gMgFZDHFJUW)nA{Osqs0s{s5>0 zf^Fz5j4}hb+FWU9fZEKM$|O%vBI#&FWYG~o)!7dfg2F6S^v^)ircfPE(9(VMP5ACm zwb@d3SEHQ5RExUbF{H?+DPY`Zco8^6{qB@KS<9Ho8|EK_|EhvMJK~cZ{i&h4x&bNy zQ@Gx4Ejob0<~@IuQE6GrkxU5Kod5xQ;L~HvuI1uuk9!~fSd3)W>V^gz8UnppAPL_6 zw-|FD<1>WTmJ=(bdWh@}K%{BudhB{rxjzXDu_@raX@1TgES7004XI> zwd&grlqL^z6K#`1ZtTr;2_SIR*v`7a0BM`I$Xk=(&1960zcMJLF#WoRaqZWZE0*fp z|IyKEDd*-Fg%F+3k*YT>(1h%-|Kqc->1;q(oiHsMovz@PTO%p{xl-AhiRC#cC*$UM zlD=0(uQ`CfpX-GP3M3wFP%yDh^c`RV9dgS=L~T`TH8Ay(<>lX{eNWbxV|?C%m^1eA zT!1)@?jABjdJL58o<;*XUz7N+>wNziV{Twp(11n3g}|6g>#M{3khs$M#$P1qV6c2` z6DPQTr~+ZSEyG$5c-av`Nnm&tsdKfW0Lgf0hOg7kmo#1&*^6DD+F<+`14dqF)lK5r zhIha+`O{EY95(M)Byi`0h*o%Q?+&ZjI5`+#Oim%lce?+!WCQhu-}FLQ!w! z!&pnfWGuczrR7uqMBB)|&$~=60y3VK*YWjlBNcEc=A8(k-;~`Nc&4N$naqtb0dv{U zfl=@&1v7&fWj31b7<@{JP{R(Xn1y=2DAjpAy?foOR)Od0hplgR^<^IWbN-sf_!7{4 zb81SONVQksPlbud-SYT&rE{|wxqNp~HroHw#{7?vR+Zc$(S0(zZWxJYwHN!F}T zW$-t}sm)@ua)T_a&_1LH7Bxj`Lt)FSB1)f>ED~bRLWSdnk)BwLg9iLcq_eQDzU3Wg zz>$U9v0Em;e%z_zJF5_y07zZ3zmN}y@jNhxCE^P%^NdoE<4dKR}B zjPfpn0${Sl>V~E>$d^GNB|E9CKUvV?mtGVB4AGhpS=_C6uW zgCx|{45zuv9@{$`1&*D4%2CbnX9y2LQch~-`msY8D6DAJXO_^q67!RNO*-sy>m)=W%)T*1AG<->MGf z6c7wtznW*}g~nGJtD^N6-H{zO6G22J9OIY9dwK;u@G-rTB~1EJ5lCEG*&hkaZpH@C z5}AN4#-HOkq**2;vrjb_Yx2j6;{H63ES}PnpWp_fmQH5vPgEZE-)g} zb!Q!dvlUvB40I%#SD62T3ik11m2Tlgtvnm2B_&15K=aIoIF7>R&hjv>pbt^Vg-$_5@^CzZqz2;{**k~DUp9(EI!0FRqN@nsa>>ohiEx-4j$ff8N!%v~) zSSNIs;LqE-B19XScP#dx39jYvV}mzuFq9ZgQP_6=FutU`Z~H*Bt&=xW9v|UdC*O{h zK}?$31=YjDHLKyj!aq%^eilp&my)4&YO!$w(`tz2k4{Hx$f z?iB=u?2ctiRwv;h%4IN8^n0&GU+E!I23cDxu^AsamS!-7hTLy1>sF7cK<_Vp#3!?2 zBKTi^xqHp6wG6l5{}r$>`&b>u7RVQjtl4y)^(Aqt1p(Fm7jNo*nXRy``lGIJE&NQH zxCOinl42_$(|MV+jZMIkMjIHDQu|qGg%|OwsIc$f>q@CazTK_`yRU~S7Z=yR!afg^ z-<=pRM)E6#)YG z$+bawc5xDDK0en`_Py*UZqt?}D|CQ$JHW^XjNEVZ6cBdpTx_g2K;XUq2fqgt|Cf0I z-&X?oNYJi^FfZ>0Fs!1cLQ}L^D}=Ye7Fhbkd1*;dJz#AGSbn|u{~C&u2b*cNGEokU z6@Yo2=r%$UCaZC+Mjsu+in-9mfPA$BGP%WR%}8q=6G`L(Q|);;Cc z7duug0+A5ck$-fZVgNB8eaBY}A^~rQ7%lsJv?p_vaE4L$VKUfxowdQLA|!Vxb(aK4 z?}NWu;zkt9q1%g)&(J&DbELbt51qCe=9|8U!(RC_d6-eh!I%F9q?E2fy3f-@L!QMX z#5<0}_jSVXV<>Fj7r`TMy16dNI=ieRT)i}39>vI<@XYCKtIt7p`$;x@l?89wzHega z-pk!vHf|+Vb~O9-mx6P*Hg9QzU@n)#DGC|_hO~K|68AzS$EV0QrfzGZf1RV#To-aSh=)PxROAA4yX=^%$<+cj*y@ zA!!;2X;?1jg)LIUfGXDZ{-szv1b+=g+1~st#+;1bO1TG`dd+#!+w@S(1&Dj_>VNN0 zNOml7v;$!$ByV{L>z;EfJjYe6FqoZ>0&4vmc_cL8u+P98HPW0xS^&D!DlQlI43b{9 zk1Ek{Wt7)H{R725o{$T*EBzBBvW4&08HW`^K=(5QufUwO^hxe@^l3}c$?o4yDzDW4 zL(^AyMb&+O69WuANK1}%NH<7_Al;qPh;--BQqtYsAP7hgh;(aJ;31kFenj-v@$|e2ZM6<;=l6AOY;;L%2Oo<(?EY9C z8ykxYxE3}+Oe9vK_L_A~nuoCN>&lX}MVh~YdTB|W(eD&Ffawe5Y$=;k;W@w5Zb(~!WBL%9C{+Ey;h$6*=*SK zg>!?C`l${9gq-uf=S}}7sd8t|yXDm}IbtxI;8;OCR41&Y=ltpm)d0m3H?l+Nvbx#o zlxOve)a~bth60)K*&oe-W1X0*_3@OYOKZH7e`x25?KV7O3Mo3sv{H^I#B{V?#k=W= z&*n&SB(@i#h@1Z5cFEl2+WEF$`EYF`OTV`OydielW*r4388x&^)53(Oo;yY$3nKRR z9h039$P+@ac>gunD?fV@z(|cs6_Ij5R>&Yu5g?!$yQ zZIZA}j>Z-(73t}~!O?)J`Ogin!Fvk^XB;o<%$13?O;$qicE#VbhzVDMO1gsUk91cH zL&00Ax?$E`inb^{1BBj#wK?-d?2Y;s!yqIIbir3kckGQL%Wpcsi6~l z+{q<5Nu_Xtj7o+ACx7=oEUJ$ZKdKZGLz&OwmauWv`WT}$-EK?@Kjb~=W26Fo7EV{V zqu0C;(p`ejnLU-$`^ICy5`Jpy^-+`9JtamPY5R+{g|FKZJawW(m>@ADJ?v^3gKJgr z)~^TUV6#WY`fMmO=yTBJ>bmU?_OtKfGY><<8VxyI@5)8?1AC)uVnZD7+=EQfHV_DM z*KfvYFOURVCQ3q0y=d&9>4w!Pnmw3r`D{S&>fW;DFJ|c)PSnrkGDxWKvYaz`s_X3y zArgF-S3RW{9lk4fn$en>{`aNts&3;)ud}nHlLA4Drbl$kWB-+^QEcO3AQ# z1o?tEi8b+CZ;0z#cE=5GDJR5p+WtWRdaI*>e@t9~&PZ&ocg&fE3qwN^qpx<{6QD8w zLZ+D_diNvz;)9Z8T<~v-tij+!mEq2A^)<}91#6mH=c8|GCMsj zN+KT_5(}bvvd#N+%{#teMh9<>Z-Z>yC0;q%K4`s}Qzizd9wEa@^rh;1b(3_`-l-@{ zIhDB8XhS+)+!`vX_%YyC`@OpJ0y6-G})b%MAko{ z?~rQxV#CQSeJKuuSFD77lr-QO*AfA1o)Qbf@MD)Af(3A1>a$djQ=#EKqtC4DjP7l` zW=-~KSvheo$F7Mg=0_|rYINml5Jd4=*wP zRxu;y7GlcUV-R=eBRZpn3i=>G-Kyt7WuRgBzYpUNwxWrS15}g8MA-0;TTvY zb&$>In;HR)I+wJz)U?6K7!@7y_TQlR$XpCWup=pnb=?CJq&jHN#u04wNrpJvMm}no z34q}r=9bAhX#YmkiW{Sy^kBl%boVXa5k?1_-tIjnoVew}sNYiSR<_f&ZihAyC7R~G z1x4e+J~_6|@y`VD(-*9I$5m`HpGk@QlAM_9X_Y^3`dwZJa&JMsF>0+7J^ z6gd!!az3MA5z5X*casvR+4|5CPuUOE<(98sZf*C2jNkJ##EjypG}f5~d4B7e zJz7`8%@6hI>$obljd+M`Y>)i);UhW%Ebp6gXzWXeKL#48F?C+AmJ|JVdp5K}O9f8? zFJeqn!cHRJ>9j1GU^yrOu-3R9?YmBw&kNUc)MP=gO%;Xt zS4rFl{jc=JLk#gQtesaTtrZyd!-F^9B0&^CwsKn040TbDO{ireTzjgTr_ij3al0a# zEPYH%HrB*RQ{w~WjoZ(W!!(`(0%xR3)aN&w^aQl^!k>f%&V!PD-?M-NdbuPtz`1#5 z*&mqw8dBsYWBJJKE4V=#IpFDI+O0;!qM=4MU&)ieO{c1;ezPf5n4teeZ~&|MA5c|^ zJQIITkIaikNf*U(8Pr2em5#xHlYfV7QpD->kRn6!ljGv9oej8DE@x!FL%1!QvrPb+ zPOG0nyC}rjV%zg?(k!^Ggdw|6;bCeyMYRoQ5*cLuaDWA$OqU>mNqx%_q)z`H*Do6e zzepP6Z=`Vpi*vmGCO1ZS`+Cmbh4-F8U$Q=g-q~{qi(RO+HPb)3Q}7rdrA^Q^0#8|Bs1wYyqrsLSbD`x>ejE}9wc~n`SlsWAmk@ky!;@IVLxX3jiSW(FsN6O z|F=>I*0eayl%8VDi~<335#2XT5Y)>sAle5^KeLLZ=I8Qes+rYBY%${Tt%szF7NCJQ zNI!ArTKTb}8D6uzgpC{xGp(?*XpX9t=GJkKHI*!qkA49Q$e%PCBB#1|Hhvy-xZ6{4 zRe%jqTB9wLbg^}=tve7KETH_G{J{ve`n$@R`@j>iDX|pj9<+%b_}WdD-uhm)jO*w= zQ6X!8k9ct7OKbAm`hTT+yA~x+Rw@-`fEn#H9*i(0yQwYbEYSJQI!54Nf;}Q!5(D8q z#>D3t5DYhhT&k=r>hLt|SX7I|;gAP4ak>c9|-8X2ues)3~0gK^dBD=U8SmF#ZmfGuQ>XwsL?-9=R)^k6m zTTBldA!sL_5qZdQQMk{Mj3>hM=!k6r1iMp|nUk#lk;VQa{Lz%ze_#=rN5=goQX3LO z+7`o@jS>tF^PgT3jO~}$1GjVrVDt&_fiy^W-+-cl?_U23wlq-+l(2yV_0j8EOH*=< zm68z4#no_?=LXU0rkf7#gIlTUp5n5c8}p1h9voo(Zj!vW5N5?O?^-f?Whcu2`}^V( zd>ogR&4KdG3}v0`=Pa*kew<*=^;_yieOs(rCJ=X$0$U*tOt6p~SaN=DYOhh+GW{{H zFgEW8<@I;F8C(r`gH01Z;OPA&5N)Bz$wbK*;#z7}XbO*c%BdX7ef-lFH+V(s!`wst zIjtrmX&Oi@ov8JK2+Ko7jrqE#2zjhMeF8m z2&b>%n8u)bL`UFXob49QjP|%HFrB;mHlAoY>C;+l@+Cj_@t17Xz!ehQs4wY;3CS8m z>a1YXV)05OJzw{);Q~g|LGoN6Zxp|@NdZ-XIG?3=6$HePemKPu$C}~5KWl>(^NvRy zy=^uh^4Pp@d9|PaQSBYb72-ro26M%!a#&N&#j$2GkF!Xe&1K|-NvBPAC||Z% z>RLSEEMCuuP6WMu9d7C^jpAiY5N>K`PKE&M#vJ_xIJA7r!iQzmP+Iuhc8B>3pZZ$E z9wX>lRW3*d>K5;KBR1DkYx6(4&iIS(E_u7VRI@kS})ABe_;FaqXce2yN1a%pex-5n>f&%fJP>PZ~ zJGo{hIjsD3`-NudZ)~}w{7)H4ELIGs7w2!Tyo5VqFUIz-7qeG(q_3!cBGpK<)$bQT zSUI1xYSKU1Qr{Bzd8w8?Ptlq5#nHyJ)0;;83YtlJ7l^_lzkV^g`k0`xbr7dK-#iKP zDQ9e4CvYA%`3xDIyU>?#YP<>>y$LbCoW%SAK?ikFn(+B+;zv2DIOmOQoyx{Y05A;|bpE9)14#i_*bw z6F&$aX5!je{1!wtWaIN*;c$Jmh6kyX-2z9RV|nCHOrJn7rSiLDHyjb<$s+S&OfkXp z#X>=l3TKoa8ZgBW4fFSklswz|YGGq9!Wfm4n*;1v;TlR*H6w88_ZE5xmUaLbzUwtE z(m^G&E^_FD2a^GJr9#um$F8n6VVi@A%F9{zQ+<9A2Y(S|Yw$XS8%l`jupJO_F=mNn z!Nky*6SBjtYwN+OMcv&(O1VQlec0YFUO$_IWLletNz6+g>YWC-Qg*-7u$U2yneNKd69{_>3_mY__b-Rm<)tm^@&-ef)7HT zJy#6{m7xp)tKqZjAqcLyi@BR)q5QDvN^!UFaDX=kZC$X#<_F!(9ihOOl8}d;SKh8_ z{N6T}eZCaKGYog`VZzQr_{Hw+!iNXW3(xCa_z1DIj?^&0qf}8|T-CGfn(sgMHE%^G z%Z5;SLv?G7aVbaBXfNN|L0h>U#C(t8hzOJWUlPsLz|Y(rpq$(vU-D^rKsqvGi*>;P z?Gg*psc!k@o-NK-WzTvK%0xl~IMXOSWUJG$t2`(=Chnc_R`R^x-vEO`-45Z&JuElWE2`{FWylGrJ~ z{+#x8b7yz&&0j4$<@|Cx$e)A@gKt73up`U95Dl)MT<5WcTO3QZYY0i4VH+|h@_*Fw z%)fD$y&&4R;yZq*bOeO#!>p+@snHI>EU{01kQo24YGfREt-bXpM}7!sf3~TC<~@rf z_aww&_qaq5)Yp#|vH2|2%+WPwM{y^=%=u#vVz@89G)S!HcubnRo6g*5A9Qz?Qv?Q&h9*6M7MmRqJ-W0Uyg>H84xi$D&)vYqh%dIofIE7#9}C6 z#R&4mQliEyFbYD*dhD;14)-UC(#W@unMgH{c%CZB0hy!LAcIV}$}Slcn!yqrbiRYs z-#Wsp|EiPxdXnu*VA#l)ok2k70l%R9JtD$|zbj}5TlJiEO)&{2zguu*abwy}Ka+5Z zBJAqbnBq)kdC_V|(^~y}jvKts^1q#1I?d2Us_Np}yM4*^Uh>OTtz4sTen%tsCZi%Fh_fq;>l}8ydx#9FBLr6sL}r;ielBwxuzj;56{2yX!uNv!v+!aopPM#g(lcVq?qBH~d~EINF)$U#T1279d|DlV^p!j10E z@5k7sDTiEE_ZMUn!je}&uk!!70FZy}Z*PcBSDPP24Sfw)h>v2z^0*-tYV?zCL-{iKc|0k?#Tmub{f2oAwMwl_xTD z>O&RkFydz4-n?@$d!7wM!uRguvuo zcRe*2u9p9i!~&Qw;7Xer2XlbKv#911k$&yQr|RA3w)mqVGS^>fM2M1!F1#n4b8}{sAcZ`jY(VW zw0ZL{-3gS`wRn9n{Yx&318(zE@TaoT{XSk{-q(YDs2^YGa72MxeIqC$PwpG>1?kf{ zfB0Vc$xMC!U*6d9Pp?fKxV7yk)zmCrkQhMRaW%wf@JV*A9P{ViVtd^*32>=9cE$2E zrL47OAWd9gFL*(6y7*XI6bwB2c>TV%$0OY&iiLSro(jeswR;kTTHt?Sj|JaM4TIiR z{-)@KWHSIFAJ~w{boB3AhjWYEs0WA5WYFc<_qz~#lw$$TlR!i;Tf=(jxFdN*(KNyG z`#llpy`ve`-yMh|q^viNW+%ex=BKKhGOYTP0-&AYKXCNk6VZyUZbZUmQ&KZufEW_h zhO2t6KTKieeQ9Kz;8OAq4yeGyUtVQ!DUK`=I{c7LH&_3#Px8x~VRRIQAQgHc@w1fh zfSHhWuX@4(wDauFxi*Qj-{Kba8sf-uliFrqf|Ca@GJ-3mY~19vXtoH`nr<)H@Tg?K z#%t(pXs{9O;C@uMninAsK>AHmh=dw~uz)_)4qRMFbd@2nrL1y&@Ly+rpz7=Wdtw(Z zt4INHQ>VWY5sgJh`2lTBpySrQjaUE!HLX9hk)n(L`%jm9`L5j%(+eo#JAdRgIZ=Tg zCF0K92KnApNpW94Tn0vv##`hE;=R#*7h;uHWvCE<_+O7wRg>pe_8QD4HgZdFJ2is< zv;EUlbsPK)dotRTQ9sV`aAF)%mkGOxyX!VU0_VE=st+y@@`L0!3yTvgiXsPv3w@F> zVb=S^x!)B0`WEda?gJ%-;EL=n1@i=#-i&?|-kJW`uS2(|WW=2`Hb@NA=RUc2bAWV~ z9Deq#Gb>y55OrPZQi__{ncI54*|qc(RwFDS1gX$2I_Y=YV!|s4)9pn-#*W2Ybi)Ge zA(_3bU{tk%o=a|$PTO;9o0du0{Y@2er)%?Nu{8vu*e!I!SAMf8-w!M+G?|{Au3+s$ zoI`S6;{nUlH~Tv%s!$R~az;>*{yU%W%>yw#qNJM&=h;}vdalV997y$nFcam>KJ_K_ z$o)n#nSwiH721c*iT1JOM~*y_aDQEC@{?lsRWrSly}IK*ZMj)?bE0X7RzI!enVlk& z*1KH+V+*$e<$DO-ucQwB!O+bQ-#c$I`pcC&@$I)f(O5}?Nr4UEsZ>rBWSY`Wr3?fAM|55<;l z<2DI1>rt8e{g2f|_)6#v0bBQ>5X{&tL0!VQ_;$H!C8v%F?5VZ^pWcsWldbe|pNfn!Pu0;l34RO!J!&E{S^_7V;Ls zfXi`bxrLh^q4<#m3C4H9r?_l!7z6GGJ@Vbl4@giy8LV^0{B&{EM22U#rT6$&(E}xv zghMIk-WUxW$9^jN`>#osu-SFaJ#5pf$}^PUww1cIzBs*Ezi~N|h_^QVI5{1-EU+*9 zQybpitHX3Cyvh6-qoRS61x?#6%QmylO5RW`UUnWg;Sp^P5&|*47~ucWiFxWd!4!8g zI%D^flxPjx=fSr5CNk}wo^vGnjBlh|Dr`O8d#_yX7_6>;=WMZgCM^YKlct3`2#i$O z6?dm@DYS|ylU_o57PdPw@L}yGo|V+CWr+fkH@5Z%xzc$ioVp@r*KBO*nmk_LPCrXz zC?&5;4%v2mik8Han;IZ!@kWLOe|DAsadd;)!?e5rGEgmiKYq?zZeQx^j@O`706i4lata9?4Kj8^&FW^G&a&#CxDMv#1pCefv19cmW6t%we?o{ML1@_pD zk?4v2!pbpTqxqo>QTHZ5Ro4NB%fW@66 z2sNhJJA?JVrW6YU{dVCtlCN`V{J$Jy+FuV0Z_fsLnU=&POwYLfD1~ld#<;J9<^J0k zDmN9sA|M5+P=qkd`11c&3xjZZHn-WTR@7$4MO^Yt@Jd~J zkCc;NW~J&^gbLr~opz~$yL4x99L@ZE;_94ID$T>~9cK_s1caWX&YXlT+AW(AL^x0o z3mI|meoY026NebM#?_(n$dk4?f8mi74Wi&AF&n0sfITjpQx^Wi;B$x>PoA!S<3CT= zm6_$+6tU^gfXNB`#A+~6^xtUa+(}9p9r18;me-}Dn%u^qSTPmQRH9n)buwV5YY55g zg_^!%R0BK4xoxfmg>3A61<|N|)LMsrD-?h(uC95)$d8Vw1qL2T$5YTAiT*iMI~N{t zcoVXZ?Vyimi|6oX0k!3QGxv51wLKFmypPDUyx`qNtRH|JIewX_JFe5r5PS8;n~E`N z8>3>r>2*`%3-ris?Ki`nukT_IK!NVJUn1jyJ$}!F_qhZal*i%_2#5Z*-I+9b z>)??w%>Nn<-p@j9gbpVT&^Gx7r}o-!=s3p``&E%N7h=<#n^3W#O!%ouMbx~L?53v# z9h8*)h4fB;9VW@4+f2zLR5hQ1waTTEgDJ;pL;@R%z+T+@^U+!FpCKwnLJULpUcS+y z@};W{A{vNAWbayUZKU9^N)W{ID$1Vfb$jveUDtIkdh~!^=7PFBq2eyp*!hoAu};_F-ky=zHuM>V2saHPo%k|K2Ktua#>C0Q6FL z4)7KmAA2D+POO?H?^h9N&&iv^gMIqElRTZGKWTTsiGgAw)Kz0zd}m2Vnc6Qzi4xUv&E!X%yk=*c^q;dE(dKkC!DTjcIz z8LW?!8OL2a-FPCo$Mh8^y-(HQs@xc(`fW*FwL*P_9i!rjcG{DZIdqCt`#o;?Sh-x) z8#y1Mnh>nMPPjSvShW6)qC9pJI2)HNuzp5K*NL;FnA|DKUpgm1{K zp1JG}3)nC^N6~B=RVi?|YLzvZK9Y8bs79bw{~qioO`2Dl+?s!uD`b+mGi5wn&7ZJ> z?Q{U8$Ajtkw=L^N`b6cWzG%D!SL+>WWWb;&g_ znAAVnFtLZP4i%N3^;w{ZP^s5Oh*@WY?z>~&4o)CChv zv_2}ce#ryZXSs9@j|TF8VUk^<0VOC^D^59F%a?Q7n%*IoQ((z6V1h~6uyx5 zhLJz+h4Dw=6frI?!Z7vJB4yU-2d_pMNsbd&DGCrW)h8PD)Nfu0C}@~`EkWr>`sGpr zT<)$R8Np9f_}|EO<^9jfKzr*P_LDeF#(qczspOxa zX#bh@EBnxQe)<_tSf}i>m0#{v&WX-6)1UfD|N@fJOCz*^;#z{=7gD`_P zjq(;=*tCAac`nB+OAJh0Llx0H17hg#BbXeSf%a6AYzZT4U&f9Adb=z?n>b~nlJTjZ zoSJK`>Qk-;CLG6kj-*}0EJK`Y$!Z{(hEypA1xLSE|K*`_P3i*z9Iu}u-r(m3z{fa( ze_*mC*Z@DE0|m1FK%>&M7q~^~2s3wMD;?4n*Xn?3kr&AR9yYpS`h;!*I7T&QuIGDr zH-}iC@u}#Qvb9)4khI=hecJnxz{)UFJ9MB`j2+?$+T6JoGxv063|lQH0=LL{-v|n2 zE_4HZ!bc&4Nkf|YC)Tao;Mb{P#Oi6&)Cq`2^VY{MSZE)d{aHODx##68Umjwzu&UUhR&Lr zh8dL2>N~{bFWx5JT@I*^xP@-+jp8s_4rxNp8m%DY-f5gCRp~d(;AuB+S`9d-79;ll zL&aB%>Z^2-TzptG43~h|YAbPb$K*_cqpy1Gd0cq}&DqRp)C39g7#qcVuN>_*mPPL+ zvah$aQQ*+U5i}n-_(7}uHFqp2YVnJaJwf<*8f*`!R=0K*P*nH|jxjK1;i6FZyc*cH zxa(l+X3Id(8aMKib2{x*qea8Y&B^vn~x8>X2A-yh%VU6h?v4$W_){RA~?T zS0-Vb?EPR5h7vb;>$tP zI0Q?Y#;!x^>x$WWf=89yfsr0Mtc-Br?}0m4nM^!Hah+KEne1z?-~ACFu= z)dacZq~FQhAy&>(9K_3PlrT7l@XiId2eBGTthCXq1wDqnwAmz2j(X%w>5~Y-UNRLt{H z!wkIc+OcUW8C!S9ZN z!fR;cpe~J`QU?>xTLIne=DnVfDv6Xh49ud@9nfR79J3m6-`wa+oG|9W@mmGQv#f94 z{he!OHqUl$t$W>&f|syYXO;e00P5}89>t%{zKj1F1CG`q3Cxc7Mje?cvwn+#Zx|v) zjzo4l)Jrj~vK!U$OuRUCv_`M*06xI1l#_3+ovkD)kT7adq9IZrC;{%0B7yzpAJ>1x z%q{-?3)KShnAVsU4iQlj^$qny@K;Mq7pr{>iXU{G+I337huM}fCbx;~WH68Wv9wW^ zY6G5OihQMqKez~Bocjj=o$t0S#59aw4z<1Bf})YQl`hDXFUN%Sh=UbG6B{mBZ*Trq z8-KR{VjG(N=&9o*sxM0DlbJI-QDMHT|D1H6CgvzM+}2m|ThnSC_zi z#75WJ2}l@L1*cA*Xjsrr#cur&HW8rj)NE> z#D;EFL#UBvtJYNHmuI5i*+U6OgO}TZPVu?gc$_6TL@osl#N7wjGz7t;e=V=Y#^$W$ zR&)EP7Z+u~Fy5#?1yGQfnV)(O=NrXcjdu*71;LJ6IbF_^Fn#5Ikbvp^K!wIYb9))k z9^ifC0MirD^OT%4h1rBn5WnI50`VJ@bZWdL`qk04EK9yr8&mAge>L7HM|v93@$nd; zxY}H<%&O|x#sm?&(?UqRe)#fr;G4XE4gP7n?t`UGTG+Ut3B4Dc`2TRS7~e;=R!-t@ zQz7zXD~O``S2>rHznhPp3>ZTzyASiKI^6C(O2$b#VRA@lprrQbvA4;ZT*qYQO=N#~ z6$o`HKp{ex5*$A!#>UxyCc9I1PbX@xeQ5b)jcy$Kk!9SPy8u4BKbl>I3do`XA^HCz=PrqO}F$Uylt!O zhrHBPa*RwHEFD2Asaxtje>s=wq$UvdLJX^8md1lN`|1E*z`eU}FaW(;rEF1pu?KXo zEt32p%jtF8)ytw4F?roLwOf2_!bs4|2%n5tMq9u7fjvYdPJ9i$6fi-d?u8Q(1xONp zRCeb2`sw-jY_J?~xBsOu^$LlN_p8`bxUkIGz=WLYOaU`e7T)9wF)|FE^IGo7b{veu zfABr!siO>-=xhf?L4ht$^JOE{&$6C8OOqOo6!`tdl77JyIk5N@z%chT&FIhbt%7nW_^1!GB zSK&bK%v=0=E{fC)9F8B12)FLqPkuxfKO5-MqXyACN?R5Ii@A86(G z1j~)A(`_Rh;8MR(QW99$>U{1CUguI&#t)tcT-8r+E>H!HivNjQbLks3xAgl?DIZ)f zw$42S_LF8|D|=8sm|%h8mB(vSrh;SXEnjw7fp`U#8lg}G&;)A7mzs$GW3#w$y9UB6 zQ%veUHJlSN%P2a%-zPBn1O~3~Epo0~kNg-c;-~Lj!Kf_#=G5taGl|nhMdnr+?aT%1;}31{2^zvf28_XSoyM8w%2iftNo;dJzgJsiH2t`~$7N z>sg4eloW?VjLF3tw!bAdt-Cx1pQ!1#n-HP8|-*r%52IZW-zv zVT7%%#A~x3F4?$aG-~F*er}_id$zqNPU~?9CUKxVa+vhEM=)dV0N~nR-`v|eN9UsW zgGdnxDXBquBL&)1^TEzobsN{C`PFd*CSuM)p&Hm%b~?yZAdLujLBuu*4)V8Vk?*8( zlL>Q`Yze?n689x27JLz-S`b7`DNMcFiuz;eil$UIygJlL8JwH?B!z#JlfW5I@Fp;f z@B^0f=7VFWAsx%()Gh zAc_ycME~Jue+Xcmd42mQs?p8;K;c5-M{voK{sd%Y!)N8aPIV`7JPc>00ZVNkCb+O=mh~_vV5lK_ac6L>z`r4FT(fU!)6W%8Vk_NtkaRM|U#8+W zXLpVe)yP3)u~h;N_K1A@2eyXzYXaJ+lE;T5^F7w=5zlSFEuQ5wz@!M@Y~4ZAJ1_ns z4gT)~nTGOhkELViPW(}IP;kd(MnNnK2oEu>{>Tf-_Dy%nAMYn(eW~CFOvxsXV4M0n zzY_j_{cUu651xZDpq|G&JcoP=2B1m{GCx_P4d$>-<*Eo$e&1bpe+dq&%sux(&s<9n zKDDgjTH45IEViCE5SYcgn{x@L+zFf-XgamwYWA*`Z)eGd)_bNFMweN=(c`?z63&h1Q190(%Ul15#!q*f~|M>Wr zlslf&$vZW08s0a&ws3}sU9XrH+_mj6XzR+CU+tNH6VSe1w6vGmBf(ej8g4xBPbopcg1c&>r!I8?$RpSOU-*^CrJf#sHkw& zPw1?~{Q<&)MrdFDBjthNgB@RZOM64xLew~KWZ4b?BKTz*B7|M4!{#3Sv+L03buJlSn%)p zAe1zZqOrF>S7*(sTmKc7I6|=WjS*l52ATDZ$0PfG*dX`?c$j_coh%+lK7vb7S3Cgd z5+ttq#XIxdx~;%J%o))zf|5w@b4W1Im92zNd83HJH=zdlRLg>0s97QBzt;dI3tH() z#zXA5yH#!>`AELoiV>e>Sow#X$86leD*(IM4SST`{wj&rf0Q>XDgy~KYrgd{o*Vpu z<7$%xm|G#%h3&T#{1rCsFJD3TlCKfM$H8su0e_Wb{I z0sMxv%wwm*ULW=`r;yd=Acbs7zN|5VPPtMvgf^b5@nd=ccE1d;71Dj*8tm?IZp!3T zU3)uHWM%#yv;~ebT3J6p!=je~S+$XZn;emvxa|RiG{sBlKZL_Gy^z2D=Ja1l%a2Dr z)5**8Ex{?035uhR4Rc8F9T$;nU87lv@h9r84*LX%A_|-j$9Li9iMvG}#s?Gx-eUBP z*EE^0zsUo1o=pr{paM$$rLe#?Ry|h9Cw@&t(p@j zuE-K+o3~eTqG?QZ4W)tGU80E}0h;wYbEOHw7Hlo1lrZ3Ln~w}RRwvf3nVJ7s68Orx zi)Tyz$VP|~JLlt0c^X9Q@L7;Uv=9MgVoTC$F!WoKBeGl}PBxNv`lkk5{XJ}oWeB#t z8IRw+QB7CNZR8FbSrUWm=Y&B>>n-A3eN2x+1!b&4? z6IaO)NOvlLmxQ4$?v5JzpV}nQ{oZt_t00}t-FI*$1WhQJeC@zF$j)US9u6s?zbFRU z$j-U{JKH6w)9G<9*sYaABseU_df~g^fua|9ndVFBqTwlIG^w_E;X3@-CRtRK#XtQE zFPKjK{Sjp@z|X}Tw)?{5u4 z9C0%>)R#pCNTLRKLHFW^jfJi5k?nZO{uL-7mi8DNg)(-qRkRUubB}oevTfrf^`z8p zJQO9QoU0`qa(vSP0kJH2r7ni4EShTqUxwdK+~I^sd~tESsVY#qXn-kEAov{IOg(Z44v|SstFk| zA1jDBjExE8Nl;+|k`t;`0hj&^h4tg8jOP1>^bid$n2(^sd(vfH`vmUr8E0Cv{F4f= zYTRMP~K;=rhQgoMWqbLF?>yn$v| zqx$g|HxU(C#$P$lgXv%6{o+n4Mo{gQ!kKy0Fw;4)=h53HWqW|3vHQ_)fP06c4r}E= zA^nmxi#F2aV{`(p^2wUN6N09~LZa$(_NQ00n*b$d>HF_c)5&EgJJ(shBTouy5@AX_ zM}t8#eewEJdOlFm1Ojj**<-_Zxnuq$iKEs5gG=j)aWSI=(TK|97d&kdUSTn_K&p}L z4y_Xsmha2u5bj11>$DupQ-T_a_C56*B znqO6xVjG)VT-jk`qyY3i6?IWHx!VLFmvi&3mOz4`gQ9Bn5vpAK;V0Yb&m~e$)tqJp zs)XOWP})GR*!V_|Fj2IsAJ%7GqR+R8$<+aR{i88taZwcn;SBWoKZ0nx>O`$&Y&~SH(SG>W(subH4T1 z>dl%ApbJ`S9hj$(5xQ{K`fvj@!gjeDd#)GVQ^r-rEr-?=Gqhzpow_$Q{5Vs9T&hZg z8q}Dk#8s0tVDWZ-avfPm84OVF3T`$W~pf`?mR+Z+JwVzD%)P9!0#TC(jmksebF zpvJnvjZkQMN50^2*sMeC8PBRt`K~`7sH|V(fV4a^R8kO4+Wm;Ff6Aok1rb|F$++%^ zaiPMw`CTpS%JSd>3G!j163F}x#0rTxg>`5Z5g%(_=U)zb_yKnguh00Sj|G9 z5|V?AhFO{oIRtoU zS710g;hNt?%x~D{uQ=l**Amay8Y!er7P9*nOZ6^Lf$-n%d6tR5booF}j31QuOjY#p zPoRc#`uAW{7X-zzaWVTOn@{)n27iKR?i@@rDhL4#`4;*Kdh1{&%y))UqJe7~_EzeP zIER`hLCg_J2adJpAp-nk)}m}O(lhwV)%J+WT8iVHL=!-Z*Z6<|mv@m3$-Y+y;+Aa& zM*u${8jo?<9{jCxMN>m}KYZ~Un_EBr{2{=)u5coGj73=B*A$sp7eK}38t1WMed#0t zJ2nTzNId}Klvs3?6)BceOo~q2jDhhn_OE#XuAxG!o0r(5U5T^?n+iBIo&ZkVSvuth zInFG1!_7QQuzHp(y$g>!>yFgp{Ucb;!Foi4pEjS=jg8U~``ueRggIv15ua4Q#CG5T zT{#CRuK!JnFQlA0ND)`B+QgKT^9HyO>LNa%!vCFj{qU%>PXBx$^pAyel?@BNM?5^# z2JrpD>au=Ba1ICoLV(R6^v-d@W*~|o@K7adT*StWsM|9-u-XtD$GvqGT|ou@%bepb z!*`gC&;;RyR4>L0_gyD_CgOL~mD4i5x~=JZK15F&U~K-&y4A%QO9yY0sQ;}0j_=ID zqH>(5so?aZ+_&lvecf}2u!=^PGTz7+ojffpI6=JEs{t{3*hbOEfXhZ;e=tkeBf-kX zPlC~&$SbNs$Nl<=4bGdwOs_QFNuGg)&vtF%$02|y0^?ZYtcy>$w721P_8iuQ-)`2w zLT760N51ht>@#9p3bmD3VxS_ZLyw%V!`gqOp#GDYNt;kbv9P_*d^p9stQTZ!qg^Ed z4F!2?p5583())Y-^z*8og*2=0LrpoOART~aS|p6LNj3nVbAH4)Aq@*QL>xn*BY4Tg z?J-}oG@xFliRiGOA7KJ__yUZ19CYN$F7@3{BW0w{7H3)YGvrwjDt6wn5W_g>^e>Jo zCdoi7(+0nCsnW?utL%QR%RyTO#o{zxVf7-}a}M&(qt9c*CK_nr5$ya9?($L_chg$} z0hpwYneo52qDY0Xs`srF<3t4}yT3rw{|ErAm)te`Ov$qhipqA(r${;s(O)q+RxL=- zR=`(=hnwZWU}F%d1%^o;k^ls0lGbc8_PG?x+l&&BSWaY-&%MVwcJlOxiM1v6eVJ<_-~36Rm2h5`uITmbVYy|(UaPg~wW;ElO$4c?6PkpIg256~euavGdR`NxZe5!s-?Kl>Wj zo{(C4O1mZ({ZENl2C3GE0ZS!BZ#}=#B9#O-Fw;4O0cy?M0Yr7-rU1F5r7?FB7rqUS z{Q$InAD2D9fotuxiKDR_qU}p?&SCzqLPCsQ3YAa#x=;tc0X)1%=Y7i{(-Uqo99WeD z|2ECQ{l@Y`ZoGJ_kQ>PuCta4=hWW6?qd_e+|*TWRL>TxL~n|( z@_lW!B(gdZtTvx8xZ47z3A;6eh@_K`H>=mL)(d!UH@di_(`O8OHT-j`1Qy?~jg6v0 zCZOVAlQiR9`P}WcPsBGC4n6`G4@sbaXW#mXA#}le+#TmCVK$&p?f2Di_uH}+H(k}q z=mb@}@F@sl`*~!mn?49}Dbca|g{h6m`|J4x!g)aJh~QCl^phcu=*SN~vGr&?IekSK zj*7i@B})3v(^~8sT;gL^t;uH9Z>>PC%95z2ns$P}>Ig5Sdty9L*db^MbfKGTNSuYv z(N1s_u*m-?<@@ZTL*Z0;>y{cmpH^88oJ~)bhi}A^xDUihs%6+k@|Y9R8)TGeurEoh z1Vl8j%&^;+6BR>zUyA-RoiVjd8j#C;kaTvJ^)_N@>mj3spyr8REPdxlEE_ zsF2&p?~P>M1IQDcYBBaw)SUQ|WW|qtO||7edfg*#`7h|3ld0gk`?IK2Y`ZGFcaAEF zd9XRs(ya}JjAsb|U0zkB7gDZ&A04SlnV5k`nduh?^E16OZG_|3A;U{lj~f?Tx|}HC z5$>XbN}GCjXWuJE)HnYh8{Aj`THqpgAsy09C*f|;^DtLH2>3@e<88jt98Y)j9Zb3s zDfUDvTX79%>A?D|2@ls9&m!iQJpG4MWKK6q+switq269t;S6PKt*CtLz{8_Dqjg~=5YIQA- z?YkGQ_v_u^nYc(KmJu1(C&PiZ=AQdLaC@?>EzlE_7GueO>5})l1;LXcX+=p(g3Wn% zkB2aV?KeGLcY10TN}7Vdw85csBxF<-XVzTGqRKn;4kEX&64($z;Vg+erPtbKrmDD* zl&su=kRPMQ9iP)zdQbQUWkuaz#i{yjyEMgK{H)l!o9;MZ=BK_nSuc0=&|{U#t5Hp* z5@*}MK19;Vx2Q2Cxu6Hy$)vp)NJ7f44RWRRa^rRNZF4t!Y(CClxa5ya zkaaQc!ba|sAD|uqX?bJqpohZqU%sw+&j1=BCT5Ykofh?oQBz3|@;0`ZL#~adF6rAk z4ZdDj)Aw5leZxVCv*LKU2y`qTFPtaF={_qdB9`{9V!eDX6U9U5+)j4Pq3`rUaA^F3 z!zX`Ea*Su9pIg;8zUKJ$E%ErC&%5TqLg0KJ<%6*P!BTpN@57Kgq3h0U2OztqTOyz@ zUJ!JjWNx)uau1I{=|a`@VpNh#__2P4a?YF-g>Odn>yjsnpN%pk@9V+B)fY77?Xnhv z?wHTn%IK|PBxQ9?lr`4maVOki@d;a4Nca%+gZ2B`mLCeJ+YHw<0HZ?Ui+o5Hf)*(H zh}SFy<2d#6?bJDWG@A;o*LL~x1_gcO3ixa*=8D7b`_grXx_AsyOh*PUeAp+tt-$5} z=z{JBTI0l!*uLu;99X~N0XBAfHVaCq5U0?)`11JO8+Br&}OAt7jjyhMgWay&HSnIuIEnj3xdCAnCu$r!RNKsxb`pciND+`C5AJ;j&s z=2RAJjhV_KZVTWX7=JWxmxCGlJC12Le)JZXaU&wndVthFSC%5z4y!@irWmySjfLC} zB6vObITzccP?*aPMO$&40}IqP4c|tX_7X>koX~jDX@BCded@D0vCK!2{6v?rqk>xF z%yGE5Tc4Ae-qFJMz;e!UksSy5$y#ZW@|%xT%y|2L-XAq14=YW1fgiAW5j!Pa>EOn2 zaG$R&0Nw$EDX*|$iF0_zwj5)R_YwBaf+#(_r>*bw6HlU1F0zUcY^wLX9BY zK8UEACt8wOFsV7PoOng27Hfo|Db|h@2&dH=m7iwRbOVO^*TFF2;(`in2x=qFc|3jH z;04TZj=LWN>8GQDFaE1t#P1Qj?Cbk(5ZAKc3x^yo2QR$dTK$kdH#yf# z1D3JPD)kaPNYXOBpWcnsCV#J5?gVuda))Emc(tuyow3fwZ1O)y9*#w-OL9$DI8_6; zN$__}Tkgm!FIghu?D+zA2&zs&`nX4cF1JYkSn4O`!TzfH>n`~VpLdC!PfEKp);L$l z&8jz{$kA_!gb(bK**FLLmmTJw6UiiB7jH6DB>KP|a~6-Mw=Pg1_onbizXUBfL^KRP zA;}_%pNqhI*Rh9fOQVaA=7321Jru2vcTz+VinekL_COcOn6T<2%{;r_XV`7_GUssK}|Yx}t!s+MuG> zFK?zD)Lp0RezID<%$#HL_41hIjZnGtuhA_$vE3FiEA>n%x{%kfQB!MkT1{&_{rJUE zvvx`2+8&G;nQBbIMD!Bg8rjn_pO>t8V$NV7!>}7*zeo|=unxvI8`)tiGP`=qI1Db1 z($av~^n0*&4^UqM4($EfH-X}MjQeYc=Qt9Z>JS#riv!(QOp=IkDKEbQC#C8|q ziozE~h*7`-QeGvVOSeuNO2DV&o~z01i)zNo^}piP+&lec!>(`d0_B`vmDr#KNlq)T zgDgKv{D6MEI}};>=x@Xx9fXq~BFem2h1|5+l%?Ew1G=W7RcF0l-nDx>U~X&PQUGb=0#F{LHZkYkdMam#9> zX&Ai7B_%Ce=4ClQ9#Xe6GYvEfG1nVtj#+s@_*!9~;e6roi%7zdH~1dR19Ya)wtJ!T z>DvLPB6L5!pjHyZso$9Yp$Ak5=ffICSj!|W=`2oN)4OjUu~5AA9p zFba*a5WV0i<-u}e=$M}b$gwp?6^+qQpMEN^0&z%(M%}I0ZBE|I>j0jS>G*7oM#)0H~DIMU_=~n094iMve zE9VOJAG4~0%~%|!8c!X)j1DrT6*{|9x{PvA2xc+xR}nq_1rqhAigGXN<7$0AGG7o7 z6vnD#QgJ=>%+ZSqOfI;HNUu=dYU8VtAE?%YQDEA-GoMT#h7MBY#o9y+6@+7@_vpV1 z?UNpsbO|pEfFT0k@{Q<(ZcV1^CY3U~MrxY5p^_Z*Xq18R;2Ze1vm#9d(x+!ak z-oK<8rS&Nrdx}S3FR-4E{~fyz2OHT%8GLQk!hBLO?(;>kX0i79Q`nR&eSIP-3 zP_N#He_2hwdYiLOijdkU;&Ktq7crg|cGH-9Rtr!V4F3$@zdu;Q`n=Fo#O;5C_F%hp0&4#z-)SkG9tF zE(KpOqN8V>sc&1XLF6enxcInvOH^y|S&s~9>9-JeW@U2jw|0z@9GIoKlwb53d%XXN?A+=62=erR57|xOS$Z!pMq(Y&8dEm%U${2 ze;Wsa{dU!Omv1#t^@aVoZb8=i1m6ts(_B7Rwqcsa(;|SP8_Pb0I!e;r{;NcF1R)8+ zsGNdV_w*%=13#NOn$J&!DGEB=zg;+fbTcl8E6^urwEq4{NtcdWg~M|Of5cX;e=m)0 zSmv&mu^i^oZG}=WJ2r43cQ8~qjKA`jnC&^^*(Q#rfhyF|d9Ec9##Z^n28#5EV<@+p z2M)`03Mm$H_uOJ%YVR%%c`Z!L-Wj9(%uqX*90G_D#99WdKK_Zy0tjO#@jeVXQ)y=W zV+!YFiCR8oXqFFN0M1lBVOU|)_>nC+9x|+M-;ydPyLCaadtZEW-*t20r26-6f_6BG zsoR~BjAz@Y!JGOUV~fgt zo1|&Mcl0BR9k_VEdL}cOtd7rqM;<-7dLc^`M(N0*a@|(^svy*OxaL5*dyIj5hhzB# zJ|d;0P6-34vaT=VWK6S&d#svAt0fARTUz?;n0d#CsosVrztqyn4?SoBxE;y(h%hFr zU|QXL9o+KD3eyS@Ue+|D>U{Obege}sh-ic=ykQ(WjjNkFY!LHTH9oWr)vhGlK3NPi z6lvChRH|{L%v7ia?Pp@;XY>?SU!eYADH>oEjQilSU2in`?C0At?$) zLQqV+oJd&96qxeU1t#5`HY-UZbonZzUCrO+!Wy^w@`&u!R}SgQ)PD*y=<03Y?L}(sTLNzD6?b?EX;Z-*yLSu_ z(UTKo9fK086u}0i$p|k5n<~DuAE`GmpROyIHea#F?6(VMrYVo2yf-})Y%K2KDEPut zz38Xbd}bCj-+enDPl!9}ccTB3kdOq2J=LP6OT~$LI>=_#L=q-aXIeM?m+Em*$O6Y& zbPH_Di&K9ZjGWuMI{8ge7fNiP9&%6LUYYf|9xW{DRA|kR0<0AzYKmoZhxoCO-bKW+ z&{buEt{~Uyc2gyZd?j}2%4`}h3m0^(*Px$fBS(cze_Zbt_<#lvugM^LN0aEH^4BkE z_$_IiJ78sD0>5uF6M&%tep#B6GHXMkh_#AvLQt6>x{zB=)G{odeedN07&3)<8qJ$r zzbDNlyzzCaGootDi3{4Blxa`2zdrbe@|!46?KPhedN`lBPUuDu!EnsKwE*~lRzcj< zJq!+)^H7nlyj5Yi22RyFt@ck5dcNc+!JGbq$?hx*!thS1=|)Fx9&8u$5X z-qX_N9u6v9G}uhGyzXpi@lhHj%RL!QQ3|t?pBK&rCmnX{}naY4fh>h*n3XozmzQ@Uf66&IE_w@l7yR-xSN9cSWS2ATEm% zSUp}@{b4F#jh2ui3@Tp1?4LrZ$Z|f>oY2p+$w=-^D;JuRV3hFGJbI^gu5%lbVDm1B zSxC;}99H`a=bjAB#KSuM#?^@*^?FXq!3$dG$W+A)V82vKa+vh4W-Kvq66job1?1JF z>YQ!*Nj!IGq-hxOp+`sNU>I^6$}9Kxn-b-Nwgz${L~|b~uh!g_QI!NM>i5h0Jt4<* zXXk6eEb+0!bOB@RAA1=a!r70HcU)=#UH$#6xycRTApSxPiZ}EeG!+RR$C?@}Wq>Ko zX$IAMMmZlBwQeK*m_sFyI~hGdxc)Xnu0NHghvHdB z^s`?}^((&u+KLOS;he)mgR^;XclxNWn}b7f&NJJ;`SS~M&+L`Zg@r9E>Qg+f=l}^L zkgx$}nuYD-4DsJAE2@vzX*7c2m(pAtlyrsR0xaX4kZ~3nSeG3rb7^7dQPMOZYwRj} zN1BTY2?~k4DES`p2E)TPy(`RSmGi!^zqH#uSf1%j@yi9*X0_!Fkw=>dnF_Y35)nmt zXnFV&<3fi{_?1r?4aqR3e+OGZrRCQ*l(!d2-b#5uXh?qJD|A?`^-20?{8ZegnK;*L zNjhnL8oAyS1jq85jhv9|&tR_lrHGH-h51!L2oH^AL2v9@JqQGBupw`SL0+E=(j|C^ zjl_mjX%mGxMo+wDGu~g;$}5Z~S=YDWmBxS;m@mGL#9=jM>IC^!H)u9VrGBk&1Yj$Q}&ir0;O@E~uZ|8D0iC|dB+cV;Ks zc+6jm9kL7k@`xBC!PQb<62z;$WN-z-Z{VNB+{+OR&wE8T3vfhk~1Y$VMV;vsG7J66W$0~7P~ z5ahRj4Jh*dREUufhqpwRpUm~QA$X!y?v$|ule9!$_2jIap-Wzl_>-^mMz3cfM-2>{ zuRuXn`45G_ei(wudbP?tudwuP?aPHe3{nUGWp{jvM^GzCE=~8RcaX|zzZN1Ch+W9J zpjWg%BdGdvqLShnhNyhp!*{V27d{gQvGOEM+oL$NNpGO0m;4wBOd6I=xeP!v*s8v0 zGP1xFrUVv>b$$G5knKA@GDq4y6)Z9f!FW{3*jh>w66W`t%nn-=Ef4Qb@^3~jRi@LQLjAyG})d$K81~s=Ul#Nat-oJG`J$}H= z4yL;GPdS|nu-`{UxCF0!>|?;BBFShQzfTsj0P-AXDUh6NwM0l{Jlw(cllKmNiRRkL z;zknWEzDj>VNa20lI;CNSoF{bg!$7WO`c}$&o_jO~tQD(_^M|}1n%q}? z5#GLjxmEpyFM{9ay#3A>R8P+woBp_{f|N{+Z&s&bGzQoTm8$Nr;X^viF-<0~6?`s% z0s@vMRpBdHyi2;iXlYeB_l$DuwtU9X^@>;Pzw-6aWTC*_8h@9iOZ)nFQGkG-fbTe~ zxudDnO~}Rctm`*trZfljJz6hJTWm599VaSa7H>A}bBK@R*1TfIp;c5RkSTM*4SRo9 z6u!j!iPq#Szp8zFA}~@+aFWUHHc~6DrFOgIlYIKxaAJ)u1wybC0+e#4)cvYk*X|UC9!z#?sG=z zy?Cj;YrHi$%&)xCsNMZ#aD+6MiSEbrF-M%M$NL1pca5fNesZDgYmDua5P#w_=HVYx zd3wkET}Z%_I1zyhwrhS;Z}=qb3HCKkDJ93~@A%#EqbLx)#k|)%%781vZyojnb`w6x z{k7)gN%P#>XS_e!ip8P52nmP zF~LFjEAmE**~RnzR>Vd}-d@KY$AJ)^?Sk6awSF*ap<^f8u8nH+^Zf!jKp;HOHpB6A zJxPI=aF^^a9LbxFc;>BOf|R6N)L^9q7%F$NUk)7OjtQ?W@z!EHT@r+7zUL3ok&|1` z9juwc%heV6Q54jNZs8i0v6Dj18Fb9rXsp&&M~SBSqh$6D{+7Y*nHAbuLfRzn8~mvh zWfVS_E6lEZ{1SHY+S|Ub zNH)I}*AY?qdtoAXf%HvC15@IRJ(U9HXjhuwHiAwEGNN-3AiamUllkDE@DQ=MQEQHj zX2F%h@5Dc&CPRfmKUy`OhQ-&7>ihyy32@U71p|e1UM4yI>etZ(r2Dc=kx$1Z7+4 zf@`mKA0D!4h0T#N-uJt5`?2hgi4ow4-9WD8C+XvIOTBC`L_?9hq2Bg^&TU}q1ua~B zh-8JvH-xU-wLi7o)^Wz(Y!Lu$zK^R?nM#|Z-xgY}fuTN#sVmYLU#fC>bt8s_HSl#qgQ z_t6it5g-)cVCO4W-}DaYz_{nK^aTMgNLXheF)ws6s2VVK$yEl$O6J!HC{o1Y({h}B zMGPH;{C%B7@Ur=VS%+N)sDf19i3NppU}B%9ocY_?k50{wwP@y1EWz)N)_*0{T>iCV zYcp?6&VhO3^{|WSB083@?_Dw)r-f|&d>qlFxQ1gd??zSeqB%@hRaBpUqlRo!;H5Y0 zw90(IRn267CK0b~^l>*%G8o=EKE_>6mO(7ZG(iJ1B;_`Lf+3T3><0#Jw!E5h^eoJ~ zo6Y*n#AZ4NiUiFux_y|p1CH+6Vd9Thm}zSiZ5L?DN@s-h1Dcig9K4&Rp342H`PiwcNiFP>eI#4|}( z-WD~pp=n^F;2U)A^*sxLXW3!P5&Lu}dHzKVASdpUj;BDdq_2s^e}klNe*U$P9rplO zGpNW*_Xg+Kb8~?5rirVnb>i0xrXa3bdSSpS=JAr^?$ZACD_E3N<%~V2NYLGW6Jty; z+M4w1ri;;E1bDeiJNZ6z{;gvBb9z>F8O@j;R=P}xkEpffu%$E9DWSPOT}#ETf1J_VP;I*>sKd&inWB1=Rv2y`TN_o{L38rd2Z-~MdkFG( zcg$cd^XJRepj!rQXmiRpZ*Q}M!duqc^}68?z;9M=M8{DoQf~m-Lcp-y^E4o-Lg2-^ z-8!F2cNrB`6Lu3n;AT0U#Q7EW`)GiW7!>KlImT2=6|`{hXd6fl4k*VnQlD|wpsi-m z%5NW5Eo@SGm&PmqdB9)#iDs0vgy10y+wam&EYu5(8CcXOl~UDy98v}jMiY&0nVK*; zB5zT!&#Lu}XTUOz31 zd2k=lK|p!;cElqZA)%7A{ptLd@O{qHDZw&eR=|3Wb77}{|E02Be^u?2)imN@7 z()k&yYUT_6z8SNWJBwP1)w-+Pt9}y3`Rm*_uP`FcUlz?GemBnYJsjrODp_f^o*E^< z?IvGIE5uy$M=JfzWR}1Xy?osF7HoIy>$>_T4GBtwy2g&;>4UZ}vQ4gtninGpteXya zV!4~Af3c|G*lL#29aQeJ7JCv~?=gKZh9X(>x*2YY+Oy`?uOY|AhsK_JCU=I+~o7C%cXFn|1q?i4LSw&xdkZbVW zBZ)!@fP5T8noHhv(*<+@a4~*du-^0B4uv^H3;NX8n|-r+oSP1ek8#k z50`n)uay^GV?{`!xsl(DGHapjWyIoqBHe2-+V4nV`!=6EZ0ec(x6tQ_{pB8MmYsuH zq=Uq3o9gdmr-lNFy~e4;(|reBhhD7uXKqr7wtKn@J}5=Ga^tSz1r(~*GV82e80vp6AHgkaSz9>+9Fvl;nR21&o8zfTa{608 z6gx7=Ar-q^3yN;PUTZ&jj()v6UT}t&a2MGmesPg9OL;o_gF3JYhP*j;b%@^2{{9*D zrTrx|6xw#tC0`hDq}1+y@eX|IWl19UrF-PUx9`up%nzX-F_dNlSa+Hq?Ojbv$k7xq zA>7YL1+$80zDd}{HqS%c+0Vs$i%^PUQ*&B^z{5hJ+0$%$dljeopX#se$I{X9FLFDY z9_{Ho+~+;;^n|^-li76He$qEBayj*yyt$utmzHOP>4eftkQyc}yf zo+*q!zkVRgCA)VR37>s;z4&$C5-;$Udw3e_x9cD&8X82!56Z#z4Qg)cYll~Ga4a+q zpiD<*UtJtryP;=*cLzqmXWI@7gDL_noqjPvA@8wozV1eL z_!-i5b8%0=iM+%)!)}&jyKs=o4I<=dB#q44PbyxdK$GBi#5)j%ZmBa z9*Npx)EwJo|MmKIk1O_9@Mjg9gvcNhc3RjQ$Bub_tt0CGfHWBZWm<&kAF6Rh1VOK0 z@PHkyK1~6WiL)U9Px8I`_TxSP%*$PT=yBH_iR+c!tBRcQnw3S)i%+`&m*j6aWVvp( z435B5_ldBaYjN?91=Xj&V`ndZTo=*6-jvT@?R9V>0#4;&(DNTTXMkLNr@vpfVs1$P z-5o6g|CL_YW#AM389LqTU5VXmG?d#LXWJ=;Dd!iX~EA%)8*29VCfy*pR^6{oU$ z(-Pk$jb6b?0M&s5g?qSiYslX|av{KTx9}P{#R@?gL1P>3lDN2tbVR#2_Fts;0-O8K zyF|9+%Z7K)xO<6^(=Hf*R&OgE?l~W4toFRST05um`*1KF0F9OU@3w?rZu@Qb-$Ukn zxVi)OgyPW>2Cz})8E}F7KMy7>^#Mu}_k<$iJZ{Jz{}0Z<9nCON-{=!NI(!N`nb5=ryUwVByCj95@e<2CZ zj#o-tj$hK9OoO%j50w9$%Q=swLTfp!fI*)US3V>A51rGUya>Cj8}rR36+0_OKDmxY zy87?+ivFP;AQ5qVO=R~1Uso$X&C3xhTT?HZiNV6Z*$s{^1ceHfO$=O;VU4fs=1}gh zc%?Dho4+$Q`WP(}0hOBzhN80pu-yKG@PTG1W2dJPDd9)@0_bwi$QhvAWtCTO(098> z@&^+WnUQcy@s(HbJU5L_sW&JB*Hm;Q-os@t+xP4JFAkcZSJ~9_m*7*&_|X5~O=98j zl=ryzkg_Lu*Wc+85`ix|j)YIkI09w^{I@+tov?AxgOG51^cA04IsV9xBgjimok_ALn{QqE+$?o=8dWd{h!U`L-9)R zq^?BYm;p%c7g$o)s=lB*x%PM{MRO|>(9vJQb)`dKAl-pXiW4-?IsIKy;dlL{?fc5E zgrRRpkn_t&Gz;7vo#rrPr8fc6)$TJ&&8_pSD-$}{f6v?qLil|l2ns-%82W$MBnlD+ zNKhrH2L|oVd1ENR6|M2$b3~=)Q{`BX;m3u(E{aJ-K!GDnR4^Pk|0dGDU{s)Nv1FC;H z6bIm*iv*AW;LX2(Ai=u+{Bm;wJo<(@ZvKz|pG%>k|J;M5K}g&mP8{$vRoXcs5c5^% zT<-fGCty0!rr(~!xm!o@gX-1kIpOKn8QdCaey@^uftzfCbdTgc_68m7Gwo;E6h|@~ zIAoMJ`PYGrz6`|6(GD@At566RGx z&&$*GU4gxq_8%rKfT!zLRpU~*In()rexGx~gBEqfOn6yAl`)Os2@^b^x-#{P*zQM( z$z6BjWs-uCmWGEnKtS7K^I{VQ|KNU#$9eqY9z(`GG~He&OcSDP<`G0(KilMQK6!>;Rja8z!hP}B(rLRh%sv9?1DSR zPUry_(uhT|M+fa@y)U!SV%7c^RDABh3OInjP%&O>aBnQJnL6Ezy_6c)Ny>_-q}4o* z4vAUP=g2B)brtvCIXe>7+IWIy`j#cSfKYcZa|q(cJUB-4CcBQfQ`p;HAlq~usi zZ-EF6Vb?X)pjkOr@CdnaeO9hE3kxn(Klh6VFgp}r_oiX|S+!hwRiFkjbD=_YmzrhG z->HR|`E3|0(R@0ds9k>TEbhTBY=K^c9R$thq};le6ibeX#0jXN+ZMDf5o<5{y>-E} zA2>0|?t)`75x?B3?;K8iXx#-LBK#%LHS=Kn*usXsul(`5W*lF8vC>)dp`I{Ydwni@ z6OC+(G`G5z;n$uaThhiNS;0N;;iwtOVz2~&345nuW)$w*v4Kr*cHEyHE227kZx}n! zaBNqV4A7?ho*-O%otDo5C{@+H(nE8ATiM@wBH(QMOSE*?7VVYno7$X}RJS(A>jAZp z_VKSMD~EbD?~W62&!2zz#KKCI-rhNG-eoH9%Z6sJIebxh>3Bff!zLW?cAIdLsjSZ@ z3SFSsrWe)u0ib6&{%AT_%C8#^F{XE~V1^~+?GU1wsZ%xQQSgw)8tLJsId62*AlBqD ztoy3J^)*GKbS--rvXWgUH!O%S`xy3dj;pkJn#5UeGI1~;OQ{K#;#H?Ej?EgpZ;`h< zq-u`mZGM4M`pvv17T1t$VxmB}GDvC@Ge!TNSi{cS+9jVT6oERh81C{OX82Nu9R4eA zar6yD)40kkEA-?2BGF4p=tEp8~gI(a2}{?rgm|=UFSdawugLp8s{pEe>9x7ONr-OE{QFy3yY^ zFH0FTZ%uCaXfYO=1fvrln`P(1ZZzF~^(@vs-xsIZP;GF@)HjSSXE4!26f=D_B>FcA zZfS*0T;QHJ5Q$=U3dVLtW=Cf-b}akmz)eEBW~_%taT;z)t$yAwVVml1N5e~u}W#9=`Qd25;-XC zEr$IngHUxCd7ki=j%;HywW_@R1!&b`*oqStr;^bssr<#9uaZ~N2ut@5$XZqCMJ)}6 z4ocV49>?ONd3|T*tXhrjK^~2?^0dEZn&QUu`06mKt?S=)()_u=8~#>Y4sFv$pQ3cy z9Dw??GQo!b)ZSp{IXxq6PecGO^=0*WyA) zm2BU{&$<4}F&#tTlX+sL>adxq5f#u{L*ZGT%UQNB+P4e+P0A`xB(Q2dU0yVEv>zVZ zZc8oIgcuMiR&=MT$&w)m%mXrBC_5s82t$- zDN~h<0m)zi2RT$A5rBsI&*E^aclt74$g+)?6D873q}=I2;{CP`Gq5p-_g-PGJvGuD zmNS(R7eFsRQ7?Mz@q+gi|MDdP{77tb^G{gNQP68&O?zS_odf&^^aCqv*Ma8JO& zEO0N@DNg0>o1LVN?|kf2<~p}T34lsz^#{^&m*3s@x$IyAOPZy69k-!zqWqzujz0Ge z!RkzGby}@YhgB$VxFNEil$djRh{Hlr^zG1*e#SCk)O2`jg_DO?CEpE6-^f+I%IZPu zoX545{brfu0}r{~zAK2=YnJW?TM@mb@cfo&vY{GdQa0XIok5;FqmxbOX=kdCfIjwq zzt#40g^=!B4j*kl-kB=mZ+wFGr9JUoKIsA&flh)B>VZ#9h#o(zeZnEnr=syO14m(K zEb!;!*sKw3>}AuksAFSb&fhZB!uPfc%>_}iey z5wm>CBZut-6XoJPmfzD;Ozpp|WpJN_V!satUo5qyX}2G-;5^bUaM%zdvE zqq8uGlN^-KaU_*wrQSIZb{)a7Nv}p$#zt*ypNX=ip$T<~$`tAj zNme_}RnzAw(f-zfF2GJAFaa=;a!1x_n33R>n%4LWqgB%K6T|h)k}_-I5_G3Gx+7Dv z>GMQn&$YH2)bu~t-on5dgHK2z6;Vql<0@jdcLb)y+FRTukdW+kW#l ztFE}O%K=A91v?b?w=N0WjXGNmGRTd$wIHi@=6THAeli-GE<2j00e${Kp(93FCbTO? zbtY7R9sW>g#=O@Qbv4LZ1V(>=G)K?#EVp@X*O&;;Dx~3G4!hpA(|W^bjtryR>zIUFb6QPMNR=q0t<`sqF z@y_O6U}_@J#PaFDQbBz#T>MN|eTBtRr;&`@Kt$*p{QW34`>k;@RMlJwHdIxGIny>qN%S8o*P6#1x(Va`hE_`J`u zzGnu(k2S`mYXh$pG}9&?tH|{BJdKNNa-3+J(p^cN&Raqz=#Oo4>kQ|?Ur# z*pg^gZHL%n?$K}8OrWkR$4;h*jr$S!13MP&hySNy^m!d4MDGf>d0RxI#$ACgjUgAX z`mLsxMD#I7eX-J|s*^v^9CIX7p<&$O#?mbWVCC9wQTvLq)An|cWPD=lt>b3+^rNOu za|+I{9cMn4_e$Gq?4o_6HuH=g!9(e6KGabAa7-~|Pd@z=c*xI=HMRyE83nY?@ejRd z(vx>HjFc3t{qmi9t#eCknB0Fo>gj*h6o2F}M5EKwC|1yR&u-Tz9L}F#ZPSK=TUrow z;*srUTR$^WYkJ7}BdUGmR}VvM&sITqXOkGwog33viJqmHV(yu{**{6Rs{s@#&4@mcI)BHSijfZ?yt^$bpV!JaKxSo&%z#xQ?dAOd?# znFK!|P=2WpM^vbEPbq&jjel~rIea{}`O9i{ZP+vGTor&5pPZA@an9osOul2C3ym8J_@pp7x zdcJav&T6mGiIq^`t_(^SR4Noxg({XmKpr)3&lc03*)tHcsMne|w&{Iln?R{@9j&an zaXTM)m!9+VIMf>Dl|7aCl|1XLjGt;I<0?zuTy#==D&(O(#I8M6E@tdi6uRih)h#~I zyM&#WXM-%uxXY$8T(JDtb44pxOJR9~>VtB>HX$8xOxTz@?Z;p+f$2ecqLATaFrG%o<=l5p(nu|?6CA3Iuh*fho zMacy1Mp|){RtV&LVipeDd%ltMxRKX=B5-V~=yiDS{h@3=)@k+46Eaia=kiq!?@!o> z*ISrS6x(s`$|5|W8UiPsPE01Ag4BBPMrE7_M~vNLjO08nsSPLrPpVgIn$_o8Z!bjr zMLV$45I_(qc$bFwtpKrUmldS%{9swXG*Hhya)nx9TSGXKu#i(7VTpS> zYyNQ5!#FJEwaD!eE`W$Wd+toC#BH|?TnJ2wLFYN<0;Mt1k2O}_z0GSUitrl+C@MY^ zPY^IwR6VoK^l4V-L-3uP&-=d)JHPWeDE@WULpp7%UpFe$<;5rYY?Amt9zM%D!;<%C z0RJ5n(WF|fa@A`{qxF^Rjc?+1$KRHJkfEd0Oe1mkBKk8oV`)(>2(d5BiOg`C}Y(I1I2$fuMR&By5_E5{a4keWOs0TmqrbrxH<2AN%c~Kj`V3;Z`CFMMwAv zv+cd3;OrtUn?lg4kcZ$Nlc1H-{+eXSHi4(+=ebdkn(VK1{1W`^W8~52#W-UAw(^JY zec>C5Fpk!IxE5Ms$=wvD`Ou`CLET+$#nsVhEPn1Xp@hL322>E#50re6YU z7pS(;3~Y#};zrG))bq@vq8F^H&D1J;HW8`XS6fM>V8m(v0azPk@=}?HP0uEb>tFoT zuZXk;>Gf~kAFXm!LwGd zm>jXEX`2Il87<4gymlFPlW3JJ{7QO&k!af+jI30>BE!E;DqO1iNKy`uGr$=$_8ai< zvKZazu|lKpqWLJWLt1g%>C@#1pihrkij?AH=+uWT--|NDsv+AXMs5^ZG+r|Ks&=vK zqm}_K&o77KdQ+dPS-(H_{HE_)o#rFb{3zxJwn2Q04lBp6yzk`ORr}dhr)*RnZ(40q zi6zuKj`&v;k`=7`L~*@!K65PR4?mqiR%seqocI#jw5u7coDQhXm=53V5TR1S%ec|( zBz@+q^_Qj$zBNxL%M)`jC}XO`tp-JR2zfofj5?^zXuLJWATIlorEFTF?`>#jq}*w) z36c(f4BBtDM`jmW7umXE57F$d|vcVwBU18 zSMf#YWl|Bp_T+0Gkx!%(AY2j9LZx-$i@VKYdOo`<6Qg%``A1=KosLD$2ZVEMv2bYb zu3M|1 zmLI1iSTQR1j?O^jz&O<0S=gd~VNFLT@iMf#42=qY6b}!;z8PDvBju z?d|xVw-Strqdju0R$-->?l82&?C8G z?5n!E2d`B<=d&3W>!Jp%NmXiK;$cGcg)4f)!<{Dailp__F(8F;ey+1$Mu5 zx!DV9xh{{4J6=~(vr|6QnA4)gR>akdPVxcAmV-?OqdpB9*kwy)ORGi=@7xkKs50-} zTH6-CA_!tI_$st^A}u?7X0%i(UHgk;&U}hnxb*3y+K88~)*KOqZrSYX(Wj)}cI$eJ zE|SrF83pH_<#s90tDu*)GL>9bek);}HJ`Aov3gqCBSsO42SMxc+1_vvXnxS+8&xgu zW|HD{PNBfJl_=yf#mwOn4!@*`L}(rNlBR5A^M|^xpkvy;iu&26l_^5_jpCqN@NS(a z^ZvSWFB=UZs!~^#ze^18D~06<=AXsH@q0d0z{?HVuC1$9rrSuz$YwbVIw>uoR3P-U zjo3@&Ls@efV`alE^Q@}9Qo6l{|*yP0#5j_IB!H$i}q z2b;?Nx?v_7A4|_ewf-B4vCot!TO-;MxuCR3Ixyk%{~HsLN0O1gYt>l(?){x<364=% z?UeCDFsfbD$&$|Xx&Kex65Gr2VWWXz9BmW+iVi(?Q8PN6vG-XDJ393B1xXvr%927` zPCgw9kX)6`2jo5|WqhAaLzJU>RC`ql^C{CPv>R+Lyhuv9GfAvz?*EE$-n}KP(Z4bt z@s_8vbaId*hUFQ)Sr>#x;_n=yX%u2+OhlT<#cirf9E; zTQMO>vhh|9LYC(04{4Us&PFL3x|wS;mh_zdc(shn*QixbcMWK6SeH)SIkms#imw_SgqZmCwC>=Ap* zdimPp4k@O(wor>~&)g;-q~UXqBP?F(I3#uRcYu~-;B9yT@9Y2TYoXhKBkcs!dq{W= zW8@KwK9{sLY8o)a zbk#%HpSHZgLFP>Kz&*A^w6SGkre*l7p%>XYeQN#^nj5lknCk$tC$3fXR0%X_wJlQ$ zBZbgcyQRD~XYxNwe86%fK{5Oq&*uRghHxC$h8rd0e)fnDNbyolQ(WhKT_A<$5my~X z+*tAQP5ig(hnO68BE-g9#ruOlyCz=5OP*9D?SQ0UC^>qf5P=2~$Bxs~na56}5-=sb~W+G3Whw>>a$r#t&y4&L!Y6jJEMfTy&*`!?%@E}-@{*Lab1oi+7 zl@nBmi$<1Svr}n;i=aRhUwC!f5R=ndBuIyrKx)Ttwy**nN!%y)QV+bf^|fRG>3R@Qmj$d`;|}S1yWlI8LWuGO^jb2kY6X-wwq!QYr;-??Iz@!39WNyF6#qn64|~zQ)FOA%6wxUJ9d)I9DYiOPvYqiLB?1) zXR=Fe!no*%`se;pDV6q4a$cFMY+dPdqFz(?y<4m5m4?z(dhD}6^|7;`zdv-1i4B>JAX{Zb6^b0yZrcyd44 z)VtY<@h~OJMOZi-4f`v3KT)4T8gt92k+FAlD!F`t#c!RqtGHOy9j33FNjEzB2_- z{VV#*5k9Kx{6G%FeETo0t4h=~j_{HPDqf{@{vWf`u)a;71OKp3?c}X)Ov5i7Q?IPA z6RVB0q-~QPmn>x?2;BiXB{=3ntX=)cxkGo-%Q zO@JfVK0Cnjrk+&g@$;M!BDa2gB-zUTI|pb?!bg5@Sc`MvM~Zzv zH$QJN=%06zP4Wr!@XHQ|?4O$AjfGw#_RC9)hV<#I3D3uSfY%mot}HZDK@4eq;q!ykvr zx61_|z=oM_NjLs-4=>G3*1ok12`UGs8;RJ<`96ObzjXkUTYXydM07_!)?$RVg@@vi z2F0s?3SInYpfyKO`JNw=?qMb-5aOHah$$V4c(GYIKAm|?hb+zt{dkE-&rO1P+vDgp zuwvX+EpT)?%Bf3uGHOH&5<8lmteJnyG#Y73>IC_%75B^|L7GYsH`#F*()3SE3GaE zN}IDxu6}9uMS5X<+mlX|pqWO#c2L*80>iYJm=d&Az$HNG7#uAQot%f!h zGH@~kzTau_I15v-jf!T-FVfRrZRzAmd;V~90SKFfQPp@IEXT|Q4f$5u56TZ39R{?=o71;=BNl-7@dm~74ZebklwedAZx$x;*H0-V`06(cYCed~+I6d(ubKJ^4p}5z<<^w3QMn&JB`t6=j7C(Aj1%IdjJFp% zqoOofdrunw*gZGXeQ~pFN{=H$^GR8(PT=32d_V^-oTbXv*^TlgGIf1NJuCnnnf-{H zbiVbf;p3%1_FTnq*T>4fjM+q<9t|4-c6&ClL;N$ImA+JGX|(@Y@x%SZv)?M6N*RPp zJSu79Va}Ev5>Zb*&&DK7CXKN$J#^J9yucq4dlP5xA_zI|B#o0kiKbOE7e9)lAh>Pe z&=K95#lfBY{DN;~JLCQPBBPm{F>j&btRet{1$ zuD9p0f_=sxd}{<=%N7a}!=U-TEEF*WJ$A8XQ@RId8lJi)Y#!?7U2zH>6p0E_!|dv8 zswp?F!b`=nWsljydgCUUW}xGYKC;^rZRkbeGrj2}N}3&z=CoCl%0yZNX?obo-Z@Xm z05F%9Y+*VGl`8p*CjR8y?^NpF(PgSz)V#Pai0F+rQOWakW$vtY^2%aSICM#1#jd>F zqk7knIk(enz#{hL^s3_X%`@Q4tei$!RGA9+-Nxi*Nj-_`!brWFz6W*r=r!Y3f}*G? zhaqB@>EWV35l$|~RYU~^EHWPza>v48HsMntOHQH=a$Gz7x;O52#*cM*HTn>O7hhD( z{xYs|ut*K7gU_VQU7f8O5qra!yCjn$K z7g-t2ZrRz8|4szg4ys(rxZ(c{p`F#AkN^snKbufqV?r2z_y)2+80vpPxwMc667m_5Ad01Vn z)`yu9a$e+wzV8esZz5;iTisGG+eO?k=9+P78?uPXQQmRGpr>N!emHi5ew>2D5nPqz$s-GhozfwQ zea6I8mzn4S2@09l#o!iOdpxQ!3F2gZK}wU^BN+~cLt)#88sK-=1_|w3JCyq6?lK*t zP%V++(r4gztVM!(H~m?MwZ=Y+D$%_|1or*k$qX=&`gsP)jQP@ISE5V|*=pA8317JQ zFI2lls6Mbh2VZ%;K^`OVCF!YeW3m9sY$hu5G8s)v%XGh%Y^7ZP>cQj>akxM z407f|0f{sBbwyYV#e6^&Uk_P^po|vKorMUzXit!Drs$xzY@k0^A#d3fWURq$zdE33 z)~qd}XwNAb21NKME+JC%32E0jB0bErb(7Lx)Gp6$+BVN4e>a+46;=B40O-lE7a-?P zBa#kxQ>}<9`uF1a=Gd_mftZ$sx8wt~0FArL;aS2*drYC44E{IpC^vFkSBKBsT$)Q^%s7A!6#c4#Cg^7k#yb<`jn-toxiwapb5iF zbEmTWV9%gAU~XI$4+IsG&m>-v-iedGOhE$s6wk&>`68Q@N(EVz8bKLwQFps3D_sFf zw4U~)Z(@g3q%5WJ6$lF2QlkH^`Zb>KKN)Iz3|n4%7%+dLGk232Pr~kCy4OG{&N_AY zrCmXX6L<p4kS^4)RH+vTnXmqtxPEL8=cui0*MQ zs)IPJ@^($X?i!GzWBoTER>leEN8?u+YIt|ryZhE{$P;y$R^p0Ks@od9nzy**^O&x5viE|eD zBnc$SsLQbRqy2Doa5alJ+c#4}`?OhPWC4)84KH~Lm9{BU{~h^YpviVCc&8~{UO(9z z0Rwx=K1e2OEVj)D+08a~<~+UUZoS=N=$e0n z;l(Reu%_G6j$!s}MvIfPrK{~kx&5QccFzRi)=!>sp=nIuTRdv$^0OeHZw0&ElhB7dGZuE{GOmPtmE~yzkju(L zEc9=Kyu$>oxPC3`BP^Wi-#dP|*dcTRCX_l3gDHPs{WP00W^S$|%!@{nGN3`53Zhfx zYqo>3i0Art5-&GmtL96_-x{DffOrL?o)*G#QRz;r?cr!pt%QHcB1L-px-Nk<+l%W;^$@OwWqoVIbXBkI%FT>NH5b^G(T0@HzvwgRjcHmJ9! z(zGQ}XMC1h2)EO{fRR!K^R}kRZm9mi{LF&WX+2Mn|mNC$3nmsD{l3E##-5VB* zUqnF;^mkAek%yw8OzdSG#Eou#hE2 zKVRK@;UZXOCm8WY<)v4}JpjN`oo+KY`nFw`5d*h4m1xjRdF?}Ir&f1B>dz9>gjKKy zHwAebLg{?poaL0+4*G~@Pt2{gA^gnM<^DJF=o%>f%#%+gL52HnKi z6;VXO{=P_?O7tMrAPY5;pPT&z42rk5*#2ejZ?dXh3C)u@wh$jfgk&+(1Xl?swB0)^ zl!d%X83+;yX3h@t_w5()&$HC<{7yd(4)3&MSB8KcD55G-BDJtwhiire|*{Ck;pe9BZSrEftx=&1m)3;R)PdcmI9k zbi)$-nI)X*okC!=IUCeQ;{Oq*=0sR3%Z~1>`U6{W6a{q@q&%iUmRc&ZFoNt7^fDA* ziC(rLWO!h&s#4Hi#$%j2&RDjv5!dC}MI|CeOBWL*nJ^6I)5OL|_MgY<__-~C?mOVtAt^xhZcWVh@$31CDq#pp); zc&$5!yU1tOmRAd>hRrxB>+mu&aW$1^_V87ilE@voAjS~H10z=S+|H$$k=K`dUmE@Gs z=<I2i0jTn3Ia0sjA6V+&%)u* zu7>r@PdM~BLpI>CNDd%LGQ`pyeWbKiT+Xtx_%D1GfoC50k6D(yNRqFw?5+UubRn!a ziJ5bJ>u>w>gSkPYF$N?TA=|$mXEygVHYR^DY5;@n6FMro#MG2sXF3~?XXZ1)uAD5u z1XdLxI9>PS%hEeL>WIw_l9hni!O8O^_PGC!yD55W(dzg9gDO-k2xm0~GS2QC0 zW)ILXs1c!e>$v#ACf4D|_w=aHuC}&KiC&6i!L(vbgtPjyG;yYcPFWUW-0S z?Mv(#^xfZ(YB9KPRrM@noJ4n{evCksqnCalE99=k2Gg#x4+!q~89M7i2}wu(CB}~3 z-z+hbSkxL)H=hyHU$uqH*TIXXrQZW8OR%=LeIsmj{1^SNl?#6`{VDbfv@h)%B&^wx z!FjZ0)iqrnD1n2F05*1SCHl#Fs^p8gA+`C4YLZl>y>T&FwDN&7KoYi57oNP&eT`^b zwh1xy{`@GhU&8^+;m!*ycp?8=J+_ns?a!$YcrF&Kzc`*EIA_JA4EWtT%{-XnP$Rf( ziiKUh?c?Lbk}=AkB*Po!)r{dH2W6lWs~UniaAJn5!l_x<||%k5h^ zeL#K{mm_^*mS(W%k8pflt~>Sb05p1qHGA09If!i6>oP$<^S(rN`_G!K@_ekfK{(pW z91nR|ZWW`qQ~$wlbXB6wUBcbhX#jT0+)Z$Q3(d=++iuxYaQos( zJ-T~Z8Fr}!BS*fy)$igJVn`J_sMhn`oU>|4;bM4;0QW-}wt2lS3TUL~TRJ}me6?wA z&*C53vF?@%COYXXKK!IGfmUelvU#+QJ}Z@C{x)%zcw`CyV-Ql?V2M0(r2@6PxDVY2 z?h7@+a&ACnLK2k7s)~;b^2vOv z8YyumA|NC4Q$-{83TLkF=guyuKu0IYaZC5b=z3QXe_m%LMkW(M@2y>mFcC(4Z=Sav z(=7vt8hXDximiHR+N;)4391#Wn`_>PVzc;Ip*Cs+)zPSpp&5a8n4xW zP$cz+iWVRZ&<5f0RxQy)cPzT^`*30NW#{9b%U_>aGF<$ErB>0ECK{Q!7DOlNn6N8W z?|ROgT#bdma|ndw8@!^sOeq7l{ZETE_%2!Qg%rH|-DXUwMn9HukRd18BrMYJ5xfSIUa zaRMNKqX2(&xSl9Q%WIeG4%Qo7l$I&0xTWvBj|meOP_20RJUs2^Z*1D_V)12BcUQc? zA|(&4IN9+10uQf?TU-gIc&W{gQ^5AuyWgR$r`63uW1SQEKORFwUnReX34W~HZ%2dw zeh}U<{^0mcE9cOXEKJ>&*wn&Y^(kD=i*}W8$QN0B{Ia6|Naz*LnHdq~JtnjzB zNR`L?mbt8ORtzwVmV8l2d;QrH!Hnj$QY{vD>bawtl0CE>SBf4 z^F<%N!$;G8hjRtM1%uOCZ0wE8SH?y@Z|qkE+DcWgdvpoZQ9as{w`8#x&X7vd&xvTl zm&ki$8emnBaIIn(c-U#=ZHjszI^~Kf&yJ}o-?s14g-@K`ollAlRra}5qFMaLg4iy% z-B%g{CK7y#TQ2LUUINIRW1h2JhPTvNWUu>&{2^fP$Q4x9k|NZjA!_SPsbqDT$XH81 z{gu1-F)stMHc{go|K^X8B_xxEJh;W-xgP|#gy!kgr#_$d9^aC5h^@z`dZ0U@ z>rnvf)@5hT>?9ONp#*{t*g&t1(uD!lLMYs1niH!smhN4cb_+Wfk&0l3KBGOfXPei-dCA=W1t?jBeWw|3+%A#ZY&k$q03qGFAw(z9mGAvXlKPQw5XIXz ze4aSIMuiNsSjyQ0(&Bz;Lx$srhR1J$fY7gdwmQjtWYHGdQQlkyf&lY}W;4SANQ*$} zeKoG(p^Gv%)f@K70e3_x(h~?zW#OZT8aSV5`g~||SP84c0XmP3-j|{%Odb<<^30oQ zTvfowB%o4nzbvxnnIsoRG!y%!_%)``@W@UYI)aH04(by0Ft?O4uJ1v$XG+8AiouDj z)Yjq)(z)$@lDaWJGz9Yu2Cc~;iCPfR2uzUEazmExl0~(b@AZ$Q_X$C@SKB5b~<$ z=t3N&C*1cGxu&hVR5N>}X!%5&Vw>8-94U3-sI8Rq+rk9aBjiq`y08{2Jl&@aa55XE zM8ujLwGy-GZPhbDvRerbv#jG-S?eEQFS?a0poua8lJ#_m5-5qWx z+dpM2CbiSl(u}i(q>XwFMj`LGYxn>UG(SX@ZR1Ll3%>+vSFc>Vo%M|YWwGo0$u6Q6 zQ<(eyI+2A*%)xx5mt21DujU~YB3nXoxQgX}+l08%?|+Mj|FcbK_ut~-|L3XyqpZl0 b^XC;lbk^yKfxiD8+(TR4;8~5T?d$&qEFbc& literal 104343 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 HbG*BzfYyxGo+jbXho+Q(k@z`6l1b=5$q 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 diff --git a/docs/_static/img/change doc.gif b/docs/_static/img/change doc.gif new file mode 100644 index 0000000000000000000000000000000000000000..a56e4bb8ccbe2a929494ef8034fb5e89ed059f80 GIT binary patch literal 1355017 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

9_%!0)i$iBg1lH}WUmH&L1pK1Ei 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≈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êV;Hjt{Sqg9yF`%ds|;C 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 z

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%0DEEA79Xhqa>*n3bFyfTzw+`GWj`v~x(j!3 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 `4GED{)7kX*SX+axrodO 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

<_bMp&eULV+f(Hrj`rW1#G3Yhl} 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

&+gNazQ&< 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 E1NZ`ang9R* literal 0 HcmV?d00001 diff --git a/docs/_static/img/framework.svg b/docs/_static/img/framework.svg new file mode 100644 index 000000000..1217fd544 --- /dev/null +++ b/docs/_static/img/framework.svg @@ -0,0 +1,4 @@ + + + +
Trading Agent
Trading Agent
Meta Controller
Meta Controller
Analyser
Analyser
Decision
Decision
Forecast Model
Forecast Model
Interface
Interface
Multi-level Workflow
Multi-level Workflow
Infrastracture
Infrastracture
Forecasting Analyser
Forecasting...
Portfolio Analyser
Portfolio A...
Execution Analyser
Execution...
Information Extractor
Information Extractor
Online Serving
Online Serving
Graph
Graph
Event
Event
Factor
Factor
Text
Text
Risk
Risk
Alpha
Alpha
Data Server
Data Server
local
local
remote
remote
Trainer
Trainer
Algorithms
Algorithms
Auto-ML
Auto-ML
Model Manager
Model Manager
Model
Model
Model
Model
Models
Models
Model
Model
Model
Model
Decision Generators
Decision Generators
Model Interpreter
Model Interpreter
Decision Generator
Decision Generator
Order execution
Order executi...
Execution Results
Execution Results
Execution Env
Execution Env
Sub-workflow
Sub-workfl...
VWAP/Close/...
Executor
VWAP/Close/......
Highly Customizable
Module
Highly Customiz...
Module in development
Module in devel...
Explanation
Explanation
Sub-workflow(1) (E.g. High-frequency order execution)
Sub-workflow(1) (E.g. High-frequ...
Execution Env
Execution E...
...
...
(1)  The sub-workflow will make more fine-grained decisions according to the decision from the upper-level trading agent
(1)  The sub-workflow will make more fine-grained decisions according to the decision from the upper-level trading agent
Stock selection
Stock selecti...
Asset allocation
Asset allocat...
Trading
Agent
Trading...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/component/backtest.rst b/docs/component/backtest.rst index 88e01e2de..e83e1023a 100644 --- a/docs/component/backtest.rst +++ b/docs/component/backtest.rst @@ -30,7 +30,7 @@ The simple example of the default strategy is as follows. from qlib.contrib.evaluate import backtest # pred_score is the prediction score - report, positions = backtest(pred_score, topk=50, n_drop=0.5, verbose=False, limit_threshold=0.0095) + report, positions = backtest(pred_score, topk=50, n_drop=0.5, limit_threshold=0.0095) To know more about backtesting with a specific ``Strategy``, please refer to `Portfolio Strategy `_. diff --git a/docs/component/highfreq.rst b/docs/component/highfreq.rst new file mode 100644 index 000000000..13ebb959d --- /dev/null +++ b/docs/component/highfreq.rst @@ -0,0 +1,120 @@ +.. _highfreq: + +============================================ +Design of hierarchical order execution framework +============================================ +.. currentmodule:: qlib + +Introduction +=================== + +In order to support reinforcement learning algorithms for high-frequency trading, a corresponding framework is required. None of the publicly available high-frequency trading frameworks now consider multi-layer trading mechanisms, and the currently designed algorithms cannot directly use existing frameworks. +In addition to supporting the basic intraday multi-layer trading, the linkage with the day-ahead strategy is also a factor that affects the performance evaluation of the strategy. Different day strategies generate different order distributions and different patterns on different stocks. To verify that high-frequency trading strategies perform well on real trading orders, it is necessary to support day-frequency and high-frequency multi-level linkage trading. In addition to more accurate backtesting of high-frequency trading algorithms, if the distribution of day-frequency orders is considered when training a high-frequency trading model, the algorithm can also be optimized more for product-specific day-frequency orders. +Therefore, innovation in the high-frequency trading framework is necessary to solve the various problems mentioned above, for which we designed a hierarchical order execution framework that can link daily-frequency and intra-day trading at different granularities. + +.. image:: ../_static/img/framework.svg + +The design of the framework is shown in the figure above. At each layer consists of Trading Agent and Execution Env. The Trading Agent has its own data processing module (Information Extractor), forecasting module (Forecast Model) and decision generator (Decision Generator). The trading algorithm generates the corresponding decisions by the Decision Generator based on the forecast signals output by the Forecast Module, and the decisions generated by the trading algorithm are passed to the Execution Env, which returns the execution results. Here the frequency of trading algorithm, decision content and execution environment can be customized by users (e.g. intra-day trading, daily-frequency trading, weekly-frequency trading), and the execution environment can be nested with finer-grained trading algorithm and execution environment inside (i.e. sub-workflow in the figure, e.g. daily-frequency orders can be turned into finer-grained decisions by splitting orders within the day). The hierarchical order execution framework is user-defined in terms of hierarchy division and decision frequency, making it easy for users to explore the effects of combining different levels of trading algorithms and breaking down the barriers between different levels of trading algorithm optimization. +In addition to the innovation in the framework, the hierarchical order execution framework also takes into account various details of the real backtesting environment, minimizing the differences with the final real environment as much as possible. At the same time, the framework is designed to unify the interface between online and offline (e.g. data pre-processing level supports using the same set of code to process both offline and online data) to reduce the cost of strategy go-live as much as possible. + +Prepare Data +=================== +.. _data:: ../../examples/highfreq/README.md + + +Example +=========================== + +Here is an example of highfreq execution. + +.. code-block:: python + + import qlib + # init qlib + provider_uri_day = "~/.qlib/qlib_data/cn_data" + provider_uri_1min = "~/.qlib/qlib_data/cn_data_1min" + provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} + qlib.init(provider_uri=provider_uri_day, expression_cache=None, dataset_cache=None) + + # data freq and backtest time + freq = "1min" + inst_list = D.list_instruments(D.instruments("all"), as_list=True) + start_time = "2020-01-01" + start_time = "2020-01-31" + +When initializing qlib, if the default data is used, then both daily and minute frequency data need to be passed in. + +.. code-block:: python + + # random order strategy config + strategy_config = { + "class": "RandomOrderStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + "kwargs": { + "trade_range": TradeRangeByTime("9:30", "15:00"), + "sample_ratio": 1.0, + "volume_ratio": 0.01, + "market": market, + }, + } + +.. code-block:: python + # backtest config + backtest_config = { + "start_time": start_time, + "end_time": end_time, + "account": 100000000, + "benchmark": None, + "exchange_kwargs": { + "freq": freq, + "limit_threshold": 0.095, + "deal_price": "close", + "open_cost": 0.0005, + "close_cost": 0.0015, + "min_cost": 5, + "codes": market, + }, + "pos_type": "InfPosition", # Position with infinitive position + } + +please refer to "../../qlib/backtest". + +.. code-block:: python + # excutor config + executor_config = { + "class": "NestedExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": "day", + "inner_executor": { + "class": "SimulatorExecutor", + "module_path": "qlib.backtest.executor", + "kwargs": { + "time_per_step": freq, + "generate_portfolio_metrics": True, + "verbose": False, + # "verbose": True, + "indicator_config": { + "show_indicator": False, + }, + }, + }, + "inner_strategy": { + "class": "TWAPStrategy", + "module_path": "qlib.contrib.strategy.rule_strategy", + }, + "track_data": True, + "generate_portfolio_metrics": True, + "indicator_config": { + "show_indicator": True, + }, + }, + } + +NestedExecutor represents not the innermost layer, the initialization parameters should contain inner_executor and inner_strategy. simulatorExecutor represents the current excutor is the innermost layer, the innermost strategy used here is the TWAP strategy, the framework currently also supports the VWAP strategy + +.. code-block:: python + # backtest + portfolio_metrics_dict, indicator_dict = backtest(executor=executor_config, strategy=strategy_config, **backtest_config) + +The metrics of backtest are included in the portfolio_metrics_dict and indicator_dict. diff --git a/docs/component/recorder.rst b/docs/component/recorder.rst index cc425fa8e..5a7d195d6 100644 --- a/docs/component/recorder.rst +++ b/docs/component/recorder.rst @@ -123,7 +123,6 @@ Here is a simple exampke of what is done in ``PortAnaRecord``, which users can r "n_drop": 5, } BACKTEST_CONFIG = { - "verbose": False, "limit_threshold": 0.095, "account": 100000000, "benchmark": BENCHMARK, diff --git a/docs/component/strategy.rst b/docs/component/strategy.rst index e4a5a94d1..c9d002ca1 100644 --- a/docs/component/strategy.rst +++ b/docs/component/strategy.rst @@ -93,7 +93,6 @@ Usage & Example "n_drop": 5, } BACKTEST_CONFIG = { - "verbose": False, "limit_threshold": 0.095, "account": 100000000, "benchmark": BENCHMARK, diff --git a/docs/component/workflow.rst b/docs/component/workflow.rst index 2b7ec19ad..84522af99 100644 --- a/docs/component/workflow.rst +++ b/docs/component/workflow.rst @@ -54,7 +54,6 @@ Below is a typical config file of ``qrun``. topk: 50 n_drop: 5 backtest: - verbose: False limit_threshold: 0.095 account: 100000000 benchmark: *benchmark @@ -242,7 +241,6 @@ The following script is the configuration of `backtest` and the `strategy` used topk: 50 n_drop: 5 backtest: - verbose: False limit_threshold: 0.095 account: 100000000 benchmark: *benchmark diff --git a/docs/developer/code_standard.rst b/docs/developer/code_standard.rst index 23ea713ba..27da3bfd1 100644 --- a/docs/developer/code_standard.rst +++ b/docs/developer/code_standard.rst @@ -6,15 +6,17 @@ Code Standard Docstring ================================= -Please use the Numpy Style. +Please use the `Numpydoc Style `_. Continuous Integration ================================= Continuous Integration (CI) tools help you stick to the quality standards by running tests every time you push a new commit and reporting the results to a pull request. +When you submit a PR request, you can check whether your code passes the CI tests in the "check" section at the bottom of the web page. + A common error is the mixed use of space and tab. You can fix the bug by inputing the following code in the command line. .. code-block:: python pip install black - python -m black . -l 120 \ No newline at end of file + python -m black . -l 120 diff --git a/docs/hidden/tuner.rst b/docs/hidden/tuner.rst index 6d62f899f..8abf2ec7c 100644 --- a/docs/hidden/tuner.rst +++ b/docs/hidden/tuner.rst @@ -93,7 +93,6 @@ We write a simple configuration example as following, fend_time: 2018-12-11 backtest: normal_backtest_args: - verbose: False limit_threshold: 0.095 account: 500000 benchmark: SH000905 @@ -306,7 +305,6 @@ About the data and backtest fend_time: 2018-12-11 backtest: normal_backtest_args: - verbose: False limit_threshold: 0.095 account: 500000 benchmark: SH000905 diff --git a/docs/introduction/introduction.rst b/docs/introduction/introduction.rst index 06fac46fa..a55edd5ec 100644 --- a/docs/introduction/introduction.rst +++ b/docs/introduction/introduction.rst @@ -15,7 +15,7 @@ With ``Qlib``, users can easily try their ideas to create better Quant investmen Framework =================== -.. image:: ../_static/img/framework.png +.. image:: ../_static/img/framework.svg :align: center diff --git a/docs/start/initialization.rst b/docs/start/initialization.rst index 32c17ff83..eaf80f4a5 100644 --- a/docs/start/initialization.rst +++ b/docs/start/initialization.rst @@ -77,7 +77,8 @@ Besides `provider_uri` and `region`, `qlib.init` has other parameters. The follo }) - `mongo` Type: dict, optional parameter, the setting of `MongoDB `_ which will be used in some features such as `Task Management <../advanced/task_management.html>`_, with high performance and clustered processing. - Users need finished `installation `_ firstly, and run it in a fixed URL. + Users need to follow the steps in `installation `_ to install MongoDB firstly and then access it via a URI. + Users can access mongodb with credential by setting "task_url" to a string like `"mongodb://%s:%s@%s" % (user, pwd, host + ":" + port)`. .. code-block:: Python diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml index ea38ae19c..039040d8f 100755 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha158.yaml @@ -93,8 +93,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml index 83720b4b2..88c6fcd07 100644 --- a/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml +++ b/examples/benchmarks/ALSTM/workflow_config_alstm_Alpha360.yaml @@ -83,8 +83,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml index 0ffe19e1b..18e19bd0f 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha158.yaml @@ -65,8 +65,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml index 57c1751a1..a6cdd1882 100644 --- a/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml +++ b/examples/benchmarks/CatBoost/workflow_config_catboost_Alpha360.yaml @@ -72,8 +72,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml index 71f0d3e64..fb8cce74d 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha158.yaml @@ -90,8 +90,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml index 8a185f05f..d1fbd7807 100644 --- a/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml +++ b/examples/benchmarks/DoubleEnsemble/workflow_config_doubleensemble_Alpha360.yaml @@ -97,8 +97,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml index 63aa1b429..5387adc24 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha158.yaml @@ -91,8 +91,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml index e06192b2b..1ffd6780e 100644 --- a/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml +++ b/examples/benchmarks/GATs/workflow_config_gats_Alpha360.yaml @@ -83,8 +83,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml index 42286fecd..82c690889 100755 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha158.yaml @@ -92,8 +92,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml index bd1a6e1bf..02c81c850 100644 --- a/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml +++ b/examples/benchmarks/GRU/workflow_config_gru_Alpha360.yaml @@ -82,8 +82,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml index 687404419..f4412c262 100755 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha158.yaml @@ -92,8 +92,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml index e6c3b5736..10a1dc5df 100644 --- a/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml +++ b/examples/benchmarks/LSTM/workflow_config_lstm_Alpha360.yaml @@ -82,8 +82,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/LightGBM/features_resample_N.py b/examples/benchmarks/LightGBM/features_resample_N.py new file mode 100644 index 000000000..13061513c --- /dev/null +++ b/examples/benchmarks/LightGBM/features_resample_N.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pandas as pd + +from qlib.data.inst_processor import InstProcessor +from qlib.utils.resam import resam_calendar + + +class ResampleNProcessor(InstProcessor): + def __init__(self, target_frq: str, **kwargs): + self.target_frq = target_frq + + def __call__(self, df: pd.DataFrame, *args, **kwargs): + df.index = pd.to_datetime(df.index) + res_index = resam_calendar(df.index, "1min", self.target_frq) + df = df.resample(self.target_frq).last().reindex(res_index) + return df diff --git a/examples/benchmarks/LightGBM/multi_freq_handler.py b/examples/benchmarks/LightGBM/multi_freq_handler.py new file mode 100644 index 000000000..07d7ac27c --- /dev/null +++ b/examples/benchmarks/LightGBM/multi_freq_handler.py @@ -0,0 +1,135 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pandas as pd + +from qlib.data.dataset.loader import QlibDataLoader +from qlib.contrib.data.handler import DataHandlerLP, _DEFAULT_LEARN_PROCESSORS, check_transform_proc + + +class Avg15minLoader(QlibDataLoader): + def load(self, instruments=None, start_time=None, end_time=None) -> pd.DataFrame: + df = super(Avg15minLoader, self).load(instruments, start_time, end_time) + if self.is_group: + # feature_day(day freq) and feature_15min(1min freq, Average every 15 minutes) renamed feature + df.columns = df.columns.map(lambda x: ("feature", x[1]) if x[0].startswith("feature") else x) + return df + + +class Avg15minHandler(DataHandlerLP): + def __init__( + self, + instruments="csi500", + start_time=None, + end_time=None, + freq="day", + infer_processors=[], + learn_processors=_DEFAULT_LEARN_PROCESSORS, + fit_start_time=None, + fit_end_time=None, + process_type=DataHandlerLP.PTYPE_A, + filter_pipe=None, + inst_processor=None, + **kwargs, + ): + infer_processors = check_transform_proc(infer_processors, fit_start_time, fit_end_time) + learn_processors = check_transform_proc(learn_processors, fit_start_time, fit_end_time) + data_loader = Avg15minLoader( + config=self.loader_config(), filter_pipe=filter_pipe, freq=freq, inst_processor=inst_processor + ) + super().__init__( + instruments=instruments, + start_time=start_time, + end_time=end_time, + data_loader=data_loader, + infer_processors=infer_processors, + learn_processors=learn_processors, + process_type=process_type, + ) + + def loader_config(self): + + # Results for dataset: df: pd.DataFrame + # len(df.columns) == 6 + 6 * 16, len(df.index.get_level_values(level="datetime").unique()) == T + # df.columns: close0, close1, ..., close16, open0, ..., open16, ..., vwap16 + # freq == day: + # close0, open0, low0, high0, volume0, vwap0 + # freq == 1min: + # close1, ..., close16, ..., vwap1, ..., vwap16 + # df.index.name == ["datetime", "instrument"]: pd.MultiIndex + # Example: + # feature ... label + # close0 open0 low0 ... vwap1 vwap16 LABEL0 + # datetime instrument ... + # 2020-10-09 SH600000 11.794546 11.819587 11.769505 ... NaN NaN -0.005214 + # 2020-10-15 SH600000 12.044961 11.944795 11.932274 ... NaN NaN -0.007202 + # ... ... ... ... ... ... ... ... + # 2021-05-28 SZ300676 6.369684 6.495406 6.306568 ... NaN NaN -0.001321 + # 2021-05-31 SZ300676 6.601626 6.465643 6.465130 ... NaN NaN -0.023428 + + # features day: len(columns) == 6, freq = day + # $close is the closing price of the current trading day: + # if the user needs to get the `close` before the last T days, use Ref($close, T-1), for example: + # $close Ref($close, 1) Ref($close, 2) Ref($close, 3) Ref($close, 4) + # instrument datetime + # SH600519 2021-06-01 244.271530 + # 2021-06-02 242.205917 244.271530 + # 2021-06-03 242.229889 242.205917 244.271530 + # 2021-06-04 245.421524 242.229889 242.205917 244.271530 + # 2021-06-07 247.547089 245.421524 242.229889 242.205917 244.271530 + + # WARNING: Ref($close, N), if N == 0, Ref($close, N) ==> $close + + fields = ["$close", "$open", "$low", "$high", "$volume", "$vwap"] + # names: close0, open0, ..., vwap0 + names = list(map(lambda x: x.strip("$") + "0", fields)) + + config = {"feature_day": (fields, names)} + + # features 15min: len(columns) == 6 * 16, freq = 1min + # $close is the closing price of the current trading day: + # if the user gets 'close' for the i-th 15min of the last T days, use `Ref(Mean($close, 15), (T-1) * 240 + i * 15)`, for example: + # Ref(Mean($close, 15), 225) Ref(Mean($close, 15), 465) Ref(Mean($close, 15), 705) + # instrument datetime + # SH600519 2021-05-31 241.769897 243.077942 244.712997 + # 2021-06-01 244.271530 241.769897 243.077942 + # 2021-06-02 242.205917 244.271530 241.769897 + + # WARNING: Ref(Mean($close, 15), N), if N == 0, Ref(Mean($close, 15), N) ==> Mean($close, 15) + + # Results of the current script: + # time: 09:00 --> 09:14, ..., 14:45 --> 14:59 + # fields: Ref(Mean($close, 15), 225), ..., Mean($close, 15) + # name: close1, ..., close16 + # + + # Expression description: take close as an example + # Mean($close, 15) ==> df["$close"].rolling(15, min_periods=1).mean() + # Ref(Mean($close, 15), 15) ==> df["$close"].rolling(15, min_periods=1).mean().shift(15) + + # NOTE: The last data of each trading day, which is the average of the i-th 15 minutes + + # Average: + # Average of the i-th 15-minute period of each trading day: 1 <= i <= 250 // 16 + # Avg(15minutes): Ref(Mean($close, 15), 240 - i * 15) + # + # Average of the first 15 minutes of each trading day; i = 1 + # Avg(09:00 --> 09:14), df.index.loc["09:14"]: Ref(Mean($close, 15), 240- 1 * 15) ==> Ref(Mean($close, 15), 225) + # Average of the last 15 minutes of each trading day; i = 16 + # Avg(14:45 --> 14:59), df.index.loc["14:59"]: Ref(Mean($close, 15), 240 - 16 * 15) ==> Ref(Mean($close, 15), 0) ==> Mean($close, 15) + + # 15min resample to day + # df.resample("1d").last() + tmp_fields = [] + tmp_names = [] + for i, _f in enumerate(fields): + _fields = [f"Ref(Mean({_f}, 15), {j * 15})" for j in range(1, 240 // 15)] + _names = [f"{names[i][:-1]}{int(names[i][-1])+j}" for j in range(240 // 15 - 1, 0, -1)] + _fields.append(f"Mean({_f}, 15)") + _names.append(f"{names[i][:-1]}{int(names[i][-1])+240 // 15}") + tmp_fields += _fields + tmp_names += _names + config["feature_15min"] = (tmp_fields, tmp_names) + # label + config["label"] = (["Ref($close, -2)/Ref($close, -1) - 1"], ["LABEL0"]) + return config diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml index 9d6f45076..8bee2bd38 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha158.yaml @@ -66,8 +66,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml index ba96b076c..b8af19ec1 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_Alpha360.yaml @@ -73,8 +73,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml index 0f71b2a36..a92f342a1 100644 --- a/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_configurable_dataset.yaml @@ -81,9 +81,7 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config diff --git a/examples/benchmarks/LightGBM/workflow_config_lightgbm_multi_freq.yaml b/examples/benchmarks/LightGBM/workflow_config_lightgbm_multi_freq.yaml new file mode 100644 index 000000000..829c87115 --- /dev/null +++ b/examples/benchmarks/LightGBM/workflow_config_lightgbm_multi_freq.yaml @@ -0,0 +1,86 @@ +qlib_init: + provider_uri: + day: "~/.qlib/qlib_data/cn_data" + 1min: "~/.qlib/qlib_data/cn_data_1min" + region: cn + dataset_cache: null + maxtasksperchild: null +market: &market csi300 +benchmark: &benchmark SH000300 +data_handler_config: &data_handler_config + start_time: 2008-01-01 + # 1min closing time is 15:00:00 + end_time: "2020-08-01 15:00:00" + fit_start_time: 2008-01-01 + fit_end_time: 2014-12-31 + instruments: *market + freq: + label: day + feature_15min: 1min + feature_day: day + # with label as reference + inst_processor: + feature_15min: + - class: ResampleNProcessor + module_path: features_resample_N.py + kwargs: + target_frq: 1d + +port_analysis_config: &port_analysis_config + strategy: + class: TopkDropoutStrategy + module_path: qlib.contrib.strategy + kwargs: + model: + dataset: + topk: 50 + n_drop: 5 + backtest: + limit_threshold: 0.095 + account: 100000000 + benchmark: *benchmark + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 +task: + model: + class: LGBModel + module_path: qlib.contrib.model.gbdt + kwargs: + loss: mse + colsample_bytree: 0.8879 + learning_rate: 0.2 + subsample: 0.8789 + lambda_l1: 205.6999 + lambda_l2: 580.9768 + max_depth: 8 + num_leaves: 210 + num_threads: 20 + dataset: + class: DatasetH + module_path: qlib.data.dataset + kwargs: + handler: + class: Avg15minHandler + module_path: multi_freq_handler.py + kwargs: *data_handler_config + segments: + train: [2008-01-01, 2014-12-31] + valid: [2015-01-01, 2016-12-31] + test: [2017-01-01, 2020-08-01] + record: + - class: SignalRecord + module_path: qlib.workflow.record_temp + kwargs: + model: + dataset: + - class: SigAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + ana_long_short: False + ann_scaler: 252 + - class: PortAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + config: *port_analysis_config diff --git a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml index 1cf28024e..9f055a62c 100644 --- a/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml +++ b/examples/benchmarks/Linear/workflow_config_linear_Alpha158.yaml @@ -72,8 +72,6 @@ task: kwargs: ana_long_short: True ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/Localformer/workflow_config_localformer_Alpha158.yaml b/examples/benchmarks/Localformer/workflow_config_localformer_Alpha158.yaml index d7e967333..cd31ecd1e 100644 --- a/examples/benchmarks/Localformer/workflow_config_localformer_Alpha158.yaml +++ b/examples/benchmarks/Localformer/workflow_config_localformer_Alpha158.yaml @@ -34,19 +34,23 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LocalformerModel @@ -70,13 +74,15 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: - ana_long_short: False - ann_scaler: 252 + ana_long_short: False + ann_scaler: 252 - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config + config: *port_analysis_config diff --git a/examples/benchmarks/Localformer/workflow_config_localformer_Alpha360.yaml b/examples/benchmarks/Localformer/workflow_config_localformer_Alpha360.yaml index 1c8489461..f9cc091fd 100644 --- a/examples/benchmarks/Localformer/workflow_config_localformer_Alpha360.yaml +++ b/examples/benchmarks/Localformer/workflow_config_localformer_Alpha360.yaml @@ -26,19 +26,23 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: LocalformerModel @@ -59,15 +63,17 @@ task: valid: [2015-01-01, 2016-12-31] test: [2017-01-01, 2020-08-01] record: - - class: SignalRecord - module_path: qlib.workflow.record_temp - kwargs: {} - - class: SigAnaRecord - module_path: qlib.workflow.record_temp - kwargs: - ana_long_short: False - ann_scaler: 252 - - class: PortAnaRecord - module_path: qlib.workflow.record_temp - kwargs: - config: *port_analysis_config \ No newline at end of file + - class: SignalRecord + module_path: qlib.workflow.record_temp + kwargs: + model: + dataset: + - class: SigAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + ana_long_short: False + ann_scaler: 252 + - class: PortAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + config: *port_analysis_config diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml index bc005b43e..8303f3945 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha158.yaml @@ -95,8 +95,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml index a4ceab8da..f52c5930d 100644 --- a/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml +++ b/examples/benchmarks/MLP/workflow_config_mlp_Alpha360.yaml @@ -82,8 +82,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/README.md b/examples/benchmarks/README.md index ee2c0a833..cd4276781 100644 --- a/examples/benchmarks/README.md +++ b/examples/benchmarks/README.md @@ -25,6 +25,7 @@ The numbers shown below demonstrate the performance of the entire `workflow` of | TCTS (Xueqing Wu, et al.)| Alpha360 | 0.0485±0.00 | 0.3689±0.04| 0.0586±0.00 | 0.4669±0.02 | 0.0816±0.02 | 1.1572±0.30| -0.0689±0.02 | | Transformer (Ashish Vaswani, et al.)| Alpha360 | 0.0141±0.00 | 0.0917±0.02| 0.0331±0.00 | 0.2357±0.03 | -0.0259±0.03 | -0.3323±0.43| -0.1763±0.07 | | Localformer (Juyong Jiang, et al.)| Alpha360 | 0.0408±0.00 | 0.2988±0.03| 0.0538±0.00 | 0.4105±0.02 | 0.0275±0.03 | 0.3464±0.37| -0.1182±0.03 | +| TRA (Hengxu Lin, et al.)| Alpha360 | 0.0491±0.01 | 0.3868±0.06 | 0.0589±0.00 | 0.4802±0.04 | 0.0898±0.02 | 1.2490±0.32 | -0.0778±0.02 | ## Alpha158 dataset | Model Name | Dataset | IC | ICIR | Rank IC | Rank ICIR | Annualized Return | Information Ratio | Max Drawdown | @@ -43,6 +44,8 @@ The numbers shown below demonstrate the performance of the entire `workflow` of | TabNet (Sercan O. Arik, et al.)| Alpha158 | 0.0383±0.00 | 0.3414±0.00| 0.0388±0.00 | 0.3460±0.00 | 0.0226±0.00 | 0.2652±0.00| -0.1072±0.00 | | Transformer (Ashish Vaswani, et al.)| Alpha158 | 0.0274±0.00 | 0.2166±0.04| 0.0409±0.00 | 0.3342±0.04 | 0.0204±0.03 | 0.2888±0.40| -0.1216±0.04 | | Localformer (Juyong Jiang, et al.)| Alpha158 | 0.0355±0.00 | 0.2747±0.04| 0.0466±0.00 | 0.3762±0.03 | 0.0506±0.02 | 0.7447±0.34| -0.0875±0.02 | +| TRA (Hengxu Lin, et al.)| Alpha158 (with selected 20 features)| 0.0409±0.00 | 0.3253±0.04 | 0.0488±0.00 | 0.4045±0.02 | 0.0673±0.02 | 1.0389±0.39 | -0.0830±0.02 | +| TRA (Hengxu Lin, et al.)| Alpha158 | 0.0442±0.00 | 0.3426±0.03 | 0.0555±0.00 | 0.4395±0.03 | 0.0833±0.03 | 1.2064±0.36 | -0.0849±0.02 | - The selected 20 features are based on the feature importance of a lightgbm-based model. - The base model of DoubleEnsemble is LGBM. diff --git a/examples/benchmarks/SFM/requirements.txt b/examples/benchmarks/SFM/requirements.txt index 6a3d13097..16de0a438 100644 --- a/examples/benchmarks/SFM/requirements.txt +++ b/examples/benchmarks/SFM/requirements.txt @@ -1,4 +1,4 @@ pandas==1.1.2 numpy==1.17.4 scikit_learn==0.23.2 -torch==1.7.0 \ No newline at end of file +torch==1.7.0 diff --git a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml index e42f75aec..5c66400bb 100644 --- a/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml +++ b/examples/benchmarks/SFM/workflow_config_sfm_Alpha360.yaml @@ -85,8 +85,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/TCTS/requirements.txt b/examples/benchmarks/TCTS/requirements.txt new file mode 100644 index 000000000..6a3d13097 --- /dev/null +++ b/examples/benchmarks/TCTS/requirements.txt @@ -0,0 +1,4 @@ +pandas==1.1.2 +numpy==1.17.4 +scikit_learn==0.23.2 +torch==1.7.0 \ No newline at end of file diff --git a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml index c6eac243c..7ca6e937f 100644 --- a/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml +++ b/examples/benchmarks/TCTS/workflow_config_tcts_Alpha360.yaml @@ -90,8 +90,6 @@ task: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: - model: - dataset: ana_long_short: False ann_scaler: 252 label_col: 1 diff --git a/examples/benchmarks/TFT/README.md b/examples/benchmarks/TFT/README.md index 5a6a9f153..991066b7f 100644 --- a/examples/benchmarks/TFT/README.md +++ b/examples/benchmarks/TFT/README.md @@ -8,7 +8,7 @@ Users can follow the ``workflow_by_code_tft.py`` to run the benchmark. ### Notes -1. Please be **aware** that this script can only support `Python 3.5 - 3.8`. +1. Please be **aware** that this script can only support `Python 3.6 - 3.7`. 2. If the CUDA version on your machine is not 10.0, please remember to run the following commands `conda install anaconda cudatoolkit=10.0` and `conda install cudnn` on your machine. 3. The model must run in GPU, or an error will be raised. 4. New datasets should be registered in ``data_formatters``, for detail please visit the source. diff --git a/examples/benchmarks/TFT/requirements.txt b/examples/benchmarks/TFT/requirements.txt index 04234aaed..f8bd00002 100644 --- a/examples/benchmarks/TFT/requirements.txt +++ b/examples/benchmarks/TFT/requirements.txt @@ -1,3 +1,2 @@ tensorflow-gpu==1.15.0 -numpy == 1.19.4 -pandas==1.1.0 \ No newline at end of file +pandas==1.1.0 diff --git a/examples/benchmarks/TFT/tft.py b/examples/benchmarks/TFT/tft.py index e1205b0e0..a854c2dd9 100644 --- a/examples/benchmarks/TFT/tft.py +++ b/examples/benchmarks/TFT/tft.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from pathlib import Path +from typing import Union import numpy as np import pandas as pd import tensorflow.compat.v1 as tf @@ -243,7 +245,7 @@ class TFTModel(ModelFT): # extract_numerical_data(targets), extract_numerical_data(p90_forecast), # 0.9) tf.keras.backend.set_session(default_keras_session) - print("Training completed.".format(dte.datetime.now())) + print("Training completed at {}.".format(dte.datetime.now())) # ===========================Training Process=========================== def predict(self, dataset): @@ -289,3 +291,25 @@ class TFTModel(ModelFT): dataset for finetuning """ pass + + def to_pickle(self, path: Union[Path, str]): + """ + Tensorflow model can't be dumped directly. + So the data should be save seperatedly + + **TODO**: Please implement the function to load the files + + Parameters + ---------- + path : Union[Path, str] + the target path to be dumped + """ + # FIXME: implementing saving tensorflow models + # save tensorflow model + # path = Path(path) + # path.mkdir(parents=True) + # self.model.save(path) + + # save qlib model wrapper + self.model = None + super(TFTModel, self).to_pickle(path) diff --git a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml index a396371dc..0508ce676 100644 --- a/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml +++ b/examples/benchmarks/TFT/workflow_config_tft_Alpha158.yaml @@ -58,8 +58,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/TRA/README.md b/examples/benchmarks/TRA/README.md index 070527ddb..5ff5b480e 100644 --- a/examples/benchmarks/TRA/README.md +++ b/examples/benchmarks/TRA/README.md @@ -1,53 +1,78 @@ # Learning Multiple Stock Trading Patterns with Temporal Routing Adaptor and Optimal Transport -This code provides a PyTorch implementation for TRA (Temporal Routing Adaptor), as described in the paper [Learning Multiple Stock Trading Patterns with Temporal Routing Adaptor and Optimal Transport](http://arxiv.org/abs/2106.12950). +Temporal Routing Adaptor (TRA) is designed to capture multiple trading patterns in the stock market data. Please refer to [our paper](http://arxiv.org/abs/2106.12950) for more details. -* TRA (Temporal Routing Adaptor) is a lightweight module that consists of a set of independent predictors for learning multiple patterns as well as a router to dispatch samples to different predictors. -* We also design a learning algorithm based on Optimal Transport (OT) to obtain the optimal sample to predictor assignment and effectively optimize the router with such assignment through an auxiliary loss term. +If you find our work useful in your research, please cite: +``` +@inproceedings{HengxuKDD2021, + author = {Hengxu Lin and Dong Zhou and Weiqing Liu and Jiang Bian}, + title = {Learning Multiple Stock Trading Patterns with Temporal Routing Adaptor and Optimal Transport}, + booktitle = {Proceedings of the 27th ACM SIGKDD Conference on Knowledge Discovery \& Data Mining}, + series = {KDD '21}, + year = {2021}, + publisher = {ACM}, +} +@article{yang2020qlib, + title={Qlib: An AI-oriented Quantitative Investment Platform}, + author={Yang, Xiao and Liu, Weiqing and Zhou, Dong and Bian, Jiang and Liu, Tie-Yan}, + journal={arXiv preprint arXiv:2009.11189}, + year={2020} +} +``` -# Running TRA +## Usage (Recommended) -## Requirements -- Install `Qlib` main branch +**Update**: `TRA` has been moved to `qlib.contrib.model.pytorch_tra` to support other `Qlib` components like `qlib.workflow` and `Alpha158/Alpha360` dataset. -## Running +Please follow the official [doc](https://qlib.readthedocs.io/en/latest/component/workflow.html) to use `TRA` with `workflow`. Here we also provide several example config files: + +- `workflow_config_tra_Alpha360.yaml`: running `TRA` with `Alpha360` dataset +- `workflow_config_tra_Alpha158.yaml`: running `TRA` with `Alpha158` dataset (with feature subsampling) +- `workflow_config_tra_Alpha158_full.yaml`: running `TRA` with `Alpha158` dataset (without feature subsampling) + +The performances of `TRA` are reported in [Benchmarks](https://github.com/microsoft/qlib/tree/main/examples/benchmarks). + +## Usage (Not Maintained) + +This section is used to reproduce the results in the paper. + +### Running We attach our running scripts for the paper in `run.sh`. And here are two ways to run the model: * Running from scripts with default parameters - You can directly run from Qlib command `qrun`: - ``` - qrun configs/config_alstm.yaml - ``` + + You can directly run from Qlib command `qrun`: + ``` + qrun configs/config_alstm.yaml + ``` * Running from code with self-defined parameters - Setting different parameters is also allowed. See codes in `example.py`: - ``` - python example.py --config_file configs/config_alstm.yaml - ``` + + Setting different parameters is also allowed. See codes in `example.py`: + ``` + python example.py --config_file configs/config_alstm.yaml + ``` Here we trained TRA on a pretrained backbone model. Therefore we run `*_init.yaml` before TRA's scipts. -# Results - -## Outputs +### Results After running the scripts, you can find result files in path `./output`: -`info.json` - config settings and result metrics. +* `info.json` - config settings and result metrics. +* `log.csv` - running logs. +* `model.bin` - the model parameter dictionary. +* `pred.pkl` - the prediction scores and output for inference. -`log.csv` - running logs. +Evaluation metrics reported in the paper: +This result is generated by qlib==0.7.1. -`model.bin` - the model parameter dictionary. - -`pred.pkl` - the prediction scores and output for inference. - -## Our Results | Methods | MSE| MAE| IC | ICIR | AR | AV | SR | MDD | -|-------------------|-------------------|---------------------|--------------------|--------------------|--------------------|--------------------|--------------------|--------------------| +|-------|-------|------|-----|-----|-----|-----|-----|-----| |Linear|0.163|0.327|0.020|0.132|-3.2%|16.8%|-0.191|32.1%| |LightGBM|0.160(0.000)|0.323(0.000)|0.041|0.292|7.8%|15.5%|0.503|25.7%| |MLP|0.160(0.002)|0.323(0.003)|0.037|0.273|3.7%|15.3%|0.264|26.2%| @@ -61,21 +86,8 @@ After running the scripts, you can find result files in path `./output`: A more detailed demo for our experiment results in the paper can be found in `Report.ipynb`. -# Common Issues +## Common Issues For help or issues using TRA, please submit a GitHub issue. -Sometimes we might encounter situation where the loss is `NaN`, please check the `epsilon` parameter in the sinkhorn algorithm, adjusting the `epsilon` according to input's scale is important. - -# Citation -If you find this repository useful in your research, please cite: -``` -@inproceedings{HengxuKDD2021, - author = {Hengxu Lin and Dong Zhou and Weiqing Liu and Jiang Bian}, - title = {Learning Multiple Stock Trading Patterns with Temporal Routing Adaptor and Optimal Transport}, - booktitle = {Proceedings of the 27th ACM SIGKDD Conference on Knowledge Discovery \& Data Mining}, - series = {KDD '21}, - year = {2021}, - publisher = {ACM}, -} -``` +Sometimes we might encounter situation where the loss is `NaN`, please check the `epsilon` parameter in the sinkhorn algorithm, adjusting the `epsilon` according to input's scale is important. diff --git a/examples/benchmarks/TRA/requirements.txt b/examples/benchmarks/TRA/requirements.txt new file mode 100644 index 000000000..ab819ec1c --- /dev/null +++ b/examples/benchmarks/TRA/requirements.txt @@ -0,0 +1,5 @@ +pandas==1.1.2 +numpy==1.17.4 +scikit_learn==0.23.2 +torch==1.7.0 +seaborn diff --git a/examples/benchmarks/TRA/workflow_config_tra_Alpha158.yaml b/examples/benchmarks/TRA/workflow_config_tra_Alpha158.yaml new file mode 100644 index 000000000..72b900127 --- /dev/null +++ b/examples/benchmarks/TRA/workflow_config_tra_Alpha158.yaml @@ -0,0 +1,132 @@ +qlib_init: + provider_uri: "~/.qlib/qlib_data/cn_data" + region: cn + +market: &market csi300 +benchmark: &benchmark SH000300 + +data_handler_config: &data_handler_config + start_time: 2008-01-01 + end_time: 2020-08-01 + fit_start_time: 2008-01-01 + fit_end_time: 2014-12-31 + instruments: *market + infer_processors: + - class: FilterCol + kwargs: + fields_group: feature + col_list: ["RESI5", "WVMA5", "RSQR5", "KLEN", "RSQR10", "CORR5", "CORD5", "CORR10", + "ROC60", "RESI10", "VSTD5", "RSQR60", "CORR60", "WVMA60", "STD5", + "RSQR20", "CORD60", "CORD10", "CORR20", "KLOW"] + - class: RobustZScoreNorm + kwargs: + fields_group: feature + clip_outlier: true + - class: Fillna + kwargs: + fields_group: feature + learn_processors: + - class: CSRankNorm + kwargs: + fields_group: label + label: ["Ref($close, -2) / Ref($close, -1) - 1"] + +num_states: &num_states 3 + +memory_mode: &memory_mode sample + +tra_config: &tra_config + num_states: *num_states + rnn_arch: LSTM + hidden_size: 32 + num_layers: 1 + dropout: 0.0 + tau: 1.0 + src_info: LR_TPE + +model_config: &model_config + input_size: 20 + hidden_size: 64 + num_layers: 2 + rnn_arch: LSTM + use_attn: True + dropout: 0.0 + +port_analysis_config: &port_analysis_config + strategy: + class: TopkDropoutStrategy + module_path: qlib.contrib.strategy + kwargs: + model: + dataset: + topk: 50 + n_drop: 5 + backtest: + limit_threshold: 0.095 + account: 100000000 + benchmark: *benchmark + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 + +task: + model: + class: TRAModel + module_path: qlib.contrib.model.pytorch_tra + kwargs: + tra_config: *tra_config + model_config: *model_config + model_type: RNN + lr: 1e-3 + n_epochs: 100 + max_steps_per_epoch: + early_stop: 20 + logdir: output/Alpha158 + seed: 0 + lamb: 1.0 + rho: 0.99 + alpha: 0.5 + transport_method: router + memory_mode: *memory_mode + eval_train: False + eval_test: True + pretrain: True + init_state: + freeze_model: False + freeze_predictors: False + dataset: + class: MTSDatasetH + module_path: qlib.contrib.data.dataset + kwargs: + handler: + class: Alpha158 + module_path: qlib.contrib.data.handler + kwargs: *data_handler_config + segments: + train: [2008-01-01, 2014-12-31] + valid: [2015-01-01, 2016-12-31] + test: [2017-01-01, 2020-08-01] + seq_len: 60 + horizon: 2 + input_size: + num_states: *num_states + batch_size: 1024 + n_samples: + memory_mode: *memory_mode + drop_last: True + record: + - class: SignalRecord + module_path: qlib.workflow.record_temp + kwargs: + model: + dataset: + - class: SigAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + ana_long_short: False + ann_scaler: 252 + - class: PortAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + config: *port_analysis_config diff --git a/examples/benchmarks/TRA/workflow_config_tra_Alpha158_full.yaml b/examples/benchmarks/TRA/workflow_config_tra_Alpha158_full.yaml new file mode 100644 index 000000000..ab8febc2f --- /dev/null +++ b/examples/benchmarks/TRA/workflow_config_tra_Alpha158_full.yaml @@ -0,0 +1,126 @@ +qlib_init: + provider_uri: "~/.qlib/qlib_data/cn_data" + region: cn + +market: &market csi300 +benchmark: &benchmark SH000300 + +data_handler_config: &data_handler_config + start_time: 2008-01-01 + end_time: 2020-08-01 + fit_start_time: 2008-01-01 + fit_end_time: 2014-12-31 + instruments: *market + infer_processors: + - class: RobustZScoreNorm + kwargs: + fields_group: feature + clip_outlier: true + - class: Fillna + kwargs: + fields_group: feature + learn_processors: + - class: CSRankNorm + kwargs: + fields_group: label + label: ["Ref($close, -2) / Ref($close, -1) - 1"] + +num_states: &num_states 3 + +memory_mode: &memory_mode sample + +tra_config: &tra_config + num_states: *num_states + rnn_arch: LSTM + hidden_size: 32 + num_layers: 1 + dropout: 0.0 + tau: 1.0 + src_info: LR_TPE + +model_config: &model_config + input_size: 158 + hidden_size: 256 + num_layers: 2 + rnn_arch: LSTM + use_attn: True + dropout: 0.2 + +port_analysis_config: &port_analysis_config + strategy: + class: TopkDropoutStrategy + module_path: qlib.contrib.strategy + kwargs: + model: + dataset: + topk: 50 + n_drop: 5 + backtest: + limit_threshold: 0.095 + account: 100000000 + benchmark: *benchmark + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 + +task: + model: + class: TRAModel + module_path: qlib.contrib.model.pytorch_tra + kwargs: + tra_config: *tra_config + model_config: *model_config + model_type: RNN + lr: 1e-3 + n_epochs: 100 + max_steps_per_epoch: + early_stop: 20 + logdir: output/Alpha158_full + seed: 0 + lamb: 1.0 + rho: 0.99 + alpha: 0.5 + transport_method: router + memory_mode: *memory_mode + eval_train: False + eval_test: True + pretrain: True + init_state: + freeze_model: False + freeze_predictors: False + dataset: + class: MTSDatasetH + module_path: qlib.contrib.data.dataset + kwargs: + handler: + class: Alpha158 + module_path: qlib.contrib.data.handler + kwargs: *data_handler_config + segments: + train: [2008-01-01, 2014-12-31] + valid: [2015-01-01, 2016-12-31] + test: [2017-01-01, 2020-08-01] + seq_len: 60 + horizon: 2 + input_size: + num_states: *num_states + batch_size: 1024 + n_samples: + memory_mode: *memory_mode + drop_last: True + record: + - class: SignalRecord + module_path: qlib.workflow.record_temp + kwargs: + model: + dataset: + - class: SigAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + ana_long_short: False + ann_scaler: 252 + - class: PortAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + config: *port_analysis_config diff --git a/examples/benchmarks/TRA/workflow_config_tra_Alpha360.yaml b/examples/benchmarks/TRA/workflow_config_tra_Alpha360.yaml new file mode 100644 index 000000000..64e3c91cb --- /dev/null +++ b/examples/benchmarks/TRA/workflow_config_tra_Alpha360.yaml @@ -0,0 +1,126 @@ +qlib_init: + provider_uri: "~/.qlib/qlib_data/cn_data" + region: cn + +market: &market csi300 +benchmark: &benchmark SH000300 + +data_handler_config: &data_handler_config + start_time: 2008-01-01 + end_time: 2020-08-01 + fit_start_time: 2008-01-01 + fit_end_time: 2014-12-31 + instruments: *market + infer_processors: + - class: RobustZScoreNorm + kwargs: + fields_group: feature + clip_outlier: true + - class: Fillna + kwargs: + fields_group: feature + learn_processors: + - class: CSRankNorm + kwargs: + fields_group: label + label: ["Ref($close, -2) / Ref($close, -1) - 1"] + +num_states: &num_states 3 + +memory_mode: &memory_mode sample + +tra_config: &tra_config + num_states: *num_states + rnn_arch: LSTM + hidden_size: 32 + num_layers: 1 + dropout: 0.0 + tau: 1.0 + src_info: LR_TPE + +model_config: &model_config + input_size: 6 + hidden_size: 64 + num_layers: 2 + rnn_arch: LSTM + use_attn: True + dropout: 0.0 + +port_analysis_config: &port_analysis_config + strategy: + class: TopkDropoutStrategy + module_path: qlib.contrib.strategy + kwargs: + model: + dataset: + topk: 50 + n_drop: 5 + backtest: + limit_threshold: 0.095 + account: 100000000 + benchmark: *benchmark + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 + +task: + model: + class: TRAModel + module_path: qlib.contrib.model.pytorch_tra + kwargs: + tra_config: *tra_config + model_config: *model_config + model_type: RNN + lr: 1e-3 + n_epochs: 100 + max_steps_per_epoch: + early_stop: 20 + logdir: output/Alpha360 + seed: 0 + lamb: 1.0 + rho: 0.99 + alpha: 0.5 + transport_method: router + memory_mode: *memory_mode + eval_train: False + eval_test: True + pretrain: True + init_state: + freeze_model: False + freeze_predictors: False + dataset: + class: MTSDatasetH + module_path: qlib.contrib.data.dataset + kwargs: + handler: + class: Alpha360 + module_path: qlib.contrib.data.handler + kwargs: *data_handler_config + segments: + train: [2008-01-01, 2014-12-31] + valid: [2015-01-01, 2016-12-31] + test: [2017-01-01, 2020-08-01] + seq_len: 60 + horizon: 2 + input_size: 6 + num_states: *num_states + batch_size: 1024 + n_samples: + memory_mode: *memory_mode + drop_last: True + record: + - class: SignalRecord + module_path: qlib.workflow.record_temp + kwargs: + model: + dataset: + - class: SigAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + ana_long_short: False + ann_scaler: 252 + - class: PortAnaRecord + module_path: qlib.workflow.record_temp + kwargs: + config: *port_analysis_config diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml index 71d41be63..0fa1b23d5 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha158.yaml @@ -75,8 +75,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml index f43af104c..0c798ae30 100644 --- a/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml +++ b/examples/benchmarks/TabNet/workflow_config_TabNet_Alpha360.yaml @@ -75,8 +75,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/Transformer/workflow_config_transformer_Alpha158.yaml b/examples/benchmarks/Transformer/workflow_config_transformer_Alpha158.yaml index 54707386f..6174abf2e 100644 --- a/examples/benchmarks/Transformer/workflow_config_transformer_Alpha158.yaml +++ b/examples/benchmarks/Transformer/workflow_config_transformer_Alpha158.yaml @@ -34,19 +34,23 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: TransformerModel @@ -70,7 +74,9 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/Transformer/workflow_config_transformer_Alpha360.yaml b/examples/benchmarks/Transformer/workflow_config_transformer_Alpha360.yaml index e568a1b30..883c18cdc 100644 --- a/examples/benchmarks/Transformer/workflow_config_transformer_Alpha360.yaml +++ b/examples/benchmarks/Transformer/workflow_config_transformer_Alpha360.yaml @@ -26,19 +26,23 @@ data_handler_config: &data_handler_config port_analysis_config: &port_analysis_config strategy: class: TopkDropoutStrategy - module_path: qlib.contrib.strategy.strategy + module_path: qlib.contrib.strategy kwargs: + model: + dataset: topk: 50 n_drop: 5 backtest: - verbose: False - limit_threshold: 0.095 + start_time: 2017-01-01 + end_time: 2020-08-01 account: 100000000 benchmark: *benchmark - deal_price: close - open_cost: 0.0005 - close_cost: 0.0015 - min_cost: 5 + exchange_kwargs: + limit_threshold: 0.095 + deal_price: close + open_cost: 0.0005 + close_cost: 0.0015 + min_cost: 5 task: model: class: TransformerModel @@ -61,7 +65,9 @@ task: record: - class: SignalRecord module_path: qlib.workflow.record_temp - kwargs: {} + kwargs: + model: + dataset: - class: SigAnaRecord module_path: qlib.workflow.record_temp kwargs: @@ -70,4 +76,4 @@ task: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: - config: *port_analysis_config \ No newline at end of file + config: *port_analysis_config diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml index dee169f18..502a5e73c 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha158.yaml @@ -64,8 +64,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml index 926224f84..a2e40eefb 100644 --- a/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml +++ b/examples/benchmarks/XGBoost/workflow_config_xgboost_Alpha360.yaml @@ -71,8 +71,6 @@ task: kwargs: ana_long_short: False ann_scaler: 252 - model: - dataset: - class: PortAnaRecord module_path: qlib.workflow.record_temp kwargs: diff --git a/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml b/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml index 0152cfd63..93d9dde56 100644 --- a/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml +++ b/examples/highfreq/workflow_config_High_Freq_Tree_Alpha158.yaml @@ -60,8 +60,6 @@ task: - class: "SignalRecord" module_path: "qlib.workflow.record_temp" kwargs: - model: - dataset: - class: "HFSignalRecord" module_path: "qlib.workflow.record_temp" kwargs: {} \ No newline at end of file diff --git a/examples/model_rolling/requirements.txt b/examples/model_rolling/requirements.txt new file mode 100644 index 000000000..10ddd5b71 --- /dev/null +++ b/examples/model_rolling/requirements.txt @@ -0,0 +1 @@ +xgboost diff --git a/examples/model_rolling/task_manager_rolling.py b/examples/model_rolling/task_manager_rolling.py index 844f18198..091a87862 100644 --- a/examples/model_rolling/task_manager_rolling.py +++ b/examples/model_rolling/task_manager_rolling.py @@ -17,7 +17,7 @@ from qlib.workflow.task.gen import RollingGen, task_generator from qlib.workflow.task.manage import TaskManager, run_task from qlib.workflow.task.collect import RecorderCollector from qlib.model.ens.group import RollingGroup -from qlib.model.trainer import TrainerRM +from qlib.model.trainer import TrainerRM, task_train from qlib.tests.config import CSI100_RECORD_LGB_TASK_CONFIG, CSI100_RECORD_XGBOOST_TASK_CONFIG diff --git a/examples/nested_decision_execution/workflow.py b/examples/nested_decision_execution/workflow.py index b6c1362fd..ef6906018 100644 --- a/examples/nested_decision_execution/workflow.py +++ b/examples/nested_decision_execution/workflow.py @@ -19,7 +19,7 @@ class NestedDecisionExecutionWorkflow: benchmark = "SH000300" data_handler_config = { "start_time": "2008-01-01", - "end_time": "2020-12-31", + "end_time": "2021-05-31", "fit_start_time": "2008-01-01", "fit_end_time": "2014-12-31", "instruments": market, @@ -53,7 +53,7 @@ class NestedDecisionExecutionWorkflow: "segments": { "train": ("2007-01-01", "2014-12-31"), "valid": ("2015-01-01", "2016-12-31"), - "test": ("2020-01-01", "2020-12-31"), + "test": ("2020-01-01", "2021-05-31"), }, }, }, @@ -75,7 +75,7 @@ class NestedDecisionExecutionWorkflow: "module_path": "qlib.backtest.executor", "kwargs": { "time_per_step": "5min", - "generate_report": True, + "generate_portfolio_metrics": True, "verbose": True, "indicator_config": { "show_indicator": True, @@ -86,7 +86,7 @@ class NestedDecisionExecutionWorkflow: "class": "TWAPStrategy", "module_path": "qlib.contrib.strategy.rule_strategy", }, - "generate_report": True, + "generate_portfolio_metrics": True, "indicator_config": { "show_indicator": True, }, @@ -101,15 +101,15 @@ class NestedDecisionExecutionWorkflow: }, }, "track_data": True, - "generate_report": True, + "generate_portfolio_metrics": True, "indicator_config": { "show_indicator": True, }, }, }, "backtest": { - "start_time": "2020-01-01", - "end_time": "2020-12-31", + "start_time": "2020-09-20", + "end_time": "2021-05-20", "account": 100000000, "exchange_kwargs": { "freq": "1min", @@ -124,8 +124,6 @@ class NestedDecisionExecutionWorkflow: def _init_qlib(self): """initialize qlib""" - # provider_uri_day = "/data/stock_data/huaxia/qlib" - # provider_uri_1min = "/data2/stock_data/huaxia_1min_qlib" provider_uri_day = "~/.qlib/qlib_data/cn_data" # target_dir GetData().qlib_data(target_dir=provider_uri_day, region=REG_CN, version="v2", exists_skip=True) provider_uri_1min = HIGH_FREQ_CONFIG.get("provider_uri") @@ -133,31 +131,7 @@ class NestedDecisionExecutionWorkflow: target_dir=provider_uri_1min, interval="1min", region=REG_CN, version="v2", exists_skip=True ) provider_uri_map = {"1min": provider_uri_1min, "day": provider_uri_day} - client_config = { - "calendar_provider": { - "class": "LocalCalendarProvider", - "module_path": "qlib.data.data", - "kwargs": { - "backend": { - "class": "FileCalendarStorage", - "module_path": "qlib.data.storage.file_storage", - "kwargs": {"provider_uri_map": provider_uri_map}, - } - }, - }, - "feature_provider": { - "class": "LocalFeatureProvider", - "module_path": "qlib.data.data", - "kwargs": { - "backend": { - "class": "FileFeatureStorage", - "module_path": "qlib.data.storage.file_storage", - "kwargs": {"provider_uri_map": provider_uri_map}, - } - }, - }, - } - qlib.init(provider_uri=provider_uri_day, **client_config, redis_port=-1) + qlib.init(provider_uri=provider_uri_map, dataset_cache=None, expression_cache=None) def _train_model(self, model, dataset): with R.start(experiment_name="train"): @@ -186,9 +160,8 @@ class NestedDecisionExecutionWorkflow: }, } self.port_analysis_config["strategy"] = strategy_config - self.port_analysis_config["backtest"]["benchmark"] = D.list_instruments( - instruments=D.instruments(market=self.market), as_list=True - ) + self.port_analysis_config["backtest"]["benchmark"] = self.benchmark + with R.start(experiment_name="backtest"): recorder = R.get_recorder() @@ -201,6 +174,7 @@ class NestedDecisionExecutionWorkflow: ) par.generate() + # user could use following methods to analysis the position # report_normal_df = recorder.load_object("portfolio_analysis/report_normal_1day.pkl") # from qlib.contrib.report import analysis_position # analysis_position.report_graph(report_normal_df) @@ -212,7 +186,7 @@ class NestedDecisionExecutionWorkflow: self._train_model(model, dataset) executor_config = self.port_analysis_config["executor"] backtest_config = self.port_analysis_config["backtest"] - backtest_config["benchmark"] = D.list_instruments(instruments=D.instruments(market=self.market), as_list=True) + backtest_config["benchmark"] = self.benchmark strategy_config = { "class": "TopkDropoutStrategy", "module_path": "qlib.contrib.strategy.model_strategy", diff --git a/examples/run_all_model.py b/examples/run_all_model.py index 1284d8e99..41aba091e 100644 --- a/examples/run_all_model.py +++ b/examples/run_all_model.py @@ -6,6 +6,7 @@ import sys import fire import time import glob +import yaml import shutil import signal import inspect @@ -23,22 +24,6 @@ from qlib.config import REG_CN from qlib.workflow import R from qlib.tests.data import GetData -# init qlib -provider_uri = "~/.qlib/qlib_data/cn_data" -exp_folder_name = "run_all_model_records" -exp_path = str(Path(os.getcwd()).resolve() / exp_folder_name) -exp_manager = { - "class": "MLflowExpManager", - "module_path": "qlib.workflow.expm", - "kwargs": { - "uri": "file:" + exp_path, - "default_exp_name": "Experiment", - }, -} - -GetData().qlib_data(target_dir=provider_uri, region=REG_CN, exists_skip=True) -qlib.init(provider_uri=provider_uri, region=REG_CN, exp_manager=exp_manager) - # decorator to check the arguments def only_allow_defined_args(function_to_decorate): @@ -88,11 +73,11 @@ def create_env(): sys.stderr.write("\n") # get anaconda activate path conda_activate = Path(os.environ["CONDA_PREFIX"]) / "bin" / "activate" # TODO: FIX ME! - return env_path, python_path, conda_activate + return temp_dir, env_path, python_path, conda_activate # function to execute the cmd -def execute(cmd, wait_when_err=False): +def execute(cmd, wait_when_err=False, raise_err=True): print("Running CMD:", cmd) with subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True, shell=True) as p: for line in p.stdout: @@ -105,6 +90,8 @@ def execute(cmd, wait_when_err=False): if p.returncode != 0: if wait_when_err: input("Press Enter to Continue") + if raise_err: + raise RuntimeError(f"Error when executing command: {cmd}") return p.stderr else: return None @@ -134,14 +121,23 @@ def get_all_folders(models, exclude) -> dict: def get_all_files(folder_path, dataset) -> (str, str): yaml_path = str(Path(f"{folder_path}") / f"*{dataset}*.yaml") req_path = str(Path(f"{folder_path}") / f"*.txt") - return glob.glob(yaml_path)[0], glob.glob(req_path)[0] + yaml_file = glob.glob(yaml_path) + req_file = glob.glob(req_path) + if len(yaml_file) == 0: + return None, None + else: + return yaml_file[0], req_file[0] # function to retrieve all the results def get_all_results(folders) -> dict: results = dict() for fn in folders: - exp = R.get_exp(experiment_name=fn, create=False) + try: + exp = R.get_exp(experiment_name=fn, create=False) + except ValueError: + # No experiment results + continue recorders = exp.list_recorders() result = dict() result["annualized_return_with_cost"] = list() @@ -155,9 +151,9 @@ def get_all_results(folders) -> dict: if recorders[recorder_id].status == "FINISHED": recorder = R.get_recorder(recorder_id=recorder_id, experiment_name=fn) metrics = recorder.list_metrics() - result["annualized_return_with_cost"].append(metrics["excess_return_with_cost.annualized_return"]) - result["information_ratio_with_cost"].append(metrics["excess_return_with_cost.information_ratio"]) - result["max_drawdown_with_cost"].append(metrics["excess_return_with_cost.max_drawdown"]) + result["annualized_return_with_cost"].append(metrics["1day.excess_return_with_cost.annualized_return"]) + result["information_ratio_with_cost"].append(metrics["1day.excess_return_with_cost.information_ratio"]) + result["max_drawdown_with_cost"].append(metrics["1day.excess_return_with_cost.max_drawdown"]) result["ic"].append(metrics["IC"]) result["icir"].append(metrics["ICIR"]) result["rank_ic"].append(metrics["Rank IC"]) @@ -185,6 +181,25 @@ def gen_and_save_md_table(metrics, dataset): return table +# read yaml, remove seed kwargs of model, and then save file in the temp_dir +def gen_yaml_file_without_seed_kwargs(yaml_path, temp_dir): + with open(yaml_path, "r") as fp: + config = yaml.load(fp) + try: + del config["task"]["model"]["kwargs"]["seed"] + except KeyError: + # If the key does not exists, use original yaml + # NOTE: it is very important if the model most run in original path(when sys.rel_path is used) + return yaml_path + else: + # otherwise, generating a new yaml without random seed + file_name = yaml_path.split("/")[-1] + temp_path = os.path.join(temp_dir, file_name) + with open(temp_path, "w") as fp: + yaml.dump(config, fp) + return temp_path + + # function to run the all the models @only_allow_defined_args def run( @@ -193,12 +208,13 @@ def run( dataset="Alpha360", exclude=False, qlib_uri: str = "git+https://github.com/microsoft/qlib#egg=pyqlib", + exp_folder_name: str = "run_all_model_records", wait_before_rm_env: bool = False, wait_when_err: bool = False, ): """ Please be aware that this function can only work under Linux. MacOS and Windows will be supported in the future. - Any PR to enhance this method is highly welcomed. Besides, this script doesn't support parrallel running the same model + Any PR to enhance this method is highly welcomed. Besides, this script doesn't support parallel running the same model for multiple times, and this will be fixed in the future development. Parameters: @@ -214,6 +230,8 @@ def run( qlib_uri : str the uri to install qlib with pip it could be url on the we or local path + exp_folder_name: str + the name of the experiment folder wait_before_rm_env : bool wait before remove environment. wait_when_err : bool @@ -240,26 +258,58 @@ def run( # Case 5 - run specific models for one time python run_all_model.py --models=[mlp,lightgbm] - # Case 6 - run other models except those are given as aruments for one time + # Case 6 - run other models except those are given as arguments for one time python run_all_model.py --models=[mlp,tft,sfm] --exclude=True """ + # init qlib + GetData().qlib_data(exists_skip=True) + qlib.init( + exp_manager={ + "class": "MLflowExpManager", + "module_path": "qlib.workflow.expm", + "kwargs": { + "uri": "file:" + str(Path(os.getcwd()).resolve() / exp_folder_name), + "default_exp_name": "Experiment", + }, + } + ) + # get all folders folders = get_all_folders(models, exclude) # init error messages: errors = dict() # run all the model for iterations for fn in folders: - # create env by anaconda - env_path, python_path, conda_activate = create_env() # get all files sys.stderr.write("Retrieving files...\n") yaml_path, req_path = get_all_files(folders[fn], dataset) + if yaml_path is None: + sys.stderr.write(f"There is no {dataset}.yaml file in {folders[fn]}") + continue sys.stderr.write("\n") + # create env by anaconda + temp_dir, env_path, python_path, conda_activate = create_env() + # install requirements.txt sys.stderr.write("Installing requirements.txt...\n") - execute(f"{python_path} -m pip install -r {req_path}", wait_when_err=wait_when_err) + with open(req_path) as f: + content = f.read() + if "torch" in content: + # automatically install pytorch according to nvidia's version + execute( + f"{python_path} -m pip install light-the-torch", wait_when_err=wait_when_err + ) # for automatically installing torch according to the nvidia driver + execute( + f"{env_path / 'bin' / 'ltt'} install --install-cmd '{python_path} -m pip install {{packages}}' -- -r {req_path}", + wait_when_err=wait_when_err, + ) + else: + execute(f"{python_path} -m pip install -r {req_path}", wait_when_err=wait_when_err) sys.stderr.write("\n") + + # read yaml, remove seed kwargs of model, and then save file in the temp_dir + yaml_path = gen_yaml_file_without_seed_kwargs(yaml_path, temp_dir) # setup gpu for tft if fn == "TFT": execute( @@ -302,19 +352,20 @@ def run( # getting all results sys.stderr.write(f"Retrieving results...\n") results = get_all_results(folders) - # calculating the mean and std - sys.stderr.write(f"Calculating the mean and std of results...\n") - results = cal_mean_std(results) - # generating md table - sys.stderr.write(f"Generating markdown table...\n") - gen_and_save_md_table(results, dataset) - sys.stderr.write("\n") - # print erros + if len(results) > 0: + # calculating the mean and std + sys.stderr.write(f"Calculating the mean and std of results...\n") + results = cal_mean_std(results) + # generating md table + sys.stderr.write(f"Generating markdown table...\n") + gen_and_save_md_table(results, dataset) + sys.stderr.write("\n") + # print errors sys.stderr.write(f"Here are some of the errors of the models...\n") pprint(errors) sys.stderr.write("\n") # move results folder - shutil.move(exp_path, exp_path + f"_{dataset}_{datetime.now().strftime('%Y-%m-%d_%H:%M:%S')}") + shutil.move(exp_folder_name, exp_folder_name + f"_{dataset}_{datetime.now().strftime('%Y-%m-%d_%H:%M:%S')}") shutil.move("table.md", f"table_{dataset}_{datetime.now().strftime('%Y-%m-%d_%H:%M:%S')}.md") diff --git a/examples/workflow_by_code.ipynb b/examples/workflow_by_code.ipynb index 1658565d6..907245ade 100644 --- a/examples/workflow_by_code.ipynb +++ b/examples/workflow_by_code.ipynb @@ -20,9 +20,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "scrolled": true - }, + "metadata": {}, "outputs": [], "source": [ "import sys, site\n", @@ -201,7 +199,7 @@ " \"module_path\": \"qlib.backtest.executor\",\n", " \"kwargs\": {\n", " \"time_per_step\": \"day\",\n", - " \"generate_report\": True,\n", + " \"generate_portfolio_metrics\": True,\n", " },\n", " },\n", " \"strategy\": {\n", @@ -362,7 +360,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -375,8 +373,7 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.3" + "pygments_lexer": "ipython3" }, "toc": { "base_numbering": 1, @@ -394,4 +391,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/examples/workflow_by_code.py b/examples/workflow_by_code.py index d7bb544f9..486e694a7 100644 --- a/examples/workflow_by_code.py +++ b/examples/workflow_by_code.py @@ -26,7 +26,7 @@ if __name__ == "__main__": "module_path": "qlib.backtest.executor", "kwargs": { "time_per_step": "day", - "generate_report": True, + "generate_portfolio_metrics": True, }, }, "strategy": { diff --git a/qlib/__init__.py b/qlib/__init__.py index 6f76bbcaa..efa89b153 100644 --- a/qlib/__init__.py +++ b/qlib/__init__.py @@ -1,17 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from pathlib import Path - -__version__ = "0.7.0.99" +_version_path = Path(__file__).absolute().parent / "VERSION.txt" # This file is copyed from setup.py +__version__ = _version_path.read_text(encoding="utf-8").strip() __version__bak = __version__ # This version is backup for QlibConfig.reset_qlib_version - - import os import yaml import logging import platform import subprocess -from pathlib import Path from .log import get_module_logger @@ -33,69 +31,71 @@ def init(default_conf="client", **kwargs): H.clear() C.set(default_conf, **kwargs) - # check path if server/local - if C.get_uri_type() == C.LOCAL_URI: - if not os.path.exists(C["provider_uri"]): - if C["auto_mount"]: - logger.error( - f"Invalid provider uri: {C['provider_uri']}, please check if a valid provider uri has been set. This path does not exist." - ) - else: - logger.warning(f"auto_path is False, please make sure {C['mount_path']} is mounted") - elif C.get_uri_type() == C.NFS_URI: - _mount_nfs_uri(C) - else: - raise NotImplementedError(f"This type of URI is not supported") + # mount nfs + for _freq, provider_uri in C.provider_uri.items(): + mount_path = C["mount_path"][_freq] + # check path if server/local + uri_type = C.dpm.get_uri_type(provider_uri) + if uri_type == C.LOCAL_URI: + if not Path(provider_uri).exists(): + if C["auto_mount"]: + logger.error( + f"Invalid provider uri: {provider_uri}, please check if a valid provider uri has been set. This path does not exist." + ) + else: + logger.warning(f"auto_path is False, please make sure {mount_path} is mounted") + elif uri_type == C.NFS_URI: + _mount_nfs_uri(provider_uri, mount_path, C["auto_mount"]) + else: + raise NotImplementedError(f"This type of URI is not supported") C.register() if "flask_server" in C: logger.info(f"flask_server={C['flask_server']}, flask_port={C['flask_port']}") logger.info("qlib successfully initialized based on %s settings." % default_conf) - logger.info(f"data_path={C.get_data_path()}") + data_path = {_freq: C.dpm.get_data_uri(_freq) for _freq in C.dpm.provider_uri.keys()} + logger.info(f"data_path={data_path}") -def _mount_nfs_uri(C): +def _mount_nfs_uri(provider_uri, mount_path, auto_mount: bool = False): LOG = get_module_logger("mount nfs", level=logging.INFO) - + if mount_path is None: + raise ValueError(f"Invalid mount path: {mount_path}!") # FIXME: the C["provider_uri"] is modified in this function # If it is not modified, we can pass only provider_uri or mount_path instead of C - mount_command = "sudo mount.nfs %s %s" % (C["provider_uri"], C["mount_path"]) + mount_command = "sudo mount.nfs %s %s" % (provider_uri, mount_path) # If the provider uri looks like this 172.23.233.89//data/csdesign' # It will be a nfs path. The client provider will be used - if not C["auto_mount"]: - if not os.path.exists(C["mount_path"]): + if not auto_mount: + if not Path(mount_path).exists(): raise FileNotFoundError( - f"Invalid mount path: {C['mount_path']}! Please mount manually: {mount_command} or Set init parameter `auto_mount=True`" + f"Invalid mount path: {mount_path}! Please mount manually: {mount_command} or Set init parameter `auto_mount=True`" ) else: # Judging system type sys_type = platform.system() if "win" in sys_type.lower(): # system: window - exec_result = os.popen("mount -o anon %s %s" % (C["provider_uri"], C["mount_path"] + ":")) + exec_result = os.popen("mount -o anon %s %s" % (provider_uri, mount_path + ":")) result = exec_result.read() if "85" in result: - LOG.warning("already mounted or window mount path already exists") + LOG.warning(f"{provider_uri} on Windows:{mount_path} is already mounted") elif "53" in result: raise OSError("not find network path") elif "error" in result or "错误" in result: raise OSError("Invalid mount path") - elif C["provider_uri"] in result: + elif provider_uri in result: LOG.info("window success mount..") else: raise OSError(f"unknown error: {result}") - # config mount path - C["mount_path"] = C["mount_path"] + ":\\" else: # system: linux/Unix/Mac # check mount - _remote_uri = C["provider_uri"] - _remote_uri = _remote_uri[:-1] if _remote_uri.endswith("/") else _remote_uri - _mount_path = C["mount_path"] - _mount_path = _mount_path[:-1] if _mount_path.endswith("/") else _mount_path + _remote_uri = provider_uri[:-1] if provider_uri.endswith("/") else provider_uri + _mount_path = mount_path[:-1] if mount_path.endswith("/") else mount_path _check_level_num = 2 _is_mount = False while _check_level_num: @@ -121,11 +121,9 @@ def _mount_nfs_uri(C): if not _is_mount: try: - os.makedirs(C["mount_path"], exist_ok=True) + Path(mount_path).mkdir(parents=True, exist_ok=True) except Exception: - raise OSError( - f"Failed to create directory {C['mount_path']}, please create {C['mount_path']} manually!" - ) + raise OSError(f"Failed to create directory {mount_path}, please create {mount_path} manually!") # check nfs-common command_res = os.popen("dpkg -l | grep nfs-common") @@ -136,11 +134,11 @@ def _mount_nfs_uri(C): command_status = os.system(mount_command) if command_status == 256: raise OSError( - f"mount {C['provider_uri']} on {C['mount_path']} error! Needs SUDO! Please mount manually: {mount_command}" + f"mount {provider_uri} on {mount_path} error! Needs SUDO! Please mount manually: {mount_command}" ) elif command_status == 32512: # LOG.error("Command error") - raise OSError(f"mount {C['provider_uri']} on {C['mount_path']} error! Command error") + raise OSError(f"mount {provider_uri} on {mount_path} error! Command error") elif command_status == 0: LOG.info("Mount finished") else: diff --git a/qlib/backtest/__init__.py b/qlib/backtest/__init__.py index d4a19eb25..38541d768 100644 --- a/qlib/backtest/__init__.py +++ b/qlib/backtest/__init__.py @@ -9,13 +9,13 @@ from .account import Account if TYPE_CHECKING: from ..strategy.base import BaseStrategy from .executor import BaseExecutor - from .order import BaseTradeDecision -from .order import Order + from .decision import BaseTradeDecision from .position import Position from .exchange import Exchange from .backtest import backtest_loop from .backtest import collect_data_loop -from .utils import CommonInfrastructure, LevelInfrastructure, TradeCalendarManager +from .utils import CommonInfrastructure +from .decision import Order from ..utils import init_instance_by_config from ..log import get_module_logger from ..config import C @@ -231,10 +231,9 @@ def backtest( Returns ------- - report: Report - it records the trading report information - It is organized in a dict format - indicator: Indicator + portfolio_metrics_dict: Dict[PortfolioMetrics] + it records the trading portfolio_metrics information + indicator_dict: Dict[Indicator] it computes the trading indicator It is organized in a dict format @@ -249,9 +248,8 @@ def backtest( exchange_kwargs, pos_type=pos_type, ) - report, indicator = backtest_loop(start_time, end_time, trade_strategy, trade_executor) - - return report, indicator + portfolio_metrics, indicator = backtest_loop(start_time, end_time, trade_strategy, trade_executor) + return portfolio_metrics, indicator def collect_data( diff --git a/qlib/backtest/account.py b/qlib/backtest/account.py index 163ee8c26..aa503ebc2 100644 --- a/qlib/backtest/account.py +++ b/qlib/backtest/account.py @@ -4,22 +4,19 @@ from __future__ import annotations import copy from typing import Dict, List, Tuple, TYPE_CHECKING from qlib.utils import init_instance_by_config -import warnings import pandas as pd from .position import BasePosition, InfPosition, Position -from .report import Report, Indicator -from .order import BaseTradeDecision, Order - -if TYPE_CHECKING: - from .exchange import Exchange +from .report import PortfolioMetrics, Indicator +from .decision import BaseTradeDecision, Order +from .exchange import Exchange """ rtn & earning in the Account rtn: from order's view 1.change if any order is executed, sell order or buy order - 2.change at the end of today, (today_clse - stock_price) * amount + 2.change at the end of today, (today_close - stock_price) * amount earning from value of current position earning will be updated at the end of trade date @@ -32,7 +29,7 @@ rtn & earning in the Account class AccumulatedInfo: - """accumulated trading info, including accumulated return\cost\turnover""" + """accumulated trading info, including accumulated return/cost/turnover""" def __init__(self): self.reset() @@ -94,9 +91,12 @@ class Account: self._pos_type = pos_type self._port_metr_enabled = port_metr_enabled + self.benchmark_config = None # avoid no attribute error + self.init_vars(init_cash, position_dict, freq, benchmark_config) + def init_vars(self, init_cash, position_dict, freq: str, benchmark_config: dict): self.init_cash = init_cash - self.current: BasePosition = init_instance_by_config( + self.current_position: BasePosition = init_instance_by_config( { "class": self._pos_type, "kwargs": { @@ -106,37 +106,33 @@ class Account: "module_path": "qlib.backtest.position", } ) - self.report = None - self.positions = {} - - # in of reset ignore None values - self.benchmark_config = benchmark_config - self.freq = freq - - self.reset(freq=freq, benchmark_config=benchmark_config, init_report=True) + self.portfolio_metrics = None + self.hist_positions = {} + self.reset(freq=freq, benchmark_config=benchmark_config) def is_port_metr_enabled(self): """ Is portfolio-based metrics enabled. """ - return self._port_metr_enabled and not self.current.skip_update() + return self._port_metr_enabled and not self.current_position.skip_update() def reset_report(self, freq, benchmark_config): # portfolio related metrics if self.is_port_metr_enabled(): self.accum_info = AccumulatedInfo() - self.report = Report(freq, benchmark_config) - self.positions = {} + self.portfolio_metrics = PortfolioMetrics(freq, benchmark_config) + self.hist_positions = {} + # fill stock value # The frequency of account may not align with the trading frequency. # This may result in obscure bugs when data quality is low. if isinstance(self.benchmark_config, dict) and self.benchmark_config.get("start_time") is not None: - self.current.fill_stock_value(self.benchmark_config["start_time"], self.freq) + self.current_position.fill_stock_value(self.benchmark_config["start_time"], self.freq) # trading related metrics(e.g. high-frequency trading) self.indicator = Indicator() - def reset(self, freq=None, benchmark_config=None, init_report=False, port_metr_enabled: bool = None): + def reset(self, freq=None, benchmark_config=None, port_metr_enabled: bool = None): """reset freq and report of account Parameters @@ -145,27 +141,23 @@ class Account: frequency of account & report, by default None benchmark_config : {}, optional benchmark config of report, by default None - init_report : bool, optional - whether to initialize the report, by default False """ if freq is not None: self.freq = freq if benchmark_config is not None: self.benchmark_config = benchmark_config - if port_metr_enabled is not None: self._port_metr_enabled = port_metr_enabled - if freq is not None or benchmark_config is not None or init_report: - self.reset_report(self.freq, self.benchmark_config) + self.reset_report(self.freq, self.benchmark_config) - def get_positions(self): - return self.positions + def get_hist_positions(self): + return self.hist_positions def get_cash(self): - return self.current.get_cash() + return self.current_position.get_cash() - def _update_accum_info_from_order(self, order, trade_val, cost, trade_price): + def _update_state_from_order(self, order, trade_val, cost, trade_price): if self.is_port_metr_enabled(): # update turnover self.accum_info.add_turnover(trade_val) @@ -176,17 +168,17 @@ class Account: trade_amount = trade_val / trade_price if order.direction == Order.SELL: # 0 for sell # when sell stock, get profit from price change - profit = trade_val - self.current.get_stock_price(order.stock_id) * trade_amount + profit = trade_val - self.current_position.get_stock_price(order.stock_id) * trade_amount self.accum_info.add_return_value(profit) # note here do not consider cost elif order.direction == Order.BUY: # 1 for buy # when buy stock, we get return for the rtn computing method # profit in buy order is to make rtn is consistent with earning at the end of bar - profit = self.current.get_stock_price(order.stock_id) * trade_amount - trade_val + profit = self.current_position.get_stock_price(order.stock_id) * trade_amount - trade_val self.accum_info.add_return_value(profit) # note here do not consider cost def update_order(self, order, trade_val, cost, trade_price): - if self.current.skip_update(): + if self.current_position.skip_update(): # TODO: supporting polymorphism for account # updating order for infinite position is meaningless return @@ -196,65 +188,61 @@ class Account: # The cost will be substracted from the cash at last. So the trading logic can ignore the cost calculation if order.direction == Order.SELL: # sell stock - self._update_accum_info_from_order(order, trade_val, cost, trade_price) + self._update_state_from_order(order, trade_val, cost, trade_price) # update current position # for may sell all of stock_id - self.current.update_order(order, trade_val, cost, trade_price) + self.current_position.update_order(order, trade_val, cost, trade_price) else: # buy stock # deal order, then update state - self.current.update_order(order, trade_val, cost, trade_price) - self._update_accum_info_from_order(order, trade_val, cost, trade_price) + self.current_position.update_order(order, trade_val, cost, trade_price) + self._update_state_from_order(order, trade_val, cost, trade_price) - def update_bar_count(self): - """at the end of the trading bar, update holding bar, count of stock""" - # update holding day count - # NOTE: updating bar_count does not only serve portfolio metrics, it also serve the strategy - if not self.current.skip_update(): - self.current.add_count_all(bar=self.freq) - - def update_current(self, trade_start_time, trade_end_time, trade_exchange): - """update current to make rtn consistent with earning at the end of bar""" + def update_current_position(self, trade_start_time, trade_end_time, trade_exchange): + """update current to make rtn consistent with earning at the end of bar, and update holding bar count of stock""" # update price for stock in the position and the profit from changed_price # NOTE: updating position does not only serve portfolio metrics, it also serve the strategy - if not self.current.skip_update(): - stock_list = self.current.get_stock_list() + if not self.current_position.skip_update(): + stock_list = self.current_position.get_stock_list() for code in stock_list: # if suspend, no new price to be updated, profit is 0 if trade_exchange.check_stock_suspended(code, trade_start_time, trade_end_time): continue bar_close = trade_exchange.get_close(code, trade_start_time, trade_end_time) - self.current.update_stock_price(stock_id=code, price=bar_close) + self.current_position.update_stock_price(stock_id=code, price=bar_close) + # update holding day count + # NOTE: updating bar_count does not only serve portfolio metrics, it also serve the strategy + self.current_position.add_count_all(bar=self.freq) - def update_report(self, trade_start_time, trade_end_time): - """update position history, report""" + def update_portfolio_metrics(self, trade_start_time, trade_end_time): + """update portfolio_metrics""" # calculate earning # account_value - last_account_value # for the first trade date, account_value - init_cash - # self.report.is_empty() to judge is_first_trade_date + # self.portfolio_metrics.is_empty() to judge is_first_trade_date # get last_account_value, last_total_cost, last_total_turnover - if self.report.is_empty(): + if self.portfolio_metrics.is_empty(): last_account_value = self.init_cash last_total_cost = 0 last_total_turnover = 0 else: - last_account_value = self.report.get_latest_account_value() - last_total_cost = self.report.get_latest_total_cost() - last_total_turnover = self.report.get_latest_total_turnover() + last_account_value = self.portfolio_metrics.get_latest_account_value() + last_total_cost = self.portfolio_metrics.get_latest_total_cost() + last_total_turnover = self.portfolio_metrics.get_latest_total_turnover() # get now_account_value, now_stock_value, now_earning, now_cost, now_turnover - now_account_value = self.current.calculate_value() - now_stock_value = self.current.calculate_stock_value() + now_account_value = self.current_position.calculate_value() + now_stock_value = self.current_position.calculate_stock_value() now_earning = now_account_value - last_account_value now_cost = self.accum_info.get_cost - last_total_cost now_turnover = self.accum_info.get_turnover - last_total_turnover - # update report for today + # update portfolio_metrics for today # judge whether the the trading is begin. - # and don't add init account state into report, due to we don't have excess return in those days. - self.report.update_report_record( + # and don't add init account state into portfolio_metrics, due to we don't have excess return in those days. + self.portfolio_metrics.update_portfolio_metrics_record( trade_start_time=trade_start_time, trade_end_time=trade_end_time, account_value=now_account_value, - cash=self.current.position["cash"], + cash=self.current_position.position["cash"], return_rate=(now_earning + now_cost) / last_account_value, # here use earning to calculate return, position's view, earning consider cost, true return # in order to make same definition with original backtest in evaluate.py @@ -264,12 +252,51 @@ class Account: cost_rate=now_cost / last_account_value, stock_value=now_stock_value, ) + + def update_hist_positions(self, trade_start_time): + """update history position""" + now_account_value = self.current_position.calculate_value() # set now_account_value to position - self.current.position["now_account_value"] = now_account_value - self.current.update_weight_all() - # update positions + self.current_position.position["now_account_value"] = now_account_value + self.current_position.update_weight_all() + # update hist_positions # note use deepcopy - self.positions[trade_start_time] = copy.deepcopy(self.current) + self.hist_positions[trade_start_time] = copy.deepcopy(self.current_position) + + def update_indicator( + self, + trade_start_time: pd.Timestamp, + trade_exchange: Exchange, + atomic: bool, + outer_trade_decision: BaseTradeDecision, + trade_info: list = None, + inner_order_indicators: List[Dict[str, pd.Series]] = None, + decision_list: List[Tuple[BaseTradeDecision, pd.Timestamp, pd.Timestamp]] = None, + indicator_config: dict = {}, + ): + """update trade indicators and order indicators in each bar end""" + # TODO: will skip empty decisions make it faster? `outer_trade_decision.empty():` + + # indicator is trading (e.g. high-frequency order execution) related analysis + self.indicator.reset() + + # aggregate the information for each order + if atomic: + self.indicator.update_order_indicators(trade_info) + else: + self.indicator.agg_order_indicators( + inner_order_indicators, + decision_list=decision_list, + outer_trade_decision=outer_trade_decision, + trade_exchange=trade_exchange, + indicator_config=indicator_config, + ) + + # aggregate all the order metrics a single step + self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) + + # record the metrics + self.indicator.record(trade_start_time) def update_bar_end( self, @@ -316,44 +343,34 @@ class Account: elif atomic is False and inner_order_indicators is None: raise ValueError("inner_order_indicators is necessary in un-atomic executor") - # TODO: `update_bar_count` and `update_current` should placed in Position and be merged. - self.update_bar_count() - self.update_current(trade_start_time, trade_end_time, trade_exchange) + # update current position and hold bar count in each bar end + self.update_current_position(trade_start_time, trade_end_time, trade_exchange) + if self.is_port_metr_enabled(): - # report is portfolio related analysis - self.update_report(trade_start_time, trade_end_time) + # portfolio_metrics is portfolio related analysis + self.update_portfolio_metrics(trade_start_time, trade_end_time) + self.update_hist_positions(trade_start_time) - # TODO: will skip empty decisions make it faster? `outer_trade_decision.empty():` + # update indicator in each bar end + self.update_indicator( + trade_start_time=trade_start_time, + trade_exchange=trade_exchange, + atomic=atomic, + outer_trade_decision=outer_trade_decision, + trade_info=trade_info, + inner_order_indicators=inner_order_indicators, + decision_list=decision_list, + indicator_config=indicator_config, + ) - # indicator is trading (e.g. high-frequency order execution) related analysis - self.indicator.reset() - - # aggregate the information for each order - if atomic: - self.indicator.update_order_indicators(trade_info) - else: - self.indicator.agg_order_indicators( - inner_order_indicators, - decision_list=decision_list, - outer_trade_decision=outer_trade_decision, - trade_exchange=trade_exchange, - indicator_config=indicator_config, - ) - - # aggregate all the order metrics a single step - self.indicator.cal_trade_indicators(trade_start_time, self.freq, indicator_config) - - # record the metrics - self.indicator.record(trade_start_time) - - def get_report(self): - """get the history report and postions instance""" + def get_portfolio_metrics(self): + """get the history portfolio_metrics and postions instance""" if self.is_port_metr_enabled(): - _report = self.report.generate_report_dataframe() - _positions = self.get_positions() - return _report, _positions + _portfolio_metrics = self.portfolio_metrics.generate_portfolio_metrics_dataframe() + _positions = self.get_hist_positions() + return _portfolio_metrics, _positions else: - raise ValueError("generate_report should be True if you want to generate report") + raise ValueError("generate_portfolio_metrics should be True if you want to generate portfolio_metrics") def get_trade_indicator(self) -> Indicator: """get the trade indicator instance, which has pa/pos/ffr info.""" diff --git a/qlib/backtest/backtest.py b/qlib/backtest/backtest.py index c707aa1f0..fa4063bc9 100644 --- a/qlib/backtest/backtest.py +++ b/qlib/backtest/backtest.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from __future__ import annotations -from qlib.backtest.order import BaseTradeDecision +from qlib.backtest.decision import BaseTradeDecision from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -19,15 +19,15 @@ def backtest_loop(start_time, end_time, trade_strategy: BaseStrategy, trade_exec Returns ------- - report: Report - it records the trading report information + portfolio_metrics: PortfolioMetrics + it records the trading portfolio_metrics information indicator: Indicator it computes the trading indicator """ return_value = {} for _decision in collect_data_loop(start_time, end_time, trade_strategy, trade_executor, return_value): pass - return return_value.get("report"), return_value.get("indicator") + return return_value.get("portfolio_metrics"), return_value.get("indicator") def collect_data_loop( @@ -68,9 +68,8 @@ def collect_data_loop( if return_value is not None: all_executors = trade_executor.get_all_executors() - - all_reports = { - "{}{}".format(*Freq.parse(_executor.time_per_step)): _executor.trade_account.get_report() + all_portfolio_metrics = { + "{}{}".format(*Freq.parse(_executor.time_per_step)): _executor.trade_account.get_portfolio_metrics() for _executor in all_executors if _executor.trade_account.is_port_metr_enabled() } @@ -79,4 +78,4 @@ def collect_data_loop( key = "{}{}".format(*Freq.parse(_executor.time_per_step)) all_indicators[key] = _executor.trade_account.get_trade_indicator().generate_trade_indicators_dataframe() all_indicators[key + "_obj"] = _executor.trade_account.get_trade_indicator() - return_value.update({"report": all_reports, "indicator": all_indicators}) + return_value.update({"portfolio_metrics": all_portfolio_metrics, "indicator": all_indicators}) diff --git a/qlib/backtest/order.py b/qlib/backtest/decision.py similarity index 99% rename from qlib/backtest/order.py rename to qlib/backtest/decision.py index a1b21be0a..049e56c00 100644 --- a/qlib/backtest/order.py +++ b/qlib/backtest/decision.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# TODO: rename it with decision.py + from __future__ import annotations from enum import IntEnum from qlib.data.data import Cal diff --git a/qlib/backtest/exchange.py b/qlib/backtest/exchange.py index 8b539de8b..9e40e1877 100644 --- a/qlib/backtest/exchange.py +++ b/qlib/backtest/exchange.py @@ -15,10 +15,9 @@ import pandas as pd from ..data.data import D from ..config import C, REG_CN -from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger -from .order import Order, OrderDir, OrderHelper -from .high_performance_ds import BaseQuote, PandasQuote, CN1minNumpyQuote +from .decision import Order, OrderDir, OrderHelper +from .high_performance_ds import BaseQuote, PandasQuote, NumpyQuote class Exchange: @@ -36,29 +35,24 @@ class Exchange: close_cost=0.0025, min_cost=5, extra_quote=None, - quote_cls=CN1minNumpyQuote, + quote_cls=NumpyQuote, **kwargs, ): """__init__ - :param freq: frequency of data :param start_time: closed start time for backtest :param end_time: closed end time for backtest :param codes: list stock_id list or a string of instruments(i.e. all, csi500, sse50) - :param deal_price: Union[str, Tuple[str, str], List[str]] The `deal_price` supports following two types of input - : str - (, ): Tuple[str] or List[str] - , or := := str - for example '$close', '$open', '$vwap' ("close" is OK. `Exchange` will help to prepend "$" to the expression) - :param subscribe_fields: list, subscribe fields. This expressions will be added to the query and `self.quote`. It is useful when users want more fields to be queried - :param limit_threshold: Union[Tuple[str, str], float, None] 1) `None`: no limitation 2) float, 0.1 for example, default None @@ -66,7 +60,6 @@ class Exchange: ) `False` value indicates the stock is tradable `True` value indicates the stock is limited and not tradable - :param volume_threshold: Union[ Dict[ "all": ("cum" or "current", limit_str), @@ -85,26 +78,22 @@ class Exchange: - "current" means that this is a real-time value and will not accumulate over time, so it can be directly used as a capacity limit. e.g. ("cum", "0.2 * DayCumsum($volume, '9:45', '14:45')"), ("current", "$bidV1") - 2) "all" means the volume limits are both buying and selling. "buy" means the volume limits of buying. "sell" means the volume limits of selling. Different volume limits will be aggregated with min(). If volume_threshold is only ("cum" or "current", limit_str) instead of a dict, the volume limits are for both by deault. In other words, it is same as {"all": ("cum" or "current", limit_str)}. - 3) e.g. "volume_threshold": { "all": ("cum", "0.2 * DayCumsum($volume, '9:45', '14:45')"), "buy": ("current", "$askV1"), "sell": ("current", "$bidV1"), } - :param open_cost: cost rate for open, default 0.0015 :param close_cost: cost rate for close, default 0.0025 :param trade_unit: trade unit, 100 for China A market. None for disable trade unit. **NOTE**: `trade_unit` is included in the `kwargs`. It is necessary because we must distinguish `not set` and `disable trade_unit` - :param min_cost: min cost, default 5 :param extra_quote: pandas, dataframe consists of columns: like ['$vwap', '$close', '$volume', '$factor', 'limit_sell', 'limit_buy']. @@ -185,7 +174,7 @@ class Exchange: # init quote by quote_df self.quote_cls = quote_cls - self.quote: BaseQuote = self.quote_cls(self.quote_df) + self.quote: BaseQuote = self.quote_cls(self.quote_df, freq) def get_quote_from_qlib(self): # get stock data from qlib @@ -273,12 +262,10 @@ class Exchange: preproccess the volume limit. get the fields need to get from qlib. get the volume limit list of buying and selling which is composed of all limits. - Parameters ---------- volume_threshold : please refer to the doc of exchange. - Returns ------- fields: set @@ -287,7 +274,6 @@ class Exchange: all volume limits of buying. sell_vol_limit: List[Tuple[str]] all volume limits of selling. - Raises ------ ValueError @@ -324,7 +310,6 @@ class Exchange: - if direction is None, check if tradable for buying and selling. - if direction == Order.BUY, check the if tradable for buying - if direction == Order.SELL, check the sell limit for selling. - """ if direction is None: buy_limit = self.quote.get_data(stock_id, start_time, end_time, field="limit_buy", method="all") @@ -372,9 +357,7 @@ class Exchange: ): """ Deal order when the actual transaction - the results section in `Order` will be changed. - :param order: Deal the order. :param trade_account: Trade account to be updated after dealing the order. :param position: position to be updated after dealing the order. @@ -393,12 +376,12 @@ class Exchange: # NOTE: order will be changed in this function trade_price, trade_val, trade_cost = self._calc_trade_info_by_order( - order, trade_account.current if trade_account else position, dealt_order_amount + order, trade_account.current_position if trade_account else position, dealt_order_amount ) - if order.deal_amount > 1e-5: - # If the order can only be deal 0 amount. Nothing to be updated + if trade_val > 1e-5: + # If the order can only be deal 0 value. Nothing to be updated # Otherwise, it will result in - # 1) some stock with 0 amount in the position + # 1) some stock with 0 value in the position # 2) `trade_unit` of trade_cost will be lost in user account if trade_account: trade_account.update_order(order=order, trade_val=trade_val, cost=trade_cost, trade_price=trade_price) @@ -407,16 +390,17 @@ class Exchange: return trade_val, trade_cost, trade_price - def get_quote_info(self, stock_id, start_time, end_time, method=ts_data_last): + def get_quote_info(self, stock_id, start_time, end_time, method="ts_data_last"): return self.quote.get_data(stock_id, start_time, end_time, method=method) - def get_close(self, stock_id, start_time, end_time, method=ts_data_last): + def get_close(self, stock_id, start_time, end_time, method="ts_data_last"): return self.quote.get_data(stock_id, start_time, end_time, field="$close", method=method) - def get_volume(self, stock_id, start_time, end_time, method="sum"): - return self.quote.get_data(stock_id, start_time, end_time, field="$volume", method=method) + def get_volume(self, stock_id, start_time, end_time): + """get the total deal volume of stock with `stock_id` between the time interval [start_time, end_time)""" + return self.quote.get_data(stock_id, start_time, end_time, field="$volume", method="sum") - def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir, method=ts_data_last): + def get_deal_price(self, stock_id, start_time, end_time, direction: OrderDir, method="ts_data_last"): if direction == OrderDir.SELL: pstr = self.sell_price elif direction == OrderDir.BUY: @@ -441,7 +425,7 @@ class Exchange: assert start_time is not None and end_time is not None, "the time range must be given" if stock_id not in self.quote.get_all_stock(): return None - return self.quote.get_data(stock_id, start_time, end_time, field="$factor", method=ts_data_last) + return self.quote.get_data(stock_id, start_time, end_time, field="$factor", method="ts_data_last") def generate_amount_position_from_weight_position( self, weight_position, cash, start_time, end_time, direction=OrderDir.BUY @@ -449,7 +433,6 @@ class Exchange: """ The generate the target position according to the weight and the cash. NOTE: All the cash will assigned to the tadable stock. - Parameter: weight_position : dict {stock_id : weight}; allocate cash by weight_position among then, weight must be in this range: 0 < weight < 1 @@ -493,7 +476,6 @@ class Exchange: def get_real_deal_amount(self, current_amount, target_amount, factor): """ Calculate the real adjust deal amount when considering the trading unit - :param current_amount: :param target_amount: :param factor: @@ -516,7 +498,6 @@ class Exchange: def generate_order_for_target_amount_position(self, target_position, current_position, start_time, end_time): """ Note: some future information is used in this function - Parameter: target_position : dict { stock_id : amount } current_postion : dict { stock_id : amount} @@ -590,8 +571,10 @@ class Exchange: value = 0 for stock_id in amount_dict: if ( - self.check_stock_suspended(stock_id=stock_id, start_time=start_time, end_time=end_time) is False + only_tradable is True + and self.check_stock_suspended(stock_id=stock_id, start_time=start_time, end_time=end_time) is False and self.check_stock_limit(stock_id=stock_id, start_time=start_time, end_time=end_time) is False + or only_tradable is False ): value += ( self.get_deal_price( @@ -613,10 +596,8 @@ class Exchange: def get_amount_of_trade_unit(self, factor: float = None, stock_id: str = None, start_time=None, end_time=None): """ get the trade unit of amount based on **factor** - the factor can be given directly or calculated in given time range and stock id. `factor` has higher priority than `stock_id`, `start_time` and `end_time` - Parameters ---------- factor : float @@ -641,7 +622,6 @@ class Exchange: ): """Parameter Please refer to the docs of get_amount_of_trade_unit - deal_amount : float, adjusted amount factor : float, adjusted factor return : float, real amount @@ -656,11 +636,9 @@ class Exchange: def _clip_amount_by_volume(self, order: Order, dealt_order_amount: dict) -> int: """parse the capacity limit string and return the actual amount of orders that can be executed. - NOTE: this function will change the order.deal_amount **inplace** - This will make the order info more accurate - Parameters ---------- order : Order @@ -694,7 +672,7 @@ class Exchange: order.start_time, order.end_time, field=limit[1], - method=ts_data_last, + method="ts_data_last", ) vol_limit_num.append(limit_value - dealt_order_amount[order.stock_id]) else: @@ -709,12 +687,10 @@ class Exchange: def _get_buy_amount_by_cash_limit(self, trade_price, cash): """return the real order amount after cash limit for buying. - Parameters ---------- trade_price : float position : cash - Return ---------- float @@ -735,9 +711,7 @@ class Exchange: def _calc_trade_info_by_order(self, order, position: Position, dealt_order_amount): """ Calculation of trade info - **NOTE**: Order will be changed in this function - :param order: :param position: Position :param dealt_order_amount: the dealt order amount dict with the format of {stock_id: float} @@ -745,18 +719,27 @@ class Exchange: """ trade_price = self.get_deal_price(order.stock_id, order.start_time, order.end_time, direction=order.direction) order.factor = self.get_factor(order.stock_id, order.start_time, order.end_time) + order.deal_amount = order.amount # set to full amount and clip it step by step + # Clipping amount first + # - It simulates that the order is rejected directly by the exchange due to large order + # Another choice is placing it after rounding the order + # - It simulates that the large order is submitted, but partial is dealt regardless of rounding by trading unit. + self._clip_amount_by_volume(order, dealt_order_amount) + if order.direction == Order.SELL: cost_ratio = self.close_cost # sell + # if we don't know current position, we choose to sell all + # Otherwise, we clip the amount based on current position if position is not None: current_amount = ( position.get_stock_amount(order.stock_id) if position.check_stock(order.stock_id) else 0 ) - if np.isclose(order.amount, current_amount): - # when selling last stock. The amount don't need rounding - order.deal_amount = order.amount - else: - order.deal_amount = self.round_amount_by_trade_unit(min(current_amount, order.amount), order.factor) + if not np.isclose(order.deal_amount, current_amount): + # when not selling last stock. rounding is necessary + order.deal_amount = self.round_amount_by_trade_unit( + min(current_amount, order.deal_amount), order.factor + ) # in case of negative value of cash if position.get_cash() + order.deal_amount * trade_price < max( @@ -765,33 +748,30 @@ class Exchange: ): order.deal_amount = 0 self.logger.debug(f"Order clipped due to cash limitation: {order}") - else: - # TODO: We don't know current position. - # We choose to sell all - order.deal_amount = order.amount elif order.direction == Order.BUY: cost_ratio = self.open_cost # buy if position is not None: cash = position.get_cash() - trade_val = order.amount * trade_price + trade_val = order.deal_amount * trade_price if cash < trade_val + max(trade_val * cost_ratio, self.min_cost): # The money is not enough max_buy_amount = self._get_buy_amount_by_cash_limit(trade_price, cash) - order.deal_amount = self.round_amount_by_trade_unit(max_buy_amount, order.factor) + order.deal_amount = self.round_amount_by_trade_unit( + min(max_buy_amount, order.deal_amount), order.factor + ) self.logger.debug(f"Order clipped due to cash limitation: {order}") else: # The money is enough - order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + order.deal_amount = self.round_amount_by_trade_unit(order.deal_amount, order.factor) else: # Unknown amount of money. Just round the amount - order.deal_amount = self.round_amount_by_trade_unit(order.amount, order.factor) + order.deal_amount = self.round_amount_by_trade_unit(order.deal_amount, order.factor) else: raise NotImplementedError("order type {} error".format(order.type)) - self._clip_amount_by_volume(order, dealt_order_amount) trade_val = order.deal_amount * trade_price trade_cost = max(trade_val * cost_ratio, self.min_cost) if trade_val <= 1e-5: diff --git a/qlib/backtest/executor.py b/qlib/backtest/executor.py index e7882714a..44f3e8db0 100644 --- a/qlib/backtest/executor.py +++ b/qlib/backtest/executor.py @@ -11,7 +11,7 @@ from collections import defaultdict from qlib.backtest.report import Indicator -from .order import EmptyTradeDecision, Order, BaseTradeDecision +from .decision import EmptyTradeDecision, Order, BaseTradeDecision from .exchange import Exchange from .utils import TradeCalendarManager, CommonInfrastructure, LevelInfrastructure, get_start_end_idx @@ -29,7 +29,7 @@ class BaseExecutor: start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, indicator_config: dict = {}, - generate_report: bool = False, + generate_portfolio_metrics: bool = False, verbose: bool = False, track_data: bool = False, trade_exchange: Exchange = None, @@ -77,8 +77,8 @@ class BaseExecutor: 'weight_method': 'value_weighted', } } - generate_report : bool, optional - whether to generate report, by default False + generate_portfolio_metrics : bool, optional + whether to generate portfolio_metrics, by default False verbose : bool, optional whether to print trading info, by default False track_data : bool, optional @@ -87,8 +87,8 @@ class BaseExecutor: - Else, `trade_decision` will not be generated trade_exchange : Exchange - exchange that provides market info, used to generate report - - If generate_report is None, trade_exchange will be ignored + exchange that provides market info, used to generate portfolio_metrics + - If generate_portfolio_metrics is None, trade_exchange will be ignored - Else If `trade_exchange` is None, self.trade_exchange will be set with common_infra common_infra : CommonInfrastructure, optional: @@ -103,7 +103,7 @@ class BaseExecutor: """ self.time_per_step = time_per_step self.indicator_config = indicator_config - self.generate_report = generate_report + self.generate_portfolio_metrics = generate_portfolio_metrics self.verbose = verbose self.track_data = track_data self._trade_exchange = trade_exchange @@ -132,7 +132,7 @@ class BaseExecutor: # NOTE: there is a trick in the code. # copy is used instead of deepcopy. So positions are shared self.trade_account: Account = copy.copy(common_infra.get("trade_account")) - self.trade_account.reset(freq=self.time_per_step, init_report=True, port_metr_enabled=self.generate_report) + self.trade_account.reset(freq=self.time_per_step, port_metr_enabled=self.generate_portfolio_metrics) @property def trade_exchange(self) -> Exchange: @@ -246,7 +246,7 @@ class BaseExecutor: raise ValueError("atomic executor doesn't support specify `range_limit`") if self._settle_type != BasePosition.ST_NO: - self.trade_account.current.settle_start(self._settle_type) + self.trade_account.current_position.settle_start(self._settle_type) obj = self._collect_data(trade_decision=trade_decision, level=level) @@ -271,7 +271,7 @@ class BaseExecutor: self.trade_calendar.step() if self._settle_type != BasePosition.ST_NO: - self.trade_account.current.settle_commit() + self.trade_account.current_position.settle_commit() if return_value is not None: return_value.update({"execute_result": res}) @@ -296,7 +296,7 @@ class NestedExecutor(BaseExecutor): start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, indicator_config: dict = {}, - generate_report: bool = False, + generate_portfolio_metrics: bool = False, verbose: bool = False, track_data: bool = False, skip_empty_decision: bool = True, @@ -335,7 +335,7 @@ class NestedExecutor(BaseExecutor): start_time=start_time, end_time=end_time, indicator_config=indicator_config, - generate_report=generate_report, + generate_portfolio_metrics=generate_portfolio_metrics, verbose=verbose, track_data=track_data, common_infra=common_infra, @@ -444,7 +444,7 @@ class SimulatorExecutor(BaseExecutor): start_time: Union[str, pd.Timestamp] = None, end_time: Union[str, pd.Timestamp] = None, indicator_config: dict = {}, - generate_report: bool = False, + generate_portfolio_metrics: bool = False, verbose: bool = False, track_data: bool = False, common_infra: CommonInfrastructure = None, @@ -462,7 +462,7 @@ class SimulatorExecutor(BaseExecutor): start_time=start_time, end_time=end_time, indicator_config=indicator_config, - generate_report=generate_report, + generate_portfolio_metrics=generate_portfolio_metrics, verbose=verbose, track_data=track_data, common_infra=common_infra, diff --git a/qlib/backtest/high_performance_ds.py b/qlib/backtest/high_performance_ds.py index 97310ffb6..235bd054b 100644 --- a/qlib/backtest/high_performance_ds.py +++ b/qlib/backtest/high_performance_ds.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - from functools import lru_cache import logging from typing import List, Text, Union, Callable, Iterable, Dict @@ -14,12 +13,12 @@ import numpy as np from ..utils.index_data import IndexData, SingleData from ..utils.resam import resam_ts_data, ts_data_last from ..log import get_module_logger -from ..utils.time import is_single_value +from ..utils.time import is_single_value, Freq import qlib.utils.index_data as idd class BaseQuote: - def __init__(self, quote_df: pd.DataFrame): + def __init__(self, quote_df: pd.DataFrame, freq): self.logger = get_module_logger("online operator", level=logging.INFO) def get_all_stock(self) -> Iterable: @@ -39,7 +38,7 @@ class BaseQuote: start_time: Union[pd.Timestamp, str], end_time: Union[pd.Timestamp, str], field: Union[str], - method: Union[str, Callable, None] = None, + method: Union[str, None] = None, ) -> Union[None, int, float, bool, IndexData]: """get the specific field of stock data during start time and end_time, and apply method to the data. @@ -83,9 +82,9 @@ class BaseQuote: closed end time for backtest field : str the columns of data to fetch - method : Union[str, Callable, None] + method : Union[str, None] the method apply to data. - e.g [None, "last", "all", "sum", "mean", qlib/utils/resam.py/ts_data_last] + e.g [None, "last", "all", "sum", "mean", "ts_data_last"] Return ---------- @@ -99,8 +98,8 @@ class BaseQuote: class PandasQuote(BaseQuote): - def __init__(self, quote_df: pd.DataFrame): - super().__init__(quote_df=quote_df) + def __init__(self, quote_df: pd.DataFrame, freq): + super().__init__(quote_df=quote_df, freq=freq) quote_dict = {} for stock_id, stock_val in quote_df.groupby(level="instrument"): quote_dict[stock_id] = stock_val.droplevel(level="instrument") @@ -110,6 +109,8 @@ class PandasQuote(BaseQuote): return self.data.keys() def get_data(self, stock_id, start_time, end_time, field, method=None): + if method == "ts_data_last": + method = ts_data_last stock_data = resam_ts_data(self.data[stock_id][field], start_time, end_time, method=method) if stock_data is None: return None @@ -121,9 +122,9 @@ class PandasQuote(BaseQuote): raise ValueError(f"stock data from resam_ts_data must be a number, pd.Series or pd.DataFrame") -class CN1minNumpyQuote(BaseQuote): - def __init__(self, quote_df: pd.DataFrame): - """CN1minNumpyQuote +class NumpyQuote(BaseQuote): + def __init__(self, quote_df: pd.DataFrame, freq, region="cn"): + """NumpyQuote Parameters ---------- @@ -131,13 +132,19 @@ class CN1minNumpyQuote(BaseQuote): the init dataframe from qlib. self.data : Dict(stock_id, IndexData.DataFrame) """ - super().__init__(quote_df=quote_df) + super().__init__(quote_df=quote_df, freq=freq) quote_dict = {} for stock_id, stock_val in quote_df.groupby(level="instrument"): quote_dict[stock_id] = idd.MultiData(stock_val.droplevel(level="instrument")) quote_dict[stock_id].sort_index() # To support more flexible slicing, we must sort data first self.data = quote_dict - self.freq = pd.Timedelta(minutes=1) + + n, unit = Freq.parse(freq) + if unit in Freq.SUPPORT_CAL_LIST: + self.freq = Freq.get_timedelta(1, unit) + else: + raise ValueError(f"{freq} is not supported in NumpyQuote") + self.region = region def get_all_stock(self): return self.data.keys() @@ -150,7 +157,7 @@ class CN1minNumpyQuote(BaseQuote): # single data # If it don't consider the classification of single data, it will consume a lot of time. - if is_single_value(start_time, end_time, self.freq): + if is_single_value(start_time, end_time, self.freq, self.region): # this is a very special case. # skip aggregating function to speed-up the query calculation try: @@ -178,9 +185,7 @@ class CN1minNumpyQuote(BaseQuote): return data[-1] elif method == "all": return data.all() - elif method == "any": - return data.any() - elif method == ts_data_last: + elif method == "ts_data_last": valid_data = data.loc[~data.isna().data.astype(bool)] if len(valid_data) == 0: return None diff --git a/qlib/backtest/position.py b/qlib/backtest/position.py index 234ec08b9..2bfb20893 100644 --- a/qlib/backtest/position.py +++ b/qlib/backtest/position.py @@ -10,7 +10,7 @@ import pandas as pd from datetime import timedelta import numpy as np -from .order import Order +from .decision import Order from ..data.data import D @@ -151,7 +151,8 @@ class BasePosition: def get_stock_weight_dict(self, only_stock: bool = False) -> Dict: """ generate stock weight dict {stock_id : value weight of stock in the position} - it is meaningful in the beginning or the end of each trade date + it is meaningful in the beginning or the end of each trade step + - During execution of each trading step, the weight may be not consistant with the portfolio value Parameters ---------- @@ -408,7 +409,7 @@ class Position(BasePosition): return self.position[code]["price"] def get_stock_amount(self, code): - return self.position[code]["amount"] + return self.position[code]["amount"] if code in self.position else 0 def get_stock_count(self, code, bar): """the days the account has been hold, it may be used in some special strategies""" @@ -531,7 +532,7 @@ class InfPosition(BasePosition): raise NotImplementedError(f"InfPosition doesn't support get_stock_weight_dict") def add_count_all(self, bar): - raise NotImplementedError(f"InfPosition doesn't support get_stock_weight_dict") + raise NotImplementedError(f"InfPosition doesn't support add_count_all") def update_weight_all(self): raise NotImplementedError(f"InfPosition doesn't support update_weight_all") diff --git a/qlib/backtest/profit_attribution.py b/qlib/backtest/profit_attribution.py index 05ee138cb..895f5c78b 100644 --- a/qlib/backtest/profit_attribution.py +++ b/qlib/backtest/profit_attribution.py @@ -18,6 +18,7 @@ def get_benchmark_weight( start_date=None, end_date=None, path=None, + freq="day", ): """get_benchmark_weight @@ -27,6 +28,7 @@ def get_benchmark_weight( :param start_date: :param end_date: :param path: + :param freq: :return: The weight distribution of the the benchmark described by a pandas dataframe Every row corresponds to a trading day. @@ -35,7 +37,7 @@ def get_benchmark_weight( """ if not path: - path = Path(C.get_data_path()).expanduser() / "raw" / "AIndexMembers" / "weights.csv" + path = Path(C.dpm.get_data_uri(freq)).expanduser() / "raw" / "AIndexMembers" / "weights.csv" # TODO: the storage of weights should be implemented in a more elegent way # TODO: The benchmark is not consistant with the filename in instruments. bench_weight_df = pd.read_csv(path, usecols=["code", "date", "index", "weight"]) @@ -224,6 +226,7 @@ def brinson_pa( group_method="category", group_n=None, deal_price="vwap", + freq="day", ): """brinson profit attribution @@ -245,7 +248,7 @@ def brinson_pa( start_date, end_date = min(dates), max(dates) - bench_stock_weight = get_benchmark_weight(bench, start_date, end_date) + bench_stock_weight = get_benchmark_weight(bench, start_date, end_date, freq) # The attributes for allocation will not if not group_field.startswith("$"): @@ -261,13 +264,14 @@ def brinson_pa( start_time=shift_start_date, end_time=end_date, as_list=True, + freq=freq, ) stock_df = D.features( instruments, [group_field, deal_price], start_time=shift_start_date, end_time=end_date, - freq="day", + freq=freq, ) stock_df.columns = [group_field, "deal_price"] diff --git a/qlib/backtest/report.py b/qlib/backtest/report.py index a364b10db..03fb85344 100644 --- a/qlib/backtest/report.py +++ b/qlib/backtest/report.py @@ -10,21 +10,24 @@ import numpy as np import pandas as pd from qlib.backtest.exchange import Exchange -from qlib.backtest.order import BaseTradeDecision, Order, OrderDir +from .decision import IdxTradeRange +from qlib.backtest.decision import BaseTradeDecision, Order, OrderDir +from qlib.backtest.utils import TradeCalendarManager from .high_performance_ds import BaseOrderIndicator, PandasOrderIndicator, NumpyOrderIndicator, SingleMetric +from ..data import D from ..tests.config import CSI300_BENCH from ..utils.resam import get_higher_eq_freq_feature, resam_ts_data -from .order import IdxTradeRange import qlib.utils.index_data as idd -class Report: +class PortfolioMetrics: """ Motivation: - Report is for supporting portfolio related metrics. + PortfolioMetrics is for supporting portfolio related metrics. Implementation: - daily report of the account + + daily portfolio metrics of the account contain those followings: return, cost, turnover, account, cash, bench, value For each step(bar/day/minute), each column represents - return: the return of the portfolio generated by strategy **without transaction fee**. @@ -33,7 +36,7 @@ class Report: - cash: the amount of cash in user's account. - bench: the return of the benchmark - value: the total value of securities/stocks/instruments (cash is excluded). - + update report """ @@ -79,7 +82,7 @@ class Report: self.values = OrderedDict() # value for each trade time self.cashes = OrderedDict() self.benches = OrderedDict() - self.latest_report_time = None # pd.TimeStamp + self.latest_pm_time = None # pd.TimeStamp def init_bench(self, freq=None, benchmark_config=None): if freq is not None: @@ -123,18 +126,18 @@ class Report: return len(self.accounts) == 0 def get_latest_date(self): - return self.latest_report_time + return self.latest_pm_time def get_latest_account_value(self): - return self.accounts[self.latest_report_time] + return self.accounts[self.latest_pm_time] def get_latest_total_cost(self): - return self.total_costs[self.latest_report_time] + return self.total_costs[self.latest_pm_time] def get_latest_total_turnover(self): - return self.total_turnovers[self.latest_report_time] + return self.total_turnovers[self.latest_pm_time] - def update_report_record( + def update_portfolio_metrics_record( self, trade_start_time=None, trade_end_time=None, @@ -169,7 +172,7 @@ class Report: elif bench_value is None: bench_value = self._sample_benchmark(self.bench, trade_start_time, trade_end_time) - # update report data + # update pm data self.accounts[trade_start_time] = account_value self.returns[trade_start_time] = return_rate self.total_turnovers[trade_start_time] = total_turnover @@ -179,30 +182,30 @@ class Report: self.values[trade_start_time] = stock_value self.cashes[trade_start_time] = cash self.benches[trade_start_time] = bench_value - # update latest_report_date - self.latest_report_time = trade_start_time - # finish report update in each step + # update pm + self.latest_pm_time = trade_start_time + # finish pm update in each step - def generate_report_dataframe(self): - report = pd.DataFrame() - report["account"] = pd.Series(self.accounts) - report["return"] = pd.Series(self.returns) - report["total_turnover"] = pd.Series(self.total_turnovers) - report["turnover"] = pd.Series(self.turnovers) - report["total_cost"] = pd.Series(self.total_costs) - report["cost"] = pd.Series(self.costs) - report["value"] = pd.Series(self.values) - report["cash"] = pd.Series(self.cashes) - report["bench"] = pd.Series(self.benches) - report.index.name = "datetime" - return report + def generate_portfolio_metrics_dataframe(self): + pm = pd.DataFrame() + pm["account"] = pd.Series(self.accounts) + pm["return"] = pd.Series(self.returns) + pm["total_turnover"] = pd.Series(self.total_turnovers) + pm["turnover"] = pd.Series(self.turnovers) + pm["total_cost"] = pd.Series(self.total_costs) + pm["cost"] = pd.Series(self.costs) + pm["value"] = pd.Series(self.values) + pm["cash"] = pd.Series(self.cashes) + pm["bench"] = pd.Series(self.benches) + pm.index.name = "datetime" + return pm - def save_report(self, path): - r = self.generate_report_dataframe() + def save_portfolio_metrics(self, path): + r = self.generate_portfolio_metrics_dataframe() r.to_csv(path) - def load_report(self, path): - """load report from a file + def load_portfolio_metrics(self, path): + """load pm from a file should have format like columns = ['account', 'return', 'total_turnover', 'turnover', 'cost', 'total_cost', 'value', 'cash', 'bench'] :param @@ -215,7 +218,7 @@ class Report: index = r.index self.init_vars() for trade_start_time in index: - self.update_report_record( + self.update_portfolio_metrics_record( trade_start_time=trade_start_time, account_value=r.loc[trade_start_time]["account"], cash=r.loc[trade_start_time]["cash"], @@ -376,8 +379,6 @@ class Indicator: price = pa_config.get("price", "deal_price").lower() if decision.trade_range is not None: - if isinstance(decision.trade_range, IdxTradeRange): - raise TypeError(f"IdxTradeRange is not supported") trade_start_time, trade_end_time = decision.trade_range.clip_time_range( start_time=trade_start_time, end_time=trade_end_time ) diff --git a/qlib/backtest/utils.py b/qlib/backtest/utils.py index b5ff84c54..51130712d 100644 --- a/qlib/backtest/utils.py +++ b/qlib/backtest/utils.py @@ -1,18 +1,17 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + from __future__ import annotations import bisect from qlib.utils.time import epsilon_change -from typing import Union, TYPE_CHECKING, Tuple, Union, List, Set +from typing import TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: - from qlib.backtest.order import BaseTradeDecision - from qlib.strategy.base import BaseStrategy + from qlib.backtest.decision import BaseTradeDecision import pandas as pd import warnings -from ..utils.resam import get_resam_calendar from ..data.data import Cal @@ -56,9 +55,9 @@ class TradeCalendarManager: self.start_time = pd.Timestamp(start_time) if start_time else None self.end_time = pd.Timestamp(end_time) if end_time else None - _calendar, freq, freq_sam = get_resam_calendar(freq=freq) + _calendar = Cal.calendar(freq=freq) self._calendar = _calendar - _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq, freq_sam=freq_sam) + _, _, _start_index, _end_index = Cal.locate_index(start_time, end_time, freq=freq) self.start_index = _start_index self.end_index = _end_index self.trade_len = _end_index - _start_index + 1 diff --git a/qlib/config.py b/qlib/config.py index 796cc5ca6..029434a88 100644 --- a/qlib/config.py +++ b/qlib/config.py @@ -15,8 +15,10 @@ import os import re import copy import logging +import platform import multiprocessing from pathlib import Path +from typing import Union class Config: @@ -73,6 +75,12 @@ REG_US = "us" NUM_USABLE_CPU = max(multiprocessing.cpu_count() - 2, 1) +DISK_DATASET_CACHE = "DiskDatasetCache" +SIMPLE_DATASET_CACHE = "SimpleDatasetCache" +DISK_EXPRESSION_CACHE = "DiskExpressionCache" + +DEPENDENCY_REDIS_CACHE = (DISK_DATASET_CACHE, DISK_EXPRESSION_CACHE) + _default_config = { # data provider config "calendar_provider": "LocalCalendarProvider", @@ -82,6 +90,15 @@ _default_config = { "dataset_provider": "LocalDatasetProvider", "provider": "LocalProvider", # config it in qlib.init() + # "provider_uri" str or dict: + # # str + # "~/.qlib/stock_data/cn_data" + # # dict + # {"day": "~/.qlib/stock_data/cn_data", "1min": "~/.qlib/stock_data/cn_data_1min"} + # NOTE: provider_uri priority: + # 1. backend_config: backend_obj["kwargs"]["provider_uri"] + # 2. backend_config: backend_obj["kwargs"]["provider_uri_map"] + # 3. qlib.init: provider_uri "provider_uri": "", # cache "expression_cache": None, @@ -173,8 +190,9 @@ MODE_CONF = { "redis_task_db": 1, "kernels": NUM_USABLE_CPU, # cache - "expression_cache": "DiskExpressionCache", - "dataset_cache": "DiskDatasetCache", + "expression_cache": DISK_EXPRESSION_CACHE, + "dataset_cache": DISK_DATASET_CACHE, + "local_cache_path": Path("~/.cache/qlib_simple_cache").expanduser().resolve(), "mount_path": None, }, "client": { @@ -189,8 +207,10 @@ MODE_CONF = { "provider_uri": "~/.qlib/qlib_data/cn_data", # cache # Using parameter 'remote' to announce the client is using server_cache, and the writing access will be disabled. - "expression_cache": "DiskExpressionCache", - "dataset_cache": "DiskDatasetCache", + "expression_cache": DISK_EXPRESSION_CACHE, + "dataset_cache": DISK_DATASET_CACHE, + # SimpleDatasetCache directory + "local_cache_path": Path("~/.cache/qlib_simple_cache").expanduser().resolve(), "calendar_cache": None, # client config "kernels": NUM_USABLE_CPU, @@ -234,11 +254,43 @@ class QlibConfig(Config): # URI_TYPE LOCAL_URI = "local" NFS_URI = "nfs" + DEFAULT_FREQ = "__DEFAULT_FREQ" def __init__(self, default_conf): super().__init__(default_conf) self._registered = False + class DataPathManager: + def __init__(self, provider_uri: Union[str, Path, dict], mount_path: Union[str, Path, dict]): + self.provider_uri = provider_uri + self.mount_path = mount_path + + @staticmethod + def get_uri_type(uri: Union[str, Path]): + uri = uri if isinstance(uri, str) else str(uri.expanduser().resolve()) + is_win = re.match("^[a-zA-Z]:.*", uri) is not None # such as 'C:\\data', 'D:' + # such as 'host:/data/' (User may define short hostname by themselves or use localhost) + is_nfs_or_win = re.match("^[^/]+:.+", uri) is not None + + if is_nfs_or_win and not is_win: + return QlibConfig.NFS_URI + else: + return QlibConfig.LOCAL_URI + + def get_data_uri(self, freq: str = None) -> Path: + if freq is None or freq not in self.provider_uri: + freq = QlibConfig.DEFAULT_FREQ + _provider_uri = self.provider_uri[freq] + if self.get_uri_type(_provider_uri) == QlibConfig.LOCAL_URI: + return Path(_provider_uri) + elif self.get_uri_type(_provider_uri) == QlibConfig.NFS_URI: + if "win" in platform.system().lower(): + # windows, mount_path is the drive + return Path(f"{self.mount_path[freq]}:\\") + return Path(self.mount_path[freq]) + else: + raise NotImplementedError(f"This type of uri is not supported") + def set_mode(self, mode): # raise KeyError self.update(MODE_CONF[mode]) @@ -248,13 +300,42 @@ class QlibConfig(Config): # raise KeyError self.update(_default_region_config[region]) + @staticmethod + def is_depend_redis(cache_name: str): + return cache_name in DEPENDENCY_REDIS_CACHE + + @property + def dpm(self): + return self.DataPathManager(self["provider_uri"], self["mount_path"]) + def resolve_path(self): # resolve path - if self["mount_path"] is not None: - self["mount_path"] = str(Path(self["mount_path"]).expanduser().resolve()) + _mount_path = self["mount_path"] + _provider_uri = self["provider_uri"] + if _provider_uri is None: + raise ValueError("provider_uri cannot be None") + if not isinstance(_provider_uri, dict): + _provider_uri = {self.DEFAULT_FREQ: _provider_uri} + if not isinstance(_mount_path, dict): + _mount_path = {_freq: _mount_path for _freq in _provider_uri.keys()} - if self.get_uri_type() == QlibConfig.LOCAL_URI: - self["provider_uri"] = str(Path(self["provider_uri"]).expanduser().resolve()) + # check provider_uri and mount_path + _miss_freq = set(_provider_uri.keys()) - set(_mount_path.keys()) + assert len(_miss_freq) == 0, f"mount_path is missing freq: {_miss_freq}" + + # resolve + for _freq, _uri in _provider_uri.items(): + # provider_uri + if self.DataPathManager.get_uri_type(_uri) == QlibConfig.LOCAL_URI: + _provider_uri[_freq] = str(Path(_uri).expanduser().resolve()) + # mount_path + _mount_path[_freq] = ( + _mount_path[_freq] + if _mount_path[_freq] is None + else str(Path(_mount_path[_freq]).expanduser().resolve()) + ) + self["provider_uri"] = _provider_uri + self["mount_path"] = _mount_path def get_uri_type(self): path = self["provider_uri"] @@ -270,14 +351,6 @@ class QlibConfig(Config): else: return QlibConfig.LOCAL_URI - def get_data_path(self): - if self.get_uri_type() == QlibConfig.LOCAL_URI: - return self["provider_uri"] - elif self.get_uri_type() == QlibConfig.NFS_URI: - return self["mount_path"] - else: - raise NotImplementedError(f"This type of uri is not supported") - def set(self, default_conf: str = "client", **kwargs): """ configure qlib based on the input parameters @@ -325,11 +398,20 @@ class QlibConfig(Config): if not (self["expression_cache"] is None and self["dataset_cache"] is None): # check redis if not can_use_cache(): - logger.warning( - f"redis connection failed(host={self['redis_host']} port={self['redis_port']}), cache will not be used!" - ) - self["expression_cache"] = None - self["dataset_cache"] = None + log_str = "" + # check expression cache + if self.is_depend_redis(self["expression_cache"]): + log_str += self["expression_cache"] + self["expression_cache"] = None + # check dataset cache + if self.is_depend_redis(self["dataset_cache"]): + log_str += f" and {self['dataset_cache']}" if log_str else self["dataset_cache"] + self["dataset_cache"] = None + if log_str: + logger.warning( + f"redis connection failed(host={self['redis_host']} port={self['redis_port']}), " + f"{log_str} will not be used!" + ) def register(self): from .utils import init_instance_by_config diff --git a/qlib/contrib/data/dataset.py b/qlib/contrib/data/dataset.py new file mode 100644 index 000000000..af4893acf --- /dev/null +++ b/qlib/contrib/data/dataset.py @@ -0,0 +1,346 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import copy +import torch +import warnings +import numpy as np +import pandas as pd + +from qlib.utils import init_instance_by_config +from qlib.data.dataset import DatasetH, DataHandler + + +device = "cuda" if torch.cuda.is_available() else "cpu" + + +def _to_tensor(x): + if not isinstance(x, torch.Tensor): + return torch.tensor(x, dtype=torch.float, device=device) + return x + + +def _create_ts_slices(index, seq_len): + """ + create time series slices from pandas index + + Args: + index (pd.MultiIndex): pandas multiindex with order + seq_len (int): sequence length + """ + assert isinstance(index, pd.MultiIndex), "unsupported index type" + assert seq_len > 0, "sequence length should be larger than 0" + assert index.is_monotonic_increasing, "index should be sorted" + + # number of dates for each instrument + sample_count_by_insts = index.to_series().groupby(level=0).size().values + + # start index for each instrument + start_index_of_insts = np.roll(np.cumsum(sample_count_by_insts), 1) + start_index_of_insts[0] = 0 + + # all the [start, stop) indices of features + # features between [start, stop) will be used to predict label at `stop - 1` + slices = [] + for cur_loc, cur_cnt in zip(start_index_of_insts, sample_count_by_insts): + for stop in range(1, cur_cnt + 1): + end = cur_loc + stop + start = max(end - seq_len, 0) + slices.append(slice(start, end)) + slices = np.array(slices, dtype="object") + + assert len(slices) == len(index) # the i-th slice = index[i] + + return slices + + +def _get_date_parse_fn(target): + """get date parse function + + This method is used to parse date arguments as target type. + + Example: + get_date_parse_fn('20120101')('2017-01-01') => '20170101' + get_date_parse_fn(20120101)('2017-01-01') => 20170101 + """ + if isinstance(target, pd.Timestamp): + _fn = lambda x: pd.Timestamp(x) # Timestamp('2020-01-01') + elif isinstance(target, int): + _fn = lambda x: int(str(x).replace("-", "")[:8]) # 20200201 + elif isinstance(target, str) and len(target) == 8: + _fn = lambda x: str(x).replace("-", "")[:8] # '20200201' + else: + _fn = lambda x: x # '2021-01-01' + return _fn + + +def _maybe_padding(x, seq_len, zeros=None): + """padding 2d

v#y< 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≠pnqWPCG{t`hrg1h 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

Y};R9^f@e$$l6~y$dFKbkFdl_^w3Y8Mn59nwN8G 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%0DvZ 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 z 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 diff --git a/docs/_static/img/analysis/risk_analysis_annualized_return.png b/docs/_static/img/analysis/risk_analysis_annualized_return.png index 18e7a90aa091c32b686b9d7aca36f2cd8e23e560..f42ecf0a316477fd4505d8c59af9cbfd9e0feb95 100644 GIT binary patch literal 46507 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