MarketDataApp/sdk-py

Official Python SDK for the Market Data API providing real-time and historical U.S. stock and options market data. Access prices, quotes, options chains, and time series data for trading, quantitative research, analytics, and automated workflows.

★ 7Forks 1PythonGitHub ↗Compare
apifinancial-datamarket-datamarketdataoptions-apioptions-datapypipythonquantitative-financerest-apisdkstock-apistock-markettrading

README

Market Data Python SDK v1.1

Access Financial Data with Ease

This is the official Python SDK for Market Data. It provides developers with a powerful, easy-to-use interface to obtain real-time and historical financial data. Ideal for building financial applications, trading bots, and investment strategies.

Tests Coverage License PyPI version Downloads Python

Connect With The Market Data Community

Website Discord Twitter Helpdesk

Features

  • Real-time Stock Data: Prices, quotes, candles (OHLCV), earnings, and news
  • Options Trading Data: Complete options chains, expirations, quotes, and lookup
  • Mutual Funds: Historical candles and pricing data
  • Market Status: Real-time market open/closed status for multiple countries
  • Utilities: API service status, an echo of your request headers, and your account's credit counters
  • Multiple Output Formats: DataFrames (pandas/polars), JSON, CSV, or Python objects
  • Built-in Retry Logic: Automatic retry with exponential backoff for reliable data fetching
  • Type-Safe: Full Pydantic validation and type hints
  • Zero Config: Works out of the box with sensible defaults

Requirements

  • Python >= 3.10

Installation

Basic Installation

Install from PyPI:

pip install marketdata-sdk-py

Or if you're using uv:

uv pip install marketdata-sdk-py

Installation with DataFrame Support

To use OutputFormat.DATAFRAME, you need to install at least one DataFrame library. See Optional Dependencies for details.

Install with pandas (recommended):

pip install "marketdata-sdk-py[pandas]"
# or using uv
uv pip install "marketdata-sdk-py[pandas]"

Install with polars:

pip install "marketdata-sdk-py[polars]"
# or using uv
uv pip install "marketdata-sdk-py[polars]"

Install with both:

pip install "marketdata-sdk-py[pandas,polars]"
# or using uv
uv pip install "marketdata-sdk-py[pandas,polars]"

Local Development Installation

For local development, install from the project directory:

pip install .
# or with optional dependencies
pip install ".[pandas]"

Configuration

The SDK requires a MarketData authentication token. You can provide it in two ways:

Option 1: Environment variable (recommended)

Create a .env file in the project root:

MARKETDATA_TOKEN=your_token_here

Option 2: Pass token directly

You can pass the token when creating a client instance:

from marketdata import MarketDataClient

client = MarketDataClient(token="your_token_here")

Usage

Create a client

from marketdata import MarketDataClient
from logging import Logger

# Token will be automatically obtained from MARKETDATA_TOKEN environment variable
client = MarketDataClient()

# Or provide the token explicitly
client = MarketDataClient(token="your_token_here")

# You can also provide a custom logger
custom_logger = get_logger()  # Your custom logger setup
client = MarketDataClient(token="your_token_here", logger=custom_logger)

Client Initialization Details:

  • The client makes a request to /user/ during initialization to seed the pre-flight credit check
  • The client includes a User-Agent header with the format marketdata-sdk-py/{version} (e.g., marketdata-sdk-py/1.1.0) in RFC 7231 compliant format
  • The library version is automatically detected from the installed package
  • All requests include an Authorization: Bearer {token} header
  • The client uses httpx.Client for HTTP requests with automatic connection pooling
  • Every request has the same fixed timeout, and it is not configurable: 2 seconds to open the connection, and 99 seconds each to write the request, wait for a free connection, and read a chunk of the answer. Those bound operations rather than the call, so a server that keeps trickling bytes can hold a request open longer; to give up sooner, cancel the call from your own code

Credits and Response Metadata

Every call returns the data you asked for. The metadata of the HTTP exchange behind it (the credits it cost, the balance after it, the request id) travels with that result, so under concurrent calls each result speaks for its own request:

import marketdata

client = marketdata.MarketDataClient()
prices = client.stocks.prices("AAPL")

meta = marketdata.get_meta(prices)          # ResponseMeta
print(meta.rate_limits.credits_consumed)    # what this call cost
print(meta.rate_limits.credits_remaining)   # the balance after it
print(meta.rate_limits.reset_time)          # datetime of the next reset
print(meta.request_id)                      # the cf-ray id, for support, or None
print(meta.rate_limits)                     # "Credits used X/Y, remaining: Z, reset at: ISO timestamp"

get_meta() works on every output format: record lists, single objects, JSON dicts and CSV paths (they stay list, dict and str for isinstance), pandas DataFrames (also reachable as df.attrs["marketdata"]) and polars DataFrames. For a call made of several requests (candle chunks, option symbols, retried attempts) credits_consumed adds up, credits_remaining is the lowest count seen in the newest reset window, and meta.responses says how many responses are behind the result. status_code and request_id describe one response, so they come from the last one that could have contributed to the result, never from a symbol or chunk that answered "no data" and was dropped from the merge, which matters because request_id is what you quote in a support ticket; it is None when the answer carried no usable cf-ray. On the metadata of a call that raised, they describe the request the exception is about, the one a ticket would be about, so they agree with exc.status_code and exc.request_id (where the exception says "N/A", the metadata says None): the response the exception carries, or 0 and None when the SDK has no response for that request (a network failure, the pre-flight refusal, a body that does not match its Content-Encoding). An exception that is not the SDK's own (a FileExistsError from the CSV write) does not say which request caused it, so there they follow the rule of a successful call. rate_limits is None when the API sent no credit headers (utilities.status() and utilities.headers()), and the only result that cannot carry metadata is None itself (a single-object endpoint with no data). meta.detected_ip is the address the API saw the call come from, on every answer it serves, the empty one included.

