Overview
A backtest needs five things: a universe that includes the names that died, prices you can take returns from, fundamentals as they were known on the day, a benchmark, and macro. Each has a call. Each also has one way to produce a plausible wrong answer, and this page names it.1. The universe: research_universe
ticker, isin, instrument_type, cnpj, cnpj_basis, first_observed, last_observed, n_sessions, setor_current, built_at.
The survivorship rule. A rebalance on date T trades the pairs with first_observed <= T <= last_observed. That is what as_of=T does, on the client. Without it you get every pair, including names that stopped trading years ago. Choosing today’s survivors and looking backwards is the classic survivorship bias.
Read these five before you trust a row.
- The ISIN is the identity, and a rename is two rows.
ELET3until 2025-11-07 andAXIA3from 2025-11-10 are separate pairs; nothing links them. - A gap still matches.
NATU3is one ISIN with no sessions from 2019-12 to 2025-07, so the filter says it “existed” on a date inside the gap.n_sessionsfar below the calendar span is the sign; check the series before assuming it traded on T. first_observedandlast_observedare facts about SILO’s tape, not listing dates. There are no listing or delisting dates and nois_active.last_observedlags the tape by up to a day (the view is rebuilt daily), soas_of=todaycan miss a name that traded this morning.setor_currentis CVM’s classification as of today, never the sector on T. Using it to neutralise a 2020 portfolio is look-ahead.
cnpj_basis says how the company link was made: fca_ticker (CVM’s published ticker map has that exact ticker), fca_issuer_stem (the ticker’s four-letter stem maps to exactly one CNPJ: an inference, labelled as one) or null (no link; cnpj and setor_current are null too). Nothing is matched by name.
2. Prices: quote_history
One ticker per call, one JSON object per session, holding only the fields you select. With no selection you get ticker, trade_date and close_adj; the raw close, OHLC and volume are one explicit selection away (fields=["close", "volume"]).
Anchored to the latest session means a past level changes when a new event lands; a return does not. Never append a new pull to an old one: re-pull the whole series, or take returns inside one pull. Every response carries
data_revision, and pages or pulls with different revisions are never combined. prices() and quote_history_all() restart when it changes in the middle of a walk.
close_adj refuses instead of guessing. A window it cannot adjust raises 22023 (reason=adjustment_unavailable) naming the ticker, the period and the cause: the paper is not a share or a unit, the issuer’s corporate events are not proven swept (or were proven before the last session), or a stock event this version does not adjust (a spin-off, a merger, a capital restitution in shares) falls inside the window. It is never the raw close under another name. Dividends, JCP and subscription rights are not adjusted: they move value to holders and change no share count, so they belong to total return.
close_total_return does not refuse by itself: a NULL is a reason to read, not a gap to fill. Select it without close_adj and an unservable session comes back NULL with its reason; select it beside close_adj and the whole call is refused whenever close_adj is. It is NULL where close_adj cannot be served for the session, where a distribution cannot be valued, and where B3’s history holds no resolved distribution for the ISIN. A non-payer and an issuer the history does not match look identical, so neither is given a price return labelled as a total return.
reason=: unknown_ticker, outside_coverage, isin_change (two ISINs the ticker’s lineage does not splice; a reused receipt code is a new instrument and is never joined), ambiguous_session, invalid_field and adjustment_unavailable. The tape starts on 2019-01-02, and a window that starts before an instrument’s first session is outside_coverage, even when it would hold no rows. Inside the coverage a missing session is a session with no trade (COTAHIST lists only papers that traded); prior_no_trade_sessions counts them.
Many tickers: prices
There is no multi-ticker function, and a name over one page takes one call per page. The SDK walks every page of every ticker for you and returns the whole request or raises:
SiloRevisionChanged after three tries). When several tickers are asked for and any is refused, prices() finishes the walk and raises SiloRefusals naming every refused ticker with the server’s reason, so a hundred-name pull never silently loses three names. Nothing is dropped or filled in. fields=["close_adj", "close_total_return"] selects more; the result has one row per ticker and session, sorted by ticker and date.
It walks the tickers one after another. On 2026-09-28 the same walk over 100 liquid names took about 70 seconds. The server drew no rate limiting on concurrent callers that day, but a concurrent client is not built, and pages from different revisions must never be combined, so run your own threads only if every walk checks
data_revision. Nothing raises the 1,000-row page, for anyone.Returns from the levels
3. The benchmark: index_history
index_code, trade_date, level, divisor_step, source. IBOV is held from 1968-01-02; IBXX, IBXL, IFIX, SMLL, IDIV, ICON, IMOB and UTIL each from its own first session (coverage() has the depth of each).
- It takes an index code, never a ticker.
BOVA11is an ETF;IBOV11is the Ibovespa options settlement code: itsasset_classisindex, and each print is that day’s settlement index, never the official close (in 2026 none of its 181 prints equals the close; they differ by 0.18% on average and by up to 1.07%). It printed on expiry days only, 12 a year, through 2024, weekly in 2025, and on nearly every session since December 2025 (181 of 187 sessions of 2026), so a denseIBOV11series is not a sign that it is the index. Neither is the index, so both are refused with22023, naming the codes held. - Every code is a total-return index, as B3 labels it. B3’s own pages and its Manual (Feb 2023, section 1.2) call IBOV, IBXX, IBXL, IFIX, SMLL, IDIV, ICON, IMOB and UTIL indices of retorno total: distributions are reinvested in the index, so a level already includes dividends. Compare it with
close_total_returnfromquote_history, never withclose_adj, which is price only. Where B3 has a price-return version (IDIV B3 Price Return) it carries its own name and is not served. Levels before an index’s publication date are B3’s back-calculation, and the endpoint does not mark them. - It is not adjusted. B3 re-scaled IBOV eleven times (1983-10-04 by 100 and ten sessions by 10, the last on 1997-03-03).
divisor_stepis true on the first session after each, where a level ratio is not a return. Exclude or splice those sessions. start=Noneis the last 365 days, not the whole history.
4. Fundamentals as they were known: as_of
By default the statement functions read each document at its latest stored version, so a later filing or a restatement appears as if it had been known earlier. That is look-ahead. financials, company_financials, income_statements, balance_sheets and cash_flow_statements take as_of:
ref_date still says which period a row is for; start / end window on that, not on the receipt date.
These functions refuse above one page and have no cursor. A long window on several statements can exceed 1,000 lines, so read one statement at a time and narrow the window.
5. Macro: split by date
macro_series has no cursor; the caller splits by date. Measured on 2026-09-28: CDI (a daily series) refuses over four years and three-year chunks fit, so 2019 onward is three calls; SELIC_META fits about 2.7 years per call. A refusal is cheap and says why, so it is safe to start wide and halve. Read unit on every row: the series differ (CDI is percent per business day, SELIC_META percent a year).
Not served
Historical index membership, BDRs in the universe, a sector history, spin-offs, mergers, subscriptions and any link across a rename, fundamentals before 2019, and recovery of superseded ITR/DFP versions.close_adj and close_total_return exist only for shares and units; for any other paper the adjusted close is refused and the raw close is one explicit selection away.
See also Quotes, Reading a fundamental, Paging without losing rows, Known limitations and the SDK.