LoginSignup
0
0

バックテスト

Posted at

WF法


# WF法
from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5, gap=0)
print(tscv)
i = 1
for train_idx, test_idx in tscv.split(time_bar):
    plot_cv(time_bar, train_idx, test_idx, i)
    i += 1
    print(f'train {len(train_idx)}', f'test {len(test_idx)}', ' ### ',
          train_idx[:5], test_idx[:5])
plt.show()

CV法

# CV法
from sklearn.model_selection import KFold

cv = KFold(n_splits=5)
print(cv)
i = 1
for train_idx, test_idx in cv.split(time_bar):
    plot_cv(time_bar, train_idx, test_idx, i)
    i += 1
    print(f'train {len(train_idx)}', f'test {len(test_idx)}', ' ### ',
          train_idx[:5], test_idx[:5])
plt.show()

CPCV法

# 引用 https://www.kaggle.com/code/treename/janestreet-cv-method-combpurgedkfoldcv/notebook
import itertools as itt
import numbers
import numpy as np
import pandas as pd

from abc import abstractmethod
from typing import Iterable, Tuple, List


class BaseTimeSeriesCrossValidator:
    """
    Abstract class for time series cross-validation.
    Time series cross-validation requires each sample has a prediction time pred_time, at which the features are used to
    predict the response, and an evaluation time eval_time, at which the response is known and the error can be
    computed. Importantly, it means that unlike in standard sklearn cross-validation, the samples X, response y,
    pred_times and eval_times must all be pandas dataframe/series having the same index. It is also assumed that the
    samples are time-ordered with respect to the prediction time (i.e. pred_times is non-decreasing).
    Parameters
    ----------
    n_splits : int, default=10
        Number of folds. Must be at least 2.
    """
    def __init__(self, n_splits=10):
        if not isinstance(n_splits, numbers.Integral):
            raise ValueError(f"The number of folds must be of Integral type. {n_splits} of type {type(n_splits)}"
                             f" was passed.")
        n_splits = int(n_splits)
        if n_splits <= 1:
            raise ValueError(f"K-fold cross-validation requires at least one train/test split by setting n_splits = 2 "
                             f"or more, got n_splits = {n_splits}.")
        self.n_splits = n_splits
        self.pred_times = None
        self.eval_times = None
        self.indices = None

    @abstractmethod
    def split(self, X: pd.DataFrame, y: pd.Series = None,
              pred_times: pd.Series = None, eval_times: pd.Series = None):
        if not isinstance(X, pd.DataFrame) and not isinstance(X, pd.Series):
            raise ValueError('X should be a pandas DataFrame/Series.')
        if not isinstance(y, pd.Series) and y is not None:
            raise ValueError('y should be a pandas Series.')
        if not isinstance(pred_times, pd.Series):
            raise ValueError('pred_times should be a pandas Series.')
        if not isinstance(eval_times, pd.Series):
            raise ValueError('eval_times should be a pandas Series.')
        if y is not None and (X.index == y.index).sum() != len(y):
            raise ValueError('X and y must have the same index')
        if (X.index == pred_times.index).sum() != len(pred_times):
            raise ValueError('X and pred_times must have the same index')
        if (X.index == eval_times.index).sum() != len(eval_times):
            raise ValueError('X and eval_times must have the same index')

        self.pred_times = pred_times
        self.eval_times = eval_times
        self.indices = np.arange(X.shape[0])


