<jupyter_start><jupyter_text>Imports<jupyter_code>%run Plots.ipynb
%run Metrics.ipynb
#Delete me
# Settings for notebook visualization
from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = 'all'
%matplotlib inline
from IPython.core.display import HTML
HTML("""<style>.output_png img {display: block;margin-left: auto;margin-right: auto;text-align: center;vertical-align: middle;} </style>""")
# Necessary imports
import yfinance as yf
import numpy as np
import pandas as pd
import quantstats as qs
import statistics as st
import os
from datetime import datetime, timedelta
from scipy.signal import convolve2d
from IPython.core.display import HTML
from collections import Counter, namedtuple
HTML("""<style>.output_png img {display: block;margin-left: auto;margin-right: auto;} </style>""")
# Other settings
qs.extend_pandas()
np.set_printoptions(edgeitems=40, linewidth=1000)
pd.set_option("display.precision", 6)
pd.set_option('display.max_columns', 500)
pd.set_option('display.width', 1000)

# Other settings

# Settings for plot visualization
plt.style.use('seaborn-darkgrid')

#plt.rcParams.keys()
plt.rcParams['figure.dpi'] = 200
plt.rcParams["figure.figsize"] = (12,3.5) #(12,5)
plt.rcParams['axes.grid'] = True
plt.rcParams['grid.linewidth'] = 0.4
#plt.rcParams['xtick.label.allignment'] = 'center'

plt.rcParams['xtick.bottom'] = plt.rcParams['ytick.labelright'] = True
plt.rcParams['ytick.left'] = plt.rcParams['ytick.right'] = True

plt.rcParams['lines.linewidth'] = 1.2
#plt.rcParams['lines.markersize'] = 0.5
plt.rcParams['patch.edgecolor'] = 'k' # Legend border 
plt.rcParams['legend.facecolor'] = 'w'
plt.rcParams["legend.frameon"] = True

np.set_printoptions(edgeitems=40, linewidth=1000)

pd.set_option("display.precision", 6)
pd.set_option('display.max_columns', 500)
pd.set_option('display.width', 1000)

#print("Notebook parameters set correctly")
os.chdir("/Users/Sergio/Documents/Master_QF/Thesis/Code/Algorithmic Strategies/")<jupyter_output><empty_output><jupyter_text>Data<jupyter_code>ini_equity_default = 100
commision_default = 0.000111538462 # 2/130000 + 12.5/130000
#commision_default = 0.0005 # Slightly Bbgger commision, for better visualization
# 0.01 = 1% of the cummulative return (equity)
# https://finance.yahoo.com/quote/%5EIRX?p=^IRX&.tsrc=fin-srch
# 13-week treasury bills
# All treasury bills from yahoo: https://finance.yahoo.com/bonds?.tsrc=fin-srch
# rf_13w = yf.download("^IRX", auto_adjust=True, start="1928-01-01") # This one starts in 1960
# rf_10y = yf.download("^TNX", auto_adjust=True, start="1928-01-01") # This one starts in 1962
# rf = pd.concat([rf_13w['Close'], rf_10y['Close']], axis=1)
# rf.columns = ['13W', '10Y']
# rf.head()
# rf.plot()
# Load DF with SP500 data
def get_sp500_data(start_date="1928-01-01", from_local_file=True, save_to_file=False):
    if from_local_file == True:
        data = pd.read_pickle('data/SP500_hist_data.pkl')
    else:
        # Download data from yfinance
        data = yf.download("^GSPC", auto_adjust=True, start=start_date)
        if save_to_file == True:
            data.to_pickle("data/SP500_hist_data.pkl")
    return data
full_df = get_sp500_data()
full_df['Market_daily_ret'] = full_df['Close'].pct_change().fillna((full_df['Close']-full_df['Open'])/full_df['Open'])
full_df = full_df[['Close', 'Open', 'Market_daily_ret']]<jupyter_output><empty_output><jupyter_text>Strategy functions  Buy and Hold<jupyter_code># Define if the strategy position (1=fast_ma higher than slow_ma, -1=short)
def buy_and_hold(df):
    position = pd.Series(1.0, index=df.index, dtype='float64')
    long_only = pd.Series(1.0, index=df.index, dtype='float64')
    
    return position, long_only<jupyter_output><empty_output><jupyter_text>Moving-Average Crossover<jupyter_code># Define if the strategy position (1=long, 0=neutral, -1=short)
