DpExchange.Webull.Rest (DpExchangeWebull v0.4.40)

Copy Markdown View Source

Webull's OpenAPI REST surface — internal.

Every call is signed, including the public-looking ones

There is no anonymous path here. /market-data/crypto/snapshots/list needs the same App Key and signature as an order. That is why this venue declares credential_benefit: :required — the first in the family to do so — and why get_price/2 takes credentials that other venues' get_price/2 does not need.

A consumer branching on capabilities/0 learns this before it calls; a consumer that assumed market data is free learns it from a 401.

Bars nest one level down, and assuming otherwise returns nothing

The crypto-bars response is a list of groups, each carrying its rows under "result":

[%{"instrument_id" => , "symbol" => , "result" => [%{"open" => }, ]}]

The adapter this came from once mapped its row decoder over the group objects. Groups have no "open", "close" or "time", so every field resolved to nil — the call returned a list of all-nil bars, and the backfill logged an empty result for every crypto pair. It looked like the venue had no data.

Both shapes are handled: a group with "result" is flattened, and a flat bar decodes directly, in case the equities path or a future change sends one.

Volume is real on stocks, absent on crypto

Webull's crypto OpenAPI exposes no trade volume — not on the bars, not on the snapshot, not on the MQTT stream. volume is nil rather than 0 on a crypto quote, because zero is a volume and this venue is not reporting one.

The stock snapshot is different: get_price/2 with category: "US_STOCK" or "US_ETF" carries a real volume, the day's aggregate. capabilities/0 therefore declares reports_trade_volume: true — the venue does report one, on a real, active path — with measured_against carrying the crypto/equity split so a caller does not read the boolean as "every quote has a number." Bars carry no volume on any category — get_volume_profile/3 is the separate equity endpoint that splits traded volume by price and side.

This package previously declared reports_trade_volume: false unconditionally, which was true of crypto and a false claim about the venue as a whole — the same crypto-generalised-to-venue mistake docs/reference/webull/negative-claims.md records for three other refusals. Fixed alongside historical_timeframes below.

Summary

Functions

Adds instruments to an existing watchlist — POST /market-data/watchlists/instruments/add.

Whether bars of timeframe are forward-adjusted, per the venue's rule.

Cancels an order by its client order id.

Checks a token's status — POST /auth/tokens/check.

Creates a server-to-server token — POST /auth/tokens/create.

Creates a watchlist and adds symbols to it — POST /market-data/watchlists/create, then .../instruments/add.

Deletes a watchlist and everything in it — POST /market-data/watchlists/delete.

The fundamentals kinds this venue publishes, as atoms.

Every account this credential can reach — /trading/accounts/list.

The auction order imbalance — snapshot or published series.

Balances for one account — /trading/assets/balances/get.

Dividends and earnings dates — Types.CorporateEvent.

The order book for one event-contract market — GET /market-data/event-contracts/depths/list.

The tape for one event-contract market — GET /market-data/event-contracts/ticks/list.

Webull's crypto fee — captured from the venue's own published pricing, not from a live per-account query.

Regulatory filings this venue indexes — Types.Filing.

Financial statements for an issuer — Types.FinancialStatement.

One fundamentals endpoint, by kind — the venue's own rows, unnormalised.

OHLC bars for a symbol and canonical timeframe.

News summaries — POST /market-data/news/summaries/get.

The option chain for an underlying — GET /trading/instruments/options/contracts/list.

The expiries listed on an underlying, without the strikes.

One order by its client order id.

The order book for an equity or ETF — /market-data/stocks/depths/list.

Open orders, or historical ones with history: true.

Open positions on one account — /trading/assets/positions/list.

Last price for one symbol.

A venue screener, by the venue's own identifier for it — Types.ScreenerResult.

Historical bars for an equity or ETF — POST /market-data/stocks/bars/list.

Every crypto symbol the venue lists, canonical.

Best bid and ask for symbol — the top of the book, not a traded price.

Tick-by-tick public trades — /market-data/stocks/ticks/list.

Every cash activity on one account — the same endpoint get_transfers/2 narrows.

Money into and out of one account — /trading/activities/cash-activities/list.

Traded volume split by price and by side — /market-data/stocks/footprints/list.

One watchlist including its membership — GET /market-data/watchlists/instruments/list.

Every event-contract category — GET /trading/instruments/event-contracts/categories/list.

The events under one series — GET /trading/instruments/event-contracts/events/list.

The tradable markets under a series or an event — GET /trading/instruments/event-contracts/markets/list.

Event-contract series — GET /trading/instruments/event-contracts/series/list.

Futures contracts by symbol or by product code — GET /trading/instruments/futures/contracts/list.

The futures product classification groups — GET /trading/instruments/futures/product-classes/list.

The watchlists held at the venue — GET /market-data/watchlists/list.

The OAuth token exchange and refresh — POST /oauth2/tokens/create on the Connect host.

The type/time-in-force pairs this venue accepts for instrument, or an error naming an instrument type this package cannot build an order for.

Every instrument type this package can build an order for, as the venue names them.

The order_type atom for a venue wire name this package decodes, or nil for a value this package does not recognise.

The venue's wire name for an order_type atom this package encodes, or nil for an atom this package does not send.

Places several orders in one request — POST /trading/orders/batch-place.

Prices an order without placing it/trading/orders/preview.

Rounds a price and quantity to what the venue will actually accept.

Removes instruments from a watchlist — POST /market-data/watchlists/instruments/remove.

Amends a working order in place — /trading/orders/replace.

The screeners this venue publishes, by the identifier get_screener/4 takes.

Reorders instruments within a watchlist — POST /market-data/watchlists/instruments/update.

The time_in_force atom for a venue wire name this package decodes, or nil for a value this package does not recognise. Same reasoning as order_type_atom/1.

The venue's wire name for a time_in_force atom this package encodes, or nil for an atom this package does not send.

Canonical timeframes the crypto and event-contract bars serve, shortest first.

Renames a watchlist or changes its sort order — POST /market-data/watchlists/update.

Every canonical timeframe served by some active endpoint on this venue — timeframes/0's eight plus 1w, 1M and 1y, which the equity, option and futures bars serve and the crypto and event-contract bars refuse (see @timespans above).

Functions

add_watchlist_instruments(watchlist_id, symbols, credentials, opts)