class CombPurgedKFoldCV(BaseTimeSeriesCrossValidator):
    """
    Purged and embargoed combinatorial cross-validation
    As described in Advances in financial machine learning, Marcos Lopez de Prado, 2018.
    The samples are decomposed into n_splits folds containing equal numbers of samples, without shuffling. In each cross
    validation round, n_test_splits folds are used as the test set, while the other folds are used as the train set.
    There are as many rounds as n_test_splits folds among the n_splits folds.
    Each sample should be tagged with a prediction time pred_time and an evaluation time eval_time. The split is such
    that the intervals [pred_times, eval_times] associated to samples in the train and test set do not overlap. (The
    overlapping samples are dropped.) In addition, an "embargo" period is defined, giving the minimal time between an
    evaluation time in the test set and a prediction time in the training set. This is to avoid, in the presence of
    temporal correlation, a contamination of the test set by the train set.
    Parameters
    ----------
    n_splits : int, default=10
        Number of folds. Must be at least 2.
    n_test_splits : int, default=2
        Number of folds used in the test set. Must be at least 1.
    embargo_td : pd.Timedelta, default=0
        Embargo period (see explanations above).
    """
    def __init__(self, n_splits=10, n_test_splits=2, embargo_td=0):
        super().__init__(n_splits)
        if not isinstance(n_test_splits, numbers.Integral):
            raise ValueError(f"The number of test folds must be of Integral type. {n_test_splits} of type "
                             f"{type(n_test_splits)} was passed.")
        n_test_splits = int(n_test_splits)
        if n_test_splits <= 0 or n_test_splits > self.n_splits - 1:
            raise ValueError(f"K-fold cross-validation requires at least one train/test split by setting "
                             f"n_test_splits between 1 and n_splits - 1, got n_test_splits = {n_test_splits}.")
        self.n_test_splits = n_test_splits

        if embargo_td < 0:
            raise ValueError(f"The embargo time should be positive, got embargo = {embargo_td}.")
        self.embargo_td = embargo_td

    def split(self, X: pd.DataFrame, y: pd.Series = None,
              pred_times: pd.Series = None, eval_times: pd.Series = None) -> Iterable[Tuple[np.ndarray, np.ndarray]]:
        """
        Yield the indices of the train and test sets.
        Although the samples are passed in the form of a pandas dataframe, the indices returned are position indices,
        not labels.
        Parameters
        ----------
        X : pd.DataFrame, shape (n_samples, n_features), required
            Samples. Only used to extract n_samples.
        y : pd.Series, not used, inherited from _BaseKFold
        pred_times : pd.Series, shape (n_samples,), required
            Times at which predictions are made. pred_times.index has to coincide with X.index.
        eval_times : pd.Series, shape (n_samples,), required
            Times at which the response becomes available and the error can be computed. eval_times.index has to
            coincide with X.index.
        Returns
        -------
        train_indices: np.ndarray
            A numpy array containing all the indices in the train set.
        test_indices : np.ndarray
            A numpy array containing all the indices in the test set.
        """
        super().split(X, y, pred_times, eval_times)

        # Fold boundaries
        fold_bounds = [(fold[0], fold[-1] + 1) for fold in np.array_split(self.indices, self.n_splits)]
        # List of all combinations of n_test_splits folds selected to become test sets
        selected_fold_bounds = list(itt.combinations(fold_bounds, self.n_test_splits))
        
        # In order for the first round to have its whole test set at the end of the dataset
        selected_fold_bounds.reverse()

        for fold_bound_list in selected_fold_bounds:
            # Computes the bounds of the test set, and the corresponding indices
            test_fold_bounds, test_indices = self.compute_test_set(fold_bound_list)
            # Computes the train set indices
            train_indices = self.compute_train_set(test_fold_bounds, test_indices)

            yield train_indices, test_indices

    def compute_train_set(self, test_fold_bounds: List[Tuple[int, int]], test_indices: np.ndarray) -> np.ndarray:
        """
        Compute the position indices of samples in the train set.
        Parameters
        ----------
        test_fold_bounds : List of tuples of position indices
            Each tuple records the bounds of a block of indices in the test set.
        test_indices : np.ndarray
            A numpy array containing all the indices in the test set.
        Returns
        -------
        train_indices: np.ndarray
            A numpy array containing all the indices in the train set.
        """
        # As a first approximation, the train set is the complement of the test set
        train_indices = np.setdiff1d(self.indices, test_indices)
        # But we now have to purge and embargo
        for test_fold_start, test_fold_end in test_fold_bounds:
            # Purge
            train_indices = purge(self, train_indices, test_fold_start, test_fold_end)
            # Embargo
            train_indices = embargo(self, train_indices, test_indices, test_fold_end)
        return train_indices

    def compute_test_set(self, fold_bound_list: List[Tuple[int, int]]) -> Tuple[List[Tuple[int, int]], np.ndarray]:
        """
        Compute the indices of the samples in the test set.
        Parameters
        ----------
        fold_bound_list: List of tuples of position indices
            Each tuple records the bounds of the folds belonging to the test set.
        Returns
        -------
        test_fold_bounds: List of tuples of position indices
            Like fold_bound_list, but with the neighboring folds in the test set merged.
        test_indices: np.ndarray
            A numpy array containing the test indices.
        """
        test_indices = np.empty(0)
        test_fold_bounds = []
        for fold_start, fold_end in fold_bound_list:
            # Records the boundaries of the current test split
            if not test_fold_bounds or fold_start != test_fold_bounds[-1][-1]:
                test_fold_bounds.append((fold_start, fold_end))
            # If the current test split is contiguous to the previous one, simply updates the endpoint
            elif fold_start == test_fold_bounds[-1][-1]:
                test_fold_bounds[-1] = (test_fold_bounds[-1][0], fold_end)
            test_indices = np.union1d(test_indices, self.indices[fold_start:fold_end]).astype(int)
        return test_fold_bounds, test_indices


