The problem: your backtest knows the future
Most fundamentals APIs serve current truth: the latest filed value for every period, restatements silently applied. That's correct for research today — and quietly fatal for backtests. Two ways history leaks in:
- Filing lag. A December fiscal year isn't knowable on January 1 — the 10-K typically arrives in late February. A backtest that reads FY numbers "in January" trades on data that didn't exist.
- Restatements. When a company restates, ordinary APIs overwrite the original. Your backtest then sees the corrected number on dates when the market was trading on the wrong one.
Every financial endpoint here accepts as_of=YYYY-MM-DD: the response contains only
values filed on or before that date. Facts are stored append-only, keyed by SEC
accession number, so "what was known when" is a query — not a reconstruction.
1 · See it work
Apple's FY2025 10-K was filed on 2025-10-31. Ask for the latest annual income statement as of October 1, 2025 — before that filing existed:
curl -H "Authorization: Bearer $MLQ_KEY" \ "https://mlq.ai/api/v1/companies/AAPL/income-statement/?period=FY&limit=1&as_of=2025-10-01"
You get FY2024 — the most recent annual data any investor actually had on that
date. Drop the as_of and the same call returns FY2025. Every period carries a
source block; check source.filed and you'll see why each value is or
isn't visible at your cutoff. That's the audit trail: the constraint isn't our promise, it's the
filing date on the record.
2 · Build a point-in-time factor table
The core loop of any fundamentals backtest: for each rebalance date, fetch metrics as of that date. Here's a quality screen (ROIC + margins) over quarterly rebalances:
import requests, pandas as pd
BASE = "https://mlq.ai/api/v1"
H = {"Authorization": "Bearer YOUR_KEY"}
UNIVERSE = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META",
"AVGO", "ORCL", "COST", "LLY"] # yours: /companies/?q=
REBALANCES = ["2025-03-01", "2025-06-01", "2025-09-01", "2025-12-01"]
rows = []
for date in REBALANCES:
for t in UNIVERSE:
r = requests.get(f"{BASE}/companies/{t}/metrics/",
params={"period": "FY", "as_of": date}, headers=H)
if r.status_code != 200:
continue # not covered / no data yet
j = r.json()
m = j.get("metrics") or {}
rows.append({
"date": date, "ticker": t,
"fy": j.get("fiscal_year"), # the FY visible on that date
"roic": m.get("return_on_invested_capital"),
"gross_margin": m.get("gross_margin"),
"net_margin": m.get("net_margin"),
"accession": (j.get("source") or {}).get("accession"), # receipts
})
df = pd.DataFrame(rows)
# rank within each rebalance date — top-half ROIC, positive margins
df["roic_rank"] = df.groupby("date")["roic"].rank(pct=True)
picks = df[(df.roic_rank > 0.5) & (df.net_margin > 0)]
print(picks.sort_values(["date", "roic"], ascending=[True, False]))
Two details doing quiet work: fy tells you which fiscal year was visible
at each date (it changes mid-year as 10-Ks land — that's the point), and accession
gives every input a receipt you can open on EDGAR.
3 · Statement-level history, same rule
Need raw line items instead of ratios? Same parameter on the statement endpoints — and
period=quarter returns the quarterly series (all quarters, newest first):
r = requests.get(f"{BASE}/companies/NVDA/income-statement/",
params={"period": "quarter", "limit": 8, "as_of": "2025-06-01"},
headers=H)
for p in r.json():
print(p["date"], p["period"], p["revenue"], p["source"]["accession"])
For maximum granularity, /companies/{ticker}/facts/?as_of= returns every
normalized concept with its filing provenance — the raw material if you compute your own factors.
4 · Pitfalls worth knowing
- fiscal_year = the calendar year the period ends in. NVDA's January-2026
year-end is
fiscal_year: 2026. Align ondate(period end), not labels. - Q4 cash-flow values are derived (annual minus nine-month, marked
form: "derived") — companies don't file a separate Q4 report. - Nulls are deliberate. Price-dependent metrics (P/E, market cap) are null — there's no price feed, so nothing here can silently mix stale prices into your factors. If a company doesn't tag a figure, you get null, never an imputed guess.
- Definitions are documented, not smoothed. Operating income is as-filed (restructuring included); debt excludes operating leases; bank revenue is the standard net basis. Field-by-field notes live in the data dictionary.
- Pace yourself. Free tier is 100 requests/day — fine for prototyping the
loop above; the paid tiers (100k/1M per month) fit full-universe runs.
List endpoints paginate with
limit+offset.
Using an agent instead?
Everything above works over the MCP server — the statement, metrics,
and facts tools all take as_of. Ask Claude: "Using MLQ, what were MSFT's margins
as known on 2025-03-01, and cite the filing."