Revenue Is Not a Number
Published February 21, 2019
A price-to-sales ratio requires two inputs. Price is easy. Sales is not.
The revenue number shown in a 10-Q is frequently cumulative. A second-quarter filing may report six months of revenue, not the revenue generated during the second quarter. A third-quarter filing may report nine months. A 10-K reports the full fiscal year. Summing the latest four revenue facts can therefore count the same months more than once.
This error is common in fundamental stock screeners because the arithmetic appears plausible. The output is positive, changes each quarter, and often remains within the same order of magnitude as the correct value. It can survive basic validation while contaminating revenue growth, price-to-sales, enterprise-value-to-sales, margins, and every fair-value model that depends on them.
A reliable SEC XBRL pipeline must solve four separate problems:
- Identify the economic period represented by each XBRL fact.
- map multiple company concepts into one canonical metric without hiding accounting differences.
- reconstruct discrete fiscal quarters from cumulative periods.
- preserve filing versions so amendments and restatements do not rewrite history silently.
The difficult part is not the final sum(). The difficult part is deciding which four numbers are eligible to be summed.
The XBRL fact is not the metric
An XBRL fact is a value attached to a concept, a context, a unit, and a filing.
For revenue, the concept might be Revenues, SalesRevenueNet, or RevenueFromContractWithCustomerExcludingAssessedTax. A company can also use an extension concept. The context defines the start date, end date, reporting entity, and sometimes a business segment or other dimension.
Two facts with the same concept and end date are not necessarily interchangeable. One may cover three months and another may cover nine months. One may describe the consolidated company and another a segment. One may come from an original filing and another from a later amendment.
The minimum useful record therefore looks like this:
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Optional, Tuple
@dataclass(frozen=True)
class XbrlFact:
cik: str
concept: str
start: date
end: date
value: Decimal
unit: str
accepted: date
accession: str
form: str
dimensions: Tuple[Tuple[str, str], ...] = ()
fiscal_year: Optional[int] = None
Do not reduce this record to (ticker, period_end, value) during ingestion. That schema discards the information needed to detect cumulative periods, segments, amendments, and fiscal-calendar boundaries.
A stock screener can compress the normalized output later. Raw filing facts should remain append-only.
Parse contexts before values
The period belongs to the XBRL context, not to the numeric node. Parse contexts first, then attach them to facts through contextRef.
The following parser is deliberately narrow. It extracts duration contexts, monetary units, and numeric facts from an XBRL instance document. Production code also needs namespace handling, scale and decimals policies, nil values, duplicate facts, and filing-level diagnostics.
from datetime import datetime
from decimal import Decimal, InvalidOperation
from lxml import etree
def parse_iso_date(text):
return datetime.strptime(text, "%Y-%m-%d").date()
def local_name(node):
return etree.QName(node).localname
def first_text(node, xpath):
values = node.xpath(xpath)
return values[0].text.strip() if values and values[0].text else None
def parse_duration_contexts(root):
contexts = {}
for context in root.xpath('//*[local-name()="context"]'):
context_id = context.get("id")
start_text = first_text(
context,
'.//*[local-name()="period"]/*[local-name()="startDate"]'
)
end_text = first_text(
context,
'.//*[local-name()="period"]/*[local-name()="endDate"]'
)
if not context_id or not start_text or not end_text:
continue
dimensions = []
for member in context.xpath(
'.//*[local-name()="explicitMember"]'
):
axis = member.get("dimension")
value = member.text.strip() if member.text else ""
dimensions.append((axis, value))
contexts[context_id] = {
"start": parse_iso_date(start_text),
"end": parse_iso_date(end_text),
"dimensions": tuple(sorted(dimensions)),
}
return contexts
def parse_units(root):
units = {}
for unit in root.xpath('//*[local-name()="unit"]'):
unit_id = unit.get("id")
measure = first_text(unit, './/*[local-name()="measure"]')
if unit_id and measure:
units[unit_id] = measure.split(":")[-1]
return units
def parse_xbrl_facts(
xml_bytes,
cik,
accepted,
accession,
form,
):
root = etree.fromstring(xml_bytes)
contexts = parse_duration_contexts(root)
units = parse_units(root)
facts = []
for node in root.iter():
context_ref = node.get("contextRef")
unit_ref = node.get("unitRef")
if context_ref not in contexts or unit_ref not in units:
continue
if node.get("{http://www.w3.org/2001/XMLSchema-instance}nil") == "true":
continue
if node.text is None:
continue
try:
value = Decimal(node.text.strip())
except InvalidOperation:
continue
context = contexts[context_ref]
facts.append(
XbrlFact(
cik=cik,
concept=local_name(node),
start=context["start"],
end=context["end"],
value=value,
unit=units[unit_ref],
accepted=accepted,
accession=accession,
form=form,
dimensions=context["dimensions"],
)
)
return facts
The parser should reject facts it cannot classify. Silent coercion is more dangerous than missing data because the resulting ratios still look numerically valid.
Filter consolidated monetary facts
Revenue used for enterprise valuation normally refers to the consolidated reporting entity. Segment revenue is useful for analysis, but it should not enter the consolidated numerator unless the model explicitly aggregates segments and eliminates intersegment transactions.
A practical first filter is:
USD_UNITS = {"USD", "US_DOLLARS"}
REVENUE_CONCEPTS = {
"Revenues",
"SalesRevenueNet",
"RevenueFromContractWithCustomerExcludingAssessedTax",
}
def is_consolidated_revenue(fact):
return (
fact.concept in REVENUE_CONCEPTS
and fact.unit in USD_UNITS
and not fact.dimensions
and fact.form in {"10-Q", "10-K", "10-Q/A", "10-K/A"}
)
This is not a universal concept mapper. It is an explicit allowlist.
The distinction matters. Treating every concept containing the word Revenue as equivalent can mix net sales, gross billings, regulated revenue, interest revenue, segment revenue, and company-specific subtotals. A fundamental stock screener should prefer a missing metric over a metric assembled from semantically incompatible facts.
Concept normalization requires evidence. Useful evidence includes labels, calculation relationships, statement location, sign, duration, historical continuity, and agreement with the rendered filing. Company-specific overrides are acceptable when they are versioned and testable.
Classify fiscal durations with tolerances
Calendar months are not a sufficient period classifier. Retailers often use 52-week or 53-week fiscal years. Quarter lengths can vary. A rigid test for exactly 90, 180, 270, or 365 days will reject valid facts and accept some invalid ones.
Use ranges:
def inclusive_days(start, end):
return (end - start).days + 1
def duration_bucket(start, end):
days = inclusive_days(start, end)
if 77 <= days <= 105:
return "Q"
if 161 <= days <= 203:
return "H1"
if 245 <= days <= 301:
return "M9"
if 343 <= days <= 385:
return "FY"
return None
These ranges are classification hints, not accounting rules. They should be combined with form type, fiscal-year metadata, prior-period continuity, and the filing’s document and fiscal period fields.
The primary invariant is ordering:
Q1 end < H1 end < M9 end < FY end
The starts should refer to the same fiscal-year origin. If they do not, subtraction is invalid.
Reconstruct discrete quarters from cumulative facts
Assume a company reports the following cumulative revenue:
| Reported duration | Revenue |
|---|---|
| First quarter | 24 |
| First six months | 51 |
| First nine months | 82 |
| Full fiscal year | 118 |
The discrete quarters are:
Q1 = 24
Q2 = 51 - 24 = 27
Q3 = 82 - 51 = 31
Q4 = 118 - 82 = 36
TTM = 24 + 27 + 31 + 36 = 118
Q4 is often not present as a standalone fact in the 10-K. It must be derived from the full-year value and the nine-month value.
The same subtraction pattern applies to Q2 and Q3 when the filing exposes cumulative year-to-date values:
from decimal import Decimal
def reconstruct_quarters(q1, h1, m9, fy):
values = [Decimal(str(x)) for x in (q1, h1, m9, fy)]
q1_value, h1_value, m9_value, fy_value = values
quarters = {
"Q1": q1_value,
"Q2": h1_value - q1_value,
"Q3": m9_value - h1_value,
"Q4": fy_value - m9_value,
}
if sum(quarters.values()) != fy_value:
raise AssertionError("Quarter reconstruction does not equal FY value")
return quarters
The subtraction is simple. Selecting compatible inputs is not.
Each cumulative fact must use the same canonical metric, unit, consolidated scope, fiscal-year start, and accounting basis. A concept change between Q2 and Q3 can make the subtraction appear valid while combining different definitions of revenue.
Why concept drift matters in 2019
Revenue recognition changes have increased the number of filings using newer revenue concepts. A company can move from SalesRevenueNet to RevenueFromContractWithCustomerExcludingAssessedTax while preserving an economically continuous series. Another company can change the composition of reported revenue at the same time.
A concept name change is not proof of an economic break. It is also not proof of continuity.
The normalization layer should record both the canonical metric and the source concept:
@dataclass(frozen=True)
class NormalizedFact:
cik: str
metric: str
source_concept: str
start: date
end: date
value: Decimal
accepted: date
accession: str
form: str
CONCEPT_TO_METRIC = {
"Revenues": "revenue",
"SalesRevenueNet": "revenue",
"RevenueFromContractWithCustomerExcludingAssessedTax": "revenue",
}
def normalize_fact(fact):
metric = CONCEPT_TO_METRIC.get(fact.concept)
if metric is None:
return None
return NormalizedFact(
cik=fact.cik,
metric=metric,
source_concept=fact.concept,
start=fact.start,
end=fact.end,
value=fact.value,
accepted=fact.accepted,
accession=fact.accession,
form=fact.form,
)
This mapping is intentionally observable. A later validation job can flag a source-concept transition and require comparison with the rendered income statement.
Do not erase provenance after normalization. Fair-value models need a reason code when a historical multiple changes because the source filing changed.
Preserve filing versions
An amended 10-Q or 10-K can replace a fact. A later filing can also restate a comparative prior period. Overwriting the old record makes historical backtests use information that was not available at the time.
Store every filing version. Select facts as of a specified date:
def latest_facts_as_of(facts, as_of):
visible = [fact for fact in facts if fact.accepted <= as_of]
latest = {}
for fact in visible:
key = (
fact.cik,
fact.metric,
fact.start,
fact.end,
)
previous = latest.get(key)
if previous is None or (
fact.accepted,
fact.accession,
) > (
previous.accepted,
previous.accession,
):
latest[key] = fact
return list(latest.values())
This selector uses filing acceptance as the information timestamp. The economic period end remains separate.
That separation supports two valid datasets:
- Latest restated fundamentals: useful for current research and current valuation.
- As-filed fundamentals: required for historical screening, factor research, and backtesting.
A serious fundamental stock screener needs both. They answer different questions.
Build a fiscal-year input set
Quarter reconstruction should operate on one fiscal year at a time. The following helper selects one fact for each cumulative duration after normalization and point-in-time filtering:
def choose_cumulative_facts(facts, fiscal_start, fiscal_end):
candidates = [
fact
for fact in facts
if fact.metric == "revenue"
and fact.start == fiscal_start
and fact.end <= fiscal_end
]
by_bucket = {}
for fact in candidates:
bucket = duration_bucket(fact.start, fact.end)
if bucket not in {"Q", "H1", "M9", "FY"}:
continue
previous = by_bucket.get(bucket)
if previous is None or (
fact.accepted,
fact.accession,
) > (
previous.accepted,
previous.accession,
):
by_bucket[bucket] = fact
required = {"Q", "H1", "M9", "FY"}
missing = required.difference(by_bucket)
if missing:
raise ValueError(
"Incomplete cumulative revenue set: {}".format(
", ".join(sorted(missing))
)
)
concepts = {
fact.source_concept for fact in by_bucket.values()
}
return by_bucket, concepts
A source-concept set with more than one member should not automatically fail. It should trigger a continuity check.
The most useful continuity check compares the filing’s current-period and comparative-period facts. If the new concept provides prior-year values that agree with the old concept, the transition is probably representational. If the comparative series changes materially, the pipeline should record a restatement or definition break.
Attach availability dates to derived quarters
Derived values inherit the latest availability date of their inputs.
Q4 cannot be known before the 10-K is filed because it depends on the full-year fact. Q2 cannot be known before the six-month fact is filed. Backdating derived quarters to the fiscal period end introduces lookahead.
@dataclass(frozen=True)
class QuarterValue:
label: str
value: Decimal
period_end: date
available_on: date
source_accessions: Tuple[str, ...]
def derived_quarter(
label,
later_fact,
earlier_fact,
):
return QuarterValue(
label=label,
value=later_fact.value - earlier_fact.value,
period_end=later_fact.end,
available_on=max(
later_fact.accepted,
earlier_fact.accepted,
),
source_accessions=tuple(sorted({
later_fact.accession,
earlier_fact.accession,
})),
)
The resulting row has two clocks:
period_end: when the economic activity occurred.available_on: when the market could first observe the derived value from the selected filings.
Every screen and valuation snapshot should state which clock it uses.
Calculate trailing twelve months revenue
Once discrete quarters exist, TTM revenue is the sum of the latest four compatible quarters available on the valuation date.
def calculate_ttm_revenue(quarters, as_of):
visible = [
quarter
for quarter in quarters
if quarter.available_on <= as_of
]
visible.sort(
key=lambda quarter: (
quarter.period_end,
quarter.available_on,
)
)
latest_four = visible[-4:]
if len(latest_four) != 4:
raise ValueError("Four visible quarters are required")
for previous, current in zip(
latest_four,
latest_four[1:],
):
gap = (current.period_end - previous.period_end).days
if not 70 <= gap <= 112:
raise ValueError(
"Non-contiguous fiscal quarters: {} days".format(gap)
)
return sum(
quarter.value for quarter in latest_four
)
The continuity range accommodates non-calendar fiscal quarters. A production implementation should additionally verify issuer, metric, currency, consolidated scope, and fiscal-quarter identity.
The function should fail closed. Guessing across a missing quarter creates a fabricated denominator.
A quarter-reconstruction chart
The chart below shows why cumulative facts cannot be summed directly. Q1 is reported as a discrete quarter. Q2, Q3, and Q4 are derived by subtracting adjacent cumulative durations. The final bar is the full trailing-twelve-month total.
The visual distinction between reported and derived quarters should remain visible in monochrome. The hatch pattern carries meaning without depending on color.
Validation rules that catch expensive mistakes
The normalization pipeline should emit diagnostics with every result. At minimum:
def validate_reconstruction(by_bucket):
q1 = by_bucket["Q"]
h1 = by_bucket["H1"]
m9 = by_bucket["M9"]
fy = by_bucket["FY"]
facts = [q1, h1, m9, fy]
if len({fact.cik for fact in facts}) != 1:
raise ValueError("Mixed issuers")
if len({fact.metric for fact in facts}) != 1:
raise ValueError("Mixed metrics")
if len({fact.start for fact in facts}) != 1:
raise ValueError("Mixed fiscal-year starts")
if not (q1.end < h1.end < m9.end < fy.end):
raise ValueError("Invalid cumulative period ordering")
quarters = reconstruct_quarters(
q1.value,
h1.value,
m9.value,
fy.value,
)
negative = {
label: value
for label, value in quarters.items()
if value < 0
}
return {
"quarters": quarters,
"source_concepts": sorted({
fact.source_concept for fact in facts
}),
"negative_quarters": negative,
"latest_available_on": max(
fact.accepted for fact in facts
),
"source_accessions": sorted({
fact.accession for fact in facts
}),
}
A negative reconstructed quarter is not automatically wrong. Revenue reversals and unusual accounting events exist. It is a review signal.
Other useful checks include:
- reconstructed quarters sum exactly to the full-year fact;
- consecutive quarter ends are plausible for the issuer’s fiscal calendar;
- units and scaling are consistent;
- consolidated facts have no segment dimensions;
- concept changes are logged;
- amendments do not become visible before their acceptance date;
- the rendered income statement agrees with the selected XBRL facts;
- year-over-year revenue growth is not computed across incompatible fiscal lengths;
- 53-week years are flagged before comparing growth rates.
Validation should produce machine-readable reason codes. A screener with ten thousand issuers cannot depend on ad hoc manual review, but it must make manual review possible.
Store normalized facts and derived metrics separately
A useful storage boundary is:
raw_xbrl_fact
normalized_statement_fact
derived_fiscal_quarter
valuation_snapshot
raw_xbrl_fact preserves the filing as received.
normalized_statement_fact maps accepted source concepts into canonical metrics while retaining provenance.
derived_fiscal_quarter contains discrete quarter values, derivation method, source accessions, period end, and availability date.
valuation_snapshot joins market data to the latest eligible fundamentals for a specified timestamp.
Do not store only the final ratio. Ratios are cheap to recompute. Provenance is expensive to reconstruct after it has been discarded.
A valuation snapshot for enterprise-value-to-sales should identify:
market timestamp
share price source
shares outstanding source
debt source
cash source
TTM revenue quarter IDs
filing acceptance cutoff
normalization version
This is the minimum audit trail for explaining why a company’s multiple changed.
The denominator is part of the model
Revenue looks like a raw accounting input. In practice, TTM revenue is a derived model output.
The model chooses concepts, contexts, units, fiscal periods, filing versions, and availability dates. Those choices affect ranking and valuation. They should be versioned like source code.
This becomes more important when the stock screener evolves into a fair-value engine. A discounted cash flow model, residual-income model, or comparable-company model can be mathematically correct and still produce false precision if its historical fundamentals were reconstructed incorrectly.
The correct engineering order is:
- preserve raw filings;
- normalize concepts with provenance;
- reconstruct discrete periods;
- enforce filing-time visibility;
- calculate trailing metrics;
- compute valuation ratios;
- rank securities.
Skipping directly to step six produces a fast system that is difficult to trust.
Final implementation rule
Never sum four SEC XBRL revenue facts until each fact has been proven to represent one discrete, compatible fiscal quarter.
For most quarterly filers, the reliable construction is:
Q1 = Q1
Q2 = H1 - Q1
Q3 = M9 - H1
Q4 = FY - M9
TTM = latest four discrete quarters
Keep the source concepts, accession numbers, period dates, and filing acceptance dates attached to every derived value.
That is the difference between a ratio calculator and a fundamental stock screener that can support valuation work.