earnings-session
Earnings calendars tag each release BMO (before the market opens) or AMC (after it closes). When the tag is wrong, or missing and treated as BMO, a backtest that buys at the open buys before the news is out, and the return it books is look-ahead. earnings-session checks the tag against SEC EDGAR. It finds the company's earnings 8-K, reads the time EDGAR accepted it, and tells you which trading session that time falls in and whether your backtest entered before it.
I wrote it after running this check by hand on my own post-earnings-drift backtest. About half of the backtest's trades came from events with no timing label, and the backtest entered them at the open as if they were BMO. In a sample of 95 of those events with an earnings 8-K, 59% of the 8-Ks were accepted at or after the close of the entry day. Once every event was moved to the session its 8-K supports, net alpha per trade fell from 1.21% to about 0.25%. The full write-up is on dev.to, with a Chinese version on Zhihu.
The second check, bars, is about prices. A backtest that takes "the first bar on or after the entry date" with no limit on the distance prices a trade from whatever bar comes next, months or years later when the ticker has no history around that date, and nothing raises an error. I found this in a monthly 10-K text-signal backtest of mine: 719 of 3,282 name-months (21.9%) were priced from bars outside their month, and for 34 tickers the price table only started in December 2025, so 2026 prices had been booked into 2019-2025. bars reads your trade list and price table, measures how far each bar sits from its target in NYSE trading days, and flags the rows to look at. It runs offline.
Install
pip install earnings-session
Python 3.10 or later. SEC asks every automated client to say who it is, so set a User-Agent with your name and a contact address before the first call:
export EARNINGS_SESSION_USER_AGENT="Your Name you@your-domain.com"
or pass --user-agent on each command. Without one the tool exits and prints the format SEC expects. bars never goes online and doesn't need one.
Use
One event
earnings-session classify --ticker OSPN --date 2019-10-29 --label bmo
{
"ticker": "OSPN",
"event_date": "2019-10-29",
"vendor_label": "bmo",
"entry_used": "2019-10-29",
"rule": "item_2.02",
"confidence": "high",
"cik": 1044777,
"company": "OneSpan Inc.",
"form": "8-K",
"accession": "0001558370-19-009352",
"accepted_et": "2019-10-29T20:27:07-04:00",
"api_value": "2019-10-30T00:27:07.000Z",
"zone_convention": "utc",
"session": "post_close",
"verdict": "after_entry",
"edgar_label": "amc",
"label_agrees": false,
"notes": ["..."]
}
(Output shortened.) The release was labelled BMO, but its 8-K was accepted at 20:27 ET. A backtest that bought at the 2019-10-29 open bought eleven hours before the filing; the stock opened 21% higher the next morning.
--entry-date sets the backtest entry (the position is opened at that day's open). Without it, the entry follows the label: the event day's open for bmo and dmh, the next trading day's open for amc, and the event day's open when there is no label.
A file of events
earnings-session batch events.csv timed.csv
Input columns: ticker, event_date (YYYY-MM-DD), and optionally entry_date and vendor_label (bmo, amc, dmh; anything else counts as no label). The output keeps the input columns and adds:
| column | meaning |
|---|---|
cik, company, form, accession |
the filing used |
accepted_et |
EDGAR acceptance time in US/Eastern, from the filing's index page |
api_value |
acceptanceDateTime as the submissions API gave it |
zone_convention |
utc, et_as_utc (Eastern time labelled Z), mismatch, or unverified (no index page) |
session, session_date |
pre_open, intraday or post_close, and the trading day it refers to |
edgar_label |
what the filing supports relative to the event date: bmo, dmh, amc, prior_day, later_day |
label_agrees |
edgar_label equals the vendor label |
entry_used, verdict |
the entry open compared against, and before_entry, entry_session or after_entry |
rule, confidence |
how the filing was found (item_2.02, other_8k, periodic, 6k_proxy) and high / medium / low |
reason |
why an event stayed unresolved (no_cik, fts_ambiguous, fts_unconfirmed, no_filing_in_window, ...) |
notes |
anything a reviewer should look at |
Audit a vendor's labels
earnings-session audit timed.csv # text
earnings-session audit timed.csv --json # the same report as JSON
== 8-K Item 2.02: n = 5 ==
vendor label x EDGAR acceptance session (relative to the event date)
bmo dmh amc prior_day later_day
bmo 0 0 2 0 0
amc 0 0 2 0 0
(none) 0 0 1 0 0
verdict vs entry open (Wilson 95%)
before_entry 2/5 = 40.0% (11.8%-76.9%)
entry_session 0/5 = 0.0% (0.0%-43.4%)
after_entry 3/5 = 60.0% (23.1%-88.2%)
by vendor label
bmo agrees 0/2 = 0.0% (0.0%-65.8%); after_entry 2/2 = 100.0% (34.2%-100.0%)
amc agrees 2/2 = 100.0% (34.2%-100.0%); after_entry 0/2 = 0.0% (0.0%-65.8%)
Rows found through other 8-Ks, 10-Qs or 10-Ks, and 6-K proxies, are reported in their own blocks and never mixed into the Item 2.02 numbers. If the input has no verdict column, audit classifies it first.
Check the price bars
earnings-session bars trades.csv --prices prices.csv -o checked.csv
trades.csv needs ticker and entry_date; exit_date and return are optional, and other columns are carried through. prices.csv is the price table your backtest read, with ticker and date columns (the rest is ignored). The entry bar is the first one on or after entry_date and the exit bar the last one on or before exit_date, which is how most backtests look them up; --entry-side and --exit-side turn either one around.
trades: 5
bars from prices.csv: 3532 rows, 3 tickers
entry = first bar on or after entry_date; exit = last bar on or before exit_date; max gap 0 trading days; history break > 250
flags (Wilson 95%)
history_break 1/5 = 20.0% (3.6%-62.4%)
no_bar 1/5 = 20.0% (3.6%-62.4%)
entry_gap 1/5 = 20.0% (3.6%-62.4%)
ok 2/5 = 40.0% (11.8%-76.9%)
distance from target, trading days
entry 0: 2, 1: 0, 2-5: 1, 6-20: 0, 21-250: 0, >250: 1, no bar: 1
exit 0: 3, 1: 0, 2-5: 0, 6-20: 0, 21-250: 0, >250: 0, no bar: 2
returns (the 'return' column)
flagged n 3, mean 0.038, sum 0.114
ok n 2, mean 0.008, sum 0.016
flagged rows carry 87.7% of the total
Each row gets one flag, the most serious that applies:
| flag | meaning |
|---|---|
early_bar |
the entry bar the backtest recorded is before entry_date: the trade was priced before the day it claims |
history_break |
a bar more than 250 trading days (--break-days) from its target: another stretch of history, maybe another company on a reused ticker |
no_bar |
the ticker isn't in the price table, or there is no bar in that direction |
entry_gap, exit_gap |
a bar more than --max-gap trading days (default 0) from its target |
ok |
none of the above |
checked.csv keeps your columns and adds, for each leg, the target day, the bar found, and the distance in trading days and calendar days, then the flag and notes. If your trade file already records the bars the backtest used (entry_bar_date, exit_bar_date), leave out --prices and those are checked instead; that is the only way to catch early_bar. --fail-on-flag exits 1 when any row is flagged, so the check can run in CI.
In the example, three flagged trades out of five carry 88% of the summed return. When the flagged rows carry the result, the backtest is measuring its own lookup.
If you build returns with pandas.merge_asof, pass tolerance. Without it the join takes the nearest bar at any distance.
From Python
from earnings_session import Classifier
with Classifier() as c: # reads EARNINGS_SESSION_USER_AGENT
r = c.classify("OSPN", "2019-10-29", label="bmo")
print(r.session, r.verdict, r.accepted_et) # post_close after_entry 2019-10-29 20:27:07-04:00
r.to_dict() # the JSON above
The CLI calls the same Classifier.classify. For a single call there is also earnings_session.classify(...).
from datetime import date
from earnings_session import find_bar
bars = [date(2025, 12, 8), date(2025, 12, 9)] # a ticker whose history starts late
find_bar(bars, date(2019, 6, 28)) # date(2025, 12, 8): six years late
find_bar(bars, date(2019, 6, 28), max_gap_td=5) # None: no bar within 5 trading days
earnings_session.check_trades runs the full bars check on a list of trades.
How it decides
- Issuer. The ticker is looked up in SEC's current ticker list. Tickers that are no longer listed (delisted, renamed, acquired) go through EDGAR full-text search, and a candidate is accepted only with evidence that it really is that ticker's issuer. Otherwise the event stays unresolved, with the reason.
- Window. Filings accepted (by US/Eastern date) from the trading day before the event date to the trading day after the entry date, or after the event date when no entry date is given.
- Filing. The original 8-K with Item 2.02 nearest the event date; 8-K/A amendments are skipped. Without one: the nearest other 8-K, then 10-Q or 10-K. A foreign issuer that files only 6-Ks gets its nearest 6-K, marked low confidence.
- Time. The "Accepted" time on the filing's index page, which is US/Eastern. The API's
acceptanceDateTimeis kept next to it. - Session. Before 09:30 ET is
pre_open, 09:30 to the close isintraday, the close and later ispost_close. The close is 16:00, or 13:00 on early-close days. Weekend and holiday acceptances count aspost_closeof the previous trading day. The calendar is NYSE's (exchange_calendarsXNYS), so Columbus Day and Veterans Day are trading days. - Verdict.
before_entryif accepted before the entry-day open,entry_sessionif during the entry-day session,after_entryif at the close or later.
METHOD.md has the full rules and the reasons behind them.
Limits
- An 8-K acceptance is an upper bound on the release time. A company can put out its press release first and file the 8-K later, never the other way round. So
before_entryis conclusive: the news was public when you bought.after_entrymeans look-ahead unless the release came a full session before its 8-K. - A late 8-K makes a real pre-open release look like intraday or post-close. In my check of 39 events labelled BMO, 34 had their 8-K before the entry open, 2 were evening releases the label got wrong, and 3 had an 8-K the next morning (a late filing or a wrong date; EDGAR alone can't tell which).
- A 6-K has no item code. For foreign issuers the tool takes the 6-K nearest the event date, which may be a different announcement. These rows are
lowconfidence and audited separately. - Tickers get reused. SEC's ticker list gives today's owner of a ticker. When the window's filings name a different company under that ticker, that company is tried first and the row says so in
notes. A ticker the issuer used only in the past can be missed if its press releases never print it next to an exchange name. Checkcompanywhen it matters. - Other 8-Ks, 10-Qs and 10-Ks are looser bounds. Small issuers sometimes announce results without an Item 2.02. The fallback filing may come days after the release, so these rows are
mediumorlowconfidence. - The API's time zone is not stable. In September 2026, 43 of 228
acceptanceDateTimevalues I checked were US/Eastern wall-clock times labelled "Z", all from issuers no longer in SEC's ticker list. Read as UTC, they put a 16:18 filing at 12:18. On 2026-10-27 I rechecked seven of them, and all seven came back as real UTC. The tool reads the index page either way and records which convention the API used. - The window is narrow. A vendor date off by more than one trading day finds no filing, and the event comes back
no_filing_in_window. barschecks dates, not prices. A bar on the right day with a wrong price passes, and a real trading halt shows up as a gap. METHOD.md section 10 has the rules.- Calendar. Early closes are the ones
exchange_calendarslists. It has no early close on 2026-07-02, and published holiday tables disagree on that day. Dates must fall between 1995 and about a year from today.
Roadmap
- Price-reaction timing for events EDGAR can't place: compare the event-day open gap and volume with the next day's, calibrated on EDGAR-timed events. Calling a release BMO when it was AMC brings back look-ahead, so the rule only says BMO when out-of-sample precision clears a Wilson lower bound of 0.95. On my data no threshold cleared it, which is why it isn't in v0.1.
SEC access
Default 5 requests per second across www.sec.gov, data.sec.gov and efts.sec.gov (SEC's limit is 10; --max-rps changes it). Rate-limit and server errors are retried with back-off; a 403 stops the run. Responses are cached in ~/.cache/earnings-session (or $XDG_CACHE_HOME/earnings-session, or --cache-dir). The ticker list, submissions and search results are refetched after 24 hours; filing index pages are kept. --no-cache turns the cache off. Expect about 3 requests per event; the ticker list is fetched once per run.
Development
pip install -e ".[dev]"
ruff check src tests scripts && ruff format --check src tests scripts
mypy --strict
pytest # offline, uses recorded SEC responses in tests/fixtures
pytest -m network # live checks against sec.gov; needs EARNINGS_SESSION_USER_AGENT
scripts/record_fixtures.py re-records the fixtures. SEC EDGAR data is in the public domain.
License
MIT
Metadata
Release files for earnings-session 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| earnings_session-0.2.0.tar.gz | 63.3 kB | With provenance | Details for earnings_session-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| earnings_session-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for earnings_session-0.2.0-py3-none-any.whl |
Total release size: 100.9 kB
Release files / earnings_session-0.2.0.tar.gz
| Download URL | earnings_session-0.2.0.tar.gz |
|---|---|
| Size | 63.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3a142665f5ec0e710118617b86d5410b4ff82e1adb4bf5689d9d2e765e1cfca9
|
|
BLAKE2b-256 checksum How to use checksums |
6260ee9b344b3813d0ca95d0c3a48a0c40a8506cb46038a4a267cbb9965e2ac2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logRelease files / earnings_session-0.2.0-py3-none-any.whl
| Download URL | earnings_session-0.2.0-py3-none-any.whl |
|---|---|
| Size | 37.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4032053295568bf7dc5281931335c7708ccbff5e712013ca70c52d95e2057bdb
|
|
BLAKE2b-256 checksum How to use checksums |
b2e8c23a33d1f26223b485260e037bb3831b521416c0125a23ec952cfd2eba5c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logSecurity for earnings-session 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for earnings_session-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.