diff --git a/README.md b/README.md index 1b4721c..7038839 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Open `http://localhost:8000`, choose a cutoff and fiscal year, inspect the formu | Lineage | Formula → named input → full fact ID → concept, dates and accession | | Replay | Hash checks and full recomputation of CSV, JSON, decisions, lineage and HTML | -Nine outputs: net margin, operating margin, cash conversion, current ratio, liability ratio, free cash flow, FCF margin, accounting balance residual and reported annual revenue growth. Fractions preserve exact values; decimal display is rounded. Growth is **not week-normalized**. +Nine outputs: net margin, operating margin, cash conversion, current ratio, liability ratio, free cash flow, FCF margin, accounting balance residual and reported annual revenue growth. Fractions preserve exact values; decimal display is rounded. Growth is **not week-normalized**. Its base must be the immediately preceding configured period with an adjacent calendar: the earlier end must be exactly one day before the current start. A skipped year or calendar gap keeps the current-period metrics and marks growth `missing_input` for `previous_period_revenue`. ## Demo evidence diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index e0b4365..b3e7050 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -19,9 +19,9 @@ Monetary inputs are integer USD; booleans, floats and numeric strings fail. Nega | Free cash flow | Operating cash flow − capex payments | | FCF margin | Free cash flow / revenue | | Balance residual | Assets − liabilities − equity | -| Revenue growth | Revenue / immediately preceding configured period revenue − 1 | +| Revenue growth | Revenue / immediately preceding, calendar-adjacent configured period revenue − 1 | -FCF is a defined analytic convention. Growth compares reported periods without week normalization; the caller is responsible for specifying appropriate consecutive annual periods. Missing input and zero denominator have distinct statuses. A nonzero balance residual is evidence, not an automatic repair. +FCF is a defined analytic convention. Growth compares reported periods without week normalization. The caller supplies the issuer's annual fiscal calendar; the engine requires the previous configured period to end exactly one day before the current one starts. It does not infer adjacency from labels or divide a multi-year change into an annual rate. A skipped year or even a one-day gap leaves current-period facts and other metrics available but marks growth `missing_input` for `previous_period_revenue`, without linking the non-adjacent period as an input. A 53-week year followed by a 52-week year remains valid when the dates are adjacent. Missing input and zero denominator have distinct statuses. A nonzero balance residual is evidence, not an automatic repair. The verifier enforces the fixed member set, rejects symlinks, checks hashes and regenerates all files, including HTML and source links. It catches output-only corruption with rewritten hashes; it cannot authenticate source data or detect a complete coordinated rewrite. The independent SQL test oracle checks selection on the demo, not arbitrary issuers' accounting policies. diff --git a/src/statementtrace/engine.py b/src/statementtrace/engine.py index e7f3e1a..fa947ab 100644 --- a/src/statementtrace/engine.py +++ b/src/statementtrace/engine.py @@ -228,8 +228,11 @@ def analyze(data, request): facts = normalize(data) panels, decisions = [], [] for cutoff in request["as_of_dates"]: - prior_revenue = None + prior_revenue, previous_end = None, None for period in request["periods"]: + # Ordered, non-overlapping requests may omit fiscal years. A previous + # listed period is a growth base only when its calendar is adjacent. + consecutive = previous_end is not None and (iso_date(period["start"], "start") - previous_end).days == 1 selected = {} for metric in METRICS: fact, reasons = choose(facts, metric, period, cutoff) @@ -257,10 +260,11 @@ def analyze(data, request): ) for key, f in selected.items() }, - "metrics": calculate(selected, prior_revenue), + "metrics": calculate(selected, prior_revenue if consecutive else None), } ) prior_revenue = selected["revenue"] + previous_end = iso_date(period["end"], "end") return { "schema_version": 1, "entity": data["entityName"], diff --git a/tests/test_growth_calendar.py b/tests/test_growth_calendar.py new file mode 100644 index 0000000..1d91f0e --- /dev/null +++ b/tests/test_growth_calendar.py @@ -0,0 +1,62 @@ +"""A gap in configured fiscal periods must not become reported annual growth.""" + +import tempfile +import unittest +from pathlib import Path + +from statementtrace.cli import demo_inputs +from statementtrace.engine import analyze +from statementtrace.report import verify, write_report + + +def growth(panel): + return next(metric for metric in panel["metrics"] if metric["metric"] == "revenue_growth") + + +class GrowthCalendarTests(unittest.TestCase): + def test_one_day_gap_keeps_current_metrics_but_omits_the_growth_base(self): + data, request = demo_inputs() + old_start = request["periods"][1]["start"] + request["periods"][1]["start"] = "2023-10-02" + for concept in data["facts"]["us-gaap"].values(): + for rows in concept["units"].values(): + for row in rows: + if row.get("start") == old_start: + row["start"] = "2023-10-02" + result = analyze(data, request) + metric = growth(result["panels"][-1]) + self.assertEqual(metric["status"], "missing_input") + self.assertEqual(metric["missing"], ["previous_period_revenue"]) + self.assertEqual(metric["exact"], "") + self.assertEqual([item["role"] for item in metric["inputs"]], ["revenue"]) + self.assertEqual(result["panels"][-1]["facts"]["revenue"]["record"]["val"], 391035000000) + other = next(item for item in result["panels"][-1]["metrics"] if item["metric"] == "net_margin") + self.assertEqual(other["status"], "ok") + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "gapped-report" + write_report(data, request, path) + self.assertTrue(verify(path)["verified"]) + + def test_skipped_year_is_not_treated_as_the_immediately_previous_fiscal_year(self): + data, request = demo_inputs() + old_start, old_end = request["periods"][0]["start"], request["periods"][0]["end"] + request["periods"][0].update(start="2021-09-25", end="2022-09-30", label="Earlier synthetic period") + for concept in data["facts"]["us-gaap"].values(): + for rows in concept["units"].values(): + for row in rows: + if row["end"] == old_end: + row["end"] = "2022-09-30" + if row.get("start") == old_start: + row["start"] = "2021-09-25" + panel = analyze(data, request)["panels"][-1] + self.assertEqual(growth(panel)["missing"], ["previous_period_revenue"]) + self.assertEqual(growth(panel)["status"], "missing_input") + + def test_calendar_adjacency_uses_dates_including_53_week_years_not_labels(self): + data, request = demo_inputs() + request["periods"][0]["label"] = "Earlier" + request["periods"][1]["label"] = "Later" + panels = analyze(data, request)["panels"] + self.assertEqual([panel["duration_days"] for panel in panels[-2:]], [371, 364]) + self.assertEqual(growth(panels[-1])["status"], "ok") + self.assertEqual(growth(panels[-1])["exact"], "1550/76657")