Stylized Facts. What does it take to predict the future... | by Ke Gui | Towards Data Science

Warning: There is no magical formula or Holy Grail here, though a new world might open the door for you.
Identifying OutliersIdentifying Outliers — Part TwoIdentifying Outliers — Part ThreeStylized FactsFeature Engineering & Feature SelectionData TransformationFractionally Differentiated FeaturesData LabellingMeta-labeling and Stacking
Identifying Outliers
Identifying Outliers — Part Two
Identifying Outliers — Part Three
Stylized Facts
Feature Engineering & Feature Selection
Data Transformation
Fractionally Differentiated Features
Data Labelling
Meta-labeling and Stacking
We always say “let the data speak for themselves”. But data can either shout loud or whispering low. Some data properties are easy to spot, while others are not so obvious and buried in the noise. Like a whisper in your ear, you got to work hard to figure out what they say. Once you reveal the hidden message from data, in some circumstance you may have a chance to predict the future by looking for statistical patterns in currently available data that you believe will continue into the future. In other words, figure out a way in which the future will look very much like the present, only longer. The purpose of this article is to show you how to get those properties from data usually unnoticed but useful.
Before we start, let’s take one minute to think about a simple question of how much information we can draw from a giving set of randomly generated data. Most people probably can give a long list, like max, min, mean, mode, median, variance, standard deviation, range, etc.. Human brain can think abstractly and no other animals can do. Those are the reasons why statistics is useful because it can convert data into information that is meaningful to people. What is more, that deducted information can be utilized to extrapolate some empirical finding, so-called stylized empirical facts in finance.
The definition of stylized facts from reference 1 at the end of this article says:
“the seemingly random variations of asset prices do share some quite nontrivial statistical properties. Such properties, common across a wide range of instruments, markets and time periods are called stylized empirical facts.”
In plain language, it pretty much says if you want to use the past data to predict the future, the data in the future get to have something in common with the data in the past. Otherwise, nothing makes sense. So, these common patterns from the past and in the future in the data are called stylized facts. There is a wide range set of stylized facts in financial assets as well explained in the reference1.
With stylized facts in mind, comes another concept, stationary. For times series data, a stationary time series is one whose statistical properties such as mean, variance, autocorrelation, etc. are all constant over time. Most statistical forecasting methods are based on the assumption that the time series can be rendered approximately stationary through the use of mathematical transformations. The predictions for the stationarized series can then be “untransformed,” by reversing whatever mathematical transformations were previously used, to obtain predictions for the original series.
Ok, that is enough talking of “stylized facts” and “stationary”, let’s do some coding to illustrate those two concepts.
For consistency, in all the 📈Python for finance series, I will try to reuse the same data as much as I can. More details about data preparation can be found here, here and here.
#import all the librariesimport pandas as pdimport numpy as npimport seaborn as sns import scipy.stats as scsimport yfinance as yf  #the stock data from Yahoo Financeimport matplotlib.pyplot as plt #set the parameters for plottingplt.style.use('seaborn')plt.rcParams['figure.dpi'] = 300df = yf.download('AAPL',                 start = '2000-01-01',                 end= '2010-12-31') #download Apple stock priced1 = pd.DataFrame(df['Adj Close'])#create a df with only stock priced1.rename(columns={'Adj Close':'adj_close'}, inplace=True)d1['simple_rtn']=d1.adj_close.pct_change()#percentage returnd1['log_rtn'] = np.log(d1.adj_close/d1.adj_close.shift(1))#log return with 1 day lagd1.head()
One more thing I want to wrangle the data is to remove the outliers, I use simple mean and 2 times of standard deviation to set the boundary.
#get mean and stdmu = d1.describe().loc['mean', 'log_rtn']sigma = d1.describe().loc['std', 'log_rtn']condition = (d1['log_rtn'] > mu + sigma * 2) | (d1['log_rtn'] < mu - sigma * 2) #set the condition to be 2 times of std around meand1['outliers'] = np.where(condition, 1, 0)#like an if, else d1.head()
then, I remove all the outliers.
#using pd's bool selection to remove outliersd1_removed_outliers = d1.loc[d1['outliers'] == 0].iloc[:, :-1]d1_removed_outliers.head()
d1_removed_outliers.info()
As you can see, there are 2667 data points left out of 2765. For convenience, let’s use the d1 as the DataFrame name again.
d1 = d1_removed_outliers
Again, all the details of how to remove outliers can be found here, here and here.
The difference between the log return and simple percentage return can be found here. In short, small changes in the natural log of a variable are directly interpretable as percentage changes. In another word, percentage changes and natural log change are almost exactly the same as long as the change is small enough(within the range +/- 5%). Indeed, the number in simple_rtn and log_rtn are very close as shown in the table above.
We can check the correlation of simple_rtn and log_rtn,
#calculate the pearson correlationd1[['simple_rtn', 'log_rtn']].corr()
These two returns are highly correlated. It is even clear from the heatmap:
#draw heatmap with seabornecmap = sns.diverging_palette(220, 20, as_cmap=True)ax = sns.heatmap(corr, annot=True, cmap=cmap,                 square=True, linewidths=3,                 linecolor='w')ax.set_title('Autocorrelation Plots', fontsize=26)sns.set(font_scale=2);
One nice thing about Pandas is that it is very easy to get those descriptive statistics straight away.
d1.describe().round(4)
One of the most discussed stylized facts is the normal (Gaussian) distribution of returns. A large number of important financial models rest on the assumption that returns of stocks are normally distributed, which you will see at the end of this article, may not be the case. Therefore, the normal distribution can be considered one of the most important distribution in finance and one of the major statistical building blocks of many financial theories.
Let’s have a look at the normality of adjusted price, percentage return and natural logarithm return. First, we define a function to extract the descriptive statistics from d1.describe()
#extract all the stats from describe() functiondef extract_data_stats(col):    d_stat = col.describe()    mu = d_stat['mean']    sigma = d_stat['std']    rtn_range = np.linspace(d_stat['min'], d_stat['max'], num=1000)    norm_pdf = scs.norm.pdf(rtn_range, loc=mu, scale=sigma)        return mu, sigma, rtn_range, norm_pdf
With the mean, standard deviation and normal Probability Density Function (PDF) ready, we can plot the histogram and PDF.
#draw the histogram with Probability Density Functiondef draw_hist(col, xlim=(-0.2, 0.2)):    mu, sigma, rtn_range, norm_pdf = extract_data_stats(col)    sns.distplot(col, kde=True, norm_hist=True, label='Hist')       plt.plot(rtn_range, norm_pdf, 'r', lw=3,              label=f'N({mu:.3f}, {sigma**2:.4f})')    plt.axvline(x=0, c='c',linestyle='--', lw=3)    plt.title(f'Distribution of {col.name}', fontsize=24)    plt.xlim(xlim)    plt.legend(loc='upper right', fontsize=20,                frameon=True,fancybox=True,                framealpha=1, shadow=True, borderpad=1);
The histogram and PDF of percentage return.
draw_hist(d1.simple_rtn)
The histogram and PDF of natural logarithm return.
draw_hist(d1.log_rtn)
The histogram and PDF of adjusted price.
draw_hist(d1.adj_close,xlim=(-10,50))
Clearly, the stock price has trend or cycles which cause it far from a normal distribution. Whereas, the log return and percentage return are very similar and close to normal. But can the normality be tested?
In case your plot may look different, here are the matplotlib parameters I used:
plt.rcParams['figure.figsize'] = [16, 9]plt.rcParams['figure.dpi'] = 300plt.rcParams['font.size'] = 20plt.rcParams['axes.labelsize'] = 20plt.rcParams['axes.titlesize'] = 24plt.rcParams['xtick.labelsize'] = 16plt.rcParams['ytick.labelsize'] = 16plt.rcParams['font.family'] = 'serif'
more about matplotlib, please go to 👉Everything about plotting in Python.
There is one stylized fact from reference1 at the end of the article related to the normality, which says:
“4. Aggregational Gaussianity: as one increases the time scale over which returns are calculated, their distribution looks more and more like a normal distribution. In particular, the shape of the distribution is not the same at different time scales.”
Let’s see does this hold water. We create a new DataFrame to hold all the lagged stock price returns.
#get 5 days lagged return by a for loopdf_simple_rtn = pd.DataFrame(d1['adj_close'])lags = 5for lag in range(1, lags+1):    col = f'lag_{lag}_simple_rtn'    df_simple_rtn[col] = df_simple_rtn['adj_close']. \                         pct_change(periods=lag)    df_simple_rtn.dropna(inplace=True)df_simple_rtn.head()
#get 5 days lagged return by a for loopdf_log_rtn = pd.DataFrame(d1['adj_close'])lags = 5for lag in range(1, lags+1):    col = f'lag_{lag}_log_rtn'    df_log_rtn[col] = np.log(df_log_rtn['adj_close']/\    df_log_rtn['adj_close'].shift(lag))                    df_log_rtn.dropna(inplace=True)df_log_rtn.head()
we can plot the histogram and PDF by modifying the previous function draw_hist()to give it extra ability to draw multiple plots at once.
#using ax to draw multi-grahpsdef draw_hist_multi(col, xlim=(-0.2, 0.2), ax=None):    mu, sigma, rtn_range, norm_pdf = extract_data_stats(col)    sns.distplot(col, kde=True, norm_hist=True, \                 label='Hist', ax=ax)       ax.plot(rtn_range, norm_pdf, 'r', lw=3, \            label=f'N({mu:.3f}, {sigma**2:.4f})')    ax.axvline(x=0, c='c',linestyle='--', lw=3)  #adj_close x axis range is wider     if ( col.name == 'adj_close'):        ax.set_xlim(-10,50)    else:        ax.set_xlim(xlim)    ax.legend(loc='upper right', fontsize=8,               frameon=True,fancybox=True);
You may notice here, I use ax instead of plt to draw the plots, the reason is well explained in 👉Everything about plotting in Python.
Now we can put all plots in a single figure with 2 X 3 subplots.
#create subplots figure with each plot drawed by draw_hist_multi() def draw_hist_subplots(df):    fig, axs = plt.subplots(nrows=2, ncols=3, figsize=(16,9))    fig.subplots_adjust(hspace = .5) #wspace=.001    fig.suptitle('Distribution of returns with increased \                 time scale', fontsize=24)                    axs = axs.ravel()    for i, col in enumerate(df.columns):        draw_hist_multi(df[col], ax=axs[i])
The key to draw multi-subplots in a loop is to use axs = axs.ravel(), by flatting out the subplots matrix, we can go through all the subplots one by one.
draw_hist_subplots(df_simple_rtn)
In terms of natural logarithm return, as expected, there is no obvious difference.
draw_hist_subplots(df_log_rtn)
From both lagged log and percentage returns, indeed, as one increases the time scale over which returns are calculated, their distribution looks more and more like a normal distribution. In particular, the shape of the distribution is not the same at different time scales.
From the graphs above, it is actually hard to tell the normality, even though the trend is obvious. By utilizing the scipy.stats package, we can do a more rigorous normality test with descriptive statistics.
Before we start to do the normality test, there are two concepts we need to know. Skewness, in statistics, is the degree of distortion from the symmetrical bell curve in a probability distribution. Whereas The kurtosis is to measure the peakiness and flatness of a distribution. High kurtosis and skewness of the return distribution imply that the investor will experience occasional extreme returns (either positive or negative).
For normally distributed data, the skewness should be about zero. A skewness value greater than zero means that there is more weight in the right tail of the distribution, vice versa.
#using DataFrame to contain all the statsdef build_stats(df):    stats = pd.DataFrame({'skew':scs.skew(df),                 'skew_test':scs.skewtest(df)[1],                 'kurtosis': scs.kurtosis(df),                 'kurtosis_test' : scs.kurtosistest(df)[1],                 'normal_test' : scs.normaltest(df)[1]},                  index = df.columns)    return stats
for percentage return
build_stats(df_simple_rtn)
for log return
build_stats(df_log_rtn)
All p-values are well below 0.05 mark and close to zero. A statistically significant test result ( p-value ≤ 0.05) means that the test hypothesis is false or should be rejected. A p-value is greater than 0.05 means that no effect was observed. With a p-value of zero, we have to reject the null hypothesis that the return of sample data has skewness and kurtosis matching those of a Gaussian distribution.
One thing I noticed is that the absolute value of skewness and kurtosis of log return actually larger than those of percentage return. After removed outliers, the mean is getting larger, the standard deviation is getting smaller and there is no change for skewness and kurtosis.
Before the end of this article, I want to have a quick look at the correlation of lagged returns, as claimed in reference1 down below.
“1. Absence of autocorrelations: (linear) autocorrelation of asset returns are often insignificant, except for very small intraday time scales (20 minutes) for which microstructure effects come into play.”
In terms of correlations, there are 3 different types, cross-correlation, autocorrelation and circus-correlation.
The cross-correlation of percentage returns are
corr_s = df_simple_rtn.corr()corr_s
and correlation of log return
corr_l = df_log_rtn.corr()corr_l
The cross-correlations are trailing off with time for both percentage returns and log returns. As illustrated in the heatmap.
#using seaborne to draw heatmapsns.set(style="white")cmap = sns.diverging_palette(220, 20, as_cmap=True)ax = sns.heatmap(corr_s, annot=True, cmap=cmap,                 square=True, linewidths=3,                 linecolor='w')ax.set_title('Correlation Plots', fontsize=32)ax.set_xticklabels(    ax.get_xticklabels(),    rotation=45,    horizontalalignment='right');
#using seaborne to draw heatmapsns.set(style="white")cmap = sns.diverging_palette(220, 20, as_cmap=True)ax = sns.heatmap(corr_l, annot=True, cmap=cmap,                 square=True, linewidths=3,                 linecolor='w')ax.set_title('Correlation Plots', fontsize=32)ax.set_xticklabels(    ax.get_xticklabels(),    rotation=45,    horizontalalignment='right');
Most of the correlation of return for a day lag (before or after) are correlated as their coefficiency are over 0.8. But the correlation fades away very quickly.
I am still having trouble to fully understand autocorrelation, even though to plot the autocorrelation is very easy.
#draw autocorrelation for log return with 50 days time lagfrom statsmodels.graphics.tsaplots import plot_acffig, ax = plt.subplots()acf = plot_acf(d1.log_rtn, lags=50, ax=ax) ax.set_xlabel('lags')                          ax.set_ylabel('auto-correlation');
I would really appreciate it if you could shed some light on the auto-correlation, cross-correlation and partial auto-correlation and leave your comments below, thanks.
There is one big question now, how to transfer your return to a normal distribution? Stay tuned for more, coming soon!
Cont, R., Empirical properties of asset returns: stylized facts and statistical issues. Quantitative Finance 2001, 1 (2), 223–236.
Cont, R., Empirical properties of asset returns: stylized facts and statistical issues. Quantitative Finance 2001, 1 (2), 223–236.