Skip to content

About

Point-in-time fundamentals from SEC EDGAR XBRL filings, with the date every number became public.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

pit-fundamentals

Point-in-time fundamentals built from SEC EDGAR XBRL filings.

Most fundamentals pipelines store one value per company per period. That value is whatever the company says today. It is not what the company said at the time, and the two differ more often than people expect.

This repo builds a panel that keeps both dates on every row:

  • period_end - the span of business the number describes
  • filed - the date the number became public

With both, you can ask what the table said on any past date and get the answer that was true then. Without them, a backtest reads numbers that did not exist yet.

What I measured

Every value the SEC holds for 99 large US companies across six fields (revenue, net income, operating income, assets, equity, cash). That is 126,266 filed facts covering 51,355 company-periods, from the start of XBRL reporting to today.

Then: how often does a figure change after it is first published?

count share of periods
Company-periods measured 51,355
Revised at least once 3,958 7.7%
Revised by more than 1% 2,105 4.1%
Revised by more than 10% 822 1.6%
Changed sign 55 0.1%

The median revision moves the number by 1.2%. The 90th percentile moves it by 25.1%. The median revised period took 378 days to reach its final value and the slowest tenth took over 735 days. By then nobody is re-checking.

It is not spread evenly

field periods revised
operating income 8,422 13.4%
revenue 10,819 10.0%
net income 11,647 6.9%
assets 6,686 6.1%
cash 6,855 4.1%
equity 6,926 3.7%

Income statement items move more than balance sheet items. Annual figures (13.7%) move more than quarterly ones (9.9%).

By company the spread is far wider than the average suggests. GE revised 34.8% of its periods, Dominion 20.4%, CVS 19.5%, Citigroup 19.3%, AT&T 18.8%. At the other end Broadcom revised 0.3%, and Cisco, UnitedHealth, Amgen and Lam Research 0.4%. That is a hundredfold difference inside one large-cap universe. If your holdings sit at the wrong end of it, the headline rate tells you nothing about your exposure.

What it costs you

The same screen built twice at five decision dates: once from filings available on that date, once from today's values for the same periods.

decision date firms figures that differ differ by >10%
2012-01-01 87 4 (4.6%) 0
2014-01-01 94 7 (7.4%) 1
2016-01-01 95 6 (6.3%) 3
2018-01-01 96 25 (26.0%) 4
2020-01-01 99 4 (4.0%) 0

The 2018 spike is ASC 606. Companies adopted the new revenue standard and recast prior years, so a quarter of the universe reads differently today than it did on 1 January 2018.

Individual cases are worse than the averages:

as of company period known then restated now gap
2018-01-01 Salesforce FY2017 $0.18bn $0.32bn +80%
2016-01-01 Ford FY2014 $3.19bn $1.23bn -61%
2016-01-01 Caterpillar FY2014 $3.71bn $2.47bn -34%
2018-01-01 Microsoft FY2017 $21.20bn $25.49bn +20%
2018-01-01 GE FY2016 $8.83bn $7.50bn -15%

Ford's 2014 net income differs by 61% depending on which version you read. Anyone looking at Ford in early 2016 saw $3.19bn. A backtest run today sees $1.23bn.

Where it does and does not matter

Being straight about this: for a coarse screen the effect is small. Ranking the universe by earnings level, the top 10 was identical point-in-time and restated at all five dates, and rank correlation on levels stayed above 0.99 every time.

The damage shows up in three narrower places:

  1. Levels. Ford at -61% is wrong by any standard, even though its rank barely moves.
  2. Growth rates, which compound two revisions. At 2018-01-01, 22 of 96 firms showed a different year-on-year growth number, 3 of them by more than 20 percentage points, and one flipped sign. Rank correlation on growth fell to 0.969 in 2014 against 0.999 on levels at the same date.
  3. Smaller companies, which are not in this universe. Mega-caps have the most scrutinised accounts in the market, so this is the optimistic case.

The honest summary is that this is a concentrated risk, not a pervasive one. It is invisible in aggregate statistics and severe in specific names and specific years. That is the worst shape for a bug to have, because testing on averages will not find it.

Two ways EDGAR will quietly hand you the wrong data

Both of these were found while building this, both are live as of the run date, and both fail silently. Neither raises an error or returns an empty result that looks like an error.

1. companyconcept returns 200 and an empty array

Coca-Cola's net income via the per-concept endpoint:

GET /api/xbrl/companyconcept/CIK0000021344/us-gaap/NetIncomeLoss.json
-> 200 OK, units: {"USD": []}

The same tag via companyfacts returns 233 facts. Assets: empty from companyconcept, 144 facts from companyfacts. Apple returns 338 correctly from both, so nothing about the request is malformed.

A pipeline built on companyconcept therefore drops Coca-Cola with a clean HTTP 200 and no exception. In the first version of this study it cut five companies down to two fields or fewer and left eighteen more incomplete, roughly a quarter of the universe. It also produced a wrong answer: Abbott ranked as the second most revised company at 32.8%, computed from only two of its six fields. With complete data Abbott sits 13th at 14.4%.

This repo reads companyfacts instead, which is one request per company rather than one per tag, and complete.

2. The ticker file points at the current registrant, not the history

company_tickers.json maps a symbol to whoever files under it now. After a holding company reorganisation that is a brand-new CIK with no past:

