Extreme values: real events or artefacts?

Forty-six daily moves over 25% in a file of 619,040 rows. How to tell a bad print from a split from a real event by looking at two of them, and the three policies available once you have decided.

Updated 21 Sept 20263 code blocks verifiedoutlierssplitscorporate-actionsdata-qualityranks

Every financial file has a tail of moves that are too large to be ordinary. Some are real: a drug trial, an earnings shock, a takeover. Some are artefacts: an unadjusted split, a spin-off, a mispunched price. There is never time to check every one, but there is always time to check two, and two is enough to choose a policy.

Find them

import pandas as pd, numpy as np

df = pd.read_csv("all_stocks_5yr.csv")
df["date"] = pd.to_datetime(df["date"])
df = df.rename(columns={"Name": "ticker"}).sort_values(["ticker", "date"]).reset_index(drop=True)
g = df.groupby("ticker")
df["ret"] = g["close"].pct_change()
df["prev_close"] = g["close"].shift(1)
df["next_ret"] = g["ret"].shift(-1)

big = df[df["ret"].abs() > 0.25].sort_values("ret")
print(len(big), "rows with |return| > 25%")
print(big[["date", "ticker", "prev_close", "close", "ret", "next_ret", "volume"]].head(6).to_string(index=False))
print(big[["date", "ticker", "prev_close", "close", "ret", "next_ret", "volume"]].tail(4).to_string(index=False))
46 rows with |return| > 25%
      date ticker  prev_close  close       ret  next_ret   volume
2015-07-02     NI       45.45 16.990 -0.626183 -0.008829 16010299
2015-07-20   EBAY       66.29 28.570 -0.569015  0.001050 37485739
2014-08-07  DISCK       81.22 39.780 -0.510219  0.029663  2670657
2014-08-07  DISCA       83.02 40.975 -0.506444  0.031849  3850670
2016-05-19    LNT       35.35 17.870 -0.494484  1.009513  1231722
2015-07-01    BAX       69.93 38.860 -0.444301 -0.014411 24435196
      date ticker  prev_close  close      ret  next_ret    volume
2014-06-24   VRTX       66.61  93.53 0.404144 -0.039239  25236837
2016-04-22    AMD        2.62   3.99 0.522901 -0.135338 143265305
2013-04-19   VRTX       52.87  85.60 0.619066 -0.038201  24894974
2016-05-20    LNT       17.87  35.91 1.009513 -0.004456    997954

Three signatures

Reading the columns above answers most cases without leaving the table.

SignatureWhat it isExample above
A large move that reverses almost exactly the next daya bad print: one wrong close−49% then +101% the next day on the same ticker
A permanent level shift with a round ratio (½, ⅓, 1/7) and an ordinary next dayan unadjusted split or stock dividendclose almost exactly halves on two share classes of one company, same day
A permanent level shift with a non-round ratio and a volume spikea spin-off (the parent gives up part of its value), or a real eventclose falls to 37% of the previous day, next day flat, volume four times the days before

A real earnings or trial move usually has the third signature with news attached; a spin-off has it without. Both are “real” in the sense that a holder experienced them, but a spin-off holder also received shares in the new company, so the close-to-close return overstates the loss.

The ratio test

For splits, the ratio of the new close to the previous close clusters at simple fractions.

big = big.assign(ratio=(big["close"] / big["prev_close"]).round(3))
print(big.loc[big["ret"] < 0, ["ticker", "date", "ratio", "next_ret"]].sort_values("ratio").head(8).to_string(index=False))
ticker       date  ratio  next_ret
    NI 2015-07-02  0.374 -0.008829
  EBAY 2015-07-20  0.431  0.001050
 DISCK 2014-08-07  0.490  0.029663
 DISCA 2014-08-07  0.494  0.031849
   LNT 2016-05-19  0.506  1.009513
   BAX 2015-07-01  0.556 -0.014411
  BHGE 2017-07-05  0.646 -0.006443
   WMB 2016-02-08  0.652  0.073477

Values near 0.5 with an ordinary next day are two-for-one splits or stock dividends. Values that reverse next day are prints. Everything else needs a second look or gets the default policy.

Two by eye

Print a window of a few days around the two most extreme rows and read them.

for _, row in pd.concat([big.head(1), big.tail(1)]).iterrows():
    tk, dt = row["ticker"], row["date"]
    w = df[(df["ticker"] == tk) & (df["date"].between(dt - pd.Timedelta(days=6), dt + pd.Timedelta(days=6)))]
    print("\n%s around %s" % (tk, dt.date()))
    print(w[["date", "close", "volume", "ret"]].to_string(index=False))
NI around 2015-07-02
      date  close   volume       ret
2015-06-26  46.25  2947353 -0.006231
2015-06-29  45.95  2179037 -0.006486
2015-06-30  45.59  2910080 -0.007835
2015-07-01  45.45  3740554 -0.003071
2015-07-02  16.99 16010299 -0.626183
2015-07-06  16.84 11162984 -0.008829
2015-07-07  17.12 10865443  0.016627
2015-07-08  17.14  7037833  0.001168

LNT around 2016-05-20
      date  close  volume       ret
2016-05-16 36.985  806028  0.002711
2016-05-17 35.990 1356858 -0.026903
2016-05-18 35.350 1061648 -0.017783
2016-05-19 17.870 1231722 -0.494484
2016-05-20 35.910  997954  1.009513
2016-05-23 35.750 1090320 -0.004456
2016-05-24 36.390 1143051  0.017902
2016-05-25 36.430 1705007  0.001099
2016-05-26 36.730 1300423  0.008235

The first is a permanent shift to 37% of the previous close with volume four to five times the days before and no rebound: an unadjusted corporate action (the company separated a large business that month; a holder received shares in the new entity, so the close-to-close return overstates the loss). The second is one wrong close, down 49% and back up 101% the next day, with ordinary volume throughout: a bad print. Neither is a return anyone experienced.

Three policies

  1. Drop the rows. Simple, honest if said, but loses the real events along with the artefacts, and a “buy the losers” study loses precisely the rows where it might matter.
  2. Winsorise. Clip returns to, say, ±20%. Keeps the rows, caps their influence, changes the numbers in a way that needs a footnote.
  3. Use ranks. Score with Spearman correlation and decile buckets, so that the largest move counts as “largest” and no more. Keeps everything, changes nothing else, and is the policy used throughout this site. See Pearson vs Spearman.

Whichever policy is chosen, the sensitivity check is to run the final number with the extreme rows removed and show it did not move. For the reversal signal on the hold-out it did not: IC +0.0156 with them, +0.0156 without. See attacking your own result.

What to write down

One line: “46 rows with |return| > 25%; the two largest inspected: one bad print, one unadjusted corporate action; kept, neutralised by ranks; result unchanged without them.” That line goes on the data slide.