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:

  1. Identify the economic period represented by each XBRL fact.
  2. map multiple company concepts into one canonical metric without hiding accounting differences.
  3. reconstruct discrete fiscal quarters from cumulative periods.
  4. 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:

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:

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.

Quarter reconstruction from cumulative XBRL facts. Q1 is reported directly; Q2–Q4 are derived by subtracting adjacent cumulative durations; TTM is the sum of four discrete quarters.

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:

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:

  1. preserve raw filings;
  2. normalize concepts with provenance;
  3. reconstruct discrete periods;
  4. enforce filing-time visibility;
  5. calculate trailing metrics;
  6. compute valuation ratios;
  7. 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.