def compute_fold_bounds(cv: BaseTimeSeriesCrossValidator, split_by_time: bool) -> List[int]:
    """
    Compute a list containing the fold (left) boundaries.
    Parameters
    ----------
    cv: BaseTimeSeriesCrossValidator
        Cross-validation object for which the bounds need to be computed.
    split_by_time: bool
        If False, the folds contain an (approximately) equal number of samples. If True, the folds span identical
        time intervals.
    """
    if split_by_time:
        full_time_span = cv.pred_times.max() - cv.pred_times.min()
        fold_time_span = full_time_span / cv.n_splits
        fold_bounds_times = [cv.pred_times.iloc[0] + fold_time_span * n for n in range(cv.n_splits)]
        return cv.pred_times.searchsorted(fold_bounds_times)
    else:
        return [fold[0] for fold in np.array_split(cv.indices, cv.n_splits)]


def embargo(cv: BaseTimeSeriesCrossValidator, train_indices: np.ndarray,
            test_indices: np.ndarray, test_fold_end: int) -> np.ndarray:
    """
    Apply the embargo procedure to part of the train set.
    This amounts to dropping the train set samples whose prediction time occurs within self.embargo_dt of the test
    set sample evaluation times. This method applies the embargo only to the part of the training set immediately
    following the end of the test set determined by test_fold_end.
    Parameters
    ----------
    cv: Cross-validation class
        Needs to have the attributes cv.pred_times, cv.eval_times, cv.embargo_dt and cv.indices.
    train_indices: np.ndarray
        A numpy array containing all the indices of the samples currently included in the train set.
    test_indices : np.ndarray
        A numpy array containing all the indices of the samples in the test set.
    test_fold_end : int
        Index corresponding to the end of a test set block.
    Returns
    -------
    train_indices: np.ndarray
        The same array, with the indices subject to embargo removed.
    """
    if not hasattr(cv, 'embargo_td'):
        raise ValueError("The passed cross-validation object should have a member cv.embargo_td defining the embargo"
                         "time.")
    last_test_eval_time = cv.eval_times.iloc[cv.indices[:test_fold_end]].max()
    min_train_index = len(cv.pred_times[cv.pred_times <= last_test_eval_time + cv.embargo_td])
    if min_train_index < cv.indices.shape[0]:
        allowed_indices = np.concatenate((cv.indices[:test_fold_end], cv.indices[min_train_index:]))
        train_indices = np.intersect1d(train_indices, allowed_indices)
    return train_indices