def ma_crossover(df, fast_ma, slow_ma):
#     if (fast_ma == 0) & (slow_ma == 0): # Not valid parameters. buy_and_hold
#         return buy_and_hold(df)
    
    if (fast_ma >= slow_ma): # Important for WF
        return buy_and_hold(df)
    
    else:
        ############ start logic of the strategy ############
        # position refers to the position at the begining of each day. So we only use prices until previous day
        df['fast_ma'] = full_df['Close'].shift().rolling(window=fast_ma).mean()
        df['slow_ma'] = full_df['Close'].shift().rolling(window=slow_ma).mean()
        df['fast-slow'] = df['fast_ma'].sub(df['slow_ma'])

        conditions = [df['fast-slow'] > 0, df['fast-slow'] < 0, df['fast-slow'] == 0]
        signals = [1.0, 0.0, 1.0]
        
        position = pd.Series(np.select(conditions, signals), index=df.index, dtype='float64')
        
        ############ end logic of the strategy ############

    long_only = pd.Series(0.0, index=df.index, dtype='float64')
    
    return position, long_only
"""
Runs a backtest with all possible combinations and returns 2 matrices, one with pnl results, and one with SR
In MA crossover strategy, param_1 refers to fast_ma. param_2 refers to slow_ma

"""
def run_all_combinations_ma_crossover(df, param_1_list, param_2_list, last_position):
    strats_pnl_matrix = np.zeros((len(param_1_list),len(param_2_list)))
    strats_ir_matrix = np.zeros((len(param_1_list),len(param_2_list)))

    #buy and hold
    strategy = buy_and_hold(df)
    _, _, _, _, market_pnl, market_ir = backtest_strat(df, strategy, previous_position=last_position)
    
    for i, param_1 in enumerate(param_1_list): #fast_ma
        for j, param_2 in enumerate(param_2_list): #slow_ma
            if param_1 < param_2: # Constraint of ma_crossover
                strategy = ma_crossover(df, param_1, param_2)
                _, _, strat_pnl, strat_ir, _, _ = backtest_strat(df, strategy, previous_position=last_position)
                strats_pnl_matrix[i,j] = strat_pnl
                strats_ir_matrix[i,j] = strat_ir

    return strats_pnl_matrix, strats_ir_matrix, market_pnl, market_ir
"""
Checks how many neighbors has each element of a matrix.
"""
def get_num_neighbors_ma_crossover(param_1_list, param_2_list):
    n_rows = len(param_1_list)
    n_cols = len(param_1_list)
    
    n_neighbors = np.full((n_rows, n_cols), 8.0) # Default number of neighbors of each cell
    n_neighbors[[0,n_cols-1], :] = n_neighbors[:, [0,n_cols-1]] = 5 # Edges
    n_neighbors[[0,n_cols-1], [0,n_cols-1]] = n_neighbors[[n_rows-1,0], [0,n_cols-1]] = 3 # Corners

    for i in range(n_rows):
        for j in range(n_cols):
            if param_1_list[i] >= param_2_list[j]:
                n_neighbors[i,j] = np.nan
        
    num_notnan_neighbors = n_neighbors - _check_nan_around_matrix(n_neighbors)
    
    return num_notnan_neighbors<jupyter_output><empty_output><jupyter_text>Sell in may and go away<jupyter_code>def sell_in_may_and_go_away(df, sell_month, sell_duration):
    if (sell_month == 0) | (sell_month > 12) | (sell_duration == 0) | (sell_duration > 12) : # Buy and hold 
#        print("buy_hold")
        return buy_and_hold(df)
    
    elif (sell_duration == 12):
        position = pd.Series(0.0, index=df.index, dtype='float64')
    else:
        ############ start logic of the strategy ############
        df['month'] = df.index.month
        
        buy_month = ((sell_month + sell_duration - 1) % 12) + 1 # -1 and +1 because we want to omit number 0
        #print(f'sell month: {sell_month}\nsell duration: {sell_duration}\n\tbuy month:{buy_month}')
        
        conditions = [(sell_month < buy_month) & df['month'].between(sell_month, buy_month-1), #between() is inclusive -> -1
                      (buy_month < sell_month) & ~df['month'].between(buy_month, sell_month-1), #between() is inclusive -> -1
        ]
        signals = [0.0, 0.0]
        position = pd.Series(np.select(conditions, signals, default=1.0), index=df.index, dtype='float64')
        
        ############ end logic of the strategy ############
    
    long_only = pd.Series(0.0, index=df.index, dtype='float64')
    
    return position, long_only