ticker CIK from SEC file NetIncomeLoss facts actual history
XOM 2115436 Exxon Mobil Corporation 4 (2026 only) CIK 34088, 229 facts, 2009-2026
BLK 2012383 BlackRock, Inc. 31 (2024-2026) CIK 1364742, 279 facts, 2009-2024

Ask for Exxon and you get four facts, all from this year, with no error. Worse, matching on company name would pick the wrong one for BlackRock: the CIK holding the history has since been renamed to "BLACKROCK FINANCE, INC.", while the new, nearly empty CIK carries the recognisable name. Only the CIK lineage is reliable.

Two of a hundred large caps hit this. study.py warns whenever a mapped CIK returns fewer than five filing years, which is what surfaced these.

Not every revision is a restatement

The largest movements in the data are not accounting errors. They have at least three causes, and telling them apart means reading the filings:

Change of reporting entity. T-Mobile holds the most extreme numbers in the panel: FY2012 net income filed as +$394m in March 2013, then -$7.34bn in the February 2014 10-K. Not a correction. MetroPCS was the legal registrant, T-Mobile USA was the accounting acquirer in the 2013 reverse merger, and the comparatives were recast to the acquirer. The CIK stayed the same while the business behind it changed.

Presentation and segment recasts. GE revenue for Q2 2015 falls from $32.75bn to $2.69bn across three filings. GE Capital moved to discontinued operations, so continuing-operations revenue was restated down. The company did not lose 92% of its sales.

Accounting standard adoption. The 2018 cluster is ASC 606 applied retrospectively. Microsoft's FY2017 net income rising 20% is this, not an error.

All three matter equally for backtesting, because in every case the number on screen today is not the number that was on screen then. They matter very differently for interpretation, and a dataset that does not separate them will mislead anyone reading it as a restatement count. I flag the pattern rather than auto-classify it, because the signal that separates these cases is in the filing text, not in the numbers.

Using it

pip install -r requirements.txt
export SEC_UA="Your Name your@email.com"   # SEC blocks unidentified clients
python study.py            # builds the panel, writes out/
python demo_lookahead.py   # reproduces the tables above
pytest tests/ -q
from pitfun import EdgarClient, fetch_facts, resolve_tags, as_of, latest

client = EdgarClient()
cik = client.ticker_map()["F"]["cik"]
facts = resolve_tags(fetch_facts(client, "F", cik, ["net_income"]))

as_of(facts, "2016-01-01")   # what you could have known that day
latest(facts)                # what Ford says now

as_of drops every row filed after the date, then keeps the most recent filing for each period. That is the only view a backtest should be allowed to see.

MCP server

mcp_server.py exposes the panel to Claude over the Model Context Protocol, so an assistant answering a question about past financials cannot quietly read a restated figure. JSON-RPC over stdio, no SDK dependency.

claude mcp add pit-fundamentals -- python /abs/path/to/mcp_server.py

Tools: fundamentals_as_of, revision_history, largest_revisions.

> revision_history for Ford net income, FY2014

F net_income  [2014-01-01 to 2014-12-31]
  filed 2015-02-13  10-K          $3.19bn
  filed 2016-02-11  10-K          $1.23bn   CHANGED -61.4%

Design notes

Tag resolution. Companies do not agree on which XBRL tag to use. Coca-Cola reports revenue only under SalesRevenueGoodsNet, with nothing under Revenues or either ASC 606 tag. Query one tag and you get an empty series that looks like a company with no sales. concepts.py maps each canonical field to an ordered list of tags and keeps the highest-priority tag covering each period, so a company that switched tags in 2018 still produces one continuous series. This also stops a tag change being counted as a revision.

Period identity. Facts are grouped on (period_start, period_end), not on period_end alone. Grouping on the end date merges Q4 with the full year and manufactures revisions that never happened. There is a test for this.

Re-reporting is not revision. A 10-K repeats last year's figures. If the value is unchanged that is not a revision, and the counts here exclude it.

Caching. Every response is cached to disk through a temp file and rename, so an interrupted backfill cannot leave truncated JSON that poisons the next run. A corrupt entry is detected and refetched rather than crashing the job. Requests are held to 8/second, under the SEC limit of 10.

Limits

  • 99 companies, all large-cap US. EA was not in the SEC ticker file and was dropped rather than substituted. Do not extrapolate to small caps or non-US filers without rerunning.
  • XBRL coverage starts around 2009. Anything earlier is not in the API.
  • Consolidated facts only. Segment detail carries XBRL dimensions and is out of scope.
  • The three causes of revision are described, not classified. Separating them automatically needs the filing text.
  • first_value is the first XBRL fact, which for older periods can post-date the original press release.
  • The CIK override list is manual. The detector finds candidates; a human confirms the predecessor.

Layout

pitfun/edgar.py       rate-limited, cached EDGAR client
pitfun/concepts.py    canonical field -> XBRL tag priority
pitfun/panel.py       bitemporal panel, as_of / latest / first_reported
pitfun/revisions.py   revision detection and statistics
universe.py           study universe, CIK overrides
study.py              runs the full study, writes out/
demo_lookahead.py     point-in-time vs restated comparison
mcp_server.py         MCP server over the panel
tests/                12 tests covering the point-in-time logic

About

Point-in-time fundamentals from SEC EDGAR XBRL filings, with the date every number became public.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages