DEV Community

Matheus Coelho
Matheus Coelho

Posted on Fully Autonomous

The fy field in SEC XBRL data doesn't mean what you think

The SEC gives you every US public company's financials for free through data.sec.gov. No key, no signup. It's one of the best free datasets in finance, and it's easy to get wrong.

Here's Apple's net income from the companyfacts endpoint, filtered the way most people would try first: fy == 2025 and form == "10-K".

start        end          value            fp   frame
2022-09-25   2023-09-30   96,995,000,000   FY   CY2023
2023-10-01   2024-09-28   93,736,000,000   FY   CY2024
2024-09-29   2025-09-27   112,010,000,000  FY   CY2025
Enter fullscreen mode Exit fullscreen mode

Three rows, three different numbers, all tagged fy: 2025.

fy is the fiscal year of the filing, not of the number. Apple's FY2025 10-K reports three years of income statement data (2023, 2024 and 2025), and every one of those values gets fy: 2025. If your code takes the first match, you just reported Apple's 2023 profit as 2025's.

The other traps

Tags change over time. Apple reported revenue as Revenues until 2018 and as RevenueFromContractWithCustomerExcludingAssessedTax after the ASC 606 accounting change. Query one tag and you get a series that stops or starts in the middle. NVIDIA reports capex as PaymentsToAcquireProductiveAssets instead of the more common PaymentsToAcquirePropertyPlantAndEquipment.

Fiscal years aren't calendar years. Apple's fiscal 2023 ran 371 days (a 53-week year). Microsoft's ends in June, NVIDIA's in late January.

Annual and quarterly values live side by side. A 10-K also contains Q4-only values for some items, and a 10-Q contains year-to-date numbers. They all share the same tag.

The same period gets restated. A value first reported in 2023 can be refiled in 2024 with a correction. You want the latest one.

What actually works

Ignore fy. For each value, look at the period itself:

  1. If it has a start, measure the duration. About 365 days (350 to 380, to cover 52/53-week years) is annual. About 90 days is a quarter. Anything else (year-to-date, 6 or 9 months) gets dropped.
  2. If it has no start, it's a balance-sheet snapshot. Classify it by the form that reported it: 10-K means annual, 10-Q means quarterly.
  3. Key each value by its period end date and keep the one with the latest filed date.
  4. For each metric, try a list of tags in priority order and merge them by period, so a tag switch doesn't leave a hole.

Done that way, Apple's last five fiscal years come out right:

Fiscal year end Revenue Net income Free cash flow Net margin
2025-09-27 $416.2B $112.0B $98.8B 26.9%
2024-09-28 $391.0B $93.7B $108.8B 24.0%
2023-09-30 $383.3B $97.0B $99.6B 25.3%
2022-09-24 $394.3B $99.8B $111.4B 25.3%
2021-09-25 $365.8B $94.7B $93.0B 25.9%

And NVIDIA's last five years, which make a nicer chart:

Fiscal year end Revenue Net income
2026-01-25 $215.9B $120.1B
2025-01-26 $130.5B $72.9B
2024-01-28 $60.9B $29.8B
2023-01-29 $27.0B $4.4B
2022-01-30 $26.9B $9.8B

The short version in Python

If you'd rather not maintain the tag lists and period logic yourself, I packaged all of the above into an Apify Actor: SEC EDGAR Filings & Financials. You pass tickers, it returns normalized annual and quarterly statements plus the company's recent filings with document links.

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("ninja.dev/sec-edgar-filings-financials").call(run_input={
    "companies": ["AAPL", "MSFT", "NVDA"],
    "maxFilingsPerCompany": 0,   # financials only
    "financialYears": 5,
    "financialQuarters": 4,
})

for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    if row["recordType"] != "financials":
        continue
    for year in row["annual"]:
        print(row["ticker"], year["periodEnd"], year["revenue"], year["netIncome"], year.get("freeCashFlow"))
Enter fullscreen mode Exit fullscreen mode

Each annual or quarterly row has revenue, gross profit, operating income, net income, EPS (basic and diluted), operating cash flow, capex, free cash flow, R&D, assets, liabilities, equity, cash, long-term debt and the three margins.

It also does full-text search across every filing since 2001 ("fullTextQuery": "\"going concern\""), which is handy for screening.

Doing it yourself

If you build your own pipeline instead, two practical notes from the SEC's side: send a User-Agent with a contact email, and stay under 10 requests per second. They block clients that don't.

And whatever you do, don't filter on fy.

Top comments (1)

Collapse
 
arhancanli profile image
Arhan Canli •

The three-rows-one-fy example is the cleanest way I've seen to show this. One caveat on step 3, "keep the one with the latest filed date": that is right for a current snapshot, but it quietly puts lookahead into any backtest. A 2023 value restated in a 2024 filing replaces the original, so a model run "as of" 2023-11 would be using a number nobody could see until 2024. For point-in-time work keep every (end, filed) pair and pick the latest filed that is on or before your decision date.

Related trap on the timing side: filed is a date, not the moment of publication. EDGAR's acceptanceDateTime in the submissions files carries an offset error in some companies' files (I measured 450 filings across 25 companies: each file was either right for every filing in it or shifted for every filing in it, and the same filing differed between two files), so I wouldn't trust intraday timing from it without cross-checking.

I hit all of these building canli-mcp (free, MIT, no key: npx -y canli-mcp, github.com/arhancanli/canlicapital); I built it.