"""
Runs a backtest with all possible combinations and returns 2 matrices, one with pnl results, and one with SR
In sell_in_may_and_go_away strategy, param_1 refers to sell_month. param_2 refers to buy_month

"""
def run_all_combinations_sell_in_may(df, param_1_list, param_2_list, last_position):
    strats_pnl_matrix = np.zeros((len(param_1_list),len(param_2_list)))
    strats_ir_matrix = np.zeros((len(param_1_list),len(param_2_list)))

    #buy and hold
    strategy = buy_and_hold(df)
    _, _, _, _, market_pnl, market_ir = backtest_strat(df, strategy, previous_position=last_position)
    
    for i, param_1 in enumerate(param_1_list): #sell_month
        if 0 < param_1 <= 12: # Constraint of #sell_month
            for j, param_2 in enumerate(param_2_list): #sell_duration
                if (0 <= param_2 <= 12): # Constraint of sell_in_may_and_go_away
                    strategy = sell_in_may_and_go_away(df, param_1, param_2)
                    _, _, strat_pnl, strat_ir, _, _ = backtest_strat(df, strategy, previous_position=last_position)
                    strats_pnl_matrix[i,j] = strat_pnl
                    strats_ir_matrix[i,j] = strat_ir

    return strats_pnl_matrix, strats_ir_matrix, market_pnl, market_ir
"""
Checks how many neighbors has each element of a matrix.
"""
def get_num_neighbors_sell_in_may_and_go_away(sell_month_list, sell_duration_list):
    n_rows = len(sell_month_list)
    n_cols = len(sell_month_list) # Done intentionally. Matrix has to be squared
    
    n_neighbors = np.full((n_rows, n_cols), 8.0) # Default number of neighbors of each cell
    n_neighbors[[0,n_cols-1], :] = n_neighbors[:, [0,n_cols-1]] = 5 # Edges
    n_neighbors[[0,n_cols-1], [0,n_cols-1]] = n_neighbors[[n_rows-1,0], [0,n_cols-1]] = 3 # Corners

    for i in range(n_rows):
        for j in range(n_cols):
            if (sell_month_list[i] < 0) | (sell_month_list[i] > 12) | (sell_duration_list[j] > 12):
                n_neighbors[i,j] = np.nan
        
    num_notnan_neighbors = n_neighbors - _check_nan_around_matrix(n_neighbors)
    
    return num_notnan_neighbors<jupyter_output><empty_output><jupyter_text>Backtest functions<jupyter_code>def backtest_print_plot(df, strategy, strat_name="ma_crossover", strat_params=(0,0), previous_position=0, ini_equity=ini_equity_default, commision=commision_default, figsize=(12,3.5), with_legend=False, plot=True): #(12,5)    
    df, _, strat_pnl, strat_ir, market_pnl, market_ir = backtest_strat(df, strategy, previous_position=previous_position, 
                                                                       ini_equity=ini_equity, commision=commision)
    print_backtest_stats(df, strat_pnl, strat_ir, market_pnl, market_ir, strat_name=strat_name, strat_params=strat_params) 
    if plot == True:
        show_plot(df, figsize=figsize, with_legend=with_legend)
    
    return df
"""
backtest_strat does the backtest of an strategy. It adds the following columns to the received dataframe: 
    'Strat_daily_ret', 'Market_cum_ret', 'Strat_position', 'Long_Only', 'Costs'
Ex: 
ret, last_position, strat_pnl, strat_ir = backtest_strat(df, lambda: ma_crossover(df, 75, 200))
"""
def backtest_strat(df, strategy, ini_equity=ini_equity_default, previous_position=0, commision=commision_default):    
#    first_position, df['Strat_position'], df['Long_only'] = get_strategy_position(df.copy(), strategy)
    df['Strat_position'], df['Long_only']  = strategy
    
    df = _get_returns_from_strat_position(df, ini_equity=ini_equity, previous_position=previous_position, commision=commision)
    
    # Useful for WF
    last_position = df.loc[df.index[-1], 'Strat_position']
    
    # Useful for printing and selecting best combination of parameters
    strat_pnl = ((df.loc[df.index[-1], 'Strat_cum_ret'] / ini_equity) - 1) * 100
    strat_ir = calculate_information_ratio(df['Strat_daily_ret'])

    market_pnl = ((df.loc[df.index[-1], 'Market_cum_ret'] / ini_equity) - 1) * 100
    market_ir = calculate_information_ratio(df['Market_daily_ret'])
    
    return df, last_position, strat_pnl, strat_ir, market_pnl, market_ir
def _get_cum_ret_from_daily_pct_ret(daily_returns, costs=commision_default, ini_equity=ini_equity_default):    
    if (isinstance(costs, pd.Series) == False):
        costs = pd.Series(costs, index=daily_returns.index)
    
    # Calculate cummulative returns as: cum_returns = ini_equity * CUMPROD ((1+daily_ret) * (1-costs))
    cum_returns = daily_returns.add(1).mul(1-costs).cumprod().mul(ini_equity)
    
    return cum_returns
"""
Given 'Market_daily_ret' and 'Strat_position', adds to df: 'Strat_daily_ret', 'Market_cum_ret', 'Strat_cum_ret', 'Costs'
Formula:
   - Strat_cum_ret = [(1+Market_daily_ret*Strat_position) * (1-commission_paid_pct)].cumprod() * ini_equity
"""
def _get_returns_from_strat_position(df, ini_equity=ini_equity_default, previous_position=0, commision=commision_default):
    # Previous_position only affect costs of the first day- represents the position where we come from. 
    # It will be used to determine if we change a position right before our backtest
    
    # We calculate the commision paid right before the first day, with respect to the previous position. Useful for WF analysis
    # Also the equity after such commision
    entry_commision = abs(df['Strat_position'].iloc[0] - previous_position) * commision
    equity_after_first_entry = ini_equity*(1-entry_commision)

    # commission_paid_pct will place a commision on the days that we have a change in Strat_position with respect to following day
    # Position after transactions at EOD, to have appropriate position for following day
    Strat_position = df['Strat_position'].shift(periods = -1)
    df['_commission_paid_pct'] = df['Strat_position'].sub(Strat_position) \
                                    .abs() \
                                    .mul(commision) \
                                    .fillna(0) # Commision for last day

    # Strat_cum_ret = [(1+Market_daily_ret*Strat_position) * (1 - _commission_paid_pct)].cumprod() * equity_after_first_entry
    df['Strat_cum_ret'] = df['Market_daily_ret'].mul(df['Strat_position']).add(1) \
                            .mul(1-df['_commission_paid_pct']) \
                            .cumprod() \
                            .mul(equity_after_first_entry)
    
    # Costs (in USD) = Strat_cum_ret.shift() * [[Market_Daily_ret*Strat_position] + 1] * (commission_in_pct)
    df['Costs'] = df['Strat_cum_ret'].shift(fill_value=equity_after_first_entry) \
            .mul(df['Market_daily_ret'].mul(df['Strat_position']).add(1)) \
            .mul(df['_commission_paid_pct'])
    df['Costs'].iat[0] += (ini_equity - equity_after_first_entry)
    
    df['Strat_daily_ret'] = df['Strat_cum_ret'].pct_change(fill_value=ini_equity)
    
    # Market returns
    df['Market_cum_ret'] = _get_cum_ret_from_daily_pct_ret(df['Market_daily_ret'], ini_equity=ini_equity, costs=0.0)
    
    cols = ['Close', 'Market_daily_ret', 'Strat_daily_ret', 'Strat_position', 'Costs', 'Long_only', 'Market_cum_ret', 'Strat_cum_ret']
    df = df.loc[:, cols]
    
    return df<jupyter_output><empty_output><jupyter_text>Walk-forward functions<jupyter_code>"""
Builds the cummulative return of the strategy and market between two dates.
Receives a df with the performance of several OOS periods joined in 'Strat_daily_ret'
"""
def prepare_oos_df(df, ini_equity=ini_equity_default, commision=commision_default):
    cols = ['Close', 'Market_daily_ret', 'Strat_daily_ret', 'Strat_position', 'Long_only', 'Costs']
    results_df = df[cols].copy()

    results_df = _get_returns_from_strat_position(results_df, ini_equity=ini_equity, commision=commision, previous_position=0)
    
    return results_df
