Skip to content

fix(mcp): edgar_trends reported the revenue slice the getters had stopped reporting - #1168

Merged
dgunning merged 10 commits into
mainfrom
fix/trends-consolidated-total
Aug 30, 2026
Merged

fix(mcp): edgar_trends reported the revenue slice the getters had stopped reporting#1168
dgunning merged 10 commits into
mainfrom
fix/trends-consolidated-total

Conversation

@dgunning

Copy link
Copy Markdown
Owner

The follow-up promised in #1164 and #1167. Depends on both — until they merge, this PR's diff contains their commits too; it shrinks to just the last commit once they land.

The disagreement

edgar_trends does its own concept selection, so it did not inherit the consolidated-total cross-check that #1167 adds to the standardized getters. Two surfaces then answered differently about the same company and year:

MET trends: 2025=2,436,000,000     the ASC-606 slice
MET getter: 2025=77,084,000,000    consolidated Revenues

MetLife tags both for FY2025, and the variant list ranks the contract tag first. That list orders names; it cannot tell that one name measures a part of what another measures.

The fix

Revenue trends now take the same cross-check, reusing is_consolidated_total_over rather than adding a third copy of the threshold — two copies drifting apart is what edgartools-fdye objected to in the first place. Opt-in per concept family and on only for revenue, for the same reason as in the getters: ProfitLoss exceeds NetIncomeLoss by the noncontrolling interest, and preferring it would change whose earnings are reported.

After, on live data:

       trends                getter
MET    77,084,000,000        77,084,000,000
NVDA  215,938,000,000       215,938,000,000
GIS    18,424,600,000        18,424,600,000
AAPL  416,161,000,000       416,161,000,000

GIS remains the control: its correct value is the lower-ranked concept in the newer period, so the cross-check must not fire for it.

A separate defect found while verifying this

Pushed to #1164, where it belongs, but worth flagging since it was invisible until the two surfaces were compared: fiscal_period does not separate annual facts from quarterly ones. Companyfacts labels quarterly facts FY as well — General Mills' 90-day Q3 and its 370-day fiscal year are both fiscal_period == "FY" with fiscal_year == 2026 — so a quarter entered the annual series and rendered under the same year label as the real annual figure:

GIS periods=2  ->  2026=18,424,600,000   2026=4,436,700,000

Two rows reading 2026, one of them a quarter. The period type is now decided by the reporting window (duration_days), which time_series returns for exactly this purpose. Instants carry no duration — a balance-sheet concept is a point in time — so they are kept in both modes; verified total_assets still resolves annually and quarterly.

Verification

8 tests here; 5 fail before and pass after, the other 3 being the "leaves correct series alone" controls that must pass on both sides. 30 pass across this file plus #1164's and #1167's, and 676 across the MCP/entity/statement selection.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LN2NaNcXEuv5YvcKFntcaZ

dgunning and others added 10 commits August 30, 2026 10:27
…1138)

`edgar_trends(concepts=["revenue"])` did not return revenue. What it returned
depended on `periods`, was wrong for some companies at every value, and never
signalled anything was off. Two defects composed.

`EntityFacts.time_series` passes `exact=":" in concept` to
`FactQuery.by_concept`, and no value in CONCEPT_MAP contained a colon, so every
lookup took the substring branch and matched every concept CONTAINING the name.
"Revenue" pulled in CostOfRevenue, DeferredRevenue and 12 others for NVIDIA;
"Assets" and "Liabilities" matched their Current, Other and Intangible variants.

Then time_series sorted by filing date and truncated to `periods * 3` rows
BEFORE the tool filtered to fiscal_period == 'FY', so the correct annual fact
could be cut from the window entirely and the value-descending dedup picked the
largest surviving wrong concept.

Apple read as revenue falling from $383B to $7.7B. NVIDIA's FY2026 came back as
cost of revenue -- and its `periods=8` result was correct when the issue was
filed, turning wrong once a 10-Q landed on 2026-08-26. The answer tracked filing
cadence, not the request.

Concepts are now fully qualified so matching is exact, the period-type filter
runs before the row limit, and each entry lists its variants in priority order
so a company that migrates tags mid-series no longer leaves a hole. All eight
mapped concepts were affected.