A failed call is billed too, so the exception carries the same metadata a result would:

try:
    quotes = client.options.quotes(["AAPL250117C00150000", "NOTASYMBOL"])
except marketdata.BaseMarketdataException as exc:
    meta = marketdata.get_meta(exc)         # None if the call never reached the API
    if meta:
        print(meta.rate_limits.credits_consumed)   # what the failure cost
        print(meta.responses)                      # requests behind it, retries included

There is no client-level snapshot: client.rate_limits was removed in 2.0 because with concurrent calls it reflected whichever request finished last. The SDK still tracks the latest known balance privately for the pre-flight check, and client.utilities.user() returns the account's balance at any time, for free.

Note: Rate limits are tracked via the following response headers:

  • x-api-ratelimit-limit: API credits available in the current window (credit_limit)
  • x-api-ratelimit-remaining: API credits remaining (credits_remaining)
  • x-api-ratelimit-consumed: API credits consumed (credits_consumed)
  • x-api-ratelimit-reset: Unix timestamp when the credits reset (reset_time)

The header names still say ratelimit; the SDK exposes them in the product's API-credits terms.

The reset_time field is automatically converted to a datetime.datetime object for easier use. It always carries an offset: a value that arrives without one is read as US/Eastern, the timezone the SDK renders every timestamp in.

IP restrictions

For an account that restricts access by IP, the API names the addresses involved and the SDK surfaces both:

try:
    prices = client.stocks.prices("AAPL")
except marketdata.ForbiddenError as exc:
    print(exc.authorized_ip)                # the address this account is authorized for
    print(exc.message)                      # says it too, so printing the error is enough
else:
    print(marketdata.get_meta(prices).detected_ip)   # the address the call came from

authorized_ip comes from X-API-Authorized-IP on the 403 and is None for a 403 that is not an IP block. detected_ip comes from X-API-Detected-IP, which the API sends on every answer it serves.

The API resolves the address for an authenticated account that is bound to a single one, which is the ordinary case. It sends no header, so both read None, for an account allowed to call from several addresses, for a staff token, for the Sheets add-on, and when it could not resolve the address at all.

The body of the 403 carries more than the header does: the address that was blocked and a link to the troubleshooting guide. Both are on exc.response.json(), under blockedIP and troubleshootingGuide. The SDK reads the header rather than those field names, because the header is what the API asked clients to move to. It does not read the legacy X-API-BLOCKED-IP, which the API is removing.

The reset_time field is automatically converted to a datetime.datetime object for easier use.

Resources

The SDK provides access to different market data resources:

  • Stocks: Access stock prices, quotes, candles (OHLCV), earnings, and news

    • Methods: prices(), quotes(), candles(), earnings(), news()
    • See Stocks Documentation for detailed usage
  • Options: Access options chains, expiration data, quotes, and lookup

  • Funds: Access funds candles (OHLC) for mutual funds

  • Markets: Access market status information (open/closed) for dates and countries

  • Utilities: API service status, request headers echo (with the IP the API detected) and the account's credit counters

Note: For stocks, options, and funds resources, the symbol, symbols, or lookup parameter (depending on the method) can be passed as the first positional argument or as a keyword argument. All other parameters must be keyword-only. For markets resource, all parameters must be keyword-only.

Quick Example

from marketdata import MarketDataClient

client = MarketDataClient()

# Get stock prices (symbols can be passed positionally or as keyword)
df = client.stocks.prices("AAPL")
# or
df = client.stocks.prices(symbols="AAPL")
print(df)

# Get stock candles (symbol can be passed positionally or as keyword)
df = client.stocks.candles("AAPL")
# or
df = client.stocks.candles(symbol="AAPL")
print(df)

# Get options chain (symbol can be passed positionally or as keyword)
chain = client.options.chain("AAPL")
# or
chain = client.options.chain(symbol="AAPL")
print(chain)

# Get options quotes (symbols can be passed positionally or as keyword)
# Note: quotes() takes option symbols (e.g., "AAPL240120C00150000"), not stock symbols
quotes = client.options.quotes("AAPL240120C00150000")
# or
quotes = client.options.quotes(symbols="AAPL240120C00150000")
print(quotes)

# Get options lookup (lookup can be passed positionally or as keyword)
# Format: "SYMBOL DD-MM-YYYY STRIKE SIDE" (e.g., "AAPL 20-12-2024 150.0 call")
lookup = client.options.lookup("AAPL 20-12-2024 150.0 call")
# or
lookup = client.options.lookup(lookup="AAPL 20-12-2024 150.0 call")
print(lookup)

