Skip to main content

Filing event study

This recipe measures a company's excess return over its sector ETF in a window around each 10-K and 10-Q filing, and compares it with the same window on every trading day. The event time is each filing's acceptance (accepted_at). It uses the company's profile, the filings index and price history.

python
import bisect
import datetime
import os
import zoneinfo

import httpx

api = httpx.Client(base_url="https://api.tauric.ai",
headers={"Authorization": f"Bearer {os.environ['TAURIC_API_KEY']}"}, timeout=30)
NEW_YORK = zoneinfo.ZoneInfo("America/New_York")
SYMBOL, SINCE = "AAPL", "2018-01-01"


def closes(symbol):
out, params = {}, {"period": "1y"}
while params.get("end", SINCE) >= SINCE:
page = api.get(f"/v1/securities/{symbol}/history", params=params).raise_for_status().json()
out.update({bar["date"]: bar["close"] for bar in page["bars"]})
params["end"] = page["coverage"]["continue_end"]
return out


def excess(first):
before, after = sessions[first - 1], sessions[first + 2]
return stock[after] / stock[before] - bench[after] / bench[before]


sector = api.get(f"/v1/securities/{SYMBOL}/profile").raise_for_status().json()["sector_key"]
sectors = api.get("/v1/securities/sectors").raise_for_status().json()["data"]
etf = next(row["proxy_etf"] for row in sectors if row["key"] == sector).removeprefix("security:")
filings = api.get(f"/v1/securities/{SYMBOL}/filings",
params={"form": "10-K,10-Q", "limit": 50}).raise_for_status().json()["data"]

stock, bench = closes(SYMBOL), closes(etf)
sessions = sorted(date for date in stock.keys() & bench.keys() if date >= SINCE)

events = []
for filing in filings:
if filing["filed"] < SINCE:
continue
accepted = datetime.datetime.fromisoformat(filing["accepted_at"]).astimezone(NEW_YORK)
day = accepted.date().isoformat()
first = bisect.bisect_right(sessions, day) if accepted.hour >= 16 else bisect.bisect_left(sessions, day)
events.append(excess(first))
print(f"{filing['form']:5} {sessions[first]} {events[-1]:+7.2%}")

every = [excess(i) for i in range(1, len(sessions) - 2)]
print(f"{len(events)} filings: mean absolute {sum(map(abs, events)) / len(events):.2%}")
print(f"{len(every)} trading days: mean absolute {sum(map(abs, every)) / len(every):.2%}")

The profile names the company's sector, and the sector list gives the ETF that measures it. A large company is a large holding of its sector ETF, which moves the excess return toward zero; a broad index ETF as the benchmark lowers the company's weight in it.

The filings index lists filings newest first. A history read returns one year, and coverage.continue_end ends the next older year, so the script pages back until it passes the start date (history). The closes are adjusted closes, so splits and cash dividends do not move a return.

The excess return is the stock's return from the close before the event day to the close two trading days after it, less the ETF's return over the same window. The event day of a filing accepted after the 16:00 New York close is the next trading day; for one accepted earlier, the same day. An earnings release that comes out the evening before the filing, or the same day, falls inside the window. The mean absolute excess return over every trading day is the baseline: a filing window well above it carries news.