Point-in-time fundamentals: filing dates, acceptance times and restatements
A fundamentals backtest needs one timestamp for every number: the moment it became public. Most datasets offer two stand-ins. The fiscal period end is weeks too early. The filing date looks right, but in a daily backtest that trades at the close it's often a few hours too early, because many 10-Ks and 10-Qs reach the SEC after the market has closed and still carry that day's date.
This page shows how large both gaps were in SEC EDGAR data for 20 large US companies, and how to join fundamentals on the time a filing was accepted instead. The code runs on simulated filings and makes no network calls.
The measured figures below describe when filings became public in that sample. They say nothing about any strategy's returns. Everything the code computes is hypothetical and comes from simulated data.
What we measured, and on what
We pulled the 10-K and 10-Q filings of 20 large US filers from SEC EDGAR on October 17, 2026: Apple, Microsoft, NVIDIA, Amazon, Alphabet, Meta, Berkshire Hathaway, JPMorgan Chase, Visa, Johnson & Johnson, Walmart, Exxon Mobil, Procter & Gamble, UnitedHealth, Home Depot, Coca-Cola, Pfizer, Intel, Cisco and GE. That's 1,332 filings from mid-2009 to 2026. Each filing is timed by the acceptance time in its SEC header (more on that below).
These are 20 large US filers we checked; your universe may differ. These companies mostly filed well inside their deadlines, and smaller companies have longer deadlines. We didn't measure small caps, foreign filers or banks that file only with their bank regulator.
Filing date is not a timestamp
EDGAR accepts filings from 6 a.m. to 10 p.m. Eastern on business days (EDGAR Filer Manual, Volume I, section 2). Under Rule 13(a)(2) of Regulation S-T, a filing submitted no later than 5:30 p.m. is deemed filed that business day, and one submitted after 5:30 p.m. is deemed filed the next business day (summarized in SEC Release 33-11159, page 3). So a filing dated D can have been accepted at any time up to 5:30 p.m. on D, an hour and a half after the 4 p.m. close.
In our sample, that window is where most filings landed:
| Accepted (Eastern) | Share of 1,332 filings |
|---|---|
| Before 09:30 | 9.7% |
| 09:30–16:00 (market hours) | 12.3% |
| 16:00–17:30 (after the close, dated that day) | 56.1% |
| After 17:30 (dated the next business day) | 21.9% |
99.0% of the after-17:30 filings carried the next business day's date, but not all of them: a few filings accepted late in the evening are still dated that day.
Now take the usual join: on day D, use every filing with filing_date <= D, and trade at D's 4 p.m. close. In our sample that join used 56.3% of filings before they were public: every filing accepted after the close and still dated D. That's the 56.1% accepted between 16:00 and 17:30, plus the few after-17:30 filings dated the same day. Again, that's 20 large US filers we checked; your universe may differ.
The leak is only one day per filing, which is why it's easy to miss. But it's the day that matters. The backtest decides at D's close on numbers released after that close, so it's credited with the move from D's close onward, which is when the market can first react to the filing.
Two ways out:
- Trade at the next session's open instead. A filing dated D was accepted by 10 p.m. on D at the latest, before the next open (holidays aside). You give up a day.
- Key the data on the acceptance time and join it to the decision time. That's what the code below does.
Period end is weeks too early
A dataset keyed by fiscal period end (for example "Q1, ending March 31") makes every value available on the day the quarter ends. In our sample, the first reported net income for each period became public this many days later (fiscal periods ending 2011 or later):
| Form | Values | 10th percentile | Median | 90th percentile | Max |
|---|---|---|---|---|---|
| 10-Q | 916 | 23 days | 29 days | 37 days | 42 days |
| 10-K | 302 | 30 days | 45 days | 58 days | 62 days |
A common patch is a fixed reporting lag. Here's the share of those values that a fixed lag would still have released before they were public:
| Lag | Share still too early |
|---|---|
| 10-Q, 30 days | 44.2% |
| 10-Q, 40 days | 0.5% |
| 10-Q, 45 days | 0.0% |
| 10-K, 60 days | 1.0% |
| 10-K, 90 days | 0.0% |
These companies are large accelerated filers, so their deadlines are 40 days after the quarter for a 10-Q and 60 days after the year for a 10-K. Accelerated filers have 40 days for a 10-Q and 75 days for a 10-K, and other registrants have 45 and 90 days (Form 10-Q and Form 10-K general instructions, section A). They tend to file later, so a lag that looks long enough here may not be for them. We didn't measure that.
A lag also fixes only the day, not the hour: a lag ending on the filing date has the same after-the-close problem as above. And many companies publish headline numbers in an earnings release before the 10-Q or 10-K. Timing values by the 10-Q or 10-K is conservative for those numbers (late, not early).
As-of joins on simulated filings
The setup: trading sessions with a 4 p.m. Eastern close, and simulated 10-Q and 10-K filings for 30 made-up companies. Acceptance times are drawn with roughly the time-of-day shares in the table above, and the filing date follows EDGAR's 5:30 p.m. rule.
import numpy as np
import pandas as pd
ET = "America/New_York"
# Trading sessions. bdate_range skips weekends only; use your exchange calendar for holidays.
days = pd.bdate_range("2021-01-04", "2025-06-30")
closes = (days + pd.Timedelta(hours=16)).tz_localize(ET) # each session's 16:00 ET close
def edgar_filing_date(accepted):
"""EDGAR's filing date: accepted after 17:30 ET counts as the next business day."""
local = accepted.dt.tz_localize(None)
day = local.dt.normalize()
late = local - day > pd.Timedelta(hours=17, minutes=30)
return day.where(~late, day + pd.offsets.BDay(1))
def make_filings(n_companies=30, seed=11):
"""Simulated 10-Q/10-K filings for 16 quarters (2021-2024), one value each."""
rng = np.random.default_rng(seed)
quarter_ends = pd.date_range("2021-03-31", periods=16, freq="QE")
f = pd.DataFrame({
"company": np.repeat(np.arange(n_companies), 16),
"period_end": np.tile(quarter_ends, n_companies),
})
n = len(f)
annual = (f["period_end"].dt.month == 12).to_numpy()
f["form"] = np.where(annual, "10-K", "10-Q")
lag = np.where(annual, rng.integers(30, 61, n), rng.integers(23, 41, n)) # days after period end
day = (f["period_end"] + pd.to_timedelta(lag, unit="D")).map(pd.offsets.BDay().rollforward)
# Time of day: before 09:30, market hours, 16:00-17:30, after 17:30.
bucket = rng.choice(4, n, p=[0.10, 0.12, 0.56, 0.22])
hours = rng.uniform(np.array([6, 9.5, 16, 17.5])[bucket], np.array([9.5, 16, 17.5, 22])[bucket])
f["accepted"] = (day + pd.to_timedelta(np.round(hours * 3600), unit="s")).dt.tz_localize(ET)
f["filing_date"] = edgar_filing_date(f["accepted"])
f["eps"] = np.round(rng.normal(1.0, 0.3, n), 2)
return f
filings = make_filings()
len(filings) # -> 480
The one function that matters maps a timestamp to the first session close after it. For a backtest that decides at the close, that's the first moment it may use the value.
def first_close_after(ts):
"""The first session close strictly after each timestamp."""
i = closes.searchsorted(pd.DatetimeIndex(ts), side="right")
return pd.Series(closes[i], index=ts.index)
examples = pd.Series(pd.to_datetime(
["2024-05-02 07:12", "2024-05-02 11:40", "2024-05-02 16:31", "2024-05-02 18:02"]).tz_localize(ET))
edgar_filing_date(examples).dt.strftime("%a %m-%d").tolist() # -> ['Thu 05-02', 'Thu 05-02', 'Thu 05-02', 'Fri 05-03']
first_close_after(examples).dt.strftime("%a %m-%d %H:%M").tolist() # -> ['Thu 05-02 16:00', 'Thu 05-02 16:00', 'Fri 05-03 16:00', 'Fri 05-03 16:00']
The third filing is the common case: accepted at 16:31, dated Thursday, first usable at Friday's close. A filing_date <= D join uses it at Thursday's close, 31 minutes before it existed. The second filing is usable at Thursday's close under this rule. If your process needs time to read a filing, add that time to accepted before the lookup.
Now compare three ways of dating every simulated filing: by period end, by filing date at that day's close, and by acceptance time. A bare date is read as midnight Eastern.
at_midnight = lambda col: filings[col].dt.tz_localize(ET)
used_from = pd.DataFrame({
"period_end": first_close_after(at_midnight("period_end")),
"filing_date": first_close_after(at_midnight("filing_date")),
"accepted": first_close_after(filings["accepted"]),
})
early = used_from.le(filings["accepted"], axis=0) # used at or before the second it became public
round(early["period_end"].mean(), 2) # -> 1.0
round(early["filing_date"].mean(), 2) # -> 0.59 (hypothetical, simulated filings)
round(early["accepted"].mean(), 2) # -> 0.0
Every period-end value is early, by weeks. The filing-date share depends on the simulated times of day (here 59% of simulated filings); the share we measured in real filings is the 56.3% above. Keyed on acceptance, nothing is early.
In a backtest you join a panel of decision times to the filings. merge_asof does it, with allow_exact_matches=False so a filing accepted at exactly 16:00:00 isn't used at the 16:00 close. Carry accepted through the join, and you can test the result:
panel = pd.merge(pd.DataFrame({"decision_time": closes}), pd.DataFrame({"company": range(30)}), how="cross")
def join_on_acceptance(panel, filings):
known = filings.sort_values("accepted")[["company", "accepted", "eps"]]
return pd.merge_asof(panel, known, left_on="decision_time", right_on="accepted",
by="company", allow_exact_matches=False)
def join_on_filing_date(panel, filings): # "filed on or before D", used at D's close
dated = filings.assign(available=filings["filing_date"].dt.tz_localize(ET) + pd.Timedelta(hours=16))
known = dated.sort_values("available")[["company", "available", "accepted", "eps"]]
return pd.merge_asof(panel, known, left_on="decision_time", right_on="available", by="company")
def unpublished_rows(joined):
"""Rows that use a value whose filing was accepted at or after the decision time."""
return int((joined["accepted"] >= joined["decision_time"]).sum())
unpublished_rows(join_on_acceptance(panel, filings)) # -> 0
unpublished_rows(join_on_filing_date(panel, filings)) # -> 281 (hypothetical, simulated filings)
int(early["filing_date"].sum()) # -> 281
The leaky join is wrong on 281 of about 35,000 company-days, one day for each filing accepted after the close and dated that day. An assertion like unpublished_rows(joined) == 0 costs one line and belongs in your pipeline. It needs the acceptance time to survive the join, which is a good reason to keep it.
Restatements: keep every version
Companies revise reported numbers. In our sample, comparing each period's first reported value with the latest one (same XBRL tag, not a stock split, changed by more than 0.1%):
| Concept | Periods reported two or more times | Revised by more than 0.1% |
|---|---|---|
| Revenue | 1,274 | 13.0% |
| Net income | 1,415 | 4.7% |
Most of these revisions didn't come through amended filings. Of the 209 revenue periods that changed at all, 4 had a revision filed as a 10-K/A or 10-Q/A. The others changed through later filings: comparative figures in regular 10-Qs and 10-Ks, or recast statements filed on Form 8-K. Two documented cases, timed by the filing that first carried the new number:
- Apple's fiscal 2009 net income was first reported as $5,704 million and became $8,235 million in a 10-K/A accepted on January 25, 2010 at 16:25:58 Eastern, after Apple adopted new revenue-recognition guidance retrospectively.
- Microsoft's fiscal 2016 net income went from $16,798 million to $20,539 million in its fiscal 2018 10-K, accepted on August 3, 2018 at 11:03:33 Eastern, after it adopted ASC 606 with full retrospective restatement.
A database that stores only the latest value, under the original filing's date, puts the restated number into the past. Store each version with the acceptance time of the filing that carried it, and look up the latest version accepted before the decision time:
versions = pd.DataFrame({
"period_end": pd.Timestamp("2023-03-31"),
"revenue": [250.0, 262.0],
"form": ["10-Q", "10-Q"], # the original, then a comparative in next year's Q1 10-Q
"accepted": pd.to_datetime(["2023-05-04 16:12", "2024-05-02 16:05"]).tz_localize(ET),
})
def as_of(versions, t):
"""The latest version accepted strictly before t, or None."""
seen = versions[versions["accepted"] < t]
return seen.sort_values("accepted")["revenue"].iloc[-1] if len(seen) else None
t = pd.Timestamp("2023-09-29 16:00", tz=ET)
as_of(versions, t) # -> 250.0
as_of(versions, pd.Timestamp("2024-06-28 16:00", tz=ET)) # -> 262.0
overwritten = versions.iloc[[0]].assign(revenue=versions["revenue"].iloc[-1]) # latest value, first date
as_of(overwritten, t) # -> 262.0
In September 2023 the overwritten table already knows a number that was published in May 2024.
Getting acceptance times from EDGAR
This section describes SEC's public data; the code above doesn't call it.
- The filing header has the acceptance time. Every filing has a header file at
https://www.sec.gov/Archives/edgar/data/<CIK>/<accession number without dashes>/<accession number>.hdr.sgml. Its<ACCEPTANCE-DATETIME>line is Eastern time. For Apple's 10-K/A above it reads20100125162558, which is 16:25:58 on January 25, 2010. The filing's index page shows the same value as "Accepted": for 100 filings we spot-checked (5 per company, 2009–2026), the two matched to the second. - The submissions API's
acceptanceDateTimeneeds care. SEC's EDGAR APIs include a submissions file per company with anacceptanceDateTimefor each filing. It ends inZ, which suggests UTC. In our sample that held for 6 of the 20 filers (429 filings). For the other 14 (952 filings), the value equals Eastern time with the UTC offset applied twice, so it reads 4 or 5 hours later than the true UTC time. One fixed correction would be wrong for one group or the other. Compare a few values with the header before you rely on them. - The XBRL facts carry a date, not a time. The companyfacts API gives each value's accession number and
fileddate. Join the accession number to the header time. - Fair access. SEC asks automated clients to send a User-Agent header with contact details and to stay within 10 requests per second (Accessing EDGAR data). Headers don't change once filed, so download each one once and cache it.
- Some values carry unexpected period dates. In the XBRL data as SEC served it when we checked, Amazon's 10-K for fiscal 2012 reports Q4 2012 revenue ($21,268 million) under Q4 2011's dates, and Q1 2011 revenue ($9,857 million) under Q1 2012's dates. Walmart's 10-K accepted on March 27, 2012 has a cash value dated December 31, 2012, nine months after the filing. Two cheap guards: drop any value whose period ends after its filing was accepted, and flag a re-reported value that exactly equals a different period's already-public value.
- Tickers change hands. SEC's ticker file maps today's tickers only. When we checked, it mapped XOM to a new holding-company CIK with almost no filing history; Exxon Mobil's history is under CIK 34088. Key your data on the CIK. More on this in survivorship bias and reused tickers.
Checklist
- Every fundamental value carries the acceptance time of the filing it came from, in Eastern time.
- Joins compare that time with the decision time (
merge_asofwithallow_exact_matches=False), not a period end or a filing date. - Every re-report is a separate version, with its own acceptance time. Nothing is overwritten.
- A test fails the build when any joined row uses a value accepted at or after its decision time.
- If you use a fixed lag anyway, measure how often it's too short on your own universe.
Checking code for this
Backtest Auditor (in development) flags code that merges fundamentals onto prices by fiscal period end. It doesn't check whether a filing date is used at the right time of day, or whether a fixed lag is long enough; the test and checklist above cover that. It will be available after release. We also have an internal research prototype of point-in-time fundamentals keyed to acceptance times. It isn't available.
Sources
- SEC, EDGAR Filer Manual, Volume I (effective March 24, 2025), section 2, "Filing Times".
- SEC, Release 33-11159, Extending Form 144 EDGAR Filing Hours (2023), page 3, which summarizes Rule 13(a)(2) of Regulation S-T (17 CFR 232.13).
- SEC, Form 10-Q and Form 10-K, general instructions, section A (filing deadlines).
- SEC, EDGAR application programming interfaces (submissions and companyfacts) and Accessing EDGAR data (fair access).
- Apple 10-K/A, accession 0001193125-10-012091: filing index and header.
- pandas,
merge_asof. - Measured figures: our analysis of SEC EDGAR data for the 20 filers listed above, retrieved October 17, 2026.
Related on this site: lookahead bias in pandas, survivorship bias and reused tickers, and our help articles.