# Get funds candles (symbol can be passed positionally or as keyword)
df = client.funds.candles("VFINX")
# or
df = client.funds.candles(symbol="VFINX")
print(df)

# Get market status (all parameters must be keyword-only)
df = client.markets.status(countback=7)
print(df)

Output Formats

The SDK supports multiple output formats for API responses. See the Universal Parameters section for details on how to specify output formats.

  • OutputFormat.DATAFRAME: Returns a pandas or polars DataFrame (default). Requires installing pandas or polars as an optional dependency. See Optional Dependencies for installation instructions.
  • OutputFormat.INTERNAL: Returns internal Python objects (see resource-specific documentation for details). Money fields are decimal.Decimal, see Money values
  • OutputFormat.JSON: Returns the decoded JSON as a dictionary, the way httpx's response.json() does (numbers with a fraction are float)
  • OutputFormat.CSV: Writes CSV data to file and returns filename string

For detailed information about return types and object structures for each resource, see the specific resource documentation:

You can specify the output format when calling resource methods:

from marketdata import MarketDataClient, OutputFormat

client = MarketDataClient()

# Get DataFrame (default) - symbols can be passed positionally or as keyword
df = client.stocks.prices("AAPL")
# or
df = client.stocks.prices(symbols="AAPL")

# Get internal objects
prices = client.stocks.prices("AAPL", output_format=OutputFormat.INTERNAL)
# or
prices = client.stocks.prices(symbols="AAPL", output_format=OutputFormat.INTERNAL)

# Get JSON
json_data = client.stocks.prices("AAPL", output_format=OutputFormat.JSON)
# or
json_data = client.stocks.prices(symbols="AAPL", output_format=OutputFormat.JSON)

# Get CSV
# All methods write to file and return filename string
csv_file = client.stocks.prices("AAPL", output_format=OutputFormat.CSV, filename="prices.csv")
# Get candles as internal objects
candles = client.stocks.candles("AAPL", output_format=OutputFormat.INTERNAL)
# If filename is not provided, a timestamped file is created in output/ directory
csv_file = client.options.chain("AAPL", output_format=OutputFormat.CSV)

CSV Output Behavior

When using OutputFormat.CSV, all resources write CSV data to a file and return the filename as a string. If filename is not provided, a timestamped file is automatically created in the output/ directory (the directory is created when the file is written, never for other output formats).

Note: When specifying a custom filename, the directory must exist and the file must not already exist. The file is created exclusively: if the path appears between validation and the write, the call fails instead of overwriting it. CSV bytes are written exactly as the API sent them on every platform. See resource-specific documentation for details on CSV output format.

Money values

With OutputFormat.INTERNAL, every money field is a decimal.Decimal holding the digits the API sent: the prices of quotes, prices and candles (ask, bid, mid, last, change, o, h, l, c), earnings per share, and the option strike, bid, mid, ask, last, intrinsic value, extrinsic value and underlying price. A binary float cannot hold most decimal amounts, so arithmetic on them drifts (0.3 - 0.1 is 0.19999999999999998); with Decimal, quote.ask - quote.bid is exact. Everything else keeps its usual type: greeks, implied volatility and percentages are float, sizes and counts are int.

from decimal import Decimal

from marketdata import MarketDataClient, OutputFormat

client = MarketDataClient()
quote = client.stocks.quotes("AAPL", output_format=OutputFormat.INTERNAL)[0]
spread = quote.ask - quote.bid       # Decimal, exact
quote.bid == Decimal("65.1")         # compare with Decimal literals
float(quote.mid)                     # a float, when a float is what you need

Mixing Decimal and float in arithmetic raises TypeError, and comparing them compares against the float's binary value, so Decimal("65.1") == 65.1 is False. Use Decimal literals, or int, which mixes freely. For the same reason json.dumps(..., default=str) writes a model's money as strings, and a pandas DataFrame built from models has object columns (polars infers its own decimal dtype): OutputFormat.DATAFRAME is the float path for analysis.

The other formats keep the plain parse. A DataFrame never holds a Decimal: it keeps the plain parse, so every column has the dtype it always had on both pandas and polars (float64 for prices with a fraction). A DataFrame is for vectorized analysis, pandas has no decimal dtype, and the same call returning a different dtype depending on which library is installed would be a trap. OutputFormat.JSON returns the decoded JSON with standard float numbers, and OutputFormat.CSV writes the API's text as it came (client.utilities builds its CSV from the decoded body). A NaN or an Infinity in a JSON body is not JSON and fails the call on every format that decodes the body; a number past what a float holds (1e400) is inf on JSON and DATAFRAME and exact on INTERNAL.

Universal Parameters

All resource methods support universal parameters that can be used to customize the API request and response:

output_format (OutputFormat, optional)

The format of the returned data. Defaults to OutputFormat.DATAFRAME.

date_format (DateFormat, optional)

The date format to use in the response. Defaults to DateFormat.UNIX. Available options:

  • DateFormat.TIMESTAMP: US/Eastern text, 2026-09-21 14:02:10 -04:00, or 2026-09-21 for a date
  • DateFormat.UNIX: Unix timestamp (seconds since epoch)
  • DateFormat.SPREADSHEET: days since 1899-12-30 of the US/Eastern wall-clock time, as a spreadsheet serial

