On August 12, Nebius reported its second-quarter results. Our earnings layer, which reads company filings and writes a short read of each quarter, never saw them. A wider window wouldn't have helped: Nebius had filed zero 8-Ks in the previous 60 days.
US companies announce results on Form 8-K, and the form says what it is. Item 2.02, "Results of Operations and Financial Condition", is a label the filer sets. We filtered on it and were done.
Nebius is a foreign private issuer, like ASML and TSMC. Foreign issuers furnish Form 6-K instead, and a 6-K has no items. It wraps whatever the company published at home: a results release, a dividend notice, an AGM circular, a buyback update, a monthly sales figure. EDGAR tells you the form type and the filer. The rest is up to you.
Between June 29 and August 26 there were 4,777 of them from 1,111 filers, between 91 and 170 a day. Only 3 of those 1,111 filers also filed an 8-K in that window. If we wanted results from foreign issuers, we had to read 6-Ks and decide, one by one, whether each was an earnings release.
Why rules and not a model
The question here is what a document is, not what it says. The answer usually sits in the first few hundred characters. A headline like "Nebius reports second quarter 2026 financial results" names a period, and the body under it is full of numbers.
The classifier also runs inside the filing collector, before anything is published, so its verdict decides what reaches the feed. We wanted the same filing to get the same verdict on every run, and we wanted to see why. Every intermediate reading is stored with the verdict in an audit row, so a disputed filing can be replayed without fetching it from the SEC again.
A model still reads every release afterwards, to summarize it and pull out the metrics. It just doesn't decide which documents are releases. I didn't benchmark a model on this step, so I can't tell you it would have done worse. What I can show you is what the rules needed.
The rule
Here is the decision order, simplified. The real version has more zones and exceptions, but the shape is the same.
def classify(title: str, text: str, filed_at: date) -> str:
head, dense = text[:6000], text[:12000]
if hard_negative(title): # dividend, AGM notice, buyback, earnings call invite
return "other"
if not (names_a_period(head) and enough_numbers(dense)
and period_ended_recently(head, filed_at, max_days=190)):
return "other"
if looks_like_a_report(title) or len(text) > 150_000:
return "report" # MD&A or full financial statements
if not results_phrase(head):
return "other"
if metric_families(dense) < 2: # revenue, net income, EPS, EBITDA, cash flow...
return "other"
return "earnings_release"
"Enough numbers" means at least 20 numeric tokens in the first 12,000 characters, or a line such as "in thousands of US dollars". The period check looks for a date like "ended June 30, 2026" and wants it to have passed less than 190 days before the filing. When there is no such date, it doesn't reject. Reports are kept apart from releases: an interim report with full financial statements gets recorded, but in this version only releases are published as news.
Writing the first version was the cheap part.
Sixty days of filings on disk
The calibration script reads the SEC's daily form indexes for the window. It keeps rows whose form type is exactly 6-K and whose filer is a listed company. For each filing, it fetches the header page and up to three EX-99 exhibits, plus the 6-K body when the exhibits don't settle it. Everything is gzipped on disk, about 150 MB. Then it runs the production parser and the production classifier over the cache, the same code the collector runs.
Fetching was slow on purpose. Four threads shared a budget of 3 requests a second, and the SEC still answered about 4% of requests with a 503. The full window took about four and a half hours, once. After that, --reclassify replays the classifier over the cache without touching the network, and a full run takes seconds. A second script diffs each run against a committed baseline of 4,744 verdicts, so the corpus doubles as a regression test.
That changed how the work went. Every edit ran against every filing. Each disagreement became an input to the rule, and nothing went on an allowlist.
What the filings taught us
The filing we built this for failed first. Nebius's own release said it "will hold an earnings webcast today". The pre-announcement negative was meant for notices like "X will report results on August 12", and it caught the webcast. A pre-announcement now needs a verb about the results themselves: will report, announce, release or publish, then results.
The comparative year comes first. Nebius's interim report covers the "Three and Six Months Ended June 30, 2025 and 2026". The anchor took 2025. To the rule, that period had ended 408 days before the filing, so it threw the document out. The period end is now the latest date that has already passed.
Half-years arrive late. Four issuers filed results in June for a half-year that ended in December, 180 to 183 days later. A foreign issuer has up to six months for a half-year report. The anchor went from 150 days to 190.
A fifth of the releases aren't in an exhibit. Several British issuers, GSK and BP among them, put the whole text in the body of the 6-K. Takeda puts the release in the body and an appendix in EX-99.1. Of the releases we found, 113 of 561 sat in the 6-K body. The body is now always the last candidate when the exhibits didn't produce a release.
Exhibit labels are free text. Infosys typed its exhibits EX-99.1 CHARTER and EX-99.2 BYLAWS. Deutsche Bank filed its report as EX-1. A picker that trusted the type field missed both.
A results headline can skip the word. "ING posts 2Q2026 net result of €1,947 million" never says "results". A reporting verb plus a metric in the headline now counts as a results phrase, and "net result" joined the profit family.
A future date is not a failed anchor. The only date in one TC Energy header was a dividend "for the quarter ending September 30". Reading it as the period end put the period 62 days in the future, and the filing failed. A header with only future dates now has no anchor, which the rule doesn't reject on.
One release, filed twice. Materialise furnished the same results twice within six minutes, first with an exhibit and then in the body of a second 6-K. A second release for the same ticker within seven days now gets an audit row and no news item. In the 60 days, 25 tickers did this.
How it came out
Over the 4,744 listed filings in the window, the classifier found 561 earnings releases (11.8%), 315 interim reports, 25 annual reports and 3,843 other filings. Before reporting season, from June 29 to July 21, 2.2% of 6-Ks were releases. From July 22 on it was 16.5%, and 27% on the busiest day.
We labeled all 561 releases by hand from their headlines. Every one was a results document. Two kinds are arguable: Korean banks' "preliminary operating results", which in Korea is the release, and BBVA's "2Q26 Earnings" deck, which has every metric but is a presentation. That puts precision at 98% or better.
For recall, we checked about 120 large foreign issuers that reported in the window, from ASML and SAP to Alibaba and Nebius. All were caught except one group: issuers whose numbers appear only in a full report longer than 150,000 characters, with IFRS statements and no short release. Lloyds, Barclays, Shell, Rio Tinto, Nokia, Deutsche Bank and HSBC land in interim_report. They get an audit row and don't reach the feed as releases. That's the known gap. Forty filings with PDF-only exhibits are out of reach for the same reason: there's no text to read.
The bug the corpus didn't catch
The verdicts were right, and a label was still wrong. Every release gets a period label, and the label is visible: it goes into the news headline, as in "ASML HOLDING NV (ASML): Financial results for Q3 2026".
Before turning publishing on, we ran the classifier in production in shadow mode. It wrote an audit row for every filing and published nothing. On the first day, the audit table showed Canadian Solar with "H1 2027", TORM with "Q1 2027" and Prenetics with "FY2027".
The year rule from the previous round took the latest year up to the filing year plus one. That fixed Star Bulk, whose headline mentions its "strongest results since the second quarter of 2022". It also promoted every forward-looking sentence in a release body: a buyback "expected to be completed in the first half of 2027", guidance for "the first quarter of 2027". On the corpus, 35 releases, about 6%, were labeled a year ahead.
The fix had to hold on every filing in the corpus. With the cache, checking that was a replay. A period phrase in the first 400 characters now beats any phrase in the body. A phrase that follows "since", "compared to", "expects", "guidance for" or "outlook for" ranks lower. And the year can be read to the left of the phrase, as in "2026 SECOND QUARTER RESULTS". Suspicious labels went from 53 to 23, and every year-ahead label left was legitimate: a company with a March fiscal year calls its June quarter Q1 FY2027. Three verdicts out of 4,744 changed, all three in the right direction.
In all, shadow mode ran for two days: 394 filings, 74 releases (18.8%), inside the seasonal range the corpus predicted. After that we turned publishing on.
Where a model would earn its place
The gap above is reading, not labeling. Finding one quarter's numbers inside a 150,000-character IFRS report is where I'd try a model first. For deciding whether a 6-K is a results release, the rules have held up, and when one gets a filing wrong, the audit row says which reading did it.
See what the rules let through
Every release the classifier accepts becomes a news item with the source type sec_form6k. You can list them with a free AlphAI API key and Python 3.10 or newer:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install "alphai-sdk==0.9.0"
import os
from datetime import datetime, timedelta, timezone
from getpass import getpass
from alphai import Client
if not os.environ.get("ALPHAI_API_KEY"):
os.environ["ALPHAI_API_KEY"] = getpass("AlphAI API key: ")
until = datetime.now(timezone.utc).replace(microsecond=0)
since = until - timedelta(days=14)
with Client(max_retries=0) as client:
page = client.news.list(
source_type="sec_form6k", from_date=since, to_date=until, page_size=20
)
for article in page.results:
published = article.original.time_published
tickers = ", ".join(article.enrichment.tickers)
print(f"{published:%Y-%m-%d} {tickers:<8} {article.title}")
The first lines when I ran it on October 14, 2026:
2026-10-14 CAMT CAMTEK LTD (CAMT): Financial results for FY
2026-10-14 HTT High Templar Tech Ltd (HTT): Financial results for H1 2026
2026-10-14 ASML ASML HOLDING NV (ASML): Financial results for Q3 2026
2026-10-13 HTOO Fusion Fuel Green PLC (HTOO): Financial results for FY2026
The top line came back with a period label that has no year. I haven't looked at why yet. The audit row for that filing will say which reading produced it, and that's the first place I'll check.
Your list will be different, and October is the start of another reporting season. If one of the labels looks wrong to you, I'd like to hear which one.
Disclosure: I run AlphAI. This article was drafted with AI assistance from our own engineering notes and code, and the example was run against the live API before publication.
Top comments (0)