@spec add_watchlist_instruments(String.t(), [String.t()], map(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Adds instruments to an existing watchlist — POST /market-data/watchlists/instruments/add.

The venue caps an account at 1000 instruments across all watchlists and rejects event contracts, futures and options here.

adjusted?(timeframe)

@spec adjusted?(String.t()) :: boolean() | nil

Whether bars of timeframe are forward-adjusted, per the venue's rule.

Daily and above are; minute bars are not. nil for a width this package does not serve — an unknown width has no answer, and false would be a claim.

Exposed because a caller stitching two widths together needs to know they are not the same series, and nothing in the bar data itself says so.

cancel_order(credentials, client_order_id, opts)

@spec cancel_order(map(), String.t(), keyword()) ::
  {:ok, :cancelled} | {:error, term()} | {:refused, term()}

Cancels an order by its client order id.

Webull's order API is keyed on the id the caller supplied, not the one the venue returned — /orders/cancel and /orders/get both take client_order_id. Order.id carries it for exactly that reason, so place_order/3 then cancel_order/3 round-trips.

Requires opts[:account_id], as every order call on this venue does.

check_token(token, credentials, opts)

@spec check_token(String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Checks a token's status — POST /auth/tokens/check.

This is the call that distinguishes the four states, and the reason to make it before trusting a stored token: PENDING has never been verified, EXPIRED has run out, and INVALID was revoked or never existed. All three fail the same way at the next request, and only this endpoint says which.

The status comes back as the venue's own string. Nothing is mapped to a boolean, because "not usable" covers three different problems with three different remedies.

create_token(credentials, opts)

@spec create_token(
  map(),
  keyword()
) :: {:ok, map()} | {:error, term()} | {:refused, term()}

Creates a server-to-server token — POST /auth/tokens/create.

The token this returns is not usable yet. It comes back PENDING, and the venue's own note says verification happens through an SMS code in the Webull app — which needs a person, and is not something this package can do. A caller that treats a successful response as an authenticated session will find every subsequent call refused.

status travels unmapped for that reason: PENDING, NORMAL, INVALID and EXPIRED are the venue's four, and only the second is a token that works.

Tokens default to 15 days and must be recreated, not renewed — there is no refresh on this endpoint. expires_at is milliseconds.

create_watchlist(name, symbols, credentials, opts)

@spec create_watchlist(String.t(), [String.t()], map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Watchlist.t()}
  | {:error, term()}
  | {:refused, term()}

Creates a watchlist and adds symbols to it — POST /market-data/watchlists/create, then .../instruments/add.

Two requests, and the second can fail after the first succeeded. The venue creates an empty watchlist and adds members separately; where the add fails, the watchlist exists and is empty, and this returns the error rather than the id — a caller that saw {:ok, watchlist} would believe the membership took. The id is in the error term so the watchlist can be found and dealt with.

An empty symbols list makes one request and creates an empty watchlist, which is a real thing to want.

The venue does not accept event contracts, futures or options here, by its own note on the add endpoint. Symbols default to US_STOCK; opts[:category] overrides.

delete_watchlist(watchlist_id, credentials, opts)

@spec delete_watchlist(String.t(), map(), keyword()) ::
  {:ok, :ok} | {:error, term()} | {:refused, term()}

Deletes a watchlist and everything in it — POST /market-data/watchlists/delete.

Irreversible, in the venue's own words. Returns :ok rather than the venue's %{"success" => true}, because the contract's delete_watchlist/2 is documented as returning :ok — and a false in that field is an error here rather than a successful call that deleted nothing.

fundamental_kinds()

@spec fundamental_kinds() :: [atom()]

The fundamentals kinds this venue publishes, as atoms.

Twenty-three endpoints under one shape. get_fundamental/4 reaches any of them; the contract's own callbacks — get_financials/3, get_corporate_events/1, get_filings/2 — reach the handful the contract has types for.

get_accounts(credentials, opts)

@spec get_accounts(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

Every account this credential can reach — /trading/accounts/list.

Takes no parameters: the credential decides what it sees.

account_class is where this venue's breadth shows. The documented values are INDIVIDUAL_CASH, INDIVIDUAL_MARGIN, ROTH_IRA, TRADITIONAL_IRA, ROLLOVER_IRA, MANAGED_ROTH_IRA, MANAGED_TRADITIONAL_IRA, CRYPTO, FUTURES and EVENTS_CASH — so a single credential can hold crypto, futures and event-contract accounts alongside cash and margin ones. This package serves crypto today; the accounts endpoint sees all of them and says so, which is why the rows come back whole rather than filtered.

Rows are the venue's own maps. An account is not a value type in this contract, and normalising account_label into something else would lose exactly the field a caller picking an account needs.

get_auction_imbalance(symbol, credentials, opts)

@spec get_auction_imbalance(String.t(), map(), keyword()) ::
  {:ok, [DpExchange.Core.Types.AuctionImbalance.t()]}
  | {:error, term()}
  | {:refused, term()}

The auction order imbalance — snapshot or published series.

Requires a Nasdaq TotalView non-display subscription, which the vendor states on both endpoints.

opts[:auction] is required and is :opening or :closing; the venue names them PRE_OPEN and PRE_CLOSE. They are different auctions with different windows, and choosing one for a caller who did not say would answer a question nobody asked.

Two endpoints, and the series carries less than the snapshot

snapshot          /market-data/stocks/noii-snapshots/list
history: true     /market-data/stocks/noii-bars/list

The bars publish the three auction prices and the time and nothing else — no paired_shares, no imbalance_shares, no imbalance_side. Those come back nil, which says the venue did not publish them on that endpoint. nil there is not an imbalance of zero, and a caller computing a ratio from the series gets nil rather than a number that looks balanced.

Outside the auction window the snapshot returns the last one, not nothing

The vendor is explicit: published during ET 9:28–9:30 and 15:50–16:00, updating every 5 seconds, and "outside these periods, historical data is returned". So the venue's own imbalance_time is carried alongside observed_at, and the two together are the only way a caller can tell a live imbalance from this morning's.

side is the venue's own value, unmapped — see Core.Types.AuctionImbalance for why.

get_balances(credentials, opts)

@spec get_balances(
  map(),
  keyword()
) ::
  {:ok, [DpExchange.Core.Types.Balance.t()]}
  | {:error, term()}
  | {:refused, term()}

Balances for one account — /trading/assets/balances/get.

Requires opts[:account_id], as every account call on this venue does. There is no all-accounts variant; a caller holding several asks per account, and which one is theirs to choose.

What this deliberately does not fill in

The venue publishes several different restrictions on a currency balance — frozen_amount, held_amount (in transit), unsettled_cash, and the derived buying_power, option_buying_power, day_buying_power and available_withdrawal. They are not the same number and they do not agree.

Core.Types.Balance has one available_balance, and there is no honest way to pick which of those it is. available_withdrawal is what can leave the account; buying_power is what can be traded and on a margin account exceeds the cash; settled and unsettled cash differ again. So available_balance is nil — the venue said several things and this package will not choose one and label it "available".

balance is cash_balance and hold is frozen_amount, both of which are the venue's own single-meaning fields. The rest is reachable through get_accounts/2 on a package that carries it, and is a known gap in this contract rather than in this venue.

get_corporate_events(credentials, opts)

@spec get_corporate_events(
  map(),
  keyword()
) ::
  {:ok, [DpExchange.Core.Types.CorporateEvent.t()]}
  | {:error, term()}
  | {:refused, term()}

Dividends and earnings dates — Types.CorporateEvent.

Two endpoints, and opts[:kind] chooses. :dividend reads the dividend calendar and :earnings the earnings one; without it both are read and the results concatenated, which is two requests and is stated here so a caller counting requests is not surprised.

opts[:symbol] is required: this venue's calendars are per issuer, not market-wide. A market-wide calendar and one issuer's are different questions, and this endpoint answers only the second.

Splits are not here. Webull publishes fund-splits for funds and nothing for equities, so a :split kind would be answerable for some symbols and silently empty for the rest. get_fundamental(:fund_splits, …) reaches the one that exists.

get_event_order_book(symbol, credentials, opts)

@spec get_event_order_book(String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

The order book for one event-contract market — GET /market-data/event-contracts/depths/list.

Four books, not two, and that is why this is not get_order_book/3. The venue returns yes_bids, yes_asks, no_bids and no_asks; Types.OrderBook has one bid side and one ask side. Picking the YES pair to be "the book" would answer about an instrument the caller never named, and the prices would be plausible — a YES ask of 0.13 and a NO ask of 0.92 are both real and neither is the other.

The venue notes that in a binary market a yes bid at X equals a no ask at 1−X. That identity is the venue's; this package does not derive one side from the other, because a derived level cannot be told from a quoted one.

Returned as %{yes_bids:, yes_asks:, no_bids:, no_asks:, quote_time:} with the venue's own level maps and its quote_time in milliseconds.

get_event_trades(symbol, credentials, opts)

@spec get_event_trades(String.t(), map(), keyword()) ::
  {:ok, [map()]} | {:error, term()} | {:refused, term()}

The tape for one event-contract market — GET /market-data/event-contracts/ticks/list.

Not get_trades/3, and that is not an omission. An event tick carries a yes_price and a no_price and a side of yes/no; Types.Trade carries one price and a side of :buy/:sell. Mapping yes to :buy would file the print against the other instrument of a two-instrument market, and the number would look right. So the venue's own rows are returned and nothing is normalised.

The venue's default count here is 30, not the 200 its other tapes use.

get_fees(credentials, opts)

@spec get_fees(
  map(),
  keyword()
) :: {:ok, map()} | {:error, term()}

Webull's crypto fee — captured from the venue's own published pricing, not from a live per-account query.

The Trading API this package speaks has no fee-schedule endpoint anywhere in its surface — checked across Instruments, Accounts, Assets and Activities, the whole of the API's own navigation. What exists under that name lives in a different product entirely: Broker API's "Fees and Credits" is an administrative interface — creating and reading fee deductions/credits a broker applies to a sub-account, not a schedule a caller queries. This package does not operate a brokerage (D8), so that surface is out of reach on principle as well as on credentials.

What Webull does publish, on webull.com/pricing, is a single flat crypto spread: 1.00% per trade, charged by Webull Pay/Bakkt, the same rate for every account — not a tier a credential selects among, so credentials plays no part in which rate comes back, and there is nothing here for a credential to gate.

No credential gate, and that is deliberate (incident, 2026-09-07)

This is the one endpoint on this venue that answers without a credential. credentials is accepted, for shape parity with every other callback in this module, and never inspected. A 2026-09-06 sweep gated this behind Auth.present?/1 on the reasoning that the real path "had never run through Auth.headers/2" — true, and beside the point: there is nothing here to sign, because this function never builds a request. Gating it broke a real consumer, who resolves venue fees to score candidate strategy genomes before any account is attached — no credential exists at that point by design, so get_fees/2 became unanswerable and their fee-overcome admission gate lost its input. source: :published_rate already says this answers from a captured constant, not a venue call; a callback that says that about itself must not also demand a credential it makes no use of. Captured 2026-09-03; re-check the page before trusting this figure if it is old by the time you read this.

get_filings(symbol, credentials, opts)

@spec get_filings(String.t(), map(), keyword()) ::
  {:ok, [DpExchange.Core.Types.Filing.t()]}
  | {:error, term()}
  | {:refused, term()}

Regulatory filings this venue indexes — Types.Filing.

This points at filings; it does not fetch them. The url on each row is the venue's own link and nothing here follows it.

get_financials(symbol, kind, credentials, opts)

@spec get_financials(String.t(), atom(), map(), keyword()) ::
  {:ok, [DpExchange.Core.Types.FinancialStatement.t()]}
  | {:error, term()}
  | {:refused, term()}

Financial statements for an issuer — Types.FinancialStatement.

kind is the contract's own vocabulary: :balance_sheet, :income, :cash_flow or :indicators. Anything else is refused, including a fundamentals kind this venue publishes that is not a statement: :company_profile is real and is not a financial statement, and answering with it would put a profile in a statement's shape.

Line items are the venue's own names, unchanged — see Core.Types.FinancialStatement.

fiscal_period is the venue's integer code translated through the venue's own legend, which its page states as 0=FY, 1=Q1, 2=Q2, 3=Q3, 4=Q4. The contract wants a label and the venue publishes a code, so the code is mapped with the venue's own key and the raw integer stays in line_items — a code this legend does not cover leaves the label nil rather than inventing one, and the integer is still there to read.

get_fundamental(kind, symbol, credentials, opts)

@spec get_fundamental(atom(), String.t(), map(), keyword()) ::
  {:ok, [map()]} | {:error, term()} | {:refused, term()}

One fundamentals endpoint, by kind — the venue's own rows, unnormalised.

A kind this venue does not publish is {:error, {:unknown_fundamental, kind}} before a request is made. Guessing a path from an atom would produce a 404 that reads like a venue outage.

opts[:type] (ANNUAL or QUARTERLY) and opts[:count] are accepted only on the endpoints that document them, and are dropped elsewhere rather than sent — a parameter an endpoint does not know is at best ignored and at worst a refusal, and neither tells the caller which happened.

Rows come back as the venue sends them. A balance sheet has ninety-odd line items under the venue's own names, and a normalised schema would either drop most of them or invent a common shape three statement types do not share.

get_historical_prices(symbol, timeframe, range, credentials, opts)

@spec get_historical_prices(String.t(), String.t(), keyword(), map(), keyword()) ::
  {:ok, [map()]} | {:error, term()} | {:refused, term()}

OHLC bars for a symbol and canonical timeframe.

Bars carry no volume — see the module doc. A bar without a venue timestamp is an error, not a bar stamped with the local clock.

get_news(credentials, opts)

@spec get_news(
  map(),
  keyword()
) ::
  {:ok, [DpExchange.Core.Types.NewsItem.t()]}
  | {:error, term()}
  | {:refused, term()}

News summaries — POST /market-data/news/summaries/get.

This one is generated, not reported. The vendor's own description is "Invokes LLM to generate news summaries", so the summary on each row is a model's paraphrase and not the publisher's text. That is recorded here because a caller quoting it is quoting a summary, and source names the venue rather than a wire.

opts[:symbols] is required — the endpoint summarises a watchlist, not the market — and takes a list. opts[:lang] is the venue's own enum and is sent only when given.

get_option_chain(underlying, credentials, opts)

@spec get_option_chain(String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.OptionChain.t()}
  | {:error, term()}
  | {:refused, term()}

The option chain for an underlying — GET /trading/instruments/options/contracts/list.

Returns Types.OptionChain: expiry × strike, both sides. The venue publishes a flat list of contracts; a flat list is lossless in data and answers none of the questions a chain is asked, so the grid is rebuilt here.

A contract this package cannot read is refused, not skipped. An expiry, a strike and a right are what address a contract; a row missing any of them yields {:error, {:unreadable_option_contract, keys}} naming the keys the venue actually sent. Dropping the row would return a chain with a hole in it that looks complete, and a caller walking strikes would never learn the strike was there.

:underlying_price is nil. This endpoint lists contracts and does not quote the underlying. Fetching it separately and stamping it on would be two observations at two times presented as one, which is how a "delta-neutral" position turns out not to be.

opts[:expiry] and opts[:strike] are passed to the venue where given — a full chain is large, and narrowing it at the venue is not the same as narrowing it here.

get_option_expirations(underlying, credentials, opts)

@spec get_option_expirations(String.t(), map(), keyword()) ::
  {:ok, [Date.t()]} | {:error, term()} | {:refused, term()}

The expiries listed on an underlying, without the strikes.

Webull publishes no expiry-only endpoint, so this reads the contract list and returns its distinct expiries, earliest first. That is a narrowing of a real response, not a substitute for a missing one — the dates are the venue's own, and no date appears here that was not on a contract the venue listed.

A caller that needs the strikes as well should call get_option_chain/3 once rather than this and then that: the two would be two reads of a list that moves.

get_order(credentials, client_order_id, opts)

@spec get_order(map(), String.t(), keyword()) ::
  {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}

One order by its client order id.

Requires opts[:account_id].

get_order_book(symbol, credentials, opts)

@spec get_order_book(String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.OrderBook.t()}
  | {:error, term()}
  | {:refused, term()}

The order book for an equity or ETF — /market-data/stocks/depths/list.

This venue's book is equities-only. The crypto snapshot endpoint publishes a top of book and no depth, and the vendor states US_OPTION is not supported here. So a crypto symbol is refused before the request rather than sent and rejected.

opts[:category] picks US_STOCK (the default) or US_ETF; opts[:depth] is the venue's own level count. opts[:overnight] includes overnight trading data, and the venue requires the parameter, so false is sent explicitly rather than omitted.

depth's default of 10 is this package's own choice, not a confirmed venue default

Unlike overnight_required, the stocks depth endpoint's own parameter table has never been captured — endpoint-inventory.md has its path and nothing else (the vendor's reference pages build parameter tables in JavaScript; a plain fetch returns only the method, path and one-line description, same limitation negative-claims.md already records for other endpoints). 10 is inherited from a sibling endpoint that IS documented — /market-data/event-contracts/depths/list states "depth (default 10)" (docs/reference/webull/futures-and-event-contracts.md) — generalised here without confirmation that the stock endpoint shares it. On US_FUTURES specifically, the venue's own page states depth is 1–10, required with no default at all (same file), so supplying 10 there is this package filling a required parameter with a value known to be in range, not a venue default being honoured.

Settling the stock case needs either a browser-rendered read of this endpoint's own parameter table (the same way futures-and-event-contracts.md was captured) or a credentialed consumer's tier-3 probe of what an omitted depth actually returns.

What is dropped, and why that is stated rather than silent

Each level carries the venue's order array — market participant IDs and per-participant sizes — and broker names beneath that. Core.Types.OrderBook levels are {price, size}, so the attribution is discarded here. That is a real loss: on a lit book, who is quoting is information a caller may want, and this contract has no place for it. The sizes that survive are the venue's own level sizes, not a sum this package computed from the participants.

timestamp is the venue's quote_time. A book the venue did not stamp is refused — a depth snapshot wearing the local clock cannot be told from a current one.

get_orders(credentials, opts)

@spec get_orders(
  map(),
  keyword()
) ::
  {:ok, [DpExchange.Core.Types.Order.t()]}
  | {:error, term()}
  | {:refused, term()}

Open orders, or historical ones with history: true.

These are two endpoints, not one with a filter. /orders/open-orders/list and /orders/historical-orders/list answer different questions, and a caller asking for "orders" without saying which gets the open ones — the set that can still change.

Requires opts[:account_id]. Returns one page; the venue paginates on client_order_id as a cursor and this does not follow it.

get_positions(credentials, opts)

@spec get_positions(
  map(),
  keyword()
) ::
  {:ok, [DpExchange.Core.Types.Position.t()]}
  | {:error, term()}
  | {:refused, term()}

Open positions on one account — /trading/assets/positions/list.

Requires opts[:account_id].

This venue states no side. Every documented field is a quantity, a price or a P&L, and direction is carried in the sign of quantity — so Position.from_signed_quantity/1 does the conversion, which is exactly what it exists for. A package that assumed :long because equities are usually long would produce a short position that is exactly backwards with every number in it still plausible.

instrument_type comes from the venue's own EQUITY | OPTION | FUTURES | CRYPTO | EVENT. An unknown one is nil, not the nearest.

liquidation_price and leverage stay nil: the venue publishes neither on this endpoint. nil there means "not stated", never "no liquidation risk" — see Core.Types.Position.

get_price(symbol, credentials, opts)

@spec get_price(String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Quote.t()} | {:error, term()} | {:refused, term()}

Last price for one symbol.

Crypto and stocks are different endpoints, chosen by opts[:category]US_CRYPTO (the default), US_STOCK or US_ETF. US_OPTION is refused: the vendor states the stock snapshot does not serve it.

Volume is nil on crypto and real on stocks

This venue publishes no crypto volume anywhere, so a crypto quote's volume is nil — never zero, which would claim a genuinely flat interval. The stock snapshot does publish volume, and it is the day's aggregate rather than the last trade's size; the venue names no per-trade size on this endpoint, so that is what a caller gets and the field carries the venue's own meaning.

get_screener(name, credentials, opts)

@spec get_screener(String.t(), map(), keyword()) ::
  {:ok, [DpExchange.Core.Types.ScreenerResult.t()]}
  | {:error, term()}
  | {:refused, term()}

A venue screener, by the venue's own identifier for it — Types.ScreenerResult.

Two venues' "top movers" answer different questions, and nothing here merges or re-ranks them: the rank is the position the venue returned the row in, and the metrics are its own fields under its own names.

Each screener takes different parameters and this sends only the ones its own page documents. gainers_losers requires rank_type and sort_by; both default to the venue's own documented defaults rather than being omitted, because the venue marks them required and an omitted required parameter is a refusal a caller cannot read.

An identifier this venue does not publish is {:error, {:unknown_screener, name}}.

get_stock_bars(symbol, timeframe, range, credentials, opts)

@spec get_stock_bars(String.t(), String.t(), keyword(), map(), keyword()) ::
  {:ok, [DpExchange.Core.Types.Candle.t()]}
  | {:error, term()}
  | {:refused, term()}

Historical bars for an equity or ETF — POST /market-data/stocks/bars/list.

A POST, where the crypto bars are a GET, and its parameters go in a JSON body rather than a query string. Same endpoint family, different verb; the vendor documents it so.

Daily and above are adjusted; minute bars are not

The vendor states it plainly: "Daily and above are forward-adjusted; minute bars are unadjusted." These are not the same series at different resolutions. A caller stitching 1m bars onto a daily series across a split gets a discontinuity that is entirely real in each half and wrong where they meet, and nothing in the data says which side was adjusted.

This package cannot fix that — the venue publishes what it publishes — so it reports it: every bar from this endpoint carries the width it was asked for, and the adjustment follows from that width by the venue's rule. adjusted?/1 answers it for a width without a request.

real_time_required defaults to Y here, unlike every other endpoint

On the crypto bars and the footprints it defaults to false. Here the vendor's default is "Y: The returned data includes the latest market data" — an in-progress bar whose boundary has not happened yet. This sends false unless asked, matching what get_historical_prices/5 does for crypto: a package that stored the venue's default would save a bar that changes after it is written.

Widths: the eight the crypto endpoint serves plus 1w, 1M and 1y.

get_symbols(credentials, opts)

@spec get_symbols(
  map(),
  keyword()
) :: {:ok, [String.t()]} | {:error, term()} | {:refused, term()}

Every crypto symbol the venue lists, canonical.

Measured 2026-08-05 against the old /openapi/instrument/crypto/list: 342 symbols, every one quoted in USD. That measurement predates the D6 migration and has not been retaken against /trading/instruments/crypto/profiles/list, which needs a credential this repository does not hold.

This endpoint paginates, and the old one did not

The replacement returns a pagination_key and expects it back to get the next page. A single call therefore returns a page, not the catalogue — and a truncated symbol list is the worst shape of failure this family has: every symbol in it is real, so nothing looks wrong, and the ones missing are simply never traded.

So this follows the key until the venue stops sending one. @max_pages bounds it: a server that always returns a key would otherwise loop forever, and an infinite loop inside a facade call is worse than an error.

get_top_of_book(symbol, credentials, opts)

@spec get_top_of_book(String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.TopOfBook.t()}
  | {:error, term()}
  | {:refused, term()}

Best bid and ask for symbol — the top of the book, not a traded price.

Same snapshot payload as get_price/3; the venue returns the last trade and the top of the book together, and this splits them. The documented schema carries bid, ask, bid_size and ask_size, so unlike some venues in this family the sizes are real here rather than nil.

get_trades(symbol, credentials, opts)

@spec get_trades(String.t(), map(), keyword()) ::
  {:ok, [DpExchange.Core.Types.Trade.t()]}
  | {:error, term()}
  | {:refused, term()}

Tick-by-tick public trades — /market-data/stocks/ticks/list.

The tape, not get_trade_history/2. That returns the credential's own fills; this returns everyone's executions, newest first as the venue sorts them.

opts[:limit] is the venue's count (default 30, max 1000). opts[:sessions] takes the venue's own list — PRE, RTH, ATH, OVN, comma-joined — and defaults to RTH. The venue marks trading_sessions required, so one is always sent; asking for regular hours by default is a choice, and it is the one that matches what get_price/3 returns.

side has five codes and this package knows two of them

The venue documents the field as "Such as: B S G L N" and defines none of them. B and S are unambiguous; G, L and N are not documented anywhere the vendor publishes, so they map to nil rather than being folded into the nearest of buy or sell.

A tick whose side is nil is a real trade with an unknown aggressor. Guessing would put volume on the wrong side of a delta, which is the number a caller reads a tape for.

broken is false on every tick: this venue publishes no bust flag here, and a venue with no concept of busts has nothing busted — which is the same answer.

get_transactions(credentials, opts)

@spec get_transactions(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

Every cash activity on one account — the same endpoint get_transfers/2 narrows.

Requires opts[:account_id].

get_transfers/2 asks the venue for DEPOSIT,WITHDRAW,TRANSFER because the contract documents it as deposit and withdrawal history. This asks for none of that filtering and returns what the endpoint actually carries: TRADE, FEES, DIVIDENDS, TAX, INTERESTS, CORPORATE_ACTION, OPTION_EA, JOURNAL, EC_SETTLEMENT and OTHER alongside the three.

The two are not interchangeable in either direction. A dividend and a deposit both credit cash and neither is the other: a caller computing what it put in must use get_transfers/2, and a caller reconciling a balance against everything that moved must use this — summing get_transfers/2 leaves out the fees.

Summing this is not a balance either. get_balances/2 is the authority; this is the explanation for the difference between two of them.

The venue's two constraints hold here as they do there: without a time range it answers the last 7 days, and a range spanning two calendar years is refused up front rather than silently truncated.

get_transfers(credentials, opts)

@spec get_transfers(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

Money into and out of one account — /trading/activities/cash-activities/list.

Requires opts[:account_id].

This endpoint is much wider than transfers, and that matters

It lists every cash activity: TRADE, FEES, DIVIDENDS, TAX, INTERESTS, CORPORATE_ACTION, OPTION_EA, JOURNAL, EC_SETTLEMENT and OTHER alongside DEPOSIT, WITHDRAW and TRANSFER. The contract asks get_transfers/2 for deposit and withdrawal history, because that is what a cost basis for transferred-in assets needs.

Returning all of it under that name would be wrong in a way that costs money. A dividend and a deposit both credit cash and neither is the other; a caller computing what it put in would count income as contribution. So this asks the venue for DEPOSIT,WITHDRAW,TRANSFER and opts[:activity_types] widens it, taking the venue's own strings.

The filter goes to the venue, not to the page. Filtering here would silently drop matching rows that were on the next page.

The venue's two constraints, enforced rather than discovered

Without a time range the venue answers the last 7 days — its default, not this package's, and stated here so a caller does not read an empty list as "no deposits ever".

start_time and end_time must fall in the same calendar year; the venue says cross-year queries are not supported. This refuses such a range up front rather than sending it and reading whatever comes back, because a venue that silently truncates to one year returns a real list that is missing the other half.

Rows come back as the venue sends them. activity_sub_type alone has 60-odd values carrying the distinction between an ACH deposit and a wire, a reversal and a payment — and no struct in this contract has anywhere to put them.

get_volume_profile(symbol, timeframe, credentials, opts)

@spec get_volume_profile(String.t(), String.t(), map(), keyword()) ::
  {:ok, [DpExchange.Core.Types.VolumeProfile.t()]}
  | {:error, term()}
  | {:refused, term()}

Traded volume split by price and by side — /market-data/stocks/footprints/list.

Requires a separate Webull subscription, which the vendor states on the endpoint. A credential without it gets the venue's own refusal; this package does not pretend to know in advance which credentials carry it.

Widths are 5s, 15s, 1m, 5m and 30m — five, where get_historical_prices/4 serves more. A width outside them is {:unsupported_timeframe, width} rather than the closest one this endpoint does have.

opts[:count] is the venue's bar count (default 200, max 1200); opts[:session] picks PRE, RTH or ATHOVN is documented as not supported and is refused here rather than sent.

real_time_required is sent as false

The vendor marks it required and says it controls whether an unfinished bar is included. false asks for completed intervals only: an in-progress footprint has a boundary that has not happened yet, and its buy/sell split will change before the interval closes. The same reasoning get_historical_prices/4 uses for bars.

The price maps come back keyed on the venue's own price strings. See Core.Types.VolumeProfile for why they are not re-keyed on Decimal.

get_watchlist(watchlist_id, credentials, opts)

@spec get_watchlist(String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Watchlist.t()}
  | {:error, term()}
  | {:refused, term()}

One watchlist including its membership — GET /market-data/watchlists/instruments/list.

The membership endpoint returns the instruments and not the watchlist's name, so name is nil here where list_watchlists/2 has it. Reading the listing to fill it in would be a second request this function did not make, and a name from a moment ago beside a membership from now.

list_event_categories(credentials, opts)

@spec list_event_categories(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

Every event-contract category — GET /trading/instruments/event-contracts/categories/list.

Takes no parameters at all. It is the root of this venue's event hierarchy: category → series → event → market, and each level's symbol addresses the next.

list_event_events(credentials, opts)

@spec list_event_events(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

The events under one series — GET /trading/instruments/event-contracts/events/list.

opts[:series_symbol] is required by the venue; missing it is {:error, :series_symbol_required} before a request is made.

opts[:status] takes the venue's ACTIVE or INACTIVE and is not defaulted: an event that has settled is still a real event, and filtering it out for a caller who did not ask would hide history.

list_event_markets(credentials, opts)

@spec list_event_markets(
  map(),
  keyword()
) ::
  {:ok, %{rows: [map()], pagination_key: String.t() | nil}}
  | {:error, term()}
  | {:refused, term()}

The tradable markets under a series or an event — GET /trading/instruments/event-contracts/markets/list.

This is the level that is actually tradable; the three above it are addressing.

status and tradable_status are two different fields with two different vocabularies, and a market can be LISTING and NT at the same time. Both survive on the row, because collapsing them into one "is it tradable" boolean is how a caller ends up routing an order at a market that is listed and not accepting one.

Paged like list_event_series/2, and returned the same way.

list_event_series(credentials, opts)

@spec list_event_series(
  map(),
  keyword()
) ::
  {:ok, %{rows: [map()], pagination_key: String.t() | nil}}
  | {:error, term()}
  | {:refused, term()}

Event-contract series — GET /trading/instruments/event-contracts/series/list.

A series is the venue's template for a recurring event ("Monthly Jobs Report"), not a tradable thing.

Paged, and the absence of a key is the end. opts[:pagination_key] continues, and the page's own pagination_key is returned alongside the rows as {:ok, %{rows: [...], pagination_key: key_or_nil}}nil means this was the last page. Returning a bare list would make the last page and a truncated one look identical.

list_futures_contracts(credentials, opts)

@spec list_futures_contracts(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

Futures contracts by symbol or by product code — GET /trading/instruments/futures/contracts/list.

Either opts[:symbols] or opts[:code], and the venue requires one of them. Missing both is {:error, :symbols_or_code_required} before a request is made, because the venue answers a request with neither in a way a caller cannot tell from "no contracts listed".

opts[:status] filters OC (tradable), CO (liquidate only) or NT (non-tradable). The venue's own default is OC, and this package does not send one — a filter this package chose would hide contracts the caller did not ask to hide.

Rows come back as the venue sends them. instrument_id on a continuous contract is the continuous contract's id, and the venue states an order needs the actual month contract's; nothing here resolves one to the other.

list_futures_product_classes(credentials, opts)

@spec list_futures_product_classes(
  map(),
  keyword()
) :: {:ok, [map()]} | {:error, term()} | {:refused, term()}

The futures product classification groups — GET /trading/instruments/futures/product-classes/list.

Two fields, product_class_id and product_class_name, and the ids are what list_futures_contracts/2 rows carry. Returned as the venue's own maps because there is nothing to normalise.

list_watchlists(credentials, opts)

@spec list_watchlists(
  map(),
  keyword()
) ::
  {:ok, [DpExchange.Core.Types.Watchlist.t()]}
  | {:error, term()}
  | {:refused, term()}

The watchlists held at the venue — GET /market-data/watchlists/list.

symbols is nil on every row, and that is not an empty watchlist. This endpoint names watchlists and does not list their membership; get_watchlist/3 reads that with a second request. nil says "not asked"; [] would say "this watchlist is empty", and the two are not the same answer.

The venue caps an account at 20 watchlists.

oauth_token(client_id, client_secret, opts)

@spec oauth_token(String.t(), String.t(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

The OAuth token exchange and refresh — POST /oauth2/tokens/create on the Connect host.

One endpoint, two operations, and grant_type picks. With opts[:code] it exchanges the authorization code the host obtained — the second leg of the consent flow. With opts[:refresh_token] it refreshes. Neither is {:error, :code_or_refresh_token_required} rather than a call the venue would reject.

A different host from every other endpointoauth-open-api… — and a form body rather than the signed JSON the rest of this package sends. That is why the package/host split cannot be read off a path: the same URL serves the host's code exchange and the package's refresh.

Two expiries come back, and they are not the same clock. expires_in is the access token's, in seconds; rt_expires_in is the refresh token's, and it is the one that ends the session when it runs out. A caller tracking only the first will be surprised.

order_combinations(instrument)

@spec order_combinations(atom()) :: {:ok, [{atom(), atom()}]} | {:error, term()}

The type/time-in-force pairs this venue accepts for instrument, or an error naming an instrument type this package cannot build an order for.

Exposed so the fake enforces the same matrix rather than a hand-copied one that drifts.

order_instrument_types()

@spec order_instrument_types() :: [atom()]

Every instrument type this package can build an order for, as the venue names them.

Exposed because capabilities/0 declares from it, and a declaration that can disagree with the builder it describes is a declaration worth nothing.

order_type_atom(name)

@spec order_type_atom(String.t() | nil) :: atom() | nil

The order_type atom for a venue wire name this package decodes, or nil for a value this package does not recognise.

Built from @order_type_names so every type this package's own order_type_name/1 can produce decodes back to the same atom — a caller placing a stop or trailing-stop order, or reading one back, must not silently lose it to nil. A genuinely unknown venue value still yields nil; that is a table lookup missing, not a guess.

order_type_name(order_type)

@spec order_type_name(atom() | nil) :: String.t() | nil

The venue's wire name for an order_type atom this package encodes, or nil for an atom this package does not send.

Exposed so the fake can round-trip a placed order through the same encode/decode this package uses on a real order, rather than echoing the caller's atom back unchanged.

place_order(credentials, request, opts)

@spec place_order(map(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}

Places a crypto order.

The venue documents which type/time-in-force pairs it accepts, and the list is short

Webull states the crypto rules outright rather than encoding them in a key name the way Coinbase does, and the effect is the same — most pairs do not exist:

MARKET            -> IOC only
LIMIT             -> DAY or GTC only
STOP_LOSS_LIMIT   -> DAY or GTC only

There is no market GTC and no limit IOC. A pair the venue does not accept is refused here, before the request is sent, rather than being sent and rejected — the venue's rejection would arrive as a business error the caller has to interpret, and the local refusal names both halves of what was wrong.

account_id is required and is never inferred

The venue takes the account on every order. This package does not look one up and choose: an account is where the money is, and a package picking one for a caller who has several would place a real order against the wrong balance. It comes from opts[:account_id] or the call fails.

Only NORMAL combo orders

The venue supports MASTER, OTO, OCO and OTOCO groupings, and states that crypto supports only NORMAL. Multi-leg and bracket orders are a Phase 11 shape for the venues that have them; sending one here would be rejected upstream.

place_orders(credentials, requests, opts)

@spec place_orders(map(), [map()], keyword()) ::
  {:ok, [map()]} | {:error, term()} | {:refused, term()}

Places several orders in one request — POST /trading/orders/batch-place.

Not place_order/3 in a loop. The venue accepts the batch as one request, and a caller that looped would be reconciling N outcomes instead of reading one response.

The venue's limits, enforced here rather than discovered. A maximum of 50 orders per request, and equities onlydocs/reference/webull/batch-orders.md quotes the page verbatim for both. A batch over the cap is refused before it is sent rather than split, because splitting turns one request into several and undoes the only reason to call this. An order whose instrument type is not equity is refused by index, so a caller knows which one.

The vendor also says this is not available to every client. A refusal here can mean the account is not entitled rather than that the batch was wrong, and the venue's own message is carried through unchanged for that reason.

Each order takes the same shape place_order/3 builds, and client_order_id is generated per order where the caller did not supply one — the venue requires one per order and requires them unique per account.

Returns the venue's own rows, one per order, unnormalised: the venue validates per order and a batch where three of five were accepted is the normal shape, not the exception.

preview_order(credentials, request, opts)

@spec preview_order(map(), map(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Prices an order without placing it/trading/orders/preview.

Takes the same request place_order/3 does and builds the same order body, so a preview and the order it previews cannot diverge.

Crypto is refused before the request. The vendor states it plainly: "For crypto trading, this feature is currently not supported." Sending one anyway would return a business error a caller cannot distinguish from a rejected order, so this refuses with {:preview_not_supported, :crypto} and names the reason.

Returns the venue's own two figures: estimated_cost and estimated_transaction_fee. What estimated_cost means depends on the instrument — for stocks and options it is the total consideration including premium and charges; for futures it is the initial margin required to open the position. Those are different quantities, and the key carries the instrument so a caller cannot read one as the other.

quantization(symbol, credentials, opts)

@spec quantization(String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Rounds a price and quantity to what the venue will actually accept.

Reads the same instruments/.../profiles/list endpoint get_symbols/1 already calls — filtered to one symbol — rather than a separate lookup. Crypto and stock instruments publish disjoint fields, verified against the vendor's own live schema, 2026-09-03:

  • crypto (V2CryptoInstrument-shaped rows): price_step, lot_size, min_trade_qty, max_trade_qty, min_trade_amt, max_trade_amt — all six present
  • stock/ETF: only lot_size. No price step, no per-unit or per-cash min or max anywhere on the row — margin ratios and share-class flags instead, none of them quantization

A stock/ETF symbol therefore answers with quantity_increment alone and every other field nil — not a guess at what the venue does not name, and not the crypto shape reused because it was already written.

Category is read from the symbol's own shape (a canonical pair has a dash; a ticker does not) rather than asked for, because quantization/1's contract takes only a symbol.

remove_watchlist_instruments(watchlist_id, symbols, credentials, opts)

@spec remove_watchlist_instruments(String.t(), [String.t()], map(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Removes instruments from a watchlist — POST /market-data/watchlists/instruments/remove.

Removal is by symbol and category, not by the instrument id the listing returns — the venue's own asymmetry.

replace_order(credentials, client_order_id, changes, opts)

@spec replace_order(map(), String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Order.t()} | {:error, term()} | {:refused, term()}

Amends a working order in place — /trading/orders/replace.

Crypto is refused before the request, for the same documented reason as preview_order/3: the vendor's own page says the endpoint modifies equity, options and futures orders and that crypto is not supported. A crypto caller wanting a different order cancels and re-places, and that window is the venue's rather than this package's.

The venue restricts what may change, per order type, and this refuses the rest rather than sending it:

MARKET               quantity only
LIMIT                order_type, time_in_force, quantity, limit_price
STOP_LOSS            order_type, time_in_force, quantity, stop_price
STOP_LOSS_LIMIT      order_type, time_in_force, quantity, limit_price, stop_price
TRAILING_STOP_LOSS   trailing_stop_step only

Keyed on client_order_id, like every other order call on this venue.

The venue's response carries no order, so the order is read back: reporting the change a caller asked for as though the venue had confirmed it is a different claim from reporting what the venue did.

screeners()

@spec screeners() :: [String.t()]

The screeners this venue publishes, by the identifier get_screener/4 takes.

sort_watchlist_instruments(watchlist_id, credentials, opts)

@spec sort_watchlist_instruments(String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, term()} | {:refused, term()}

Reorders instruments within a watchlist — POST /market-data/watchlists/instruments/update.

opts[:sorts] is required and is a map of symbol to position. The endpoint updates sort order and nothing else, and a call without positions would send the venue a list of symbols with no change in it.

tif_atom(name)

@spec tif_atom(String.t() | nil) :: atom() | nil

The time_in_force atom for a venue wire name this package decodes, or nil for a value this package does not recognise. Same reasoning as order_type_atom/1.

tif_name(tif)

@spec tif_name(atom() | nil) :: String.t() | nil

The venue's wire name for a time_in_force atom this package encodes, or nil for an atom this package does not send.

Exposed for the same reason as order_type_name/1.

timeframes()

@spec timeframes() :: [String.t()]

Canonical timeframes the crypto and event-contract bars serve, shortest first.

update_watchlist(watchlist_id, credentials, opts)

@spec update_watchlist(String.t(), map(), keyword()) ::
  {:ok, DpExchange.Core.Types.Watchlist.t()}
  | {:error, term()}
  | {:refused, term()}

Renames a watchlist or changes its sort order — POST /market-data/watchlists/update.

This does not change membership. The venue's update endpoint touches the watchlist's own properties and nothing else; add_watchlist_instruments/4 and remove_watchlist_instruments/4 are the membership writes. A contract caller reading "replaces a watchlist's name or membership" gets the first half here, and the second is refused rather than silently skipped — opts[:symbols] is {:error, :membership_not_updatable_here}.

Only what is given is changed: the venue leaves unprovided fields alone.

wide_timeframes()

@spec wide_timeframes() :: [String.t()]

Every canonical timeframe served by some active endpoint on this venue — timeframes/0's eight plus 1w, 1M and 1y, which the equity, option and futures bars serve and the crypto and event-contract bars refuse (see @timespans above).

This is a fact about the venue, not the finished capabilities/0 declaration: Core.Capabilities has one flat historical_timeframes list for the whole package, with no per-asset-class shape, so a width reachable on any path belongs in that declaration too — which is now the family's written rule rather than an inference made here: see DpExchange.Core.Capabilities' "A list-valued capability is a UNION across asset classes" section, which cites this function as its worked example. This package chose that reading before it was written down, and the rule's second half is what makes it honest: the per-call path must fail closed, which is what the last paragraph below describes. One exception: 1y, which Webull.capabilities/0 subtracts because dp_exchange_core's Timeframe.nameable/0 does not admit it (see @core_unnameable_widths in webull.ex). Read this function for what the venue serves; read capabilities/0 for what this package can currently say about it. Which category a given call actually reaches a width on is enforced per-call either way: get_historical_prices/5 with a crypto or event-contract category returns {:error, {:unsupported_timeframe, _}} for 1w, 1M and 1y rather than silently degrading to the nearest width it does serve.