On OutputFormat.DATAFRAME, date columns hold the same US/Eastern datetimes under every format (a date is its midnight), except under an explicit DateFormat.UNIX, which keeps the numbers.

columns (list[str], optional)

Specify which columns to include in the response. If not provided, all available columns are returned.

The API applies the filter to the answer it sends, so a filtered answer arrives without the fields the INTERNAL models require, s included unless you list it among the columns. output_format=OutputFormat.INTERNAL therefore ignores the filter and asks for the whole answer; every other output format hands you exactly the columns you asked for.

add_headers (bool, optional)

Whether to include headers in the response. Uses API alias headers.

use_human_readable (bool, optional)

Whether to use human-readable format for values. Uses API alias human.

mode (Mode, optional)

The data feed mode to use. Available options:

  • Mode.LIVE: Live market data
  • Mode.CACHED: Cached data
  • Mode.DELAYED: Delayed data

filename (str | Path, optional)

File path for CSV output (only used with output_format=OutputFormat.CSV).

  • Must end with .csv
  • Directory must exist (if filename is provided)
  • File must not already exist
  • If not provided, a timestamped file is created in output/ directory (the directory is automatically created if it doesn't exist)

Example: Using Universal Parameters

from marketdata import MarketDataClient, OutputFormat, DateFormat, Mode
from pathlib import Path

client = MarketDataClient()

# Use custom date format and mode
df = client.stocks.prices(
    "AAPL",
    date_format=DateFormat.TIMESTAMP,
    mode=Mode.LIVE
)

# Specify columns to include
df = client.stocks.prices(
    "AAPL",
    columns=["symbol", "mid", "change_percent"]
)

# Save CSV with custom filename
csv_file = client.stocks.prices(
    "AAPL",
    output_format=OutputFormat.CSV,
    filename=Path("data/aapl_prices.csv")
)

Error Handling

Resource methods raise on failure; they never return an error object and never return None. Every exception the SDK raises derives from BaseMarketdataException, so one except clause is enough, and every one of them carries the support context described below.

from marketdata import BaseMarketdataException, MarketDataClient

client = MarketDataClient()
try:
    quotes = client.stocks.quotes("AAPL")
except BaseMarketdataException as e:
    print(e.support_info)   # paste this block into a support ticket

Connection failures and undecodable bodies are wrapped (NetworkError, ParseError); errors that are not about the API at all (a Pydantic ValidationError for a bad parameter value, a FileExistsError on CSV output) propagate as they are.

BaseMarketdataException and support_info

Every SDK exception exposes the same six attributes, request_id (the cf-ray header), request_url, status_code, timestamp (US/Eastern), message and exception_type, as plain attributes, as a support_context dict and as the formatted support_info block. Failures that never reached the API (validation, the rate-limit pre-flight) report N/A and 0 for the request fields. An answer that carried no usable cf-ray reports N/A for request_id alone; the status and the URL are the real ones.

--- MARKET DATA SUPPORT INFO ---
request_id:     8a1b2c3d4e5f6g7h-SJC
request_url:    https://api.marketdata.app/v1/stocks/quotes/
status_code:    429
timestamp:      2025-02-21 12:00:00
message:        Rate limit exceeded
exception_type: RateLimitError
--------------------------------

All exception classes are importable from marketdata as well as from marketdata.exceptions.

RateLimitError

Raised when the account has no API credits left, from two places that a caller can tell apart by error.response:

  • The API answered 429. error.response is the answer, and error.retry_after carries the seconds it asked for, when it sent a Retry-After header.
  • The SDK refused to send the request. error.response is None and the request fields read N/A. This happens only when the last answer said there were no credits left and that window has not reset yet; error.retry_after is the number of seconds until it does. An unknown balance never refuses a request, and neither does an exhausted balance whose window has already reset.
from marketdata import MarketDataClient
from marketdata.exceptions import RateLimitError

try:
    client = MarketDataClient()
    df = client.stocks.prices("AAPL")
    # or
    df = client.stocks.prices(symbols="AAPL")
except RateLimitError as e:
    print(f"Rate limit exceeded: {e}")

The exception classes

One class per kind of failure, mapped from the HTTP status the API answered (SDK requirements §6.1 and §9.1):

Status Exception Retried
400 BadRequestError no
401 AuthenticationError no, fails immediately
403 ForbiddenError, with authorized_ip when the block is by IP no
404 with an error message NotFoundError no
404 with s: "no_data" none: the call returns an empty result (see below)
429 RateLimitError, with retry_after in seconds when the API sent it no
500 InternalError no
501 and above ServerError yes, exponential backoff
connection failure, timeout, protocol or proxy error NetworkError yes, unless the client caused it: a base URL without a scheme, a malformed request, a proxy that refuses the connection
undecodable body, a body that does not match its Content-Encoding, a NaN or Infinity in a JSON body, or a value an INTERNAL model cannot read ParseError no
any other 4xx MarketdataHttpError no

A 500 means the API itself failed on your request, so retrying would not help; 501 and above mean the API was unavailable or a gateway answered for it, which is why only those are retried. The two are separate classes: catching one never catches the other.

The output format you asked for never changes which exception a request raises, nor its message: the API sends the error as {"s": ..., "errmsg": ...} for JSON and as an s,errmsg table for CSV, and the SDK reads both.

All HTTP classes derive from MarketdataHttpError and keep the underlying httpx objects on request and response. RateLimitError is also raised by the pre-flight credit check, before any request goes out; in that case response is None, its request fields read N/A, and retry_after is the number of seconds until the balance resets.

No data is not an error

When the API has no data for a valid question (candles over a weekend, news for a quiet day) it answers 404 with s: "no_data". The SDK does not raise for that. It returns the natural empty value for the output format you asked for:

Output format Empty result
DATAFRAME a DataFrame with no rows and the column names, order and dtypes of a populated one; under columns=, the requested ones (names of the model, or API names under use_human_readable=True; the API's aliases such as open or price are not translated)
INTERNAL [] for list-shaped resources (prices, quotes, candles, news, markets.status, utilities.status), None for single-object ones (earnings, options.chain, options.expirations, options.lookup, options.quotes, utilities.headers, utilities.user)
JSON the API's {"s": "no_data"} body
CSV a file with the header row only (the requested columns under columns=; an empty file under add_headers=False)

For the fan-out calls, a chunk (stocks.candles) or a symbol (options.quotes) with no data is simply absent from the merged result; the whole call is empty only when every part is. In CSV output the merged file keeps the header the API sent (the requested columns, the human-readable names) and every row of every part; a part whose body is not a CSV of that resource raises ParseError. On the other formats the parts are merged on the model's columns among those the API sent, in the order it sent them (the request order under columns=), and a part that lacks one of them, carries one that is not a list as long as its others, or whose body is not a JSON object of that resource, raises ParseError too: merged, it would put the next part's values on its rows. A value the model cannot read raises ParseError naming the part it came from.

The API renders the CSV empty answer as a 200 with a placeholder body instead of a 404 (MarketData-App/api#422); the SDK recognises it, so CSV output behaves as above.

One columns= case where the empty and the populated shapes still differ. The API resolves its own column aliases (open for o, price, date), which the SDK does not mirror, because the accepted set depends on the endpoint. Asking for one of those (stocks.candles(columns=["open"])) gets a populated frame with the single column the API sent and an empty frame with every model column, so the two cannot be concatenated. Filter on the names the model exposes (or the API names of its twin under use_human_readable=True) and both shapes have the same columns in the same order.

ValueError

Raised for various validation errors:

  • Invalid date formats when parsing timestamps
  • Invalid output format values
  • Invalid filename format (must end with .csv, directory must exist, file must not exist)
  • Invalid input parameters
from marketdata import MarketDataClient, OutputFormat
from pathlib import Path

try:
    client = MarketDataClient()
    # Invalid filename (doesn't end with .csv)
    csv_file = client.stocks.prices(
        "AAPL",
        output_format=OutputFormat.CSV,
        filename=Path("data/invalid.txt")
    )
except ValueError as e:
    print(f"Validation error: {e}")

MinMaxDateValidationError

Raised when date range validation fails (e.g., from_date is greater than to_date), before any request is made:

from marketdata import MarketDataClient
from marketdata.exceptions import MinMaxDateValidationError
import datetime

client = MarketDataClient()
try:
    candles = client.stocks.candles(
        "AAPL",
        from_date=datetime.date(2024, 12, 31),
        to_date=datetime.date(2024, 1, 1),
    )
except MinMaxDateValidationError as e:
    print(f"Date range validation error: {e}")

KeywordOnlyArgumentError

Raised when arguments are passed incorrectly. Only the symbol or symbols parameter can be passed as a positional argument. All other parameters must be keyword-only:

from marketdata import MarketDataClient, OutputFormat
from marketdata.exceptions import KeywordOnlyArgumentError

try:
    client = MarketDataClient()
    # ❌ This will raise KeywordOnlyArgumentError
    df = client.stocks.prices("AAPL", OutputFormat.DATAFRAME)
    
    # ✅ Correct usage
    df = client.stocks.prices("AAPL", output_format=OutputFormat.DATAFRAME)
    # or
    df = client.stocks.prices(symbols="AAPL", output_format=OutputFormat.DATAFRAME)
except KeywordOnlyArgumentError as e:
    print(f"Invalid argument usage: {e}")

Catching specific errors

Catch the specific class when you want to react differently; BaseMarketdataException catches them all:

from marketdata import MarketDataClient
from marketdata.exceptions import (
    AuthenticationError,
    BadRequestError,
    BaseMarketdataException,
    InternalError,
    NetworkError,
    RateLimitError,
    ServerError,
)

client = MarketDataClient()
try:
    prices = client.stocks.prices("AAPL")
except AuthenticationError:
    print("Check MARKETDATA_TOKEN")
except RateLimitError as e:
    print(f"Out of credits, retry after {e.retry_after} seconds")
except BadRequestError as e:
    print(f"The API rejected the request: {e.message}")
except InternalError as e:
    print(f"The API failed on this request: {e.message}")
except (ServerError, NetworkError) as e:
    print(f"The API stayed unavailable after retries: {e.status_code} {e.message}")
except BaseMarketdataException as e:
    print(e.support_info)

Retry Mechanism

The SDK includes automatic retry logic for handling transient errors: availability errors above 500 (ServerError) and connection failures or timeouts (NetworkError). Nothing else is retried: a 4xx, a 500 (InternalError, the API itself failed) and a rate limit are final answers.

Retry Configuration

  • Default retry attempts: 3
  • Backoff strategy: Exponential with multiplier 0.5, minimum wait 0.5 seconds, maximum wait 5 seconds
  • Retried failures: any HTTP status code greater than 500 (502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout, ...) and any connection failure or timeout. A 500 (InternalError), every 4xx and a 429 are not retried.
  • Request timeout: 99 seconds, with a 2 second connect timeout, the same for every request and not configurable (SDK requirements §10)

How It Works

The retry mechanism only retries ServerError (501 and above) and NetworkError, and only if the API service status is ONLINE or UNKNOWN. The retry adapter uses the tenacity library and will retry up to the specified number of attempts with exponential backoff between retries; a Retry-After header from the API overrides the computed wait.

The retry wraps one request. In the calls made of several requests, stocks.candles (one per year-sized chunk of an intraday range) and options.quotes (one per symbol), each request retries on its own: a chunk or a symbol that fails is re-issued alone, the healthy responses are kept, and one unreachable symbol never re-sends (or re-bills) the others. The attempts still add up in the result's metadata (see Credits and Response Metadata).

One failed request fails the whole call, and the healthy ones are still billed. The requests run in parallel and the call waits for all of them, so when one symbol or chunk ends in a terminal error the others have already been sent, charged and possibly retried. The exception carries that cost: marketdata.get_meta(exc).rate_limits.credits_consumed is what the failed call actually spent.

How long that wait can be, with the default three retries: the error surfaces once the slowest sibling finishes its own ladder, so about 7 seconds of backoff (1 + 2 + 4) when the siblings answer quickly, longer if the API sends Retry-After, and up to four HTTP timeouts (about 6 and a half minutes at the fixed 99 seconds) if they hang instead of answering. Sibling requests keep being sent and billed during that time. Shortening it means cancelling the pending requests, which is #98.

Important: Resource methods either return the requested result (DataFrame, list of objects, dict, or the CSV filename) or raise. There is no error return value; see Error Handling.

API Status Checking

The SDK includes automatic API status checking for certain resource methods. When a retryable failure occurs, the SDK verifies that the API service is online before retrying the request.

How It Works

  • Automatic checking: Methods with API status checking (@api_error_handler decorator) verify service availability when a retryable failure occurs
  • Cached status: API status information is cached and refreshed automatically every 4 minutes and 30 seconds
  • Service-specific: Each method checks the status of its specific service endpoint
  • Retry logic: The SDK only retries requests if the service status is ONLINE or UNKNOWN. If the service is OFFLINE, the error is raised immediately

Methods with API Status Checking

All resource methods include API status checking and automatic retry logic. See the specific resource documentation for details on each method's behavior.

Error Handling

If a service is offline when checked, the method raises the original ServerError (or NetworkError) instead of retrying:

from marketdata import MarketDataClient
from marketdata.exceptions import NetworkError, ServerError

client = MarketDataClient()
try:
    result = client.stocks.candles("AAPL")
except (ServerError, NetworkError) as e:
    print(f"Service unavailable or request failed: {e}")

Status Refresh Behavior

The SDK automatically refreshes the API status cache when:

  • The cached status is older than 4 minutes and 30 seconds
  • A retryable failure (ServerError, NetworkError) occurs in a method with status checking

The status refresh request does not count against rate limits (check_rate_limits=False) and is not part of any result's metadata (part_of_result=False), ensuring that status checking does not interfere with your API usage while providing up-to-date service availability information.

Advanced Configuration

You can customize the base URL, API version, logging level, and universal parameters through environment variables:

# Required
MARKETDATA_TOKEN=your_token_here

# API Configuration
MARKETDATA_BASE_URL=https://api.marketdata.app
MARKETDATA_API_VERSION=v1
MARKETDATA_LOGGING_LEVEL=INFO

# Universal Parameters (optional - can also be passed as method arguments)
MARKETDATA_OUTPUT_FORMAT=dataframe
MARKETDATA_DATE_FORMAT=unix
MARKETDATA_COLUMNS=symbol,mid,change_percent
MARKETDATA_ADD_HEADERS=true
MARKETDATA_USE_HUMAN_READABLE=false
MARKETDATA_MODE=live

Defaults:

  • MARKETDATA_BASE_URL: https://api.marketdata.app
  • MARKETDATA_API_VERSION: v1
  • MARKETDATA_LOGGING_LEVEL: WARNING
  • Universal parameters: None (uses method defaults)

Logging: the SDK logs to the marketdata.logger logger. A client built without a logger= attaches a stream handler to it whenever the logger has none, writing to sys.stderr at MARKETDATA_LOGGING_LEVEL; the logger gets that level too while it has none of its own. A level your application sets on marketdata.logger is kept, and records below it are dropped before any handler sees them, the SDK's included. A handler you attach to that logger before the first client is built stands in for the SDK's; one attached later is added next to it. Two limits: a level set only on a parent logger (marketdata, or the root through logging.basicConfig) is not inherited, and a level equal to the one the SDK applied cannot be told apart from it, so it keeps following MARKETDATA_LOGGING_LEVEL.

Note: Universal parameters set via environment variables will be used as defaults for all API calls, but can be overridden by passing them as method arguments. See the Universal Parameters section for available values.

Project Structure

.
├── docs/
│   ├── stocks.md         # Stocks resource documentation
│   ├── options.md        # Options resource documentation
│   ├── funds.md          # Funds resource documentation
│   ├── markets.md        # Markets resource documentation
│   └── utilities.md      # Utilities resource documentation
├── src/
│   ├── tests/            # Test suite
│   │   ├── conftest.py   # Pytest configuration and fixtures
│   │   ├── test_client.py
│   │   ├── test_stocks_prices.py
│   │   ├── test_stocks_candles.py
│   │   ├── test_options_chain.py
│   │   ├── test_options_expirations.py
│   │   ├── test_options_quotes.py
│   │   ├── test_params.py
│   │   └── data/         # Test data fixtures
│   └── marketdata/
│       ├── __init__.py
│       ├── client.py          # Main MarketDataClient class
│       ├── exceptions.py      # The exception taxonomy (BadRequestError, ServerError, RateLimitError, ...)
│       ├── logger.py          # Logging configuration
│       ├── params.py          # Parameter decorators and validation (@universal_params)
│       ├── retry.py           # Retry mechanism using tenacity
│       ├── settings.py        # Configuration and environment variables
│       ├── types.py           # Data types (UserRateLimits)
│       ├── utils.py           # Utility functions (format_timestamp, initialize_dataframe, etc.)
│       ├── docs.py            # Documentation generation utilities
│       ├── internal_settings.py  # Internal settings (MAX_CONCURRENT_REQUESTS)
│       ├── input_types/       # Input validation types
│       │   ├── __init__.py
│       │   ├── base.py        # Base input type classes (OutputFormat, DateFormat, Mode, UserUniversalAPIParams)
│       │   ├── stocks.py      # Stocks input types (StocksPricesInput, StocksQuotesInput, StocksCandlesInput)
│       │   ├── funds.py       # Funds input types (FundsCandlesInput)
│       │   ├── markets.py     # Markets input types (MarketStatusInput)
│       │   └── options.py     # Options input types (OptionsChainInput, OptionsExpirationsInput, OptionsQuotesInput, OptionsLookupInput)
│       ├── output_types/      # Output data types
│       │   ├── __init__.py
│       │   ├── stocks_prices.py  # Stock prices output types (StockPrice, StockPricesHumanReadable)
│       │   ├── stocks_quotes.py  # Stock quotes output types (StockQuote, StockQuotesHumanReadable)
│       │   ├── stocks_candles.py  # Stock candles output types (StockCandle, StockCandlesHumanReadable)
│       │   ├── stocks_earnings.py  # Stock earnings output types (StockEarnings, StockEarningsHumanReadable)
│       │   ├── stocks_news.py  # Stock news output types (StockNews, StockNewsHumanReadable)
│       │   ├── funds_candles.py   # Funds candles output types (FundsCandle, FundsCandlesHumanReadable)
│       │   ├── markets_status.py  # Markets status output types (MarketStatus, MarketStatusHumanReadable)
│       │   ├── options_chain.py   # Options chain output types (OptionsChain)
│       │   ├── options_expirations.py  # Options expirations output types (OptionsExpirations)
│       │   ├── options_quotes.py  # Options quotes output types (OptionsQuotes)
│       │   └── options_lookup.py  # Options lookup output types (OptionsLookup)
│       └── resources/
│           ├── __init__.py
│           ├── base.py        # BaseResource class with common functionality
│           ├── stocks/        # Stocks API resource
│           │   ├── __init__.py  # StocksResource class definition
│           │   ├── prices.py  # Stock prices endpoint
│           │   ├── quotes.py  # Stock quotes endpoint
│           │   ├── candles.py # Stock candles endpoint
│           │   ├── earnings.py # Stock earnings endpoint
│           │   └── news.py    # Stock news endpoint
│           ├── funds/         # Funds API resource
│           │   ├── __init__.py  # FundsResource class definition
│           │   └── candles.py # Funds candles endpoint
│           ├── markets/       # Markets API resource
│           │   ├── __init__.py  # MarketsResource class definition
│           │   └── status.py  # Markets status endpoint
│           └── options/       # Options API resource
│               ├── __init__.py  # OptionsResource class definition
│               ├── chain.py   # Options chain endpoint
│               ├── expirations.py  # Options expirations endpoint
│               ├── quotes.py  # Options quotes endpoint
│               └── lookup.py  # Options lookup endpoint
└── pyproject.toml        # Project configuration and dependencies

Dependencies

Required

  • httpx>=0.28.1: HTTP client library for making API requests
  • pydantic>=2.12.5: Data validation and settings management
  • pydantic-settings>=2.12.0: Configuration management from environment variables
  • tenacity>=9.1.2: Retry logic library for handling transient errors

Optional Dependencies

The SDK supports multiple DataFrame libraries for OutputFormat.DATAFRAME. You must install at least one of the following:

  • pandas (recommended): pandas>=2.3.3
  • polars: polars-lts-cpu>=1.33.1

Installation

Install with pandas (recommended):

pip install "marketdata-sdk-py[pandas]"
# or using uv
uv pip install "marketdata-sdk-py[pandas]"

Install with polars:

pip install "marketdata-sdk-py[polars]"
# or using uv
uv pip install "marketdata-sdk-py[polars]"

Install with both:

pip install "marketdata-sdk-py[pandas,polars]"
# or using uv
uv pip install "marketdata-sdk-py[pandas,polars]"

DataFrame Handler Priority

When using OutputFormat.DATAFRAME, the SDK automatically selects an available DataFrame library in the following order:

  1. pandas (if installed)
  2. polars (if pandas is not installed)

If neither pandas nor polars is installed, a ValueError will be raised when attempting to use OutputFormat.DATAFRAME:

ValueError: No dataframe output handler found

Note: You can use other output formats (OutputFormat.INTERNAL, OutputFormat.JSON, OutputFormat.CSV) without installing pandas or polars.

Development Dependencies

  • ruff>=0.16.0: Linter, import sorter and formatter
  • pandas>=2.3.3: DataFrame library (for testing)
  • polars-lts-cpu>=1.33.1: DataFrame library (for testing)
  • pytest>=9.0.1: Testing framework
  • respx>=0.22.0: HTTPX mocking library for tests

Implementation Details

Date Format Handling

With OutputFormat.INTERNAL, every date in a response object is a US/Eastern datetime.datetime, whatever date_format was asked for. The SDK reads a value with the API's own rule:

  • timestamp strings: a datetime with its UTC offset (2026-09-21 14:02:10 -04:00), or a date (2026-09-21), which is midnight of that day in US/Eastern
  • Spreadsheet serials: a number from 10000 up to 200000, days since 1899-12-30 of the US/Eastern wall-clock time, rounded to the second
  • Unix times: a number from 200000 on, in seconds, from 1e10 in milliseconds and from 1e13 in nanoseconds

A number under 10000 is not a date for the API (it reads it as a relative range), so a response that carried one raises ParseError.

The from_date and to_date of an intraday stocks.candles() call are read by the SDK only when they are ISO dates or numbers the API reads as dates (a spreadsheet serial or a Unix time), because it splits a long range into one request per year. A relative range (a number under 10000, such as "60") or a keyword ("yesterday") goes to the API as it is, in one request, and the API resolves it. When using OutputFormat.DATAFRAME, timestamp conversion behavior varies by resource. See the specific resource documentation for details.

DataFrame Processing

When using OutputFormat.DATAFRAME, the SDK performs automatic data cleaning:

  • The s (status) column is removed from all DataFrames
  • DataFrames are automatically indexed by their primary identifier (see resource-specific documentation for details)
  • Timestamp columns are converted to datetime.datetime objects in most cases (see resource-specific documentation for exceptions)

For detailed information about DataFrame structure and indexing for each method, see the specific resource documentation.

Parameter Validation

The SDK uses Pydantic for input validation:

  • All input parameters are validated using Pydantic models
  • Boolean parameters are converted to lowercase strings for the API ("true"/"false")
  • List parameters are joined with commas
  • Enum parameters use their .value attribute
  • Date parameters are formatted appropriately for the API

Concurrent Requests

The SDK uses concurrent requests for efficient data fetching in specific scenarios:

  • stocks.candles(): For intraday resolutions (minutely/hourly), large date ranges are automatically split into year-long, non-overlapping chunks and fetched concurrently (up to 50 concurrent requests by default)
  • options.quotes(): Multiple option symbols are fetched concurrently (up to 50 concurrent requests by default)

When concurrent requests are used, responses are automatically merged into a single result. A chunk or symbol with no data is left out of the merge; if every part has no data the call returns an empty result, and if the API answered with nothing usable at all a MarketdataHttpError is raised.

See the specific resource documentation for details on concurrent request behavior.

Rate Limit Tracking

Rate limits are tracked via response headers and updated after each request:

  • Rate limit information is extracted from response headers after every API call, error answers included
  • The private tracker keeps the newest state it has seen; out-of-order answers do not move it backwards
  • The check runs before each request (unless check_rate_limits=False, which is how the /user/ call at start-up and the service-status refresh go out)
  • The request is refused only when the balance is known to be zero in a window that has not reset yet. An unknown balance and an exhausted window that has already reset both let the request through, because the tracker is only fed by answers: refusing on those would mean the state could never change

Development

Tests

./test.sh   # unit suite: mocked HTTP, runs offline

The live integration suite under src/tests/integration/ calls api.marketdata.app and is excluded from the default run. It needs a token and fails, rather than skips, without one:

MARKETDATA_TOKEN=... uv run pytest src/tests/integration -m integration

It uses the free-trial symbols (AAPL, VFINX), so a run costs no API credits. CI runs it on every pull request.

Linting

ruff lints (unused imports, undefined names), sorts imports and formats. CI runs the same two commands in check mode on every pull request, and the pre-commit hooks run them on staged files, once you install them:

uv run pre-commit install    # once per clone
./lint.sh                    # rewrites, and exits non-zero if anything is left

To check without rewriting, on the paths CI checks:

uv run ruff check src/ examples/ .github/scripts/
uv run ruff format --check src/ examples/ .github/scripts/

Build

./build.sh

License

See the LICENSE file for more details.

Contributors

MarketDataDev02MarketDataAppMarketDataDev03MarketDataDev01dependabot[bot]

Issues