def prepare_wf_info_str(param1_list, param2_list, IS_start, IS_end, OOS_start, OOS_end, memory_len):
    num_combinations = len(param1_list)*len(param1_list)
    num_is_years = round((IS_end[0]-IS_start[0]).days/365, 0)
    num_oos_years = round((OOS_end[0]-OOS_start[0]).days/365, 0)
    info_wf_str = (f'Running {num_combinations} backtests on \n'
                   f'\t{len(IS_start)} IS rolling windows of {num_is_years} years\n'
                   f'\t{len(OOS_start)} OOS rolling windows of {num_oos_years} years\n'
                   f'->Total of {num_combinations*len(IS_start)+len(OOS_start)*2} backtests'
                   f'\tMemory of {memory_len} IS periods\n'
                  )

    return info_wf_str
def print_periods(IS_start_years, IS_end_years, OOS_start_years, OOS_end_years):
    print("Number of periods: {} : {}     {} : {}".format(len(IS_start_years), len(IS_end_years), len(OOS_start_years), len(OOS_end_years)))
    print("\tIn SAMPLE\t\tOOS")
    i = 0
    for iss, ie, oi, oe in zip(IS_start_years, IS_end_years, OOS_start_years, OOS_end_years):
        if (i < 3) | (i > (len(IS_start_years) - 3)):
            print("{:%Y-%m-%d} : {:%Y-%m-%d} \t {:%Y-%m-%d} : {:%Y-%m-%d}".format(iss, ie, oi, oe))
        elif (i == 4):
            print("... ... ... ... ... ... \t ... ... ... ... ... ...")
        i += 1
"""
Checks how many nan are around each element of a matrix. Useful to see the real numbers of neighbors of each element in the matrix.
Receives a matrix of type numpy.ndarray
"""
def _check_nan_around_matrix(matrix):
    n_rows = matrix.shape[0]
    n_cols = matrix.shape[1]

    num_nan_around = np.full((n_rows, n_cols), 0)

    for i in range(n_rows):
        for j in range(n_cols):
            counter = 0
            for ii in range(i-1, i+2):
                if (ii >= 0) and (ii < n_rows):
                    for jj in range(j-1, j+2):
                        if (jj >= 0) and (jj < n_cols):
                            if np.isnan(matrix[ii, jj]) == True:
                                counter += 1
            num_nan_around[i,j] = counter

    return num_nan_around
def _get_robust_ir(strats_ir_matrix, num_neighbors_matrix):
    
    # strats_ir_matrix_neighbors = Sum of IR from neighbors / Number of neighbors
    _sum_ir_neighbors = convolve2d(strats_ir_matrix, np.ones((3,3)),'same') - strats_ir_matrix    
    _strats_ir_matrix_neighbors = np.divide(_sum_ir_neighbors, num_neighbors_matrix)
    
    # Robust_IR = (Individual SR + neighbors SR) / 2    
    strats_ir_matrix_robust = np.divide(np.add(strats_ir_matrix, _strats_ir_matrix_neighbors), 2)
    
    return strats_ir_matrix_robust
def _get_weights(strats_ir_matrix_robust, market_ir, max_weight):
    
    ####### Assign a weight to each combination of params and choose the best historical combination #######
    _num_nan = np.isnan(strats_ir_matrix_robust).sum() - 1
    
    # First we check if all IR are negative
    all_negative = True if ((np.max(np.nan_to_num(strats_ir_matrix_robust, nan = -1)) < 0) & (market_ir < 0)) else False
    
    if (all_negative == True):
        market_ir = np.abs(market_ir)
        _no_negatives_strats_ir_matrix_robust = np.abs(strats_ir_matrix_robust)
    else:
        # We exclude the negative IRs to calculate the total_ir. also market_ir if negative
        _no_negatives_strats_ir_matrix_robust = np.where(strats_ir_matrix_robust < 0, 0, strats_ir_matrix_robust)
        if (market_ir < 0): market_ir = 0 
    
    # save sum of all ir in total_ir, including market_ir
    _total_ir = np.sum(np.nan_to_num(_no_negatives_strats_ir_matrix_robust, nan=0)) + market_ir
    
    weights = np.divide(np.nan_to_num(_no_negatives_strats_ir_matrix_robust, nan=market_ir), _total_ir)
        
    #print("Sum of all Robust_IR: {:.3f}    Average (of positives): {:.3f}".format(_total_ir, _total_ir/(_no_negatives_strats_ir_matrix_robust.size-_num_nan)))
    
    # We replace values with larger weight than the constraint of max_weight
    weights = np.where(weights < max_weight, weights, max_weight)
    
    if (all_negative == True): weights = - weights
    
    return weights
