Skip to main content

Request historical data with valid boundaries

Historical bars are a bounded query unless keepUpToDate=True turns the newest bar into a continuing subscription. A successful request means the initial query completed; an empty list does not prove there was no market activity.

Complete connection and readiness, account scope, and contract qualification first. These examples use a paper port and read-only connection, request data only, and place no order.

Minimal reproducible request

Capture one timezone-aware boundary and reuse it for retries or pagination. This prevents successive attempts from silently asking for different windows.

import asyncio
from datetime import datetime, timezone

from ib_async import Contract, IB, Stock


async def main() -> None:
ib = IB()
try:
await ib.connectAsync(
host="127.0.0.1",
port=4002,
clientId=11,
timeout=10,
readonly=True,
raiseSyncErrors=True,
)
search = Stock(
"AAPL", "SMART", "USD", primaryExchange="NASDAQ"
)
qualified = await ib.qualifyContractsAsync(search)
if len(qualified) != 1 or not isinstance(qualified[0], Contract):
raise LookupError("contract did not resolve uniquely")
contract = qualified[0]

fixed_end = datetime.now(timezone.utc).replace(microsecond=0)
bars = await ib.reqHistoricalDataAsync(
contract=contract,
endDateTime=fixed_end,
durationStr="2 D",
barSizeSetting="5 mins",
whatToShow="TRADES",
useRTH=True,
formatDate=2,
keepUpToDate=False,
timeout=30,
)
if not bars:
raise LookupError("empty or timed-out historical query")
if any(
not isinstance(bar.date, datetime)
or bar.date.utcoffset() is None
for bar in bars
):
raise ValueError("intraday bar is not a timezone-aware datetime")
print(fixed_end.isoformat(), bars[0].date, bars[-1].date)
finally:
ib.disconnect()


asyncio.run(main())

IB.reqHistoricalDataAsync returns after the initial historicalDataEnd callback. IB.reqHistoricalData is the blocking wrapper over the same asynchronous operation. A completed one-shot query has no continuing subscription to cancel.

Define the query boundary

ParameterBoundary to record
endDateTimeEmpty means the server's current moment. For reproducible one-shot work, pass an aware datetime and persist the exact UTC value. keepUpToDate=True and continuous-future requests require an empty end.
durationStrInteger plus S, D, W, M, or Y. This is a lookback from the end, not a row count.
barSizeSettingAn exact supported token such as 1 secs, 5 secs, 1 min, 5 mins, 1 hour, or 1 day. Validate it together with the duration.
whatToShowThe series used to build bars, such as TRADES, MIDPOINT, BID, or ASK. Instrument support varies.
useRTHWhether IBKR filters the result to the instrument's regular trading hours. Store it with the data.
formatDateIn pinned ib_async, 2 converts intraday values to aware UTC datetimes. Daily bars remain date-like because the protocol supplies only yyyyMMdd.
keepUpToDateMakes the unfinished newest bar a continuing subscription. It supports TRADES, MIDPOINT, BID, and ASK, and requires an empty end.
timeoutA client-side wait limit. Zero waits indefinitely. When a positive finite deadline expires, this request path cancels and returns an empty list. The production owner below rejects an indefinite deadline so cancellation can finalize.

Daily bars require separate interpretation because they are date-only rather than timestamps. Persist the request timezone, session policy, and retrieval time instead of inventing an intraday timestamp.

Pair duration and bar size

IBKR limits the duration available for each bar size. The current table permits only 2000 S for one-second bars. For five-second and larger bars it accepts broader duration units; for example, five-second bars allow at most 86400 S, so a longer request must use a day-based duration. Treat the official maximum-duration table as the source of truth instead of retaining an old hard-coded step-size table.

For a large fixed range:

  1. Capture the inclusive target start and fixed end once.
  2. Request the newest valid page whose duration/bar-size pair is allowed.
  3. Move the next end backward from the oldest returned timestamp with a small overlap.
  4. Deduplicate by qualified contract identity, series, bar size, and bar timestamp.
  5. Stop at the target start, the earliest available point, or a classified terminal error.

Pacing and unavailable history

Use one scheduler for historical work. Keep concurrency materially below the official maximum of 50 open historical requests and apply the small-bar rules to bars of 30 seconds or less:

  • do not repeat an identical request inside 15 seconds;
  • do not make six or more requests for the same contract, exchange, and tick type inside two seconds;
  • do not exceed 60 requests in ten minutes;
  • count a BID_ASK request twice.

Do not blind-retry a pacing error. Queue it behind the scheduler, retain the original fixed range, and use bounded backoff. Apply bounded concurrency and explicit pacing-error handling to larger bars as well.

Important retention and product exclusions include 30-second-or-smaller bars older than six months, futures more than two years after expiration, expired options/futures options/warrants/structured products, and native combo history. Confirm current availability before designing a backfill around an expired instrument.

Historical trade data filters some off-NBBO activity. Its volume and VWAP can therefore differ from unfiltered real-time data, and historical adjustments, compression, or filtering can make results retrieved at different times differ. Historical bars are a market-data view, not an immutable execution ledger.

Production-pattern streaming owner

With keepUpToDate=True, the initial bars arrive first and historicalDataEnd resolves the request. Later updates commonly reuse the newest timestamp every four to six seconds until that bar completes. Pinned ib_async ignores an update older than the newest bar and ignores an exact duplicate. It replaces a changed bar with the same timestamp or appends a later timestamp, then emits BarDataList.updateEvent with hasNewBar=False for a replacement or True for an append.