def purge(cv: BaseTimeSeriesCrossValidator, train_indices: np.ndarray,
          test_fold_start: int, test_fold_end: int) -> np.ndarray:
    """
    Purge part of the train set.
    Given a left boundary index test_fold_start of the test set, this method removes from the train set all the
    samples whose evaluation time is posterior to the prediction time of the first test sample after the boundary.
    Parameters
    ----------
    cv: Cross-validation class
        Needs to have the attributes cv.pred_times, cv.eval_times and cv.indices.
    train_indices: np.ndarray
        A numpy array containing all the indices of the samples currently included in the train set.
    test_fold_start : int
        Index corresponding to the start of a test set block.
    test_fold_end : int
        Index corresponding to the end of the same test set block.
    Returns
    -------
    train_indices: np.ndarray
        A numpy array containing the train indices purged at test_fold_start.
    """
    time_test_fold_start = cv.pred_times.iloc[test_fold_start]
    # The train indices before the start of the test fold, purged.
    train_indices_1 = np.intersect1d(train_indices, cv.indices[cv.eval_times < time_test_fold_start])
    # The train indices after the end of the test fold.
    train_indices_2 = np.intersect1d(train_indices, cv.indices[test_fold_end:])
    

    return np.concatenate((train_indices_1, train_indices_2))

n_split=5
n_test_splits=2
n_purge = 10
embargo_td = 0

t1_ = time_bar.reset_index().index
t1 = pd.Series(t1_).shift(n_purge).fillna(0).astype(int)
t2 = pd.Series(t1_).shift(-n_purge).fillna(1e12).astype(int)

cpcv = CombPurgedKFoldCV(n_splits=n_split, n_test_splits=n_test_splits, embargo_td=embargo_td)

cv = list(cpcv.split(time_bar.reset_index(), pred_times=t1, eval_times=t2))


i = 1
print(f'グループ数{n_split}')
print(f'交差検証の組み合わせ数{n_split*(n_split-1)/2}')
for train_idx, val_idx in cv:
    plot_cv(time_bar,
            train_idx,
            val_idx,
            i=i,
            n=n_split * (n_split - 1) / 2,
            figsize=(10, 14))
    i += 1
plt.show()



def CPCV(df, n_split=5, n_purge=5):
    """CPCV用のK-Fold
    
    Parameters:
    ----------
    df: pd.DataFrame
        特徴量と目的変数を格納したDataFrame
    n_split: int
        CVの分割数
    n_purge: int
        パージングする数

    Returns:
    ----------
    ret: list
        以下の要素を持ったリスト
        - train_idx: list
            学習期間のインデックス
        - val_idx1: list
            一つ目の検証期間のインデックス
        - val_idx2: list
            二つ目の検証期間のインデックス
    """
    length = len(df)
    idx = list(range(length))
    ret = []
    split_idx = [int(length * i / n_split) for i in range(n_split + 1)]
    for i in range(n_split):
        for j in range(i + 1, n_split):
            val_from1 = split_idx[i]
            val_to1 = split_idx[i + 1]
            val_from2 = split_idx[j]
            val_to2 = split_idx[j + 1]
            val_idx1 = idx[val_from1:val_to1]
            val_idx2 = idx[val_from2:val_to2]
            train_idx = []
            if val_from1 - n_purge > 1:
                train_idx += idx[:val_from1 - n_purge]
            if val_to1 + n_purge < val_from2 - n_purge:
                train_idx += idx[val_to1 + n_purge:val_from2 - n_purge]
            if val_to2 + n_purge < len(idx):
                train_idx += idx[val_to2 + n_purge:]
            ret.append([train_idx, val_idx1, val_idx2])
    return ret

# CPCV法
n_split = 5