"""
Receives SR of market and each tested parameter combination to return the best one. 
Best one = max((SR of each element)*50% + (average SR of its neightbors)*50%)
"""

def get_best_combination(strats_ir_matrix, market_ir, num_neighbors_matrix, allow_long_only=True):
    # Robust_ir without market_ir included
    strats_ir_matrix_robust = _get_robust_ir(strats_ir_matrix, num_neighbors_matrix)
        
    # Get index from best IR
    fast_index, slow_index = np.unravel_index(np.nanargmax(strats_ir_matrix_robust), strats_ir_matrix_robust.shape)

    if (allow_long_only == True):
        if market_ir > strats_ir_matrix_robust[fast_index, slow_index]:
            fast_index = slow_index = -1
    
        # Now we add market_ir to robust_ir matrix
        strats_ir_matrix_robust = np.nan_to_num(strats_ir_matrix_robust, nan=market_ir)

    return fast_index, slow_index, strats_ir_matrix_robust
"""
Receives SR of market and each tested parameter combination to return the best one. 
Best one = max((SR of each element)*50% + (average SR of its neightbors)*50%)
"""
# cum_weights
def get_best_combination_with_memory(strats_ir_matrix, cum_weights, memory_len, market_ir, num_neighbors_matrix, 
                                     param1_list, param2_list, allow_long_only=True):
    
    # Robust_ir of current period. without market_ir included
    strats_ir_matrix_robust = _get_robust_ir(strats_ir_matrix, num_neighbors_matrix)

    if allow_long_only == False:
        market_ir = 0
        _num_nan = np.isnan(strats_ir_matrix).sum()
    else: # allow_long_only == True:
        _num_nan = np.isnan(strats_ir_matrix).sum() - 1
    
    # weights (with market_ir included)
#     print(f"Max_weight = {10/(strats_ir_matrix.size-_num_nan)}")
    weights = _get_weights(strats_ir_matrix_robust, market_ir, max_weight=10/(strats_ir_matrix.size-_num_nan)) #Original -> 10
    
    # Get last [memory_len] weights, and add current weight to the cummulative weights. With market_ir if allow_long_only=True
    updated_cum_weights = weights if (memory_len == 0) else np.add(np.sum(cum_weights[-memory_len:], axis=0), weights)
    
    #print(np.round(np.flip(updated_cum_weights, axis=0), 4))

    # Get index from best combination in cum_weights
    param1_index, param2_index = np.unravel_index(np.argmax(updated_cum_weights), updated_cum_weights.shape)
    
    # Now we add market_ir to robust_ir matrix
    strats_ir_matrix_robust = np.nan_to_num(strats_ir_matrix_robust, nan=market_ir)
    
    
    buy_and_hold_cum_weight = Counter(updated_cum_weights.ravel()).most_common()[0][0]
    best_comb_cum_weight = updated_cum_weights[param1_index, param2_index]
    
    #print(buy_and_hold_cum_weight)
    #print(best_comb_cum_weight)
    if  best_comb_cum_weight > buy_and_hold_cum_weight:
        param1_best = param1_list[param1_index]
        param2_best = param2_list[param2_index]
    else: # If buy_and_hold performed better, we select buy_and_hold for the OOS
        param1_best = 0
        param2_best = 0

    best_combination = {'param1': param1_best, 'param2': param2_best, 'param1_index': param1_index, 'param2_index': param2_index}
    
    return best_combination, strats_ir_matrix_robust, weights
from functools import reduce

def deep_get(_dict, keys):
    return reduce(lambda d, key: d.get(key, None) if isinstance(d, dict) else None, keys, _dict)

def deeper_get(_dict, key):
    ret = []
    for _d in _dict.keys():
        ret.append(deep_get(_dict[_d], key))
        #ret.append(reduce(lambda d, key: d.get(key, None) if isinstance(d, dict) else None, keys, _d))
    return ret<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.1202049284444068, 'fraction_numerical': 0.01867219917012448, 'mean_word_length': 3.817254741994697, 'pattern_counts': {'":': 0, '<': 50, '<?xml version=': 0, '>': 49, 'https://': 2, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 11, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '7', 'dir': 'jupyter-structured-clean-dedup', 'id': '175243', 'license': 'MIT', 'n_tokens_mistral': 8740, 'n_tokens_neox': 8221, 'n_words': 1728, 'path': 'Functions.ipynb', 'repo_name': 'scastellanog/Walk-forward-optimization', 'size': '23618'}