The returned list owns the request ID needed by IB.cancelHistoricalData. Keep that exact object and cancel it in finally; cancellation returns no acknowledgement.

from __future__ import annotations

import asyncio
from datetime import datetime, timezone
from math import isfinite

from ib_async import Contract, IB, Stock
from ib_async.objects import BarDataList


def validate_history_boundary(
end: datetime | str, keep_up_to_date: bool
) -> datetime | str:
if keep_up_to_date:
if end != "":
raise ValueError("keepUpToDate requires empty endDateTime")
return end
if not isinstance(end, datetime):
raise ValueError("one-shot history requires a fixed datetime")
if end.tzinfo is None or end.utcoffset() is None:
raise ValueError("historical boundary must be timezone-aware")
return end


class HistoricalSubscription:
def __init__(
self, ib: IB, contract: Contract, timeout: float = 30
) -> None:
if not isfinite(timeout) or timeout <= 0:
raise ValueError("historical owner timeout must be positive and finite")
self.ib = ib
self.contract = contract
self.timeout = timeout
self.bars: BarDataList | None = None

async def start(self) -> BarDataList:
if self.bars is not None:
raise RuntimeError("historical subscription already active")
end = validate_history_boundary("", keep_up_to_date=True)
request = asyncio.create_task(
self.ib.reqHistoricalDataAsync(
contract=self.contract,
endDateTime=end,
durationStr="1 D",
barSizeSetting="5 mins",
whatToShow="TRADES",
useRTH=True,
formatDate=2,
keepUpToDate=True,
timeout=self.timeout,
)
)
try:
bars = await asyncio.shield(request)
except asyncio.CancelledError:
try:
bars = await request
except Exception:
pass
else:
self.bars = bars
self.close()
raise
self.bars = bars
if not bars:
raise LookupError("empty or timed-out initial history")
return bars

def close(self) -> bool:
if self.bars is None:
return False
self.ib.cancelHistoricalData(self.bars)
self.bars = None
return True


async def main() -> None:
ib = IB()
owner: HistoricalSubscription | None = None
try:
await ib.connectAsync(
host="127.0.0.1",
port=4002,
clientId=11,
timeout=10,
readonly=True,
raiseSyncErrors=True,
)
search = Stock(
"AAPL", "SMART", "USD", primaryExchange="NASDAQ"
)
qualified = await ib.qualifyContractsAsync(search)
if len(qualified) != 1 or not isinstance(qualified[0], Contract):
raise LookupError("contract did not resolve uniquely")

owner = HistoricalSubscription(ib, qualified[0])
bars = await owner.start()
await asyncio.wait_for(bars.updateEvent, timeout=30)
newest = bars[-1]
retrieved_at = datetime.now(timezone.utc)
print(newest.date, newest.close, retrieved_at.isoformat())
finally:
if owner is not None:
owner.close()
ib.disconnect()


if __name__ == "__main__":
asyncio.run(main())

Shielding the initial request is deliberate: cancelling start() waits for that finite request deadline, captures its returned owner object, cancels any streaming subscription, and only then propagates cancellation. Assigning self.bars before checking for an empty result is also deliberate. In pinned version 2.1.0, an async timeout sends a server cancellation and clears partial bars, so timeout and genuine empty history share the same return shape. For a streaming request, that timeout path leaves the local subscription mapping present; close() calls cancelHistoricalData again to end local ownership. Treat the call as failed, inspect correlated error/log state, and never translate the empty list into “no trading occurred.”

Persist each accepted page before advancing the pagination cursor: qualified contract ID and routing fields, exact requested start/end, duration, bar size, series, useRTH, format/timezone, retrieval time, and the raw bar identity. Make writes idempotent because overlapping pages and streaming replacements are expected.

Failure and recovery matrix

SignalMeaning for this workflowOwner action
Empty listTimeout, no available rows, closed/illiquid period, unsupported series, entitlement, or retention boundary are still ambiguous.Fail closed; correlate errors and logs before classifying. Retry only through the pacing owner.
Message 162 / 165Historical market-data service error/query message.Record request ID and text; classify the returned detail rather than retrying every variant.
Message 166Expired-contract history violation.Stop that range or change the product/time boundary based on retention policy.
Message 391Invalid date, time, or timezone.Reject the boundary and rebuild it as an aware fixed value.
Message 1101Connectivity restored and market-data requests were lost.Rebuild desired streaming subscriptions once, with new owners, after readiness is restored.
Message 1102Connectivity restored and data was maintained.Reconcile before replaying; do not duplicate a surviving subscription.

On process restart, do not assume an old request ID or list can be recovered. Recreate only desired subscriptions after connection and contract readiness, and keep stored historical pages separate from active subscription state.

Evidence and offline boundary

Behavioral claims above are backed by the pinned ib_async 2.1.0 request and cancellation implementation, its asynchronous request lifecycle, its streaming update handling, and the current IBKR Campus sections for request parameters, maximum duration per bar, streaming updates, pacing, unavailable history, and filtering.

CI compiles and signature-checks both examples. Offline tests exercise the boundary validator, cancellation-safe owner, pinned timeout/cancellation mapping, and update ordering without connecting to TWS. They do not prove entitlements, venue retention, farm availability, pacing behavior, returned prices, or callback timing; verify those read-only behaviors in a paper session before relying on them operationally.