cv = CPCV(time_bar, n_split=n_split, n_purge=10)
i = 1
print(f'グループ数{n_split}')
print(f'交差検証の組み合わせ数{n_split*(n_split-1)/2}')
for train_idx, val_idx1, val_idx2 in cv:
    plot_cv(time_bar,
            train_idx,
            val_idx1 + val_idx2,
            i=i,
            n=n_split * (n_split - 1) / 2,
            figsize=(10, 14))
    i += 1
plt.show()

# バックテスト経路の作成
def get_backtest_path(df, features, y, model, n_path=5, n_purge=5):
    """
    1. 経路生成数n_pathに基づき,cpcvを実行
    2. modelを受け取り学習
    3. 学習済みモデルの推論結果をretに保存する

    Parameters:
    ----------
    df: pd.DataFrame
        特徴量と目的変数を格納したDataFrame
    features: list
        予測に使用する列名が入ったリスト
    y: str
        dfに含まれる目的変数名
    model: 
        fitとpredictメソッドを持つ機械学習モデル
    n_path: int
        バックテスト経路数
    n_purge: int
        パージングする数

    Returns:
    ----------
    ret: pd.DataFrame
        各バックテスト経路分の予測結果を持ったデータフレーム
        各列は,バックテストの経路番号を表す
    """
    n_split = n_path + 1

    cv = CPCV(df, n_split=n_split, n_purge=n_purge)

    # 結果格納用のDataFrame
    ret = df[[y]].copy()
    for i in range(n_path):
        ret.loc[:, i] = np.nan
    for train_idx, val_idx1, val_idx2 in cv:
        train_idx = df.index[train_idx]
        val_idx1 = df.index[val_idx1]
        val_idx2 = df.index[val_idx2]
        model.fit(df.loc[train_idx, features], df.loc[train_idx, y])
        for i in range(n_path):  # 対象Foldが未格納のカラムに格納する
            if np.isnan(ret.loc[val_idx1[0], i]):
                ret.loc[val_idx1, i] = model.predict(df.loc[val_idx1,
                                                            features])
                break
        for i in range(n_path):
            if np.isnan(ret.loc[val_idx2[0], i]):
                ret.loc[val_idx2, i] = model.predict(df.loc[val_idx2,
                                                            features])
                break
    return ret

CPCV法の実装

# 目的変数の作成
y = time_bar.cl / time_bar.op
y = y.shift(-1)
time_bar['ret'] = y

# 後の損益計算時で使用するため価格差分値を計算
time_bar['price_diff'] = time_bar.cl - time_bar.op
time_bar['price_diff'] = time_bar['price_diff'].shift(-1)

y = y.apply(lambda x: 1 if x > 1 else -1)
time_bar['target'] = y

from sklearn.ensemble import RandomForestClassifier
n_path = 5
rfc = RandomForestClassifier()
Xy = time_bar.dropna(axis=0)
ret = get_backtest_path(Xy, ['op','hi','lo','cl'], 'target', model=rfc, n_path=n_path)
ret = ret.add_prefix('path_')
ret.rename(columns={'path_target':'target'}, inplace=True)
from sklearn.metrics import accuracy_score, f1_score, roc_curve, auc

scores = []

for i in range(n_path):
    fpr, tpr, _ = roc_curve(ret.target, ret[f'path_{i}'])
    scores.append({
        'Accuracy' : accuracy_score(ret.target, ret[f'path_{i}']),
        'auc' : accuracy_score(ret.target, ret[f'path_{i}']),
        'f1-score' : f1_score(ret.target, ret[f'path_{i}'])
    })
pd.json_normalize(scores)

cv_result = pd.merge(time_bar, ret.iloc[:,1:], left_index=True, right_index=True)
cv_result

bt_result = cv_result[['op','hi','lo','cl','ret']].copy()
for i in range(n_path):
    bt_result[f'return_{i}'] = cv_result['price_diff'] * cv_result[f'path_{i}']


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0