Not the same defect as #1149: that one is the exact-name priority loop in
_get_standardized_concept_value, with no fuzzy matching and no truncation.
Revenue for insurers still shows the ASC-606 slice here until this path routes
through that selector (edgartools-fdye); tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LN2NaNcXEuv5YvcKFntcaZ
Builds on #1151 by @biautomator, which ranks standardized-concept candidates by
recency. That settles which YEAR to answer from. It cannot settle which of two
concepts tagged for that same year is the consolidated total: among same-recency
candidates the variant list decides, and that list orders names, not what the
names measure.

MetLife tags both for FY2025 --
  RevenueFromContractWithCustomerExcludingAssessedTax   2,436,000,000
  Revenues                                            77,084,000,000
-- and the contract tag is ranked first, so get_revenue() returned the slice, a
32x understatement. The income statement showed $2.4B of revenue above $6.1B of
operating income, which cannot be true and also disagreed with get_revenue() for
the same company and year.

A same-period candidate that dwarfs the ranked pick is now treated as the total
the pick is a slice of. Magnitude is only ever a cross-check on the ranked pick,
never the ranking itself: taking the largest outright would prefer
IncludingAssessedTax over Excluding, and gross over net. It is opt-in per
concept family and off everywhere else -- net income's variants are not slices
of one another, and preferring ProfitLoss over NetIncomeLoss would change whose
earnings are reported.

General Mills is the control and is unchanged at 18,424,600,000: its correct
value is the lower-ranked concept in the newer period, and its Revenues tag is a
small slice of a different year, so neither "newest wins" nor "largest wins" is
safe on its own.

The statement builder holds a second copy of the priority list and had drifted
from the getter. Both now share one threshold in edgar/entity/utils.py -- it
cannot live in either module, since entity_facts imports enhanced_statement --
and the revenue row is named after the concept its value came from rather than
the highest-priority concept merely present.

Closes edgartools-fdye.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LN2NaNcXEuv5YvcKFntcaZ
Found while verifying the concept fix. `fiscal_period` does not separate annual
facts from quarterly ones: companyfacts labels quarterly facts FY as well.
General Mills' 90-day Q3 and its 370-day fiscal year are both
fiscal_period == 'FY' with fiscal_year == 2026, so filtering on that alone let a
quarter into the annual series, where it rendered under the same year label as
the real annual figure:

  GIS periods=2  ->  2026=18,424,600,000  2026=4,436,700,000

Two rows reading 2026, one of them a quarter. The period type is now decided by
the reporting window (duration_days), which time_series returns for exactly this
purpose. Instants carry no duration -- a balance-sheet concept is a point in
time -- and belong in either series, so nulls are kept; verified that
total_assets still resolves in both annual and quarterly modes.

The window bounds are wide enough for 52/53-week fiscal calendars and for the
short stub reported when a company changes its fiscal year end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LN2NaNcXEuv5YvcKFntcaZ
…pped reporting

edgar_trends does its own concept selection, so it did not inherit the
consolidated-total cross-check added to the standardized getters, and the two
surfaces disagreed about the same company and year:

  MET trends: 2025=2,436,000,000     the ASC-606 slice
  MET getter: 2025=77,084,000,000    consolidated Revenues

MetLife tags both for FY2025 and the variant list ranks the contract tag first.
That list orders names; it cannot tell that one name measures a part of what
another measures.

The cross-check reuses is_consolidated_total_over rather than adding a third
copy of the threshold -- two copies drifting apart is what edgartools-fdye
objected to in the first place. It is opt-in per concept family and on only for
revenue, for the same reason as in the getters: ProfitLoss exceeds NetIncomeLoss
by the noncontrolling interest, and preferring it would change whose earnings
are reported.

GIS remains the control and is unchanged: its correct value is the lower-ranked
concept in the newer period, so the cross-check must not fire for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LN2NaNcXEuv5YvcKFntcaZ
…ed-total

# Conflicts:
#	edgar/ai/mcp/tools/trends.py
CodeFactor flagged the two zip() calls for the missing strict= parameter. The
per-period largest value is a groupby, and the swap is a comprehension over the
chosen rows, so neither zip nor the intermediate replacements dict is needed.
Same behaviour, three fewer moving parts.

Lint count on this file is back to main's three pre-existing findings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LN2NaNcXEuv5YvcKFntcaZ
@dgunning
dgunning merged commit 9ef545d into main Aug 30, 2026
10 of 11 checks passed
@dgunning
dgunning deleted the fix/trends-consolidated-total branch August 30, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant